Java注解的值能支持从文件动态读取吗?

问题背景

最近遇到一个场景:有一些场景想在注解上使用变量,方便后续可以动态通过配置更新,而不需要重新编译java文件,如:

@ExtractInterface(abilityId = "${abilityId}") // ${abilityId} 能否是一个变量?
public class Multiplier {
}
注解

注解是java很常用的一个特性,在JDK和各大框架中都可以看见注解的妙用。注解可以看成接口的语法糖,找个AbilityContext接口的字节码通过 javap 查看如下,很明显看出来是一个继承 Annotation 接口的接口:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ExtractInterface {
    String abilityId() default "";
}
public interface ch20.annotations.apt.ExtractInterface extends java.lang.annotation.Annotation {
  public abstract java.lang.String abilityId();
}

<Thinking in Java> 中也有个定义:

注解(也被成为元数据)为我们在代码中添加信息提供了一种形式化的方法,使我们可以在稍后某个时刻非常方便地使用这些数据。

注解常用的有以下几类:

  • 内置的注解:JDK自带的几个,写代码的时候可以直接使用

    • Override
    • Deprecated
    • SuppressWarnings
    • FunctionalInterface
    • SafeVarargs
  • 元注解:即用于定义注解的注解,用于自定义注解

    • @Retention:表示需要在什么级别保存该注释信息,即被描述的注解在什么范围内有效:
      • SOURCE:源文件可以拿到
      • CLASS:字节码可以拿到
      • RUNTIME:运行时可以拿到
    • @Target:表示注解修饰的对象范围,即指明定义的注解一般可以用在什么地方,常用的有下面这几类:
      • CONSTRUCTOR
      • FIELD
      • LOCAL_VARIABLE:局部变量
      • METHOD
      • PACKAGE
      • PARAMETER
      • TYPE:用于描述类、接口(包括注解类型) 或enum声明
    • @Inherited:表示该注解可以被继承
    • @Documented
  • 自定义注解:按照自己的需要使用元注解自定义注解

我们的需求背景就是自定义注解相关,比如下面这个示例,

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ExtractInterface {
    String abilityId() default "";
}

我们一般像下方这样使用:

@ExtractInterface(abilityId = "123456") // 这里指定abilityId
public class Multiplier {
}

那问题就是这里 123456 可以指定一个变量吗?

问题分析

原生机制
@ExtractInterface(abilityId = var) // 这里使用变量会报错
public class Multiplier {
}

所以很清楚默认是不支持的,直接给出结论:

结论:注解的那个值不能是一个变量,要求必须是一个编译时常量,可以看下 stack overflow上的讨论

那什么是编译时常量:https://docs.oracle.com/javase/specs/jls/se7/html/jls-15.html#jls-15.28

A compile-time constant expression is an expression denoting a value of primitive type or a String that does not complete abruptly and is composed using only the following:

  • Literals of primitive type and literals of type String
  • Casts to primitive types and casts to type String
  • […] operators […]
  • Parenthesized expressions whose contained expression is a constant expression.
  • Simple names that refer to constant variables.
  • Qualified names of the form TypeName . Identifier that refer to constant variables.
其它机制

原生的不支持,就想了能不能另辟蹊径,对于java的流程,从源码到最终在JVM里执行,流程如下:

Java代码
解析和填充符号表
处理注解
分析和字节码生成
字节码文件

所以我们只需要在这个流程中动态替换成配置文件中的变量即可,那可能有哪些方法呢?

正则动态修改源码

根据问题背景,我们知道主要的使用场景是后期维护的时候动态更改配置,然后让这个配置动态生效,那这个时机其实给了我们改源码的一次机会,比如最简单的方式:正则替换,把需要替换的地方使用一个占坑符先占着,然后把从配置文件读取的值替换掉,再覆盖掉原来的文件即可

这样在执行 javac 之前文件源文件已经替换成新的变量值了,后续就正常处理就行了,但是这种机制明显不适合上线,最多自己没事玩玩

APT

在注解上面,还有一种比较高级的技术:APT(Annotation Processing Tool),即注解处理工具。

在解释APT如何操作之前,需要先了解下注解的两种不同方式:

  • 运行时注解:经常的场景是 Retention 定义成 Runtime,在运行时常通过反射的方式来使用注解
  • 编译时注解:而此注解是程序在编译期间通过注解处理器处理的

而这个解析编译时注解就是APT要做的事情,即不需要通过反射的方式即可操作注解,因为是在编译期间就操作了注解,所以对性能不会有影响。

具体的APT技术的详解,可以google一下,目前这个技术应用的产品还蛮多的。这里还需要了解下ATP是递归查询解析的,下面这个流程:

Y
N
Start
扫描注解
处理注解
生成文件
End

对于我们这个场景其实有两个处理方式:

  • 根据APT在编译期间替换注解变量生成新的源文件
  • 根据APT在编译字节码的时候解析这个注解的变量
APT 示例

下面给一个APT的实例,这个示例只是一个思路:

// 自定义一个注解
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME) // 这里定义成RUNTIME是因为这个注解在运行时需要使用,及时是RUNTIME注解我们也能用APT去处理
public @interface ExtractInterface {
    String abilityId() default "";
}
@ExtractInterface(abilityId = "${abilityId}")
public class Multiplier {
}

下面需要在编译器处理上述注解,所以需要定义一个APT,APT核心是继承 AbstractProcessor,示例如下:

/**
 * 继承AbstractProcessor,实现一个ATP
 *
 * @since 2020-08-15
 */
@SupportedSourceVersion(SourceVersion.RELEASE_8)
@SupportedAnnotationTypes("ch20.annotations.apt.ExtractInterface")
public class InterfaceExtractorProcessor extends AbstractProcessor {
    private int count = 0;// 这里是展示会执行多少次,因为编译时注解是递归解析执行,直到没有新的文件生成

    private Filer filer; // 用于操作文件
    private Messager messager; // 用于打印消息日志

    @Override
    public synchronized void init(ProcessingEnvironment processingEnv) {
        super.init(processingEnv);
        filer = processingEnv.getFiler(); // 从运行上下文获取Filer
        messager = processingEnv.getMessager(); // 从运行上下文获取打印句柄
    }
    
    /**
     * 核心处理类, 在这里一般可以操作两种场景:
     * 1. 重新生成一个新的类,然后替换原有的类,新的类里是我们动态生成的源码
     * 2. 操作抽象语法树,对字节码进行操作等等
     */
    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        messager.printMessage(Diagnostic.Kind.NOTE, "开始进入process方法 "+ count++);
        
        System.out.println();
        // 获得所有类
        Set<? extends Element> rootElements = roundEnv.getRootElements();
        System.out.println("all class:");

        for (Element rootElement : rootElements) {
            System.out.println(rootElement.getSimpleName()); // 打印类名
        }
        String output = "";

        // 获得有注解的元素, 这里 ExtractInterface 只能修饰类
        Set<? extends Element> elementsAnnotatedWith = roundEnv.getElementsAnnotatedWith(ExtractInterface.class);
        System.out.println("annotated class:");
        for (Element element : elementsAnnotatedWith) {
            String className = element.getSimpleName().toString(); // 也就是 Multiplier 类
            System.out.println("  " + className);

            output = element.getAnnotation(ExtractInterface.class).abilityId(); // 获取变量的占位符 ${abilityId}
            updateFile(output);
        }

        System.out.println(readProperties("abilityId"));
        return true;
    }

    /**
     * 加载classpath里的properties文件,在这里承载可配的变量值
     */
    private String readProperties(String key) {
        ResourceBundle resourceBundle = ResourceBundle.getBundle("annotations/application");
        return resourceBundle.getString(key);
    }

    // 这里可以替换对应的变量值,然后生成对应的文件
    private String replaceKey(String source, String key){
        messager.printMessage(Diagnostic.Kind.NOTE, "开始更新字节码文件 "+ key);
        final String left = "${";
        final String right = "}";
        return source.replace(key, readProperties(key.replace(left, "").replace(right, "")));
    }

    // 重新写一个文件,这里模拟的是用字符串拼接打印替换后的值
    private void updateFile(String key) {
        messager.printMessage(Diagnostic.Kind.NOTE, "开始写入新文件");
        final String left = "${";
        final String right = "}";

        StringBuilder newSource = new StringBuilder();
        newSource.append("package ch20.annotations.apt;\n\npublic class ")
                .append("NewClass")
                .append(" {\n  public static void main(String[] args) {\n")
                .append("    System.out.println(\"")
                .append(readProperties(key.replace(left, "").replace(right, "")))
                .append("\");\n  }\n}");
        try {
            JavaFileObject sourceFile = filer.createSourceFile("ch20.annotations.apt.NewClass");
            Writer writer = sourceFile.openWriter();
            writer.write(newSource.toString());
            writer.flush();
            writer.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

上面是一个思路,是一个示例代码,我的包路径如下ch20.annotations.apt,运行的步骤如下:

首先编译注解处理器:javac -encoding UTF-8 -d  src\main\java\ch20\annotations\apt\ src\main\java\ch20\annotations\apt\InterfaceExtractorProcessor.java src\main\java\ch20\annotations\apt\ExtractInterface.java

执行注解处理器:javac -encoding UTF-8 -cp src\main\java\ch20\annotations\apt\;src\main\resources\ -processor ch20.annotations.apt.InterfaceExtractorProcessor -d src\main\java\ch20\annotations\apt\ -s src\ src\main\java\ch20\annotations\apt\*.java

最后

再回到最初的问题,对于该问题展开了很多,也尝试了很多,发现对于上层业务来说,这些框架性质的东西都有点太重,最终我还是把这些注解转化成了配置项,在启动的时候加载了一把,然后通过 Class.forName 的方式反射去注入了,这样在后期维护的时候改配置即可,算是达到了我的最初的要求,这种代码感觉也比较简单

参考

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值