通過使用Byte Buddy便捷創建Java Agent

Java agent 是在另外一個 Java 應用(“目標”應用)啟動之前要執行的 Java 程序,這樣 agent 就有機會修改目標應用或者應用所運行的環境。在本文中,我們將會從基礎內容開始,逐漸增強其功能,借助字節碼操作工具 Byte Buddy,使其成為高級的 agent 實現。

在最基本的用例中,Java agent 會用來設置應用屬性或者配置特定的環境狀態,agent 能夠作為可重用和可插入的組件。如下的樣例描述瞭這樣的一個 agent,它設置瞭一個系統屬性,在實際的程序中就可以使用該屬性瞭:

public class Agent {
  public static void premain(String arg) {
    System.setProperty("my-property", “foo”);
  }
}

如上面的代碼所述,Java agent 的定義與其他的 Java 程序類似,隻不過它使用premain方法替代 main 方法作為入口點。顧名思義,這個方法能夠在目標應用的 main 方法之前執行。相對於其他的 Java 程序,編寫 agent 並沒有特定的規則。有一個很小的區別在於,Java agent 接受一個可選的參數,而不是包含零個或更多參數的數組。

如果要使用這個 agent,必須要將 agent 類和資源打包到 jar 中,並且在 jar 的 manifest 中要將Agent-Class屬性設置為包含premain方法的 agent 類。(agent 必須要打包到 jar 文件中,它不能通過拆解的格式進行指定。)接下來,我們需要啟動應用程序,並且在命令行中通過 javaagent 參數來引用 jar 文件的位置:

java -javaagent:myAgent.jar -jar myProgram.jar我們還可以在位置路徑上設置可選的 agent 參數。在下面的命令中會啟動一個 Java 程序並且添加給定的 agent,將值 myOptions 作為參數提供給premain方法:

java -javaagent:myAgent.jar=myOptions -jar myProgram.jar通過重復使用javaagent命令,能夠添加多個 agent。

但是,Java agent 的功能並不局限於修改應用程序環境的狀態,Java agent 能夠訪問 Java instrumentation API,這樣的話,agent 就能修改目標應用程序的代碼。Java 虛擬機中這個鮮為人知的特性提供瞭一個強大的工具,有助於實現面向切面的編程。

如果要對 Java 程序進行這種修改,我們需要在 agent 的premain方法上添加類型為Instrumentation的第二個參數。Instrumentation 參數可以用來執行一系列的任務,比如確定對象以字節為單位的精確大小以及通過註冊ClassFileTransformers實際修改類的實現。ClassFileTransformers註冊之後,當類加載器(class loader)加載類的時候都會調用它。當它被調用時,在類文件所代表的類加載之前,類文件 transformer 有機會改變或完全替換這個類文件。按照這種方式,在類使用之前,我們能夠增強或修改類的行為,如下面的樣例所示:

public class Agent {
 public static void premain(String argument, Instrumentation inst) {
   inst.addTransformer(new ClassFileTransformer() {
     @Override
     public byte[] transform(
       ClassLoader loader,
       String className,
       Class<?> classBeingRedefined, // 如果類之前沒有加載的話,值為 null
       ProtectionDomain protectionDomain,
       byte[] classFileBuffer) {
       // 返回改變後的類文件。
     }
   });
 }
}

通過使用Instrumentation實例註冊上述的ClassFileTransformer之後,每個類加載的時候,都會調用這個 transformer。為瞭實現這一點,transformer 會接受一個二進制和類加載器的引用,分別代表瞭類文件以及試圖加載類的類加載器。

Java agent 也可以在 Java 應用的運行期註冊,如果是在這種場景下,instrumentation API 允許重新定義已加載的類,這個特性被稱之為“HotSwap”。不過,重新定義類僅限於替換方法體。在重新定義類的時候,不能新增或移除類成員,並且類型和簽名也不能進行修改。當類第一次加載的時候,並沒有這種限制,如果是在這樣的場景下,那classBeingRedefined會被設置為 null。

Java 字節碼與類文件格式

類文件代表瞭 Java 類編譯之後的狀態。類文件中會包含字節碼,這些字節碼代表瞭 Java 源碼中最初的程序指令。Java 字節碼可以視為 Java 虛擬機的語言。實際上,JVM 並不會將 Java 視為編程語言,它隻能處理字節碼。因為它采用二進制的表現形式,所以相對於程序的源碼,它占用的空間更少。除此之外,將程序以字節碼的形式進行表現能夠更容易地編譯 Java 以外的其他語言,如 Scala 或 Clojure,從而讓它們運行在 JVM 上。如果沒有字節碼作為中間語言的話,那麼其他的程序在運行之前,可能還需要將其轉換為 Java 源碼。

但是,在代碼處理的時候,這種抽象卻帶來瞭一定的成本。如果要將ClassFileTransformer應用到某個類上,那我們不能將該類按照 Java 源碼的形式進行處理,甚至不能假設被轉換的代碼最初是由 Java 編寫而成的。更糟糕的是,探查類成員或註解的反射 API 也是禁止使用的,這是因為類加載之前,我們無法訪問這些 API,而在轉換進程完成之前,是無法進行加載的。

所幸的是,Java 字節碼相對來講是一個比較簡單的抽象形式,它包含瞭很少量的操作,稍微花點功夫我們就能大致將其掌握起來。Java 虛擬機執行程序的時候,會以基於棧的方式來處理值。字節碼指令一般會告知虛擬機,需要從操作數棧(operand stack)上彈出值,執行一些操作,然後再將結果壓到棧中。

讓我們考慮一個簡單的樣例:將數字 1 和 2 進行相加操作。JVM 首先會將這兩個數字壓到棧中,這是通過 _iconst_1_ 和 _iconst_2_ 這兩個字節指令實現的。_iconst_1_ 是個單字節的便捷運算符(operator),它會將數字 1 壓到棧中。與之類似,_iconst_2_ 會將數字 2 壓到棧中。然後,會執行 _iadd_ 指令,它會將棧中最新的兩個值彈出,將它們求和計算的結果重新壓到棧中。在類文件中,每個指令並不是以其易於記憶的名稱進行存儲的,而是以一個字節的形式進行存儲,這個字節能夠唯一地標記特定的指令,這也是 _bytecode_ 這個術語的來歷。上文所述的字節碼指令及其對操作數棧的影響,通過下面的圖片進行瞭可視化。

對於人類用戶來講,會更喜歡源碼而不是字節碼,不過幸運的是 Java 社區創建瞭多個庫,能夠解析類文件並將緊湊的字節碼暴露為具有名稱的指令流。例如,流行的 ASM 庫提供瞭一個簡單的 visitor API,它能夠將類文件剖析為成員和方法指令,其操作方式類似於閱讀 XML 文件時的 SAX 解析器。如果使用 ASM 的話,那上述樣例中的字節碼可以按照如下的代碼來進行實現(在這裡,ASM 方式的指令是visitIns,能夠提供修正的方法實現):

MethodVisitor methodVisitor = ...
methodVisitor.visitIns(Opcodes.ICONST_1);
methodVisitor.visitIns(Opcodes.ICONST_2);
methodVisitor.visitIns(Opcodes.IADD);

需要註意的是,字節碼規范隻不過是一種比喻的說法(metaphor),因為 Java 虛擬機允許將程序轉換為優化後的機器碼(machine code),隻要程序的輸出能夠保證是正確的即可。因為字節碼的簡潔性,所以在已有的類中取代和修改指令是很簡單直接的。因此,使用 ASM 及其底層的 Java 字節碼基礎就足以實現類轉換的 Java agent,這需要註冊一個ClassFileTransformer,它會使用這個庫來處理其參數。

克服字節碼的不足

對於實際的應用來講,解析原始的類文件依然意味著有很多的手動工作。Java 程序員通常感興趣的是類型層級結構中的類。例如,某個 Java agent 可能需要修改所有實現給定接口的類。如果要確定某個類的超類,那隻靠解析ClassFileTransformer所給定的類文件就不夠瞭,類文件中隻包含瞭直接超類和接口的名字。為瞭解析可能的超類型關聯關系,程序員依然需要定位這些類型的類文件。

在項目中直接使用 ASM 的另外一個困難在於,團隊中需要有開發人員學習 Java 字節碼的基礎知識。在實踐中,這往往會導致很多的開發人員不敢再去修改字節碼操作相關的代碼。如果這樣的話,實現 Java agent 很容易為項目的長期維護帶來風險。

為瞭克服這些問題,我們最好使用較高層級的抽象來實現 Java agent,而不是直接操作 Java 字節碼。Byte Buddy 是開源的、基於 Apache 2.0 許可證的庫,它致力於解決字節碼操作和 instrumentation API 的復雜性。Byte Buddy 所聲稱的目標是將顯式的字節碼操作隱藏在一個類型安全的領域特定語言背後。通過使用 Byte Buddy,任何熟悉 Java 編程語言的人都有望非常容易地進行字節碼操作。

Byte Buddy 簡介

Byte Buddy 的目的並不僅僅是為瞭生成 Java agent。它提供瞭一個 API 用於生成任意的 Java 類,基於這個生成類的 API,Byte Buddy 提供瞭額外的 API 來生成 Java agent。

作為 Byte Buddy 的簡介,如下的樣例展現瞭如何生成一個簡單的類,這個類是 Object 的子類,並且重寫瞭 toString 方法,用來返回“Hello World!”。與原始的 ASM 類似,“intercept”會告訴 Byte Buddy 為攔截到的指令提供方法實現:

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

從上面的代碼中,我們可以看到 Byte Buddy 要實現一個方法分為兩步。首先,編程人員需要指定一個ElementMatcher,它負責識別一個或多個需要實現的方法。Byte Buddy 提供瞭功能豐富的預定義攔截器(interceptor),它們暴露在ElementMatchers類中。在上述的例子中,toString方法完全精確匹配瞭名稱,但是,我們也可以匹配更為復雜的代碼結構,如類型或註解。

當 Byte Buddy 生成類的時候,它會分析所生成類型的類層級結構。在上述的例子中,Byte Buddy 能夠確定所生成的類要繼承其超類 Object 的名為 toString 的方法,指定的匹配器會要求 Byte Buddy 重寫該方法,這是通過隨後的

<ahref="http://bytebuddy.net/javadoc/0.7.1/net/bytebuddy/implementation/Implementation.html">Implementation</a>

實例實現的,在我們的樣例中,這個實例也就是

<ahref="http://bytebuddy.net/javadoc/0.7.1/net/bytebuddy/implementation/FixedValue.html">FixedValue</a>

當創建子類的時候,Byte Buddy 始終會攔截(intercept)一個匹配的方法,在生成的類中重寫該方法。但是,我們在本文稍後將會看到 Byte Buddy 還能夠重新定義已有的類,而不必通過子類的方式來實現。在這種情況下,Byte Buddy 會將已有的代碼替換為生成的代碼,而將原有的代碼復制到另外一個合成的(synthetic)方法中。

在我們上面的代碼樣例中,匹配的方法進行瞭重寫,在實現裡面,返回瞭固定的值“Hello World!”。intercept方法接受 Implementation 類型的參數,Byte Buddy 自帶瞭多個預先定義的實現,如上文所使用的FixedValue類。但是,如果需要的話,可以使用前文所述的 ASM API 將某個方法實現為自定義的字節碼,Byte Buddy 本身也是基於 ASM API 實現的。

定義完類的屬性之後,就能通過 make 方法來進行生成。在樣例應用中,因為用戶沒有指定類名,所以生成的類會給定一個任意的名稱。最終,生成的類將會使用ClassLoadingStrategy來進行加載。通過使用上述的默認WRAPPER策略,類將會使用一個新的類加載器進行加載,這個類加載器會使用環境類加載器作為父加載器。

類加載之後,使用 Java 反射 API 就可以訪問它瞭。如果沒有指定其他構造器的話,Byte Buddy 將會生成類似於父類的構造器,因此生成的類可以使用默認的構造器。這樣,我們就可以檢驗生成的類重寫瞭toString方法,如下面的代碼所示:

assertThat(dynamicType.newInstance().toString(), 
           is("Hello World!"));

當然,這個生成的類並沒有太大的用處。對於實際的應用來講,大多數方法的返回值是在運行時計算的,這個計算過程要依賴於方法的參數和對象的狀態。

通過委托實現 Instrumentation

要實現某個方法,有一種更為靈活的方式,那就是使用 Byte Buddy 的 MethodDelegation。通過使用方法委托,在生成重寫的實現時,我們就有可能調用給定類和實例的其他方法。按照這種方式,我們可以使用如下的委托器(delegator)重新編寫上述的樣例:

class ToStringInterceptor {
  static String intercept() {
    return “Hello World!”;
  }
}

借助上面的 POJO 攔截器,我們就可以將之前的 FixedValue 實現替換為

MethodDelegation.to(ToStringInterceptor.class):

Class<?> dynamicType = new ByteBuddy()
  .subclass(Object.class)
  .method(ElementMatchers.named("toString"))
  .intercept(MethodDelegation.to(ToStringInterceptor.class))
  .make()
  .load(getClass().getClassLoader(),          
        ClassLoadingStrategy.Default.WRAPPER)
  .getLoaded();

使用上述的委托器,Byte Buddy 會在 to 方法所給定的攔截目標中,確定 _ 最優的調用方法 _。就ToStringInterceptor.class來講,選擇過程隻是非常簡單地解析這個類型的唯一靜態方法而已。在本例中,隻會考慮一個靜態方法,因為委托的目標中指定的是一個 _ 類 _。與之不同的是,我們還可以將其委托給某個類的 _ 實例 _,如果是這樣的話,Byte Buddy 將會考慮所有的虛方法(virtual method)。如果類或實例上有多個這樣的方法,那麼 Byte Buddy 首先會排除掉所有與指定 instrumentation 不兼容的方法。在剩餘的方法中,庫將會選擇最佳的匹配者,通常來講這會是參數最多的方法。我們還可以顯式地指定目標方法,這需要縮小合法方法的范圍,將ElementMatcher傳遞到MethodDelegation中,就會進行方法的過濾。例如,通過添加如下的filter,Byte Buddy 隻會將名為“intercept”的方法視為委托目標:

MethodDelegation.to(ToStringInterceptor.class)
                .filter(ElementMatchers.named(“intercept”))

執行上面的攔截之後,被攔截到的方法依然會打印出“Hello World!”,但是這次的結果是動態計算的,這樣的話,我們就可以在攔截器方法上設置斷點,所生成的類每次調用toString時,都會觸發攔截器的方法。

當我們為攔截器方法設置參數時,就能釋放出MethodDelegation的全部威力。這裡的參數通常是帶有註解的,用來要求 Byte Buddy 在調用攔截器方法時,註入某個特定的值。例如,通過使用@Origin註解,Byte Buddy 提供瞭添加 instrument 功能的方法的實例,將其作為 Java 反射 API 中類的實例:

class ContextualToStringInterceptor {
  static String intercept(@Origin Method m) {
    return “Hello World from ” + m.getName() + “!”;
  }
}

當攔截toString方法時,對 instrument 方法的調用將會返回“Hello world from toString!”。

除瞭@Origin註解以外,Byte Buddy 提供瞭一組功能豐富的註解。例如,通過在類型為 Callable的參數上使用@Super註解,Byte Buddy 會創建並註入一個代理實例,它能夠調用被 instrument 方法的原始代碼。如果對於特定的用戶場景,所提供的註解不能滿足需求或者不太適合的話,我們甚至能夠註冊自定義的註解,讓這些註解註入用戶特定的值。

實現方法級別的安全性

