Day642.反射注解和泛型问题 -Java业务开发常见错误

Posted 阿昌喜欢吃黄桃

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Day642.反射注解和泛型问题 -Java业务开发常见错误相关的知识,希望对你有一定的参考价值。

反射、注解和泛型问题

Hi,我是阿昌,今天记录学习分享的是一些反射、注解和泛型问题

如果你从来没用过反射、注解和泛型,可以先通过官网有一个大概了解:


一、反射调用方法 方法重载问题

反射的功能包括,在运行时动态获取类和类成员定义,以及动态读取属性调用方法。也就是说,针对类动态调用方法,不管类中字段和方法怎么变动,我们都可以用相同的规则来读取信息和执行方法。

因此,几乎所有的 ORM(对象关系映射)、对象映射、MVC 框架都使用了反射。反射的起点是 Class 类,Class 类提供了各种方法帮我们查询它的信息。你可以通过这个文档,了解每一个方法的作用。

举例:有两个叫 age 的方法,入参分别是基本类型 int 和包装类型 Integer。

@Slf4j
public class ReflectionIssueApplication 
  private void age(int age) 
      log.info("int age = ", age);
  

  private void age(Integer age) 
      log.info("Integer age = ", age);
  

如果不通过反射调用,走哪个重载方法很清晰,比如传入 36 走 int 参数的重载方法,传入 Integer.valueOf(“36”) 走 Integer 重载:

ReflectionIssueApplication application = new ReflectionIssueApplication();
application.age(36);
application.age(Integer.valueOf("36"));

但使用反射时的误区是,认为反射调用方法还是根据入参确定方法重载

比如,使用 getDeclaredMethod 来获取 age 方法,然后传入 Integer.valueOf(“36”):

getClass().getDeclaredMethod("age", Integer.TYPE).invoke(this, Integer.valueOf("36"));

输出的日志证明,走的是 int 重载方法:

14:23:09.801 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo1.ReflectionIssueApplication - int age = 36

其实,要通过反射进行方法调用,第一步就是通过方法签名来确定方法。具体到这个案例,getDeclaredMethod 传入的参数类型 Integer.TYPE 代表的是 int,所以实际执行方法时无论传的是包装类型还是基本类型,都会调用 int 入参的 age 方法。

把 Integer.TYPE 改为 Integer.class,执行的参数类型就是包装类型的 Integer。

这时,无论传入的是 Integer.valueOf(“36”) 还是基本类型的 36:

getClass().getDeclaredMethod("age", Integer.class).invoke(this, Integer.valueOf("36"));
getClass().getDeclaredMethod("age", Integer.class).invoke(this, 36);

都会调用 Integer 为入参的 age 方法:

14:25:18.028 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo1.ReflectionIssueApplication - Integer age = 36
14:25:18.029 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo1.ReflectionIssueApplication - Integer age = 36

反射调用方法,是以反射获取方法时传入的方法名称和参数类型来确定调用方法的


二、泛型经过类型擦除多出桥接方法的坑

泛型是一种风格或范式,一般用于强类型程序设计语言,允许开发者使用类型参数替代明确的类型,实例化时再指明具体的类型。

它是代码重用的有效手段,允许把一套代码应用到多种数据类型上,避免针对每一种数据类型实现重复的代码。

Java 编译器对泛型应用了强大的类型检测,如果代码违反了类型安全就会报错,可以在编译时暴露大多数泛型的编码错误。但总有一部分编码错误,比如泛型类型擦除的坑,在运行时才会暴露。

一个例子有一个项目希望在类字段内容变动时记录日志,于是开发同学就想到定义一个泛型父类,并在父类中定义一个统一的日志记录方法,子类可以通过继承重用这个方法。代码上线后业务没啥问题,但总是出现日志重复记录的问题


父类:有一个泛型占位符 T;有一个 AtomicInteger 计数器,用来记录 value 字段更新的次数,其中 value 字段是泛型 T 类型的,setValue 方法每次为 value 赋值时对计数器进行 +1 操作。我重写了 toString 方法,输出 value 字段的值和计数器的值:

class Parent<T> 
    //用于记录value更新的次数,模拟日志记录的逻辑
    AtomicInteger updateCount = new AtomicInteger();
    private T value;
    //重写toString,输出值和值更新次数
    @Override
    public String toString() 
        return String.format("value: %s updateCount: %d", value, updateCount.get());
    
    //设置值
    public void setValue(T value) 
        this.value = value;
        updateCount.incrementAndGet();
    

