ASM字節碼編程 | 如果你只寫CRUD,那這種技術棧你永遠碰不到!!!

小傅哥 | https://bugstack.cn
沉澱、分享、成長,專注於原創專題案例,以最易學習編程的方式分享知識,讓自己和他人都能有所收穫。目前已完成的專題有;Netty4.x實戰專題案例、用Java實現JVM、基於JavaAgent的全鏈路監控、手寫RPC框架、架構設計專題案例、源碼分析、算法學習等。

一、前言

寫這篇文章的時候我在想可能大部分程序員包括你我,常常都在忙於業務開發或奔波在日常維護與修復BUG的路上,當不能從中吸取技術營養與改變現狀後,就像一臺恆定運行的機器,逃不出限定宇宙速度的一個圈裏。可能你也會有自己的難處,平時加班太晚沒有時間學習、週末家裏瑣事太多沒有精力投入,放假計劃太滿沒有空閒安排。總之,學習就會被擱置。而當一年年的過去後,當自己的年齡與能力不成匹配後又會後悔沒有給多投入一些時間學習成長。

尤其是一線編碼的技術人,除了我們所能看到的在技術框架裏(SSM)開發的業務代碼,你是否有遇到過學習瓶頸,而這種瓶頸又是你自己不知道自己不會什麼,就像下面這些技術列表裏,你有了解多少;

1. javaagent
2. asm
3. jvmti
4. javaassit
5. netty
6. 算法,搜索引擎
7. cglib
8. 混沌工程
9. 中間件開發
10. 高級測試;壓力測試、鏈路測試、流量回放、流量染色
11. 故障系列;突襲、重現、演練
12. 分佈式的數據一致性
13. 文件操作;es、hive
14. 註冊中心;zookeeper、Eureka
15. 互聯網工程開發技術棧;spring、mybaits、網關、rpc(thrift, grpc, dubbo)、mq、緩存redis、分庫分表、定時任務、分佈式事物、限流、熔斷、降級
16. 數據庫binlog解析 
17. 架構設計;DDD領域驅動設計、微服務、服務治理
18. 容器;k8s, docker
19. 分佈式存儲;ceph
20. 服務istio
21. 壓測 jmter
22. Jenkins-部署java代碼項目 + ansible
23. 全鏈路監控,分佈式追蹤
24. 語音識別、語音合成
26. lvs nginx haproxy iptables
27. hadoop mapreduce hive sqoop hbase flink kylin druid

那麼!在本公衆號(bugstack蟲洞棧)中,會專門介紹一些高級技術的應用,可能在平時開發中看不到,但是卻一直出現在你的框架中,以某個支撐服務而存在。好,現在開始就搞一下其中的一個技術點 ASM,看看它的真面目。那麼學習之前先看下他有什麼用途;

  1. 類的代理,如cglib
  2. 混沌工程
  3. 反向工程
  4. 結合 javaagent 做到非入侵式監控,方法耗時、日誌、機器性能等等
  5. 破解

ASM 是一個 Java 字節碼操控框架。它能被用來動態生成類或者增強既有類的功能。ASM 可以直接產生二進制 class 文件,也可以在類被加載入 Java 虛擬機之前動態改變類行爲。Java class 被存儲在嚴格格式定義的 .class 文件裏,這些類文件擁有足夠的元數據來解析類中的所有元素:類名稱、方法、屬性以及 Java 字節碼(指令)。ASM 從類文件中讀入信息後,能夠改變類行爲,分析類信息,甚至能夠根據用戶要求生成新類。

