《领域驱动设计之PHP实现》全书翻译 - 用PHP实现六边形架构

用 PHP 实现六边形架构(Hexagonal Architecture)

  1. 《领域驱动设计之PHP实现》全书翻译 - DDD入门
  2. 《领域驱动设计之PHP实现》全书翻译 - 架构风格
  3. 《领域驱动设计之PHP实现》全书翻译 - 值对象
  4. 《领域驱动设计之PHP实现》全书翻译 - 实体
  5. 《领域驱动设计之PHP实现》全书翻译 - 服务
  6. 《领域驱动设计之PHP实现》全书翻译 - 领域事件
  7. 《领域驱动设计之PHP实现》全书翻译 - 模块
  8. 《领域驱动设计之PHP实现》全书翻译 - 聚合
  9. 《领域驱动设计之PHP实现》全书翻译 - 工厂
  10. 《领域驱动设计之PHP实现》全书翻译 - 仓储
  11. 《领域驱动设计之PHP实现》全书翻译 - 应用服务
  12. 《领域驱动设计之PHP实现》全书翻译 - 集成上下文
  13. 《用PHP实现六边形架构》

以下文章由 Carlos Buenosvinos 于 2014 年 6 月 发布在 php architect 杂志。

引言

随着领域驱动设计(DDD)的兴起,促进领域中心化设计的架构变得越来越流行。六边形架构,也就是端口与适配器(Ports and Adapters),就是这种情况,而 PHP 开发人员似乎刚刚重新发现了它。六边形架构于 2005 年由敏捷宣言的作者之一 Alistair Cockburn 发明,它允许应用程序由用户,程序,自动化测试或批处理脚本平等驱动,并且可以独立于最终的运行时设备和数据库进行开发和测试。这使得不可知的 web 基础设施更易于测试,编写和维护。让我们看看如何使用真正的 PHP 示例来应用它。

你的公司正在建设一个叫做 Idy 的头脑风暴系统。用户添加 ideas 并且评级,因此最感兴趣的那个 idea 可以被公司实现。现在是星期一早上,另一个 sprint 开始了,并且你正与你的团队和产品经理在审查一些用户故事。**因为用户没有日志,我想对 idea 评级,并且作者应该被邮件通知(As a not logged in user, I want to rate an idea and the author should be notified
by email)**,这一点很重要,不是吗?

第一种方法

作为一个优秀的开发者,你决定分治这个用户故事,所以你将从第一部分,I want to rate an idea 开始。之后,你会面对 the author should be notified by email。这看下来像个计划。

就业务规则而言,对 ideas 评级,与在 ideas 仓储里通过其标识查询它一样容易,仓储含有所有 ideas,添加评级,重新计算平均值并将 ideas 保存回去。如果想法不存在或者仓储不可用,我们应该抛出异常,以便我们可以显示错误消息,重定向用户或执行业务要求我们执行的任何操作。

为了执行这个用例,我们仅需要 idea 标识和来自用户的评级。两个整数会来自用户请求。

你公司的 web 应用正在处理 Zend Framework 1 旧版程序。与大多数公司一样,应用程序中的某些部分可能是新开发的,更 SOLID(注:面向对象五大原则),而其他部分可能只是一个大泥球。但是,你知道使用什么框架是无关紧要的,重要的是编写干净的代码为公司带来低维护成本。

你试图应用上次会议中记得的一些敏捷原则,它是什么,是的,我记得是“make it work,make it right, make it fast”。经过一段时间的工作后,你将获得清单 1 所示的内容。

class IdeaController extends Zend_Controller_Action
{
    public function rateAction()
    {
// Getting parameters from the request
        $ideaId = $this->request->getParam('id');
        $rating = $this->request->getParam('rating');
// Building database connection
        $db = new Zend_Db_Adapter_Pdo_Mysql([
            'host'
            => 'localhost',
            'username' => 'idy',
            'password' => '',
            'dbname'
            => 'idy'
        ]);
// Finding the idea in the database
        $sql = 'SELECT * FROM ideas WHERE idea_id = ?';
        $row = $db->fetchRow($sql, $ideaId);
        if (!$row) {
            throw new Exception('Idea does not exist');
        }
// Building the idea from the database
        $idea = new Idea();
        $idea->setId($row['id']);
        $idea->setTitle($row['title']);
        $idea->setDescription($row['description']);
        $idea->setRating($row['rating']);
        $idea->setVotes($row['votes']);
        $idea->setAuthor($row['email']);
// Add user rating
        $idea->addRating($rating);
// Update the idea and save it to the database
        $data = [
            'votes' => $idea->getVotes(),
            'rating' => $idea->getRating()
        ];
        $where['idea_id = ?'] = $ideaId;
        $db->update('ideas', $data, $where);
// Redirect to view idea page
        $this->redirect('/idea/' . $ideaId);
    }
}

我知道读者可能会想:谁直接通过控制器访问数据?这是一个 90 年代的例子吧!好好,你是对的。如果你已经在使用框架,则可能你也正在使用 ORM。可能是由你自己开发或者现有的(例如 Doctrine,Eloquent,Zend 等等)。在这种情况下,你与那些具有数据库连接对象但在孵化前不算鸡的人相比,要走得更远。

对于新手,清单 1 的代码正好可以工作。但是,如果你仔细看控制器(Controller),不仅看到业务规则,还看到 web 框架是怎样路由请求到你的业务规则,引用数据库或者怎样连接它。如此接近,你会看到对基础设施的引用。

基础设施是使你业务规则工作的细节。明显地,我们需要一些方式来获得它们(API,web,控制台应用等等)并且我们需要一些物理位置来存储我们的 ideas(内存,数据库,NoSQL等等)。但是,我们应该能够将这些组件中的任何一个行为相同但实现方式不同的组件交换。那么从数据库访问(Database access)开始怎样?

所有这些 Zend_DB_Adapter 连接(或者直接使用 MySQL 命令,如果需要的话)都要求提升为某种封装了获取和持久化 idea 对象的对象。他们要求成为仓储。

仓储和持久化边缘

无论业务规则还是基础设施发生变化,我们都需要编辑同一块代码。相信我,在计算机世界,你不希望很多人因不同原因接触同一块代码。试着让函数做一件事和仅做一件事,这样就不太可能让人弄乱相同的代码。你可以通过查看“单一职责原则(SRP)”了解更多信息。有关此原理的更多信息:http://www.objectmentor.com/r...

清单 1 明显是这种情况。如果我们想转移到 Redis 或者添加作者通知功能,你将不得不更新 rateAction 方法。与 rateAction 无关的几率很高。清单 1 的代码很脆弱。如果在你的团队经常听到:If it works,don‘t touch it,说明没有遵循 SRP。

因此,我们必须解耦代码并且封装处理查询和持久化 ideas 的职责到另一个对象。最好的方式,正如之前解释过,就是使用仓储。挑战接受!让我们看看清单 2:

class IdeaController extends Zend_Controller_Action
{
    public function rateAction()
    {
        $ideaId = $this->request->getParam('id');
        $rating = $this->request->getParam('rating');
        $ideaRepository = new IdeaRepository();
        $idea = $ideaRepository->find($ideaId);
        if (!$idea) {
            throw new Exception('Idea does not exist');
        }
        $idea->addRating($rating);
        $ideaRepository->update($idea);
        $this->redirect('/idea/' . $ideaId);
    }
}

class IdeaRepository
{
    private $client;

    public function __construct()
    {
        $this->client = new Zend_Db_Adapter_Pdo_Mysql([
            'host' => 'localhost',
            'username' => 'idy',
            'password' => '',
            'dbname' => 'idy'
        ]);
    }

    public function find($id)
    {
        $sql = 'SELECT * FROM ideas WHERE idea_id = ?';
        $row = $this->client->fetchRow($sql, $id);
        if (!$row) {
            return null;
        }
        $idea = new Idea();
        $idea->setId($row['id']);
        $idea->setTitle($row['title']);
        $idea->setDescription($row['description']);
        $idea->setRating($row['rating']);
        $idea->setVotes($row['votes']);
        $idea->setAuthor($row['email']);
        return $idea;
    }

    public function update(Idea $idea)
    {
        $data = [
            'title' => $idea->getTitle(),
            'description' => $idea->getDescription(),
            'rating' => $idea->getRating(),
            'votes' => $idea->getVotes(),
            'email' => $idea->getAuthor(),
        ];
        $where = ['idea_id = ?' => $idea->getId()];
        $this->client->update('ideas', $data, $where);
    }
}

这种方式更好,IdeaController 的 rateAction 变得更容易理解。在读取时,它关注的是业务规则。IdeaRepository 是一个业务概念。当与业务人员讨论时,他们会明白 IdeaRepository 是什么:即放置 Ideas 和 读取它们的地方。

仓储**使用类似集合的接口访问领域对象,在领域和数据映射层之间充分媒介。正如 Martin Folwer 的模式目录中说所的一样。

如果你已经用了像 Doctrine 这样的 ORM,那么你当前的仓储扩展自一个 EntityRepository。如果你需要获取其中一个,你可以通过 Doctrine EntityManager 来做这项工作。生成的代码几乎相同,并在控制器中额外访问了 EntityManger 以获取 IdeaRepository。

此时,我们可以在整个代码里看到六边形的边缘之一,持久化边缘。但是,这方面的设计不好,IdeaRepository 是什么以及如何实现它之间仍然存在一些关系。

为了更有效果的分离我们的应用边界和基础设施边界,我们需要额外使用一些接口从实现中精确解耦行为。

解耦业务和持久化

当你开始与产品经理,业务分析师或者项目经理讨论数据库中的问题时,你是否经历过这样的场景?当你解析怎样持久化和查询对象时,你是否还记得他们的面部表情?他们根本不知道你在说什么。

事实是他们根本不在乎,但这没关系。决定把 ideas 存储在 MySQL 服务器,Redis,还是 SQLite 是你的问题,不是他们的。记住,从业务角度来看,你的基础设施是细节问题。无论你使用 Symfony 还是 Zend Framework,MySQL 还是 PostgreSQL,REST 还是 SAOP 等等,业务规则都不会改变。

这就是为什么将 IdeaRepository 从其实现中解耦如此重要。最简单的方法就是使用一个合适的接口。我们可以怎样实现它?看看下面的清单 3。

class IdeaController extends Zend_Controller_Action
{
    public function rateAction()
    {
        $ideaId = $this->request->getParam('id');
        $rating = $this->request->getParam('rating');
        $ideaRepository = new MySQLIdeaRepository();
        $idea = $ideaRepository->find($ideaId);
        if(!$idea) {
            throw new Exception('Idea does not exist');
        }
        $idea->addRating($rating);
        $ideaRepository->update($idea);
        $this->redirect('/idea/' . $ideaId);
    }
}
interface IdeaRepository
{
    /**
     * @param int $id
     * @return null|Idea
     */
    public function find($id);
    /**
     * @param Idea $idea
     */
    public function update(Idea $idea);
}
class MySQLIdeaRepository implements IdeaRepository
{
// ...
}

是不是很简单?我们把 IdeaRepository 的行为抽离到一个接口,重命令 IdeaRepository 为 MySQLIdeaRepository 并且更新 rateAction 以便使用我们的 MySQLIdeadRepository。但是好处是什么?

现在,我们可以用任何实现相同的接口替换控制中使用的仓储。因此,让我们尝试不同的实现。

迁移持久化到 Redis

在 sprint 期间,并且在与一些同事沟通后,你相信使用 NoSQL 策略可以提高功能的性能。Redis 是你的好朋友之一。继续,向我展示你的清单 4:

class IdeaController extends Zend_Controller_Action
{
    public function rateAction()
    {
        $ideaId = $this->request->getParam('id');
        $rating = $this->request->getParam('rating');
        $ideaRepository = new RedisIdeaRepository();
        $idea = $ideaRepository->find($ideaId);
        if (!$idea) {
            throw new Exception('Idea does not exist');
        }
        $idea->addRating($rating);
        $ideaRepository->update($idea);
        $this->redirect('/idea/' . $ideaId);
    }
}

interface IdeaRepository
{
// ...
}

class RedisIdeaRepository implements IdeaRepository
{
    private $client;

    public function __construct()
    {
        $this->client = new Predis\Client();
    }

    public function find($id)
    {
        $idea = $this->client->get($this->getKey($id));
        if (!$idea) {
            return null;
        }
        return unserialize($idea);
    }

