领域驱动设计战术模式:领域服务
領域驅動設計戰術部分,是一組面向業務的設計模式,是基于技術的一種思維方式,相對開發人員來說更接地氣,是提升個人格局比較好的切入點。
該文章為戰術模式的第四篇,重心講解領域服務模式。
在建模時,有時會遇到一些業務邏輯的概念,它放在實體或值對象中都不太合適。這就是可能需要創建領域服務的一個信號。從概念上說,領域服務代表領域概念,它們是存在于問題域中的行為,它們產生于與領域專家的對話中,并且是領域模型的一部分。
通過本 Chat,您可以:
在建模時,有時會遇到一些業務邏輯的概念,它放在實體或值對象中都不太合適。這就是可能需要創建領域服務的一個信號。
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 上下文監聽事件,并將用戶上下文內的概念轉為為名片上下文中的概念。
@Valuepublic 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 是用戶上下文,在用戶激活后向外發布的領域事件。
@Servicepublic 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 為例。
@Datapublic 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 方法,來完成服務的注入。
@Datapublic 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 框架中獲取實體后,使用依賴注入完成領域服務的注入。
@Datapublic 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 服務定位器
有時在實體中添加字段以維持領域服務引用,會使的實體變得臃腫。此時,我們可以通過服務定位器進行領域服務的查找。
一般情況下,服務定位器會提供一組靜態方法,以方便的獲取其他服務。
@Componentpublic 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 獲取領域服務。
@Datapublic 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 領域事件解耦
一種完全避免將領域服務注入到實體中的模式是領域事件。
當重要的操作發生時,實體可以發布一個領域事件,注冊了該事件的訂閱器將處理該事件。此時,領域服務駐留在消息的訂閱方內,而不是駐留在實體中。
比較常見的實例是用戶通知,例如,在用戶激活后,為用戶發送一個短信通知。
@Datapublic 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. 小結
- 有時,行為不屬于實體或值對象,但它是一個重要的領域概念,這就暗示我們需要使用領域服務模式。
- 領域服務代表領域概念,它是對通用語言的一種建模。
- 領域服務主要使用實體或值對象組成無狀態的操作。
- 領域服務位于領域模型中,對于依賴基礎設施的領域服務,其接口定義位于領域模型中。
- 過多的領域服務會導致貧血模型,使之與問題域無法很好的配合。
- 過少的領域服務會導致將不正確的行為添加到實體或值對象上,造成概念的混淆。
- 當實體依賴領域服務時,可以使用手工注入、依賴注入和領域事件等多種方式進行處理。
本文首發于 GitChat,未經授權不得轉載,轉載需與 GitChat 聯系。
閱讀全文: http://gitbook.cn/gitchat/activity/5d551ee2fd2738650e9dd675
您還可以下載 CSDN 旗下精品原創內容社區 GitChat App , GitChat 專享技術內容哦。
總結
以上是生活随笔為你收集整理的领域驱动设计战术模式:领域服务的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 均值定理最大值最小值公式_数学均值定理怎
- 下一篇: 1.搭建普罗米休斯监控,实现可视化展示