本篇內(nèi)容來源于本人部門的開發(fā)經(jīng)驗總結--注者:廖同學
什么是 DDD
DDD 全稱領域驅(qū)動設計杂瘸,分為戰(zhàn)略設計和戰(zhàn)術設計兩個層次另患。我們在此討論的均屬于戰(zhàn)術設計范疇不狮。
DDD 戰(zhàn)術設計本質(zhì)上是面向?qū)ο蟮囊环N設計方法。根本目的與面向?qū)ο笠恢拢匀皇菫榱私鉀Q軟件項目中不斷增長的復雜性問題讳嘱。
DDD 的適應范圍比面向?qū)ο笤O計要狹窄,但據(jù)我們的實踐酿愧,至少在服務端開發(fā)的領域沥潭,DDD 能很好地產(chǎn)生他的效用。
DDD 能帶來什么
- 統(tǒng)一術語嬉挡,降低團隊溝通成本
- 提高代碼可讀性钝鸽,甚至達到無文檔化(代碼即文檔)
- 提高代碼復用性
- 帶來靈活性,擁抱變化
DDD 不能帶來什么
- 性能
- bug
- 一勞永逸的設計
- ……
DDD 落地
DDD 一詞起源于 Eric Evans 的一本書《領域驅(qū)動設計——軟件核心復雜性應對之道》庞钢。許多同學應該都知道拔恰,并且多少看過這本書,但是大多數(shù)人都會覺得非常抽象基括、難以理解颜懊,看完后也不知道該如何將這些理論運用到實踐中去。我個人的看法是风皿,其實并不是這本書難以理解河爹,而是這本書誕生于 C/S 架構流行的年代,里面許多案例其實是以 C/S 的角度去舉例的桐款。而我們現(xiàn)在流行的是 B/S 架構的軟件咸这,并且許多框架(如 Spring)幾乎已經(jīng)成為了服務端軟件開發(fā)的必選項,如果只是照搬書上的那些例子魔眨,自然是無法很好地進行落地的媳维。
以下談及的內(nèi)容是我在帶領團隊的過程中總結出來的一些 DDD 在服務端的落地實踐,并不代表適合所有團隊或所有技術棧遏暴。
DDD 編寫的代碼所屬層次
我們把 DDD 設計的相關代碼放到 Domain 層侨艾,這一層是介于經(jīng)典三層架構中 Service 與 DAO 層之間的特殊的一層,但嚴格意義上來說還是屬于 Service 層(處理業(yè)務邏輯)拓挥,可以想象成在原先的 Service 層上又劃分了一層出來唠梨。
如下圖所示
示例
下面是我們在 JAVA 工程中采用的一個 DDD 包結構規(guī)范
TODO::
實體
以標識作為其基本定義的對象稱為實體 - Eric Evans
換句話說,即所有實體必須有一個唯一標識侥啤。
在我們的實踐中当叭,我們一般使用 id 字段作為實體的唯一標識茬故。如果要區(qū)別某個對象是否一個實體,只要看他是否有 id 即可蚁鳖。
實體除了唯一標識外磺芭,往往還有很多其它屬性,因此實體往往還會依賴一個倉儲對象醉箕。有關倉儲钾腺,會在后面提及。
一個典型的實體定義如下:
public class Project {
private Long id;
private ProjectRepository repo;
public Project(Long id, ProjectRepository repo) {
this.id = id;
this.repo = repo;
}
public ProjectDO data() {
return repo.selectById(this.id);
}
}
引用
我們建議實體間的聚合采用軟關聯(lián)的方式讥裤,原因是在服務端開發(fā)中放棒,這種有狀態(tài)的對象朝生夕滅的情況非常常見(服務端要管理的對象非常多,不可能將所有實體都存在內(nèi)存中己英,一般一個請求過來時會創(chuàng)建對象间螟,請求結束后在下一次 GC 這個對象就會被銷毀),而實體之間的關聯(lián)可能是非常復雜的损肛,每次使用時都構建一個完整的聚合非常不劃算厢破。
可以看看以下兩種方式的區(qū)別:
硬關聯(lián)
public class Project {
private Long id;
private List<Application> apps;
public Project(Long id, List<Application> apps) {
this.id = id;
this.apps = apps;
}
public List<Application> listApplications() {
return this.apps;
}
}
軟關聯(lián)
public class Project {
private Long id;
private ApplicationManager applicationManager;
public Project(Long id, ApplicationManager applicationManager) {
this.id = id;
this.applicationManager = applicationManager;
}
public List<Application> listApplications() {
return this.listAllApplicationId()
.stream()
.map(id -> applicationManager.get(id))
.collect(Collectors.toList())
}
}
FAQ
Q: 實體定義方法時是否可以使用值類型
A: 可以,但一般情況下不建議(特殊情況可以這樣做治拿,如考慮性能等問題的時候)摩泪,因為這會導致方法的復用性大大降低。即使這樣做了劫谅,也應該盡量返回較通用的值對象(如 DO)见坑,應避免使用 DTO, VO 等。
工廠
雖然在上面我們采用了軟關聯(lián)的方式建立實體之間的引用關系同波,但這并不代表要構建一個實體就非常簡單了鳄梅,原因是我們的實體除了依賴其它實體外,往往還需要依賴許多其它對象(如領域服務未檩、Manager戴尸、倉儲等),并且隨著業(yè)務的變化冤狡,實體的依賴往往還會隨之發(fā)生變化孙蒙,如果還是通過傳統(tǒng)的 new 方式去創(chuàng)建一個實體,會產(chǎn)生一些災難性的問題:
- 使用者必須清楚實體的創(chuàng)建細節(jié)悲雳,這會大大增加代碼的復雜度
- 每當實體的構造方式發(fā)生變化時挎峦,不得不調(diào)整所有創(chuàng)建實體的代碼邏輯以解決代碼編譯問題
綜上,工廠的概念依然有必要存在于服務端 DDD 中合瓢。
通用實現(xiàn)
一個通用 Factory 的實現(xiàn)示例如下
public abstract class Factory {
private static ProjectRepository projectRepository;
public void setProjectRepository(ProjectRepository projectRepository) {
this.projectRepository = projectRepository;
}
public Project newProject(Long id) {
return new Project(id, projectRepository);
}
}
這種實現(xiàn)要求我們在應用啟動的時候坦胶,通過鉤子函數(shù)去為這個 Factory 把所有要用到的對象準備好,每當 Factory 需要的依賴變化時,都得調(diào)整這個鉤子函數(shù)顿苇,稍顯麻煩∏椭洌現(xiàn)在服務端已經(jīng)有許多非常成熟、方便的 IoC 框架(如 Spring)纪岁,有條件的時候我們也會結合這些框架來實現(xiàn) Factory凑队。
結合 Spring
一個基于 Spring 實現(xiàn)的 Factory 如下
@Component
public class Factory {
@Autowired
private ProjectRepository projectRepository;
public Project newProject(Long id) {
return new Project(id, projectRepository);
}
}
實體管理者(Manager)
我們稱其為 Manager,對應的其實是 Eric Evans 在書中提到的倉儲(實體倉儲)幔翰。為什么我們不使用倉儲這個概念呢漩氨?原因是在服務端開發(fā)中本身就有倉儲(數(shù)據(jù)倉儲,也叫 DAO)這個概念遗增。為了避免概念混淆叫惊,我們使用了另一個概念 Manager。
與 Eric Evans 的倉儲概念定義一致贡定,Manager 可以為使用者提供實體的創(chuàng)建赋访、刪除及條件查詢操作可都。
Manager 往往還需要依賴倉儲(查詢持久化數(shù)據(jù))及工廠(創(chuàng)建實體)缓待,并且可以發(fā)布事件。
倉儲
上面提到我們用 Manager 這個概念代替了原本 Evans 說的倉儲概念渠牲,那么我們現(xiàn)在提及的倉儲概念又是用來做什么的呢旋炒?
我們這里定義的倉儲只負責與持久化數(shù)據(jù)打交道,即數(shù)據(jù)倉儲签杈。為什么不直接使用 ORM瘫镇?是因為我們考慮到在現(xiàn)在流行的微服務架構中,服務拆分答姥、沉淀是很經(jīng)常發(fā)生的事铣除。原先的大服務中,某個實體的數(shù)據(jù)可能是通過 ORM 去查詢數(shù)據(jù)庫得到的鹦付,而在拆分后尚粘,就變成了通過遠程調(diào)用去獲取了。為了解決這一問題敲长,我們使用倉儲這一概念使得持久化數(shù)據(jù)的操作過程變得透明郎嫁,如果發(fā)生服務拆分沉淀,那么我們的領域?qū)硬恍枰鋈魏涡薷模ㄖ灰拍畹亩x沒有發(fā)生變化)祈噪,只要調(diào)整倉儲層的實現(xiàn)即可泽铛。
一些使用原則
- 實體不應該依賴屬于其它實體的倉儲
- 實體不應該繞過倉儲直接訪問數(shù)據(jù)(如直接操作 ORM 框架)
領域服務
領域服務用于處理一些在概念上不屬于實體的操作,這些操作本質(zhì)上往往是一些活動或行為辑鲤,并且是無狀態(tài)的盔腔。對于這類操作,將其強制進行歸類會顯得非常別扭,于是便引入了領域服務這一概念弛随。需要明確的是澈蝙,其與三層架構的 Service 層(應用服務)并不是一個概念。另外與 Evans 在書中提及的示例不同撵幽,為了避免混亂灯荧,我們一般不會為領域服務的類命名加上 Service 后綴。
示例
在某個管理主機的應用中盐杂,可以指定主機執(zhí)行一些 Shell 命令逗载,并且會將輸出全部存儲起來。但由于該操作執(zhí)行頻繁链烈,因此輸出記錄會相當龐大厉斟,需要需要定時查找超過 15 天的執(zhí)行記錄并將其清理。
在以上背景中强衡,存在幾個實體:Host擦秽、Exec、ExecOutput漩勤。從我們的描述中可知感挥,我們需要完成的這個操作無法歸類到任何一個實體中,因此我們需要一個 ExecClearer
的領域服務來幫助我們完成該操作越败。
由于領域服務是無狀態(tài)的触幼,因此我們一般將其定義為單例
@Compoment
public class ExecClearer {
private ExecManager execManager;
public void clearOutDated(Integer interval) {
// 以下實現(xiàn)代碼與我們要說明的內(nèi)容無關,可以無視
OutDatedExecFinder finder = new OutDatedExecFinder(interval, execManager);
while (finder.hasNext()) {
finder.nextCollection()
.stream().forEach(Exec::destroy);
}
}
}
在其它地方究飞,我們可以直接注入該領域服務置谦,并使用
@Slf4j
@Component
public class ExecScheduledTask {
@Autowired
private ExecClearer clearer;
@Value("${exec.output.interval.days:15}")
private Integer intervalDays;
@Scheduled(cron = "0 0 0 * * ?")
public void deleteExecData() {
log.info("starting clear exec data, intervalDays=>{}", intervalDays);
clearer.clearOutDated(intervalDays);
log.info("clear exec data end");
}
}
領域事件
在我們的領域活動(實體、Manager 等操作)中會出現(xiàn)一系列的重要的事件亿傅,而這些事件的訂閱者媒峡,往往需要對這些事件作出響應(例如,新增用戶后葵擎,可能會觸發(fā)一系列動作:發(fā)送歡迎信息谅阿、發(fā)放優(yōu)惠券等等)。領域事件可以簡單地理解為是發(fā)布訂閱模式在 DDD 中的一種運用坪蚁。
在我們的實踐中奔穿,一般采用事件總線來快速地發(fā)布一個領域事件。
事件總線的接口定義一般如下
public interface EventBus {
void post(Event event);
}
通過調(diào)用 EventBus.post()
方法敏晤,我們可以快速發(fā)布一個事件贱田。
同時我們還會提供一個抽象類 AbstractEventPublisher
public class AbstractEventPublisher implements EventPublisher {
private EventBus eventBus;
public void setEventBus(EventBus eventBus) {
this.eventBus = eventBus;
}
@Override
public void publish(Event event) {
if (eventBus != null) {
eventBus.post(event);
} else {
log.warn("event bus is null. event " + event.getClass() + " will not be published!");
}
}
}
public interface EventPublisher {
void publish(Event event);
}
這樣我們可以讓實體或 Manager 繼承自 AbstractEventPublisher,其便有了發(fā)布事件的能力嘴脾。至于如何訂閱并處理這些事件男摧,取決于 EventBus 的實現(xiàn)方式蔬墩。舉個例子,我們一般使用 Guava 的 EventBus耗拓,定義相關的 handler
并注冊到 EventBus 中便可方便地處理這些事件
@Component
public class DomainEventBus extends EventBus implements InitializingBean {
@Autowired
private FooEventHandler fooEventHandler;
@Override
public void afterPropertiesSet() {
this.register(fooEventHandler);
}
}
@Component
@Slf4j
public class FooEventHandler implements DomainEventHandler {
@Override
@Subscribe
public void listen(ProjectCreatEvent e) {
// do something here...
}
}
限界上下文
顧名思義诀浪,在實際系統(tǒng)中會有非常多的業(yè)務上下文据悔。對于這些業(yè)務上下文,可能會重復出現(xiàn)很多同名實體,這些實體有可能是同一個概念验夯,也有可能不是鸭津。
任何概念都有他適用的范圍盒卸,我們在討論的時候一定要明晰我們所討論的這些概念所處的一個上下文是什么较雕,否則我們的溝通就有可能不在同一個頻道上。
單元測試
采用 DDD 的編碼模式后食拜,業(yè)務邏輯主要聚集在實體中鸵熟,原三層架構中的 Service 層會變得非常“薄”负甸。因此流强,單元測試主要會針對實體、領域服務等進行編寫呻待。
DDD 設計
理解了 DDD 中的全部概念打月,也并不意味著就能做出一個好的設計了。
DDD 的設計最重要的是做好以下幾點:
- 準確地定義實體
- 準確地定義實體應該有哪些方法
- 確立實體與實體之間的關系
實體的設計其實是一個建模的過程带污。面向?qū)ο蟮脑O計方法本質(zhì)就是將現(xiàn)實世界的對象關系以簡化的形式提煉為模型僵控。
模型是現(xiàn)實世界的一種簡化香到,但不應該與現(xiàn)實世界沖突鱼冀。
概念不一致
關系不一致