在建模時,有時會遇到一些業務邏輯的概念,它放在實體或值物件中都不太合適。這就是可能需要建立領域服務的一個訊號。
1 理解領域服務
從概念上說,領域服務代表領域概念,它們是存在於問題域中的行為,它們產生於與領域專家的對話中,並且是領域模型的一部分。
模型中的領域服務表示一個無狀態的操作,他用於實現特定於某個領域的任務。 當領域中某個操作過程或轉化過程不是實體或值物件的職責時,我們便應該將該操作放在一個單獨的元素中,即領域服務。同時務必保持該領域服務與通用語言是一致的,並且保證它是無狀態的。
領域服務有幾個重要的特徵:
- 它代表領域概念。
- 它與通用語言儲存一致,其中包括命名和內部邏輯。
- 它無狀態。
- 領域服務與聚合在同一包中。
1.1 何時使用領域服務
如果某操作不適合放在聚合和值物件上時,最好的方式便是將其建模成領域服務。
一般情況下,我們使用領域服務來組織實體、值物件並封裝業務概念。領域服務適用場景如下:
- 執行一個顯著的業務操作過程。
- 對領域物件進行轉換。
- 以多個領域物件作為輸入,進行計算,產生一個值物件。
1.2 避免貧血領域模型
當你認同並非所有的領域行為都需要封裝在實體或值物件中,並明確領域服務是有用的建模手段後,就需要當心了。不要將過多的行為放到領域服務中,這樣將導致貧血領域模型。
如果將過多的邏輯推入領域服務中,將導致不準確、難理解、貧血並且低概念的領域模型。顯然,這樣會抵消 DDD 的很多好處。
領域服務是排在值物件、實體模式之後的一個選項。有時,不得已為之是個比較好的方案。
1.3 與應用服務的對比
應用服務,並不會處理業務邏輯,它是領域模型直接客戶,進而是領域服務的客戶方。
領域服務代表了存在於問題域內部的概念,他們的介面存在於領域模型中。相反,應用服務不表示領域概念,不包含業務規則,通常,他們不存在於領域模型中。
應用服務存在於服務層,處理像事務、訂閱、儲存等基礎設施問題,以執行完整的業務用例。
應用服務從使用者用例出發,是領域的直接使用者,與領域關係密切,會有專門章節進行詳解。
1.4 與基礎設施服務的對比
基礎設施服務,從技術角度出發,為解決通用問題而進行的抽象。
比較典型的如,郵件傳送服務、簡訊傳送服務、定時服務等。
2. 實現領域服務
2.1 封裝業務概念
領域服務的執行一般會涉及實體或值物件,在其基礎之上將行為封裝成業務概念。
比較常見的就是銀行轉賬,首先銀行轉賬具有明顯的領域概念,其次,由於同時涉及兩個賬號,該行為放在賬號聚合中不太合適。因此,可以將其建模成領域服務。
public class Account extends JpaAggregate {
private Long totalAmount;
public void checkBalance(Long amount) {
if (amount > this.totalAmount){
throw new IllegalArgumentException("餘額不足");
}
}
public void reduce(Long amount) {
this.totalAmount = this.totalAmount - amount;
}
public void increase(Long amount) {
this.totalAmount = this.totalAmount + amount;
}
}
複製程式碼
Account 提供餘額檢測、扣除和新增等基本功能。
public class TransferService implements DomainService {
public void transfer(Account from, Account to, Long amount){
from.checkBalance(amount);
from.reduce(amount);
to.increase(amount);
}
}
複製程式碼
TransferService 按照業務規則,指定轉賬流程。
TransferService 明確定義了一個存在於通用語言的一個領域概念。領域服務存在於領域模型中,包含重要的業務規則。
2.2 業務計算
業務計算,主要以實體或值物件作為輸入,通過計算,返回一個實體或值物件。
常見場景如計算一個訂單應用特定優惠策略後的應付金額。
public class OrderItem {
private Long price;
private Integer count;
public Long getTotalPrice(){
return price * count;
}
}
複製程式碼
OrderItem 中包括產品單價和產品數量,getTotalPrice 通過計算獲取總價。
public class Order {
private List<OrderItem> items = Lists.newArrayList();
public Long getTotalPrice(){
return this.items.stream()
.mapToLong(orderItem -> orderItem.getTotalPrice())
.sum();
}
}
複製程式碼
Order 由多個 OrderItem 組成,getTotalPrice 遍歷所有的 OrderItem,計算訂單總價。
public class OrderAmountCalculator {
public Long calculate(Order order, PreferentialStrategy preferentialStrategy){
return preferentialStrategy.calculate(order.getTotalPrice());
}
}
複製程式碼
OrderAmountCalculator 以實體 Order 和領域服務 PreferentialStrategy 為輸入,在訂單總價基礎上計算折扣價格,返回打折之後的價格。
2.3 規則切換
根據業務流程,動態對規則進行切換。
還是以訂單的優化策略為例。
public interface PreferentialStrategy {
Long calculate(Long amount);
}
複製程式碼
PreferentialStrategy 為策略介面。
public class FullReductionPreferentialStrategy implements PreferentialStrategy{
private final Long fullAmount;
private final Long reduceAmount;
public FullReductionPreferentialStrategy(Long fullAmount, Long reduceAmount) {
this.fullAmount = fullAmount;
this.reduceAmount = reduceAmount;
}
@Override
public Long calculate(Long amount) {
if (amount > fullAmount){
return amount - reduceAmount;
}
return amount;
}
}
複製程式碼
FullReductionPreferentialStrategy 為滿減策略,當訂單總金額超過特定值時,直接進行減免。
public class FixedDiscountPreferentialStrategy implements PreferentialStrategy{
private final Double descount;
public FixedDiscountPreferentialStrategy(Double descount) {
this.descount = descount;
}
@Override
public Long calculate(Long amount) {
return Math.round(amount * descount);
}
}
複製程式碼
FixedDiscountPreferentialStrategy 為固定折扣策略,在訂單總金額基礎上進行固定折扣。
2.4 基礎設施(第三方介面)隔離
領域概念本身屬於領域模型,但具體實現依賴於基礎設施。
此時,我們需要將領域概念建模成領域服務,並將其置於模型層。將依賴於基礎設施的具體實現類,放置於基礎設施層。
比較典型的例子便是密碼加密,加密服務應該位於領域中,但具體的實現依賴基礎設施,應該放在基礎設施層。
public interface PasswordEncoder {
String encode(CharSequence rawPassword);
boolean matches(CharSequence rawPassword, String encodedPassword);
}
複製程式碼
PasswordEncoder 提供密碼加密和密碼驗證功能。
public class BCryptPasswordEncoder implements PasswordEncoder {
private Pattern BCRYPT_PATTERN = Pattern
.compile("\\A\\$2a?\\$\\d\\d\\$[./0-9A-Za-z]{53}");
private final Log logger = LogFactory.getLog(getClass());
private final int strength;
private final SecureRandom random;
public BCryptPasswordEncoder() {
this(-1);
}
public BCryptPasswordEncoder(int strength) {
this(strength, null);
}
public BCryptPasswordEncoder(int strength, SecureRandom random) {
if (strength != -1 && (strength < BCrypt.MIN_LOG_ROUNDS || strength > BCrypt.MAX_LOG_ROUNDS)) {
throw new IllegalArgumentException("Bad strength");
}
this.strength = strength;
this.random = random;
}
public String encode(CharSequence rawPassword) {
String salt;
if (strength > 0) {
if (random != null) {
salt = BCrypt.gensalt(strength, random);
}
else {
salt = BCrypt.gensalt(strength);
}
}
else {
salt = BCrypt.gensalt();
}
return BCrypt.hashpw(rawPassword.toString(), salt);
}
public boolean matches(CharSequence rawPassword, String encodedPassword) {
if (encodedPassword == null || encodedPassword.length() == 0) {
logger.warn("Empty encoded password");
return false;
}
if (!BCRYPT_PATTERN.matcher(encodedPassword).matches()) {
logger.warn("Encoded password does not look like BCrypt");
return false;
}
return BCrypt.checkpw(rawPassword.toString(), encodedPassword);
}
}
複製程式碼
BCryptPasswordEncoder 提供基於 BCrypt 的實現。
public class SCryptPasswordEncoder implements PasswordEncoder {
private final Log logger = LogFactory.getLog(getClass());
private final int cpuCost;
private final int memoryCost;
private final int parallelization;
private final int keyLength;
private final BytesKeyGenerator saltGenerator;
public SCryptPasswordEncoder() {
this(16384, 8, 1, 32, 64);
}
public SCryptPasswordEncoder(int cpuCost, int memoryCost, int parallelization, int keyLength, int saltLength) {
if (cpuCost <= 1) {
throw new IllegalArgumentException("Cpu cost parameter must be > 1.");
}
if (memoryCost == 1 && cpuCost > 65536) {
throw new IllegalArgumentException("Cpu cost parameter must be > 1 and < 65536.");
}
if (memoryCost < 1) {
throw new IllegalArgumentException("Memory cost must be >= 1.");
}
int maxParallel = Integer.MAX_VALUE / (128 * memoryCost * 8);
if (parallelization < 1 || parallelization > maxParallel) {
throw new IllegalArgumentException("Parallelisation parameter p must be >= 1 and <= " + maxParallel
+ " (based on block size r of " + memoryCost + ")");
}
if (keyLength < 1 || keyLength > Integer.MAX_VALUE) {
throw new IllegalArgumentException("Key length must be >= 1 and <= " + Integer.MAX_VALUE);
}
if (saltLength < 1 || saltLength > Integer.MAX_VALUE) {
throw new IllegalArgumentException("Salt length must be >= 1 and <= " + Integer.MAX_VALUE);
}
this.cpuCost = cpuCost;
this.memoryCost = memoryCost;
this.parallelization = parallelization;
this.keyLength = keyLength;
this.saltGenerator = KeyGenerators.secureRandom(saltLength);
}
public String encode(CharSequence rawPassword) {
return digest(rawPassword, saltGenerator.generateKey());
}
public boolean matches(CharSequence rawPassword, String encodedPassword) {
if (encodedPassword == null || encodedPassword.length() < keyLength) {
logger.warn("Empty encoded password");
return false;
}
return decodeAndCheckMatches(rawPassword, encodedPassword);
}
private boolean decodeAndCheckMatches(CharSequence rawPassword, String encodedPassword) {
String[] parts = encodedPassword.split("\\$");
if (parts.length != 4) {
return false;
}
long params = Long.parseLong(parts[1], 16);
byte[] salt = decodePart(parts[2]);
byte[] derived = decodePart(parts[3]);
int cpuCost = (int) Math.pow(2, params >> 16 & 0xffff);
int memoryCost = (int) params >> 8 & 0xff;
int parallelization = (int) params & 0xff;
byte[] generated = SCrypt.generate(Utf8.encode(rawPassword), salt, cpuCost, memoryCost, parallelization,
keyLength);
if (derived.length != generated.length) {
return false;
}
int result = 0;
for (int i = 0; i < derived.length; i++) {
result |= derived[i] ^ generated[i];
}
return result == 0;
}
private String digest(CharSequence rawPassword, byte[] salt) {
byte[] derived = SCrypt.generate(Utf8.encode(rawPassword), salt, cpuCost, memoryCost, parallelization, keyLength);
String params = Long
.toString(((int) (Math.log(cpuCost) / Math.log(2)) << 16L) | memoryCost << 8 | parallelization, 16);
StringBuilder sb = new StringBuilder((salt.length + derived.length) * 2);
sb.append("$").append(params).append('$');
sb.append(encodePart(salt)).append('$');
sb.append(encodePart(derived));
return sb.toString();
}
private byte[] decodePart(String part) {
return Base64.getDecoder().decode(Utf8.encode(part));
}
private String encodePart(byte[] part) {
return Utf8.decode(Base64.getEncoder().encode(part));
}
}
複製程式碼
SCryptPasswordEncoder 提供基於 SCrypt 的實現。
2.5 模型概念轉化
在限界上下文整合時,經常需要對上游限界上下文中的概念進行轉換,以避免概念的混淆。
例如,在使用者成功啟用後,自動為其建立名片。
在使用者啟用後,會從 User 限界上下文中發出 UserActivatedEvent 事件,Card 上下文監聽事件,並將使用者上下文內的概念轉為為名片上下文中的概念。
@Value
public class UserActivatedEvent extends AbstractDomainEvent {
private final String name;
private final Long userId;
public UserActivatedEvent(String name, Long userId) {
this.name = name;
this.userId = userId;
}
}
複製程式碼
UserActivatedEvent 是使用者上下文,在使用者啟用後向外發布的領域事件。
@Service
public class UserEventHandlers {
@EventListener
public void handle(UserActivatedEvent event){
Card card = new Card();
card.setUserId(event.getUserId());
card.setName(event.getName());
}
}
複製程式碼
UserEventHandlers 在收到 UserActivatedEvent 事件後,將來自使用者上下文中的概念轉化為自己上下文中的概念 Card。
2.6 在服務層中使用領域服務
領域服務可以在應用服務中使用,已完成特定的業務規則。
最常用的場景為,應用服務從儲存庫中獲取相關實體並將它們傳遞到領域服務中。
public class OrderApplication {
@Autowired
private OrderRepository orderRepository;
@Autowired
private OrderAmountCalculator orderAmountCalculator;
@Autowired
private Map<String, PreferentialStrategy> strategyMap;
public Long calculateOrderTotalPrice(Long orderId, String strategyName){
Order order = this.orderRepository.getById(orderId).orElseThrow(()->new AggregateNotFountException(String.valueOf(orderId)));
PreferentialStrategy strategy = this.strategyMap.get(strategyName);
Preconditions.checkArgument(strategy != null);
return this.orderAmountCalculator.calculate(order, strategy);
}
}
複製程式碼
OrderApplication 首先通過 OrderRepository 獲取 Order 資訊,然後獲取對應的 PreferentialStrategy,最後呼叫 OrderAmountCalculator 完成金額計算。
在服務層使用,領域服務和其他領域物件可以根據需求很容易的拼接在一起。
當然,我們也可以將領域服務作為業務方法的引數進行傳遞。
public class UserApplication extends AbstractApplication {
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private UserRepository userRepository;
public void updatePassword(Long userId, String password){
updaterFor(this.userRepository)
.id(userId)
.update(user -> user.updatePassword(password, this.passwordEncoder))
.call();
}
public boolean checkPassword(Long userId, String password){
return this.userRepository.getById(userId)
.orElseThrow(()-> new AggregateNotFountException(String.valueOf(userId)))
.checkPassword(password, this.passwordEncoder);
}
}
複製程式碼
UserApplication 中的 updatePassword 和 checkPassword 在流程中都需要使用領域服務 PasswordEncoder,我們可以通過引數將 UserApplication 所儲存的 PasswordEncoder 傳入到業務方法中。
2.7 在領域層中使用領域服務
由於實體和領域服務擁有不同的生命週期,在實體依賴領域服務時,會變的非常棘手。
有時,一個實體需要領域服務來執行操作,以避免在應用服務中的拼接。此時,我們需要解決的核心問題是,在實體中如何獲取服務的引用。通常情況下,有以下幾種方式。
2.7.1 手工連結
如果一個實體依賴領域服務,同時我們自己在管理物件的構建,那麼最簡單的方式便是將相關服務通過建構函式傳遞進去。
還是以 PasswordEncoder 為例。
@Data
public class User extends JpaAggregate {
private final PasswordEncoder passwordEncoder;
private String password;
public User(PasswordEncoder passwordEncoder) {
this.passwordEncoder = passwordEncoder;
}
public void updatePassword(String pwd){
setPassword(passwordEncoder.encode(pwd));
}
public boolean checkPassword(String pwd){
return passwordEncoder.matches(pwd, getPassword());
}
}
複製程式碼
如果,我們完全手工維護 User 的建立,可以在建構函式中傳入領域服務。
當然,如果實體是通過 ORM 框架獲取的,通過建構函式傳遞將變得比較棘手,我們可以為其新增一個 init 方法,來完成服務的注入。
@Data
public class User extends JpaAggregate {
private PasswordEncoder passwordEncoder;
private String password;
public void init(PasswordEncoder passwordEncoder){
this.setPasswordEncoder(passwordEncoder);
}
public User(PasswordEncoder passwordEncoder) {
this.passwordEncoder = passwordEncoder;
}
public void updatePassword(String pwd){
setPassword(passwordEncoder.encode(pwd));
}
public boolean checkPassword(String pwd){
return passwordEncoder.matches(pwd, getPassword());
}
}
複製程式碼
通過 ORM 框架獲取 User 後,呼叫 init 方法設定 PasswordEncoder。
2.7.2 依賴注入
如果在使用 Spring 等 IOC 框架,我們可以在從 ORM 框架中獲取實體後,使用依賴注入完成領域服務的注入。
@Data
public class User extends JpaAggregate {
@Autowired
private PasswordEncoder passwordEncoder;
private String password;
public void updatePassword(String pwd){
setPassword(passwordEncoder.encode(pwd));
}
public boolean checkPassword(String pwd){
return passwordEncoder.matches(pwd, getPassword());
}
}
複製程式碼
User 直接使用 @Autowired 注入領域服務。
public class UserApplication extends AbstractApplication {
@Autowired
private AutowireCapableBeanFactory beanFactory;
@Autowired
private UserRepository userRepository;
public void updatePassword(Long userId, String password){
User user = this.userRepository.getById(userId).orElseThrow(() -> new AggregateNotFountException(String.valueOf(userId)));
this.beanFactory.autowireBean(user);
user.updatePassword(password);
this.userRepository.save(user);
}
public boolean checkPassword(Long userId, String password){
User user = this.userRepository.getById(userId).orElseThrow(() -> new AggregateNotFountException(String.valueOf(userId)));
this.beanFactory.autowireBean(user);
return user.checkPassword(password);
}
}
複製程式碼
UserApplication 在獲取 User 物件後,首先呼叫 autowireBean 完成 User 物件的依賴繫結,然後在進行業務處理。
2.7.3 服務定位器
有時在實體中新增欄位以維持領域服務引用,會使的實體變得臃腫。此時,我們可以通過服務定位器進行領域服務的查詢。
一般情況下,服務定位器會提供一組靜態方法,以方便的獲取其他服務。
@Component
public class ServiceLocator implements ApplicationContextAware {
private static ApplicationContext APPLICATION;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
APPLICATION = applicationContext;
}
public static <T> T getService(Class<T> service){
return APPLICATION.getBean(service);
}
}
複製程式碼
ServiceLocator 實現 ApplicationContextAware 介面,通過 Spring 回撥將 ApplicationContext 繫結到靜態欄位 APPLICATION 上。getService 方法直接使用 ApplicationContext 獲取領域服務。
@Data
public class User extends JpaAggregate {
private String password;
public void updatePassword(String pwd){
setPassword(ServiceLocator.getService(PasswordEncoder.class).encode(pwd));
}
public boolean checkPassword(String pwd){
return ServiceLocator.getService(PasswordEncoder.class).matches(pwd, getPassword());
}
}
複製程式碼
User 物件直接使用靜態方法獲取領域服務。
以上模式重點解決如果將領域服務注入到實體中,而 領域事件 模式從相反方向努力,解決如何阻止注入的發生。
2.7.4 領域事件解耦
一種完全避免將領域服務注入到實體中的模式是領域事件。
當重要的操作發生時,實體可以釋出一個領域事件,註冊了該事件的訂閱器將處理該事件。此時,領域服務駐留在訊息的訂閱方內,而不是駐留在實體中。
比較常見的例項是使用者通知,例如,在使用者啟用後,為使用者傳送一個簡訊通知。
@Data
public class User extends JpaAggregate {
private UserStatus status;
private String name;
private String password;
public void activate(){
setStatus(UserStatus.ACTIVATED);
registerEvent(new UserActivatedEvent(getName(), getId()));
}
}
複製程式碼
首先,User 在成功 activate 後,將自動註冊 UserActivatedEvent 事件。
public class UserApplication extends AbstractApplication {
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private UserRepository userRepository;
private DomainEventBus domainEventBus = new DefaultDomainEventBus();
@PostConstruct
public void init(){
this.domainEventBus.register(UserActivatedEvent.class, event -> {
sendSMSNotice(event.getUserId(), event.getName());
});
}
private void sendSMSNotice(Long userId, String name) {
// 傳送簡訊通知
}
public void activate(Long userId){
updaterFor(this.userRepository)
.publishBy(domainEventBus)
.id(userId)
.update(user -> user.activate())
.call();
}
}
複製程式碼
UserApplication 通過 Spring 的回撥方法 init,訂閱 UserActivatedEvent 事件,在事件觸發後執行發簡訊邏輯。activate 方法在成功更新 User 後,將對快取的事件進行釋出。
3. 領域服務建模模式
3.1 獨立介面是否有必要
很多情況下,獨立介面時沒有必要的。我們只需建立一個實現類即可,其命名與領域服務相同(名稱來自通用語言)。
但在下面情況下,獨立介面時有必要的(獨立介面對解耦是有好處的):
- 存在多個實現。
- 領域服務的實現依賴基礎框架的支援。
- 測試環節需要 mock 物件。
3.2 避免靜態方法
對於行為建模,很多人第一反應是使用靜態方法。但,領域服務比靜態方法存在更多的好處。
領域服務比靜態方法要好的多:
- 通過多型,適配多個實現,同時可以使用模板方法模式,對結構進行優化;
- 通過依賴注入,獲取其他資源;
- 類名往往比方法名更能表達領域概念。
從表現力角度出發,類的表現力大於方法,方法的表現力大於程式碼。
3.3 優先使用領域事件進行解耦
領域事件是最優雅的解耦方案,基本上沒有之一。我們將在領域事件中進行詳解。
3.4 策略模式
當領域服務存在多個實現時,天然形成了策略模式。
當領域服務存在多個實現時,可以根據上下文資訊,動態選擇具體的實現,以增加系統的靈活性。
詳見 PreferentialStrategy 例項。
4. 小結
- 有時,行為不屬於實體或值物件,但它是一個重要的領域概念,這就暗示我們需要使用領域服務模式。
- 領域服務代表領域概念,它是對通用語言的一種建模。
- 領域服務主要使用實體或值物件組成無狀態的操作。
- 領域服務位於領域模型中,對於依賴基礎設施的領域服務,其介面定義位於領域模型中。
- 過多的領域服務會導致貧血模型,使之與問題域無法很好的配合。
- 過少的領域服務會導致將不正確的行為新增到實體或值物件上,造成概念的混淆。
- 當實體依賴領域服務時,可以使用手工注入、依賴注入和領域事件等多種方式進行處理。