事件驱动的微服务-总体设计

事件驱动的微服务-总体设计

我在 “微服务之间的最佳调用方式” 中讲到了微服务之间的两种调用方式。微服务刚兴起时,大部分都是RPC的调用模式。我也写了一个RPC的架构,详情参见 “清晰架构(Clean Architecture)的Go微服务” 。但现在事件驱动的微服务越来越流行,因为大家觉得它是松耦合的。我会写一个新的系列来讲述如何构建事件驱动的微服务。本文是这个系列的第一篇,总体设计。

本文通过一个具体的例子来讲解事件驱动微服务的设计,它包含两个微服务,一个是订单服务(Order),另一个是支付服务(Payment),它们各自独立,每个服务有自己的源码库,数据库,各自单独部署。它们之间通过事件驱动的方式互相调佣。

在拿到一个新的项目时,我们有时会觉得不知道从哪下手。最通常的的思路是化繁为简,把一个大的项目分解为一个个小的部分再各个击破。把程序分层也是这个思路的具体应用。这里我们仍然沿用RPC时的架构,按照清晰架构把业务逻辑分成三层,域模型(model),用例(usecase)和数据服务(dataservice)。在事件驱动模式时会增加新的层,就是事件层(Event),它包含事件和事件驱动器(Event Handler)。在本文中,你将会读到如何对原来的架构进行扩展,增加事件处理功能。

本文从下面三个方面讲解程序的设计:

  • 模块设计
  • 框架(Framework)和库(Library)
  • 共享程序和第三方库

它们是设计中最基本的也是最重要的东西,有了它其他的东西才能在这个基础之上建立起来。

模块设计:

程序设计中的很重要的一步就是把程序的功能拆分成小的模块,并把程序分层,然后把这些模块放入相应的层级,最后确立模块之间的依赖关系。

程序分层

本程序基本延用了清晰架构的原则,但由于增加了事件驱动的部分,因此我引入了 “Domain-Driven Design” 的一些概念对清晰架构进行了一些改造。毕竟事件驱动的很多概念都是由DDD(Domain-Driven Design)提出来的,它有关于事件驱动的一整套理论和实践。对于DDD的理解,不同的人的解释可能稍有差别,其中最著名的应该来自于Eric Evans的书 “Domain-Driven Design: Tackling Complexity in the Heart of Software” 。但因为它稍微有一点虚,有些概念并没有明确的代码,因此可能会有不同的解读。由于这个原因,我采用了 “Patterns, Principles, and Practices of Domain-Driven Design” 这本书中对DDD的解释,它里面所有的概念都有具体的代码和实例,全部都是落了地的东西,这样至少不会产生歧义。

在“Pattern,Principles,and Practices of Domain-Driven Design”这本书中,它列举了DDD的8个组成模块,它们是值对象(Value Object),实体(Entities),域服务(Domain Service),域事件(Domain Event),聚合根(Aggregates),工厂(Factories),仓储(Respository)和事件溯源(Even Sourcing)。其中值对象,实体和聚合根都是域模型。域服务就是清晰架构中的用例。工厂(Factories)是用来创建类的,也就相当于Spring里的程序容器。仓储(Respository)就是数据持久层。

我们来看一下怎样把它们映射到清晰架构。首先,事件溯源(Even Sourcing)不是DDD的组成模块,而是一种实现方式(你可以用它,也可以不用),我们先把它去掉。剩下的7个模块是我们需要的。对于如何将DDD分层,大家的意见也并不统一,不过大致可以分成四层,领域层(Domain Layer),程序层(Application Layer),基础设施层(Infrastructure Layer)和用户界面层(User Interface Layer)。本文的重点在后端程序,所以我们只讨论前三层(把用户界面层去掉)。对于前三层的解释和应该包含的模块,大家也有不同意见。我先来讲一下上面书中的解释。对领域层(Domain Layer)的分歧较少,它主要是处理领域的业务逻辑。程序层(Application Layer)主要有三个功能,第一是用例,也就是有些业务逻辑涉及到多个域模型,放到那个单个模型都不合适,就放到程序层;第二是商业过程(Business Process),就是有些业务逻辑有流程,也需要涉及到多个域模型。第三是业务逻辑要调用外部的一些功能,例如发邮件,发消息。这部分又分成两块,一块是接口定义,放在程序层中,另一部分是具体实现,放在基础设施层(Infrastructure Layer)。

总体来说,这个分法还是比较靠谱的,但是我对它的一些细节还是有不同看法的。首先,用例里主要还是业务逻辑,只不过是跨了多个域模型,肯定还是应该放在领域层。其次,商业过程(Business Process)业务主要还是业务逻辑,也应该放在领域层。程序层(Application Layer)应该只有对外服务的接口,这样也符合书中对程序层的描述。因为作者一直在讲,程序层要尽量小。领域层才是大头。

如果我们把上面提到的7个模块分到不同的层中,我·觉得应该是这样的:

  • 领域层(Domain Layer): 包括聚合根(Aggregates),值对象(Value Object),实体(Entities),域服务(Domain Service),仓储(Respository),域事件(Domain Event)
  • 程序层(Application Layer): 包括外部服务的接口
  • 基础设施层(Infrastructure Layer): 包括外部服务接口的具体实现

工厂(Factories)不在上述任何一层里面,它其实就是程序容器,可以单独列为一层。

有一点需要说明的是我并没有完全采用DDD的架构,本程序的主要框架还是清晰架构,但由于清晰架构里没有对事件驱动的明确指引,因此我引入了DDD的事件驱动部分来对清晰架构进行改造和扩充,但总的来讲,本设计的底子还是清晰架构。

程序结构:

下面我们就讲一下在程序中是如何实现上面讲到的模块化和分层结构。

上面就是订单服务的目录结构,其中“Domain”对应领域层, “applicationservice”对应程序层和基础设施层,“app”对应程序容器。

领域层:

上面就是领域层的目录结构,这层是整个程序的大头。这层包含有命令(Command),事件(Event),域模型(Model)和用例(Usecase)。其中命令(Command)和事件(Event)是事件驱动模式独有的。Event目录里有事件(Event)和事件驱动器(Event Handler)。

事件会在两个或多个微服务之间传递,因此是被这些微服务共享的。一个问题是,要不把这些事件抽出来放在一个单独的模块中,这样不同的微服务就可以共享这些事件?这不是一个好主意,因为它会增大微服务之间的耦合度。尽管事件是被多个微服务共享的,但实际上它们在各个微服务里可能并不完全相同。例如,支付微服务发送一个支付完成事件给订单微服务,支付微服务需要在事件中增加一个字段“支付备注”,而订单微服务并不想马上就用这个字段,如果共享事件的话就比较麻烦。如果支付微服务和订单微服务各自单独立地定义事件,就保持了各自的独立性。虽然传递的事件里有“支付备注”字段,但订单服务可以选择忽略它(订单服务不必修改代码)。这样虽然有一些重复代码,但维护起来更方便。

域模型设计与RPC的域模型基本相同,我这里不仔细讲,有兴趣的请参见 清晰架构(Clean Architecture)的Go微服务: 程序设计 。稍有不同的是在事件驱动模式下,引入了“eventbus”(是一个接口)的概念用来处理事件,在业务逻辑里需要调用这个接口,因此需要把“eventbus”注入到用例(usecase)里。

程序层:

上面就是程序层的目录结构。这层现在只有一个服务,数据库服务,。其实还有另外三个服务,一个是日志服务,一个是消息服务,另一个是事件总线服务(Eventbus),但由于这三个服务都是在第三方库里定义的,因此就没有放在订单服务的程序层里。

其实程序层里的大部分接口都是可以共享的,那么是不是应该把他们都定义成共享库呢?我觉得是可以的,但如果只有接口定义(没有具体实现)的话,它应该很小,放在项目里也没有太大的问题。

基础设施层:

现在,这一层只有数据库服务的具体实现。日志,消息和事件总线服务(Eventbus)的实现都在第三方库中。程序层和基础设施层虽然属于不同层,但在现在的目录结构中是放在一起的,并没有把他们分开,你如果要把它们分开也没有问题。

程序容器:

这个也是单独的一部分,因为比较复杂,我会在本系列的一篇文章“事件驱动的微服务-程序容器设计”里单独讲解。

仓储(Respository)

一个比较有争议的地方应该是仓储(Respository),在DDD中是把他放在领域层。其实不单是DDD,几乎任何框架都把它放在领域层。我在写RPC的微服务时也是把它放在领域层。但如果你仔细想的话,仓储(Respository)是数据库的具体实现,按照DDD的理论是应该放在基础设施层。但由于几乎所有的框架都是把它放在领域层,我们已经养成了习惯,自然而然这么做了,根本没有仔细考虑。

另外一个原因就是仓储中的数据对域模型确实比较重要,因此把它放在离域模型近的地方可能比较方便。但我这里还是按照规则把它放在程序层,如果以后觉得有问题再改也不迟。

依赖关系:

在程序设计中,先要把程序拆成小的相对独立的模块,然后就是要确定各部分之间的依赖关系。这是程序设计里非常重要的一步。

依赖关系都是单向的,如果出现了循环依赖,Go就会报错。依赖关系都是从上往下的,也就是上层依赖下层。越是下层的东西越容易复用,因为它依赖的东西少。越是上层的东西越重,因为有太多的依赖关系。因此衡量程序的好坏,一个重要的指标就是它所依赖的的库,依赖的越少,程序的质量越高。在Go语言里,就是看“import”语句。“import”越少,程序越好。

依赖关系乍一看很简单,但仔细研究的话还是有不少内容的。它分类为,层级依赖关系,包依赖关系,接口依赖和实现依赖。下面会仔细讲解。

层级依赖关系:

我们先来看大的层级。领域层和基础设施层之间的关系,肯定是领域层依赖基础设施层。但如果是直接依赖具体实现,那么就把领域层和具体的基础设施实现绑定了,因此需要解耦。就创建了一个程序层,这样领域层和基础设施层都依赖程序层,就解除了绑定。

在领域层内部,它里面又有小的层次,命令,事件,域模型和用例。其中域模型不需要依赖任何一层,而别人都需要依赖他,因此他是最底层。命令和事件都有可能调用用例,因此它们是用例的上层。而命令和事件应该是互相独立的,因此没有依赖关系。

依赖关系性质

依赖关系有两种,一种是接口依赖,另一种是具体实现依赖。比如容器层(“app”)和领域层(“domain”)之间的关系就是”app”依赖”domain”(主要是依赖“domain”里的“model”),而”domain”不依赖“app”。你可能要问,为什么会是这样呢?”domain”里要用到“app”建立的类呀。注意”domain”里用到的是接口,而不是具体的类,接口是在”domain”里定义的,而不是在“app”里定义的。因此,接口依赖是一种非常灵活的依赖关系,是松耦合的。

包依赖关系:

层级依赖是抽象的依赖关系,但最终还是要落实到语言层面。在Go语言中就是包依赖关系,这是Go语言的最细颗粒度的依赖关系。在Go语言中不能产生循环依赖,否则报错。

包依赖关系和层级依赖关系大部分时候是一致的,但有时由于种种愿因,它们也会出现错位。例如,数据持久逻辑是放在基础设施层里的,它是不应该依赖域模型的。但在本程序中却是。实际上,数据持久层里的域模型应该被替换成DTO,而DTO不是属于域模型,这样就不会出现错位依赖。但如果引入DTO会让程序更复杂,又没有增加新的功能,因此就没有引入。实际上,DTO和域模型都是面向对象的概念,你如果用面向函数的概念来思考就顺畅了。在面向函数的模式里,就只有数据(Data)和函数(function),因此DTO和域模型都是数据,实际上是一个东西。

结构修改:

我在本程序里对原来的程序结构(参见 清晰架构(Clean Architecture)的Go微服务: 程序结构 )做了一点小的修改。原来的结构并没有“domain”这个目录,现在我增加了这个目录,并把所有与与业务逻辑相关的目录都放在它之下,这样程序结构更合理。其实,我在写上个框架(RPC)时就有了这个想法,但当时框架已经写完了,就没有再改。现在增加了事件驱动的功能,就更凸显了更改的必要性。以订单服务为例,它的主要功能都包含在两个目录里,“app”是程序容器,“domain”(领域)是业务逻辑。“domain”里面有四个目录,“model”是域模型,“usecase”是用例,“event”包含事件和事件驱动器(Event Handler),“command”是命令。其中“event”和“command”是事件驱动模式独有的,其它的与RPC模式是一样的。

框架(Framework)和库(lib)

当完成了程序的层级划分和模块拆分之后,下一步就是决定程序的框架。我在写RPC的时候没有使用现成的框架,而是自己写了一个。在做事件驱动的微服务时,我考虑了很长时间是不是要尝试一下使用现成的框架,这样就有机会比较外面的框架和自己的框架的区别。Go语言有不少很好的微服务框架例如 Go kitGo Micro ,它们的功能都很强大。但我最终没有选择他们主要是它们都包含了太多我不需要的东西,有些重,因此我还是决定在使用自己原来的框架。现在程序已经完成,我对结果还是很满意的。

共享代码和第三方库

一个程序会用到许多模块,有些需要你自己编写,另外一些可以直接使用第三方的现成库。例如本程序的事件驱动部分和SQL驱动程序都使用的是第三方库。值得庆幸的是Go语言的基本库非常强大,已经能完成许多功能,通常情况下不需要太多的第三方库。另外就是你自己的不同程序之间会共享一些功能,如果把它们放在各自的程序里就会有重复代码。在写本程序时,我把一些共享功能从程序里抽出来,写成了第三方库。例如日志功能和消息中间件接口。这样做的一个好处就是这些库是不依赖于框架的,任何程序都可以用它。我会在本系列的一篇文章“事件驱动的微服务-创建第三方库”里详细讲解。

总结:

码农都有自己独特的方式来判断程序的好坏。有的嗅觉灵敏,用鼻子来闻程序是不是有“Code Smell”。有的用眼睛来看。

经过这样的设计之后,整个程序的结构已经很顺畅了,它看起来就像一件艺术品。因此人们说程序设计是科学和艺术的结合。如果说有什么瑕疵的话,那就是前面讲到的基础设施层中的数据库代码里有对域模型的依赖,这种依赖关系是不应该出现的。如果要让它完美就要把域模型改成DTO(Data Transfer Object),但这样改过之后会让程序更复杂,又没有增加新的功能,只是从设计角度看更漂亮了。我毕竟是码农,最重要的是用最简单的方法来完成需要的功能。因此,只好忍痛容忍这点瑕疵了。

最重要的是把程序的结构理顺了之后,整个程序是用一个一个小的模块搭建起来的,各个模块之间之间的依赖关系简洁明确,大大地简化了以后程序升级,复用和维护的难度。就像盖一栋大楼,如果地基牢固,整个结构设计合理,里面再怎么装修,折腾也不会倒塌。

源程序:

完整的源程序链接: