【迁移】CQRS 很难吗?(译文:底部有原文地址)

用户头像
罗琦
关注
发布于: 2020 年 05 月 22 日

分享下最近一直在研究的DDD-CQRS。



有些人说“CQRS很难吗?”



其实它和微服务结合起来才是王道!



是吗?好吧,我之前也一直是这么认为的!但是当我开始编写使用CQRS的第一件软件作品时,一切都改变了。我发现他一点都不复杂。而且我认为在大型团队中保持这种编程行为将变得更容易。



我也曾思考为什么人们普遍认为CQRS很难,后来我想到是为什么了。因为有很多规则!人们讨厌规则,现实社会中已经很多了,所以很多人沉迷于虚拟世界之中。规则让我们不舒服,因为我们必须遵守它。在这篇博文中,我将从几方面论证这些规则是容易理解的。



一般来说,我们认为CQRS是读写分离架构的一种实现方式。当我以这种方法来理解的时候我发现在简单的CQS实现和真实的CQRS之间还是有几个很重要的步骤需要区分对待的。这些步骤介绍了我之前提到的规则。



我们的旅程从下面这幅图开始:





上面是一张我们都很熟悉的经典N层架构图。如果往其中加入一些CQS的话,我们就简单地实现了将业务逻辑分离为命令和查询:





如果你在维护一个旧系统这可能是最难的一步(因为老代码基本上都是意大利面条似的)。同时这一步也可能是最有效果的,因为你从中对自己的代码有了一个整体的梗概。



下面先来介绍下命令(Command)和查询(Query)的定义吧!



首先,发送命令是唯一改变系统状态的方式。Commands对所有系统的变更都要负责。如果没有command,系统的状态就一直保持不变!Command不应该返回任何值。我使用两个类来实现:Command和CommandHandler。Command仅仅是被CommandHandler是用来作为一些操作的输入参数。在我这里,command仅仅是领域模型中调用的一系列特定操作。



query相当于读操作。通过读取系统的状态,过滤,聚合,转换数据,最后以某种特定的格式传输。它可以被执行多次并且不会影响到系统的状态。我通常在一个类中使用多个execute(...)方法来实现,但我现在却认为将Query和QueryHandler、QueryExecutor分开来讲或许是对的。



回到上面的那张图,我需要澄清一些事;我私自加进去了额外的变化,就是Model变成了Domain Model。model代表了数据的容器,而domain model则包含了复杂的商业逻辑。这点变化对于我们感兴趣的架构来说没什么直接的影响,但是值得注意的是由于command接管了修改系统状态的职责,主要的复杂性都集中在了Domain Model中。



稍后你就会发现Domain Model对于写模型来说很有用,但是对于读来讲却表现不是很好。





我们可以使用这种分离模型,通过ORM映射来构造查询,但是在某些场景,尤其是当ORM遇到负载失衡的时候,下面这个架构可能会更合适:





现在我们成功地在逻辑层面分离了Command和Query,但是他们还在共享同一个数据库。这意味着实际上读模型在使用的是DB的物化视图(也可通过数据库层面的读写分离代替视图)。当我们的系统不需要解决性能问题,而且我们记得在写模型变更的时候更新我们的查询的时候,这个解决方案还可以。



下一步是介绍完整的分离数据模型:





CQRS != Event Sourcing



Event Sourcing(事件溯源)经常伴随着CQRS出现。ES的的定义很简单:我们的领域产生的事件即表明了系统中发生过的变化。如果我们从刚开始就记录了整个系统并进行回放,我们就会拿到当初系统的状态记录。可以想象下银行账户,从刚开始的空账户,通过回放每个单一事务得到最终(也就是目前)的收支情况。所以,只要我们存储了所有的事件,就能得到系统当前的状态。





但是对于CQRS来说,领域模型到底是怎样存储的不是特别重要,ES仅仅是其中的一个选项。(也可以使用in-memory或mongo,redis等方式)



写模型



由于写模型定义了领域模型的主要职责,并且做了很多商业决定,它是系统的心脏。它体现了系统的真实状态,这些状态可以用来做出有意义的决定。



读模型



刚开始我使用写模型来构建凑合的查询,在不断的尝试之后,我们发现这很费时间。因为我们是工程师,优化是第二需求。我们设计的模型在读取时将时间消耗在了关联查询上。我们被迫预先计算出一些有报表需求的数据,这样会使它在查询时表现得很快。这很有趣,因为我们在这里使用到了缓存。依我看来这是对读模型最好的诠释:它就是一个合理存在的缓存。缓存在这里没有细讲,是因为我们还没有触及到项目的发布,非功能性需求不应该过早设计。



事实上,读模型可以设计的很复杂,你可以使用图数据库(类似Neoj)来存储社交网络,而使用RDBMS(关系型数据库)来存储财务数据。



设计良好的读模型是需要一定的考量的。如果你的项目不是很大,写模型已经可以胜任几乎所有的读取需求,那么设计个读模型将会导致大量的拷贝代码,这种做法无疑是在浪费时间。但是如果你的写模型存储了一系列的事件,那么你将毫不费力得从中获取到任何时间点上的数据,而不用将事件从零开始进行回放。这个过程被称作Eager Read Derivation,也是我认为的在CQRS最复杂的一块。读模型即如我之前所说,是另一种形式的缓存。而Phil Karlton曾说过“在计算机科学中只有两个问题值得我们关注:缓存的时效性和命名问题”



最终一致性



如果我们的模型处于物理上隔离的状况,那么同步就需要花费一些时间,但是这段时间对于某些业务来讲是不能耽误的。在我的项目中,如果所有的部分都运行得很正确,读模型处于不同步的状态是可以忽略不计的。然而,我们必须将时间上的差异性考虑进去尤其是在开发更复杂的系统时。我们设计的UI也是为了能够及时的处理最终一致性。



我们不得不承认即使在写模型更新的时候读模型也相应更新的情况下,用户也是难以接受老旧数据的出现。更何况我们根部不确定展现在用户面前的数据是否是最新的。



下面我将要讲述一些实际的代码例子:



我是怎样将CQRS引入我的项目中的?



我认为CQRS是简单的以至于不需要引入任何框架。你可以从少于100行的代码开始慢慢地实现它,当需要时再去扩展新功能。你不用做任何努力(学习新的技术),因为CQRS已经简化了软件开发。下面是我的实现:



public interface ICommand {

}

public interface ICommandHandler
where TCommand : ICommand {
void Execute(TCommand command);
}

public interface ICommandDispatcher {
void Execute(TCommand command)
where TCommand : ICommand;
}




我定义了两个接口用来描述命令和他的执行环境。这么做是因为我想保持参数的扁平化,不想要任何依赖。我的命令处理器(command handler)能够从DI容器中请求依赖,根本没有必要手动初始化它(除了在自测用例中)。实际上,ICommand接口出现在这里无非是想告诉开发人员我们把这个类当做command来用。



public interface IQuery {

}

public interface IQueryHandler
where TQuery : IQuery {
TResult Execute(TQuery query);
}

public interface Interface IQueryDispatcher {
TResult Execute(TQuery query)
where TQuery : IQuery;
}



这里讲IQuery接口作为返回的query结果类型。但这不是最优雅的方法,但是在编译器类型被确定了。



public class CommandDispatcher : ICommandDispatcher {
private readonly IDependencyResolver _resolver;

public CommandDispatcher(IDependencyResolver resolver) {
_resolver = resolver;
}

public void Execute(TCommand command)
where TCommand : ICommand {
if(command == null) {
throw new ArgumentNullException("command");
}
var handler = _resolver.Resolver>();

if(handler == null) {
throw new CommandHandlerNotFoundException(typeof(TCommand));
}

handler.Execute(command);
}
}



这里的CommandDispatcher相对来说是短小的,它只有一个职责就是为command初始化合适的command handler并执行。为了避免手写command注册和初始化过程,我使用了DI容器来帮我做了。但是如果你不想使用DI容器你可以自己来实现。我认为这并不难。只有一个问题,那就是通用类型是比较难定义的,这在刚开始这么做时可能有些挫败感。但这种实现使用起来已经很简单了,这里是一个简单的command和handler的例子:

 

public class SignOnCommand : ICommand {

public AssignmentId Id { get; private set; }
public LocalDateTime EffectiveDate { get; private set; }

public SignOnCommand(AssignmentId assignmentId, LocalDateTime effectiveDate) {

Id = assignmentId;
EffectiveDate = effectiveDate;
}
}

public class SignOnCommandHandler : ICommandHandler {

private readonly AssignmentRepository _assignmentRepository;
private readonly SignOnPolicyFactory _factory;

public SignOnCommandHandler(AssignmentRepository assignmentRepository,
SignOnPolicyFactory factory) {
_assignmentRepository = assignmentRepository;
_factory = factory;
}

public void Execute(SignOnCommand command) {
var assignment = _assignmentRepository.GetById(command.Id);

if(assignment == null) {

throw new MeaningfulDomainException("Assignment not found!");
}

var policy = _factory.GetPolicy();

assignment.SignOn(command.EffectiveDate, policy);
}
}



只需要将SignOnCommand传入dispatcher就可以了:



_commandDispatcher.Execute(new SignOnCommand(new AssignmentId(rawId), effectiveDate));



就是这么简单!唯一的区别就是它返回了指定的数据,依赖于之前定义的通用的Execute方法,返回了强类型的结果:



public class QueryDispatcher : IQueryDispatcher {
private readonly IDependencyResolver _resolver;

public QueryDispatcher(IDependencyResolver resolver) {
_resolver = resolver;
}

public void Execute(TQuery query)
where TQuery : IQuery {
if(query == null) {
throw new ArgumentNullException("query");
}
var handler = _resolver.Resolver>();

if(handler == null) {
throw new QueryHandlerNotFoundException(typeof(TQuery));
}

handler.Execute(query);
}
}



这个实现是扩展性极强的。比如我们想引入事务到comamnd dispatcher中,可以像下面这样做,无须动用任何原有的实现代码:



public class TransactionalCommandDispatcher : ICommandDispatcher {
private readonly ICommandDispatcher _next;
private readonly ISessionFactory _sessionFactory
;

public TransactionalCommandDispatcher(ICommandDispatcher next,
ISessionFactory sessionFactory) {
_next = next;
_sessionFactory = sessionFactory;
}

public void Execute(TCommand command)
where TCommand : ICommand {
using(var session = _sessionFactory.GetSession())
using(var tx = session.BeginTransaction()) {

try {
_next.Execute(command);
ex.Commit();
} catch {
tx.Rollback();
throw;
}
}
}
}



通过使用这种“伪切面”,我们可以简单地实现Command和Query dispatcher的扩展。



现在你明白了CQRS并不难,基本的观点已经讲的很清晰了,但是你还是需要遵从一些规则。这篇博客没有包含全部的你想知道的东西,所以我推荐你读一读下面这些文章。



参考文献:



CQRS Documents by Greg Young



Clarified CQRS by Udi Dahan



CQRS by Martin Fowler



CQS by Martin Fowler



“Implementing DDD” by Vaughn Vernon



原文地址:https://www.future-processing.pl/blog/cqrs-simple-architecture/



用户头像

罗琦

关注

后浪 2017.12.15 加入

字节跳动工程师

评论

发布
暂无评论
【迁移】CQRS很难吗?(译文:底部有原文地址)