Skip to content

2020 03 25 [ASM字节码编程]如果你只写CRUD,那这种技术你永远碰不到

fuzhengwei edited this page May 16, 2020 · 1 revision

作者:小傅哥
博客:https://bugstack.cn - 原创系列专题

沉淀、分享、成长,让自己和他人都能有所收获!

一、前言

写这篇文章的时候我在想可能大部分程序员包括你我,常常都在忙于业务开发或奔波在日常维护与修复BUG的路上,当不能从中吸取技术营养与改变现状后,就像一台恒定运行的机器,逃不出限定宇宙速度的一个圈里。可能你也会有自己的难处,平时加班太晚没有时间学习、周末家里琐事太多没有精力投入,放假计划太满没有空闲安排。总之,学习就会被搁置。而当一年年的过去后,当自己的年龄与能力不成匹配后又会后悔没有给多投入一些时间学习成长。

尤其是一线编码的技术人,除了我们所能看到的在技术框架里(SSM)开发的业务代码,你是否有遇到过学习瓶颈,而这种瓶颈又是你自己不知道自己不会什么,就像下面这些技术列表里,你有了解多少;

1. javaagent
2. asm
3. jvmti
4. javaassit
5. netty
6. 算法,搜索引擎
7. cglib
8. 混沌工程
9. 中间件开发
10. 高级测试;压力测试、链路测试、流量回放、流量染色
11. 故障系列;突袭、重现、演练
12. 分布式的数据致性
13. 文件操作;eshive
14. 注册中心;zookeeperEureka
15. 互联网工程开发技术栈;springmybaits、网关、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);

结果;

access1
name:<init>
desc:()V
access1
namequeryUserInfo
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;
    }

测试结果:

access1
namesum
desc:(II)I
signaturenull
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年开发人员最正确的事!

📝 首页

🌏 知识星球码农会锁

实战项目:「DDD+RPC分布式抽奖系统」、专属小册、问题解答、简历指导、架构图稿、视频课程

🐲 头条

⛳ 目录

  1. 源码 - :octocat: 公众号:bugstack虫洞栈 文章所涉及到的全部开源代码
  2. Java
  3. Spring
  4. 面向对象
  5. 中间件
  6. Netty 4.x
  7. 字节码编程
  8. 💯实战项目
  9. 部署 Dev-Ops
  10. 📚PDF 下载
  11. 关于

💋 精选

🐾 友链

建立本开源项目的初衷是基于个人学习与工作中对 Java 相关技术栈的总结记录,在这里也希望能帮助一些在学习 Java 过程中遇到问题的小伙伴,如果您需要转载本仓库的一些文章到自己的博客,请按照以下格式注明出处,谢谢合作。

作者:小傅哥
链接:https://bugstack.cn
来源:bugstack虫洞栈

2021年10月24日,小傅哥 的文章全部开源到代码库 CodeGuide 中,与同好同行,一起进步,共同维护。

这里我提供 3 种方式:

  1. 提出 Issue :在 Issue 中指出你觉得需要改进/完善的地方(能够独立解决的话,可以在提出 Issue 后再提交 PR )。
  2. 处理 Issue : 帮忙处理一些待处理的 Issue
  3. 提交 PR: 对于错别字/笔误这类问题可以直接提交PR,无需提交Issue 确认。

详细参考:CodeGuide 贡献指南 - 非常感谢你的支持,这里会留下你的足迹

  • 加群交流 本群的宗旨是给大家提供一个良好的技术学习交流平台,所以杜绝一切广告!由于微信群人满 100 之后无法加入,请扫描下方二维码先添加作者 “小傅哥” 微信(fustack),备注:加群。
微信:fustack

  • 公众号(bugstack虫洞栈) - 沉淀、分享、成长,专注于原创专题案例,以最易学习编程的方式分享知识,让自己和他人都能有所收获。
公众号:bugstack虫洞栈

感谢以下人员对本仓库做出的贡献或者对小傅哥的赞赏,当然不仅仅只有这些贡献者,这里就不一一列举了。如果你希望被添加到这个名单中,并且提交过 Issue 或者 PR,请与我联系。

Clone this wiki locally