新項(xiàng)目從零到一DDD實(shí)戰(zhàn)思考與總結(jié)
領(lǐng)域驅(qū)動(dòng)設(shè)計(jì)(DDD)是一種業(yè)務(wù)領(lǐng)域建模方法論、業(yè)務(wù)架構(gòu)設(shè)計(jì)方法論,戰(zhàn)略設(shè)計(jì)階段從業(yè)務(wù)領(lǐng)域視角劃分領(lǐng)域邊界,抽象業(yè)務(wù)建立領(lǐng)域模型;戰(zhàn)術(shù)設(shè)計(jì)階段則根據(jù)清晰的領(lǐng)域邊界、領(lǐng)域模型進(jìn)行架構(gòu)設(shè)計(jì)與開發(fā)實(shí)現(xiàn)。
DDD解決了核心復(fù)雜業(yè)務(wù)設(shè)計(jì)問題,簡(jiǎn)化業(yè)務(wù)系統(tǒng)的實(shí)現(xiàn),讓業(yè)務(wù)邏輯高度內(nèi)聚,與基礎(chǔ)設(shè)施、框架解耦,清晰的領(lǐng)域邊界解決微服務(wù)的拆分問題。聚合之間通過聚合根ID引用與領(lǐng)域事件松耦合,保持高內(nèi)聚、松耦合讓項(xiàng)目代碼隨著業(yè)務(wù)需求的不斷迭代保持整潔。
本篇筆者以近期的一個(gè)項(xiàng)目實(shí)戰(zhàn)跟大家分享筆者目前對(duì)DDD的理解,以及在實(shí)戰(zhàn)DDD過程中遇到的問題思考與總結(jié),僅個(gè)人經(jīng)驗(yàn),偏戰(zhàn)術(shù)設(shè)計(jì)。
從實(shí)戰(zhàn)項(xiàng)目中理解DDD核心概念
領(lǐng)域通常指的就是業(yè)務(wù)范圍,每個(gè)公司都有自己明確的業(yè)務(wù)范圍。通常每個(gè)公司內(nèi)部都有很多個(gè)系統(tǒng),如一家電商公司可能會(huì)有物流系統(tǒng)、電商系統(tǒng)、直播系統(tǒng)等等,每個(gè)系統(tǒng)做的事情則是更細(xì)分的領(lǐng)域。
茉莉紅交所(紅探長(zhǎng))項(xiàng)目是筆者入職茉莉數(shù)科集團(tuán)后做的第一個(gè)項(xiàng)目,也是一個(gè)新的項(xiàng)目,由于沒有歷史包袱,筆者從零開始搭建整個(gè)項(xiàng)目,因此選擇在該項(xiàng)目試行DDD。
該項(xiàng)目業(yè)務(wù)是OTO(線上到線下)探店,OTO探店就是該項(xiàng)目的領(lǐng)域。
探店其實(shí)是一種商家線上付費(fèi)下單、平臺(tái)為商家匹配達(dá)人、達(dá)人線下探店線上內(nèi)容推廣的內(nèi)容營(yíng)銷模式,可以是品嘗美食或是免門票游玩景點(diǎn)等,達(dá)人最終通過短視頻、直播、圖文內(nèi)容等方式為商家做推廣。那么無論是探美食店、探游樂園,探店都是這個(gè)領(lǐng)域的核心。
在探店這個(gè)領(lǐng)域中,核心的業(yè)務(wù)名詞有:商家、達(dá)人、店鋪、訂單、任務(wù),而核心事件有:店鋪入駐、商家發(fā)布訂單、達(dá)人接單等。
附上此項(xiàng)目對(duì)應(yīng)版本的架構(gòu)設(shè)計(jì)圖:
提示:中間部分不是每個(gè)限界上下文對(duì)應(yīng)一個(gè)微服務(wù),可以是多個(gè)限界上下文合并在一個(gè)微服務(wù)。
限界上下文是業(yè)務(wù)概念的邊界,是業(yè)務(wù)問題最小粒度的劃分。在OTO探店業(yè)務(wù)領(lǐng)域中會(huì)包含多個(gè)限界上下文,我們通過找出這些確定的限界上下文對(duì)系統(tǒng)進(jìn)行解耦,要求每一個(gè)限界上下文其內(nèi)部必須是緊密組織的、職責(zé)明確的、具有較高的內(nèi)聚性。
我們劃分出的限界上下文如下圖所示。
提示:為什么將任務(wù)和訂單拆分為不同限界上下文(任務(wù)不是作為訂單聚合根的實(shí)體,而是作為一個(gè)獨(dú)立聚合的聚合根)?這是因?yàn)樯碳野l(fā)布的一個(gè)訂單允許有不同的多個(gè)達(dá)人接單,一個(gè)達(dá)人也可以接不同商家的訂單,這并不是簡(jiǎn)單的一對(duì)多關(guān)系。這更像是商品與訂單的關(guān)系,而不是訂單與訂單item的關(guān)系。
在劃分出限界上下文后,還需要根據(jù)限界上下文識(shí)別出問題子域。問題子域是對(duì)業(yè)務(wù)問題的劃分,相對(duì)限界上下文來說,是對(duì)業(yè)務(wù)問題更大粒度的劃分。
- 核心(子)域:產(chǎn)品的核心競(jìng)爭(zhēng)力、盈利來源;
- 通用子域:常見的,不同領(lǐng)域都可共用的,可通過購(gòu)買就能使用的;
- 支撐子域:非核心域、又非通用域,具有個(gè)性化需求,用于支撐核心域運(yùn)作;
根據(jù)限界上下文,我們劃分出的子域如下圖所示。
- OTO探店核心域:商家下單、平臺(tái)審核訂單、達(dá)人接單、平臺(tái)審核任務(wù)、達(dá)人回填內(nèi)容鏈接等;
- 店鋪支撐子域:商家店鋪入駐、平臺(tái)審核店鋪、商家綁定店鋪、店鋪轉(zhuǎn)移等;
- ......
在劃分出子域后,我們需要為領(lǐng)域建模。
領(lǐng)域建模是通過將業(yè)務(wù)抽象為聚合、實(shí)體、聚合根、值對(duì)象模型的方式,封裝和承載全部的業(yè)務(wù)邏輯,保持業(yè)務(wù)的高內(nèi)聚和低耦合。
聚合:負(fù)責(zé)封裝業(yè)務(wù)邏輯,內(nèi)聚決策命令和領(lǐng)域事件,容納實(shí)體、聚合根、值對(duì)象。
- 聚合根:也是一種實(shí)體,是聚合的根節(jié)點(diǎn),如訂單;
- 實(shí)體:聚合的主干,具有唯一標(biāo)識(shí)和生命周期,如訂單Item;
- 值對(duì)象:實(shí)體的附加業(yè)務(wù)概念,用于描述實(shí)體所包含的業(yè)務(wù)信息,如訂單收件地址。
70%的場(chǎng)景下,一個(gè)聚合內(nèi)都只有一個(gè)實(shí)體,那就是聚合根。
在技術(shù)實(shí)現(xiàn)上,一個(gè)聚合就是一個(gè)包,里面存放領(lǐng)域服務(wù)、工廠、資源庫(kù)、聚合根、實(shí)體、值對(duì)象。
領(lǐng)域?qū)影膭澐忠?guī)則通常為:
- ----限界上下文
- --domain
- ------聚合A
- -------- (聚合根、實(shí)體、值對(duì)象、領(lǐng)域服務(wù)、資源庫(kù)、領(lǐng)域事件)
- ------聚合B
- -------- (聚合根、實(shí)體、值對(duì)象、領(lǐng)域服務(wù)、資源庫(kù)、領(lǐng)域事件)
特別的,一個(gè)限界上下文可能包含多個(gè)聚合,但一個(gè)聚合只能存在于一個(gè)限界上下文。
以上包的劃分只是領(lǐng)域?qū)拥膭澐郑缶酆细?、?shí)體、值對(duì)象、領(lǐng)域服務(wù)、資源庫(kù)、領(lǐng)域事件等類存放在聚合包下,無論是使用DDD經(jīng)典四層架構(gòu),還是六邊形架構(gòu)。
以店鋪上下文為例,我們采樣六邊形架構(gòu)實(shí)現(xiàn),整個(gè)模塊的層次、包劃分如下:
- com.mmg.hjs.storecontext(限界上下文)
- --adapters(適配層)
- ----api(API接口適配)
- ------webmvc(前端接口,請(qǐng)求從網(wǎng)關(guān)進(jìn)來)
- ------dubbo(內(nèi)部微服務(wù)調(diào)用,實(shí)現(xiàn)應(yīng)用層gateway包下的接口)
- ----persistence(持久化適配)
- ------dao(mybatis的Mapper類與xml文件)
- ------po(對(duì)應(yīng)數(shù)據(jù)庫(kù)表生成的類)
- ------StoreAssembler.class(領(lǐng)域?qū)嶓w轉(zhuǎn)PO的轉(zhuǎn)換器)
- ------StoreRepositoryImpl.class(實(shí)現(xiàn)領(lǐng)域?qū)拥馁Y源庫(kù)接口)
- ----cache(緩存適配)
- --application(應(yīng)用層)
- ----gateway(與其它限界上下文通信并與適配層接耦的抽象接口)
- ----job(可選,定時(shí)任務(wù))
- ----usecase(應(yīng)用服務(wù),按用例拆分多個(gè)類,避免單個(gè)類臃腫)
- ----assembler(聚合根轉(zhuǎn)DTO轉(zhuǎn)換器)
- ----dto(DTO類)
- ----cqe(CQE模式)
- ------command(請(qǐng)求參數(shù))
- ------query(查詢參數(shù))
- ------event(可選,事件參數(shù),注意:非領(lǐng)域事件)
- --domain(領(lǐng)域?qū)樱?/span>
- ----store(店鋪聚合)
- ------model(聚合根、實(shí)體、值對(duì)象)
- ------event(領(lǐng)域事件)
- ------StoreDomainService.class(領(lǐng)域服務(wù))
- ------StoreRepository.class(資源庫(kù)抽象接口)
在分層架構(gòu)模式中,我們需要嚴(yán)格遵循:上層只能依賴下層,下層不能依賴上層。
例如,在一次創(chuàng)建訂單的操作中,用于接收前端請(qǐng)求參數(shù)的CreateOrderCommand屬于應(yīng)用層的類,雖然我們?cè)贑ontroller(接口層)可直接使用CreateOrderCommand,但這屬于上層依賴下層,并且不是領(lǐng)域?qū)?,也并未暴露聚合根?nèi)部結(jié)構(gòu),因此是允許的。
如果反過來,直接將CreateOrderCommand對(duì)象傳遞給聚合根,那就構(gòu)成下層依賴上層了,因此這是不允許的。CreateOrderCommand必須在應(yīng)用層拆解為創(chuàng)建訂單所需要的值對(duì)象,或者實(shí)體對(duì)象,再調(diào)用領(lǐng)域服務(wù)方法,領(lǐng)域服務(wù)方法調(diào)用訂單工廠創(chuàng)建訂單,最后才交給資源庫(kù)持久化訂單聚合根。
在領(lǐng)域?qū)永锩妫鼫?zhǔn)確的說是在聚合包內(nèi),存儲(chǔ)的是聚合下的聚合根、實(shí)體、值對(duì)象、資源庫(kù)、領(lǐng)域服務(wù)、聚合根工廠類,而由于資源庫(kù)的實(shí)現(xiàn)需要依賴orm框架或其它框架實(shí)現(xiàn)持久化聚合根、事件的發(fā)布需要依賴MQ實(shí)現(xiàn)等,所以資源庫(kù)定義成接口由上層實(shí)現(xiàn),事件發(fā)布也定義為接口由上層實(shí)現(xiàn)。
對(duì)于資源庫(kù)與事件發(fā)布者,由于領(lǐng)域服務(wù)需要依賴資源庫(kù)獲取和保存聚合根,也依賴事件發(fā)布者發(fā)布事件,這種情況下我們可以使用Spring框架的自動(dòng)注入,但應(yīng)該使用構(gòu)造函數(shù)注入,而不是在字段上加注解的方式注入,避免注入資源庫(kù)、事件發(fā)布者為NULL的情況,且不應(yīng)該添加Spring框架的注解(盡量不耦合)。
應(yīng)用服務(wù)、領(lǐng)域服務(wù)、聚合根、資源庫(kù)的職責(zé)
在實(shí)現(xiàn)DDD的過程中,我們需要嚴(yán)格遵守代碼規(guī)范才能保持代碼的整潔,否則隨著需求的迭代,項(xiàng)目很容易就失去DDD該有的模樣,變得即不DDD也不MVC。
在DDD中,Repository(資源庫(kù))是聚合根的容器,與DAO扮演相同角色,但它只提供持久化聚合根的操作(新增或更新),以及提供根據(jù)ID獲取聚合根的查詢操作。在所有的領(lǐng)域?qū)ο笾?,只有聚合根才擁有Repository,因?yàn)镽epository不同于DAO,它所扮演的角色只是向領(lǐng)域模型提供聚合根。
資源庫(kù)(Repository) 的職責(zé)是提供聚合根或者持久化聚合根,除此之外應(yīng)「盡可能」的沒有其它行為,否則聚合根就會(huì)嚴(yán)重退化成DAO。
- public interface Repository<DO, KEY> {
- void save(DO obj);
- DO findById(KEY id);
- void deleteById(KEY id);
- }
聚合根與領(lǐng)域服務(wù)(DomainService) 負(fù)則封裝實(shí)現(xiàn)業(yè)務(wù)邏輯,應(yīng)用服務(wù)(ApplicationService) 不處理業(yè)務(wù)邏輯,而只是對(duì)領(lǐng)域服務(wù)/聚合根方法調(diào)用的封裝。
正常情況下,處理一次業(yè)務(wù)請(qǐng)求將經(jīng)過:
- 應(yīng)用服務(wù)->領(lǐng)域服務(wù)->通過資源庫(kù)獲取聚合根
- ->通過資源庫(kù)持久化聚合根
- ->發(fā)布領(lǐng)域事件
但也允許:
- 應(yīng)用服務(wù)->通過資源庫(kù)獲取聚合根
- ->通過資源庫(kù)持久化聚合根
- ->發(fā)布領(lǐng)域事件
對(duì)于不能直接通過聚合根完成的業(yè)務(wù)操作就需要通過領(lǐng)域服務(wù)。
但必須遵守原則:
- 聚合根不能直接操作其它聚合根,聚合根與聚合根之間只能通過聚合根ID引用;
- 同限界上下文內(nèi)的聚合之間的領(lǐng)域服務(wù)可直接調(diào)用;
- 兩個(gè)限界上下文的交互必須通過應(yīng)用服務(wù)層抽離接口->適配層適配。
聚合根工廠負(fù)責(zé)創(chuàng)建聚合根,但并非必須的,可以將聚合根的創(chuàng)建寫到聚合根下并改為靜態(tài)方法,非常復(fù)雜的創(chuàng)建過程才建議寫工廠類。
以修改用戶信息為例,可在應(yīng)用服務(wù)通過資源庫(kù)獲取用戶聚合根,再調(diào)用用戶聚合根的修改用戶信息方法,最后通過資源庫(kù)持久化用戶聚合根。
- public class UserModifyInfoUseCase{
- /**
- * 更新用戶基本信息
- *
- * @param command
- * @param token
- */
- @Transactional(rollbackFor = Throwable.class, isolation = Isolation.READ_COMMITTED)
- public void updateUserInfo(ModifyUserInfoCommand command, Long loginUserId) {
- // 獲取聚合根
- Account account = findByAccountId(loginUserId);
- // 調(diào)用業(yè)務(wù)方法
- account.modifyAccountInfo(AccountInfoValobj.builder()
- .nickname(command.getNickname())
- .avatarUrl(command.getAvatarUrl())
- .country(command.getCountry())
- .province(command.getProvince())
- .city(command.getCity())
- .gender(Sex.valueBy(command.getGender()))
- .build());
- // 通過資源庫(kù)持久化
- repository.save(account);
- // 更新緩存
- accountCache.cache(loginUserId, getUserById(account.getId()));
- }
- }
這里用戶聚合根能到看到自己的信息,用戶自己修改自己的信息可直接通過聚合根完成,因此這種場(chǎng)景下我們不需要領(lǐng)域服務(wù)。
復(fù)雜場(chǎng)景如用戶綁定手機(jī)號(hào)碼就不能直接在領(lǐng)域服務(wù)中完成。
綁定手機(jī)號(hào)碼一般流程為:獲取短信驗(yàn)證碼、校驗(yàn)短信驗(yàn)證碼、校驗(yàn)手機(jī)號(hào)碼是否已經(jīng)綁定了別的賬號(hào)。
其中獲取短信驗(yàn)證碼與校驗(yàn)短信驗(yàn)證碼應(yīng)放在應(yīng)用服務(wù)完成,而校驗(yàn)手機(jī)號(hào)碼是否已經(jīng)綁定了別的賬號(hào)就需要由領(lǐng)域服務(wù)完成,因?yàn)榫酆细鶡o法完成這個(gè)判斷, 聚合根看不到別的賬號(hào),聚合根不能擁有資源庫(kù),且應(yīng)用服務(wù)不能處理業(yè)務(wù)邏輯。
聚合根
- public class Account extends BaseAggregate<AccountEvent>{
- // .....
- private String phone;
- public void bindMobilePhone(String phoneNumber) {
- if (!StringUtils.isEmpty(this.phone)) {
- throw new AccountParamException("已經(jīng)綁定過手機(jī)號(hào)碼了,如需更新可走更換手機(jī)號(hào)碼流程");
- }
- this.phone = phoneNumber;
- }
- }
領(lǐng)域服務(wù)
- @Service
- public class AccountDomainService {
- private AccountRepository repository;
- public AccountDomainService(AccountRepository repository) {
- this.repository = repository;
- }
- public void bindMobilePhone(Long userId, String phone) {
- Account account = repository.findById(userId);
- if (account == null) {
- throw new AccountNotFoundException(userId);
- }
- // 號(hào)碼被其它賬戶綁定了
- boolean exist = repository.findByPhone(phone) != null;
- if (exist) {
- throw new AccountBindPhoneException(phone);
- }
- account.bindMobilePhone(phone);
- repository.save(account);
- }
- }
應(yīng)用服務(wù)
- @Service
- public class UserBindPhoneUseCase {
- /**
- * 綁定手機(jī)號(hào)碼-發(fā)送驗(yàn)證碼
- *
- * @param command
- * @param token
- */
- public void bindMobilePhoneSendVerifyCode(VerifyCodeSendCommand command, Long loginUserId) {
- // 生成驗(yàn)證碼
- String verifyCode = ValidCodeUtils.generateNumberValidCode(4);
- // 緩存驗(yàn)證碼
- verifyCodeCache.save(command.getPhone(),verifyCode,timeout);
- // 調(diào)用消息服務(wù)發(fā)送驗(yàn)證碼
- messageClientGateway.sendSmsVerifyCode(command.getPhone(), verifyCode);
- }
- /**
- * 綁定手機(jī)號(hào)碼-提交綁定
- *
- * @param command
- * @param token
- */
- public void bindMobilePhone(BindPhoneCommand command, Long userId) {
- // 校驗(yàn)驗(yàn)證碼
- String verifyCode = verifyCodeCache.get(command.getPhone());
- if (!command.getVerifyCode().equalsIgnoreCase(verifyCode)) {
- throw new VerifyPhoneCodeApplicationException();
- }
- // 通過領(lǐng)域服務(wù)綁定手機(jī)號(hào)碼
- accountDomainService.bindMobilePhone(userId, command.getPhone());
- // 更新賬號(hào)緩存
- accountCache.cache(userId, getUserById(userId));
- }
- }
接口層
- @RestController
- @RequestMapping("account/bindMobilePhone")
- public class UserBindPhoneController {
- @Resource
- private UserBindPhoneUseCase useCase;
- @ApiOperation("綁定手機(jī)號(hào)-獲取驗(yàn)證碼")
- @GetMapping("/verifyCode")
- public Response<Void> bindMobilePhone(@RequestParam("phone") String phone) {
- Long userId = WebUtils.getLoginUserId();
- useCase.bindMobilePhoneSendVerifyCode(phone, userId);
- return Response.success();
- }
- @ApiOperation("綁定手機(jī)號(hào)-提交綁定")
- @PostMapping("/submit")
- public Response<Void> bindMobilePhone(@RequestBody @Validated BindPhoneCommand command) {
- Long userId = WebUtils.getLoginUserId();
- useCase.bindMobilePhone(command, userId);
- return Response.success();
- }
- }
CQE模式
CQE即Command、Query、Event。接收前端創(chuàng)建訂單請(qǐng)求使用Command,接收前端分頁查詢請(qǐng)求使用Query,消費(fèi)事件(非領(lǐng)域事件)則使用Event。
除Event外,所有寫請(qǐng)求都應(yīng)該使用Command接收參數(shù),而所有查詢都應(yīng)該使用Query接收參數(shù),只在參數(shù)只有一個(gè)ID的查詢情況下,可省略Query。
在查詢分離情況下,Query是可直接傳遞到DAO的(接口層->應(yīng)用層->DAO)。因此使用Query封裝查詢條件能夠提高方法的復(fù)用,當(dāng)添加查詢條件時(shí),無需給方法加多一個(gè)參數(shù)。
CQRS模式
CQRS(Command Query Resposibility Segregation),即命令查詢職責(zé)分離模式。軟件模型中存在讀模型和寫模型之分,以我們寫業(yè)務(wù)代碼的經(jīng)驗(yàn)也知道,一次請(qǐng)求,要么是作為一個(gè)“命令”執(zhí)行一次操作,要么作為一個(gè)”查詢“向調(diào)用方返回?cái)?shù)據(jù),兩者不可能共存。CQRS是將“命令”和“查詢”分別使用不同的對(duì)象模型來表示。
CQRS的讀操作放在應(yīng)用層。
共享存儲(chǔ)-共享模型-CQRS
共享存儲(chǔ)指同一個(gè)表結(jié)構(gòu)存儲(chǔ)數(shù)據(jù),共享模型指使用聚合根從數(shù)據(jù)庫(kù)讀取數(shù)據(jù)。
例如查詢訂單詳情,訂單的聚合根為Order。
- // 訂單聚合根
- public class Order extends BaseAggregate {
- }
在應(yīng)用層OrderDetailsUseCase通過OrderRepository查詢訂單聚合根,再調(diào)用裝配器將聚合根轉(zhuǎn)為讀模型。
- public class OrderDetailsUseCase {
- public OrderDto byId(String id) {
- Order order = orderRepository.byId(id);
- return orderDaoAssembler.toDto(order);
- }
- }
- OrderDaoAssembler方法是將Order轉(zhuǎn)為讀模型實(shí)體,也就是將DO轉(zhuǎn)為DTO。
注意:讀操作和寫操作不要寫在同一個(gè)應(yīng)用服務(wù)中,避免耦合,且應(yīng)用服務(wù)應(yīng)按用例拆分多個(gè)類(不需要很細(xì)),避免應(yīng)用服務(wù)越寫越臃腫。
共享存儲(chǔ)-讀寫分離模型-CQRS
共享存儲(chǔ)-讀寫分離模型指讀寫還是操作同一張表,只是寫模型與讀模型不同,寫通過聚合根操作,而讀模型繞過聚合根、Repository,直接操作數(shù)據(jù)庫(kù),此時(shí)的讀模型就是用于裝載從數(shù)據(jù)庫(kù)查詢的數(shù)據(jù),并且不需要再作轉(zhuǎn)換就可以響應(yīng)給調(diào)用方,這里的讀模型就是DTO。
對(duì)于單個(gè)聚合根內(nèi)的查詢,使用「共享存儲(chǔ)-讀寫分離模型」模型可以應(yīng)付復(fù)雜的查詢場(chǎng)景,并且可以提升性能。
對(duì)于需要跨多個(gè)聚合根的查詢,「共享存儲(chǔ)-共享模型」無法實(shí)現(xiàn)此需求場(chǎng)景,而分別查詢多個(gè)聚合根后,再合并查詢結(jié)果不僅是將原本簡(jiǎn)單的事情變復(fù)雜,還大大影響性能,因此更有必要采用「共享存儲(chǔ)-讀寫分離模型」。
對(duì)于查詢訂單詳情希望帶上商品信息,如果商品與訂單在同一個(gè)服務(wù),并且同一個(gè)數(shù)據(jù)庫(kù),那么便可以使用join多表查詢。
共享存儲(chǔ)-讀寫分離模型-CQRS實(shí)戰(zhàn)舉例:
接口層
- @RequestMapping("/order")
- @RestController
- public class OrderQueryController {
- @GetMapping("/query")
- public Response<PageInfo<OrderQueryDto>> queryOrder(OrderQuery query) {
- return Response.success(orderQueryUseCase.queryOrder(query,WebUtils.getLoginUserId()));
- }
- }
應(yīng)用層
- @Service
- public class OrderQueryUseCase implements Cqrs {
- public PageInfo<OrderQueryDto> queryOrder(OrderQuery query, Long loginUserId) {
- Long merchantId = merchantGateway.getMerchantId(loginUserId);
- IPage<OrderQueryDto> orderPage = new Page<>(query.getPage(), query.getPageSize());
- List<OrderQueryDto> orders = orderMapper.selectOrderBy(merchantId,query,orderPage);
- PageInfo<OrderQueryDto> pageInfo = new PageInfo<>(page, pageSize);
- pageInfo.setTotalCount((int) orderPage.getTotal());
- pageInfo.setList(orders);
- return pageInfo;
- }
- }
讀寫分離存儲(chǔ)-讀寫分離模型
即讀與寫操作不同數(shù)據(jù)庫(kù)。例如,對(duì)于查詢訂單詳情希望帶上商品信息,如果商品是一個(gè)微服務(wù)、訂單是一個(gè)微服務(wù),并且兩個(gè)微服務(wù)使用不同的數(shù)據(jù)庫(kù),如果要提升性能,就需要通過額外的數(shù)據(jù)同步服務(wù),將訂單與商品查詢結(jié)果合并后存入一個(gè)新的表(分離存儲(chǔ))或者是存儲(chǔ)到NoSQL數(shù)據(jù)庫(kù)。數(shù)據(jù)同步可通過底層數(shù)據(jù)庫(kù)Binlog+Kafka消費(fèi)實(shí)現(xiàn),還有一種是通過消費(fèi)領(lǐng)域事件實(shí)現(xiàn),但影響應(yīng)用性能。
提示:對(duì)于復(fù)雜的報(bào)表統(tǒng)計(jì),建議通過Binlog+Kafka同步到一張大表,為不與業(yè)務(wù)耦合,應(yīng)獨(dú)立為一個(gè)數(shù)據(jù)服務(wù)。
領(lǐng)域事件的發(fā)布
在DDD中有一個(gè)原則,一個(gè)業(yè)務(wù)用例對(duì)應(yīng)一個(gè)事務(wù),一個(gè)事務(wù)對(duì)應(yīng)一個(gè)聚合根,即在一次事務(wù)中只能對(duì)一個(gè)聚合根操作。
但在實(shí)際應(yīng)用中,一個(gè)業(yè)務(wù)用例往往需要修改多個(gè)聚合根,而不同的聚合根可能在不同的限界上下文中,引入領(lǐng)域事件即不破壞DDD的一個(gè)事務(wù)只修改一個(gè)聚合根的原則,也能實(shí)現(xiàn)限界上下文之間的解耦。
在DDD中,領(lǐng)域?qū)邮菢I(yè)務(wù)邏輯的具體實(shí)現(xiàn),所有以解決問題子域的業(yè)務(wù)代碼都高度內(nèi)聚在限界上下文中、高度內(nèi)聚在聚合中,即聚合根、實(shí)體以及領(lǐng)域服務(wù)內(nèi)。
對(duì)于領(lǐng)域事件發(fā)布,我們的實(shí)現(xiàn)是在聚合根中臨時(shí)保存,最后在領(lǐng)域服務(wù)/應(yīng)用服務(wù)發(fā)布,領(lǐng)域?qū)映橄笫录l(fā)布接口,由適配器層實(shí)現(xiàn),并注入到領(lǐng)域服務(wù)/應(yīng)用服務(wù)。
一個(gè)原因是領(lǐng)域?qū)硬粦?yīng)該依賴其它框架的Api,另一個(gè)原因則與領(lǐng)域事件是由聚合根/領(lǐng)域服務(wù)創(chuàng)建有關(guān)。
那為什么領(lǐng)域事件由聚合根/領(lǐng)域服務(wù)創(chuàng)建,而不是在應(yīng)用層創(chuàng)建?
發(fā)布領(lǐng)域事件當(dāng)然是在領(lǐng)域?qū)影l(fā)出,可以是聚合根發(fā)出,也可以在領(lǐng)域服務(wù)發(fā)出,業(yè)務(wù)在聚合下高度內(nèi)聚,什么時(shí)候該發(fā)出什么事件也只有聚合內(nèi)最清楚,應(yīng)用服務(wù)不過是封裝業(yè)務(wù)實(shí)現(xiàn)的步驟。
由于業(yè)務(wù)邏輯最核心的實(shí)現(xiàn)是聚合根內(nèi),而聚合根是一個(gè)實(shí)體,不能說每次構(gòu)造聚合根都傳入一個(gè)事件發(fā)布者,那從資源庫(kù)獲取聚合根時(shí)又由誰傳入事件發(fā)布者?
所以推薦的做法是在聚合根下臨時(shí)存儲(chǔ)聚合根發(fā)出的事件,在領(lǐng)域服務(wù)中、在調(diào)用資源庫(kù)持久化聚合根之后再發(fā)布領(lǐng)域事件。當(dāng)然,在不使用領(lǐng)域服務(wù)的情況下,則由應(yīng)用層在調(diào)用資源庫(kù)持久化聚合根之后再發(fā)布領(lǐng)域事件。
應(yīng)用服務(wù)一個(gè)業(yè)務(wù)用例只是對(duì)應(yīng)領(lǐng)域服務(wù)方法的一層很薄的封裝,即不會(huì)在一個(gè)應(yīng)用服務(wù)方法中調(diào)用兩個(gè)領(lǐng)域服務(wù)方法。這實(shí)際上也是我們要注意,如果出現(xiàn)這種情況,說明領(lǐng)域服務(wù)方法封裝的不夠好。所以領(lǐng)域事件由領(lǐng)域服務(wù)發(fā)布是允許的,但事件發(fā)布者必須抽象為接口,與資源庫(kù)一樣在領(lǐng)域服務(wù)的構(gòu)建方法傳入。
關(guān)于我們?yōu)槭裁聪韧ㄟ^Spring框架發(fā)布事件再在訂閱者中實(shí)現(xiàn)將事件發(fā)布到MQ。
一個(gè)事件可能當(dāng)前限界上下文內(nèi)也需要消費(fèi),即可能有多個(gè)限界上下文需要消費(fèi),一個(gè)事件對(duì)應(yīng)多個(gè)消費(fèi)者。
如訂單限界上下文內(nèi)訂單聚合產(chǎn)生的創(chuàng)建訂單事件,訂單限界上下文內(nèi)需要消費(fèi)訂單創(chuàng)建事件,用于構(gòu)造消息通知,然后給消息通知隊(duì)列寫入消息;同時(shí),其它限界上下文也需要消費(fèi)訂單創(chuàng)建事件。因此,一個(gè)領(lǐng)域事件我們可能需要發(fā)布到多個(gè)消息隊(duì)列中。
先通過Spring框架發(fā)布事件再在訂閱者中實(shí)現(xiàn)將事件發(fā)布到MQ其實(shí)是借助Spring框架實(shí)現(xiàn)責(zé)任鏈模式。這當(dāng)然不是必須的,也算不上是規(guī)范。
但不管如何,不要直接在領(lǐng)域服務(wù)/應(yīng)用服務(wù)中調(diào)用MQ的API直接發(fā)布事件,發(fā)布事件到MQ應(yīng)在適配層實(shí)現(xiàn)。并且封裝事件發(fā)布者的好處在于,當(dāng)需要確保消息至少投遞一次時(shí),這些邏輯不需要寫在應(yīng)用服務(wù)中。
最令人頭疼的代碼
在實(shí)戰(zhàn)DDD的過程中,我們編寫最多的代碼無疑就是DO(聚合根)轉(zhuǎn)DTO(讀模型)以及DO轉(zhuǎn)PO(映射到數(shù)據(jù)庫(kù)表)和PO轉(zhuǎn)DO的轉(zhuǎn)換器代碼。百分之八十的BUG都來自這些整齊劃一的屬性拷貝代碼,容易漏字段。
那為什么需要這么多層轉(zhuǎn)換呢,直接將聚合根響應(yīng)給請(qǐng)求、直接持久化聚合根不行嗎?
首先,在DDD中我們必須先獲取到聚合根再通過聚合根完成業(yè)務(wù)邏輯,最終通過資源庫(kù)持久化聚合根。
為什么需要將DO轉(zhuǎn)PO,這是必須要做的事情嗎?
如果我們選擇關(guān)系型數(shù)據(jù)庫(kù)持久化聚合根,那么就可能需要將聚合根拆分存儲(chǔ)到多個(gè)表,并且對(duì)于枚舉類型我們也需要轉(zhuǎn)成數(shù)值類型再存儲(chǔ)?;谶@些場(chǎng)景就需要將聚合根轉(zhuǎn)為PO再調(diào)用對(duì)應(yīng)表的DAO存儲(chǔ)到數(shù)據(jù)庫(kù)中。
為什么需要將DO轉(zhuǎn)DTO?
除了我們必須要遵守不暴露聚合根內(nèi)部結(jié)構(gòu)給外部之外,前端需要的數(shù)據(jù)也是不一樣的,比如我們需要將枚舉類型字段拆成值和名稱兩個(gè)字段,以及需要屏蔽一些字段。
為了不暴露聚合根內(nèi)部結(jié)構(gòu),聚合根應(yīng)只暴露GET方法,用于外部獲取字段值,同時(shí)使用Builder模式提供builder方法給資源庫(kù)(使用裝配器)將PO轉(zhuǎn)為聚合根。對(duì)于實(shí)體也可以這樣做,而值對(duì)象只提供所有參數(shù)的構(gòu)造方法和GET方法。當(dāng)然了,使用哪種做法都沒有錯(cuò)。
對(duì)于通用的轉(zhuǎn)換操作我們?yōu)槊總€(gè)聚合根提供一個(gè)實(shí)現(xiàn)將DO(聚合根)轉(zhuǎn)為DTO的裝配器(轉(zhuǎn)換器)、以及一個(gè)實(shí)現(xiàn)PO和DO相互轉(zhuǎn)換的裝配器(轉(zhuǎn)換器)。
基于這種實(shí)現(xiàn),筆者也尋找過能夠解決這些繁瑣操作提升工作效率的方法,我們?cè)囘^用mapstruct框架,但mapstruct也只適用于簡(jiǎn)單的聚合根,對(duì)于復(fù)雜內(nèi)部結(jié)構(gòu)的聚合根映射也需要寫一堆注解,工作量沒有減少反而增加了問題排查的難度。使用Spring提供的屬性拷貝工作類也是一樣的,無法解決問題。
優(yōu)化聚合根的持久化性能
對(duì)于使用關(guān)系型數(shù)據(jù)庫(kù)持久化聚合根的場(chǎng)景,在“只能通過Repository的save方法持久化聚合根”這個(gè)約束下,save方法在性能上是有非常大的損耗的,因?yàn)楦乱粋€(gè)聚合根需要同時(shí)更新聚合根下的實(shí)體。為了降低性能影響,可在更新之前對(duì)比一下內(nèi)存中的快照,只對(duì)有更新的實(shí)體執(zhí)行更新操作,筆者單獨(dú)寫了一篇文章介紹如何實(shí)現(xiàn):《DDD資源庫(kù)Repository的性能優(yōu)化》。
如今分布式數(shù)據(jù)庫(kù)已經(jīng)成熟,不建議在新項(xiàng)目中引入分庫(kù)分表ORM框架以及分布式事務(wù)框架,更不建議使用分庫(kù)分表,這些應(yīng)該交由底層數(shù)據(jù)庫(kù)完成,或增加一層代理完成。
總結(jié)
因?yàn)镈DD缺少權(quán)威性的實(shí)踐指導(dǎo)和代碼約束,我們只能是通過實(shí)踐慢慢積累經(jīng)驗(yàn)。個(gè)人的理解也并非完全正確的,對(duì)于限界上下文的劃分,我們只是憑經(jīng)驗(yàn)劃分,但又缺少經(jīng)驗(yàn),新業(yè)務(wù)也處于不斷摸索狀態(tài),現(xiàn)在的限界上下文劃分、建模不代表將來不會(huì)推倒重來。
參考文獻(xiàn):
領(lǐng)域驅(qū)動(dòng)實(shí)戰(zhàn)思考(三):DDD的分段式協(xié)作設(shè)計(jì)
領(lǐng)域驅(qū)動(dòng)設(shè)計(jì)(DDD)在美團(tuán)點(diǎn)評(píng)業(yè)務(wù)系統(tǒng)的實(shí)踐
領(lǐng)域驅(qū)動(dòng)設(shè)計(jì)在愛奇藝打賞業(yè)務(wù)的實(shí)踐
《領(lǐng)域驅(qū)動(dòng)設(shè)計(jì)(Thoughtworks洞見)》
本文轉(zhuǎn)載自微信公眾號(hào)「Java藝術(shù)」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請(qǐng)聯(lián)系Java藝術(shù)公眾號(hào)。