领域中的服务表示一个无状态的操作,它用于实现特定于某个领域的任务。 当某个操作不适合放在聚合和值对象上时,最好的方式便是使用领域服务。有时我们倾向于使用聚合根上的静态方法来实现这些这些操作,但是在 DDD中,这是一种坏味道
早期项目成员们在Product中维护了一个Backlogitem实例的集合。这种建模方式使得他们可以计算一个Produc的总业务优先级:
当时这种设计方式非常完美,businessPriorityTotals只需遍历所有 Backlogitem实例,计算出总业务优先级。并且,这种方式适当地使用了值对象 BusinessPriorityTotals。 但我不这么认为,通过对聚合的分析我们知道,这里的Product对象过于庞大,而Backlogitem本身就应该成为一个聚合。因此,上面的实例方法businessPriorityTotals已不再适用于这种场景。
由于Product不再包含Backlogitem集合,团队成员们的第一反应便是使用一个资源库 BacklogltemRepository来获取所需的Backlogitem实例,这是一种好的做法吗? 事实上,团队中的高级开发者并不建议这么做。一个基本原则:应尽量避免在聚合中使用资源库。那么,将businessPriorityTotals()方法声明为静态方法,然后将 Backlogitem集合作为参数传入,如何? 这样,几乎不用对该方法做多少修改,只需传入新参数:
那Product是创建该静态方法的最佳位置吗? 看来要将该方法放在合适的地方并非易事。由于该方法只使用了每个Backlogitem中的值对象,将该方法放在Backlogitem似乎更合适。但这里计算所得的业务价值却属于Product而非Backlogitem,进退维谷!
团队中的高级开发者发话了。他指出:这些问题用一个单一的建模工具即可解决,即领域服务(Domain Service)。
那领域服务是如何工作的?
听到“服务”,自然想到一个远程客户端与某复杂业务系统交互,该场景基本描述了SOA中 的一个服务。有多种技术和方法可以实现SOA服务,最终这些服务强调的都是系统层面的远程过程调用(RPC)或面向消息的中间件(MoM)。这些技术使得我们可通过服务与分布在不同地方的系统进行业务交互。
以上这些都不是领域服务。
请不要将领域服务与应用服务混淆。 应用服务并不会处理业务逻辑,但领域服务恰恰是处理业务逻辑。应用服务是领域模型很自然的客户,也是领域服务的客户。 虽然领域服务中有“服务”这个词,但它并不意味着需要远程的、重量级的事务操作。
当领域中的某个操作过程或转换过程不是实体或值对象的职责时,此时我们便应该将该操作放在一个单独的接口,即领域服务。请确保该领域服务和通用语言是一致的;并且保证它是无状态的。
通常领域模型主要关注特定于某个领域的业务。同样,领域服务也具有相似特点。由于领域服务有可能在单个原子操作中处理多个领域对象,这将增加领域服务的复杂性。
有时,当与另一个限界上下文交互时,领域服务的确需要进行远程操作,但此时关注的并不是将领域服务作为一个服务提供方,而是将其作为RPC的客户端。
什么情况下,一个操作不属于实体或值对象?如下几点,你可使用领域服务:
当一个方法不便放在实体或值对象,使用领域服务便是最佳的解决方案。不过也请确保领域服务是无状态的,并且能明确表达限界上下文中的通用语言。
请不要过于倾向于将一个领域概念建模成领域服务,而是只有在有必要时才这么做。一不小心,我们就有可能陷入将领域服务作为“银弹”的陷阱。 过度使用领域服务将导致贫血领域模型,即所有业务逻辑都位于领域服务中,而非实体和值对象。
那应该在什么情况下使用领域服务,来看案例:
看一个需要建立领域服务的例子。
考虑身份与访问上下文,我们需要对一个User进行认证。
为什么领域服务在此时是必要的呢?难道不可以简单地将该认证操作放在实体上? 从客户角度来看,我们可能会使用以下代码实现认证:
// client finds User and asks it to authenticate itself
boolean authentic = false;
User user = DomainRegistry
.userRepository()
.userWithUsername(aTenantld, aUsername);
if(user != null) {
authentic = user.isAuthentic(aPassword);
}
return authentic;
以上设计至少存在如下问题
这种建模方式并不能准确表达出团队成员所指的“对User进行认证”的过程。它缺少了 “检查Tenant否处于激活状态”这个前提条件。如果一个User所属的Tenant处于非激活状态,我们便不应该对该User进行认证。或许我们可以通过以下方法予以解决:
这种方式的确对Tenant的活跃性做了检查,同时我们也将User#isAuthentic换成Tenant#authenticate 但这种方式也有问题:
对于以上解决方案,我们似乎给模型带来了太多的问题。对于最后一种方案,我们必须从以下解决办法中选择一种:
以上这些方法都无济于事,同时客户端依然非常复杂。强加在客户端上的职责应该在我们自己的模型中予以处理。只与领域相关的信息决不能泄漏到客户端。即使客户端是一个应用服务,它也不应该负责对身份与访问权限的管理。
回想一下,客户端需要处理的唯一业务职责是:调用单个业务操作,而由该业务操作去处理所有业务细节:
简单而优雅。客户端只需获取到一个无状态的 Authenticationservice,然后调用authenticate。这种方式将所有认证细节放在领域服务,而非应用服务。在需要的情况下,领域服务可使用任何领域对象完成操作,包括对密码的加密。 客户端无需知道任何认证细节。 通用语言也得到满足,因为我们将所有领域术语都放在了身份管理这个领域,而非一部分放在领域模型,另一部分放在客户端。
领域服务方法返回一个UserDescirptor值对象,这是一个很小的对象,并且是安全的。与User相比,它只包含3个关键属性:
该UserDescriptor对象可存放在一次Web会话(Session)中。对于作为客户端的应用服务,它可进一步将该UserDescriptor返回给它自己的调用者。
根据创建领域服务的目的,有时对领域服务进行建模是非常简单的。你需要决定你所创建的领域服务是否需要一个独立接口。如果是,你的领域服务接口可能与以下接口相似:
package com.saasovation.identityaccess.domain.model.identity;
该接口和那些与身份相关的聚合(比如Tenant, User和Group)定义在相同的模块中,因为Authenticationservice也是一个与身份相关的概念。当前,我们将所有与身份相关的概念都放在identity模块中。该接口定义本身是简单的,只有一个 authenticate方法。 对于该接口的实现类,我们可以选择性地将其存放在不同地方。如果你正使用依赖倒置原则或六边形架构,那你可能会将这个多少有些技术性的实现类放置在领域模型外。比如,技术实现类可放置在基础设施层的某个模块中。
以下是对该接口的实现:
在对一个User进行认证时:
如果用户提交的aTenantld, username和password都正确,我们将获得相应的User实例。但此时我们依然不能对该User进行认证,我们还需要处理最后一条需求:
即便我们通过资源库找到了一个User,该User也有可能处于未激活。通过向User添加激活功能,Tenants可从另一层面控制对User的认证。因此,认证过程的最后一步即是检查所获取到的User实例是否为null和是否处激活状态。
对于领域服务来说,以上的例子同样是可行的。我们甚至会认为这样的例子更合适,因为我们知道不会再有另外的实现类。 但不同的租户可能有不同的安全认证标准,所以产生不同的认证实现类也是有可能的。 然而此时,SaaSOvation的团队成员决定弃用独立接口,而是采用了上例中的实现方法。
常见的命名实现类的方法便是给接口名加上Impl后缀。按这种方法,我们的认证实现类为AuthenticatioinServicelmpl。实现类和接口通常被放在相同包下,这是一种好的做法吗? 如果你釆用这种方式来命名实现类,这往往意味着你根本就不需要一个独立接口。因此,在命名一个实现类时,我们需要仔细地思考。这里的 AuthenticationServicelmpI并不是好的实现类名,而DefaultEncryptionAuthenticationService也不见得能好到哪。
基于这些原因,SaaSOvation团队成员决定去除独立接口,而直接使用Authenticationservice作为实现类。
如果领域服务具有多个实现类,应根据各种实现类的特点进行命名。而这往往又意味着在你的领域中存在一些特定的行为功能。
有人认为采用相似的名字来命名接口和实现类有助代码浏览和定位。但还有人认为将接口和实现类放在相同包中会使包变很大,这是一种糟糕的模块设计,因此他们偏向于将接口和实现类放在不同包,我们在依赖倒置原则便是这么做。 比如,可以将接口Encryptionservice放在领域模型,而将 MD5EncryptionService放在基础设施层。
对于非技术性的领域服务,去除独立接口不会破坏可测试性。因为这些领域服务所依赖的所有接口都可以注入进来或通过服务工厂(Service Factory)进行创建。 非技术性的领域服务,比如计算性的服务等都必须进行正确性测试。 这是一个具有争议性的话题,很大一部分人依然釆用 Impl后缀的方式来命名实现类。即便如此,DDD仍然有强烈的理由不这么做。当然,选择权在你自己手上。
有时,领域服务总是和领域密切相关,并且不会有技术性的实现,或者不会有多个实现,此时采用独立接口便只是一个风格上的问题。 独立接口对于解偶来说是有用处的,此时客户端只需要依赖于接口,而不需要知道具体的实现。但是,如果我们使用了依赖注入或者工厂,即便接口和实现类是合并在一起的,我们依然能达到这样的目的。 换句话说,以下的DomainRegistry可以在客户端和服务实现之间进行解耦,此时的DomainRegistry便是一个服务工厂。
//DomainRegistry在客户端与具体实现之间解耦
UserDescriptor userDescriptor =
DomainRegistry
.authenticationservice()
.authenticate(aTenantld, aUsername, aPassword);
或者,如果你使用的是依赖注入,你也可以得到同样的好处:
public class SomeApplicationService ... {
@Autowired
private Authenticationservice authenticationservice;
依赖倒置容器(比如Spring)将完成服务实例的注入工作。由于客户端并不负责服务的实例化,它并不知道接口类和实现类是分开的还是合并。 与服务工厂和依赖注入相比,有时他们更倾向于将领域服务作为构造函数参数或者方法参数传入,因为这样的代码拥有很好的可测试性,甚至比依赖注入更加简单。也有人根据实际情况同时采用以上三种方式,并且优先采用基于构造函数的注入方式。本章中有些例子使用了DomainRegistry,但这并不是说我们应该优 先考虑这种方式。互联网上很多源代码例子都倾向于使用构造函数注入,或者直接将领域服务作为方法参数传入。
该例子来自于敏捷项目管理上下文。该例子中的领域服务从多个聚合的值对象中计算所需结果。就目前来看,我们没有必要使用独立接口。该领域服务总是采用相同方式进行计算。除非有需求变化,不然我们没有必要将接口和实现分离开来。