什么是软件架构
从抽象的角度来说,软件架构就是组件和组件之间依赖关系。
比如一个企业的组织架构,就是人与人之间的协作关系。
同样,对于应用架构而言,代码是其核心组成要素,架构就是这些代码该如何被组织,也就是要如何处理模块(Module)、组件(Component)、包(Package)和类(Class)之间的关系。简而言之,应用架构就是要解决代码要如何被组织的问题。
MVC三层架构
在企业软件开发领域,有很多技术栈可以选择,比如微软技术栈有WindowsForms、Silverlight、WPF、ASP.NET WebForms/MVC/Blazor, 在学生时代,我们都是在事件处理器(EventHandler)中将界面上的数据拼接到sql模板中,然后将sql传递给数据库来执行。
图 曾经的代码不做分层
在这种开发模式下,开发人员需要手动拼接sql, 很容易出错,而且还容易产生SQL注入攻击。后来我们又学习了面向对象分析和设计、设计模式、企业软件架构模式,我们开始分层,将我们的代码分为表现层、服务层和数据访问层。
-
表现层
- 接收请求,即以HTTP或TCP的方式接收请求
- 在请求处理器中接收用户在浏览器中的输入,我们将用户输入(ViewObject)和当前登录状态组装成数据契约(BusinessObject)传递给服务层。
- 统一处理异常。比如在ASP.NET MVC和Spring中都可以注册类似Interceptor的扩展点来统一异常处理。
- 统一输入和输出。比如输入使用对象(XXXVO)来封装, 输出使用DtoResult泛型类来封装。
-
服务层
- 服务层接收数据契约,处理业务逻辑。
- 处理缓存。
- 处理本地事务或分布式事务。
-
数据访问层
- 使用类似SqlHelper、Hibernate、Mybatis这类框架来处理数据为访问。
- 使用类似RedisHelper、Spring-Data-Redis这类框架来处理RedisElasticsearchMongoDB等数据访问。
- 使用类似OssHelper这类框架来处理文件上传和下载。
图 传统的三层架构
传统的这种三层架构,本质上还是在以面向过程的方式来编写代码,面向表编程,表模型即代表了业务模型,业务模型只具有业务属性,没有业务行为,业务行为散落在服务层,服务层臃肿,内部职责分离不彻底。同时服务层包括了使用RedisTeplateMQTemplateEsTemplate等访问数据的代码片段,比如下面这样的代码。
图 服务层内部职责分离不彻底
图 服务层内部做了数据访问的职责
因为我们采用了贫血模型,所以这种初始化对象访问服务持久化数据的情况比较多,导致我们的服务层代码臃肿,每次需要做出变更时,我们不得不完整阅读服务层代码,占用大量的时间和精力,这就是我们说的“探索期成本高”的问题。就是经常说的看2-3个小时甚至2-3天的代码,然后才能找到一个合适的位置增加代码。
总结一下就是,三层架构主要存在以下缺点:
- 没有遵守分离关注点、单一职责及开闭原则。
- 业务行为散落在服务中,没有内聚到模型中。
由于以上缺点,三层架构难以引导我们实现“高内聚、低耦合”的代码。
那么我们如何改进呢?想一想传统三层架构的弊端,想一想SOLID设计原则,我们需要将服务层代码进行拆分,可以将业务逻辑分离到领域模型(Domain Model)、领域服务(Domain Service)、事件处理器(Domain Event Pub/Sub)、仓储服务(Repository)等不同角色的组件中,并采用一些设计模式和一些最佳实践,比如:
- 使用策略模式来应对不同规则、不同规格的业务逻辑
- 使用状态模式来应对业务单据的状态流转
- 将一组代码抽离成新的模型或放入领域模型中
- 使用规则引擎来应对业务逻辑定制和扩展需求
图 改进后的三层架构
- 服务层定义领域服务、仓储及外部服务代理接口,领域服务实现中使用仓储加载领域模型,在领域模型中包含一些原来在服务层的业务逻辑。
- 同时,为了解耦,让不同的领域模块负责不同的事情,引入EventBus, 通过事件来传递消息,业务语义更清晰更容易理解。
- 数据访问层来实现服务层定义的仓储接口及外部服务代码接口。
- 服务层不再包含具体的技术细节,更关注业务“语义”;数据访问层包含具体技术细节,访问数据库、缓存、配置及其他NoSQL等中间件。
上面讲到的改进后的三层架构中,服务层的概念和职责还是挺多的,还是有些“臃肿”。因此还需要继续拆分,这就是DDD经典四层架构。
图 DDD 经典四层架构
用户接口层(User Interface)
- 包含登录上下文、端特有的异常处理、验证等通用界面层组件。
- Assembler转换器,用于将RquestParam、VO转换成DTO,用于传递到应用服务层交给仓库使用,并将应用服务层返回的DTO转换成VO。
- Facde接口,用于封装应用服务,适配不同的前端需要的接口适配及字段转换,完成接口和数据适配后,以粗粒度向API网关发布服务
应用服务层(Application Service)
- 连接用户接口层和领域层,面向用例和业务流程,协调多个聚合完成服务的组合和编排,在这一层具有较少的业务逻辑,也可能是跨模型的一些领域服务。主要由上层的Facade来使用
- 处理“产品经理”、“用户”不关心的非业务逻辑,比如数据转换、缓存、事务。
- 应用层还负责事件的订阅和发布,以及与其他外部服务的交互,事件的具体实现则在领域层
领域层(Domain)
- 领域层位于应用层之下,是领域模型的核心,主要实现领域模型的核心业务逻辑,体现领域模型的业务能力
- 领域层关注实现领域对象的充血模型和聚合本身的原子业务逻辑(只处理本模型边界范围内的事情,跨边界的事情,可能需要引入新的模型来处理,比如XXXPromotionPriceModel, XXXPointModel),至于用户操作和业务流程,则交给应用层去编排。这样的设计可以保证领域模型不容易受外部需求变化的影响,保证领域模型的稳定
- 跨多个聚合的领域逻辑在领域服务层实现,由领域服务组织和协调多聚合的多实体,实现原子业务逻辑
基础设施层(Infrastructure)
- 基础层贯穿了DDD所有层,包括第三方工具,API网关,消息中间件,分布式事务,消息最终一致性能力,数据库,缓存能力的提供。
- 基础层有仓储模式的代码逻辑,通过领域层的仓储接口,解耦领域层和基础层,保证领域核心业务逻辑的干净,降低DB资源变化给领域层带来的影响。
DDD架构模式
DDD(Domain-Driven Design 领域驱动设计)是由Eric Evans最先提出,目的是对软件所涉及到的领域进行建模,以应对软件系统规模过大时引起的软件复杂性的问题。整个过程大概是这样的,开发团队和领域专家一起通过通用语言(Ubiquitous Language)去理解和消化领域知识,从领域知识中提取和划分为一个一个的子领域(核心子域,支撑子域,通用子域),并在每一个子领域上建立模型,再重复以上步骤,这样周而复始,构建出一套符合当前领域的模型。
DDD是一种设计思想,针对软件行业来说,利用这种思想可以指导行业专家和研发人员深入交流,通过事件风暴、共创会的形式对业务进行分析,合理划分领域逻辑,让每一个领域足够高内聚、低耦合、充分模块化,让口头和书面沟通交流更一致、更高效,让信息世界的软件模型更接近真实世界的业务流程(即物理模型)。
同时DDD也充分应用了单一职责、依赖倒置、组合复用等软件设计原则,定义了领域层、基础设施层、应用层(即领域服务层)、用户界面层,共四层的代码结构模型。四层架构不是实现软件的唯一方式,还可以尝试六边形架构、CQRS、事件驱动架构等,它们分别适用于不同的应用场景。
由此可见,DDD其实是面向对象方法论的一个升华,无外乎是通过划分领域(聚合根、实体、值对象)、将领域行为封装到领域对象(充血模型)、内外交互封装到防腐层、职责封装到对应的模块和分层,从而实现了高内聚低耦合。
DDD使用充血模型,更适合写模型的处理。那么读模型呢?我们一般都是怎么做的呢?我们一般都是通过多表连接,写一个分页查询。在这种情况下,我们再强调事务一致性边界、聚合根、实体、值对象等就不太合适了。这种情况,扁平的宽表思维模式更容易理解。只是我们面对的业务越来越复杂,数据量越来越大,简单的分页查询不足以应对所有场景。我们需要单独对读模型进行分析处理,所以有人总结出来一种新的CQRS架构模式。
CQRS架构模式
将领域模型中的查询相关的方法分离出来,只保留唯一的通过实体标识查询聚合实例的方法,比如fromId()。这样一来,领域模型一分为二,我们将移除所有查询方法的模型称为命令模型(Command Model), 将只包含查询逻辑的,用于优化查询的模型称为查询模型(Query Model)。
EDA架构模式
事件驱动架构,英文名Event Driven Architeture,是一种基于发布/订阅模式的消息异步通信的架构,你可以把它理解为架构层面的观察者模式。
事件驱动架构的特点:
以下是一个使用Spring Cloud Stream实现的事件驱动的微服务架构示例代码:
这里使用了Spring Cloud Stream框架,它提供了一种简单的方式来实现事件驱动的微服务架构。在生产者中,我们使用@EnableBinding(Source.class)注解来绑定输出通道,然后使用source.output().send()方法来发送消息。在消费者中,我们使用@EnableBinding(Sink.class)注解来绑定输入通道,然后使用@StreamListener(Sink.INPUT)注解来监听消息。
// 生产者
@Service
@EnableBinding(Source.class)
public class MyProducer {
private final Source source;
public MyProducer(Source source) {
this.source = source;
}
public void sendMessage(String message) {
source.output().send(MessageBuilder.withPayload(message).build());
}
}
// 消费者
@Service
@EnableBinding(Sink.class)
public class MyConsumer {
@StreamListener(Sink.INPUT)
public void handleMessage(String message) {
System.out.println("Received message: " + message);
}
}
COLA 4.0架构模式
COLA是阿里的张健飞老师基于分层架构、六边形架构、DDD架构思想创办一个应用架构最佳实践。我们会发现它具有一定的普适性,可以指导我们应对业务场景的一些共性问题。
分层结构
对于一个典型的业务应用系统来说,COLA会做如下层次定义,每一层都有明确的职责定义:
1)适配层(Adapter Layer):负责对前端展示(web,wireless,wap)的路由和适配,对于传统B/S系统而言,adapter就相当于MVC中的controller;
2)应用层(Application Layer):主要负责获取输入,组装上下文,参数校验,调用领域层做业务处理,如果需要的话,发送消息通知等。层次是开放的,应用层也可以绕过领域层,直接访问基础实施层;
3)领域层(Domain Layer):主要是封装了核心业务逻辑,并通过领域服务(Domain Service)和领域对象(Domain Entity)的方法对App层提供业务实体和业务逻辑计算。领域是应用的核心,不依赖任何其他层次;
4)基础实施层(Infrastructure Layer):主要负责技术细节问题的处理,比如数据库的CRUD、搜索引擎、文件系统、分布式服务的RPC等。此外,领域防腐的重任也落在这里,外部依赖需要通过gateway的转义处理,才能被上面的App层和Domain层使用。
分包结构
每一层的包结构如下:
各个包结构的简要功能描述,如下表所示:
我们看到,只有executorgatewaygatewayimpl这三个包是必选的,这不就是我们最早写程序的情况吗?传统的三层架构。
我们之所以要做“划分领域,领域之下按功能分层,每一层按功能分包”,就是为了减少系统耦合,提升业务主义表达能力,提升系统的可维护性和可测试性。
在每一层中顶层按照领域分包,在领域内按功能分包。
解耦
所谓耦合就是联系的紧密程度,只要有依赖就会有耦合,不管是进程内的依赖,还是跨进程的RPC依赖,都会产生耦合。依赖不可消除,同样,耦合也不可避免。我们所能做的不是消除耦合,而是把耦合降低到可以接受的程度。在软件设计中,有大量的设计模式,设计原则都是为了解耦这一目的。
在DDD中有一个很棒的解耦设计思想——防腐层(Anti-Corruption),简单说,就是应用不要直接依赖外域的信息,要把外域的信息转换成自己领域上下文(Context)的实体再去使用,从而实现本域和外部依赖的解耦。
在COLA中,我们把AC这个概念进行了泛化,将数据库、搜索引擎等数据存储都列为外部依赖的范畴。利用依赖倒置,统一使用gateway来实现业务领域和外部依赖的解耦。
其实现方式如下图所示,主要是在Domain层定义Gateway接口,然后在Infrastructure提供Gateway接口的实现。
举个例子,假如有一个电商系统,对于下单这个操作,它需要联动订单服务、商品服务、库存服务、营销服务等多个系统才能完成。
那么在订单域,该如何获取商品和库存信息呢?最直接的方式,无外乎就是RPC调用商品和库存服务,拿到DTO直接使用就完了。
然而,商品域吐出的是一个大而全的DTO(可能包含几十个字段),而在下单这个阶段,订单所需要的可能只是其中几个字段而已。更合适的做法,应该是在订单域中,使用gateway对商品域和库存域的依赖进行解耦。
这样做有两个好处,一个是降低了对外域信息依赖的耦合;另一个是通过上下文映射(Context mapping),确保本领域边界上下文(Bounded context)下领域知识的完整性,实现了统一语言(Ubiquitous language)。
总结
截止目前,COLA4.0被清晰地划分为COLA架构和COLA组件两部分。