第 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 |
CLASS | class 文件,运行时不加载(默认) | 字节码增强工具 |
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() { }属性类型限制:只能是基本类型、String、Class、枚举、注解,以及它们的数组。
五、组合注解
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 表达式 →