为什么要写这篇博客
在成为职业软件工程师之前,我还在上大学的时候,学过不少编程语言,Java,Python,Go,C,C++,JS。工作的第一年,Java 是我的主要开发语言,第二年,语言换成了 GO,日常里,我也会用 TS 写一些简单小玩意。在 AI 编程工具深入辅助程序开发之后,选什么样的编程语言已经不是当前这个时代的值得纠结和争执的问题了,所以今天我想聊一个,相比具体代码语言更高一级的话题:哪些行为会破坏代码的可测性导致工程越来越难维护。
这个思考来自我工作的真实经历,虽然面对不同语言和不同工程结构,但我总能遇到同一个问题:为什么这个服务运行不了本地单元测试!!!
我的上一段工作是 Java,不过在当下这个年头,与其说是写 Java 不如说是写 Spring DSL,我们公司又对 Spring 做了定制化的魔改,所以我写的更像是我们公司的 JVM DSL,与 Java 关系也没有那么大了。传统的 Maven 工程一般用 Junit 做单元测试,但在 Spring 里,单个 Junit 做测试显然有点力不从心,最主要原因是由于 Spring 用反射托管了 DI 流程,如果 Bean 不是用构造器方法的方式声明依赖,我们还需要引入 SpringTest,Mockio 这些另外的单测辅助包完成测试依赖的注入,当然这已经算是比较理想的能完成单测执行方案了。
当然现实远比想的复杂,Spring Test 本质也是需要启动一个 Spring 进程,默认用的是生产的 Spring 配置文件和 POM 依赖。这意味着,在 Spring 中,如果你引入了一些奇怪的 SPI 依赖,比如 Dubbo(点名批评),在单测时,你有可能会因为 SPI 流程中 Dubbo 连不上 Nacos 或 Zookeeper 导致你本地的某个纯内存业务逻辑测试启动失败。
所以我还需要重新写一个 Spring-test 配置文件,并对 RPC 接口再包装一层防腐层,或者自己全流程 Mock 所有的 Bean,否者我甚至启动不了一个与 RPC 完全无关的内部逻辑测试。
其实这也不能完全算是 Spring SPI 的问题,SPI 初始化有对特定环境的依赖,并在没有对应环境时阻止整个进程启动是合理的设计思路。比如启动时依赖本地容器的环境变量或配置文件用于标识泳道信息,或者要求服务注册,连接中间件和健康检查,否则禁止进程启动,这些都是合理的设计。
但不可否认的是,这些额外引入的成本让单测不再容易,只要经历过一次这种折磨,单测这个环节就绝对再也不会纳入开发流程中。
为什么代码需要可测性,为什么会有单测
其实不管你写不写单测,你的代码都有可测性的要求,比如在测试环境做集成测试,或者在预发环境回归测试,可测性是工程质量的硬性指标。
单测(Unit Test)出现的初心是减少代码测试的成本,对于一些纯粹的内存操作逻辑,或者一些内存性能基线,本地能完成逻辑的测试和性能测试是最方便快捷的。比如我们提交了一个字符文本处理的函数,并且在各种地方使用了这个函数,如果需要在集成测试环境才能测试这个函数是否执行正确或者是否性能合格,显然是不合适的。
单测的目的有且只有一个,那就是用最低成本验证代码单元的正确性。
不过我相信大多数开发者,并没有很重视单测这件事情,有的是因为内存逻辑真的很少也不复杂,大多数 CRUD 的场景里,单测根本没有什么必要,还有的是因为由于历史债务和一些糟糕的中间件,导致原本简单的单测成本直线上升,还不如不单测。
所以,哪些行为会破坏代码的可测性?提高单测的成本?
Java 的例子已经举过了,一些 SPI 的引入会带来严重的不可测性,除此之外,还有 AOP 编程,Static 静态方法或者静态块内存在外部依赖的同时,本身只提供简单的内存逻辑,比如一些配置中心的客户端,在 static 块内发起网络请求,并抛出不可捕获的 error。但其实本身可以只抽象成一个 KV 存储而已或者变量而已,只要初始化这个 Class 就会导致进程崩溃,配置大多时候又和各种分支判断链路相关,这种难以 Mock 还自带副作用的客户端极大增加了单测的难度。
我还经历过一个 Go 的例子,因为 Go 包管理的特性,一个 package 下可能有多个文件,比如 model.go 和 client.go。有一个基建包装的中间件客户端,client.go 里面的 init 函数会初始化客户端实例,会强依赖一些本地配置文件和网络连接,否则就 panic 导致进程退出,但是逆天的是,同 package 下还有一个 model.go 维护了一些常量和类型。也就是说,哪怕我只是需要使用一些数据类型或者常量,我 import 这个包的常量时候就会开始初始化客户端并 panic 还无法 recover,这个逆天设计导致一大堆单测受牵连无法本地运行。
这些糟糕的静态设计几乎是大多数代码可测性降低的原因,很明显,开发者在设计这些三方包的时候,根本没想过这些包会参与到单测中来,甚至他们自己的工程里都几乎没有单测。如果是开源项目,这些缺陷几乎可以给项目宣告死刑了。
不过,对于这些自带严重副作用的静态设计,想避免开并不难,具体的方法我后文里会详细论述,现实更糟糕的情况是,你发现第三方包严重隔离了副作用并拥有极高的可测性,自己写的代码却犯了这些糟糕的错误没有发现。
为什么 AI 时代,单测更加重要
前面列举的那些让本地单测跑不起来的设计,在 AI 时代的危害会更大。
AI 时代有不少开发范式,无论是 SDD 还是其他什么东西,有一个问题一直绕不开:如何保证 AI 写的代码的质量和正确性?在 AI 时代,代码的产出效率和数量几乎出现断崖式飞跃,人工 Code Reviewer 做质量门禁完全是天方夜谭,AI Code Reviewer 需要的上下文和 Token 消耗几乎相当于重跑一次执行计划,性价比过低,且幻觉问题一直是个隐患。
在 AI 时代,保证代码质量和正确性的局部最优解,就是建立严格的测试流水线,用最小的 Token 资源消耗或人力消耗,在 AI 写代码之前,确定所有的 API,数据格式和接口约定,功能边界,并提前设置单元测试和端到端测试门禁线。SDD,TDD 都在做这个事,AI 每次写完代码自动跑一次测试流水线,把测试错误信息补充到上下文中,既减少 Token 资源的浪费,也能用于压缩清理上下文中的非必要信息。
测试流水线是一个很复杂的系统,包含单元测试,集成测试,回归测试,自动冒烟测试,性能基线测试等等,有的甚至需要编译部署服务才能完成,对于 AI 开发来说,需要和具体容器端到端交互的测试,结果需要一层一层拆包断言,流程需要靠看日志,显然又慢又浪费上下文,而一个能在本地运行单测的工程,AI 几乎可以每次写完代码之后顺手跑一次单测,只需要不到一分钟时间,轻松验证功能的正确性,避免一些基础功能逻辑错误的 BUG 在集成回归测试里才被发现。
一个可测性高、副作用隔离优秀的代码仓库,能极大提高 AI 的任务完成能力。
怎么让你的代码的可测性变高?
结论先行:提升代码可测性的核心手段有两点——依赖倒置将副作用隔离在接口边界之后,DDD 分层将纯内存逻辑收敛至领域层与应用层。下文分别展开。
依赖倒置
对于所有有副作用的依赖,只依赖接口约定,不依赖具体实现。
举个具体例子。假设领域层需要用到缓存能力,反例是在业务代码中直接 new RedisClient(host, port) 并调用——这既耦合了具体实现,又把构造期的网络副作用引入了纯内存逻辑。
正确的做法是抽象一个接口约定:
public interface CachePort {
void set(String key, String value);
String get(String key);
}
领域层 / 应用层只依赖 CachePort 这个约定,从构造器注入,完全不感知背后是 Redis 还是本地 Map:
public class OrderService {
private final CachePort cache;
public OrderService(CachePort cache) { this.cache = cache; }
// 业务逻辑里只用 cache.get / cache.set,不关心实现
}
真正的 Redis 实现放在基建层,由 bootstrap 完成注入。单测时,只需注入一个内存实现的 FakeCache,即可覆盖 OrderService 的全部逻辑——零网络、零外部依赖、秒级跑完。这正是「只依赖接口约定」带来的可测性收益。
DDD 分层
DDD 分层是解决依赖副作用的另一条优秀路径,可以看作依赖倒置在更高维度上的抽象:依赖倒置用接口把单个依赖的副作用挡在边界之后,DDD 则进一步用层级的划分把整条调用链的单向性钉死。调用链从上往下走,数据库、RPC、中间件等外部依赖只允许沉淀到最底层,纯内存逻辑留在上层——越靠上的层越没有副作用,也就越能被单测直接覆盖。
关键约束是依赖只能向下:领域层不感知应用层,应用层不感知基建层。一旦出现反向依赖,副作用就会重新泄露回上层,可测性随之崩塌。业界常见的四层划分如下:
- 领域层只有纯内存计算的逻辑,哪怕是相互的依赖和引用,也几乎是纯内存和约定的引用。领域层是最好单测的一层,所有写在这一层的代码几乎都可以不用 Mock 依赖直接进行单元测试。
- 应用层负责维护基建层的约定接口,并编排领域层实例与基建层接口之间的依赖关系。应用层的测试会稍微复杂一点,因为单测不能涉及真实的基建层实例,所以必须 Mock,但整体成本还是非常低的,并且可以做一部分端到端的逻辑测试。
- 基建层负责接入真实的外部依赖,并将其包装成符合应用层约定的实现。基建层全都是外部依赖,完全没有单测的必要。
- bootstrap 层和 Main 入口才是基建层初始化和应用层依赖注入的地方,也是完全不需要单测。
单元测试可测的边界是领域层和应用层,在这些包应该都是无副作用的纯内存计算的代码。而基建层和 Bootstrap 由于依赖了太多三方包的副作用,几乎没有可测性,但这两个地方也几乎没有测试的必要,所以对整个项目可测性几乎没有影响。
如果说依赖倒置是在单个依赖上划出边界,DDD 分层就是把这条边界上升为整个系统的结构约束——两者一脉相承,后者是前者的更高层次抽象。
最后我想说
虽然我们知道了怎么做,但是实践起来也是道阻且长,不过,意识到总比不知道强。