1. 文章
  2. Java 进阶线
  3. 网络、框架与复习

单元测试、反射、注解与动态代理:理解框架的基础机制

更新于 24 分钟阅读 第 14 / 15 站 #java#java-advanced#day14

用断言确认行为,用元数据发现方法,用代理复用公共逻辑。

我的学习 →

Java 进阶线 · 线路目录
本页导读

day14|Junit 单元测试与 java.lang.reflect 包 API

本日学完 JavaSE 的最后四块内容:Junit 单元测试(怎么写测试、测试前后做什么)、反射(Class/Constructor/Field/Method 四件套)、注解(自定义注解、元注解、注解解析)与动态代理(Proxy + InvocationHandler)。这四块在日常业务代码里用得少,但它们是 Spring、JUnit 等框架的底层机制——学完应能读懂”框架凭什么能自动帮我们做事”,并能自己写出一个简易框架。

1. 单元测试与 JUnit 快速入门

1.1 什么是单元测试

单元测试就是:针对最小的功能单元(方法),编写测试代码对其进行正确性测试。

1.2 在 main 方法里测试有什么问题

以前的测试方式只有一种:在 main 方法中写测试代码,去调用其他方法进行测试。这种方式有三个问题:

  1. 无法实现自动化测试:一个方法测试失败,可能影响其他方法的测试(后面的代码根本走不到)。
  2. 无法得到测试的报告:需要程序员自己去观察测试是否成功。
  3. 测试代码和业务代码混在一起,没有统一的组织方式。

以前在 main 方法里测试所有业务方法的写法

1.3 JUnit 单元测试框架

JUnit 是第三方公司开源出来的、用来对方法进行测试的框架(很多开发工具已经集成了 JUnit 框架,比如 IDEA)。

相比在 main 方法中测试,它的优点:

  • 可以灵活地编写测试代码:可以针对某个方法单独执行测试,也支持一键完成对全部方法的自动化测试,且各自独立(一个失败不影响其他)。
  • 不需要程序员去分析测试结果,会自动生成测试报告。

JUnit 测试报告,红色感叹号表示失败、绿色对勾表示通过

1.4 快速入门步骤

需求:某系统有多个业务方法,请使用 Junit 单元测试框架,编写测试代码完成对这些方法的正确性测试。

具体步骤:

  1. 将 JUnit 框架的 jar 包导入到项目中(注意:IDEA 集成了 JUnit 框架,不需要我们手工导入)。
  2. 为需要测试的业务类,定义对应的测试类;并为每个业务方法编写对应的测试方法(必须:公共、无参、无返回值)。
  3. 测试方法上必须声明 @Test 注解,然后在测试方法中编写代码调用被测试的业务方法进行测试。
  4. 开始测试:选中测试方法,右键选择 “JUnit 运行”;如果测试通过则是绿色,如果测试失败则是红色。
public class StringUtil {
    public static void printNumber(String name) {
        System.out.println("名字长度:" + name.length());
    }
}

public class StringUtilTest {
    @Test
    public void testPrintNumber() {
        StringUtil.printNumber(null);
        StringUtil.printNumber("");
        StringUtil.printNumber("admin");
    }
}

测试某个方法直接右键该方法启动测试;测试全部方法则选择类或者模块启动。红色失败,绿色通过。

1.5 断言(Assert)

(以下内容来自本日讲义补充,PPT 未展开。)断言的意思是:程序员可以提前预测程序的运行结果,检查运行结果是否与预期一致。

public class StringUtilTest {
    @Test
    public void testGetMaxIndex() {
        int i1 = StringUtil.getMaxIndex(null);
        Assert.assertEquals("null测试失败!", -1, i1);

        int i2 = StringUtil.getMaxIndex("");
        Assert.assertEquals("空字符串测试失败!", -1, i2);

        int i3 = StringUtil.getMaxIndex("admin");
        Assert.assertEquals("admin测试失败!", 4, i3);

        System.out.println("测试通过!");
    }
}

Assert.assertEquals(消息, 预期值, 实际值):三者不一致时测试失败,并给出预期值与实际值的对比。

讲义配图:断言失败的报告,Expected 预期值 4、Actual 实际值 5

2. JUnit 框架的常用注解

2.1 JUnit 4 版本的常用注解

在测试方法执行前执行的方法,常用于初始化资源;在测试方法执行完后再执行的方法,常用于释放资源。

注解说明
@Test测试类中的方法必须用它修饰才能成为测试方法,才能启动执行
@Before用来修饰一个实例方法,该方法会在每一个测试方法执行之前执行一次
@After用来修饰一个实例方法,该方法会在每一个测试方法执行之后执行一次
@BeforeClass用来修饰一个静态方法,该方法会在所有测试方法之前只执行一次
@AfterClass用来修饰一个静态方法,该方法会在所有测试方法之后只执行一次

JUnit 4 常用注解表

2.2 JUnit 5 版本的常用注解

JUnit 5 对 JUnit 4 的注解做了改名,作用完全一样。

注解说明
@Test测试类中的方法必须用它修饰才能成为测试方法,才能启动执行
@BeforeEach用来修饰一个实例方法,该方法会在每一个测试方法执行之前执行一次
@AfterEach用来修饰一个实例方法,该方法会在每一个测试方法执行之后执行一次
@BeforeAll用来修饰一个静态方法,该方法会在所有测试方法之前只执行一次
@AfterAll用来修饰一个静态方法,该方法会在所有测试方法之后只执行一次

JUnit 5 常用注解表

(补充说明)JUnit 4 的注解在 org.junit 包下,JUnit 5 的在 org.junit.jupiter.api 包下,两套注解不能混用。

2.3 注解方法的执行顺序

public class StringUtilTest {
    @Before
    public void before() {
        System.out.println("===========before===========");
    }

    @After
    public void after() {
        System.out.println("===========after===========");
    }

    @BeforeClass
    public static void beforeClass() {
        System.out.println("===========beforeClass===========");
    }

    @AfterClass
    public static void afterClass() {
        System.out.println("===========afterClass===========");
    }

    @Test
    public void testPrintNumber() {
        StringUtil.printNumber(null);
        StringUtil.printNumber("");
        StringUtil.printNumber("admin");
    }

    @Test
    public void testGetMaxIndex() {
        // 断言测试...
    }
}

执行结果的特点:

  1. 被 @BeforeClass 标记的方法,执行在所有方法之前。
  2. 被 @AfterClass 标记的方法,执行在所有方法之后。
  3. 被 @Before 标记的方法,执行在每一个 @Test 方法之前。
  4. 被 @After 标记的方法,执行在每一个 @Test 方法之后。

2.4 应用场景:测试前后的资源初始化与释放

假设每个测试方法都要用同一个 Socket 对象,用完还要关闭。就可以把”开一次、关一次”的工作交给 @BeforeClass / @AfterClass:

public class StringUtilTest {
    private static Socket socket;

    @BeforeClass
    public static void beforeClass() {
        System.out.println("===========beforeClass===========");
        // 初始化 Socket 对象
        socket = new Socket();
    }

    @AfterClass
    public static void afterClass() {
        System.out.println("===========afterClass===========");
        // 关闭 Socket
        socket.close();
    }
}

3. 反射:认识反射与获取 Class 对象

3.1 反射是什么

Java API 文档中对反射的说明翻译成人话就是:反射(Reflection)就是加载类,并允许以编程的方式解剖类中的各种成分(成员变量、方法、构造器等)。

3.2 反射学什么

先充分认识”获取类的信息、操作它们”,再了解它的作用和应用场景。学习路线共四步:

  1. 反射第一步:加载类,获取类的字节码 Class 对象。
  2. 获取类的构造器:Constructor 对象。
  3. 获取类的成员变量:Field 对象。
  4. 获取类的成员方法:Method 对象。

反射学什么——Class、Constructor、Field、Method 四件套

3.3 获取 Class 对象的三种方式

反射的第一步是将字节码加载到内存,获取字节码对象(Student.java → Student.class → 加载进内存)。有三种写法:

方式代码说明
方式一Class c1 = 类名.class类名点 class
方式二Class c2 = Class.forName("全类名")调用 Class 提供的 public static Class forName(String package)
方式三Class c3 = 对象.getClass()调用 Object 提供的 public Class getClass()
public class Test1Class {
    public static void main(String[] args) throws Exception {
        // 1、方式一:类名.class
        Class c1 = Student.class;
        System.out.println(c1);

        // 2、方式二:Class.forName(全类名)
        Class c2 = Class.forName("com.itheima.d2_reflect.Student");
        System.out.println(c2);

        // 3、方式三:对象.getClass();
        Student s1 = new Student();
        Class c3 = s1.getClass();
        System.out.println(c3);

        System.out.println(c1 == c2); // true
        System.out.println(c2 == c3); // true
    }
}

三种方式获取到的字节码对象是同一个(== 为 true)。此外 c1.getName() 获取全类名、c1.getSimpleName() 获取简单类名。

Student.java → Student.class → 加载进内存

课件说法:反射第一步是”加载类,获取类的字节码”。更准确的表述:类的加载由虚拟机按需触发(第一次使用该类、执行 Class.forName() 等时),反射拿到的只是同一个字节码对象在内存中的引用。

3.4 反射就在身边:IDEA 的代码提示

用对象调用方法时,IDEA 会把该对象能调用的方法列出来供选择。IDEA 怎么知道对象有这些方法?因为对象能调用的方法全都来自类,IDEA 通过反射技术获取类中有哪些方法,再以提示框形式显示出来。

IDEA 通过反射列出对象可调用的方法与成员

4. 反射获取类的构造器

4.1 获取构造器的 API

Class 提供了从类中获取构造器的方法:

方法说明
Constructor<?>[] getConstructors()获取全部构造器(只能获取 public 修饰的)
Constructor<?>[] getDeclaredConstructors()获取全部构造器(只要存在就能拿到)
Constructor<T> getConstructor(Class<?>... parameterTypes)获取某个构造器(只能获取 public 修饰的)
Constructor<T> getDeclaredConstructor(Class<?>... parameterTypes)获取某个构造器(只要存在就能拿到)

记忆规律:

  • get:获取。
  • Declared:有这个单词表示可以获取任意一个,没有这个单词表示只能获取 public 修饰的。
  • Constructor:构造方法的意思。
  • 后缀 s:表示可以获取多个,没有后缀 s 只能获取一个。

4.2 获取全部构造器与单个构造器

public class Test2Constructor {
    @Test
    public void testGetConstructors() {
        // 1、反射第一步:必须先得到这个类的 Class 对象
        Class c = Cat.class;
        // 2、获取类的全部构造器
        // Constructor[] constructors = c.getConstructors(); // 只能拿 public 的
        Constructor[] constructors = c.getDeclaredConstructors(); // 存在即可拿
        // 3、遍历构造器
        for (Constructor con : constructors) {
            System.out.println(con.getName() + " ===> " + con.getParameterCount());
        }
    }

    @Test
    public void testGetConstructor() throws Exception {
        // 1、反射第一步:必须先得到这个类的 Class 对象
        Class c = Cat.class;
        // 2、获取类的某个构造器:无参数构造器
        Constructor con1 = c.getDeclaredConstructor();                        // 定位无参数的
        Constructor con2 = c.getDeclaredConstructor(String.class, int.class); // 定位有参数的
    }
}

4.3 构造器的作用:初始化对象并返回

获取到类的构造器,作用依然是初始化对象返回。使用 Constructor 中的方法:

方法说明
T newInstance(Object... initargs)调用此构造器对象表示的构造器完成对象初始化(返回对象)
void setAccessible(boolean flag)设置禁用访问控制检查(暴力反射),非 public 构造器必须先打开权限
// 3、得到构造器的目的依然是初始化对象返回
Cat c1 = (Cat) con1.newInstance();
System.out.println(c1);

con2.setAccessible(true);   // 禁止检查访问权限(暴力反射)
Cat c2 = (Cat) con2.newInstance("叮当猫", 30);
System.out.println(c2);

5. 反射获取类的成员变量

5.1 获取成员变量的 API

方法说明
public Field[] getFields()获取类的全部成员变量(只能获取 public 修饰的)
public Field[] getDeclaredFields()获取类的全部成员变量(只要存在就能拿到)
public Field getField(String name)获取类的某个成员变量(只能获取 public 修饰的)
public Field getDeclaredField(String name)获取类的某个成员变量(只要存在就能拿到)

命名规律同上:有 Declared 表示”任意权限都能拿”,带后缀 s 表示”拿多个”。

5.2 成员变量的作用:取值与赋值

方法说明
void set(Object obj, Object value)给某个对象的该成员变量赋值
Object get(Object obj)取出某个对象中该成员变量的值
void setAccessible(boolean flag)非 public 成员变量需要先打开权限(暴力反射),再取值、赋值
public class Test3Field {
    @Test
    public void testGetFields() throws Exception {
        // 1、反射第一步:必须先得到类的 Class 对象
        Class c = Cat.class;
        // 2、获取类的全部成员变量
        Field[] fields = c.getDeclaredFields();
        // 3、遍历这个成员变量数组
        for (Field field : fields) {
            System.out.println(field.getType() + "==>" + field.getName());
        }
        // 4、定位某个成员变量
        Field fname = c.getDeclaredField("name");

        // 5、成员变量的作用依然是:取值和赋值
        Cat cat = new Cat();
        fname.setAccessible(true);             // 暴力反射
        fname.set(cat, "哆啦A梦");              // 赋值
        String name = (String) fname.get(cat); // 取值
        System.out.println(name);
    }
}

6. 反射获取类的成员方法

6.1 获取成员方法的 API

方法说明
Method[] getMethods()获取类的全部成员方法(只能获取 public 修饰的)
Method[] getDeclaredMethods()获取类的全部成员方法(只要存在就能拿到)
Method getMethod(String name, Class<?>... parameterTypes)获取类的某个成员方法(只能获取 public 修饰的)
Method getDeclaredMethod(String name, Class<?>... parameterTypes)获取类的某个成员方法(只要存在就能拿到)

6.2 成员方法的作用:执行它

方法说明
Object invoke(Object obj, Object... args)在某个对象上触发该方法执行,返回方法返回值
void setAccessible(boolean flag)非 public 成员方法需要先打开权限(暴力反射),再触发执行
public class Test4Method {
    @Test
    public void testGetMethods() throws Exception {
        // 1、反射第一步:先得到 Class 对象
        Class c = Cat.class;
        // 2、获取类的全部成员方法
        Method[] methods = c.getDeclaredMethods();
        // 3、遍历这个数组中的每个方法对象
        for (Method method : methods) {
            System.out.println(method.getName() + " " + method.getParameterCount());
        }

        // 4、定位单个方法
        Method eat1 = c.getDeclaredMethod("eat");
        Method eat2 = c.getDeclaredMethod("eat", String.class);

        // 5、定位方法的目的是:依然是执行它!
        Cat cat = new Cat();
        Object result = eat1.invoke(cat);
        System.out.println(result);

        eat2.setAccessible(true);
        Object result2 = eat2.invoke(cat, "鱼儿");
        System.out.println(result2);
    }
}

无返回值的方法(如 run())执行后 invoke() 返回 null;有返回值的方法返回真实结果。

7. 反射的作用与应用场景

7.1 反射的作用

  • 基本作用:可以得到一个类的全部成分然后操作。
  • 可以破坏封装性(很突出)。
  • 也可以破坏泛型的约束性(很突出)。
  • 最重要的用途是:适合做 Java 的框架——基本上主流框架都会基于反射设计出一些通用的功能。

7.2 应用:用反射做一个简易版框架

需求:对于任意一个对象,该框架都可以把对象的字段名和对应的值,保存到文件中去。

实现步骤:

  1. 定义一个方法,可以接收任意对象。
  2. 每收到一个对象后,使用反射获取该对象的 Class 对象,然后获取全部的成员变量。
  3. 遍历成员变量,然后提取成员变量在该对象中的具体值。
  4. 把成员变量名和其值,写出到文件中去。
public class ObjectFrame {
    // 目标:保存任意对象的字段和其数据到文件中去
    public static void saveObject(Object obj) throws Exception {
        // 这个对象中有多少个字段我们不清楚,谁清楚呢?只有反射可以解决。
        PrintWriter pw = new PrintWriter(new FileWriter("day14-junit-reflect-annotation-proxy/src/obj.txt", true));
        Class c = obj.getClass();

        // 0.还得知道是啥对象
        // String className = c.getName();      // com.itheima.d2_reflect.Student
        String className = c.getSimpleName();   // Student
        pw.println("==================" + className + "====================");

        // 1、提取这个 Class 对象中的全部字段
        Field[] fields = c.getDeclaredFields();
        for (Field field : fields) {
            String name = field.getName();
            field.setAccessible(true);
            String value = field.get(obj) + "";
            pw.println(name + "=" + value);
        }
        pw.close();
    }
}
public class Test5Frame {
    @Test
    public void save() throws Exception {
        Student s1 = new Student("柳岩", 40, '女', 167.5, "女星");
        Teacher s2 = new Teacher("波妞", 6000);

        ObjectFrame.saveObject(s1);
        ObjectFrame.saveObject(s2);
    }
}

运行后生成的文件内容如下(框架对任意对象都能工作,对象的字段名与值一一对应):

简易框架把 Student、Teacher 的字段名与值写入文件的结果

8. 注解(Annotation)

8.1 认识注解

注解就是 Java 代码里的特殊标记,比如 @Override、@Test 等,作用是:让其他程序根据注解信息来决定怎么执行该程序。

注意:注解可以用在类上、构造器上、方法上、成员变量上、参数上等位置。

@Test 标记的方法被当作测试方法执行,未标记的 test2() 不会被执行

@Override 拼写错误时 IDEA 直接报错——注解让其他程序做检查

8.2 自定义注解

自定义注解的格式:

public @interface 注解名称 {
    public 属性类型 属性名() default 默认值;
}
public @interface MyTest1 {
    String aaa();
    boolean bbb() default true;   // default true 表示默认值为 true,使用时可以不赋值
    String[] ccc();
}

使用注解时需要加 @ 符号:

@MyTest1(aaa = "牛魔王", ccc = {"HTML", "Java"})
public class AnnotationTest1 {
    @MyTest1(aaa = "铁扇公主", bbb = false, ccc = {"Python", "前端", "Java"})
    public void test1() {
    }
}

特殊属性名 value:如果注解中只有一个 value 属性,使用注解时 value 名称可以不写。

public @interface MyTest2 {
    String value();          // 特殊属性
    int age() default 10;
}

@MyTest2("孙悟空")           // 等价于 @MyTest2(value = "孙悟空")
public class AnnotationTest1 {
}

8.3 注解的本质

对注解的字节码进行反编译可以看到:

// 源码里定义的
public @interface MyTest1 {
    String aaa();
    boolean bbb();
    String[] ccc();
}

// 反编译后等价于
public interface MyTest1 extends Annotation {
    public abstract String aaa();
    public abstract boolean bbb();
    public abstract String[] ccc();
}

结论:

  1. 注解本质是一个接口,Java 中所有注解都继承了 Annotation 接口。
  2. 注解中的属性,本质上是抽象方法。
  3. @注解(...) 其实就是一个实现类对象,实现了该注解以及 Annotation 接口。
  4. 注解里的属性值(如 aaa = "孙悟空"),可以通过调用 aaa()、bbb()、ccc() 方法获取到——解析注解时会用到。

8.4 注解的作用

对 Java 中类、方法、成员变量做标记,然后进行特殊处理。

例如:JUnit 框架中,标记了注解 @Test 的方法就可以被当成测试方法执行,而没有标记的就不能当成测试方法执行。

9. 元注解

9.1 什么是元注解

元注解指的是修饰注解的注解。

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface Test {
}

9.2 @Target:声明注解只能在哪些位置使用

取值位置
ElementType.TYPE类、接口
ElementType.FIELD成员变量
ElementType.METHOD成员方法
ElementType.PARAMETER方法参数
ElementType.CONSTRUCTOR构造器
ElementType.LOCAL_VARIABLE局部变量
@Target(ElementType.TYPE)   // 声明 @MyTest3 注解只能用在类上
public @interface MyTest3 {
}

如果把 @MyTest3 用在方法上、变量上,IDEA 会直接报错;写成数组形式就可以声明多个位置:

// 声明 @MyTest3 注解只能用在类上和方法上
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface MyTest3 {
}

9.3 @Retention:声明注解的保留周期

取值含义
RetentionPolicy.SOURCE只作用在源码阶段,字节码文件中不存在
RetentionPolicy.CLASS保留到字节码文件阶段,运行阶段不存在(默认值)
RetentionPolicy.RUNTIME一直保留到运行阶段(开发常用)
@Target({ElementType.TYPE, ElementType.METHOD})   // 只能用类上和方法上
@Retention(RetentionPolicy.RUNTIME)               // 保留到运行时期
public @interface MyTest3 {
}

@Target 约束自定义注解可以标记的范围,@Retention 用来约束自定义注解的存活范围。

10. 注解的解析

10.1 什么是注解的解析

注解的解析就是:判断类上、方法上、成员变量上是否存在注解,并把注解里的内容解析出来。

指导思想:要解析谁上面的注解,就应该先拿到谁。

  • 要解析类上面的注解,先获取该类的 Class 对象,再通过 Class 对象解析其上面的注解。
  • 要解析成员方法上的注解,先获取该成员方法的 Method 对象,再通过 Method 对象解析其上面的注解。

Class、Method、Field、Constructor 都实现了 AnnotatedElement 接口,它们都拥有解析注解的能力。

10.2 AnnotatedElement 接口提供的解析注解方法

方法说明
public Annotation[] getDeclaredAnnotations()获取当前对象上面的注解
public T getDeclaredAnnotation(Class<T> annotationClass)获取指定的注解对象
public boolean isAnnotationPresent(Class<Annotation> annotationClass)判断当前对象上是否存在某个注解

10.3 解析案例

需求:定义注解 MyTest4(含 String value()、double aaa() 默认值 100、String[] bbb(),只能用在类和成员方法上,保留到运行时);定义类 Demo,在类与其 test1 方法上使用该注解;定义测试类 AnnotationTest3 解析 Demo 类中的全部注解。

① 定义注解

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface MyTest4 {
    String value();
    double aaa() default 100;
    String[] bbb();
}

② 使用注解

@MyTest4(value = "蜘蛛侠", aaa = 99.9, bbb = {"至尊宝", "黑马"})
public class Demo {
    @MyTest4(value = "孙悟空", aaa = 199.9, bbb = {"紫霞", "牛夫人"})
    public void test1() {
    }
}

③ 解析注解

public class AnnotationTest3 {
    @Test
    public void parseClass() {
        // 1.先获取 Class 对象
        Class c = Demo.class;
        // 2.解析 Demo 类上的注解
        if (c.isAnnotationPresent(MyTest4.class)) {
            MyTest4 myTest4 = (MyTest4) c.getDeclaredAnnotation(MyTest4.class);
            System.out.println(myTest4.value());
            System.out.println(myTest4.aaa());
            System.out.println(myTest4.bbb());
        }
    }

    @Test
    public void parseMethods() {
        // 1.先获取 Class 对象
        Class c = Demo.class;
        // 2.解析 Demo 类中 test1 方法上的注解
        Method m = c.getDeclaredMethod("test1");
        if (m.isAnnotationPresent(MyTest4.class)) {
            MyTest4 myTest4 = (MyTest4) m.getDeclaredAnnotation(MyTest4.class);
            System.out.println(myTest4.value());
            System.out.println(myTest4.aaa());
            System.out.println(myTest4.bbb());
        }
    }
}

11. 注解的应用场景:模拟 JUnit 框架

需求:定义若干个方法,只要加了 MyTest 注解,就会触发该方法执行。

分析:

  1. 定义一个自定义注解 MyTest,只能注解方法,存活范围是一直都在。
  2. 定义若干个方法,部分方法加上 @MyTest 注解修饰,部分方法不加。
  3. 模拟一个 JUnit 程序,可以触发加了 @MyTest 注解的方法执行。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyTest {
}
public class AnnotationTest4 {
    @MyTest
    public void test1() {
        System.out.println("=====test1====");
    }

    @MyTest
    public void test2() {
        System.out.println("=====test2====");
    }

    public void test3() {
        System.out.println("=====test3====");
    }

    public static void main(String[] args) throws Exception {
        AnnotationTest4 a = new AnnotationTest4();

        // 1.先获取 Class 对象
        Class c = AnnotationTest4.class;

        // 2.解析 AnnotationTest4 类中所有的方法对象
        Method[] methods = c.getDeclaredMethods();
        for (Method m : methods) {
            // 3.判断方法上是否有 MyTest 注解,有就执行该方法
            if (m.isAnnotationPresent(MyTest.class)) {
                m.invoke(a);
            }
        }
    }
}

这段代码就是 JUnit 的核心思路:注解负责标记,反射负责找到标记并执行。代码工程里还有一个扩展版:注解 @TestDlei 带一个 int value() default 1 属性,表示该方法重复执行的次数(片段)。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TestDlei {
    int value() default 1;
}

// 解析时读出注解属性,决定执行次数
if (method.isAnnotationPresent(TestDlei.class)) {
    TestDlei dlei = method.getDeclaredAnnotation(TestDlei.class);
    int count = dlei.value();
    for (int i = 0; i < count; i++) {
        method.invoke(t);
    }
}

12. 动态代理

12.1 程序为什么需要代理

以大明星为例:唱歌、跳舞是明星真正擅长的事,但每次唱歌前要准备话筒、收钱,跳舞前要准备场地、收钱——这些繁琐的事明星不想干,于是找一家中介公司(代理)来处理。

唱歌() {
    真正开始唱歌
}
跳舞() {
    真正开始跳舞
}

对象如果嫌身上干的事太多,可以通过代理来转移部分职责。

代理结构——代理对象与真实对象都实现 Star 接口

12.2 代理长什么样:由接口决定

中介公司怎么知道要派一个有唱歌、跳舞方法的代理呢?对象有什么方法想被代理,代理就一定要有对应的方法——Java 用接口来保证这一点:

public interface Star {
    String sing(String name);
    void dance(String name);
}

明星类实现这个接口,Proxy 就能根据该接口生成一个同样具有这些方法的代理对象:

public class SuperStar implements Star {
    private String name;

    public SuperStar(String name) {
        this.name = name;
    }

    @Override
    public String sing(String name) {
        System.out.println(this.name + "唱歌一首:" + name);
        return "谢谢!谢谢!";
    }

    @Override
    public void dance(String name) {
        System.out.println(this.name + "跳了一支:" + name);
    }
}

12.3 生成代理对象:Proxy.newProxyInstance()

java.lang.reflect.Proxy 类提供了为对象产生代理对象的方法:

public static Object newProxyInstance(ClassLoader loader,
                                      Class<?>[] interfaces,
                                      InvocationHandler h)
参数含义
参数一 loader用于指定用哪个类加载器去加载生成的代理类
参数二 interfaces指定接口,这些接口用于指定生成的代理长什么,也就是有哪些方法
参数三 h用来指定生成的代理对象要干什么事情

12.4 InvocationHandler.invoke():代理到底干什么

public class ProxyUtil {
    // 为实现类对象创建代理对象,并返回
    public static Star createProxy(SuperStar zrn) {
        Star proxy = (Star) Proxy.newProxyInstance(ProxyUtil.class.getClassLoader(),
                zrn.getClass().getInterfaces(), new InvocationHandler() {
                    @Override
                    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                        // proxy:代理对象本身,一般我们不用
                        // method:正在代理的方法,哪个方法被调用,method 就代表哪个方法
                        // args:正在被代理的方法的参数
                        if ("sing".equals(method.getName())) {
                            System.out.println("准备话筒,收钱20万!");
                        } else if ("dance".equals(method.getName())) {
                            System.out.println("准备场地,收钱300万!");
                        }
                        // 其他的行为都是真实明星对象自己去做!
                        Object result = method.invoke(zrn, args);
                        return result;
                    }
                });
        return proxy;
    }
}

调用方式:以后找代理唱歌、跳舞即可。

public class Test {
    public static void main(String[] args) {
        // 1、创建一个明星对象
        SuperStar zrn = new SuperStar("章若楠");
        // 2、创建章若楠的代理对象
        Star proxy = ProxyUtil.createProxy(zrn);
        System.out.println(proxy.sing("好日子"));
        proxy.dance("科目三");
    }
}

12.5 代理的执行流程

每次用代理对象调用接口中的方法时,都会回调 InvocationHandler 中的 invoke() 方法,在其中执行”代理要干的事”,再通过 method.invoke(真实对象, args) 把真正的工作交回给真实对象。

说明:本日讲义中引用的”代理执行流程图”(讲义 assets 中的 1669622605490.png)在讲义 assets 目录里缺失,未能归档,此处以文字描述流程。

12.6 应用:用代理优化用户管理类

场景:某系统有一个用户管理类,包含用户登录、删除用户、查询用户等功能,系统要求统计每个功能的执行耗时情况,以便后期观察程序性能。

原来的实现里每个方法都自己写”记录开始时间 → 业务 → 记录结束时间 → 打印耗时”,重复代码多,而且这些代码不属于业务本身:

/**
 * 用户业务接口
 */
public interface UserService {
    // 登录功能
    void login(String loginName, String passWord) throws Exception;
    // 删除用户
    void deleteUsers() throws Exception;
    // 查询用户,返回数组的形式
    String[] selectUsers() throws Exception;
}

改造思路:把计算耗时的操作交给代理对象来做,业务实现类只保留业务代码。

public class ProxyUtil {
    public static UserService createProxy(UserServiceImpl obj) {
        UserService proxy = (UserService) Proxy.newProxyInstance(ProxyUtil.class.getClassLoader(),
                obj.getClass().getInterfaces(), new InvocationHandler() {
                    @Override
                    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                        long startTime = System.currentTimeMillis();

                        // 真正走业务对象的方法
                        Object result = method.invoke(obj, args);

                        long end = System.currentTimeMillis();
                        System.out.println(method.getName() + "方法耗时:" + (end - startTime) / 1000.0 + "s");

                        return result;
                    }
                });
        return proxy;
    }
}
public class Test {
    public static void main(String[] args) throws Exception {
        // 1、创建用户业务对象(实际上是代理对象)
        UserService userService = ProxyUtil.createProxy(new UserServiceImpl());

        // 2、调用用户业务的功能
        userService.login("admin", "123456");
        System.out.println("----------------------------------------------------");

        userService.deleteUsers();
        System.out.println("----------------------------------------------------");

        String[] names = userService.selectUsers();
        System.out.println("查询到的用户是:" + Arrays.toString(names));
    }
}

这样:业务实现类只关心业务,公共的耗时统计被集中到代理的 invoke() 中——这就是代理最典型的好处(Spring 的事务、日志、权限校验也是同样的套路)。

13. 代码工程与作业

  • 本日配套讲义(兼作业与校对材料):day14-Java高级技术/讲义/day14-单元测试、反射、注解、动态代理/day14-单元测试、反射、注解、动态代理.md
  • 本日代码工程:day14-Java高级技术/代码/day14-junit-reflect-annotation-proxy/,包结构与今天的四块内容一一对应:
    • com.itheima.d1_junit:StringUtil、StringUtilTest(@Before / @After / @BeforeClass / @AfterClass + 断言)
    • com.itheima.d2_reflect:Test1Class、Test2Constructor、Test3Field、Test4Method、ObjectFrame、Test5Frame、Cat、Student、Teacher
    • com.itheima.d3_annotation:MyTestMyTest4、AnnotationDemo1AnnotationDemo4、TestDlei
    • com.itheima.d4_proxy:Star、SuperStar、ProxyUtil、Test
    • com.itheima.d5_proxy2:UserService、UserServiceImpl、ProxyUtil、Test
  • 练习要点:把 ObjectFrame.saveObject() 改成同时输出字段类型;给 MyTest 版模拟 JUnit 框架加上”统计通过/失败个数并打印报告”。

14. 复习要点

  1. 单元测试的定义(针对最小功能单元”方法”),以及在 main 中测试的三个问题(无法自动化、失败会互相影响、拿不到测试报告)。
  2. JUnit 的两个优点与快速入门四步;测试方法的三条硬性要求:公共、无参、无返回值,且必须用 @Test 标记;红色失败、绿色通过。
  3. 断言 Assert.assertEquals(消息, 预期值, 实际值) 的作用与失败报告形式。
  4. JUnit 4 与 JUnit 5 注解的对应关系(@Before→@BeforeEach、@After→@AfterEach、@BeforeClass→@BeforeAll、@AfterClass→@AfterAll)与执行时机(实例方法每个测试方法执行一次,静态方法整体只执行一次)。
  5. 反射的定义(加载类并以编程方式解剖类中各种成分)与学习四步:Class → Constructor → Field → Method。
  6. 获取 Class 对象的三种方式(类名.class、Class.forName(全类名)、对象.getClass()),三者是同一个对象。
  7. 获取构造器/成员变量/成员方法的 API 与命名规律:有 Declared 能拿任意权限、没有只能拿 public;带 s 拿多个、不带拿一个。
  8. 三件套的作用:构造器 newInstance() 初始化对象、成员变量 set()/get() 取值赋值、成员方法 invoke() 触发执行;非 public 成员需要 setAccessible(true)(暴力反射)。
  9. 反射的作用:获取类的全部成分并操作、破坏封装性、破坏泛型约束性,最重要的用途是做 Java 框架(简易框架 ObjectFrame、IDEA 代码提示)。
  10. 注解的定义与作用;自定义注解的格式、value 属性可省略名称的规则;注解本质是继承 Annotation 接口的接口,@注解(...) 是实现类对象。
  11. 元注解 @Target(ElementType 各取值)与 @Retention(SOURCE / CLASS / RUNTIME)的作用。
  12. 注解解析的指导思想(注解在谁身上就先拿到谁)与 AnnotatedElement 的三个方法;模拟 JUnit 框架的完整思路(注解标记 + 反射执行)。
  13. 动态代理三要素:Proxy.newProxyInstance(loader, interfaces, handler) 三个参数的含义、invoke(proxy, method, args) 三个参数的含义与回调时机、接口决定代理有哪些方法;能说出用代理消除耗时统计重复代码的好处。