从Mybatis-Plus开始认识SerializedLambda

从Mybatis-Plus开始认识SerializedLambda

背景

对于使用过Mybatis-Plus的Java开发者来说,肯定对以下代码不陌生:

@TableName("t_user")
@Data
public class User {
	private String id;
	private String name;
	private String password;
	private String gender;
	private int age;
}

@Mapper
public interface UserDAO extends BaseMapper<User> {

}

@Service
public class UserService {
	@Resource
	private UserDAO userDAO;

	public List<User> getUsersBetween(int minAge, int maxAge) {
		return userDAO.selectList(new LambdaQueryWrapper<User>()
				.ge(User::getAge, minAge)
				.le(User::getAge, maxAge));
	}
}

在引入Mybatis-Plus之后,只需要按照上述代码定义出基础的DO、DAO和Service,而不用再自己显式编写对应的SQL,就能完成大部分常规的CRUD操作。Mybatis-Plus的具体使用方法和实现原理此处不展开,有兴趣的读者可以移步Mybatis-Plus官网了解更多信息。

第一次看到UserServicegetUsersBetween()方法的实现时,可能有不少读者会产生一些疑惑:

  • User::getAge这是什么语法?
  • Mybatis-Plus是如何根据这个这个User::getAge来推测出生成SQL时的列名的?

接下来我们就从这两个问题入手,来了解Java 8开始引入的SerializedLambda

User::getAge的背后——Lambda表达式和方法引用

Lambda表达式

Lambda表达式是Java 8开始引入的一大新特性,是一个非常有用的语法糖,让Java开发者也可以体验一下“函数式”编程的感觉。Lambda表达式主要的功能之一就是简化了我们创建匿名类的过程,当然,这里的匿名类只能有一个方法。举个例子,当我们想创建一个线程时,使用匿名类可以这样处理:

public static void main(String[] args) throws InterruptedException {
	//匿名类实现了Runnable接口
	Thread thread = new Thread(new Runnable() {
		//重写run方法
		@Override
		public void run() {
			System.out.println("stdout from thread: " + Thread.currentThread().getName());
		}
	});
	thread.start();
	thread.join();
}

而使用Lambda表达式则可以简化为:

public static void main(String[] args) throws InterruptedException {
	Thread thread = new Thread(() -> System.out.println("stdout from thread: " + Thread.currentThread().getName()));
	thread.start();
	thread.join();
}

这就是Lambda表达式最基本的也是最为核心的功能——让编写实现只有一个抽象方法的接口的匿名类变得简单。而这种只有一个抽象方法的接口被称为函数式接口

只能有一个抽象方法的言外之意是函数式接口可以有其他的非抽象方法,如静态方法和默认方法

通常函数式接口会使用@FunctionalInterface注解修饰,表示这是一个函数式接口。此注解的作用是让编译器检查被注解的接口是否符合函数式接口的规范,若不符合编译器会产生对应的错误

好奇什么时候会报错的小伙伴可参考官方文档描述:

  • If a type is annotated with this annotation type, compilers are required to generate an error message unless:

  • The type is an interface type and not an annotation type, enum, or class.
    The annotated type satisfies the requirements of a functional interface

更多Lambda表达式相关的内容可参考官方文档: Lambda Expression和其他资料。

方法引用

有时我们编写的Lambda表达式仅仅是简单地调用了一个方法,而没有进行其他操作,这时候就可以再一次进行简化,甚至连Lambda表达式都不用写了,直接写被调用的方法引用就行了。 依旧以创建一个线程为例:

public class Main {
	public static void main(String[] args) throws InterruptedException {
		//这里Lambda表达式只有一个作用,就是调用别的方法来处理任务
		Thread thread = new Thread(() -> sayHello());
		thread.start();
		thread.join();
	}
	public static void sayHello() {
		System.out.println("stdout from thread: " + Thread.currentThread().getName());
	}
}

对于上述代码,似乎设计者认为() -> sayHello()这个表达式都有点多余,所以引入了方法引用,可以将上述代码简化为:

public class Main {
	public static void main(String[] args) throws InterruptedException {
		//Main::sayHello即是方法引用的写法
		Thread thread = new Thread(Main::sayHello);
		thread.start();
		thread.join();
	}
	public static void sayHello() {
		System.out.println("stdout from thread: " + Thread.currentThread().getName());
	}
}

按官方文档的说法就是,这种形式更加紧凑,可读性更高。用文档的原话就是:

You use lambda expressions to create anonymous methods. Sometimes, however, a lambda expression does nothing but call an existing method. In those cases, it's often clearer to refer to the existing method by name. Method references enable you to do this; they are compact, easy-to-read lambda expressions for methods that already have a name.

这里有个小细节,最后一句话提到they are compact, easy-to-read lambda expressions...也正好给方法引用定了性,即方法引用本身还是一种Lambda表达式,只是形式比较特殊罢了

回到主题,说到这里,相信读者也就明白了,User::getAge不过就是一个方法引用罢了,而更本质一点,也不过就是一个Lambda表达式而已,而其语义可以理解为它指向了User类中的getAge方法

说明白了User::getAge是何物之后,接下来就该看看Mybatis-Plus是如何使用它的了

Mybatis-Plus是怎么利用方法引用的?

通过源码跟踪,会发现Mybatis-Plus中有一个名为AbstractLambdaWrapper的类,其中有一个名为columnToString()的方法,其作用就是通过Getter提取出列名。其实现如下:

//Mybatis-Plus中将Getter转换为列名的方法。参数column即为对应要解析的Getter的方法引用
protected String columnToString(SFunction<T, ?> column) {
	return this.columnToString(column, true);
}

protected String columnToString(SFunction<T, ?> column, boolean onlyColumn) {
	ColumnCache cache = this.getColumnCache(column);
	return onlyColumn ? cache.getColumn() : cache.getColumnSelect();
}

columnToString()仅是一个入口,具体逻辑则是在同类的getColumnCache()方法中:

protected ColumnCache getColumnCache(SFunction<T, ?> column) {
	//从Getter方法引用中提取元数据。元数据中就包含了Getter的方法名
	LambdaMeta meta = LambdaUtils.extract(column);
	//从Getter方法名中截取字段名
	String fieldName = PropertyNamer.methodToProperty(meta.getImplMethodName());

	//下边是Mybatis-Plus缓存相关的逻辑,可忽略
	Class<?> instantiatedClass = meta.getInstantiatedClass();
	this.tryInitCache(instantiatedClass);
	return this.getColumnCache(fieldName, instantiatedClass);
}

从上述代码中可知,从Getter方法引用中提取Getter方法的具体名称的逻辑是在LambdaUtils.extract()中完成的,再来看看这个方法的实现:

public static <T> LambdaMeta extract(SFunction<T, ?> func) {
	if (func instanceof Proxy) {
		//从IDEA代理对象获取,这个逻辑不重要,可以忽略掉
		return new IdeaProxyLambdaMeta((Proxy)func);
	} else {
		try {
			//重点在这里,通过反射从方法引用(Lambda表达式)中找到'writeReplace'方法
			Method method = func.getClass().getDeclaredMethod("writeReplace");
			method.setAccessible(true);
			//反射调用writeReplace方法,将结果强制转型为 SerializedLambda
			return new ReflectLambdaMeta((SerializedLambda)method.invoke(func), func.getClass().getClassLoader());
		} catch (Throwable var2) {
			return new ShadowLambdaMeta(com.baomidou.mybatisplus.core.toolkit.support.SerializedLambda.extract(func));
		}
	}
}

LambdaUtils.extract()中,通过对Lambda表达式进行反射查找一个名为writeReplace()的方法并调用,最终得到的结果强制转型为SerializedLambda类型。这就是通过方法引用得到方法具体名称的最主要的步骤
LambdaUtils.extract()执行完成后得到一个LambdaMeta对象,这个对象中封装了Lambda表达式(在这里就是某个Getter的方法引用)的元数据,其中的getImplMethodName()方法的实现本质就是调用了SerializedLambda的同名方法:

public class ReflectLambdaMeta implements LambdaMeta {
	...
	private final SerializedLambda lambda;
	...

	public String getImplMethodName() {
		return this.lambda.getImplMethodName();
	}
	...
}

再来看调用LambdaUtils.extract()getColumnCache()函数中的代码:

String fieldName = PropertyNamer.methodToProperty(meta.getImplMethodName());

这里调用上边提到的getImplMethodName()方法,最终得到的就是某个方法引用对应的方法名称,然后通过methodToProperty()再将方法名称转换为字段名称:

//逻辑比较简单,就是按照Getter的命名规则
//将getXXX 或 isXXX 的get和is前缀给拿掉,剩下的XXX就是属性名
public static String methodToProperty(String name) {
	if (name.startsWith("is")) {
		name = name.substring(2);
	} else {
		if (!name.startsWith("get") && !name.startsWith("set")) {
			throw new ReflectionException("Error parsing property name '" + name + "'.  Didn't start with 'is', 'get' or 'set'.");
		}
		name = name.substring(3);
	}
	if (name.length() == 1 || name.length() > 1 && !Character.isUpperCase(name.charAt(1))) {
		name = name.substring(0, 1).toLowerCase(Locale.ENGLISH) + name.substring(1);
	}
	return name;
}

到这里,第二个问题,Mybaits-Plus是如何将User::getAge转换成对应列名的逻辑也就清晰了:

  • Mybatis-Plus的AbstractLambdaWrappercolumnToString(User::getAge)负责得到字符串形式的列名
  • columnToString(User::getAge)则是调用getColumnCache(User::getAge)方法来提取列名
  • getColumnCache(User::getAge)中使用LambdaUtils.extract(User::getAge)来反射获取User::getAge这个方法引用(Lambda表达式)的元数据。(核心是得到SerializedLambda对象)
  • 通过SerializedLambdagetImplMethodName()方法得到了方法引用的具体名称

注意,SerializedLambda类是JDK的,不是Mybatis-Plus的

  • 得到方法名称后,再通过methodToProperty()从方法名获取字段名,这一步主要是剔掉is或者get前缀

从这里也能看出来,符合标准Getter命名规范的才能被解析,即遵循getXXX / isXXX格式

最后补充一点,这只是将User::getAge这种方法引用最终转为"age"这样的属性名的逻辑。Mybatis-Plus中后续还有一些注解可以控制列名的映射,这里暂不讨论

SerializedLambda

通过前面的铺垫,终于到了介绍本文的主角——SerializedLambda的时刻了

那什么是SerializedLambdaSerializedLambda顾名思义就是序列化后的Lambda。这个类中记录了Lambda表达式的上下文信息,主要包括:

  • 捕获类信息(capturingClass):即这个Lambda表达式是在哪个类中用到的
  • 函数接口类(functionalInterfaceClass):函数接口类路径
  • 函数接口的方法名(functionalInterfaceMethodName):函数接口中抽象方法的名称
  • 函数接口方法签名(functionalInterfaceMethodSignature):函数接口中抽象方法的签名
  • 实现类(implClass):哪个类实现了此函数接口
  • 实现方法名(implMethodName):实现此函数接口对应的方法名
  • 实现方法的签名(implMethodSignature):实现此函数接口对应的方法的签名
  • 实现方法类型(implMethodKind):getStatic/invokeVirtual/invokeStatic等调用类型
  • 捕获的参数(capturedArgs):Lambda表达式可能会用到外部变量,这里记录捕获到的变量

