領域驅動程式設計,程式碼怎麼寫?

一  前言

相較於大家熟練使用的 MVC 分層架構,領域驅動設計更適用於複雜業務系統和需要持續迭代的軟體系統的架構模型。關於領域驅動設計的概念及優勢,可以參考的文獻非常多,大多數的同學都看過相關的書籍,所以本文不討論領域驅動概念層面的東西,而是試圖從程式設計實踐的層面,對領域驅動開發做一些簡單的介紹。

加入阿里健康之後,我所在的團隊也在積極推進領域驅動設計的應用,相關同學也曾給出優秀的腳手架程式碼,但目前看起來落地情況並不太理想,個人淺見,造成這種結果主要有四個原因。
  1. 大家更熟悉 MVC 的程式設計模式,需要快速實現某個功能的時候,往往傾向於使用較為穩妥、熟悉的方式。
  2. 大家對領域驅動程式設計應該怎麼編寫並沒有一個統一的認知(Axon Framework[1] 對領域驅動設計實現的非常好,但它太“重”了)。
  3. DDD 落地本身就比較難,往往需要事件驅動和 Event Store 來完美實現,而這二者是我們不常用的。
  4. 領域驅動設計是面向複雜系統的,業務發展初期看上去都比較簡單,一上來就搞領域驅動設計有過度設計之嫌。這也是領域驅動設計常常在系統不得不重構的是時候才被拿出來討論的原因。
筆者曾在研發過程中研究、實踐過領域驅動程式設計,對領域驅動框架 Axon Framework 也做了深入的瞭解,(也許是因為業務場景相對簡單)當時落地效果還不錯。拋卻架構師的視角,從一線研發同學的角度來看,基於領域驅動程式設計的核心優勢在於:
  1. 實施面向物件的程式設計模式,進而實現高內聚、低耦合。
  2. 在複雜業務系統的迭代過程中,保證程式碼結構不會無限制地變得混亂,因此保證系統可持續維護。
領域驅動開發最重要的當然是正確地進行領域拆解,這個拆解工作可以在理論的指導下,結合設計者對業務的深入分析和充分理解進行。本文假定開發前已經進行了領域劃分,側重於研究編碼階段具體如何實踐才能體現領域驅動的優勢。

二  保險領域知識簡介

以保險業務為例來進行程式設計實踐,一個高度抽象的保險領域劃分如圖所示。透過用例分析,我們把整個業務劃分成產品域、承保、核保、理賠等多個領域(Bounded-Context),每個領域又可以根據業務發展情況拆分子域。當然,完備保險業務要比圖中展現的複雜太多,這裡我們不作為業務知識介紹的篇章,只是為了方便後續的程式碼實踐。

三  領域驅動開發的程式碼結構

1  領域驅動的程式碼分層

可以使用不同的 Java 專案釋出不同的微服務對領域進行隔離,也可以在同一個 Java 專案中,使用不同 module 進行領域隔離。這裡我們使用 module 進行領域隔離的實現。但是無論採用何種方式進行領域隔離,領域之間的互動只能使用對方的二方包或者 API 層提供的 HTTP 服務,而不能直接引入其他領域的其他服務。

在每個領域內部,相對於 MVC 對應用三層架構的拆分,領域驅動的設計將應用模組內部分為如圖示的四層。

使用者介面層

負責直接面向外部使用者或者系統,接收外部輸入,並返回結果,例如二方包的實現類、Spring MVC 中的 Controller、特定的資料檢視轉換器等通常位於該層。在程式碼層面常常使用的包命名可以是 interface, api, facade 等。使用者介面層的入參、出參類定義採用 POJO 風格。

使用者介面層是輕的一層,不含業務邏輯。安全認證,簡單的入參校驗(例如使用 @Valid 註解),訪問日誌記錄,統一的異常處理邏輯,統一返回值封裝應當在這層完成。

使用者介面層所需要的功能實現是由應用層完成,這裡一般不需要進行依賴倒置。編碼時,該層可以直接引入應用層中定義的介面,因而該層依賴應用層。需要注意的是,雖然理論上使用者介面層可以直接使用領域層和基礎設施層的能力,但這裡建議大家在對這種用法熟練掌握前,最好採用嚴格的分層架構,即當前層只依賴其下方相鄰的一層。
應用層

應用層具體實現介面層中需要功能,但該層並不實現真正的業務規則,而是根據實際的 use case 來協調呼叫領域層提供的能力。

訊息傳送、事件監聽、事務控制等建議在這一層實現。在程式碼層面常常使用的包命名可以是 application, service, manager 等。它用來取代 Spring MVC 中 service 層,並把業務邏輯轉移到領域層。
領域層

領域層面向物件的,它主要用來體現和實現領域裡的物件所具備的固有能力。因此,在領域驅動程式設計中,領域層的程式設計實現是不允許依賴其他外部物件的,領域層的程式設計是在我們對領域內的物件所具備的固有能力和它要在當前業務場景下展現什麼樣的能力有一定了解後,可以直接編碼實現的。

例如我們最開始接觸面向物件的程式設計的時候,常常會遇到的一個例子是鳥會飛、狗會游泳,假設我們的業務域只關心這些物件的運動,我們可以做如下的實現。
publicinterfaceMoveable{voidmove();}publicabstractclassAnimalimplementsMoveable{}publicclassBirdextendsAnimal{publicvoidmove(){//try to fly System.out.println("I'am flying"); }}publicclassDogextendsAnimal{publicvoidmove(){//try to swim System.out.println("I'am swimming"); }}
基於領域驅動的程式設計需要這樣(充血模型)去實現物件的能力,而不是像我們在 MVC 架構中常常使用貧血模型,把業務邏輯寫在 service 中。

當然,即使採用了這樣的程式設計方式,距離實現領域驅動還差的遠,一些看似簡單的問題就可能給我們帶來巨大的不安感。例如複雜的物件應當如何初始化和持久化?同樣一個事物在不同領域都存在,但其關注點不同時這個事物應當分別怎麼抽象?不同領域的物件需要對方的資訊時,應當怎麼獲取?

這些問題,我們也會在程式碼示例部分嘗試給出一些參考的方案。
基礎設施層

基礎設施層為上面各層提供通用的技術能力,例如監聽、傳送訊息的能力,資料庫/快取/NoSQL資料庫/檔案系統等倉儲的 CRUD 能力等。

2  小結

根據對領域驅動設計各層的進一步分析,一個更加具體化的分層結構如下。
基於上面的分層原則,前述保險領域一個可以參考的程式碼結構如下,我們將在下面編碼示例詳細講解每一個分包的理念和作用。

四  領域驅動開發的程式碼

理論上,DOMAIN 不依賴其他層次且是業務核心,我們應當先編寫領域層程式碼,但是一則由於我們對保險領域知識的欠缺,可能不清楚保單到底有哪些固有能力;二則為了便於講解,因此我們直接藉助一個用例來展示程式碼。

1  用例

  1. 使用者在前端頁面選擇保險產品,選擇可選的保障責任,輸入投/被保人資訊,選擇支付方式(分期/躉交等)並支付後提交投保請求;
  2. 服務端接受投保請求 -> 核保 -> 出單 -> 下發保單權益。
    這裡用例 1 是用例 2 的前置用例,我們假定用例 1 已經順利完成(用例 1 中完成了費率計算),只來實現用例 2,並且用例 2 也只是大略的實現,只要能把程式碼樣式展示即可。

2  使用者介面層程式設計實踐

分包結構
其中 client 是對 inusurance-client (公共二方包) 部分的實現,web 是 rest 風格介面的實現。
用例程式碼
@AllArgsConstructor@RestController@RequestMapping("/insure")publicclassPolicyController{privatefinal InsuranceInsureService insuranceInsureService;/** * 投保出單 * @param request * @return 保單 ID */@RequestMapping(value = "/issue-policy", method = RequestMethod.POST)public String issuePolicy(IssuePolicyRequest request){return insuranceInsureService.issuePolicy(request); }}
這裡用到的入參和返回值的類都在應用層中定義。

3  應用層程式設計實踐

1、分包結構
  • 其中最外層介面是面向具體業務場景的,可以根據業務發展再進行分包。
  • pojo 包中定義了應用層用到的各種資料類(上面的 IssuePolicyRequest 就在這裡)及其向其他層傳播時需要進行型別轉換的轉化器。
  • tasks 包中定義了一些定時任務的入口。
注意,在領域程式設計實踐中,會需要非常多的型別轉換,我們可以藉助一些框架(例如 MapStruct[2])來減少這些型別轉換給我們帶來的繁瑣工作。
2、用例程式碼
@Service@AllArgsConstructorpublicclassInsuranceInsureServiceImplimplementsInsuranceInsureService{privatefinal PolicyFactory policyFactory;privatefinal StakeHolderConvertor stakeHolderConvertor;privatefinal PolicyService policyService;/** * 事務控制一般在應用層 * 但是需要注意底層儲存對事務的支援特性 * 底層是分庫分表時,可能需要其他手段來保證事務,或者將非核心的操作從事務中剝離(例如資料庫 ID 生成) */@Override@Transactional(rollbackFor = Exception.class)public String issuePolicy(IssuePolicyRequest request){ Policy policy = policyFactory.createPolicy(request.getProductId(), stakeHolderConvertor.convert(request.getStakeHolders()));//出單流程控制 policyService.issue(policy); PolicyIssuedMessage message = new PolicyIssuedMessage(); message.setPolicyId(policy.getId()); MQPublisher.publish(MQConstants.INSURANCE_TOPIC, MQConstants.POLICY_ISSUED_TAG, message);return policy.getId().toString(); }}
這裡程式碼展示的是應用層對用例 2 的處理。
    • 使用領域層的工廠類構建 Policy 聚合。如果需要傳遞複雜物件,需要先用型別轉換器將應用層的資料類轉化為領域層的實體類或者值物件。
    • 使用領域層服務控制出單流程
    • 傳送出單成功訊息,其他領域監聽到感興趣的訊息會進行響應。

4  領域層程式設計實踐

1、分包結構
這裡領域層一共有5個一級分包。
  • anticorruption 是領域防腐層,是當前領域需要獲知其他領域或者外部資訊時,對其他領域二方包的封裝。防腐層從程式碼層面來看,可以避免呼叫外部客戶端時,在領域內部進行復雜的引數拼裝和結果的轉換。
  • factory 解決了複雜聚合的初始化問題。我們設計好領域模型供外部呼叫,但如果外部也必須使用如何裝配這個物件,則必須知道物件的內部結構。對呼叫方開發來說這是很不友好的。其次,複雜物件或者聚合當中的領域知識(業務規則)需要得到滿足,如果讓外部自己裝配複雜物件或聚合的話,就會將領域知識洩露到呼叫方程式碼中去。需要注意的是,這裡主要是把聚合或實體需要的資料填充進來,而不涉及物件的行為。
    因此這裡工廠的核心作用是從各處拉取初始化聚合或實體所需要的外部資料。
@Service@AllArgsConstructorpublicclassPolicyFactory{/** * 產品領域防腐層服務 */privatefinal ProductService productService;/** * 從各種資料來源查詢直接能查到的前置資料,填充到 policy 中 * @param productId * @param stakeHolders * @return */public Policy createPolicy(Long productId, List<StakeHolder> stakeHolders) { PolicyProduct product = productService.getById(productId);//其他填充資料,這裡呼叫了聚合自身的靜態工廠方法 Policy policy = Policy.create(product, stakeHolders);return policy; }}
  • model 中是領域物件的定義。其中 vo 包中定義了領域內用到的值物件。可以看到這裡有PolicyProduct 這樣一個保險產品類,在投保領域,我們關注的是和保單相關的某個產品及其快照資訊,因此我們在這裡定義一個保單保險產品類,防腐層負責把從產品域獲得的保險產品資訊轉換為我們關心的保單保險產品類物件。
    按照領域驅動設計的最佳實踐,領域物件模型中不允許出現 service、repository 這些用以獲取外部資訊的東西,它的核心概念是一個完備的實體初始化完成後,它能做什麼,或者它經歷了什麼之後狀態會發生怎樣的變化。

    下面是領域核心心的聚合 
    Policy 的示例程式碼。
@GetterpublicclassPolicy{private Long id;private PolicyProduct product;private List<StakeHolder> stakeHolders;private Date issueTime;/** * 工廠方法 * @param product * @param stakeHolders * @return */publicstatic Policy create(PolicyProduct product, List<StakeHolder> stakeHolders){ Policy policy = new Policy(); policy.product = product; policy.stakeHolders = stakeHolders;return policy; }/** * 保單出單 */publicvoidissue(Long id){this.id = id;this.issueTime = new Date(); }}
  • repository 是倉儲包,只定義倉儲介面,不關心具體實現,具體的實現交由基礎設施層負責,體現了依賴倒置的思想。
  • service 是領域服務,它定義一些不屬於領域物件的行為,但是又有必要的操作,比如一些流程控制。
2、用例程式碼
@Service@AllArgsConstructorpublicclassPolicyService{privatefinal InsureUnderwriteService insureUnderwriteService;privatefinal PolicyRepository policyRepository;publicvoidissue(Policy policy){if(!insureUnderwriteService.underwrite(policy)){thrownew BizException("核保失敗"); } policy.issue(IdGenerator.generate());//儲存資訊//policyRepository.save(policy); policyRepository.create(policy); }}
這裡注意我們注掉了一行 policyRepository.save(policy);,那麼為什麼要區別 save 和 create 呢?

save 是領域驅動設計中最正確的做法:我的聚合或者實體有變動,倉儲不用關心是新建還是更新,幫我儲存起來就好了。聽上去很美好,但對關係型資料庫儲存卻是很不友好的。因此,在我們的場景裡,需要違背一下書上所謂的最佳實踐,我們告訴倉儲是要新建還是更新,甚至如果是更新的話更新的是哪些列。
另外領域驅動的最佳實踐是基於事件驅動的,AxonFramework 對其有完美的實現,應用層發出一個 IssuePolicyCommand 指令,領域層接收該指令,完成保單建立後發出PolicyIssuedEvent,該 event 會被監聽並且持久化到 event store 中。這種方式目前看起來在我們這裡落地的可能性不大,不做更多介紹。

5  基礎設施層程式設計實踐

1、分包結構
這裡只展示了 repository 的實現,但實際上這裡還有 RPC 呼叫的二方包實現類注入等很多內容。上文說到領域層不關心倉儲的實現,交由基礎設施層負責。基礎設施層可以根據需要使用關係型資料庫、快取或者NoSQL,領域層是無感知的。這裡我們以關係型資料庫為例來,dao 和 dataobject 等都可以使用例如 mybatis generator 等工具生成,領域物件 和 dataobject 之間的轉換由 convertor 負責。
2、用例程式碼
@Repository@AllArgsConstructorpublicclassPolicyRepositoryImplimplementsPolicyRepository{privatefinal PolicyDAO policyDAO;privatefinal StakeHolderDAO stakeHolderDAO;privatefinal PolicyConvertor policyConvertor;privatefinal StakeHolderConvertor stakeHolderConvertor;@Overridepublic String save(Policy policy){thrownew UnsupportedOperationException(); }@Overridepublic String create(Policy policy){ policyDAO.insert(policyConvertor.convert(policy)); stakeHolderDAO.insertBatch(stakeHolderConvertor.convert(policy));//...其它資料入庫return policy.getId().toString(); }@OverridepublicvoidupdatePolicyStatus(String newStatus){ }}
這部分程式碼比較簡單,無需贅言。

五  結語

關於領域驅動,筆者仍處於初學者階段,再好的設計,隨著業務的發展,程式碼也難免變得混亂,這個過程中,每個參與者都有責任。最後,總結一下我們維持程式碼初心的一些原則,和大家分享。
  • 深入理解業務場景,分析用例,進行正確的領域劃分。
  • 確定好實現方式後,大家儘量按照既定模式/風格程式設計,有異議的地方可以一起討論後統一改動。
  • 不引入不必要的複雜度。
  • 不斷對系統設計進行最佳化改進,對繁瑣的程式碼,用設計模式進行最佳化。
  • 寫註釋。
[1]https://docs.axoniq.io/reference-guide
[2]https://mapstruct.org

開發者評測局特別節目暨無影評測大賽頒獎典禮
重磅來襲!
阿里雲開發者社群重磅評測欄目《開發者評測局》暨無影評測大賽頒獎典禮重磅開播。CSDN TOP 1 博主“處女座程式猿”、清華大學教授卓晴,蘇寧消金安全運維總經理顧黃亮等來自開發者、高校、企業的參賽代表嘉賓與無影內部團隊展開了深度圓桌論壇,共話“雲時代雲辦公”。點選閱讀原文檢視完整影片!

相關文章