两年后放弃Lombok:代码符号显化更吸引注意力


Java团队耗时两年最终放弃Lombok,核心原因是其“隐形代码”导致生产环境调试困难、新人上手慢、注解存在隐藏陷阱。改用显式代码后,堆栈可读、构建简化、团队掌控力提升。

作者背景
Devrim Ozcay,资深后端工程师,经历过多个Java项目从原型到大规模微服务架构的全周期。

Lombok在你一个人写小玩具的时候挺好使。但只要你的团队超过三个人,项目要跑在生产环境上,半夜有可能会出故障,那Lombok给你省的那点敲键盘的时间,迟早会变成你掉头发的数量。我们团队用了两年,最后还是把它请出去了。现在堆栈信息清清楚楚,新人来了不用先上一个小时的“Lombok注解用法课”,半夜三点出问题也能顺着堆栈一路查到底。爽多了。

你问我为什么?别急,咱从头开始撸。

我们刚开始觉得Lombok简直是个宝

那时候我们团队刚接一个大项目,八成都是POJO类,就是那种只有一堆字段,然后每个字段都要写getter、setter、toString、equals、hashCode的苦力类。一个类轻松上两百行,一半以上是IDE自动生成的样板代码。代码评审的时候,满屏都是getUserName、setUserName、getAge、setAge,看得人眼睛疼。

然后有人安利了Lombok。嘿呀,真香。

我们在代码里写个@Data,它就自动帮你把getter、setter、toString、equals、hashCode全包圆了。写个@Builder,就能链式调用创建对象。写个@Slf4j,直接用log.info。爽不爽?爽。IDE不叫唤了,代码量瘦了一大圈,评审速度都快了。

更骚的是@SneakyThrows。被检查异常烦得不行?加上这个注解,编译器闭嘴了。我们那时候看Lombok就像看哆啦A梦的口袋,啥都能变出来。

但是你知道吗,这种“魔法”最大的问题就是——你看不见它到底干了啥。

第一次生产事故让所有人懵了

事情是这样的。有个订单实体类,用了@Builder。我们在代码里做了字段校验,按理说价格字段绝对不能为空。结果生产环境冒出一条数据,价格是null。

我们看了源代码,校验逻辑写得清清楚楚。看了调用方,传进来的参数非空。那这null到底怎么钻进来的?

打开日志,堆栈信息指向了一个叫OrderEntity.builder()的方法。这个方法的代码在哪儿?不在我们的代码库里。因为那是Lombok编译时候生成的。

我们一行一行跟踪,发现@Builder生成的构造器有一个特点:如果你在一个字段上没调用.price()方法,它会保持默认值。但是那个字段如果是final的,并且没有默认值,@Builder会悄悄把它跳过。不是报错,不是警告,就是跳过。然后这个字段就成了null。

我们那个校验逻辑,写在了业务代码里,但是null在对象创建的那一刻就已经存在了。后续校验根本来不及。

新来的同事看完第一句话就是:“构造器在哪儿?我没看到构造器啊。” 然后我们还得翻出Lombok的文档,给他解释什么是@AllArgsConstructor、@NoArgsConstructor,为什么用@Builder的时候这些也牵扯进来了。

那个时候我们就开始觉得不对劲了。

调试Lombok代码就跟玩密室逃脱一样

如果说生产事故是第一巴掌,那日常调试就是持续的小刀割肉。

Java出异常的时候,堆栈信息会告诉你在哪个文件的哪一行出了问题。这个信息特别宝贵,尤其半夜被叫起来的时候,一眼看到行号,立马能锁定位置。

但是Lombok生成的代码,行号指向的是编译后出来的东西,不是你手写的源文件。你点进去,要么是空白,要么是一堆你不认识的字节码。

有一次我们遇到一个NullPointerException。堆栈里显示的方法是toString。我们找遍整个类,没人写过toString。后来反应过来,是@Data生成的。但是那个toString里哪个字段是null?看堆栈看不出来。因为生成的代码把所有字段拼在一起,中间某个字段坏了,它就崩了,但不告诉你是哪个字段。

我们自己写的toString就好办了:每次只输出一个字段,看到哪个位置崩了,就知道是哪个字段的问题。但是Lombok生成的是一整串拼接,要么全好,要么全崩,崩了你只能猜。

你说这气人不气人?

新人上手慢到怀疑人生

我们团队有刚毕业的,也有干了五六年但第一次碰Lombok的。他们看我们代码的第一反应都差不多:

“这个@Data是什么意思?”
“为什么我没有写setter,但是可以用?”
“我想改一下equals的逻辑,但是代码里没有equals方法,我到底能不能自己写一个?”
“我写了equals方法,Lombok会不会覆盖我的?”

这些问题本身不复杂,但是每个都要解释。而且解释完了,过两周他们又忘了,因为代码里看不见的那些方法,是违反直觉的。人脑天生对看不见的东西记不住。

更麻烦的是,有些注解还有副作用。比如@EqualsAndHashCode,默认会把你类里所有的字段都算进去,包括那些集合类型。如果你的类里有个List,两个对象List里元素顺序不一样,equals就返回false。你可能根本不需要这个行为,但你得额外写exclude才能关掉。

这就好比你说“我想吃个蛋炒饭”,结果厨房给你端上来一份加了榴莲、臭豆腐、巧克力酱的蛋炒饭,你还要专门说“不要加榴莲臭豆腐巧克力酱”,他才给你正常版本。累不累?

那些看似省事实则埋雷的注解细节

咱们一个个说。

@Builder。前面讲了,会跳过final字段。你得用  @Builder.Default 去手动处理。多少人知道这个?我们团队用了半年才发现。而且@Builder生成的构造器,默认是全参数的。如果你有些字段不需要在构造时赋值,不好意思,它不管你,全都列上。

@Data。这个注解等于  @Getter @Setter @RequiredArgsConstructor @ToString @EqualsAndHashCode 五个注解的合体。副作用是什么?你的类突然变成了完全开放的状态,每个字段都有getter和setter。有些内部状态你根本不想让人改,结果setter自动生成了。防不住。

@Value。说是不可变类,但如果你字段本身是可变对象,比如Date、List、Map,它生成的getter直接返回引用。外面拿到引用,改了里面的值,你的不可变就成笑话了。

@SneakyThrows。检查异常它帮你藏了。但是藏了不代表不抛。抛出的时候,异常类型被包装了,你catch的时候发现类型对不上,崩溃。

每个注解都像是一个小地雷。单独看都没啥,但放在复杂业务里,十几个微服务,不同的人维护,不同的人加了不同的注解,叠在一起,就变成了地雷阵。

我们是怎么一步步拆掉Lombok的

我们没有一夜之间全删光。那样项目会炸。我们分了四个阶段。

第一阶段。停用@Builder,从DTO开始。DTO是最外层的数据传输对象,数量多,但是逻辑简单。我们手动给每个DTO写构造器,或者用静态工厂方法。刚开始有点疼,因为要写不少字。但一周以后,所有DTO的创建逻辑清清楚楚,review的时候一眼能看到哪些字段是必须的,哪些是可选的。

第二阶段。干掉@Data和@Value。这两个最坑,因为副作用太多。我们改成手动写getter,setter按需提供。有些只读字段,连setter都不给。这样代码长了大概30%,但是每个方法都是实际存在的,你在IDE里点一下就能跳转到定义,新人也看得懂。

第三阶段。处理equals和hashCode。以前靠@Data自动生成,现在我们自己写。你可能会问,这玩意自己写不累吗?我们用IntelliJ的快捷菜单,Alt+Insert,选equals和hashCode,IDE自动生成标准的代码。点一下就行,比写注解没慢多少,但是生成的代码老老实实躺在文件里,你想看随时看。

第四阶段。连@Slf4j都换了。这个其实副作用最小,但我们想彻底告别“看不见的代码”。改成老派的:

private static final Logger logger = LoggerFactory.getLogger(MyClass.class);

就多了一行。但是以后任何人打开这个类,都能看到logger是怎么来的。不用装任何IDE插件,不用配置编译环境,换个电脑也能编译通过。

整个迁移过程大概花了两周。每天干一点,每次改几个类,跑一遍单元测试。因为之前写了足够多的测试,所以没出过大乱子。

删掉Lombok以后我们得到了什么

第一,堆栈信息回归正常。再也不会出现“这个方法在代码里找不到”的情况。每一行异常,都能点开看到实际代码。排bug速度提升至少一倍。

第二,代码评审回归本质。以前评审时,看到@Data就过了,因为看不到具体实现。现在每行代码都写在那里,谁写的setter逻辑对不对,equals里有没有把不该比的东西放进去,一眼看到。有次评审发现一个人写的equals里比较了createTime,但是createTime精确到毫秒,两个对象明明业务上相等,只是创建时间差了几毫秒,equals就给false了。这个问题如果用了@Data根本不会发现,因为@Data把createTime自动包含进去了。

