註解是Java很強大的部分,但大多數時候我們傾向於使用而不是去建立註解。例如,在Java原始碼裡不難找到Java編譯器處理的@Override註解,
Spring框架的@Autowired註解, 或
Hibernate框架使用的@Entity 註解,但我們很少看到自定義註解。雖然自定義註解是Java語言中經常被忽視的一個方面,但在開發可讀性程式碼時它可能是非常有用的資產,同樣有助於理解常見框架(如Spring或Hibernate)如何簡潔地實現其目標。
在本文中,我們將介紹註解的基礎知識,包括註解是什麼,它們如何在示例中使用,以及如何處理它們。為了演示註解在實踐中的工作原理,我們將建立一個Javascript Object Notation(JSON)序列化程式,用於處理帶註解的物件並生成表示每個物件的JSON字串。在此過程中,我們將介紹許多常見的註解塊,包括Java反射框架和註解可見性問題。感興趣的讀者可以在
GitHub上
找到已完成的JSON序列化程式的原始碼。
什麼是註解?
註解是應用於Java結構的裝飾器,例如將後設資料與類,方法或欄位相關聯。這些裝飾器是良性的,不會自行執行任何程式碼,但執行時,框架或編譯器可以使用它們來執行某些操作。更正式地說,Java語言規範(JLS)
第9.7節提供了以下定義:
註解是資訊與程式結構相關聯的標記,但在執行時沒有任何影響。
請務必注意此定義中的最後一句:註解在執行時對程式沒有影響。這並不是說框架不會基於註解的存在而改變其執行時行為,而是包含註解本身的程式不會改變其執行時行為。雖然這可能看起來是細微差別,但為了掌握註解的實用性,理解這一點非常重要。
例如,某個例項的欄位新增了@Autowired註解,其本身不會改變程式的執行時行為:編譯器只是在執行時包含註解,但註解不執行任何程式碼或注入任何邏輯來改變程式的正常行為(忽略註解時的預期行為)。一旦我們在執行時引入Spring框架,我們就可以在解析程式時獲得強大的依賴注入(DI)功能。通過引入註解,我們已經指示Spring框架向我們的欄位注入適當的依賴項。我們將很快看到(當我們建立JSON序列化程式時)註解本身並沒有完成此操作,而是充當標記,通知Spring框架我們希望將依賴項注入到帶註解的欄位中。
Retention和Target
建立註解需要兩條資訊:(1)retention策略和(2)target。保留策略(retention)指定了在程式的生命週期註解應該被保留多長時間。例如,註解可以在編譯時或執行時期間保留,具體取決於與註解關聯的保留策略。從Java 9開始,有
三種標準保留策略,總結如下:
策略 | 描述 |
Source | 編譯器會丟棄註解 |
Class | 註解是在編譯器生成的類檔案中記錄的,但不需要在執行時處理類檔案的Java虛擬機器(JVM)保留。 |
Runtime | 註解由編譯器記錄在類檔案中,並由JVM在執行時保留 |
正如我們稍後將看到的,註解保留的執行時選項是最常見的選項之一,因為它允許Java程式反射訪問註解並基於存在的註解執行程式碼,以及訪問與註解相關聯的資料。請注意,註解只有一個關聯的保留策略。
註解的目標(target)指定註解可以應用於哪個Java結構。例如,某些註解可能僅對方法有效,而其他註解可能對類和欄位都有效。從Java 9開始,有
11個標準註解目標,如下表所示:
目標 | 描述 |
Annotation Type | 註解另一個註解 |
Constructor | 註解建構函式 |
Field | 註解一個欄位,例如類的例項變數或列舉常量 |
Local variable | 註解區域性變數 |
Method | 註解類的方法 |
Module | 註解模組(Java 9中的新增功能) |
Package | 註解包 |
Parameter | 註解到方法或建構函式的引數 |
Type | 註解一個型別,例如類,介面,註解型別或列舉宣告 |
Type Parameter | 註解型別引數,例如用作通用引數形式的引數 |
Type Use | 註解型別的使用,例如當使用new關鍵字建立型別的物件時 ,當物件強制轉換為指定型別時,類實現介面時,或者使用throws關鍵字宣告throwable物件的型別時(有關更多資訊,請參閱Type Annotations and Pluggable Type Systems Oracle tutorial) |
有關這些目標的更多資訊,請參見
JLS的第9.7.4節。要注意,註解可以關聯一個或多個目標。例如,如果欄位和建構函式目標與註解相關聯,則可以在欄位或建構函式上使用註解。另一方面,如果註解僅關聯方法目標,則將註解應用於除方法之外的任何構造都會在編譯期間導致錯誤。
註解引數
註解也可以具有引數。這些引數可以是基本型別(例如int或double),String,類,列舉,註解或前五種型別中任何一種的陣列(參見
JLS的第9.6.1節)。將引數與註解相關聯允許註解提供上下文資訊或者可以引數化註解的處理器。例如,在我們的JSON序列化程式實現中,我們將允許一個可選的註解引數,該引數在序列化時指定欄位的名稱(如果沒有指定名稱,則預設使用欄位的變數名稱)。
如何建立註解?
對於我們的JSON序列化程式,我們將建立一個欄位註解,允許開發人員在序列化物件時標記要轉換的欄位名。例如,如果我們建立汽車類,我們可以使用我們的註解來註解汽車的欄位(例如品牌和型號)。當我們序列化汽車物件時,生成的JSON將包括make和model鍵,其中值分別代表make和model欄位的值。為簡單起見,我們假設此註解僅用於String型別的欄位,確保欄位的值可以直接序列化為字串。
要建立這樣的欄位註解,我們使用@interface 關鍵字宣告一個新的註解:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface JsonField {
public String value() default "";
}複製程式碼
我們宣告的核心是public @interface JsonField,宣告帶有public修飾符的註解——允許我們的註解在任何包中使用(假設在另一個模組中正確匯入包)。註解宣告一個String型別value的引數,預設值為空字串。
請注意,變數名稱value具有特殊含義:它定義單元素註解(
JLS的第9.7.3節),並允許我們的註解使用者向註解提供單個引數,而無需指定引數的名稱。例如,使用者可以使用@JsonField("someFieldName")並且不需要將註解宣告為註解@JsonField(value = "someFieldName"),儘管後者仍然可以使用(但不是必需的)。包含預設值空字串允許省略該值,value如果沒有顯式指定值,則導致值為空字串。例如,如果使用者使用表單宣告上述註解@JsonField,則該value引數設定為空字串。
總之,我們建立了一個名為JsonField的public單元素註解,它在執行時由JVM保留,並且只能應用於欄位。此註解只有單個引數,型別String的value,預設值為空字串。通過建立註解,我們現在可以註解要序列化的欄位。
如何使用註解?
使用註解僅需要將註解放在適當的結構(註解的任何有效目標)之前。例如,我們可以建立一個Car類:
public class Car {
@JsonField("manufacturer")
private final String make;
@JsonField
private final String model;
private final String year;
public Car(String make, String model, String year) {
this.make = make;
this.model = model;
this.year = year;
}
public String getMake() {
return make;
}
public String getModel() {
return model;
}
public String getYear() {
return year;
}
@Override
public String toString() {
return year + " " + make + " " + model;
}
} 複製程式碼
該類使用@JsonField註解的兩個主要用途:(1)具有顯式值,(2)具有預設值。我們也可以使用@JsonField(value = "someName")註解一個欄位,但這種樣式過於冗長,並沒有助於程式碼的可讀性。因此,除非在單元素註解中包含註解引數名稱可以增加程式碼的可讀性,否則應該省略它。對於具有多個引數的註解,需要顯式指定每個引數的名稱來區分引數(除非僅提供一個引數,在這種情況下,如果未顯式提供名稱,則引數將對映到value引數)。
鑑於@JsonField註解的上述用法,我們希望將Car序列化為JSON字串{"manufacturer":"someMake", "model":"someModel"} (注意,我們稍後將會看到,我們將忽略鍵manufacturer 和model在此JSON字串的順序)。在這之前,重要的是要注意新增@JsonField註解不會改變類Car的執行時行為。如果編譯這個類,包含@JsonField註解不會比省略註解時增強類的行為。類的類檔案中只是簡單地記錄這些註解以及引數的值。改變系統的執行時行為需要我們處理這些註解。
如何處理註解?
處理註解是通過
Java反射應用程式程式設計介面(API)完成的。反射API允許我們編寫程式碼來訪問物件的類、方法、欄位等。例如,如果我們建立一個接受Car物件的方法,我們可以檢查該物件的類(即Car),並發現該類有三個欄位:(1)make,(2)model和(3)year。此外,我們可以檢查這些欄位以發現每個欄位是否都使用特定註解進行註解。
這樣,我們可以遍歷傳遞給方法的引數物件關聯類的每個欄位,並發現哪些欄位使用@JsonField註解。如果該欄位使用了@JsonField註解,我們將記錄該欄位的名稱及其值。處理完所有欄位後,我們就可以使用這些欄位名稱和值建立JSON字串。
確定欄位的名稱需要比確定值更復雜的邏輯。如果@JsonField包含value引數的提供值(例如"manufacturer"之前使用的@JsonField("manufacturer")),我們將使用提供的欄位名稱。如果value引數的值是空字串,我們知道沒有顯式提供欄位名稱(因為這是value引數的預設值),否則,顯式提供了一個空字串。後面這幾種情況下,我們都將使用欄位的變數名作為欄位名稱(例如,在private final String model宣告中)。
將此邏輯組合到一個JsonSerializer類中:
public class JsonSerializer {
public String serialize(Object object) throws JsonSerializeException {
try {
Class<?> objectClass = requireNonNull(object).getClass();
Map<String, String> jsonElements = new HashMap<>();
for (Field field : objectClass.getDeclaredFields()) {
field.setAccessible(true);
if (field.isAnnotationPresent(JsonField.class)) {
jsonElements.put(getSerializedKey(field), (String) field.get(object));
}
}
System.out.println(toJsonString(jsonElements));
return toJsonString(jsonElements);
} catch (IllegalAccessException e) {
throw new JsonSerializeException(e.getMessage());
}
}
private String toJsonString(Map<String, String> jsonMap) {
String elementsString = jsonMap.entrySet().stream().map(entry -> "\"" + entry.getKey() + "\":\"" + entry.getValue() + "\"").collect(Collectors.joining(","));
return "{" + elementsString + "}";
}
private static String getSerializedKey(Field field) {
String annotationValue = field.getAnnotation(JsonField.class).value();
if (annotationValue.isEmpty()) {
return field.getName();
} else {
return annotationValue;
}
}
} 複製程式碼
請注意,為簡潔起見,已將多個功能合併到該類中。有關此序列化程式類的重構版本,請參閱codebase儲存庫中的此分支(https://github.com/albanoj2/dzone-json-serializer/tree/srp_generalization)。我們還建立了一個異常,用於表示在serialize方法處理物件時是否發生了錯誤:
public class JsonSerializeException extends Exception {
private static final long serialVersionUID = -8845242379503538623L;
public JsonSerializeException(String message) {
super(message);
}
} 複製程式碼
儘管JsonSerializer該類看起來很複雜,但它包含三個主要任務:(1)查詢使用@JsonField註解的所有欄位,(2)記錄包含@JsonField註解的所有欄位的名稱(或顯式提供的欄位名稱)和值,以及(3)將所記錄的欄位名稱和值的鍵值對轉換成JSON字串。
requireNonNull(object).getClass()檢查提供的物件不是null (如果是,則丟擲一個NullPointerException)並獲得與提供的物件關聯的
Class物件。並使用此物件關聯的類來獲取關聯的欄位。接下來,我們建立String到String的Map,儲存欄位名和值的鍵值對。
隨著資料結構的建立,接下來遍歷類中宣告的每個欄位。對於每個欄位,我們配置為在訪問欄位時禁止Java語言訪問檢查。這是非常重要的一步,因為我們註解的欄位是私有的。在標準情況下,我們將無法訪問這些欄位,並且嘗試獲取私有欄位的值將導致IllegalAccessException丟擲。為了訪問這些私有欄位,我們必須禁止對該欄位的標準Java訪問檢查。setAccessible(boolean) 定義如下:
返回值true 表示反射物件應禁止Java語言訪問檢查。false 表示反射物件應強制執行Java語言訪問檢查。
在獲得對該欄位的訪問許可權之後,我們檢查該欄位是否使用了註解@JsonField。如果是,我們確定欄位的名稱(通過@JsonField註解中提供的顯式名稱或預設名稱),並在我們先前構造的map中記錄名稱和欄位值。處理完所有欄位後,我們將欄位名稱對映轉換為JSON字串。
處理完所有記錄後,我們將所有這些字串與逗號組合在一起。這會產生一個字串"<fieldName1>":"<fieldValue1>","<fieldName2>":"<fieldValue2>",...。一旦這個字串被連線起來,我們用花括號括起來,建立一個有效的JSON字串。
Car car=new Car("Ford","F150","2018");
JsonSerializer serializer=new JsonSerializer();
serializer.serialize(car); 複製程式碼
{"model":"F150","manufacturer":"Ford"}複製程式碼
正如預期的那樣,Car物件的maker和model欄位已經被序列化,使用欄位的名稱作為鍵,欄位的值作為值。請注意,JSON元素的順序可能與上面看到的輸出相反。發生這種情況是因為對於類的宣告欄位陣列沒有明確的排序,如
getDeclaredFields文件中所述:
返回陣列中的元素未排序,並且不按任何特定順序排列。
由於此限制,JSON字串中元素的順序可能會有所不同。為了使元素的順序具有確定性,我們必須自己強加排序。由於JSON物件被定義為一組無序的鍵值對,因此根據
JSON標準,不需要強制排序。但請注意,序列化方法的測試用例應該輸出{"model":"F150","manufacturer":"Ford"} 或者{"manufacturer":"Ford","model":"F150"}。
結論
Java註解是Java語言中非常強大的功能,但大多數情況下,我們使用標準註解(例如@Override)或通用框架註解(例如@Autowired),而不是開發人員。雖然不應使用註解來代替以物件導向的方式,但它們可以極大地簡化重複邏輯。例如,我們可以註解每個可序列化欄位而不是在介面中的方法建立一個toJsonString以及所有可以序列化的類實現此介面。它還將序列化邏輯與域邏輯分離,從域邏輯的簡潔性中消除了手動序列化的混亂。
雖然在大多數Java應用程式中不經常使用自定義註解,但是對於Java語言的任何中級或高階使用者來說,需要了解此功能。這個特性的知識不僅增強了開發人員的知識儲備,同樣也有助於理解最流行的Java框架中的常見註解。
聯絡郵箱:public@space-explore.com