【深度好文】深度分析如何获取方法参数名

声明:本文属原创文章,首发于公号: 程序员自学之道,转载请注明出处!

发现问题

对Java字节码有必定了解的朋友应该知道,Java 在编译的时候,默认会将方法参数名丢弃,所以咱们没法在运行时获取参数名称。可是在使用 SpringMVC 的时候,我发现一个奇怪的现象,当咱们须要接收请求参数的时候,相应的 Controller 方法只须要正常声明,就能够直接接收正确的参数,例如:html

注:如下例子使用 maven 进行编译,且非 SpringBoot 项目,SpringBoot 已经自动解决了参数名解析的问题,后面我们会讨论
@RestController
@RequestMapping("calculator")
public class CalculatorController {
    @GetMapping("add")
    public int add(int aNum, int bNum) {
        return aNum + bNum;
    }
}

当接收到 http://localhost:8080/calculator/add?aNum=12&bNum=3 这样的请求时,会返回 15,即aNum 和 bNum 都能被正确解析java

然而,当咱们使用 MyBatis 时,若是接口方法有多个参数并且咱们没有打上 @Param 注解的话,执行的时候就会报错。例如,咱们有以下的接口:git

@Mapper
public interface AccountMapper {
    @Select("select * from `account` where `name` = #{name} and mobile_phone = #{mobilePhone}")
    Account getByNameAndMobilePhone(String name, String mobilePhone);
}

方法中包含两个参数,可是没有打上 @Param 注解,这时候若是调用这个方法,会报错:程序员

org.apache.ibatis.binding.BindingException: Parameter 'name' not found. Available parameters are [arg1, arg0, param1, param2]

从错误信息中能够看出,是由于 MyBatis 没有正确解析方法参数名称致使异常github

这就很奇怪了,为何 Spring 能够正确解析方法参数名称,可是 MyBatis 却不行?Java编译的时候不是默认会将方法参数名丢弃吗?我只是普通编译,并无作特殊处理,那Spring又是从哪里找到方法参数名的呢?spring

带着这些问题,我开始进行研究和探索。shell

获取参数名的几种方式

经过查阅各类资料,我知道,获取参数名称的方式主要有两种。apache

1、-g 参数

当咱们对 Java 源码进行编译时,不管是直接使用命令行仍是使用 IDE 为咱们编译,实际上最终都是调用 javac 命令进行的,在编译的时候,咱们若是添加上 -g 参数,即告诉编译器,咱们须要调试信息,这时,生成的字节码当中就会包含局部变量表的信息(方法参数也是局部变量),因而咱们就能够经过解析字节码获取参数名了api

咱们用最最经典的 HelloWorld 程序中的 main 方法为例,看一下编译的效果:mvc

public class HelloWorld{
    public static void main(String[] argsName){
        System.out.println("HelloWorld!");
    }
}

咱们直接执行以下 javac 命令来编译并查看生成的字节码信息:

javac HelloWorld.java
javap -verbose HelloWorld.class

HelloWorld没有调试信息的字节码
能够看到,咱们的参数名 argsName 已经被抹掉了。而若是字节码中都没有咱们所须要的信息,那么在运行时,反射或者是别的方法也都无能为力了,巧妇难为无米之炊呐。

接下来,咱们试一下添加 -g 参数会发生什么:

javac -g HelloWorld.java
javap -verbose HelloWorld.class

添加了-g参数的字节码信息
能够看到,这里多了一个 LocalVariableTable,即局部变量表,其中就有咱们的参数名称 argsName!

那么,咱们如何在方法运行时从字节码信息中获取参数名称呢?你能够直接经过 javap 来获取字节码信息,而后本身去根据信息的格式去解析,然而这样过低效了,并且太繁琐了。

这时候若是咱们请大名鼎鼎的 ASM 来当“导游”,带着咱们游览字节码内部构造,实现起来就轻松多了。

这个 ASM 可牛了, 它不只能够查看字节码的信息,甚至能够动态修改类的定义或者新建一个本来没有的类!在各类框架中被普遍地使用,SpringAOP中使用的 CGLib 底层就是使用 ASM 来实现的。有兴趣能够查看官网: https://asm.ow2.io/ 以前我也写过一篇文章 《Java用ASM写一个HelloWorld程序》,有兴趣能够看一下。

言归正传,如何经过 ASM 来获取参数名称呢? 直接上代码:

首先添加依赖:

<dependency>
     <groupId>asm</groupId>
    <artifactId>asm</artifactId>
    <version>3.3.1</version>
</dependency>
/**
 * 使用字节码工具ASM来获取方法的参数名
 */
public static String[] getMethodParamNames(final Method method) throws IOException {
  final int methodParameterCount =  method.getParameterTypes().length;
  final String[] methodParametersNames = new String[methodParameterCount];
  ClassReader cr = new ClassReader(method.getDeclaringClass().getName());
  ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
  cr.accept(new ClassAdapter(cw) {
      @Override
      public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
          MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
          final Type[] argTypes = Type.getArgumentTypes(desc);
          //参数类型不一致
          if (!method.getName().equals(name) || !matchTypes(argTypes,  method.getParameterTypes())) {
              return mv;
          }
          return new MethodAdapter(mv) {
              @Override
              public void visitLocalVariable(String name, String desc, String signature, Label start, Label end, int index) {
                  //若是是静态方法,第一个参数就是方法参数,非静态方法,则第一个参数是 this, 而后才是方法的参数
                  int methodParameterIndex = Modifier.isStatic(method.getModifiers()) ? index : index - 1;
                  if (0 <= methodParameterIndex && methodParameterIndex < methodParameterCount) {
                      methodParametersNames[methodParameterIndex] = name;
                  }
                  super.visitLocalVariable(name, desc, signature, start, end, index);
              }
          };
      }
  }, 0);
  return methodParametersNames;
}

/**
 * 比较参数是否一致
 */
private static boolean matchTypes(Type[] types, Class<?>[] parameterTypes) {
   if (types.length != parameterTypes.length) {
       return false;
   }
   for (int i = 0; i < types.length; i++) {
       if (!Type.getType(parameterTypes[i]).equals(types[i])) {
           return false;
       }
   }
   return true;
}

简而言之,ASM使用了访问者模式,它就像一个导游,带着咱们去游览字节码文件中的各个“景点”。咱们实现不一样的 Visitor 接口就像是手上握有不一样景点门票的游客,导游会带着 ClassVisitor 去整体参观类定义的景观,而类内部有方法,若是你想看一下方法内部的定义,须要"额外购票",即须要实现 MethodVisitor 才能跟着导游去参观方法定义这个景点。而在游览各个景点的时候,咱们能够只游览咱们感兴趣的部分,这就能够继承适配器(ClassAdapter和MethodAdapter分别是ClassVisitor和MethodVisitor的适配器)而后只实现咱们感兴趣的方法便可。

这里对于类的定义,咱们只对方法感兴趣,所以只实现 visitMethod 方法;在方法中,咱们只对 LocalVariableTable 有兴趣,所以只实现 visitLocalVariable 方法。这样咱们获得了局部变量表,再根据一些规则就能够拿到咱们的参数名称了!是否是很棒!

顺便说一下,若是你使用 maven 来管理项目的话,这个 -g 参数会在编译的时候自动加上,所以咱们不须要额外添加就能够经过字节码拿到,这也就是为何 SpringMVC 能够拿到方法参数名称的缘由。

可是这种方式对于接口和抽象方法是无论用的,由于抽象方法没有方法体,也就没有局部变量,天然也就没有局部变量表了
抽象方法字节码
MyBatis 是经过接口跟 SQL 语句绑定而后生成代理类来实现的,所以它没法经过解析字节码来获取方法参数名。

虽然经过字节码的方法的确能够拿到参数名,但仍是不方便,并且它对接口和抽象方法的参数名也无能为力。有没有更方便更全面的方法呢?答案是:有的。

-parameters 参数

JDK8 在反射包中引入了 java.lang.reflect.Parameter 来获取参数相关的信息

A small but useful example is support for method parameter names at run time: storing such names in the class file structure goes hand in hand with offering a standard API to retrieve them (java.lang.reflect.Parameter) - 《The Java Virtual Machine Specification》

可是它依赖于编译时添加 -parameters 参数,也就是说,只有在编译的时候添加了这个参数才能在运行时经过反射获取参数信息。仍是用咱们的 HelloWorld 程序,咱们来试一下添加 -parameters 参数:

javac -parameters HelloWorld.java
javap -verbose HelloWorld.class

添加 -parameters 参数后的字节码文件
能够看到,字节码文件中多了 MethodParameters 部分,里面存放的就直接是咱们所须要在的参数名!咱们能够直接经过反射获取:

HelloWorld.class.getMethod("main",String[].class).getParameters()[0].getName()

问题来了,咱们如何在编译的时候自动加上 -parameters 这个参数呢?毕竟咱们不可能只在本身的 IDE 上作设置,也不可能本身写脚原本编译。

若是你使用 maven 来管理项目的话,能够直接经过插件来完成:

<build>
  <plugins>
      <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-compiler-plugin</artifactId>
          <version>3.8.0</version>
          <configuration>
              <source>${java.version}</source>
              <target>${java.version}</target>
              <parameters>true</parameters>
          </configuration>
      </plugin>
  </plugins>
</build>

这样这个 -parameters 参数就会在编译的时候自动加上了

关于 SpringBoot

文章开头曾提到,SpringBoot 已经自动解决了参数名解析的问题,它其实就是经过 -parameters 参数来实现的。在 spring-boot-starter-parent.pom 文件中它为咱们添加了上面提到的插件及参数:
spring-boot-starter-parent.pom添加了parameters参数
有了这个参数并且是在 JDK8+ 中运行的话不管是 SpringMVC 仍是 MyBatis 均可以获取到正确的方法参数名了!

总结

获取参数名称的方式主要有两种:

  1. 编译时添加 -g 参数,而后经过解析字节码读取局部变量表获取

    maven在编译时会自动添加这个参数,可是用的时候须要解析字节码,并且对于接口和抽象方法无能为力,由于接口和抽象方法没有方法体,也就没有局部变量,所以也就没有局部变量表,因此没法经过局部变量表来获取参数名称。

  2. JDK8+ 编译时添加 -parameters 参数,而后经过反射获取

    能够经过配置插件自动添加,使用很是方便,直接经过反射便可拿到参数信息。可是须要 JDK8 及以上才能使用。

SpringMVC 和 MyBatis :

  • 有 -parameters 参数的场景,两个框架均可以正确解析参数名。
  • 只有 -g 参数时

    • SpringMVC 经过解析字节码获取 Controller 的方法参数以绑定请求参数
    • MyBatis 须要与接口绑定,而 -g 参数对接口和抽象类无效,所以不能正确解析参数名
  • -g 和 -parameters 都没有时,二者都没法正确解析参数名

后记

不知不觉写了这么多,如今也快凌晨两点了。

对于获取方法参数名这个问题的探究最先实际上是来源于我在写 http-api-invoker (github 地址:https://github.com/dadiyang/h... 这个框架的时候意识到的。这个框架跟MyBatis相似,它将接口与 url 进行绑定而后生成代理类来发送 http 请求,咱们无需关注参数拼接和序列化、请求发送和返回值处理的过程,只须要定义好咱们的接口并打上注解便可。

在不断优化和使用的过程当中我发现,每一个接口方法都须要打 @Param 注解太麻烦,而 MyBatis 也一样有这个问题,然而 SpringMVC 却能够解决。所以为了更加完善这个框架,我开始一探究竟。作了不少的功课,把整个前因后果都了解清楚了,可是一直没有时间整理。

如今终于忙里偷闲趁着周末把这篇文章写出来了,惋惜因为最近我让 http-api-invoker 框架兼容到 JDK6,尚未想好怎样让它在支持 JDK6 的前提下更好地利用 JDK8+ 的 -parameters 特性。这个留到之后再作进一步的探索吧。

更多原创好文,请关注程序员自学之道

参考文献:

相关文章
相关标签/搜索