📜 ⬆️ ⬇️

Proper Use of Yii

Introduction


In fact, the title should be a question mark. For a long time I did not code on both yii and php in general. Now, returning, I want to rethink my principles of development, to understand where to go next. And the best way is to state them and put them on the review to the professionals, which I do in this post. Despite the fact that I pursue purely selfish goals, the post will be useful to many beginners, and not even beginners.

Design and concepts


In the text, the concepts “controller” and “model” will appear in two contexts: MVC and Yii, pay attention to this. In non-obvious places, I will explain what context I use.
A “view” is a view in the context of MVC.
“View” is a file from the views folder.
I will highlight patterns in CAPITAL letters.

Go!



Yii is a very flexible framework. This makes it possible for some developers not to care about structuring their code, which always leads to a bunch of bugs and complex refactoring. However, Yii has nothing to do with it - quite often the problems begin with a banal misunderstanding of the MVC principle.
')
Therefore, in this post I will look at the basics of MVC, and its C and V in the context of Yii. The letter M is a separate complex topic that deserves its post. All code examples will be commonplace, but reflect the essence of the principles.

MVC


MVC is a great design principle that helps avoid many problems. In my opinion, the necessary-sufficient knowledge about this design pattern can be gleaned from becoming on Wikipedia.

Unfortunately, I have often seen when the expression “Yii is the MVC framework” was taken too verbatim (that is, M is CModel , C is CController , V is views from the views folder), which leads away from understanding the principle . This causes a lot of errors, for example, when the controller selects all the necessary data for the view, or when pieces of business logic are carried to the controller.

The controller (“C”) is the operational level of the application. Do not confuse it with the class CContrller . CContrller has many responsibilities. In MVC, the concept of "controller" is primarily a CController' action. In the case of performing any operation on an object, the controller does not need to know exactly how to perform this operation — this is the “M” task. In the case of the display of an object, it does not need to know how to display an object — that is the “V” task. In fact, the controller should simply take the desired object (s) and tell it (them) what to do.

The model (“M”) is the level of the business logic of the application. It is dangerous to associate the concept of a model in Yii with the concept of a model in MVC. A model is not only an entity class (usually CModel ). This includes, for example, special validators CValidator , or SERVICES (if they reflect business logic), REPOSITORIES, and much more. The model does not need to know anything about the controllers or displays that use it. It contains only business logic and nothing else.

Presentation ("V") - the display level. You should not take it as just a php file to display (although, as a rule, it is). He has his own, sometimes very complicated logic. And if we need some specific data to display the object, for example, a list of languages ​​or something else, this level should request them. Unfortunately, in Yii, it is impossible to associate a view with any particular class (unless using CWidget , etc.), which would contain the display logic. But it is easy to implement it yourself (rarely needed, but sometimes it is extremely useful).

Yii itself provides us with a luxurious infrastructure for all these three levels.

Typical MVC errors



I will give a couple of typical mistakes. These examples are extremely exaggerated, but they reflect the essence. On the scale of a large application, these errors grow into catastrophic problems.

1. Suppose we need to display the user with his posts. A typical action looks something like this:

  public function actionUserView($id) { $user = User::model()->findByPk($id); $posts = Post::model()->findAllByAttributes(['user_id' => $user->id]); $this->render('userWithPosts', [ 'user' => $user, 'posts' => $posts ]); } 

Here is a mistake. The controller does not need to know how the user will be displayed. He has to find the user, and tell him to "show up with the help of this view." Here we bring part of the display logic to the controller (namely, the knowledge that it needs posts).

The problem is that if you do it like in the example, you can forget about code reuse and catch universal duplication.

Wherever we want to use this view, we will have to transfer to it a list of posts, which means we will have to select them everywhere in advance - duplication of code.

Also, we will not be able to reuse this action. If we remove a selection of posts from it, and make the name of the view a parameter (for example, by implementing it as a CAction ), we can use it wherever we need to display a view with user data. It would look something like this:

  public function actions() { return [ 'showWithPost' => [ 'class' => UserViewAction::class, 'view' => 'withPost' ], 'showWithoutPost' => [ 'class' => UserViewAction::class, 'view' => 'withoutPost' ], 'showAnythingUserView' => [ 'class' => UserViewAction::class, 'view' => 'anythingUserView' ] ]; } 


If you interfere with the controller and the display - it is not possible.

This error creates only duplication of code. The second error is much more disastrous.

2. Suppose we need to translate the news into the archive. This is done by setting the status field. We look action:

  public function actionArchiveNews($id) { $news = News::model()->findByPk($id); $news->status = News::STATUS_ARCHIVE; $news->save(); } 


