Skip to content
第 35 / 250 章后端⏱ 10 分钟阅读

第 35 章:注解

学习目标

  • 理解注解的本质
  • 掌握元注解与自定义注解
  • 实战:手写迷你 JUnit 框架

一、注解的本质

注解 = 给代码贴标签。它本身不做任何事,需要有人读取执行逻辑

关键认知:注解只是元数据。@Override 由编译器读,@Service 由 Spring 读,你自己的注解需要你自己写代码去读(用反射,见第 33 章)。

二、JDK 内置注解

注解作用
@Override声明重写父类方法,写错方法名编译报错
@Deprecated标记已过时,调用处会有删除线警告
@SuppressWarnings抑制编译警告
@FunctionalInterface声明函数式接口(只能有一个抽象方法)
@SafeVarargs抑制泛型可变参数警告

三、元注解(修饰注解的注解)

java
@Retention(RetentionPolicy.RUNTIME)   // ① 生命周期
@Target({ElementType.METHOD})         // ② 能贴在哪
@Documented                           // ③ 生成 javadoc 时包含
@Inherited                            // ④ 子类能继承这个注解
public @interface MyAnnotation { }

@Retention:生命周期

保留到用途
SOURCE源码,编译后丢弃@Override、Lombok
CLASSclass 文件,运行时不加载(默认字节码增强工具
RUNTIME运行期可反射读取Spring / 自定义注解必选

⚠️ 最常见的坑:忘了写 @Retention(RUNTIME),反射死活读不到注解。

@Target:作用位置

位置
TYPE类 / 接口 / 枚举
FIELD字段
METHOD方法
PARAMETER方法参数
CONSTRUCTOR构造器
LOCAL_VARIABLE局部变量
ANNOTATION_TYPE注解上(用于组合注解)

四、自定义注解

java
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RateLimit {

    /** ① 每秒允许的请求数 */
    int permitsPerSecond() default 10;

    /** ② value 是特殊名字:只赋这一个属性时可省略属性名 */
    String value() default "";

    /** ③ 数组类型 */
    String[] tags() default {};
}

使用

java
@RateLimit(permitsPerSecond = 100, tags = {"api", "hot"})
public void hotApi() { }

@RateLimit("order")     // value 可省略属性名
public void orderApi() { }

属性类型限制:只能是基本类型、StringClass、枚举、注解,以及它们的数组。

五、组合注解

java
// Spring 的 @RestController 就是组合注解
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Controller           // ① 组合了 @Controller
@ResponseBody         // ② 和 @ResponseBody
public @interface RestController {
    @AliasFor(annotation = Controller.class)
    String value() default "";
}

好处:把常用注解打包,减少样板。你也可以自己组合,比如 @ApiEndpoint = @RestController + @Validated + @Slf4j

六、实战:手写迷你 JUnit

目标:写 @Test 注解,扫描类中所有标注方法并执行,输出通过/失败统计。

第 1 步:定义注解

java
@Retention(RetentionPolicy.RUNTIME)   // ① 必须 RUNTIME
@Target(ElementType.METHOD)
public @interface Test {
    /** 期望抛出的异常类型,Void 表示不期望异常 */
    Class<? extends Throwable> expected() default None.class;

    /** 占位类:表示「没有期望异常」 */
    class None extends Throwable { }
}

第 2 步:定义断言工具

java
public final class Assert {

    public static void assertEquals(Object expected, Object actual) {
        if (!Objects.equals(expected, actual)) {
            throw new AssertionError(
                    "期望: " + expected + ",实际: " + actual);   // ① 信息要能定位问题
        }
    }

    public static void assertTrue(boolean condition) {
        if (!condition) throw new AssertionError("期望 true,实际 false");
    }

    private Assert() { }
}

第 3 步:写测试运行器

java
public class MiniJUnit {

    public static void run(Class<?> testClass) throws Exception {
        int passed = 0, failed = 0;
        long start = System.currentTimeMillis();

        // ① 创建测试类实例(要求有无参构造)
        Object instance = testClass.getDeclaredConstructor().newInstance();

        // ② 遍历所有方法
        for (Method method : testClass.getDeclaredMethods()) {

            Test test = method.getAnnotation(Test.class);
            if (test == null) continue;              // ③ 没有 @Test 就跳过

            method.setAccessible(true);

            try {
                method.invoke(instance);             // ④ 反射执行测试方法

                // ⑤ 如果期望抛异常但没抛 → 失败
                if (test.expected() != Test.None.class) {
                    System.out.printf("✗ %s 失败:期望抛出 %s,但没有抛出%n",
                            method.getName(), test.expected().getSimpleName());
                    failed++;
                } else {
                    System.out.printf("✓ %s 通过%n", method.getName());
                    passed++;
                }

            } catch (InvocationTargetException e) {
                // ⑥ 注意:反射调用抛出的异常被包在 InvocationTargetException 里
                Throwable cause = e.getCause();

                if (test.expected().isInstance(cause)) {   // ⑦ 抛出了期望的异常 → 通过
                    System.out.printf("✓ %s 通过(如期抛出 %s)%n",
                            method.getName(), cause.getClass().getSimpleName());
                    passed++;
                } else {
                    System.out.printf("✗ %s 失败:%s%n",
                            method.getName(), cause.getMessage());
                    failed++;
                }
            }
        }

        System.out.printf("%n共 %d 个用例,通过 %d,失败 %d,耗时 %dms%n",
                passed + failed, passed, failed, System.currentTimeMillis() - start);
    }
}

⑥ 为什么要 e.getCause()method.invoke() 内部捕获目标方法抛出的一切异常,包装成 InvocationTargetException 再抛出。不解包的话,你拿到的永远是这个包装类,看不到真正的失败原因。这是反射最容易踩的坑之一。

第 4 步:写测试类验证

java
public class CalculatorTest {

    private final Calculator calc = new Calculator();

    @Test
    public void testAdd() {
        Assert.assertEquals(5, calc.add(2, 3));
    }

    @Test
    public void testSubtract() {
        Assert.assertEquals(1, calc.subtract(3, 2));
    }

    @Test(expected = ArithmeticException.class)
    public void testDivideByZero() {
        calc.divide(1, 0);                       // 期望抛异常
    }

    @Test
    public void testWillFail() {
        Assert.assertEquals(100, calc.add(1, 1));   // 故意失败
    }

    public static void main(String[] args) throws Exception {
        MiniJUnit.run(CalculatorTest.class);
    }
}

运行输出

✓ testAdd 通过
✓ testSubtract 通过
✓ testDivideByZero 通过(如期抛出 ArithmeticException)
✗ testWillFail 失败:期望: 100,实际: 2

共 4 个用例,通过 3,失败 1,耗时 12ms

真实的 JUnit 5 就是这个思路的工业级版本,额外加上了:@BeforeEach/@AfterEach 生命周期、参数化测试、并行执行、扩展模型、IDE 集成协议等。

七、注解处理的两种时机

时机技术代表
编译期APT(Annotation Processor)Lombok、MapStruct、Dagger
运行期反射Spring、MyBatis、JUnit

Lombok 的 @Data 是编译期直接改语法树生成 getter/setter,所以运行时没有任何性能开销

八、本章小结

要点关键
注解本质元数据标签,本身不做事
@Retention自定义注解必须 RUNTIME
@Target限制贴在哪
value()唯一属性时可省略属性名
读取注解靠反射 getAnnotation()
invoke 异常必须 getCause() 解包

动手练习

练习 1:基础题

给 MiniJUnit 增加 @BeforeEach / @AfterEach 支持:每个测试方法执行前后自动调用。

练习 2:进阶题

实现 @Cacheable(key = "user:#id") 注解 + 动态代理,把方法返回值缓存到 Map 中,第二次调用直接返回缓存。


下一章第 36 章:Lambda 表达式

本站基于 VitePress 构建 · 由 Codebook 团队维护