曹工说Spring Boot源码(25)– Spring注解扫描的瑞士军刀,ASM + Java Instrumentation,顺便提提Jar包破解

  • 2020 年 3 月 27 日
  • 笔记

写在前面的话

相关背景及资源:

曹工说Spring Boot源码(1)– Bean Definition到底是什么,附spring思维导图分享

曹工说Spring Boot源码(2)– Bean Definition到底是什么,咱们对着接口,逐个方法讲解

曹工说Spring Boot源码(3)– 手动注册Bean Definition不比游戏好玩吗,我们来试一下

曹工说Spring Boot源码(4)– 我是怎么自定义ApplicationContext,从json文件读取bean definition的?

曹工说Spring Boot源码(5)– 怎么从properties文件读取bean

曹工说Spring Boot源码(6)– Spring怎么从xml文件里解析bean的

曹工说Spring Boot源码(7)– Spring解析xml文件,到底从中得到了什么(上)

曹工说Spring Boot源码(8)– Spring解析xml文件,到底从中得到了什么(util命名空间)

曹工说Spring Boot源码(9)– Spring解析xml文件,到底从中得到了什么(context命名空间上)

曹工说Spring Boot源码(10)– Spring解析xml文件,到底从中得到了什么(context:annotation-config 解析)

曹工说Spring Boot源码(11)– context:component-scan,你真的会用吗(这次来说说它的奇技淫巧)

曹工说Spring Boot源码(12)– Spring解析xml文件,到底从中得到了什么(context:component-scan完整解析)

曹工说Spring Boot源码(13)– AspectJ的运行时织入(Load-Time-Weaving),基本内容是讲清楚了(附源码)

曹工说Spring Boot源码(14)– AspectJ的Load-Time-Weaving的两种实现方式细细讲解,以及怎么和Spring Instrumentation集成

曹工说Spring Boot源码(15)– Spring从xml文件里到底得到了什么(context:load-time-weaver 完整解析)

曹工说Spring Boot源码(16)– Spring从xml文件里到底得到了什么(aop:config完整解析【上】)

曹工说Spring Boot源码(17)– Spring从xml文件里到底得到了什么(aop:config完整解析【中】)

曹工说Spring Boot源码(18)– Spring AOP源码分析三部曲,终于快讲完了 (aop:config完整解析【下】)

曹工说Spring Boot源码(19)– Spring 带给我们的工具利器,创建代理不用愁(ProxyFactory)

曹工说Spring Boot源码(20)– 码网恢恢,疏而不漏,如何记录Spring RedisTemplate每次操作日志

曹工说Spring Boot源码(21)– 为了让大家理解Spring Aop利器ProxyFactory,我已经拼了

曹工说Spring Boot源码(22)– 你说我Spring Aop依赖AspectJ,我依赖它什么了

曹工说Spring Boot源码(23)– ASM又立功了,Spring原来是这么递归获取注解的元注解的

曹工说Spring Boot源码(24)– Spring注解扫描的瑞士军刀,asm技术实战(上)

工程代码地址 思维导图地址

工程结构图:

概要

上一篇,我们讲了ASM基本的使用方法,具体包括:复制一个class、修改class版本号、增加一个field、去掉一个field/method等等;同时,我们也知道了怎么才能生成一个全新的class。

但是,仅凭这点粗浅的知识,我们依然不太理解能干嘛,本篇会带大家实现简单的AOP功能,当然了,学完了之后,可能你像我一样,更困惑了,那说明你变强了。

本篇的核心是,在JVM加载class的时候,去修改class,修改class的时候,加入我们的aop逻辑。JVM加载class的时候,去修改class,这项技术就是load-time-weaver,实现load-time-weaver有两种方式,这两种方式,核心差别在于修改class的时机不同。

  • 第一种,定制classloader,在把字节码交给JVM去defineClass之前,去织入切面逻辑
  • 第二种,利用Java 官方提供的instrumentation机制,注册一个类转换器到 JVM。JVM在加载class的时候,就会传入class的原始字节码数组,回调我们的类转换器,我们类转换器中可以修改原始字节码,并将修改后的字节码数组返回回去,JVM就会用我们修改后的字节码去defineClass了

在直接开始前,声明本篇文章,是基于下面这篇文章中的代码demo,我自己稍做了修改,并附上源码(原文是贴了代码,但是没有直接提供代码地址,不贴心啊)。

初探 Java agent

要达成的目标

目标就是给下面的测试类,加上一点点切面功能。

package org.xunche.app;  public class HelloXunChe {      public static void main(String[] args) throws InterruptedException {          HelloXunChe helloXunChe = new HelloXunChe();          helloXunChe.sayHi();      }      public void sayHi() throws InterruptedException {          System.out.println("hi, xunche");          sleep();      }      public void sleep() throws InterruptedException {          Thread.sleep((long) (Math.random() * 200));      }  }  

我们希望,class在执行的时候,能够打印方法执行的耗时,也就是,最终的class,需要是下面这样的。

package org.xunche.app;  import org.xunche.agent.TimeHolder;  public class HelloXunChe {      public HelloXunChe() {      }      public static void main(String[] args) throws InterruptedException {          TimeHolder.start(args.getClass().getName() + "." + "main");          // 业务逻辑开始          HelloXunChe helloXunChe = new HelloXunChe();          helloXunChe.sayHi();          //业务逻辑结束          HelloXunChe helloXunChe = args.getClass().getName() + "." + "main";          System.out.println(helloXunChe + ": " + TimeHolder.cost(helloXunChe));      }      public void sayHi() throws InterruptedException {          TimeHolder.start(this.getClass().getName() + "." + "sayHi");          System.out.println("hi, xunche");          // 业务逻辑开始          this.sleep();          //业务逻辑结束          String var1 = this.getClass().getName() + "." + "sayHi";          System.out.println(var1 + ": " + TimeHolder.cost(var1));      }      public void sleep() throws InterruptedException {          TimeHolder.start(this.getClass().getName() + "." + "sleep");          // 业务逻辑开始          Thread.sleep((long)(Math.random() * 200.0D));          //业务逻辑结束          String var1 = this.getClass().getName() + "." + "sleep";          System.out.println(var1 + ": " + TimeHolder.cost(var1));      }  }  

所以,我们大概就是,要做下面的这样一个切面:

@Override  protected void onMethodEnter() {      //在方法入口处植入      String className = getClass().getName();      String s = className + "." + methodName;      TimeHolder.start(s);  }  @Override  protected void onMethodExit(int i) {      //在方法出口植入      String className = getClass().getName();      String s = className + "." + methodName;      long cost = TimeHolder.cost(s);      System.out.println(s + ": " + cost);  }  

但是,习惯了动态代理的我们,看上面的代码可能会有点误解。上面的代码,不是在执行目标方法前,调用切面;而是:直接把切面代码嵌入了目标方法。

想必大家都明确了要达成的目标了,下面说,怎么做。

java agent/instrumentation机制

这部分,大家可以结合开头那个链接一起学习。

首先,我请大家看看java命令行的选项。直接在cmd里敲java,出现如下:

看了和没看一样,那我们再看一张图,在大家破解某些java编写的软件时,可能会涉及到jar包破解,比如:

大家可以使用jad这类反编译软件,打开jar包看下,看看里面是啥:

可以发现,里面有一个MANIFEST.MF文件,里面指定了Premain-Class这个key-value,从这个名字,大家可能知道了,我们平时运行java程序,都是运行main方法,这里来个premain,那这意思,就是在main方法前面插个队呗?

你说的没有错,确实是插队了,拿上面的破解jar包举例,里面的Premain-Class方法,对应的Agent类,反编译后的代码如下:

核心代码就是图里那一行:

java.lang.instrument.Instrumentation  public interface Instrumentation {      /**       * Registers the supplied transformer. All future class definitions       * will be seen by the transformer, except definitions of classes upon which any       * registered transformer is dependent.       * The transformer is called when classes are loaded, when they are       * {@linkplain #redefineClasses redefined}. and if <code>canRetransform</code> is true,       * when they are {@linkplain #retransformClasses retransformed}.       * See {@link java.lang.instrument.ClassFileTransformer#transform       * ClassFileTransformer.transform} for the order       * of transform calls.       * If a transformer throws       * an exception during execution, the JVM will still call the other registered       * transformers in order. The same transformer may be added more than once,       * but it is strongly discouraged -- avoid this by creating a new instance of       * transformer class.       * <P>       * This method is intended for use in instrumentation, as described in the       * {@linkplain Instrumentation class specification}.       *       * @param transformer          the transformer to register       * @param canRetransform       can this transformer's transformations be retransformed       * @throws java.lang.NullPointerException if passed a <code>null</code> transformer       * @throws java.lang.UnsupportedOperationException if <code>canRetransform</code>       * is true and the current configuration of the JVM does not allow       * retransformation ({@link #isRetransformClassesSupported} is false)       * @since 1.6       */      void      addTransformer(ClassFileTransformer transformer, boolean canRetransform);        ...  }  

这个类,就是官方jdk提供的类,官方的本意呢,肯定是让大家,在加载class的时候,给大家提供一个机会,去修改class,比如,某个第三方jar包,我们需要修改,但是没有源码,就可以这么干;或者是一些要统一处理,不方便在应用中耦合的功能:比如埋点、性能监控、日志记录、安全监测等。

说回这个方法,参数为ClassFileTransformer,这个接口,就一个方法,大家看看注释:

	/**       * ...       *  	 * @param classfileBuffer       the input byte buffer in class file format - must not be modified       *       * @throws IllegalClassFormatException if the input does not represent a well-formed class file       * @return  a well-formed class file buffer (the result of the transform),                  or <code>null</code> if no transform is performed.       * @see Instrumentation#redefineClasses       */      byte[]      transform(  ClassLoader         loader,                  String              className,                  Class<?>            classBeingRedefined,                  ProtectionDomain    protectionDomain,                  byte[]              classfileBuffer)          throws IllegalClassFormatException;  
  • classfileBuffer,就是原始class字节码数组,官方注释说:一定不能修改它
  • 返回的byte[]数组,注释:一个格式正确的class文件数组,或者null,表示没有进行转换

别的也不多说了,反正就是:jvm给你原始class,你自己修改,还jvm一个改后的class。

所以,大家估计也能猜到破解的原理了,但我还是希望大家:有能力支持正版的话,还是要支持。

接下来,我们回到我们的目标的实现上。

agent模块开发

完整代码:https://gitee.com/ckl111/all-simple-demo-in-work/tree/master/java-agent-premain-demo

增加类转换器

package org.xunche.agent;  import org.objectweb.asm.*;  import org.objectweb.asm.commons.AdviceAdapter;    import java.lang.instrument.ClassFileTransformer;  import java.lang.instrument.Instrumentation;  import java.security.ProtectionDomain;    public class TimeAgentByJava {      public static void premain(String args, Instrumentation instrumentation) {          instrumentation.addTransformer(new TimeClassFileTransformer());      }  }  

类转换器的详细代码如下:

private static class TimeClassFileTransformer implements ClassFileTransformer {          @Override          public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {              if (className.startsWith("java") || className.startsWith("jdk") || className.startsWith("javax") || className.startsWith("sun") || className.startsWith("com/sun")|| className.startsWith("org/xunche/agent")) {                  //return null或者执行异常会执行原来的字节码                  return null;              }              // 1              System.out.println("loaded class: " + className);              ClassReader reader = new ClassReader(classfileBuffer);              // 2              ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);              // 3              reader.accept(new TimeClassVisitor(writer), ClassReader.EXPAND_FRAMES);              // 4              return writer.toByteArray();          }      }  
  • 1处,将原始的类字节码加载到classReader中

    ClassReader reader = new ClassReader(classfileBuffer);

  • 2处,将reader传给ClassWriter,这个我们没讲过,大概就是使用classreader中的东西,来构造ClassWriter;可以差不多理解为复制classreader的东西到ClassWriter中。

    大家可以看如下代码:

    public ClassWriter(final ClassReader classReader, final int flags) {      super(Opcodes.ASM6);      symbolTable = new SymbolTable(this, classReader);      ...    }  

    这里new了一个对象,SymbolTable。

    SymbolTable(final ClassWriter classWriter, final ClassReader classReader) {      this.classWriter = classWriter;      this.sourceClassReader = classReader;        // Copy the constant pool binary content.      byte[] inputBytes = classReader.b;      int constantPoolOffset = classReader.getItem(1) - 1;      int constantPoolLength = classReader.header - constantPoolOffset;      constantPoolCount = classReader.getItemCount();      constantPool = new ByteVector(constantPoolLength);      constantPool.putByteArray(inputBytes, constantPoolOffset, constantPoolLength);      ...  }  

    大家直接看上面的注释吧,Copy the constant pool binary content。反正吧,基本可以理解为,classwriter拷贝了classreader中的一部分东西,应该不是全部。

    为什么不是全部,因为我试了下:

        public static void main(String[] args) throws IOException {          ClassReader reader = new ClassReader("org.xunche.app.HelloXunChe");          ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);          byte[] bytes = writer.toByteArray();            File file = new File(  "F:\gitee-ckl\all-simple-demo-in-work\java-agent-premain-demo\test-agent\src\main\java\org\xunche\app\HelloXunChe.class");          FileOutputStream fos = new FileOutputStream(file);          fos.write(bytes);          fos.close();      }  

    上面这样,出来的class文件,是破损的,格式不正确的,无法反编译。

  • 3处,使用TimeClassVisitor作为writer的中间商,此时,顺序变成了:

    classreader –> TimeClassVisitor –> classWriter

  • 4处,返回writer的字节码,给jvm;jvm使用该字节码,去redefine一个class出来

类转换器的具体实现

public static class TimeClassVisitor extends ClassVisitor {          public TimeClassVisitor(ClassVisitor classVisitor) {              super(Opcodes.ASM6, classVisitor);          }      	// 1          @Override          public MethodVisitor visitMethod(int methodAccess, String methodName, String methodDesc, String signature, String[] exceptions) {              MethodVisitor methodVisitor = cv.visitMethod(methodAccess, methodName, methodDesc, signature, exceptions);              // 2              return new TimeAdviceAdapter(Opcodes.ASM6, methodVisitor, methodAccess, methodName, methodDesc);          }      }  }  
  • 1处,visitMethod方法,会返回一个MethodVisitor,ASM会拿着我们返回的methodVisitor,去访问当前这个方法
  • 2处,new了一个适配器,TimeAdviceAdapter。

我们这里的TimeAdviceAdapter,主要是希望在方法执行前后做点事,类似于切面,所以继承了一个AdviceAdapter,这个AdviceAdaper,帮我们实现了MethodVisitor的全部方法,我们只需要覆写我们想要覆盖的方法即可。

比如,AdviceAdaper,因为继承了MethodVisitor,其visitCode方法,会在访问方法体时被回调:

@Override    public void visitCode() {      super.visitCode();      // 1      onMethodEnter();    }    //2    protected void onMethodEnter() {}    
  • 1处,回调本来的onMethodEnter,是一个空实现,就是留给子类去重写的。
  • 2处,可以看到,空实现。

所以,我们最终的TimeAdviceAdaper,代码如下:

public static class TimeAdviceAdapter extends AdviceAdapter {          private String methodName;          protected TimeAdviceAdapter(int api, MethodVisitor methodVisitor, int methodAccess, String methodName, String methodDesc) {              super(api, methodVisitor, methodAccess, methodName, methodDesc);              this.methodName = methodName;          }          @Override          protected void onMethodEnter() {              //在方法入口处植入              if ("<init>".equals(methodName)|| "<clinit>".equals(methodName)) {                  return;              }              String className = getClass().getName();              String s = className + "." + methodName;              TimeHolder.start(s);          }          @Override          protected void onMethodExit(int i) {              //在方法出口植入              if ("<init>".equals(methodName) || "<clinit>".equals(methodName)) {                  return;              }              String className = getClass().getName();              String s = className + "." + methodName;              long cost = TimeHolder.cost(s);              System.out.println(s + ": " + cost);          }      }  

这份代码看着可还行?可惜啊,是假的,是错误的!写asm这么简单的话,那我要从梦里笑醒。

为啥是假的,因为:真正的代码,是长下面这样的:

看到这里,是不是想溜了,这都啥玩意,看不懂啊,不过不要着急,办法总比困难多。

类转换器的真正实现方法

我们先装个idea插件,叫:asm-bytecode-outline。这个插件的作用,简而言之,就是帮你把java代码翻译成ASM的写法。在线装不了的,可以离线装:

asm-bytecode-outline

装好插件后,只要在我们的TimeAdviceAdapter类,点右键:

就会生成我们需要的ASM代码,然后拷贝:

什么时候拷贝结束呢?

1585318732857

基本上,这样就可以了。

填坑指南

作为一个常年掉坑的人,我在这个坑里也摸爬了整整一天。

大家可以看到,我们的java写的方法里,是这样的:

        @Override          protected void onMethodEnter() {              //在方法入口处植入              if ("<init>".equals(methodName)|| "<clinit>".equals(methodName)) {                  return;              }              String className = getClass().getName();              // 1.              String s = className + "." + methodName;              TimeHolder.start(s);          }  
  • 1处,访问了本地field,methodName

所以,asm也帮我们贴心地生成了这样的语句:

mv.visitFieldInsn(Opcodes.GETFIELD, "org/xunche/agent/TimeAgentByJava$TimeAdviceAdapter", "methodName", "Ljava/lang/String;");    

看起来就像是说,访问org/xunche/agent/TimeAgentByJava$TimeAdviceAdapter类的methodName字段。

但是,这是有问题的。因为,这段代码,最终aop切面会被插入到target:

public class HelloXunChe {      private String methodName = "abc";      public static void main(String[] args) throws InterruptedException {          HelloXunChe helloXunChe = new HelloXunChe();          helloXunChe.sayHi();      }      public void sayHi() throws InterruptedException {          System.out.println("hi, xunche");          sleep();      }        public void sleep() throws InterruptedException {          Thread.sleep((long) (Math.random() * 200));      }    }  

我实话跟你说,这个target类里,压根访问不到org/xunche/agent/TimeAgentByJava$TimeAdviceAdapter类的methodName字段。

我是怎么发现这个问题的,之前一直报错,直到我在target后来加了这么一行:

public class HelloXunChe {      private String methodName = "abc";      ...  }  

哎,没个大佬带我,真的难。

当然,我是通过这个确认了上述问题,最终解决的思路呢,就是:把你生成的class,反编译出来看看,看看是不是你想要的。

所以,我专门写了个main测试类,来测试改后的class是否符合预期。

public class SaveGeneratedClassWithOriginAgentTest {        public static void main(String[] args) throws IOException {          //1          ClassReader reader = new ClassReader("org.xunche.app.HelloXunChe");          ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);          reader.accept(new TimeAgentByJava.TimeClassVisitor(writer), ClassReader.EXPAND_FRAMES);          byte[] bytes = writer.toByteArray();            // 2          File file = new File(  "F:\ownprojects\all-simple-demo-in-work\java-agent-premain-demo\test-agent\src\main\java\org\xunche\app\HelloXunCheCopy2.class");          FileOutputStream fos = new FileOutputStream(file);          fos.write(bytes);          fos.close();      }  }  
  • 1处这段代码,就是模拟在classTransformer中的那段。
  • 2处,将最终要返回给jvm的那段class字节码,写到一个文件里,然后我们就可以反编译,看看有问题没。

所以,上面那段asm,大家如果看:

初探 Java agent

会发现,访问methodname那句代码,是这么写的:

mv.visitLdcInsn(methodName);

这就是,相当于直接把methodName写死到最终的class里去了;最终的class就会是想要的样子:

public void sayHi() throws InterruptedException {          //1          TimeHolder.start(this.getClass().getName() + "." + "sayHi");          System.out.println("hi, xunche");          this.sleep();          // 2          String var1 = this.getClass().getName() + "." + "sayHi";          System.out.println(var1 + ": " + TimeHolder.cost(var1));      }  
  • 1/2处,直接把sayHi写死到target了,而不是此时再去访问field。

maven插件配置premain-class

插件中,配置Premain-Class

<plugin>                  <groupId>org.apache.maven.plugins</groupId>                  <artifactId>maven-jar-plugin</artifactId>                  <version>2.3.1</version>                  <configuration>                      <archive>                          <manifest>                              <addClasspath>true</addClasspath>                          </manifest>                          <manifestEntries>                              <Premain-Class>                                  org.xunche.agent.TimeAgent                              </Premain-Class>                          </manifestEntries>                      </archive>                  </configuration>              </plugin>  

测试模块开发

测试模块,没啥开发的,就只有那个target那个类。

运行

最终我是这么运行的:

java -javaagent:agent.jar -classpath lib/*;java-agent-premain-demo.jar org/xunche/app/He  lloXunChe  

这里指定了lib目录,主要是agent模块需要的jar包:

简单的运行效果如下:

loaded class: org/xunche/app/HelloXunChe  methodName = 0 <init>  methodName = 0 main  methodName = 0 sayHi  methodName = 0 sleep  hi, xunche  org.xunche.app.HelloXunChe.abc: 129  org.xunche.app.HelloXunChe.abc: 129  

总结

ASM这个东西,想要不熟悉字节码就去像我上面这样傻瓜操作,坑还是比较多的,比较难趟。回头有空再介绍字节码吧。我也是半桶水,大家一起学习吧。
本节源码:
https://gitee.com/ckl111/all-simple-demo-in-work/tree/master/java-agent-premain-demo