《領域驅動設計之PHP實現》全書翻譯 - 服務

服務

  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實現六邊形架構》

你已經瞭解什麼是實體和值對象了。做爲基本構建塊,它們應該包含任何應用程序絕大多數的業務邏輯。然而,還有一些場景,實體和值對象並非最好的方案。讓咱們看看 Eric Evans 在他的書《領域驅動設計:軟件核心複雜性應對之道》中提到過的:php

當領域裏一個重要過程或者轉換不是實體或者值對象的天然責任時,則要增長一個操做到模型中做爲一個單獨的接口,並定義爲一個服務。根據模型語言來定義接口,並確保操做名詞是通用語言的一部分。使服務無狀態化。

所以,當有一些操做須要體現,而實體和值對象並非最好選擇時,你應該考慮將這些操做建模爲服務。在領域驅動設計裏,你會碰到三種典型的不一樣類型的服務:html

  • 應用服務: 操做標量類型,將它們轉換成領域類型。標題類型能夠視爲任何領域模型未知的類型。這包括基本數據類型,以及不屬於領域範圍的類型。咱們在本章對此僅做概述,若是對此主題須要更深瞭解,請查看 第 11 章: 應用
  • 領域服務: 僅操做屬於領域的服務。它們包含一些有意義的概念,這些能夠在通用語言裏發現,它們包含的操做不適合值對象或者實體。
  • 基礎服務: 是知足基礎設施關注點的一些操做,例如發送郵件以及記錄有意義的日誌數據。對於六邊形架構而言,他們存在於領域邊界以外。

應用服務

應用服務是處於外界和領域邏輯間的中間件。這種機制的目的是將外界命令轉換成有意義的領域指令。web

讓咱們看一下 User signs up to our platform 這個例子。用由表及裏的方法(交付機制)開始,咱們須要爲領域操做組合輸入請求。使用像 Symfony 這樣的框架做爲交付機制,代碼將以下所示:算法

class SignUpController extends Controller
{
    public function signUpAction(Request $request)
    {
        $signUpService = new SignUpUserService(
            $this->get('user_repository')
        );
        try {
            $response = $signUpService->execute(new SignUpUserRequest(
                $request->request->get('email'),
                $request->request->get('password')
            ));
        } catch (UserAlreadyExistsException $e) {
            return $this->render('error.html.twig', $response);
        }
        return $this->render('success.html.twig', $response);
    }
}

正如你所見,咱們新建了一個應用服務實例,來傳遞全部的依賴須要 - 在這個案例裏就是一個 UserRepositoryUserRepository 是一個能夠用任何指定的技術(例如:MySQL,Redis,ElasticSearch)來實現的接口。接着,咱們爲應用服務構建了一個請求對象,以便抽象交付機制 - 在這個例子裏即一個來自於業務邏輯的 web 請求。最後,咱們執行應用服務,獲取回覆,並用回覆來渲染結果。在領域這邊,咱們經過協調邏輯來檢驗應用服務的一種可能實現,以知足 User signs up 用例:sql

class SignUpUserService
{
    private $userRepository;

    public function __construct(UserRepository $userRepository)
    {
        $this->userRepository = $userRepository;
    }

    public function execute(SignUpUserRequest $request)
    {
        $user = $this->userRepository->userOfEmail($request->email);
        if ($user) {
            throw new UserAlreadyExistsException();
        }
        $user = new User(
            $this->userRepository->nextIdentity(),
            $request->email,
            $request->password
        );
        $this->userRepository->add($user);
        return new SignUpUserResponse($user);
    }
}

代碼中都是關於咱們想解決的領域問題,而不是關於咱們用來解決它的具體技術。用這種方法,咱們可以將高層次抽象與低層次實現細節解耦。交付機制與領域間的通訊是經過一種稱爲 DTO 的數據結構,這咱們已經在 第二章: 架構風格 中介紹過:數據庫

class SignUpUserRequest
{
    public $email;
    public $password;

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

對於回覆對象的建立,你可使用 getters 或者公開的實例變量。應用服務應該注意事務範圍及安全性。不過,你須要在 第 11 章: 應用服務,深刻研究更多關於這些以及其它與應用服務相關的內容。編程

領域服務

在與領域專家的對話中,你將遇到通用語言裏的一些概念,它們不能很好地表示爲一個實體或者值對象:segmentfault

