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