未发现数据库操作:编写Spring Boot分析器教我的Spring知识
我花了数月时间构建一个静态分析器,用来回答 Spring Boot 代码库中的一个问题:如果我改动这个端点,改动会波及到什么?
我发现的每一个严重 bug 都有着相同的形态。
不是图中的一条边连错,也不是某个功能缺失。而是一种“自信的缺失”——工具用断言式的口吻说,某个端点没有触碰数据库、没有开启事务、没有发布消息,然而它三者全做了。
这最终给我的教训更多关于 Spring,而非分析器。这些 bug 之所以存在,都是因为 Spring 会运行源码中没有任何地方显式调用的代码,而且我读代码的方式和评审者读 pull request 一样:从 handler 开始,跟着调用往下走。
下面就是这种读法会漏掉的东西。数据来自我自己在公开仓库上的运行结果。
端点并不在注解所在的地方
第一个让我停下的测量结果:Spring PetClinic REST——10 个 controller、528 个方法,只找到 1 个端点。 而那个唯一的端点还是指向 Swagger 的 / 重定向。
应用实际提供了约 36 条路由。src/main/java 中没有任何方法级别的映射注解。
它们都在接口上。这是 openapi-generator 的 spring 生成器在 interfaceOnly 模式下的产物;也有不少团队为了共享 API 契约而手写这种代码:
实际路由是 /api/owners。注意这里的不对称性:路由来自接口,基础路径来自实现类。 你在 controller 里 grep,什么都找不到。
第二个测量结果更糟。halo——1,349 个 Java 文件,只找到 13 个端点,而实际分布在 58 个生产文件中的路由声明有 183 条。
这些路由是函数式的:
这段代码里有三个陷阱。整个风格对注解扫描是不可见的。Builder 有一个完全不带 path 的 POST(HandlerFunction) 重载——如果你把第一个参数当作 path 来读,就会丢掉一条本已掌握的路由。而且路径在外层的 nest 里,所以你必须知道 nest 的括号作用域,否则相邻的 nest 会把前缀泄漏进彼此。
还有一个更小的同类问题:Spring 把 @RequestMapping("api") 和 @RequestMapping("/api") 一视同仁。PetClinic REST 的 37 条路由中有 25 条使用前一种写法。 不做归一化,你的索引里就是 api/pets,而每个读者看到的都是 /api/pets。
Spring 会运行没有任何代码调用的东西
这一类别会造成“假缺失”,也是评审者最值得内化的部分。
controller 中每个 handler 执行前,都会运行 @ModelAttribute 方法。 在我修掉所有其它已知的假阴性之后,对 Spring PetClinic 的测量结果:仍有八个端点被断然标记为“数据库操作:未找到”。其中六个会先执行这个方法,再进入 handler:
这个说法对 handler 为真,对请求却为假。没有任何调用点可以让你找到它。以 handler 为根做调用图遍历,无论多完整都永远不会到达这里——然后它还会报告自己已经遍历完整。
Spring Data 会合成源码中不存在的接口方法。 interface OwnerRepository extends JpaRepository 除了你写的方法外,还继承了 save、findById、flush。handler 调用 owners.save(owner) 时,方法名在任何索引记录里都匹配不到。
PetClinic 全部五个写端点都报告为没有数据库操作。halo 中还有六处。
@Transactional 通常写在类上。 Spring 会把它应用到 bean 的每个 public 方法上,大多数 service 也只用 public class FooServiceImpl 上方的一个注解来声明事务。
如果只读方法注解,我的工具会报告一个完全事务化的 service 没有事务边界——然后继续自信地建议:这些写操作应该包进一个事务里。而这个类早就在类级别上这么做了。
这就是该失败模式的缩影:不是沉默,而是自信地建议去做代码已经做了的事情。
应用事件同样没有调用点。 publishEvent(new OrderPlaced(...)) 通过声明的参数类型到达 @EventListener(OrderPlaced.class)——这是运行时接线,不是名字巧合。如果不把这两半连接起来,追踪就会停在“发布了一个事件”,而 listener 做的一切——通常才是真正的数据库工作——就这样完全消失了。
而且它是在发布之后运行的,可能还在另一个线程上,位于调用方的事务之外。
交给 executor 的工作会形成一个空洞。 在一个真实客户项目上发现:
调用图在这里干净地终止,并报告自己完整。我选择不去猜提交的是哪个 Callable——遍历错误的代码体就是编造发现,这比承认空白更糟。
名称会骗人,但骗法具体且可学习
@Table 由两个不同的注解以两种方式拼写。jakarta.persistence.Table 接受 name =,没有 value。Spring Data JDBC 的 @Table("owners") 是位置参数。我只读了 value 和位置形式,于是每次扫描中所有 JPA 实体的表名都是 null——这是压倒性的常见情形,却静默地为空。
JPQL 不是 SQL,把它当 SQL 读会凭空造出 schema 对象。LEFT JOIN FETCH p.releases r 是立即抓取指令;我的表名模式抓到了 FETCH 这个词本身。项目的数据图上因此多了一个名为 fetch 的目标,它最终成了 shopizer 最大的“表”,有 213 个消费者。
路径表达式也会更安静地做同样的事:p.releases 变成 schema p 下名为 releases 的表——一个看起来完全真实的名称。
实体名还会和 SQL 关键字冲突。shopizer 里有 @Entity @Table(name = "ORDERS") class Order,所以 select o from Order o 中唯一能读懂的实体名,被当作关键字 order 划掉了。打破平局要靠项目自身的 @Entity 集合,并且要区分大小写:JPQL 中的实体引用是 Java 类名,order 和 ORDER 都不是 Order。
另一点值得知道:repository 这一构造型并不是数据库访问的边界。@Service 里的 JdbcTemplate、用于原生查询的 EntityManager、jOOQ、R2DBC、裸写的 PreparedStatement——对组件扫描来说,它们都不是 repository。读写分离的代码会给它们起名叫 jdbcTemplate1、jdbcTemplate2,这是主流做法,并不稀奇。
消息边界才是连接错误的地方
这部分我以为自己早就懂了——毕竟我在重度使用 SQS 的服务上工作多年。结果我在三个方面都错了。
一个队列,两种拼写。 生产者持有 URL,因为 SDK 需要 URL;@SqsListener("orders-q") 接受裸名称;ARN 是第三种形式。按字符串相等去 join,你会对同一个仓库里明明有消费者的队列,报告“发布到一个无人消费的队列”。
没有注解的消费者。 在 spring-cloud-aws 出现之前的代码库里,标准形态是:在 @PostConstruct 启动的 Runnable 里调用 sqsClient.receiveMessage(...)。纯注解扫描恰恰会在这类项目上报告“没有消费者”——而这些项目很可能运行得最久。
RabbitMQ 根本不会按目标字符串去 join。 生产者命名的是 exchange 加 routing key;listener 绑定的是 queue。字符串相等会把 exchange 和一个碰巧同名的 queue 连起来。AMQP 的路由是 exchange → routing key → queue,在某个声明的 binding 证明这条路由之前,一次 publish 和一个 listener 只是两条各自属实的、尚未联系的事实。
例外是两参数版本的 convertAndSend 重载,它使用默认 exchange——此时 routing key 就是 queue 名。参数个数决定了参数的含义。
还有每个人都会在 TTL 队列上实现的延迟模式,意味着真正运行的 listener 离发送方隔着两跳拓扑:sender → TTL exchange → TTL queue → dead-letters → real exchange → listener。
我最终总结出的规则
在某个节点上,我不再试图找出更多的边,而是开始区分两种不同的“不知道”。
如果图无法在三个候选方法之间做选择,于是把三个都遍历了,那么每个方法体都被读过。所有可能发生的事已经被完整覆盖——这是一个超集,而不是抽样。仍然未知的只是实际运行哪一个。
这是**归属(attribution)**问题,它不会让一个缺失结论失效。“这里没有任何东西会开启事务”这一判断是可靠的,只要每个候选都被检查过,无论最终执行哪一个。如果让归属不确定性否决缺失结论,得出的答案会因为无法区分三个 setVersionNumber 方法——其中没有一个是在做任何事——而拒绝回答是否存在数据库更新。
覆盖率才授权你下“缺失”结论。归属问题授权不了。
最让人不舒服的教训来自一次性能调整。我把每个节点的边上限从 40 提高到 400,结果在一个仓库上产生了 49 个新的、错误的、确定性的“no database operations”。这个上限此前一直是唯一阻止这些误报出现的东西——纯属巧合。完整性一直由某个当初并非为了这个原因而设置的限额保护着。
所以这个指标被我故意反着用了。完整性不是用来优化的质量分数,而是“可以下确定性结论”的许可证。如果不同时提高分辨率,光提高完整性就会把沉默转化为自信的错误。
这和读 diff 有什么关系
我造了一台机器来回答“这个改动会波及到什么”,这台机器每次最严重的失败,都是一个自信的不会。
这一点值得你静下来想一想,因为审阅 pull request 的评审者跑的是同样的扫描。你从被改动的方法开始,顺着你能看到的调用走下去,然后得出结论:它不碰数据库,或者不跨事务边界,或者不会发布任何东西。
你和我第一版工具有着同样的盲点。handler 上方的 @ModelAttribute。不存在于任何文件中的那个 save。写在类上而非方法上的 @Transactional。没有调用点的 listener。交给 executor 的那个 Callable。
它们都不隐蔽——这些都是有文档记录的,读到这篇文章的大多数人也早就各自知道每一条。它们只是在调用点不可见,而调用点正是我们做改动、做评审的地方。
我想给出的建议很小:当一个改动看起来只影响局部时,先把“它不会波及到任何东西”当作一个需要证据的主张,而不是默认前提。这句话当初花了我几个月,我的分析器才算挣到了说它的权利。
它仍然看不到的地方
本着上文的精神,以下是我已知的空白。
自调用是最大的一个。被 @Transactional 或 @Async 标注的方法如果由同一个 bean 的另一个方法调用,就永远不会经过代理,于是注解静默地什么也不做——而我的分析器完全检测不到这一点。这大概是被引用得最多的 Spring 坑之一,它就在这份清单上。
@TransactionalEventListener 的 phase 属性虽然被识别为注解,但没有读取它。目前也没有 Open-Session-In-View 或懒加载分析。属性占位符只做精确键匹配,不支持宽松的 kebab/camel 绑定。
把这些写出来,比被人追问要便宜。
原文《No Database Operations Found: What Writing a Spring Boot Analyzer Taught Me About Spring》首发于 foojay。