almost 3 years ago

Translated from "Runtime Code Generation with Byte Buddy - Create agents, run tools before main() loads, and modify classes on the fly" by FABIAN LANGE, Java Magazine, November/December 2015, page 19. Copyright Oracle Corporation.

用Byte Buddy於執行期生成程式碼

建立代理人、在main()之前執行工具與程式執行中修改類別

能在JVM直譯器或JIT編譯器執行前修改程式的bytecode是Java平台常被遺忘的一項能力,當監控工具(profilers)或是物件關係對應(object-relational mapping)函式庫使用這能力時,應用程式開發者卻鮮少使用它。這突顯出未開發的潛能,因為於執行期生成程式碼讓橫切關注點(cross-cutting concerns)如記錄(log)與安全檢查(security)的實作變容易,或是改變第三方套件的行為(例如提供測試用的模擬物件),又或者是撰寫效能資料收集的代理人。

可惜的是,直到今日,在執行期間產生bytecode是困難的,現有三個生成bytecode的函式庫:

這些函式庫的設計是用來撰寫或修改Java程式中特定的bytecode指令,要使用它們,您需要了解bytecode是如何運作的,與Java原始碼相比這是相當難以瞭解的,此外,這些函式庫較Java原始碼難以使用與測試,因為Java編譯器無法檢驗,例如呼叫一個函式時參數順序是否合乎它的signature,又或者是否違反Java語言格規。最後,因為它們的歲數,這些函式庫不支援Java的新特性,像是annotation、泛型(generics)、預設函式及lambdas。

下述的例子說明您如何使用ASM函式庫實作一個函式呼叫另一個僅一個字串參數的靜態函式:

methodVisitor.visitVarInsn(Opcodes.ALOAD, 0);
methodVisitor.visitMethodInsn(
  Opcodes.INVOKESTATIC
  "com/instana/agent/Agent"
  "record"
  "(Ljava/lang/String;)V"
)

cglib與Javassist沒有差太多,它們都需要使用bytecode與以字串表達signatures,如您所見,它們看起來像組合語言而非Java。

Byte Buddy是採用不同作法解決此問題的函式庫,Byte Buddy的使命是讓不懂Java (bytecode)指令的開發者也能使用執行期間程式生成。此函式庫亦支援所有的Java特性,且不限於動態產生介面的實作(JDK內建的proxy utilities也是用這方式)。Byte Buddy的API將一般Java函式呼叫背後的bytecode運算元全部抽象化,不過,仍保留Byte Buddy用來實作的建築在ASM函式庫之上的後門。

注意: 本文中所有範例都是用Byte Buddy 0.6的API

Hello World, Byte Buddy

下面來自Byte Buddy文件的HelloWorld例子(Listing 1)呈現出在執行期間簡潔地建立一個新類別所需的一切。

Listing 1.
Class<? extends Object> clazz = new ByteBuddy()
  .subclass(Object.class)
  .method(ElementMatchers.named("toString"))
  .intercept(FixedValue.value("Hello World!"))
  .make()
  .load(getClass().getClassLoader(), ClassLoadingStrategy.Default.WRAPPER)
  .getLoaded();
assertThat(clazz.newInstance().toString(), is("Hello World!"));

Byte Buddy所有API都是可串聯的建構者風格(builder-style fluent) API並支援函數式編程。從告訴Byte Buddy您想繼承某個類別開始,就本例來說,您單純繼承Object,當然您可以繼承任何非final的類別,Byte Buddy保證回傳像Class<? extends SuperClass>這樣的泛型型別。現在,您擁有子類別的建構者(builder),您可以請Byte Buddy攔截(intercept)名為toString的函式,當函式被呼叫時,回傳一個固定值而非原先java.lang.Object所定義的值。

您可能會想知道這裡所謂的攔截(intercept),通常,當您繼承某個類別,您一般會用覆寫(override)的方式改變繼承來的函式,攔截是從AOP (aspect-oriented programming)來的術語,描述當一個函式被呼叫時要做什麼事的一種強而有力的概念。

當您完成宣告這子類別該怎麼運作,您呼叫make取得所謂Unloaded的類別表述,這表述的行為和.class檔是一樣的,事實上,它甚至能存成class檔。

最後,如Listing 1中所示,您可以用類別載入器載入這類別並取得載入類別的參照(reference),當以Byte Buddy開始,載入時使用的ClassLoadingStrategy通常沒這麼重要,但是,某些情況下,為了可見度因素或是想以特定順序載入,您需要特定的類別載入器來載入類別。

要注意的是Byte Buddy建立的類別與一般類別無異,不像其他函式庫或代理,沒有留下任何痕跡,產生的程式就像是Java編譯器為了實現此子類別所產生的一樣。

ElementMatchers and Implementations

