程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> JAVA編程 >> JAVA綜合教程 >> 通過使用Byte Buddy,便捷地創建Java Agent,bytebuddy

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

編輯:JAVA綜合教程

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


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重寫該方法,這是通過隨後的 Implementation 實例實現的,在我們的樣例中,這個實例也就是FixedValue

當創建子類的時候,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

問啊-一鍵呼叫程序員答題神器,牛人一對一服務,開發者編程必備官方網站:www.wenaaa.com

QQ群290551701 聚集很多互聯網精英,技術總監,架構師,項目經理!開源技術研究,歡迎業內人士,大牛及新手有志於從事IT行業人員進入!

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved