1. 程式人生 > >後端開發實踐系列之四——簡單可用的CQRS編碼實踐

後端開發實踐系列之四——簡單可用的CQRS編碼實踐

本文只講了一件事情:軟體模型中存在讀模型和寫模型之分,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);
}

在專案的演進中,你可能需要依次實現以下需求:

  1. 查詢某個Order詳情,詳情中不用包含Order的某些欄位;
  2. 查詢Order列表,列表中所展示的資料比Order詳情更少;
  3. 根據時間、類別和金額等多種篩選條件查詢Order列表;
  4. 展示Order中的產品(Product)概要資訊,而Product屬於另一個業務實體;
  5. 展示Order下單人的暱稱,下單人資訊屬於另一個單獨的賬戶系統,使用者修改暱稱之後,Order下單人暱稱也需要相應更新;
  6. ......

當這些需求實現完後,你可能會發現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

示例程式碼請參考:

https://github.com/e-commerce-sample

請注意,本文的示例電商專案只是一個虛構出來的簡單專案,僅僅用於演示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詳情時,我們並不需要返回領域模型中的productIdcreatedAt欄位,於是在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服務中,存在ProductCategory兩個聚合根物件, 在查詢Product時,我們希望一併帶上Category的資訊,為此建立ProductWithCategoryRepresentation如下:

@Value
public class ProductWithCategoryRepresentation {
    private String id;
    private String name;
    private String categoryId;
    private String categoryName;
}

ProductRepresentationService中,直接從資料庫獲取ProductCategory資料,此時需要對PRODUCTCATEGORY兩張表做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上的付出變成一件值得做的事情。