当前位置: 首页 > 科技观察

使用CQRS避免查询对模型设计的影响

时间:2023-03-13 07:53:17 科技观察

本文转载自微信公众号“codeeasy”,作者严华。转载本文请联系codeeasy公众号。使用DDD(领域驱动设计)后,代码写法有什么不同?这可能是程序员接触DDD后最关心的一个问题。本系列文章将剖析一些优秀的DDD示例代码,一窥豹。这是第四篇,继续以IDDD_Sample为例进行分析。今天我们就来谈谈如何设计“读取”类型的应用服务,以及如何获得更好的性能。设计模型时不考虑查询。在按照DDD设计领域模型时,有两个有用的原则:模型不应该感知显示技术,比如显示是使用WEB还是APP,设计模型时不考虑复杂的查询。领域模型设计和报表需求之后,经常遇到的挑战就是查询性能不好。与以数据为中心的设计思想相比,以领域对象为中心的设计思想将数据存储设计得粒度更细,冗余更少,确实不利于查询。另一个对查询不友好的是聚合的概念。比如我要查询一个订单列表,列表中每一行只需要订单聚合根的数据,不需要查询每个订单下的所有子实体,否则性能会很差贫穷的。可以使用JPA提供的懒加载只加载聚合根,但是会带来更多的其他问题。延迟加载已被视为已弃用的反模式。大多数业务系统读多写少。领域模型很重要,但提供满足性能要求的查询也很重要。DDD是如何解决这个矛盾的呢?如果模型对查询不友好怎么办?DDD推荐使用CQRS(CommandQueryResponsibilitySegregation,命令查询责任隔离)模型来解决这个问题。https://martinfowler.com/bliki/CQRS.html我们来看看IDDD_Sample的com.saasovation.collaboration.application.forum下的应用服务。这些服务分为两类:image.png,一类是ApplicationService,一类是QueryService。我们在上一篇文章中已经看到了ApplicationService中的方法。该方法的输入参数是一个Command对象,返回值是一个CommandResponse对象。方法中通过Repository获取聚合根,操作聚合根后通过Repository持久化。这就是CQRS中的“C”,命令(Command),它会改变系统的状态。在执行协作上下文时,输入参数没有被打包成Command对象。可以参考agilepm上下文中ApplicationService的实现。IDDD_Sample为了演示各种风格,不同上下文的实现方法并不统一。有人把这个ApplicationService中的每个方法都变成了CommandHandler,这样可读性更好,但本质上是一样的。CQRS中的“Q”指Query。顾名思义,查询不会改变系统的状态。分别是什么意思?最粗浅的理解就是把这些查询方法放在QueryService里面,而不是放在ApplicationService里面。但这只是表象。我们看一个查询方法是怎么写的:publicForumDiscussionsDataforumDiscussionsDataOfId(StringaTenantId,StringaForumId){returnthis.queryObject(ForumDiscussionsData.class,"select"+"forum.closed,forum.creator_email_address,forum.creator_identity,"+",forum.forumcreator_name.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_addressaso_discussions_author_email_address,"+"disc.author_identityaso_discussions_author_identity”+“disc.author_nameaso_discussions_author_name”+“disc.closedaso_discussions_closed”+“disc.discussion_idaso_discussions_discussion_id”+“disc.exclusive_owneraso_discussions_exclusive_owner”+“disc.forum_idaso_discussions_forum_id”+“disc.subjectaso_idaso_subject”+“disc.subjectaso_idaso_ten”+“disc.exclusive_owneraso_discussions_exclusive_owner”discussions_tenant_id"+"fromtbl_vw_forumasforumleftouterjointbl_vw_discussionasdisc"+"onforum.forum_id=disc.forum_id"+"where(forum.tenant_id=?andforum.forum_id=?)",newJoinOn("forum_id","o_discussions_forum_id"),aTenantId),}aForum来自ForumQueryService,我们发现这个查询服务既没有使用domainobjects也没有使用Repositories,甚至join中的两个表代表了两个聚合根的数据!在Query方法中可以直接查询数据库返回一个DTO进行查询display这个可以看成是分离的第一个意思,就是domainobjects和Repositories不能用在Query里面,我觉得不能用,意思是也可以用,第二个意思是分离数据存储的方式,最简单的方式是处理Command,ApplicationService/CommandHandler访问主库,QueryService访问从库,更复杂一点,ApplicationService访问MySQL,QueryService访问NoSQL比如Elasticsearch。这时候就需要进行数据同步。触发数据同步,可以监听Domainevents,也可以使用canal的框架实现监控binlog。除了专为查询而设计的NoSQL具有更好的查询性能外,同时我们还可以专门针对查询优化数据结构设计。例如,将多个关联和聚合的数据放在一起。image.png实际上,IDDD_Sample例子中的协同上下文是这样实现的——领域对象的存储使用LevelDB,查询使用MySQL。这样我在很多年前看到一个团队使用它。他们也把Service分为WriteServcie和ReadService,但是他们没有叫CQRS,也没有总结成一个模型,而是根据经验来的。事件溯源和CQRS经常与CQRS一起出现的一种模式是事件溯源。EventSourcing是一种更“先锋”的模式,它不存储对象的状态,而是存储影响其状态的所有事件。当需要查询对象的当前状态时,只需要回放所有事件即可获取。但是这种回放的代价太大,所以可以保留对象的最新快照,需要结合CQRS方式。image.pngIDDD_Sample示例中的协作上下文实际上使用了事件源。我们来看它的Repository实现,它存储事件而不是实体本身:事件跟踪加上CQRS很“酷”,但一般不推荐,实现成本太高。publicclassEventStoreForumRepositoryextendsEventStoreProviderimplementsForumRepository{@Overridepublicvoidsave(ForumaForum){EventStreamIdeventId=newEventStreamId(aForum.tenant().id(),aForum.forumId().id(),aForum.mutatedVersion());this.eventStore,WendaForum(ev..mutatingEvents());}}即使只使用CQRS模式,也建议循序渐进,从最简单的,最基础的开始,把QueryService和ApplicationService分开,设计的时候不要被QueryService设计影响域模型。