共计 12361 个字符,预计需要花费 31 分钟才能阅读完成。
每篇一句
胡适:多谈些问题,少聊些主义
前言
Spring MVC
和 MyBatis
作为当下最为流行的两个框架,大家平时开发中都在用。如果你往深了一步去思考,你应该会有这样的疑问:
- 在使用
Spring MVC
的时候,你即使不使用注解,只要参数名和请求参数的 key 对应上了,就能自动完成数值的封装 - 在使用
MyBatis
(接口模式)时,接口方法向 xml 里的 SQL 语句传参时,必须(当然不是 100% 的必须,特殊情况此处不做考虑)
使用@Param('')
指定 key 值,在 SQL 中才可以取到
我敢相信这绝不是我一个人的疑问,因为我在第一次使用 MyBatis
的时候就产生过这个疑问并且也尝试过去掉 @Param
注解,因为我觉得一个名称让我写两次是有点多此一举的(我太懒了)。
和 Spring MVC
人性化处理比起来,当时觉得 MyBatis
对这块的处理简直弱爆了。费解了这么长时间,今天我终于可以解释这个现象了,来揭开它的面纱~
问题发现
java
使用者都知道,.java
文件属于源码文件,它需要经过了 javac
编译器编译为 .class
字节码文件才能被 JVM
执行的。
对 .class
字节码稍微有点了解的小伙伴应该也知道这一点:Java
在编译的时候对于方法,默认是 不会
保留方法参数名 ,因此如果我们在运行期想从.class
字节码里直接拿到方法的参数名是做不到的。
如下案例,很明显就是获取不到真实参数名喽:
public static void main(String[] args) throws NoSuchMethodException {Method method = Main.class.getMethod("test1", String.class, Integer.class);
int parameterCount = method.getParameterCount();
Parameter[] parameters = method.getParameters();
// 打印输出:System.out.println("方法参数总数:" + parameterCount);
Arrays.stream(parameters).forEach(p -> System.out.println(p.getType() + "----" + p.getName()));
}
打印内容:
方法参数总数:2
class java.lang.String----arg0
class java.lang.Integer----arg1
从结果中可以看到我们并不能获取到 真实方法参数名 (获取到的是无意义的arg0、arg1
等),这个结果符合我们的理论知识以及预期。
若你有一定技术敏感性,这个时候你应该有这样的疑问:在使用 Spring MVC
的时候,Controller
的方法中不使用注解一样可以自动封装啊,形如这样:
@GetMapping("/test")
public Object test(String name, Integer age) {
String value = name + "---" + age;
System.out.println(value);
return value;
}
请求:/test?name=fsx&age=18
。控制台输出:
fsx---18
从结果中可见:看似办不到的 case,Spring MVC
竟然给做到了(获取到了方法参数名,进而完成封装),是不是有点不可思议???
再看此例(还原 Spring MVC 获取参数名的场景):
public static void main(String[] args) throws NoSuchMethodException {Method method = Main.class.getMethod("test1", String.class, Integer.class);
MethodParameter nameParameter = new MethodParameter(method, 0);
MethodParameter ageParameter = new MethodParameter(method, 1);
// 打印输出:// 使用 Parameter 输出
Parameter nameOriginParameter = nameParameter.getParameter();
Parameter ageOriginParameter = ageParameter.getParameter();
System.out.println("=================== 源生 Parameter 结果 =====================");
System.out.println(nameOriginParameter.getType() + "----" + nameOriginParameter.getName());
System.out.println(ageOriginParameter.getType() + "----" + ageOriginParameter.getName());
System.out.println("===================MethodParameter 结果 =====================");
System.out.println(nameParameter.getParameterType() + "----" + nameParameter.getParameterName());
System.out.println(ageParameter.getParameterType() + "----" + ageParameter.getParameterName());
System.out.println("============== 设置上 ParameterNameDiscoverer 后 MethodParameter 结果 ===============");
ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
nameParameter.initParameterNameDiscovery(parameterNameDiscoverer);
ageParameter.initParameterNameDiscovery(parameterNameDiscoverer);
System.out.println(nameParameter.getParameterType() + "----" + nameParameter.getParameterName());
System.out.println(ageParameter.getParameterType() + "----" + ageParameter.getParameterName());
}
输出结果:
=================== 源生 Parameter 结果 =====================
class java.lang.String----arg0
class java.lang.Integer----arg1
===================MethodParameter 结果 =====================
class java.lang.String----null
class java.lang.Integer----null
============== 设置上 ParameterNameDiscoverer 后 MethodParameter 结果 ===============
class java.lang.String----name
class java.lang.Integer----age
从结果能看出来:Spring MVC
借助 ParameterNameDiscoverer
完成了方法参数名的获取,进而完成数据封装。关于 ParameterNameDiscoverer
它的讲解,可先行参阅:【小家 Spring】Spring 标准处理组件大合集(ParameterNameDiscoverer、AutowireCandidateResolver、ResolvableType。。。)
该问介绍了 ParameterNameDiscoverer
的基本使用和提供的能力,但并没有深入分析。那么本文就分析为何 Spring MVC
为何可以正确的解析到方法参数名称这个问题,从字节码角度深入分析其缘由~
为了便于理解,先简单说说字节码中的两个概念:LocalVariableTable
和LineNumberTable
。它哥俩经常被拿出来一起说,当然本文关注的焦点是LocalVariableTable
,但也借此机会一笔带过LineNumberTable
。
LineNumberTable
你是否曾经疑问过:线上程序抛出异常时显示的行号,为啥就恰好就是你源码的那一行呢 ?有这疑问是因为JVM
执行的是 .class
文件,而该文件的行和 .java
源文件的行肯定是对应不上的,为何行号却能在 .java
文件里对应上?
这就是 LineNumberTable
它的作用了:LineNumberTable 属性存在于代码(字节码)属性中,它建立了字节码偏移量到源代码行号之间的联系
LocalVariableTable
LocalVariableTable
属性建立了方法中的局部变量与源代码中的局部变量之间的对应关系。这个属性也是存在于代码(字节码)中~
从名字可以看出来:它是 局部变量
的一个集合。描述了局部变量和描述符 以及和源代码的对应关系。
下面我使用 javac
和javap
命令来演示一下这个情况:.java
源码如下:
package com.fsx.maintest;
public class MainTest2 {public String testArgName(String name,Integer age){return null;}
}
说明:源码我都是顶头写的,所以请注意行号~
使用 javac MainTest2.java
编译成 .class
字节码,然后使用 javap -verbose MainTest2.class
查看该字节码信息如下:
从图中可看到,我红色标注出的行号和源码处完全一样,这就解答了我们上面的行号对应的疑问了:LineNumberTable
它记录着在源代码处的行号。
Tips:此处并没有,并没有,并没有LocalVariableTable
。
源码不变,我使用 javac -g MainTest2.java
来编译,再看看对应的字节码信息如下(注意和上面的区别):
这里多了一个LocalVariableTable
,即局部变量表,就记录着我们方法入参的形参名字。既然记录着了,这样我们就可以通过分析字节码信息来得到这个名称了~
说明:javac 的调试选项主要包含了三个子选项:
lines,source,vars
如果不使用 - g 来编译, 只保留源文件和行号信息;如果使用 - g 来编译那就都有了~
和 -parameters
有什么区别??
知道 -g
编译参数的少,反倒对 Java8
新推出的 -parameters
知道的人更多一些。那么它和 -g
参数有什么区别呢???
百闻不如一见,我比较喜欢自己搞个例子来说明问题,.java
源代码如下:
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
public class MainTest2 {public static void main(String[] args) throws NoSuchMethodException {Method method = MainTest2.class.getMethod("testArgName", String.class, Integer.class);
System.out.println("paramCount:" + method.getParameterCount());
for (Parameter parameter : method.getParameters()) {System.out.println(parameter.getType().getName() + "-->" + parameter.getName());
}
}
public String testArgName(String name, Integer age) {return null;}
}
下面分别用 javac、javac -g、javac -parameters
来编译后再执行,结果图如下:
从分别编译、再运行打印的结果图中看,结果以及他们的区别已经很清晰了,我就不再笔墨,有疑问的可以给我留言。
另外附上 -parameters
编译后的字节码信息,方便你做分析对比:
== 获取方法参数名的 3 种方式介绍 ==
虽然 Java 编译器默认情况下会抹去方法的参数名,但有上面介绍了字节码的相关知识可知,我们还是有方法来得到方法的参数名的。下面介绍 3 个方案,供以参考。
方法一:使用-parameters
最为简单直接的方式,Java8
源生支持:直接从 java.lang.reflect.Parameter
就能获取到,形如这样:
public class MainTest2 {public static void main(String[] args) throws NoSuchMethodException {Method method = MainTest2.class.getMethod("testArgName", String.class, Integer.class);
System.out.println("paramCount:" + method.getParameterCount());
for (Parameter parameter : method.getParameters()) {System.out.println(parameter.getType().getName() + "-->" + parameter.getName());
}
}
public String testArgName(String name, Integer age) {return null;}
}
输出:
paramCount:2
java.lang.String-->name
java.lang.Integer-->age
当然,它有两个最大的弊端:
- 必须 Java8 或以上(由于 java8 已经普及率非常高了,所以这个还好)
- 编译参数必须有
-parameters
(由于依赖编译参数,所以对迁移是不太友好的,这点比较致命)
指定 -parameters
编译参数的方式:
- 手动命令方式编译:
javac -parameters XXX.java
- IDE(以 Idea 为例)编译:
- Maven 编译:通过 编译插件 指定,保证项目迁移的正确性(推荐)
<!-- 编译环境在 1.8 编译 且附加编译参数:-parameters-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.0</version>
<configuration>
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
<source>${java.version}</source>
<target>${java.version}</target>
<compilerVersion>${java.version}</compilerVersion>
<encoding>${project.build.sourceEncoding}</encoding>
</configuration>
</plugin>
优点:简单方便
缺点:需要特别的指定 -parameters
,不太方便( 当然使用 maven 编辑插件来指定是相对靠谱的方案且推荐使用)
方案二:使用 -g
+ javap
命令
如上例子可以使用 javac -g
编译后,再使用 javap
获取到字节码信息,然后自己根据信息的格式把参数名提取出来(自己做、自己做、自己做)
这无异于让你自己解析 http 协议一般,你愿意做吗???所以此办法虽为一种办法,但是显然不便采用
方案三:借助ASM
(推荐)
说到 ASM
,小伙伴们至少对这个名字应该是不陌生的。它是一个Java
字节码操控框架,它能被用来动态生成类或者增强既有类的功能,它能够改变类行为,分析类信息,甚至能够根据用户要求生成新类。
对于 ASM
来说,Java class
被描述为一棵树;使用“Visitor”模式(导游模式)遍历整个二进制结构;事件驱动的处理方式使得用户只需要关注于对其编程有意义的部分(比如本文只关注方法参数,其它的不关心),而不必了解 Java 类文件格式的 所有细节。
ASM 方式 ,它仍旧还是基于编译后的字节码做事的,正所谓 巧妇难为无米之炊,所以它仍旧必须依赖于编译时的LocalVariableTable
(-g 参数)。
你可能会发问:我使用 idea 编译 /maven 编译都没有自己去指定 - g 参数啊,为什么也好使呢?你的疑问同样也是我的疑问,我至今也还没弄清楚更根本的原因,但是我可以说如下两个现象:
- idea 默认使用的是 javac 编译器,编译出来的字节码是带有
LocalVariableTable
的。但你也可以关闭它,如下图:
- maven 默认使用的也是 javac 编译,字节码也带有
LocalVariableTable
(但是 maven 编译时候的编译命令、参数等,我无法获知。恳请精通 maven 的同学指点~)
小插曲:关于代理的科普(Proxy、CGLIB、Javassist、ASM):
-
ASM
:Java
字节码开源操控框架。操纵的级别是底层 JVM 的 汇编指令级别,这就要求使用者对 class 组织结构和 JVM 汇编指令有一定的了解,要求颇高。 -
Javassist
:效果同上。相较于ASM
它的特点是操作简单,并且速度还可以(当然没有 ASM 快)。重要的是:它并不要求你了解 JVM 指令 / 汇编指令~ -
Proxy
动态代理:动态生成(非提前编译好)代理类:$Proxy0 extends Proxy implements MyInterface{...}
,这就决定了它只能对接口(或者实现接口的类)进行代理,单继承机制 也决定了它不能对(抽象)类进行代理~ -
CGLIB
:是一个 基于 ASM的强大的,高性能,高质量的字节码生成库。它可以在运行期扩展 Java 类与实现 Java 接口。
Spring AOP
以及Hibernate
对代理对象的创建中都使用了CGLIB
前面文章有介绍过了直接使用 CGLIB
的API
来操作字节码 / 生成代理对象,本文将简单演示一下直接使用 ASM
框架来操作的示例:
ASM 使用示例
首先导入 asm
依赖包:
<!-- https://mvnrepository.com/artifact/asm/asm -->
<dependency>
<groupId>asm</groupId>
<artifactId>asm</artifactId>
<version>3.3.1</version>
</dependency>
说明:asm 现已升级到 7.x 版本了,并且 GAV 已变化。由于我对 3.x 熟悉点,所以此处我还是守旧吧~
基于 ASM
提供工具方法 getMethodParamNames(Method)
,获取到任何一个Method
的入参名称:
public class MainTest2 {
// 拿到指定的 Method 的入参名们(返回数组,按照顺序返回)public static String[] getMethodParamNames(Method method) throws IOException {String methodName = method.getName();
Class<?>[] methodParameterTypes = method.getParameterTypes();
int methodParameterCount = methodParameterTypes.length;
String className = method.getDeclaringClass().getName();
boolean isStatic = Modifier.isStatic(method.getModifiers());
String[] methodParametersNames = new String[methodParameterCount];
// 使用 org.objectweb.asm.ClassReader 来读取到此方法
ClassReader cr = new ClassReader(className);
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
// 这一步是最红要的,开始 visitor 浏览了
// ClassAdapter 是 org.objectweb.asm.ClassVisitor 的子类~~~~
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);
// 只 visitor 方法名相同和参数类型都相同的方法~~~
if (!methodName.equals(name) || !matchTypes(argTypes, methodParameterTypes)) {return mv;}
// 构造一个 MethodVisitor 返回 重写我们关心的方法 visitLocalVariable~~~
return new MethodAdapter(mv) {
// 特别注意:如果是静态方法,第一个参数就是方法参数,非静态方法,则第一个参数是 this , 然后才是方法的参数
@Override
public void visitLocalVariable(String name, String desc, String signature, Label start, Label end, int index) {
// 处理静态方法与否~~
int methodParameterIndex = isStatic ? 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;
}
}
运行案例:
public class MainTest2 {
// 使用工具方法获取 Method 的入参名字~~~
public static void main(String[] args) throws SecurityException, NoSuchMethodException, IOException {Method method = MainTest2.class.getDeclaredMethod("testArgName", String.class, Integer.class);
String[] methodParamNames = getMethodParamNames(method);
// 打印输出
System.out.println(StringUtils.arrayToCommaDelimitedString(methodParamNames));
}
private String testArgName(String name, Integer age) {return null;}
}
输出:
name,age
效果复合预期,使用 ASM 拿到了我们期望的真实的方法参数名(没有指定任何编译参数哦)。使用基于 ASM 的方式,即使你是 Java8 以下的版本,都是能够正常获取到的,因为它并不依赖编译参数~~~
== 有了这些基础知识,那么书归正传,来解释文首的疑问:==
Spring MVC 为何好使?
首先使用上需明确 :Spring MVC
好使但它并不依赖于 -parameters
参数,也不依赖于 -g
这个编译参数,因为它是借助 ASM
来实现的~
spring-core
中有个 ParameterNameDiscoverer
就是用来获取参数名的,底层用的是 asm 解析,但是接口方法的参数名无法得到 , 即只能是非接口类的方法参数名可以。
从文首的例子可以看出 Spring MVC
它最终依赖的是 DefaultParameterNameDiscoverer
去帮忙获取到入参名,看看这块代码:
// @since 4.0
public class DefaultParameterNameDiscoverer extends PrioritizedParameterNameDiscoverer {public DefaultParameterNameDiscoverer() {if (!GraalDetector.inImageCode()) {if (KotlinDetector.isKotlinReflectPresent()) {addDiscoverer(new KotlinReflectionParameterNameDiscoverer());
}
addDiscoverer(new StandardReflectionParameterNameDiscoverer());
addDiscoverer(new LocalVariableTableParameterNameDiscoverer());
}
}
}
DefaultParameterNameDiscoverer
它就是一个责任链模式的体现,靠它添加进来的实现类来处理,也就是这哥俩:StandardReflectionParameterNameDiscoverer
:依赖于 -parameters
才会有效(有 java 版本要求和编译参数要求)LocalVariableTableParameterNameDiscoverer
:基于 ASM
实现,无版本和编译参数要求~
备注:
Spring
使用ASM
无需额外导包,因为自给自足了:
MyBatis 为何不好使?
首先使用上需要明确这一点:MyBatis
是 通过接口 跟 SQL 语句绑定然后生成代理类来实现的。
既然有了强大的 ASM
,那么问题来了:难道ASM
也帮不到 MyBatis
来简化开发?
看看我给的这个例子或许你就能明白了并不能怪 MyBatis
呀:
public class MainTest2 {
// 使用工具方法获取 Method 的入参名字~~~
public static void main(String[] args) throws SecurityException, NoSuchMethodException, IOException {Method method = MainTest2.class.getDeclaredMethod("testArgName", String.class, Integer.class);
String[] methodParamNames = getMethodParamNames(method);
// 打印输出
System.out.println(StringUtils.arrayToCommaDelimitedString(methodParamNames));
}
}
// 接口方法
interface MyDemoInterface{String testArgName(String name, Integer age);
}
输出:
null,null
可见即使强如 ASM
,也是木有办法直接获取到接口的形参名的。
这是可以被理解的,因为接口方法不是实际方法,它的形参名是会被实现类覆盖的,所以接口方法的形参名意义不大~
Tips:接口上的
default
方法和static
方法的参数名是可以被正常获取到的,有兴趣的小伙伴可以自己动手试试~
至于 ASM
为何 对接口无效 ,其根本原因我展示一下字节码一看便清楚了:
因为抽象方法没有方法体,也就没有局部变量,自然也就没有局部变量表了,所以即使使用 ASM
也拿不到它的变量名~
说明:在
Java8
后使用-parameter
参数即使是接口,是可以直接通过 Method 获取到入参名的,这个对MyBatis
是好用的。当然为了保证兼容性,个人建议还是乖乖使用 @Param 注解来指定吧~
至此,我有理由相信小伙伴是和我一样,彻底搞明白为何 Spring MVC 可以,但 MyBatis 却不可以这个疑问了吧~~~
总结
本文深入到字节码处分析了这个有可能也是困扰了你很久的问题(问题如题),希望为你答疑解惑了。同时也介绍了 ASM
的基本用法,或许对你后续理解别的框架会有所帮助~
== 若对 Spring、SpringBoot、MyBatis 等源码分析感兴趣,可加我 wx:fsx641385712,手动邀请你入群一起飞 ==
== 若对 Spring、SpringBoot、MyBatis 等源码分析感兴趣,可加我 wx:fsx641385712,手动邀请你入群一起飞 ==