建模领域服务
根据创建领域服务的目的,有时对领域服务进行建模是非常简单的。你需要决定你所创建的领域服务是否需要一个独立接口[Fowler,P of EAA]。如果是,你的领域服务接口可能与以下接口相似:
package com.saasovation.identityaccess.domain.model.identity;
public interface AuthenticationService {
public UserDescriptor authenticate(
TenantId aTenantId,
String aUsername,
String aPassword);
}
该接口和那些与身份相关的聚合(比如Tenant, User和Group)定义在相同的模块(9)中,因为AuthenticationService也是一个与身份相关的概念。当前,我们将所有与身份相关的概念都放在identity模块中。该接口定义本身是简单的,只有一个authenticate()方法。
对于该接口的实现类,我们可以选择性地将其存放在不同的地方。如果你正使用依赖倒置原则(4)或六边形(4)架构,那么你可能会将这个多少有些技术性的实现类放置在领域模型之外。比如,技术实现类可以放置在基础设施层的某个模块中。
以下是对该接口的实现:
package com.saasovation.identityaccess.infrastructure.services;
import com.saasovation.identityaccess.domain.model.DomainRegistry;
import com.saasovation.identityaccess.domain.model.identity.↵
AuthenticationService;
import com.saasovation.identityaccess.domain.model.identity.Tenant;
import com.saasovation.identityaccess.domain.model.identity.TenantId;
import com.saasovation.identityaccess.domain.model.identity.User;
import com.saasovation.identityaccess.domain.model.identity.UserDescriptor;
public class DefaultEncryptionAuthenticationService
implements AuthenticationService {
public DefaultEncryptionAuthenticationService() {
super();
}
@Override
public UserDescriptor authenticate(
TenantId aTenantId,
String aUsername,
String aPassword) {
if (aTenantId == null) {
throw new IllegalArgumentException(
"TenantId must not be null.");
}
if (aUsername == null) {
throw new IllegalArgumentException(
"Username must not be null.");
}
if (aPassword == null) {
throw new IllegalArgumentException(
"Password must not be null.");
}
UserDescriptor userDescriptor = null;
Tenant tenant =
DomainRegistry
.tenantRepository()
.tenantOfId(aTenantId);
if (tenant != null && tenant.isActive()) {
String encryptedPassword =
DomainRegistry
.encryptionService()
.encryptedValue(aPassword);
User user =
DomainRegistry
.userRepository()
.userFromAuthenticCredentials(
aTenantId,
aUsername,
encryptedPassword);
if (user != null && user.isEnabled()) {
userDescriptor = user.userDescriptor();
}
}
return userDescriptor;
}
}
该方法首先对null参数进行检查。如果在正常情况下认证失败,那么该方法返回的UserDescriptor将为 null。
在对一个User进行认证时,我们首先根据aTenantld从Tenant的资源库中取出对应的Tenant。如果Tenant存在并且处于激活状态,下一步我们将对传入的明文密码进行加密。加密的目的在于,我们需要通过加密后的密码来获取一个User。在获取一个User时,我们不但需要传入aTenantld和username,还需要传入加密后的密码进行匹配(对于两个明文相同的密码,加密后也是相同的)。User的资源库将根据这三个参数来定位一个User。
如果用户提交的aTenantld, username和password都是正确的,我们将获得相应的User实例。但是,此时我们依然不能对该User进行认证,我们还需要处理最后一条需求:
- 只有在一个User被激活后,我们才能对该User进行认证。
即便我们通过资源库找到了一个User,该User也有可能处于未激活状态。通过向User添加激活功能,Tenant可以从另一个层面来控制对User的认证。因此,认证过程的最后一步即是检查所获取到的User实例是否为null和是否处于激活状态。
独立接口有必要吗
由于这里的AuthenticationService并没有一个技术上的实现,我们真的有必要为其创建一个独立接口并将其与实现类分离在不同的层和模块中吗?这是没有必要的。我们只需要创建一个实现类即可,其名字与领域服务的名字相同。
package com.saasovation.identityaccess.domain.model.identity;
public class AuthenticationService {
public AuthenticationService() {
super();
}
public UserDescriptor authenticate(
TenantId aTenantId,
String aUsername,
String aPassword) {
...
}
}
对于领域服务来说,以上的例子同样是可行的。我们甚至会认为这样的例子更加合适,因为我们知道不会再有另外的实现类。但是,不同的租户可能有不同的安全认证标准,所以产生不同的认证实现类也是有可能的。然而此时,SaaSOvation的团队成员决定弃用独立接口,而是采用了上例中的实现方法。
给领域服务的实现类命名
在Java世界中.常见的命名实现类的方法便是给接口名加上Impl后缀。按照这种方法.我们的认证实现类为AuthenticmioinServicelmpI。此外.实现类和接口通常被放在相同的包下。这是一种好的做法吗?
事实上,如果你采用这种方式来命名实现类.这往往意味着你根本就不需要一个独立接口。因此.在命名一个实现类时.我们需要仔细地思考。这里的AuthenticationServicelmpI并不是一个好的实现类名.而 DefaultEncryptionAuthentica—tionService也不见得能好到哪里去。基于这些原因.SaaSOvation的团队成员决定去除独立接口.而直接使用AuthenticationService作为实现类。
如果领域服务具有多个实现类.那么我们应该根据各种实现类的特点进行命名,而这往往又意味着在你的领域中存在一些特定的行为功能。
有人认为采用相似的名字来命名接口和实现类有助于代码浏览和定位。但是. 还有人则认为将接口和实现类放在相同的包中会使包变得很大,这是_种糟糕的模块设计,因此他们偏向于将接口和实现类放在不同的包中.我们在依赖倒置原则⑷中便是这么做的。比如.可以将接口 EncryptionService放在领域模型中.而将MD5EncryptionService放在基础设施层中。
对于非技术性的领域服务来说.去除独立接口是不会破坏可测试性的.因为这些领域服务所依赖的所有接口都可以注入进来.或者通过服务工厂(ServiceFactory)进行创建。请记住.非技术性的领域服务,比如计算性的服务等.都必须进行正确性测试。
可以理解.这是一个具有争议性的话题.我也知道有很大一部分人依然采用 Impl后缀的方式来命名实现类。即便如此,我们仍然有强烈的理由不这么做。当然.选择权在你自己手上。
有时,领域服务总是和领域密切相关,并且不会有技术的实现,或者不会有多个实现,此时采用独立接口便只是一个风格上的问题。Fowlei* 在[Fowler,P of EAA]中说,独立接口对于解偶来说是有用处的,此时客户端只需要依赖于接口,而不需要知道具体的实现。但是,如果我们使用了依赖注入或者工厂[Gammaet al.],即便接口和实现类是合并在一起的,我们依然能达到这样的目的。换句
话说,以下的DomainRegistry可以在客户端和服务实现之间进行解耦,此时的 DomainRegistry便是一个服务工厂
//DomainRegistry在客户端与具体实现之间解耦
// the registry decouples client from implementation knowledge
UserDescriptor userDescriptor =
DomainRegistry
.authenticationService()
.authenticate(aTenantId, aUsername, aPassword);
或者,如果你使用的是依赖注人,你也可以得到同样的好处:
public class SomeApplicationService ... {
@Autowired
private AuthenticationService authenticationService;
...
}
依赖倒置容器(比如Spring)将完成服务实例的注入工作。由于客户端并不负责服务的实例化,它并不知道接口类和实现类是分开的还是合并在一起的。
与服务工厂和依赖注入相比,有时他们更倾向于将领域服务作为构造函数参数或者方法参数传人2,因为这样的代码拥有很好的可测试性,甚至比依赖注入更加简单。也有人根据实际情况同时采用以上三种方式,并且优先采用基于构造函数的注入方式。本章中有些例子使用了DomainRegistry,但这并不是说我们应该优先考虑这种方式。互联网上很多源代码例子都倾向于使用构造函数注入,或者直接将领域服务作为方法参数传人。
2. 译注:更多的时候,将对象作为构造函数参数传人也被看成是一种依赖注入。 ↩
一个计算过程
让我们来看一个计算过程的例子,该例子来自于敏捷项目管理上下文。该例子中的领域服务从多个聚合的值对象中计算所需结果。就目前来看,我们没有必要使用独立接口。该领域服务总是采用相同的方式进行计算。除非有需求变化,不然我们没有必要将接口和实现分离开来。
回忆一下,SaaSOvation的开发者们曾经在Product上创建了静态方法来完成计算过程,以下是接下来发生的事...
团队中的高级开发者同时指出.采用领域服务比静态方法更好。此时的领域服务和当前的静态方法完成相似的功能.即计算并返回一个BusinessPriorityTotals值对象。但是,该领域服务还需要完成额外的工作,包括找到一个Product中所有未完成的Backlogltem,然后单独计算它们的BusinessPriority。以下是实现代码:
package com.saasovation.agilepm.domain.model.product;
import com.saasovation.agilepm.domain.model.DomainRegistry;
import com.saasovation.agilepm.domain.model.tenant.Tenant;
public class BusinessPriorityCalculator {
public BusinessPriorityCalculator() {
super();
}
public BusinessPriorityTotals businessPriorityTotals(
Tenant aTenant,
ProductId aProductId) {
int totalBenefit = 0;
int totalPenalty = 0;
int totalCost = 0;
int totalRisk = 0;
java.util.Collection<BacklogItem> outstandingBacklogItems =
DomainRegistry
.backlogItemRepository()
.allOutstandingProductBacklogItems(
aTenant,
aProductId);
for (BacklogItem backlogItem : outstandingBacklogItems) {
if (backlogItem.hasBusinessPriority()) {
BusinessPriorityRatings ratings =
backlogItem.businessPriority().ratings();
totalBenefit += ratings.benefit();
totalPenalty += ratings.penalty();
totalCost += ratings.cost();
totalRisk += ratings.risk();
}
}
BusinessPriorityTotals businessPriorityTotals =
new BusinessPriorityTotals(
totalBenefit,
totalPenalty,
totalBenefit + totalPenalty,
totalCost,
totalRisk);
return businessPriorityTotals;
}
}
BacklogltemRepository用于查找所有未完成的Backlogltem实例。一个未完成的Backlogltem是拥有Planned,Scheduled或者Committed状态的Backlogltem,而状态为Done或Removed的Backlogltem则是已经完成的。我们并不推荐将资源库对Backlogltem的获取放在聚合实例中,相反,将其放在领域服务中则是一种好的做法。
有了一个Product下所有未完成的Backlogltem,我们便可以对它们进行遍历,并计算出BusinessPriority的总和。计算所得的总和进一步用于实例化一个BusinessPriorityTotals,然后返回给客户端。领域服务不一定非常复杂,即使有时的确会出现这种情况。上面的例子则是非常简单的。
请注意,在上面的例子中,我们绝对不能将业务逻辑放到应用层中。即使你认为这里的for循环非常简单,它依然是业务逻辑。当然,还有另外的原因:
BusinessPriorityTotals businessPriorityTotals =
new BusinessPriorityTotals(
totalBenefit,
totalPenalty,
totalBenefit + totalPenalty,
totalCost,
totalRisk);
在实例化 BusinessPriorityTotals 时,它的 total Value属性由 total Benefit 和 totalPenalty相加所得。这是和领域密切相关的业务逻辑,自然不能泄漏到应用层中。当然,你可能会说,可以将totalBenefit和totalPenalty作为两个参数分别传给应用服务。然而,虽然这是一种改进模型的方式,但这也并不意味着将剩下的计算逻辑放在应用层就是合理的。
虽然我们不会将业务逻辑放在应用层,但是应用层却可以作为领域服务的客户端:
public class ProductService ... {
...
private BusinessPriorityTotals productBusinessPriority(
String aTenantId,
String aProductId) {
BusinessPriorityTotals productBusinessPriority =
DomainRegistry
.businessPriorityCalculator()
.businessPriorityTotals(
new TenantId(aTenantId),
new ProductId(aProductId));
return productBusinessPriority;
}
}
在上例中,应用层中的一个私有方法负责获取一个Product的总业务优先级。该方法可能只需要向ProductService的客户端(比如用户界面)提供BusinessPriorityTotals 的部分数据即可。
转换服务
在基础设施层中,更加技术性的领域服务通常是那些用于集成目的的服务。
正是这个原因,我们将与此相关的例子放在了集成限界上下文(13)中,其中你将看到领域服务接口、实现类、适配器[Gamma etal.]和不同的转换器。
为领域服务创建一个迷你层
有时我们可能希望在实体和值对象之上创建一个领域服务的迷你层。正如我先前所说,这样做可能会导致贫血领域模型这种反模式。
但是,对于有些系统来说,为领域服务创建一个不至于导致贫血领域模型的迷你层是值得的。当然,这取决于领域模型的特征。对于本书的身份与访问上下文来说,这样的做法是非常有用的。
如果你正工作在这样的领域里,并且你决定为领域服务创建一个迷你层,请注意这样的迷你层和应用层中的服务是不同的。在应用服务中,我们关心的是事务和安全,但是这些不应该出现在领域服务中。