Java 反射获取方法参数名

2,169 阅读6分钟
原文链接: click.aliyun.com

问题

在编写一个jws(游戏中心的WEB框架)增强工具的时候,需要得到方法的参数名,而jws本身是可以获取参数名的(不然controller里将请求参数与方法参数绑定的功能也无法实现了).

但使用了jws提供的获取参数名方法时,却出现返回的参数名不正确的问题(只会出现在idea里面):

screenshot

所以说:

  • 为什么可以获取方法参数?
  • 为什么eclipse和生产环境里不会发生这种问题?
  • 怎样可以正确获取方法的参数名?

问题排查

获取方法参数

众所周知,在java里面,直到java8才可以**正式**的通过反射获取方法参数名,而且还需要额外添加-parameters参数,官方理由是:

  • 参数名信息会使class文件变大,让处理消耗更多的资源
  • 容易被反编译,暴露敏感方法

所以正常来说,对java8以前的class文件进行反编译,方法参数名全部会变成var1,var2这样的东西(名字是反编译工具自己起的...).

但是某些时候,在一些WEB框架里,例如Spring MVC,JWS,却可以自动的将请求参数与对应的方法参数进行绑定.

是因为只要在编译工具javac里加上-g参数,就可以额外把本地变量名(参数也是其中一种)加到class文件中了.

javac中的debug信息

对于jvm来说,运行端代码,只需要有代码的字节码就可以了,根本不需要知道源码是什么样的.

我们之所以在ide里,可以对程序设置断点,可以对字节码反编译,是因为编译工具javac可以把一些源码相关的额外调试信息放到class文件里,具体通过-g参数控制(官方文档),可以添加的信息有:

  • 源码文件描述信息,**默认添加**(目测没什么用,就是多了一行'Compiled from ...')
  • 字节码与源码的行数的映射,即class文件里的LineNumberTable,**默认添加**,用于断点调试和异常栈(运行栈)中的代码行数
    • 没有的话在ide里无法设置断点调试,且抛出的异常栈中不会显示调用的代码行数,而是显示Unknown Source
  • 本地局部变量名表,即class文件里的LocalVariableTable,**默认不添加**,用户存放本地局部变量对应的变量名,包括参数名

所以,如果在编译时把局部变量信息放到了class文件里,运行时就可以通过字节码工具动态从class文件里拿到方法参数名了.

例子可以参考spring的org.springframework.core.LocalVariableTableParameterNameDiscoverer或以下文章.

debug信息的默认设置

在javac和ECJ(Eclipse Compiler for Java)里,调试信息的默认设置都是-g:lines,source

我们的工程用的框架之所以基本都能获取方法参数名,是因为:

  • jws:预编译功能通过ECJ实现,并且设置了生成LocalVariableTable
  • maven:compile插件默认生成所有debug信息(见设置文档),其他构建工具自行查找...
  • idea/eclipse:默认都生成所有debug信息

LocalVariableTable的结构

通过javap工具,可以看到方法里的LocalVariableTable是这个样子的:

    LocalVariableTable:
      Start  Length  Slot  Name   Signature
         21       4     6   bbb   Ljava/lang/String;
         32       4     6   ccc   Ljava/lang/String;
          0      55     0  this   Lservices/api/server/TestApiService;
          0      55     1 test1   Ljava/lang/String;
          0      55     2 test2   Ljava/lang/String;
          5      50     3  test   Ljava/lang/Integer;
          9      46     4   aaa   Ljava/lang/String;
         12      43     5 isTest   Z

可以看到,LocalVariableTable里面的变量顺序跟程序中的顺序是不一致的,而jws里提供给外部调用的方法是直接取LocalVariableTable中前n个变量信息(n=参数个数,非static方法还会忽略掉第一个变量),自然返回的参数名就是错误的.

(但可以看到,返回的局部变量名是正确的,而不是var1之类的名称,说明class文件里是包含LocalVariableTable的)

至于为什么LocalVariableTable里的变量数据不是有序的,没有搜到确切原因(如果知道麻烦告知),但这种情况应该是正常的,因为:

  • 可以搜到有人咨询这个问题
  • 上述例子是本地通过官方jdk编译出来的class文件,jdk6~8结构都一致

(没理解错官方文档只说了LocalVariableTable这个Code Attribute的顺序是随意的,但没说里面的变量数据是否有序)

测试代码:

public class Test {
    public static String test(String roomId, String ucid) {
        Integer test = 1;
        String aaa = "a";
        System.out.println(test);
        boolean isTest = true;
        if (isTest) {
            String bbb = "bbb";
            aaa = "aaa";
        } else {
            String ccc = "ccc";
            aaa = "aa";
        }
        return roomId + ucid;
    }
}

变量名顺序问题

用上述代码经过验证,ECJ编译出来的class文件里的LocalVariableTable是有序的,而eclipse和jws都使用了这个编辑器,而idea默认是javac,所以只在idea下会出现jws获取参数名不正确的问题.

正确获取参数名

LocalVariableTable本身是一个Code Attribute,其中**Start**,**Length**和**Slot**是计算的关键.

java运行栈结构见文章中的75~91页.

  • Start:局部变量在方法中开始生效的偏移量,比如0就代表进入方法的时候就赋值,3代表执行到方法内的第3行命令才进行赋值
  • Length:局部变量生效范围的长度,比如在一个if语句里的局部变量,如果赋值偏移量是3,而if语句的结束偏移量是5,则Lenght为2
  • Slot:变量存放在局部变量区中的index,因为局部变量区中的空间可以复用(当一个变量失效后会被移除),所以此数字有可能重复

要正确获取对应的参数名,就需要对LocalVariableTable的数据进行排序,排序依据

  • 参数和this的start都是0,因为在方法执行前就会生效
  • slot是按顺序分配空间的,实例方法的第一个临时变量一定是this,所以如果有this则slot一定是0
  • 参数在方法上的顺序跟slot的排序结果一致,因为是按参数的顺序对参数赋值的

所以排序算法为:

先按Start排序,再按slot排序,根据实际情况看要不要去掉this(简单点start+slot然后排序就可以了)

问题解决方式

说了这么多,解决方式很简单:

  1. 修改获取方法参数名的算法,排序后再获取对应的参数名

  2. 把idea的编译器改成eclipse的,在[Preferences]->[Java Compiler]里的[Use compiler],就变成和eclipse一样的编译结果了

    (eclipse的编译器还有另外一些功能,见文章官方文档)