点击箭头处“蓝色字”,关注我们哦!!
Java
01
Background
随着程序功能的⽇益复杂,项⽬中依赖了越来越多的技术组件,有些是开源第三⽅的,有些是内部⾃研的,⽽这些技术组件更多是以 library ⽽不是 framework 的形态存在。随着程序的迭代运⾏,慢慢积累了以下问题:
1. 很多同学不知道这些 library 怎么⽤,如何进⾏恰当的配置部署,甚⾄都不知道有这么个东⻄存在。那么这些library 提供的能⼒就等于没⽤ (即:你再强,我不⽤,等于没有)。
2. 每个团队都有⾃⼰特殊的需求,很难有⼀个实现⽅案可以满⾜所有⼈的定制化需求。但是这些library 往往没有提供合适的扩展点供我们去定制化。
3. ⼤部分的 library 的⽣效都依赖于特定的配置,在实践过程中经常发现缺少特定的框架配置或者配置的参数有误,容易导致各种各样问题的发⽣。⽐如在⽣产环境中开启了 DEBUG 的⽇志级别造成磁盘空间不够;接⼝缺少熔断限流策略的保护;timeout 配置的过⻓等等.
4. 这些 library 的接⼊往往对项⽬代码具有⼀定的侵⼊性,我们更倾向于⼀个零侵⼊性的方案。综上所述,我们需要引⼊⼀个技术⽅案,⼀个 framework 去解决这些问题!
02
What Is Java Agent
我们都了解 Spring AOP 的原理,其通过 JDK Proxy 及 CGLIB ⽣成代理类来实现⽬标⽅法的织⼊,这⾥有⼀个⽐较重要的概念 - 织⼊(Weaving)。
织⼊为英⽂ Weaving 的直译,可字⾯理解为将额外代码插⼊⽬标代码逻辑中,实现对⽬标逻辑的增强、监控等功能。织⼊的⼿段有很多种,不同的⼿段也对应不同的织⼊时机:
即 compile-time-weaving,编译期织⼊。在源码编译阶段,通过修改/新增源码,⽣成预期内的字节码⽂件如AspectJ、Lombok、MapStruct等。Lombok、MapStruct等使⽤了 Pluggable Annotation Processing API 技术实现对⽬标代码的修改/新增,⽽AspectJ则直接使⽤了acj编译器。
即 run-time-weaving,运⾏期织⼊。在程序运⾏阶段,利⽤代理或者Copy⽬标逻辑的⽅式,⽣成新的Class并加载。常⻅如 Spring AOP 中的 JDK Proxy、CGLIB等。
即 load-time-weaving,加载期织⼊。在Class⽂件的加载期,对将要加载的字节码⽂件进⾏修改,⽣成新的字节码进⾏替换。典型代表就是 Java Agent技术,Java Agent 配合 Java Instrumentation API 只提供了字节码修改替换的能⼒,字节码⽂件的修改还需要借助外部字节码注⼊框架,如javassist、asm、bytebuddy 等。
相⽐于 CTW 和 RTW,LTW 具有以下优势:
1. 配合 Java Instrumentation API,实现灵活,功能强⼤。CTW 往往只适合⽣成⼀些简单的模板代码,⽐如getter/setter、toString 等⽅法。⽽且编译的时候需要依赖源码,所以不⽅便对第三⽅类库进⾏编译期织⼊。Java Agent可以动态地、在不依赖源码的前提下对需要加载的字节码进⾏修改、注⼊。
2. 接⼊⼏乎零侵⼊性,只需要在启动参数前加上⼀段配置,就可以透明地 Hook 住所有代码。(包括我们⾃⼰的和第三⽅类库的)
3.相⽐于 Spring AOP 作⽤范围更⼤,限制更⼩。Spring AOP 只能作⽤于 Spring 容器中的Bean,⽽且有诸多限制,⽐如⼀般情况下只能增强公共⽅法、不能新增⽅法,不能实现接⼝等等。⽽ Java Agent ⼏乎没有限制,可以直接对⽬标类新增⽅法、字段和添加实现接⼝等操作,甚⾄可以对由BootStrapClassLoader 加载的类进⾏增强
回顾⼀下,类是怎么被加载到 JVM ⾥⾯的?即加载->验证->准备->解析->初始化。在加载阶段,⼀定会有⼀个步骤是把类的字节码信息读⼊ JVM(可能是.class⽂件、可能是⽹络等)。那么我们可不可以在获取到类的字节码后,开始下⼀个步骤前,修改⼀下类的字节码信息呢?
Java Agent 是从 JDK1.5 开始引⼊的,算是⼀个⽐较⽼的技术了。作为 Java 的开发⼯程师,我们常⽤的命令之⼀就是java 命令,⽽ Java Agent 本身就是 java 命令的⼀个参数(即 -javaagent)。-javaagent 参数之后需要指定⼀个 jar 包。
在 Java 虚拟机启动时,执⾏ main() ⽅法之前,虚拟机会先找到 -javaagent 命令指定 Jar 包,然后执⾏指定的 premain-class 中的 premain() ⽅法。⽤⼀句概括其功能的话就是:premain() ⽅法是 main()⽅法执⾏之前的⼀个拦截器。
使⽤ Java Agent 的步骤⼤致如下
1. 定义⼀个 MANIFEST.MF ⽂件,在其中添加并指定 premain-class 配置项
2. 创建 premain-class 配置项指定的类,并在其中实现 premain() ⽅法,⽅法签名如下:
3. 将 MANIFEST.MF ⽂件和 premain-class 指定的类⼀起打包成⼀个 Jar 包.
4. 使⽤ -javaagent 指定该 Jar 包的路径即可执⾏其中的 premain() ⽅法。
什么是instrumentation?
Instrumentation是⼀个接⼝,定义了字节码修改的规范,其位于 java.lang.instrument 包下⾯。
Instrumentation的⼀些主要⽅法如下:
重点关注⼀下 这个⽅法,在这个⽅法⼊参⾥⾯有⼀个关键的接⼝类是 ClassFileTransformer
顾名思义就是类⽂件转换器。它只有⼀个接⼝定义⽅法:
通过 addTransformer ⽅法将ClassFileTransformer注册之后,后续被加载的类都会被注册的ClassFileTransformer 拦截并转换。
然而,直接使用Java Agent 原生的API,实现一个ClassFileTransformer 来对类的二进制数据进行增强转换,其实并不容易.其中涉及到的底层原理和实现细节对开发人员的门槛较高!
03
MoonWalker At A Galance
基于以上种种,起点服务端团队决定打造⼀款基于 Java Agent、插件式、零侵⼊性的技术框架,以增强⽬前的实现⽅案。我们希望做到以下三点:
透明:研发时按照正常业务逻辑做就⾏,学习成本会更低,踩的坑更少(例如:这⾥为什么不抛异 常?因为你加的这个注解把异常拦掉了)。特别是在现在⽂档和最佳实践不健全,各种使⽤⽅式都 是⼝⼝相传的情况下,作⽤会更加明显。
强约束:现在积累的⼀些技术组件,更多是以 library ⽽不是 framework 的形态存在。很多同学不知道 library 怎么⽤,甚⾄都不知道有这么个东⻄存在。那么这些 library 提供的能⼒就等于没⽤(即:你再强,我不⽤,等于没有)。⽽ Java Agent 只需在 JVM 启动参数前加上⼀段配置,就可以“透明”地获得强约束。
体系化:现在的实现⽅案更多是发现问题就给出⼀个新的 library,或给已有的 library 增加⼀些feature,⽽新的library 或 feature 的⽣效往往需要改动业务代码来接⼊。因此,希望通过 Java Agent 的⽅式去提供⼀套 framework,后续有任何新的策略就通过新增插件的⽅式来作⽤到这套framework 上,同时⽆需改动业务代码,即零侵⼊性。
总⽽⾔之:优势就是可以透明地 Hook 住所有基本逻辑.(包括我们⾃⼰的和第三⽅类库的)
3.1 MoonWalker简介
MoonWalker 是起点服务端为了解决之前所述的种种问题,通过结合 Java Agent 等相关技术所研发的⼀款框架,能够在类加载到 JVM 之前将开发者所需要增加的代码直接织⼊到⽬标类的⽬标⽅法中,且对原来代码完全⽆任何侵⼊,同时对⽬标类和⽬标⽅法⼏乎⽆任何限制。开发者在编写具体增强的代码时,直接采⽤ Java 代码书写,⽆需了解字节码底层的指令、助记符等,因此容易上⼿且⻔槛较低。
框架模块⼤致分为两块,Agent 内核和 Plugin模块.Plugin 模块负责定义具体的增加逻辑,即需要对哪些类的哪些⽅法做哪些增强。Agent 内核会在 premain ⽅法执⾏过程中扫描解析各个 Plugin,并将其进⾏注册;在 main ⽅法执⾏过程中,⽬标类在加载到 JVM之前,Plugin 会对其进⾏字节码的注⼊从⽽实现增强。
3.2 技术选型
MoonWalker 部分技术选型和实现⽅案借鉴⾃ SkyWalking ,⼀款国⼈开源的APM产品,2019 年 4 ⽉17 ⽇ SkyWalking 从 Apache 基⾦会的孵化器毕业成为顶级项⽬。
框架利⽤Byte Buddy完成对字节码的修改,Byte Buddy 是⼀个开源 Java 库,其主要功能是帮助⽤户屏蔽字节码操作,以及复杂的 Instrumentation API 。Byte Buddy 提供了⼀套类型安全的 API 和注解,我们可以直接使⽤这些 API 和注解轻松实现复杂的字节码操作。另外,Byte Buddy 提供了针对 Java Agent 的额外 API,帮助开发⼈员在 Java Agent 场景下轻松增强已有代码。
3.3 Quick Start
下⾯演示如何在应⽤中接⼊MoonWalker:
1. 在业务应⽤中通过 maven 引⼊MoonWalker的依赖
2. 在启动命令前加上
3. 启动后观察⽇志
04
MoonWalker In Depth
通过上⾯的介绍,相信⼤家已经对 MoonWalker 有了⼀个初步的了解,接下来会主要介绍:
1. TargetClass && TargetMethod (⽬标类和⽬标⽅法)
需要被增强的⽅法以及⽅法所在的⽬标类
2. Interceptor(拦截器)
⼀个 Interceptor(拦截器)定义了如何对 TargetMethod(⽬标⽅法)进⾏增强,可细分为MethodInterceptor 即针对⽅法的增强,包括静态⽅法和实例⽅法;和ConstructorInterceptor 即针对构造⽅法的增强
3. InterceptPoint (拦截点)
⼀个 InterceptPoint (拦截点)定义了需要对哪个 TargetMethod(⽬标⽅法)织⼊哪个Interceptor 的逻辑,即TargetMethod 的匹配规则和对应的 Interceptor(拦截器),可细分为ConstructorInterceptPoint、StaticMethodInterceptPoint 和 InstanceMethodInterceptPoint
4. InterceptChain(拦截链)
⼀个 InterceptChain(拦截链)就是多个 Interceptor(拦截器)的有序集合。即⼀个TargetMethod(⽬标⽅法)可以被多个 InterceptPoint (拦截点)命中拦截,这些命中的InterceptPoint 所定义的 Interceptor 的有序集合最终组成了这个⽅法具体的拦截链
5. MethodDelegationTemplate (⽅法代理模板)
MethodDelegationTemplate (⽅法代理模板)的作⽤是提供⼀个代码模板、⼀层桥接,将InterceptChain(拦截链)织⼊到 TargetMethod (⽬标⽅法)从⽽糅合形成最终增强后的⽅法代码
简单来说, MethodDelegationTemplate ⾥⾯的成员变量 interceptorChain 就是拦截链.intercept方法就是真正的拦截方法,它将会代理整个 TargetMethod ,通过ByteBuddy的注解,将实例对象、类信息、方法信息、入参等进行依赖注入,然后由 interceptChain负责顺序地执行增强的代码和原始方法.
6. PluginDefine (插件定义)
⼀个插件定义了以下内容:
1. Name(插件的名字)
2. Class to enhance(需要增强哪些类)
TargetClass (⽬标类) 的匹配规则
3. Available condition(满⾜哪些条件下⽣效)
⽐如某些插件必须在⽣产环境下⽣效
4. Classes to witness(是否需要进⾏版本⻅证)
当且仅当应⽤存在⻅证类列表,插件才⽣效.让我们看看这种情况:
⼀个类库存在两个发布的版本(如1.0 和2.0).其中包括相同的目标类.但不同的方法或不同的方法参数列表.所以我们需要根据库的不用版本使用插件的不同版本.然而版本显然不是一个选项,这时需要使用见证类列表,判断出当前引用类库的发布版本.
通过判断存在 org.springframework.web.servlet.view.xslt.AbstractXsltView类应用使用SpringMVC 3,使用apm-springmvc-annotation-3.x-plugin.jar.
通过判断存在 org.springframework.web.servlet.tags.ArgumentTag类,应用使用SpringMVC 4.使用apm-springmvc-annotation-4.x-plugin.jar.
默认情况,插件生效,无需见证类列表.
5. InterceptPoint Collection(拦截点集合)
包括 ConstructorInterceptPoint Collection、StaticMethodInterceptPoint Collection 和
InstanceMethodInterceptPoint Collection
premain方法
main方法
1. premain ⽅法执⾏过程中,类的转换增强并不会发⽣,真正的增强发⽣在 main ⽅法执⾏,
premain ⽅法只进⾏ Plugin 的扫描解析,和转换逻辑的注册绑定。
2. 只有类的加载时机才会触发类的转换增强,然⽽类在何时被加载并不能确定。因此有可能在进程从 启动到结束的整个⽣命周期中,某个类都有可能不会被加载,从⽽不会被增强,当然对应的 Plugin 不会起作⽤。
3. Interceptor 在加载,初始化,实例化过程中,是处于⼀个较为复杂的 classLoader 环境中,只有定制化的PluginClassLoader 才能保证其最终实例化成功,具体细节后⾯会详述。
4. 修改后的类
做一个对比
左边是 ProxyStat 的 addInvokeTime 原始代码
右边是增强后 addInvokeTime的代码
这里的delegate$96kv751就是这个addlnvoke Time的拦截链的一层封袋,简单来说增强后的方法就是直接调用了拦截链的intercept方法,将入参、方法信息、实例等都作为参数传递出去,由拦截链负责顺序地执行增强的代码和原始方法.
1.相关概念回顾
每个类加载器都有⾃⼰的 namespace(命名空间),命名空间由该加载器及所有⽗加载器所加载的类组成。
在同⼀个 namespace(命名空间)中,不会出现类的完整名字(包括类的包名)相同的两个类。
在不同的 namespace(命名空间)中,有可能会出现类的完整名字(包括类的包名)相同的两个类。
同⼀个 namespace(命名空间)内的类是相互可⻅的,⼦加载器的 namespace(命名空 间)包含所有⽗加载器的命名空间。因此由⼦加载器加载的类能看⻅⽗加载器的类。例如系统 类加载器加载的类能看⻅根类加载器加载的类。
由⽗加载器加载的类不能看⻅⼦加载器加载的类。如果两个加载器之间没有直接或间接的⽗⼦ 关系,那么它们各⾃加载类相互不可⻅。
2.问题回顾
背景: ProxyStatPlugin 这个插件需要对Rpc 框架的每次调用的耗时情况进行采集上报,需要拦截ProxyStat 这个类的addlnvoke Time 方法.在我们Rpc框架的运行环境中,这个类所在的
Jar包是由AppClassLoader 加载的,而插件依赖的第三方类库是由Rpc框架的自定义的Class Loader 加载的.
第一版
⼀开始,我将 plugin 和 agent 打成⼀个 Jar 包,由 Java Agent 命令执⾏的 Jar 是由AppClassLoader加载的,从⽽导致 Plugin 的 interceptor 也是由 AppClassLoader 所加载,⽽ interceptor 所依赖的第三⽅类库是由 Rpc 框架⾃定义的ClassLoader 所加载的。根据由⽗加载器加载的类不能看⻅⼦加载器加载的类,所以会导致失败报错。下图中标注的代码 引⼊了第三⽅类库,在类的初始化或者实例化阶段都会产⽣异常,导致增强失败。
第二版
后⾯进⾏修改,将 plugin 和 agent 打成两个 Jar 包,通过⾃定义 PluginClassLoader 根据配置相对路径的classpath 来加载 Plugin,同时将 TargetClass (⽬标类) 的定义类加载器(或者线程上下⽂加载器)作为PluginClassLoader 的 parent(⽗加载器),保证 interceptor 由较为“下层”的类加载器加载。因为类加载器越处于“下层”,它的“上层加载器”就越多,它的namespace(命名空间)范围就越⼤。
进⼀步的,为了保证 interceptor 是由较为“下层”的类加载器加载的,这⾥的PluginClassLoader 是打破了双亲委派机制的。双亲委派机制其实是⼀个 parent-first 的实现,与之相反就是 child-first 的实现,即在通过PluginClassLoader 加载类时,如果这个类是在 Plugin Jar 中定义的,那么就由 PluginClassLoader⾃⼰去加载,否则还是由 parent(⽗加载器)去加载。
具体代码实现:
左边是classLoader里面双亲委派的实现,右边是child-first的实现
3.扩展问题
如何要去修改 BootStrapClassLoader 所加载的类,如何分析并解决其中的 classLoader 问题?
⼀个 V 站的例⼦:
原因很简单:ThreadPoolExecutor 是由 BootStrapClassLoader 加载的,织⼊的代码有⾃定义的MDCInheritableThreadLocal,肯定是由 BootStrapClassLoader 的⼦加载器所加载的,由⽗加载器加载的类不能看⻅⼦加载器加载的类。
类似得,在 MoonWalker 如果需要实现对修改 BootStrapClassLoader 所加载的类,也会有这种问题
上图中 InstanceMethodDelegationTemplate 就是上⾯所说的MethodDelegationTemplate(⽅法代理模板),作⽤是提供⼀个代码模板、⼀层桥接,将 InterceptChain(拦截链)织⼊到TargetMethod (⽬标⽅法)从⽽糅合形成最终增强后的⽅法代码。这个类以及所引⽤的第三⽅依赖(上图标注的代码)对于BootStrapClassLoader 都是不可⻅的。为了解决这个问题,就必须要让它们在 BootStrapClassLoader 中可⻅。
那怎么让指定的类 BootStrapClassLoader 中可见?
解决的思路:将这些类 Inject (注入)到 BootStrapClassLoader 使得 BootStrapClassLoader能够正常加载解析这些类,如同加载解析 java.lang.Object 一样.
方法有这么几种:
1.将这些类打成⼀个临时 jar ⽂件,通过 Instrumentation 的voidappendToBootstrapClassLoaderSearch(JarFile jarfile) 方法
2.通过 Unsafe 里的 public native Class<?> defineClass(String var1,byte[] var2,int var3,int var4,ClassLoader var5,ProtectionDomain var6) 方法
由于 Java Agent 的运⾏需要指定 Agent Jar 的全路径,所以需要考虑如何将 Agent Jar 安置到⼀个固定的路径。⽬前MoonWalker 采⽤通过 maven 引⼊的⽅式将 Agent Jar 和 Plugin Jar 和应⽤代码⼀起进
⾏打包部署,这种⽅式的代价较⼩。否则就需要通过运维介⼊,将 Agent Jar 和 Plugin Jar 投放到物理机中,或者打⼊到docker 的镜像中。
在⾮ springboot 项⽬中,这种⽅式是没有问题的,Agent Jar 和 Plugin Jar 会和第三⽅ Jar ⼀起解压到固定的 lib ⽬录下。但是在 springboot 项⽬中,这种⽅式⼀般情况下是⾏不通的。因为 springboot 是通过⾃定义的 springboot-maven-plugin 将项⽬打包成为⼀个 Fat Jar 来部署的。我们所有通过 maven 引⼊的依赖都在这个 Fat Jar ⾥的 BOOT-INF/lib 路径下,需要通过springboot ⾃定义的LauncherClassLoader 来加载。如果 Agent Jar 打包在这个 Fat Jar ⾥的 BOOT-INF/lib 路径下,相当于Agent Jar 内嵌在 Fat Jar ⾥⾯,是⽆法在 Java Agent 指令中指定它的路径的,从⽽⽆法在 Java Agent 机制下运⾏。
我们可以通过扩展 springboot-maven-plugin 这个 maven 插件,使得 Agent Jar 不会被打包到 Fat Jar ⾥的BOOT-INF/lib 路径下,⽽是直接解压到 Fat Jar ⾥的顶层⽬录下,即和 springboot 启动相关的类处在同⼀层级,⼀起被AppClassLoader 加载。
通过⾃定义 LayoutFactory,使得⽣产出的 Layout 实现 CustomLoaderLayout 这个接⼝,从⽽获得可以写⼊⾃定义loaderClass 的能⼒。通过这种⽅式,最终打包的 Fat Jar 就可以既具备 Executable Jar(可执⾏ Jar)的能⼒,⼜具备 Agent Jar 的能⼒,所以我们⽆需再考虑如何安置 Agent Jar ,只需将原来的启动命令 java-jar demo.jar 改成 java-javaagent:demo,jar-jar demo.jar
1.实现 ClassEnhancePluginDefine 接口,定义插件的名字、需要增强哪些类、满足哪些条件下生效、是否需要进行版本见证、对应该类的 InterceptPoint 集合等.(注意: ClassEnhancePluginDefine 实现类中所有有关类的信息,方法信息,Interceptor 信息等必须都由字符串直接描述,比如“ test.moonwalker.plugin.benchmark.interceptor.TestBenchmarkInterceptor";不能用TestBenchmarkInterceptor.class.getName() 这种方式,因为在扫描解析 Plugin 的时候,还无法正确加载相关类)
2.编写具体的 Interceptor ,实现MethodInterceptor 或者 ConstructorInterceptor ,实现intercept 方法,在其中编写具体增强逻辑
一个例子,对有特定注解的方法进行监控:
05
Present And Future
MoonWalker ⼤约于 2021 年 2 ⽉春节前夕正式上线,部署到了起点服务端⼏个访问量较⼤的应⽤上, 经受住了春节期间⼤流量的考验。
⽬前已有 Plugin:
Rpc-Plugin:对 Rpc 的调⽤耗时、状态集中上报到监控服务
Logback-Plugin:在⽣产环境,强制 Appender ⾛异步,且忽略 DEBUG 级别⽇志
作者介绍
陈炯强,JAVA 开发工程师
目前就职于阅文起点技术中心
主要负责起点服务端通用框架的研发工作