精选文章|设计一个“高效”的字节码插桩框架

技术

1

设计一个“高效”的

字节码插桩框架

背景

在做性能监控及项目优化的过程中,不可避免地需要使用字节码插桩的来实现一些需求。

比如:

  • 函数体前后插桩实现函数耗时检测;
  • Activity、Fragment相关生命周期函数插桩,实现UI活动耗时检测;
  • Thread、ThreadPoolExecutor构造函数调用替换,实现线程相关性能检测;
  • 移除Log类相关的函数调用,避免不必要的日志打印行为;

等等

当重复的进行了一些项目“字节码插桩”的相关插件的开发后,我们考虑这方面重复性的工作是否可以做进一步地简化,因为每次重复地进行 "新建Plugin项目"、"编写ASM 插桩"、"发布插件"、"引入插件到源项目中" 流程,开发周期会比较长,因此我们希望可以简化这些流程,开发类似功能时只需要关注具体需要做的字节码修改的操作即可。

字节码修改框架

Java平台的常用的字节码插桩框架主要包括ASM、AspectJ、Javaassit

这里对这三种字节码框架做一个简单的介绍。

ASM(https://asm.ow2.io/)直接针对的是Class文件的字节码,因此它对开发者对Java字节码的了解程度有较高的要求,同时它所提供的修改能力也是最完善的。

举一些使用场景,比如Kotlin在Java平台的编译器最终生成class文件时就是使用的ASM来实现的。Android项目编译过程中生成的一些R类也是通过ASM来生成的。

AspectJ主要针对的是面向函数切面的编程需求,类似ASM框架的编程,需要处理的是对编译后的class文件的修改行为(通过ClassReader、ClassWriter实现),而AspectJ以注解的方式,可以让开发者在源代码项目 中就定义所要执行的字节码操作行为,这种方式从“简易性”上来说比ASM 要强很多,但它的局限性是如果你需要 AspectJ 所定义的API之外的一些行为时就无能无力的,比如在“字节码指令层面替换某个函数的调用”,AspectJ针对目标函数编译后所生成的字节码中会多出很多中间类,如果你的需求是针对大范围的函数替换,此时不建议使用AspectJ。

Javaassit从能力和易用性上介于ASM和AspectJ之间。

对于Javaassit框架使用的比较少,从官方的介绍中有如下特性简介:“Javassist提供了两个级别的API:源代码级别和字节码级别。如果用户使用源级API,他们可以编辑类文件,而不需要了解Java字节码的规范。整个API仅使用Java语言的词汇表进行设计。你甚至可以以源文本的形式指定插入的字节码;Javassist动态地编译它。另一方面,字节码级API允许用户像其他编辑器一样直接编辑类文件。”

下图是从项目的角度来看三种框架的“作用域”,AspectJ可以在源项目中编写,所见即所得 , 而ASM和JavaAssist框架通常需要在单独的插件项目中开发,单独编译打包之后作为源项目工程的Plugin被引入。

picture.image

01

现状及思考

上个小节已经列举了现有的三种字节码插桩框架,在目前的项目(Android项目)开发中,如果对性能要求比较高,或者是一些只能使用ASM框架进行修改的,我们会直接使用ASM来开发。

如果只是简单的监控某个函数的执行,目标函数的数量不多,并且该函数执行的频次不高(比如Activity的onCrate)则出于开发效率的考虑,会直接使用AspectJ。

使用AspectJ的好处是插桩代码逻辑可以直接写在源项目中,我们可以非常清楚的知道所插桩的行为,整个行为对于开发者来说是相对比较透明的。

我们简单看一个在Android项目中使用AspectJ的例子:

picture.image

picture.image

以上Aspect代码表示在调用Context的StartActivity函数是进行字节码插桩,再其函数调用后,打印一个日志,我们看生成的字节码效果。

picture.image

从上图生成的class可以看出使用AspectJ会产生一些字节码方面“副作用” ,首先AspectJ会生成一个原函数逻辑的备份函数即xx_aroundBody0,这个无可厚非,另外AspectJ默认会生成一个静态成员变量用于存放“切面”的信息(JoinPoint中可以获取原函数的参数、及调用函数的对象、this指向等),不管你是否会使用到。

在某些情况下,比如,如果我们的需求是替换原函数的逻辑,不需要执行原函数, 我们可以不需要ProcessPoint,即使实现的代码如下所示,函数参数中并没有声明ProcessPoint变量。

picture.image

但是,生成的字节码依旧会包含这个静态成员变量。

picture.image

所以这个方面相比AspectJ,我们可以做一些优化,让插桩行为的“副作用”尽可能小,如无必要则不要生成一些中间字节码产物。

AspectJ提供了Pointcuts注解(https://www.eclipse.org/aspectj/doc/released/progguide/starting-aspectj.html#pointcuts)用于描述、限定目标函数,这个注解的功能可以满足大部分的需求比如:

@Pointcut(call(void Point.setX(int)))表示目标函数为Point类中名为setX,函数参数为int类型的函数。

@Pointcut还支持&& || ! 等判断条件,比如 @Pointcut(call(void Point.setX(int)) || call(void Point.setY(int)))同时表示setX或者setY函数。

但是考虑到Android项目的一些特殊性,比如 我们的目标是以类的继承关系为体系的,比如目标为:Context类的所有直接子类的startActivity函数 ,此时AspectJ的Poincut就无法满足需求了。

针对一些特殊的需求,比如替换对某个函数的调用,修改函数返回值,移除某个函数的调用等需求,通常使用AspctJ“副作用”较大,或者是直接使用AspectJ无法满足时(比如修改字段、将某个函数的实现变为空实现、移除项目对某个函数的调用等),此时我们会考虑使用 ASM来完成。

上个小节也提到,使用ASM需要开发者对字节码有一定的了解,另外使用ASM的一个问题是,使用ASM框架的代码不能和源项目的代码在一个模块中,通常需要建立单独的插件模块,并在插件模块中编写相应的ASM代码,代码开发完成后,需要将插件模块编译、发布到maven,然后再接入到源项目模块中,整个流程下来周期还是比较长的。

可以看出ASM和AspectJ各有优缺点,通常我们需要根据不同的需求并考虑开发效率和代码运行效率来选择使用ASM或者是AspectJ。

这里的源项目模块是指我们通常理解的被包含在最终编译产物(.jar)中的代码,以Android项目为例,即表示最终会被打包进APK的代码。

而插件代码是不会被编译进APK的。

02

方向

针对以上问题并结合实际的业务场景,我们希望能够有一个既像AspectJ开发效率较高,又比AspectJ执行效率更高、功能更丰富的字节码插桩框架。

这里我首先整理了目前一些常见的业务需求,新的字节码框架至少需要满足以下需求:

  • 提供完善的目标范围限定能力

  • 提供常见的字节码插桩功能

  • 和AspectJ类似的使用方式,基于注解可以直接在原项目中进行开发

  • 考虑编译速度、增量编译的能力等

03

程序设计

我将 “字节码插桩” 这个动作的“信息”分为两部分,分别是“Target”及“Action”。

“Target”表示插桩的目标,类似于AspcetJ的 Pointcut(切入点) 概念。

“Action”表示针对目标所执行的具体字节码修改的行为。

比如原函数前后插入指定代码、修改函数返回值、替换某个函数的调用等等,不同的 “Action”在具体实现时可以用不同的注解来表示,我们先将这些注解统称为“Action”。

因此像AspectJ一样,我们首先需要设计一些注解用于让开发者申明“Target” 及 “Action” 信息,我们首先思考下 “Target” 及"Action" 应当能够表达哪些信息。

Target (字节码修改的目标范围)

Target信息的主要作用就是限定ASM的作用域,根据不同的需求,可以从不同的纬度来限定。

以ClassName为维度

这里的类名包括类的Pacakge信息。比如我们希望限定插桩行为只对某个三方库生效,那么我们可以通过限定类的包范围。

以类名为维度,我们可以考虑提供以下限定能力:

  • 限定类所在的包范围
  • 限定为指定的某些类名 (1个或多个)
  • 排除某些包或者某些类
  • 类名支持正则表达式筛选

以类的继承体系为维度

在某些特殊场景下,我们的目标不是针对某个具体的类,而是类的子类或者直接子类,比如我们的目标是在Activity的所有直接子类onWindowFocusChanged函数被调用时插入一段代码。

这里以类的继承体系,我们可以提供以下的范围限定。

  • 某个类自身
  • 某个类的直接子类
  • 某个类的所有子类
  • 某个类及其所有子类
  • 某个类的所有最终子类

直接子类表示,一级继承某个目标类的所有类。

最终子类,表示继承于目标类 且该类没有再被其他类继承的类(即它不是任何类的父类)。

Action (字节码修改的具体内容)

限定了字节码操作的范围后,接下来就是指定具体要执行的字节码修改的行为,我们知道如果需要提供最完善的字节码修改能力,则直接使用ASM库即可,但是我们对这个新的框架的定义是能够覆盖大部分的场景即可,我们设计的框架不是为了取代ASM,而是在 ASM的基础上进行封装,提供一系列注解及API来完成常见的一些需求,以此来提高开发效率。

在初期,我们可以考虑先提供以下字节码插桩行为:

  • @Around
  • 类似AspectJ,对原函数进行需改,支持在原函数前后插入一些代码
  • @ReplaceCall
  • 扫描所有符合要求的函数调用,并替换成新的目标函数调用
  • @Redefine
  • 重写某个方法的实现

除了以函数为目标,如果有场景是修改某个变量的值,比如对某个类中定义的基本类型或者String类型 的字段的初始值,也可以考虑进行支持。

  • @ReDefineFieldValue
  • 修改字段定义的初始值

注解仅仅是表示了插桩行为的类型,具体的 “插桩”代码如何表示呢。

我们希望最终提供给开发者的方式是和编写正常的业务代码一样,直接声明某个函数,可以在这个函数内直接编写最终被修改后的代码,我们将这类函数称为weaver函数。

但是这里在某些场景下会遇到一些问题,比如如果需要在weaver函数内部访问目标函数所在类的某个成员变量、或者是需要调用原对象中某个私有的函数,此时由于Java访问控制符的限制,在我们的插桩函数代码中,我们无法像原函数一样可以直接访问private的一些成员,此时就需要我们的框架提供一些API来适配并实现这种能力,这个会在后面具体的框架具体解释,简单来说,就是会提供一些 “桩子”的能力,使用了这些"桩子"的字段或者函数在编译阶段“桩子”的字节码会被替换成原有的类的字节码。

分组及功能开关

在实际的项目中,我们遇到过一些需要控制字节码插桩开关 或者进行功能分组并控制开关 的场景,比如针对不同的编译环境(debug包 release包) 需要有选择性的开启或者关闭某些字节码插桩功能。

在之前,这些功能通常每个都是单独的插件了,每个插件独立各自的功能开关。

但我们目前的框架本身是一个插件,这个插件功能要么全开要么全关,所以针对以上需求,我们需要设计更细粒度的功能开关控制,这里我们可以设计一个分组 注解,这个分组注解 可以使用在某个Weaver类或者weaver函数上,然后在gradle的extension中配置开关。

  • @Group
  • 为该类下所有插桩行为分组

picture.image

picture.image

在插件中,扫描这些weaver类或者函数时,会读取上面的@Group 注解,并以注解上标示的值(groupName)做为分组的依据。分组之后,会根据开发者在plugin配置中,声明的 group开关来控制这组“字节码插桩”功能是否打开。

插件的实现

picture.image

我们将整个程序分为两部分,一部分称之为接口层(interface),这部分是直接在源项目中使用的,它提供了一些注解以及API来描述字节码插桩的行为。

第二部分是Plugin这是插件的核心实现, interface只是提供了插桩所需要的信息,如何获取这些信息并应用 、修改原程序的字节码,这部分是由Plugin部分来实现的。这部分涉及到的细节及内容较多,因此不在设计篇进行具体阐述。

picture.image

Plugin中的大致流程是,扫描原来所有的类,首先找到带有这些注解信息的类及函数,并进行信息收集,这些信息包括注解上的值,以及使用了注解的函数(ASM中的MethodNode), 这里保存MethodNode,是因为 MethodNode用于辅助生成新的函数字节码信息。

在第一遍的字节码扫描中收集了这些信息后,就可以进行第二遍字节码扫描,对符合目标的函数进行字节码修改。

04

总结及后续

本文首先简单介绍了常见的字节码插桩及其优缺点,并基于个人项目中的一些开发场景提出一个介于ASM和AspectJ之间更高效的字节码插桩框架原型及设计思路。

对于该框架的具体实现会另起一篇文章进行详细的介绍,敬请期待。

回到本文的标题,这里的 “高效”是从两个维度考虑的,一个维度是时间效率,这里的时间效率是相对直接使用ASM而言,我们不再需要单独进行Plugin的开发,并且插桩代码直接在源项目中开发。

另一个是使用上的效率,如果使用ASM则需要开发者对字节码以ASM的API有一定的了解,针对通用需求,以注解的形式来描述插桩的行为,相比直接使用ASM API要友好不少,减少学习成本。

框架存在的一些限制是和AspectJ一样,框架所提供的能力之外的字节码插桩行为就无能为力的,但如果这个新的插桩需求,并且这个“插桩功能”可以被封装成通用的一个实现,我们可以设计新的"Action"注解,并将字节码修改的具体操作封装到插件中,这样后续如果遇到相似的行为就不需要重复开发了。

在开发效率方面,还有一个需要考虑的点是插件对项目的编译效率的影响,如果你的项目较复杂Jar包比较多,在Class字节码处理时还需要进行一些优化(缓存、增量编译、多线程处理等)以提高编译速度,这里可以参考 booster在这一块使用的并行化处理,提高编译速度。

参考项目及资料

https://asm.ow2.io/

https://www.eclipse.org/aspectj/doc/next/progguide/starting-aspectj.html

https://github.com/didi/booster

https://github.com/bytedance/ByteX
https://github.com/eleme/lancet

picture.image

关注得物技术,携手走向技术的云端

picture.image

文| Knight-ZXW

picture.image

0
0
0
0
评论
未登录
看完啦,登录分享一下感受吧~
暂无评论