探讨篇(二):分层架构的艺术 - 打造合理且高效的架构体系
上篇从服务粒度角度进行了探讨,本文继续从服务内的分层角度探讨。 本文的观点源自我在学习与实践过程中的深思熟虑,尚处于不断探索和验证的阶段。希望能“抛砖引玉”,激发更多的讨论与交流。让我们共同进步,在探讨与实证中寻求真知。
一、背景
应用分层看似直观,但实践中常见误区:开放接口 Api 层(或 controller 层)逻辑繁复,manager 层调用混乱,service 层沦为传输站。这种疏忽导致代码重用性差,层次混乱,维护成本增加。其实在许多系统中,普遍都存在着层次逻辑不明确、模块间循环依赖以及代码扩展性差、修改 A 功能影响 B 逻辑牵一发动全身等问题,这些都会引发连锁反应,进而影响系统的整体稳定性。
先来 3 个很常见的案例,看看代码是否合理?如果合理/不合理?背后原因是什么?
案例 1:Controller 层调用 Dao 层
案例 2:A 服务的 AService 层调用 B 服务的 BDao 层 合理吗?
案例 3:A 服务的 AService 层调用 manager 层,manager 层反过来调用 service 层,是否合理?
二、分层拓扑结构
分层思想是应用系统最常见的一种架构模式,它是将整体系统拆分成 N 个层次,每个层次有独立的职责,多个层次协同提供完整的功能。我记得作为程序员的时候,第一个接触系统的设计就是「MVC」架构。它将整体的系统分成了 Model(模型),View(视图)和 Controller(控制器)三个层次,也就是将用户视图和业务处理隔离开,并且通过控制器连接起来,很好地实现了表现和逻辑的解耦,是一种标准的软件分层架构。
大多数分层结构是有四个标准层构成:展示层、业务层、持久层、数据库层。
上图从物理分层(部署)的角度说明各种拓扑结构的变体。比如第三种变体对于具有内部嵌入式数据库或者内存数据库的小型应用程序可能很有价值。很多外包预制的产品都采用第三种变体构建给客户。
分层架构中每一层在架构中都有特定的角色和职责。比如表现层负责用户界面和浏览器逻辑处理,而业务层负责执行与请求关联的特定业务规则。
分层架构是一种技术划分的架构,和领域分区的架构相反。比如 promise 产能域包含在每一层。
三、分层隔离性
先讲几个概念
1.开放层:开放层说明这层对上面请求都开放,可以跳过任何层访问该层。比如 controller 层可以直接访问 dao 层。
2.封闭层:封闭层跟开放层相反。封闭意味着一个请求从顶向下从一个层到另外一个层,请求不能跳过任何层。必须通过下面的层一层一层到达下面的层。 比如 service 层是封闭的。那么 controller 层不能直接访问 dao 层,必须经过 service 层,service 层访问 dao 层。
如上图请求必须沿着一定的方向逐层传递,不能跨层——每一层都是 closed 的,这样做的好处是隔离了变化,某一层的变化只影响相邻层,而不会影响其他层。 特殊情况下,某层也可以设置为 open —— 即允许被跨越。
分层架构中的每一层既可以封闭也可以开放。要回答上面 3 个案例这个代码分层是否合理(要求业务层和持久层是开放的)?答案关键在于分层隔离性。
分层隔离性意味着在架构的一个分层中所做的更改通常不会影响其他分层的组件,前提是这些层之间的契约保持不变。由于每个层都有自己的职责。并且对上层而言是透明的。只有将职责限制在自己的边界内,整体层次结构才清晰明了。
1.为了支持分层隔离性。与主请求相关的层必须是封闭的。如果展示层可以直接访问持久层,那么对持久层做的更改会影响业务层和展示层。从而产生程序组件相互依赖,架构变的脆弱,更改困难。
2.分层隔离性还允许在不影响任何其他层的情况下替换架构中的任何一层。
3.封闭层促进了分层隔离性。有助于更改,但有时候开放某些层是有意义的。
四、分层架构本质
那么分层架构的本质是什么呢?其实我们仔细思考,你会发现不管是跨进程的分层架构,还是进程内的 MVC 分层,都是一个“数据移动”,然后“被处理”和“被呈现”的过程,归根结底一句话:互联网分层架构,是一个数据移动,处理,呈现的过程。
数据是移动的:
•跨进程移动:数据从数据库和缓存里,转移到 service 层,到 controller 层,到浏览器 client 层
•同进程移动:数据从 model 层,转移到 control 层,转移到 view 层
数据要移动,有两个东西很重要:
•数据传输的格式
•数据在各层次的形态
先看数据传输的格式,即协议很重要:
•service 与 db/cache 之间,二进制协议/文本协议是数据传输的载体
•server 与 service 之间,RPC 的二进制协议是数据传输的载体
•client 和 web-server 之间,http 协议是数据传输的载体
再看数据在各层次的形态,以用户数据为例:
•db 层,数据是以“行”为单位存在的 row(uid, name, age)
•cache 层,数据是以 kv 的形式存在的 kv(uid -> User)
•service 层,会把 row 或者 kv 转化为对程序友好的 User 对象
•web-server 层,会把对程序友好的 User 对象转化为对 http 友好的 json 对象
•client 层:最终端上拿到的是 json 对象
五、分层架构演进的核心原则与方法
那么当我们要做分层设计的时候,需要考虑哪些关键因素呢?
1.是否需要增加一层?
2.是否需要服务化?
3.是否需要抽取通用业务?
这些问题,其实不好回答。最主要的一点就是你需要理清楚每个层次的边界是什么。当业务逻辑简单时,层次之间的边界的确清晰,开发新的功能时也知道哪些代码要往哪儿写。但是当业务逻辑变得越来越复杂时,边界就会变得越来越模糊。这将会引出“分层架构演进”的核心原则与方法:
•让上游更高效的获取与处理数据,复用
•让下游能屏蔽数据的获取细节,封装
弄清楚这个原则与方法,再加上一些经验积累,就能回答提出的这些问题了:
那如何让数据的获取更加高效快捷呢?通用业务服务层的抽象势在必行。
•开放接口层(JSF 接口): 比如对外提供的 JSF 服务接口,可将 Service 层方法封装成 RPC 开放接口。通过 JSF 进行限流熔断等控制。这是 JSF 服务的第一层,类似 controller 层。这一层代码核心是轻业务逻辑、入参校验、异常兜底。
•业务逻辑层(Service 层):复用性低,核心是****业务编排逻辑。
•通用处理层(Manager 层) :可复用逻辑。 这一层主要有如下作用:
1.可以将原先 Service 层的一些通用能力下沉到这一层,比如与缓存和存储交互策略,中间件 MQ 等接入;
2.你也可以在这一层封装对公司第三方 RPC 或者外部接口的调用,比如调用 GIS、订单中间件服务等。
3.跟 DAO 层交互,对多个 DAO 的组合复用。
Manager 层与 Service 层的关系是:Manager 层提供原子的服务接口,Service 层负责依据业务逻辑来编排原子接口。
再来回答上面代码问题。
案例 1:不推荐 Controller 层直接访问 DAO 层。原因如下:
1.破坏分层架构:破坏了清晰的分层架构,降低了代码的可读性和可扩展性。
2.增加系统脆弱性:没有经过 Service 层的业务逻辑校验直接操作数据库,可能会引起数据不一致或者安全隐患。
3.代码维护性差:对于简单的数据操作,虽然看似直接从 Controller 调用 DAO 能减少代码量,但长远来看,这种做法会使得系统难以维护和扩展。
案例 2:A 服务的 Service 层应该调用 B 服务的 Service 层而不是直接调用其 DAO 层。原因如下:
1.职责划分清晰:AService 层应当通过 BService 层来进行通信,而不是直接调用 B 底层的 DAO 层。这样做可以保持清晰的层次结构,每一层的职责明确,便于维护和扩展。
2.业务逻辑隔离:B 服务的 Service 层封装了对 DAO 的操作和业务逻辑,比如对 Cache 缓存的处理。如果 A 服务直接调用 B 服务的 DAO,将无法利用 B 服务的业务逻辑处理能力,可能导致业务逻辑错误或不一致。
3.封装性:如果 B 服务的 DAO 层直接被 A 服务使用,那么 B 服务的内部的实现细节将会暴露给 A 服务,这违反了封装原则。正确的做法应该是 B 服务提供 Service 层接口供 A 服务调用,这样 A 服务不需要关心 B 服务的实现细节。
案例 3:A 服务的 AService 层调用 manager 层,manager 层不能反过来调用 service 层。原因如下:
1.Manager 层再回过头来调用 Service 层通常是不合理的,因为这破坏了层次结构的单向性和各层的职责边界。其他原因跟案例 2 类似
六、分层模型规约
1、领域模型
•DO(Data Object):此对象与数据库表结构一一对应,通过 DAO 层向上传输数据源对象。
•CO(Cache Object):由于 promise 时效业务数据强依赖缓存数据,为了跟数据库表结构区分,特定义了一个 CO,此对象与 Redis 中存储的结构一一对应。通过 CAO 层向上传输数据源对象。
•DTO(Data Transfer Object):数据传输对象,Service 或 Manager 向外传输的对象。
•BO(Business Object):业务对象,可以由 Service 层输出的封装业务逻辑的对象。
•VO(View Object):显示层对象,通常是 Web 向模板渲染引擎层传输的对象。
2、分层异常处理模型
1.在 DAO 层,由于可能会遇到多种类型的异常,建议使用catch(Exception e)
的方式进行捕获,并抛出一个自定义的DAOException
,DAO 层不需要打印日志。这是因为在 Manager 或 Service 层,异常会被再次捕获并记录到日志文件中。
2.在 Service 层出现异常时,必须记录出错日志到磁盘,其中日志记录应该遵循一定的规范,包括错误码、异常信息和必要的上下文信息。日志内容应该清晰明了,相当于保护案发现场。
3.异常封装:对于业务层面的异常,应当进行适当的封装,定义统一的异常模型。避免直接将底层异常暴露给上层模块,以保持业务逻辑的清晰性。比如 DependencyFailureException:表示服务端依赖的其他服务出现错误,服务端是不可用的,可以尝试重试,类比 HTTP 的 5XX 响应状态码。InternalFailureException:表示服务端自身出现错误,服务端是不可用的,可以尝试重试,类比 HTTP 的 5XX 响应状态码。
4.Web 层绝不应该继续往上抛异常,因为已经处于顶层,无继续处理异常的方式,如果意识到这个异常将导致页面无法正常渲染,那么就应该直接跳转到友好错误页面,加上友好的错误提示信息。
5.开放接口层不能直接抛异常,应该将异常处理成 code 错误码和错误信息 message 方式返回。其中错误码应该能够快速识别错误的来源,便于团队成员快速定位问题。同时,错误码应易于比对,有助于团队对错误原因达成共识。其中错误编码可参考 HTTP 协议的响应状态码:
•2XX(成功响应):表示操作被成功接收并处理。例如,200 表示请求成功。
•4XX(客户端错误):表示请求包含语法错误或无法完成请求。例如,404 表示请求的资源(网页等)不存在。
•5XX(服务端错误):表示服务器在处理请求的过程中发生了错误。例如,500 表示服务器内部错误,无法完成请求。
七、探讨点
1.分层架构需要注意架构污水池反模式。什么意思呢?当请求作为简单的传递一层到另外一层。而在每一层不做业务逻辑时,就会出现这种反模式。那如何处理呢?根据 2/8 法则,如果 20%请求是这样可以接受,反过来如果 80%代码请求都是这样,则说明当前分层架构不适合,这时候应该开放架构中的所有层。
2.分层架构的思想旨在通过横向切分和根据业务职责的划分来规划软件系统的逻辑结构,以便于开发和维护。然而,实际应用中,由于历史代码原因、团队成员间编码习惯的差异、以及编码过程中优先考虑业务发展等因素,常常导致分层不彻底或混乱。这种分层的混乱不仅是因为开发者沿袭前人的代码习惯,还因为个人风格的差异,如在接口层混入业务逻辑,或在 Service 层频繁调用远程服务,进一步加剧了这一问题。当维护者面对与自己习惯迥异的代码时,要在保持系统稳定性和按个人风格调整之间做出选择,变得尤为困难。 这种情况下,通过 CodeReview 已经为时已晚或者很难发现,那如何在开发初期通过有效的策略,规避或尽量避免团队破坏分层结构,成了一个迫切需要解决的问题。
八、总结
无论历史代码如何分层,只要新的分层能够明确职责、简化维护,并得到团队的认同,那么它就是有效的。
如果你有更好的分层思路,或者上面所描述的有什么错误的地方还请留言指正一下。谢谢!
参考:
1.互联网分层架构的本质: https://mp.weixin.qq.com/s/X1JnXFIkn57eyx3slKQKLQ
2.京东物流软件系统稳定性建设方法
作者:京东物流 冯志文
来源:京东云开发者社区
版权声明: 本文为 InfoQ 作者【京东科技开发者】的原创文章。
原文链接:【http://xie.infoq.cn/article/96f32d2d323c4bb13f7b7ca1e】。文章转载请联系作者。
评论