1、简介

Java代码转换后的JVM指令存在Code区中。如果能对Code区的指令进行新增、修改,即能达到增强字节码的效果。字节码增强技术就是一类对现有字节码进行修改或者动态生成全新字节码文件的技术

2、前世今生

2.1 从AOP开始说起

用过Spring的同学肯定对AOP的概念不陌生,AOP能使得程序能在代码前后进行一些功能织入(如日志记录,执行耗时统计等)。 其特点是通过代理方式对目标类在运行期间织入功能。
AOP的实现技术有如下几种:
image.png
以实例来说明,假设有如下接口AopDemoService,sayHello方法简单输出一句话,要在原有输出的前后增加输出”start”,”end”:

2.1.1 静态代理

程序运行前就已经存在代理类的字节码文件,代理类和原始类的关系在运行前就已经确定
image.png
image.png
缺点:不灵活,每一个需要被代理的目标类,都需要实现一个代理类,增加类、增加方法都需要对代理类代码进行修改

2.1.2 动态代理

2.1.2.1 JavaProxy

动态代理解决了静态代理的问题,程序运行期间通过JVM反射等机制动态生成代理类,代理类和目标类的关系是运行时才确定的
Java中的动态代理,需要自定义实现一个InvocationHandler类,在其invoke方法中原有逻辑进行增强
image.png
缺点:实现类必须实现接口,Java中的动态代理通过传入的接口来反射生成一个新的类,在新的类中调用InvocationHandler.invoke对方法进行代理

2.1.2.2 Cglib

当某个类没有实现某个接口时,可以通过CGLIB来创建一个继承实现类的子类,用Asm库动态修改子类的代码来实现AOP效果
先定义一个类,不实现任何接口,其中有2个方法,一个普通方法,一个final修饰的方法
image.png
image.png
再调用Enhancer来动态生成子类,分别调用其普通方法和用final修饰的方法
image.png
可以看到普通方法被增强了,前后输出了start、end。但是final修饰的方法并没有达到预期效果
缺点:不能对final修饰的类或方法进行增强

2.1.3 字节码增强实现AOP

既然CGLIB使用asm库动态修改子类的代码来实现AOP效果,那么能不能直接使用操作字节码的框架,来修改原有的字节码来达到增强效果呢?ASM和JavaAssist两个框架提供了修改字节码的功能

2.1.3.1 ASM

对于需要手动操纵字节码的需求,可以使用ASM,它可以直接生产 .class字节码文件,也可以在类被加载入JVM之前动态修改类行为。ASM的应用场景有AOP(Cglib就是基于ASM)、热部署、修改其他jar包中的类等
先看ASM对字节码操作的过程图
image.png
过程如下
先通过ClassReader读取编译好的.class文件
其通过访问者模式(Visitor)对字节码进行修改,常见的Visitor类有:对方法进行修改的MethodVisitor,或者对变量进行修改的FieldVisitor等
通过ClassWriter重新构建编译修改后的字节码文件、或者将修改后的字节码文件输出到文件中
既然Visitor是修改字节码的关键,看下如何基于ASM实现AOP功能,先看Visitor的实现
image.png

  1. 首先是MyClassVisitor,MyClassVisitor继承自ClassVisitor,用以观察某个类的字节码文件,其中visitMethod方法用于判断当前读取到字节码文件的哪个方法了,当读取到我们想进行增强的方法时,交给MyMethodVisitor对原方法进行增强
  2. MyMethodVisitor负责对具体方法进行增强,visitCode会在某个方法被访问时调用,故前置增强逻辑在此编写,visitInsn会在无参数的指令的执行时调用,退出语句return被调用时就会调用visitInsn方法,因此,后置增强逻辑可以写在这里
  3. 至于具体的增强指令visitFieldInsn,visitMethodInsn,并不是用java语句级别的,需要对字节码指令有一定了解

缺点:直接使用字节码指令,需要对字节码指令有一定了解

2.1.3.2 JavaAssist

