領域驅動設計戰術模式--領域服務

文心紫竹發表於2019-04-09

在建模時,有時會遇到一些業務邏輯的概念,它放在實體或值物件中都不太合適。這就是可能需要建立領域服務的一個訊號。

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 中的 updatePasswordcheckPassword 在流程中都需要使用領域服務 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 避免靜態方法

對於行為建模,很多人第一反應是使用靜態方法。但,領域服務比靜態方法存在更多的好處。

領域服務比靜態方法要好的多:

  1. 通過多型,適配多個實現,同時可以使用模板方法模式,對結構進行優化;
  2. 通過依賴注入,獲取其他資源;
  3. 類名往往比方法名更能表達領域概念。

從表現力角度出發,類的表現力大於方法,方法的表現力大於程式碼。

3.3 優先使用領域事件進行解耦

領域事件是最優雅的解耦方案,基本上沒有之一。我們將在領域事件中進行詳解。

3.4 策略模式

當領域服務存在多個實現時,天然形成了策略模式。

當領域服務存在多個實現時,可以根據上下文資訊,動態選擇具體的實現,以增加系統的靈活性。

詳見 PreferentialStrategy 例項。

4. 小結

  • 有時,行為不屬於實體或值物件,但它是一個重要的領域概念,這就暗示我們需要使用領域服務模式。
  • 領域服務代表領域概念,它是對通用語言的一種建模。
  • 領域服務主要使用實體或值物件組成無狀態的操作。
  • 領域服務位於領域模型中,對於依賴基礎設施的領域服務,其介面定義位於領域模型中。
  • 過多的領域服務會導致貧血模型,使之與問題域無法很好的配合。
  • 過少的領域服務會導致將不正確的行為新增到實體或值物件上,造成概念的混淆。
  • 當實體依賴領域服務時,可以使用手工注入、依賴注入和領域事件等多種方式進行處理。

相關文章