構建Java Agent,而不是使用框架

ImportNew發表於2015-04-21

Java annotations自從被引入到Java之後,一直扮演著整合各種API的作用,尤其是對大型應用框架而言。在這方面,Spring和Hibernate都是Java annotation應用的好例子——僅僅需要增加幾行簡單的Java annotation程式碼,就可以實現非常複雜的程式邏輯。儘管對這些API(的寫法)存在一些爭論,但是大多數程式設計師認為,只要使用得當,這種宣告式程式設計在形式上還是很有表達能力的。不過,只有少量程式設計師基於Java annotation來編寫框架API,或者應用程式中介軟體。之所以造成這種現象很主要的一個原因是,程式設計師們認為Java annotation會降低程式碼的可讀性。在本文中,我就想告訴大家,實現這些基於annotation的API其實並不是完全無用的,只要使用恰當的工具,其實你也並不需要了解太多Java內部函式的知識。

在 實現基於annotation的API時,很明顯的一個問題就是:這些API在Java執行時是不會被JVM處理的。這樣造成的結果就是,你沒法給一個用 戶annotation賦予一個具體的含義。例如:如果我們定義了一個@Log annotation,然後我們期望在標註了@Log的地方,每呼叫一次就形成一條日誌記錄。

class Service {
  @Log
  void doSomething() { 
    // do something ...
  }
}

單靠@Log 標註本身寫在哪裡,是不可能完成執行程式邏輯的任務的,這就需要標註的使用者去發起生成日誌的任務。明顯,這種工作原理讓annotation看上去毫無 意義,因為再呼叫doSomething方法的時候,我們根本無法去觀察生成的log裡面相應的狀態。因此,annotation僅僅是作為一個標記而存 在,對程式邏輯來說毫無貢獻可言。

填坑

為了克服上述的功能性侷限,很多基於標註的框架都採用了子類覆蓋類方法的模式,來賦予特定標註相關的程式邏輯功能。這種方法普遍使用了物件導向的整合機制。對於我們上面提到的@Log標註來說,子類實現機制會產生一個類似於下面的類LoggingService:

class LoggingService extends Service {
  @Override
  void doSomething() { 
    Logger.log("doSomething() was called");
    super.doSomething();
  }
}

當然,上面定義這些類的程式碼通常是不需要程式設計師手寫的,而是在Java執行時,通過諸如 cglib或Javassst這樣的庫來自動生成。上面提到的兩個庫都提供了簡易的API,可以用於生成增強型的子類程式。這種把類定義的過程放到執行時 的做法,其比較好的一個副作用是,在不特別規定程式規範,也不用修改已有的使用者程式碼的前提下,能夠有效實現 logging框架的功能。這樣就可以避免“顯式建立風格”,也就不用新建一個Java原始檔去手寫程式碼了。

但是,可伸縮性好嗎?

然而,上面的解決方案又帶來了另一個不足。我們通過自動生成子類的方式 實現標註的程式邏輯,必須保證在例項化的時候不能使用父類的建構函式。否則呼叫標註方法的時候,還是無法完成呼叫新增日誌的功能:原因很明顯,用父類的構 造函式例項化物件,無法建立出包含子類覆蓋方法的正確例項(這是基本的物件導向多型的概念——譯者注)。更糟糕的是——當使用上述方法進行執行時程式碼生成 的時候——LoggingService類無法直接被例項化,因為Java編譯器在編譯的時候,執行時期間生成的類程式碼還根本就不存在。

基於上述原因,Spring或者Hibernate這些框架使用了 “物件工廠”的模式。在其框架邏輯的範疇內,不允許直接(通過建構函式)對物件進行例項化,而是通過工廠類來完成新建物件的工作。這種方式在Spring 設計之初就被採納,用來管理各種bean。Hibernates採用了相似的做法,大多數Hibernates的例項被視為查詢的結果物件,因此也不是顯 式地來例項化的。然而,有一個特例是,當試圖儲存一個在資料庫中還不存在的物件例項的時候,Hibernates的使用者需要用Hibernates返回 的物件來替換之前儲存的物件例項。從這個例子來看Hibernates的問題,忽略上述的替換會造成一個普通的初學者錯誤。除此之外,幸虧有了這些工廠 類,才能讓子類化的方法對框架使用者透明,因為Java的型別系統可以用子類例項來替代其父類。因此,只要是使用者需要呼叫自定義服務的地方,都可以用到 LoggingService的例項。

很遺憾,這種採用工廠類來建立物件的方法雖然(在理論上)被證明是可行的,但(在實際中)用來實現我們提 出的@Log標註的邏輯,卻依然非常困難,因為這種方法必須要讓每一個標註類都去構建一個對應的工廠類方法。很顯然,這麼做會讓我們的程式碼模板的尺寸顯著 增長。更有甚者,為了避免在產生日誌的方法中把邏輯寫死(硬編碼),我們甚至會為logging標註類建立不止一套程式碼模板。還有,如果有人不小心呼叫了 建構函式,那麼就可能會有微妙的bug出現,因為在這種情況下,產生的物件例項對標註的處理方式很可能跟我們預期的是不同的。再有,工廠類的設計其實並不 容易。如果我們要新增一個@Log標記到某一個類上面,但是這個類已經被定義成了一個Hibernates bean了,那怎麼辦? 這聽上去好像沒什麼意義,但是在操作的時候就必須要設計額外的配置去把我們定義的工廠類和框架自帶的工廠類整合起來。最後一點,也是結論,採用工廠模式寫 出的程式碼臃腫不堪,如果還要做到讓這些程式碼同時兼備可讀性,然後還要所使用的框架完美結合,這實現代價也太高了。這就是為什麼我們要引入Java Agent的原因。Java agent這種被低估了的模式能夠提供一種優秀的替代方案,用來實現我們想要的子類化方法。

一個簡單的Agent

Java agent是用一個簡單的jar檔案來表示的。跟普通的Java程式很相似,Java agent定義了一些類作為入口點。 這些作為入口點的類需要包含一個靜態方法,這些方法會在你原本的Java程式的main方法呼叫之前被呼叫:

class MyAgent {
  public static void premain(String args, Instrumentation inst) {
    // implement agent here ...
  }
}

關於處理Java agent時最有趣的部分,是premain方法中的第二個引數。這個引數是以一個Instrumentation介面的實現類例項的形式存在的。這個接 口提供了一種機制,能夠通過定義一個ClassFileTransformer,來干預對Java類的載入過程。有了這種轉設施,我們就能夠在Java類 被使用之前,去實現對類邏輯的強化。

這個API的使用一開始看上去不那麼直觀,很可能是一種新的(程式設計模式)挑戰。Class檔案的轉換是通過修改編 譯過後的Java類位元組碼來完成的。 實際上,JVM並不知道什麼是Java語言, 它只知道什麼是位元組碼。也正是因為位元組碼的抽象特性,才讓JVM能夠具有執行多種語言的能力,例如Groovy, Scala等等。這樣一來,一個註冊了的類檔案轉換器就只需要負責把一個位元組碼序列轉換成另外一個位元組碼序列就可以了。

盡 管已經有了像ASM、BCEL這樣的類庫,提供了一些簡易的API,能夠對編譯過的Java類進行操作,但是使用這些庫的門檻較高,需要開發者對原始的字 節碼的工作原理有充分的瞭解。更可怕的是,想直接操作位元組碼並做到不出問題,這基本上就是一個冗長拉鋸的過程,甚至非常細微的錯誤,JVM也會直接丟擲又 臭又硬的VerifierError。不過還好,我們還有更好,更簡單的選擇,來對位元組碼進行操作。

Byte Buddy這是一個我編寫,並負責維護的工具庫。這個庫提供了簡潔的API,用來對編譯後的Java位元組碼進行操作,也可以用來建立Java agent. 從某些方面來看,Byte Buddy也是一個程式碼生成的工具庫,這和cglib以及Javassit的功能很類似。然而,跟他們不同的是,Byte Buddy還能夠提供統一的API,實現子類化,以及重定義現有類的功能。在本文中,我們只會研究如何用Java agent來重定義一個類。如果讀者有更多的興趣,可以參照Byte Buddy’s webpage which offers a detailed tutorial ,那個裡面有很詳細的描述。

使用Byte Buddy建立simple agent

Byte Buddy提供的一種定義手段採用了依賴注入的方法。其原理是這樣的:使用一個攔截器類——這個類是一個POJO——來獲得標註引數所需要的資訊。例如: 將Byte Buddy的@Origin標註使用在一個Method型別的引數上,Byte Buddy即可推演出攔截器目前要攔截的就是method變數。這樣,我們就可以定義一個泛型的攔截器,只要method一出現,就會被攔截器攔截。

class LogInterceptor {
  static void log(@Origin Method method) {
    Logger.log(method + " was called");
  } 
}

當然,Byte Buddy可以作用於多個標註上。

但是,這些攔截器如何能夠表示我們提出的日誌框架所需要的程式碼邏輯呢?到目前為止,我們僅僅是定義了一個攔截器,用來攔截我們的method呼叫。還缺少對 於method所在的原始程式碼序列的呼叫。幸運的是,Byte Buddy提供的手段是可組合(compose)的。首先我們定義一個MethodDelegation類,並將其組合到LogInterceptor 中,這個攔截器類會在每一次method被呼叫的時候去預設呼叫攔截器的靜態方法。以此為起點,我們可以通過一種序列呼叫的方式,將代理類和原先呼叫 method的程式碼組合起來,就跟Super MethodCall表示的一樣:

class LogAgent {
MethodDelegation.to(LogInterceptor.class)
  .andThen(SuperMethodCall.INSTANCE)
}

最後,我們還需要通知Byte Buddy,將被攔截的方法與特定的邏輯繫結。就像我們在前面闡述的一樣,我們想把一段邏輯(就是記錄日誌的功能——譯者注施加到每一個加了@Log標 注的地方。在Byte Buddy中,通過使用ElementMatcher方法,(被標註的)方法就會被識別出來,這和Java 8的斷言機制很類似。在靜態工具類ElementMatcher中,我們可以用相應的matcher來識別我們(用@Log)標註後的方 法:ElementMatchers.isAnnotatedWith(Log.class)。

通 過上述的方式,我們就實現一個agent的定義,可以完成我們提出logging framework的要求。就跟我們在前文敘述的原理一樣,Byte Buddy提供了一套工具API來構建Java agent,這些工具API則是基於可對(編譯後的)class進行修改的(JavaEE原生)API。就跟下面這段API一樣,其設計上與面向領域語言 相似,從程式碼的字面上就可以輕鬆弄懂其含義。顯然,定義一個agent就僅僅需要幾行程式碼而已:

class LogAgent {
  public static void premain(String args, Instrumentation inst) {
    new AgentBuilder.Default()
      .rebase(ElementMatchers.any())
      .transform( builder -> return builder
                              .method(ElementMatchers.isAnnotatedWith(Log.class))
                              .intercept(MethodDelegation.to(LogInterceptor.class)
                                  .andThen(SuperMethodCall.INSTANCE)) )
      .installOn(inst);
  }
}

注意,上面這段最簡Java agent程式碼不會對原有的程式碼產生干擾。對於已有的程式碼來說,附加的邏輯程式碼就彷彿是直接把硬編碼插入到帶有標註的方法處一樣(類似於C++內聯的效果——譯者注)

現實情況是怎樣的?

當然,我們在這裡展示的基於agent的logger只是一個教學例子。通常情況下,那些覆蓋面很廣的框架也都會提供類似的 功能特性,直接呼叫即可。例如Spring或者Dropwizard的類似功能都很好用。然而,這些框架提供的功能基本上都著眼於處理(具體的)程式設計問 題。對大多數軟體應用來說,這種思路也許還不錯。再者,這些框架的思路有時也著眼於大規模的(應用)。如此一來,使用這些框架來做事,就有可能造成很多問 題,通常是會導致有漏洞的抽象邏輯,並可能進一步造成軟體運維成本的爆炸性增長。這種假設絕對不是危言聳聽,特別是在你的應用規模增長,需求變更頻繁和分 叉的時候,又要用框架提供的功能來解決問題,就很可能出現上述麻煩。

相反的做法,我們可以去構建一個更加有針對性的框架或者類庫,採用“挑選&融入”的風格,每次用一個完備的元件去替換原有的存在問題的元件。如果這樣還不能解決問題,我們還可以乾脆去搞一個自定義的解決 方案,並保證新的解決方案不會影響到應用中的原有程式碼。據我們所知,第二種做法對於JVM來說實現起來有些困難,主要原因是因為Java的強型別機制造成 的。不過,通過使用Java agents,克服這些型別限制也不是完全不可能的。

概括地來說,我認為所有涉及到橫向操作的概念,都應該採用agent驅動的方式 來實現,並且應該使用針對性的框架,而不是採用那些大得嚇死人的框架給你提供的內建方法。我也真心希望有更多的應用能夠考慮採用上述的方法。在一般情況 下,使用agent來註冊特定方法的listener,並加以實現,是完全可以滿足需求的。根據我對大體積Java應用程式碼的觀察,這種間接的模組編碼方 法能夠避免(模組間)的強耦合性。還有一個甜蜜的副作用就是,這種方法讓程式碼的測試變得很容易。跟測試的原理相同,在啟動應用的時候不載入agent,就 能按需關閉相應的應用特性(例如本文中的logging例子)。所有這些操作都不需要改動任何一行程式碼,也就不會造成程式的崩潰,因為JVM會自動忽略掉 那些在執行時無法解析的標註。安全、日誌、快取還有很多其他的方面,有很多理由,需要採用本文的方式來處理。因此,我們要說,採用agent,不要用框 架。

相關文章