用PHP实现六边形架构

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

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

引言

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

你的公司正在建设一个叫作 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)**,这一点很重要,不是吗?redis

第一种方法

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

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

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

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

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

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 这样的大牛。

相关文章
相关标签/搜索