【深度好文】深度分析如何獲取方法參數名

聲明:本文屬原創文章,首發於公號:程序員自學之道,轉載請註明出處!html

發現問題

對Java字節碼有必定了解的朋友應該知道,Java在編譯的時候,默認會將方法參數名丟棄,所以咱們沒法在運行時獲取參數名稱。可是在使用 SpringMVC 的時候,我發現一個奇怪的現象,當咱們須要接收請求參數的時候,相應的 Controller 方法只須要正常聲明,就能夠直接接收正確的參數,例如:java

注:如下例子使用 maven 進行編譯,且非 SpringBoot 項目,SpringBoot 已經自動解決了參數名解析的問題,後面我們會討論git

@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 都能被正確解析程序員

然而,當咱們使用 MyBatis 時,若是接口方法有多個參數並且咱們沒有打上 @Param 註解的話,執行的時候就會報錯。例如,咱們有以下的接口:github

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

方法中包含兩個參數,可是沒有打上 @Param 註解,這時候若是調用這個方法,會報錯:spring

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

從錯誤信息中能夠看出,是由於 MyBatis 沒有正確解析方法參數名稱致使異常shell

這就很奇怪了,爲何 Spring 能夠正確解析方法參數名稱,可是 MyBatis 卻不行?Java編譯的時候不是默認會將方法參數名丟棄嗎?我只是普通編譯,並無作特殊處理,那Spring又是從哪裏找到方法參數名的呢?apache

帶着這些問題,我開始進行研究和探索。api

獲取參數名的幾種方式

經過查閱各類資料,我知道,獲取參數名稱的方式主要有兩種。mvc

1、-g 參數

當咱們對 Java 源碼進行編譯時,不管是直接使用命令行仍是使用 IDE 爲咱們編譯,實際上最終都是調用 javac 命令進行的,在編譯的時候,咱們若是添加上 -g 參數,即告訴編譯器,咱們須要調試信息,這時,生成的字節碼當中就會包含局部變量表的信息(方法參數也是局部變量),因而咱們就能夠經過解析字節碼獲取參數名了

咱們用最最經典的 HelloWorld 程序中的 main 方法爲例,看一下編譯的效果:

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 來實現的。有興趣能夠查看官網: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 地址:github.com/dadiyang/ht…) 這個框架的時候意識到的。這個框架跟MyBatis相似,它將接口與 url 進行綁定而後生成代理類來發送 http 請求,咱們無需關注參數拼接和序列化、請求發送和返回值處理的過程,只須要定義好咱們的接口並打上註解便可。

在不斷優化和使用的過程當中我發現,每一個接口方法都須要打 @Param 註解太麻煩,而 MyBatis 也一樣有這個問題,然而 SpringMVC 卻能夠解決。所以爲了更加完善這個框架,我開始一探究竟。作了不少的功課,把整個前因後果都瞭解清楚了,可是一直沒有時間整理。

如今終於忙裏偷閒趁着週末把這篇文章寫出來了,惋惜因爲最近我讓 http-api-invoker 框架兼容到 JDK6,尚未想好怎樣讓它在支持 JDK6 的前提下更好地利用 JDK8+ 的 -parameters 特性。這個留到之後再作進一步的探索吧。

更多原創好文,請關注程序員自學之道

參考文獻:

相關文章
相關標籤/搜索