SkyWalking Agent原理

  |   0 评论   |   0 浏览

无侵入实现原理

上面使用Skywalking并没有修改程序中任何一行 Java 代码,这里便是使用到了 Java Agent 技术,如果平常基于增删改查业务逻辑那就基本不会使用到Java Agent,但我们平时用过的不少工具如热部署工具JRebel,SpringBoot的热部署插件,各种线上诊断工具(btrace, greys),阿里开源的arthas都是基于java Agent来实现的。在JDK1.5以后就有java Agent,使用agent技术构建一个独立于应用程序的代理程序(即Agent),用来协助监测、运行甚至替换其他JVM上的程序。使用它可以实现虚拟机级别的AOP功能,典型的优势就是无代码侵入。Agent大体可分为两种:

  • 在主程序之前运行的Agent。
  • 在主程序之后运行的Agent(前者的升级版,1.6以后提供)。

主程序之前运行的Agent

premain为主程序之前运行的Agent,在实际使用过程中,javaagent是java命令的一个参数。通过java 命令启动我们的应用程序的时候,可通过参数 -javaagent 指定一个 jar 包(也就是我们的代理agent),能够实现在我们应用程序的主程序运行之前来执行我们指定jar包中的特定方法,在该方法中我们能够实现动态增强Class等相关功能,并且该 jar包有2个要求:

  • 这个 jar 包的 META-INF/MANIFEST.MF 文件必须指定 Premain-Class 项,该选项指定的是一个类的全路径。
  • Premain-Class 指定的那个类必须实现 premain() 方法。

从字面上理解,Premain-Class 就是运行在 main 函数之前的的类。当Java 虚拟机启动时,在执行 main 函数之前,JVM 会先运行-javaagent所指定 jar 包内 Premain-Class 这个类的 premain 方法 。

-javaagent所在包java.lang.instrument,是rt.jar 中定义的一个包,有两个重要的类:

java.lang.instrument包提供了一些工具帮助开发人员在 Java 程序运行时动态修改系统中的 Class 类型。其中使用该软件包的一个关键组件就是 Javaagent,从本质上来讲,Java Agent 是一个遵循一组严格约定的常规 Java 类,就如上面说到 javaagent命令要求指定的类中必须要有premain()方法,并且对premain方法的签名也有要求,签名必须满足以下两种格式:

public static void premain(String agentArgs, Instrumentation inst) public static void premain(String agentArgs)

JVM 会优先加载 带 Instrumentation 签名的方法,加载成功忽略第二种,如果第一种没有,则加载第二种方法。

创建PreAgentDemo的maven项目,编写一个agent程序com.itxs.agent.PreAgentDemo,完成premain方法的签名,这里先做一个简单的日志输出。

package com.itxs.agent; import java.lang.instrument.Instrumentation; public class PreAgentDemo { public static void premain(String agentArgs, Instrumentation instrumentation) { System.out.println("PreAgentDemo run"); System.out.println("PreAgentDemo receive params agentArgs=" + agentArgs); } }

maven项目pom文件增加如下坐标

maven-assembly-plugin false jar-with-dependencies true true com.itxs.agent.PreAgentDemo true true make-assembly package single

PreAgentDemo项目进行打包,得到 PreAgentDemo-1.0.jar,放在G:\other下,查看jar包中的MANIFEST.MF文件

Can-Redefine-Classes :true表示能重定义此代理所需的类,默认值为 false(可选) Can-Retransform-Classes :true 表示能重转换此代理所需的类,默认值为 false (可选) Premain-Class :包含 premain 方法的类(类的全路径名)

接着创建一个test-demo项目,编写一个简单测试类App,运行JVM参数添加

-javaagent:G:\other\PreAgentDemo-1.0.jar=param1=value1,param2=value2,param3=value3

上运行结果可以看到在测试程序main函数启动前先输出premain方法打印的日志。实际开发中大部分类加载都会通过该方法。当然,遗漏的主要是系统类,因为很多系统类先于 agent 执行,而用户类的加载肯定是会被拦截的。也就是说,这个方法是在 main 方法启动前拦截大部分类的加载活动,既然可以拦截类的加载,那么就可以去做重写类这样的操作,结合第三方的字节码编译工具,比如ASM,bytebuddy,javassist,cglib等等来改写实现类。
Instrumentation 中的核心 API 方法:

  • addTransformer()/removeTransformer() 方法:注册/注销一个 ClassFileTransformer 类的实例,该 Transformer 会在类加载的时候被调用,可用于修改类定义(修改类的字节码)。
  • redefineClasses() 方法:该方法针对的是已经加载的类,它会对传入的类进行重新定义。
  • getAllLoadedClasses()方法:返回当前 JVM 已加载的所有类。
  • getInitiatedClasses() 方法:返回当前 JVM 已经初始化的类。
  • getObjectSize()方法:获取参数指定的对象的大小。

主程序之后运行的Agent

agentmain,可以在 main 函数开始运行之后再运行。跟premain函数一样, 开发者可以编写一个含有agentmain函数的 Java 类。

public static void agentmain (String agentArgs, Instrumentation inst) public static void agentmain (String agentArgs)

同样需要在MANIFEST.MF文件里面设置“Agent-Class”来指定包含 agentmain 函数的类的全路径。在前面工程基础上增加com.itxs.agent.AgentDemo文件,也是简单打印日志。

package com.itxs.agent; import java.lang.instrument.Instrumentation; public class AgentDemo { public static void agentmain(String agentArgs, Instrumentation instrumentation) { System.out.println("AgentDemo run"); } }

在pom.xml中添加配置如下

com.itxs.agent.AgentDemo

重新打包 PreAgentDemo-1.0.jar并覆盖到G:\other下,在测试类App修改如下代码

package com.itxs; import com.sun.tools.attach.*; import java.io.IOException; import java.util.List; public class App { public static void main( String[] args ) throws IOException, AgentLoadException, AgentInitializationException, AttachNotSupportedException { System.out.println( "itxs app main run!" ); //获取当前系统中所有 运行中的 虚拟机 List list = VirtualMachine.list(); for (VirtualMachineDescriptor vm : list) { if (vm.displayName().endsWith("com.itxs.App")) { VirtualMachine virtualMachine = VirtualMachine.attach(vm.id()); virtualMachine.loadAgent("G:/other/PreAgentDemo-1.0.jar"); virtualMachine.detach(); } } } }

list()方法会去寻找当前系统中所有运行着的JVM进程,你可以打印vmd.displayName()看到当前系统都有哪些JVM进程在运行。因为main函数执行起来的时候进程名为当前类名,所以通过这种方式可以去找到当前的进程id。在windows中安装的jdk无法找到,如遇到这种情况手动将你jdk安装目录下:lib目录中的tools.jar添加进当前工程的Libraries中。

agent要在主程序运行后加载,我们不可能在主程序中编写加载的代码,只能另写程序,那么另写程序如何与主程序进行通信?这里用到的机制就是attach机制,它可以将JVM A连接至JVM B,并发送指令给JVM B执行。

字节码操作(增强)

Byte Buddy概述

Byte Buddy 官方地址 https://bytebuddy.net/

Byte Buddy是一个代码生成和操作库,用于在Java应用程序运行时创建和修改Java类,并且不需要编译器的帮助。与Java类库附带的代码生成实用程序不同,Byte Buddy允许创建任意类,并且不局限于为创建运行时代理实现接口。此外,Byte Buddy提供了一个方便的API,可以手动更改类,可以使用Java代理,也可以在构建期间更改类。

  • 无需理解字节码指令,即可使用较为简单的API就能很容易操作字节码,控制类和方法。
  • 已支持Java 11,轻量,仅取决于Java字节代码解析器库ASM的访问者API,它本身不需要任何其他依赖项。
  • 比起JDK动态代理、cglib、Javassist,Byte Buddy在性能上具有一定的优势。

反射机制可以知道调用的方法或字段,但反射性能很差,反射能绕开类型安全检查,不安全,比如权限暴力破解;java编程语言代码生成库也有多种:

  • Java Proxy:是 JDK 自带的一个代理工具,它允许为实现了一系列接口的类生成代理类。Java Proxy 要求目标类必须实现接口是一个非常大限制,例如在某些场景中目标类没有实现任何接口且无法修改目标类的代码实现,Java Proxy 就无法对其进行扩展和增强了。
  • CGLIB 诞生于 Java 初期,但不幸的是没有跟上 Java 平台的发展。虽然 CGLIB 本身是一个相当强大的库,但也变得越来越复杂,导致许多用户放弃了CGLIB 。
  • Javassist:其使用对 Java 开发者来说是非常友好的,它使用Java 源代码字符串和 Javassist 提供的一些简单 API ,共同拼凑出用户想要的 Java 类,Javassist 自带一个编译器,拼凑好的 Java 类在程序运行时会被编译成为字节码并加载到 JVM 中。Javassist 库简单易用,而且使用 Java 语法构建类与平时写 Java 代码类似,但是 Javassist 编译器在性能上比不了 Javac 编译器,而且在动态组合字符串以实现比较复杂的逻辑时容易出错。
  • Byte Buddy:提供了一种非常灵活且强大的领域特定语言,通过编写简单的 Java 代码即可创建自定义的运行时类。与此同时,Byte Buddy 还具有非常开放的定制性,能够应付不同复杂度的需求。

上面所有代码生成技术中推荐使用Byte Buddy,因为Byte Buddy代码生成可的性能最高;Byte Buddy 的主要侧重点在于生成更快速的代码,如下图

ByteBuddy API

Class dynamicType = new ByteBuddy() // 生成 Object的子类 .subclass(Object.class) // 生成类的名称 .name("com.itxs.type") // 拦截其中的toString()方法 .method(ElementMatchers.named("toString")) // 让toString()方法返回固定值 .intercept(FixedValue.value("Hello World!")) .make() // 加载新类型,默认WRAPPER策略,也即是ClassLoadingStrategy.Default.WRAPPER可以不写 .load(getClass().getClassLoader(),ClassLoadingStrategy.Default.WRAPPER) .getLoaded();

Byte Buddy 动态增强代码总有如下三种方式:

  • subclass:对应 ByteBuddy.subclass() 方法。这种方式比较好理解,就是为目标类(即被增强的类)生成一个子类,在子类方法中插入动态代码。
  • rebasing:对应 ByteBuddy.rebasing() 方法。当使用 rebasing 方式增强一个类时,Byte Buddy 保存目标类中所有方法的实现,也就是说,当 Byte Buddy 遇到冲突的字段或方法时,会将原来的字段或方法实现复制到具有兼容签名的重新命名的私有方法中,而不会抛弃这些字段和方法实现。从而达到不丢失实现的目的。这些重命名的方法可以继续通过重命名后的名称进行调用。
  • redefinition:对应 ByteBuddy.redefine() 方法。当重定义一个类时,Byte Buddy 可以对一个已有的类添加属性和方法,或者删除已经存在的方法实现。如果使用其他的方法实现替换已经存在的方法实现,则原来存在的方法实现就会消失。

上面三种增强代码后得到的是 DynamicType.Unloaded 对象,表示的是一个未加载的类型,可以使用 ClassLoadingStrategy加载此类型;Byte Buddy 提供了几种类加载策略,这些加载策略定义在 ClassLoadingStrategy.Default 中:

  • WRAPPER 策略 :创建一个新的 ClassLoader 来加载动态生成的类型。
  • CHILD_FIRST 策略 :创建一个子类优先加载的 ClassLoader,即打破了双亲委派模型。
  • INJECTION 策略 :使用反射将动态生成的类型直接注入到当前 ClassLoader 中。

method() 方法可以通过传入的 ElementMatchers 参数匹配多个需要修改的方法,这里的ElementMatchers.named("toString") 即为按照方法名匹配 toString() 方法。如果同时存在多个重载方法,则可以使用 ElementMatchers 其他 API 描述方法的签名,如下所示

// 指定方法名称 ElementMatchers.named("toString") // 指定方法的返回值 .and(ElementMatchers.returns(String.class)) // 指定方法参数 .and(ElementMatchers.takesArguments(0));

intercept() 方法,通过 method()方法拦截到的所有方法会由 Intercept() 方法指定的 Implementation 对象决定如何增强;这里的 FixValue.value() 会将方法的实现修改为固定值,上例中就是固定返回 “Hello World!” 字符串。Byte Buddy 中可以设置多个 method() 和 Intercept() 方法进行拦截和修改,Byte Buddy 会按照栈的顺序来进行拦截。

ByteBuddy 普通类代理示例

在test-demo项目中添加ByteBuddy的依赖

net.bytebuddy byte-buddy 1.12.12 net.bytebuddy byte-buddy-agent 1.12.12 test

创建普通类OrderService

package com.itxs.service; public class OrderService { public String addOrder(){ System.out.println("=====do addOrder=========="); return "1000000001"; } public String getOrder(String orderId){ System.out.println("=====do getOrder=========="); return orderId; } public String getOrder(String orderId,String status){ System.out.println("=====do getOrder two params=========="); return orderId+status; } }

创建拦截器类TestInterceptor

package com.itxs.interceptor; import net.bytebuddy.implementation.bind.annotation.*; import java.lang.reflect.Method; import java.util.concurrent.Callable; public class TestInterceptor { @RuntimeType //将返回值转换成具体的方法返回值类型,加了这个注解 intercept 方法才会被执行 public Object intercept( // 被拦截的目标对象 (动态生成的目标对象) @This Object target, // 正在执行的方法Method 对象(目标对象父类的Method) @Origin Method method, // 正在执行的方法的全部参数 @AllArguments Object[] argumengts, // 目标对象的一个代理 @Super Object delegate, // 方法的调用者对象 对原始方法的调用依靠它 @SuperCall Callable callable) throws Exception { //目标方法执行前执行日志记录 System.out.println("prepare do method="+method.getName()); // 调用目标方法 Object result = callable.call(); //目标方法执行后执行日志记录 System.out.println("have down method="+method.getName()); return result; } }

创建普通类代理测试类ByteBuddyTest

package com.itxs; import com.itxs.interceptor.TestInterceptor; import com.itxs.service.OrderService; import net.bytebuddy.ByteBuddy; import net.bytebuddy.dynamic.loading.ClassLoadingStrategy; import net.bytebuddy.implementation.MethodDelegation; import net.bytebuddy.matcher.ElementMatchers; public class ByteBuddyTest { public static void main(String[] args) throws IllegalAccessException, InstantiationException { Class generateClass = new ByteBuddy() // 创建一个UserService 的子类 .subclass(OrderService.class) //指定类的名称 .name("com.itxs.service.OrderServiceImpl") // 指定要拦截的方法 .method(ElementMatchers.named("getOrder").and(ElementMatchers.returns(String.class).and(ElementMatchers.takesArguments(2)))) // 为方法添加拦截器 如果拦截器方法是静态的 这里可以传 LogInterceptor.class .intercept(MethodDelegation.to(new TestInterceptor())) // 动态创建对象,但还未加载 .make() // 设置类加载器 并指定加载策略(默认WRAPPER) .load(ByteBuddy.class.getClassLoader(), ClassLoadingStrategy.Default.WRAPPER) // 开始加载得到 Class .getLoaded(); OrderService orderService = generateClass.newInstance(); System.out.println(orderService.addOrder()); System.out.println(orderService.getOrder("2000000000")); System.out.println(orderService.getOrder("3000000000","支付中")); } }

在程序中用到ByteBuddy的MethodDelegation对象,它可以将拦截的目标方法委托给其他对象处理,注解使用说明如下:

  • @RuntimeType:不进行严格的参数类型检测,在参数匹配失败时,尝试使用类型转换方式(runtime type casting)进行类型转换,匹配相应方法。
  • @This:注入被拦截的目标对象(动态生成的目标对象)。
  • @Origin:注入正在执行的方法Method 对象(目标对象父类的Method)。如果拦截的是字段的话,该注解应该标注到 Field 类型参数。
  • @AllArguments:注入正在执行的方法的全部参数。
  • @Super:注入目标对象的一个代理。
  • @SuperCall:这个注解比较特殊,我们要在 intercept() 方法中调用 被代理/增强 的方法的话,需要通过这种方式注入,与 Spring AOP 中的 ProceedingJoinPoint.proceed() 方法有点类似,需要注意的是,这里不能修改调用参数,从上面的示例的调用也能看出来,参数不用单独传递,都包含在其中了。另外,@SuperCall 注解还可以修饰 Runnable 类型的参数,只不过目标方法的返回值就拿不到了。

运行ByteBuddyTest,增强的方法输出就是上面代码中方法匹配名称为getOrder且返回值为String且有两个入参的结果。

自定义Agent案例

Java Agent十分强大,使用Transformer等高级功能进行类替换,方法修改等,要使用Instrumentation的相关API则需要对字节码等技术有较深的认识。接下来ByteBuddy结合Java Agent技术实现一个统计方法耗时的示例。

在上面的PreAgentDemo项目中加入依赖byte-buddy和byte-buddy-agent的依赖,上面测试工程Pom文件有

创建耗时统计拦截器类

package com.itxs.agent.interceptor; import net.bytebuddy.implementation.bind.annotation.Origin; import net.bytebuddy.implementation.bind.annotation.RuntimeType; import net.bytebuddy.implementation.bind.annotation.SuperCall; import java.lang.reflect.Method; import java.util.concurrent.Callable; public class TimeConsumingInterceptor { /*** * 拦截方法 * @param method:拦截的方法 * @param callable:调用对象的代理对象 * @return * @throws Exception */ @RuntimeType // 声明为static public static Object intercept(@Origin Method method, @SuperCall Callable callable) throws Exception { //时间统计开始 long start = System.currentTimeMillis(); // 执行原函数 Object result = callable.call(); //执行时间统计 System.out.println(method.getName() + ":time consuming total" + (System.currentTimeMillis() - start) + "ms"); return result; } }

创建JavaAgentCase的premain实现

package com.itxs.agent; import com.itxs.agent.interceptor.TimeConsumingInterceptor; import net.bytebuddy.agent.builder.AgentBuilder; import net.bytebuddy.implementation.MethodDelegation; import net.bytebuddy.matcher.ElementMatchers; import java.lang.instrument.Instrumentation; public class JavaAgentCase { /*** * 执行方法拦截 * @param agentArgs:-javaagent 命令携带的参数。在前面介绍 SkyWalking Agent 接入时提到 * agent.service_name 这个配置项的默认值有三种覆盖方式, * 其中,使用探针配置进行覆盖,探针配置的值就是通过该参数传入的。 * @param instrumentation:java.lang.instrumen.Instrumentation 是 Instrumention 包中定义的一个接口,它提供了操作类定义的相关方法。 */ public static void premain(String agentArgs, Instrumentation instrumentation) { // 动态构建操作,根据transformer规则执行拦截操作,匹配上的具体的类型描述 AgentBuilder.Transformer transformer = (builder, typeDescription, classLoader, javaModule) -> { // 构建拦截规则 return builder // method()指定哪些方法需要被拦截,ElementMatchers.any()表示拦截所有方法 .method(ElementMatchers.any()) // intercept()指定拦截上述方法的拦截器 .intercept(MethodDelegation.to(TimeConsumingInterceptor.class)); }; // 采用Byte Buddy的AgentBuilder结合Java Agent处理程序 new AgentBuilder // 采用ByteBuddy作为默认的Agent实例 .Default() // 拦截匹配方式:类以com.itxs.service开始,也即是com.itxs.service包下的所有类 .type(ElementMatchers.nameStartsWith("com.itxs.service")) // 拦截到的类由transformer处理 .transform(transformer) // 安装到 Instrumentation .installOn(instrumentation); } }

重新打包好PreAgentDemo-1.0.jar,准备测试类UserService.java

package com.itxs.service; import java.util.Random; import java.util.concurrent.TimeUnit; public class UserService { private static Random random = new Random(); public void getUser(){ System.out.println("=====do getUser=========="); try { TimeUnit.SECONDS.sleep(random.nextInt(5)); } catch (InterruptedException e) { e.printStackTrace(); } } public void updateUser(){ System.out.println("=====do updateUser=========="); try { TimeUnit.SECONDS.sleep(random.nextInt(5)); } catch (InterruptedException e) { e.printStackTrace(); } } }

创建启动测试类

package com.itxs; import com.itxs.service.UserService; public class Application { public static void main( String[] args ) { System.out.println("Application main start run-----------"); UserService service = new UserService(); service.getUser(); service.updateUser(); } }

启动参数中jvm参数添加javaagent,可参考上面示例,执行Application的main后从日志可以看到UserService的方法被增强了


标题:SkyWalking Agent原理
作者:michael
地址:https://blog.junxworks.cn/articles/2024/02/03/1706963795367.html