引言
在上一篇中引入了 Annotation Processing 101 这篇博客的全部内容,但是考虑到有些小伙伴的英文有些吃力,加上这篇博客的质量确实非常不错,所以还是把它翻译出来了。
简介
在本文的开头我将解释如何写一个注解解释器。如下是我的教程。首先,我将解释什么是注解解释,以及你利用这个强大的工具能够做什么。之后我们会一步步实现一个简单的注解解释器。
基础
在开头需要澄清一件很重要的事:我们要讨论的不是在运行时通过反射获取注解的值。相反,注解解释发生在编译时。
注解解释是一个为了扫描和处理注解而内建在javac中的一个编译工具。你可以为特定的注解注册你自己的注解解释器。在这里我假设你已经知道什么是注解以及如何声明一个注解类型。如果你对于注解不熟悉,你可以在官方文档 找到更多的信息。注解解释从Java 5引入,但是直到Java 6才有可用的API(在2006年12月发布)。当然,它也确实花了些时间让java世界意识到它的强大之处,所以注解真正流行也就是最近几年的事。
为某些特定注解的解释器将java代码(或者编译好的字节码)作为输入,并且产生文件(通常是.java文件)作为输出。
这意味着什么呢?
意味着你可以生成java代码!
生成的java代码在一个单独的.java文件中。所以你不能操作已有的代码,比如为类增加一个方法。生成的java代码会像其他手写的java源文件一样被javac编译。
AbstractProcessor
让我们来看一下注解解释器的方法。每个注解解释器都是继承自AbstractProcessor,如下:
|
|
- init(ProcessingEnvironment env): 每个注解解释器必须有一个空的构造方法。带有ProcessingEnvironment参数的init()方法会被注解解释工具调用。其中ProcessingEnvironemt提供一些有用的工具类,如Elements, Types和Filer. 后面我们会使用到它们。
- process(Set<? extends TypeElement>annotations, RoundEnvironment env): 这相当于解释器的main()方法了。在这里你可以写上你的代码,用于扫描,求值和处理注解并且产生java文件。使用传入的RoundEnvironment参数,你可以查询注解标注的元素,我们在后面会谈到。
- getSupportedAnnotationTypes(): 在这里你必须声明当前注解解释器需要注册的注解。注意到返回类型是字符串集合,包含了你所有注解类型的全名。换句话说,你在这里定义注解解释器需要处理的所有注解类型。
- getSupportedSourceVersion(): 用于标识你使用的java版本。通常返回SourceVersion.latestSupported(). 但是,如果你由于某些原因只想使用Java 6,你也可以返回SourceVersion.RELEASE_6这样的值。我建议使用SourceVersion.latestSupported();
在Java 7中你可以使用注解来代替重写getSupportedAnnotationTypes()和getSupportedSourceVersion(),如下所示:
|
|
不过,考虑到兼容性,我建议重写getSupportedAnnotationTypes()和getSupportedSourceVersion()而不是使用@SupportedAnnotationTypes和@SupportedSourceVersion这两个注解。
另外一件你必须知道的事情是注解解释器在它自己的jvm上运行。
你没看错! javac开启了一个完全独立的java虚拟机以运行注解解释器。
那这意味着什么呢?
意味着你可以使用任何你会在其他java应用中使用的东西。 比如使用guava! 如果你想,你可以使用类似dagger这样的依赖注入工具或者任意其他你想使用的库。即使是一个很小的注解解释器,你也应该关心算法复杂度以及设计模式,就像你在其他java应用中那样。
注册你的解释器
你可能会问”我如何向javac注册我的解释器?”。答案就是你必须提供一个.jar文件,就像其他你打包的jar文件一样,你将注解解释器打包在那个jar文件中。 此外,你还必须在META-INF/service目录下创建一个名为javax.annotation.processing.Processor的文件。所以你的jar文件结构如下:
|
|
javax.annotation.processing.Processor文件的内容是所有注解解释器的完整路径名,如下所示:
|
|
有了MyProcessor.jar之后,javac就会在编译时自动检测和读取到javax.annotation.processing.Processor文件并且注册MyProcessor.
示例: 工厂模式
是时候展现一个完整的示例了。我们会使用maven作为我们的编译系统和依赖管理工具。即使你对maven不熟也不要紧,因为它并非必须的。完整的代码可以在github上找到。
首先,我必须说,找到一个对于教程来说足够简单并且使用解释器就能解决的问题并不容易。这里我们会实现一个非常简单的工厂模式(不是抽象工厂模式)。它只是为你提供注解解释过程的一个简单介绍。所以问题的阐述可能会有点无聊,而且并不是真实工程中遇到的一个问题。
再一次声明,你要学的是注解解释过程而不是设计模式。
如下为问题描述:我们想实现一个pizza店,这个pizza店给顾客们提供两种pizza(分别是Margherita和Calzone)和Tiramisu甜点。
如下代码片段,不解释:
|
|
为了在Pizza店预订,顾客必须输入餐名:
|
|
正如你所看到的,在order()方法中有很多的语句,并且无论何时我们如果想要添加一种新pizza,都必须增加一个新语句。但是,桥豆麻袋,使用注解解释和工厂模式的话,我们就可以让注解解释器产生如下的if语句。 我们想要的语句类似下面这样:
|
|
而MealFactory应该像这样:
|
|
@Factory注解
我们想要通过注解解释器来生成MealFactory这个类。更广泛地说,我们想要提供一个注解和一个解释器来生成工厂类。
首先看一下@Factory这个注解的定义:
|
|
这个定义的含义是用@Factory修饰的类应该提供type()和id()的值,比如我们在CalzonePizza类中进行如下的映射:
|
|
|
|
|
|
你可能会怀疑我们是否能够直接应用@Factory这个注解在Meal接口上。
实际上,注解并非继承,对于类X添加注解并不意味着X的子类Y就含有该注解。
在开始写注解解释器之前,我们必须明确如下规则:
1.只有类才可以被@Factory注解,因为接口或抽象类不能通过new创建实例;
2.带有@Factory注解的类必须提供至少一个公共的默认构造方法(即无参构造方法)。否则我们不能创建实例。
3.带有@Factory注解的类必须直接或间接地继承特定类(或者实现特定接口);
4.带有相同类型的@Factory注解按类聚合在一起,从而构成一个工厂类。产生的类名以”Factory”结尾,比如type=Meal.class会产生MealFactory这个类
5.id只能是字符串并且唯一
注解解释器
我会通过增加代码的方式逐步教你如何写一个解释器。省略号(…)代表那部分代码省略了,它意味着这部分代码要么在前面讨论过,要么会在后面讲解。目标是使代码片段可读性更好。正如前面提到的,完整的代码可以在github 上找到。
好了,让我们先看一下FactoryProcessor的整体架构吧:
|
|
在第一行中你会看到@AutoService(Processor.class).
那这是什么呢?
它其实是来自另外一个注解解释器的注解。
AutoService注解解释器是由Google开发的并且生成了META-INF/services/javax.annotation.processing.Processor文件 。
你没看错! 我们可以在一个注解解释器中使用其他注解解释器的注解。很方便是吧!
在getSupportedAnnotationTypes()方法中我们说过@Factory注解被当前解释器处理。
Elements和TypeMirrors
在init()方法中我们获取到了如下对象的引用:
- Elements: 一个与Element类协作的工具类
- Types: 一个与TypeMirror协作的工具类
- Filer: 正如其名字所说的,使用Filer你可以创建文件
在一个注解处理中我们会扫描java源文件。源代码的每个部分都是Element的某种类型。
换句话说:Element代表了一个编程元素,如包,类和方法。每个元素代表一个静态的,语言层面的概念。在如下示例中,我添加了相应的注释:
|
|
你必须改变你看待源代码的方式:把它看成结构化的text,而不是可执行的代码。你可以把它想象成一个你尝试解析的XML文件(或者是编译原理中的抽象语法树)。正如XML解析器中有些带有元素的DOM一样。你可以从Element出发导航到它的父元素或子元素。
例如,你有一个TypeElement代表public class Foo, 你可以按如下方法迭代它的子元素:
|
|
正如你所看到的那样,Element代表了源码。TypeElement代表了源码中的类型元素如类。但是,TypeElement并不包含类本身的信息。从TypeElement中你可以获取到类名,但是你无法获取到类的其他信息,例如它的父类信息。 这类信息可通过TypeMirror来获取。你可以通过调用element.asType()来获取到一个Element的TypeMirror.
搜索@Factory
下面让我们逐步来实现process()方法。首先我们从搜索@Factory注解的类开始:
|
|
上面的代码都很简单。其中roundEnv.getElementsAnnotatedWith(Factory.class)返回@Factory注解过的Element链表. 你可能已经注意到我特意避免说”返回@Factory注解过的类链表”,因为它实际上就是返回Element链表。记住:Element可以是一个类,方法,变量或其他元素。所以下一步我们要做的就是检查Element是否为类:
|
|
这段代码的作用是什么呢?
是为了确保只有类型为class的Element才会被解释器处理。前面我们已经学习了类是TypeElements.
那么我们为什么不直接使用 if(!(annotatedElement instanceof TypeElement)) 来判断呢?
因为接口也是TypeElement呀! 所以在注解处理中你应该避免使用instanceof,宁愿配合TypeMirror使用ElementKind或者TypeKind.
错误处理
在init()中我们获取了Messager的引用。 Messager提供了注解解释器报告错误信息,警告和其他通知的方法。它并不是一个给你的Logger, 虽然它确实可以在开发过程中充当这个作用。Messager是用于写入消息到使用你的注解解释器的第三方开发者的。在官方文档中有不同层次的信息描述。其中很重要的一个是Kind.ERROR, 因为这类消息是用于标示我们的解释器处理失败。 或许第三方开发者错误使用了我们的@Factory注解(比如注解在一个接口上). 这个概念与传统的java开发中抛出异常不一样。
如果你在process()中抛出一个异常,那么运行注解解释的jvm就会crash,从而导致使用我们的FactoryProcessor的第三方开发者会从javac得到一个难以理解的异常,因为它包含了FactoryProcessor的堆栈信息。
因此注解解释器需要Messager这个类。它会打印出优美的错误信息。此外,你可以导致这个错误的element. 在现代IDE(如Intellij)中第三方开发者可以点击错误信息然后IDE会跳转到相应的源文件中出错的地方。
回到process()的实现中,如果用户对于非class元素使用了@Factory注解:
|
|
为了获得Messager展示的信息,注解解释器必须保证不在crash状态下完成任务。 这就是为什么我们在调用error()之后返回。如果我们不在这里返回,process()方法会继续运行,因为messager.printMessage(Diagnostic.Kind.ERROR)并没有停止这个进程。 在这种情况下,就很容易导致空指针异常。
综上,如果在process()中有一个未处理的异常,javac会打印内部的空指针异常堆栈,而不是你在Messager中的错误信息。
数据模型
在我们继续检查@Factory注解的类是否遵守我们的5条原则之前,先理清一下其中涉及的数据结构。 有时问题或解释器看起来很简单,以至于程序员倾向于将整个处理器写在一个方法(procedural manner)中.
但是你知道吗?
一个注解解释器也是一个java应用。所以要使用OOP, 接口,设计模式以及任何你会在在其他java应用中用到的东西。
我们的FactoryProcessor确实相当简单,但是有些信息我们想存储为对象。 在FactoryAnnotatedClass中存储注解类数据信息,比如完整的类名,以及@Factory注解本身的数据。 从而我们存储TypeElement并且求解@Factory注解的值:
|
|
上面的代码很多,但是最重要的是在构造方法中的如下代码:
|
|
这里我们获取@Factory注解的值,并且检查id是否为空。 如果id为空,则抛出一个IllegalArgumentException. 你可能有点糊涂,因为我们之前说了需要使用Messager而不是抛出异常。 但是这里我们在内部抛出一个异常,同时在process()中捕获它,在后面会看到。 这样做的原因有两个:
- 我想展示你应该像在其他的java应用中那样编码。 而抛出异常和捕获异常在java中是较好的实践;
- 如果我们想从FactoryAnnotatedClass中打印出消息,我们必须传递Messager, 正如在前面的错误处理中已经提到的那样,解释器就不得不结束以使Messager打印出错误消息。所以如果我们会通过Messager写错误信息,那么我们如何”通知” process()错误已经发生了呢? 最简单和直观的方法是抛出异常并且让process()捕获。
下一步我们想获取@Factory注解的类型值。我们感兴趣的是类的完整名。
|
|
这里有点棘手,因为类型是java.lang.Class. 也就意味着,这是一个真实的类对象。 由于注解解释运行在编译之前,所以我们必须考虑到如下两种情况:
- 类已经编译过了: 当一个第三方的.jar包中包含了带有@Factory注解的.class文件。在这种情况下,我们可以直接访问这个类,就像我们在try块中做得那样;
- 类还没被编译: 这种情况对应的是当我们尝试去编译我们带有@Factory注解的源码时。此时尝试直接访问类会抛出一个MirroredTypeException. 幸运的是MirroredTypeException包含了一个TypeMirror, 它代表着我们未编译的类。由于我们知道它的类型必定是类(我们在前面已经检查过了),从而可以将它强转为DeclaredType,并且访问TypeElement以读取完整的类名。
好了,现在我们需要另外一个名为FactoryGroupedClasses的数据结构,它基本上将FactoryAnnotatedClasses聚合到了一起。
|
|
如你所见,这个类中的重点就是Map. 这个Map用于映射一个@Factory.id() 到FactoryAnnotatedClass. 我们选择Map的原因是想要保证每个id都是唯一的。调用generateCode()的目的是产生Factory代码, 这个会在后面讨论。
匹配规则
下面我们继续实现process()方法。下一步我们想检查被注解的类是否至少有一个public构造方法,并且不是抽象类,继承某个类型并且是一个公共类:
|
|
从上面的代码可知,我们增加了一个isValidClass()方法,它检查我们的规则是否已经编译:
- 类必须是public的: classElement.getModifiers().contains(Modifier.PUBLIC)
- 类不能是抽象的: classElement.getModifiers().contains(Modifier.ABSTRACT)
- 类必须是子类或者是在@Factory.type()中声明的类。首先我们使用elementUtils.getTypeElement(item.getQualifiedFactoryGroupName())来创建传入的类(@Factory.type())的Element. 在获取类的全限定名后,你可以创建TypeElement(通过TypeMirror) 。下一步我们检查它是接口还是类: superClassElement.getKind() == ElementKind.INTERFACE. 所以这里就有两种情况:如果它是一个接口,那么就是classElement.getInterfaces().contains(superClassElement.asType()); 如果是一个类,那么我们就必须通过调用currentClass.getSuperClass()扫描继承关系。注意到这个检查也可通过typeUtils.isSubtype()完成;
- 类必须有一个公共的空构造方法:所以我们通过classElement.getEnclosedElements()迭代所有封装的元素,并且检查ElementKind.CONSTRUCTOR, Modifier.PUBLIC和constructorElement.getParameters().size()==0;
如果以上条件都满足,那么isValidClass()返回true, 反之它打印出相应的错误信息并且返回false.
聚合所有被注解的类
在前面我们已经过了isValidClass()检查,后面会增加与FactoryGroupedClasses相对应的FactoryAnnotatedClass:
|
|
代码生成
我们已经收集了所有被@Factory注解过的类,并且保存在了FactoryAnnotatedClass中,而且分类成FactoryGroupedClasses. 现在我们将要为每个工厂类生成java文件:
|
|
写一个java文件与我们在java中写其他的文件是类似的。我们使用Filer提供的Writer来完成。我们可以像连接字符串一样写我们的生成代码。幸运的是,以很多有趣的开源库闻名的Square公司给我们提供了JavaWriter 这个神器,利用它可以很方便地生成java代码:
|
|
小贴士:由于JavaWriter非常流利,导致有很多的解释器,库和工具都依赖于JavaWriter. 如果你想使用类似maven或gradle这样的依赖管理工具,并且某个库依赖于更新版本的JavaWriter的话,可能会出现问题。因此,我建议直接复制和重新打包JavaWriter到你的注解解释器代码中。
更新: 使用 JavaPoet 替换JavaWriter.
处理轮数
注解解释可能需要花费不止一轮。 官方的java文档定义的解释如下:
注解处理发生在一系列轮次中。在每轮中,一个解释器可能会被要求解释源码中注解的一个子集,并且类文件的产生是在优先的轮次中。输入到首轮的解释是工具运行的初始输入; 这些初始输入可被视为第0轮。
一个更简单的定义: 一个解释轮次就调用一个注解处理器的process()方法。 以我们的工厂为例:FactoryProcessor被初始化一次(新的解释器不会在每轮中都创建),但是process()方法可被调用多次,如果新的源文件被创建的话。
这听起来有点奇怪,是不是?
原因就在于,产生的源码文件也可能会包含@Factory注解类,而此时它也可被FactoryProcess解释。
举个栗子,PizzaStore示例中会有三轮解释:
Round | Input | Output |
---|---|---|
1 | CalzonePizza.java Tiramisu.javaMargheritaPizza.javaMeal.javaPizzaStore.java | MealFactory.java |
2 | MealFactory.java | — none — |
3 | — none — | — none — |
另外一个我解释解释轮次的原因在于,如果你查看FactoryProcessor的源码,你就会发现我们收集数据并且将它们保存在私有域Map factoryClasses中。在首轮中我们检查了MagheritaPizza, CalzonePizza和Tiramisu, 之后产生文件MealFactory.java. 第二轮中我们将MealFactory作为输入。由于在MealFactory中没有了@Factory注解,从而不再收集到数据,也不会有错误产生。
然后事实打脸了:
Attempt to recreate a file for type com.hannesdorfmann.annotationprocessing101.factory.MealFactory
这个问题产生的原因在于我们从来没有清除factoryClasses. 这意味着,在第二轮的process()中还保存有第一轮的数据,从而产生与第一轮一样的java文件 。在这种情况下,我们知道只会在第一轮检测@Factory注解过的类,因此我们采用如下方法可以很简单地解决这个bug:
|
|
这里当然还有其他的处理方式,比如设置一个boolean标志等。不过重点在于:记住注解解释会被多次调用,并且不能重写或者重新创建生成的代码文件。
解释器和注解的分离
如果你已经看过我们这个工厂模式的解释器的git repo 的话,你就会看到我们将repo分为两个maven module. 这样做的目的是想给大家展示这样一种可能性:在项目中只编译注解,而注解解释器module只是为了编译服务(即不包含在编译后的代码中)。
是不是有点蒙了?
也就是说如果我们只有一个artifact(maven依赖要用到的),另外一个想使用我们解释器的开发者就会既包含@Factory注解,也需要包含FactoryProcessor的代码。
而实际上开发者并不希望包含FactoryProcessor的代码。
你或许听过Android开发中65K方法数限制。如果我们在FactoryProcessor中使用了guava,并且只提供一个包含注解和解释器代码的artifact, 那么Android apk就不仅包含FactoryProcessor代码,还会包含guava代码,而guava本身就有20,000个方法。因此注解和解释器的分离很有必要。
生成的类的实例化
正如你在PizzaStore示例中所看到的那样,生成的类MealFactory是一个普通的java类,正如其他手写的一样。此外,你必须通过手写来将它实例化:
|
|
如果你是一个Android开发者,你应该对一个著名的注解处理器很熟悉——ButterKnife. 在ButterKnife中你通过@InjectView来注解Android views. ButterKnifeProcessor产生一个称为MyActivityViewInjector()的类,然后你可以使用ButterKnife.inject(activity). ButterKnife内部使用反射来实例化MyActivity$$ViewInjector():
|
|
但是反射是否太慢从而带来性能问题呢?我们是否能够通过注解处理来避免这个问题呢?
答案是肯定的,反射确实带来了性能问题。 但是,它加速了开发过程,因为开发者不必手动来实例化。 ButterKnife使用一个HashMap来缓存实例化的对象。 所以MyActivityViewInjector可从HashMap中获取相应的实例。
FragmentArgs 和ButterKnife的工作原理类似。 它使用反射来实例化开发者本来需要手写的部分。当进行注解处理时,FragmentArgs产生一个特殊的”查找“类,类似HashMap. 所以整个FragmentArgs库只在第一次执行一次反射以生成这个特殊的类似HashMap的对象。一旦这个对象通过Class.forName()产生了,fragment参数注入就运行在native的java代码中。
总之,在反射和实用中找到一个折衷,这取决于开发者。
结论
看到这里,希望你对于注解解释有一个深入的理解了。必须再次强调的是:注解解释是一个非常强大的工具,可以帮助减少手写模板代码。同时也希望你能够借助注解解释器实现比我这个简单示例更复杂的东西,比如,泛型的类型擦除,因为注解解释发生在类型擦除之前。
正如你已经知道的,在写注解解释器时,你需要解决两个常见的问题:第一个是如果你想要在其他类中使用ElementUtils, TypeUtils和Messager,你就必须将它们作为参数传入。在 AnnotatedAdapter (我的其中一个android注解解释器)中,我尝试通过Dagger来解决这个问题。对于这样简单的一个解释器,这样好像有点小题大做了,但实际上最终这个问题得到了很好的解决。
第二件事就是你必须请求Elements的信息。正如我前面说过的,通过Elements来处理可被视为解析XML或者HTML. 对于HTML你可以使用jQuery. 如果在注解解释器中有类似jQuery的一些东西会很方便。如果你知道一些相关的库,请在下方评论中告诉我。
请注意FactoryProcessor的部分代码是有限制和缺陷的。这些”错误”我前面已经明确指出了(比如重复创建一个文件)。如果你开始基于FactoryProcessor写你自己的注解解释器,请不要重蹈覆辙。你应该在一开始就尽力避免这样的问题。
在未来的博客中,我将会写注解解释器的单元测试相关的东西。不过,我的下一篇博客会是关于android中软件架构的。请继续关注。
更新
我在droidcon Germany 2015中作了一个注解解释器的报告,可以在 youtube 上观看当时的视频。