每一個企業應用一般由公司運營的多個領域組成。計費,庫存,運輸管理,產品目錄等等領域是常見示例。解決全部問題最簡單的方法彷佛傾向於單一系統。可是,你可能想知道,是否必定要這樣?若是將這個龐大的總體應用程序分紅較小的獨立塊,能夠減小在這些不一樣領域工做的團隊之間的摩擦,該怎麼辦?在本章中,咱們將探索如何作到這一點,因此要對戰略設計的看法和啓發式學習作好準備。php
使用分佈式系統用分佈式系統處理很困難。把系統分紅獨立自主的部分有它的好處,但同時帶來了複雜性。例如,分佈式系統的協調和同步並不是易事,所以要謹慎考慮。正如 Martin Fowler 在 PoEAA 一書中所說的那樣,分佈式系統的第必定律始終是:不要分佈式web
集成一個應用程序不一樣部分最多見的一種技術一直是共享相同的數據存儲,以及相同的代碼庫。這一般稱爲單體應用(monolithic application),它一般以單個數據存儲結束,該數據存儲託管與應用程序中全部關注事項相關的數據。數據庫
考慮一個電子商務應用。共享數據存儲將包含圍繞目錄,帳單,庫存等全部關注事項(例如:關係數據庫中的表)。這種方法自己沒有什麼問題,例如,在複雜度不過高的小型線性應用中。可是,在複雜的領域中,可能會出現一些問題。若是你在涉及多個應用程序問題的多個表之間共享數據,則事務將對性能產生重大影響。json
另外一個會出現的技術性較弱的問題是通用語言。分離限界上下文的主要優勢是每一個上下文都有一個單一的通用語言。這樣,模型將被分離成本身的上下文。在同一個上下文中將全部模型混合在一塊兒會致使歧義和混亂。segmentfault
回到電子商務系統,想象一下咱們想引入T恤的概念。在目錄上下文,T恤是一種具備顏色,尺寸,材料和一些精美圖片等屬性的產品。可是,在庫存系統中,咱們並不真正關心這些事情。在這裏,產品具備不一樣的含義,咱們關注的是不一樣的屬性,例如重量,倉庫中的位置或尺寸。將這兩個上下文混合在一塊兒會使概念糾纏並使設計複雜化。用領域驅動設計的術語來講,以這種方式混合的概念就是所謂的共享內核(Shared Kernel)。api
共享內核
是指定團隊贊成共享的領域模型的子集。固然,這包括與模型的子集一塊兒的,與模型的該部分相關聯的代碼或數據庫設計的子集。明確共享的內容具備特殊的地位,在沒有與其餘團隊協商的狀況下不該更改。常常集成功能系統,但頻率不如團隊內部連續集成的速度。在這些集成中,運行兩個團隊的測試。Eric Evans - 《領域驅動設計:軟件核心複雜性應對之道》服務器
咱們不建議使用共享內核,由於多個團隊可能會在其開發過程當中發生衝突,這不只會致使維護問題,並且會引發衝突。可是,若是你選擇使用共享內核,則應事先在相關各方之間達成一致。從概念上講,這種方法還存在其餘問題,例如人們將其視爲放置不屬於其餘任何地方的東西的袋子,而且這個問題會無限期的增加。處理總體的不斷增加的複雜性的一種更好的方法是將其分解爲不一樣的自治部分,例如經過 REST,RPC 或消息系統進行通訊。這就要求劃清界限,每一個上下文最終均可能擁有本身的基礎架構(數據存儲,服務器,消息中間件等),甚至是本身的團隊。架構
正如你想象的那樣,這可能致使某種程度的重複,但這是咱們爲下降複雜性而願意作出的權衡。在領域驅動設計中,咱們將這些獨立的部分稱爲限界上下文。app
當兩個限界上下文之間存在單向集成時,其中一個充當提供者(上游),另外一個充當客戶(下游),咱們最終獲得一個客戶 - 供應商開發團隊。框架
在兩個團隊之間創建清晰的客戶/供應商關係。在計劃會議中,讓下游團隊扮演上游團隊的客戶角色。協商和預算下游需求的任務,以便每一個人都瞭解承諾和進度。共同開發自動驗收測試,以驗證預期的接口。將這些測試添加到上游團隊的測試套件中,以做爲其持續集成的一部分運行。該測試將使上游團隊有能力進行更改,而沒必要擔憂下游的反作用。Eric Evans - 《領域驅動設計:軟件核心複雜性應對之道》
客戶 - 供應商開發團隊是集成限界上下文最多見的方式,而且當團隊緊密工做時一般呈現一個共贏的局面。
繼續電子商務的例子,考慮將收入報告給舊的傳統零售商財務系統。集成可能會很是昂貴,從而致使不值得進行努力。在領域驅動設計戰略術語中,這稱爲分離方式。
集成問題昂貴的。有時候收益很小。所以,聲明一個限界上下文根本不與其餘任何上下文關聯,從而使開發人員能夠在這個很小的範圍內找到簡單,專業的解決方案。Eric Evans - 《領域驅動設計:軟件核心複雜性應對之道》
再次考慮電子商務例子以及與第三方運輸服務的集成。這兩個領域在模型,團隊和基礎架構上都不一樣。負責維護第三方運輸服務的團隊將不會參與你的產品計劃或爲電子商務系統提供任何解決方案。這些團隊沒有親密關係。咱們能夠選擇接受並遵循他們的領域模型。在戰略設計中,這就是所謂的追隨集成(Conformist Integration)。
經過嚴格遵照上游團隊的模型,消除上綁定上下文之間翻譯的複雜性。儘管這限制了下游設計人員的風格,而且應用程序提供理想的模型,可是選擇 CONFORMITY 能夠極大地簡化集成。另外,你將與供應商團隊共享通用語言。供應商位於駕駛員座位上,所以使他們之間的交流變得容易。利他主義可能足以使他們與你共享信息。Eric Evans - 《領域驅動設計:軟件核心複雜性應對之道》
爲了使事情更簡單,咱們假設限界上下文存在客戶 - 供應商關係。
對於現代 RPC,咱們經過 RESTful 資源引用 RPC。一個限界上下文提示了與外界交互的清晰接口。它暴露了能夠經過 HTTP 動詞操做的資源。咱們能夠說限界上下文提供了一組服務和操做。從策略上講,這就是所謂的開放主機服務(Open Host Service)。
開放主機服務定義一個協議,以一組服務的形式訪問子系統。打開協議,以便全部須要與你集成的人均可以使用它。加強並擴展協議以處理新的集成需求,除非單個團隊有特殊需求。而後,使用一次性翻譯器擴展該特殊狀況的協議,以便共享協議能夠保持簡單和一致。
Eric Evans - 《領域驅動設計:軟件核心複雜性應對之道》
讓咱們研究本書提供的,GitHub 隨附的應用程序,Last Wishes 中的示例。
這個應用是一個讓降死之人留下他們最後的遺願的 web 平臺。它有兩個上下文:一個負責處理遺願-遺願上下文,一個負責爲系統用戶提供積分-遊戲化上下文。在遺願上下文裏,用戶可能具備 與用戶在遊戲化上下文中所得到的分數有關的徽章。這意味着咱們須要將兩個上下文集成在一塊兒,以顯示用戶在遺願上下文上擁有的徽章。
遊戲化上下文是由定製的事件源引擎提供支持的成熟的事件驅動應用程序。根據 Richardson 成熟度模型( Richardson Maturity Model)。這是一個完整的 Symfony 應用程序,它使用 FOSRestBundle,BazingaHateoasBundle,JMSSerializerBundle,NelmioApiDocBundle 和 OngrElasticsearchBundle 來提供 3 級以上的 REST API(一般稱爲 Glory of REST)。此上下文中觸發的全部事件都將針對 Elasticsearch 服務器進行投影,以生成視圖所需的數據。咱們將經過諸如 http://gamification.context.h... 之類的端點公開給定用戶的分數。
咱們還將從 Elasticsearch 獲取用戶投影並將其序列化爲先前與客戶端協商的格式:
namespace AppBundle\Controller; use FOS\RestBundle\Controller\Annotations as Rest; use FOS\RestBundle\Controller\FOSRestController; use Nelmio\ApiDocBundle\Annotation\ApiDoc; class UsersController extends FOSRestController { /** * @ApiDoc( * resource = true, * description = "Finds a user given a user ID", * statusCodes = {* 200 = "Returned when the user have been found", * 404 = "Returned when the user could not be found" * } * ) * * @Rest\View( * statusCode = 200 * ) */ public function getUserAction($id) { $repo = $this->get('es.manager.default.user'); $user = $repo->find($id); if (!$user) { throw $this->createNotFoundException( sprintf( 'A user with an ID of %s does not exist', $id ) ); } return $user; }
正如咱們在第 2 章,架構風格中解釋的那樣,讀取被視爲基礎設施問題,所以無需將它們包裝在一個 Command / Command Handler 流中。
獲得的 JSON + HAL 用戶表述像這樣:
{ "id": "c3c587c6-610a-42df", "points": 0, "_links": { "self": { "href": "http://gamification.ctx/api/users/c3c587c6-610a-42df" } } }
如今咱們處於集成兩個上下文的良好位置。咱們只須要在遺願上下文中編寫客戶端便可消費咱們剛剛建立的端點。咱們應該混合兩種領域模型嗎?直接消化遊戲化上下文將意味着使遺願上下文適應遊戲化上下文,從而產生一個 Conformist integration。可是,分離這些問題彷佛值得付出努力。咱們須要一個層來保證遺願上下文中的領域模型的完整性和一致性,而且須要將積分(遊戲化)轉換爲徽章(遺願)。在領域驅動設計中,這種轉換機制稱爲防腐層(Anti-Corruption layer)。
防腐層即建立一個隔離層,以根據客戶端本身的領域模型爲客戶提供功能。該層經過其現有接口與另外一個系統進行通訊,幾乎不須要或不須要對其進行任何修改。在內部,該層在兩個模型之間都須要在兩個方向上平移。
Eric Evans - 《領域驅動設計:軟件核心複雜性應對之道》
那麼,防腐層長什麼樣子呢?大多數時候,服務會和 Adapters 與 Facades 的結合體進行交互。服務封閉並隱藏了這些轉換背後的底層複雜性。Facades 有助於隱藏和封裝從遊戲化模型中獲取數據所需的訪問詳細信息。Adapters 一般使用專門的轉換器在模型之間轉換。
讓咱們看看如何在遺願模型中定義用戶服務,該服務將負責檢索給定用戶得到的徽章:
namespace Lw\Domain\Model\User; interface UserService { public function badgesFrom(UserId $id); }
如今,讓咱們看看基礎設施方面的實現。咱們將使用一個 adapter 作過程轉換:
namespace Lw\Infrastructure\Service; use Lw\Domain\Model\User\UserId; use Lw\Domain\Model\User\UserService; class TranslatingUserService implements UserService { private $userAdapter; public function __construct(UserAdapter $userAdapter) { $this->userAdapter = $userAdapter; } public function badgesFrom(UserId $id) { return $this->userAdapter->toBadges($id); } }
以及這裏是 UserAdapter 的 HTTP 實現:
namespace Lw\Infrastructure\Service; use GuzzleHttp\Client; class HttpUserAdapter implements UserAdapter { private $client; public function __construct(Client $client) { $this->client = $client; } public function toBadges( $id) { $response = $this->client->get( sprintf('/users/%s', $id), [ 'allow_redirects' => true, 'headers' => [ 'Accept' => 'application/hal+json' ] ] ); $badges = []; if (200 === $response->getStatusCode()) { $badges = (new UserTranslator()) ->toBadgesFromRepresentation( json_decode( $response->getBody(), true ) ); } return $badges; } }
如你所見,Adapter 也充當了遊戲化上下文的 Facade。咱們這樣作是由於在遊戲化一側獲取用戶資源很是簡單。Adapter 使用 UserTranslator 執行轉換:
namespace Lw\Infrastructure\Service; use Lw\Infrastructure\Domain\Model\User\FirstWillMadeBadge; use Symfony\Component\PropertyAccess\PropertyAccess; class UserTranslator { public function toBadgesFromRepresentation($representation) { $accessor = PropertyAccess::createPropertyAccessor(); $points = $accessor->getValue($representation, 'points'); $badges = []; if ($points > 3) { $badges[] = new FirstWillMadeBadge(); } return $badges; } }
Translator 專門將遊戲化上下文中的積分轉換爲徽章。
咱們已經展現瞭如何集成兩個限界上下文,其中各個團隊共享一個客戶 - 供應商關係。遊戲化上下文經過由 RESTful 協議實現的開放主機服務暴露集成。另外一方面,遺願上下文經過防腐層使用服務,該層負責將模型從一個領域轉換爲另外一個領域,從而確保遺願上下文的完整性。
RESTful 資源並非實現限界上下文集成的惟一方法。就像咱們將看到的那樣,消息中間件也能夠支持不一樣上下文之間的解耦集成。
咱們可使用拉(pull)策略來尋求另外一個開放主機服務。遺願上下文按期拉取遊戲化上下文,以使徽章同步(例如:經過 cron 這樣的調度程序)。這種解決方案將影響用戶的體驗,而且將浪費大量沒必要要的資源。
更好的方法是使用消息中間件。使用這種解決方案,上下文能夠把消息推送到中間件(一般是消息中間件)。感興趣的各方都可以按需以解耦的方式進行訂閱,檢查和使用。爲此,咱們須要一種專門的,共享的和通用的通訊語言,以便全部各方均可以理解所傳輸的信息。這就是所謂的發佈語言(Published Language)。
發佈語言是使用一個良好文檔化的共享語言,該共享語言能夠將必要的領域信息表示爲通用的通訊媒介,並在必要時進行該語言的進出翻譯。
Eric Evans - 《領域驅動設計:軟件核心複雜性應對之道》
在考慮這些消息的格式並仔細研究咱們的領域模型時,咱們意識到咱們已經擁有了所須要的:第 6 章,領域事件。它沒必要定義限界上下文之間進行通訊的新方式。相反,咱們能夠僅使用領域事件來定義跨上下文的通用語言。領域專家關心的事情的定義剛好符合咱們正在尋找的東西:一種正式的發佈語言。
在咱們的示例中,咱們可使用 RabbitMQ 做爲消息中間件。這多是最可靠,最強壯的 AMQP 協議消息系統之一。咱們還將結合普遍使用的庫 php-amqplib 和 RabbitMQBundle。
讓咱們從遺願上下文開始,由於它是在用戶註冊或許願時觸發事件。正如咱們在第 6 章,領域事件中已經看到的那樣,將領域事件存儲到持久化機制裏是個好主意,所以咱們假設已經完成了工做。咱們須要一個消息發佈者從事件存儲中獲取領域事件並將其發佈到消息中間件。咱們已經在第 6 章,領域事件中完成了與 RabbitMQ 的集成,所以咱們只須要在遊戲化上下文中實現代碼便可。咱們將監聽遺願上下文觸發的事件。因爲咱們使用 Symfony 框架,咱們將利用 RabbitMQBundle 包。
咱們將爲 User Registered 和 Wish Was Made 事件定義兩個消息消費者:
namespace AppBundle\Infrastructure\Messaging\PhpAmqpLib; use Lw\Gamification\Command\SignupCommand; use OldSound\RabbitMqBundle\RabbitMq\ConsumerInterface; use PhpAmqpLib\Message\AMQPMessage; class PhpAmqpLibLastWillUserRegisteredConsumer implements ConsumerInterface { private $commandBus; public function __construct($commandBus) { $this->commandBus = $commandBus; } public function execute(AMQPMessage $message) { $type = $message->get('type'); if('Lw\Domain\Model\User\UserRegistered' === $type) { $event = json_decode($message->body); $eventBody = json_decode($event->event_body); $this->commandBus->handle( new SignupCommand($eventBody->user_id->id) ); return true; } return false; } }
注意在這個例子中,咱們僅僅用 Lw\Domain\Model\User\UserRegistered
處理消息:
namespace AppBundle\Infrastructure\Messaging\PhpAmqpLib; use Lw\Gamification\Command\RewardUserCommand; use Lw\Gamification\Domain\Model\AggregateDoesNotExist; use OldSound\RabbitMqBundle\RabbitMq\ConsumerInterface; use PhpAmqpLib\Message\AMQPMessage; class PhpAmqpLibLastWillWishWasMadeConsumer implements ConsumerInterface { private $commandBus; public function __construct($commandBus) { $this->commandBus = $commandBus; } public function execute(AMQPMessage $message) { $type = $message->get('type'); if ('Lw\Domain\Model\Wish\WishWasMade' === $type) { $event = json_decode($message->body); $eventBody = json_decode($event->event_body); try { $points = 5; $this->commandBus->handle( new RewardUserCommand( $eventBody->user_id->id, $points ) ); } catch (AggregateDoesNotExist $e) { // Noop } return true; } return false; } }
一樣,咱們僅對跟蹤 Lw\Domain\Model\Wish\WishWasMade
事件感興趣。
在兩個例子中,咱們都使用了命令總線(Command Bus),這咱們在第 10 章,應用中討論過。可是,咱們能夠將其比喻爲解耦命令和接收者的高速公路。執行命令的時間和方式與觸發它的人無關。
遊戲化上下文使用 Iactician(和 IacticianBundle),一個簡單的命令總線,能夠擴展並適應你的系統。所以,如今咱們幾乎準備好開始使用遺願上下文中的事件。
咱們惟一須要作的事就是在 Symonfony 中定義 RabbitMQBundle 的配置文件 config.yml
:
services: last_will_user_registered_consumer: class: AppBundle\Infrastructure\Messaging\PhpAmqpLib\PhpAmqpLibLastWillUserRegisteredConsumer arguments: - @tactician.commandbus last_will_wish_was_made_consumer: class: AppBundle\Infrastructure\Messaging\PhpAmqpLib\PhpAmqpLibLastWillWishWasMadeConsumer arguments: - @tactician.commandbus old_sound_rabbit_mq: connections: default: host: " %rabbitmq_host%" port: " %rabbitmq_port%" user: " %rabbitmq_user%" password: " %rabbitmq_password%" vhost: " %rabbitmq_vhost%" lazy: true consumers: last_will_user_registered: connection: default callback: last_will_user_registered_consumer exchange_options: name: last-will type: fanout queue_options: name: last-will last_will_wish_was_made: connection: default callback: last_will_wish_was_made_consumer exchange_options: name: last-will type: fanout queue_options: name: last-wil
RabbitMQ 最方便的配置多是發佈/訂閱模式。遺願上下文發佈的全部消息都將分發給全部鏈接的消費者。這在 RabbitMQ 交換機配置裏稱爲 fanout。
交換機由負責將消息傳統到相應隊列的代理組成:
> php app/console rabbitmq:consumer --messages=1000 last_will_user_registered > php app/console rabbitmq:consumer --messages=1000 last_will_wish_was_made
使用這兩個命令,Symfony 將同時執行兩個消費者,而且他們將開始監聽領域事件。咱們已指定限制消費 1000 條消息,由於 PHP 並非執行長時間運行進程的最佳平臺。最好使用 Supervisor 之類的工具按期監視和重啓進程。
儘管咱們只看到它的一小部分,戰略設計是領域驅動設計的靈魂和核心。它的精髓部分有助於開發更好和更多的語義化模型。咱們推薦使用消息中間件集成限界上下文,由於它天然地產出簡單,解耦,和事件驅動的架構。