有人說:“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