6

应用服务

  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实现六边形架构》

应用层是将领域模型与查询或更改其状态的客户端分离的层。应用服务是此层的构建块。正如 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 应用里等效的内容如下:

<?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_up_user_application_service"
                class="SignUpUserService">
            <argument type="service" id="user_repository" />
        </service>
        <service
                id="user_repository"
                class="RedisUserRepository">
            <argument type="service">
                <service class="Predis\Client" />
            </argument>
        </service>
    </services>
</container>

现在,你有了在 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 和 数据转换器允许我们从领域概念中解耦数据表述。用依赖注入容器构建应用服务非常直观。并且在复杂布局中组合应用服务,我们有大量的方法。


捷叔叔
1.1k 声望1.8k 粉丝