Active Record, a powerful and essential component of such frameworks.
The Problem: Active Records and Its Violation of the SRP
问题:违反了单一职责原则(SRP)的活动记录
Patterns of Enterprise Application Architecture and is widely used in PHP Frameworks.
Despite the fact that it is a very necessary approach, the Active Record (AR) pattern violates the Single Responsibility Principle (SRP) because AR models:
- Deal with querying and data saving.
- Know too much about the other models in the system (through relationships).
- Are often directly involved in the application’s business logic (because the implementation of data storage is closely linked to said business logic).
This violation of the SRP is a good tradeoff for rapid development when you need to create an application prototype as soon as possible, but it is quite harmful when the application grows into a middle or a large-scale project. “God” models and fat controllers are difficult to test and maintain, and freely using models everywhere in controllers leads to tremendous difficulties when you inevitably have to change the database structure.
The solution is simple: divide the Active Record’s responsibility into several layers and inject cross-layer dependencies. This approach will also simplify testing because it allows you to mock those layers not currently being tested.
The Solution: A Layered Structure for PHP MVC Frameworks
解决方案:针对PHP MVC框架的分层结构
A “fat” PHP MVC application has dependencies everywhere, interlocking and error-prone, while a layered structure uses dependency injection to keep things clean and clear cut.
There are five primary layers that we’ll cover:
- controller layer
- service layer
- DTOs, a subset of the service layer
- View decorators, a subset of the service layer
- repository layer
dependency injection container, an object that knows how to instantiate and configure objects. You don’t need to create a class because the framework handles all the magic. Consider the following:
class SiteController extends \Illuminate\Routing\Controller
{
protected $userService;
public function __construct(UserService $userService)
{
$this->userService = $userService;
}
public function showUserProfile(Request $request)
{
$user = $this->userService->getUser($request->id);
return view('user.profile', compact('user'));
}
}
class UserService
{
protected $userRepository;
public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}
public function getUser($id)
{
$user = $this->userRepository->getUserById($id);
$this->userRepository->logSession($user);
return $user;
}
}
class UserRepository
{
protected $userModel, $logModel;
public function __construct(User $user, Log $log)
{
$this->userModel = $user;
$this->logModel = $log;
}
public function getUserById($id)
{
return $this->userModel->findOrFail($id);
}
public function logSession($user)
{
$this->logModel->user = $user->id;
$this->logModel->save();
}
}
UserService
is injected into SiteController
, UserRepository
is injected into UserService
and the AR models User
and Logs
are injected into the UserRepository
The Controller Layer 控制层
middleware whereas, in Yii, it’s called behavior) while routing and HTTP verb rules are handled by the framework. This leaves a very narrow functionality for the programmer to code into a controller.
The essence of a controller is to get a request and deliver the results. A controller shouldn’t contain any application business logic; otherwise, it’s difficult to reuse code or change how the application communicates. If you need to create an API instead of rendering views, for example, and your controller doesn’t contain any logic, you just change the way you return your data and you’re good to go.
This thin controller layer often confuses programmers, and, since a controller is a default layer and the top-most entry point, many developers just keep adding new code to their controllers without any additional thinking about architecture. As a result, excessive responsibilities get added, responsibilities like:
- Business logic (which it makes impossible to reuse business logic code).
- Direct changes of model states (in which case any changes in the database would lead to tremendous changes everywhere in the code).
- Model relation logic (such as complex queries, joining of multiple models; again, if something is changed in the database or in the relation logic, we would have to change it in all controllers).
Let’s consider an over-engineered controller example:
//A bad example of a controller
public function user(Request $request)
{
$user = User::where('id', '=', $request->id)
->leftjoin('posts', function ($join) {
$join->on('posts.user_id', '=', 'user.id')
->where('posts.status', '=', Post::STATUS_APPROVED);
})
->first();
if (!empty($user)) {
$user->last_login = date('Y-m-d H:i:s');
} else {
$user = new User();
$user->is_new = true;
$user->save();
}
return view('user.index', compact('user'));
}
Why is this example bad? For numerous reasons:
- It contains too much business logic.
last_login
- It knows about database relations, so if something changes in database we have to change it everywhere.
- It’s not reusable, leading to code repetition.
A controller should be thin; really, all it should do is take a request and return results. Here’s a good example:
//A good example of a controller
public function user (Request $request)
{
$user = $this->userService->getUserById($request->id);
return view('user.index', compact('user'));
}
service layer.
The Service Layer 服务层
The service layer is a layer of business logic. Here, and only here, information about business process flow and interaction between the business models should be situated. This is an abstract layer and it will be different for each application, but the general principle is independence from your data source (the responsibility of a controller) and data storage (the responsibility of a lower layer).
This is the stage with the most potential for growth problems. Often, an Active Record model is returned to a controller, and as a result, the view (or in the case of API response, the controller) must work with the model and be aware of its attributes and dependencies. This makes things messy; if you decide to change a relation or an attribute of an Active Record model, you have to change it everywhere in all your views and controllers.
Here’s a common example you might come across of an Active Record model being used in a view:
<h1>{{$user->first_name}} {{$user->last_name}}</h1>
<ul>
@foreach($user->posts as $post)
<li>{{$post->title}}</li>
@endforeach
</ul>
first_name
Data Transfer Objects 数据传输对象
Data from the service layer needs to be wrapped into a simple immutable object—meaning it can’t be changed after it is created—so we don’t need any setters for a DTO. Furthermore, the DTO class should be independent and not extend any Active Record models. Careful, though—a business model is not always the same as an AR model.
Consider a grocery delivery application. Logically, a grocery store order needs to include delivery information, but in the database, we store orders and link them to a user, and the user is linked to a delivery address. In this case, there are multiple AR models, but the upper layers shouldn’t know about them. Our DTO class will contain not only the order but also delivery information and any other parts that are in line with a business model. If we change AR models related to this business model (for example, we move delivery information into the order table) we will change only field mapping in the DTO object, rather than changing your usage of AR model fields everywhere in the code.
By employing a DTO approach, we remove the temptation to change the Active Record model in the controller or in the view. Secondly, the DTO approach solves the problem of connectivity between the physical data storage and the logical representation of an abstract business model. If something needs to be changed on the database level, the changes will affect the DTO object rather than the controllers and views. Seeing a pattern?
Let’s take a look at a simple DTO:
//Example of simple DTO class. You can add any logic of conversion from an Active Record object to business model here
class DTO
{
private $entity;
public static function make($model)
{
return new self($model);
}
public function __construct($model)
{
$this->entity = (object) $model->toArray();
}
public function __get($name)
{
return $this->entity->{$name};
}
}
Using our new DTO is just as straightforward:
//usage example
public function user (Request $request)
{
$user = $this->userService->getUserById($request->id);
$user = DTO::make($user);
return view('user.index', compact('user'));
}
View Decorators 视图装饰者
decorator
While a DTO object can perform a decorator’s job, it really only works for common actions like date formatting. A DTO should represent a business model, whereas a decorator embellishes data with HTML for specific pages.
Let’s look at a snippet of a user profile status icon that doesn’t employ a decorator:
<div class="status">
@if($user->status == \App\Models\User::STATUS_ONLINE)
<label class="text-primary">Online</label>
@else
<label class="text-danger">Offline</label>
@endif
</div>
<div class="info"> {{date('F j, Y', strtotime($user->lastOnline))}} </div>
While this example is straightforward, it’d be easy for a developer to get lost in more complicated logic. This is where a decorator comes in, to clean up the HTML’s readability. Let’s expand our status icon snippet into a full decorator class:
class UserProfileDecorator
{
private $entity;
public static function decorate($model)
{
return new self($model);
}
public function __construct($model)
{
$this->entity = $model;
}
public function __get($name)
{
$methodName = 'get' . $name;
if (method_exists(self::class, $methodName)) {
return $this->$methodName();
} else {
return $this->entity->{$name};
}
}
public function __call($name, $arguments)
{
return $this->entity->$name($arguments);
}
public function getStatus()
{
if($this->entity->status == \App\Models\User::STATUS_ONLINE) {
return '<label class="text-primary">Online</label>';
} else {
return '<label class="text-danger">Offline</label>';
}
}
public function getLastOnline()
{
return date('F j, Y', strtotime($this->entity->lastOnline));
}
}
Using the decorator is easy:
public function user (Request $request)
{
$user = $this->userService->getUserById($request->id);
$user = DTO::make($user);
$user = UserProfileDecorator::decorate($user);
return view('user.index', compact('user'));
}
Now we can use model attributes in the view without any conditions and logic, and it’s much more readable:
<div class="status"> {{$user->status}} </div>
<div class="info"> {{$user->lastOnline}} </div>
Decorators also can be combined:
public function user (Request $request)
{
$user = $this->userService->getUserById($request->id);
$user = DTO::make($user);
$user = UserDecorator::decorate($user);
$user = UserProfileDecorator::decorate($user);
return view('user.index', compact('user'));
}
Each decorator will do its job and decorate only its own part. This recursive embedding of several decorators allows for a dynamic combination of their features without introducing additional classes.
The Repository Layer 存储层
The repository layer works with the concrete implementation of data storage. It’s best to inject the repository through an interface for flexibility and easy replacement. If you change your data storage, you have to create a new repository that implements your repository interface, but at least you don’t have to change the other layers.
The repository plays the role of a query object: It gets data from the database and conducts the work of several Active Record models. Active Record models, in this context, play the role of single data model entities—any object in the system that you care to model and store information about. While each entity contains information, it doesn’t know how it appeared (if it was created or obtained from the database), or how to save and change its own state. The responsibility of the repository is to save and/or update an entity; this provides better separation of concerns by keeping management of entities in the repository and making entities simpler.
Here’s a straightforward example of a repository method that builds a query using knowledge about the database and Active Record relations:
public function getUsers()
{
return User::leftjoin('posts', function ($join) {
$join->on('posts.user_id', '=', 'user.id')
->where('posts.status', '=', Post::STATUS_APPROVED);
})
->leftjoin('orders', 'orders.user_id', '=', 'user.id')
->where('user.status', '=', User::STATUS_ACTIVE)
->where('orders.price', '>', 100)
->orderBy('orders.date')
->with('info')
->get();
}
Keeping Slim with Single Responsibility Layers 用单一职责层级保持小巧
In a newly created application, you’ll only find folders for сontrollers, models, and views. Neither Yii nor Laravel add additional layers in their example application’s structure. Easy and intuitive, even for novices, the MVC structure simplifies work with the framework, but it is important to understand that their sample application is an example; it isn’t a standard or a style, and it doesn’t impose any rules about application architecture. By dividing tasks into separate, single responsibility layers, we get a flexible and extensible architecture that is easy to maintain. Remember:
- Entities
- Repositories
- service layer
- Controllers
So if you start a complex project or a project that has a chance to grow in the future, consider a clear division of responsibilities into the controller, the service, and the repository layers.