用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 這樣的大牛。

相關文章
相關標籤/搜索