如何讓Java編譯器幫你寫代碼
背景
監(jiān)控是服務(wù)端應(yīng)用需要具備的一個(gè)非常重要的能力,通過(guò)監(jiān)控可以直觀的看到核心業(yè)務(wù)指標(biāo)、服務(wù)運(yùn)行質(zhì)量等,而要做到可監(jiān)控就需要進(jìn)行相應(yīng)的監(jiān)控埋點(diǎn)。大家在埋點(diǎn)過(guò)程中經(jīng)常會(huì)編寫大量重復(fù)代碼,雖能實(shí)現(xiàn)基本功能,但耗時(shí)耗力,不夠優(yōu)雅。根據(jù)“DRY(Don't Repeater Yourself)"原則,這是代碼中的“壞味道”,對(duì)有代碼潔癖的人來(lái)講,這種重復(fù)是不可接受的。那有什么方法解決這種“重復(fù)”嗎?經(jīng)過(guò)綜合調(diào)研,基于前端編譯器插樁技術(shù),實(shí)現(xiàn)了一個(gè)埋點(diǎn)組件,通過(guò)織入埋點(diǎn)邏輯,讓Java 編譯器幫我們寫代碼。經(jīng)過(guò)不斷打磨,已經(jīng)被包括京東APP主站服務(wù)端在內(nèi)的很多團(tuán)隊(duì)廣泛使用。
本文主要是結(jié)合監(jiān)控埋點(diǎn)這個(gè)場(chǎng)景分享一種解決樣板化代碼的思路,希望能起到拋磚引玉的作用。下面將從組件介紹、技術(shù)選型過(guò)程、實(shí)現(xiàn)原理及部分源碼實(shí)現(xiàn)逐步展開(kāi)講解。
組件介紹
京東內(nèi)部監(jiān)控系統(tǒng)叫UMP,與所有的監(jiān)控系統(tǒng)一樣,核心部分有埋點(diǎn)、上報(bào)、分析整合、報(bào)警、看板等等,本文講的組件主要是為對(duì)監(jiān)控埋點(diǎn)原生能力的增強(qiáng),提供一種更優(yōu)雅簡(jiǎn)潔的實(shí)現(xiàn)。
我們先來(lái)看下傳統(tǒng)硬編碼的埋點(diǎn)方式,主要分為創(chuàng)建埋點(diǎn)對(duì)象、可用率記錄、提交埋點(diǎn) 3 個(gè)步驟:
通過(guò)上圖可以看到,真正的邏輯只有紅框中的范圍,為了完成埋點(diǎn)要把這段代碼都圍繞起來(lái),代碼層級(jí)變深,可讀性差,所有埋點(diǎn)都是這樣的樣板代碼。
下面來(lái)看下使用組件后的埋點(diǎn)方式:
通過(guò)對(duì)比很容易看到,使用組件后的方式只要在方法上加一個(gè)注解就可以了,代碼可讀性有明顯的提升。組件由埋點(diǎn)封裝API和AST操作處理器 2 部分組成。
- 埋點(diǎn)API封裝:在運(yùn)行時(shí)被調(diào)用,對(duì)原生埋點(diǎn)做了封裝和抽象,方便使用者進(jìn)行監(jiān)控KEY的擴(kuò)展。
- AST操作處理器:在編譯期調(diào)用,它將根據(jù)注解@UMP把埋點(diǎn)封裝API按照規(guī)則織入方法體內(nèi)。
(注:結(jié)合京東實(shí)際業(yè)務(wù)場(chǎng)景,組件實(shí)現(xiàn)了fallback、自定義可用率、重名方法區(qū)分、配套的IDE插件、監(jiān)控key自定義生成規(guī)則等細(xì)節(jié)功能,由于本文主要是講解底層實(shí)現(xiàn)原理,詳細(xì)功能不在此贅述,感興趣的京東同事可以內(nèi)網(wǎng)聯(lián)系咨詢:liushijie3)
技術(shù)選型過(guò)程
通過(guò)上面的示例代碼,相信很多人覺(jué)得這個(gè)功能很簡(jiǎn)單,用 Spring AOP 很快就能搞定了。的確很多團(tuán)隊(duì)也是這么做的,不過(guò)這個(gè)方案并不是那么完美,下面的選型分析中會(huì)有相關(guān)的解釋,請(qǐng)耐心往下看。如下圖,從軟件的開(kāi)發(fā)周期來(lái)看,可織入埋點(diǎn)的時(shí)機(jī)主要有 3 個(gè)階段:編譯期、編譯后和運(yùn)行期。
01編譯期
這里的編譯期指將Java源文件編譯為class字節(jié)碼的過(guò)程。Java編譯器提供了基于 JSR 269 規(guī)范[1]的注解處理器機(jī)制,通過(guò)操作AST (抽象語(yǔ)法樹(shù),Abstract Syntax Tree,下同)實(shí)現(xiàn)邏輯的織入。業(yè)內(nèi)有不少基于此機(jī)制的應(yīng)用,比如Lombok 、MapStruct 、JPA 等;此機(jī)制的優(yōu)點(diǎn)是因?yàn)樵诰幾g期執(zhí)行,可以將問(wèn)題前置,沒(méi)有多余依賴,因此做出來(lái)的工具使用起來(lái)比較方便。缺點(diǎn)也很明顯,要熟練操作 AST并不是想的那么簡(jiǎn)單,不理解前后關(guān)聯(lián)的流程寫出來(lái)的代碼不夠穩(wěn)定,因此要花大量時(shí)間熟悉編譯器底層原理。當(dāng)然這個(gè)過(guò)程對(duì)使用者來(lái)講是沒(méi)有感知的。
02編譯后
編譯后是指編譯成 class 字節(jié)碼之后,通過(guò)字節(jié)碼進(jìn)行增強(qiáng)的過(guò)程。此階段插樁需要適配不同的構(gòu)建工具:Maven、Gradle、Ant、Ivy等,也需要使用方增加額外的構(gòu)建配置,因此存在開(kāi)發(fā)量大和使用不夠方便的問(wèn)題,首先要排除掉此選項(xiàng)??赡苤挥袠O少數(shù)場(chǎng)景下才會(huì)需要在此階段插樁。
03運(yùn)行期
運(yùn)行期是指在程序啟動(dòng)后,在運(yùn)行時(shí)進(jìn)行增強(qiáng)的過(guò)程,這個(gè)階段有 3 種方式可以織入邏輯,按照啟動(dòng)順序,可以分為:靜態(tài) Agent、AOP 和動(dòng)態(tài) Agent。
1、 靜態(tài) Agent
JVM 啟動(dòng)時(shí)使用 -javaagent 載入指定 jar 包,調(diào)用 MANIFEST.MF 文件里的 Premain-Class 類的 premain 方法觸發(fā)織入邏輯。是技術(shù)中間件最常使用的方式,借助字節(jié)碼工具完成相關(guān)工作。應(yīng)用此機(jī)制的中間件有很多,比如:京東內(nèi)部的鏈路監(jiān)控 pfinder、外部開(kāi)源的 skywalking 的探針、阿里的 TTL 等等。這種方式優(yōu)點(diǎn)是整體比較成熟,缺點(diǎn)主要是兼容性問(wèn)題,要測(cè)試不同的 JDK 版本代價(jià)較大,出現(xiàn)問(wèn)題只能在線上發(fā)現(xiàn)。同時(shí)如果不是專業(yè)的中間件團(tuán)隊(duì),還是存在一定的技術(shù)門檻,維護(hù)成本比較高;
2、 Spring AOP
Spring AOP大家都不陌生,通過(guò) Spring 代理機(jī)制,可以在方法調(diào)用前后織入邏輯。AOP 最大的優(yōu)點(diǎn)是使用簡(jiǎn)單,同樣存在不少缺點(diǎn):
- 同一類內(nèi)方法A調(diào)用方法B時(shí),是無(wú)法走到切面的,這是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”。這個(gè)問(wèn)題會(huì)導(dǎo)致內(nèi)部方法調(diào)用的邏輯執(zhí)行不到。在監(jiān)控埋點(diǎn)這個(gè)場(chǎng)景下就會(huì)出現(xiàn)丟數(shù)據(jù)的情況;
- AOP只能環(huán)繞方法,方法體內(nèi)部的邏輯沒(méi)有辦法干預(yù)??坎蹲疆惓E袛噙壿嬍遣粔虻模行﹫?chǎng)景需要是通過(guò)返回值狀態(tài)來(lái)判斷邏輯是否正常,使用介紹里面的示例代碼就是此種情況,這在 RPC 調(diào)用解析里是很平常的操作。
- 私有方法、靜態(tài)方法、final class和方法等場(chǎng)景無(wú)法走切面
3、 動(dòng)態(tài) Agent
動(dòng)態(tài)加載jar包,調(diào)用MANIFEST.MF文件中聲明的Agent-Class類的agentmain方法觸發(fā)織入邏輯。這種方式主要用來(lái)線上動(dòng)態(tài)調(diào)試,使用此機(jī)制的中間件也有很多,比如:Btrace、Arthas等,此方式不適合常駐內(nèi)存使用,因此要排除掉。
04最終方案
選擇通過(guò)上面的分析梳理可知,要實(shí)現(xiàn)重復(fù)代碼的抽象有 3 種方式:基于JSR 269 的插樁、基于 Java Agent 的字節(jié)碼增強(qiáng)、基于Spring AOP的自定義切面。接下來(lái)進(jìn)一步的對(duì)比:
如上表所示,從實(shí)現(xiàn)成本上來(lái)看,AOP 最簡(jiǎn)單,但這個(gè)方案不能覆蓋所有場(chǎng)景,存在一定的局限性,不符合我們追求極致的調(diào)性,因此首先排除。Java Agent 能達(dá)到的效果與 JSR 269 相同,但是啟動(dòng)參數(shù)里需要增加 -javaagent 配置,有少量的運(yùn)維工作,同時(shí)還有 JDK 兼容性的坑需要趟,對(duì)非中間件團(tuán)隊(duì)來(lái)說(shuō),這種方式從長(zhǎng)久看會(huì)帶來(lái)負(fù)擔(dān),因此也要排除。
基于 JSR 269 的插樁方式,對(duì)Java編譯器工作流程的理解和 AST 的操作會(huì)帶來(lái)實(shí)現(xiàn)上的復(fù)雜性,前期投入比較大,但是組件一旦成型,會(huì)帶來(lái)一勞永逸的解決方案,可以很自信的講,插樁實(shí)現(xiàn)的組件是監(jiān)控埋點(diǎn)場(chǎng)景里的銀彈(事實(shí)證明了這點(diǎn),不然也不敢這么吹)。
冰山之上,此組件給使用者帶來(lái)了簡(jiǎn)潔優(yōu)雅的體驗(yàn),一個(gè)jar包,一行代碼,妙筆生花。那冰山之下是如何實(shí)現(xiàn)的呢?那就要從原理說(shuō)起了。
插樁實(shí)現(xiàn)原理
簡(jiǎn)單來(lái)講,插樁是在編譯期基于 JSR 269的注解處理器中操作AST的方式操縱語(yǔ)法節(jié)點(diǎn),最終編譯到class文件中。要做好插樁理解相關(guān)的底層原理是必要的。大多數(shù)讀者對(duì)編譯器相關(guān)內(nèi)容比較陌生,這里會(huì)用較大的篇幅做個(gè)相對(duì)系統(tǒng)的介紹。
Java編譯器是將源碼翻譯成 class 字節(jié)碼的工具,Java編譯器有多種實(shí)現(xiàn):Open JDK的javac、Eclipse的ecj和ajc、IBM的jikes等,javac是公司內(nèi)主要的編譯器,本文是基于Open JDK 1.8 講解。
作為一款工業(yè)級(jí)編譯器內(nèi)部實(shí)現(xiàn)比較復(fù)雜,其涵蓋的內(nèi)容足夠?qū)懸槐緯恕=Y(jié)合本人對(duì)javac源碼的理解,嘗試通俗易懂的講清楚插樁涉及到的知識(shí),有不盡之處歡迎指正。有興趣進(jìn)一步研究的讀者建議閱讀 javac源碼[6]。下面將講解編譯器執(zhí)行流程,相關(guān)javac源碼導(dǎo)航,以及注解處理器如何運(yùn)作。
01編譯器執(zhí)行流程
根據(jù)官網(wǎng)資料[3]javac 處理流程可以粗略的分為 3個(gè)部分:Parse and Enter、Annotation Processing、Analyse and Generate,如下圖:
Parse and EnterParse
階段主要通過(guò)詞法分析器(Scanner)讀取源碼生產(chǎn) token 流,被語(yǔ)法分析器(JavacParser)消費(fèi)構(gòu)造出AST,Java代碼都可以通過(guò)AST表達(dá)出來(lái),讀者可以通過(guò)JCTree查看相關(guān)的實(shí)現(xiàn)。為了讓讀者能更直觀的理解AST,本人做了一個(gè)源碼解析成AST后的圖形化展示:
示例源碼:
token流:[ package ] <- [ com ] <- [ . ] <- …... <- [ } ]解析成AST后如下:
Enter階段主要是根據(jù)AST填充符號(hào)表,此處為插樁之后的流程,因此不再展開(kāi)。
Annotation Processing
注解處理階段,此處會(huì)調(diào)用基于 JSR269 規(guī)范的注解處理器,是javac對(duì)外的擴(kuò)展。通過(guò)注解處理器讓開(kāi)發(fā)者(指非javac開(kāi)發(fā)者,下同)具備自定義執(zhí)行邏輯的能力,這就是插樁的關(guān)鍵。在這個(gè)階段,可以獲取到前一階段生成的AST,從而進(jìn)行操作。
Analyse and Generate
分析AST并生成class字節(jié)碼,此處為插樁之后的流程,不再展開(kāi)。
02相關(guān)javac源碼導(dǎo)航
javac觸發(fā)入口類路徑是:com.sun.tools.javac.Main,代碼如下:
經(jīng)驗(yàn)證Maven 執(zhí)行構(gòu)建調(diào)的是此類中的main方法。其他構(gòu)建工具未做驗(yàn)證,猜測(cè)類似的。在JDK內(nèi)部也提供了javax.tools.ToolProvider#getSystemJavaCompiler的入口,實(shí)際上內(nèi)部實(shí)現(xiàn)也是調(diào)的這個(gè)類里的compile方法。
經(jīng)過(guò)一系列的命令參數(shù)解析和初始化操作,最終調(diào)到真正的核心入口,方法是com.sun.tools.javac.main.JavaCompiler#compile,如下圖:
這里有3個(gè)關(guān)鍵調(diào)用:
- 852行:初始化注解處理器,通過(guò)Main入口的調(diào)用是通過(guò)JDK SPI的方式收集。
- 855 – 858行:對(duì)應(yīng)前面流程圖里的Parse and Enter和Annotation Processing兩個(gè)階段的流程,其中方法processAnnotations便是執(zhí)行注解處理器的觸發(fā)入口。
- 860行:對(duì)應(yīng)Analyse and Generate階段的流程。
03注解處理器
Java從JDK 1.6 開(kāi)始,引入了基于JSR 269 規(guī)范的注解處理器,允許開(kāi)發(fā)者在編譯期間執(zhí)行自己的代碼邏輯。如本文講的UMP監(jiān)控埋點(diǎn)插樁組件一樣,由此衍生出了很多優(yōu)秀的技術(shù)組件,如前面提到的Lombok、Mapstruct等。注解處理器使用比較簡(jiǎn)單,后面示例代碼有注解處理器簡(jiǎn)單實(shí)現(xiàn)也可以參考。這里重點(diǎn)講一下注解處理器整體執(zhí)行原理:
- 編譯開(kāi)始的時(shí)候,會(huì)執(zhí)行方法initProcessAnnotations (compile的截圖852行),以SPI的方式收集到所有的注解處理器,SPI對(duì)應(yīng)接口:javax.annotation.processing.Processor。
- 在方法processAnnotations中執(zhí)行注解處理器調(diào)用方法JavacProcessingEnvironment#doProcessing。
- 所有的注解處理器處理完畢一次,稱為一輪(round),每輪開(kāi)始會(huì)執(zhí)行一次Processor#init方法以便開(kāi)發(fā)者自定義初始化信息,如緩存上下文等。初始化完成后,javac會(huì)根據(jù)注解、版本等條件過(guò)濾出符合條件的注解處理器,并調(diào)用其接口方法Processor#process,即開(kāi)發(fā)者自定義的實(shí)現(xiàn)。
- 在開(kāi)發(fā)者自定義的注解處理器里,實(shí)現(xiàn)AST操作的邏輯。
- 一輪執(zhí)行完成后,發(fā)現(xiàn)新的Java源文件或者class文件,則開(kāi)啟新的一輪。直到不再產(chǎn)生Java或者class文件為止。有的開(kāi)源項(xiàng)目實(shí)現(xiàn)注解處理器時(shí),為了保證自身可以繼續(xù)執(zhí)行,會(huì)通過(guò)這個(gè)機(jī)制創(chuàng)建一個(gè)空白的Java文件達(dá)到目的,其實(shí)這也是理解原理的好處。
- 如果在一輪中未發(fā)現(xiàn)新的Java源文件和class文件產(chǎn)生則執(zhí)行最后一輪(lastRound)。最后一輪執(zhí)行完畢后,如果有新的Java源文件生成,則進(jìn)行Parse and Enter 流程處理。到這里,整個(gè)注解處理器的流程就結(jié)束了。
- 進(jìn)入Analyse and Generate階段,最終生成class,完成整體編譯。
接下來(lái)將通過(guò)UMP監(jiān)控埋點(diǎn)功能來(lái)展示怎么在注解處理器中操作AST。
源碼示例
關(guān)于AST 操作的探索,早在2008年就有相關(guān)資料了[4],Lombok、Mapstruct都是開(kāi)源的工具,也可以用來(lái)參考學(xué)習(xí)。這里簡(jiǎn)單講一個(gè)示例,展示如何插樁。
注解處理器使用框架
上圖展示了注解處理器具體的基本使用框架,init、process是注解處理器的核心方法,前者是初始化注解處理器的入口,后者是操作AST的入口。javac還提供了一些有用的工具類,比如:
- TreeMaker:創(chuàng)建AST的工廠類,所有的節(jié)點(diǎn)都是繼承自JCTree,并通過(guò)TreeMaker完成創(chuàng)建。
- JavacElements:操作Element的工具類,可以用來(lái)定位具體AST。
向類中織入一個(gè)import節(jié)點(diǎn)
這里舉一個(gè)簡(jiǎn)單場(chǎng)景,向類中織入一個(gè)import節(jié)點(diǎn):
為方便理解對(duì)代碼實(shí)現(xiàn)做了簡(jiǎn)化,可以配合注釋查看如何織入:
總的來(lái)說(shuō),織入邏輯是通過(guò)TreeMaker創(chuàng)建AST 節(jié)點(diǎn),并操作現(xiàn)有AST織入創(chuàng)建的節(jié)點(diǎn),從而達(dá)到了織入代碼的目的。
反思與總結(jié)
到這里,講了埋點(diǎn)組件的使用、技術(shù)選型、以及插樁相關(guān)的內(nèi)容,最終開(kāi)發(fā)出來(lái)的組件在工作中也起到了很好的效果。但是在這個(gè)過(guò)程中有一些反思。
插樁門檻高
通過(guò)前面的內(nèi)容不難得出一個(gè)事實(shí),要實(shí)現(xiàn)一個(gè)小小的功能,需要開(kāi)發(fā)者花費(fèi)大量的精力去學(xué)習(xí)理解編譯器底層的一些原理。從ROI角度看,投入和產(chǎn)出是嚴(yán)重不成正比的。為了能提供可靠的實(shí)現(xiàn),個(gè)人花費(fèi)了大量業(yè)余時(shí)間去做技術(shù)選型分析和編譯器相關(guān)知識(shí),可以說(shuō)是純靠個(gè)人的興趣和一股倔勁一點(diǎn)點(diǎn)搭建起來(lái)的,細(xì)節(jié)是魔鬼,這個(gè)踩坑的過(guò)程比較枯燥。實(shí)際上插樁機(jī)制有很多通用的場(chǎng)景可以探索,之所以一直很少見(jiàn)到此類機(jī)制的應(yīng)用。主要是其門檻較高,對(duì)大多數(shù)開(kāi)發(fā)者來(lái)說(shuō)比較陌生。因此降低開(kāi)發(fā)者使用門檻才能讓一些想法變成現(xiàn)實(shí)。做一把好用的錘子,比砸入一個(gè)釘子要更有價(jià)值。在監(jiān)控埋點(diǎn)插樁組件真正落地時(shí),在項(xiàng)目?jī)?nèi)做了一定抽象,并支持了一些開(kāi)關(guān)、自定義鏈路跟蹤等功能。但從作用范圍來(lái)講是不夠的,所以下一步計(jì)劃做一個(gè)插樁方面的技術(shù)框架,從易用性、可維護(hù)性等方面做好進(jìn)一步的抽象,同時(shí)做好可測(cè)試性相關(guān)工作,包含驗(yàn)證各版本JDK的支持、各種Java語(yǔ)法的覆蓋等。
插樁是把雙刃劍
javac官方對(duì)修改AST的方式持保守態(tài)度,也存在一些爭(zhēng)議。然而時(shí)間是最好的驗(yàn)證工具,從Lombok 等組件的發(fā)展看出,插樁機(jī)制是能經(jīng)住長(zhǎng)久考驗(yàn)的。如何合理利用這種能力是非常重要的,合理使用可使系統(tǒng)簡(jiǎn)潔優(yōu)雅,使用不當(dāng)就等于在代碼里下毒了。所以要有節(jié)制的修改AST,要懂前后運(yùn)行機(jī)制,圍繞通用的場(chǎng)景使用,避免濫用。
認(rèn)識(shí)當(dāng)前上下文環(huán)境的局限性
遇到問(wèn)題時(shí),如果在當(dāng)前的上下文環(huán)境里找不到合適的解決方案,從這個(gè)環(huán)境跳出來(lái)?yè)Q個(gè)維度也許能看到不同的風(fēng)景。就像物理機(jī)到虛擬機(jī)再到現(xiàn)在的容器,都是打破了原來(lái)的規(guī)則逐步發(fā)展出新的技術(shù)生態(tài)。大多數(shù)的開(kāi)發(fā)工作都是基于一個(gè)高層次的封裝上面進(jìn)行,而突破往往都是從底層開(kāi)始的,適當(dāng)?shù)臅r(shí)候也可以向下做一些探索,可能會(huì)產(chǎn)生一些有價(jià)值的東西。