爲了更方便的學習ASM,我將《ASM4使用手冊》以及一些技術點整理成在線文檔,可以隨時方便查閱(http://asm.itstack.org);

ASM4使用手冊

另外關於本文中出現的代碼例子,可以通過在公衆號(bugstack蟲洞棧)內回覆,源碼下載獲取。

二、環境配置

  1. jdk 1.8
  2. idea 2019.3.1
  3. asm-commons 6.2.1

三、工程信息

ASM4使用手冊

  • itstack-demo-asm-01:字節碼編程,HelloWorld
  • itstack-demo-asm-02:字節碼編程,兩數之和
  • itstack-demo-asm-03:字節碼增強,輸出入參
  • itstack-demo-asm-04:字節碼增強,調用外部方法

以上源碼可以通過關注公衆號:bugstack蟲洞棧,回覆 下載源碼 獲取

四、HelloWorld還可以這樣寫

你所熟悉的HelloWorld是不這樣;

public class HelloWorld {
    public static void main(String[] var0) {
        System.out.println("Hello World");
    }
}

那你有嘗試反解析下他的類查看下彙編指令嗎,javap -c HelloWorld

public class org.itstack.demo.test.HelloWorld {
  public org.itstack.demo.test.HelloWorld();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #3                  // String Hello World
       5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: return
}
指令 描述
getstatic 獲取靜態字段的值
ldc 常量池中的常量值入棧
invokevirtual 運行時方法綁定調用方法
return void函數返回

如果你還感興趣其他指令,可以參考這個字節碼指令表:Go!

好! 以上呢,是我很熟悉的一段代碼了,那麼現在我們把這段代碼用ASM方式寫出來;

import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

private static byte[] generate() {
    ClassWriter classWriter = new ClassWriter(0);
    // 定義對象頭;版本號、修飾符、全類名、簽名、父類、實現的接口
    classWriter.visit(Opcodes.V1_7, Opcodes.ACC_PUBLIC, "org/itstack/demo/asm/AsmHelloWorld", null, "java/lang/Object", null);
    // 添加方法;修飾符、方法名、描述符、簽名、異常
    MethodVisitor methodVisitor = classWriter.visitMethod(Opcodes.ACC_PUBLIC + Opcodes.ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null);
    // 執行指令;獲取靜態屬性
    methodVisitor.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
    // 加載常量 load constant
    methodVisitor.visitLdcInsn("Hello World");
    // 調用方法
    methodVisitor.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
    // 返回
    methodVisitor.visitInsn(Opcodes.RETURN);
    // 設置操作數棧的深度和局部變量的大小
    methodVisitor.visitMaxs(2, 1);
    // 方法結束
    methodVisitor.visitEnd();
    // 類完成
    classWriter.visitEnd();
    // 生成字節數組
    return classWriter.toByteArray();
}

以上的代碼,“小朋友,你是否有很多問好???^1024”,其實以上的代碼都是來自於 ASM 框架的代碼,這裏面所有的操作與我們使用使用 javap -c XXX 所反解析出的字節碼是一樣的,只不過是反過來使用指令來編寫代碼。

  1. 定義一個類的生成 ClassWriter

  2. 設定版本、修飾符、全類名、簽名、父類、實現的接口,其實也就是那句;public class HelloWorld

  3. 接下來開始創建方法,方法同樣需要設定;修飾符、方法名、描述符等。這裏面有幾個固定標識;

    1. 類型描述符

      | Java 類型 | 類型描述符 |
      |:—|:—|
      | boolean | Z |
      | char | C |
      | byte | B |
      | short | S |
      | int | I |
      | float | F |
      | long | J |
      | double | D |
      | Object | Ljava/lang/Object; |
      | int[] | [I |
      | Object[][] | [[Ljava/lang/Object; |

    2. 方法描述符

      | 源文件中的方法聲明 | 方法描述符 |
      |:—|:—|
      | void m(int i, float f) | (IF)V |
      | int m(Object o) | (Ljava/lang/Object;)I |
      | int[] m(int i, String s) | (ILjava/lang/String;)[I |
      | Object m(int[] i) | ([I)Ljava/lang/Object; |

    ([Ljava/lang/String;)V== void main(String[] args)

  4. 執行指令;獲取靜態屬性。主要是獲得 System.out

  5. 加載常量 load constant,輸出我們的HelloWorld methodVisitor.visitLdcInsn("Hello World");

  6. 最後是調用輸出方法並設置空返回,同時在結尾要設置操作數棧的深度和局部變量的大小

這樣輸出一個 HelloWorld 是不還是蠻有意思的,雖然你可能覺得這編碼起來實在太難了吧,也非常難理解。首先如果你看過我的專欄,用《Java寫一個Jvm虛擬機》,那麼你可能會感受到這裏面的知識點還是不那麼陌生的。另外這裏的編寫,ASM還提供了插件,可以方便的讓你開發字節碼。接下來就介紹一下使用方式。

五、有插件的幫助字節碼開發也不是很難

對於新人來說如果用字節碼增強開發一些東西確實挺難,尤其是一些複雜的代碼塊使用字節碼指令操作還是很有難度的。那麼,其實也是有簡單辦法就是使用 ASM 插件。這個插件可以很輕鬆的讓你看到一段代碼的指令碼以及如何用ASM去開發。

  1. 安裝插件(ASM Bytecode Outline)

    安裝插件(ASM Bytecode Outline)

  2. 測試使用

    測試使用(ASM Bytecode Outline)

是不是看到有插件的幫助下,心裏有所激動了,至少寫這樣的東西有了抓手。這樣你就可以很方便的去操作一些增強字節碼的功能了。

六、用字節碼寫出一個兩數之和計算

好!有了上面的插件,也有了一些基礎知識的瞭解。那麼我們開發一個計算兩數之和的方法,之後運行計算結果。

這是我們的目標

public class SumOfTwoNumbers {

    public int sum(int i, int m) {
        return i + m;
    }

}

使用字節碼編程方式實現

import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

private static byte[] generate() {
    ClassWriter classWriter = new ClassWriter(0);
    {
        MethodVisitor methodVisitor = classWriter.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
        methodVisitor.visitVarInsn(Opcodes.ALOAD, 0);
        methodVisitor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
        methodVisitor.visitInsn(Opcodes.RETURN);
        methodVisitor.visitMaxs(1, 1);
        methodVisitor.visitEnd();
    }
    {
        // 定義對象頭;版本號、修飾符、全類名、簽名、父類、實現的接口
        classWriter.visit(Opcodes.V1_7, Opcodes.ACC_PUBLIC, "org/itstack/demo/asm/AsmSumOfTwoNumbers", null, "java/lang/Object", null);
        // 添加方法;修飾符、方法名、描述符、簽名、異常
        MethodVisitor methodVisitor = classWriter.visitMethod(Opcodes.ACC_PUBLIC, "sum", "(II)I", null, null);
        methodVisitor.visitVarInsn(Opcodes.ILOAD, 1);
        methodVisitor.visitVarInsn(Opcodes.ILOAD, 2);
        methodVisitor.visitInsn(Opcodes.IADD);
        // 返回
        methodVisitor.visitInsn(Opcodes.IRETURN);
        // 設置操作數棧的深度和局部變量的大小
        methodVisitor.visitMaxs(2, 3);
        methodVisitor.visitEnd();
    }
    // 類完成
    classWriter.visitEnd();
    // 生成字節數組
    return classWriter.toByteArray();
}
  • 上面有兩個括號 {},第一個是用於生成一個空的構造函數

    public AsmSumOfTwoNumbers() {
    }
    
  • 接下來的指令就比較簡單了,首先使用 ILOAD進行數值的兩次壓棧也就是弄到操作數棧裏去操作,接下來開始執行 IADD,將兩數相加。

  • 最後返回結果 IRETURN,注意是返回的 I 類型。到此這段方法快就實現完成了。反編譯後如下;

    //
    // Source code recreated from a .class file by IntelliJ IDEA
    // (powered by Fernflower decompiler)
    //
    
    package org.itstack.demo.asm;
    
    public class AsmSumOfTwoNumbers {
    	public AsmSumOfTwoNumbers() {
    	}
    
    	public int doSum(int var1, int var2) {
    		return var1 + var2;
    	}
    }
    

執行代碼塊

public static void main(String[] args) throws Exception {
    // 生成二進制字節碼
    byte[] bytes = generate();
    // 輸出字節碼
    outputClazz(bytes);
    // 加載AsmSumOfTwoNumbers
    GenerateSumOfTwoNumbers generateSumOfTwoNumbers = new GenerateSumOfTwoNumbers();
    Class<?> clazz = generateSumOfTwoNumbers.defineClass("org.itstack.demo.asm.AsmSumOfTwoNumbers", bytes, 0, bytes.length);
    // 反射獲取 main 方法
    Method method = clazz.getMethod("sum", int.class, int.class);
    Object obj = method.invoke(clazz.newInstance(), 6, 2);
    System.out.println(obj);
}
  • 這段執行操作和我們在使用 java 的反射操作一樣,也是比較容易的。此時我們是調用了新的字節碼類,同時還將字節碼輸出方便我們查看生成的 class類。

七、在原有方法上字節碼增強監控耗時

到這我們基本瞭解到通過字節碼編程,可以動態的生成一個類。但是在實際使用的過程中,我們可能有的時候是需要修改一個原有的方法,在開始和結尾添加一些代碼,來監控這個方法的耗時。這也是非侵入式監控的最基本模型。

定義一個方法

public class MyMethod {

    public String queryUserInfo(String uid) {
        System.out.println("xxxx");
        System.out.println("xxxx");
        System.out.println("xxxx");
        System.out.println("xxxx");
        return uid;
    }

}

像這個方法插入監控

public class TestMonitor extends ClassLoader {

    public static void main(String[] args) throws IOException, NoSuchMethodException, IllegalAccessException, InstantiationException, InvocationTargetException {

        ClassReader cr = new ClassReader(MyMethod.class.getName());
        ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_MAXS);

        {
            MethodVisitor methodVisitor = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
            methodVisitor.visitVarInsn(Opcodes.ALOAD, 0);
            methodVisitor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
            methodVisitor.visitInsn(Opcodes.RETURN);
            methodVisitor.visitMaxs(1, 1);
            methodVisitor.visitEnd();
        }

        ClassVisitor cv = new ProfilingClassAdapter(cw, MyMethod.class.getSimpleName());
        cr.accept(cv, ClassReader.EXPAND_FRAMES);

        byte[] bytes = cw.toByteArray();
        outputClazz(bytes);

        Class<?> clazz = new TestMonitor().defineClass("org.itstack.demo.asm.MyMethod", bytes, 0, bytes.length);
        Method queryUserInfo = clazz.getMethod("queryUserInfo", String.class);
        Object obj = queryUserInfo.invoke(clazz.newInstance(), "10001");
        System.out.println("測試結果:" + obj);

    }

    static class ProfilingClassAdapter extends ClassVisitor {

        public ProfilingClassAdapter(final ClassVisitor cv, String innerClassName) {
            super(ASM5, cv);
        }

        public MethodVisitor visitMethod(int access,
                                         String name,
                                         String desc,
                                         String signature,
                                         String[] exceptions) {
            System.out.println("access:" + access);
            System.out.println("name:" + name);
            System.out.println("desc:" + desc);

            if (!"queryUserInfo".equals(name)) return null;

            MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
            
            return new ProfilingMethodVisitor(mv, access, name, desc);
        }

    }

    static class ProfilingMethodVisitor extends AdviceAdapter {

        private String methodName = "";

        protected ProfilingMethodVisitor(MethodVisitor methodVisitor, int access, String name, String descriptor) {
            super(ASM5, methodVisitor, access, name, descriptor);
            this.methodName = name;
        }

        @Override
        protected void onMethodEnter() {
            mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false);
            mv.visitVarInsn(LSTORE, 2);
            mv.visitVarInsn(ALOAD, 1);
        }

        @Override
        protected void onMethodExit(int opcode) {
            if ((IRETURN <= opcode && opcode <= RETURN) || opcode == ATHROW) {
                mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");

                mv.visitTypeInsn(NEW, "java/lang/StringBuilder");
                mv.visitInsn(DUP);
                mv.visitMethodInsn(INVOKESPECIAL, "java/lang/StringBuilder", "<init>", "()V", false);
                mv.visitLdcInsn("方法執行耗時(納秒)->" + methodName+":");
                mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(Ljava/lang/String;)Ljava/lang/StringBuilder;", false);

                mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "nanoTime", "()J", false);
                mv.visitVarInsn(LLOAD, 2);
                mv.visitInsn(LSUB);

                mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "append", "(J)Ljava/lang/StringBuilder;", false);
                mv.visitMethodInsn(INVOKEVIRTUAL, "java/lang/StringBuilder", "toString", "()Ljava/lang/String;", false);
                mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);

            }
        }
    }

}
  • 整體的代碼塊有點大,我們可以分爲塊來看,如下;
    1. ClassReader cr = new ClassReader(MyMethod.class.getName());讀取原有類,也是字節碼增強的開始
    2. ClassVisitor cv = new ProfilingClassAdapter(cw, MyMethod.class.getSimpleName());開始增強字節碼
    3. onMethodEnteronMethodExit,在方法進入和方法退出時添加耗時執行的代碼。

測試結果:

直接運行TestMonitor.java;

access:1
name:<init>
desc:()V
access:1
name:queryUserInfo
desc:(Ljava/lang/String;)Ljava/lang/String;
ASM類輸出路徑:/E:/itstack/git/github.com/itstack-demo-asm/itstack-demo-asm-03/target/classes/AsmTestMonitor.class
xxxx
xxxx
xxxx
xxxx
方法執行耗時(納秒)->queryUserInfo:132300
測試結果:10001

八、字節碼控制打印方法的入參

那麼除了可以監控方法的執行耗時,還可以將方法的入參信息進行打印出來。這樣就可以在一些異常情況下,看到日誌信息。

其他代碼與上面相同,這裏只列一下修改的地方

static class ProfilingMethodVisitor extends AdviceAdapter {
    private String methodName = "";
    protected ProfilingMethodVisitor(MethodVisitor methodVisitor, int access, String name, String descriptor) {
        super(ASM5, methodVisitor, access, name, descriptor);
        this.methodName = name;
    }
    @Override
    protected void onMethodEnter() {
        mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
        mv.visitVarInsn(ALOAD, 1);
        mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
    }
    @Override
    protected void onMethodExit(int opcode) {
    }
}
  • 從這裏可以看到,在方法進入時候使用指令碼 GETSTATIC,獲取輸出對象類
  • 接下來使用 ALOAD,從局部變量1中裝載引用類型值入棧
  • 最後輸出入參信息

測試結果:

直接運行TestMonitor.java;

 Class<?> clazz = new TestMonitor().defineClass("org.itstack.demo.asm.MyMethod", bytes, 0, bytes.length);
 Method queryUserInfo = clazz.getMethod("queryUserInfo", String.class);
 Object obj = queryUserInfo.invoke(clazz.newInstance(), "10001");
 System.out.println("測試結果:" + obj);

結果;

access:1
name:<init>
desc:()V
access:1
name:queryUserInfo
desc:(Ljava/lang/String;)Ljava/lang/String;
ASM類輸出路徑:/E:/itstack/git/github.com/itstack-demo-asm/itstack-demo-asm-04/target/classes/AsmTestMonitor.class
10001

...

10001 就是我們的方法入參

九、用字節碼增強調用外部方法

好!那麼執行到這,我們可以想到如果只是將一些信息打印到控制檯還是沒有辦法做業務的,我們需要在這個時候將各種屬性信息調用外部的類,進行發送到服務端。比如使用;mq、日誌等。

定義日誌信息輸出類

public class MonitorLog {

    public static void info(String name, int... parameters) {
        System.out.println("方法:" + name);
        System.out.println("參數:" + "[" + parameters[0] + "," + parameters[1] + "]");
    }

}
  • 這個類主要模擬字節碼增強後,方法調用輸出一些信息

增強字節碼

static class ProfilingMethodVisitor extends AdviceAdapter {
    private String name;
    
	...
	 
    @Override
    protected void onMethodEnter() {
        // 輸出方法和參數
        mv.visitLdcInsn(name);
        mv.visitInsn(ICONST_2);
        mv.visitIntInsn(NEWARRAY, T_INT);
        mv.visitInsn(DUP);
        mv.visitInsn(ICONST_0);
        mv.visitVarInsn(ILOAD, 1);
        mv.visitInsn(IASTORE);
        mv.visitInsn(DUP);
        mv.visitInsn(ICONST_1);
        mv.visitVarInsn(ILOAD, 2);
        mv.visitInsn(IASTORE);
        mv.visitMethodInsn(INVOKESTATIC, "org/itstack/demo/asm/MonitorLog", "info", "(Ljava/lang/String;[I)V", false);
    }
}
  • 這裏的有一部分字節碼操作,其實在增強後最終的效果如下;

    public int sum(int i, int m) {
       Monitor.info("sum", i, m);
       return i + m;
    }
    

測試結果:

access:1
name:sum
desc:(II)I
signature:null
ASM類輸出路徑:/E:/itstack/git/github.com/itstack-demo-asm/itstack-demo-asm-05/target/classes/AsmTestMonitor.class
方法:sum
參數:[6,2]
結果:8

通過測試內容可以看到,我們已將方法名稱與參數信息打印完整。好!到這我們已經基本入門了 ASM 字節碼編程的大門,後續還有更多章節,歡迎關注,公衆號:bugstack蟲洞棧

十、總結

  • 高級編程技術的內容還不止於此,不要只爲了一時的功能實現,而放棄深挖深究的機會。也許就是你不斷的增強拓展個人的知識技能,才讓你越來越與衆不同。
  • ASM 這種字節碼編程的應用是非常廣的,但可能確實平時看不到的,因爲他都是與其他框架結合一起作爲支撐服務使用。像這樣的技術還有很多,比如 javaassitnetty等等。
  • 對於真的要學習一樣技術時,不要只看爽文,但爽文也確實給了你敲門磚。當你要徹底的掌握某個知識的時候,最重要的是成體系的學習!壓榨自己的時間,做有意義的事,是3-7年開發人員最正確的事!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章