可以看到,我們在運行時可以借助簡單的 Java 代碼,使用 MethodDelegation 來動態重寫某個方法。這隻是一個簡單的樣例,但是這項技術可以用到更加實際的應用之中。在本文剩餘的內容中,我們將會開發一個樣例,它會使用代碼生成技術實現一個註解驅動的庫,用來限制方法級別的安全性。在我們的第一個迭代中,這個庫會通過生成子類的方式來限制安全性。然後,我們將會采取相同的方式來實現 Java agent,完成相同的功能。

樣例庫會使用如下的註解,允許用戶指定某個方法需要考慮安全因素:

@interface Secured {
  String user();
}

例如,假設應用需要使用如下的Service類來執行敏感操作,並且隻有用戶被認證為管理員才能執行該方法。這是通過為執行這個操作的方法聲明 Secured 註解來指定的:

class Service {
  @Secured(user = “ADMIN”)
  void doSensitiveAction() {
    // 運行敏感代碼...
  }
}

我們當然可以將安全檢查直接編寫到方法中。在實際中,硬編碼橫切關註點往往會導致復制 – 粘貼的邏輯,使其難以維護。另外,一旦應用需要涉及額外的需求時,如日志、收集調用指標或結果緩存,直接添加這樣的代碼擴展性不會很好。通過將這樣的功能抽取到 agent 中,方法就能很純粹地關註其業務邏輯,使得代碼庫能夠更易於閱讀、測試和維護。

為瞭讓我們規劃的庫保持盡可能得簡單,按照註解的協議聲明,如果當前用戶不具備註解的用戶屬性時,將會拋出IllegalStateException異常。通過使用 Byte Buddy,這種行為可以用一個簡單的攔截器來實現,如下面樣例中的SecurityInterceptor所示,它會通過其靜態的 user 域,跟蹤當前用戶已經進行瞭登錄:

class SecurityInterceptor {

  static String user = “ANONYMOUS”

  static void intercept(@Origin Method method) {
    if (!method.getAnnotation(Secured.class).user().equals(user)) {
      throw new IllegalStateException(“Wrong user”);
    }
  }
}

通過上面的代碼,我們可以看到,即便給定用戶授予瞭訪問權限,攔截器也沒有調用原始的方法。為瞭解決這個問題,Byte Buddy 有很多預定義的方法可以實現功能的鏈接。借助MethodDelegation類的andThen方法,上述的安全檢查可以放到原始方法的調用之前,如下面的代碼所示。如果用戶沒有進行認證的話,安全檢查將會拋出異常並阻止後續的執行,因此原始方法將不會執行。

將這些功能集合在一起,我們就能生成Service的一個子類,所有帶有註解方法的都能恰當地進行安全保護。因為所生成的類是 Service 的子類,所以它能夠替代所有類型為Service的變量,並不需要任何的類型轉換,如果沒有恰當認證的話,調用doSensitiveAction方法就會拋出異常:

new ByteBuddy()
  .subclass(Service.class)
  .method(ElementMatchers.isAnnotatedBy(Secured.class))
  .intercept(MethodDelegation.to(SecurityInterceptor.class)
                             .andThen(SuperMethodCall.INSTANCE)))
  .make()
  .load(getClass().getClassLoader(),   
        ClassLoadingStrategy.Default.WRAPPER)
  .getLoaded()
  .newInstance()
  .doSensitiveAction();

不過壞消息是,因為實現 instrumentation 功能的子類是在運行時創建的,所以除瞭使用 Java 反射以外,沒有其他辦法創建這樣的實例。因此,所有 instrumentation 類的實例都應該通過一個工廠來創建,這個工廠會封裝創建 instrumentation 子類的復雜性。這樣造成的結果就是,子類 instrumentation 通常會用於框架之中,這些框架本身就需要通過工廠來創建實例,例如,像依賴管理的框架 Spring 或對象 – 關系映射的框架 Hibernate,而對於其他類型的應用來講,子類 instrumentation 實現起來通常過於復雜。

實現安全功能的 Java agent

通過使用 Java agent,上述安全框架的一個替代實現將會修改Service類的原始字節碼,而不是重寫它。這樣做的話,我們就沒有必要創建托管的實例瞭,隻需簡單地調用

new Service().doSensitiveAction()即可,如果對應的用戶沒有進行認證的話,就會拋出異常。為瞭支持這種方式,Byte Buddy 提供一種稱之為 _rebase 某個類 _ 的理念。當 rebase 某個類的時候,不會創建子類,所采用的策略是實現 instrumentation 功能的代碼將會合並到被 instrument 的類中,從而改變其行為。在添加 instrumentation 功能之後,在被 instrument 的類中,其所有方法的原始代碼均可進行訪問,因此像SuperMethodCall這樣的 instrumentation,工作方式與創建子類是完全一樣的。

創建子類與 rebase 的行為是非常類似的,所以兩種操作的 API 執行方式是一致的,都會使用相同的DynamicType.Builder接口來描述某個類型。兩種形式的 instrumentation 都可以通過ByteBuddy類來進行訪問。為瞭使 Java agent 的定義更加便利,Byte Buddy 還提供瞭AgentBuilder類,它希望能夠以一種簡潔的方式應對一些通用的用戶場景。為瞭定義 Java agent 實現方法級別的安全性,將如下的類定義為 agent 的入口點就足以完成該功能瞭:

class SecurityAgent {
  public static void premain(String arg, Instrumentation inst) {
    new AgentBuilder.Default()
    .type(ElementMatchers.any())
    .transform((builder, type) -> builder
    .method(ElementMatchers.isAnnotatedBy(Secured.class)
    .intercept(MethodDelegation.to(SecurityInterceptor.class)
               .andThen(SuperMethodCall.INSTANCE))))
    .installOn(inst);
  }
}

如果將這個 agent 打包為 jar 文件並在命令行中進行指定,那麼所有帶有Secured註解的方法將會進行“轉換”或重定義,從而實現安全保護。如果不激活這個 Java agent 的話,應用在運行時就不包含額外的安全檢查。當然,這意味著如果對帶有註解的代碼進行單元測試的話,這些方法的調用並不需要特殊的搭建過程來模擬安全上下文。Java 運行時會忽略掉無法在 classpath 中找到的註解類型,因此在運行帶有註解的方法時,我們甚至完全可以在應用中移除掉安全庫。

另外一項優勢在於,Java agent 能夠很容易地進行疊加。如果在命令行中指定多個 Java agent 的話,每個 agent 都有機會對類進行修改,其順序就是在命令行中所指定的順序。例如,我們可以采取這種方式將安全、日志以及監控框架聯合在一起,而不需要在這些應用間增添任何形式的集成層。因此,使用 Java agent 實現橫切的關註點提供瞭一種更為模塊化的代碼編寫方式,而不必針對某個管理實例的中心框架來集成所有的代碼。

_Byte Buddy 的源碼可以免費地在 GitHub 上獲取到。入門手冊可以在 http://bytebuddy.net上找到。Byte Buddy 當前的可用版本是 0.7.4,所有樣例均是基於該版本的。因為其革新性以及對 Java 生態系統的貢獻,該庫曾經在 2015 年獲得過 Oracle 的 Duke’s Choice 獎項。

關於作者

Rafael Winterhalter是一位軟件咨詢師,在挪威的奧斯陸工作。他是靜態類型的支持者,對 JVM 有極大的熱情,尤其關註於代碼 instrumentation、並發和函數式編程。Rafael 日常會撰寫關於軟件開發的博客,經常出席相關的會議,並被認定為 JavaOne Rock Star。在工作以外的編碼過程中,他為多個開源項目做出過貢獻,經常會花精力在 Byte Buddy 上,這是一個為 Java 虛擬機簡化運行時代碼生成的庫。因為他的貢獻,Rafael 得到過 Duke’s Choice 獎項。

查看英文原文: Easily Create Java Agents with Byte Buddy

以上就是通過使用Byte Buddy便捷創建Java Agent的詳細內容,更多關於Byte Buddy創建Java Agent的資料請關註WalkonNet其它相關文章!

推薦閱讀: