舒服了,学习了,踩到一个 Lombok 的坑!

你好呀,我是歪歪。

踩坑了啊,最近踩了一个 lombok 的坑,有点意思,给你分享一波。

我之前写过一个公共的服务接口,这个接口已经有好几个系统对接并稳定运行了很长一段时间了,长到这个接口都已经交接给别的同事一年多了。

因为是基础服务嘛,相对稳定,所以交出去之后他也一直没有动过这部分代码。

但是有一天有新服务要对接这个接口,同事反馈说遇到一个诡异的问题,这个新服务调用的时候,接口里面报了一个空指针异常。

根据日志来看,那一行代码大概是这样的:

//为了脱敏我用field1、2、3来代替了
if(reqDto.getField1() 
    && reqDto.getField2()!=null
    && reqDto.getField3()!=null){
        //满足条件则执行对应业务逻辑
    }

reqDto 是接口入参对象,有好多字段。具体到 field1、2、3 大概是这样的:

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReqDto { 
    private Boolean field1 = true;
    private String field2;
    private String field3;
}

所以看到这一行抛出了空指针异常,我直接就给出了一个结论:首先排除 field1 为 null,因为有默认值。那只可能 reqDto 传进来的就是 null,导致在 get 字段的时候出现了空指针异常。

但是很不幸,这个结论一秒就被推翻了。

因为 reqDto 是请求入参,在方法入口处选了几个关键字段进行打印。

如果 reqDto 是 null 的话,那么日志打印的时候就会先抛出空指针异常了。

然后我又开始怀疑是部署的代码版本和我们看的版本不一致,可能并不是这一行报错。

和测试同学确认之后,也排除了这个方向。

盯着报错的那一行代码又看了几秒,排除所有不可能之后,我又下了一个结论:调用的时候,传递进来的 field1 主动设值为了 null。

也就是说调用方有这样的代码:

ReqDto reqDto = new ReqDto();
reqDto.setField1(null);

我知道,这样的代码看起来很傻,但是确实只剩下这一种可能了。

于是我去看了调用方构建参数的写法,准备吐槽一波为什么要写设置为 null 这样的坑爹代码。

然而,当时我就被打脸了,调用方的代码是这样的:

ReqDto reqDto = ReqDto.builder()
        .field2("why")
        .field3("max")
        .build();

用的是 builder 模式构建的对象,并不是直接 new 出来的对象。

我一眼看着这个代码也没有发现毛病,虽然没有对 Boolean 类型的 field1 进行设值,但是我有默认值啊。

问调用方为什么不设值,对方的回答也是一句话:我看你有默认值,我本来也是想传 true,但是一看你的默认值就是 true,所以就没有给值了。

对啊,这逻辑无懈可击啊,难道......

是 builder 在里面搞事情了?

于是我里面写了一个代码进行了验证:

好你个浓眉大眼的 @Builder,果然是你在搞事情。

问题现象基本上就算是定位到了,用 @Builder 注解的时候,丢失默认值了。

所以拿着 “@Builder 默认值” 这样的关键词一搜:

立马就能找到这样的一个注解:@Builder.Default

对应到我的案例应该是这样的:

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReqDto { 
    @Builder.Default
    private Boolean field1 = true;
    private String field2;
    private String field3;
}

这样,再次运行 Demo 就会发现有默认值了:

同时我们从两个写法生成的 class 文件中也可以看出一些端倪。

没有@Builder.Default 注解的时候,class 文件中 ReqDtoBuilder 类中关于 field1 字段是这样的:

但是有 @Builder.Default 注解的时候,是这样的:

明显是不同的处理方式。

反正,网上一搜索,加上 @Builder.Default 注解,问题就算是解决了。

但是紧接着我想到了另外一个问题:为什么?

为什么我明明给了默认值,@Builder 不使用,非得给再显示的标记一下呢?

于是我带着这个问题在网上冲了一大圈,不说没有找到权威的回答了,甚至没有找到来自“民间”的回答。

所以我也只能个人猜测一下,我觉得可能是 Lombok 觉得这样的赋默认值的写法是 Java 语言的规范:

private Boolean field1 = true;

规范我 Lombok 肯定遵守,但是我怎么知道你这个字段有没有默认值呢?

我肯定是有手段去检查的,但是我必须要每个字段都盲目的去瞅一眼,这个方案对我不友好啊。

这样,我给使用者定一个规范:你给我打个标,主动告诉我那些字段是有默认值的。对于打了标的字段,我才去解析对应的默认值,否则我就不管了。

如果你直接 new 对象,那是 Java 的规范,我管不了。

但是如果你使用 Builder 模式,你就得遵守我的规范。不然出了问题也别赖我,谁叫你不准守我的规范。

打个标,就是 @Builder.Default。

必须要强调的是,这个观点是歪师傅纯粹的个人想法,不保真。如果你有其他的看法也可以提出来一起交流,学习一波。

吃个瓜

虽然我没有找到关于 @Builder.Default 注解存在的意义的官方说明,但是我在 github 上找到了这个一个链接:

https://github.com/projectlombok/lombok/issues/1347

里面的讨论的问题和我们这个注解有点关系,而且我认为这是一个非常明确的 bug,但是官方却当做 feature 给处理了。

简单的一起吃个瓜。

2017 年 3 月 29 日的时候,一个老哥抛出了一个问题。

首先我们看一下提出问题的老哥给的代码:

就上面这个代码,如果我们这样去创建对象:

MyClass myClass = new MyClass();

按照 Java 规范来说,我们附了默认值的,调用 myClass.getEntitlements() 方法返回的肯定是一个空集合嘛。

但是,这个老哥说当 new MyClass 对象的时候,这个字段变成了 null:

他就觉得很奇怪,于是抛出了这个问题。

然后另外有人立马补充了一下。说不仅是 list/set/map,任何其他 non-primitive 类型都会出现这个问题:

啥意思呢,拿我们前面的案例来说就是,你用 1.16.16 这个版本,不加 @Builder.Default 注解,运行结果是符合预期的:

但是加上 @Builder.Default 注解,运行结果会变成这样:

build 倒是正确了,但是 new 对象的时候,你把默认值直接给干没了。

看到这个运行结果的第一个感觉是很奇怪,第二个感觉是这肯定是 lombok 的 BUG。

问题抛出来之后,紧接着就有老哥来讨论了:

这个哥们直接喊话官方:造孽啊,这么大个 BUG 还有没有人管啦?

同时他还抛出了一个观点:老实说,为字段生成默认值的最直观方法就是从字段初始化中获取值,而不是需要额外的 Builder.Default 注解来标记。

这个观点,和我前面的想法倒是不谋而合。但是还是那句话:一切解释权归官方所有,你要用,就得遵守我制定的规范。

那么到底是改了啥导致产生了这么一个奇怪的 BUG 呢?

注意 omega09 这个老哥的发言的后半句:field it will be initialized twice.

initialized twice,初始化两次,哪来的两次?

我们把目光放到这里来:

@NoArgsConstructor,这是个啥东西?

这不就是让 lombok 给我们搞一个无参构造函数吗?

搞无参构造函数的时候,不是得针对有默认值的字段,进行一波默认值的初始化吗?

这个算一次了。

前面我们分析了 @Builder.Default 也要对有默认值的字段初始化一次。

所以是 twice,而且这两次干得都是同一个活。

开发者一看,这不行啊,得优化啊。

于是把 @NoArgsConstructor 的初始化延迟到了 @Builder.Default 里面去,让两次合并为一次了。

这样一看,用 Builder 模式的时候确实没问题了,但是用 new 的时候,默认值就没了。

这是一种经典的顾头不顾尾的解决问题的方式。

作者可能也没想到,大家在使用的时候会把 @Builder 和 @NoArgsConstructor 两个注解放在一起用。

作者可能还觉得委屈呢:这明明就是两种不同的对象构建方式啊,二选一就行了,你要放在一起?哎哟,你干嘛~

接着一个叫做 davidje13 的老哥接过了话茬,顺着 omega09 老哥的话往下说,他除了解释两个注解放在一起使用的场景外,还提到了一个词:least-surprise。

least-surprise,是一个软件设计方面的词汇,翻译过来就是最小惊吓原则。

简单来说就是我们的程序所表现出的行为,应该尽量满足在其领域内具有一致性、显而易见、可预测、遵循惯例。

比如我们认为的惯例是 new 对象的时候,如果有默认值会附上默认值。

结果你这个就搞没了,就不遵循惯例了。

当然,你还是可以拿出那句万金油的话:一切解释权归官方所有,你要用,就得遵守我制定的规范。我的规范就是不让你们混用。

这就是纯纯的耍无赖了,相当于是做了一个违背祖宗的决定。

然而这个问题似乎并没有官方人员参与讨论,直到这个时候,2018 年 3 月 27 日:

rspiller 就是官方人员,他说:我们正在调查此事。

此时,距离这个问题提出的时间已经过去了一年。

我是比较吃惊的,因为我认为这是一个比较严重的 BUG 了,程序员在使用的时候会遇到一些就类似于我认为这个字段一定是有默认值的,但是实际上却变成了 null 这种莫名其妙的问题。

在官方人员介入之后,这个问题再次活跃起来。

一位 xak2000 老哥也发表了自己的看法,并艾特了官方人员:

他的观点我是非常认同的,给你翻译一波。

他说,导致这个问题的原因是为了消除可能出现的重复初始化。但实际上,与修改 POJO 字段的默认初始化这种完全出乎意料的行为相比,重复初始化的问题要小得多。

当然,解决这个问题的最佳方法是以某种方式摆脱双重初始化,同时又不破坏字段初始化器。

但如果这不可能,或者太难,或者时间太长,那么,就让重复初始化发生吧!

然后把“重复初始化”写到 @Builder.Default javadocs 中,大不了再给这几个字加个粗。

如果有人确实写了一些字段初始化比较复杂的程序,这可能会导致一些问题,但比起该初始化却没有初始化带来的问题要少得多。

在当前的这个情况下,当突然抛出一个空指针异常的时候,我真的很蒙蔽啊。

当然了,也有人提出了不一样的看法:

这个哥们的核心思路刚刚相反,就是呼吁大家不要把 @Builder 和 @NoArgsConstructor 混着用。

从“点赞数”你也能看出来,大家都不喜欢这个方案。

而这个 BUG 是在 2018 年 7 月 26 日,1.18.2 版本中才最终解决的:

https://projectlombok.org/changelog

此时,距离这个问题提出,已经过去了一年又四个月。

值得注意的是,在官方的描述里面,用的是 FEATURE 而不是 BUGFIX。

个中差异,你可以自己去品一品。

但是现在 Lombok 都已经发展到 1.18.32 版本了,1.16.x 版本应该没有人会去使用了。

所以,大家大概率是不会踩到这个坑的。

我觉得这个事情,了解“坑”具体是啥不重要,而是稍微走进一下开源项目维护者的内心世界。

开源不易,有时候真的就挺崩溃的。

编译时注解

既然聊到 Lombok 了,顺便也简单聊聊它的工作原理。

Lombok 的核心工作原理就是编译时注解,这个你知道吧?

不知道其实也很正常,因为我们写业务代码的时候很少自定义编译时注解,顶天了搞个运行时注解就差不多了。

其实我了解的也不算深入,只是大概知道它的工作原理是什么样的,对于源码没有深入研究。

但是我可以给你分享一下两个需要注意的地方和可以去哪里了解这个玩意。

以 Lombok 的日志相关的注解为例。

首先第一个需要注意的地方是这里:

log 相关注解的源码位于这个部分,可以看到很奇怪啊,这些文件是以 SCL.lombok 结尾的,这是什么玩意?

这是 lombok 的小心思,其实这些都是 class 文件,但是为了避免污染用户项目,它做了特殊处理。

所以你打开这类文件的时候选择以 class 文件的形式打开就行了,就可以看到里面的具体内容。

比如你可以看看这个文件:

lombok.core.handlers.LoggingFramework

你会发现你们就像是枚举似的,写了很多日志的实现:

这个里面把每个注解需要生成的 log 都硬编码好了。正是因为这样,Lombok 才知道你用什么日志注解,应该给你生成什么样的 log。

比如 log4j 是这样的:

private static final org.apache.logging.log4j.Logger log = org.apache.logging.log4j.LogManager.getLogger(TargetType.class);

而 SLF4J 是这样的:

private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(TargetType.class);

第二个需要注意的地方是找到入口:

这些 class 文件加载的入口在于这个地方,是基于 Java 的 SPI 机制:

AnnotationProcessorHider 这个类里面有两行静态内部类,我们看其中一个, AnnotationProcessor ,它是继承自 AbstractProcessor 抽象类:

javax.annotation.processing.AbstractProcessor

这个抽象类,就是入口中的入口,核心中的核心。

在这个入口里面,初始化了一个类加载器,叫做 ShadowClassLoader:

它干的事儿就是加载那些被标记为 SCL.lombok 的 class 文件。

然后我是怎么知道 Lombok 是基于编译时注解的呢?

其实这玩意在我看过的两本书里面都有写,有点模糊的印象,写文章的时候我又翻出来读了一遍。

首先是《深入理解 Java 虚拟机(第三版)》的第四部分程序编译与代码优化的第 10 章:前端编译与优化一节。

里面专门有一小节,说插入式注解的:

Lombok 的主要工作地盘,就在 javac 编译的过程中。

在书中的 361 页,提到了编译过程的几个阶段。

从 Java 代码的总体结构来看,编译过程大致可以分为一个准备过程和三个处理过程:

  • 1.准备过程:初始化插入式注解处理器。
  • 2.解析与填充符号表过程,包括:
    • 词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树。
    • 填充符号表。产生符号地址和符号信息。
  • 3.插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段,本章的实战部分会设计一个插入式注解处理器来影响Javac的编译行为。
  • 4.分析与字节码生成过程,包括:
    • 标注检查。对语法的静态信息进行检查。
    • 数据流及控制流分析。对程序动态运行过程进行检查。
    • 解语法糖。将简化代码编写的语法糖还原为原有的形式。(java中的语法糖包括泛型、变长参数、自动装拆箱、遍历循环foreach等,JVM运行时并不支持这些语法,所以在编译阶段需要还原。)
    • 字节码生成。将前面各个步骤所生成的信息转换成字节码。

如果说 javac 编译的过程就是 Lombok 的工作地盘,那么其中的“插入式注解处理器的注解处理过程”就是它的工位了。

书中也提到了 Lombok 的工作原理:

第二本书是《深入理解 JVM 字节码》,在它的第 8 章,也详细的描述了插件化注解的处理原理,其中也提到了 Lombok:

最后画了一个示意图,是这样的:

如果你看懂了书中的前面的十几页的描述,那么看这个图就会比较清晰了。

总之,Lombok 的核心原理就是在编译期对于 class 文件的魔改,帮你生成了很多代码。

如果你有兴趣深入了解它的原理的话,可以去看看我前面提到的这两本书,里面都有手把手的实践开发。

我就不写了,一个原因是因为确实门槛较高,写出来生涩难懂,对我们日常业务开发帮助也不大。

另外一个原因那不是因为我懒嘛。

posted @ 2024-03-25 12:41  why技术  阅读(4161)  评论(8编辑  收藏  举报