谈谈 Java 代码的兼容性
最近踩了个坑,事情的经过是这样,我在做一个需求,要在某个实体类中加个字段,这个类的名字是 Banner
。
但是当我打开这个类的时候,看到的除了字段定义以外还有一大堆使用 idea 生成的 getter/setter 方法。甚至这些 getter/setter 方法占用的代码行数反而更多,严重干扰视线,阅读代码体验极差。
这时我就产生了重构的想法,思路是删掉这些没必要的 getter/setter 方法,改用 lombok 的 @Data
注解代替。因为 lombok 本来在项目中就有使用,所以应该不会有什么问题。改完之后,我测试了我正在做的这个功能,一切正常,代码部署到测试环境之后也运行良好。
但是万万没有想到,问题竟然出现在与这个功能看起来毫不相关的另一个模块。这个模块启动后抛出了一个 NoSuchMethodError
:
抛异常的地方确实是我改过的 Banner
类,但是 lombok 应该会为我们生成相应的 getter/setter 方法,所以这里不应该找不到才对,难道是 lombok 抽风了?
查找原因的时候,有的同事认为原因是我把 lombok 的依赖设置成 optional,导致运行的时候没有 lombok 的 jar 才出现这个异常。然而这种理解是错误的,因为 lombok 生成代码的原理是通过 javac 提供的 APT(Annotation Processing Tool,注解处理器)机制在编译过程中对 Java 代码的 AST 进行修改,这一切都发生在编译时,因此在运行时并不需要 lombok 的存在。换句话说,如果是因为 lombok 导致的问题,不会等到运行的时候才抛出异常,而是在编译的时候就崩了。
真正的原因比较隐蔽,仔细寻找之后才能发现。在我修改前的 Banner
类中,有一个 id
字段,它的定义是这样的:
1 | private Long id; |
可以看到,这里使用的是包装类型的 Long
,但是它的 getter/setter 方法使用的却是基本类型的 long
:
1 | public long getId() { |
严格来说,这样根本不符合 Java Bean 的规范,使用 idea 也不可能会生成这样的 getter/setter 方法,所以我猜测原代码的作者应该是先使用 idea 生成了代码,然后手动修改了里面的类型。
当我把这两个方法删掉,加上 lombok 的 @Data
注解之后,lombok 给我们生成的的 getter/setter 方法的类型会与字段的类型相同,即 Long getId()
。
理论上,当方法的签名从 long getId()
变成 Long getId()
之后,代码是不会报错的,因为就算原来有地方使用了 long
来接收返回值,我们的方法签名改成 Long
之后返回的包装类型也会被自动拆箱。然而正因为它不会报错,才让我没有立即发现问题。
当我们讨论一段被修改的代码的兼容性的时候,我们其实隐含了两层完全不一样的意思。兼容性分为两个层次:
- 源码级兼容:当我们修改了一段代码,依赖它的其他代码在编译时不需要修改即可直接通过,此为源码级兼容。
- 二进制级兼容:比源码级兼容更进一层,当我们修改了一段代码,依赖它的其他代码不需要修改,甚至也不需要重新编译也可运行正常。
一般来说,我们平时写代码只需要做到源码级兼容即可,二进制级兼容只在很少情况下才会需要。
在这个例子中,我们的方法签名在无意间从 long getId()
变成了 Long getId()
,这在源码层面是兼容的,所以编译的时候不会报错。但是在 Java 字节码中,long getId()
和 Long getId()
是两个完全不一样的方法,因此这个修改是二进制不兼容的。
我的项目的模块依赖是这样的:
Banner.java
在模块 C 中,我修改之后 deploy 了一个新版本到 maven 仓库,因此其他模块可以下载到它。
模块 B 依赖了模块 C,并且在里面使用了 Banner
类的 long getId()
方法,这正是发生这次错误的原因。
模块 A 同时依赖了模块 B 和模块 C。因为我修改过模块 C,并且 deploy 了一个新版本,因此在构建的时候会下载这个最新的 jar 包,但是我并没有修改过模块 B,所以模块 A 在构建的时候使用的仍然是旧的 jar 包。这个旧的 jar 包在运行的时候会尝试去调用签名为 long getId()
的方法,但是这个方法的签名已经被我在无意间改成了 Long getId()
,因此才会发生找不到方法的异常。
找到异常的原因之后,解决方法很自然就有了,那就是重新编译模块 B,并把它 deploy 到 maven 仓库中即可。
这次的问题是一个说明代码兼容性的不同层次的一个很好的例子,这种问题排查起来虽然不算太难,但是发生的原因十分隐蔽,也足够我们折腾一会。为了避免大家以后踩到和我类似的坑,在这里我把整个过程记录下来,然后给出一点不成熟的小建议:
面向甩锅编程,如非必要,坚决不要修改除自己需求以外的任何一行代码,更不要幻想重构,否则出了事情可能会背锅- 字段的 getter/setter 方法要符合 Java Bean 的通用规范,否则其他同学在阅读或修改代码的时候容易忽略掉一些细微之处,某些框架也可能会因为这个产生一些奇怪的 bug。
- 为保证 getter/setter 方法能严格符合规范,推荐尽量使用 lombok,不推荐使用 idea 的代码生成,多出来的代码会多出额外的维护成本。
- 就算使用 idea 来生成 getter/setter 方法,在生成后也请尽量不要去编辑,若以后字段的名字或类型有变化,可把原来的 getter/setter 方法删掉再重新生成一次。
- 如果这些模块属于同一个工程,建议使用 maven 父子工程来组织项目,这样在编译模块 A 的时候,也会同时编译它依赖的模块 B 和模块 C,可以很大程度避免此类问题。
当然,最好的方法还是赶紧换成 Kotlin (强行安利),去 tm 的 getter/setter…