记一次bug
在给游戏添加了掉血逻辑之后重启服务器,发现并没有起作用。查看日志发现了问题所在。红框位置应该是UserAttkCmd才对,怎么变成了父类呢?进入初始化方法的代码寻找问题。
/**
* 初始方法
*/
public static void init(){
String packageName = CmdHandlerFactory.class.getPackage().getName();
// 扫描指定包路径下的类
Set> clazzSet = PackageUtil.listSubClazz(packageName, true, ICmdHandler.class);
for(Class> clazz : clazzSet) {
if((clazz.getModifiers() & Modifier.ABSTRACT) != 0){
continue;
}
Method[] declaredMethods = clazz.getDeclaredMethods();
// 消息类型
Class> msgType = null;
for(Method method : declaredMethods){
if(!method.getName().equals("handle")){
continue;
}
// 获取handle函数的参数类型
Class>[] parameterTypes = method.getParameterTypes();
if(parameterTypes.length != 2 ||
!GeneratedMessageV3.class.isAssignableFrom(parameterTypes[1])){
continue;
}
msgType = parameterTypes[1];
break;
}
if(null == msgType){
continue;
}
try {
ICmdHandler extends GeneratedMessageV3> handler =
(ICmdHandler extends GeneratedMessageV3>) clazz.newInstance();
//存入map字典中
handlerMap.put(msgType, handler);
LOGGER.info("{} <===> {}",msgType.getTypeName(), clazz.getName());
} catch (Exception e) {
LOGGER.error(e.getMessage(), e);
}
}
}
初始化方法的逻辑是先通过包扫描工具扫描指定目录指定接口的子类,拿到这些子类的class文件后,通过反射获取这些消息处理器类的handle方法中消息参数的类型,再将消息类型clazz对象和 xxxxCmdHandler对象存入map字典中。
然而处理器工厂类在初始化的过程中,错误的将GeneratedMessageV3(消息的父类) 与 UserAttkCmdHandler(用户攻击处理器)建立起关联,导致没法解析攻击类消息。
通过跟踪断点发现了问题所在。只有3个方法的UserAttkCmdHandler类,通过反射的getDeclaredMethods()却取到了4个method对象,其中有两个handle()方法。通过查找资料,知道了其中参数是父消息类的handle方法是桥接方法,是由编译器自动生成的。
什么时候会生成桥接方法
一个子类在继承(或实现)一个父类(或接口)的泛型方法时,在子类中明确指定了泛型类型,那么在编译时编译器会自动生成桥接方法(当然还有其他情况会生成桥接方法,这里只是列举了其中一种情况)。如下所示:
package com.cloudy;
/**
* @author cloudy
* @date 2020-04-19 16:22
*/
public interface SuperClass {
T method(T param);
}
package com.mikan;
/**
* @author cloudy
* @date 2020-04-19 16:22
*/
public class SubClass implements SuperClass {
public String method(String param) {
return param;
}
}
subClass编译后会生成两个method方法,其中一个是桥接方法。桥接方法调用了实际的泛型方法,来看看下面的测试代码:
package com.cloudy;
/**
* @author cloudy
* @date 2020-04-19 16:33
*/
public class BridgeMethodTest {
public static void main(String[] args) throws Exception {
SuperClass superClass = new SubClass();
System.out.println(superClass.method("abc123"));// 调用的是实际的方法
System.out.println(superClass.method(new Object()));// 调用的是桥接方法
}
}
这里声明了SuperClass类型的变量指向SubClass类型的实例,典型的多态。在声明SuperClass类型的变量时,不指定泛型类型,那么在方法调用时就可以传任何类型的参数,因为SuperClass中的方法参数实际上是Object类型,而且编译器也不能发现错误。在运行时当参数类型不是SubClass声明的类型时,会抛出类型转换异常,因为这时调用的是桥接方法,而在桥接方法中会进行强制类型转换,所以才会抛出类型转换异常。上面的代码输出结果如下:
abc123
Exception in thread "main" java.lang.ClassCastException: java.lang.Object cannot be cast to java.lang.String
为什么要生成桥接方法
在java1.5以前,比如声明一个集合类型:
List list = new ArrayList();
那么往list中可以添加任何类型的对象,但是在从集合中获取对象时,无法确定获取到的对象是什么具体的类型,所以在1.5的时候引入了泛型,在声明集合的时候就指定集合中存放的是什么类型的对象:
List list = new ArrayList();
那么在获取时就不必担心类型的问题,因为泛型在编译时编译器会检查往集合中添加的对象的类型是否匹配泛型类型,如果不正确会在编译时就会发现错误,而不必等到运行时才发现错误。因为泛型是在1.5引入的,为了向前兼容,所以会在编译时去掉泛型(泛型擦除),但是我们还是可以通过反射API来获取泛型的信息,在编译时可以通过泛型来保证类型的正确性,而不必等到运行时才发现类型不正确。由于java泛型的擦除特性,如果不生成桥接方法,那么与1.5之前的字节码就不兼容了。
SuperClass在编译完成后泛型实际上就成了Object了,所以方法实际上成了
public abstract Object method(Object param);
而SubClass实现了SuperClass这个接口,如果不生成桥接方法,那么SubClass就没有实现接口中声明的方法,语义就不正确了,所以编译器才会自动生成桥接方法,来保证兼容性。
总结
我们在通过反射进行方法调用时,可能会同时取到桥接方法和其对应的实际的方法。因此要通过判断方法名、参数的个数以及泛型类型参数才能准确获取想拿到的方法。
多添加一个参数类型的校验,bug就解决了。如下:
if(parameterTypes.length != 2 ||
!GeneratedMessageV3.class.isAssignableFrom(parameterTypes[1])
|| parameterTypes[1] == GeneratedMessageV3.class){
continue;
}