這是一個講解DDD落地的文章系列,作者是《實現領域驅動設計》的譯者滕雲。本文章系列以一個真實的並已成功上線的軟體專案——碼如雲(https://www.mryqr.com)為例,系統性地講解DDD在落地實施過程中的各種典型實踐,以及在面臨實際業務場景時的諸多取捨。
本系列包含以下文章:
案例專案介紹
既然DDD是「領域」驅動,那麼我們便不能拋開業務而只講技術,為此讓我們先從業務上了解一下貫穿本文章系列的案例專案 —— 碼如雲(不是馬雲,也不是碼雲)。如你已經在本系列的其他文章中瞭解過該案例,可跳過。
碼如雲是一個基於二維條碼的一物一碼管理平臺,可以為每一件「物品」生成一個二維條碼,並以該二維條碼為入口展開對「物品」的相關操作,典型的應用場景包括固定資產管理、裝置巡檢以及物品標籤等。
在使用碼如雲時,首先需要建立一個應用(App),一個應用包含了多個頁面(Page),也可稱為表單,一個頁面又可以包含多個控制元件(Control),比如單選框控制元件。應用建立好後,可在應用下建立多個範例(QR)用於表示被管理的物件(比如機器裝置)。每個範例均對應一個二維條碼,手機掃碼便可對範例進行相應操作,比如檢視範例相關資訊或者填寫頁面表單等,對錶單的一次填寫稱為提交(Submission);更多概念請參考碼如雲術語。
在技術上,碼如雲是一個無程式碼平臺,包含了表單引擎、審批流程和資料包表等多個功能模組。碼如雲全程採用DDD完成開發,其後端技術棧主要有Java、Spring Boot和MongoDB等。
碼如雲的原始碼是開源的,可以通過以下方式存取:
應用服務和領域服務
對於服務類(程式碼中的各種Service類),想必程式設計師們都不會陌生,比如在做Spring專案時,在Controller層的後面通常會有一個XxxService
存在。如果對程式碼職責劃分得好一點呢,那麼該Service還會協調其他各方完成對請求的處理;而如果程式碼設計得不那麼好呢,估計就是一個Service負責從頭到尾的所有了。在DDD中,也有類似的服務類,即應用服務(Application Service)和領域服務(Domain Service),不過DDD對於這些服務類的職責做出了明確的界定,在本文中我們將對此做出詳細地講解。
應用服務
在本系列的前幾篇文章中我們講到,在DDD中領域模型(主要包含聚合根,實體,值物件,工廠等)是軟體系統的核心,所有的業務邏輯都發生在其中。在理想情況下,DDD只需要領域模型就夠了(畢竟領域驅動嘛)。但是,軟體執行於計算機這種基礎設施之上,顯然不止於領域模型這麼簡單,至少還應該包含以下方面:
- 資料的網路傳輸
- 應用協定的解析
- 對業務用例的協調
- 事務處理
- 業務資料的持久化
- 紀錄檔
- 認證授權等非業務邏輯類關注點
以Spring MVC為例,在編寫程式碼時我們直接面對的是Controller。在Controller背後,Spring框架和Servlet容器已經為我們處理好了資料的網路傳輸以及HTTP協定解析等底層設施,此時的Controller似乎已經是一個比較高階的程式設計物件了。咋一看,我們得到了這麼一個場景:一邊是Controller,一邊是領域模型,何不直接使用Controller呼叫領域模型完成上述的第3點到7點呢?並非完全不可以,但是直接在Controller中呼叫領域模型的缺點也非常明顯:
- Controller屬於Spring框架,依然是一個非常技術性的存在,而上述的第3到7點大多與具體的框架無關,因此更應該作為一個單獨的關注點來處理,以達到與具體框架解耦的目的
- 對用例的協調是可以複用的,比如以後需要通過桌面GUI(比如JavaFx)來實現的話,其協調邏輯和此時的Controller是相同的,總不至於再拷一份原始碼過去吧
由此可以看出,在技術性的Controller和業務性的領域模型之間,還應該有一個值得被當做單獨關注點的存在。而另一方面,從領域模型本身來說,它只是業務知識在軟體中的表達,並不負責直接處理外界請求,而是需要有一個門面性的存在來協助它。綜合起來,在DDD中我們將這個「存在」稱之為應用服務。
先來看個關於應用服務的例子,在碼如雲中,租戶的管理員可以對成員(Member)進行啟用或禁用操作,以啟用成員為例,此時的Controller程式碼如下:
//MemberController
@PutMapping(value = "/{memberId}/activation")
public void activateMember(@PathVariable("memberId") @NotBlank @MemberId String memberId,
@AuthenticationPrincipal User user) {
memberCommandService.activateMember(memberId, user);
}
對應的應用服務(MemberCommandService)程式碼如下:
//MemberCommandService
@Transactional
public void activateMember(String memberId, User user) {
user.checkIsTenantAdmin();
Member member = memberRepository.byIdAndCheckTenantShip(memberId, user);
member.activate(user);
memberRepository.save(member);
log.info("Activated member[{}].", memberId);
}
原始碼出處:com/mryqr/core/member/command/MemberCommandService.java
從上面兩段程式碼中,我們可以總結出以下幾點:
- Controller的作用非常簡單,就一行程式碼,即呼叫應用服務,這麼做的目的是希望程式儘量早地從技術框架解耦;
- 應用服務
MemberCommandService
遵循DDD中的業務請求處理三部曲原則,即先載入Member
,再呼叫Member
上的業務方法activate()
,最後呼叫資源庫memberRepository.save(member)
儲存Member
,整個過程中,應用服務主要起組織協調作用,並不負責實際的業務邏輯; MemberCommandService
方法上標註了@Transactional
,也即應用服務負責處理事務邊界;- 在完成協調工作之前,
MemberCommandService
通過呼叫user.checkIsTenantAdmin()
來檢查操作使用者是否為租戶管理員,也即應用服務也會負責協調對許可權的處理; - 打紀錄檔,一個應用服務對應一個獨立的業務用例,用例處理完後需要紀錄檔記錄;
- 從整個上看,應用服務與其所在的Spring框架是解耦的。
應用服務是領域模型的門面
在DDD中,領域模型並不直接接收外界的請求,而是通過應用服務向外提供業務功能。此時的應用服務就像酒店的前臺一樣,對外面對客戶,對內則將客戶的請求代理派發給內部的領域模型。應用服務將核心的領域模型和外界隔離開來,可以說應用服務是在「呵護」著領域模型。
既然應用服務只是起協調代理的作用,也意味著應用服務不應該包含過多的邏輯,而應該是很薄的一層。另外,應用服務是以業務用例為粒度接收外部請求的,也即應用服務類中的每一個共有方法即對應一個業務用例,進而意味著應用服務也負責處理事務邊界,使得對一個業務用例的處理要麼全部成功,要麼全部失敗。對應到實際編碼過程中,@Tranactional
註解並不是想怎麼打就怎麼打的,而是主要應該打到應用服務上。
應用服務應該與框架無關
應用服務要做到與技術框架無關,因為應用服務向外代表著業務用例,而業務用例不因框架的變化而變化,當我們把應用服務放到諸如Spring MVC這種Web框架中,它能正常工作,當我們將它遷移到桌面GUI程式中,它也應該可以正常工作。從這個角度,可以將應用服務比作電子元器件,比如CPU,一個CPU在華碩的主機板上可以正常使用,將其轉插到微星主機板中也是可以的。
這裡有個需要討論的點是@Transactional
,這個註解是屬於Spring框架的,將其打在了到應用服務上,這不違背了「應用服務與框架無關」的原則嗎?嚴格上來講,的確如此,但是這個妥協我們認為是可以做的,原因如下:(1)@Transational
註解是打在應用服務方法之上的,並不直接侵入應用服務的方法實現內部,因此這種侵入性並不會導致應用服務中邏輯的混亂,替換的成本也不高;(2)@Transational
本是通過Spring的AOP實現,如果的確不想使用,可以在Controller中呼叫應用服務的地方使用Spring的TranactionalTemplate
類完成,或者另行封裝一個TransactionWrapper
之類的東西供Controller呼叫,這樣一來咱們的應用服務就的確和Spring框架沒任何關係了,但是從務實的角度考慮,這種做法有些得不償失。就上例而言,如果的確有一天你需要像電腦更換CPU那樣將系統從Spring遷移到Guice框架,通過簡單的適配便達到目的了。
領域服務
領域服務雖然和應用服務都有「服務」二字,但是它們並沒有多少聯絡,分別在不同的DDD崗位上各司其職,並且源自於兩種完全不同的邏輯推演。
在本系列的前幾篇文章中,我們知道了領域模型中最重要的概念是聚合根物件,理想情況下我們希望所有的業務邏輯都發生在聚合根之中,在實際編碼中我們也是朝著這個目標行進的。但是,理想和現實始終是存在差距的,在有些情況下將業務邏輯放到聚合根中並不合適,於是我們做個妥協,將這部分業務邏輯放到另外的地方——領域服務。
還是來看個實際的例子,在碼如雲中,成員(Member)可以修改自己的手機號,在修改手機號時,需要判斷新手機號是否已經被他人佔用。這裡的「檢查手機號是否被佔用」是一種跨聚合根的業務邏輯,單單憑當事的Member
自身是否無法完成的,因為該Member
無法感知到其他Member
的狀態。另外,「手機號不能重複」這種邏輯恰恰是一種業務邏輯,應該屬於領域模型的一部分。
讓我們將思考問題的方式反過來,通過自底向上的方式再看看,要實現跨聚合根的檢查,無論如何是需要存取資料庫的,這落入了資源庫(Repository)的職責範疇,為此我們在Member
對應的資源庫MemberRepository
中實existsByMobile(mobileNumber)
方法用於檢查一個手機號mobileNumber
是否已經被佔用。接下來的問題在於,對該方法的呼叫應該由誰完成?此時至少有3種方式:
- 在應用服務中呼叫:這種呼叫不再是簡單的協調式呼叫,而是感知到了業務邏輯的呼叫,這違背了應用服務的基本原則,因此不應該使用這種方式;
- 將
MemberRepository
作為引數傳入Member
中,這的確是一種方式,但是這種方式使得聚合根Member
接受了與業務資料無關的方法引數,是一種API汙染,因此我們也不推薦; - 作為一個單獨的關注點,另立門戶:將這部分邏輯放到一個單獨的類中,這個類依然屬於領域模型,此時的「另立門戶」便是一個領域服務了。
在使用了領域服務後,整個請求的流程稍微有些變化。首先在應用服務MemberCommandService
中, 我們不再遵循經典的請求處理三部曲,而是通過呼叫領域服務MemberDomainService
來更新Member
的狀態:
//MemberCommandService
@Transactional
public void changeMobile(ChangeMyMobileCommand command, User user) {
Member member = memberRepository.byId(user.getMemberId());
memberDomainService.changeMobile(member, command.getMobile(), command.getPassword());
memberRepository.save(member);
log.info("Mobile changed by member[{}].", member.getId());
}
原始碼出處:com/mryqr/core/member/command/MemberCommandService.java
這裡,應用服務MemberCommandService
在載入到對應的Member
物件後,將該Member
傳遞給了領域服務MemberDomainService.changeMobile()
,並期待著這個領域服務會幹正確的事情(即更新Member
的手機號)。最後,應用服務再呼叫memberRepository.save()
將更新後的Member
物件儲存到資料庫中。在這個過程中,應用服務的「將請求代理給領域模型」這種結構並沒有發生變化,並且也無需關心領域服務的內部細節。事實上,此時對請求的處理依然是三部曲,只是其中的第2步從「呼叫聚合根上的業務方法」變成了「呼叫領域服務上的業務方法」。
領域服務MemberDomainService
的實現如下:
//MemberDomainService
public void changeMobile(Member member, String newMobile) {
if (Objects.equals(member.getMobile(), newMobile)) {
return;
}
if (memberRepository.existsByMobile(newMobile)) {
throw new MryException(MEMBER_WITH_MOBILE_ALREADY_EXISTS,
"修改手機號失敗,手機號對應成員已存在。",
mapOf("mobile", newMobile, "memberId", member.getId()));
}
member.changeMobile(newMobile, member.toUser());
}
MemberDomainService
先呼叫memberRepository.existsByMobile()
判斷手機號是否被佔用,如被佔用則丟擲異常,反之才呼叫Member.changeMobile()
完成實際的狀態更新。
在碼如雲,我們發現多數情況下領域服務的存在都是為了解決類似於本例中的「檢查某個值是否重複」這樣的場景,比如檢查成員郵箱是否被佔用,檢查分組名稱是否重複等。事實上,這類問題被業界廣泛討論過,有興趣的讀者可以參考這裡和這裡。當然,領域服務遠不止處理此類場景,比如有時生成ID是通過某些複雜的演演算法或者呼叫第三方完成,此時便可以將其封裝在領域服務中。此外,DDD中的工廠可以被認為是一種特殊型別的領域服務。
可以看到,DDD中的應用服務和領域服務分別解決了兩個完全不同的問題,他們主要的區別在於:
- 應用服務處於領域模型的外側,是領域模型的客戶(呼叫方),其作用是協調各方完成業務用例;而領域服務則是屬於領域模型的一部分;
- 應用服務不處理業務邏輯,領域服務裡全是業務邏輯;
- 每一個業務用例都需要經過應用服務,而領域服務則是一種迫不得已而為之的妥協。
到這裡,再去看看自己程式碼中的那些Service類,是不是可以嘗試著對它們歸個類了?
總結
在本文中,我們分別對應用服務和領域服務做了展開講解,包含它們各自產生的邏輯以及它們之間的區別。在實際編碼中,通常的編碼方式是:從Controller中呼叫應用服務,應用服務協調各方完成對業務用例的處理,業務邏輯優先放入聚合根中,如果不合適才考慮使用領域服務。在下一篇領域事件中,我們將講到領域事件在DDD中的應用。