一、Agent原理剖析
使用Skywalking的时候,并没有修改程序中任何一行 Java 代码,这里便使用到了 Java Agent 技术,我 们接下来展开对Java Agent 技术的学习。
1.1 Java Agent
Java Agent 是从 JDK1.5 开始引入的,算是一个比较老的技术了。作为 Java 的开发工程师,我们常用的 命令之一就是 java 命令,而 Java Agent 本身就是 java 命令的一个参数(即-javaagent)。正如上一课 时接入 SkyWalking Agent 那样, -javaagent 参数之后需要指定一个 jar 包,这个 jar 包需要同时满足下 面两个条件:
- 在META-INF目录下的MANIFEST.MF文件中必须指定premain-class配置项;
- premain-class配置项指定的类必须提供premain()方法。
在 Java 虚拟机启动时,执行 main() 函数之前,虚拟机会先找到-javaagent 命令指定 jar 包,然后执行 premain-class 中的 premain() 方法。用一句概括其功能的话就是:main() 函数之前的一个拦截器。
使用Java Agent的步骤大概如下:
- 定义一个MANIFEST.MF文件,在其中添加premain-class配置项;
- 创建premain-class配置项指定的类,并在其中实现premain()方法,方法如下:
- 将MANIFEST.MF文件和premain-class指定的类一起打包成一个jar包;
- 使用-javaagent指定该jar包的路径可执行其中的premain()方法;
/**** 执行方法拦截* @param agentArgs:-javaagent 命令携带的参数。在前面介绍 SkyWalking Agent 接入时提到* agent.service_name 这个配置项的默认值有三种覆盖方式,* 其中,使用探针配置进行覆盖,探针配置的值就是通过该参数传入的。* @param inst:java.lang.instrumen.Instrumentation 是 Instrumention 包中定义的一个接口,它提供了操作类定义的相关方法。*/public static void premain(String agentArgs, Instrumentation inst){System.out.println("参数:" + agentArgs);}
1.2定义自己的Agent
1)探针工程
创建工程 hailtaxi-agent用来编写agent包,该类需要用 maven-assembly-plugin打包,我们先引入 该插件:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><groupId>com.itheima</groupId><artifactId>hailtaxi-agent</artifactId><version>1.0-SNAPSHOT</version><properties><maven.compiler.source>8</maven.compiler.source><maven.compiler.target>8</maven.compiler.target></properties><dependencies><dependency><groupId>net.bytebuddy</groupId><artifactId>byte-buddy</artifactId><version>1.9.2</version></dependency><dependency><groupId>net.bytebuddy</groupId><artifactId>byte-buddy-agent</artifactId><version>1.9.2</version></dependency></dependencies><build><plugins><plugin><artifactId>maven-assembly-plugin</artifactId><configuration><appendAssemblyId>false</appendAssemblyId><descriptorRefs><descriptorRef>jar-with-dependencies</descriptorRef></descriptorRefs><archive> <!--自动添加META-INF/MANIFEST.MF --><manifest><!-- 添加 mplementation-*和Specification-*配置项--><addDefaultImplementationEntries>true</addDefaultImplementationEntries><addDefaultSpecificationEntries>true</addDefaultSpecificationEntries></manifest><!-- 将 premain-class 配置项设置为com.jokermqc.LoginAgent--><manifestEntries><Premain-Class>com.jokermqc.LoginAgent</Premain-Class><!--<Premain-Class>com.jokermqc.AgentByteBuddy</Premain-Class>--></manifestEntries></archive></configuration><executions><execution><id>make-assembly</id><phase>package</phase><goals><goal>single</goal></goals></execution></executions></plugin></plugins></build>
</project>
在该工程中编写一个类 com.itheima.LoginAgent :
public class LoginAgent {/**** 执行方法拦截* @param agentArgs:-javaagent 命令携带的参数。在前面介绍 SkyWalking Agent 接入时提到* agent.service_name 这个配置项的默认值有三种覆盖方式,* 其中,使用探针配置进行覆盖,探针配置的值就是通过该参数传入的。* @param inst:java.lang.instrumen.Instrumentation 是 Instrumention 包中定义的一个接口,它提供了操作类定义的相关方法。*/public static void premain(String agentArgs, Instrumentation inst){System.out.println("参数:" + agentArgs);}
}
从字面上理解,就是运行在main()函数之前的类。在Java虚拟机启动时,在执行main()函数之前,会先运行指定类的premain()方法,在premain()方法中对class文件进行修改,它有两个入参:
agentArgs:启动参数,在JVM启动时指定;
instrumentation:上文所将的Instrumentation的实例,我们可以在方法中调用上文所讲的方法,注册对应的Class转换器,对Class文件进行修改
如下图,借助Instrumentation,JVM启动时的处理流程是这样的:JVM会执行指定类的premain()方法,在premain()中可以调用Instrumentation对象的addTransformer方法注册ClassFileTransformer。当JVM加载类时会将类文件的字节数组传递给ClassFileTransformer的transform方法,在transform方法中对Class文件进行解析和修改,之后JVM就会加载转换后的Class文件:
然后把工程进行打包,得到hailtaxi-agent-1.0-SNAPASHOT.jar,这个就是对应的探针包。
此时我们把jar包解压, MANIFEST.MF 内容如下:
Manifest-Version: 1.0
Archiver-Version: Plexus Archiver
Created-By: Apache Maven
Built-By: admin
Build-Jdk: 1.8.0_91
Specification-Title: hailtaxi-agent
Specification-Version: 1.0-SNAPSHOT
Implementation-Title: hailtaxi-agent
Implementation-Version: 1.0-SNAPSHOT
Implementation-Vendor-Id: com.itheima
Premain-Class: com.itheima.LoginAgent
2)普通工程
我们在创建一个普通工程 hailtaxi-user ,在该工程中创建一个普通类并编写main方法:
public class UserInfo {public static void main(String[] args) throws InterruptedException {System.out.println("张三是个中国人!");//调用say()方法say();TimeUnit.SECONDS.sleep(2);}
}
然后我们再在启动命令中加上刚刚生成的agent包,启动命令如下:
-javaagent:D:/project/skywalking/hailtaxi-agent/target/hailtaxi-agent-1.0- SNAPSHOT.jar=hailtaxi-user
此时运行效果如下:
1.3 自定义方法统计方法耗时
Java Agent 能做的事情非常多,而刚才打印一句日志只是一个能功能展示。要想使用 java agent做 更多事,这里需要关注一下 premain() 方法中的第二个参数: Instrumentation 。Instrumentation 位于 java.lang.instrument 包中,通过这个工具包,我们可以编写一个强大的 Java Agent 程序。
因为Instrumentation操作字节码非常麻烦,所以一般不会通过Instrumentation 来操作字节码,而是通过Byte Buddy,下面来介绍一下byte Buddy。
1.3.1Byte Buddy介绍
Byte Buddy 是一个开源 Java 库,其主要功能是帮助用户屏蔽字节码操作,以及复杂的
Instrumentation API 。 Byte Buddy 提供了一套类型安全的 API 和注解,我们可以直接使用这些 API 和 注解轻松实现复杂的字节码操作。另外,Byte Buddy 提供了针对 Java Agent 的额外 API,帮助开发人 员在 Java Agent 场景轻松增强已有代码。
引入Byte Buddy依赖:
<dependency><groupId>net.bytebuddy</groupId><artifactId>byte-buddy</artifactId><version>1.9.2</version></dependency><dependency><groupId>net.bytebuddy</groupId><artifactId>byte-buddy-agent</artifactId><version>1.9.2</version></dependency>
创建统计拦截器:
/*** @author maoqichaun* @date 2024年03月05日 18:47*/
public class MethodTimeInterceptor {/*** 这里有一点类似于Spring AOP** @param method 拦截的方法* @param callable 调用对象的代理对象* @return java.lang.Object* @author maoqichuan* @date 2024/3/5*/@RuntimeTypepublic static Object interceptor(@Origin Method method, @SuperCall Callable<?> callable) throws Exception {//时间统计开始long start = System.currentTimeMillis();// 执行原函数Object result = callable.call();//执行时间统计System.out.println(method.getName() + ":" + (System.currentTimeMillis() - start) + "ms");return result;}
}
这里整体实现类似动态代理执行过程,也类似SpringAop中的环绕通知,其中几个注解我们一起来学习 一下:
@RuntimeType 注解:告诉 Byte Buddy 不要进行严格的参数类型检测,在参数匹配失败时,尝试使用 类型转换方式(runtime type casting)进行类型转换,匹配相应方法。
@Origin 注解:注入目标方法对应的 Method 对象。如果拦截的是字段的话,该注解应该标注到 Field 类型参数。
@SuperCall:这个注解比较特殊,我们要在 intercept() 方法中调用目标方法的话,需要通过这种方 式注入,与 Spring AOP 中的 ProceedingJoinPoint.proceed() 方法有点类似,需要注意的是,这里不能修改调用参数,从上面的示例的调用也能看出来,参数不用单独传递,都包含在其中了。另外, @SuperCall 注解还可以修饰 Runnable 类型的参数,只不过目标方法的返回值就拿不到了。
配置agent拦截:
public class AgentByteBuddy {/**** 执行方法拦截* @param agentArgs:-javaagent 命令携带的参数。在前面介绍 SkyWalking Agent 接入时提到* agent.service_name 这个配置项的默认值有三种覆盖方式,* 其中,使用探针配置进行覆盖,探针配置的值就是通过该参数传入的。* @param inst:java.lang.instrumen.Instrumentation 是 Instrumention 包中定义的一个接口,它提供了操作类定义的相关方法。*/public static void premain(String agentArgs, Instrumentation inst) throws IllegalAccessException, InstantiationException {//动态构建操作,根据transformer规则执行拦截操作AgentBuilder.Transformer transformer = new AgentBuilder.Transformer() {@Overridepublic DynamicType.Builder<?> transform(DynamicType.Builder<?> builder,TypeDescription typeDescription,ClassLoader classLoader,JavaModule javaModule) {//构建拦截规则return builder//method()指定哪些方法需要被拦截,ElementMatchers.any()表示拦截所有方法.method(ElementMatchers.<MethodDescription>any())//intercept()指定拦截上述方法的拦截器.intercept(MethodDelegation.to(MethodTimeInterceptor.class));}};//采用Byte Buddy的AgentBuilder结合Java Agent处理程序new AgentBuilder//采用ByteBuddy作为默认的Agent实例.Default()//拦截匹配方式:类以com.itheima开始(其实即使com.jokermqc包下的所有类).type(ElementMatchers.nameStartsWith("com.jokermqc"))//拦截到的类由transformer处理.transform(transformer)//安装到 Instrumentation.installOn(inst);}
}
同时将pom.xml中的premain-class替换成 AgentByteBuddy,然后javaagent的jar替换一下即可生效;
二、 Byte Buddy
在前面学习 Java Agent 技术时,结合 Byte Buddy 技术实现了统计方法执行时间的功能。 Byte Buddy 在Skywalking中被广泛使用,我们接下来继续学习Byte Buddy,为后续分析 SkyWalking Agent打下基 础。
2.1 Byte Buddy 应用场景
Java 是一种强类型的编程语言,即要求所有变量和对象都有一个确定的类型,如果在赋值操作中出现类 型不兼容的情况,就会抛出异常。强类型检查在大多数情况下是可行的,然而在某些特殊场景下,强类 型检查则成了巨大的障碍。
我们在做一些通用工具封装的时候,类型检查就成了很大障碍。比如我们编写一个通用的Dao实现数据 操作,我们根本不知道用户要调用的方法会传几个参数、每个参数是什么类型、需求变更又会出现什么 类型,几乎没法在方法中引用用户方法中定义的任何类型。我们绝大多数通用工具封装都采用了反射机 制,通过反射可以知道用户调用的方法或字段,但是Java反射有很多缺陷:
1:反射性能很差
2:反射能绕开类型安全检查,不安全,比如权限暴力破解
学完agent后,我们可以基于agent做出一些改变,运行时代码生成在 Java 应用启动之后再动态生成一 些类定义,这样就可以模拟一些只有使用动态编程语言编程才有的特性,同时也不丢失 Java 的强类型 检查。在运行时生成代码需要特别注意的是 Java 类型被 JVM 加载之后,一般不会被垃圾被回收,因此 不应该过度使用代码生成。
java编程语言代码生成库不止 Byte Buddy一个,以下代码生成库在 Java 中也很流行:
- Java Proxy:Java Proxy 是 JDK 自带的一个代理工具,它允许为实现了一系列接口的类生成代理类。Java Proxy 要求 目标类必须实现接口是一个非常大限制,例如,在某些场景中,目标类没有实现任何接口且无法修改目 标类的代码实现,Java Proxy 就无法对其进行扩展和增强了。
- CGLIB:CGLIB 诞生于 Java 初期,但不幸的是没有跟上 Java 平台的发展。虽然 CGLIB 本身是一个相当强大的 库,但也变得越来越复杂。鉴于此,导致许多用户放弃了 CGLIB 。
- Javassist:Javassist 的使用对 Java 开发者来说是非常友好的,它使用Java 源代码字符串和 Javassist 提供的一些简 单 API ,共同拼凑出用户想要的 Java 类,Javassist 自带一个编译器,拼凑好的 Java 类在程序运行时会 被编译成为字节码并加载到 JVM 中。Javassist 库简单易用,而且使用 Java 语法构建类与平时写 Java 代 码类似,但是 Javassist 编译器在性能上比不了 Javac 编译器,而且在动态组合字符串以实现比较复杂
的逻辑时容易出错。
-
Byte Buddy:Byte Buddy 提供了一种非常灵活且强大的领域特定语言,通过编写简单的 Java 代码即可创建自定义的 运行时类。与此同时,Byte Buddy 还具有非常开放的定制性,能够应付不同复杂度的需求。
上面所有代码生成技术中,我们推荐使用Byte Buddy,因为Byte Buddy代码生成可的性能最高,Byte Buddy 的主要侧重点在于生成更快速的代码,如下图:
2.2 Byte buddy学习
我们接下来详细讲解一下Byte Buddy Api ,对重要的方法和类进行深度剖析。
2.2.1 ByteBuddy语法
任何一个由 Byte Buddy 创建/增强的类型都是通过 ByteBuddy 类的实例来完成的,我们先来学习一下 ByteBuddy类,如下代码:
DynamicType.Unloaded<?> dynamicType = new ByteBuddy()// 生成 Object的子类.subclass(Object.class)// 生成类的名称为"com.jokermqc.Type".name("com.jokermqc.Type").make();
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 中。
实现如下:
Class<?> dynamicClazz = new ByteBuddy()// 生成 Object的子类.subclass(Object.class)// 生成类的名称为"com.joker.Type".name("com.joker.Type").make().load(Demo.class.getClassLoader()//使用WRAPPER 策略加载生成的动态类型.ClassLoadingStrategy.Default.WRAPPER).getLoaded();
前面动态生成的dynamicType类只是简单的继承了Object类,在实际的应用中动态生成的新类型一般的目的就是为了增强原始的方法,下面通过一个示例展示Byte Buddy如何增强toString()方法;
// 创建ByteBuddy对象String str = new ByteBuddy()// subclass增强方式.subclass(Object.class)// 新类型的类名.name("com.joker.Type")// 拦截其中的toString()方法.method(ElementMatchers.named("toString"))// 让toString()方法返回固定值.intercept(FixedValue.value("Hello World!")).make()// 加载新类型,默认WRAPPER策略.load(ByteBuddy.class.getClassLoader()).getLoaded()// 通过 Java反射创建 com.xxx.Type实例.newInstance()// 调用 toString()方法.toString();
首先需要关注的是这里的method方法,method()方法可以通过传入的ElementMatchers参数匹配多个需要修改的方法,这的ElementMarchers.named("toString")就是按照方法名来匹配。如果通过存在多个重载方法,也可以使用ElementMarchers其他API来进一步描述方法的签名,如下所示:
// 指定方法名称ElementMatchers.named("toString")// 指定方法的返回值.and(ElementMatchers.returns(String.class))// 指定方法参数.and(ElementMatchers.takesArguments(0));
接下来要关注的是intercept方法,通过method方法拦截到的所有方法会有intercept()方法指定的Implementtation对象决定如何增强,这里的FixValue.value()会将方法的视线修改为固定值,上面的例子就是固定返回字符串:“Hello World!”。
2.2.2 ByteBuddy创建代理
我们先创建一个普通类,再为该类创建代理对象,创建代理对方法进行拦截处理。
1)普通类:
public class UserService {//方法1public String username(){System.out.println("com.jokermqc.service.UserService.username.....");return "张三";}//方法2public String address(String username){System.out.println("com.jokermqc.service.UserService.address(String username).....");return username+"来自 【湖北省武汉市】";}//方法3public String address(String username,String city){System.out.println("com.jokermqc.service.UserService.address(String username,String city).....");return username+"来自 【湖北省"+city+"】";}
}
2)创建代理对象
//创建ByteBuddyUserService userService = new ByteBuddy()//指定创建UserServiceImpl对象的子类.subclass(UserService.class)//匹配方法,所有方法均被拦截.method(ElementMatchers.isDeclaredBy(UserService.class))//任何拦截都返回一个固定值.intercept(MethodDelegation.to(new AspectLog()))//创建动态对象.make().load(ByteBuddy.class.getClassLoader(),ClassLoadingStrategy.Default.INJECTION).getLoaded().newInstance();userService.username();userService.address("王五","武汉");userService.address("张三");
2.2.3 ByteBuddy在程序中的应用
上面我们创建代理的案例中,把返回值设置成了固定值,但在真实程序汇总通常是要做特定业务流程处 理,比如事务、日志、权限校验等,此时我们需要用到ByteBuddy的MethodDelegation对象,它可以 将拦截的目标方法委托给其他对象处理,这里有几个注解我们先进行说明:
-
@RuntimeType:不进行严格的参数类型检测,在参数匹配失败时,尝试使用类型转换方式(runtime typecasting)进行类型转换,匹配相应方法;
-
@This:注入被拦截的目标对象。
-
@AllArguments:注入目标方法的全部参数。
-
Origin:注入目标方法对应的 Method 对象。如果拦截的是字段的话,该注解应该标注到 Field 类型参数。
-
@Super:注入目标对象。通过该对象可以调用目标对象的所有方法。
-
@SuperCall:这个注解比较特殊,我们要在 intercept() 方法中调用目标方法的话,需要通过这种 方式注入,与 Spring AOP 中的 ProceedingJoinPoint.proceed() 方法有点类似,需要注意的是, 这里不能修改调用参数,从上面的示例的调用也能看出来,参数不用单独传递,都包含在其中了。 另外,@SuperCall 注解还可以修饰 Runnable 类型的参数,只不过目标方法的返回值就拿不到了。
我们对上面的源对象userservice进行一个增强,做一个日志切面;
1)创建代理对象
public static void main(String[] args) throws Exception {//创建ByteBuddyUserService userService = new ByteBuddy()//指定创建UserServiceImpl对象的子类.subclass(UserService.class)//匹配方法,所有方法均被拦截.method(ElementMatchers.isDeclaredBy(UserService.class))//任何拦截都返回一个固定值.intercept(MethodDelegation.to(new AspectLog()))//创建动态对象.make().load(ByteBuddy.class.getClassLoader(),ClassLoadingStrategy.Default.INJECTION).getLoaded().newInstance();userService.username();userService.address("王五","武汉");userService.address("张三");}
2)增强处理类
public class AspectLog {@RuntimeTypepublic Object intercept(// 目标对象@This Object obj,// 注入目标方法的全部参数@AllArguments Object[] allArguments,// 调用目标方法,必不可少@SuperCall Callable<?> zuper,// 目标方法@Origin Method method,// 目标对象@Super Object instance) throws Exception {//目标方法执行前执行日志记录System.out.println("准备执行Method="+method.getName());// 调用目标方法Object result = zuper.call();//目标方法执行后执行日志记录System.out.println("方法执行完成Method="+method.getName());return result;}
}
运行结果如下:
以上就是Skywalking Agent原理的解析,主要是介绍了什么是Java Agent,以及Byte Buddy的使用,因为在Skywalking中就是使用了ByteBuddy对字节码进行增强,有了这个技术基础才能更好的理解Skywalking源码;