18

谈谈 Java 代码的兼容性 - Vincent's Site

 4 years ago
source link: https://www.liuwj.me/posts/talk-about-the-compatibility-of-java-code/?
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

谈谈 Java 代码的兼容性

发表于 2019-10-20   |   标签 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() 是两个完全不一样的方法,因此这个修改是二进制不兼容的。

我的项目的模块依赖是这样的:

java-code-compatibility-02.png

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…


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK