本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結
上節我們探討了反射,反射相關的類中都有方法獲取註解資訊,我們在前面章節中也多次提到過註解,註解到底是什麼呢?
在Java中,註解就是給程式新增一些資訊,用字元@開頭,這些資訊用於修飾它後面緊挨著的其他程式碼元素,比如類、介面、欄位、方法、方法中的引數、構造方法等,註解可以被編譯器、程式執行時、和其他工具使用,用於增強或修改程式行為等。這麼說比較抽象,下面我們會具體來看,先來看Java的一些內建註解。
內建註解
Java內建了一些常用註解,比如:@Override、@Deprecated、@SuppressWarnings,我們簡要介紹下。
@Override
@Override修飾一個方法,表示該方法不是當前類首先宣告的,而是在某個父類或實現的介面中宣告的,當前類"重寫"了該方法,比如:
static class Base {
public void action() {};
}
static class Child extends Base {
@Override
public void action(){
System.out.println("child action");
}
@Override
public String toString() {
return "child";
}
}
複製程式碼
Child的action()重寫了父類Base中的action(),toString()重寫了Object類中的toString()。這個註解不寫也不會改變這些方法是"重寫"的本質,那有什麼用呢?它可以減少一些程式設計錯誤。如果方法有Override註解,但沒有任何父類或實現的介面宣告該方法,則編譯器會報錯,強制程式設計師修復該問題。比如,在上面的例子中,如果程式設計師修改了Base方法中的action方法定義,變為了:
static class Base {
public void doAction() {};
}
複製程式碼
但是,程式設計師忘記了修改Child方法,如果沒有Override註解,編譯器不會報告任何錯誤,它會認為action方法是Child新加的方法,doAction會呼叫父類的方法,這與程式設計師的期望是不符的,而有了Override註解,編譯器就會報告錯誤。所以,如果方法是在父類或介面中定義的,加上@Override吧,讓編譯器幫你減少錯誤。
@Deprecated
@Deprecated可以修飾的範圍很廣,包括類、方法、欄位、引數等,它表示對應的程式碼已經過時了,程式設計師不應該使用它,不過,它是一種警告,而不是強制性的,在IDE如Eclipse中,會給Deprecated元素加一條刪除線以示警告,比如,Date中很多方法就過時了:
@Deprecated
public Date(int year, int month, int date)
@Deprecated
public int getYear()
複製程式碼
呼叫這些方法,編譯器也會顯示刪除線並警告,比如:
在宣告元素為@Deprecated時,應該用Java文件註釋的方式同時說明替代方案,就像Date中的API文件那樣,在呼叫@Deprecated方法時,應該先考慮其建議的替代方案。@SuppressWarnings
@SuppressWarnings表示壓制Java的編譯警告,它有一個必填引數,表示壓制哪種型別的警告,它也可以修飾大部分程式碼元素,在更大範圍的修飾也會對內部元素起效,比如,在類上的註解會影響到方法,在方法上的註解會影響到程式碼行。對於上面Date方法的呼叫,如果不希望顯示警告,可以這樣:
@SuppressWarnings({"deprecation","unused"})
public static void main(String[] args) {
Date date = new Date(2017, 4, 12);
int year = date.getYear();
}
複製程式碼
除了這些內建註解,Java並沒有給我們提供更多的可以直接使用的註解,我們日常開發中使用的註解基本都是自定義的,不過,一般也不是我們定義的,而是由各種框架和庫定義的,我們主要還是根據它們的文件直接使用。
框架和庫的註解
各種框架和庫定義了大量的註解,程式設計師使用這些註解配置框架和庫,與它們進行互動,我們看一些例子。
Jackson
在63節,我們介紹了通用的序列化庫Jackson,並介紹瞭如何利用註解對序列化進行定製,比如:
- 使用@JsonIgnore和@JsonIgnoreProperties配置忽略欄位
- 使用@JsonManagedReference和@JsonBackReference配置互相引用關係
- 使用@JsonProperty和@JsonFormat配置欄位的名稱和格式等
在Java提供註解功能之前,同樣的配置功能也是可以實現的,一般通過配置檔案實現,但是配置項和要配置的程式元素不在一個地方,難以管理和維護,使用註解就簡單多了,程式碼和配置放在一起,一目瞭然,易於理解和維護。
依賴注入容器
現代Java開發經常利用某種框架管理物件的生命週期及其依賴關係,這個框架一般稱為DI(Dependency Injection)容器,DI是指依賴注入,流行的框架有Spring、Guice等,在使用這些框架時,程式設計師一般不通過new建立物件,而是由容器管理物件的建立,對於依賴的服務,也不需要自己管理,而是使用註解表達依賴關係。這麼做的好處有很多,程式碼更為簡單,也更為靈活,比如容器可以根據配置返回一個動態代理,實現AOP,這部分我們後續章節再介紹。
看個簡單的例子,Guice定義了Inject註解,可以使用它表達依賴關係,比如像下面這樣:
public class OrderService {
@Inject
UserService userService;
@Inject
ProductService productService;
//....
}
複製程式碼
Servlet 3.0
Servlet是Java為Web應用提供的技術框架,早期的Servlet只能在web.xml中進行配置,而Servlet 3.0則開始支援註解,可以使用@WebServlet配置一個類為Servlet,比如:
@WebServlet(urlPatterns = "/async", asyncSupported = true)
public class AsyncDemoServlet extends HttpServlet {...}
複製程式碼
Web應用框架
在Web開發中,典型的架構都是MVC(Model-View-Controller),典型的需求是配置哪個方法處理哪個URL的什麼HTTP方法,然後將HTTP請求引數對映為Java方法的引數,各種框架如Spring MVC, Jersey等都支援使用註解進行配置,比如,使用Jersey的一個配置示例為:
@Path("/hello")
public class HelloResource {
@GET
@Path("test")
@Produces(MediaType.APPLICATION_JSON)
public Map<String, Object> test(
@QueryParam("a") String a) {
Map<String, Object> map = new HashMap<>();
map.put("status", "ok");
return map;
}
}
複製程式碼
類HelloResource將處理Jersey配置的根路徑下/hello下的所有請求,而test方法將處理/hello/test的GET請求,響應格式為JSON,自動對映HTTP請求引數a到方法引數String a。
神奇的註解
通過以上的例子,我們可以看出,註解似乎有某種神奇的力量,通過簡單的宣告,就可以達到某種效果。在某些方面,它類似於我們在62節介紹的序列化,序列化機制中通過簡單的Serializable介面,Java就能自動處理很多複雜的事情。它也類似於我們在併發部分中介紹的synchronized關鍵字,通過它可以自動實現同步訪問。
這些都是宣告式程式設計風格,在這種風格中,程式都由三個元件組成:
- 宣告的關鍵字和語法本身
- 系統/框架/庫,它們負責解釋、執行宣告式的語句
- 應用程式,使用宣告式風格寫程式
在程式設計的世界裡,訪問資料庫的SQL語言,編寫網頁樣式的CSS,以及後續章節將要介紹的正規表示式、函數語言程式設計都是這種風格,這種風格降低了程式設計的難度,為應用程式設計師提供了更為高階的語言,使得程式設計師可以在更高的抽象層次上思考和解決問題,而不是陷於底層的細節實現。
建立註解
框架和庫是怎麼實現註解的呢?我們來看註解的建立。
@Override的定義
我們通過一些例子來說明,先看@Override的定義:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}
複製程式碼
定義註解與定義介面有點類似,都用了interface,不過註解的interface前多了@,另外,它還有兩個元註解@Target和@Retention,這兩個註解專門用於定義註解本身。
@Target
@Target表示註解的目標,@Override的目標是方法(ElementType.METHOD),ElementType是一個列舉,其他可選值有:
- TYPE:表示類、介面(包括註解),或者列舉宣告
- FIELD:欄位,包括列舉常量
- METHOD:方法
- PARAMETER:方法中的引數
- CONSTRUCTOR:構造方法
- LOCAL_VARIABLE:本地變數
- ANNOTATION_TYPE:註解型別
- PACKAGE:包
目標可以有多個,用{}表示,比如@SuppressWarnings的@Target就有多個,定義為:
@Target({TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, LOCAL_VARIABLE})
@Retention(RetentionPolicy.SOURCE)
public @interface SuppressWarnings {
String[] value();
}
複製程式碼
如果沒有宣告@Target,預設為適用於所有型別。
@Retention
@Retention表示註解資訊保留到什麼時候,取值只能有一個,型別為RetentionPolicy,它是一個列舉,有三個取值:
- SOURCE:只在原始碼中保留,編譯器將程式碼編譯為位元組碼檔案後就會丟掉
- CLASS:保留到位元組碼檔案中,但Java虛擬機器將class檔案載入到記憶體時不一定會在記憶體中保留
- RUNTIME:一直保留到執行時
如果沒有宣告@Retention,預設為CLASS。
@Override和@SuppressWarnings都是給編譯器用的,所以@Retention都是RetentionPolicy.SOURCE。
定義引數
可以為註解定義一些引數,定義的方式是在註解內定義一些方法,比如@SuppressWarnings內定義的方法value,返回值型別表示引數的型別,這裡是String[],使用@SuppressWarnings時必須給value提供值,比如:
@SuppressWarnings(value={"deprecation","unused"})
複製程式碼
當只有一個引數,且名稱為value時,提供引數值時可以省略"value=",即上面的程式碼可以簡寫為:
@SuppressWarnings({"deprecation","unused"})
複製程式碼
註解內引數的型別不是什麼都可以的,合法的型別有基本型別、String、Class、列舉、註解、以及這些型別的陣列。
引數定義時可以使用default指定一個預設值,比如,Guice中Inject註解的定義:
@Target({ METHOD, CONSTRUCTOR, FIELD })
@Retention(RUNTIME)
@Documented
public @interface Inject {
boolean optional() default false;
}
複製程式碼
它有一個引數optional,預設值為false。如果型別為String,預設值可以為"",但不能為null。如果定義了引數且沒有提供預設值,在使用註解時必須提供具體的值,不能為null。
@Inject多了一個元註解@Documented,它表示註解資訊包含到Javadoc中。
@Inherited
與介面和類不同,註解不能繼承。不過註解有一個與繼承有關的元註解@Inherited,它是什麼意思呢?我們看個例子:
public class InheritDemo {
@Inherited
@Retention(RetentionPolicy.RUNTIME)
static @interface Test {
}
@Test
static class Base {
}
static class Child extends Base {
}
public static void main(String[] args) {
System.out.println(Child.class.isAnnotationPresent(Test.class));
}
}
複製程式碼
Test是一個註解,類Base有該註解,Child繼承了Base但沒有宣告該註解,main方法檢查Child類是否有Test註解,輸出為true,這是因為Test有註解@Inherited,如果去掉,輸出就變成false了。
檢視註解資訊
建立了註解,就可以在程式中使用,註解指定的目標,提供需要的引數,但這還是不會影響到程式的執行。要影響程式,我們要先能檢視這些資訊。我們主要考慮@Retention為RetentionPolicy.RUNTIME的註解,利用反射機制在執行時進行檢視和利用這些資訊。
在上節中,我們提到了反射相關類中與註解有關的方法,這裡彙總說明下,Class、Field、Method、Constructor中都有如下方法:
//獲取所有的註解
public Annotation[] getAnnotations()
//獲取所有本元素上直接宣告的註解,忽略inherited來的
public Annotation[] getDeclaredAnnotations()
//獲取指定型別的註解,沒有返回null
public <A extends Annotation> A getAnnotation(Class<A> annotationClass)
//判斷是否有指定型別的註解
public boolean isAnnotationPresent(Class<? extends Annotation> annotationClass)
複製程式碼
Annotation是一個介面,它表示註解,具體定義為:
public interface Annotation {
boolean equals(Object obj);
int hashCode();
String toString();
//返回真正的註解型別
Class<? extends Annotation> annotationType();
}
複製程式碼
實際上,所有的註解型別,內部實現時,都是擴充套件的Annotation。
對於Method和Contructor,它們都有方法引數,而引數也可以有註解,所以它們都有如下方法:
public Annotation[][] getParameterAnnotations()
複製程式碼
返回值是一個二維陣列,每個引數對應一個一維陣列,我們看個簡單的例子:
public class MethodAnnotations {
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
static @interface QueryParam {
String value();
}
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
static @interface DefaultValue {
String value() default "";
}
public void hello(@QueryParam("action") String action,
@QueryParam("sort") @DefaultValue("asc") String sort){
// ...
}
public static void main(String[] args) throws Exception {
Class<?> cls = MethodAnnotations.class;
Method method = cls.getMethod("hello", new Class[]{String.class, String.class});
Annotation[][] annts = method.getParameterAnnotations();
for(int i=0; i<annts.length; i++){
System.out.println("annotations for paramter " + (i+1));
Annotation[] anntArr = annts[i];
for(Annotation annt : anntArr){
if(annt instanceof QueryParam){
QueryParam qp = (QueryParam)annt;
System.out.println(qp.annotationType().getSimpleName()+":"+ qp.value());
}else if(annt instanceof DefaultValue){
DefaultValue dv = (DefaultValue)annt;
System.out.println(dv.annotationType().getSimpleName()+":"+ dv.value());
}
}
}
}
}
複製程式碼
這裡定義了兩個註解@QueryParam和@DefaultValue,都用於修飾方法引數,方法hello使用了這兩個註解,在main方法中,我們演示瞭如何獲取方法引數的註解資訊,輸出為:
annotations for paramter 1
QueryParam:action
annotations for paramter 2
QueryParam:sort
DefaultValue:asc
複製程式碼
程式碼比較簡單,就不贅述了。
定義了註解,通過反射獲取到註解資訊,但具體怎麼利用這些資訊呢?我們看兩個簡單的示例,一個是定製序列化,另一個是DI容器。
應用註解 - 定製序列化
定義註解
上節我們演示了一個簡單的通用序列化類SimpleMapper,在將物件轉換為字串時,格式是固定的,本節演示如何對輸出格式進行定製化。我們實現一個簡單的類SimpleFormatter,它有一個方法:
public static String format(Object obj)
複製程式碼
我們定義兩個註解,@Label和@Format,@Label用於定製輸出欄位的名稱,@Format用於定義日期型別的輸出格式,它們的定義如下:
@Retention(RUNTIME)
@Target(FIELD)
public @interface Label {
String value() default "";
}
@Retention(RUNTIME)
@Target(FIELD)
public @interface Format {
String pattern() default "yyyy-MM-dd HH:mm:ss";
String timezone() default "GMT+8";
}
複製程式碼
使用註解
可以用這兩個註解來修飾要序列化的類欄位,比如:
static class Student {
@Label("姓名")
String name;
@Label("出生日期")
@Format(pattern="yyyy/MM/dd")
Date born;
@Label("分數")
double score;
public Student() {
}
public Student(String name, Date born, Double score) {
super();
this.name = name;
this.born = born;
this.score = score;
}
@Override
public String toString() {
return "Student [name=" + name + ", born=" + born + ", score=" + score + "]";
}
}
複製程式碼
我們可以這樣來使用SimpleFormatter:
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
Student zhangsan = new Student("張三", sdf.parse("1990-12-12"), 80.9d);
System.out.println(SimpleFormatter.format(zhangsan));
複製程式碼
輸出為:
姓名:張三
出生日期:1990/12/12
分數:80.9
複製程式碼
利用註解資訊
可以看出,輸出使用了自定義的欄位名稱和日期格式,SimpleFormatter.format()是怎麼利用這些註解的呢?我們看程式碼:
public static String format(Object obj) {
try {
Class<?> cls = obj.getClass();
StringBuilder sb = new StringBuilder();
for (Field f : cls.getDeclaredFields()) {
if (!f.isAccessible()) {
f.setAccessible(true);
}
Label label = f.getAnnotation(Label.class);
String name = label != null ? label.value() : f.getName();
Object value = f.get(obj);
if (value != null && f.getType() == Date.class) {
value = formatDate(f, value);
}
sb.append(name + ":" + value + "\n");
}
return sb.toString();
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
}
複製程式碼
對於日期型別的欄位,呼叫了formatDate,其程式碼為:
private static Object formatDate(Field f, Object value) {
Format format = f.getAnnotation(Format.class);
if (format != null) {
SimpleDateFormat sdf = new SimpleDateFormat(format.pattern());
sdf.setTimeZone(TimeZone.getTimeZone(format.timezone()));
return sdf.format(value);
}
return value;
}
複製程式碼
這些程式碼都比較簡單,我們就不解釋了。
應用註解 - DI容器
定義@SimpleInject
我們再來看一個簡單的DI容器的例子,我們引入一個註解@SimpleInject,修飾類中欄位,表達依賴關係,定義為:
@Retention(RUNTIME)
@Target(FIELD)
public @interface SimpleInject {
}
複製程式碼
使用@SimpleInject
我們看兩個簡單的服務ServiceA和ServiceB,ServiceA依賴於ServiceB,它們的定義為:
public class ServiceA {
@SimpleInject
ServiceB b;
public void callB(){
b.action();
}
}
public class ServiceB {
public void action(){
System.out.println("I'm B");
}
}
複製程式碼
ServiceA使用@SimpleInject表達對ServiceB的依賴。
DI容器的類為SimpleContainer,提供一個方法:
public static <T> T getInstance(Class<T> cls)
複製程式碼
應用程式使用該方法獲取物件例項,而不是自己new,使用方法如下所示:
ServiceA a = SimpleContainer.getInstance(ServiceA.class);
a.callB();
複製程式碼
利用@SimpleInject
SimpleContainer.getInstance會建立需要的物件,並配置依賴關係,其程式碼為:
public static <T> T getInstance(Class<T> cls) {
try {
T obj = cls.newInstance();
Field[] fields = cls.getDeclaredFields();
for (Field f : fields) {
if (f.isAnnotationPresent(SimpleInject.class)) {
if (!f.isAccessible()) {
f.setAccessible(true);
}
Class<?> fieldCls = f.getType();
f.set(obj, getInstance(fieldCls));
}
}
return obj;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
複製程式碼
程式碼假定每個型別都有一個public預設構造方法,使用它建立物件,然後檢視每個欄位,如果有SimpleInject註解,就根據欄位型別獲取該型別的例項,並設定欄位的值。
定義@SimpleSingleton
在上面的程式碼中,每次獲取一個型別的物件,都會新建立一個物件,實際開發中,這可能不是期望的結果,期望的模式可能是單例,即每個型別只建立一個物件,該物件被所有訪問的程式碼共享,怎麼滿足這種需求呢?我們增加一個註解@SimpleSingleton,用於修飾類,表示型別是單例,定義如下:
@Retention(RUNTIME)
@Target(TYPE)
public @interface SimpleSingleton {
}
複製程式碼
使用@SimpleSingleton
我們可以這樣修飾ServiceB:
@SimpleSingleton
public class ServiceB {
public void action(){
System.out.println("I'm B");
}
}
複製程式碼
利用@SimpleSingleton
SimpleContainer也需要做修改,首先增加一個靜態變數,快取建立過的單例物件:
private static Map<Class<?>, Object> instances = new ConcurrentHashMap<>();
複製程式碼
getInstance也需要做修改,如下所示:
public static <T> T getInstance(Class<T> cls) {
try {
boolean singleton = cls.isAnnotationPresent(SimpleSingleton.class);
if (!singleton) {
return createInstance(cls);
}
Object obj = instances.get(cls);
if (obj != null) {
return (T) obj;
}
synchronized (cls) {
obj = instances.get(cls);
if (obj == null) {
obj = createInstance(cls);
instances.put(cls, obj);
}
}
return (T) obj;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
複製程式碼
首先檢查型別是否是單例,如果不是,就直接呼叫createInstance建立物件。否則,檢查快取,如果有,直接返回,沒有的話,呼叫createInstance建立物件,並放入快取中。
createInstance與第一版的getInstance類似,程式碼為:
private static <T> T createInstance(Class<T> cls) throws Exception {
T obj = cls.newInstance();
Field[] fields = cls.getDeclaredFields();
for (Field f : fields) {
if (f.isAnnotationPresent(SimpleInject.class)) {
if (!f.isAccessible()) {
f.setAccessible(true);
}
Class<?> fieldCls = f.getType();
f.set(obj, getInstance(fieldCls));
}
}
return obj;
}
複製程式碼
小結
本節介紹了Java中的註解,包括註解的使用、自定義註解和應用示例。
註解提升了Java語言的表達能力,有效地實現了應用功能和底層功能的分離,框架/庫的程式設計師可以專注於底層實現,藉助反射實現通用功能,提供註解給應用程式設計師使用,應用程式設計師可以專注於應用功能,通過簡單的宣告式註解與框架/庫進行協作。
下一節,我們來探討Java中一種更為動態靈活的機制 - 動態代理。
(與其他章節一樣,本節所有程式碼位於 github.com/swiftma/pro…,位於包shuo.laoma.dynamic.c85下)
未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),從入門到高階,深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。