編譯時期捕鼠


在 Java 5 導入標註時,其實就提供了個標註處理工具(Annotation Processing Tool, APT),可以使用非標準的 com.sun.mirror 等 API 來撰寫註解處理器,再透過 apt 工具程式,於靜態時期處理標註。

接著在 Java 6 中納入了 JSR269:Pluggable Annotation Processing API,標準化了標註處理器的 API(套件為 javax.annotation.processingjavax.lang.model 等),而 Java 7 將 apt 工具與原先的非標準 API 標為廢棄(deprecated),後續在 Java 8 正式移除 aptcom.sun.mirror 等相關 API。

具體來說,現在開發者可以繼承 AbstractProcessor(實作了 javax.annotation.processing.Processor),使用 @SupportedSourceVersion 指定 Java 版本,@SupportedAnnotationTypes 指定要處理的標註類型名稱,並定義 process 方法來處理標註,在 javac 編譯時若使用 -processor--processor-path 指定標註處理器來源,或者類別路徑包含的 JAR 中,META-INF 中有 javax.annotation.processing.Processor 檔案,其中撰寫了處理器類別全名,在編譯器剖析、生成語法樹之後,若原始碼中有指定要處理的標註,就會載入標註處理器並執行 process 方法。

例如,若你撰寫了 Debug 標註:

package cc.openhome;

public @interface Debug {   
}

Some 上做了標註:

package cc.openhome;

@Debug
public class Some {
    @Debug
    public void test() {
    }

    public void test2() {
    }
}

若要在編譯時期處理 @Debug 資訊,可以寫個處理器:

package cc.openhome;

import java.util.Set;

import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.TypeElement;

@SupportedSourceVersion(SourceVersion.RELEASE_10)
@SupportedAnnotationTypes({ "cc.openhome.Debug" })
public class DebugProcessor extends AbstractProcessor {

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        System.out.println(roundEnv.processingOver());
        System.out.println(roundEnv.getRootElements());
        System.out.println(annotations);
        return false;
    }
}

若使用 javac 編譯 Some 時,類別路徑中可以找到這個處理器,而且使用 -processor 或 JAR 中包含了這個處理器的資訊,就會看到底下訊息,例如:

> javac --release 10 -cp bin -processor cc.openhome.DebugProcessor -d bin src/cc/openhome/Some.java
false
[cc.openhome.Some]
[cc.openhome.Debug]
true
[]
[]

如果在 process 中修改了語法樹,編譯器就會回到上一步重新處理,然後再次執行 process,每次執行 process 稱為一個 round,可以透過 RoundEnvironment 來取得語法樹元素資訊,例如 getRootElements 取得了前一個 round 時處理的根元素(在這邊就是類別),這個循環會進行到語法樹沒有任何修改,然後進入 last round,可以透過 processingOver 來得知是否 last round。

process 最後傳回的 boolean 值表示,該處理器是否已經完成了支持的標註處理,其他處理器可以不用進一步處理了。

當然,修改語法樹基本上是不建議的,不過 Java 的生態圈裏,有個 Lombok 就是這麼做的,它運用了非標準的Java Compiler Tree API(com.sun.tools.javac 等 API)修改語法樹,之後將修改過的結果交由編譯器分析、產生位元組碼並儲存為 .class。

通常建議將標註處理器用於原始碼檢查、程式碼產生器,這就需要知道原始碼的資訊,在編譯時期,javac 本身會運行在一個 JVM,這也是為何你可以如上使用 Java 程式碼來運用 Processor 的原因,而原始碼資訊,主要會運用 javax.lang.model.typejavax.lang.model.element 中的相關 API 來封裝(而不是透過 Reflection API,因為 Reflection API 只能用在執行時期)。

例如,若想在編譯時期,找出 Some 上被 @Debug 標註的元素:

package cc.openhome;

import java.util.List;
import java.util.Set;

import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedSourceVersion;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.TypeElement;

@SupportedSourceVersion(SourceVersion.RELEASE_10)
@SupportedAnnotationTypes({"cc.openhome.Debug"})
public class DemoProcessor extends AbstractProcessor {

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        Set<? extends Element> elements = roundEnv.getRootElements();
        for(Element root: elements) {
            if(root.getAnnotation(Debug.class) != null) {
                System.out.println(root);
            }
            List<? extends Element> enclosedElems = root.getEnclosedElements();
            for(Element elem : enclosedElems) {
                if(elem.getAnnotation(Debug.class) != null) {
                    System.out.println(elem);
                }
            }
        }
        return false;
    }
}

在編譯時指定此標註處理器,就會看到底下資訊:

cc.openhome.Some
test()

Google 的 AutoValue 就是基於 JSR269,可自動為被標註類別產生具有 Getter、Setter 等方法的原始碼,有興趣的話,可以看看它的原始碼是怎麼實作的,從中認識更多編譯時期處理標註的方式。