首頁 > 軟體

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

2022-03-05 13:00:48

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的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com