當您使用Byte Buddy替類別新增或修改行為,最常做的事是找尋成員變數(fields)、建構子(constructors)或是函式(methods)。為簡化這些作業,Byte Buddy提供像是hasParameter()isAnnotatedWith()等大量的元素比對器(ElementMatchers)檢查函式的signature。它也有isEquals()isSetter()等別名用慣用的Java命名規則來比對函式名稱,使用這些預先定義的比對器,可以簡單扼地要描述要攔截的函式而不會很瑣碎。此外,可以客製化實作想要的元素比對器以涵蓋更複雜的使用情境。

另外,還有許多預先定義的替換用的Implementations可以在intercept()中使用,舉二個例子,MethodCall用參數呼叫別的函式,Forwarding用相同的參數呼叫另一個物件的相同函式。

更有力的攔截機制是MethodDelegation:當委託給一個函式,您可以先執行一段自訂的程式然後再轉給原先的實作,此外您仍可以用@Origin annotation動態取得原先呼叫者的資訊。如Listing 2所示,當委託給另一個函式,您仍可以動態取得原先呼叫者的資訊,待會說明。

Listing 2.
public static class Agent {
  public static String record(@Origin Method m) {
    System.out.println(m + " called");
  }
}

Class<?> clazz = new ByteBuddy()
  .subclass(Object.class)
  .method(ElementMatchers.isConstructor())
  .intercept(MethodDelegation.to(Agent.class).andThen(SuperMethodCall.INSTANCE))
// & make instance;

若有多個攔截標的,MethodDelegation自動查找最符合函式signature的代理,雖然查找是強而有力且可以客製化,我建議讓查找間單易於理解,在函式被執行後,感謝andThen(SuperMethodCall.INSTANCE),原先的呼叫繼續執行。

查找到的函式可以接受大量被標註(annotated)的參數,您可以用@Argument(position)@AllParameters取得原先函式的參數,用@Origin取得原先函式的資訊,參數的型別可以是java.lang.reflect.Methodjava.lang.Class甚至是java.lang.invoke.MethodHandle (若使用Java 7或之後的版本),這些參數提供關於函式從哪被呼叫的資訊,對於除錯相當有幫助;假設不同的函式被相同的攔截函式所攔截,也能提供不同程式路徑的資訊。

Byte Buddy提供@DefaultCall@SuperCall參數,用來呼叫原先的函式或上層類別的函式。

模擬 (Mocking)

有時您想為某個可能在執行期間發生的情境寫單元測試,但您無法為測試確實觸發那個情境,例如,如Listing 3所示,亂數產生器必須產生一個特定的結果才能讓您測試程式流程。

Listing 3.
public class Lottery {
  public boolean win() {
    return random.nextInt(100) == 0;
  }
}

Random mockRandom = new ByteBuddy()
  .subclass(Random.class)
  .method(named("nextInt"))
  .intercept(value(0))
  // & make instance;

Lottery lottery = new Lottery(mockRandom);
assertTrue(lottery.win());

Byte Buddy提供各式攔截器,所以很容易撰寫模擬物件(mocks)或探針(spies)。但是,為了更多的模擬物件,我建議改用專屬的模擬函式庫,事實上,極受歡迎的模擬函式庫Mockito目前已用Byte Buddy重新改寫。

到目前為止,我已經用subclass()建立一個實質上像打類固醇的子類別。Byte Buddy有另外二種操作模式:rebaseredefine,二者都可以改變指定類別的實作:rebase保有既有的程式,而redefine整個覆寫,但是,這些調整有個限制:只能更改已載入的類別,Byte Buddy須以Java agent的形式運作。

在單元測試或其他特殊情況,您可以確信Byte Buddy初次載入一個類別,在載入過程中修改其實作。針對這情況,Byte Buddy支援一個稱為TypeDescription的概念,表達Java類別處於未載入的狀態,您能為(尚未載入的)classpath設置一個類別池(pool),然後在載入類別前修改它們,例如,我可以用Listing 4的程式修改Listing 3的Lottery類別。

Listing 4.
TypePool pool = TypePool.Default.ofClassPath();
new ByteBuddy()
  .redefine(pool.describe("Lottery").resolve(), ClassFileLocator.ForClassLoader.ofClassPath())
  .method(ElementMatchers.named("win"))
  .intercept(FixedValue.value(true))
  // & make and load;

assertTrue(new Lottery().win());

注意:您不能在呼叫describe時用Lottery.class,因為這會在Byte Buddy修改前載入這類別,一旦Java類別被載入,已經不可能正常地卸載該類別。

使用Byte Buddy建立AOP代理人

在接下來的例子中,我建立一個監控效能與紀錄的代理人,它會攔截對JAX-WS端點的程式呼叫,然後印出這個程式呼叫花費多少時間。類似這樣的代理人,需要遵守在java.lang.instrument的Javadoc所描述的公約。使用命令列參數-javaagent來啟動它,且會在真正的main函式之前被執行(因此,稱為premain),通常,代理人會為自己安裝一個掛鉤(hook),使其能在一般程式載入類別前被觸發,這繞過無法修改已載入類別的限制。代理人是可以疊加的,且您能愛用多少就用多少。Listing 5展示一個代理人的程式。

