Java 註解詳解

低吟不作語發表於2020-11-26

本文部分摘自 On Java 8


基本語法

註解是 Java 5 所引入的眾多語言變化之一,是附加在程式碼中的一些元資訊,用於一些工具在編譯、執行時進行解析和使用,起到說明、配置的功能。註解不會也不能影響程式碼的實際邏輯,僅僅起到輔助性的作用,包含在 java.lang.annotation 包中

註解的語法十分簡單,只要在現有語法中新增 @ 符號即可,java.lang 包提供瞭如下五種註解:

  • @Override

    表示當前的方法定義將覆蓋基類的方法,如果你不小心把方法簽名拼錯了,編譯器就會發出錯誤提示

  • @Deprecated

    如果使用該註解的元素被呼叫,編譯器就會發出警告資訊,表示不鼓勵程式設計師使用

  • @SuppressWarnings

    關閉不當的編譯器警告資訊

  • @SafeVarargs

    禁止對具有泛型可變引數的方法或建構函式的呼叫方發出警告

  • @FunctionalInterface

    宣告介面型別為函式式介面


定義註解

註解的定義看起來和介面的定義很像,事實上它們和其他 Java 介面一樣,也會被編譯成 class 檔案

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TestAnnotation {}

除開 @ 符號, @Test 的定義看起來更像一個空介面。註解的定義也需要一些元註解,元註解用於註解其他的註解

註解 解釋
@Target 表示註解可以用於哪些地方。可能的 ElementType 引數包括:
CONSTRUCTOR:構造器的宣告
FIELD:欄位宣告(包括 enum 例項)
LOCAL_VARIABLE:區域性變數宣告
METHOD:方法宣告
PACKAGE:包宣告
PARAMETER:引數宣告
TYPE:類、介面(包括註解型別)或者 enum 宣告
@Retention 表示註解資訊儲存的時長。可選的 RetentionPolicy 引數包括:
SOURCE:註解將被編譯器丟棄
CLASS:註解在 class 檔案中可用,但是會被 VM 丟棄
RUNTIME:VM 將在執行期也保留註解,因此可以通過反射機制讀取註解的資訊
@Documented 將此註解儲存在 Javadoc 中
@Inherited 允許子類繼承父類的註解
@Repeatable 允許一個註解可以被使用一次或者多次(Java8)

不包含任何元素的註解稱為標記註解,上例中的 @Test 就是標記註解。註解通常也會包含一些表示特定值的元素,當分析處理註解的時候,程式可以利用這些值。註解的元素看起來就像介面的方法,但可以為其指定預設值

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TestAnnotation {
    int id();
    String description() default "no description";
}

....

public class TestUtils {
    // 在方法上使用註解 @TestAnnotation
    @UseCase(id = 47, description = "description")
    public void test() {
        ...
    }
}

註解元素可用的型別如下所示,如果使用了其他型別,編譯器就會報錯:

  • 所有基本型別(int、float、boolean 等)
  • String
  • Class
  • enum
  • Annotation
  • 以上型別的陣列

如果沒有給出 description 的值,在分析處理這個類的時候會使用該元素的預設值。元素的預設值不能有不確定的值,也就是說,元素要麼有預設值,要麼就在使用註解時提供元素的值

這裡還有另外一個限制:任何非基本型別的元素,無論是在原始碼宣告時還是在註解介面中定義預設值時,都不能使用 null 作為值。如果我們希望表現一個元素的存在或者缺失的狀態,可以自定義一些特殊的值,比如空字串或者負數用於表達某個元素不存在

註解不支援繼承,你不能使用 extends 關鍵字來繼承 @interface


註解處理器

如果沒有用於讀取註解的工具,那麼註解不會比註釋更有用。使用註解中一個很重要的作用就是建立與使用註解處理器。Java 擴充了反射機制的 API 用於幫助你創造這類工具。同時他還提供了 javac 編譯器鉤子在編譯時使用註解

下面是一個非常簡單的註解處理器,我們用它來讀取被註解的 TestUtils 類,並且使用反射機制來尋找 @TestAnnotation 標記

public class TestAnnotationTracker {
    
    public static void trackTestAnnotation(Class<?> cl) {
        for(Method m : cl.getDeclaredMethods()) {
            TestAnnotation ta = m.getAnnotation(TestAnnotation.class);
            if(ta != null) {
                System.out.println(ta.id() + "\n " + ta.description());
            }
        }
    }
    
    public static void main(String[] args) {
        trackTestAnnotation(TestUtils.class);
    }
}

這裡用到了兩個反射的方法:getDeclaredMethods() 和 getAnnotation(),getAnnotation() 方法返回指定型別的註解物件,在本例中就是 TestAnnotation,如果被註解的方法上沒有該型別的註解,返回值就為 null。通過呼叫 id() 和 description() 方法來提取元素值


使用註解實現物件 - 資料庫對映

當有些框架需要一些額外的資訊才能與你的原始碼協同工作,這種情況下註解就會變得十分有用。自定義例如物件/關係對映工具(Hibernate 和 MyBatis)通常都需要 XML 描述檔案,而這些檔案脫離於程式碼之外。除了定義 Java 類,程式設計師還必須重複的提供某些資訊,而例如類名和包名等資訊已經在原始類中提供過了,經常會導致程式碼和描述檔案的同步問題

假設你現在想提供一些基本的物件/關係對映功能,能夠自動生成資料庫表。你可以使用 XML 描述檔案來指明類的名字、每個成員以及資料庫對映的相關資訊。但是,通過使用註解,你可以把所有資訊都儲存在 JavaBean 原始檔中。為此你需要一些用於定義資料庫表名稱、資料庫列以及將 SQL 型別對映到屬性的註解

首先建立一個用來對映資料庫表的註解,用來修飾類、介面(包括註解型別)或者 enum 宣告

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

如下是修飾欄位的註解

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Constraints {
    boolean primaryKey() default false;
    boolean allowNull() default true;
    boolean unique() default false;
}
public @interface SQLString {
    int value() default 0;
    String name() default "";
    Constraints constraints() default @Constraints;
}
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SQLInteger {
    String name() default "";
    Constraints constraints() default @Constraints;
}

@Constraints 代表了資料庫通常提供的約束的一小部分,primaryKey(),allowNull() 和 unique() 元素都提供了預設值,大多數情況下,註解的使用者都不需要輸入太多東西

另外兩個 @interface 定義的是 SQL 型別。如果希望這個框架更有價值的話,我們應該為每個 SQL 型別都定義相應的註解。不過作為示例,兩個元素足夠了。這些 SQL 型別具有 name() 元素和 constraints() 元素。後者利用了巢狀註解的功能,將資料庫列的型別約束資訊嵌入其中。注意 constraints() 元素的預設值是 @Constraints,沒有在括號中指明 @Constraints 元素的值,因此,constraints() 的預設值為所有元素都為預設值。如果要使得嵌入的 @Constraints 註解中的 unique() 元素為 true,並作為 constraints() 元素的預設值,你可以像如下定義:

public @interface Uniqueness {
    Constraints constraints() default @Constraints(unique = true);
}

下面是一個簡單的,使用瞭如上註解的類

@DBTable(name = "MEMBER")
public class Member {
    @SQLString(30)
    String firstName;
    @SQLString(50)
    String lastName;
    @SQLInteger
    Integer age;
    @SQLString(value = 30, constraints = @Constraints(primaryKey = true))
    String reference;
    static int memberCount;
    public String getReference() { return reference; }
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    @Override
    public String toString() { return reference; }
    public Integer getAge() { return age; }
}

類註解 @DBTable 註解給定了元素值 MEMBER,它將會作為表的名字。類的屬性 firstName 和 lastName 都被註解為 @SQLString 型別並且給了預設元素值分別為 30 和 50,並在嵌入的 @Constraint 註解中設定 primaryKey 元素的值

下面是一個註解處理器的例子,它將讀取一個類檔案,檢查上面的資料庫註解,並生成用於建立資料庫的 SQL 命令:

public class TableCreator {
    
    public static void generateSql(String[] classnames) throws Exception {
        for (String className : classnames) {
            Class<?> cl = Class.forName(className);
            DBTable dbTable = cl.getAnnotation(DBTable.class);
            String tableName = dbTable.name();
            // 如果表名為空字串,則使用類名
            if (tableName.length() < 1) {
                tableName = cl.getName().toUpperCase();
            }
            List<String> columnDefs = new ArrayList<>();
            for (Field field : cl.getDeclaredFields()) {
                String columnName = null;
                Annotation[] anns = field.getDeclaredAnnotations();
                // 該屬性不是列
                if (anns.length < 1) {
                    continue;
                }
                // 處理整數型別
                if (anns[0] instanceof SQLInteger) {
                    SQLInteger sInt = (SQLInteger) anns[0];
                    // 如果列名為空字串,則使用屬性名
                    if (sInt.name().length() < 1) {
                        columnName = field.getName().toUpperCase();
                    } else {
                        columnName = sInt.name();
                    }
                    columnDefs.add(columnName + " INT" + getConstraints(sInt.constraints()));
                }
                // 處理字串型別
                if (anns[0] instanceof SQLString) {
                    SQLString sString = (SQLString) anns[0];
                    if (sString.name().length() < 1) {
                        columnName = field.getName().toUpperCase();
                    } else {
                        columnName = sString.name();
                    }
                    columnDefs.add(columnName + " VARCHAR(" + sString.value() + ")" +
                            getConstraints(sString.constraints()));
                }
                // 構造並輸出 sql 字串
                StringBuilder createCommand = new StringBuilder("CREATE TABLE " + tableName + "(");
                for (String columnDef : columnDefs) {
                    createCommand.append("\n " + columnDef + ",");
                }
                String tableCreate = createCommand.substring(0, createCommand.length() - 1) + ");";
                System.out.println("Table Creation SQL for " + className + " is:\n" + tableCreate);
            }
        }
    }