第三,新人上手速度起飞。新来的同事第一天就能看懂我们的POJO类。不需要单独花时间讲Lombok的坑。他们甚至感慨:“你们这代码挺朴素的嘛,好懂。” 朴素就对了。

第四,构建和IDE再也不打架。以前偶尔会遇到Lombok版本和IDE插件版本不匹配,注解不生效,编译报一堆错。删了Lombok以后,JDK自带的工具链搞定一切。CI/CD流水线也干净了,少了一个需要特殊处理的依赖。

最核心的好处是安全感。你知道你写的每一行代码都是真实存在的。你控制的每一个方法都是你自己写的或者明确知道从哪里生成的。那种“代码在我掌控之中”的感觉,比少写几行getter重要一万倍。

显式代码为什么在团队里更好使

Lombok本身不坏。它很聪明,非常聪明。能够通过注解在编译期生成代码,这个技术挺牛的。但是聪明不等于适合团队协作。

团队协作需要什么?需要透明、需要低认知负担、需要每个人都能在最短时间内理解另一部分代码在做什么。

Lombok的问题在于,它把复杂度藏在了编译器里。你写代码的时候觉得简单,但是读代码的人、调试代码的人、修bug的人,要付出额外的心智去猜测“这个注解到底干了啥”。

在一个人维护的小项目里,这不是问题。但在十二个人同时开发的微服务系统里,每个人都要去猜测另外十一个人写的注解,这成本就爆炸了。

我们现在的原则是:代码可以啰嗦一点,但不能有魔法。能显式写的就显式写。getter、setter、构造器、equals、hashCode、toString,这些虽然占行数,但是每一行都是自解释的。

有人说这样写起来慢。其实不然。现代IDE有Live Template。我们团队配了几个简单的快捷方式:

gfn → 自动生成字段的getter
sfn → 自动生成字段的setter
tostr → 生成标准的toString
cnstr → 生成包含必填字段的构造器

敲几下键盘,两秒钟,一段标准代码就出来了。比写注解加上import语句慢不了多少,但是生成的代码是真实存在的,所有人都能看见。

哪些场景Lombok仍然可以考虑

我不是一棍子打死。如果你满足下面所有条件,用Lombok没问题:

项目是你一个人维护的。
项目不会长期演进超过半年。
不需要严格的调试和生产诊断。
团队成员不超过两个人。

快速原型、个人小工具、某些内部测试工具,用Lombok确实省时间。

但是任何要上生产、要长期维护、要多人协作的项目,我劝你三思。因为你省的那点时间,迟早会在半夜三点被一个看不见的null给要回来。

有时候我们还是会怀念那么一下下

说实话,偶尔确实会想Lombok。尤其是要写一个新DTO,二十个字段。你想想,一个一个写getter、setter,每个方法四行,二十个字段就是一百六十行。这时候@Data像个小妖精一样在你耳边说:“用我吧,一行搞定。”

但忍住了。然后打开Live Template,噼里啪啦两分钟,全部生成完。心理踏实。

还有@Builder。有些对象构造的时候参数很多,链式调用确实优雅。但是我们后来用了另一种模式:静态工厂方法加Builder模式的手写版。代码多了三十行,但是每一步逻辑都在,出了bug能断点进去看。

值不值得?值得。因为每个项目活到最后,维护的时间都远远超过写代码的时间。前期多花一个小时写清楚,后期能省下几十个小时的调试和解释时间。

最终结论:魔法好玩,生产无情

Lombok像是一个魔术师。刚开始你看着各种东西变来变去觉得好玩。但是当你需要搞清楚那些东西是怎么变出来的时候,魔术反而成了障碍。

在生产环境里,没有人需要魔术。需要的是可读、可追踪、可调试的代码。需要的是凌晨三点被叫醒的时候,能看着堆栈信息一路追到根因。需要的是一个新人来了不用先学一套“魔法规则”就能开始修bug。

我们放弃了Lombok,换回来的是安心。代码长了点,但是心里踏实了。

如果你还在用Lombok,没问题。我们也是过来人。但是下次你的堆栈指向一个你根本没写过的方法的时候,想想今天说的这些。也许你会试着写一个getter,就写一个。看看感觉如何。

毕竟,能让你睡得安稳的代码,才是好代码。