  • 用戶可以自行登陸系統
  • 購物車能夠成爲一個訂單

上面兩個例子是很是具體的概念,它們中任何一個都不能天然地綁定到實體或者值對象上面。進一步強調這種奇怪之處,咱們能夠嘗試以下方式模型化這種行爲:安全

class User
{
    public function signUp($aUsername, $aPassword)
    {
// ...
    }
}

class Cart
{
    public function createOrder()
    {
// ...
    }
}

在第一種實現方式中,咱們不可能知道給定的用戶名與密碼與上次調用的用戶實例之間的關聯。顯然,這個操做並不適合當前實體。相反,它應該被提取出來做爲一個單獨的類,使其意圖明確。數據結構

考慮到這一點,咱們能夠建立一個領域服務,其惟一責任就是驗證用戶身份:

class SignUp
{
    public function execute($aUsername, $aPassword)
    {
// ...
    }
}

相似地,在第二個示例中,咱們能夠建立一個領域服務專門從給定的購物車中建立訂單:

class CreateOrderFromCart
{
    public function execute(Cart $aCart)
    {
// ...
    }
}

領域服務能夠被定義爲:一個操做並不天然知足一個實體或者值對象的領域任務。做爲表示領域操做的概念,客戶端應使用域名服務,而無論其運行的歷史記錄如何。領域服務自己不保存任何狀態,所以領域服務是無狀態的操做。

領域服務與基礎設施服務

在建模領域服務時,一般會遇到基礎設施依賴問題。例如,在一個須要處理密碼哈希的認證機制裏。在這種狀況下,你可使用一個單獨的接口,它能夠定義多種哈希機制。使用這種模式依然可讓你在領域和基礎設施間明確分離關注點:

namespace Ddd\Auth\Infrastructure\Authentication;
class DefaultHashingSignUp implements Ddd\Auth\Domain\Model\SignUp
{
    private $userRepository;

    public function __construct(UserRepository $userRepository)
    {
        $this->userRepository = $userRepository;
    }

    public function execute($aUsername, $aPassword)
    {
        if (!$this->userRepository->has($aUsername)) {
            throw UserDoesNotExistException::fromUsername($aUsername);
        }
        $aUser = $this->userRepository->byUsername($aUsername);
        if (!$this->isPasswordValidForUser($aUser, $aPassword)) {
            throw new BadCredentialsException($aUser, $aPassword);
        }
        return $aUser;
    }

    private function isPasswordValidForUser(
        User $aUser, $anUnencryptedPassword
    )
    {
        return password_verify($anUnencryptedPassword, $aUser->hash());
    }
}

這裏有另一個基於 MD5 實現的算法:

namespace Ddd\Auth\Infrastructure\Authentication;

use Ddd\Auth\Domain\Model\SignUp
class Md5HashingSignUp implements SignUp
{
    const SALT = 'S0m3S4lT';
    private $userRepository;

    public function __construct(UserRepository $userRepository)
    {
        $this->userRepository = $userRepository;
    }

    public function execute($aUsername, $aPassword)
    {
        if (!$this->userRepository->has($aUsername)) {
            throw new InvalidArgumentException(
                sprintf('The user "%s" does not exist.', $aUsername)
            );
        }
        $aUser = $this->userRepository->byUsername($aUsername);
        if ($this->isPasswordInvalidFor($aUser, $aPassword)) {
            throw new BadCredentialsException($aUser, $aPassword);
        }
        return $aUser;
    }

    private function salt()
    {
        return md5(self::SALT);
    }

    private function isPasswordInvalidFor(
        User $aUser, $anUnencryptedPassword
    )
    {
        $encryptedPassword = md5(
            $anUnencryptedPassword . '_' . $this->salt()
        );
        return $aUser->hash() !== $encryptedPassword;
    }
}

選擇這種方式使咱們可以在基礎設施層有多種領域服務的實現。換句話說,咱們最終獲得了多種基礎設施領域服務。每種基礎設施服務負責處理一種不一樣的哈希機制。根據實現的不一樣,能夠經過依賴注入容器(例如,經過symfony的依賴注入組件)輕鬆管理使用狀況:

<?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="sign_in" alias="sign_in.default"/>
        <service id="sign_in.default"
                 class="Ddd\Auth\Infrastructure\Authentication
\DefaultHashingSignUp">
            <argument type="service" id="user_repository"/>
        </service>
        <service id="sign_in.md5"
                 class="Ddd\Auth\Infrastructure\Authentication
\Md5HashingSignUp">
            <argument type="service" id="user_repository"/>
        </service>
    </services>
</container>

假如,在將來,咱們想處理一種新的哈希類型,咱們能夠簡單地從實現領域實現接口開始。而後就是在依賴注入容器中聲明服務,並將服務別名依賴關係替換爲新的類型。

一個代碼重用的問題

儘管以前的實現描述明確地定義了 「關注點分離」,但每次咱們想實現一種新的哈希機制時,不得不重複實現密碼驗證算法。一種解決辦法就是分離這兩個職責,從而改進代碼的重用。相反,咱們能夠用策略模式來將提取密碼哈希算法邏輯放到一個定製的類中,用於全部定義的哈希算法。這就是對擴展開放,對修改關閉:

namespace Ddd\Auth\Domain\Model;
class SignUp
{
    private $userRepository;
    private $passwordHashing;

    public function __construct(
        UserRepository $userRepository, PasswordHashing $passwordHashing
    )
    {
        $this->userRepository = $userRepository;
        $this->passwordHashing = $passwordHashing;
    }

    public function execute($aUsername, $aPassword)
    {
        if (!$this->userRepository->has($aUsername)) {
            throw new InvalidArgumentException(
                sprintf('The user "%s" does not exist.', $aUsername)
            );
        }
        $aUser = $this->userRepository->byUsername($aUsername);
        if ($this->isPasswordInvalidFor($aUser, $aPassword)) {
            throw new BadCredentialsException($aUser, $aPassword);
        }
        return $aUser;
    }

    private function isPasswordInvalidFor(User $aUser, $plainPassword)
    {
        return !$this->passwordHashing->verify(
            $plainPassword,
            $aUser->hash()
        );
    }
}

interface PasswordHashing
{
    /**
     * @param $plainPassword
     * @param string $hash
     * @return boolean
     */
    public function verify($plainPassword, $hash);
}

定義不一樣的哈希算法與實現 PasswordHasing 接口同樣簡單:

namespace Ddd\Auth\Infrastructure\Authentication;
class BasicPasswordHashing
    implements \Ddd\Auth\Domain\Model\PasswordHashing
{
    public function verify($plainPassword, $hash)
    {
        return password_verify($plainPassword, $hash);
    }
}

class Md5PasswordHashing
    implements Ddd\Auth\Domain\Model\PasswordHashing
{
    const SALT = 'S0m3S4lT';

    public function verify($plainPassword, $hash)
    {
        return $hash === $this->calculateHash($plainPassword);
    }

    private function calculateHash($plainPassword)
    {
        return md5($plainPassword . '_' . $this->salt());
    }

    private function salt()
    {
        return md5(self::SALT);
    }
}

測試領域服務

給定多個領域服務實現的用戶認證例子,明顯有益於服務的測試。可是,一般狀況下,測試模板方法實現是很麻煩的。所以,咱們使用一種普通的密碼哈希實現來達到測試目的:

class PlainPasswordHashing implements PasswordHashing
{
    public function verify($plainPassword, $hash)
    {
        return $plainPassword === $hash;
    }
}

如今咱們能夠在領域服務中測試全部用例:

class SignUpTest extends PHPUnit_Framework_TestCase
{
    private $signUp;
    private $userRepository;

    protected function setUp()
    {
        $this->userRepository = new InMemoryUserRepository();
        $this->signUp = new SignUp(
            $this->userRepository,
            new PlainPasswordHashing()
        );
    }

    /**
     * @test
     * @expectedException InvalidArgumentException
     */
    public function itShouldComplainIfTheUserDoesNotExist()
    {
        $this->signUp->execute('test-username', 'test-password');
    }

    /**
     * @test
     * @expectedException BadCredentialsException
     */
    public function itShouldTellIfThePasswordDoesNotMatch()
    {
        $this->userRepository->add(
            new User(
                'test-username',
                'test-password'
            )
        );
        $this->signUp->execute('test-username', 'no-matching-password');
}

    /**
     * @test
     */
    public function itShouldTellIfTheUserMatchesProvidedPassword()
    {
        $this->userRepository->add(
            new User(
                'test-username',
                'test-password'
            )
        );
        $this->assertInstanceOf(
            'Ddd\Domain\Model\User\User',
            $this->signUp->execute('test-username', 'test-password')
        );
    }
}

貧血領域模型 VS. 充血領域模型

你必須當心不要在系統中過分使用領域服務抽象。走上這條路會剝離你的實體和值對象的全部行爲,從而致使它們成爲單純的數據容器。這與面向對象編程的目標相反,後者是將數據和行爲封裝到一個稱爲對象的語義單元,目的是爲了表達現實世界的概念和問題。領域服務的過分使用被認爲是一種反模式,這會致使貧血模型。

一般,當開始一個新項目和新功能時,最容易首先掉入數據建模的陷阱。這廣泛包括認爲每一個數據表都有一個一對一對象表示形式。然而,這種想法多是,也可能不是確切的狀況。

假設咱們的任務是創建一個訂單處理系統。若是咱們從數據建模開始,咱們可能會獲得以下的 SQL 腳本:

CREATE TABLE `orders` (
`ID` INTEGER NOT NULL AUTO_INCREMENT,
`CUSTOMER_ID` INTEGER NOT NULL,
`AMOUNT` DECIMAL(17, 2) NOT NULL DEFAULT '0.00',
`STATUS` TINYINT NOT NULL DEFAULT 0,
`CREATED_AT` DATETIME NOT NULL,
`UPDATED_AT` DATETIME NOT NULL,
PRIMARY KEY (`ID`)
) ENGINE=INNODB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

由此看來,建立一個訂單類的表示相對容易。這種表示包括所需的訪問器方法,這些方法用來從數據庫表設置或獲取數據:

class Order
{
    const STATUS_CREATED = 10;
    const STATUS_ACCEPTED = 20;
    const STATUS_PAID = 30;
    const STATUS_PROCESSED = 40;
    private $id;
    private $customerId;
    private $amount;
    private $status;
    private $createdAt;
    private $updatedAt;

    public function __construct(
        $customerId,
        $amount,
        $status,
        DateTimeInterface $createdAt,
        DateTimeInterface $updatedAt
    )
    {
        $this->customerId = $customerId;
        $this->amount = $amount;
        $this->status = $status;
        $this->createdAt = $createdAt;
        $this->updatedAt = $updatedAt;
    }

    public function setId($id)
    {
        $this->id = $id;
    }

    public function getId()
    {
        return $this->id;
    }

    public function setCustomerId($customerId)
    {
        $this->customerId = $customerId;
    }

    public function getCustomerId()
    {
        return $this->customerId;
    }

    public function setAmount($amount)
    {
        $this->amount = $amount;
    }

    public function getAmount()
    {
        return $this->amount;
    }

    public function setStatus($status)
    {
        $this->status = $status;
    }

    public function getStatus()
    {
        return $this->status;
    }

    public function setCreatedAt(DateTimeInterface $createdAt)
    {
        $this->createdAt = $createdAt;
    }

    public function getCreatedAt()
    {
        return $this->createdAt;
    }

    public function setUpdatedAt(DateTimeInterface $updatedAt)
    {
        $this->updatedAt = $updatedAt;
    }

    public function getUpdatedAt()
    {
        return $this->updatedAt;
    }
}

這種實現的一個使用用例示例是按以下更新訂單狀態:

// Fetch an order from the database
$anOrder = $orderRepository->find(1);
// Update order status
$anOrder->setStatus(Order::STATUS_ACCEPTED);
// Update updatedAt field
$anOrder->setUpdatedAt(new DateTimeImmutable());
// Save the order to the database
$orderRepository->save($anOrder);

從代碼重用的角度來看,此代碼存在相似於初始化用戶認證案例。爲了解決這個問題,這種作法的維護者建議使用一個服務層,從而使操做明確和可重用。如今能夠將前面的實現封裝到單獨的類中:

class ChangeOrderStatusService
{
    private $orderRepository;