The error of this example is that we transfer the business logic to the controller. This also leads to the inability to reuse the code (I will explain why below), but this is only a trifle compared to the second problem: what if we change the translation method? For example, instead of changing the status, we will assign true to the inArchive field? And this action will be performed in several places of the application? And this is not news, but a transaction for $ 10 million?

In the example, these places are easy to find - just make Find Usage for the STATUS_ARCHIVE constant. But if you did it with the help of the query "status = 'archive'" - it is much more difficult to find, because even one extra space - and you would not find this line.

Business logic must always remain in the model. Here we should single out a separate method in essence, which translates the news into an archive (or in some other way, but precisely in the layer of business logic). This example is extremely exaggerated, few make the same mistake.

But in the example from the first error there is also this problem, much less obvious:

  $posts = Post::model()->findAllByAttributes(['user_id' => $user->id]); 


Knowing exactly how Post and User are connected is also the business logic of the application. Therefore, this string should not occur in the controller or in the view. Here, the correct solution would be to use a relay for a User , or a scop for a Post :

  //  $posts = $user->posts; //  $posts = Post::model()->forUser($user)->findAll(); 


Magic caction


Controllers (in MVC terminology, in Yii terminology, actions) are the most reusable part of the application. They carry almost no application logic. In most cases, they can be safely copied from project to project.

Let's see how you can implement the UserViewAction from the examples above:

 class UserViewAction extends CAction { /** * @var string view for render */ public $view; /** * @param $id string user id * @throws CHttpException */ public function run($id) { $user = User::model()->findByPk($id); if(!$user) throw new CHttpException(HttpResponse::STATUS_NOT_FOUND, "User not found"); $this->controller->render($this->view, $user); } } 


Now we can ask any view in the action config. This is a good example of reuse code, but it is not perfect. We modify the code so that it works not only with the User model, but with any successor of CActiveRecord :

 class ModelViewAction extends CAction { /** * @var string model class for action */ public $modelClass; /** * @var string view for render */ public $view; /** * @param $id string model id * @throws CHttpException */ public function run($id) { $model = CActiveRecord::model($this->modelClass)->findByPk($id); if(!$model) throw new CHttpException(HttpResponse::STATUS_NOT_FOUND, "{$this->modelClass} not found"); $this->controller->render($this->view, $model); } } 


In essence, we simply replaced the hard-coded User class with the configurable property $modelClass As a result, we have an action that can be used to output any model using any view.

At first glance, it is not flexible, but this is just an example for understanding the general principle. PHP is a very flexible language, and it gives us room for creativity:



Such actions can be written for almost any action: CRUD, validation, execution of business operations, work with related objects, etc.

In fact, it is enough to write about 30-40 such actions, which will cover 90% of the controller code (of course, if you share a model, a view and a controller). The most pleasant plus, of course, is a reduction in the number of bugs, because much less code + easier to write tests + when the action is used in a hundred places, they pop up much faster.

Example action for Update


I will give a couple of examples. Here's an action on update

 class ARUpdateAction extends CAction { /** * @var string update view */ public $view = 'update'; /** * @var string model class */ public $modelClass; /** * @var string model scenario */ public $modelScenario = 'update'; /** * @var string|array url for return after success update */ public $returnUrl; /** * @param $id string|int|array model id * @throws CHttpException */ public function run($id) { $model = CActiveRecord::model($this->modelClass)->findByPk($id); if($model === null) throw new CHttpException(HttpResponse::STATUS_NOT_FOUND, "{$this->modelClass} not found"); $model->setScenario($this->modelScenario); if($data = Yii::app()->request->getDataForModel($model)) { $model->setAttributes($data); if($model->save()) Yii::app()->request->redirect($this->returnUrl); else Yii::app()->response->setStatus(HttpResponse::STATUS_UNVALIDATE_DATA); } $this->controller->render($this->view, $model); } } 


I took his code from CRUD gii, and reworked it a bit. In addition to introducing the $modelClass property for reusability, it is complemented by several other important points:



Naturally, this controller is much simpler than real:



One more important point which is omitted here is the reduction of the input data to the required types. Now the data is usually sent to json , which partially facilitates the task. But the problem still remains, for example, if the client sends a timestamp , and in the model - MongoDate . Providing the model with the correct data is definitely the controller's task. But the information about which types of fields are the knowledge of the model class.

In my opinion, the best place to perform a cast is the Yii::app()->request->getDataForModel($model) method. You can get field types in several ways, for me the most attractive ones are:



In any case, we can make the interface IAttributesTypes where we define the getAttributesTypes method, and declare the HttpRequest::getDataForModel as public getDataForModel(IAttributesTypes $model) . And let each class itself determine how to implement the interface.

Example action for List


Perhaps this is the most difficult example, I will give it to show the division of responsibilities between classes:

 class MongoListAction extends CAction { /** * @var string view for action */ public $view = 'list'; /** * @var array|EMongoCriteria predefined criteria */ public $criteria = []; /** * @var string model class */ public $modelClass; /** * @var string scenario for models */ public $modelScenario = 'list'; /** * @var array dataProvider config */ public $dataProviderConfig = []; /** * @var string dataProvuder class */ public $dataProviderClass = 'EMongoDocumentDataProvider'; /** * @var string filter class */ public $filterClass; /** * @var string filter scenario */ public $filterScenario = 'search'; /** * */ public function run() { //            /** @var $filter EMongoDocument */ $filterClass = $this->filterClass ? $this->filterClass : $this->modelClass; $filter = new $filterClass($this->filterScenario); $filter->unsetAttributes(); if($data = Yii::app()->request->getDataForModel($filter)) $filter->setAttributes($data); $filter->search(); //    ,            //        $filter->getDbCriteria()->mergeWith($this->criteria); //    .     yiimongodbsuite     //    (   - ) /** @var $dataProvider EMongoDocumentDataProvider */ $dataProviderClass = $this->dataProviderClass; $dataProvider = new $dataProviderClass($filter, $this->dataProviderConfig); //     .    ,         self::setScenario($dataProvider->getData(), $this->modelScenario); //   $this->controller->render($this->view, [ 'dataProvider' => $dataProvider, 'filter' => $filter ]); } } 


And an example of its use, displaying inactive users:

  public function actions() { return [ 'unactive' => [ 'class' => MongoListAction::class, 'modelClass' => User::class, 'criteria' => ['scope' => User::SCOPE_UNACTIVE], 'dataProviderClass' => UserDataProvider::class ], ]; } 


The logic of the work is simple: we get the filtering criterion, make the data provider, and output.

Filter:

For simple filtering by attribute value, it is enough to use a model of the same class. But usually, filtering is much more complicated - it can have its own very complex logic, which may well make a bunch of queries to the database or something else. Therefore, it is sometimes reasonable to inherit the filter class from the model, and implement this logic there.

But the only purpose of the filter is to get the criteria for the selection. The implementation of the filter in the example is not entirely successful. The fact is that despite the ability to set a filter class (using $filterClass ), it still implies that it will be a Model . This is evidenced by calling the methods $filter->unsetAttributes() and $filter->search() , which are inherent in models.

The only thing you need to filter is to receive input data and give EMongoCriteria . It just has to implement this interface:

 interface IMongoDataFilter { /** * @param array $data * @return mixed */ public function setFilterAttributes(array $data); /** * @return EMongoCriteria */ public function getFilterCriteria(); } 


I inserted Filter in the method names in order not to depend on the declaration of the setAttributes and getDbCriteria in the implementation class. To use the model as a filter, it is best to write a simple treit:

 trait MongoDataFilterTrait { /** * @param array $data * @return mixed */ public function setFilterAttributes(array $data) { $this->unsetAttributes(); $this->setAttrubites($data); } /** * @return EMongoCriteria */ public function getFilterCriteria() { if($this->validate()) $this->search(); return $this->getDbCriteria(); } } 


IMongoDataFilter action to use the interface, we could use any class that implements the IMongoDataFilter interface, IMongoDataFilter it is a model or something else.

Data provider:
All that concerns the logic of sampling the necessary data - the data provider is responsible for this. Sometimes it also contains quite complex logic, so it makes sense to configure its class using $dataProviderClass .

For example, in the case of the yiimongodbsuite extension, in which there is no possibility to describe relays, we need to load them manually. (in fact, it is better to add this extension, but the example is good).

The load logic can be placed in some class repository, but if it is the responsibility of a particular data provider to return data along with relays, it is the data provider that should call the repository handler method. About reusability data providers, I will write below.

Criteria in the use of action:


I want to once again draw attention to the most "bug-generating" problem:

Knowing who to display (in this case, inactive users) is the knowledge of the controller. But the knowledge of what criterion determines the inactive user is the knowledge of the model.

In the example of using the action, everything is done correctly. With the help of the scoup, we indicated who we want to withdraw, but the scop itself is in the model.

In fact, skoup is a “piece” of SPECIFICATION. You can easily rewrite the action to work with specifications. Although, it is claimed only in complex applications. In most cases, skoup - the perfect solution.

About the separation of the controller and presentation:


Sometimes completely separate the view from the controller is impractical. For example, if for listing the list we need only a few attributes of the model, it’s foolish to select the whole document. But these are features of specific actions that are configured using configuration (in this case, the task of select the criterion). The most important thing is that we learned these settings from the action code, making them reusable.


A bunch of action with the model class


In most cases, the controller (namely CController ) works with one class (for example, with User ). In this case, there is no special need for each action to specify the class of the model - it is easier to specify it in the controller. But in action, this opportunity must be left.
To resolve this situation, in an action you need to register a getter and a setter for $ modelClass. The getter will look like this:

 public function getModelClass() { if($this->_modelClass === null) { if($this->controller instanceof IModelController && ($modelClass = $this->controller->getModelClass())) $this->_modelClass = $modelClass; else throw new CException('Model class must be setted'); } return $this->_modelClass; } 


In principle, you can even make the controller blank for a standard CRUD:

 /** * Class BaseARController */ abstract class BaseARController extends Controller implements IModelController { /** * @return string model class */ public abstract function getModelClass(); /** * @return array default actions */ public function actions() { return [ 'list' => ARListAction::class, 'view' => ARViewAction::class, 'create' => ARCreateAction::class, 'update' => ARUpdateAction::class, 'delete' => ARDeleteAction::class, ]; } } 


Now we can do a CRUD controller in several lines:

 class UserController extends BaseARController { /** * @return string model class */ public function getModelClass() { return User::class; } } 


Total controllers


A large set of customizable actions reduces code duplication. If you divide the classes of actions into a clear structure (for example, an action on editing CActiveRecordand EMongoDocumentdiffer only in the way the objects are selected), duplication can be practically avoided. Such code is much easier to refactor. And it’s harder to make a bug.
Of course, such actions can not cover absolutely all needs. But a significant part of them is definitely yes.

Representation


Yii gives us a great infrastructure for building it. This CWidget, CGridColumn, CGridView, Menuand more. Do not be afraid to use it all, expand, rewrite.

This is all easily studied by reading the documentation, but I want to explain something else.

I mentioned above that the controller should not know exactly how the entity will be displayed, so it should not contain code for fetching data for views. I am well aware that this statement will cause a lot of protests - everyone always prepares the data in the controllers. Even Yii himself hints to us that the controller and the view are connected, passing the copy of the controller as a view $this.

But it is not.On the controller side, the benefits of getting rid of high coherence with views are obvious. But what to do with the views? I will answer this question here.

I will consider two common cases: the presentation of an entity with related data, and the presentation of a list of entities. Examples are trivial, but the essence will be explained.

Suppose we have an online store. There is a client (model Client), his address (model Address) and orders (model Order). One client can have one address and many orders.

Representation of an entity with related data


Suppose we need to display information about the client, his address, and a list of his orders.

In essence, each view has its own “interface”. This is the data transferred to it from CController::renderand the controller instance itself (accessible by $this). The less data is transferred to it - the better, because the more it is independent. This approach will make the view reusable within the project. Especially considering that views in Yii are quietly invested in each other, and can even “communicate” with each other, for example, with the help CController::$clips.

The necessary data to display our view is the client object. Having it, we calmly get all the other data.

Here it is necessary to retreat and pay attention to the letter "M" of MVC.

Each subject area has its own essence and connections between them. And it is very important that our code displays them as identically as possible.
In our store, the customer owns both the address and the order. This means that in the model Clientswe have to explicitly display these relationships using properties $client->adressor methods.$client->getOrders()
It is very important. I will tell you more about this in the next post.


If the domain is properly designed, we will always have an easy way to get the related data. And it absolutely solves the problem with the fact that the controller did not give us a list of orders.

In this case, the output code is as simple as possible:
 $this->widget('zii.widgets.CDetailView', [ 'model' => $client, 'attributes' => [ 'fio', 'age', 'address.city', 'adress.street' ] ]); foreach($client->orders as $order) { $this->widget('zii.widgets.CDetailView', [ 'model' => $order, 'attributes' => [ 'date', 'sum', 'status', ] ]); } 


If we decide to divide this view so that we can use its parts independently, then the code will be like this:

  $this->renderPartial('_client', $client); $this->renderPartial('_address', $client->address); $this->renderPartial('_orders', $client->orders); 


This code is simple, but has a flaw - if the client has many orders, you need to output it with pagination.
No one bothers to push us all this in the date of the provider. Suppose a model Orderis a mongo document. Will wrap in EMongoDocumentDataProvider:

  $this->widget('zii.widgets.grid.CGridView', [ 'dataProvider' => new EMongoDocumentDataProvider((new Order())->forClient($client)), 'columns' => ['date', 'sum', 'status'] ]); 


Creating a data provider in a view is somewhat unusual. But in fact, everything is in place here: the controller has already fulfilled its responsibilities, knowledge of how related Clientand Userlocated in the subject area (thanks to scopes forClient), and knowledge of how to display data are in the view.

In fact, some of my colleagues, seeing this, twisted at the temple - creating a data provider in a view - what kind of nonsense? At the same time, they themselves performed similar actions in widgets, not realizing that the widget is, first of all, the presentation infrastructure.

A widget is a great tool for creating a reusable and flexible presentation, as well as a logical distinction. But its purpose is a representation, so there is no conceptual difference where the above code is located - in the widget or in the view.

Entity List View


The representation of the list of entities differs from the representation of a specific entity only in the selection of data.

Suppose that Client, Addressand Orderare three different collections in MongoDB. In the case of withdrawal of a single client, we can easily call $client->address. This will make a query to the database, but it is inevitable.

If we select 100 clients, and for each call $client->addresswe will receive 100 queries to the database - this is unacceptable. Download addresses for all customers at once.

If we used AR, we would describe relays, and use them in the action criteria. But with MongoDB(more precisely, with the extension yiimongodbsuite) it will not work.

The best place to implement a sample of additional data is the data provider. It, as an object intended for data sampling, must know what data it should return and how to select it.

This is done somehow like this:
 class ClientsDataProvider extends EMongoDocumentDataProvider { /** * @param bool $refresh * @return array */ public function getData($refresh=false) { if($this->_data === null || $refresh) { $this->_data=$this->fetchData(); //   id  $addressIds = []; foreach($this->_data as $client) $addressIds[] = $client->addressId; //   $adresses = Address::model()->findAllByPk($addressIds); ...          .... } return $this->_data; } } 


There are 2 problems here:



The solution is to move the load code to the repository, which can be the model class itself.

If we move it there, our data provider will look like this:

 class ClientsDataProvider extends EMongoDocumentDataProvider { /** * @param bool $refresh * @return array */ public function getData($refresh=false) { if($this->_data === null || $refresh) { $this->_data=$this->fetchData(); Client::loadAddresses($this->_data); } return $this->_data; } } 


Now everything is in place.

«»:
Client , Address . , Client. . , , , — -. , . Client , . .


-


Data providers are also reusable (as part of the application). Suppose we have 2 actions: displaying a list of orders, and the above user page, where the list of orders is also displayed.

In both cases, we can use the same data provider, which will load us with the necessary data.
I also see no reason not to make them configurable.

Controller as $ this in views


In my opinion, this is a mistake. Of course, a class CControllerperforms many actions unrelated to its conceptual purpose. But nevertheless in his views, his immediate presence creates confusion. I have seen many times (yes, to confess, and I did it myself), as the presentation logic was carried to the controller (some special methods for formatting or something like that) only because the controller was present in all its views. It is not right. A view must be represented by its own separate class.

Conclusion


All examples are greatly simplified. The real class of controllers, the structure of the models are much larger.

This is too complicated and confusing - many will think so. Many, having sat down to work for a similar code, without having understood the structure, will simply cut it out and write “by simple”.

This is quite understandable - I just described the interaction of several classes - and already a wild confusion, the simplest implementation code scattered around a bunch of files. But in reality it is a clear and logical structure of classes in which each line is exactly in its place.

Perhaps a small project will destroy such an approach. On writing of one infrastructure rather decent time is necessary. But for the big one, this is the only chance to survive.

Afterword


Despite the fact that the post is called "how to do it right", it does not pretend to be correct. I myself do not know how to. He is an attempt to convey that we need a more intelligent approach to the design of classes and their interaction.

PHP developers gave us a powerful language. The developers of Yii gave us a great framework. But look around - representatives of other languages ​​and frameworks consider us to be bylocloders. PHP and Yii - we disgrace them.

With our negligent attitude to design, the banal ignorance of the basic principles of MVC, object-oriented design, the language in which we write, and the framework that we use - all this we bring PHP. We let Yii down. We bring the companies we work for and who provide us. But most importantly - we let ourselves down.

Think about it.

All good.

Source: https://habr.com/ru/post/211739/


All Articles