应用服务
应用层是将领域模型与查询或更改其状态的客户端分离的层。应用服务是此层的构建块。正如 Vaughn Vernon 所说:“应用服务是领域模型的直接客户端。” 你可以考虑把应用服务当作外部世界(HTML 表单,API 客户端,命令行,框架,UI 等等)与领域模型自身之间的连接点。考虑向外部展示系统的顶级用例,也许会有帮助。例如:“作为来宾,我想注册”,“作为以登录用户,我要购买产品”,等等。
在这一章,我们将解释怎样实现应用服务,理解命令模式(Command pattern)的角色,并且确定应用服务的职责。所以,让我们考虑一个注册新用户(signing up a new user)的用例。
从概念上讲,为注册新用户,我们必须:
从客户端获取电子邮箱(email)和密码(password)
检查电子邮箱(email)是否在使用
创建一个新用户(user)
将用户(user)添加到已有用户集合(user set)
返回我们刚创建的用户(user)
让我们开始干吧!
请求(Requests)
我们需要发送电子邮件(email)和密码(password)给应用服务。这有许多方法可以从客户端来做这样事情(HTML 表单,API 客户端,或者甚至命令行)。我们可以通过用方法签名发送一个标准的参数(email 和 password),或者构建并发送一个含有这些信息的数据结构。后面的这种方法,即发送 DTO,把一些有趣的功能拿到台面上。通过发送对象,可以在命令总线上对其进行序列化和排队。也可以添加类型安全和一些 IDE 帮助。
数据传输对象(Data Transfer Object)
DTO 是一种在过程之间转移数据的数据结构。不要把它误认为是一种全能的对象。DTO 除了存储和检索它自身数据(存取器),没有其他任何行为。DTO 是简单的对象,它不应该含有任何需要测试的业务逻辑。
正如 Vaughn Vernon 所说:
应用服务方法签名仅使用基本类型(整数,字符串等等),或者 DTO 作为这些方法的替代方法,更好的方法可能是设计命令对象(Command Object)。这不一定是正确或错误的方法,这主要取决于你的口味和目标。
一个含有应用服务所含数据的 DTO 实现可能像这样:
namespace Lw\Application\Service\User;
class SignUpUserRequest
{
private $email;
private $password;
public function __construct($email, $password)
{
$this->email = $email;
$this->password = $password;
}
public function email()
{
return $this->email;
}
public function password()
{
return $this->password;
}
}
正如你所见,SignUpUserRequest 没有行为,只有数据。这可能来自于一个 HTML 表单或者一个 API 端点,尽管我们不关心这些。
构建应用服务请求
从你最喜欢的框架交付机制创建一个请求,应该是最直观的。在 web 中,你可以将控制器请求中的参数打包成一个 DTO 并将它们下发给服务。对 CLI 命令来说也是相同的原则:读取输入参数并下发。
通过使用 Symfony,我们可以提取来自 HttpFoundation 组件的请求中的所需数据:
// ...
class UsersController extends Controller
{
/**
* @Route('/signup', name = 'signup')
* @param Request $request
* @return Response
*/
public function signUpAction(Request $request)
{
// ...
$signUpUserRequest = new SignUpUserRequest(
$request->get('email'),
$request->get('password')
);
// ...
}
// ...
在一个使用 Form 组件来捕获和验证参数的更精细的 Silex 应用程序上,它看起来像这样:
// ...
$app->match('/signup', function (Request $request) use ($app) {
$form = $app['sign_up_form'];
$form->handleRequest($request);
if ($form->isValid()) {
$data = $form->getData();
try {
$app['sign_in_user_application_service']->execute(
new SignUpUserRequest(
$data['email'],
$data['password']
)
);
return $app->redirect(
$app['url_generator']->generate('login')
);
} catch (UserAlreadyExistsException $e) {
$form
->get('email')
->addError(
new FormError(
'Email is already registered by another user'
)
);
} catch (Exception $e) {
$form
->addError(
new FormError(
'There was an error, please get in touch with us'
)
);
}
}
return $app['twig']->render('signup.html.twig', [
'form' => $form->createView(),
]);
});
请求的设计
在设计你的请求对象时,你应该总是遵循这些原则:使用基本类型,序列化设计,并且不包含业务逻辑在里面。这样,你可以在单元测试时节省开支。
使用基本类型
我们推荐使用基本类型来构建你的请求对象(就是字符串,整数,布尔值等等)。我们仅仅是抽象出输入参数。你应该能够从交付机制当中独立地消费应用服务。即使是非常复杂的 HTML 表单,也总是可以在控制器级别转换为基本类型。你应该不想混淆你的框架和业务逻辑。
在某些情况下,很容易直接使用值对象。不要这样做。值对象定义的更新将影响所有客户端,并且你会将客户端与领域逻辑耦合在一起。
序列化
使用基本类型的一个副作用就是,任何请求对象可以轻松地序列化为字符串,发送并存储在消息系统或者数据库中。
无业务逻辑
避免在你的请求对象中加入任何业务甚至验证逻辑。验证应该放到你的领域中(这里指的是实体,值对象,领域服务等等)。验证是保持业务不变性和领域约束的方法。
无测试
应用程序请求是数据结构,不是对象。数据结构的单元测试就像测试 getters 和 setters。这没有行为需要测试,因此对请求对象和 DTO 进行单元测试没有太多价值。这些结构将作为更复杂的测试(例如集成测试或验收测试)的副作用而覆盖。
命令是请求对象的替代方法。我们设计一个具有多种应用方法服务,并且每个方法都含有你放到 Request 中的参数。对于简单的应用程序来说这是可以的,但后面我们就得操心这个问题。
应用服务剖析
当我们在请求中封装好了数据,就该处理业务逻辑了。正如 Vaughn Vernon 所说:“尽量保证应用服务很小很薄,仅仅用它们在模型上协调任务。”
首先要做的事情就是从请求中提取必要信息,即 email 和 password。必要的话,我们需要确认是否含有特殊 email 的用户。如果不关心这些的话,那么我们创建和添加用户到 UserRepository。在发现有用户具有相同 email 的特殊情况下,我们抛出一个异常以便客户端以自己的方式处理(显示错误,重试,或者直接忽略它)。
namespace Lw\Application\Service\User;
use Ddd\Application\Service\ApplicationService;
use Lw\Domain\Model\User\User;
use Lw\Domain\Model\User\UserAlreadyExistsException;
use Lw\Domain\Model\User\UserRepository;
class SignUpUserService
{
private $userRepository;
public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}
public function execute(SignUpUserRequest $request)
{
$email = $request->email();
$password = $request->password();
$user = $this->userRepository->ofEmail($email);
if ($user) {
throw new UserAlreadyExistsException();
}
$this->userRepository->add(
new User(
$this->userRepository->nextIdentity(),
$email,
$password
)
);
}
}
漂亮!如果你想知道构造函数里的 UserRepository 是做什么的,我们会在后续向你展示。
处理异常
应用服务抛出异常是向客户端反馈不正常的情况和流程的方法。这一层上的异常与业务逻辑有关(像查找一个用户),但并不是实现细节(像 PDOException,PredisException, 或者 DoctrineException)。
依赖倒置
处理用户不是服务的职责。正如我们在第 10 章,仓储中所见,有一个专门的类来处理 User 集合:UserRepository。这是一个从应用服务到仓储的依赖。我们不想用仓储的具体实现耦合应用服务,因此这之后会导致我们用基础设施细节耦合服务。所以我们依赖具体实现依赖的契约(接口),即 UserRepository。
UserRepository 的特定实现会在运行时被构建和传送,例如用 DoctrineUserRepository,一个使用 Doctine 的专门实现。传递一个专门的实现在测试时同样正常。例如,NotAvailableUserRepository 可以是一个专门的实现,它会在一个操作每个执行时抛出一个异常。这样,我们可以测试所有应用服务行为,包括悲观路径(sad paths),即使当出现了问题,应用服务也必须正常工作。
应用服务也可以依赖领域服务(如 GetBadgesByUser)。在运行时,此类服务的实现可能相当复杂。可以想像一个通过 HTTP 协议整合上下文的 HttpGetBadgesByUser。
依赖抽象,我们可以使我们的应用服务不受底层基础设施更改的影响。
应用服务实例
仅实例化应用服务很容易,但根据依赖构建的复杂度,构建依赖树的可能很棘手。为此,大多数框架都带有依赖注入容器。如果没有,你最后会在控制器的某处得到类似以下的代码:
$redisClient = new Predis\Client([
'scheme' => 'tcp',
'host' => '10.0.0.1',
'port' => 6379
]);
$userRepository = new RedisUserRepository($redisClient);
$signUp = new SignUpUserService($userRepository);
$signUp->execute(new SignUpUserRequest(
'user@example.com',
'password'
));
我们决定为 UserRepository 使用 Redis 的实现。在之前的代码示例中,为构建一个在内部使用 Redis 的仓储,我们构建了所有所需的依赖项。这些依赖是:一个 Predis 客户端,以及所有连接到 Redis 服务器的参数。这不仅效率低下,还会在控制器内重复传播。
你可以将创建逻辑重构为一个工厂,或者你可以使用依赖注入容器(大多数现代框架都包含了它)。
使用依赖注入容器是否不好?
一点也不。依赖注入只是一个工具。它们有助于剥离构建依赖时的复杂性。对构建基础构件也很有用。Symfony 提供了一个完整的实现。
请考虑以下事实,将整个容器作为一个整体传递给服务之一是不好的做法。这就像将整个应用程序的上下文与领域耦合在一起。如果一个服务需要指定的对象,请从框架来创建它们,并且把它们作为依赖传递给服务。但不要使服务觉察到其中的上下文。
让我们看看如何在 Silex 中构建依赖:
$app = new \Silex\Application();
$app['redis_parameters'] = [
'scheme' => 'tcp',
'host' => '127.0.0.1',
'port' => 6379
];
$app['redis'] = $app->share(function ($app) {
return new Predis\Client($app['redis_parameters']);
});
$app['user_repository'] = $app->share(function($app) {
return new RedisUserRepository(
$app['redis']
);
});
$app['sign_up_user_application_service'] = $app->share(function($app) {
return new SignUpUserService(
$app['user_repository']
);
});
// ...
$app->match('/signup' ,function (Request $request) use ($app) {
// ...
$app['sign_up_user_application_service']->execute(
new SignUpUserRequest(
$request->get('email'),
$request->get('password')
)
);
// ...
});
正如你所见,$app 被当作服务容器使用。我们注册了所有所需的组件,以及它们的依赖。sign_up_user_application_service 依赖上面的定义。改变 user_repository 的实现就像返回其他东西一样简单(MySQL,MongoDB 等等),所以我们根本不需要改变服务代码。
Symfony 应用里等效的内容如下:
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">
id="sign_up_user_application_service"
class="SignUpUserService">
id="user_repository"
class="RedisUserRepository">
现在,你有了在 Symonfy Service Container 中的应用服务定义,之后获取它也非常直观。所有交付机制(Web 控制器,REST 控制器,甚至控制台命令)都共享相同的定义。在任何实现 ContainerWare 接口的类上,服务都是可用的。获取服务与 $this->get('sign_up_user_application_service') 一样容易。
总而言之,你怎样构建服务(adhoc,服务容器,工厂等等)并不重要。重要的是保持你的应用服务设置在基础设施边界之外。
自定义一个应用服务
自定义服务的主要方法是选择你要传递的依赖。根据你服务容器能力,这可能有一点棘手,因此你可以添加一个 setter 方法来即时改变依赖。例如,你可能需要更改依赖输出,以便你能设置一个默认项然后改变它。如果逻辑变得过于复杂,你可以创建一个应用服务工厂,它可以为你处理这种局面。
执行
这有两种调用应用服务的方法:一个是每个用例对应一个含有单个 execution 方法的专用类,以及多个应用服务和用例在同一个类。
一个类一个应用服务
这是我们更喜欢的方法,并且这可能适合所有场景:
class SignUpUserService
{
// ...
public function execute(SignUpUserRequest $request)
{
// ...
}
}
每个应用服务使用一个专用的类使得代码在应对外部变化时更健壮(单一职责原则)。这几乎没有原因去改变类,因为服务仅仅只做一件事。应用服务会更容易测试,理解,因为它们只做一件事。这使得实现一个通用的应用服务契约更容易,使类装饰更简单(查看第 10 章,仓储的子章节 事务)。这将同样会更高内聚,因为所有依赖由单一的用例所独有。
execution 方法可以有一个更具表达性的名称,例如 signUp。但是,命令模式的执行通过应用服务标准格式化了通用契约,从而使得这更容易装饰,这对于事务非常方面。
一个类多个应用服务方法
有时候,将内聚的应用服务组织在同一个类中可能是个好主意:
class UserService
{
// ...
public function signUp(SignUpUserRequest $request)
{
// ...
}
public function signIn(SignUpUserRequest $request)
{
// ...
}
public function logOut(LogOutUserRequest $request)
{
// ...
}
}
我们并不推荐这种做法,因为并不是所有应用服务都 100% 内聚的。一些服务可能需要不同的依赖,从而导致你的应用服务依赖其并不需要的东西。另一个问题是这种类会快速成长。因为它违反了单一职责原则,这将导致有多种原因改变它从而破坏它。
返回值
注册后,我们可能会考虑将用户重定向到个人资料页面。回传所需信息给控制器最自然的方式是直接从服务中返回实体。
class SignUpUserService
{
// ...
public function execute(SignUpUserRequest $request)
{
$user = new User(
$this->userRepository->nextIdentity(),
$email,
$password
);
$this->userRepository->add($user);
return $user;
}
}
然后,我们会从控制器中拿到 id 字段,然后重定向到别的地方。但是,三思而后行。我们返回了功能齐全的实体给控制器,这将使得交付机制可以绕过应用层直接与领域交互。
假设 User 实体提供了一个 updateEmailAddress 方法。你可以不这样做,但从长远来看,有人可能会考虑使用它:
$app-> match( '/signup' , function (Request $request) use ($app) {
// ...
$user = $app['sign_up_user_application_service']->execute(
new SignUpUserRequest(
$request->get('email'),
$request->get('password'))
);
$user->updateEmailAddress('shouldnotupdate@email.com');
// ...
});
不仅仅是那样,而且表现层所需的数据与领域管理的数据不相同。我们并不想围绕表现层演变和耦合领域层。相反,我们想自由进化。
为达到此目的,我们需要一种弹性的方式来解耦这两层。
来自聚合实例的 DTO
我们可以用表现层所需的信息返回干净的(sterile)数据。正如我们之前所见,DTO 非常适合这种场景。我们仅仅需要在应用服务中组合它们并将其返回给客户端:
class UserDTO
{
private $email ;
// ...
public function __construct(User $user)
{
$this->email = $user->email ();
// ...
}
public function email ()
{
return $this->email ;
}
}
UserDTO 将在表现层上从 User 实体暴露我们任何所需的数据,从而避免暴露行为:
class SignUpUserService
{
public function execute(SignUpUserRequest $request)
{
// ...
$user = // ...
return new UserDTO($user);
}
}
使命达成!现在我们可以把参数传给模板引擎并把它们转换进 挂件(widgets),标签(tags),或者子模板(subtemplates),或者用数据做任何我们想在表现层一侧所做的:
$app->match('/signup' , function (Request $request) use ($app) {
/**
* @var UserDTO $user
*/
$userDto=$app['sign_up_user_application_service']->execute(
new SignUpUserRequest(
$request->get('email'),
$request->get('password')
)
);
// ...
});
但是,让应用服务决定如何构建 DTO 揭示了另一个限制。由于构建 DTO 仅仅取决于应用服务,因此很难适配不同的客户端。考虑在同一用例上,Web 控制器重定向所需的数据和 REST 响应所需的数据,根本不相同。
让我们允许客户端通过传递特定的 DTO 汇编器(Assembler)来定义如何构建 DTO:
class SignUpUserService
{
private $userDtoAssembler;
public function __construct(
UserRepository $userRepository,
UserDTOAssembler $userDtoAssembler
) {
$this->userRepository = $userRepository;
$this->userDtoAssembler = $userDtoAssembler;
}
public function execute(SignUpUserRequest $request)
{
$user = // ...
return $this->userDtoAssembler->assemble($user);
}
}
现在客户端可以通过传递一个指定的 UserDTOAssembler 来自定义回复。
数据转换器(Data Transformer)
在一些情况下,为更复杂回复(像 JSON,XML,CSV,和 iCAL 契约)生成中间 DTO 可能被视为不必要的开销。我们可以将表述输出到缓冲区中,然后在交付端获取它。
转换器有助于降低由高层次领域概念到低层次客户端细节的开销。让我们看一个例子:
interface UserDataTransformer
{
public function write(User $user);
/**
* @return mixed
*/
public function read();
}
考虑为一个给定产品生成不同的数据表述的示例。通常,产品信息通过一个 web 接口(HTML)提供,但我们可能对提供其他格式感兴趣,例如 XML,JSON,或者 CSV。这可能会启动与其他服务的集成。
考虑一个类似 blog 的例子。我们可以用 HTML 暴露我们潜在的作者给外部,但一些用户对通过 RSS 消费我们的文章感兴趣。这个用例(应用服务)仍然相同。而表述却不一样。
DTO 是一个干净且简单的方案,它能够以不同表述形式传递给模板引擎,但最后数据转换这一步的逻辑可能有点复杂,因为这样的模板逻辑可能会成为一个需要维护,测试和理解的问题。
数据转换器可能在特定的例子上会更好。他们对领域概念(聚合,实体等等)来说是黑盒子,因为输入和只读表述(XML,JSON,CSV 等等)与输出一样。这些转换器也很容易测试:
class JsonUserDataTransformer implements UserDataTransformer
{
private $data;
public function write(User $user)
{
// More complex logic could be placed here
// As using JMSSerializer, native json, etc.
$this->data = json_encode($user);
}
/**
* @return string
*/
public function read()
{
return $this->data;
}
}
这很简单。想知道 XML 或者 CSV 是怎样的?让我们看看怎样通过应用服务整合数据转换器:
class SignUpUserService
{
private $userRepository;
private $userDataTransformer;
public function __construct(
UserRepository $userRepository,
UserDataTransformer $userDataTransformer
) {
$this->userRepository = $userRepository;
$this->userDataTransformer = $userDataTransformer;
}
public function execute(SignUpUserRequest $request)
{
$user = // ...
$this->userDataTransformer()->write($user);
}
/**
* @return UserDataTransformer
*/
public function userDataTransformer()
{
return $this->userDataTransformer;
}
}
这与 DTO 汇编器方法相似,但是这一次没有返回一个具体的值。数据转换器用来持有数据和与数据交互。
使用 DTO 最主要的问题是过度写入它们。大多数时候,你的领域概念和 DTO 表述会呈现相同的结构。大多数时候,你会觉得没必要花时间去做一个这样的映射。这的确令人沮丧,表述与聚合的关系不是 1:1。你可以将两个聚合以一个表述呈现。你也可以用多种方式呈现同一相聚合。你怎样做取决于你的用例。
不过,依据 Martin Fowler 所说:
当你在表现展示台的模型与基础领域模型之间存在重大不匹配时,使用 DTO 之类的方法很有用。在这种情况下,从领域模型映射特定表述的外观/网关(facade/gateway并且为表述呈现一个方便的接口是有意义的。这是值得做的,但是仅对于具有这种不匹配的场景才值得做(在这种情况下,这不是多余的工作,因为无论如何你都需要在场景下进行操作。)
我们认为从长远角度来看是值得投资的。在大中型项目中,接口表述和领域概念总是无规律的变化。你可能想将它们各自解耦以便为更新降低摩擦。使用 DTO 或数据转换器允许你自由演变模型而不必总是考虑破坏布局(layout)。
复合层上的多个应用服务
大多数时候,布局(layout)总是与单个应用服务不一样。我们的项目有相同复杂的接口。
考虑一个特定项目的首页。我们该怎样渲染如此多的用例?这有一些选项,让我们看看吧。
AJAX 内容的集成
你可以让浏览器直接通过 AJAX 或 Hijax 请求不同端点并在布局上组合数据。这可以避免在你的控制器混合多个应用服务,但它可能有性能开销,因为触发了多个请求。
ESI 内容的集成
Edge Side Includes(ESI)是一种小型的标记语言,与之前的方法相似,但是是在服务器端。它需要额外的精力来配置额外的中间件,例如 NGINX 或 Varnish,以使其正常工作。
Symfony 子请求
如果你使用 Symfony,子请求可能是一个有趣的选择。依据 Symfony Documentation:
除了发送给 HttpKernel::handle 的主请求之外,你也可以发送所谓的子请求(sub request)。子请求的外观和行为看起来与其它请求一样,但通常用来渲染页面的一小部分而不是整个页面。你大多数时候从控制器发送子请求(或者从模板内部,它由你的控制器渲染)。这会创建了另一个完整的请求 - 回复周期,在此周期,新的请求被转换为回复。内部唯一不同的是一些监听器(例如,安全)只能根据主请求执行操作。每个监听器都传递 KernelEvent 的某个子类,该子类的 MasterRequest() 可用于检查当前请求是主请求还是子请求。
这非常棒,因为在没有 AJAX 开销或者不使用复杂的 ESI 配置的情况下,你将会从调用独立的应用服务中受益。
一个控制器(controller),多个应用服务
最后一个选择可能是用同一个控制器管理多个应用服务,从而控制器的逻辑会变得有点脏,因为它要处理和合成传递给视图的回复。
测试应用服务
由于你对测试应用服务自身行为感兴趣,因此没必要将其转换为具有针对真实数据的复杂设置的集成测试。你对测试低层次细节是不感兴趣的,因此在大多数情况下,单元测试就足够了。
class SignUpUserServiceTest extends \PHPUnit_Framework_TestCase
{
/**
* @var \Lw\Domain\Model\User\UserRepository
*/
private $userRepository;
/**
* @var SignUpUserService
*/
private $signUpUserService;
public function setUp()
{
$this->userRepository = new InMemoryUserRepository();
$this->signUpUserService = new SignUpUserService(
$this->userRepository
);
}
/**
* @test
* @expectedException
* \Lw\Domain\Model\User\UserAlreadyExistsException
*/
public function alreadyExistingEmailShouldThrowAnException()
{
$this->executeSignUp();
$this->executeSignUp();
}
private function executeSignUp()
{
return $this->signUpUserService->execute(
new SignUpUserRequest(
'user@example.com',
'password'
)
);
}
/**
* @test
*/
public function afterUserSignUpItShouldBeInTheRepository()
{
$user = $this->executeSignUp();
$this->assertSame(
$user,
$this->userRepository->ofId($user->id())
);
}
}
我们为 User 仓储提供了一个内存实现。这就是所谓的 Fake:仓储的全功能实现使我们的测试成为一个单元。我们不需要去测试类的行为。那会使我们的测试缓慢而脆弱。
检查领域事件的归属也很有趣。如果创建用户触发了用户注册的事件,则确保该事件触发可能是一个好主意:
class SignUpUserServiceTest extends \PHPUnit_Framework_TestCase
{
// ...
/**
* @test
*/
public function itShouldPublishUserRegisteredEvent()
{
$subscriber = new SpySubscriber();
$id = DomainEventPublisher::instance()->subscribe($subscriber);
$user = $this->executeSignUp();
$userId = $user->id();
DomainEventPublisher::instance()->unsubscribe($id);
$this->assertUserRegisteredEventPublished(
$subscriber, $userId
);
}
private function assertUserRegisteredEventPublished(
$subscriber, $userId
) {
$this->assertInstanceOf(
'UserRegistered', $subscriber->domainEvent
);
$this->assertTrue(
$subscriber->domainEvent->userId()->equals($userId)
);
}
}
class SpySubscriber implements DomainEventSubscriber
{
public $domainEvent;
public function handle($aDomainEvent)
{
$this->domainEvent = $aDomainEvent;
}
public function isSubscribedTo($aDomainEvent)
{
return true;
}
}
事务
事务是与持久化机制相关的实现细节。领域层不需要关心底层实现细节。考虑在这一层开始,提交,或者回滚一个事务是种坏味道。这一层的细节属于基础设施层。
处理事务最好的方式是不处理它们。我们可以用一个装饰器包装我们的应用服务来自动处理事务会话。
我们已经在我们的一个仓储中为这个问题实现了一个方案,同时你可以在这里检查它:
interface TransactionalSession
{
/**
* @return mixed
*/
public function executeAtomically(callable $operation);
}
这个契约只用了一小块代码并且自动执行。取决于你的持久化机制,你会得到不同的实现。
让我们看看怎样用 Doctrine ORM 来做:
class DoctrineSession implements TransactionalSession
{
private $entityManager;
public function __construct(EntityManager $entityManager)
{
$this->entityManager = $entityManager;
}
public function executeAtomically(callable $operation)
{
return $this->entityManager->transactional($operation);
}
}
客户端是怎样使用上面的代码:
/** @var EntityManager $em */
$nonTxApplicationService = new SignUpUserService(
$em->getRepository('BoundedContext\Domain\Model\User\User')
);
$txApplicationService = new TransactionalApplicationService(
$nonTxApplicationService,
new DoctrineSession($em)
);
$response = $txApplicationService->execute(
new SignUpUserRequest(
'user@example.com',
'password'
)
);
现在我们有了事务会话的 Doctrine 实现,为我们的应用服务创建一个装饰器会很棒。通过这种方法,我们使得事务性请求对领域透明化:
class TransactionalApplicationService implements ApplicationService
{
private $session;
private $service;
public function __construct(
ApplicationService $service, TransactionalSession $session
) {
$this->session = $session;
$this->service = $service;
}
public function execute(BaseRequest $request)
{
$operation = function () use ($request) {
return $this->service->execute($request);
};
return $this->session->executeAtomically($operation);
}
}
使用 Doctrine Session 的一个很好的副作用是,它会自动管理 flush 方法,因此你无需在领域或基础设施中添加 flush。
安全
如果你想知道一般如何管理和处理用户凭据和安全性,除非这是你领域的责任,否则我们建议让框架来处理它。用户会话是交付机制的关注点。用这样的概念污染领域将使开发变得更加困难。
领域事件
领域事件监听器不得不在应用服务执行之前配置好,否则没有人会被通知到。在某些情况下,必须先明确并配置监听器,然后才能执行应用服务。
// ...
$subscriber = new SpySubscriber();
DomainEventPublisher::instance()->subscribe($subscriber);
$applicationService = // ...
$applicationService->execute(...);
大多数时候,这可以通过配置依赖注入容器做到。
命令助手(Command Handlers)
执行应用服务的一个有趣的方式是通过一个命令总线(Command Bus)库。一个好的选择是 Tactician。来自 Tactician 官网上的介绍:
什么是命令总线?这个术语大多数用于当我们用服务层组合命令模式时。它的职责是取出一个命令对象(描述用户想做什么)并且匹配一个 Handler(用来执行)。这可以使你的代码结构整齐。
我们的应用服务就是服务层,并且我们的请求对象看起来非常像命令。
如果我们有一个链接到所有应用服务的机制,并且基于请求执行正确的请求,那不是很好吗?好吧,这实际上就是命令总线。
Tiactician 库和其他选择
Tactician 是一个命令总线库,它允许你在应用服务中使用命令模式。它对于应用服务尤其方便,但是你可以使用任何输入形式。
让我们看看 Tiactician 官网的例子:
// You build a simple message object like this:
class PurchaseProductCommand
{
protected $productId;
protected $userId;
// ...and constructor to assign those properties...
}
// And a Handler class that expects it:
class PurchaseProductHandler
{
public function handle(PurchaseProductCommand $command)
{
// use command to update your models, etc
}
}
// And then in your Controllers, you can fill in the command using your favorite
// form or serializer library, then drop it in the CommandBus and you're done!
$command = new PurchaseProductCommand(42, 29);
$commandBus->handle($command);
这就是了,Tactician 是 $commandBus 服务。它搭建了所有查找正确的 handler 和 方法的管道,这可以避免许多样板代码,这里命令和 Handlers 仅仅是正常的类,但是你可以配置最适合你应用的一个。
总而言之,我们可以总结,命令就是请求对象,并且命令 Handlers 就是应用服务。
Tactician 一个非常酷的地方就是它们非常容易扩展。Tactician 为公用任务提供插件,像日志和数据库事务。这样,你可以忘掉在每个 handler 上做的连接。
Tactician 另一个有意思的插件言归正传 Bernard 集成。Bernard 是一个异步工作队列,它允许你将一些任务放到之后的进程。大量的进程会阻碍回复。大多数时候,我们可以分流以及在之后延迟它们的执行。最佳实践是,一旦分支进程完成,就立刻回复消费者让他们知道。
Matthias Noback 开发了另一个相似的项目,叫做 SimpleBus,它可以作为 Tactician 的替代方案。主要区别是 SimpleBus Command Handlers 没有返回值。
小结
应用服务呈现你限界上下文中的应用服务。高层次的用例应该简单且薄,因为它们的目的是围绕领域协调演变。应用服务是领域逻辑交互的入口。我们看到请求和命令保持事物有条不紊。DTO 和 数据转换器允许我们从领域概念中解耦数据表述。用依赖注入容器构建应用服务非常直观。并且在复杂布局中组合应用服务,我们有大量的方法。