SerializedLambda包含的信息可知,我们可以通过这个类型的对象拿到关于Lambda表达式的一些基础信息。而Mybatis-Plus正是利用了这一点,其拿到了某个Getter的方法引用(一定记住方法引用也是一种Lambda),然后调用writeReplace()方法得到关于该方法引用的SerializedLambda对象,这个对象就包含了这个方法引用的描述信息,其中就包含了这个方法引用对应方法的名称(implMethodName

总的来说,SerializedLambda可以理解为Lambda表达式的序列化形式,而序列化主要就是将内存对象的关键属性提出来转化为可传输和可持久化的形式,我们可以通过序列化后的结果大致了解到该对象的结构。SerializedLambda的一大作用正是如此,我们可以通过它来了解到原始Lambda表达式大概是由哪些关键因素构成的

无中生有的writeReplace方法

在前文获取SerializedLambda对象时有这么几行代码:

...
func.getClass().getDeclaredMethod("writeReplace");
method.setAccessible(true);
(SerializedLambda)method.invoke(func);
...

这是典型的反射调用代码,反射这里就不多展开说了。可能很多人关心的是,这个writeReplace()方法从何而来?有何用处?

writeReplace()并非专为SerializedLambda而设计,这个方法其实是Java的序列化机制自带的一个扩展点,任何需要被序列化的类,可以在类中声明这个方法来控制序列化此类对象时使用的替代对象。这样说起来可能有点绕,下边我们来看一个简单的示例:

假设有一个User类,定义如下:

@Data
public class User implements Serializable {
	private String id;
	private String name;
	private String password;
	private String gender;
	private int age;

	//声明writeReplace方法
	public Object writeReplace() throws ObjectStreamException {
		System.out.println("User's writeReplace() is been called.");
		return "user";
	}
}

接下来使用ObjectOutputStream来序列化User对象:

public static void main(String[] args) throws Exception {
	User user = new User();
	user.setName("longqinx");
	ObjectOutputStream out = new ObjectOutputStream(new ByteArrayOutputStream());
	out.writeObject(user);
}

执行上述代码后可以看到控制台输出了User's writeReplace() is been called.,证明我们在User类中声明的writeReplace方法确实被调用了

通过上述示例,我们可以得到初步的结论:writeReplace()方法是一个Java内部约定的方法,其作用是在序列化某个类型对象的时候,允许我们自定义一个替代对象去序列化。比如上述示例中序列化User对象时,我们使用一个String对象作为代替品。如果类中定义了此方法,则序列化时会自动调用,反之按常规序列化逻辑进行序列化

注意,这里的序列化指的是使用Java自身的序列化机制完成的序列化,而不是使用Jackson这种序列化框架

回到正题,编译器会Lambda表达式类型自动生成一个writeReplace()方法,该方法返回一个SerializedLambda作为真正序列化的对象,以此保证对Lambda表达式的正确序列化
而我们则可以利用这一性质,主动反射调用writeReplace()方法来获取SerializedLambda对象,从而得到Lambda表达式的一些元数据,有了这些元数据我们就能发挥创意做一些更有趣的东西

实战——实现一个根据Getter方法引用获取字段名的工具类

1. 定义函数接口

@FunctionalInterface
public interface Getter<T,R> extends Serializable {
	R get(T t);
}
  • 注意,这里必须要继承自Serializable接口,不然编译器不会为对应的Lambda表达式生成writeReplace()方法,也就无法获取到SerializedLambda对象

2. 实现工具类

public class FieldNameExtractor {
	/**
	 * 从Getter方法引用提取字段名
	 *
	 * @param getter 方法引用,必须是getter的
	 * @return 字段名
	 */
	public static <T, R> String extractFieldNameFromGetter(Getter<T, R> getter) {
		try {
			//反射获取writeReplace方法
			Method writeReplace = getter.getClass().getDeclaredMethod("writeReplace");
			writeReplace.setAccessible(true);
			//调用writeReplace方法
			SerializedLambda serializedLambda = (SerializedLambda) writeReplace.invoke(getter);
			//获取实现方法,也就是方法引用对应的方法名
			String methodName = serializedLambda.getImplMethodName();
			return extractFieldName(methodName);
		} catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException e) {
			throw new RuntimeException(e);
		}
	}

	private static String extractFieldName(String methodName) {
		String fieldName;
		if (methodName.startsWith("is")) {
			fieldName = methodName.substring(2);
		} else if (methodName.startsWith("get")) {
			fieldName = methodName.substring(3);
		} else {
			throw new IllegalArgumentException("method name should start with 'is' or 'get'");
		}
		return Character.toLowerCase(fieldName.charAt(0)) + fieldName.substring(1);
	}
}

3. 测试

public class Main {
	public static void main(String[] args) throws Exception {
		//输出name
		System.out.println(FieldNameExtractor.extractFieldNameFromGetter(User::getName));
		//输出age
		System.out.println(FieldNameExtractor.extractFieldNameFromGetter(User::getAge));
	}
}

函数接口定义解惑

读者在看到上述示例代码后,可能存在疑惑,为何Getter这个函数式接口要这样定义,为什么有两个泛型参数TR

其实只用一个泛型参数即可,这时候应该这样定义:

@FunctionalInterface
public interface InstanceGetter<R> extends Serializable {
	R get();
}

工具类中实现逻辑不变,只是调整参数类型即可:

//参数改为InstanceGetter类型,其他不变
public static <R> String extractFieldNameFromGetter(InstanceGetter<R> getter) {
	try {
		//反射获取writeReplace方法
		Method writeReplace = getter.getClass().getDeclaredMethod("writeReplace");
		writeReplace.setAccessible(true);
		//调用writeReplace方法
		SerializedLambda serializedLambda = (SerializedLambda) writeReplace.invoke(getter);
		//获取实现方法,也就是方法引用对应的方法名
		String methodName = serializedLambda.getImplMethodName();
		return extractFieldName(methodName);
	} catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException e) {
		throw new RuntimeException(e);
	}
}

但在使用的时候,传递参数时就不能用User::getName或User::getAge这样的形式了,而应该先实例化User对象,用实例方法引用:

public class Main {
	public static void main(String[] args) throws Exception {
		User user = new User();
		//注意这里是 user::getName而不是User::getName,是用user这个实例来得到方法引用
		System.out.println(FieldNameExtractor.extractFieldNameFromGetter(user::getName));
		System.out.println(FieldNameExtractor.extractFieldNameFromGetter(user::getAge));
	}
}

相信看了这两个对比之后读者也就能察觉到其中的不同了:User::getName是通过类名引用的,而user::getName是通过实例对象引用的

前者真正要被调用时,还得知道在哪个对象上调用(类似反射的invoke),所以会有一个泛型参数 T 来表示对象的类型,而R则是Getter的返回值类型;

后者则是通过实例对象得到的方法引用,这时候Lambda能捕获到这个实例对象,因此在调用时自然也知道该在哪个对象上调用,此时就可以省去 T 这个泛型参数了

总结

回答一开始的问题

  • User::getAge这是什么语法?

Java 8开始引入Lambda表达式和方法引用的概念,User::getAge这种写法称为方法引用,其本质上也是一种Lambda表达式

  • Mybatis-Plus是如何根据这个这个User::getAge来推测出生成SQL时的列名的?

Java中有个SerializedLambda类,其用于表示序列化后的Lambda表达式,通过此类可以获取方法名、实现类名等众多关于Lambda表达式的元数据。对于一个可序列化的Lambda表达式,可通过反射调用其writeReplace方法获取关联的SerializedLambda对象。

当对User::getAge这个Lambda表达式执行此操作时,得到的SerializedLambda中就包含了User类中getAge()这个方法的名称、签名等信息。此时通过getter命名规范,去掉is或get前缀,并将首字符小写即可得到字段名

其他一些没有提到的

在笔者实际的研究过程中,充分利用了IDEA进行调试,但限于篇幅,这个过程并未在本文中详细描述。感兴趣的读者可以自己动手去认真调试一番。这里给几个思路:

  • 在写函数式接口时,试一试继承Serializable和不继承时反射调用writeReplace()方法的结果
  • 拿到一个Lambda表达式对象,尝试反射一下其中有哪些方法
  • 反射一下使用了Lambda表达式的类,看看有什么特别之处
  • 获取一个Lambda表达式关联的SerializedLambda对象,看看里边存了些什么
posted @ 2024-07-17 21:00  二价亚铁  阅读(496)  评论(0编辑  收藏  举报