Java 项目愚蠢的分层及解决办法

《整洁架构之道》的最后一章《细节决定成败》又在讨论 Javaer 永恒的问题:分层后 DAO Service Controller 应该按功能分包还是按层分包。 按功能分包的人认为这些文件在业务上是一起的,应该放在同一个包。按层分包的人认为每个层代表了不同的技术,应该按层分包。

可以想象,按层分包的人主张,DAO 层应该打包成一个 jar,对 PG 数据库用户提供 PG 的 jar,对 mysql 的提供 mysql 的 jar,到时换库很灵活。

而按功能分包的人现在有一个更好的理由,未来可以按功能切分成微服务!

《细节决定成败》这一章似乎是国内的译者写的,Bob 应当不太可能关注这种问题。但是问题的确存在。

假如我们把系统拆分来看,在一个古老的 CS 系统,分为服务器和客户端两大部分。客户端是 .net 开发的,服务端是 java 开发的。业务运行在 Java 服务器,客户端同样也表达一些业务知识,当然,主要是呈现性的知识。在包的组织上,客户端基本和服务器会采取一一对应的包设计。

这种系统里一个功能分在两个项目中,上面讨论的问题也就消失了。

从这个类比看,按层拆分似乎是最正确的选择。假如开发语言不同,同一个功能分成两个工程就很合理了。

那么,为什么大家还要纠结拆分呢?如果从一开始就分为三个工程,dao,service,controller 各一个工程,问题不是解决了?

问题出在那儿?

首先最直接的感受是效率的损耗,一个功能分前后端可以说是无奈的选择,现在一个后端还要分三个工程,老板就要问一问有没有价值了。

DAO 层要独立,原因是所谓的“数据库是细节”,不能绑定太深,未来可能要换数据库。

Controller 要独立,因为它是属于 Web 的,不是业务。

随后,更多的分层来了,Service 要分 ServiceInterface 和 ServiceImpl,DAO 也要分 ServiceInterface 和 ServiceImpl。

Service 这么分的理由是 Controller 没有资格访问具体的 ServiceImpl,应当通过依赖反转由容器对 Controller 赋予 ServiceImpl。

而 DAO 拆分的理由是,Service 直接触碰 DAO 太脏,DAO 可能更换,甚至可以说,DAO 接口是由 Service 出具的,只有那点可怜的 DAOImpl 才属于 DAO 工程。

也就是说,如果切分为 3 个工程,Service 这层应当出具一个 Service 接口工程给 Controller 工程,并且出具一个 DAO 接口工程给 DAO 工程。

以上就是完美的划分。

但是,在实践中,

  1. Controller 代码太少了,Controller 是不能改变业务逻辑的,所以 Controller 仅仅是将 Service 的一些函数映射出去而已。专门搞一个 Controller 工程非常傻。
  2. 数据库没有想象的那么烂————Uncle Bob 踩过早期数据库的坑,他固执的认为数据库仅仅是一个存储,带着这种错误的认知他甚至没有把关系运算当作一个范式。但是在实际项目中,DAO并不是简单的存取,往往用到关系运算或如 mongodb 的 map-reduce,换数据库难度是非常大的。试想,Model 最终都在数据库里,Service 要取用数据都要走数据库,而号称实现业务的 Service,使用的却是普通的运行于内存的编程语言,随时可能因为 JVM 宕机导致业务逻辑中断,并且还不支持事务,由此,一个业务件竟然不敢持有业务状态。并且
  3. Service DAO Controller 全部位于同一个 Spring 容器,这使得这种切分显得很荒谬。Controller 是 Web 服务器层的东西,带有高强度的 IO,随时会被冲垮,Service 这么宝贵的东西竟然和 Controller 混在一起,是不是很奇葩。
  4. 这就导致很多业务最终并不是靠 Service.java 或 ServiceImp.java,而是通过规则引擎、消息队列、工作队列等工作。

这时有人会说, 照你这个说法,Java 根本就不适合写业务?

不是没有可能。我可以理直气壮的说,如果一个 Service 把持久扔掉,它根本没有资格做 Service。此外, Controller 不过是 Service 的 RPC 入口。

最严重的问题是,它很蹩脚,这么蹩脚的设计不禁让我们思考:什么是理想的划层?

看三个例子:

网络 7 层,包括应用层传输层等,HTTP 是建立在 TCP 上的,TCP 这一层不需要为 HTTP 做任何调整。上一层对下一层是完全无知无感的,当层划分好后,甚至能做到下一层的变动不会影响上一层。

又如操作系统的层,我们日用的进程文件等等,属于操作系统提供的层。这一层上的应用例如视频播放器完全不需要考虑我的进程有没有被调度到CPU,磁盘磁道怎么安排,磁盘改成 NVME 怎么办等等,视频播放器绝不会说我要加一个功能需要操作系统改一改升个级。

像 JVM 这种跨平台虚拟机进一步对操作系统做了抽象,我们甚至可以编写与目标操作系统完全无关的程序。

上述这些分层都是完全解耦的,上一层依赖下一层,但是下一层对上一层无感,不需要考虑上一层的心情。

套用一下让子弹飞体:什么叫分层?这 TM 才叫分层!

不怕不识货就怕货比货,现在我们可以发现 Java 项目这种所谓的分层有多么荒谬。既然已经分层还要考虑横切还是竖切,那就说明根本没有实现正交,Service 层不是建立在 DAO 基础上,DAO 也不建立在 Service 上,它们互相缠绕,把一个现实的功能凌迟的到处都是。

为什么 Java 要把持久化到数据库搞成一个层呢?究其原因乃是 Uncle Bob 等人踩过的一些坑使其一朝被蛇咬十年怕井绳,Uncle Bob 豪迈的宣称:ORM 只是细节,只应当放在 Dao 层;对象数据库害了我们,我们把宝押在某某数据库、对象数据库,犯了大错,诸如此类。

客户要求换数据库固然会导致重写很多代码,但是哪天客户要求换程序语言呢?假如客户要把 Java 换成 C#,rust,python 呢?那我们是不是该庆幸当初写的全是 SQL,只要把数据库连接方式改一下把SQL抄过来就 OK?据此推理下去,为了随时替换编程语言,我们把系统搞成 nginx + 存储过程?

如果我们不像 Uncle Bob 等人一样,带着畏惧情绪去看待数据库,而是把关系运算也当作一种范式,OOP + 关系运算结合才是业务的实际状态,换数据库和换 Java 一样,是绝对偶发的情形,问题就会简单不少。重复一遍,业务是由数据库和 OOP 共同完成的

同样的,Service 不可能是一个闷葫芦,它要对外提供服务,就必然有对外服务的手段,而这根本不足以形成一个层。为什么呢?Controller 没有资格自行决定暴露哪些 Service 接口,暴露哪些是业务规定的,因此,暴露 Service 这个工作就应当由 Service 自己说明。这可以是 Annotation 也可以是配置文件之类的,但是这并不足以构成一个层。在现实代码中 Controller 主要工作就是做输入校验,把 request 转化为 Service 的查询对象,如果 controller 只做这么一点工作,它应该是通用的,这部分工作应当变成一种协议,也就是 Tcp 层 Http 层之后的一个对象路由层,用过 mina 的人都知道我在说什么。

该层输出的对象大体是这样的

class UserQuery extends Query{
  @Required
  String name
  GenderEnum gender
}
abstract class Query{
  abstract void validate() throws ValidationError;
  HttpRequest request;
}

至于向该层投放的对象,由于是 Restful 接口,输出的查询结果用普通的 POJO 甚至 Map 均可。

有兴趣还可以继续推演。如需要将 Service 保持参数形态,如 UserService.search(String name, Gender gender) 而不是 UserServer.search(UserQuery query))那么这种层应当怎么设计?

把完整的框架发明出来超出了本文讨论范围。

综上,
第一:Service 理应和 DAO 合并,业务本就是透过 Java 和关系运算共同实现的。
第二:Controller 可能没有资格存在,应当处理为一个对象路由层。

其它讨论:

  • 有人会说,那么其它的中间件呢?为什么只有数据库有这种地位?消息队列呢?

看完上述分析我们可以理解,消息队列对于 Service 同样是一个对象路由层。

  • 那么 ES 呢?

ES 之类其它带有运算的存储,必然是业务共同体。假如 ES 是替换数据库的,那么,根据需求,我们可以从原来的 Service 派生一个 ESService,覆盖部分方法(部分替换),也可以将原 Service 抽象为 Service + PGService,然后另外派生一个 ESService(完全替换)。

posted @ 2024-12-20 13:21  Inshua  阅读(545)  评论(9编辑  收藏  举报