ASM虽然可以达到修改字节码的效果,但是代码实现上更偏底层,是一个个虚拟机指令的组合,不好理解、记忆,和Java语言的编程习惯有较大差距。
利用Javassist实现字节码增强时,可以无须关注字节码刻板的结构,其优点就在于编程简单。直接使用java编码的形式,而不需要了解虚拟机指令,就能动态改变类的结构或者动态生成类。其中最重要的是ClassPool、CtClass、CtMethod、CtField这四个类
ClassPool:保存CtClass的池子,通过classPool.get(类全路径名)来获取CtClass
CtClass:编译时类信息,它是一个class文件在代码中的抽象表现形式
CtMethod:对应类中的方法
CtField:对应类中的属性、变量
image.png
相比ASM,JavaAssist的方式可以说更加简单、易懂
缺点:上面ASM和JavaAssist的Demo,都有一个共同点:两者例子中的目标类都没有被提前加载到JVM中,如果只能在类加载前对类中字节码进行修改,那将失去其存在意义,毕竟大部分运行的Java系统,都是在运行状态的线上系统。
先尝试下,在JVM提前加载了类的情况下,使用JavaAssist对字节码进行修改会发生什么,在上面的demo中加入如下语句,模拟类提前加载的情况
image.png
其报错原因是因为:JVM是不允许在运行时动态重载一个类的
那么如何实现在JVM运行时去动态的重新加载类呢?

2.1.4 动态重载

2.1.4.1 Instrumentation接口

instrument是JVM提供的一个可以修改已加载类的类库。它需要依赖JVMTI的Attach API机制实现,在JDK 1.6之后,instrument支持了在运行时对类定义的修改。要使用instrument的类修改功能,我们需要实现它提供的ClassFileTransformer接口,定义一个类文件转换器。接口中的transform()方法会在类文件被加载时调用
先看下其关键方法:
image.png
我们需要实现ClassFileTransformer接口,并在自定义的transform方法中,利用ASM或者JavaAssist等字节码操作框架对类的字节码进行修改,修改后返回字节码的byte[]数组

2.1.4.2 JavaAgent

光有Instrumentation接口还不够,如何将其注入到一个正在运行JVM的进程中去呢?我们还需要自定义一个Agent,借助Agent的能力将Instrumentation注入到运行的JVM中

