酷播亮新聞
最棒的知識補給站

CQRS – 簡單的架構設計

(給

ImportNew

加星標,提高Java技能)

轉自:Original OSC-協作翻譯

https://www.oschina.net/translate/cqrs-simple-architecture

有人說:「CQRS很難!」

是嗎? 好吧,我也曾這樣認為! 但,當我開始使用 CQRS 編寫我的第一個軟體時,它很快就不攻自破。更為重要的是,我認為從長遠來看,以這種方式維護軟體更加容易。

我開始思考:為何人們在一開始時認為它是多麼困難難和複雜? 我有一個理論:它包含規則! 進入擁有規則的世界總是不舒服的,我們需要適應這些規則。在這篇文章中,我想證明在這種情況下,這些規則是非常易於理解的。

在通往 CQRS 的路上…

從根本上來說,我們可以將 CQRS 視為對軟體架構命令查詢分離規則的實現。在使用此方法的工作中,我注意到在最簡單的 CQS 實現與真正成熟的 CQRS 之間有幾個步驟。我想那些步驟可以順利地引入我之前已經提到的規則。

雖然第一步沒有實現我對 CQRS 的定義(但是有時候這麼稱呼的),但是他們還可以為你的軟體引入一些真正的價值。每個步驟都引入一些有趣的想法,可以有助於構建或清理你的代碼庫/架構。

通常,我們的旅程從這裡開始:

我們可能都知道,這是一個典型的 N-層架構。如果我們想在這添加一些 CQS,我們可以「簡單地」將業務邏輯層分離為命令和查詢:

如果你還在使用老式代碼庫,這可能是最難的一步,就像從義大利面式代碼中閱讀分離出副作用一樣不簡單。同時這個步驟可能也是最有好處的一個;它會給你一個副作用執行的位置的概述。

等一下!你正在討論 CQS,CQRS ,但是你還沒有定義到底什麼是命令或查詢!

沒錯。我們開始定義它們吧!在這裡,我會給你我個人、直觀的對命令和查詢的定義。它並不全面,而且在實現之前必須加以深化。

命令

——首先,觸發命令是唯一改變系統狀態的方法。命令負責引起所有的對系統的改變。如果沒有命令,系統狀態保持不變!命令不應該返回任何值。我使用兩個類來實現它:Command 和 CommandHandler 。Command 只是一個普通的對象,CommandHandler 將它用於表示某些操作的輸入值(參數)。我認為命令是簡單地調用領域模型中的特定操作(不一定是每個命令都有的操作)。

查詢

——同樣的,查詢是一個讀操作。它讀取系統的狀態,過濾,聚總,以及轉換數據,並將其轉化為最有用的格式。它可以執行多次,而且不會影響系統的狀態。我之前是使用一個有一些 Execute(…) 函數的類來實現它,但是現在我認為分離成 Query 和 QueryHandler/QueryExecutor 可能會更有用。

回到示意圖,我需要澄清一些事情;我已經隱秘地做了一個補充修改,模型改為領域模型。由於我認為模型是一組數據容器,而領域模型包括了業務規則中本質複雜性。因為我們對這裡的體系架構感興趣,這個修改不會直接影響我們的進一步考慮。但是值得一提的是,儘管命令負責改變系統的狀態,本質複雜性應該放到領域模型。

好的,現在我們可以添加新的命令或者編寫新的查詢。短時間內,很明顯,適用於寫的領域模型並不一定適合讀。從某種特殊模型中更容易讀取數據,這並不是一個重大的發現:


我們可以引入分離模型,由 ORM 映射並構建查詢,但是在某些情況下,特別是當 ORM 引入開銷時,它將對簡化結構有所幫助。

我認為這個特殊的改變應當被好好地考慮!

現在的問題是我們仍然有僅在邏輯層級上分離的讀和寫模型,因為他們共享公共資料庫。這就意味著我們已經分離了讀模型,但最有可能是被一些 DB 視圖給虛擬化了,物化視圖的情況下更好。如果我們的系統沒有性能問題,並且我們記住在寫模型改變的時候更新查詢,那這個方案是可行的。


下一步是引入完全分離的數據模型:

在我看來,這是第一個符合 Greg Young 提出的原始想法的模型,現在我們稱它為 CQRS 。但是它仍然有問題!我之後再寫。

CQRS != 事件溯源


事件溯源是與 CQRS 一起提出的一個概念,通常被標識為 CQRS 的一部分。ES(Event Sourcing)的概念很簡單:我們的領域生成的事件表示系統中的每一個更改。如果我們從系統開始記錄每一個事件,而且從最初狀態開始重現,我們會得到系統的當前狀態。它與銀行賬戶的事務相似;我們可以從空賬戶開始,重現每一個單獨的事務,然後(有希望地)得到當前的餘款。因此,如果我們已經存儲了所有的事件,我們能得到系統的當前狀態。

雖然 ES 是存儲系統的狀態的一種很好的方法,但是 CQRS 並不一定需要它。對於 CQRS ,領域模型實際上如何存儲並不重要,而且這只是一個選項。

讀模型和寫模型

當我們閱讀 CQRS 時,分離模型的概念似乎非常清晰和直接,但在實現過程中似乎並不清楚。寫模型的責任是什麼?我是否應該將所有數據放入我的讀取模型中?嗯,這得看情況!

寫模型

我喜歡把我的寫作模型看作是系統的核心。這是我的領域模型,它做業務決策,它很重要。它做出業務決策的事實在這裡是至關重要的,因為它定義了這個模型的主要職責:它代表系統的真實狀態,可以用來做出有價值的決策的狀態。這種模式是唯一的真理來源。

如果你想了解更多關於設計領域模型的知識,我推薦你閱讀領域驅動設計技術哲學。

讀模型

在我第一次嘗試 CQRS 時,我使用了 WRITE 模型來構建查詢……它是 OK 的(或者至少是有效的)。過了一段時間,我們到達了項目中需要花費大量時間進行查詢的地方。為什麼?因為我們是程序員,優化是我們的第二天性。我們將模型設計為規範化,因此我們的讀取端受到連接的影響。我們被迫預先計算一些報告的數據以保持快速。這很有趣,因為實際上我們引入了緩存。在我看來,這是讀取模型的最佳定義:它是一個合法的緩存。由於我們必須發布項目,而非功能性的需求沒有得到滿足,因此,緩存是通過設計來實現的。

標籤讀取模型可以建議它存儲在一個資料庫中,僅此而已。實際上讀取模型可能非常複雜,你可以使用圖形資料庫來存儲社會連接,使用 RDBMS 來存儲財務數據。這是一個多語言持久性很自然的地方。

設計好的讀模型是一系列的權衡,例如純規範化與純非規範化。如果你的項目很小,並且大多數讀取都可以根據寫模型有效地進行,那麼創建副本將浪費時間和計算能力。但是,如果你的寫模型是作為一系列事件存儲的,那麼使用所有必需的數據而不從頭重新播放所有事件將是非常有用的。這個過程叫做快速讀取派生,在我看來,它是 CQRS 中最複雜的東西之一,這是我前面提到的一個難點。正如我之前所說,讀模型是緩存的一種形式,正如我們所知:

在計算機科學中只有兩件困難的事情:緩存失效和命名。 ——Phil Karlton

我說它是一個「合法」的緩存,這個詞對我來說也有額外的意義,在我們的系統中,我們有明顯的理由更新緩存。我們的域模型產生的事件是更新讀模型的自然原因。

最終一致性

如果我們的模型在物理上是分開的,那麼同步將需要一些時間,這是很自然的,但是這一次對業務人員來說是非常可怕的。在我的項目中,如果每個部分都正常工作,那麼 READ model 不同步的時間通常可以忽略不計。然而,在開發更複雜的系統時,我們肯定需要考慮時間風險。設計良好的 UI 對於處理最終的一致性也很有幫助。

我們必須假設,即使讀取模型與寫入模型同步更新,用戶仍然會根據陳舊的數據做出決策。不幸的是,我們不能確定當數據呈現給用戶時它是否仍然新鮮(比如在 Web 瀏覽器中呈現)。

如何將 CQRS 引入到項目中?

我相信 CQRS 如此簡單,不需要引入任何框架。你可以從少於100行代碼的最簡單的實現開始,然後當需要的時候再引入新特性來擴展它。你不需要任何魔法,因為 CQRS 很簡單,而且它簡化了軟體。這是我的實現:

public

interface

ICommand

{
}

public

interface

ICommandHandler

   

where

TCommand

:

ICommand

{
   

void

Execute

(TCommand command)

;
}

public

interface

ICommandDispatcher

{
   

void

Execute

(TCommand command)

       where TCommand : ICommand;
}

我定義幾個介面描述命令和他們的執行環境。為什麼我用兩個介面來定義一條命令?我這麼做是因為我想要保持參數為普通對象,這樣就可以不用任何依賴來創建。我的命令 handler 可以從 DI 容器中請求依賴,而且除了在測試中,不需要在任何地方實例化。事實上,ICommand 介面在這的作用相當於標記,來告訴開發者他是否可以將這個類作為命令來使用。

public

interface

IQuery

{
}

public

interface

IQueryHandler

   

where

TQuery

:

IQuery

{
   

TResult

Execute

(TQuery query)

;
}

public

interface

IQueryDispatcher

{
   

TResult

Execute

(TQuery query)

       where TQuery : IQuery;
}

此定義非常類似 IQuery 介面,但它還定義了查詢結果的類型。 這不是最優雅的解決方案,但結果是在編譯時校驗返回的類型。

public

class

CommandDispatcher

:

ICommandDispatcher

{
   

private

readonly IDependencyResolver _resolver;

   

public

CommandDispatcher

(IDependencyResolver resolver)

   {
       _resolver = resolver;
   }

   

public

void

Execute

(TCommand command)

       where TCommand : ICommand
   {
       

if

(command ==

null

)
       {
           

throw

new

ArgumentNullException(

“command”

);
       }

       var handler = _resolver.Resolve>();

       

if

(handler ==

null

)
       {
           

throw

new

CommandHandlerNotFoundException(typeof(TCommand));
       }

       handler.Execute(command);
   }
}

我的 CommandDispatcher 相當短,它只負責為給定的命令實例化適當的命令助手並執行它。為了避免手動輸入命令去註冊和實例化,我已經使用了 DI 容器來做這件事,但如果你不想使用任何 DI 容器,你仍然可以自己做。我說過,這個實現將是簡單的,我相信是這樣。唯一的問題可能是泛型引入的噪音,它可能剛開始的時候會令人沮喪。這個實現在使用上確實是簡單的。下面是一個命令和助手的示例:

public

class

SignOnCommand

:

ICommand

{
   

public

AssignmentId Id { get;

private

set; }
   

public

LocalDateTime EffectiveDate { get;

private

set; }

   

public

SignOnCommand

(AssignmentId assignmentId, LocalDateTime effectiveDate)

   {
       Id = assignmentId;
       EffectiveDate = effectiveDate;
   }
}

public

class

SignOnCommandHandler

:

ICommandHandler

{
   

private

readonly AssignmentRepository _assignmentRepository;
   

private

readonly SignOnPolicyFactory _factory;

   

public

SignOnCommandHandler

(AssignmentRepository assignmentRepository,  
                               SignOnPolicyFactory factory)

   {
       _assignmentRepository = assignmentRepository;
       _factory = factory;
   }

   

public

void

Execute

(SignOnCommand command)

   {
       var assignment = _assignmentRepository.GetById(command.Id);

       

if

(assignment ==

null

)
       {
           

throw

new

MeaningfulDomainException(

“Assignment not found!”

);
       }

       var policy = _factory.GetPolicy();

       assignment.SignOn(command.EffectiveDate, policy);
   }
}

只需要將 SignOnCommand 傳給分派器來執行這個命令:

_commandDispatcher.Execute(

new

SignOnCommand(

new

AssignmentId(rawId), effectiveDate));

就是這樣。QueryDispatcher 看起來很相似,唯一不同是它返回了一些數據,多虧了我之前寫的通用代碼,Execute 方法返回強類型結果:

public

class

QueryDispatcher

:

IQueryDispatcher

{
   

private

readonly IDependencyResolver _resolver;

   

public

QueryDispatcher

(IDependencyResolver resolver)

   {
       _resolver = resolver;
   }

   

public

TResult

Execute

(TQuery query)

       where TQuery : IQuery
   {
       

if

(query ==

null

)
       {
           

throw

new

ArgumentNullException(

“query”

);
       }

       var handler = _resolver.Resolve>();

       

if

(handler ==

null

)
       {
           

throw

new

QueryHandlerNotFoundException(typeof(TQuery));
       }

       

return

handler.Execute(query);
   }
}

就像我說的,這個實現是可以擴展的。例如,我們可以為命令 dispatcher 引入事務,而無需通過創建 decorator 來改變原始實現。

public

class

TransactionalCommandDispatcher

:

ICommandDispatcher

{
   

private

readonly ICommandDispatcher _next;
   

private

readonly ISessionFactory _sessionFactory;

   

public

TransactionalCommandDispatcher

(ICommandDispatcher next,
           ISessionFactory sessionFactory)

   {
       _next = next;
       _sessionFactory = sessionFactory;
   }

   

public

void

Execute

(TCommand command)

       where TCommand : ICommand
   {
       using (var session = _sessionFactory.GetSession())
           using (var tx = session.BeginTransaction())
           {
               

try

               {
                   _next.Execute(command);
                   tx.Commit();
               }
               

catch

               {
                   tx.Rollback();
                   

throw

;
               }
           }
   }
}

通過使用這個偽方法,我們可以輕鬆地擴展命令和查詢分派器。你可以添加「即發即棄」的命令執行方法和大量日誌。

正如你看到的,CQRS 沒那麼難,基本的思想很清晰,但是你需要遵守一些規則。我確信這篇文章沒有涵蓋全部的內容,這就是我建議你多讀一些的原因。

參考書目

  • CQRS 文檔,Greg Young 著:https://cqrs.files.wordpress.com/2010/11/cqrs_documents.pdf

  • Clarified CQRS,Udi Dahan 著:http://www.udidahan.com/2009/12/09/clarified-cqrs/

  • CQRS, Martin Fowler 著:http://martinfowler.com/bliki/CQRS.html

  • CQS, Martin Fowler 著:http://martinfowler.com/bliki/CommandQuerySeparation.html

  • 「實現 DDD」 Vaughn Vernon 著:https://vaughnvernon.co/?page_id=168

看完本文有收穫?請轉發分享給更多人

關注「ImportNew」,提升Java技能

喜歡就點「好看」唄~

喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 ImportNew 的精彩文章:
如有侵權請來信告知:酷播亮新聞 » CQRS – 簡單的架構設計