    private static String getConstraints(Constraints con) {
        String constraints = "";
        if (!con.allowNull())
            constraints += " NOT NULL";
        if (con.primaryKey())
            constraints += " PRIMARY KEY";
        if (con.unique())
            constraints += " UNIQUE";
        return constraints;
    }
}

編譯時註解處理

當 @Retention 的 RetentionPolicy 引數被標註為 SOURCE 或 CLASS,此時你無法通過反射去獲取註解資訊,因為註解在執行期是不存在的。使用 javac 可以建立編譯時註解處理器,在編譯時掃描和處理註解。你可以自定義註解,並註冊到對應的註解處理器。註解處理器可以生成 Java 程式碼,這些生成的 Java 程式碼會組成新的 Java 原始檔,但不能修改已經存在的 Java 類,例如向已有的類中新增方法。如果你的註解處理器建立了新的原始檔,在新一輪處理中註解處理器會檢查原始檔本身,在檢測一輪之後持續迴圈,直到不再有新的原始檔產生,然後編譯所有的原始檔

我們來編寫一個簡單的註解處理器,如下是註解的定義

@Retention(RetentionPolicy.SOURCE)
@Target({ElementType.TYPE, ElementType.METHOD,
        ElementType.CONSTRUCTOR,
        ElementType.ANNOTATION_TYPE,
        ElementType.PACKAGE, ElementType.FIELD,
        ElementType.LOCAL_VARIABLE})
public @interface Simple {
    String value() default "-default-";
}

@Retention 的引數為 SOURCE,這意味著註解不會存留在編譯後的 class 檔案,因為這對應編譯時處理註解是沒有必要的,在這裡,javac 是唯一有機會處理註解的方式

package annotations.simplest;
@Simple
public class SimpleTest {
    @Simple
    int i;
    @Simple
    public SimpleTest() {}
    @Simple
    public void foo() {
        System.out.println("SimpleTest.foo()");
    }
    @Simple
    public void bar(String s, int i, float f) {
        System.out.println("SimpleTest.bar()");
    }
    @Simple
    public static void main(String[] args) {
        @Simple
        SimpleTest st = new SimpleTest();
        st.foo();
    }
}

執行 main 方法,程式就會開始編譯,如下是一個簡單的處理器,作用就是把註解相關的資訊列印出來

package annotations.simplest;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import java.util.*;
@SupportedAnnotationTypes("annotations.simplest.Simple")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class SimpleProcessor extends AbstractProcessor {
    
    @Override
    public boolean process(Set<? extends TypeElement> annotations,
    	RoundEnvironment env) {
        for(TypeElement t : annotations) {
            System.out.println(t);
        }
        for(Element el : env.getElementsAnnotatedWith(Simple.class)) {
            display(el);
        }
        return false;
    }
    
    private void display(Element el) {
        System.out.println("==== " + el + " ====");
        System.out.println(el.getKind() +	// 返回此元素的種類,欄位,方法,或是類
                " : " + el.getModifiers() +	// 返回此元素的修飾符
                " : " + el.getSimpleName() +	// 返回此元素的簡單名稱
                " : " + el.asType());	// 返回此元素定義的型別
        // 如果元素為CLASS型別,動態向下轉型為更具體的元素型別,並列印相關資訊
        if(el.getKind().equals(ElementKind.CLASS)) {
            TypeElement te = (TypeElement)el;
            System.out.println(te.getQualifiedName());
            System.out.println(te.getSuperclass());
            System.out.println(te.getEnclosedElements());
        }
        // 如果元素為METHOD型別,動態向下轉型為更具體的元素型別,並列印相關資訊
        if(el.getKind().equals(ElementKind.METHOD)) {
            ExecutableElement ex = (ExecutableElement)el;
            System.out.print(ex.getReturnType() + " ");
            System.out.print(ex.getSimpleName() + "(");
            System.out.println(ex.getParameters() + ")");
        }
    }
}

使用 @SupportedAnnotationTypes 和 @SupportedSourceVersion 註解來確定支援哪些註解以及支援的 Java 版本

註解處理器需要繼承抽象類 javax.annotation.processing.AbstractProcessor,唯一需要實現的方法就是 process(),這裡是所有行為發生的地方。第一個引數獲取到此註解處理器所要處理的註解集合,第二個引數保留了剩餘資訊,這裡我們所做的事情只是列印了註解(只存在一個)。process() 中實現的第二個操作是迴圈所有被 @Simple 註解的元素,並且針對每一個元素呼叫 display() 方法。展示所有 Element 自身的基本資訊


相關文章