如何讓Java編譯器幫你寫代碼

語言: CN / TW / HK

作者:京東零售 劉世傑

導讀

本文結合京東監控埋點場景,對解決樣板代碼的技術選型方案進行分析,給出最終解決方案後,結合理論和實踐進一步展開。通過關注文中的技術分析過程和技術場景,讀者可收穫一種樣板代碼思想過程和解決思路,並對Java編譯器底層有初步瞭解。

一、背景

監控是服務端應用需要具備的一個非常重要的能力,通過監控可以直觀的看到核心業務指標、服務運行質量等,而要做到可監控就需要進行相應的監控埋點。大家在埋點過程中經常會編寫大量重複代碼,雖能實現基本功能,但耗時耗力,不夠優雅。根據“DRY(Don't Repeat Yourself)"原則,這是代碼中的“壞味道”,對有代碼潔癖的人來講,這種重複是不可接受的。

那有什麼方法解決這種“重複”嗎?經過綜合調研,基於前端編譯器插樁技術,實現了一個埋點組件,通過織入埋點邏輯,讓Java 編譯器幫我們寫代碼。經過不斷打磨,已經被包括京東APP主站服務端在內的很多團隊廣泛使用。

本文主要是結合監控埋點這個場景分享一種解決樣板化代碼的思路,希望能起到拋磚引玉的作用。下面將從組件介紹技術選型過程實現原理部分源碼實現逐步展開講解。

二、組件介紹

京東內部監控系統叫UMP,與所有的監控系統一樣,核心部分有埋點、上報、分析整合、報警、看板等等,本文講的組件主要是為對監控埋點原生能力的增強,提供一種更優雅簡潔的實現。

下面先來看下傳統硬編碼的埋點方式,主要分為創建埋點對象、可用率記錄、提交埋點 3 個步驟:

通過上圖可以看到,真正的邏輯只有紅框中的範圍,為了完成埋點要把這段代碼都圍繞起來,代碼層級變深,可讀性差,所有埋點都是這樣的樣板代碼。

下面來看下使用組件後的埋點方式:

通過對比很容易看到,使用組件後的方式只要在方法上加一個註解就可以了,代碼可讀性有明顯的提升。

組件由埋點封裝API和AST操作處理器 2 部分組成。

埋點API封裝:在運行時被調用,對原生埋點做了封裝和抽象,方便使用者進行監控KEY的擴展。

AST操作處理器:在編譯期調用,它將根據註解@UMP把埋點封裝API按照規則織入方法體內。

(注:結合京東實際業務場景,組件實現了fallback、自定義可用率、重名方法區分、配套的IDE插件、監控key自定義生成規則等細節功能,由於本文主要是講解底層實現原理,詳細功能不在此贅述)

三、技術選型過程

通過上面的示例代碼,相信很多人覺得這個功能很簡單,用 Spring AOP 很快就能搞定了。的確很多團隊也是這麼做的,不過這個方案並不是那麼完美,下面的選型分析中會有相關的解釋,請耐心往下看。如下圖,從軟件的開發週期來看,可織入埋點的時機主要有 3 個階段:編譯期、編譯後和運行期。

3.1 編譯前

這裏的編譯期指將Java源文件編譯為class字節碼的過程。Java編譯器提供了基於 JSR 269 規範[1]的註解處理器機制,通過操作AST (抽象語法樹,Abstract Syntax Tree,下同)實現邏輯的織入。業內有不少基於此機制的應用,比如Lombok 、MapStruct 、JPA 等;此機制的優點是因為在編譯期執行,可以將問題前置,沒有多餘依賴,因此做出來的工具使用起來比較方便。缺點也很明顯,要熟練操作 AST並不是想的那麼簡單,不理解前後關聯的流程寫出來的代碼不夠穩定,因此要花大量時間熟悉編譯器底層原理。當然這個過程對使用者來講是沒有感知的。

3.2 編譯後

編譯後是指編譯成 class 字節碼之後,通過字節碼進行增強的過程。此階段插樁需要適配不同的構建工具:Maven、Gradle、Ant、Ivy等,也需要使用方增加額外的構建配置,因此存在開發量大和使用不夠方便的問題,首先要排除掉此選項。可能只有極少數場景下才會需要在此階段插樁。

3.3 運行期

運行期是指在程序啟動後,在運行時進行增強的過程,這個階段有 3 種方式可以織入邏輯,按照啟動順序,可以分為:靜態 Agent、AOP 和動態 Agent。

3.3-1 靜態 Agent

JVM 啟動時使用 -javaagent 載入指定 jar 包,調用 MANIFEST.MF 文件裏的 Premain-Class 類的 premain 方法觸發織入邏輯。是技術中間件最常使用的方式,藉助字節碼工具完成相關工作。應用此機制的中間件有很多,比如:京東內部的鏈路監控 pfinder、外部開源的 skywalking 的探針、阿里的 TTL 等等。這種方式優點是整體比較成熟,缺點主要是兼容性問題,要測試不同的 JDK 版本代價較大,出現問題只能在線上發現。同時如果不是專業的中間件團隊,還是存在一定的技術門檻,維護成本比較高;

3.3-2 Spring AOP

Spring AOP大家都不陌生,通過 Spring 代理機制,可以在方法調用前後織入邏輯。AOP 最大的優點是使用簡單,同樣存在不少缺點:

1) 同一類內方法A調用方法B時,是無法走到切面的,這是Spring 官方文檔的解釋[2] “However, once the call has finally reached the target object (the SimplePojo reference in this case), any method calls that it may make on itself, such as this.bar() or this.foo(), are going to be invoked against the this reference, and not the proxy”。這個問題會導致內部方法調用的邏輯執行不到。在監控埋點這個場景下就會出現丟數據的情況;

