教你用java位元組碼做點有趣的事之脫敏外掛

咖啡拿鐵發表於2019-03-03

一些重複的活,能交給程式做就絕不自己做,這就是程式設計師精神。

0 寫在前面

本篇是本系列的最後一篇,在這篇中教你用ASM實際開發中做一些可用的東西。包括之前說的如何修改toString,完成一些脫敏。

1 Instrumentation

上一篇位元組碼之ASM教你瞭如何去修改位元組碼?相信看過的同學已經對如何修改位元組碼已經有一定印象了,但是這裡有個問題,上一節我們是通過讀取.class檔案在記憶體裡面使用,並不能影響我們實際jvm中使用的class。這個的確是一個比較難解決的問題,至少在jdk1.5之前是這樣的,在jdk1.5的時候java.lang.instrument出世了。它把Java的instrument功能從原生程式碼中解放出來,使之可以用 Java 程式碼的方式解決問題。java.lang.instrument是在JVM TI的基礎上提供的Java版本的實現。 Instrumentation提供的主要功能是修改jvm中類的行為。 Java SE6中有兩種應用Instrumentation的方式,premain(命令列)和agentmain(執行時)。

1.1 premain

我們知道java程式啟動都得通過main方法啟動,而premain的意思就是在Main啟動之前會執行premain。
首先編寫一個Java類,然後包含下面兩個中的一個方法即可:

public static void premain(String agentArgs, Instrumentation inst);
public static void premain(String agentArgs);
複製程式碼

上面兩個同時存在時1比2優先順序高。這個方法有兩個引數:

  • agentArgs:這個是main函式中傳入的引數,這裡傳入的引數的字串陣列,需要自己解析。
  • Instrumentation:這個是我們的核心, instrument 包中定義的一個介面,也是這個包的核心部分,集中了其中幾乎所有的功能方法,例如類定義的轉換和操作等等。

然後實現ClassFileTransformer介面,ClassFileTransform用於類的轉換,其介面transform是轉換類的關鍵,其第四個入參也是我們後續修改位元組碼的關鍵:

public class ClassTransformerImpl implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        System.out.println("類的名字為:" + className);
        return classfileBuffer;
    }
}
複製程式碼

上面再transform中我們列印了所有類的名字,
回到我們的premain中我們的方法如下:

public class PerfMonAgent {
    static private Instrumentation inst = null;
    public static void premain(String agentArgs, Instrumentation _inst) {
        System.out.println("PerfMonAgent.premain() was called.");
        // Initialize the static variables we use to track information.
        inst = _inst;
        // Set up the class-file transformer.
        ClassFileTransformer trans = new ClassTransformerImpl();
        System.out.println("Adding a PerfMonXformer instance to the JVM.");
        //將我們自定義的類轉換器傳入進去
        inst.addTransformer(trans);
    }
}
複製程式碼

我們可以把上面的premain方法修改如下:

public class PerfMonAgent {
    static private Instrumentation inst = null;
    public static void premain(String agentArgs, Instrumentation _inst) {
        System.out.println("PerfMonAgent.premain() was called.");
        // Initialize the static variables we use to track information.
        inst = _inst;
        // Set up the class-file transformer.
        ClassFileTransformer trans = new ClassTransformerImpl();
        System.out.println("Adding a PerfMonXformer instance to the JVM.");
        //將我們自定義的類轉換器傳入進去
        inst.addTransformer(trans);
    }
}
複製程式碼

程式碼方面的已經定義完畢。接下來需要將其進行打包如果你沒用Maven那麼你需要在其中的 manifest 屬性當中加入” Premain-Class”來指定當中編寫的那個帶有 premain 的 Java 類。如果你是使用的maven那麼你可以用

<plugins>
    <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-jar-plugin</artifactId>
        <version>2.2</version>
        <configuration>
            <archive>
                    <manifestEntries>
                           <Premain-Class>instrument.PerfMonAgent</Premain-Class>
                           //這個是用來引入第三方包,需要在這裡引入 <Boot-Class-Path>/Users/lizhao/.m2/repository/org/ow2/asm/asm/5.0.4/asm-5.0.4.jar</Boot-Class-Path>
                    </manifestEntries>
            </archive>
        </configuration>
    </plugin>
</plugins>
複製程式碼

最後你可以使用了,你隨意編寫一個帶main方法的類:

java -javaagent:jar 檔案的位置 [= 傳入 premain 的引數 ] 
複製程式碼

如果是idea編譯器你可以在vm配置中輸入

教你用java位元組碼做點有趣的事之脫敏外掛

然後run main方法,就會輸出你的類名字。

1.2 agentmain

premain是Java SE5開始就提供的代理方式,給了開發者諸多驚喜,不過也有些須不變,由於其必須在命令列指定代理jar,並且代理類必須在main方法前啟動。因此,要求開發者在應用前就必須確認代理的處理邏輯和引數內容等等,在有些場合下,這是比較困難的。比如正常的生產環境下,一般不會開啟代理功能,所有java SE6之後提供了agentmain,用於我們動態的進行修改,而不需要在設定代理。在 JavaSE6文件當中,開發者也許無法在 java.lang.instrument包相關的文件部分看到明確的介紹,更加無法看到具體的應用 agnetmain 的例子。不過,在 Java SE 6 的新特性裡面,有一個不太起眼的地方,揭示了 agentmain 的用法。這就是 Java SE 6 當中提供的 Attach API。

Attach API 不是Java的標準API,而是Sun公司提供的一套擴充套件 API,用來向目標JVM”附著”(Attach)代理工具程式的。有了它,開發者可以方便的監控一個JVM,執行一個外加的代理程式。
這裡不做篇幅介紹attach api怎麼執行的,總而言之需要依靠accach api整個過程依然比較麻煩,感興趣的同學可以自行閱讀:
https://www.ibm.com/developerworks/cn/java/j-lo-jse61/

1.3小結

有了我們的Instrument之後我們就找到了我們class的來源,依靠上一節的知識,我們就能隨心所欲的修改位元組碼了。

2.動手為toString脫敏

2.1設計

首先我們需要對我們接下來要做的東西進行設計,做到心裡有底,這樣才能遇事不慌。

2.1.1 目標

修改toString的位元組碼,讓以前列印明文的toString(),能針對我們自定義的需求進行脫敏。

2.1.2 自定義

打算通過註解進行自定義脫敏,@DesFiled進行標記要脫敏的field,@Desenstized進行標記脫敏的類,通過繼承一個basefilter進行脫敏的擴充套件。

2.2動手之前

動手之前要先明確一下,必須明確下工具是否已經準備好了

  • asm外掛是否已經下載?
  • asm的maven包是否已經引入?
  • 我的公眾號是否已經關注?
    如果都完成了我們便可以做下面的事了,我們首先定義好我們的註解:
@java.lang.annotation.Target({java.lang.annotation.ElementType.FIELD})
@java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.RUNTIME)
@java.lang.annotation.Inherited
public @interface DesFiled {
    /**
     * 加密型別
     * @return
     */
    public Class<? extends BaseDesFilter> value() default BaseDesFilter.class;

}
@java.lang.annotation.Target({java.lang.annotation.ElementType.TYPE})
@java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.RUNTIME)
public @interface Desensitized {
}
複製程式碼

還有我們的脫敏的filter介面,以及他的實現類用於手機號field的脫敏,其實也就是轉換:

public interface BaseDesFilter <T>{
    default T desc(T needDesc){
        return needDesc;
    };
}
public class MobileDesFilter implements BaseDesFilter {
    //不同型別轉換
    @Override
    public Object desc(Object needDesc) {
        if(needDesc instanceof Long ){
            needDesc = String.valueOf(needDesc);
        }
        if (needDesc instanceof String){
            return DesensitizationUtil.mobileDesensitiza((String) needDesc);
        }
        //如果這個時候是列舉類,todo
        return needDesc;
    }
}
複製程式碼

然後我們編寫一個用於脫敏的類:

@Desensitized
public class StreamDemo1 {


    @DesFiled(MobileDesFilter.class)
    private String name;
    private String idCard;
    @DesFiled(AddressDesFilter.class)
    private List<String> mm;
    

    @Override
    public String toString() {
        return "StreamDemo1{" +
                "name=`" + name + ``` +
                ", idCard=`" + idCard + ``` +
                ", mm=" + mm +
                `}`;
        }
    }
複製程式碼

這個時候你的asm外掛就可以大顯神威了,(不僅是這裡,以後如果大家開發asm相關的,用外掛看他本來的程式碼,然後進行對比),這裡我們通過asm外掛生成一版asm的程式碼這個時候可以截圖儲存,然後我們手動的修改toString方法:

@Override
    public String toString() {
        return "StreamDemo1{" +
                "name=`" + DesFilterMap.getByClassName("MobileDesFilter").desc(name) + ``` +
                ", idCard=`" + idCard + ``` +
                ", mm=" + mm +
                `}`;
    }
複製程式碼

用外掛生成,這裡通過對比我們能知道如果要加一個脫敏的方法,我們需要在ASM中增加什麼。

教你用java位元組碼做點有趣的事之脫敏外掛
教你用java位元組碼做點有趣的事之脫敏外掛

我們可以看見兩張圖在append之間是有一些區別的(這裡要說明下編譯器會把+號優化成StringBuilder的append)

而我們需要做的就是把第二張圖裡面紅框寫的替換成第一張圖裡紅框的。簡單的來說第一張圖只是先獲取this引用,然後進行field的獲取。第二張圖是需要先獲取到脫敏方法的引用然後傳入this.name進行脫敏。

這下我們就知道自己需要做的了,這個時候其實完全不需要看接下來的細節了,可以自己去嘗試一下,看看是如何去實現。

2.2開始動手

首先定義一個類轉換器:

public class PerfMonXformer implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader, String className,
                            Class<?> classBeingRedefined, ProtectionDomain protectionDomain,
                            byte[] classfileBuffer) throws IllegalClassFormatException {
        byte[] transformed = null;
        System.out.println("Transforming " + className);
        ClassReader reader = new ClassReader(classfileBuffer);
        //自動計算棧幀
        ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
        //選擇支援Java8的asm5
        ClassVisitor classVisitor = new DesClassVistor(Opcodes.ASM5,classWriter);
        reader.accept(classVisitor,ClassReader.SKIP_DEBUG);
        return classWriter.toByteArray();
    }
}
複製程式碼

在類轉換器中用到了我們上一節ASM的知識,然後我們自定義一個ClassVisitor叫DesClassVistor,用來進行訪問類的處理,然後通過我們的classWriter生成byte陣列:

public class DesClassVistor extends ClassVisitor implements Opcodes{

    private static final String classAnnotationType = "L"+ Desensitized.class.getName().replaceAll("\.","/")+";";
    /**
     * 用來標誌是否進行脫敏
     */
    private boolean des;
    private String className;
    private Map<String, FiledInfo> filedMap = new HashMap<>();
    public DesClassVistor(int i) {
        super(i);
    }

    public DesClassVistor(int api, ClassVisitor cv) {
        super(api, cv);
    }

    @Override
    public void visit(int jdkVersion, int acc, String className, String generic, String superClass, String[] superInterface) {
        this.className = className;
        super.visit(jdkVersion, acc, className, generic, superClass, superInterface);
    }

    /**
     *
     * @param type 註解型別
     * @param seeing 可見性
     * @return
     */
    @Override
    public AnnotationVisitor visitAnnotation(String type, boolean seeing) {
        if (classAnnotationType.equals(type)){
            this.des = true;
        }
        return super.visitAnnotation(type, seeing);
    }

    /**
     *
     * @param acc 訪問許可權
     * @param name 欄位名字
     * @param type 型別
     * @param generic 泛型
     * @param defaultValue 預設值
     * @return
     */
    @Override
    public FieldVisitor visitField(int acc, String name, String type, String generic, Object defaultValue) {
        FieldVisitor fv = super.visitField(acc, name, type, generic, defaultValue);
        if (des == false || acc >= ACC_STATIC){
            return fv;
        }
        FiledInfo filedInfo = new FiledInfo(acc, name, type, generic, defaultValue);
        filedMap.put(name, filedInfo);
        FieldVisitor testFieldVisitor = new DesFieldVisitor(filedInfo,fv);
        return testFieldVisitor;
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
        MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
        if (this.des == false || !"toString".equals(name)){
            return mv;
        }
        MethodVisitor testMethodVistor = new DesMethodVistor(mv, filedMap);
        return testMethodVistor;
    }

}
複製程式碼

這裡重寫了三個比較重要的方法:

  • visitAnnotation:用於判斷是否有@Desensitized的註解,如果有則設定des=true用來表示開啟註解
  • visitField:用來將asm中的filed轉換成我們自己自定義的FieldInfo並放入map,後續方便處理,並將filed交給自定義的DesFieldVisitor進行處理filed
  • visitMethod:用來將asm中的toString方法放入自定義的DesMethodVistor用來處理toString方法。

對於filed的處理有如下程式碼:

public class DesFieldVisitor extends FieldVisitor {

    private static final String desFieldAnnotationType = "L"+ DesFiled.class.getName().replaceAll("\.","/")+";";
    private FiledInfo info;
    public DesFieldVisitor(int i) {
        super(i);
    }

    public DesFieldVisitor(int i, FieldVisitor fieldVisitor) {
        super(i, fieldVisitor);
    }

    public DesFieldVisitor(FiledInfo filedInfo, org.objectweb.asm.FieldVisitor fv) {
        super(Opcodes.ASM5, fv);
        info = filedInfo;
    }

    @Override
    public AnnotationVisitor visitAnnotation(String s, boolean b) {
        AnnotationVisitor av = super.visitAnnotation(s, b);
        if (!desFieldAnnotationType.equals(s)){
            return av;
        }
        info.setDes(true);
        AnnotationVisitor avAdapter = new DesTypeAnnotationAdapter(Opcodes.ASM5, av, this.info);
        return avAdapter;
    }
}
複製程式碼

通過重寫了visitAnnotation,進行判斷來獲取是否有DesFiled註解以及註解上的資訊。

public class DesMethodVistor extends MethodVisitor implements Opcodes{
    Map<String, FiledInfo> filedMap;
    public DesMethodVistor(int i) {
        super(i);
    }

    public DesMethodVistor(int i, MethodVisitor methodVisitor) {
        super(i, methodVisitor);
    }

    public DesMethodVistor(MethodVisitor mv, Map<String, FiledInfo> filedMap) {
        super(ASM5, mv);
        this.filedMap = filedMap;
    }

    @Override
    public void visitVarInsn(int opcode, int var) {
        if (!(opcode == Opcodes.ALOAD && var == 0)){
            super.visitVarInsn(opcode, var);
        }
    }

    /**
     * 新增過濾邏輯
     * @param opcode
     * @param owner
     * @param name
     * @param desc
     */
    @Override
    public void visitFieldInsn(int opcode, String owner, String name, String desc) {
        FiledInfo filedInfo = filedMap.get(name);
        if (filedInfo.isNotDes()){
            super.visitVarInsn(ALOAD, 0);
            super.visitFieldInsn(opcode, owner, name, desc);
            return;
        }
        mv.visitLdcInsn(filedInfo.getFilterClass().getName());
        mv.visitMethodInsn(INVOKESTATIC, ASMUtil.getASMOwnerByClass(DesFilterMap.class), "getByClassName", "(Ljava/lang/String;)Lasm/filter/BaseDesFilter;", false);
        super.visitVarInsn(ALOAD, 0);
        super.visitFieldInsn(opcode, owner, name, desc);
        mv.visitMethodInsn(INVOKEINTERFACE, ASMUtil.getASMOwnerByClass(BaseDesFilter.class), "desc", "(Ljava/lang/Object;)Ljava/lang/Object;", true);
        mv.visitMethodInsn(INVOKESTATIC, ASMUtil.getASMOwnerByClass(String.class), "valueOf", "(Ljava/lang/Object;)Ljava/lang/String;", true);
    }
}
複製程式碼

通過重寫visitFieldInsn方法進行脫敏的位元組碼的改造。
具體的程式碼可以參照我的asm-log,在StreamDemo中配置好vm引數,執行main方法即可。
參照我的程式碼:

@Desensitized
public class StreamDemo1 {


    @DesFiled(MobileDesFilter.class)
    private String name;
    private String idCard;
    @DesFiled(AddressDesFilter.class)
    private List<String> mm;


    @Override
    public String toString() {
        return "StreamDemo1{" +
                "name=`" + name + ``` +
                ", idCard=`" + idCard + ``` +
                ", mm=" + mm +
                `}`;
    }
    public static void main(String[] args) throws Exception {
        StreamDemo1 streamDemo1 = new StreamDemo1();
        streamDemo1.setName("18428368642");
        streamDemo1.setIdCard("22321321321");
        streamDemo1.setMm(Arrays.asList("北京是朝陽區打撒所大所大","北京是朝陽區打撒所大所大"));
        System.out.println(streamDemo1);
    }

   
}
複製程式碼

在類上和類的變數是都寫上註解,一個使用手機號的脫敏類,一個使用地址的脫敏類,執行main方法,就能輸出如下:

StreamDemo1{name=`184****8642`, idCard=`22321321321`, mm=[北京是朝陽區打*****, 北京是朝陽區打*****]}
複製程式碼

這樣就避免你用自己寶貴的時間重複的去每個類中,去修改toString,這樣的確是太低效,作為程式設計師那就需要有自己的hack精神,能交給程式做的決不用自己做。

2.3做完之後的思考

用位元組碼做一個工具,的確學到了很多,至少以後對看懂位元組碼,看懂一些Java對語法糖處理有很大的幫助,但是這個工具不是很通用,打個jar包出來,你需要配置agent或者你用attach api,這樣的話對業務配置還挺麻煩的。所以可以通過其他的技術來完成我們的工具,比如註解處理器修改抽象語法樹,就像Lombok一樣對業務入侵較小。

同時ASM的作用不僅僅是和instrument搭配,大家可以看看cglib切面的原始碼,或者看看fastjson的原始碼,你可以根據jvm中已經載入好的類,然後修改其位元組碼修改成新的其他類,這裡可以是代理類,也可以是一個完全新的類。

最後

由於自己的水平有限,尤其是在描述這種比較冷門的知識的時候不能抽象得很好,希望大家能理解體諒,同時也希望大家看完之後能自己做一個有關於asm的小工具,可以是打方法耗時時間,也可以是統一事務管理。

本來打算接下來馬上寫修改語法樹教程,想教大家如何手擼一個Lombok(java必備神器),但是發現這類知識點比較生僻的文章的確比較難懂,修改語法樹又比位元組碼可能稍微困難一點,各種文件都比較少,又加上最近工作比較忙,只有下班後寫到凌晨,感覺不是能很好將比較複雜的知識點抽象成簡單的,決定先暫時不寫了。如果對Lombok原理或者如果對如何實現自己的Lombok有興趣的可以參考我的slothlog github(順便求下star)裡面很多地方都標註了註釋,如果有什麼不明白的可以關注我的公眾號,加我微信私聊。

如果大家覺得這篇文章對你有幫助,或者想提前獲取後續章節文章,或者你有什麼疑問想提供1v1免費vip服務,都可以關注我的公眾號,關注即可免費領取上百G最新java學習資料視訊,以及最新面試資料,你的關注和轉發是對我最大的支援,O(∩_∩)O:

教你用java位元組碼做點有趣的事之脫敏外掛

相關文章