    public function update(Idea $idea)
    {
        $this->client->set(
            $this->getKey($idea->getId()),
            serialize($idea)
        );
    }

    private function getKey($id)
    {
        return 'idea:' . $id;
    }
}

是不是也很简单?你创建了一个 实现 IdeadRepository 接口的 RedisIdeaRepository,并且我们决定使用 Predis 作为连接管理器。代码看起来更少,更简单和更快。但控制器怎么办呢?它保持不变,我们只是更改了要使用的仓储,但它只有一行代码。

作为读者的一个练习,试着用 SQLite,一个文件或者内存实现的数组来创建 IdeaRepository,如果考虑了 ORM 仓储如何与领域仓储相适应,以及 ORM @annotations 如何影响架构,则有额外加分。

解耦业务和 Web 框架

我们已经看到了从一个持久化策略到另一个的更换是如何的简单。但是,持久化并非是我们六边形唯一的边缘。用户与应用怎样交流有考虑吗?

你的 CTO 已经在你的团队迁移到 Symfony 2 的路线图中进行了设置,因此在当前的 ZF1 应用中开发新功能时,我们希望简化即将到来的迁移工作。这很棘手,请向我展示你的清单 5:

class IdeaController extends Zend_Controller_Action
{
    public function rateAction()
    {
        $ideaId = $this->request->getParam('id');
        $rating = $this->request->getParam('rating');
        $ideaRepository = new RedisIdeaRepository();
        $useCase = new RateIdeaUseCase($ideaRepository);
        $response = $useCase->execute($ideaId, $rating);
        $this->redirect('/idea/' . $ideaId);
    }
}

interface IdeaRepository
{
// ...
}

class RateIdeaUseCase
{
    private $ideaRepository;

    public function __construct(IdeaRepository $ideaRepository)
    {
        $this->ideaRepository = $ideaRepository;
    }

    public function execute($ideaId, $rating)
    {
        try {
            $idea = $this->ideaRepository->find($ideaId);
        } catch (Exception $e) {
            throw new RepositoryNotAvailableException();
        }
        if (!$idea) {
            throw new IdeaDoesNotExistException();
        }
        try {
            $idea->addRating($rating);
            $this->ideaRepository->update($idea);
        } catch (Exception $e) {
            throw new RepositoryNotAvailableException();
        }
        return $idea;
    }
}

让我们审查这些更改。我们的控制器不再有任何业务规则。我们把所有这些逻辑放到一个叫做 RateIdeaUseCase 的新对象。这个对象就是所说的控制器(Controller),互动器(Interactor),或者应用服务(Application Service)。

这个魔法由 execute 方法完成。所有像 RedisIdeaRepository 这样的依赖都作为一个参数传递给构造器。我们用例里所有对 IdeadRepository 的引用都指向该接口,而不是任何具体的实现。

这真的很酷。如果你仔细看 RateIdeaUseCase,这里没有任何关于 MySQL 或者 Zend Framework 的东西。没有引用,没有实例,没有注解,什么也没有。看起来就像你的基础设施毫不关心这些一样。只仅仅关注业务逻辑。

此外,我们还调整了抛出的异常。业务流程也有例外。NotAvailableRepository 和 IdeaDoesNotExist 是其中两个。基于被抛出的那个,我们可以在框架边界以不同的方式做出应对。

有时候,用例接收到的参数数量可能过多。为了组织它们,使用一个 数据访问对象(DTO)构建一个 用例请求(UseCase request)来一起传递它们是相当常见的。让我们看看你如何在清单 6 中解决的:

class IdeaController extends Zend_Controller_Action
{
    public function rateAction()
    {
        $ideaId = $this->request->getParam('id');
        $rating = $this->request->getParam('rating');
        $ideaRepository = new RedisIdeaRepository();
        $useCase = new RateIdeaUseCase($ideaRepository);
        $response = $useCase->execute(
            new RateIdeaRequest($ideaId, $rating)
        );
        $this->redirect('/idea/' . $response->idea->getId());
    }
}

class RateIdeaRequest
{
    public $ideaId;
    public $rating;

    public function __construct($ideaId, $rating)
    {
        $this->ideaId = $ideaId;
        $this->rating = $rating;
    }
}

class RateIdeaResponse
{
    public $idea;

    public function __construct(Idea $idea)
    {
        $this->idea = $idea;
    }
}

class RateIdeaUseCase
{
// ...
    public function execute($request)
    {
        $ideaId = $request->ideaId;
        $rating = $request->rating;
// ...
        return new RateIdeaResponse($idea);
    }
}

这里主要的变化是引入了两个新对象,一个 Request 和一个 Response。它们并非是强制的,也许一个用户没有 request 或者没有 response。另一个重要的细节是,你怎样构建这个 request。在这个例子中,我们通过从 ZF 请求对象中获取参数来构建它。

好,且慢,这真正的好处是什么?好处是从一个框架换成另一个框架更简单,或者从另一种交付机制执行我们的用例更加容易。让我们看看这一点。

用 API 评级 idea

今天,产品经理走到你面前和你说:用户应该可以用我们的移动 APP 来评级。我觉得我们应该升级 API。你可以在这个 sprint 完成它吗?这又是另外一个问题。没关系!你的承诺给公司留下了深刻印象。

正如 Robert C.Martin 所说:web 是一种交付机制。。。你的系统架构应该对如何交付是不可知的。你应该能够将其交付为一个控制台应用程序,web 应用,甚至 Web 服务应用,而不会造成不必要的复杂性或基本体系结构的任何更改。

你当前的 API 由基于 Symfony2 组件组成的 PHP 迷你框架 Silex 构建的,让我们看看清单 7:

require_once __DIR__.'/../vendor/autoload.php';
$app = new Silex\Application();
// ... more routes
$app->get(
    '/api/rate/idea/{ideaId}/rating/{rating}',
    function ($ideaId, $rating) use ($app) {
        $ideaRepository = new RedisIdeaRepository();
        $useCase = new RateIdeaUseCase($ideaRepository);
        $response = $useCase->execute(
            new RateIdeaRequest($ideaId, $rating)
        );
        return $app->json($response->idea);
    }
);
$app->run();

上面有你熟悉的地方吗?你可以标识出你之前见过的一些代码吗?我可以给你一个线索:

$ideaRepository = new RedisIdeaRepository();
$useCase = new RateIdeaUseCase($ideaRepository);
$response = $useCase->execute(
    new RateIdeaRequest($ideaId, $rating)
);

是的!我记得那 3 行代码。它们看起来与 web 应用完全一样。 这是对的,因为用户封装了你准备请求,获取回复并采取相应行动所需的业务规则。

我们提供给用户另一条评级 idea 的途径,另一种交付机制。主要的不同点就是创建 RateIdeaRequest 的地方。在第一个例子中,它来自一个 ZF 请求而现在它来自使用路由匹配参数的 Silex 请求。

控制台评级应用

有时候,一个用例会从 cron 或者 命令行执行。作为例子,批处理过程或一些测试命令行可加快开发速度。当用 web 或 API 测试这个功能时,你相信用命令行来做会更好,因此你不必通过浏览器来做。

如果你使用的是 shell 脚本文件,我建议你看看 Symfony Console 组件。它的代码看起来像这样:

namespace Idy\Console\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class VoteIdeaCommand extends Command
{
    protected function configure()
    {
        $this
            ->setName('idea:rate')
            ->setDescription('Rate an idea')
            ->addArgument('id', InputArgument::REQUIRED)
            ->addArgument('rating', InputArgument::REQUIRED);
    }
    protected function execute(
        InputInterface $input,
        OutputInterface $output
    ) {
        $ideaId = $input->getArgument('id');
        $rating = $input->getArgument('rating');
        $ideaRepository = new RedisIdeaRepository();
        $useCase = new RateIdeaUseCase($ideaRepository);
        $response = $useCase->execute(
            new RateIdeaRequest($ideaId, $rating)
        );
        $output->writeln('Done!');
    }
}

再次看到这 3 行代码。正如之前那样,用例和它的业务逻辑仍然不变,我们只是提供了一种新的交付机制。恭喜,你已经发现了用户一侧的六边形边缘。

