暗无天日

=============>DarkSun的个人博客

你的依赖列表里,有多少是业务逻辑

依赖列表里混了什么

随便打开一个微服务项目的依赖文件。Java 的 pom.xml ,Go 的 go.mod ,Node 的 package.json ,都行。从头扫一遍,数一数里面有多少行是真正的业务逻辑。

一个典型的 Java 微服务依赖列表大概长这样:

  • Web 服务器(Tomcat、Netty、Undertow)
  • 序列化框架(Jackson、Gson)
  • 依赖注入容器(Spring、Guice)
  • 服务发现客户端(Eureka、Consul)
  • 健康检查端点
  • 配置管理(Spring Cloud Config、Consul KV)
  • 监控指标库(Micrometer、Dropwizard)
  • 日志框架(Logback、Log4j2)
  • 重试逻辑(Resilience4j)
  • 熔断器
  • HTTP 客户端配置

真正属于业务逻辑的依赖可能只占很少一部分。

这些基础设施库本身不是问题,每一个都在解决真实的需求。问题在于它们和业务逻辑混在同一个依赖文件里,编译、打包、部署、打补丁,全都绑在一起。

依赖文件不区分"这是业务依赖"还是"基础设施依赖"。在编译器看来,Spring 和你的订单计算逻辑没有区别,都是 classpath 上的 jar 包。

这种不分类的混装,制造了三个层次的耦合代价。

代价一:改一个依赖,全部重建

go.modpom.xmlpackage.json 有一个共同特征:依赖是一个扁平列表。你没法在里面标注"这几行是基础设施,那几行是业务"。

后果是,任何一个基础设施依赖的变动,都会触发整个服务的重新编译和部署。

升级语言运行时版本?每个服务都要重新构建和测试。换消息队列从 RabbitMQ 到 Kafka?每个用到消息的服务都要改代码、重新编译、重新部署。加一个可观测性工具,每个微服务都得更新依赖。换云厂商就更狠了,配置、SDK 调用、部署清单全部重写。

这些变动不涉及一行业务逻辑代码。但因为基础设施依赖和业务依赖共享同一个编译产物,每个变动都波及每一个微服务。

一个 Netty 的安全补丁,能迫使所有内嵌 web 服务器的服务全部重新构建。而在微服务架构里,几乎所有服务都内嵌了 web 服务器。

代价二:跨层打架

第二个问题更隐蔽。同一件事,比如路由请求或重试失败调用,框架在做,云平台 SDK 在做,容器编排工具也在做。多个层面同时插手同一件事,谁都觉得该听自己的。

服务路由,Spring 框架想管,Kubernetes 的服务网格也想管。配置管理也一样,框架自己有一套,Consul KV 和 Kubernetes ConfigMap 又各有一套。重试逻辑更混乱,云厂商 SDK 内置了一套,Resilience4j 又来一套。到了熔断这块,框架的熔断器和服务网格的流量控制策略都觉得该听自己的。

说白了这造成了架构层面的职责重叠。每一层都觉得某个跨模块的通用职责归自己管,但谁也没有完整的控制权。这类需求(服务发现、配置、重试、熔断)在软件工程里有个名字叫"横切关注点"(cross-cutting concern),就是指那些横跨多个业务模块、不属于任何一个具体功能的活儿。

这种架构上的职责重叠加大了生产环境排查问题的难度。

代价三:部署节奏绑架

基础设施和业务逻辑共享同一个部署单元,就必须共享同一个部署节奏。

部署单元是什么?Java 打出来的 fat-jar(把所有依赖塞进一个 jar 包),Go 编译出来的二进制,Node 打出来的 Docker 镜像。不管哪种形式,基础设施和业务逻辑都在同一个产物里。

你的业务团队想上线一个新功能,但正好碰上安全团队要求打一个基础库的安全补丁。两件事不能同时做,因为它们改的是同一个部署产物。要么先上业务变更再打补丁,要么先打补丁再上业务变更。一方要等另一方。

反过来,你想升级 Go 或 Java 的运行时版本,理应只影响基础设施层。但因为运行时被打包进了每个服务的部署单元,升级运行时意味着重新构建和测试每一个服务。

部署单元的耦合直接转化为发布排期的耦合。团队的发布节奏不再由业务决定,而是被基础设施的变更频率拖着走。

解决方法:分拆

解决这三个代价的方法就是让基础设施和业务逻辑不要共享部署单元。

这个想法不新鲜。Java 历史上的几代技术都在做这件事:

  • Applet 运行在浏览器提供的环境里
  • Servlet 运行在应用服务器(Tomcat、Jetty)里
  • EJB 运行在企业容器(JBoss、WebLogic)里
  • OSGi 的模块运行在运行时框架(Eclipse Equinox)里

每一代的做法都差不多:运行时负责网络、资源调度、生命周期这些基础设施的活儿,应用只管业务逻辑。

fat-jar 加 Docker 的时代把这个分离丢掉了。Java 开始像 Go 一样把所有东西打成一个二进制,然后在容器编排平台上重新实现一遍运行时本来提供的基础设施管理。但容器编排平台做得并不一定比原来的应用服务器好。

不管用什么技术实现,分拆的核心原则是:基础设施的变更(运行时版本、网络栈、监控工具)不需要动业务代码,反过来业务逻辑的变更(新功能、bug 修复)也不需要重新打包基础设施。两层可以各排各的发布计划,升级互不干扰,回滚也各管各的。

当然这不是呼吁回到 EJB 时代。具体用什么工具实现分离那就是另一个问题了。

微服务 : 架构 : 耦合 : 基础设施