你已经了解什么是实体和值对象了。作为基本构建块,它们应该包含任何应用程序绝大多数的业务逻辑。然而,还有一些场景,实体和值对象并不是最好的方案。让我们看看 Eric Evans 在他的书《领域驱动设计:软件核心复杂性应对之道》中提到过的:
当领域里一个重要过程或者转换不是实体或者值对象的自然责任时,则要增加一个操作到模型中作为一个单独的接口,并定义为一个服务。根据模型语言来定义接口,并确保操作名词是通用语言的一部分。使服务无状态化。
因此,当有一些操作需要体现,而实体和值对象并不是最好选择时,你应该考虑将这些操作建模为服务。在领域驱动设计里,你会碰到三种典型的不同类型的服务:
- 应用服务: 操作标量类型,将它们转换成领域类型。标题类型可以视为任何领域模型未知的类型。这包括基本数据类型,以及不属于领域范围的类型。我们在本章对此仅作概述,如果对此主题需要更深了解,请查看 第 11 章: 应用。
- 领域服务: 仅操作属于领域的服务。它们包含一些有意义的概念,这些可以在通用语言里发现,它们包含的操作不适合值对象或者实体。
- 基础服务: 是满足基础设施关注点的一些操作,例如发送邮件以及记录有意义的日志数据。对于六边形架构而言,他们存在于领域边界之外。
应用服务
应用服务是处于外界和领域逻辑间的中间件。这种机制的目的是将外界命令转换成有意义的领域指令。
让我们看一下 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 用例:
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 章: 应用服务,深入研究更多关于这些以及其它与应用服务相关的内容。
领域服务
在与领域专家的对话中,你将遇到通用语言里的一些概念,它们不能很好地表示为一个实体或者值对象:
- 用户能够自行登录系统
- 购物车可以成为一个订单
上面两个例子是非常具体的概念,它们中任何一个都不能自然地绑定到实体或者值对象上面。进一步强调这种奇怪之处,我们可以尝试如下方式模型化这种行为:
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 章:领域事件中会被提及。不过,当事件大多数时候被领域服务,而不是实体触发时,它再次表明你可能正在创建一个贫血模型。
小结
以上,服务表示了我们系统内的操作,我们可以将它区分为三种类型:
- 应用服务: 帮助协调来自外部的请求进入领域内部。这些服务不包含领域逻辑。事务都在应用层进行处理;把服务包装到事务装饰器里将使你的代码事务不可知。
- 领域服务: 仅用领域概念操作,即为通用语言表达的概念。记住推迟实现细节而优先考虑行为,因为滥用领域服务将导致贫血模型以及糟糕的面向对象设计。
- 基础服务: 基础设施上的操作,处理像发送邮件或者日志信息等。
我们最重要的建议是,在决定创建领域服务时应考虑所有情况。首先试着将你的业务逻辑放到实体或值对象中。与同事进行沟通,重新检查。假如在试过不同方法后,最佳选择是创建一个领域服务,那么就用它吧。