利用 Project Lombok 自定义 AST 转换
转自 http://www.ibm.com/developerworks/cn/java/j-lombok/
利用 Project Lombok 自定义 AST 转换
何时以及如何为自定义代码生成扩展 Lombok
Alex Ruiz 在本文中介绍了 Project Lombok,探讨了它的一些独特的编程特色,包括注释驱动代码生成,以及简洁、紧凑、可读的代码。然后,他会提示大家关注 Lombok 更有价值的用途:利用自定义 AST(Abstract Syntax Tree,抽象语法树)转换来对其进行扩展。扩展 Lombok 使得您可以生成自己的项目或者域特定样板代码,但是,这也确实需要大量的工作。最后 Alex 提供了一些技巧,就是通过简化流程的关键步骤,以及一个自由使用的 JavaBeans 自定义扩展。
即使对于保守的 Java™ 开发人员来说,冗长的语法也是 Java 语言编程的一个弱点。虽然有时可通过采用 Groovy 之类的新语言来避免冗长,但是,很多时候采用 Java 编程是最适合的,有时甚至就是这样要求的。那么您可能会想要尝试 Project Lombok,它是个开源的、用于 Java 平台的代码生成库。
Lombok 可以方便地减少 Java 应用程序中样板文件的代码量,这样,您就不需要编码大量的 Java 语法。但是使 Lombok 如此贴心的不只是语法,它是一种独特的代码生成方法,能够开启所有 Java 开发可能性。
在 本文中,我将介绍 Project Lombok,并说明其优越之处,尽管并不完美,但丰富了 Java 开发人员的工具箱。我将为大家提供对 Lombok 的概述,包括它的工作方式以及它最适用的场景,并简单罗列其优缺点。接下来,我将为大家介绍一个最有用,但也很复杂的 Lombok 用例:将其扩展为一个自定义代码基。这可能是您自己的代码或者现有的 Java 模板,它还不属于 Lombok 库的一部分。无论哪种方式,文章的后续部分将侧重于扩展 Lombok 的技巧与窍门,包括确定是否值得在 Lombok API 上花费时间,或者是否能够为您特定的应用程序更好地编写样本文件。
所包括的示例代码(见 下载)扩展 Lombok 来生成 JavaBeans 样板代码。这在 Apache 2.0 环境下许可免费使用。
什么使得 Lombok 与众不同
也 许选用 Lombok 而不是其他代码生成工具的主要原因就是 Lombok 不仅生成 Java 源或者比特代码:它会通过在编译阶段修改其结构来转换抽象语法树(AST)。AST 代表已解析源代码的树,它由编译器创建,与 XML 文件的 DOM 树模型类似。通过修改(或转换)AST,Lombok 可对源代码进行修剪,来避免膨胀,这与纯文本代码生成不同。Lombok 所生成的代码对于同一编译单元的类是可见的,这不同于带库的直接字符编码操作,比如 CGLib 或者 ASM。
Lombok 支持多个触发代码生成的机制,包括了非常流行的 Java 注释。利用 Java 注释,开发人员能够修改已注释的类,这是常规 Java 触发流程所禁止的。
关于 Lombok 使用的例子,可参考清单 1 中的类:
清单 1. 一个简单 Java 类
public class Person { private String firstName; private String lastName; private int age; }
向代码中增加 equals
、hashCode
、以及 toString
实施并不困难,只是单调乏味而容易出错。您可采用 Eclipse 之类的现代 Java IDE 来自动生成主要的样本代码,但是,那只是部分解决方案。这是节省了时间与精力,但将以牺牲可读性与可理解性为代价,因为样本代码通常会向应用程序源增加干扰词。
然而,Lombok 有一个很智能的方法来解决样板代码问题。以 清单 1 为例,可通过为 Person.java
类增加 @Data
注释,来方便地生成所需的方法。图 1 展示了 Lombok 在 Eclipse 中的代码生成。在大纲视图中,可以看到在编译类中展示了所生成的方法,同时源文件仍处于样文件之外。
图 1. 活动的 Lombok
Lombok 的具体细节
Lombok 支持流行的 Java 编译器 javac 以及 Eclipse Compiler for Java(ECJ)。尽管这两个编译器产生类似的输出,但是他们的实现却完全不同。结果是, Lombok 自带两套注释处理程序(挂接到 Lombok 中的代码以及包含的代码生成逻辑):每个编译器一个。幸运的是,这是透明的,因此,作为用户,我们仅需面对一套 Java 注释。
Lombok 还提供与 Eclipse 的紧密集成:保存 Java 文件会自动触发 Lombok 的代码生成(没有明显的延迟)并更新 Eclipse 的大纲视图来展示所生产的成员,如 图 1 所示。
对于想要 了解内部情况的开发人员,Lombok delombok
工具将为您提供指导,可通过 Maven 或者 Ant 命令行访问。Delombok 获取通过 Lombok 转换的代码,并依据它来生成普通的 Java 源文件。“已被 delombok 处理过” 的代码将会包含之前由 Lombok 所完成的转换,格式为普通文本。例如,如果将 delombok
应用到 图 1 的代码中,您将能够看到,equals
、hashCode
、以及 toString
已被实施。
不存在完美的事物:Lombok 的缺点
在选择 Lombok 并准备在项目中进行应用之前,您应当知道它有一些限制。其中两个主要的方面是:
- Lombok 的强大可能是个弱点。反对 Lombok 的观点中最主要的是,它表现 “太多的魔术”。首先,通过删除 Java 代码冗长,Lombok 改变了很多 Java 程序员对该语言所喜爱之处:所见即所得。有了 Lombok,.java 文件无法再展示 .class 文件所包含的内容。
其次,正如我们所了解的,特定 Lombok 转换将根本地改变 Java 语法。@SneakyThrows
转换就是个明显的例子。它允许不在方法定义中声明所检查的异常,而将其扔掉,如同它们是未经检查的异常:清单 2. @SneakyThrows — pretty sneaky
// normally, we would need to declare that this method throws Exception @SneakyThrows public void doSomething() { throw new Exception(); }
- Lombok 的注释命名约定不沟通意图。在 Lombok 中,注释不再仅是元数据:它们实质上是像命令 一样驱动代码生成。我相信注释
@GenerateGetter
将能够比当前注释@Getter
更好地交流意图。
除了这些 Lombok 相关问题之外,还有一些有关 Eclipse 集成的问题。在大多数情况下,这是由于 Eclipse 不了解 Lombok 代码生成情况所造成的:
- Eclipse 在与 Lombok 一起生成代码时,会时不时地引发
NullPointerException
。问题的原因现在还不清楚。关闭并重新打开 Eclipse 通常就能解决此问题。 - Lombok 会为 Eclipse 中的重构增加麻烦。例如,利用 Eclipse 来重命名含有 Lombok 生成 getters 与 setters 方法的字段,需要按 Alt-Shift-R 两次,来使用 Rename Field 对话,而不是执行 in-place 字段重命名。在 Preview 步骤中,需要从正在重构的类型中取消选定 getXXX 与 setXXX。
- 由于不存在用于 Lombok 生成代码的 Java 源,调试会变得有点混乱。例如,如果要尝试处理 Lombok 生成 getter
getName
的代码,Eclipse 调试工具会跳到字段name
的注释@Getter
。除此之外,当 Lombok 出现时,Eclipse 调试工具会向平常一样工作。
总的说来,这些问题可以绕过,而且今后其中大部分问题可能会被 Lombok 与 Eclipse dev 团队所解决。但是,最好对所要应用的技术有所了解。这可以随时向工具箱中增加新的工具。
扩展 Lombok
Lombok 生成大部分公共 Java 样本代码,包括 getters、setters、equals
、以及 hashCode
,仅举几个例子。这个很有用,但有时您还需要生成自己的样本代码。例如,Lombok 还不支持一些公共编码模式,比如 JavaBeans。在有些情况下,您可能还需要生成指定给项目或者域的代码。
关 于扩展的最佳用例就是在项目早期阶段,利用新的代码模式来进行原型设计与试验。这些代码模式会越来越成熟,因此,Lombok 会使其变更或者增强实施变得很简单:仅需修改注释处理程序(挂接到 Lombok 中来生成代码的那部分代码段)并编译。所有基本代码将被自动更新(除非在所生成代码中的公共约定有变化,导致编译出错)一旦这些代码模式确定了,就可以选 择 delombok
代码。因此,您就可以使用常规 Java 源了。
为扩展 Lombok,需要识别或者创建将触发 Lombok 代码生成的注释。接下来,将需要为所确定的每个注释编写注释处理程序。注释处理程序 是实现一对 Lombok 接口以及转换逻辑的类 — aka 代码生成。
以下部分包含了一些建议,从项目设置到测试,这些在创建自己的 AST 转换时可能会很有用。其中还包括了一些代码示例,演示了用于支持 JavaBeans 的功能性 Lombok 扩展。后续文章将深入介绍。
Lombok 生成 JavaBeans 代码
正 如我前面所提到的,Lombok 当前支持公共代码模式,但并不能完全涵盖,包括 JavaBeans。为了演示 Lombok 扩展,我编写了一个用于生成 JavaBeans 全程(plumbing)代码的非常简单的项目。除了展示如何利用自定义注释处理程序来为 javac 与 ECJ 扩展 Lombok,本项目还打包了一些很有用的工具(比如用于每个编译器的字段与方法构建程序),这些工具使得整个流程更清晰、更简单。
我采用了 Eclipse 3.6(Helios)以及用于版本 0.10-BETA2 的 Lombok git
库的快照。代码包含了生成 JavaBean “绑定” setters 的。附加的 zip 文件(见 下载 部分)包含以下内容:
- Ant 构建文件
- 注释
@GenerateBoundSetter
与@GenerateJavaBean
- 生成 “绑定” setter 的注释处理程序(用于 javac 及 ECJ)
- 一些 JavaBeans plumbing(例如
PropertyChangeSupport
字段的生成)
附加的代码 具有完整的功能,并已获得 Apache 2.0 下的许可。可从 GitHub(见 参考资料)获得升级版本的代码。此处有一个有关代码功能的快速浏览可寻找灵感。
如果在清单 3 中采用我的触发处理程序编写代码,Lombok 将会生成类似清单 4 中的代码:
清单 3. Lombok! Generate JavaBean!
@GenerateJavaBean public class Person { @GenerateBoundSetter private String firstName; }
清单 4. 生成 JavaBean 支持代码的示例
public class Person { public static final String PROP_FIRST_NAME = "firstName"; private String firstName; private PropertyChangeSupport propertySupport = new PropertyChangeSupport(this); public void addPropertyChangeListener(PropertyChangeListener listener) { propertySupport.addPropertyChangeListener(listener); } public void removePropertyChangeListener(PropertyChangeListener listener) { propertySupport.removePropertyChangeListener(listener); } public void setFirstName(String value) { String oldValue = firstName; firstName = value; propertySupport.firePropertyChange(PROP_FIRST_NAME, oldValue, firstName); } }
参阅包含在 示例代码 中的 readme.txt 文件,来了解如何从示例代码的构建文件生成 Eclipse 项目。
入门:Javac 还是 ECJ ?
以 我的观点看,任何 Lombok 扩展都需要同时支持 javac 与 ECJ,至少现在是这样。Javac 是 Ant 与 Maven 之类的构建工具所默认采用的编译器。然而,在写这篇文章的时候,在与 Lombok 一起使用时,Eclipse 能提供最流畅的编码体验。同时支持两个编译器,对于提高开发人员的生产效率是至关重要的。
Javac 与 ECJ 采用类似的 AST 结构。不幸的是,他们的部署完全不同,这使得您不得不为每个注释编写两个注释处理程序,一个用于 javac 另一个用于 ECJ。有个好消息是 Lombok 团队已经开始了统一 AST API 的相关工作,这将最终实现了在采用两个编译器时,只需要为每个注释编写一个注释处理程序(见 参考资料)。
了解 Lombok 的源代码
接下来需要了解将要处理的事情,对此,最好是去查看源代码。
Lombok 在 javac 与 ECJ 中采用非公共 API 来实现其智能的代码生成技术。因为代码将被插入到 Lombok 中,所以即使没有相同的 API,也应当拥有类似的 API。
非 公共 API 的主要问题是缺少文档与可靠性。幸运的是,据 Lombok 团队说,他们还没有遇到有关新版本 Eclipse(当 Java 7 发布以后我们就有机会看到)的兼容性问题。目前,缺乏文档是不得不处理的最大的问题。此外,即使有很好的文档,学习两个不同编译器的 API 确实是个艰苦并耗时的任务。我们需要的是一个有关 javac 与 ECJ 的 “快速而实用的指南” — 其中一些超出了本文的范围。
有 一个好消息是,Lombok 团队已经完成了大量关于利用 javac 与 ECJ 生成 AST 节点的相关文档工作。强烈建议您阅读一下他们的代码。他们提供了最通用的用例:比如变量声明,方法实施等。阅读 Lombok 的源代码是学习 javac 与 ECJ 的 API 的最快捷的方法。清单 5 展示了 Lombok 所拥有的源代码的示例:
清单 5. Generating local variables with Javac
/* final int PRIME = 31; */ { if (!fields.isEmpty() || callSuper) { statements.append(maker.VarDef(maker.Modifiers(Flags.FINAL), primeName, maker.TypeIdent(Javac.getCTCint(TypeTags.class, "INT")), maker.Literal(31))); } }
正如您所见,Lombok 团队已经记录了什么块产生什么。下一次需要生成本地变量的声明时,您可以回到此源,并以此为参考。
不要仅限于阅读 Lombok 的 .java 文件。Lombok 开发人员已经提供了用于设置于构建项目以及用于测试注释处理程序的指针。以下部分会介绍这些主题的更多细节。
依赖管理
如果尝试在项目中自动化依赖管理,那么就很难返回手动方式。Java 体系中有多个构建工具来提供依赖管理,包括 Ivy 与 Maven(见 参考资料)。然而,当创建 Lombok 扩展时,选择范围缩小为一个,并且它是 Ivy。
选 择 Ivy 的理由之一是所有必要的依赖,例如 javac,都位于 Maven 的中心库中 — 这就排除了 Maven。另一个理由是 Ivy 支持 Maven 库中所没有的管理依赖。可以很方便地指定下载依赖的链接。这一配置需要自定义 ivysettings.xml 配置文件,这个比较简单。
Ivy 位于 Ant 之上,提供对于构建的依赖管理。Lombok 团队采用他们自己开发的 Ivy 的优化版本,ivyplusplus(见 参考资料)。这一 Ivy 扩展提供了一些有用的 Ant 目标(targets),比如从一系列依赖中创建 Eclipse 与 IntelliJ 项目文件。
增加依赖
要设置 Lombok 扩展项目需要如下文件:
- build.xml 文件:Ant 构建文件:
- 第一次调用构建时下载 ivyplusplus(从特定位置)。
- 指定 Ivy 配置文件的位置。
- 编译、测试并打包代码。
- buildScripts/ivy.xml 文件:指定项目的依赖。
- buildScripts/ivysettings.xml 文件:指定库(Maven 或者仅为 URLs),从该库来获取依赖。
- buildScripts/ivy-repo 文件夹:每个包含一个 XML 文件,在 ivy.xml 中指定每个依赖。这些 XML 文件描述了一个依赖构件(比如,提供下载的位置、主页等等)
您不必做重复的工作。为节省时间与精力,可关注一下来自 Lombok 的构建文件,或者来自本文 附加资源 与其他所需的内容。
注释命名
正如前面所提到的,Lombok 的注释不仅是元数据,它还能很好地完成通信任务。它们应当指出它们负责触发一些类型的代码生成。因此,我强烈建议您将所有 Lombok 相关的注释放到 “Generate” 前面。在本文的 源代码 中,我已对触发 JavaBeans 相关源代码 @GenerateBoundSetter
与 @GenerateJavaBean
的注释命名。这一命名规则至少给不熟悉基本代码的开发人员一个线索,即在构建环境中存在生成代码的处理过程。
文档记录 AST 转换
在扩展 Lombok 时,文档很重要。文档注释处理程序将有益于 AST 转换的维护者,而文档注释将有益于其用户。
文档注释处理程序
采用 javac 或 ECJ API 的代码阅读或了解起来并不繁琐。即使其生成最简单的 Java 代码,也是复杂与耗时的。文档记录注释处理程序会减轻您和您团队的维护工作。关于文档记录问题,我发现以下内容很有用:
- 类级别 Javadoc 注释解释了在一定高度注释处理程序生成了什么代码。我认为解释生成了什么代码的最好方式是在注释中包含示例代码,如清单 6 所示:
清单 6. Class-level Javadoc of an annotation handler
/** * Instructs lombok to generate the necessary code to make an annotated Java * class a JavaBean. * <p> * For example, given this class: * * <pre> * @GenerateJavaBean * public class Person { * * } * </pre> * our lombok annotation handler (for both javac and eclipse) will generate * the AST nodes that correspond to this code: * * <pre> * public class Person { * * private PropertyChangeSupport propertySupport * = new PropertyChangeSupport(this); * * public void addPropertyChangeListener(PropertyChangeListener l) { * propertySupport.addPropertyChangeListener(l); * } * * public void removePropertyChangeListener(PropertyChangeListener l) { * propertySupport.removePropertyChangeListener(l); * } * } * </pre> * </p> * * @author Alex Ruiz */
- 通常,基本代码中非 javadoc 注释解释了代码块生成的内容,如清单 7 所示:
清单 7. Documenting what a block of code generates
// public void setFirstName(String value) { // final String oldValue = firstName; // firstName = value; // propertySupport.firePropertyChange(PROP_FIRST_NAME, oldValue, // firstName); // } JCVariableDecl fieldDecl = (JCVariableDecl) fieldNode.get(); long mods = toJavacModifier(accessLevel) | (fieldDecl.mods.flags & STATIC); TreeMaker treeMaker = fieldNode.getTreeMaker(); List<JCAnnotation> nonNulls = findAnnotations(fieldNode, NON_NULL_PATTERN); return newMethod().withModifiers(mods) .withName(setterName) .withReturnType(treeMaker.Type(voidType())) .withParameters(parameters(nonNulls, fieldNode)) .withBody(body(propertyNameFieldName, fieldNode)) .buildWith(fieldNode);
文档注释
增加一个与我们在注释处理程序中所采用注释相类似的类级别 Javadoc 注释(在 清单 6 中),有助于注释用户知道并理解当他们使用这些注释是所发生的情况。
编译器的一致性
如果决定同时支持 javac 与 ECJ,这一提示将很有用。当拥有两套注释处理程序时,任何错误修正、变更、或者增加都应当对两套(或分支)同时应用。分支越类似,变更就会越快越安全。这种相似性必须同时出现在包级别与文件级别。
包级别一致性:越多越好,每个分支(javac 与 ECJ)应当具有同等数量的类,采用相同的名字,如图 2 所示:
图 2. javac 与 ECJ 分支包的相似性
文件级别一致性:因为这两个分支可能或多或少具有类似数量的类,具有类似的名字,具有相同名字的每对文件中的注释必须尽量类似:字段、方法计数、方法名字等等,应当都基本相同。清单 8 展示了用于 javac 和 ECJ 的 generatePropertySupportField
方法。请注意,即使对于不同 AST API,这些方法的实现也是非常相似的。
清单 8. 比较 javac 和 ECJ 注释处理程序
// javac private void generatePropertyChangeSupportField(JavacNode typeNode) { if (fieldAlreadyExists(PROPERTY_SUPPORT_FIELD_NAME, typeNode)) return; JCExpression exprForThis = chainDots(typeNode.getTreeMaker(), typeNode, "this"); JCVariableDecl fieldDecl = newField().ofType(PropertyChangeSupport.class) .withName(PROPERTY_SUPPORT_FIELD_NAME) .withModifiers(PRIVATE | FINAL) .withArgs(exprForThis) .buildWith(typeNode); injectField(typeNode, fieldDecl); } // ECJ private void generatePropertyChangeSupportField(EclipseNode typeNode) { if (fieldAlreadyExists(PROPERTY_SUPPORT_FIELD_NAME, typeNode)) return; Expression exprForThis = referenceForThis(typeNode.get()); FieldDeclaration fieldDecl = newField().ofType(PropertyChangeSupport.class) .withName(PROPERTY_SUPPORT_FIELD_NAME) .withModifiers(PRIVATE | FINAL) .withArgs(exprForThis) .buildWith(typeNode); injectField(typeNode, fieldDecl); }
测试 AST 转换
测试自定义 AST 转换比您想象的更容易,这要感谢 Lombok 所提供的测试基础设施。为说明测试 AST 转换有多容易,我们来看一下清单 9 中的 JUnit 测试用例:
清单 9. 所有 ECJ 注释处理程序的单元测试
import static lombok.DirectoryRunner.Compiler.ECJ; import java.io.File; import lombok.*; import lombok.DirectoryRunner.Compiler; import lombok.DirectoryRunner.TestParams; import org.junit.runner.RunWith; /** * @author Alex Ruiz */ @RunWith(DirectoryRunner.class) public class TestWithEcj implements TestParams { @Override public Compiler getCompiler() { return ECJ; } @Override public boolean printErrors() { return true; } @Override public File getBeforeDirectory() { return new File("test/transform/resource/before"); } @Override public File getAfterDirectory() { return new File("test/transform/resource/after-ecj"); } @Override public File getMessagesDirectory() { return new File("test/transform/resource/messages-ecj"); } }
该测试工作或多或少有点类似下面的情况:
- 测试编译了由
getBeforeDirectory
指定的文件夹中的所有 Java 文件,采用由getCompiler
与 Lombok 指定的编译器。 - 编程完成后,测试利用
delombok
创建了已编译类的文本表示。 - 测试读取
getAfterDirectory
指定的文件夹中的文件。这些文件包含所期望的已编译类的内容。测试将这些文件的内容与在[第 2 步]中所获取的文件进行对比。对比的文件必须具有相同的名字。 - 测试从在
getMessagesDirectory
中指定的文件夹中读取文件。这些文件包含了所期望的编译器消息(警告与错误)。测试将这些文件的内容与编译过程中所展示的实际值相对比,如果编译 Java 文件则不需要消息文件,不存在所期望的消息。通过名字来匹配。例如,如果编译CompleteJavaBean.java
时有期望的编译器消息,则包含此类消息的文件应当命名为CompleteJavaBean.java.messages
。 - 如果所期望的值与实际值匹配,则测试通过;否则,失败。
如您所见,这是一个有很大不同但很有效的测试注释处理程序的方法:
- 每个编译器一个 JUnit 测试,而不是每个注释处理程序一个 JUnit 测试。
- 和每个用例有一个测试方法不同,我们有一个文本文件包含所期望的生成代码以及包含所期望编译器消息的可选文本文件。
- 测试不关心如何使用 javac 与 ECJ API。测试验证所生成代码是正确的。
验证所生成的代码
我 所描述的测试在验证注释处理程序生成所期望的代码过程中很有用。然而,还需要测试所生成代码真的完成了您所期望的任务。要验证所生成代码特性的正确性,需 要编写采用您的 AST 转换的 Java 类,然后编写测试来检查所生成代码的特性。要像代码是您所编写的那样进行测试。
编译并返回那些测试的最简单方法是采用 Ant,这意味着利用 javac 来编译。因为已经测试并了解了采用 ECJ 所生成代码是正确的,所以不必在 Eclipse 内部(这会使设置严重复杂化)运行这些测试。
我已在本文示例代码中(见 下载)包含了用于 javac 与 ECJ 注释处理程序的测试。
结束语
Project Lombok 是简化冗长 Java 代码的有效工具。它通过以不寻常的智能方法使用 Java 注释与编译 API 来实现这一目的。与其他工具一样,它并不完美。实现获益(代码简洁化)是要付代价的:Java 代码失去了其 WYSIWYG 风格,而且,开发者失去了一些喜爱的 IDE 功能。在向工具箱中增加 Lombok 之前一定要考虑好它的利弊,确定所得是否大于所失。
如 果决定采用 Lombok,那就可能会希望对其进行扩展,来生成自己的样板代码。目前,虽然扩展 Lombok 并不简单,但它是可行的。本文提供了一些关于扩展 Lombok 的指导,并描述了如何进行操作。花费时间与经历来进行 Lombok 扩展,还是手工创建样板代码,这两者那个更划算您自己决定。