後端開發實踐系列之四——簡單可用的CQRS編碼實踐
- 2019 年 10 月 11 日
- 筆記
本文只講了一件事情:軟件模型中存在讀模型和寫模型之分,CQRS便為此而生。
20多年前,Bertrand Meyer在他的《Object-Oriented Software Construction》一書中提出了CQS(Command Query Seperation,命令查詢分離)的概念,指出:
Every method should either be a command that performs an action, or a query that returns data to the caller, but never both. (一個方法要麼作為一個「命令」執行一個操作,要麼作為一次「查詢」向調用方返回數據,但兩者不能共存。)
這裡的「命令」可以理解為更新軟件狀態的寫操作,Martin Fowler將此稱為「Modifier」;而「查詢」即為讀操作,是無副作用的。這種分離的好處在於使程序變得更容易推理與維護,由於查詢操作不會更新軟件狀態,在編碼時我們將更加有信心。試想,如果程序中出了一個bug,如果這個bug出現在查詢過程中,那麼我們至少可以消除這個bug可能給軟件帶來臟數據的恐懼。
後來,Greg Young在此基礎上提出了CQRS(Command Query Resposibility Segregation,命令查詢職責分離),將CQS的概念從方法層面提升到了模型層面,即「命令」和「查詢」分別使用不同的對象模型來表示。
採用CQRS的驅動力除了從CQS那裡繼承來的好處之外,還旨在解決軟件中日益複雜的查詢問題,比如有時我們希望從不同的維度查詢數據,或者需要將各種數據進行組合後返回給調用方。此時,將查詢邏輯與業務邏輯糅合在一起會使軟件迅速腐化,諸如邏輯混亂、可讀性變差以及可擴展性降低等等一些列問題。
一個例子
設想電商系統中的訂單(Order)對象,一開始其對應的OrderRepository類可以簡單到只包含2個方法:
public interface OrderRepository { void save(Order order); Order byId(String id); }
在項目的演進中,你可能需要依次實現以下需求:
- 查詢某個Order詳情,詳情中不用包含Order的某些字段;
- 查詢Order列表,列表中所展示的數據比Order詳情更少;
- 根據時間、類別和金額等多種篩選條件查詢Order列表;
- 展示Order中的產品(Product)概要信息,而Product屬於另一個業務實體;
- 展示Order下單人的昵稱,下單人信息屬於另一個單獨的賬戶系統,用戶修改昵稱之後,Order下單人昵稱也需要相應更新;
- ……
當這些需求實現完後,你可能會發現OrderRepository和領域模型已經被各種「查詢」功能淹沒了。什麼?OrderRepository不是給領域模型提供Order聚合根對象的嗎,為什麼卻充斥着如此多的查詢邏輯?
CQRS通過單獨的讀模型解決上述問題,其大致的架構圖如下:
對於Command側,主要的講究是將業務用例建模成對應的Command對象,然後在對Command的處理流程中應用核心的業務邏輯,其中最重要的是領域模型的建模,關於此的內容請參考筆者的《領域驅動設計(DDD)編碼實踐》文章,本文着重介紹Query側的編碼實踐。
在本文中,查詢模型(Query Model)也被表達為讀模型(Read Model);命令模型(Command Model)也被表達為寫模型(Write Model)。
CQRS實現模式概覽
常見誤解
在網上搜索一番,你會發現很多關於CQRS的文章都將CQRS與Event Sourcing(事件溯源)結合起來使用,這容易讓人覺得採用CQRS就一定需要同時使用Event Sourcing,事實上這是一種誤解。CQRS究其本意只是要求「讀寫模型的分離」,並未要求使用Event Sourcing;再者,Event Sourcing會極大地增加軟件的複雜度,而本文追求的是「簡單可用的CQRS」,因此本文將不會涉及Event Sourcing相關內容。更多內容,請參考簡化版CQRS的文章。
另外需要指出的是,讀寫模型的分離並不一定意味着數據存儲的分離,不過在實際應用中,數據存儲分離是一種常見的CQRS實踐模式,在這種模式中,寫模型的數據會同步到讀模型數據存儲中,同步過程通常通過消息機制完成,在DDD場景下,消息通常承載的是領域事件(Domain Event)。
查詢模型的數據來源
無論是單體還是微服務,所讀數據的唯一正確來源(Single Source of Truth)最終都來自於業務實體(Entity)對象(比如DDD中的聚合根),基於此,所讀數據的來源形式大致分為以下幾種:
- 所讀數據來源於同一個進程空間的單個實體(後文簡稱「單進程單實體」),這裡的進程空間指某個單體應用或者單個微服務;
- 所讀數據來源於同一個進程空間中的多個實體(後文簡稱「單進程跨實體」);
- 所讀數據來源於不同進程空間中的多個實體(後文簡稱「跨進程跨實體」)。
讀寫模型的分離形式
CQRS中的讀寫分離存在2個層次,一層是代碼中的模型是否需要分離,另一層是數據存儲是否需要分離,總結下來有以下幾種:
- 共享存儲/共享模型:讀寫模型共享數據存儲(即同一個數據庫),同時也共享代碼模型,數查詢據通過模型轉換(Projection)後返回給調用方,事實上這不能算CQRS,但是對於很多中小型項目而言已經足夠;
- 共享存儲/分離模型:共享數據存儲,代碼中分別建立寫模型和讀模型,讀模型通過最適合於查詢的方式進行建模;
- 分離存儲/分離模型:數據存儲和代碼模型都是分離的,這種方式通常用於需要聚合查詢多個子系統的情況,比如微服務系統。
將以上「查詢模型的數據來源」與「讀寫模型的分離形式」相組合,我們可以得到以下不同的CQRS模式及其適用範圍:
數據來源形式 | 模型分離形式 | 適用範圍 |
---|---|---|
單進程單實體 | 共享存儲/共享模型 | 其實算不上CQRS,但對於很多中小型項目已經足夠 |
單進程單實體 | 共享存儲/分離模型 | 適用於單實體查詢比較複雜或者對查詢效率要求較高的場景 |
單進程單實體 | 不同存儲/分離模型 | 適用於對單個實體的查詢非常複雜的場景 |
單進程跨實體 | 共享存儲/共享模型 | 不適用 |
單進程跨實體 | 共享存儲/分離模型 | 適用於查詢比較複雜的場景,比如需要做多表join操作 |
單進程跨實體 | 分離存儲/分離模型 | 適用於複雜查詢或者對查詢效率要求較高的情況 |
跨進程跨實體 | 共享存儲/共享模型 | 不適用 |
跨進程跨實體 | 共享存儲/分離模型 | 不適用 |
跨進程跨實體 | 分離存儲/分離模型 | 主要用於微服務中需要對多個服務進行聚合查詢的場景 |
總結下來,有以下幾種常見做法:
- 單進程單實體 + 共享存儲/共享模型
- 單進程單實體 + 共享存儲/分離模型
- 單進程跨實體 + 共享存儲/分離模型
- 單進程跨實體 + 分離存儲/分離模型
- 跨進程跨實體 + 分離存儲/分離模型
接下來,針對以上幾種常見做法,本文將依次給出編碼示例。
CQRS編碼實踐
本文的示例是一個簡單的電商系統,其中包含以下微服務:
服務 | 用途 | 所含實體 | Git地址 |
---|---|---|---|
訂單服務 | 用於用戶下單 | Order | ecommerce-order-service |
訂單查詢服務 | 用於訂單的CQRS查詢操作 | 無 | ecommerce-order-query-service |
產品服務 | 用於管理/展示產品信息 | Product Category(產品目錄) |
ecommerce-product-service |
庫存服務 | 用於管理產品對應的庫存 | Inventory | ecommerce-inventory-service |
示例代碼請參考:
請注意,本文的示例電商項目只是一個虛構出來的簡單項目,僅僅用於演示CQRS的各種編碼模式,並不具備實際參考價值。
針對以上各種CQRS模式組合,本文將使用電商系統中的以下業務用例進行演示:
CQRS模式 | 業務查詢用例 | 所屬服務 |
---|---|---|
單進程單實體 + 共享存儲/共享模型 | Inventory詳情查詢 | 庫存服務 |
單進程單實體 + 共享存儲/分離模型 | Product摘要查詢 | 產品服務 |
單進程跨實體 + 共享存儲/分離模型 | Product詳情查詢(包含Category信息) | 產品服務 |
單進程跨實體 + 分離存儲/分離模型 | Product詳情查詢(包含Category信息) | 產品服務 |
跨進程跨實體 + 分離存儲/分離模型 | Order詳情查詢(包含Product信息) | 訂單查詢服務 |
1. 單進程單實體 + 共享存儲/共享模型
對於簡單的單體或者微服務應用,這種方式是最自然最直接的方式,事實上我們並不需要太多設計上的思考便能想到這種方式。在這種方式中,存在單個領域實體模型同時用於讀寫操作,在向調用方返回查詢數據時,需要針對性地對領域模型進行轉換,轉換的目的在於:
- 調用方所需的數據模型與領域模型可能不一致;
- 有些敏感信息是不能返回給調用方的,需要屏蔽;
- 從設計上講,領域模型不能直接返回給調用方,否則會產生領域模型的泄露
- 將領域模型直接返回給調用方會在領域模型與對外接口間產生強耦合,不利於領域模型自身的演進。
這裡,我們以「庫存(Inventory)詳情查詢」為例進行演示,Inventory
領域模型定義如下:
public class Inventory{ private String id; private String productId; private String productName; private int remains; private Instant createdAt; }
在獲取Inventory詳情時,我們並不需要返回領域模型中的productId
和createdAt
字段,於是在Inventory
中創建相應的轉換方法如下:
public InventoryRepresentation toRepresentation() { return new InventoryRepresentation(this.id, this.productName, this.remains); }
這裡的InventoryRepresentation
即表示讀模型,後綴Representation
取自REST中的「R」,表示讀模型是一種數據展現,下文將沿用這種命名形式。在InventoryApplicationService
服務中返回InventoryRepresentation
:
public InventoryRepresentation byId(String inventoryId) { return repository .byId(inventoryId) .toRepresentation(); }
值得一提的是,在查詢Inventory時,我們使用了應用服務(ApplicationService)-InventoryApplicationService
,此時的InventoryApplicationService
同時承擔了讀操作和寫操作的業務入口,在實踐中也可以將此二者分離開來,即讓InventoryApplicationService
只負責寫操作,而另行創建InventoryRepresentationService
專門用於讀操作。
另外,拋開CQRS,為了保證每一個聚合根實體自身的完備性,即便在沒有調用方查詢的情況下,筆者也建議為每一個聚合根提供一個Representation
並對外暴露查詢接口。因此每一個聚合根中都會有一個toRepresentation()
方法,該方法僅僅返回當前聚合根的狀態,而不會關聯其他實體對象(比如下文提到的「單進程跨實體」)。
2. 單進程單實體 + 共享存儲/分離模型
有時,即便是對於單個實體,其查詢也會變得複雜,為了維護讀寫過程彼此的清晰性,我們可以對讀模型和寫模型分別建模,事實上這也是CQRS的本意。
在Product服務中,需要返回Product的摘要信息,並對返回列表進行分頁處理,為此獨立於ApplicationService創建ProductRepresentationService
,直接從數據庫讀取數據構建ProductSummaryRepresentation
。
@Transactional(readOnly = true) public PagedResource<ProductSummaryRepresentation> listProducts(int pageIndex, int pageSize) { MapSqlParameterSource parameters = new MapSqlParameterSource(); parameters.addValue("limit", pageSize); parameters.addValue("offset", (pageIndex - 1) * pageSize); List<ProductSummaryRepresentation> products = jdbcTemplate.query(SELECT_SQL, parameters, (rs, rowNum) -> new ProductSummaryRepresentation(rs.getString("ID"), rs.getString("NAME"), rs.getBigDecimal("PRICE"))); int total = jdbcTemplate.queryForObject(COUNT_SQL, newHashMap(), Integer.class); return PagedResource.of(total, pageIndex, products); }
這裡,我們繞過了領域模型Product
,也繞過了其對應的ProductRepository
,以最快速的方式從數據庫中直接獲取數據。
3. 單進程跨實體 + 共享存儲/分離模型
既然單個實體都有必要使用分離模型,那麼在同一個進程空間中的跨實體查詢更有理由使用分離模型的形式。對於簡單形式跨實體查詢,還用不着使用分離的存儲,只需要做一些join聯合查詢即可。
在Product服務中,存在Product
和Category
兩個聚合根對象, 在查詢Product
時,我們希望一併帶上Category
的信息,為此創建ProductWithCategoryRepresentation
如下:
@Value public class ProductWithCategoryRepresentation { private String id; private String name; private String categoryId; private String categoryName; }
在ProductRepresentationService
中,直接從數據庫獲取Product
和Category
數據,此時需要對PRODUCT
和CATEGORY
兩張表做join操作:
@Transactional(readOnly = true) public ProductWithCategoryRepresentation productWithCategory(String id) { String sql = "SELECT PRODUCT.ID, PRODUCT.NAME, CATEGORY.ID AS CATEGORY_ID, CATEGORY.NAME AS CATEGORY_NAME FROM PRODUCT JOIN CATEGORY ON PRODUCT.CATEGORY_ID=CATEGORY.ID WHERE PRODUCT.ID=:productId;"; return jdbcTemplate.queryForObject(sql, of("productId", id), (rs, rowNum) -> new ProductWithCategoryRepresentation(rs.getString("ID"), rs.getString("NAME"), rs.getString("CATEGORY_ID"), rs.getString("CATEGORY_NAME"))); }
需要注意的是,如果join的級聯太多,那麼會大大影響查詢的效率,並且使程序變得更加複雜。一般來講,如果join次數達到了3次及其以上,建議考慮採用分離存儲的形式。
4. 單進程跨實體 + 分離存儲/分離模型
依然以返回ProductWithCategoryRepresentation
為例,假設我們認為先前的join操作太複雜或者太低效了,需要採用專門的數據庫來簡化查詢提升效率。
為此創建單獨的讀模型數據庫表PRODUCT_WITH_CATEGORY
:
CREATE TABLE PRODUCT_WITH_CATEGORY ( PRODUCT_ID VARCHAR(32) NOT NULL, PRODUCT_NAME VARCHAR(100) NOT NULL, CATEGORY_ID VARCHAR(32) NOT NULL, CATEGORY_NAME VARCHAR(100) NOT NULL, PRIMARY KEY (PRODUCT_ID) ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
讀寫同步通常通過領域事件的形式完成,由於是在同一個進程空間中,因此讀寫同步相比於跨進程的同步來說,可以有更多的選擇:
- 使用進程內事件機制(比如Guava的EventBus),在與寫操作相同的事務中同步,這種方式的好處是可以保證寫操作與同步操作的原子性進而確保讀寫間的數據一致性,缺點是在寫操作過程中存在額外的數據庫同步開銷進而增加了寫操作的延遲時間;
- 使用進程內事件機制,獨立事務同步(比如Guava的AsyncEventBus),這種方式的好處是寫操作和同步操作彼此獨立互不影響,缺點是無法保證二者的原子性進而可能使系統產生臟數據;
- 使用獨立的消息機制(比如RabbitMQ/Kafka等),獨立事務同步,可以將查詢功能分離為單獨的子系統,事實上這種方式已經與「跨進程跨實體 + 分離存儲/分離模型」相似,因此請參考「5. 跨進程跨實體 + 分離存儲/分離模型」小節。
5. 跨進程跨實體 + 分離存儲/分離模型
這種方式在微服務中最常見,因為微服務系統首先是多進程的,每個服務都內聚性地管理自身的聚合根對象,另外,微服務的數據存儲通常也是獨佔式的,意味着在微服務系統中數據存儲一定是分離的,在這種場景下,跨微服務之間的查詢通常採用「API Compositon」模式或者本文的CQRS模式。
在"跨進程跨實體 + 分離存儲/分離模型"中,存在一個單獨的查詢服務用於CQRS的讀操作,查詢所需數據通常通過事件機制從不同的其他業務服務中同步而來,讀操作所返回的數據通過API Gateway或者BFF向外暴露,示意圖如下:
在本文的示例電商項目中,需要在查詢Order的時候同時帶上Product的信息,但是由於Order和Product分別屬於不同的服務,為此創建ecommerce-order-query-service
查詢服務,該服務負責接收Order和Product服務發佈的領域事件以同步其自身的讀模型OrderWithProductRepresentation
。
在ecommerce-order-query-service
服務中,在接收到OrderEvent
事件後,OrderQueryRepresentationService
負責分別調用Order和Product的接口完成數據同步:
public void cqrsSync(OrderEvent event) { String orderUrl = "http://localhost:8080/orders/{id}"; String productUrl = "http://localhost:8082/products/{id}"; OrderRepresentation orderRepresentation = restTemplate.getForObject(orderUrl, OrderRepresentation.class, event.getOrderId()); List<Product> products = orderRepresentation.getItems().stream().map(orderItem -> { ProductRepresentation productRepresentation = restTemplate.getForObject(productUrl, ProductRepresentation.class, orderItem.getProductId()); return new Product(productRepresentation.getId(), productRepresentation.getName(), productRepresentation.getDescription()); }).collect(Collectors.toList()); OrderWithProductRepresentation order = new OrderWithProductRepresentation( orderRepresentation.getId(), orderRepresentation.getTotalPrice(), orderRepresentation.getStatus(), orderRepresentation.getCreatedAt(), orderRepresentation.getAddress(), products ); dao.save(order); log.info("CQRS synced order {}.",orderId); }
在本例中,ecommerce-order-query-service
查詢服務使用了關係型數據庫,但在實際應用中應該根據項目所需選擇適當的數據存儲機制。例如,對於海量數據的查詢,可以選擇諸如MongoDB或者Cassandra之類的NoSQL數據庫;而對於需要進行全文搜索的場景,可以採用Elasticsearch等。
事實上,在接收並處理事件時,存在2中風格,一種是本例中的僅將事件作為消息通知,然後調用其他服務的API接口完成同步,另一種是直接使用事件所攜帶的數據進行同步,更多關於這2種風格的比較,請參考筆者的《事件驅動架構(EDA)編碼實踐》文章。
事件驅動架構總是意味着異步,它將給軟件帶來以下方面的影響:
-
讀模型和寫模型之間不再是強事務一致性,而是最終一致性。
-
從用戶體驗上講,用戶發起操作之後將不再立即返回結果數據,此時要麼需要調用方(比如前端)進行輪詢查詢,要麼需要在用戶體驗上[做些權衡】(http://danielwhittaker.me/2014/10/27/4-ways-handle-eventual-consistency-ui/),比如使用確認頁面延遲用戶對查詢數據的獲取。
關於Representation對象的命名
命名總是一件令開發者頭疼的事情,特別對於需要返回多種數據形式的查詢接口來說。為此,筆者自己採用以下方式命名不同的Representation
對象,以Order為例:
OrderRepresentation
:僅僅包含聚合根實體自身狀態詳情,一種常見的形式是通過Order.toRepresentation()
方法獲得OrderSummaryRepresentation
:用於返回聚合根的列表,僅僅包含Order本身的狀態OrderWithProductRepresentation
:用於返回帶有Product數據的Order詳情OrderWithProductSummaryRepresentation
:用於返回帶有Product數據的Order列表
當然,命名是一件見仁見智的事情,以上也絕非最佳方式,不過總的原則是要一致、清晰、可讀。
什麼時候該採用CQRS
事實上,不管是Martin Fowler、Udi Dahan還是Chris Richardson,都提醒到需要慎用CQRS,因為它會帶來額外的複雜性;而另有人(比如Gabriel Schenker)卻提到,當前很多軟件邏輯複雜性能低下恰恰是因為沒有選擇CQRS造成的。
的確,不管在架構層面還是編碼層面,採用CQRS的都會增加程序的複雜度和代碼量,不過,這種複雜性可以在很大程度上被其所帶來的「條理性」所抵消,「有條理的多」恰恰是為了簡單。因此,當你的項目正在承受本文一開始的「一個例子」小節中所提到的「痛楚」時,不妨試一試本文提到的幾種簡化版的CQRS實踐。
總結
本文本着「簡單可用的CQRS」的目的講到了不同的CQRS實現模式,其中包含如何在單體和微服務架構中進行不同的CQRS落地實踐。可以看出,CQRS並不像人們想像中的那麼難,通過適當的設計與選擇,CQRS可以在很大程度上將程序架構變得更加的有條理,進而使軟件項目在CQRS上的付出變成一件值得做的事情。