CQRS與Event Sourcing之淺見

引言

DDD是近年軟件設計的熱門。CQRS與Event Sourcing做爲實施DDD的一種選擇,也逐步進入人們的視野。圍繞這兩個主題,軟件開發的大咖[Martin Fowler][Greg Young][Udi Dahan]分別有所論述,[MSDNC QRS Journey][Implementing DDD][Patterns, Principles, and Practices of DDD]等著述也提供了範例,國內外各大論壇的文章和DDD開源框架更是數不勝數,爲學習CQRS和Event Sourcing提供了大量指導。html

其中,Greg Young的論文最爲系統。故本文經過解讀其論文,簡單梳理了CQRS與Event Sourcing的發展脈絡,釐出其中的主要技術重點,並提出以Akka做爲落地方案,以求對這兩個主題有一個較爲全面的總結。錯謬之處,還望指正。前端

本文並未就DDD的相關方法和戰術模式等進行介紹。git

傳統系統等於數據庫表

這是典型的傳統架構,其中Application Service是Domain Model的屏風,負責與Client打交道:github

Figure

在傳統架構下,數據在UI、模型和持久化的數據庫之間流動,遵循下圖所示的循環。數據庫

Figure

系統從數據庫中讀出DTO(數據傳輸對象,Data Transfer Object)後,根據DTO與領域對象的映射規則,將其轉換爲領域對象,同時呈如今UI上。當用戶在UI上完成修改後,又根據一樣的映射規則,將其更新到領域對象上,同時持久化到數據庫當中。最後,系統根據持久化結果刷新UI,完成一次領域操做,從而保證了從UI到領域對象,再到持久化數據之間的一致性。這當中,DTO只是爲防止暴露模型細節而設計的領域對象的投影,UI則體現爲對該DTO各字段的對照呈現。編程

Figure

很天然地,系統的全部業務流程也隨之演變成一系列圍繞DTO的CRUD操做(Create-Read-Update-Delete)。因而在很長一段時間裏,下圖這樣千篇一概的界面發展成爲MIS(信息管理系統,Management Infomation System)類軟件的主流,圖中左下角的記錄導航條成爲標配元素。在這種狀況下,若是把DTO看成數據庫裏的一行記錄,那麼整個系統能夠視做以DTO爲Row、以CRUD操做爲主要事務的一系列數據庫Table。windows

Figure

傳統架構利弊明顯

傳統架構(包括分層架構)簡單直觀,只要設計好數據模型,系統設計就算成功了一大半,並且全部寫入操做都由事務包裹,可以達到強一致性要求。但它也有如下幾個弊端:安全

  • 在通過Application Service這層屏風後,用戶意圖所有被分解爲CRUD操做,在領域對象之間沒法得以體現。
  • 爲保證DTO的信息完整和數據一致性,部分與操做無關的信息也將一併被歸入DTO,查詢和構造DTO將成爲系統的主要任務,而領域模型的業務流程相應被肢解和沖淡。
  • 完成一次領域操做,須要在DTO與領域對象間進行屢次轉譯,增長了系統額外負擔。這種轉譯被稱爲阻抗失配(Impedance Mismatch),其實質就是多維的對象圖Graph與二維的關係Relationship之間相互轉換時發生的、不可避免的信息丟失。
  • 讀寫操做將圍繞同一數據模型展開,即便有數據庫分庫分表方案支持,其效率也不可避免地要受到競態影響。

貧血模型換湯不換藥

傳統架構中的CRUD模式,其最大的弊端在於語義與操做的脫節。Application Service中的API一般表明着用例的某個方面,所以尚含有領域語義,好比API PlaceOrder()表示用戶下單。然而該API在到達內部模型後,就被拆解映射爲CRUD操做Order.Create()。相應地,API AddOrderItem()被映射爲Order.Update()CancelOrder()被映射爲操做Order.Delete(),等等。這樣的彆扭,又在DTO轉譯的負擔之上,給理解和維護模型帶來了必定的困難。架構

因而,爲了儘量保留用戶意圖,咱們首先想到經過命名規範和方法的二次封裝,使CRUD操做在字面上接近API的語義,好比用Product.Rename()封裝Product.Update(Name = "NewName")。但這樣的作法並未能改變實質,所以即便套用了Aggregate、Value Object和Repository等DDD的戰術概念,但這種徹底以「DTO結構 + CRUD操做」爲主要元素構成的模型,被Martin Fowler等人稱爲「[貧血模型]」(Anemic Model)。併發

接下來,吸收貧血模型的教訓,開始着手創建富含領域行爲的各類領域對象。當API PlaceOrder()最終交給Order.Place()完成時,工做彷佛已經畫上了圓滿句號。

在函數式編程範式中,「抽象數據類型ADT + 代數方法」組成的模型,與「DTO + CRUD」的方式很是相似,但從函數式編程的視角,這纔是最合理、最優雅的模型。那麼,它到底是不是貧血模型呢?😄

加入Command一舉多得

當模型再也不貧血以後,對充血模型中領域對象的方法調用,將與CRUD存在典型區別,由於傳遞給領域對象方法的再也不是「肥胖的」DTO,而只有那些必要的少許參數,且方法名直接就表達了領域語義。好比,Order.RelocateAddress(address),而不是Order.Update(Address="NewAddress")

接下來,採用重構手法,將這些必要參數封裝爲Command對象,縮短方法調用的參數列表長度。進而在此基礎上,引入[Command Pattern],將本來直接調用領域對象的方法,變成先構造Command對象、再委託Command對象執行統一的Execute()接口方法的兩個步驟。

最後,再以Service API爲請求方,Command對象爲載體,領域對象的方法爲Command Handler,使上述模式演變爲Requst-Reponse Pattern,實現了API與領域對象方法之間調用關係的脫耦,接口變得更加一致和優雅。上面的例子就變成Service.Send(RelocateAddressCommand)Order.HandleCommand(RelocateAddressCommand)

至此,改進模型的工做應該算真正結束了吧。用戶經Service API構造併發出Command對象,領域對象接收並處理Command對象,完成自身狀態更新,而後把狀態轉譯爲DTO持久化到數據庫。同時,Service API根據Command對象處理結果,將狀況反饋給呈現層實現UI刷新。

這樣的結果,雖然增長了系統的複雜度,但爲實現Undo/Redo等複雜機制提供了基礎,同時Command對象藉助消息中間件傳遞,還能夠實現Application Service Layer與Domain Model的跨主機部署,爲分佈式應用提供了條件。最關鍵的是,Command對象自己富含領域語義,其名稱體現了用戶意圖,其字段限制了模型受影響的範圍。

從中還能夠獲得以下的啓示:

  • 雖然Command同DTO同樣都是靜態結構,但它用命名更清晰地表達了「要模型作什麼」的含義,並且其屬性只包含了「作什麼」所須要的必要信息,於是更能準確地表達用戶的意圖。
  • Command與Command Handler組成了命令及其解釋器的特定結構,Command的祈使時態也說明了它只是一種請求,可能會被拒絕。
  • 在發現Command時,要儘可能避免Create、Edit、Update、Change或者Delete這樣的用詞,而要去發掘RegisterCustomer、CorrectAddress或者RelocateCustomer這樣更富含領域的用詞,不然無疑會再回到CRUD的老路上(Udi Dahan在[演講]裏也特別提到Delete的問題)。

改進UI以適配新架構

走到這一步,Service API、Command對象、Domain Model這幾方面都已經作到「面向領域」了,剩下的只有UI和持久化了。

Microsoft在[Inductive User Interface]指南中,總結了改進用戶體驗的一些建議,強調不要寄但願於用戶徹底瞭解軟件的總體架構和工做原理、流程,而要儘可能使用引導式、聚焦式的UI設計,幫助用戶專一於當前某個具體領域行爲,確保一次只完成一項任務。在目前架構條件下,UI是Command的發起人,因此UI的關注點能夠相應地限制於Command所需的那部分,這便獲得了Task-based UI

以前的例子按Task-based UI的要求改造後,當用戶點擊列表項「已離職」下方的複選框時,就會彈出第二個對話框,提示填寫離職的緣由。

Figure

這樣的UI設計變化,就比如論述題與填空題的區別。傳統UI就象論述題,用戶得知道解答論述題的套路:先解釋主要概念,再回答特性、分類等等。而Task-based UI就象填空題,用戶始終是在一個上下文裏回答當前的提問,這樣必然更直觀和人性化。

引入CQS開闢新天地

通過前述改進,架構與循環分別變成下面這樣:

Figure

Figure

若是把循環按左右一分爲二,左半部分都執行的查詢操做,右半部分都是寫入操做,因而設想把API一分爲二,其中Command部分的方法都沒有返回值,但會修改聚合對象實例狀態;Query部分的方法只返回查詢結果,但不會修改任何東西。這便獲得了CQS原則(Command Query Separation)。

關於CQS原則,Meyer的這句話很是準確:「Asking a question should not change the answer」

Figure

在CQRS Journey的[Conference案例]中,ConferenceService就是典型的CQS示例。

CQRS和ES走入視野

在使用CQS原則對Service API進行切分後,進一步根據讀寫職責不一樣,把領域模型切分爲Command端與Query端兩個部分,便獲得了下圖所示的CQRS模式(命令與查詢職責分離,Command and Query Responsibility Segregation)。Command端與Query端共享同一份持久數據,但Command端只寫入狀態,Query端只讀取狀態。

Figure

爲進一步提升效率,讀寫端的持久數據分離成爲必然選擇,但也產生了新的矛盾——如何在兩端進行數據同步,以達到最終一致性(Eventual Consistency)。

一方面,從CQRS模式的結構看,系統狀態變化都發生在Command端,所以只有Command端掌握着具體是哪些內容發生了變化,若是把變化的這些內容封裝在一塊兒,代表系統「剛剛發生了哪些變化」,就獲得了所謂的事件Event。

反觀Query端,查詢返回的老是反映系統當前狀態的靜態數據。根據「當前狀態 + 變化 = 新的狀態」,若是能從Command端獲得「變化」,就能獲得變化後的「新的狀態」。而Event正好符合「變化」的定義,因此選擇從Command端將Event推送到Query端,Query端根據Event刷新狀態,就能保證兩端的模型都反映系統的最終狀態,達到最終一致性。

另外一方面,在解決了取得最終一致性的難題後,還得設法改進數據的持久化。

首先能肯定的是,從Query端查詢獲得的老是系統當前狀態的靜態數據,因此從傳統架構一直沿用到CQRS模式下的DTO方案依然有效。可是,因爲這樣的DTO直接映射領域對象,會暴露領域對象細節,並且這種映射會產生阻抗失配,致使過多的間接查詢和多聚合數據的聯結,使優化查詢變得很是困難。因此,爲提升查詢效率,能夠採起相似關係數據庫中「視圖」的方式,直接面向數據模型,採用一切可以使用的數據庫技術,構造一個Thin Read Layer

再是Command端的持久化。根據「初始狀態 + 若干次變化 = 當前狀態」,在初始狀態上依次疊加每一次變化,一樣能獲得當前狀態。其中聚合對象實例的初始狀態是固定的,每一次變化即處理Command後產出的事件Event,那麼只要保存好全部發生過的歷史事件,就能從初始對象重現(Replay)到當前狀態。因此,Command端的持久化最終演變成事件歷史的持久化,這即是事件存儲(Event Storage)。

最終,事件的產生、存儲、推送和重現,即構成了完整的事件溯源(Event Sourcing)。

在CQRS與Event Sourcing的支持下,系統架構也相應地變成了下圖這樣:

Figure

探究新架構

CQRS使Event Sourcing成爲改變和存儲系統狀態的核心機制。在這種模式下,由Application Service Layer統攬整個業務流程。Service首先從Query端查詢系統狀態,爲執行Command準備好上下文環境;而後Service構造好Command,併發送給利用Repository.GetByID()加載(重現)獲得的聚合對象實例;接着聚合對象實例使用內置的Command Handler完成命令處理,更新聚合狀態,併產生Event,在其被持久化的同時推送往Query端;Query端收到Event後,對其自身維護的系統狀態也進行更新,達到與Command端一樣的一致,以迎接下一次Service的查詢。

從上述過程可知,Service是一切活動的發起者和組織者,Command的執行環境均由Service準備,Command是活動內容的承載者,聚合是活動的執行者,而Event是活動的推進者。

同時要注意,Command本質是對領域模型的一種請求,可能會被模型拒絕執行(悲傷路徑)。而Event則不一樣,它表明着系統剛剛完成了某項任務,一定發生了某種變化。事件的用語一定是確定的過去式,而不只僅是某個事實,好比應該是OpenFileFailed,而不是FileNotFound。

對須要多個步驟、跨越多個聚合協做才能完成的活動,本質上一樣遵循上述循環,但爲保證步驟間的有效銜接,又有一個新的模式Saga推出(在CQRS Journey和部分框架中,被稱爲Process Manager)。

Saga發出Command,也訂閱Event。它在向某個聚合發出第一個Command後,就等待Event的回饋,而後根據該Event準備下一個步驟所需的上下文環境,接着向某個聚合發出下一個Command,再等待下一個Event回饋,如此周而復始,直到流程結束。

關於Saga應否有狀態,爭論也很是多。CQRS Journey第6章A Saga on Sagas專門就Saga進行了講解。我的意見,Saga應當是無狀態的(Stateless),不然還得花費額外精力去持久化Saga的狀態。在這方面,能夠參考Web服務的一些設計原則與方案。

重要提示
「世上沒有後悔藥,只有亡羊補牢」——因爲事件意味着改變已經發生,因此沒法被Undo,所以在以事件驅動的系統裏,沒有還原和回滾,只有善後和補救,這是與以事務爲中心的傳統架構的重要差異。

此外,C端與Q端的差異主要有如下幾點:

Command Side Query Side
一致性 一般使用事務維護強一致性。 一般採用最終一致性。
數據存儲 爲限制事務邊界,一般要求符合第三範式。 爲減小聯結操做,一般知足第一範式便可。
擴展性 處理命令一般只佔到系統事務很小的一部分,因此對擴展要求不高。 一般是命令處理量的數倍,所以對擴展性有較高要求。
方法 改變聚合對象實例的狀態,而不返回任何結果(或者僅返回成功與否的標誌)。Repository將剔除GetByID之外的其餘方法。 一般返回DTO給調用者,再呈現到UI。
數據來源 處理的目標即領域對象的自己。 處理的目標是DTO,但它已經從領域對象的投影演變成直接面向數據模型的特異化結構。
瞭解程度 對領域模型必須有完整的理解和掌握。 只需能理解數據模型並從中拼合出DTO便可,對業務規則等無需關注。

一些實現細節

Command與Event

Command的常見實現以下所示,其中AggregateId指示是由哪一個聚合對象實例處理,Version指示在將Command發送給該聚合時聚合的最新版本,以備發生併發衝突時進行檢驗。

class Command {
  Guid Id;
  Guid AggregateId;
  Int Version;
  // 包含其餘信息的字段
}

Event的常見實現與Commanda基本相同,區別只是AggregateId指示是由哪一個聚合對象實例產生的Event,Version表示Event發生時聚合對象實例的版本。

Command與Event的Handler

聚合Aggregate是Command的處理器和事件的發佈器,其Command Handler與Event Handler的基本結構以下:

class Aggregate {
  public readonly Guid AggregateId;
  public readonly List<Event> UnsavedEvents = new List<Event>();
  public Int Version = 0;

  public void HandleCommand(Command c) {
    if (!Valid(c))
      throw new AggregateException();

    var e = new SomeEvent(AggregateId, ...);
    this.HandleEvent(e);    
    e.Version = this.Version;
    this.UnsavedEvents.Add(e);    
    
    DoAnythingWithSideEffect();
  }

  void HandelEvent(Event  e) {
    ModifyState();
    this.Version ++;    
  }

  public void Replay(List<Event> events) {
    foreach(var e in events) {
      this.HandleEvent(e);
    }
  }
}

Repository與Event/Data Storage

Repository是聚合的集合,其主要方法GetByID()負責返回聚合對象實例給調用者。當該實例還沒有在內存當中之時,將從Event Storage讀取全部對應該聚合Id的事件,接着構造一個空白的初始對象,利用獲取的歷史事件按版本前後重現到對象的最新版本,此後即可直接從內存中返回實例,而再也不須要重複上述加載過程了,這被稱爲In-Memory特性。

重現部分的簡單實現,參見前述Aggregate.Replay()

Event Storage是一個追加型的數據庫。因爲事件總與聚合對象實例相關,因此一個以聚合對象實例的Id爲key、事件序列化流爲value的Key-Value型NoSQL數據庫將很是適合這樣的場景。固然,傳統的關係數據庫也徹底能勝任。數據庫的結構也很簡單,每條Event做爲一條記錄,大體爲這樣的結構:

Name Type Content
Id Guid Event的Id,方便索引
AggregateId Guid 產出該事件的聚合對象實例Id
Version Integer 該事件的版本編號
Data Blob Event序列化獲得的二進制流

Data字段的序列化除採用二進制流的方式,也可使用Json或者XML等結構化文本方式。
除上述字段外,還可附加Time Stamp等字段,這給系統回溯到指定時點提供了最基本的數據支持。

而在Query端,其數據主要目的爲前端展現,因此在數據模型設計上,更趨向於「面向界面」或「面向查詢」,須要一次性加載呈現所需的所有數據,因此私覺得MongoDB這樣的文檔型NoSQL數據庫很是符合Query端的狀況。

延遲加載與快照

在傳統架構下,Repository從Data Storage中加載聚合對象實例,一般很糾結因而否使用延遲加載(Lazy Load)。

而在Event Sourcing條件下,由於寫模型本質是歷史的疊加,每一次操做都是追加事件,而不是刷新整個對象,因此延遲加載沒有存在必要。

在CQRS Journey第33頁有一段關於Lazy Load在CQRS條件下有無必要的對話能夠借鑑。

可是,每次從Event Storage讀取全部屬於某個聚合對象實例的事件而後進行重現,還是能夠改進的,方法就是使用快照(Snapshot)。

快照就是特定版本的聚合對象實例,因此構建快照的方法和重現得到一個聚合對象實例是相似的:構造一個空白的初始對象,利用獲取的歷史事件,按版本前後重現到特定版本。正由於快照等價於某個版本的聚合對象,因此快照的生成能夠徹底獨立並行於系統運行,並且能夠在快照基礎上重現其後續版本的事件,以獲得更新版本的聚合對象實例。

Figure

併發衝突

Command只有一個接收者,而Event能夠有若干個訂閱者,因此Command總與特定類型的聚合Command Handler綁定。在引入Command隊列後,根據聚合對象實例的Id進行Command分組,便可保證一個聚合對象實例在任意時刻只會處理一條Command,從而保證聚合的線程安全。這也是借鑑了Actor模式(此處的Actor並不是特指Akka框架裏的Actor,而是範指如下這樣的模式。)

每一個Actor,都是一個封閉的、有狀態的、自帶郵箱、經過消息與外界進行協做的併發實體。在Actor之間的消息發送、接收都是併發的,可是在Actor內部,消息被郵箱存儲後都是串行處理的。即Actor在同一時刻只會對一條異步消息作出迴應,從而回避加鎖等併發策略。

若是不採用Actor模式,那麼就須要本身處理併發衝突。因爲Command與Command Handler是一對一的,因此只有當存在多個相同Id的聚合對象實例時,好比爲提升吞吐量而將多個同一Id的聚合對象實例分佈於不一樣結點,或者因結點切換致使發生同一聚合對象實例被同時修改時,可能會發生併發衝突。此時聚合的版本號,將成爲併發控制的有力武器之一,主要策略不外乎樂觀或者悲觀兩種方式:

  • 樂觀策略:僅當聚合當前版本與Event Storage中的最新版本一致,才證實聚合是最新的,能夠提交對聚合的修改,不然進行重試。
  • 悲觀策略:每一次都從Event Storage重塑整個聚合,並利用同步鎖等機制,保證排他性地修改聚合狀態。

另外一方面,正如CQRS Journey第256頁的「Commands and optimistic concurrency」所述,因爲Command的執行環境來自於UI和Query端,因此當Query端與UI未同步時,好比管理員Tom剛停售某Product,而此時顧客Jimmy已經在提交包含該Product的Order,這便會出現破壞最終一致性的狀況。相應的一個解決方案,就是在Query模型裏保存當前聚合對象實例的最新版本號(即最近一個事件的版本號),而後由Service在構造Command對象時附上該版本號(參見前述Command的常見結構)。最後,由聚合對象實例在收到該Command對象時,與自身當前版本號做對比。若二者一致,即代表Query端目前發送來的Command正是基於聚合對象實例的當前最新版本。

兩步提交

在CQRS與Event Sourcing搭配的狀況下,事件在持久化的同時更新Query端是一個顯著的技術難點,由於這兩個動做必須同時成功,不然將會破壞最終一致性。若是持久化成功,而更新Query端失敗,那麼Query端呈現的就不是正確的系統狀態;若是持久化失敗,而更新Query端成功,那麼Command端執行環境與系統實際狀態不符。

爲此,CQRS Journey總結了業內的三種方案:

  • 將兩個動做放進一個事務中執行。因爲該事務將跨越讀寫兩端,是典型的分佈式事務,因此性能和可用性都較差,只有當分佈式事務框架足以知足要求時纔會考慮這個方案。
  • 引入消息隊列,將本來分散在讀寫兩端的兩步提交,改成集中在寫端的一個事務中,完成事件存入Event Storage和向消息隊列推送事件的工做,再由讀端負責從消息隊列取出事件自行完成更新。這種狀況下,兩步提交的工做都主要在寫端實現,相比第一種方案有了明顯進步。
  • 在第二種方案基礎上,改進Event Storage設計,由Event Storage自己實現將消息壓入消息隊列,此時寫模型將只須要一個事務完成事件的持久化便可。這種方式下,事務的邊界進一步縮小,寫模型本來要負擔的「兩步提交」被簡化爲「一步提交」,性能獲得更大幅的提高。可是Event Storage的推送能力,將成爲重大考驗。

Greg Young在論文及其開發的框架[EventStore]中,都採用了最後一種方案。其主要思想是給每條Event添加一個Long類型的SequenceNumber字段,該字段在庫中是惟一且遞增的,表明着事件被推送的順序號。只要Event Storage保存好推送成功的最後一條事件的SequenceNumber,就能夠肯定推送完成的狀況了。

使用Akka框架實現

Akka簡介

Actor模型最先出自1973年Carl Hewitt等人所著論文A Universal Modular ACTOR Formalism for Artificial Intelligence

Akka是Lightbend公司推出的一個基於Actor模型的分佈式框架,目前主要支持的語言包括Java和Scala。

如下是官網及個人筆記連接:

實現細節

用Akka實現CQRS與Event Sourcing的示意圖以下:

Figure

  • 由Command與Event組成的Protocol,是Actor與外界溝通的惟一媒介。
  • EventSourcedBehavior是Write Model的核心,承擔着聚合的主體責任,主要定義了Command和Event的Handler。
  • Event的SequenceNumber由框架自動生成,reply和snapshot由框架提供。
  • 聚合狀態單獨定義在State裏,借State模式實現狀態遷移。
  • Tag爲事件作上標記,方便Read Model選擇使用。
  • PersistenceQuerier是Read Model的核心,負責從Read Journal中根據Tag讀取事件流,更新自身的讀數據模型,從而實現讀寫模型的最終一致性。
  • Serialize爲Command和Event提供序列化支持,可以使用Json或二進制格式。

🔗 Akka的CQRS示例,分別有Scala和Java版本

微服務

Lightbend公司在Akka基礎上,推出了一個微服務框架Lagom。

Lagom框架堅持,微服務是按服務邊界Boundary將系統切分爲若干個組成部分的結果,這意味着要使它們與限界上下文Bounded Context、業務功能和模塊隔離等要求保持一致,才能達到可伸縮性和彈性要求,從而易於部署和管理。所以,在設計微服務時應考慮大小是否「Lagom」,而非是否足夠「Micro」。

如下是官網和個人筆記連接:

Lagom封裝了服務定位、服務網關、消息隊列和路由、集羣等功能。每一個服務由服務描述子、調用標識符、消息處理器等組成,在服務的內部實現中,由Akka提供的EventSourcedBehavior承擔實際的消息處理和持久化。

🔗 Lagom的Hello World示例,分別有Scala和Java版本

寫在最後

本文是近年我的學習DDD和Event Sourcing的心得總結。限於篇幅,沒有就更多細節進行探討。

在實踐中,我使用DDD指導建模的流程可簡單總結以下:

  • 使用事件風暴,查找全部可能的Event。
  • Command是Event的原由,所以從Event逐一倒查全部的Command。
  • Command與Command Handler一一對應,因此逐步向聚合添加職責。
  • 根據Command屬性,爲聚合添加相應屬性,造成領域概念一覽表。
  • 當聚合中的一些屬性沒法用Int、String等基本屬性進行描述時,封裝Value Object對領域概念進行說明。
  • 根據Command涉及不一樣聚合之間的協做,釐清聚合之間的關係,逐步豐富聚合圖譜。
  • 待聚合圖譜完整和清晰以後,根據變化邊界進行劃分,造成各BC及模塊。
相關文章
相關標籤/搜索