我们如何编码 - ORM 与贫血领域模型(Anemic Domain Models)

许多受人尊敬的关于代码架构的书籍都是由拥有多年经验的开发人员使用强大的面向对象语言(如 Smalltalk 和 Java)编写的。 这些语言在没有我们现有的框架工具的情况下成长起来。 事实上,正是这些作者和开发人员创造了我们今天使用的大多数开发实践。

「现代」语言编写的许多框架是从这些作者所教授的课程中诞生的。 然而,这些框架通常是复杂的。 虽然他们提供了许多有用的工具,但是他们将如何有组织地实现这些工具的问题留给了开发人员。 需要一些更简单的东西! 幸运的是(对于所有人!) 37 Signals 的富有进取心的团队满足了这种需求。

Rails 彻底革新了 web 开发。 它致力于快速应用开发(RAD),由此提出了一些解决普遍应用程序问题的意见。这些意见规定了我们对实现的选择,使我们能够专注于功能而不是实现

看到 Rails 的成功,其他语言的 web 框架也迅速跟进。 为截至日期和一大堆事情疲于奔命的开发人员(我们所有人)为了美好的生活,也热切拥抱这些框架。

然而,在转向面向 RAD 的框架的过程中,我们忽略了(或者是心甘情愿地忽略了)保守派苦心学到的许多经验教训!

我们很多时候都是在我们的工具中进行编码的,因为它们是非常好的工具。 然而,我们的 RAD 框架倾向于成为我们的应用程序,而不是简单的工具来实现我们的应用程序。 例如,我们经常鼓吹「瘦控制器,胖模型」,然后把业务逻辑塞进我们的服务(和持久)层,而不是塞进模型。

在我对代码架构的研究中,我注意到有一件事特别地驱动了我们如何(以及在哪里!) 编写我们的业务逻辑: 我们的活动记录(Active Record) ORM。 这些功能强大、节省时间的模型也是我们的应用程序中出现许多冲突原因

在本文中,我将介绍在常用的 活动记录(Active Record) 模式中实现一些业务逻辑的示例,然后我将展示如何使用数据映射器(Data Mapper)模式实现相同的逻辑。 在这两个例子中,我们将看到如何将业务逻辑移到业务实体中,从而避免贫血业务领域模式。 我们还将介绍这两种 ORM 风格的优缺点,以及它们如何影响我们编写代码的方式。

规则

我们要做的是一个(惊喜!) 假装的客户支持系统。 在这个系统中,当客户发送一条新消息寻求支持时,会创建工单(Ticket)

以下是一些规则:

  • 所有的工单都被分配了一个类别
  • 一个工单在某个时刻被分配给一个工作人员(不是在创建时)
    • 工作人员有他们负责的工单种类
    • 工作人员只能被分配他们自己所负责的工单类别的工单
  • 如果一个工单处于「已关闭」状态,任何新收到的消息将重新打开工单

两种方式

如前所述,我们将从两个方面研究这个问题:首先,我们使用 Active Record 样式 ORM 的「常规」方式的背景。其次,我们将着手于将业务逻辑移到域实体中,并研究如何使用 Data Mapper 样式的 ORM 帮助将业务逻辑与数据持久性分离。

我们将遵循在两种样式之间进行编码时通常看到的模式。

活动记录(Active Record)

首先,让我们创建一些模型。由于我们使用某种形式的活动记录(Active Record) ORM,我们通常不需要定义我们的字段和数据——活动记录(Active Record)实现通过读取数据库表的结构来处理这个问题。 我们只需告诉它要使用哪些表和表之间的关系。

工单:

<?php namespace Help;

use Some\Package\SomeOrm;

class Ticket extends SomeOrm {

    $table = 'tickets';

    $hasMany = 'Message';

    $hasOne = array('Staffer', 'Category');

}

消息:

<?php namespace Help;

use Some\Package\SomeOrm;

class Message extends SomeOrm {

    $table = 'messages';

}

工作人员:

<?php namespace Help;

use Some\Package\SomeOrm;

class Staffer extends SomeOrm {

    $table = 'staffers';

    $hasMany = 'Category'

}

(为了简洁起见,我省略了一个 Category 类。 它只是我们用例的一个字符串)。

模型已经创建好了,还有一些业务逻辑需要实现。 让我们回顾一下上面定义的规则。 首先,一个工单被分配一个类别:

$ticket = new Ticket;
$ticket->subject = 'Some Subject';
$ticket->dateOpened = new \DateTime;

// 一个Facade, 或其他的什么 :P
$category = Category::find('name', 'Default');

$ticket->category = $category->id;

$ticketId = $ticket->save();

稍后,我们需要为这张工单指派一名工作人员。

$ticket = Ticket::find($ticketId);

$staffer = Staffer::find($stafferId)

// 分配工作人员, 条件是
// 工作人员拥有该工单所属的工单类别
foreach( $staffer->categories as $category )
{
    if( $ticket->category == $category )
    {
        $ticket->staffer = $staffer;
    }
}

if( is_null($ticket->staffer) )
{
    // 验证错误处理
    // 没有分配到工作人员
}

我们已经实现了一些模型和逻辑。 让我们来看看这里发生了什么。

逻辑在哪里?

我已经在上下文之外编写了上述业务逻辑; 我们可以看到它并不直接出现在我们的模型中。 那么我们把这个逻辑放在哪里呢? 通常,我们首先将其直接放在控制器中。 这些导致了我们都读到过的冗长而糟糕的控制器。 从长远来看,它很难维护,因为如果我们在控制器中保留业务逻辑,我们可能会在整个代码库中重复一些这样的操作。 我们逻辑上的任何变化都会影响到许多地方的变化。

值得注意的是,任何控制器代码的运行都是将 HTTP 请求路由到代码的结果。 它实际上仍然是 HTTP 请求周期的一部分,这不一定是我们的应用程序所关心的。 我们的应用程序只是想知道是否有一个请求需要做一些事情。 是否是一个 HTTP 请求并不重要。

因此,在决定抛弃控制器之后,我们不得不思考这些逻辑能放在何处。 关于这一点,有很多相互矛盾的说法(我自己也写了一些)。 我们中的一些人为「 forms」创建了一个类,它本质上是我们的应用程序的「命令」。 这些 Form (Command)类可以在将结果持久化到数据库之前接受输入并运行业务逻辑和其他操作。

其他人可能(也)创建仓库类,并选择将其中的一些逻辑放入其中。 最初由 smalltalk / java 工作人员描述的仓库(Repository)用于检索现有的业务「实体」并将其保存到数据库中。 如果我们使用一个带有活动记录(Active Record) ORM 的仓库,我们创建了一个有用的抽象,但是我们仍然通过将业务逻辑添加到仓库中来模糊关注点分离和单一责任原则,而仓库的责任应该仅仅是数据持久性。

当使用具有活动记录(Active Record) ORM 的仓库时,我们以不同于最初设想的方式使用仓库。 使用活动记录(Active Record),仓库成为活动记录 ORM 和应用程序其余部分之间的抽象层。 尽管在确保行为在整个应用程序中始终如一地表现的策略模式方面很有用,但 ORM 仍然最终负责持久性,而不是仓库。

如果存储库的目的是数据持久性,我们是否还应该在其中放置域逻辑?存储库是否应该知道向已关闭的票证添加新消息也应该重新打开票证?这似乎与“单一责任”的概念不符!

因此,我们仍在寻找这种业务逻辑的「完美」地方。

我们将业务逻辑添加到模型本身。这似乎是最合理的,因为票证应该了解其自身的业务逻辑-这是对自身的“单一责任”!我们可以添加一个 addMessage() 方法,该方法会在需要时更新状态!我们还可以创建一个 assignStaffer() 方法,该方法仅在职员有正确的类别可用时才分配职员。

现在,我们的业务逻辑被封装在一个地方,并且我们正在使我们的模型变得不那么贫乏-它包含业务逻辑,而不是简单地作为 Active Record 持久性逻辑的包装。

考虑到这一点,让我们以批判的眼光来看待这种情况。

活动记录模式没有单一责任。它仍然对自己的持久性负有最终责任,现在最重要的是业务逻辑。

另外,我们仍然在应用程序的许多地方使用我们的模型,这些地方不应该都能够访问具有更改数据库能力的对象。实际上,只要我们的存储库类返回活动记录 ORM,我们就会“泄漏”持久性逻辑,以供任何类使用(即使是在我们的视图中!)

这意味着,尽管我们为使用 Active Record ORM 进行了很好的设置,但我们仍然期望其他开发人员不要将其全部弄乱。

能有多混乱呢?

