教你用java字节码做点有趣的事之脱敏插件

3,729 阅读12分钟

一些重复的活,能交给程序做就绝不自己做,这就是程序员精神。

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配置中输入

然后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中增加什么。

我们可以看见两张图在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: