使用CQRS避免查詢對模型設(shè)計(jì)的影響
本文轉(zhuǎn)載自微信公眾號「codeasy」,作者閻華。轉(zhuǎn)載本文請聯(lián)系codeasy公眾號。
使用了DDD(領(lǐng)域驅(qū)動(dòng)設(shè)計(jì))后,代碼編寫有什么不一樣呢?這可能是程序員們在接觸DDD后最關(guān)心的一個(gè)問題。這個(gè)系列文章會(huì)對一些優(yōu)秀的DDD實(shí)例代碼進(jìn)行分析,管中窺豹,略見數(shù)斑。這是第四篇,繼續(xù)以IDDD_Sample為例做分析。
今天我們說一下“讀”類型的應(yīng)用服務(wù)怎么設(shè)計(jì),以及如何獲得更好的性能。
模型設(shè)計(jì)時(shí)不要考慮查詢
按DDD設(shè)計(jì)領(lǐng)域模型時(shí),有兩個(gè)很有用的原則:
- 模型對展現(xiàn)技術(shù)要無感知,比如展現(xiàn)是用WEB,還是APP端,在做模型設(shè)計(jì)時(shí)不要考慮
- 不要關(guān)心復(fù)雜查詢和報(bào)表的需求
那這樣領(lǐng)域模型設(shè)計(jì)出來后,經(jīng)常受到的挑戰(zhàn)是查詢性能不好。相比于以數(shù)據(jù)為中心的設(shè)計(jì)思維,以領(lǐng)域?qū)ο鬄橹行牡脑O(shè)計(jì)思維設(shè)計(jì)出的數(shù)據(jù)存儲(chǔ)粒度更細(xì),冗余更少,確實(shí)不利于查詢。
另外一個(gè)對查詢不友好的地方在于聚合這個(gè)概念。比如我想查一個(gè)訂單列表,列表中的每一行只需要訂單這個(gè)聚合根的數(shù)據(jù),不需要把每一條訂單下的子實(shí)體都被查詢出來,那樣性能會(huì)很差。
可以使用JPA提供的懶加載來實(shí)現(xiàn)只加載聚合根,但是會(huì)帶來更多的其它的問題,懶加載已被認(rèn)為是不被推薦使用的反模式。
大部分的業(yè)務(wù)系統(tǒng)都是讀多寫少,領(lǐng)域模型是重要,但能提供符合性能要求的查詢也很重要。DDD是怎么解決這個(gè)矛盾的呢?
模型對查詢不友好怎么辦
DDD推薦使用CQRS( Command Query Responsibility Segregation, 命令職責(zé)分離)這種模式來解決這個(gè)問題。
https://martinfowler.com/bliki/CQRS.html
我們來看看IDDD_Sample的 com.saasovation.collaboration.application.forum 下的應(yīng)用服務(wù),這些服務(wù)分成了兩類:
image.png
一類是 ApplicationService 一類是 QueryService 。
ApplicationService里的方法我們上篇文章里見過了,方法的入?yún)⑹且粋€(gè) Command 對象,返回值是一個(gè) CommandResponse 對象。方法里通過 Repository 獲取聚合根,操作聚合根后,再通過 Repository 來持久化。這就是CQRS里的“C”,即命令(Command),它會(huì)改變系統(tǒng)的狀態(tài)。
在collaboration這個(gè)上下文的實(shí)現(xiàn)里,沒有把入?yún)b成Command對象,可以參考agilepm上下文中ApplicationService的實(shí)現(xiàn)。IDDD_Sample為了演示各種風(fēng)格,不同上下文的實(shí)現(xiàn)方式不太統(tǒng)一。也有人是把這種ApplicationService里的一個(gè)個(gè)方法變成一個(gè)個(gè)CommandHandler,可讀性更好,但本質(zhì)上是一樣的。
CQRS里的“Q”指的是Query,顧名思義,查詢不會(huì)改變系統(tǒng)狀態(tài)的。
那分離是什么意思呢?
最粗淺的理解是把這些查詢方法放到 QueryService 里,而不是放到ApplicationService 里。但這只是個(gè)表象。我們看一個(gè)Query方法是怎么寫的:
- public ForumDiscussionsData forumDiscussionsDataOfId(String aTenantId, String aForumId) {
- return this.queryObject(
- ForumDiscussionsData.class,
- "select "
- + "forum.closed, forum.creator_email_address, forum.creator_identity, "
- + "forum.creator_name, forum.description, forum.exclusive_owner, forum.forum_id, "
- + "forum.moderator_email_address, forum.moderator_identity, forum.moderator_name, "
- + "forum.subject, forum.tenant_id, "
- + "disc.author_email_address as o_discussions_author_email_address, "
- + "disc.author_identity as o_discussions_author_identity, "
- + "disc.author_name as o_discussions_author_name, "
- + "disc.closed as o_discussions_closed, "
- + "disc.discussion_id as o_discussions_discussion_id, "
- + "disc.exclusive_owner as o_discussions_exclusive_owner, "
- + "disc.forum_id as o_discussions_forum_id, "
- + "disc.subject as o_discussions_subject, "
- + "disc.tenant_id as o_discussions_tenant_id "
- + "from tbl_vw_forum as forum left outer join tbl_vw_discussion as disc "
- + " on forum.forum_id = disc.forum_id "
- + "where (forum.tenant_id = ? and forum.forum_id = ?)",
- new JoinOn("forum_id", "o_discussions_forum_id"),
- aTenantId,
- aForumId);
- }
來自 ForumQueryService
我們發(fā)現(xiàn)這個(gè)查詢服務(wù)里即沒有使用領(lǐng)域?qū)ο笠矝]使用 Repository ,甚至 join 的兩個(gè)表是代表兩個(gè)聚合根的數(shù)據(jù)!在Query方法里可以直接查詢數(shù)據(jù)庫去返回一個(gè)查詢顯示用的DTO,這可以看做是分離的第一個(gè)意思,即Query里可以不使用領(lǐng)域?qū)ο蠛? Repository 。
我覺得可以不使用,意味著也可以使用
第二個(gè)意思是數(shù)據(jù)存儲(chǔ)的分離。最簡單的方式是處理 Command 的 ApplicationService/CommandHandler 訪問的是主庫,而 QueryService 訪問的是從庫。
在復(fù)雜一點(diǎn),ApplicationService 可以訪問MySQL,而 QueryService 可以訪問Elasticsearch這種NoSQL,這時(shí)候需要做數(shù)據(jù)的同步。觸發(fā)數(shù)據(jù)同步,可以監(jiān)聽領(lǐng)域事件來實(shí)現(xiàn),也可以使用canal這種框架監(jiān)聽binlog來實(shí)現(xiàn)。除了專門為查詢而設(shè)計(jì)的NoSQL本身有更好的查詢性能,同時(shí),我們在數(shù)據(jù)結(jié)構(gòu)設(shè)計(jì)上也可以專門為查詢來做優(yōu)化,比如把多個(gè)關(guān)聯(lián)聚合的數(shù)據(jù)放到一起。
image.png
其實(shí),IDDD_Sample的例子中的 collaboration 這個(gè)上下文就是這么實(shí)現(xiàn)的——領(lǐng)域?qū)ο蟮拇鎯?chǔ)使用的是LevelDB,查詢用的是MySQL。
這種方式我很多年前就看到一個(gè)團(tuán)隊(duì)在用,他們也把Service分為WriteServcie和ReadService兩種,但是并沒有叫CQRS這個(gè)名字,也沒有總結(jié)為一種模式,而是憑著經(jīng)驗(yàn)這么做了。
事件溯源和CQRS
和CQRS經(jīng)常一起出現(xiàn)的一種模式是事件溯源(Event Sourcing)。事件溯源是一種更“前衛(wèi)”的模式,它不存儲(chǔ)對象的狀態(tài),相反,存儲(chǔ)影響其狀態(tài)的所有事件。
需要查詢對象的當(dāng)前狀態(tài)時(shí),只要把所有事件回放一遍就能得到。但是,這種回放太昂貴了,所以可以保留一份對象的最新快照,這就需要和CQRS模式結(jié)合。

image.png
IDDD_Sample例子中的 collaboration 這個(gè)上下文實(shí)際上使用的就是事件溯源。我們看它的 Repository 實(shí)現(xiàn),存儲(chǔ)的是事件而不是實(shí)體本身:
事件溯源加上CQRS很“酷”,但一般不建議使用,實(shí)現(xiàn)的成本太高。
- public class EventStoreForumRepository
- extends EventStoreProvider
- implements ForumRepository {
- @Override
- public void save(Forum aForum) {
- EventStreamId eventId =
- new EventStreamId(
- aForum.tenant().id(),
- aForum.forumId().id(),
- aForum.mutatedVersion());
- this.eventStore().appendWith(eventId, aForum.mutatingEvents());
- }
- }
即使是只使用CQRS這一模式,也建議循序漸進(jìn),從簡單開始,最基本的,把QueryService 和 ApplicationService 分開,且在設(shè)計(jì)領(lǐng)域模型時(shí)不要受 QueryService 設(shè)計(jì)的影響。