我们模型中的业务逻辑很容易被遗漏。例如,尽管我们在 Ticket 模型中有一个assignStaffer()方法,但仍然有可能发生这样的情况——即您(在紧急的情况下)或另一个程序员(可能不熟悉代码库)会编写类似$ticket->staffer = Staffer::find($someId); $ticket->save(); 的代码,从而忽略我们关于 Category 和 Staffer 的业务逻辑约束。

那么,最终我们会得到了什么?好吧,在最坏的情况下,是可怕的胖控制器。我们的代码更难维护,且可能存在很多无法复用的代码。最好的情况是,我们可以在代码中较少的地方更好地利用业务逻辑。但是,我们仍然依赖约定。代码依然有可能忽略所需的业务逻辑!

接下来让我们看看如何通过选择 Data Mapper 模式而不是 Active Record 来优化。

日期映射器

在数据映射领域,我们可以从“普通旧对象”开始建模我们的业务逻辑。然后,我们可以创建一个“实体映射”存储库,它将数据库数据映射到我们的实体对象中。实体本身没有“任何”持久逻辑。相反,存储库负责从这些实体中解析出数据,并在数据库上执行所需的CRUD。

在代码中查看,这意味着我们从以下内容开始:

$ticket = Ticket::find($id);
// 或者,如果我们有存储库:
$ticket = $ticketRepo->findById($id);

// 关于活动记录模型的一些操作

$ticket->save();

对于这样的事情:

$ticket = $ticketRepo->find($id);

// 对实体的某些操作

$ticketRepo->save($ticket);

注在活动记录示例中,我们没有使用存储库来持久化数据。从技术上讲,我们可以这样做,尽管存储库最多只能隐藏此操作(在活动记录类上调用save()方法),而不是自己执行 (并不是说这有什么不对的。).

但是,如果 Repository 只负责处理持久性,那么实体就只负责它们的业务逻辑。让我们看看实体可能是什么样子。

现在的 Ticket Class:

<?php namespace Help;

class Ticket {

    /**
     * Subject
     * @var string
     */
    protected $subject;

    /**
     * Messages in Ticket
     * Collection of Messages
     * @var Array
     */
    protected $messages = array();

    /**
     * Ticket Status
     * @var string
     */
    protected $status;

    /**
     * Staffer assigned to Ticket
     * @var Staffer
     */
    protected $staffer;

    /**
     * Category Assigned to Ticket
     * @var String
     */
    protected $category;

    /**
     * 创建 Ticket
     * @param string   $subject
     * @param DateTime $dateOpened
     * @param Customer $customer
     * @param Array    $messages
     */
    public function __construct($subject, $status="open", $category, $messages=array())
    {
        $this->subject = $subject;
        $this->updateStatus($status); // Use your setters!
        $this->category = $category;

        foreach( $messages as $message )
        {
            $this->addMessage($message);
        }
    }

    /**
     * 更新 Ticket 状态
     * @param  string $status
     * @throws \InvalidArgumentException
     * @return void
     */
    public function updateStatus($status)
    {
        $status = mb_strtolower($status);

        if( $status !== 'open' || $status !== 'closed' )
        {
            throw new \InvalidArgumentException('Illegal status given: '.$status);
        }

        $this->status = $status;
    }

    /**
     * 添加消息到 Ticket
     * @param Message $message
     */
    public function addMessage(Message $message)
    {
        // Re-open on a new Message
        // if Ticket is closed
        if( $this->status === 'closed' )
        {
            $this->status = 'open';
        }

        $this->messages[] = $message;
    }

    /**
     * 给职员派发 Ticket
     * @param  Staffer $staffer
     * @throws \DomainException
     * @return void
     */
    public function assignStaffer(Staffer $staffer)
    {
        if( ! $staffer->isAvailableFor($this->category) )
        {
            throw new \DomainException('Staffer cannot be assigned to a Ticket of category '.$this->category);
        }

        $this->staffer = $staffer;
    }

    /**
     * 获取消息
     * @return Array
     */
    public function getMessages()
    {
        return $this->messages;
    }
}

在我们的模型中还有更多代码!您也可以将其称为『贫血』或『富裕』。

然后我们就有了我们的(更简单的)消息 Message 和员工 Staffer 类:

# File Message.php
<?php namespace Help;

class Message {

    /**
     * Date Message Received
     * @var \DateTime
     */
    protected $dateReceived;

    /**
     * Message
     * @var String
     */
    protected $message;

    /**
     * Create a new Message
     * @param DateTime $dateReceived
     * @param String   $message
     */
    public function __construct(\DateTime $dateReceived, $message)
    {
        $this->dateReceived = $dateReceived;
        $this->message = $message;
    }
}

# File Staffer.php
<?php namespace Help;

class Staffer {

    /**
     * Staffer email
     * @var string
     */
    protected $email;

    /**
     * Categories Staffer
     *  is assigned To
     * @var Array
     */
    protected $availableCategories;

    public function __construct($email, Array $availableCategories)
    {
        $this->email = $email;
        $this->availableCategories = $availableCategories;
    }

    /**
     * Test if Staffer has the given Category
     * available to them.
     *
     * @param  String
     * @return Boolean
     */
    public function isAvailableFor($category)
    {
        return in_array($category, $this->availableCategories);
    }
}

如上,我们的 Ticket 类要丰富得多。它有其明确的属性和方法。我们与这些实体交互的唯一方式是通过实体本身。与我们的 Active Record 类不同,我们不能直接转到持久层并对数据进行更改。草率的开发不能这么容易地绕过我们的业务逻辑。

附带说明:我经常设置属性为 protected 。我将使用一个 Trait,它将实现__get())魔术方法。这就要由代码来 根据业务逻辑设置 属性,从而实现这些用例。

我们在这里可以让代码变得清晰。就像全栈框架通过给我们更少的选择进而使我们的生活变得简洁,域实体限制我们与域交互的方式,进而使交互更加清晰。

另外,我们的业务逻辑正好位于我们的域实体中。它不会泄漏到服务层(验证、存储库或命令/表单类),也不会泄漏到 HTTP 层(控制器)。

这将我们的关注点进行了有效的分离。我们的业务实体确保数据的准确性,并遵守规则。我们的存储库可以持久储存数据,并将该数据转换为业务实体。我们的服务/应用程序层可以负责编排请求和实体之间的交互,而不必了解幕后的业务逻辑(除了对异常等作出响应外)。

同时让我们想想测试。因为这些是普通的 PHP 对象,所以我们可以更简单地进行测试。我们甚至不需要 mock 依赖,因为依赖项本身很可能是其他域实体。

但请注意这也存在一定的缺陷。我们不会将验证类或其他项目注入到域实体中。虽然某些服务(如验证)可能在我们的应用程序层中是必需的,但域层可能没有这些-除非某些验证规则是业务领域的绝对核心。

确定域实体的内容可能有点主观。这是“域驱动器设计”的主题,我们将在本文中提及,但不会深入探讨这个话题。

这里也有一些更微妙的区别。例如,我们将 Ticket 实体视为『聚合』类,这意味着它包含其他实体( Staffer 和 Messages)。更有趣的是,在这种情况下,Ticket 也可以是“根聚合”。它是您可以从中访问和修改 Staffer 或与 Ticket 关联的 Messages 实体。

与其允许直接访问 Staffer,不如通过 Ticket。要澄清的是——没有代码可以执行如下操作:

$staffer->assignedToTicket($ticket);

相反,将职员分配给 Ticket 只在 Ticket 本身内完成。类似于 Rails 通过减少选择来提高生产力,我们已经使业务领域更清晰,这对我们自己和其他开发人员更有用。

结论

本文用意并非要你停止使用 Active Record 类型的 ORM。Active Record 的成功体现在它在大多数我们最喜爱的框架(Rails,Django,Laravel,SailsJS等)中的使用。它既简单又方便。一个好的 ORM 及其处理关系的能力可以清晰的方式满足我们的大部分需求。

然而,当我们的应用程序变得更大(或者有很长的保质期)时,最好知道还有其他模式可用。

将业务逻辑保持在不太活跃的 Active Record 模型中有潜在的陷阱。但是,这些缺点主要是『软性』问题,例如确保开发人员知道不要意外规避已建立的业务逻辑。

另一方面,使用数据映射器可以为您开发丰富、复杂的域模型提供一条清晰的途径,同时保持代码的清晰度。

如果您是正在寻找 Data Mapper 的 PHP 开发人员,我建议您查看 Doctrine2

本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。

原文地址:https://fideloper.com/how-we-code

译文地址:https://learnku.com/laravel/t/40587

本文为协同翻译文章,如您发现瑕疵请点击「改进」按钮提交优化建议
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!