用分层结构打造苗条MVC框架


原文请访问:Maintain Slim PHP MVC Frameworks with a Layered Structure

基于诸如Yii和Laravel这样MVC框架的一个必然问题就是:臃肿的controller(控制器)和臃肿的model(模型)。而使控制器和模型变得臃肿的主要东西就是这些框架中强大而又必要的组件 -- Active Record。

问题:违反了单一职责原则的Active Record

Active Record是一个架构模式,一种访问数据库中数据的方式。由Martin Fowler在他2003年的《企业应用框架模式》一书命名并广泛应用在PHP框架中。

尽管它一是个非常必要的方式,然而Active Record(AR)模式违反了单一职责原则(SRP),因为AR模型:

  • 处理了数据查询与数据存储。
  • (通过关系)知道了系统中太多其他的模型。
  • 经常在应用的业务逻辑中直接调用(因为数据存储的实现与业务逻辑的表达密切相关)。

当需要尽快创建一个应用原型时,违反SRP对于快速开发来说是一桩好的交易,但当应用成长为一个中等或者大型项目时,这是非常有害的。“上帝”模型和臃肿的控制器难以测试和维护,并且如果你不可避免地不得不改变数据库结构的话,在控制器的各个地方随意使用模型将会导致维护成本巨大。

解决方案很简单:把Active Record的职责划分到几个层,并注入跨层(cross-layer)依赖。这种方式还能简化测试,因为它允许你模拟非当前所测试的层。

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

“臃肿的”PHP MVC应用随处都有依赖,错综复杂且容易出错,而分层结构则使用依赖注入保持干净和清晰。

主要有这五层,我们稍候会讲到:

  • 控制层
  • 服务层
  • 数据传输对象(DTO),服务层的一个子集
  • 视图装饰者,服务层的一个子集
  • 存储层

为了实现一个分层结构,我们需要一个依赖注入容器,一个知道如何实例化和配置对象的对象。你不需要创建一个类,因为框架会处理全部这些神奇的东西。考虑以下代码:

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被注入到了SiteControllerUserRepository被注入到了UserService,而AR模型ARLogs则被注入到了UserRepository类中。这个容器的代码相当直截了当,所以让我们来讨论一下这五层结构。

控制层

现代MVC框架,像Laravel和Yii,帮你承担了传统控制器中很多的挑战:当框架处理路由和HTTP谓词规则时,输入验证和预过滤器被移到了应用程序的另外一部分(在Laravel中称为中间层,而在Yii则称为行为)。留下很小一部分功能要程序员编进控制器。

控制器的本质是获取一个请求,然后分发对应的结果。控制器不应该包含任何业务逻辑,否则很难重用代码或者改变应用程序沟通的方式。例如你需要创建一个API而不是渲染视图,如果控制器不包含任何逻辑,那么你只需要改变数据返回的方式即可。

这个瘦瘦的控制器经常会让程序员感到困惑,此外,由于控制器是一个默认的层并作为最高入口点,很多程序员只是一味地往控制器添加新代码,而丝毫不考虑任何架构。结果,添加了过多的职责,一如:

  • 业务逻辑(使重用业务逻辑代码变得有可能)
  • 直接改变模型状态(这种情况下,数据库的任何改变都会导致各处代码发生巨大的改变。译者注:可谓牵一发而动全身
  • 模型关系逻辑(例如复杂查询,多个模型的关联;再一次,如果数据库或者关系逻辑发生改变,我们将不得不在全部控制器中修改它)。

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

// 一个糟糕的控制器示例
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'));
}

为什么这是一个糟糕的例子?有很多原因:

  • 它包含了太多业务逻辑。
  • 它直接使用了Active Record,所以如果你改变了数据库中的某些东西,像重命名last_login字段,你不得不在全部控制器中修改它。
  • 它知道了数据库中的关系,所以如果数据库中的某些东西发生变化了我们也不得不相应做出修改。
  • 它是不可重用的,导致了代码重复。

控制器应该是苗条的;真的,它要做的全部事就是接收请求然后返回结果。这是一个好的例子:

//  一个好的控制器示例
public function user (Request $request)  
{
 $user = $this->userService->getUserById($request->id);
 return view('user.index', compact('user'));
}

但其他东西去哪了?它属于在上面所说的服务层。

服务层

服务层即业务逻辑层。关于业务流程的信息以及业务模型之间的交互应该放置在这里,并且只应该放置在这里。这是一个抽象层,并且对于每个应用程序都是不同的,但通常原则是独立于数据源(控制器的职责)和数据存储(底层的职责)。

这是最有可能增长问题的阶段。往往,Active Record会返回给控制器,结果视图(或者API响应情况下的控制器)必须与模型一起工作并且要识别其属性和依赖。这把东西搞混乱了;如果你决定修改某个关系或者Active Record模型中的某个属性,你不得不在全部视图和控制器中修改它。

以下是一个在视图中使用Active Record模型的例子,你可能遇到过:

<h1>{{$user->first_name}} {{$user->last_name}}</h1>  
<ul>  
@foreach($user->posts as $post)
<li>{{$post->title}}</li>  
@endforeach
</ul>  

它看起来直截了当,但如果我重命名了first_name字段,突然间全部额头到了这个模型字段的视图我都得修,一个容易出错的过程。避免这个难题的最简单的方式是使用数据传输对象,简称DTO。

数据传输对象

来自服务层的数据应包装到一个简单的不可变对象中 -- 意味着一旦被创建后就不能再修改 -- 所以对于DTO不需要任何setter。因此,DTO类应该是独立的且不需要扩展任何Active Record模型。注意,业务模型并不总是和AR模型相同。

考虑一个杂货店派送应用程序。逻辑上,一个杂货店订单需要包含收货信息,但在数据库,我们存储了订单并把他们链接到某个用户,然后再把用户链接到某个收货地址。在这种情况下,有多个AR模型,但上层不应该知道他们。我们的DTO不仅要包含信息还要包含收货信息,以及任何其他与业务模式相符的部分。假如我们改变了与这个业务模型有关的AR模型(例如,把收货信息移到订单表),只需要修改DTO对象中的字段映射,而不用修改代码中各处AR模型的使用方式。

通过聘用DTO方式,我们移除了在控制器或者视图中修改Active Record模型的诱惑。其次,DTO方式解决了物理数据存储与抽象业务模型的逻辑表现之间的关联问题。如果需要修改数据库层面的某些东西,这些修改只会影响DTO对象,而不是控制器和视图。看到一个模式了吗?

来看一个简单的DTO:

// 简单DTO类示例。在这里你可以添加任何从Active Record到业务模型的转换
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};
   }

}

使用我们新的DTO也十分简单:

// 使用示例
public function user (Request $request)  
{
 $user = $this->userService->getUserById($request->id);
 $user = DTO::make($user);
 return view('user.index', compact('user'));
}

视图装饰者

对于分离视图逻辑(像根据状态选择按钮的颜色),使用额外一层装饰者是有意义的。装饰者是一个设计模式,它允许通过包装自定义方法对一个核心对象进行点缀。它通常发生在有某种特殊逻辑片段的视图中。

虽然DTO对象可以执行装饰者的工作,它实际上只为公共的动作而工作,如日期格式化。DTO应该代表业务模型,而装饰者为特定页面用HTML点缀数据。

来看一段没有应用装饰者的用户个人资料的状态图标的代码:

<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>  

虽然这个例子很简单,但开发人员很容易迷失在更复杂的逻辑中。这就是装饰者的用武之地,以整理HTML的可读性。让我们把状态图标片段扩展到一个完整的装饰者类:

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));
   }
}

这个装饰者的使用很容易:

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'));
}

现在可以在视图中使用模型属性而不需要任何条件和逻辑,这样可有可读性:

<div class="status"> {{$user->status}} </div>  
<div class="info"> {{$user->lastOnline}} </div>  

还可以结合多个装饰者:

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'));
}

每个装饰者做了它本分的工作并且装饰它自己的那部分。几个装饰者的递归点缀使得他们的特性可以动态结合而不用引入额外的类。

存储层

存储层和具体的数据存储实现一起工作。出于灵活性和容易的替换性,最好是通过一个接口来注入到存储层。如果改变了数据存储,你得创建一个实现了存储层接口的新的仓储,但至少不用改变其他的层。

存储层扮演了一个查询对象的角色:它从数据库获取数据并且指导多个Active Record模型。在这个上下文的Active Record模型,扮演了单一数据模型实体的角色 -- 在系统中的任何关心建模和信息存储的对象。虽然每个实体包含了信息,但它不知道这些信息是如何出来的(假设它是从数据库创建或提取而来),也不知道如何保存和改变其自身的状态。存储层的职责是保存和/或更新实体;通过维持实体的管理在存储层且让实体变得更简单,提供了更好的关注点分离。

这是一个直截了当的存储层方法示例,他使用数据库和Active Record关系的知识构建了一个查询:

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();
}

用单一职责层级保持苗条

在一个新建的应用程序中,你只能找到控制器、模型和视图的目录。Yii和Laravel在他们应用程序结构的示例中都没有添加额外的层级。简单而直观,甚至对于新手来说也是,MVC结构简化了和框架一起使用的工作,但重要的是要明白他们的应用程序样板只是一个示例;它不是一种标准也不是一种风格,对于应用架构它也没有强加任何规则。通过把任务拆分到隔离、单一职责的层级,我们得到了一个灵活、可扩展、容易维护的架构。请记住:

  • 实体是单一数据模型。
  • 存储层提取和准备数据。
  • 服务层只有业务逻辑。
  • 控制器与全部外部资源进行沟通,如用户的输入或者第三方服务。

所以如果你开启了一个复杂的项目,或者一个未来有机会成长的项目,考虑一下控制器、服务层、存储层这样清晰的职责划分。

dogstar

一位喜欢翻译的开发人员,艾翻译创始人之一。

广州