为什么要分层
一看到这个问题我马上想到以前用 JSP 写程序,直接在 JSP 里面写了包括 UI,业务逻辑,SQL查询。这种 JSP 会带来 2 个问题,一个是因为代码很长导致可读性差,另一个是因为逻辑太定制导致复用性差。
而分层就是为了提升可读性和复用性,从而提升系统的可维护性。分层本质上是一种分类,把代码放到不同的地方方便理解和查找。就像衣柜一样,把衣服根据种类,季节等等分类好之后,要选一套衣服就会变得非常简单。但成本在于多了一些认知负载,需要知道每种类型的规则。并且当分类不适合需要重新涉及分类时需要进行调整。例如衣柜多了分类之后你需要了解现在有多少种分类,每种分类是要放什么衣服,当某个种类的衣服太多,需要多设计一种分类方法分小一些。这意味着不是分的越多越好。
什么是好的分类
怎么分很关键,决定了收益和成本。好的分类有下面几个特点 1。边界清晰,理解成本低。很容易就知道应该放到哪一层。 2。不同种类大小相对不会差很多。如果只分2类,一类已经占了99%,另一类只占1%,那这个分类筛选度太小。
所以重点不是怎么分,而是为什么要这么分,能清楚分析成本和收益才是最重要的。
常见的分类方式
那一般是怎么分比较好的?常见的分类方式有2种,一种是纵向划分,这种是会按完整业务能力去进行划分的,例如用户管理,订单管理,就类似微服务的划分。另一种则是横向划分,这种是按不同的职责划分的,单独一部分无法提供完整的业务能力。这种就是常见的分层。总的来说就是每一层的关注点不同,关注点包括作用不同,重要程度,迭代的频率,研究的问题不同,复杂程度等等。每一层都要做到心里有数,才能真正发挥分层的作用。
在大部分分层里面,会先分成2大块,应用层(包含领域层)和适配器(或者称为基础设施层)。有2个思考点,而这2个思考点也会比较容易对这两部分进行区分。 1。作用不同。应用层更多是为了表现业务逻辑,而非具体实现。应用层追求的是能让普通人也能看得懂里面的逻辑,意味着应用层包含的都是业务的语言,而非技术的语言,例如用户提交订单,应该命名为 submitOrder,而非 insertOrder。而适配器层则和存储,中间件交互,通讯协议等相关。 2。研究的问题不同。应用层考虑的是如何满足用户的功能需求。而适配器层则更多的考虑质量需求,例如硬件兼容性,性能,可靠性等。
应用层也可能会拆分成应用层 + 领域层,适配器层也可能会拆成入口适配器和出口适配器,这点后面会谈到。
分层的错误理解
几乎每个程序员都不陌生,我们在写代码的时候,必须要先了解系统的分层,才能把代码写在合适的地方。分层是为了让项目结构化,并且更好的分离关注点。但实际应用中往往会出现问题。
问题1: 表面分层,实际没分
但问题在于分层的边界有时候太过模糊,导致很容易出现架构分了几层,但逻辑大部分只写在一层里面,例如出现应用层很厚,而其他层很薄的情况。模块或者服务的依赖约束能解决一部分问题,但却会引入新的问题,归根到底还是因为大家对分层的理解仍停留在强制要求的规范上面,而对分层的本质缺少理解导致的。
举个例子,下面的项目是常规的三层架构,但可以细看应用层已经掺杂了控制层和持久层的逻辑,导致应用层非常庞大。
java
class OrderService{
public JSONObject updateOrder(JSONObject body,HttpServletRequest httpServletRequest){
String orderId = body.getString("OrderId");
String userId = httpServletRequest.getHeader("userId");
// 省略更新订单逻辑
JSONObject responseBody = new JSONObject();
responseBody.put("code",200);
return responseBody;
}
}
上面应该是应用层的代码,但实际上包含了应该属于 Controller层的 HttpServletRequest ,并且参数的获取理应也是 Controller 层的职责,却放到了应用层。
java
class OrderService{
void cancelOder(Order order) {
// 省略创建订单的逻辑
// 删除订单缓存
redisService.deleteCache("order-"+order.getId())
//发送完成待办数据
msgProducer.sendSyncMsg(
"OrderService",
"cancelOrder",
UuidUtils.getUuid(),
CancelOrder(order))
}
}
AppService 代表是应用层的类,但实现逻辑却包含了 mq 的发送,redis 操作,这种逻辑应该属于适配器层。其实用于 mq 和 redis 的 dto也是属于适配器层(不属于领域层也应用层)
上面两个问题导致了基础设施层非常薄,但应用层却变成非常厚,分层失去了意义。
问题2: 照猫画虎的分层
有些项目可能一来就直接参考整洁架构或者菱形架构去设计模块,导致分层很多,入手难度增加,且增加了工作量。但分层其实不是必须的,也没有绝对正确的分层方式,分层也不是一成不变的。分层其实会带来开发成本。只有当分层的收益大于分层的成本时,分层才有意义。这就需要我们能真正理解收益是什么。
常见分层模型
分层架构是一种架构风格,意味着针对不同场景会有不同的通用的分层方式。例如偏通用业务架构一些的有 MVC,端口和适配器架构,或者针对特定领域的分层架构例如 nginx 的分层架构,IM 系统的分层架构等。
不同层次的场景也会有不同的分层方式,例如宏观一点有 bff 架构,微观一点的常见的三层架构(UI层,业务逻辑层和持久层)。
后面会对部分常见的偏通用业务架构的分层模型进行分析,主要是了解他们设计的原因。这几种架构模型非常有代表性,它们之间也有千丝万缕的关系。这几个架构包括
-
端口和适配器架构
-
DDD 里面的分层架构
-
整洁架构
-
菱形对称架构
分层模型的共性
分层模型里面主要包括两部分, 一部分是层的定义, 另一部分是层之间的关系.
分层的命名
尽管有各种各样的分层,它们实则有联系。例如三层架构里面的业务逻辑层和持久层约等于端口和适配器里面的应用层和适配器层。菱形对称架构里面的北向网关和南向网关也约等于端口和适配器。DDD 分层模型里面的应用层和领域层约等于整洁架构里面的 User Cases 和 Entities。整洁架构里面的接口适配器层约等于端口和适配器架构里面的适配器层。
分层架构之间相互借鉴,有的分层实际概念非常相似,只是名字有点点不一样,例如领域层和Entities。有的分层是基于其他分层的拓展,例如适配器就包含了持久层。有的分层是某个分层的细化,例如端口和适配器模型里面的应用层,就被整洁架构分解成了 User Cases 和 Entityes。
所以不用太纠结层的命名,只要知道它代表的含义和内容,和其他层的区别就好了。
分层的依赖关系
稳定依赖原则
大部分分层,层之间的依赖关系都是遵循 稳定依赖原则 (来自架构整洁之道)
稳定依赖原则意思是依赖必须指向更稳定的方向。<架构整洁之道>有提到一个稳定性指标,简单理解就是一个模块被依赖的越多,越稳定。稳定性是被设计出来的,并不是说哪个组件改动少就一定是稳定的,而是你需要哪个组件稳定,你就要提升它的稳定性指标。也就是尽量的让其他组件依赖它。
领域层应该是最稳定的
为什么大部分领域层都是处于最底层,也就是被其他层依赖呢? 例如整洁架构和 DDD 的三层架构。
因为我们希望领域层是最稳定的,稳定意味着其他层发生变化,领域层不一定需要变。为什么希望领域层是最稳定的? 并不是因为领域层是不会经常变,而是因为领域层才最体现系统的核心价值,才是最复杂的。
我们分析一下为什么 DDD 分层架构里面,基础设施层为什么要依赖领域层。基础设施层相当于适配器层。领域层更偏向业务实现,我们甚至希望领域层的代码普通人也能读懂,这就意味着领域层不能包含技术实现的内容。而适配器则更偏向于技术,因为适配器需要考虑如何去适配其他的系统或者中间件,所以必定要考虑其他系统的规范和接入方式。
大部分业务系统都是先定义需求,才考虑技术实现的,技术为业务服务。所以我们在设计的时候应尽量避免技术实现影响到业务。例如数据量越来越大,mysql 需要读写分离,这时候我们期望的是在不影响业务的情况下进行。对于复杂的业务系统,业务挑战远远大于技术挑战。在<人月神话>中,作者把"打造构成抽象软件实体的复杂概念结构"作为软件系统的根本问题,也强调了业务的重要性。至于什么是业务相关什么是技术相关的,后面谈到 DDD 分层架构的时候还会谈到。
有没有可能在一些系统里面基础设施层才是应该最稳定的呢? 对于例如 mq 的管理后台来说,核心是 mq 的能力,管理后台只是基于 mq 的能力去提供部分能力。我们期望 mq 的能力是稳定的,不因为管理后台的需求导致 mq 提供的能力发生变化,所以管理后台的用例是需要依赖 mq 的能力。这种 mq 的能力看上去是基础设施层提供的能力,让其他层依赖它看上去是理所应当的。但从另一个角度看,对于 mq 的管理后台来说,它的领域层应该就是 mq 提供的能力。也就是说不是所有技术都是一定是基础设施层的,这要看你在做什么系统。我们说基础设施层是偏技术实现的,只是基于常见的业务系统。
依赖倒置解决依赖问题
一般来说 A 调用 B,意味着 A 依赖 B。常见的调用关系是 Controller (适配器) -> Service (应用层) -> Mapper (适配器) , 按照调用关系来看, 适配器依赖应用层, 应用层也依赖适配器, 会导致层的循环依赖, 也使得每部分都不是稳定的。因此要通过依赖倒置解决依赖问题,具体实现很简单,就是适配器提取接口,接口放到应用层就可以解决这个问题。抽取方法很简单,但这个接口是否是应用层就很难说了。
下面有一些用了接口,但并没有解决依赖问题的情况。
java
@Service
class OrderService{
@Autowired
private WxService wxService;
public void submitOrder(Order order) {
// 省略提交订单逻辑
// 发送微信提醒下单成功
wxService.sendWeChatTextMsg(new WeChatTemplateDto());
}
}
class WxServiceImpl extends WxService {
@Override
public void sendWeChatTextMsg(WeChatTemplateDto weChatTemplate) {
// 发送微信逻辑 发送微信提醒下单成功
}
}
上面的代码 OrderService 是订单服务,是应用层逻辑,而 WxService 是适配器逻辑,这里面有2个问题 1。方法命名没有领域含义,不是基于应用层的命名。sendWeChatTextMsg 只是发送微信文本消息,是一种实现方式,目的是为了通过微信提醒下单成功,而通过微信提醒下单成功的方式有很多,包括图文消息,视频消息等。所以更好的命名方式是 notifyOrderedSuccessfullyByWx。 2。参数不属于应用层。从设计的出发点考虑就知道了,WeChatTemplateDto 是基于通用的微信发送模板制定的,和业务领域没有什么关系。构建参数的过程应该属于适配器的逻辑。所以正确的方式应该是只传 Order,然后适配器里面再根据 Order 转成 WxChatTemplateDto。
提取接口只是一种实现方式,关键是要实现依赖的倒置。而依赖的倒置并不是把接口移动到应用层就完事了,而是在设计的时候就应该面向应用层来设计,屏蔽适配器的实现,才能真正实现依赖倒置。
总结
分层看上去简单, 但实际上要用好并不容易。分层对于业务系统的可维护性非常重要, 只有真正对分层理解了,才能在不同的分层架构里面游刃有余,以不变应万变,找到项目最适合的分层架构。