这仍然有很多工作要做。正如你可能听说过的,一个真正的工匠会使用 TDD (测试驱动开发)。既然我们已经开始了我们的故事,则必须在之后的测试保持正确。

评级 idea 测试用例

Michael Feathers 引入了遗留代码(legacy code)的定义,即没有经过测试的代码。你并不希望你的代码刚诞生就成为遗留代码,对吗?

为了对用例对象做单元测试,你决定从最简单的部分开始,如果仓储不可用会怎么办?我们可以怎样生成这样的行为?我们可以在运行单元测试时停掉 Redis 吗?不行。我们需要一个拥有这样行为的对象。让我们在清单 9 模拟(mock)一个这样的对象。

class RateIdeaUseCaseTest extends \PHPUnit_Framework_TestCase
{
    /**
     * @test
     */
    public function whenRepositoryNotAvailableAnExceptionIsThrown()
    {
        $this->setExpectedException('NotAvailableRepositoryException');
        $ideaRepository = new NotAvailableRepository();
        $useCase = new RateIdeaUseCase($ideaRepository);
        $useCase->execute(
            new RateIdeaRequest(1, 5)
        );
    }
}
class NotAvailableRepository implements IdeaRepository
{
    public function find($id)
    {
        throw new NotAvailableException();
    }
    public function update(Idea $idea)
    {
        throw new NotAvailableException();
    }
}

很好,NotAvailableRepository 有我们所需的行为,并且我们可以用 RateIdeaUseCase 来使用它,因为它实现了 IdeaRepository 接口。

下一个要测试的用例是,如果 idea 不在仓储怎么办? 清单 10 展示了这些代码:

class RateIdeaUseCaseTest extends \PHPUnit_Framework_TestCase
{
// ...
    /**
     * @test
     */
    public function whenIdeaDoesNotExistAnExceptionShouldBeThrown()
    {
        $this->setExpectedException('IdeaDoesNotExistException');
        $ideaRepository = new EmptyIdeaRepository();
        $useCase = new RateIdeaUseCase($ideaRepository);
        $useCase->execute(
            new RateIdeaRequest(1, 5)
        );
    }
}
class EmptyIdeaRepository implements IdeaRepository
{
    public function find($id)
    {
        return null;
    }
    public function update(Idea $idea)
    {
    }
}

这里,我们使用了相同的策略但是用了一个 EmptyIdeaRepository。它同样实现了相同的接口,但无论 finder 方法接收什么标识($id),这个实现总是返回一个 null。

我们为什么要测试这些用例?记住 Kent Beck's 的话:测试一切可能会破坏的事情。

让我们继续测试剩余的一些功能。我们需要检查一种特殊情况,它与拥有无法写入的可读仓储有关。解决方案可以在清单 11 中找到:

class RateIdeaUseCaseTest extends \PHPUnit_Framework_TestCase
{
// ...
    /**
     * @test
     */
    public function whenRatingAnIdeaNewRatingShouldBeAdded()
    {
        $ideaRepository = new OneIdeaRepository();
        $useCase = new RateIdeaUseCase($ideaRepository);
        $response = $useCase->execute(
            new RateIdeaRequest(1, 5)
        );
        $this->assertSame(5, $response->idea->getRating());
        $this->assertTrue($ideaRepository->updateCalled);
    }
}
class OneIdeaRepository implements IdeaRepository
{
    public $updateCalled = false;
    public function find($id)
    {
        $idea = new Idea();
        $idea->setId(1);
        $idea->setTitle('Subscribe to php[architect]');
        $idea->setDescription('Just buy it!');
        $idea->setRating(5);
        $idea->setVotes(10);
        $idea->setAuthor('john@example.com');
        return $idea;
    }
    public function update(Idea $idea)
    {
        $this->updateCalled = true;
    }
}

好,现在这个关键部分仍然存在。我们有不同的方法测试它,我们可以写自己的 mock 或者使用像 Mockery 或 Prophecy 这样的 mock 框架。让我们选择第一种。另一个有趣的练习就是写这个例子以及使用这些框架来完成之前的例子。

class RateIdeaUseCaseTest extends \PHPUnit_Framework_TestCase
{
// ...
    /**
     * @test
     */
    public function whenRatingAnIdeaNewRatingShouldBeAdded()
    {
        $ideaRepository = new OneIdeaRepository();
        $useCase = new RateIdeaUseCase($ideaRepository);
        $response = $useCase->execute(
            new RateIdeaRequest(1, 5)
        );
        $this->assertSame(5, $response->idea->getRating());
        $this->assertTrue($ideaRepository->updateCalled);
    }
}

class OneIdeaRepository implements IdeaRepository
{
    public $updateCalled = false;

    public function find($id)
    {
        $idea = new Idea();
        $idea->setId(1);
        $idea->setTitle('Subscribe to php[architect]');
        $idea->setDescription('Just buy it!');
        $idea->setRating(5);
        $idea->setVotes(10);
        $idea->setAuthor('john@example.com');
        return $idea;
    }

    public function update(Idea $idea)
    {
        $this->updateCalled = true;
    }
}

太好了!用例已经 100% 覆盖了。也许,下次我们可以使用 TDD 来做,因此会先完成测试部分。不过,测试这些功能真的很容易,因为解耦的方法在架构层面提升了。

可能你想知道这样:

$this->updateCalled = true;

我们需要一种方法来保证 update 方法在用例执行时就已经被调用。这可以解决,这个双重对象(doube object)称为 spy (间谍),mock 的表兄弟。

什么时候使用 mocks?作为一个通用规则,在跨越边界时使用 mocks。在这个用例中,由于从领域跨越到了持久化边界,我们需要 mocks。

那么对于测试基础设施呢?

测试基础设施

如果你想对应用进行 100% 的测试,则需要测试你的基础设施。在做这个之前,你需要知道这些单元测试会比业务更加耦合你的实现。这意味着对实现细节的更改被破坏的可能性变得更高。因此这点需要你权衡考虑。

那么,如果你想继续,我们需要做一些修改。我们需要解耦更多东西。让我们看看清单 13:

class IdeaController extends Zend_Controller_Action
{
    public function rateAction()
    {
        $ideaId = $this->request->getParam('id');
        $rating = $this->request->getParam('rating');
        $useCase = new RateIdeaUseCase(
            new RedisIdeaRepository(
                new Predis\Client()
            )
        );
        $response = $useCase->execute(
            new RateIdeaRequest($ideaId, $rating)
        );
        $this->redirect('/idea/' . $response->idea->getId());
    }
}
class RedisIdeaRepository implements IdeaRepository
{
    private $client;
    public function __construct($client)
    {
        $this->client = $client;
    }
// ...
    public function find($id)
    {
        $idea = $this->client->get($this->getKey($id));
        if (!$idea) {
            return null;
        }
        return $idea;
    }
}

如果我们想对 RedisIdeaRepository 进行 100% 的单元测试,则需要在不指定TypeHinting 的情况下把 PredisClient 作为一个参数传递给仓储,以便我们可以传递一个 mock 来使得必要代码流程覆盖所有用例。

这使得我们要更新 Controller 来构建 Redis 连接,把它传递给仓储并且把结果传递给用例。

现在,这些所有都是关于创建 mocks,测试用例,以及做断言的乐趣。

头疼,这么多依赖

我必须手动创建这么多依赖是正常的吗?不是。这通常是使用功能这些功能的依赖注入组件或者服务容器。同样,Symfony 可以提供帮助,但是, 你也可以试试 PHP-DI 4。

让我们看看在应用中使用 Symfony Service Container 组件后的清单 14:

class IdeaController extends ContainerAwareController
{
    public function rateAction()
    {
        $ideaId = $this->request->getParam('id');
        $rating = $this->request->getParam('rating');
        $useCase = $this->get('rate_idea_use_case');
        $response = $useCase->execute(
            new RateIdeaRequest($ideaId, $rating)
        );
        $this->redirect('/idea/' . $response->idea->getId());
    }
}

控制器修改后有了访问容器的权限,这就是为什么它继承了一个新的 ContainerAwareController 基类,其只有一个 get 方法来检索每个包含的服务:

<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           xsi:schemaLocation="
http://symfony.com/schema/dic/services
http://symfony.com/schema/dic/services/services-1.0.xsd">
    <services>
        <service
                id="rate_idea_use_case"
                class="RateIdeaUseCase">
            <argument type="service" id="idea_repository" />
        </service>
        <service
                id="idea_repository"
                class="RedisIdeaRepository">
            <argument type="service">
                <service class="Predis\Client" />
            </argument>
        </service>
    </services>
</container>

在清单 15,你可以发现配置服务容器的 XML 文件。它真的很容易理解,但如果你需要更多信息,去看看 Symonfy Service Container Component 网站里的内容。

领域服务和六边形通知边缘

我们是否忘记了什么事情?the author should be notified by email,对!没错。让我们看看修改后的清单 16 中的用例是怎么做的:

class RateIdeaUseCase
{
    private $ideaRepository;
    private $authorNotifier;

    public function __construct(
        IdeaRepository $ideaRepository,
        AuthorNotifier $authorNotifier
    )
    {
        $this->ideaRepository = $ideaRepository;
        $this->authorNotifier = $authorNotifier;
    }

    public function execute(RateIdeaRequest $request)
    {
        $ideaId = $request->ideaId;
        $rating = $request->rating;
        try {
            $idea = $this->ideaRepository->find($ideaId);
        } catch (Exception $e) {
            throw new RepositoryNotAvailableException();
        }
        if (!$idea) {
            throw new IdeaDoesNotExistException();
        }
        try {
            $idea->addRating($rating);
            $this->ideaRepository->update($idea);
        } catch (Exception $e) {
            throw new RepositoryNotAvailableException();
        }
        try {
            $this->authorNotifier->notify(
                $idea->getAuthor()
            );
        } catch (Exception $e) {
            throw new NotificationNotSentException();
        }
        return $idea;
    }
}

正如你看到的,我们添加了一个新的参数来传递 AuthorNotifier 服务,它会发送电子邮件(email)给作者(author)。这就是端口与适配器中的端口。我们赋闲在 execute 方法中更新了业务规则。

仓储并不是访问你基础设施的唯一对象,也不是只有使用接口或者抽象类才能对其解耦。领域服务同样可以。当你的领域中的实体拥有有一个并不是很清晰的行为时,你应该创建一个领域服务。一个典型的模式就是写一个具有具体实现的抽象领域服务,以及一些其它适配器(adapter)会来实现的抽象方法。

作为练习,请为 AuthorNotifier 抽象服务定义实现细节。可以用 SwiftMailer 或者 普通的邮件调用。这由你决定。

一起概括

为了有一个整洁架构来帮助你轻松地创建和测试应用,我们使用了六边形架构。为此,我们将用户故事的业务规则封装到一个 UseCase 或 互动者对象(Interactor object)。我们通过框架的 request 来构建 UseCase 请求,实例化 UseCase 和它的所有依赖并且执行它们。我们基于它获得 response 并采用相应行动。如果我们的框架具有依赖注入(Dependency Injection)组件,则可以使用它来简化代码。

为了使用从不同客户端的访问这些功能(web, API, 控制台等等),相同的用例对象可以来自不同的交付机制(delivery mechanisms)。

对于测试,请使用 mocks,其行为就像定义的所有接口一样,这样也可以覆盖特殊情况或错误流程。然后请享受做好的工作。

六边形架构

在几乎所有的博客和书籍中,你都可以找到有关代表不同软件领域的同心圆图案。正如 Robert C.Martin 在他的 Clean Architecture 博客中解释的那样,外围(outer)是你的基础设施所在的位置。内部(inner)是你的实体所在的位置。使该架构起作用的首要规则是“依赖规则”。该规则表明,源代码依赖性只能指向内部。内部的任何事物对外围的事物不可知。

要点

如果 100% 单元测试代码覆盖率对你的应用程序很重要,请使用此方法。另外,如果你希望能够切换存储策略,Web 框架或任何其他类的第三方代码。对于需要满足不断变化的持久化应用程序,该架构特别有用。

下一步是什么

如果你想了解更多有关六边形架构和其他相似概念,则可以查看本文开头提供的相关 URL,请查看 CQRS 和事件源。另外,不要忘记订阅有关 DDD 的 Google groups 和 RSS,例如 http://dddinphp.org/ 以及在 Twitter 上关注像 @VaughnVernon,@ericevans0 这样的大牛。

阅读 1.9k

推荐阅读