Java 超程式設計及其應用

youzan.com發表於2017-05-12

首先,我們且不說超程式設計是什麼,他能做什麼.我們先來談談生產力.

同樣是實現一個投票系統,一個是python程式設計師,基於django-framework,用了半小時就搭建了一個完整系統,另外一個是標準的SSM(Spring-SpringMVC-Mybatis)Java程式設計師,用了半天,才把環境剛剛搭好.

可以說,社群內,成功的web框架中基本沒有不強依賴超程式設計技術的,框架做的工作越多,應用編寫就越輕鬆.

那什麼是超程式設計

超程式設計是寫出編寫程式碼的程式碼

試想以下,如果那些原本需要我們手動編寫的程式碼,可以自動生成,我們是不是又更多的時間來做更加有意義的事情?有些框架之所以開發效率高,其原因也是因為框架層面,把大量的需要重複編寫的程式碼,採用超程式設計的方式給自動生成了.

甚至,我們可以大膽在想一步,如果有個更加智慧的機器人,幫我們寫程式碼,那麼我們是不是又可以省掉更多的精力,來做更加有意義的事情?

如果我們的應用框架有這樣一種能力,那麼可以省掉我們大部分的重複工作.

比如經常被Java程式設計師詬病的大段大段的setter/getter/toString/hashCode/equals方法,這些方法其實在模型欄位定義好了之後,這些方法其實基本上就已經標準化了,比如常用的IDE(eclipse,IDEA)都支援自動生成這些方法,這樣挺好,可以省掉我們好多精力. 但是這樣做的還不夠好,當我們嘗試去理解一個模型的時候,視線裡有大量這些的冗餘方法,會增加我們對於模型理解的負擔. lombok給出了一個解決方案通過註解的方法,來自動為模型生成setter/getter/toString/hashCode方法,使我們的程式碼精簡了很多.

比如另外一個Java程式設計師詬病的地方,用mybatis訪問資料庫,即使我們的對資料庫的操作僅僅是簡單的增刪查改,我們也需要對每一個操作的定義sql,我們需要編寫

  • 領域模型物件
  • DAO的interface
  • mybatis的mapper檔案

程式設計師世界有個撓癢癢定理

當一個東西令你覺得癢了,那麼很有可能,這個東西也令其他程式設計師癢了,而且github上面也許已經有了現成的專案可以借鑑.

比如 mybatis generator就可以根據資料庫結構自動生成上面這些檔案, 他大大減少了初次搭建專案的負擔.

但是檔案生成了,我麼就得維護,我們會往裡面加其它東西,比如加欄位,增加其它操作. 這樣當資料庫的表結構有變動之後,我們就要維護所有涉及到的檔案,這個工作量其實也不小. 有沒有更好的方法?本文後面會提出一種解決方案.

Java超程式設計的幾種姿勢

反射(reflection)

自省

我們要生成程式碼,我至少得知道我們現有的程式碼長什麼樣子吧?

正如,我們要化妝(給自己化妝,亦或是給別人化妝)我們至少得看得清楚我們的容貌,別人的容貌吧.

reflection這個名字起得真有意思,把程式的自省比喻成照鏡子,對著這個鏡子,程式就知道,喲,

  • 這是一個Class
  • 這個Class有幾個Field
  • 這個Field是什麼型別的
  • 這個Field是否static,是否是final
  • 這個Class還有幾個Method
  • 這個Method的返回型別是什麼
  • 這個Method的引數列表型別什麼
  • 每個引數有什麼註解

引數的名字在執行時已經擦除了,獲取不到

反射的API除了提供了以上的能力之外,還提供了一個動態代理的功能.

動態代理

所謂動態代理,它的動態其實是相對於靜態代理而言的.在靜態代理裡面,代理物件與被代理物件的型別都實現了同樣的介面,這樣當客戶端持有一個介面物件的時候,就可以用代理的物件來替換這個真實物件,同時這個代理物件就像在扮演真實物件的祕書,很多需要真實物件處理的東西,其實都是這個代理做的.大部分場景下,他會直接把問題轉給真實物件處理,同時,他還做了其它事情

  • 比如記錄一下日誌啊
  • 比如選擇性拒絕啊(我們老闆太忙,這個請求我替我們老闆拒絕了)
  • 甚至還可以通過請求其它服務,來偽造結果(mock)

所有的這些代理工作的實現,都是在寫程式碼的時候,手動實現好的. 明顯,這很不超程式設計

動態代理的神奇之處在於,本來老闆是沒有祕書的,只是突然決定要請一個祕書,就臨時變了一個祕書出來,老闆能做的事情,他都能做(Proxy.newProxyInstance()需要傳一個介面列表,這個新生成的類,就會實現這些介面)

有了這種變化能力,我們不僅僅可以動態變出AA的代理AAProxy,而且還能動態變出BB的代理BBProxy,甚至更多. 看出區別了嗎?

如果有10個需要代理的類,在靜態代理中,我們就需要編寫10個代理類;而在動態代理中,我們可以僅需要編寫一個實現了java.lang.reflect.InvocationHandler介面的類即可.

我們編寫的不是程式碼,而是生成程式碼的程式碼

甚至更誇張的是,本來公司沒老闆(被代理類),現在決定要一個老闆,我們描述一下這個老闆需要什麼能力(實現的介面),就能動態的變一個類似於老闆的東西(代理物件),而這個東西,還挺像個老闆的(實現了老闆的介面,並且能夠符合人們預期工作)

就像retrofit這個專案實現的一樣,通過一個介面,以及這個介面上的註解,就能動態生成一個符合預期的,http介面的Java SDK.(程式碼就不貼了,有興趣自己到官網參觀).我之前,也借鑑這種模式,寫了一個公司內部http介面的生成器. 這種編碼方式,更加乾淨,更加直觀.

其它使用動態代理技術的專案

  • Spring的基於介面的AOP
  • dubbo reference物件的生成

位元組碼增強(bytecode enhancement)

我們知道,Java的類是編譯成位元組碼存在class檔案中的,類的載入,其實就是位元組碼被讀取,生成Class類的過程.

我們是否能夠通過某種途徑,改變這個位元組碼呢?

要回答這個問題,我們可以先反問一句,我們是否有改變一個已經載入了的Class的需求呢?還真有,比如我們想給一個類的某些標記了@Log註解的方法進行打日誌記錄,我們想統計一個標記了@Perf註解的方法的執行時間. 如果我們無法改變一個類,那麼我們就必須在每個類裡面加類似的程式碼,這顯然不環保. 由於這是個強需求,如果Java不允許修改意見載入的類,那麼Java無疑會被實現了這些feature其它技術所淘汰,基於這個反向推理,由於Java現在還那麼火,所以可以推測,Java應該支援這種feature.

載入時

為了實現上面這種需求,Java5就推出了java.lang.instrument並且在jdk6進一步加強.

要實現一個類的轉換,我們需要執行如下步驟:

  • 就像我們編寫Java程式入口main方法一樣,我們通過編寫一個public static void premain(String agentArgs, Instrumentation inst);方法
  • 然後再方法體裡面註冊一個java.lang.instrument.ClassFileTransformer
  • 然後實現這個transformer
  • 然後將整個程式打包,並且在META-INF/MANIFEST.MF註明實現了premain方法的類名
  • 最終在程式啟動的時候,java -javaagent:myagent.jar

JVM就會載入myagent.jar中的META-INF/MANIFEST.MF, 讀取Premain-Class的值,並且載入我們的Premain-class類,然後在main方法執行之前,執行這個方法,由於我們在方法體重註冊了transformer,這樣後續一旦有類在載入之前,都會先執行我們的transformer的transform方法,進行位元組碼增強.

java.lang.instrument.ClassFileTransformer的介面有一個方法

byte[] transform(  
    ClassLoader         loader,
    String              className,
    Class<?>            classBeingRedefined,
    ProtectionDomain    protectionDomain,
    byte[]              classfileBuffer)
throws IllegalClassFormatException;

我們可以利用一些位元組碼增強的類庫,對傳入的位元組碼陣列進行解析,然後修改,然後序列化成位元組碼,作為方法結果返回

常用的位元組碼增強類庫

  • ASM
  • cglib
  • javassist

其中javassist因為API易於使用,且專案一直活躍,所以推薦使用.

執行時

Java也可以在類已經載入到記憶體中的情況,對類進行修改,不過這個修改有個限制,只能修改方法體的實現,不能對類的結構進行修改.

類似的eclipse以及IDEA的動態載入,就是這個原理.

Annotation Processing

執行時或者載入時的位元組碼增強,雖然牛逼,但是其有個致命性短板,它增加的方法,無法在編譯時被程式碼感知,也就是說,我們在執行時給MyObj類增加的方法getSomeThing(Param param),無法在其它原始碼中,通過myObj.getSomeThing(param)這種方式進行呼叫,而只能通過反射的方式進行呼叫,這無疑醜陋了很多.也許Java也是考慮到這種需求,才發明了Annotation Processing這種編譯過程

Java編譯過程

compile process

如圖所示,Java的編譯過程分為三步

  1. Parse & Enter: 這一步主要負責將Java的原始碼解析成抽象語法樹(AST)
  2. Annotation Processing: 這一步就會執行使用者定義的AnnotationProcessing邏輯,生成新的程式碼/資源,然後重複執行過程1,直到沒有新的原始碼生成
  3. Analyse & Generate: 這一步才是真正的生成位元組碼的過程

這個編譯過程中,我們可以擴充套件的是,第二部,我們可以自己實現一個javax.annotation.processing.Processor類,然後將這個類告訴編譯器,然後編譯器就會在編譯原始碼的時候,呼叫介面的process邏輯,我們就可以在這裡生成新的原始檔與資原始檔!

遺憾的是,編譯器並沒有顯示的API提供給我們,允許我們修改已有class的抽象語法樹,也就是說,我們無法在通過正規途徑編譯時給一個類增加成員;這裡強調了正規途徑是因為確認是存在一些非正規途徑,可以讓我們去修改這棵樹. lombok就是這麼做的

lombok是做什麼的?

lombok允許我們通過簡易的註解,來自動生成我們模型的getter,setter,constructor,toString等常用方法,可以讓我們的模型程式碼更加乾淨.

瞭解了上述的Java的編譯過程,我們其實就可以想想,是否可以通過程式碼生成的方式,來去掉我們平時詬病,卻一直難以根除的痛?

基於Annotation Processing的MybatisDAO & mapper檔案自動生成

分析

對於一個model而已,常用的操作包括以下幾種

  • insert(model)
  • selectByXXX(model)
  • countByXXX(model)
  • updateByXXXAndYYY(model)
  • deleteByXXX(model)

如果僅僅提供model,是不是就足以生成對應的DAO介面申明以及對應mapper配置?

  • 表名: 簡單點,可以直接根據模型名來推斷,也可以通過註解增加方法,來允許自定義表名
  • insert/update的欄位列表: 直接去模型的欄位列表即可
  • select/update/delete的時候,我們是需要知道我們根據什麼欄位進行過濾,這個資訊我們是需要告訴Processor的,因為我們可以考慮增加一個註解@Index來告訴Processor,這些欄位是索引欄位,可以根據這些欄位進行過濾

基於上面分析,我們有了以下大致思路

  • 我們首先定義一個@DAO,用於標記我們的模型class
  • 然後定義一個@Index,用於標記模型的欄位
  • 然後定義一個DAOGeneratorProcessor繼承自AbstractProcessor,並且申明支援DAO
    • process方法的實現中,我們會分析模型的語法書,提取出類名,欄位列表
    • 找出標記了@Index的欄位列表,然後對涉及到過濾的方法生成所有的組合,比如
      • selectByOrderAndSellerId
      • selectBySellerId
      • selectByOrderNo
    • 生成對應的介面宣告,以及mapper檔案

這種組合索引欄位,生成方法名的方式比較粗暴,比如如果有N個@Index欄位,對應的selectByXXX方法就會有2**N,大部分場景下,這個N都不會超過3個,比如訂單表,就是orderno,商品表,就是itemid

由於annotation是編譯器的擴充套件,這一點體驗比較好,一旦我們定義好了模型(比如Order.class),然後編譯模型,我們就可以在程式碼其它地方,就可以直接引用OrderDAO這個物件類(這個類是生成的哦),可以回顧一下Java的編譯過程.

實踐

實踐中,雖然生成的DAO可以覆蓋我們大部分的用例,但是並不能覆蓋所有我們的需求場景,因此,我們推薦將生成的DAO統一叫做BasicDAO,這樣有些個性化的需求,我們仍然可以同自己書寫SQL的方式來自定義,這樣在解決重複冗餘的前提下,也能很好的適應複雜的業務場景.

總結

Java本身是一門靜態語言,程式從原始碼,到執行的程式,中間會經歷很多的環節.

這些環節都可以作為我們超程式設計的切入點,不同的環節,可以發揮不同的威力,使用得當,可以幫助我們提供生產力的同時,也能很好優化我們的程式碼效能。

相關文章