实战:javac插入式注解处理器

简介: 一套编程语言中编译子系统的优劣,很大程度上决定了程序运行性能的好坏和编码效率的高低,尤其在Java语言中,运行期即时编译与虚拟机执行子系统非常紧密地互相依赖、配合运作。了解JDK如何编译和优化代码,有助于我们写出适合JDK自优化的程序。

一套编程语言中编译子系统的优劣,很大程度上决定了程序运行性能的好坏和编码效率的高低,尤其在Java语言中,运行期即时编译与虚拟机执行子系统非常紧密地互相依赖、配合运作。了解JDK如何编译和优化代码,有助于我们写出适合JDK自优化的程序。看过javac源码,我们就知道,当我们的编译器在把java文件编译为字节码的时候,会对java源程序做各方面的校验,在本文的实战中,我们将会使用注解处理器API来编写一款拥有自己编码风格的校验工具,为Javac编译器添加一个额外的功能,在编译程序时检查程序名是否符合上述对类(或接口)、方法、字段的命名要求。

前提

Java程序命名应当符合下列格式的书写规范。

  • 类(或接口):符合驼式命名法,首字母大写。
  • 方法:符合驼式命名法,首字母小写。
  • 字段: 类或实例变量:符合驼式命名法,首字母小写。常量:要求全部由大写字母或下划线构成,并且第一个字符不能是下划线。

代码实现

我们实现注解处理器的代码需要继承抽象类javax.annotation.processing.AbstractProcessor,这个抽象类中只有一个必须覆盖的abstract方法:“process()”,它是Javac编译器在执行注解处理器代码时要调用的过程,我们可以从这个方法的第一个参数“annotations”中获取到此注解处理器所要处理的注解集合,从第二个参数“roundEnv”中访问到当前这个Round中的语法树节点,每个语法树节点在这里表示为一个Element。

注解处理器除了process()方法及其参数之外,还有两个可以配合使用的Annotations:@SupportedAnnotationTypes和@SupportedSourceVersion,前者代表了这个注解处理器对哪些注解感兴趣,可以使用星号“*”作为通配符代表对所有的注解都感兴趣,后者指出这个注解处理器可以处理哪些版本的Java代码。

每一个注解处理器在运行的时候都是单例的,如果不需要改变或生成语法树的内容,process()方法就可以返回一个值为false的布尔值,通知编译器这个Round中的代码未发生变化,无须构造新的JavaCompiler实例,在这次实战的注解处理器中只对程序命名进行检查,不需要改变语法树的内容,因此process()方法的返回值都是false。

注解处理器NameCheckProcessor.java


package cn.tf.jvm.part10;

import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.TypeElement;
import java.util.Set;

// 可以用"*"表示支持所有Annotations
@SupportedAnnotationTypes("*")
// 只支持JDK 1.8的Java代码
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class NameCheckProcessor extends AbstractProcessor {

private NameChecker nameChecker;

/**
 * 初始化名称检查插件
 */
@Override
public void init(ProcessingEnvironment processingEnv) {
    super.init(processingEnv);
    nameChecker = new NameChecker(processingEnv);
}

/**
 * 对输入的语法树的各个节点进行进行名称检查
 */
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
    if (!roundEnv.processingOver()) {
        for (Element element : roundEnv.getRootElements())
            nameChecker.checkNames(element);
        }
        return false;
    }
}

从上面代码可以看出,NameCheckProcessor能处理基于JDK 1.8的源码,它不限于特定的注解,对任何代码都“感兴趣”,而在process()方法中是把当前Round中的每一个RootElement传递到一个名为NameChecker的检查器中执行名称检查逻辑。

然后来看NameChecker.java,它通过一个继承于javax.lang.model.util.ElementScanner6的NameCheckScanner类,以Visitor模式来完成对语法树的遍历,分别执行visitType()、visitVariable()和visitExecutable()方法来访问类、字段和方法,这3个visit方法对各自的命名规则做相应的检查,checkCamelCase()与checkAllCaps()方法则用于实现驼式命名法和全大写命名规则的检查。

package cn.tf.jvm.part10;

import javax.annotation.processing.Messager;
import javax.annotation.processing.ProcessingEnvironment;
import javax.lang.model.element.*;
import javax.lang.model.util.ElementScanner6;
import javax.lang.model.util.ElementScanner8;

import java.util.EnumSet;

import static javax.lang.model.element.ElementKind.*;
import static javax.lang.model.element.Modifier.*;
import static javax.tools.Diagnostic.Kind.WARNING;

/**
 * 程序名称规范的编译器插件:<br>
 * 如果程序命名不合规范,将会输出一个编译器的WARNING信息
 */
public class NameChecker {
private final Messager messager;

NameCheckScanner nameCheckScanner = new NameCheckScanner();

NameChecker(ProcessingEnvironment processsingEnv) {
this.messager = processsingEnv.getMessager();
}

/**
 * 对Java程序命名进行检查,根据《Java语言规范》第三版第6.8节的要求,Java程序命名应当符合下列格式:
 *
 * <ul>
 * <li>类或接口:符合驼式命名法,首字母大写。
 * <li>方法:符合驼式命名法,首字母小写。
 * <li>字段:
 * <ul>
 * <li>类、实例变量: 符合驼式命名法,首字母小写。
 * <li>常量: 要求全部大写。
 * </ul>
 * </ul>
 */
public void checkNames(Element element) {
     nameCheckScanner.scan(element);
}

/**
 * 名称检查器实现类,继承了JDK 1.6中新提供的ElementScanner6<br>
 * 将会以Visitor模式访问抽象语法树中的元素
 */
private class NameCheckScanner extends ElementScanner8<Void, Void> {

/**
 * 此方法用于检查Java类
 */
@Override
public Void visitType(TypeElement e, Void p) {
    scan(e.getTypeParameters(), p);
    checkCamelCase(e, true);
    super.visitType(e, p);
    return null;
}

/**
 * 检查方法命名是否合法
 */
@Override
public Void visitExecutable(ExecutableElement e, Void p) {
    if (e.getKind() == METHOD) {
        Name name = e.getSimpleName();
    if (name.contentEquals(e.getEnclosingElement().getSimpleName()))
        messager.printMessage(WARNING, "一个普通方法 “" + name + "”不应当与类名重复,避免与构造函数产生混淆", e);
        checkCamelCase(e, false);
    }
    super.visitExecutable(e, p);
    return null;
}

/**
 * 检查变量命名是否合法
 */
@Override
public Void visitVariable(VariableElement e, Void p) {
    // 如果这个Variable是枚举或常量,则按大写命名检查,否则按照驼式命名法规则检查
    if (e.getKind() == ENUM_CONSTANT || e.getConstantValue() != null || heuristicallyConstant(e))
        checkAllCaps(e);
    else
        checkCamelCase(e, false);
    return null;
}

/**
 * 判断一个变量是否是常量
 */
private boolean heuristicallyConstant(VariableElement e) {
    if (e.getEnclosingElement().getKind() == INTERFACE)
        return true;
    else if (e.getKind() == FIELD && e.getModifiers().containsAll(EnumSet.of(PUBLIC, STATIC, FINAL)))
        return true;
    else {
        return false;
    }
}

/**
 * 检查传入的Element是否符合驼式命名法,如果不符合,则输出警告信息
 */
private void checkCamelCase(Element e, boolean initialCaps) {
    String name = e.getSimpleName().toString();
    boolean previousUpper = false;
    boolean conventional = true;
    int firstCodePoint = name.codePointAt(0);
    
    if (Character.isUpperCase(firstCodePoint)) {
        previousUpper = true;
        if (!initialCaps) {
            messager.printMessage(WARNING, "名称“" + name + "”应当以小写字母开头", e);
            return;
        }
    } else if (Character.isLowerCase(firstCodePoint)) {
        if (initialCaps) {
            messager.printMessage(WARNING, "名称“" + name + "”应当以大写字母开头", e);
            return;
        }
    } else
        conventional = false;
    
    if (conventional) {
    int cp = firstCodePoint;
    for (int i = Character.charCount(cp); i < name.length(); i += Character.charCount(cp)) {
    cp = name.codePointAt(i);
    if (Character.isUpperCase(cp)) {
        if (previousUpper) {
            conventional = false;
            break;
        }
        previousUpper = true;
    } else
        previousUpper = false;
    }
}

if (!conventional)
    messager.printMessage(WARNING, "名称“" + name + "”应当符合驼式命名法(Camel Case Names)", e);
}

/**
 * 大写命名检查,要求第一个字母必须是大写的英文字母,其余部分可以是下划线或大写字母
 */
private void checkAllCaps(Element e) {
    String name = e.getSimpleName().toString();
    
    boolean conventional = true;
    int firstCodePoint = name.codePointAt(0);
    
    if (!Character.isUpperCase(firstCodePoint))
           conventional = false;
    else {
    boolean previousUnderscore = false;
    int cp = firstCodePoint;
    for (int i = Character.charCount(cp); i < name.length(); i += Character.charCount(cp)) {
    cp = name.codePointAt(i);
    if (cp == (int) '_') {
        if (previousUnderscore) {
            conventional = false;
            break;
        }
        previousUnderscore = true;
    } else {
        previousUnderscore = false;
    if (!Character.isUpperCase(cp) && !Character.isDigit(cp)) {
        conventional = false;
        break;
    }
    }
  }
}

if (!conventional)
    messager.printMessage(WARNING, "常量“" + name + "”应当全部以大写字母或下划线命名,并且以字母开头", e);
}
}
}

在BADLY_NAMED_CODE.java中包含多个不规范的命名,我们需要用到前面的两个类来校验下面这个文件是否符合要求。


    package cn.tf.jvm.part10;

    public class BADLY_NAMED_CODE {
        enum colors {
        red, blue, green;
    }

    static final int _FORTY_TWO = 66;

    public static int NOT_A_CONSTANT = _FORTY_TWO;

    protected void BADLY_NAMED_CODE() {
        return;
    }

    public void NOTcamelCASEmethodNAME() {
        return;
    }
    }

运行和测试

我们可以通过Javac命令的“-processor”参数来执行编译时需要附带的注解处理器,在相应的工程下src/java/mian目录下执行以下命令编译

 javac -encoding UTF-8 cn/tf/jvm/part10/NameChecker.java
 javac -encoding UTF-8 cn/tf/jvm/part10/NameCheckProcessor.java

最后使用编译好的文件进行使用

 javac -processor cn.tf.jvm.part10.NameCheckProcessor cn/tf/jvm/part10/BADLY_NAMED_CODE.java

执行结果如下:

cn\tf\jvm\part10\BADLY_NAMED_CODE.java:3: 警告: 名称“BADLY_NAMED_CODE”应当符合驼式命名法(Camel Case Names)
public class BADLY_NAMED_CODE {
   ^
cn\tf\jvm\part10\BADLY_NAMED_CODE.java:4: 警告: 名称“colors”应当以大写字母开头
enum colors {
^
cn\tf\jvm\part10\BADLY_NAMED_CODE.java:5: 警告: 常量“red”应当全部以大写字母或下划线命名,并且以字母开头
red, blue, green;
^
cn\tf\jvm\part10\BADLY_NAMED_CODE.java:5: 警告: 常量“blue”应当全部以大写字母或下划线命名,并且以字母开头
red, blue, green;
 ^
cn\tf\jvm\part10\BADLY_NAMED_CODE.java:5: 警告: 常量“green”应当全部以大写字母或下划线命名,并且以字母开头
red, blue, green;
   ^
cn\tf\jvm\part10\BADLY_NAMED_CODE.java:8: 警告: 常量“_FORTY_TWO”应当全部以大写字母或下划线命名,并且以字母开头
static final int _FORTY_TWO = 66;
 ^
cn\tf\jvm\part10\BADLY_NAMED_CODE.java:10: 警告: 名称“NOT_A_CONSTANT”应当以小写字母开头
public static int NOT_A_CONSTANT = _FORTY_TWO;
  ^
cn\tf\jvm\part10\BADLY_NAMED_CODE.java:12: 警告: 一个普通方法 “BADLY_NAMED_CODE”不应当与类名重复,避免与构造函数产生混淆
protected void BADLY_NAMED_CODE() {
   ^
cn\tf\jvm\part10\BADLY_NAMED_CODE.java:12: 警告: 名称“BADLY_NAMED_CODE”应当以小写字母开头
protected void BADLY_NAMED_CODE() {
   ^
cn\tf\jvm\part10\BADLY_NAMED_CODE.java:16: 警告: 名称“NOTcamelCASEmethodNAME”应当以小写字母开头
public void NOTcamelCASEmethodNAME() {
^
10 个警告

扩展

Javac编译动作的入口是com.sun.tools.javac.main.JavaCompiler类,javac编译入口,重要类源码如下:

public void compile(List<JavaFileObject> sourceFileObjects,
                    List<String> classnames,
                    Iterable<? extends Processor> processors)
    throws IOException // TODO: temp, from JavacProcessingEnvironment
{
    if (processors != null && processors.iterator().hasNext())
        explicitAnnotationProcessingRequested = true;
    // as a JavaCompiler can only be used once, throw an exception if
    // it has been used before.
    if (hasBeenUsed)
        throw new AssertionError("attempt to reuse JavaCompiler");
    hasBeenUsed = true;

    start_msec = now();
    try {
        /**
         * 插入注解处理
         */
        initProcessAnnotations(processors);

        /**
         * 词法分析、语法分析
         * parseFiles(sourceFileObjects) 分析源码。获取语法树JCCompilationUnit 集合
         * 
         * 填充符号表
         * enterTrees() 抽象语法树的顶局节点都先被放到待处理列表中并逐个处理列表中的节点。
         * 所有的类符号被输入到外围作用域的符号表中确定类的参数(对泛型类型而言)、超类型和接口
         * 如果需要添加默认构造器,将类中出现的符号输入到类自身的符号表中。
         */
        // These method calls must be chained to avoid memory leaks
        delegateCompiler =
            processAnnotations(
                enterTrees(stopIfError(CompileState.PARSE, parseFiles(sourceFileObjects))),
                classnames);
        
        /**
         * 语义分析
         */
        delegateCompiler.compile2();
        delegateCompiler.close();
        elapsed_msec = delegateCompiler.elapsed_msec;
    } catch (Abort ex) {
        if (devVerbose)
            ex.printStackTrace();
    } finally {
        if (procEnvImpl != null)
            procEnvImpl.close();
    }
}

总结:NameCheckProcessor的实战例子只演示了JSR-269嵌入式注解处理器API中的一部分功能,基于这组API支持的项目还有用于校验Hibernate标签使用正确性的本质上与NameCheckProcessor所做的事情差不多)、自动为字段生成getter和setter方法的Lombok等。

参考资料:《深入理解Java虚拟机2》

目录
相关文章
|
1月前
|
搜索推荐 Java 编译器
Javac 编译自定义注解及分析 Lombok 的注解实现
Javac 编译自定义注解及分析 Lombok 的注解实现
47 0
|
Kotlin
kotlin 中使用 注解处理器的坑
kotlin 中使用 注解处理器的坑
kotlin 中使用 注解处理器的坑
|
Java 编译器 API
注解处理器是干嘛的
注解处理器是干嘛的
|
Java Maven Spring
SpringBoot中的自定义注解开发与注解处理器
使用AnnotatedElement接口中的方法提取注解中的数据,像Class/Constructor/Field/Method/Package这些类都实现了AnnotatedElement接口 注:只有当定义Annotation时使用了@Retention(RetentionPolicy.RUNTIME)修饰, JVM才会在装载class文件时提取保存在class文件中的Annotation,该Annotation才会在运行时可见,这样我们才能够解析
|
Java 编译器 API
Java 注解处理器及其应用
前言 注解作为一种元数据,需要其他地方进行读取,在前面的文章 重识 Java 注解 中我们了解到,在运行时可以通过反射获取注解信息。元注解 @Retention 定义了注解的保留策略,具体有 SOURCE、CLASS、RUNTIME,那么保留策略不为运行时的注解有什么用呢?
312 0
Java 注解处理器及其应用
|
搜索推荐 Java 编译器
Javac编译自定义注解及分析Lombok的注解实现
在上一篇中,我留下了几个疑问,我们使用lombok的注解时,为什么加了个注解就可以帮我们自动生成代码呢?是谁给我们做了这件事情呢?它的原理是什么样的呢?
783 0
Javac编译自定义注解及分析Lombok的注解实现
【Groovy】编译时元编程 ( 编译时处理 ASTTransformation 接口实现 | 配置 ASTTransformation )
【Groovy】编译时元编程 ( 编译时处理 ASTTransformation 接口实现 | 配置 ASTTransformation )
173 0
【Groovy】编译时元编程 ( 编译时处理 ASTTransformation 接口实现 | 配置 ASTTransformation )
|
Java Android开发
【Android APT】注解处理器 ( 根据注解生成 Java 代码 )
【Android APT】注解处理器 ( 根据注解生成 Java 代码 )
136 0
|
Java Android开发
【Android APT】注解处理器 ( 配置注解依赖、支持的注解类型、Java 版本支持 )
【Android APT】注解处理器 ( 配置注解依赖、支持的注解类型、Java 版本支持 )
121 0
|
Android开发
【Android APT】编译时技术 ( 编译时注解 和 注解处理器 依赖库 )(二)
【Android APT】编译时技术 ( 编译时注解 和 注解处理器 依赖库 )(二)
103 0
【Android APT】编译时技术 ( 编译时注解 和 注解处理器 依赖库 )(二)