    public function __construct(OrderRepository $orderRepository)
    {
        $this->orderRepository = $orderRepository;
    }

    public function execute($anOrderId, $anOrderStatus)
    {
// Fetch an order from the database
        $anOrder = $this->orderRepository->find($anOrderId);
        // Update order status
        $anOrder->setStatus($anOrderStatus);
// Update updatedAt field
        $anOrder->setUpdatedAt(new DateTimeImmutable());
// Save the order to the database
        $this->orderRepository->save($anOrder);
    }
}

或者,在更新訂單數量的狀況下,考慮這樣:

class UpdateOrderAmountService
{
    private $orderRepository;

    public function __construct(OrderRepository $orderRepository)
    {
        $this->orderRepository = $orderRepository;
    }

    public function execute($orderId, $amount)
    {
        $anOrder = $this->orderRepository->find(1);
        $anOrder->setAmount($amount);
        $anOrder->setUpdatedAt(new DateTimeImmutable());
        $this->orderRepository->save($anOrder);
    }
}

這樣客戶端的代碼將大大減小,同時帶來簡潔明確的操做:

$updateOrderAmountService = new UpdateOrderAmountService(
    $orderRepository
);
$updateOrderAmountService->execute(1, 20.5);

實現這種方法能夠獲得很大程度的代碼重用性。有人若是但願更新訂單數量,只須要找到一個 UpdateOrderAmountService 實例並用合適的參數調用 execute 方法便可。

然而,選擇這條路將破壞前面討論過的面向對象原則,而且在沒有任何優點的狀況下帶來了構建領域模型的成本。

貧血領域模型破壞封裝

若是咱們從新審視咱們用服務層定義的服務代碼,咱們能夠看到,做爲使用 Order 實體的客戶端,咱們須要瞭解其內部表示的每一個詳細信息。這一發現違背了面向對象的基本原則,即將數據與行爲結合起來。

貧血領域模型帶來代碼重用錯覺

假設這裏有一個實例,一個客戶端繞過 UpdateOrderAmountService,直接用 OrderRepository 檢索,更新和持久化。而後,UpdateOrderAmountService 服務的全部其它額外業務邏輯可能不被執行。這可能致使訂單存儲不一致的狀態。所以,不變量應該受到正確地保護,而最好的方法就是用真正的領域模型來處理它。在這個例子中,Order 實體是確保這一點的最佳地方:

class Order
{
// ...
    public function changeAmount($amount)
    {
        $this->amount = $amount;
        $this->setUpdatedAt(new DateTimeImmutable());
    }
}

請注意,將這個操做下放到實體中,並根據通用語言來命名它,系統將得到出色的代碼重用性。如今任何人想改變訂單數量,都必須直接調用 Order::changeAmount 方法。

這樣就獲得了更爲豐富的類,其目的就是代碼重用。這一般就叫作富領域模型

如何避免貧血領域模型

避免陷入貧血領域模型的方法是,當開始一個新項目或者新功能時,首先考慮行爲。數據庫,ORM等都是實現細節,咱們應該在開發過程當中儘量推遲決定使用這些工具。這樣作,咱們能夠專一一個屬性真正所關心的:行爲。

與實體的狀況同樣,領域服務在第 6 章:領域事件中會被說起。不過,當事件大多數時候被領域服務,而不是實體觸發時,它再次代表你可能正在建立一個貧血模型。

小結

以上,服務表示了咱們系統內的操做,咱們能夠將它區分爲三種類型:

  • 應用服務: 幫助協調來自外部的請求進入領域內部。這些服務不包含領域邏輯。事務都在應用層進行處理;把服務包裝到事務裝飾器裏將使你的代碼事務不可知。
  • 領域服務: 僅用領域概念操做,即爲通用語言表達的概念。記住推遲實現細節而優先考慮行爲,由於濫用領域服務將致使貧血模型以及糟糕的面向對象設計。
  • 基礎服務: 基礎設施上的操做,處理像發送郵件或者日誌信息等。

咱們最重要的建議是,在決定建立領域服務時應考慮全部狀況。首先試着將你的業務邏輯放到實體或值對象中。與同事進行溝通,從新檢查。假如在試過不一樣方法後,最佳選擇是建立一個領域服務,那麼就用它吧。

相關文章
相關標籤/搜索