子类: Child1 的实现是这样的:继承父类,但没有提供父类泛型参数;定义了一个参数为 String 的 setValue 方法,通过 super.setValue 调用父类方法实现日志记录。我们也能明白,开发同学这么设计是希望覆盖父类的 setValue 实现:

class Child1 extends Parent 
    public void setValue(String value) 
        System.out.println("Child1.setValue called");
        super.setValue(value);
    

在实现的时候,子类方法的调用是通过反射进行的。实例化 Child1 类型后,通过 getClass().getMethods 方法获得所有的方法;然后按照方法名过滤出 setValue 方法进行调用,传入字符串 test 作为参数:

Child1 child1 = new Child1();
Arrays.stream(child1.getClass().getMethods())
        .filter(method -> method.getName().equals("setValue"))
        .forEach(method -> 
            try 
                method.invoke(child1, "test");
             catch (Exception e) 
                e.printStackTrace();
            
        );
System.out.println(child1.toString());

运行代码后可以看到,虽然 Parent 的 value 字段正确设置了 test,但父类的 setValue 方法调用了两次,计数器也显示 2 而不是 1:

Child1.setValue called
Parent.setValue called
Parent.setValue called
value: test updateCount: 2

显然,两次 Parent 的 setValue 方法调用,是因为 getMethods 方法找到了两个名为 setValue 的方法,分别是父类和子类的 setValue 方法。

这个案例中,子类方法重写父类方法失败的原因,包括两方面:

  • 一是,子类没有指定 String 泛型参数,父类的泛型方法 setValue(T value) 在泛型擦除后是 setValue(Object value),子类中入参是 String 的 setValue 方法被当作了新方法;
  • 二是,子类的 setValue 方法没有增加 @Override 注解,因此编译器没能检测到重写失败的问题。这就说明,重写子类方法时,标记 @Override 是一个好习惯。

但是,开发同学认为问题出在反射 API 使用不当,却没意识到重写失败。他查文档后发现,getMethods 方法能获得当前类和父类的所有 public 方法,而 getDeclaredMethods 只能获得当前类所有的 public、protected、package 和 private 方法。

于是,他就用 getDeclaredMethods 替代了 getMethods:

Arrays.stream(child1.getClass().getDeclaredMethods())
    .filter(method -> method.getName().equals("setValue"))
    .forEach(method -> 
        try 
            method.invoke(child1, "test");
         catch (Exception e) 
            e.printStackTrace();
        
    );

这样虽然能解决重复记录日志的问题,但没有解决子类方法重写父类方法失败的问题,得到如下输出:

Child1.setValue called
Parent.setValue called
value: test updateCount: 1

其实这治标不治本,其他人使用 Child1 时还是会发现有两个 setValue 方法,非常容易让人困惑。

幸好,架构师在修复上线前发现了这个问题,让开发同学重新实现了 Child2,继承 Parent 的时候提供了 String 作为泛型 T 类型,并使用 @Override 关键字注释了 setValue 方法,实现了真正有效的方法重写:

class Child2 extends Parent<String> 
    @Override
    public void setValue(String value) 
        System.out.println("Child2.setValue called");
        super.setValue(value);
    

但很可惜,修复代码上线后,还是出现了日志重复记录:

Child2.setValue called
Parent.setValue called
Child2.setValue called
Parent.setValue called
value: test updateCount: 2

可以看到,这次是 Child2 类的 setValue 方法被调用了两次。开发同学惊讶地说,肯定是反射出 Bug 了,通过 getDeclaredMethods 查找到的方法一定是来自 Child2 类本身;而且,怎么看 Child2 类中也只有一个 setValue 方法,为什么还会重复呢?

调试一下可以发现,Child2 类其实有 2 个 setValue 方法,入参分别是 String 和 Object。

如果不通过反射来调用方法,我们确实很难发现这个问题。其实,这就是泛型类型擦除导致的问题

Java 的泛型类型在编译后擦除为 Object。虽然子类指定了父类泛型 T 类型是 String,但编译后 T 会被擦除成为 Object,所以父类 setValue 方法的入参是 Object,value 也是 Object。

如果子类 Child2 的 setValue 方法要覆盖父类的 setValue 方法,那入参也必须是 Object。

所以,编译器会为我们生成一个所谓的 bridge 桥接方法,你可以使用 javap 命令来反编译编译后的 Child2 类的 class 字节码:

javap -c /Users/zhuye/Documents/common-mistakes/target/classes/org/geekbang/time/commonmistakes/advancedfeatures/demo3/Child2.class
Compiled from "GenericAndInheritanceApplication.java"
class org.geekbang.time.commonmistakes.advancedfeatures.demo3.Child2 extends org.geekbang.time.commonmistakes.advancedfeatures.demo3.Parent<java.lang.String> 
  org.geekbang.time.commonmistakes.advancedfeatures.demo3.Child2();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method org/geekbang/time/commonmistakes/advancedfeatures/demo3/Parent."<init>":()V
       4: return


  public void setValue(java.lang.String);
    Code:
       0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: ldc           #3                  // String Child2.setValue called
       5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: aload_0
       9: aload_1
      10: invokespecial #5                  // Method org/geekbang/time/commonmistakes/advancedfeatures/demo3/Parent.setValue:(Ljava/lang/Object;)V
      13: return


  public void setValue(java.lang.Object);
    Code:
       0: aload_0
       1: aload_1
       2: checkcast     #6                  // class java/lang/String
       5: invokevirtual #7                  // Method setValue:(Ljava/lang/String;)V
       8: return

可以看到,入参为 Object 的 setValue 方法在内部调用了入参为 String 的 setValue 方法,也就是代码里实现的那个方法。

如果编译器没有帮我们实现这个桥接方法,那么 Child2 子类重写的是父类经过泛型类型擦除后、入参是 Object 的 setValue 方法。

这两个方法的参数,一个是 String 一个是 Object,明显不符合 Java 的语义:

class Parent 

    AtomicInteger updateCount = new AtomicInteger();
    private Object value;
    public void setValue(Object value) 
        System.out.println("Parent.setValue called");
        this.value = value;
        updateCount.incrementAndGet();
    


class Child2 extends Parent 
    @Override
    public void setValue(String value) 
        System.out.println("Child2.setValue called");
        super.setValue(value);
    

使用 jclasslib 工具打开 Child2 类,同样可以看到入参为 Object 的桥接方法上标记了 public + synthetic + bridge 三个属性

synthetic 代表由编译器生成的不可见代码,bridge 代表这是泛型类型擦除后生成的桥接代码:

知道这个问题之后,修改方式就明朗了,可以使用 method 的 isBridge 方法,来判断方法是不是桥接方法:

  • 通过 getDeclaredMethods 方法获取到所有方法后,必须同时根据方法名 setValue 和非 isBridge 两个条件过滤,才能实现唯一过滤;
  • 使用 Stream 时,如果希望只匹配 0 或 1 项的话,可以考虑配合 ifPresent 来使用 findFirst 方法。

修复代码如下:

Arrays.stream(child2.getClass().getDeclaredMethods())
        .filter(method -> method.getName().equals("setValue") && !method.isBridge())
        .findFirst().ifPresent(method -> 
    try 
        method.invoke(chi2, "test");
     catch (Exception e) 
        e.printStackTrace();
    
);

这样就可以得到正确输出了:

Child2.setValue called
Parent.setValue called
value: test updateCount: 1

最后小结下,使用反射查询类方法清单时,我们要注意两点:

  • getMethods 和 getDeclaredMethods 是有区别的,前者可以查询到父类方法,后者只能查询到当前类。
  • 反射进行方法调用要注意过滤桥接方法。

三、注解可以继承吗?

注解可以为 Java 代码提供元数据,各种框架也都会利用注解来暴露功能,比如 Spring 框架中的 @Service、@Controller、@Bean 注解,Spring Boot 的 @SpringBootApplication 注解。

框架可以通过类或方法等元素上标记的注解,来了解它们的功能或特性,并以此来启用或执行相应的功能。

通过注解而不是 API 调用来配置框架,属于声明式交互,可以简化框架的配置工作,也可以和框架解耦。

案例:开发同学可能会认为,类继承后,类的注解也可以继承,子类重写父类方法后,父类方法上的注解也能作用于子类,但这些观点其实是错误或者说是不全面的。


