谈谈 Java 代码的兼容性

最近踩了个坑,事情的经过是这样,我在做一个需求,要在某个实体类中加个字段,这个类的名字是 Banner

但是当我打开这个类的时候,看到的除了字段定义以外还有一大堆使用 idea 生成的 getter/setter 方法。甚至这些 getter/setter 方法占用的代码行数反而更多,严重干扰视线,阅读代码体验极差。

这时我就产生了重构的想法,思路是删掉这些没必要的 getter/setter 方法,改用 lombok 的 @Data 注解代替。因为 lombok 本来在项目中就有使用,所以应该不会有什么问题。改完之后,我测试了我正在做的这个功能,一切正常,代码部署到测试环境之后也运行良好。

但是万万没有想到,问题竟然出现在与这个功能看起来毫不相关的另一个模块。这个模块启动后抛出了一个 NoSuchMethodError

img

抛异常的地方确实是我改过的 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
2
3
4
5
6
7
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}

严格来说,这样根本不符合 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…

0条评论
游客评论 游客