Show Me Code——領域驅動設計中的程式碼實現
1. 初步填充分層架構實現
分層架構設計後我們確定了程式碼實現的層間關係,現在就先以布控任務這個領域模型為例填充下我們的程式碼骨架。程式碼已託管至Gitee。
1.1 領域物件實現
二話不說先把領域物件連人帶椅子搬上來,在domain層建立ControlTask。主鍵外來鍵、審計欄位、關鍵屬性悉數登場。
java
public class ControlTask {
private Long id;
private Long userId;
private String name;
private String target;
private LocalDateTime startAt;
private LocalDateTime endAt;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime lastUpdatedAt;
private Long lastUpdatedBy;
public ControlTask() {
}
// Getter and Setter
}
然後可以按照類間呼叫時序或者層間依賴逐個填充各層實現。
1.2 被動介面卡實現
作為被動觸發器,需要http端點接收呼叫請求,依賴應用層的應用服務。通過Dto(Data Transfer Object)和領域物件做資料轉換。
java
@RestController
public class ControlResource {
private ControlService controlService;
@Autowired
public ControlResource(ControlService controlService) {
this.controlService = controlService;
}
@PostMapping("api/controls")
public ControlDto addControl(@RequestBody ControlDto request) {
// Get UserId
return controlService.addControl(request, userId);
}
}
1.3 資料傳輸物件與應用服務實現
實現過程始終要注意不壞層間依賴關係。涉及物件轉換這裡採用的方法是將ControlDto直接放到應用層。
應用服務作為領域層的門面,負責整合領域層的業務邏輯,封裝成更粗粒度的資料物件。
這裡應用服務需要做的事情具有代表性——帶有業務規則的校驗請求引數、建立領域物件、持久化、封裝資料傳輸物件返回。從而先形成對領域物件和倉庫的依賴。
java
@Service
public class ControlService {
private final ControlValidator controlValidator;
private final ControlRepository controlRepository;
@Autowired
public ControlService(
ControlValidator controlValidator,
ControlRepository controlRepository
) {
this.controlValidator = controlValidator;
this.controlRepository = controlRepository;
}
public ControlDto addControl(ControlDto request, Long userId) {
controlValidator.validate(request);
ControlTask controlTask = buildControlTask(request, userId);
controlTask = controlRepository.save(controlTask);
return buildControlDto(controlTask);
}
private ControlDto buildControlDto(ControlTask controlTask) {
// todo
}
private ControlTask buildControlTask(ControlDto request, Long userId) {
// todo
}
}
1.4 領域服務實現
至於業務規則的引數校驗,可以進一步抽象為領域服務(Domain Service),劃撥到領域層。這個過程對應一種DDD設計模式——表意介面(Intention-Revealing Interfaces),是DDD特別重視程式碼命名反映領域知識,與統一語言保持一致的一種實踐方法。
通過布控任務校驗器ControlValidator統一封裝請求物件的業務規則校驗,比如這裡對布控時間段以及布控任務名稱的校驗。
java
@Component
public class ControlValidator {
private final InputTextValidator inputTextValidator;
private final DateTimeValidator dateTimeValidator;
@Autowired
public ControlValidator(InputTextValidator inputTextValidator, DateTimeValidator dateTimeValidator) {
this.inputTextValidator = inputTextValidator;
this.dateTimeValidator = dateTimeValidator;
}
public void validate(ControlDto request) {
inputTextValidator.defaultInputShouldNotLongerThan(request.getName());
dateTimeValidator.startTimeShouldEarly(request.getStartAt(), request.getEndAt());
}
}
這裡留一個隱患,Validator位於領域層,ControDto位於應用層,validate導致層間依賴關係又被破壞了,我們將在下一章節徹底處理掉。
補充一下應用層與領域層內的業務邏輯歸屬,在應用層和領域層都會處理業務邏輯,區別是隻能由研發自己瞭解的放在應用層,需要和領域專家共同探討的放在領域層,領域物件是不能包含只有研發自己才懂的內容。
1.5 主動介面卡實現
再回到應用服務上,需要持久化領域物件。在分層架構設計中我們重點介紹了引入DIP保證層間依賴關係。
我們直接給出依賴倒置後的類圖效果。
應用服務ControlService到倉庫介面有三個箭頭,代表著ControService使用了這三個介面定義的屬性,能夠通過屬性導航到倉庫,但是倉庫沒有屬效能夠導航到ControlService,所以關聯關係是單向的,應用層依賴領域層。
另一方面,主動介面卡的倉庫實現也有虛線箭頭指向倉庫介面,代表著倉庫實現關係,介面卡也依賴於領域層。從而順利解決層間依賴關係。
面向過程的三層架構直接是在service中依賴repository,這裡只需要把原來的倉庫介面變為倉庫的實現類,抽離出的介面移入領域層。就像這樣,ControlRepositoryJdbc就是原來的倉庫介面,變為了倉庫實現。
java
@Repository
public class ControlRepositoryJdbc implements ControlRepository {
@Override
public ControlTask save(ControlTask controlTask) {
// todo
}
}
ControlRepository移入到了領域層,領域服務的依賴還在領域層,被動介面卡作為倉庫介面的實現還在介面卡層,並沒有像原來一樣依賴領域層或應用層,從而保證了層間依賴關係的穩定。
java
public interface ControlRepository {
ControlTask save(ControlTask controlTask);
}
1.6 程式碼結構
經過這麼一番折騰,布控任務管理的領域模組基本建成,程式碼結構是這樣的:
java
|-- adapter
|-- driven
|-- restful
|-- ControlResource.java
|-- driving
|-- persistence
|-- ControlRepositoryJdbc.java
|-- application
|-- controlmng
|-- ControlService.java
|-- ControlDto.java
|-- domain
|-- controlmng
|-- ControlTask.java
|-- ControlValidator.java
|-- ControlRepository.java
|-- SafetymonitorApplication.java
2.領域物件建立規則複雜度降解
我們不難發現,領域物件的建立邏輯也會領域層的一部分。簡單建立可以直接在構造器完成,但是對於複雜建立就需要考慮解耦保證領域物件的簡潔與聚焦,降低領域物件的建立複雜度。
建立邏輯複雜包括規則複雜與結構複雜,上文領域服務的抽取就是應對規則複雜的解決辦法,更準確的說是校驗規則複雜。後面的文章我們還會碰到結構複雜的情況。這裡就規則複雜看下常用降解複雜度的方法。
2.1 表意介面與領域服務
拿到領域模型後我們建立實體,對照業務規則表寫了一大片校驗邏輯,這些校驗邏輯互相依賴較少,而且會涉及訪問倉庫介面的情況。
如何重構呢,首先提取函式,可以通過現代IDE的快捷鍵迅速完成,比如說IDEA的Extract Method功能(Alt+Shift+M)。方法與變數命名要能體現領域知識,也就是表意介面,比如verify,startTimeShouldEarly等。
接著,將這些方法移動到領域層成為各個校驗器如DateTimeValidator,通過校驗器ControlValidator組合其他校驗器,ControlValidator也就是領域服務,應用服務從耦合校驗邏輯變為依賴領域服務。
布控模組業務邏輯的程式碼結構變成了這樣:
java
|-- application
|-- controlmng
|-- ControlService.java
|-- ControlDto.java
|-- domain
|-- controlmng
|-- validator
|-- DateTimeValidator.java
|-- InputTextValidator.java
|-- ControlValidator.java
|-- ControlTask.java
|-- ControlRepository.java
2.2 工廠模式
對於領域物件的建立邏輯,DDD提供了工廠(Factory)模式來聚焦。從而達成一種隱喻(metaphor)——工廠創造產品然後存入倉庫。以及上一章節留下的一個坑,領域服務依賴了應用層的資料傳輸物件,解決辦法就是採用Builder模式。
首先,建立ControlBuilder,接管並替換掉原來的領域服務ControlValidator,將規則校驗以及領域物件建立封裝起來。
java
public class ControlBuilder {
private final InputTextValidator inputTextValidator;
private final DateTimeValidator dateTimeValidator;
private Long userId;
private String name;
private String target;
private LocalDateTime startAt;
private LocalDateTime endAt;
private Long createdBy;
@Autowired
public ControlBuilder(InputTextValidator inputTextValidator, DateTimeValidator dateTimeValidator) {
this.inputTextValidator = inputTextValidator;
this.dateTimeValidator = dateTimeValidator;
}
public ControlBuilder userId(Long userId) {
this.userId = userId;
return this;
}
// 省略其他build屬性
public ControlTask build() {
validate();
ControlTask controlTask = new ControlTask();
controlTask.setUserId(this.userId);
controlTask.setName(this.name);
controlTask.setTarget(this.target);
controlTask.setStartAt(this.startAt);
controlTask.setEndAt(this.endAt);
controlTask.setCreatedBy(this.createdBy);
controlTask.setCreatedAt(LocalDateTime.now());
return controlTask;
}
private void validate() {
inputTextValidator.defaultInputShouldNotLongerThan(this.name);
dateTimeValidator.startTimeShouldEarly(this.startAt, this.endAt);
}
}
然後,建立布控任務領域物件Builder工廠ControlBuilderFactory生產Builder。
注意因為建立ControlBuilder有可變屬性,所以不能單例注入到ControlService中(沒有使用@Component註解),可以使用Spring的prototype原型模式,或是就像這樣直接new一個新的ControlBuilder物件。
java
@Component
public class ControlBuilderFactory {
private final InputTextValidator inputTextValidator;
private final DateTimeValidator dateTimeValidator;
@Autowired
public ControlBuilderFactory(InputTextValidator inputTextValidator, DateTimeValidator dateTimeValidator) {
this.inputTextValidator = inputTextValidator;
this.dateTimeValidator = dateTimeValidator;
}
public ControlBuilder create() {
return new ControlBuilder(inputTextValidator, dateTimeValidator);
}
}
最後,再來看下我們布控任務領域模型的門面——應用服務ControlService。
屬性依賴只有工廠與倉庫,是不就能和上面的隱喻對上了,程式碼整潔邊界清晰。
通過Builder模式,建立領域物件也變得直觀,減少了引數順序不對導致引數錯誤的失誤。
java
@Service
public class ControlService {
private final ControlBuilderFactory controlBuilderFactory;
private final ControlRepository controlRepository;
@Autowired
public ControlService(
ControlBuilderFactory controlBuilderFactory,
ControlRepository controlRepository
) {
this.controlBuilderFactory = controlBuilderFactory;
this.controlRepository = controlRepository;
}
public ControlDto addControl(ControlDto request, Long userId) {
ControlBuilder controlBuilder = controlBuilderFactory.create();
ControlTask controlTask = controlBuilder.userId(userId)
.name(request.getName())
.target(request.getTarget())
.startAt(request.getStartAt())
.endAt(request.getEndAt())
.createdBy(userId)
.build();
controlTask = controlRepository.save(controlTask);
return buildControlDto(controlTask);
}
private ControlDto buildControlDto(ControlTask controlTask) {
// todo
}
}
3.面向物件還是面向過程
經典的程式設計正規化大家討論了很多年,究竟是面向物件還是面向過程,通過老馬的著書經歷可見一二。
在OO剛問世的時候,桌面軟體開發盛行,比如Microsoft Office套件。開發特點是軟體執行的所有的資料都可以裝入記憶體,物件之間的導航自由;而在J2EE誕生時,企業應用開發逐漸佔據市場主導地位,應用資料日益龐大,資料庫技術得到大力發展,每次只能取出很小一部分放入記憶體,物件之間是沒法自由導航的,這也就造成了OO很難在企業應用中使用,貧血模型氾濫。
老馬的《重構》也在二十多年後的第二版中將實現語言變為了工程化沒那麼強的JavaScript,由面向物件的原教旨主義者變成了程式設計正規化的中立主義者。也就是說對於程式設計正規化而言,不拘泥於面向過程、面向物件、面向方面等等,而是將他們結合起來——面向多種程式設計正規化程式設計。
這也是鍾敬老師帶來的啟發,最後,給出鍾敬老師的程式碼風格規則:
- 領域物件不訪問資料庫
- 領域服務只能讀資料庫
- 應用服務可以讀寫資料庫
- 用 ID 表示物件之間的關聯
- 領域物件有自己的領域服務
- 在以上前提下利用封裝和繼承