胡适:多谈些问题,少聊些主义
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 竟然给做到了(获取到了方法参数名,进而完成封装),是不是有点不可思议???
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 。
你是否曾经疑问过: 线上程序抛出异常时显示的行号,为啥就恰好就是你源码的那一行呢 ?有这疑问是因为 JVM 执行的是 .class 文件,而该文件的行和 .java 源文件的行肯定是对应不上的,为何行号却能在 .java 文件里对应上?
这就是 LineNumberTable 它的作用了: LineNumberTable属性存在于代码(字节码)属性中, 它建立了字节码偏移量到源代码行号之间的联系
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 编译后的字节码信息,方便你做分析对比:
虽然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
-parameters
-parameters 编译参数的方式: javac -parameters XXX.java
<!-- 编译环境在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参数啊,为什么也好使呢?你的疑问同样也是我的疑问, 我至今也还没弄清楚更根本的原因,但是我可以说如下两个现象 :
LocalVariableTable 的。但你也可以关闭它,如下图:
LocalVariableTable (但是maven编译时候的编译命令、参数等,我无法获知。恳请精通maven的同学指点~) 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 依赖包:
<!-- 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注解来指定吧~
本文深入到字节码处分析了这个有可能也是困扰了你很久的问题(问题如题),希望为你答疑解惑了。同时也介绍了 ASM 的基本用法,或许对你后续理解别的框架会有所帮助~
== 若对Spring、SpringBoot、MyBatis等源码分析感兴趣,可加我wx:fsx641385712,手动邀请你入群一起飞 ==
== 若对Spring、SpringBoot、MyBatis等源码分析感兴趣,可加我wx:fsx641385712,手动邀请你入群一起飞 ==