《实现领域驱动设计》- 领域服务
领域中的服务表示一个无状态的操作,它用于实现特定与某个领域的任务。当某个操作不适合放在聚合和值对象上时,最好的方式便是使用领域服务了。有时我们倾向于使用聚合根上的静态方法来实现这些操作,但是在DDD中,这是一种坏味道。
我们应该尽量避免在聚合中使用资源库。
什么是领域服务(首先,什么不是领域服务)
当我们在软件开发领域中听到“服务”这个词时,自然地我们可能会想到一个远程客户端与某个复杂的业务系统交互的场景,该场景基本上描述了SOA中的一个服务。有多种技术和方法可以实现SOA服务,最终这些服务强调的都是系统层面的远程调用(RPC)或者面向消息的中间件(MoM)。这些技术使得我们可以通过服务与分布在不同地方的系统进行业务交互。
以上这些都不是领域服务。
另外,请不要将领域服务与应用服务混杂在一起了。在应用服务中,我们并不会处理业务逻辑,但是领域服务却恰恰是处理业务逻辑的。
虽然领域服务中有“服务”这个词,但它并不意味着需要远程的、重量级的事务操作。但有时,当与另一个限界上下文交互时,领域服务的确需要进行远程操作,但此时我们关注的并不是将领域服务作为一个服务提供方,而是将其作为RPC的客户端。
领域模型中的服务的确是一种非常好的建模工具。现在我们已经知道领域服务不是什么了,那么,它到底又是什么呢?
有时,它不见得是一件东西......当领域中的某个操作过程或转换过程不是实体或值对象的职责时,我们便应该将该操作放在一个单独的接口中,即领域服务。请确保该领域服务和通用语言是一致的;并且保证它是无状态的。[Evans]
通常来说,领域模型主要关注于特定于某个领域的业务,同样,领域服务也具有相似的特点。由于领域服务有可能在单个原子操作中处理多个领域对象,这将增加领域服务的复杂性。
那么,在什么情况下,一个操作不属于实体或值对象呢?要给出一个全面的原因列表是困难的,这里我罗列了以下几点。你可以使用领域服务来:
- 执行一个显著的业务操作过程。
- 对领域对象进行转换。
- 以多个领域对象作为输入进行计算,结果产生一个值对象。
需要明确的是,对于最后一点中的计算过程,它应该具有“显著的业务操作过程”的特点。这也是领域服务很常见的应用场景,它可能需要多个聚合作为输入。当一个方法不方便放在实体或值对象上时,使用领域服务便是最佳的解决方法。请确保领域服务是无状态的,并且能够明确地表达限界上下文中的通用语言。
请确定你是否需要一个领域服务
请不要过于倾向于将一个领域概念建模成领域服务,而是只有在有必要的时候才这么做。一不小心,我们就有可能陷入将领域服务作为“银弹”的陷阱。过度地使用领域服务将导致贫血领域模型,即所有的业务逻辑都位于领域服务中,而不是实体和值对象中。
下面的例子为我们展示了仔细思考的重要性。让我们来看一个需要建立领域服务的例子。考虑“身份与访问上下文”,我们需要对一个User进行认证。
“系统必须对User进行认证,并且只有当Tenant处于激活状态时才能对User进行认证。”
我们来看看为什么领域服务在此时是必要的。我们可以简单地将认证操作放在实体上吗?从客户的角度来看,我们可能会使用以下代码来实现认证过程:
// client finds User and asks it to authenticate itself boolean authentic = false; User user = DomainRegistry .userRepository() .userWithUsername (aTenantId, aUsername); if(user!=null){ authentic = user.isAuthentic(aPassword); } return authentic;
对于以上设计,我认为至少存在两个问题。首先,客户端需要知道某些认证细节,他们需要知道一个User,然后再对该User进行密码匹配。这种方法也不能显式地表达通用语言。这里,我们询问的是一个User“是否被认证了”,而没有表达出“认证”这个过程。在有可能的情况下,我们应该尽量使建模术语直接地表达出团队成员的交流语言。但是,还有更糟糕的。
这种建模方式并不能准确地表达出“对User进行认证”的过程。它缺少了“检查Tenant是否处于激活状态”这个前提条件。如果一个User所属的Tenant处于非激活状态,我们便不应该对User进行认证。或许我们可以通过以下方法予以解决:
这种方式的确对Tenant的活跃性做了检查,同时我们也将User的isAuthentic()方法换成了Tenant的authenticate()方法。
然而,这种方式也是有问题的。这种方式给客户端带来了额外的负担,此时客户端需要知道更多的认证细节,而这些是他们不应该知道的。当然,我们可以将Tenant的isActive() 方法放在authenticate(0中,但这并不是一个显式的模型。同时,这将带来另外一个问题,即此时的Tenant需要知道如何对密码进行操作。
对于以上解决方案,我们似乎给模型带来了太多问题。对于最后一种解决方案,我们必须从以下四种解决办法中选择一种:
- 在Tenant中处理对密码的加密,然后将加密后的密码传给User。这种方法违背了单一职责原则。
- 由于一个User必须保证对密码的加密,它可能已经知道了一些加密信息。如果是这样,我们可以在User上创建一个方法,该方法对明文密码进行认证。但是,在这种方式下,认证过程变成了Tenant上的门面(Facade),而实际的认证功能全在User上。另外,User上的认证方法必须声明为protected,以防止外界客户端对认证方法的直接调用。
- Tenant依赖于User对密码进行加密,然后将加密后的密码与原密码进行匹配。这种方法似乎在对象协作之间增加了额外的步骤。此时,Tenant依然要知道认证细节。
- 让客户端对密码进行加密,然后将其传给Tenant。这样导致的问题在于,客户端承载了它本不应该有的职责。
以上这些方法都无济于事,同时客户端依然非常复杂。强加在客户端上的职责应该在我们自己的模型中予以处理。只与领域相关的信息决不能泄漏到客户端。即使客户端是一个应用服务,它也不应该负责对身份与访问权限的管理。
客户端需要处理的唯一业务职责是:调用单个业务操作,而由该业务操作去处理所有的业务细节:
//应用服务只用于协调任务 UserDescriptor userDescriptor = DomainRegistry .authenticationService() .authenticate(aTenantId, aUsername, aPaassword)
以上方式是简单的,也是优雅的。客户端只需要获取到一个无状态的 AuthenticationService,然后调用它的authenticate()方法即可。这种方式将所有的认证细节放在领域中,而不是应用服务。在需要的情况下,领域服务可以使用领域对象来完成操作,包括对密码的加密过程。客户端不需要知道任何认证细节。此时,通用语言也得到了满足,因为我们将所有的领域术语都放在了身份管理这个领域中,而不是一部分放在领域模型中,另一部分放在客户端中。
领域服务方法返回一个UserDescirptor值对象,这是一个很小的对象,并且是安全的。与User相比,它只包含3个关键属性:
public class UserDescriptor implements Seriaalizable{ private String emailAddress; private TenantId tenantId; private String username; public UserDescriptor( TenantId aTenantId, String aUsername, String anEmailAddress) { ...... } ...... }
该UserDescirptor对象可以存放在一次Web会话(session)中。对于作为客户端的应用来说,它可以进一步将该UserDescirptor返回给它自己的调用者。
建模领域服务
根据创建领域服务的目的,有时对领域服务建模师非常简单的。你需要决定你所创建的领域服务是否需要一个独立接口。如果是,你的领域服务接口可能与以下接口类似:
public interface AuthenticationService { public UserDescriptor authenticate( TenantId aTenantId, String aUsername, String aPassword); }
该接口和那些与身份相关的聚合定义在相同的模块中,因为AuthenticationService也是一个与身份相关的概念。
对于该接口的实现类,我们可以选择性地将其存放在不同的地方。如果你正使用依赖倒置原则或六边形架构,那么你可能会将这个多少有些技术性的实现类放置在领域模型之外。比如,技术实现类可以放在基础设施层的某个模块中。
以下是对该接口的实现:
package com.saasovation.identityaccess.infrastrructure.services; import com.saasovation.identityaccess.domain.model.DomainRegistry import com.saasovation.identityaccess.domain.model.identity. Authentication Service; import com.saasovation.identityaccess.domain.moddel.identity.Tenant import com.saasovation.identityaccess.domain.moddel.identity.TenantId import com.saasovation.identityaccess.domain.mod1e1 identity.User; import com.saasovation.identityaccess.domain.model. identity.UserDescriptor; public class DefaultEncryptionAuthenticatioonService implements AuthenticationService{ @Override public UserDescriptor authenticate( TenantId aTenantId, String aUsername, String aPassword) { if(aTenantId== null){ throw new IllegalArgumentException( "TenantIdmust 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参数进行检查。如果在正常情况下认证失败,那么该方法返回的UserDescirptor将为null。
在对一个User进行认证时,我们首先根据aTenantId从Tenant的资源库中取出对应的Tenant。如果Tenant存在并且处于激活状态下,下一步我们将对传入的明文密码进行加密。加密的目的在于,我们需要通过加密后的密码来获取一个User。在获取一个User时,我们不但需要传入aTenantId和userName,还需要传入加密后的密码进行匹配。User的资源库将根据这三个参数来定位一个User。
如果用户提交的aTenantId、username和password都是正确的的,我们将获得相应的User实例。但是,此时我们依然不能对该User进行认证,我们需要检查该User实例是否被激活。
独立接口有必要吗?
由于这里的AuthenticationService并没有一个技术上的实现,我们真的有必要为其创建一个独立接口并将其与实现类分离在不同的层和模块中吗?这是没有必要的。我们只需要创建一个实现类即可,其名字与领域服务的相同。
对于领域服务来说,以上的例子同样是可行的。我们甚至会认为这样的例子更加合适,因为我们知道不会再有另外的实现类。但是,不同的租户可能有不同的安全认证标准,所以产生不同的认证实现类也是有可能的。
有时,领域服务总是和领域密切相关,并且不会有技术性的实现,或者不会有多个实现,此时采用独立接口便只是一个风格上的问题。Fowler在[Fowler, P of EAA]中说,独立接口对于解耦来说是有用处的,此时客户端只需要依赖于接口,而不需要知道具体的实现。但是,如果我们使用了依赖注入或者工厂,即便接口和实现类是合并在一起的,我们依然能达到这样的目的。换句话说,以下的DomainRegistry可以在客户端和服务实现之间进行解耦,此时的DomainRegistry便是一个服务工厂。
//DomainRegistry在客户端与具体实现之间解耦 UserDescriptor userDescriptor = DomainRegistry .authenticationService() .authenticate (aTenantId, aUsername, aPassword);
或者,如果你使用的是依赖注入,也可以得到同样的好处:
public class SomeApplicationService...{ @Autowired private AuthenticationService authentication Service; ... }
依赖倒置容器将完成服务实例的注入工作。由于客户端并不负责服务的实例化,它并不知道接口类和实现类是分开的还是合并在一起的。
与服务工厂和依赖注入相比,有时他们更倾向于将领域服务作为构造函数参数或方法参数传入,因为这样的代码拥有很好的可测试性,甚至比依赖注入更加简单。
一个计算过程
让我们来看一个计算过程的例子,该例子中的领域服务从多个聚合的值对象中计算所需结果。就目前来看,我们没有必要使用独立接口。该领域服务总是采用相同的方式进行计算。除非有需求变化,不然我们没有必要将接口和实现分离开来。
采用领域服务比静态方法更好。此时的领域服务和当前的静态方法完成相似的功能,即计算并返回一个BusinessPriorityTotals值对象。但是,该领域服务还需要完成额外的工作,包括找到一个Product中所有未完成的Backloglttem然后单独计算它们的BusinessPriority。以下是实现代码:
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;
Collection<BacklogItem> outstandingBadcklogItems = DomainRegistry .backlogItemRepository() .all0utstandingProductBacklogItems (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;
]
BacklogItemRepository用于查找所有未完成的BacklogItem实例。一个未完成的BacklogItem是拥有Planned、Scheduled或者Committed状态的BacklogItem,而状态为Done或Removed的BacklogItem则是已经完成的。我们并不推荐将资源库对BacklogItem的获取放在聚合实例中,相反,将其放在领域服务中则是一种好的做法。
有了一个Product下所有未完成的BacklogItem,我们便可以对它们进行遍历,并计算出BusinessPriority的总和。计算所得的总和进一步用于实例化一个BusinessPriorityTotals,然后返回客户端。领域服务不一定非常复杂,即使有时的确会出现这种情况。
转换服务
在基础设施层中,更加技术性的领域服务通常是那些用于集成目的的服务。正是这个原因,我们将与此相关的例子放在集成限界上下文中,其中你将看到领域服务接口、实现类、适配器和不同的转换器。