本篇內(nèi)容來(lái)源于本人部門的開發(fā)經(jīng)驗(yàn)總結(jié)--注者:廖同學(xué)
什么是 DDD
DDD 全稱領(lǐng)域驅(qū)動(dòng)設(shè)計(jì),分為戰(zhàn)略設(shè)計(jì)和戰(zhàn)術(shù)設(shè)計(jì)兩個(gè)層次。我們?cè)诖擞懻摰木鶎儆趹?zhàn)術(shù)設(shè)計(jì)范疇。
DDD 戰(zhàn)術(shù)設(shè)計(jì)本質(zhì)上是面向?qū)ο蟮囊环N設(shè)計(jì)方法。根本目的與面向?qū)ο笠恢?,仍然是為了解決軟件項(xiàng)目中不斷增長(zhǎng)的復(fù)雜性問(wèn)題。
DDD 的適應(yīng)范圍比面向?qū)ο笤O(shè)計(jì)要狹窄,但據(jù)我們的實(shí)踐,至少在服務(wù)端開發(fā)的領(lǐng)域,DDD 能很好地產(chǎn)生他的效用。
DDD 能帶來(lái)什么
- 統(tǒng)一術(shù)語(yǔ),降低團(tuán)隊(duì)溝通成本
- 提高代碼可讀性,甚至達(dá)到無(wú)文檔化(代碼即文檔)
- 提高代碼復(fù)用性
- 帶來(lái)靈活性,擁抱變化
DDD 不能帶來(lái)什么
- 性能
- bug
- 一勞永逸的設(shè)計(jì)
- ……
DDD 落地
DDD 一詞起源于 Eric Evans 的一本書《領(lǐng)域驅(qū)動(dòng)設(shè)計(jì)——軟件核心復(fù)雜性應(yīng)對(duì)之道》。許多同學(xué)應(yīng)該都知道,并且多少看過(guò)這本書,但是大多數(shù)人都會(huì)覺得非常抽象、難以理解,看完后也不知道該如何將這些理論運(yùn)用到實(shí)踐中去。我個(gè)人的看法是,其實(shí)并不是這本書難以理解,而是這本書誕生于 C/S 架構(gòu)流行的年代,里面許多案例其實(shí)是以 C/S 的角度去舉例的。而我們現(xiàn)在流行的是 B/S 架構(gòu)的軟件,并且許多框架(如 Spring)幾乎已經(jīng)成為了服務(wù)端軟件開發(fā)的必選項(xiàng),如果只是照搬書上的那些例子,自然是無(wú)法很好地進(jìn)行落地的。
以下談及的內(nèi)容是我在帶領(lǐng)團(tuán)隊(duì)的過(guò)程中總結(jié)出來(lái)的一些 DDD 在服務(wù)端的落地實(shí)踐,并不代表適合所有團(tuán)隊(duì)或所有技術(shù)棧。
DDD 編寫的代碼所屬層次
我們把 DDD 設(shè)計(jì)的相關(guān)代碼放到 Domain 層,這一層是介于經(jīng)典三層架構(gòu)中 Service 與 DAO 層之間的特殊的一層,但嚴(yán)格意義上來(lái)說(shuō)還是屬于 Service 層(處理業(yè)務(wù)邏輯),可以想象成在原先的 Service 層上又劃分了一層出來(lái)。
如下圖所示
示例
下面是我們?cè)?JAVA 工程中采用的一個(gè) DDD 包結(jié)構(gòu)規(guī)范
TODO::
實(shí)體
以標(biāo)識(shí)作為其基本定義的對(duì)象稱為實(shí)體 - Eric Evans
換句話說(shuō),即所有實(shí)體必須有一個(gè)唯一標(biāo)識(shí)。
在我們的實(shí)踐中,我們一般使用 id 字段作為實(shí)體的唯一標(biāo)識(shí)。如果要區(qū)別某個(gè)對(duì)象是否一個(gè)實(shí)體,只要看他是否有 id 即可。
實(shí)體除了唯一標(biāo)識(shí)外,往往還有很多其它屬性,因此實(shí)體往往還會(huì)依賴一個(gè)倉(cāng)儲(chǔ)對(duì)象。有關(guān)倉(cāng)儲(chǔ),會(huì)在后面提及。
一個(gè)典型的實(shí)體定義如下:
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);
}
}
引用
我們建議實(shí)體間的聚合采用軟關(guān)聯(lián)的方式,原因是在服務(wù)端開發(fā)中,這種有狀態(tài)的對(duì)象朝生夕滅的情況非常常見(服務(wù)端要管理的對(duì)象非常多,不可能將所有實(shí)體都存在內(nèi)存中,一般一個(gè)請(qǐng)求過(guò)來(lái)時(shí)會(huì)創(chuàng)建對(duì)象,請(qǐng)求結(jié)束后在下一次 GC 這個(gè)對(duì)象就會(huì)被銷毀),而實(shí)體之間的關(guān)聯(lián)可能是非常復(fù)雜的,每次使用時(shí)都構(gòu)建一個(gè)完整的聚合非常不劃算。
可以看看以下兩種方式的區(qū)別:
硬關(guān)聯(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;
}
}
軟關(guān)聯(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: 實(shí)體定義方法時(shí)是否可以使用值類型
A: 可以,但一般情況下不建議(特殊情況可以這樣做,如考慮性能等問(wèn)題的時(shí)候),因?yàn)檫@會(huì)導(dǎo)致方法的復(fù)用性大大降低。即使這樣做了,也應(yīng)該盡量返回較通用的值對(duì)象(如 DO),應(yīng)避免使用 DTO, VO 等。
工廠
雖然在上面我們采用了軟關(guān)聯(lián)的方式建立實(shí)體之間的引用關(guān)系,但這并不代表要構(gòu)建一個(gè)實(shí)體就非常簡(jiǎn)單了,原因是我們的實(shí)體除了依賴其它實(shí)體外,往往還需要依賴許多其它對(duì)象(如領(lǐng)域服務(wù)、Manager、倉(cāng)儲(chǔ)等),并且隨著業(yè)務(wù)的變化,實(shí)體的依賴往往還會(huì)隨之發(fā)生變化,如果還是通過(guò)傳統(tǒng)的 new 方式去創(chuàng)建一個(gè)實(shí)體,會(huì)產(chǎn)生一些災(zāi)難性的問(wèn)題:
- 使用者必須清楚實(shí)體的創(chuàng)建細(xì)節(jié),這會(huì)大大增加代碼的復(fù)雜度
- 每當(dāng)實(shí)體的構(gòu)造方式發(fā)生變化時(shí),不得不調(diào)整所有創(chuàng)建實(shí)體的代碼邏輯以解決代碼編譯問(wèn)題
綜上,工廠的概念依然有必要存在于服務(wù)端 DDD 中。
通用實(shí)現(xiàn)
一個(gè)通用 Factory 的實(shí)現(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);
}
}
這種實(shí)現(xiàn)要求我們?cè)趹?yīng)用啟動(dòng)的時(shí)候,通過(guò)鉤子函數(shù)去為這個(gè) Factory 把所有要用到的對(duì)象準(zhǔn)備好,每當(dāng) Factory 需要的依賴變化時(shí),都得調(diào)整這個(gè)鉤子函數(shù),稍顯麻煩。現(xiàn)在服務(wù)端已經(jīng)有許多非常成熟、方便的 IoC 框架(如 Spring),有條件的時(shí)候我們也會(huì)結(jié)合這些框架來(lái)實(shí)現(xiàn) Factory。
結(jié)合 Spring
一個(gè)基于 Spring 實(shí)現(xiàn)的 Factory 如下
@Component
public class Factory {
@Autowired
private ProjectRepository projectRepository;
public Project newProject(Long id) {
return new Project(id, projectRepository);
}
}
實(shí)體管理者(Manager)
我們稱其為 Manager,對(duì)應(yīng)的其實(shí)是 Eric Evans 在書中提到的倉(cāng)儲(chǔ)(實(shí)體倉(cāng)儲(chǔ))。為什么我們不使用倉(cāng)儲(chǔ)這個(gè)概念呢?原因是在服務(wù)端開發(fā)中本身就有倉(cāng)儲(chǔ)(數(shù)據(jù)倉(cāng)儲(chǔ),也叫 DAO)這個(gè)概念。為了避免概念混淆,我們使用了另一個(gè)概念 Manager。
與 Eric Evans 的倉(cāng)儲(chǔ)概念定義一致,Manager 可以為使用者提供實(shí)體的創(chuàng)建、刪除及條件查詢操作。
Manager 往往還需要依賴倉(cāng)儲(chǔ)(查詢持久化數(shù)據(jù))及工廠(創(chuàng)建實(shí)體),并且可以發(fā)布事件。
倉(cāng)儲(chǔ)
上面提到我們用 Manager 這個(gè)概念代替了原本 Evans 說(shuō)的倉(cāng)儲(chǔ)概念,那么我們現(xiàn)在提及的倉(cāng)儲(chǔ)概念又是用來(lái)做什么的呢?
我們這里定義的倉(cāng)儲(chǔ)只負(fù)責(zé)與持久化數(shù)據(jù)打交道,即數(shù)據(jù)倉(cāng)儲(chǔ)。為什么不直接使用 ORM?是因?yàn)槲覀兛紤]到在現(xiàn)在流行的微服務(wù)架構(gòu)中,服務(wù)拆分、沉淀是很經(jīng)常發(fā)生的事。原先的大服務(wù)中,某個(gè)實(shí)體的數(shù)據(jù)可能是通過(guò) ORM 去查詢數(shù)據(jù)庫(kù)得到的,而在拆分后,就變成了通過(guò)遠(yuǎn)程調(diào)用去獲取了。為了解決這一問(wèn)題,我們使用倉(cāng)儲(chǔ)這一概念使得持久化數(shù)據(jù)的操作過(guò)程變得透明,如果發(fā)生服務(wù)拆分沉淀,那么我們的領(lǐng)域?qū)硬恍枰鋈魏涡薷模ㄖ灰拍畹亩x沒有發(fā)生變化),只要調(diào)整倉(cāng)儲(chǔ)層的實(shí)現(xiàn)即可。
一些使用原則
- 實(shí)體不應(yīng)該依賴屬于其它實(shí)體的倉(cāng)儲(chǔ)
- 實(shí)體不應(yīng)該繞過(guò)倉(cāng)儲(chǔ)直接訪問(wèn)數(shù)據(jù)(如直接操作 ORM 框架)
領(lǐng)域服務(wù)
領(lǐng)域服務(wù)用于處理一些在概念上不屬于實(shí)體的操作,這些操作本質(zhì)上往往是一些活動(dòng)或行為,并且是無(wú)狀態(tài)的。對(duì)于這類操作,將其強(qiáng)制進(jìn)行歸類會(huì)顯得非常別扭,于是便引入了領(lǐng)域服務(wù)這一概念。需要明確的是,其與三層架構(gòu)的 Service 層(應(yīng)用服務(wù))并不是一個(gè)概念。另外與 Evans 在書中提及的示例不同,為了避免混亂,我們一般不會(huì)為領(lǐng)域服務(wù)的類命名加上 Service 后綴。
示例
在某個(gè)管理主機(jī)的應(yīng)用中,可以指定主機(jī)執(zhí)行一些 Shell 命令,并且會(huì)將輸出全部存儲(chǔ)起來(lái)。但由于該操作執(zhí)行頻繁,因此輸出記錄會(huì)相當(dāng)龐大,需要需要定時(shí)查找超過(guò) 15 天的執(zhí)行記錄并將其清理。
在以上背景中,存在幾個(gè)實(shí)體:Host、Exec、ExecOutput。從我們的描述中可知,我們需要完成的這個(gè)操作無(wú)法歸類到任何一個(gè)實(shí)體中,因此我們需要一個(gè) ExecClearer 的領(lǐng)域服務(wù)來(lái)幫助我們完成該操作。
由于領(lǐng)域服務(wù)是無(wú)狀態(tài)的,因此我們一般將其定義為單例
@Compoment
public class ExecClearer {
private ExecManager execManager;
public void clearOutDated(Integer interval) {
// 以下實(shí)現(xiàn)代碼與我們要說(shuō)明的內(nèi)容無(wú)關(guān),可以無(wú)視
OutDatedExecFinder finder = new OutDatedExecFinder(interval, execManager);
while (finder.hasNext()) {
finder.nextCollection()
.stream().forEach(Exec::destroy);
}
}
}
在其它地方,我們可以直接注入該領(lǐng)域服務(wù),并使用
@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");
}
}
領(lǐng)域事件
在我們的領(lǐng)域活動(dòng)(實(shí)體、Manager 等操作)中會(huì)出現(xiàn)一系列的重要的事件,而這些事件的訂閱者,往往需要對(duì)這些事件作出響應(yīng)(例如,新增用戶后,可能會(huì)觸發(fā)一系列動(dòng)作:發(fā)送歡迎信息、發(fā)放優(yōu)惠券等等)。領(lǐng)域事件可以簡(jiǎn)單地理解為是發(fā)布訂閱模式在 DDD 中的一種運(yùn)用。
在我們的實(shí)踐中,一般采用事件總線來(lái)快速地發(fā)布一個(gè)領(lǐng)域事件。
事件總線的接口定義一般如下
public interface EventBus {
void post(Event event);
}
通過(guò)調(diào)用 EventBus.post() 方法,我們可以快速發(fā)布一個(gè)事件。
同時(shí)我們還會(huì)提供一個(gè)抽象類 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);
}
這樣我們可以讓實(shí)體或 Manager 繼承自 AbstractEventPublisher,其便有了發(fā)布事件的能力。至于如何訂閱并處理這些事件,取決于 EventBus 的實(shí)現(xiàn)方式。舉個(gè)例子,我們一般使用 Guava 的 EventBus,定義相關(guān)的 handler 并注冊(cè)到 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...
}
}
限界上下文
顧名思義,在實(shí)際系統(tǒng)中會(huì)有非常多的業(yè)務(wù)上下文。對(duì)于這些業(yè)務(wù)上下文,可能會(huì)重復(fù)出現(xiàn)很多同名實(shí)體,這些實(shí)體有可能是同一個(gè)概念,也有可能不是。
任何概念都有他適用的范圍,我們?cè)谟懻摰臅r(shí)候一定要明晰我們所討論的這些概念所處的一個(gè)上下文是什么,否則我們的溝通就有可能不在同一個(gè)頻道上。
單元測(cè)試
采用 DDD 的編碼模式后,業(yè)務(wù)邏輯主要聚集在實(shí)體中,原三層架構(gòu)中的 Service 層會(huì)變得非常“薄”。因此,單元測(cè)試主要會(huì)針對(duì)實(shí)體、領(lǐng)域服務(wù)等進(jìn)行編寫。
DDD 設(shè)計(jì)
理解了 DDD 中的全部概念,也并不意味著就能做出一個(gè)好的設(shè)計(jì)了。
DDD 的設(shè)計(jì)最重要的是做好以下幾點(diǎn):
- 準(zhǔn)確地定義實(shí)體
- 準(zhǔn)確地定義實(shí)體應(yīng)該有哪些方法
- 確立實(shí)體與實(shí)體之間的關(guān)系
實(shí)體的設(shè)計(jì)其實(shí)是一個(gè)建模的過(guò)程。面向?qū)ο蟮脑O(shè)計(jì)方法本質(zhì)就是將現(xiàn)實(shí)世界的對(duì)象關(guān)系以簡(jiǎn)化的形式提煉為模型。
模型是現(xiàn)實(shí)世界的一種簡(jiǎn)化,但不應(yīng)該與現(xiàn)實(shí)世界沖突。
概念不一致
關(guān)系不一致