插入式注解處理器在《深入了解Java虛拟機》一書中有一些介紹(前端編譯篇有提到),但一直沒有機會使用,直到碰到這個需求,覺得再合适不過了,就簡單用了一下,這裡做個記錄。
了解過lombok底層原理的都知道其使用的就是的插入式注解,那麼今天筆者就以真實場景示範一下插入式注解的使用。
需求
我們為公司提供了一套通用的JAVA基礎元件包,元件包内有不同的子產品,比如熔斷子產品、負載均子產品、rpc子產品等等,這些子產品均會被打成jar包,然後釋出到公司的内部代碼倉庫中,供其他人引入使用。
這份代碼會不斷的疊代,我們希望可以通過promethus來監控現在公司内使用各版本代碼庫的比例,希望達到的效果圖如下:
我們希望看到每一個版本的使用率,這有利于我們做版本相容,必要的時候可以對古早版本使用者溯源。
問題
需求似乎很簡單,但真要擷取自身的jar版本号還是挺麻煩的,有個比較簡單但陰間的辦法,就是給每一個元件都加上目前的jar版本号,寫到配置檔案裡或者直接設定成常量,這樣上報promethus時就可以直接擷取到jar包版本号了,這個方法雖然可以解決問題,但每次疊代版本都要跟着改一遍所有元件包的版本号資料,過于麻煩。
有沒有更好的解決辦法呢?比如我們可不可以在gradle打包建構時拿到jar包的版本号,然後注入到每個元件中去呢?就像lombok那樣,不需要寫get、set方法,隻需要加個注解标記就可以自動注入get、set方法。
比如我們可以給每個元件定義一個空常量,加上自定義的注解:
@TrisceliVersion
public static final String version = "";
然後像lombok生成set/get方法那樣注入真正的版本号:
@TrisceliVersion
public static final String version = "1.0.31-SNAPSHOT";
參考lombok的實作,這其實是可以做到的,下面來看解決方案。
解決
java中解析一個注解的方式主要有兩種:編譯期掃描、運作期反射,這是lombok @Setter的實作:
@Target({ElementType.FIELD, ElementType.TYPE})
@Retention(RetentionPolicy.SOURCE)
public @interface Setter {
// 略...
}
可以看到@Setter的Retention是SOURCE類型的,也就是說這個注解隻在編譯期有效,它甚至不會被編入class檔案,是以lombok無疑是第一種解析方式,那用什麼方式可以在編譯期就讓注解被解析到并執行我們的解析代碼呢?答案就是定義插入式注解處理器(通過JSR-269提案定義的Pluggable Annotation Processing API實作)
插入式注解處理器的觸發點如下圖所示:
也就是說插入式注解處理器可以幫助我們在編譯期修改抽象文法樹(AST)!是以現在我們隻需要自定義一個這樣的處理器,然後其内部拿到jar版本資訊(因為是編譯期,可以找到源碼的path,源碼裡随便搞個檔案存放版本号,然後用java io讀取進來即可),再将注解對應文法樹上的常量值設定成jar包版本号,文法樹變了,最終生成的位元組碼也會跟着變,這樣就實作了我們想在編譯期給常量version注入值的願望。
自定義一個插入式注解處理器也很簡單,首先要将自己的注解定義出來:
@Documented
@Retention(RetentionPolicy.SOURCE) //隻在編譯期有效,最終不會打進class檔案中
@Target({ElementType.FIELD}) //僅允許作用于類屬性之上
public @interface TrisceliVersion {
}
然後定義一個繼承了AbstractProcessor的處理器:
/**
* {@link AbstractProcessor} 就屬于 Pluggable Annotation Processing API
*/
public class TrisceliVersionProcessor extends AbstractProcessor {
private JavacTrees javacTrees;
private TreeMaker treeMaker;
private ProcessingEnvironment processingEnv;
/**
* 初始化處理器
*
* @param processingEnv 提供了一系列的實用工具
*/
@SneakyThrows
@Override
public synchronized void init(ProcessingEnvironment processingEnv) {
super.init(processingEnv);
this.processingEnv = processingEnv;
this.javacTrees = JavacTrees.instance(processingEnv);
Context context = ((JavacProcessingEnvironment) processingEnv).getContext();
this.treeMaker = TreeMaker.instance(context);
}
@Override
public SourceVersion getSupportedSourceVersion() {
return SourceVersion.latest();
}
@Override
public Set<String> getSupportedAnnotationTypes() {
HashSet<String> set = new HashSet<>();
set.add(TrisceliVersion.class.getName()); // 支援解析的注解
return set;
}
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
for (TypeElement t : annotations) {
for (Element e : roundEnv.getElementsAnnotatedWith(t)) { // 擷取到給定注解的element(element可以是一個類、方法、包等)
// JCVariableDecl為字段/變量定義文法樹節點
JCTree.JCVariableDecl jcv = (JCTree.JCVariableDecl) javacTrees.getTree(e);
String varType = jcv.vartype.type.toString();
if (!"java.lang.String".equals(varType)) { // 限定變量類型必須是String類型,否則抛異常
printErrorMessage(e, "Type '" + varType + "'" + " is not support.");
}
jcv.init = treeMaker.Literal(getVersion()); // 給這個字段指派,也就是getVersion的傳回值
}
}
return true;
}
/**
* 利用processingEnv内的Messager對象輸出一些日志
*
* @param e element
* @param m error message
*/
private void printErrorMessage(Element e, String m) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, m, e);
}
private String getVersion() {
/**
* 擷取version,這裡省略掉複雜的代碼,直接傳回固定值
*/
return "v1.0.1";
}
定義好的處理器需要SPI機制被發現,是以需要定義META.services:
測試
建立測試子產品,引入剛才寫好的代碼包:
這是Test類:
現在我們隻需要讓gradle build一下,新得到的位元組碼中該字段就有值了:
這隻是插入式注解處理器 功能的冰山一角,既然它可以通過修改抽象文法樹來控制生成的位元組碼,那麼自然就有人能充分利用其特性來實作一些很酷的插件,比如lombok,我們再也不用寫諸如set/get這種模闆式的代碼了,隻要我們足夠有創意,就可以讓基于這一套API實作的插件在功能上有很大的發揮空間。