首先,定义一个包含 value 属性的 MyAnnotation 注解,可以标记在方法或类上:

@Target(ElementType.METHOD, ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyAnnotation 
    String value();

然后,定义一个标记了 @MyAnnotation 注解的父类 Parent,设置 value 为 Class 字符串;

同时这个类的 foo 方法也标记了 @MyAnnotation 注解,设置 value 为 Method 字符串。接下来,定义一个子类 Child 继承 Parent 父类,并重写父类的 foo 方法,子类的 foo 方法和类上都没有 @MyAnnotation 注解。

@MyAnnotation(value = "Class")
@Slf4j
static class Parent 

    @MyAnnotation(value = "Method")
    public void foo() 
    


@Slf4j
static class Child extends Parent 
    @Override
    public void foo() 
    

再接下来,通过反射分别获取 Parent 和 Child 的类和方法的注解信息,并输出注解的 value 属性的值(如果注解不存在则输出空字符串):

private static String getAnnotationValue(MyAnnotation annotation) 
    if (annotation == null) return "";
    return annotation.value();



public static void wrong() throws NoSuchMethodException 
    //获取父类的类和方法上的注解
    Parent parent = new Parent();
    log.info("ParentClass:", getAnnotationValue(parent.getClass().getAnnotation(MyAnnotation.class)));
    log.info("ParentMethod:", getAnnotationValue(parent.getClass().getMethod("foo").getAnnotation(MyAnnotation.class)));

    //获取子类的类和方法上的注解
    Child child = new Child();
    log.info("ChildClass:", getAnnotationValue(child.getClass().getAnnotation(MyAnnotation.class)));
    log.info("ChildMethod:", getAnnotationValue(child.getClass().getMethod("foo").getAnnotation(MyAnnotation.class)));

输出如下:

17:34:25.495 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ParentClass:Class
17:34:25.501 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ParentMethod:Method
17:34:25.504 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ChildClass:
17:34:25.504 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ChildMethod:

可以看到,父类的类和方法上的注解都可以正确获得,但是子类的类和方法却不能。这说明,子类以及子类的方法,无法自动继承父类和父类方法上的注解

如果你详细了解过注解应该知道,在注解上标记 @Inherited 元注解可以实现注解的继承。那么,把 @MyAnnotation 注解标记了 @Inherited,就可以一键解决问题了吗?

@Target(ElementType.METHOD, ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface MyAnnotation 
    String value();

重新运行代码输出如下:

17:44:54.831 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ParentClass:Class
17:44:54.837 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ParentMethod:Method
17:44:54.838 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ChildClass:Class
17:44:54.838 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ChildMethod:

可以看到,子类可以获得父类上的注解;子类 foo 方法虽然是重写父类方法,并且注解本身也支持继承,但还是无法获得方法上的注解。如果你再仔细阅读一下@Inherited 的文档就会发现,@Inherited 只能实现类上的注解继承

要想实现方法上注解的继承,你可以通过反射在继承链上找到方法上的注解。但,这样实现起来很繁琐,而且需要考虑桥接方法。

好在 Spring 提供了 AnnotatedElementUtils 类,来方便我们处理注解的继承问题。

这个类的 findMergedAnnotation 工具方法,可以帮助我们找出父类和接口、父类方法和接口方法上的注解,并可以处理桥接方法,实现一键找到继承链的注解:

Child child = new Child();
log.info("ChildClass:", getAnnotationValue(AnnotatedElementUtils.findMergedAnnotation(child.getClass(), MyAnnotation.class)));
log.info("ChildMethod:", getAnnotationValue(AnnotatedElementUtils.findMergedAnnotation(child.getClass().getMethod("foo"), MyAnnotation.class)));

修改后,可以得到如下输出:

17:47:30.058 [main] INFO org.geekbang.time.commonmistakes.advancedfeatures.demo2.AnnotationInheritanceApplication - ChildClass:Class
17以上是关于Day642.反射注解和泛型问题 -Java业务开发常见错误的主要内容,如果未能解决你的问题,请参考以下文章

使用反射和泛型简化Golang查询数据库代码的方案

C#讲解反射和泛型。让程序猿少掉头发

反射获取泛型类泛型方法

数据访问层 - LINQ-To-SQL 和泛型。我可以优化这个吗?

反射(承接上面的泛型注解与反射)

QuerydslPredicate 和泛型类