通過一組RESTful API暴露CQRS系統功能
原文地址:Exposing CQRS Through a RESTful API
命令和查詢責任分離(CQRS)是由Greg Young提出的一種將系統的讀(查詢)、寫(命令)操作分離為兩種獨立子系統的架構模式。命令通常是異步執行的,并存儲在一個事務型數據庫中,而讀操作則通常是最終一致的,并且數據來自于解正規化的視圖。
本文在此提出并為讀者展示一種為CQRS系統創建一套RESTful API的方式。這種方式結合了HTTP的語義、REST API基于資源的風格,并能夠處理分布式計算的某些問題,例如最終一致性和并發性。
此外我們還提供了一套原型API,它建立于Greg Young編寫的m-r CQRS原型之上,后者也被稱為SimplestPossibleThing。m-r可以認為是CQRS原型的事實標準,它鼓舞了許多團隊采用并創建CQRS系統。雖然這個m-r原型很簡單,但它已經能夠展示在現實世界中使用RESTful CQRS系統的某些機遇和挑戰了。
我們在將下一部分審閱m-r的領域模型,隨后對相關特性的API設計進行一些探索。最后,我們將對一些所做的選擇展開討論,并且討論一些RESTful m-r的概念和理論內容。
m-r領域
m-r模型是一個經過簡化的庫存管理系統的領域模型,你可以創建新庫存物品(假設它是某種類型的產品),重命名或取消激活(即邏輯刪除)它們。被取消激活的物品將不再為用戶所見,而所有活動的物品都可以被獲取,并且能夠看到每個物品的所有細節。你也能夠增加或減少這些庫存物品,指定所加入或減少的物品數據。換句話說,在建立庫存量之后,就可以開始使用這個系統了。
用戶將通過同步的查詢來查看物品列表或是物品細節,對于物品狀態的修改將通過命令來實現。在現實世界中,命令應該是異步執行的,但由于代碼中使用了內存中的事件總線(Event Bus)及事件處理函數,因此在最終實現中命令都是同步執行的。
m-r模型實現了CQRS:命令和查詢被分別存儲在不同的地方,并且各自由系統中完全不同的部分進行處理。
除了CQRS之外,m-r也使用了事件溯源(Event Sourcing)作為它的持久化機制。在這種方式中,對于領域模型的修改會被捕獲為一系列的事件,這些事件會按照它們被調用的順序存儲起來。為了獲取某個模型的當前狀態,需要將所有事件按照它們發生的順序進行重播。換句話說,模型中實體的狀態信息是不會被持久化的。舉例來說,如果我們創建了一個庫存物品,隨后將它重命名兩次,那么我們將會得到一個InventoryItemCreated事件和兩個InventoryItemRenamed事件,這些事件都會被保存在事件存儲(Event Store)中。
事件是連續的,并且每個事件都帶有一個版本號,用以在并發時進行檢查。舉例來說,如果某個庫存物品在版本2的基礎上進行重命名,但正好有另一個重命名發生在同一個物品上,并使它的當前版本變為3,那么這種情況就會導致并發異常。
命令與領域事件通常是一對一的關系,當調用了某個命令之后,領域模型會發起并存儲一個事件。領域事件是事件溯源的基石,它和跨多個邊界上下文(bounded context)的事件不同,往往粒度更細,并且只包括所需的最小數量的信息。因此,它并不是一個適合于在不同的邊界上下文之間進行集成的工具。除了使用一個進程內的事件總線之外,m-r還用到了一個內存中的事件存儲。這個存儲本質就是一個哈希表,它使用模型的id作為鍵,并且持續跟蹤模型中發生的任何事件。
如欲了解CQRS和事件溯源的更多信息,你可以閱讀Greg Young的這本迷你書。
創建一套上層的REST API
如果你傾向于先去感受一下最終的實現,可以在這里看一下一個目前(暫時性)可運行的原型。我們鼓勵你使用fiddler或者瀏覽器自帶的開發工具去檢查一下這個簡單的示例中的HTTP請求。在GitHub上可以找到包括這套API和一個基本的Angular應用的源代碼。不過我們還是要強調,它的實現方式和使用的技術并非關鍵所在,讀者更應該關注于設計方式及HTTP的展現。
公開領域的構造
對于這個API層來說,最重要的責任是將底層的領域建模為資源,并通過HTTP語義暴露出來。在這個過程中,API層將創建一個公共領域,它由資源(以及它們的唯一標識符->URL)以及輸入和輸出的消息所構成。底層的領域越簡單,這個公開領域和底層領域的相似程度就越高。
在這個例子中,我們創建的公開領域與底層的領域還是比較相似的,但即使是這種簡單的領域,我們也不能夠直接將底層的領域暴露出去:這可能造成領域的內部實現被泄漏出去,而且領域內部也不一定包含API層所需的全部屬性。比方說,所有的內部命令都會用一個整數來表示并發時所需的版本號,而在公開領域中則用字符串表示這個屬性。我們稍后將會使用這個屬性作為ETag,而根據HTTP規格要求,ETag必須是不透明的。
簡單來說,我們所創建的公開領域表現了內部的領域類,但又不完全相同。這種公開領域通常被稱為一個視圖模型(Vide Model)。這個術語并不太準確,因為這種表達方式感覺上對公開領域有些排斥,將它視為一種“啞”模型,因此我們傾向于使用一個新術語“輸出模型”(output model)。它將被應用到輸入和輸出消息中(命令和輸出模型)。
資源
我們很自然地想到應該有一個InventoryItem資源,因此我們將領域中的這個單根實體暴露為一個單獨的資源,可以用/api/InventoryItem方便地進行表示。每個庫存物品將用/api/InventoryItem/{id}進行表示,m-r使用了全局唯一標識符(GUID)作為Id。
使用這個單獨的根對象就可以完整的表現我們的領域了。還有一種方式是使用/api/InventoryItem/{id}/Stock這個資源作為添加和刪除庫存量(即簽入或移除物品)的方法。從本質上說它們沒有什么高下之分,無非是哪種方式能夠更好地表現資源而已。由于第一種方式更加簡便,因此我們就使用這種方式。
查詢
我們需要兩個查詢:GetInventoryItems和GetInventoryItemDetails。這里我們將通過兩個GET方法/api/InventoryItem和/api/InventoryItem/{id}暴露出這兩個查詢功能。
GetInventoryItems方法能夠獲取僅包含了物品名稱和Id的一個列表,它會根據ACCEPT頭決定返回JSON或是XML(ASP.NET Web API能夠支持這一功能)。如果某個資源適合于緩存,那么所有的GET請求都有可能返回緩存數據。GetInventoryItems返回InventoryItemListDataCollection作為輸出消息。雖然可以通過數據內容的哈希生成ETag,不過這里我們選擇將列表中每一項的Id和名稱進行哈希后得到的結果作為ETag返回給客戶端(例如瀏覽器)。客戶端可以選擇將資源緩存起來,并針對ETag使用If-Non-Match進行條件請求。我們選擇將資源的max-age設為0,因此客戶端的GET會始終使用條件請求,不過也可以選擇設置一個人為的過期時間。
GET /api/InventoryItem HTTP/1.1 Accept:application/json, text/plain, */* Accept-Encoding:gzip,deflate,sdch If-None-Match:"LdHipfxR7BsfBI3hwqt2BLsno8ic98KmrIA1y67Nnw4="
返回結果
HTTP/1.1 304 Not Modified
ETag: "LdHipfxR7BsfBI3hwqt2BLsno8ic98KmrIA1y67Nnw4="
GetInventoryItemDetails方法會返回某個庫存物品的細節,包括Id,Name和CurrentCount屬性,最后一項屬性記錄了當前的庫存數量。雖然內部領域的讀取模型(read model)包含了版本號,但如果將某個數值類型的版本號直接作為ETag會產生安全性問題,因為客戶端可以輕易地猜出下一個數值。因此,我們選擇了使用高級加密標準(AES)對版本號進行加密后,作為InventoryItemDetails方法的ETag輸出。
為每個操作都重新實現ETag對于API層來說有些負擔過重,因此我們定義了一個IConcurrencyAware接口:
public interface IConcurrencyAware { string ConcurrencyVersion { get; set; } }
每個支持ETag的輸出模型都要實現這個接口,當API層看到某個輸出模型支持這個接口時,就會讀取版本號并設置ETag值。另一方面,當API層對條件式GET請求進行響應時,會將生成的ETag與客戶端在If-None-Match頭中傳入的值進行比較。所有這些操作都可以通過一個單獨的全局filter實現:ConcurrencyAwareFilter。
需要注意的是,添加、刪除或者重命名某個庫存物品時應該使物品列表的緩存失效。請看下面的例子(條件式GET請求的邏輯是在瀏覽器端完成的,不需要特別編寫代碼實現):
GET /api/InventoryItem HTTP/1.1
If-None-Match:"CWtdfNImBWZDyaPj4UjiQr/OrCDIpmjVhwp8Zjy+Ok0="
返回結果是一個狀態碼為200的完整響應,并且包含了一個新的ETag值:
HTTP/1.1 200 OK Cache-Control:max-age=0, private Content-Length:68 ETag:"0O/961NRFDiIwvl66T1057MG4jjLaxDBZaZHD9EGeks=" Content-Type:application/json; charset=utf-8; domain- model=InventoryItemListDataCollection; version=1.0.0.0; format=application%2fjson; schema=application%2fjson; is-text=true ...
請注意Content-Type頭包含了額外的參數,這是對于“媒體類型的五種級別”(或者簡稱5LMT)概念的一種實現,這種方式不是將所有信息都塞到一個單獨的令牌(token)中,而是使用不同的參數來表達對用戶有用的不同級別的數據,能夠表達不同級別的有用信息。下文會對這個主題做進一步的討論。
命令
查詢通常會映射到GET方法,而命令則需要映射到POST、PUT、DELETE和PATCH方法。將HTTP謂詞映射到CRUD操作是一種流行的觀念,但在真實世界中很少能夠將謂詞和數據庫操作一一對應。實際上,REST API并不在對持久化存儲之上的一個簡單封裝,相反,它是指引用戶去了解業務領域、操作與工作流的一扇門。因此它必須能夠不依賴于特定的謂詞去表達某個維度的意圖。
一種常見的方式是使用遠程過程調用(RPC)風格的資源,例如/api/InventoryItem/{id}/rename。雖然它看上去確實去除了對某種謂詞的依賴,但它違反了REST面向資源的表現能力。我們需要記住,資源是一個名詞,HTTP謂詞則表示動詞和動作,而自描述的消息(REST的宗旨之一)則是表達其它維度信息和意圖的手段。實際上,在HTTP消息中所包含的命令就應該足以描述任何人為的操作了。但是,完全依賴于請求體中的消息也有它自己的問題,因為請求體通常是作為流傳遞的,要在辯認出它的具體操作之前獲取整個請求體有時是不可能做到的,而且這也不是一種明智的做法。這里,我們將展示一種基于5LMT中的第4級別(即領域模型)處理請求的方式,命令的類型將包含在Content-Type頭中的某個參數內。
PUT /api/InventoryItem/4454c398-2fbb-4215-b986-fb7b54b62ac5 HTTP/1.1 Accept:application/json, text/plain, */* Accept-Encoding:gzip,deflate,sdch Content-Type:application/json;domain-model=RenameInventoryItemCommand
這樣就能夠將請求正確地輸送給服務端相應的處理方法了。那這種方式是否將過多的信息泄露給客戶端了呢?并非如此。輸入輸出消息的schema(以及名稱)是公開領域的一部分,客戶端必須能夠完整地訪問到它,因此它們依賴于schema也是在我們所預期的。
至于客戶端的實現只用了最少量的代碼,這里使用了一個AngularJS的裝飾(decorator)封裝了$http服務,它能夠讀取這個原型的返回內容,并且能夠在Content-Type頭中加入額外的參數信息。只要保持JavaScript構造函數的名稱不變就沒有問題。
我們已經解決了辨認當前正被調用的方法的問題,接下來需要將命令按照語義映射到相應的HTTP謂詞。在將命令映射到謂詞時,選擇正確謂詞的關鍵不僅僅在于語義,同樣要考慮冪等性(至于謂詞的安全性則無需顧忌,因為任何一個命令謂詞都是不安全的)。PUT、PATCH和DELETE是冪等的,而POST則不是冪等的(多次調用一個冪等的謂詞的結果與僅調用一次是相同的)。
CreateInventoryItemCommand
從CRUD范式的角度來說,CreateInventoryItemCommand很自然地適用于POST方法。(這里只顯示重要的頭信息)
POST /api/InventoryItem HTTP/1.1 Content-Type:application/json;domain-model=CreateInventoryItemCommand {"name": "CQRS Book"}
返回的響應如下:
HTTP/1.1 202 Accepted
Location: http://localhost/SimpleCQRS.Api/api/InventoryItem/
109712b9-c3d5-4948-9947-b07382f9c8d9
該操作將在location頭信息中返回這個將被創建的庫存物品(因為所有操作都是異步執行的)的URL地址。
DeactivateInventoryItemCommand
如同前文所述,取消激活庫存物品就代表一次邏輯刪除。此外,刪除操作是冪等的,因為多次刪除一個庫存物品的效果和一次刪除是一樣的。因此我們將使用DELETE選項作為取消激活某個物品的方式(該方法帶有一個空的方法體)。
DELETE /api/InventoryItem/f2b75f21-001a-4eed-b8f3-35bf5e4e9b0d HTTP/1.1 Content-Type:application/json;domain-model=DeactivateInventoryItemCommand {}
返回的響應如下:
HTTP/1.1 202 Accepted
雖然也可以在方法體中傳遞id,但在URL中已經提供了id信息。DeactivateInventoryItemCommand構造函數的唯一職責是正確地設置domain-model這個參數。
RenameInventoryItemCommand
RenameInventoryItemCommand比起其它命令來說更有趣一點。首先,重命名一個庫存物品也就是進行修改,因此使用PUT謂詞是最合適的。另一方面,如果你正在重命名某個物品時,你的同事也在嘗試將其重命名為另一個名字的話會怎樣呢?這就是一個并發問題。HTTP通過If-Unmodified-Since和If-Match提供了對資源進行并發修改時的保護機制。因為我們使用了ETag,因此就相應地設置If-Match:
PUT /api/InventoryItem/f2b75f21-001a-4eed-b8f3-35bf5e4e9b0d HTTP/1.1 Content-Type:application/json;domain-model=RenameInventoryItemCommand If-Match:"DL1IsUoH709K+N5TXFzlQeQI5arO8r/U0SzXcRhuXLc=" {"newName": "CQRS Book 1"}
AngularJs的controller會傳遞ETag值,并傳入模型中,之后在條件式PUT請求時進行使用。如你所見,ETag的值僅僅是對領域模型中版本號的一種表現,但我們對其進行加密以滿足HTTP規格的需要。服務端獲取到這個值之后進行解密并還原成版本號的數值。如果版本號不匹配,領域模型就會拋出一個ConcurrencyException異常,在API層的ConcurrencyExceptionFilterAttribute類捕獲到這個異常之后,會以HTTP語義的方式表現該異常。
HTTP/1.1 412 Precondition Failed
這個例子很好地說明了HTTP的并發如何與CQRS的并發檢查機制相結合。
CheckInItemsToInventoryCommand和RemoveItemsFromInventoryCommand
這兩個命令就更加有趣了。我們將往庫存中加入或刪除一些物品。從某方面來說,這種操作是對庫存物品的數量進行更新,因此可以將其實現為一個PUT(也許PATCH更合適)方法。但因為這兩個命令并非冪等(比如說,調用CheckInItemsToInventoryCommand兩次應該添加兩次庫存),因此最適合的謂詞實際上是POST。
客戶端將在Content-Type頭信息中的參數中設置領域模型的名稱,如同我們之前所見的一樣。
POST /api/InventoryItem/f2b75f21-001a-4eed-b8f3-35bf5e4e9b0d HTTP/1.1 Content-Type:application/json;domain-model=CheckInItemsToInventoryCommand {"count": "230"}
返回的響應是一樣的:
HTTP/1.1 202 Accepted
HTTP的其它方面
實現HTTP的一些其它方面也會帶來一些好處,HEAD也是一個重要的謂詞,它的響應結果和GET方法一樣,但返回的響應體中不包括任何內容。我們為所有GET資源都實現了HEAD謂詞,例如:
HEAD /api/InventoryItem HTTP/1.1 Accept:application/json, text/plain, */* Accept-Encoding:gzip,deflate,sdch
將返回
HTTP/1.1 200 OK
ETag: "LdHipfxR7BsfBI3hwqt2BLsno8ic98KmrIA1y67Nnw4="
具體在實現中會將HEAD請求轉向給GET方法的處理函數,而框架本身會在最后負責移除返回的內容。這一系列實現都是自動觸發的,因此在響應中可以正確地獲得ETag。
另一個需要實現的重要謂詞是OPTIONS,這個謂詞可以用以生成API文檔,不過我們這里只是簡單的返回該資源支持的所有謂詞:
OPTIONS /api/InventoryItem/f2b75f21-001a-4eed-b8f3-35bf5e4e9b0d HTTP/1.1
它將返回如下內容:
HTTP/1.1 200 OK Allow: GET,POST,OPTIONS,HEAD,DELETE,PUT Content-Length: 46 Content-Type: application/json; charset=utf-8; domain-model=String%5b%5d; version=4.0.0.0; format=application%2fjson; schema=application%2fjson; is-text=true ["GET","POST","OPTIONS","HEAD","DELETE","PUT"]
請注意,響應中的Allow頭對于OPTIONS請求來說是必須的。不過HTTP規格本身并沒有指定OPTIONS響應體中具體寫法,因此我們就將允許的謂詞作為一個字符串數組返回(注意,在domain-model參數中的String[]是經過UrlEncoded方法編碼的結果)。可以利用這個謂詞生成符合各種schema和語言需求的API文檔。
除了這些方法之外的任何調用都會返回一個方法未找到(method not found)或者405狀態碼,ASP.NET Web API自身已經實現了這一功能:
PUT /api/InventoryItem HTTP/1.1
{}
它將返回:
HTTP/1.1 405 Method Not Allowed Allow: POST,GET,HEAD,OPTIONS {"message":"Http Method not supported"}
討論
這一部分將詳細敘述某些理論概念,以及我們的決定中一些比較困難,或者可能引起爭議的部分。
可選的并發檢查
在m-r最初的實現中,所有命令(除了CreateInventoryItemCommand,它已經隱式地包含了值為0的版本號)都包含一個整數型的CurrentVersion字段。而這個版本中將它們修改為可選的(即C#中的可空類型)。
在一方面,服務端應該負責保證自身狀態的完整性。因此它不能、也不應該依賴于客戶端所提供的版本號。并發檢查是作為一個特性提供給客戶端的,而不是服務端用以保證模型完整性的機制。如果客戶端關心并發行為,那它就可以選擇性地發送版本號,這已經通過在ETag中的加密信息提供給它們了。要記住的是,并發檢查與服務端的事件版本號是不同的概念,后者是服務端的內部實現機制。
另一方面,對于某些操作來說,并發檢查是沒有意義的。舉例來說,如果兩個客戶端在同一時間(調用CheckInItemsToInventoryCommand方法)添加了20個庫存物品,并且它們都具有版本號n,那么其中有一個命令就會失敗,但這種失敗是不必要的,因為我們確實需要添加40個物品。這種問題在高訪問量的情況下會被放大。想象一下,如果大量的用戶涌入亞馬遜網站去購買哈利波特的最新一期,在多數情況下他們都會遇到并發問題。
在HTTP中執行PUT(和PATCH)操作時會認為并發是一個可選的檢查,這一點并非偶然。雖然并發檢查可以異步執行,但我們需要盡力保證它必須同步執行,因此當我們返回狀態碼202(已接受)時,就代表服務端已經確認了沒有并發沖突情況的產生。
媒體類型的五種級別(5LMT)和創建新的媒體類型
在社區里常見的一種做法是創建新的媒體類型,通常稱為打造新的媒體類型。舉例來說:
Content-Type:application/vnd.InventoryItemListDataCollection.1.0.0.0+json;
這種使用非正規的方式表示某個媒體類型的子類型已經成為了一種通用的實踐(已經實際上成為一種約定了),它將子系統分解為一些特定的、或者是正式的元素,并通過+號連接在一起。已經有些經過注冊的媒體類型使用了這種約定,例如application/rss+xml和application/atom+xml。這兩個示例處于媒體類型級別中的第3級別(或者叫做schema級別),而application/xml則處于第2級別(format級別)。某種意義上說,application/atom+xml就是一種application/xml類型,它們使用相同的format,而前者還指明了會使用ATOM schema。
雖然這一約定會在未來版本的HTTP規格中得到認可,但它并未解決媒體類型不斷增長的問題。首先,使用任何未注冊的媒體類型都是HTTP規格所不提倡的,使用以上類型的Content-Type值也是一樣。實際上,如果我們需要在所有API中為五個不同媒體級別的任意組合都注冊一種媒體類型,那互聯網號碼分配局(IANA)恐怕需要發動一大批人去專門從事這個規模巨大的任務了。另一方面,許多客戶端系統使用基于dictionary的媒體類型去處理這種請求,它們將不能夠應付新創建的媒體類型。
因此使用5LMT能夠允許現有的客戶端繼續按照之前的方式正常工作,而更先進的客戶端則可以利用更高級別的信息,它們都是作為獨立的實體提供的。
通過一個公開的領域保護內部領域是關鍵所在
將服務端的內部實現進行抽象對客戶端來說是非常重要的。如同之前所述,為較小的領域所創建的公開領域和內部領域會比較相似,但即使是在m-r這個示例中,我們也不能夠將內部領域直接暴露出來,而必須創建一個獨立的模型,它表現了客戶端能夠接收和交互的信息。
我們還應該將公開領域文檔化,并展現給客戶端。這一方面的進展值得關注,因為已經有各種不同的方法和實踐開始露出水面了(從WADL到Swagger、RAML和RestDown等等)。
結論
不僅通過一套REST API暴露CQRS是可能的,而且HTTP語義的豐富性也使得我們能夠在它的基礎上編寫一套流暢而有效的API。整個流程包括創建一個由命令和查詢(輸入輸出消息)組成的公開領域,以及能夠處理并發和緩存的各種資源。此外,我們還需要將內部領域的查詢和命令映射為HTTP謂詞,并且使用狀態碼以表現狀態轉換和異常。使用5LMT將有助于創建完全RESTful,而不是遠程過程調用風格的資源。所有這些都可以通過一個很小但可以運行的原型應用進行展現,該原型是通過ASP.NET Web API和AngularJS實現的。