Agent是JVMTI的一种实现,Agent有两种启动方式

  1. 一是随Java进程启动而启动,经常见到的java -agentlib就是这种方式;
  2. 二是运行时载入,通过attach API,将模块(jar包)动态地Attach到指定进程id的Java进程内
    2.1.4.2.1 PremainClass随JVM进程启动
    定义如下类JavaAgent,指定其方法名为premain,并调用instrumentation新增一个自定义的ClassFileTransformer,意味着JVM进程启动时若参数中指定了该Agent,则会触发transform接口对类进行增强
    image.png
    在/resources/META-INF/MANIFEST.MF中新增如下命令:Premain-Class: aop.demo.agent.JavaAgent指定了启动类为JavaAgent
    image.png
    重启web工程,加入如下vm参数:
    image.png
    这种方式已Agent+JavaAssist的方式实现了零侵入方式的AOP,其原理就是JVM会优先调用PreMain方法(即Agent中的方法),后面才会调用Main方法。
    但是缺点也是显而易见的,Agent必须随着JVM进程启动而加载的方式,不够灵活
    假设现在线上机器某个类报了异常,我想多加一行日志输出语句,以这种方式,只能对Agent重新打包,并重新启动JVM进程重新注入Agent
    2.1.4.2.2 AgentClass以Attach方法注入Agent
    随着进程启动的Premain方式的Agent更偏向是一种初始化加载时的修改方式,而Attach API的loadAgent()方法,能够将打包好的Agent jar包动态Attach到目标JVM上,是一种运行时注入Agent、修改字节码的方式
    image.png
    市面上诸如Arthas、Btrace这种JVM监控工具即是基于这种思路实现
    看下实现思路
    先实现一个AttachAgent,其中也是往instrumentation接口中加入一个自定义的ClassFileTransformer,同时调用retransformClasses方法重新加载AopDemoServiceWithoutInterface类,来触发ClassFileTransformer中的transform方法对字节码进行修改
    image.png
    MANIFEST.MF文件内容如下
    image.png
    测试类中加入如下代码,pid为JVM运行的进程号,可以通过jps命令获取,调用VirtualMachine.loadAgent方法,能够将指定Agent注入到pid对应的JVM进程中
    image.png

    3、在部署平台中的应用

    3.1关闭Spring Cloud SDK 微服务能力

    关闭Spring Cloud SDK微服务能力的方式有如下两种:

    3.1.1、方式1 手动修改代码/配置

    要求要求用户手动修改代码/配置,关闭原有Spring Cloud SDK微服务能力。

    3.1.2、方式2 探针方案

    考虑Spring Cloud SDK 均采用Java编程语言编写,因此可以考虑探针(Java Agent技术)来动态关闭Spring Cloud SDK中的服务治理能力。

    3.1.3、方式1 和 方式2 对比

    | 方式 | 优点 | 缺点 | | —- | —- | —- | | 方式 | 优点 | 缺点 | | 方式1(手动修改) |
    - 无开发成本
    |
    - 需要驱动用户修改,对用户有感
    | | 方式2(探针方案) |
    - 探针采用字节码增强技术,用户无需手动修改,用户无感知
    |
    - 需要开发探针,同时探针需要适配多个版本的SDK
    - 探针研发成本复杂,需要清晰Spring Cloud SDK源码实现逻辑
    |

考虑到用户迁移至Service Mesh的无感知,显然方式2(探针方案)更优雅,考虑到探针实现的复杂和Spring Cloud应用程序SDK多版本复杂的现状。在实际的落地过程中方式2 + 方式1 成为一种实际落地方案。特别说明:方式2可以实现Spring Cloud 应用使用频率最多的场景(如RestTemplate、Feign),方式1只在少量特殊场景中应用。

3.1.4、动态修改字节码切入点选择

通过分析Spring Cloud Consul的源代码发现,其核心实现文件如下:
1.具体的的类信息参考:链接地址
2. 参考代码库:https://console.cloud.baidu-int.com/devops/icode/repos/baidu/bce-bms/consumer-demo/tree/consul_discovery
需要探针修改Spring Cloud服务框架中关于注册发现的逻辑,具体的逻辑如下所示:
修改前:

  1. private List<ConsulServer> getServers() {
  2. // 通过consul client进行服务发现的逻辑
  3. }

修改后

  1. private List<ConsulServer> getServers() {
  2. // 通过探针修改该方法的实现
  3. // 1. 返回List<ConsulServer>列表,其中元素长度为1,
  4. // 2. ConsulServer对象的属性值只需要设置host和port即可,host的信息为该类中的属性值信息serviceId,port信息为80。说明,其serviceId为目前需要访问的服务名称,80固定值为服务的端口号
  5. }

3.1.5、核心代码

  1. /**
  2. * hook 目标类的函数
  3. *
  4. * @param ctClass 目标类
  5. */
  6. @Override
  7. protected void hookMethod(CtClass ctClass) {
  8. LOGGER.info("Consul.method转换,ReplaceConsulHock.hookMethod");
  9. try {
  10. // 定位到方法
  11. LOGGER.info("Consul.method转换,替换方法名称" + REPLACE_METHOD_NAME);
  12. CtMethod cm = ctClass.getDeclaredMethod(REPLACE_METHOD_NAME);
  13. // 修改方法代码体
  14. cm.setBody(ReplaceText.REPLACE_CONSUL_HOCK_METHOD_TEXT);
  15. } catch (Exception e) {
  16. e.printStackTrace();
  17. LOGGER.error("Consul.method转换 exception", e);
  18. }
  19. }
  20. public static final String REPLACE_CONSUL_HOCK_METHOD_TEXT = "{\n" +
  21. " \n" +
  22. " com.baidu.formula.consul.discovery.model.HealthService.Service service = " +
  23. " new com.baidu.formula.consul.discovery.model.HealthService.Service();\n" +
  24. " String environment = System.getenv(\"EM_ENV_NAME\");\n" +
  25. " service.setPort(new java.lang.Integer(80));\n" +
  26. " java.util.Map/*<String, String>*/ meta = new java.util.HashMap/*<>*/();\n" +
  27. " meta.put(\"environment\", environment);\n" +
  28. " service.setMeta(meta);\n" +
  29. " \n" +
  30. "// service.setAddress(serviceId);\n" +
  31. " \n" +
  32. " com.baidu.formula.consul.discovery.model.HealthService.Node node = " +
  33. " new com.baidu.formula.consul.discovery.model.HealthService.Node();\n" +
  34. " node.setNode(\"127.0.0.1\");\n" +
  35. " \n" +
  36. " \n" +
  37. " com.baidu.formula.consul.discovery.model.HealthService healthService = " +
  38. " new com.baidu.formula.consul.discovery.model.HealthService();\n" +
  39. " healthService.setChecks(new java.util.ArrayList/*<>*/());\n" +
  40. " healthService.setNode(node);\n" +
  41. " \n" +
  42. " healthService.setService(service);\n" +
  43. " \n" +
  44. " com.baidu.formula.consul.discovery.model.ConsulServer consulServer = " +
  45. " new com.baidu.formula.consul.discovery.model.ConsulServer(healthService);\n" +
  46. " if (serviceId.contains(\"\\.\")) {\n" +
  47. " java.lang.String [] serviceStrs = serviceId.split(\"\\\\.\");\n" +
  48. " java.lang.String serviceNew = serviceStrs[1] + \"-\" + environment + \".\" + serviceStrs[0];\n" +
  49. " consulServer.setHost(serviceNew);\n" +
  50. " } else {\n" +
  51. " consulServer.setHost(serviceId + \"-\" + environment);\n" +
  52. " }\n"+
  53. " java.lang.System.out.println(\"consulServer is \"" +
  54. " + com.ecwid.consul.json.GsonFactory.getGson().toJson(consulServer));"+
  55. " return java.util.Collections.singletonList(consulServer);\n" +
  56. " }";

3.2 静默接入Apollo

直接通过agent加载apollo包

3.2.1 找不到依赖的spring框架的类

双亲委派机制
image.png
image.png
问题分析:agent相关包是通过父加载器加载的,spring相关的包是通过子类加载器加载的,父类访问不到子类的class
解决方案:使用app那个加载器加载agent相关包,问题解决

3.2.2 idea里可以跑起来,打成Jar包,还是找不到class

3.2.2.1 idea启动方式

在正常的开发过程中,我们只需要提供一个包含了main()方法的类,并带上@SpringBootApplication注解,那么这个类就是Springboot的启动类。
image.png
按照这种方式启动,那么这些类都是通过应用类加载器加载的

3.2.2.2 Jar包启动方式

image.png
BOOT-INF里面的内容不需要多看,就是跟我们应用代码相关的class以及依赖的jar。
MANIFEST.MF描述了该Jar文件的很多信息,详细请参考MANIFEST.MF详解
那么这里面需要关注的两个值:Main-Class和Start-Class

  • Main-Class:定义jar文件的入口类,该类必须是一个可执行的类,作为一个可执行的jar包,就必须提供这个属性,且对应的class必须提供main方法。
  • Start-Class:springboot应用实际启动类,即含有@SpringBootApplication注解的那个启动类。

image.png
本质上是使用自定义的类加载器LaunchedURLClassLoader来自定义进行富Jar的加载

3.2.3 解决方法

修改”org.springframework.boot.loader.archive.JarFileArchive”的字节码,把apollo jar包纳入springboot加载的范围
image.png