作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
Elvira Sheina的头像

Elvira Sheina

Elvira是一名高级软件工程师,在全栈web开发和自动化方面拥有近15年的经验.

Expertise

Previously At

Nord Security
Share

胖控制器和模型:对于大多数基于MVC框架(如Yii和Laravel)的大型项目来说,这是一个不可避免的问题. 使控制器和模型变胖的主要原因是 Active Record,是此类框架的一个强大而重要的组成部分.

问题:活动记录及其对SRP的违反

活动记录是一种体系结构模式,是访问数据库中数据的一种方法. 它是由马丁·福勒在他2003年的书中命名的 企业应用程序体系结构模式 and is widely used in PHP Frameworks.

尽管事实上这是一种非常必要的方法, 活动记录(AR)模式违反了单一职责原则(SRP),因为AR模型:

  • 处理查询和数据保存.
  • 对系统中的其他模型了解太多(通过关系).
  • 通常直接涉及应用程序的业务逻辑(因为数据存储的实现与所述业务逻辑密切相关)。.

当您需要尽快创建应用程序原型时,这种对SRP的违背是快速开发的一种很好的权衡, 但是当应用程序发展成一个中型或大型项目时,它是非常有害的. “上帝”模型和胖控制器很难测试和维护, 当您不可避免地必须更改数据库结构时,在控制器中任意位置自由使用模型会导致巨大的困难.

解决方案很简单:将活动记录的职责划分为几个层,并注入跨层依赖. 这种方法还将简化测试,因为它允许您模拟当前未被测试的那些层.

解决方案:PHP MVC框架的分层结构

一个“胖”的PHP MVC应用程序到处都有依赖项, 联锁且容易出错, 而分层结构则使用依赖注入来保持事物的整洁和清晰.

我们将涉及五个主要层面:

  • The controller layer
  • The service layer
  • DTOs是服务层的子集
  • View decorators是服务层的子集
  • The repository layer

A Layered PHP Structure

要实现分层结构,我们需要一个 依赖注入容器,一个知道如何实例化和配置对象的对象. 您不需要创建类,因为框架会处理所有神奇的事情. Consider the following:

类SiteController扩展\照亮\路由\控制器
{
   protected $userService;

   __construct(UserService $ UserService)
   {
       $this->userService = $userService;
   }

   showUserProfile(Request $ Request)
   {
       $user = $this->userService->getUser($request->id);
       return view('user.概要文件的,紧凑(“用户”));
   }
}

class UserService
{
   protected $userRepository;

   __construct(UserRepository $ UserRepository)
   {
       $this->userRepository = $userRepository;
   }

   getUser($id)
   {
       $user = $this->userRepository->getUserById($id);
       $this->userRepository->logSession($user);
       return $user;
   }
}

class UserRepository
{
   保护$userModel, $logModel;

   公共函数__construct(User $ User, Log $ Log)
   {
       $this->userModel = $user;
       $this->logModel = $log;
   }

   getUserById($id)
   {
       return $this->userModel->findOrFail($id);
   }

   logSession($user)
   {
       $this->logModel->user = $user->id;
       $this->logModel->save();
   }
}

In the above example, UserService is injected into SiteController, UserRepository is injected into UserService and the AR models User and Logs are injected into the UserRepository class. 这个容器代码相当简单,所以让我们来讨论层.

The Controller Layer

像Laravel和Yii这样的现代MVC框架为你承担了许多传统控制器的挑战:输入验证和预过滤器被移动到应用程序的另一部分(在Laravel中), it’s in what’s called middleware 然而,在Yii中,它被称为 behavior),而路由和HTTP动词规则则由框架处理. 这给程序员在控制器中编写代码留下了非常有限的功能.

控制器的本质是获取请求并交付结果. A controller shouldn’t contain any application business logic; otherwise, 很难重用代码或更改应用程序的通信方式. 如果您需要创建API而不是呈现视图, for example, 你的控制器不包含任何逻辑, 你只需要改变返回数据的方式就可以了.

这种薄的控制器层常常使程序员感到困惑, and, 因为控制器是一个默认层,也是最顶层的入口点, 许多开发人员只是不断地向他们的控制器添加新代码,而没有额外考虑架构. 因此,增加了过多的责任,例如:

  • 业务逻辑(这使得重用业务逻辑代码变得不可能).
  • 直接更改模型状态(在这种情况下,数据库中的任何更改都会导致代码中的任何地方发生巨大变化).
  • 为关系逻辑(如复杂查询)建模, joining of multiple models; again, 如果数据库或关系逻辑中的某些内容发生了更改, 我们必须在所有控制器中更改它).

让我们考虑一个过度设计控制器的例子:

//控制器的一个坏例子
公共函数用户(请求$请求)
{
   $user = User::where('id', '=', $request->id)
   ->leftjoin('posts', function ($join) {
       $join->on('posts.user_id', '=', 'user.id')
           ->where('posts.状态','=',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:

  • 它包含了太多的业务逻辑.
  • 它直接与活动记录一起工作, 如果你改变了数据库中的一些东西, like rename the last_login 字段,你必须在所有控制器中更改它.
  • 它知道数据库关系, 因此,如果数据库中有什么变化,我们必须改变它的所有地方.
  • 它不可重用,导致代码重复.

A controller should be thin; really, all it should do is take a request and return results. Here’s a good example:

//控制器的一个很好的例子
公共函数用户(请求$请求)
{
 $user = $this->userService->getUserById($request->id);
 return view('user.index', compact('user'));
}

但是其他的东西都去哪了? It belongs in the service layer.

The Service Layer

服务层是业务逻辑层. Here, and only here, 应该放置有关业务流程流和业务模型之间交互的信息. 这是一个抽象层,对于每个应用程序都是不同的, 但一般原则是独立于数据源(控制器的职责)和数据存储(较低层的职责)。.

这是最有可能出现成长问题的阶段. Often, 活动记录模型返回到控制器, and as a result, 视图(在API响应的情况下), 控制器必须与模型一起工作,并了解模型的属性和依赖关系. This makes things messy; if you decide to change a relation or an attribute of an Active Record model, 你必须在所有视图和控制器中修改它.

这里有一个常见的例子,你可能会遇到一个活动记录模型在视图中使用:

{{$user->first_name}} {{$user->last_name}}

    @foreach($user->posts as $post)
  • {{$post->title}}
  • @endforeach

看起来很简单,但是如果我重命名 first_name 字段时,我突然必须更改使用该模型字段的所有视图,这是一个容易出错的过程. 避免这个难题的最简单方法是使用数据传输对象(dto).

Data Transfer Objects

来自服务层的数据需要包装到一个简单的不可变对象中——这意味着它在创建后不能被更改——因此我们不需要DTO的任何setter. 此外,DTO类应该是独立的,不扩展任何活动记录模型. 不过要小心——商业模式并不总是和AR模式一样.

考虑一个杂货配送应用程序. Logically, 杂货店的订单需要包含送货信息, but in the database, 我们存储订单并将其链接到用户, 用户被链接到一个送货地址. 在这种情况下,有多个AR模型,但上层不应该知道它们. 我们的DTO类不仅包含订单,还包含交付信息和与业务模型一致的任何其他部分. 如果我们改变与这个商业模式相关的AR模型(例如, 我们将交付信息移动到订单表中),我们将只更改DTO对象中的字段映射, 而不是在代码中到处改变AR模型字段的用法.

By employing a DTO approach, 我们消除了在控制器或视图中更改Active Record模型的诱惑. Secondly, DTO方法解决了物理数据存储和抽象业务模型的逻辑表示之间的连接问题. 如果需要在数据库级别更改某些内容, 更改将影响DTO对象,而不是控制器和视图. Seeing a pattern?

让我们来看一个简单的DTO:

//简单DTO类的例子. 您可以在这里添加从活动记录对象到业务模型的任何转换逻辑 
class DTO
{
   private $entity;

   公共静态函数make($model)
   {
       return new self($model);
   }

   公共函数__construct($model)
   {
       $this->entity = (object) $model->toArray();
   }

   公共函数__get($name)
   {
       return $this->entity->{$name};
   }

}

使用我们的新DTO同样简单:

//usage example
公共函数用户(请求$请求)
{
 $user = $this->userService->getUserById($request->id);
 $user = DTO::make($user);
 return view('user.index', compact('user'));
}

View Decorators

用于分离视图逻辑(如根据某些状态选择按钮的颜色), 使用额外的装饰层是有意义的. A decorator 设计模式是否允许通过使用自定义方法包装核心对象来对其进行修饰. 它通常发生在视图中,带有一些特殊的逻辑.

而DTO对象可以执行装饰器的工作, 它实际上只适用于日期格式等常见操作. DTO应该代表业务模型, 而装饰器用HTML修饰特定页面的数据.

让我们来看一个没有使用装饰器的用户配置文件状态图标的片段:

@if($user->status == \App\Models\User::STATUS_ONLINE) @else @endif
{{date('F j, Y', strtotime($user->lastOnline))}}

虽然这个例子很简单, 开发人员很容易迷失在更复杂的逻辑中. 这时就需要一个修饰器来清理HTML的可读性. 让我们把状态图标片段扩展成一个完整的装饰器类:

class UserProfileDecorator
{
   private $entity;

   公共静态函数装饰($model)
   {
       return new self($model);
   }

   公共函数__construct($model)
   {
       $this->entity = $model;
   }

   公共函数__get($name)
   {
       $methodName = 'get' . $name;
       if (method_exists(self::class, $methodName)) {
           return $this->$methodName();
       } else {
           return $this->entity->{$name};
       }
   }

   公共函数__call($name, $arguments)
   {
       return $this->entity->$name($arguments);
   }

   getStatus()
   {
       if($this->entity->status == \App\Models\User::STATUS_ONLINE) {
           return '';
       } else {
           return '';
       }
   }

   getLastOnline()
   {
       return  date('F j, Y', strtotime($this->entity->lastOnline));
   }
}

使用装饰器很容易:

公共函数用户(请求$请求)
{
 $user = $this->userService->getUserById($request->id);
 $user = DTO::make($user);
 $user = UserProfileDecorator::装饰($user);
 return view('user.index', compact('user'));
}

现在我们可以在视图中使用模型属性,而不需要任何条件和逻辑, 而且可读性更强:

{{$user->status}}
{{$user->lastOnline}}

装饰者也可以组合:

公共函数用户(请求$请求)
{
 $user = $this->userService->getUserById($request->id);
 $user = DTO::make($user);
 $user = UserDecorator::装饰($user);
 $user = UserProfileDecorator::装饰($user);
 return view('user.index', compact('user'));
}

每个装饰师将做自己的工作,只装饰自己的部分. 这种多个装饰器的递归嵌入允许动态组合它们的特性,而无需引入额外的类.

The Repository Layer

存储库层与数据存储的具体实现一起工作. 最好通过接口注入存储库,以获得灵活性和易于替换. 如果您更改了数据存储, 您必须创建一个实现存储库接口的新存储库, 但至少你不需要改变其他层.

存储库扮演查询对象的角色:它从数据库获取数据并执行几个活动记录模型的工作. Active Record models, in this context, 扮演单个数据模型实体的角色—系统中您关心建模和存储信息的任何对象. 而每个实体包含信息, 它不知道它是如何出现的(如果它是创建的或从数据库获得的), 或者如何保存和更改自己的状态. 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.

下面是一个简单的存储库方法示例,它使用关于数据库和活动记录关系的知识来构建查询:

public function getUsers()
{
return User::leftjoin('posts', function ($join) {
    $join->on('posts.user_id', '=', 'user.id')
        ->where('posts.状态','=',Post::STATUS_APPROVED);
   })
   ->leftjoin('orders', 'orders.user_id', '=', 'user.id')
   ->where('user.状态','=',User::STATUS_ACTIVE)
   ->where('orders.price', '>', 100)
   ->orderBy('orders.date')
   ->with('info')
   ->get();
}

用单一职责层保持苗条

在新创建的应用程序中,您将只能找到用于存储控制器、模型和视图的文件夹. Yii和Laravel都没有在其示例应用程序的结构中添加额外的层. Easy and intuitive, even for novices, MVC结构简化了使用框架的工作, but it is important to understand that their sample application is an example; it isn’t a standard or a style, 而且它不强加任何关于应用程序架构的规则. 通过把任务分开, 单一职责层, 我们得到了一个易于维护的灵活且可扩展的体系结构. Remember:

  • Entities are single data models.
  • Repositories fetch and prepare data.
  • The service layer has only business logic.
  • Controllers 与所有外部资源沟通,如用户输入或第三方服务.

所以,如果你开始一个复杂的项目,或者一个有机会在未来发展的项目, 考虑将职责清晰地划分到控制器中, the service, and the repository layers.

Tags

就这一主题咨询作者或专家.
Schedule a call
Elvira Sheina的头像
Elvira Sheina

Located in 塔什干,乌兹别克斯坦塔什干省

Member since December 9, 2015

About the author

Elvira是一名高级软件工程师,在全栈web开发和自动化方面拥有近15年的经验.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

Expertise

Previously At

Nord Security

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

Toptal Developers

Join the Toptal® community.