2) AOP只能環繞方法,方法體內部的邏輯沒有辦法干預。靠捕捉異常判斷邏輯是不夠的,有些場景需要是通過返回值狀態來判斷邏輯是否正常,使用介紹裏面的示例代碼就是此種情況,這在 RPC 調用解析裏是很平常的操作。

3) 私有方法、靜態方法、final class和方法等場景無法走切面

3.3-3 動態 Agent

動態加載jar包,調用MANIFEST.MF文件中聲明的Agent-Class類的agentmain方法觸發織入邏輯。這種方式主要用來線上動態調試,使用此機制的中間件也有很多,比如:Btrace、Arthas等,此方式不適合常駐內存使用,因此要排除掉。

3.4 最終方案選擇

通過上面的分析梳理可知,要實現重複代碼的抽象有 3 種方式:基於JSR 269 的插樁、基於 Java Agent 的字節碼增強、基於Spring AOP的自定義切面。接下來進一步的對比:

如上表所示,從實現成本上來看,AOP 最簡單,但這個方案不能覆蓋所有場景,存在一定的侷限性,不符合我們追求極致的調性,因此首先排除。Java Agent 能達到的效果與 JSR 269 相同,但是啟動參數裏需要增加 -javaagent 配置,有少量的運維工作,同時還有 JDK 兼容性的坑需要趟,對非中間件團隊來説,這種方式從長久看會帶來負擔,因此也要排除。基於 JSR 269 的插樁方式,對Java編譯器工作流程的理解和 AST 的操作會帶來實現上的複雜性,前期投入比較大,但是組件一旦成型,會帶來一勞永逸的解決方案,可以很自信的講,插樁實現的組件是監控埋點場景裏的銀彈(事實證明了這點,不然也不敢這麼吹)。

冰山之上,此組件給使用者帶來了簡潔優雅的體驗,一個jar包,一行代碼,妙筆生花。那冰山之下是如何實現的呢?那就要從原理説起了。

四、插樁實現原理

簡單來講,插樁是在編譯期基於 JSR 269的註解處理器中操作AST的方式操縱語法節點,最終編譯到class文件中。要做好插樁理解相關的底層原理是必要的。大多數讀者對編譯器相關內容比較陌生,這裏會用較大的篇幅做個相對系統的介紹。

Java編譯器是將源碼翻譯成 class 字節碼的工具,Java編譯器有多種實現:Open JDK的javac、Eclipse的ecj和ajc、IBM的jikes等,javac是公司內主要的編譯器,本文是基於Open JDK 1.8 講解。

作為一款工業級編譯器內部實現比較複雜,其涵蓋的內容足夠寫一本書了。結合本人對javac源碼的理解,嘗試通俗易懂的講清楚插樁涉及到的知識,有不盡之處歡迎指正。有興趣進一步研究的讀者建議閲讀 javac源碼[6]。

下面將講解編譯器執行流程,相關javac源碼導航,以及註解處理器如何運作。

4.1 編譯器執行流程

根據官網資料[3]javac 處理流程可以粗略的分為 3個部分:Parse and Enter、Annotation Processing、Analyse and Generate,如下圖:

Parse and Enter

Parse階段主要通過詞法分析器(Scanner)讀取源碼生產 token 流,被語法分析器(JavacParser)消費構造出AST,Java代碼都可以通過AST表達出來,讀者可以通過JCTree查看相關的實現。為了讓讀者能更直觀的理解AST,本人做了一個源碼解析成AST後的圖形化展示:

(注:AST圖形生成通過IDEA插件JavaParser-AST-Inspector生成dot格式文本,並使用線上工具GraphvizOnline轉換為圖片,見參考資料5、7)

示例源碼:

token流:

[ package ] <- [ com ] <- [ . ] <- …... <- [ } ]

解析成AST後如下:

Enter階段主要是根據AST填充符號表,此處為插樁之後的流程,因此不再展開。

Annotation Processing

註解處理階段,此處會調用基於 JSR269 規範的註解處理器,是javac對外的擴展。通過註解處理器讓開發者(指非javac開發者,下同)具備自定義執行邏輯的能力,這就是插樁的關鍵。在這個階段,可以獲取到前一階段生成的AST,從而進行操作。

Analyse and Generate

分析AST並生成class字節碼,此處為插樁之後的流程,不再展開。

4.2 相關javac源碼導航

javac觸發入口類路徑是:com. sun. tools. javac. Main,代碼如下:

經驗證Maven 執行構建調的是此類中的main方法。其他構建工具未做驗證,猜測類似的。在JDK內部也提供了javax. tools. Tool Provider# get System Java Compiler的入口,實際上內部實現也是調的這個類裏的compile方法。

經過一系列的命令參數解析和初始化操作,最終調到真正的核心入口,方法是com. sun. tools. javac. main. Java Compiler# compile,如下圖:

這裏有3個關鍵調用:

852行:初始化註解處理器,通過Main入口的調用是通過JDK SPI的方式收集。

855–858行:對應前面流程圖裏的Parse and Enter和Annotation Processing兩個階段的流程,其中方法processAnnotations便是執行註解處理器的觸發入口。

860行:對應Analyse and Generate階段的流程。

4.3 註解處理器

Java從JDK 1.6 開始,引入了基於JSR 269 規範的註解處理器,允許開發者在編譯期間執行自己的代碼邏輯。如本文講的UMP監控埋點插樁組件一樣,由此衍生出了很多優秀的技術組件,如前面提到的Lombok、Mapstruct等。註解處理器使用比較簡單,後面示例代碼有註解處理器簡單實現也可以參考。這裏重點講一下註解處理器整體執行原理:

1、編譯開始的時候,會執行方法init Process Annotations (compile的截圖852行),以SPI的方式收集到所有的註解處理器,SPI對應接口:javax. annotation. processing. Processor。

2、在方法process Annotations中執行註解處理器調用方法Javac Processing Environment# do Processing。

3、所有的註解處理器處理完畢一次,稱為一輪(round),每輪開始會執行一次Processor# init方法以便開發者自定義初始化信息,如緩存上下文等。初始化完成後,javac會根據註解、版本等條件過濾出符合條件的註解處理器,並調用其接口方法Processor# process,即開發者自定義的實現。

4、在開發者自定義的註解處理器裏,實現AST操作的邏輯。

5、一輪執行完成後,發現新的Java源文件或者class文件,則開啟新的一輪。直到不再產生Java或者class文件為止。有的開源項目實現註解處理器時,為了保證自身可以繼續執行,會通過這個機制創建一個空白的Java文件達到目的,其實這也是理解原理的好處。

6、如果在一輪中未發現新的Java源文件和class文件產生則執行最後一輪(last Round)。最後一輪執行完畢後,如果有新的Java源文件生成,則進行Parse and Enter 流程處理。到這裏,整個註解處理器的流程就結束了。

