如何讓Java編譯器幫你寫程式碼

京東雲開發者發表於2023-01-29

作者:京東零售 劉世傑

導讀

本文結合京東監控埋點場景,對解決樣板程式碼的技術選型方案進行分析,給出最終解決方案後,結合理論和實踐進一步展開。透過關注文中的技術分析過程和技術場景,讀者可收穫一種樣板程式碼思想過程和解決思路,並對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 {}

相關文章