Listing 5.
public class Agent {
  public static void premain(String args, Instrumentation inst) {
    new AgentBuilder.Default()
      .rebase(isAnnotatedWith(Path.class))
        .transform((b, td) ->
          b.method(
            isAnnotatedWith(GET.class)
            .or(isAnnotatedWith(POST.class)))
          .intercept(to(Agent.class)))
        .installOn(inst);
  }

  @RuntimeType
  public static Object profile(@Origin Method m, @SuperCall Callable<?> c) throws Exception {
    long start = System.nanoTime();
    try {
      return c.call();
    } finally {
      long end = System.nanoTime();
      System.out.println("Call to " + m + " took " + (end - start) +" ns");
    }
  }
}

取得預設的AgentBuilder後,我告訴它該rebase哪個類別。這例子只修改有javax.ws.rs.Path annotation的類別,接著,我告訴它如何轉換這些類別,例子中,代理人會攔截有GETPOST標註的函式,然後委託給profile函式。要讓這能運作,必須用installOn()將代理人掛載在Instrumentation中。

profile函式本身使用三個annotations:RuntimeType告知Byte Buddy將回傳型別Object調整成跟被攔截函式真正的回傳型別一樣;Origin取得真正被攔截函式的參照,好用來列印函式名稱;SuperCall執行原有的函式。和前個例子相較,我需要自己執行super call (原有的函式),因為我希望程式能在原有的函式呼叫前與呼叫後都被執行,如此才能量測時間。

比較Byte Buddy實作函式攔截的方法與Java預設的InvocationHandler,您會發現Byte Buddy的方法優化許多,因為攔截過程中會帶入必要的參數,但InvocationHandler卻需要滿足下列的介面:

Object invoke(Object proxy, Method method, Object[] args)

若參數與回傳值是基礎型別(primitive type),這優化帶來的好處更是顯而易見的,因為基礎型別都需要自動包成物件(autobox)才能使用,另外RuntimeType讓Byte Buddy盡可能減少包裝(boxing)的次數。雖然JVM的最佳化盡可能避免包裝,但對於像InvocationHandler這樣複雜的介面,卻不是總是適用的。

不借助-javaagent的方式使用代理人

使用代理人在程式執行期間產生或修改程式是相當有用的,但是,強制使用-javaagent參數有時候反而是不便的,借助原先設計用來在執行期間載入檢測(diagnostic)工具的Java Attach API,Byte Buddy提供一個便利的功能,在正在執行的JVM裝上(attach)代理人。您額外需要包含ByteBuddyAgent工具類別的byte-buddy-agent.jar檔案,您呼叫ByteBuddyAgent.installOnOpenJDK(),它所做的事和用-javaagent啟動JVM所做的事一模一樣。唯一不同的是,用這個方法您不是呼叫installOn(inst)而是改呼叫installOnByteBuddyAgent()。[譯註:參考Listing 5]

結論

除了既有的JDK動態代理與三個常見的第三方bytecode修改函式庫,Byte Buddy填補了一個很重要的缺口,它可串聯的API使用泛型,因此您不會遺失您正在修改的型別,若用其他方法很容易發生 [譯註:指遺失這件事]。Byte Buddy提供豐富的比對器(matchers)、轉換器(transformers)與實作(implementations) [譯註:要再查查更好的翻譯]集合,且允許藉由lambdas使用這些功能,讓程式相對簡潔易讀。

就結果來說,Byte Buddy對不熟悉讀bytecodes與低層運作的開發者來說是可以完全理解的,隨著接下來的0.7版,Byte Buddy將會支援泛型的全部基礎建設,如此,Byte Buddy將能在執行期間更輕易地與泛型及型別annotation互動,身為一個寫過許多bytecode操作(bytecode-handling)程式的開發者,我使用也推薦這個函式庫。 [編輯:Byte Buddy獲得2015 Java研討會的Duke’s Choice Award]

LEARN MORE
JVM Specification for Java 8
Byte Buddy on Stack Overflow

譯者的告白
術語或專有名詞恐怕是翻譯時最頭痛的事之一,例如前篇文章出現的annotation翻成註釋,後來想想還是改回英文,因為看到「註釋」真的可以聯想回annotation嗎?我不太肯定。這篇文章中的一些術語,像builder-style fluent APIs,其實也沒有慣用的中文翻譯,這讓我想起侯捷在《C++ Primer中文版》的導讀中對於術語翻譯還是不翻譯的抉擇,最後我決定採取的方式:除非有非常慣用的中文術語或造成讀起來不通順,不然還是保留英文術語吧!

← JCommander:解析命令列有更好的方法 JVM如何取得、載入並執行函示庫 →