你已经了解什么是实体和值对象了。做为基本构建块,它们应该包含任何应用程序绝大多数的业务逻辑。然而,还有一些场景,实体和值对象并非最好的方案。让咱们看看 Eric Evans 在他的书《领域驱动设计:软件核心复杂性应对之道》中提到过的:php
当领域里一个重要过程或者转换不是实体或者值对象的天然责任时,则要增长一个操做到模型中做为一个单独的接口,并定义为一个服务。根据模型语言来定义接口,并确保操做名词是通用语言的一部分。使服务无状态化。
所以,当有一些操做须要体现,而实体和值对象并非最好选择时,你应该考虑将这些操做建模为服务。在领域驱动设计里,你会碰到三种典型的不一样类型的服务:html
应用服务是处于外界和领域逻辑间的中间件。这种机制的目的是将外界命令转换成有意义的领域指令。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); } }
正如你所见,咱们新建了一个应用服务实例,来传递全部的依赖须要 - 在这个案例里就是一个 UserRepository
。UserRepository
是一个能够用任何指定的技术(例如: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') ); } }
你必须当心不要在系统中过分使用领域服务抽象。走上这条路会剥离你的实体和值对象的全部行为,从而致使它们成为单纯的数据容器。这与面向对象编程的目标相反,后者是将数据和行为封装到一个称为对象的语义单元,目的是为了表达现实世界的概念和问题。领域服务的过分使用被认为是一种反模式,这会致使贫血模型。
一般,当开始一个新项目和新功能时,最容易首先掉入数据建模的陷阱。这广泛包括认为每一个数据表都有一个一对一对象表示形式。然而,这种想法多是,也可能不是确切的状况。
假设咱们的任务是创建一个订单处理系统。若是咱们从数据建模开始,咱们可能会获得以下的 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 章:领域事件中会被说起。不过,当事件大多数时候被领域服务,而不是实体触发时,它再次代表你可能正在建立一个贫血模型。
以上,服务表示了咱们系统内的操做,咱们能够将它区分为三种类型:
咱们最重要的建议是,在决定建立领域服务时应考虑全部状况。首先试着将你的业务逻辑放到实体或值对象中。与同事进行沟通,从新检查。假如在试过不一样方法后,最佳选择是建立一个领域服务,那么就用它吧。