7、進入Analyse and Generate階段,最終生成class,完成整體編譯。

接下來將通過UMP監控埋點功能來展示怎麼在註解處理器中操作AST。

五、源碼示例

關於AST 操作的探索,早在2008年就有相關資料了[4],Lombok、Mapstruct都是開源的工具,也可以用來參考學習。這裏簡單講一個示例,展示如何插樁。

註解處理器使用框架

上圖展示了註解處理器具體的基本使用框架,init、process是註解處理器的核心方法,前者是初始化註解處理器的入口,後者是操作AST的入口。javac還提供了一些有用的工具類,比如:

TreeMaker:創建AST的工廠類,所有的節點都是繼承自JCTree,並通過TreeMaker完成創建。

JavacElements:操作Element的工具類,可以用來定位具體AST。

向類中織入一個import節點

這裏舉一個簡單場景,向類中織入一個import節點:

為方便理解對代碼實現做了簡化,可以配合註釋查看如何織入:

總的來説,織入邏輯是通過TreeMaker創建AST 節點,並操作現有AST織入創建的節點,從而達到了織入代碼的目的。

六、反思與總結

到這裏,講了埋點組件的使用、技術選型、以及插樁相關的內容,最終開發出來的組件在工作中也起到了很好的效果。但是在這個過程中有一些反思。

1、插樁門檻高

通過前面的內容不難得出一個事實,要實現一個小小的功能,需要開發者花費大量的精力去學習理解編譯器底層的一些原理。從ROI角度看,投入和產出是嚴重不成正比的。為了能提供可靠的實現,個人花費了大量業餘時間去做技術選型分析和編譯器相關知識,可以説是純靠個人的興趣和一股倔勁一點點搭建起來的,細節是魔鬼,這個踩坑的過程比較枯燥。實際上插樁機制有很多通用的場景可以探索,之所以一直很少見到此類機制的應用。主要是其門檻較高,對大多數開發者來説比較陌生。因此降低開發者使用門檻才能讓一些想法變成現實。做一把好用的錘子,比砸入一個釘子要更有價值。

在監控埋點插樁組件真正落地時,在項目內做了一定抽象,並支持了一些開關、自定義鏈路跟蹤等功能。但從作用範圍來講是不夠的,所以下一步計劃做一個插樁方面的技術框架,從易用性、可維護性等方面做好進一步的抽象,同時做好可測試性相關工作,包含驗證各版本JDK的支持、各種Java語法的覆蓋等。

2、插樁是把雙刃劍

javac官方對修改AST的方式持保守態度,也存在一些爭議。然而時間是最好的驗證工具,從Lombok 等組件的發展看出,插樁機制是能經住長久考驗的。如何合理利用這種能力是非常重要的,合理使用可使系統簡潔優雅,使用不當就等於在代碼裏下毒了。所以要有節制的修改AST,要懂前後運行機制,圍繞通用的場景使用,避免濫用。

3、認識當前上下文環境的侷限性

遇到問題時,如果在當前的上下文環境裏找不到合適的解決方案,從這個環境跳出來換個維度也許能看到不同的風景。就像物理機到虛擬機再到現在的容器,都是打破了原來的規則逐步發展出新的技術生態。大多數的開發工作都是基於一個高層次的封裝上面進行,而突破往往都是從底層開始的,適當的時候也可以向下做一些探索,可能會產生一些有價值的東西。

參考文獻

[1] JSR 269:

https://www.jcp.org/en/jsr/detail?id=269

[2] Understanding AOP Proxies:

https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#aop-understanding-aop-proxies

‍[3] Compilation Overview:

https://openjdk.org/groups/compiler/doc/compilation-overview/index.html

[4] The Hacker’s Guide to Javac:

http://scg.unibe.ch/archive/projects/Erni08b.pdf

[5] JavaParser-AST-Inspector:

https://github.com/MysterAitch/JavaParser-AST-Inspector

[6] OpenJDK source:

http://hg.openjdk.java.net/jdk8u/jdk8u60/langtools/

[7] Graphviz Online:

https://dreampuf.github.io/GraphvizOnline/#digraph G {}