心跳项目从第一篇开始就在使用 @SpringBootApplication、@RestController 和 @GetMapping。这些注解不会像普通方法一样自己执行代码,它们只是把额外信息附加到类或方法上。Spring 在启动和请求处理阶段读取这些信息,再决定注册组件、映射路径或创建代理。
要理解这个过程,需要先掌握 Java 注解与反射的边界。本章只观察 JDK 提供的机制;下一篇再看 Spring 容器怎样利用这些机制管理对象。
1. 注解
注解是一种结构化元数据,可以标记类、方法、字段、参数等程序元素:
1@RestController2public class HealthController {34@GetMapping("/api/v1/health")5public HealthResponse health() {6return HealthResponse.success("UP", "spring");7}8}
这里的两个注解表达不同信息:
| 注解 | 标记位置 | 表达的信息 |
|---|---|---|
@RestController | 类 | 这个类型承担 REST 控制器职责 |
@GetMapping | 方法 | 这个方法处理指定路径的 GET 请求 |
注解本身不会创建 HealthController,也不会监听端口。真正执行这些工作的仍然是普通 Java 对象和框架代码。注解只提供框架可以读取的声明。
注解可以包含属性。@GetMapping("/api/v1/health") 是 @GetMapping(path = "/api/v1/health") 的简写,因为该注解的 value 与 path 互为别名。不同注解的属性由各自类型定义,不能假设所有注解都支持相同名称。
2. 自定义注解
使用 @interface 可以定义注解类型。下面创建一个只负责标记心跳操作的注解:
01package cn.usehook.spring.basics;0203import java.lang.annotation.Documented;04import java.lang.annotation.ElementType;05import java.lang.annotation.Retention;06import java.lang.annotation.RetentionPolicy;07import java.lang.annotation.Target;0809@Documented10@Target(ElementType.METHOD)11@Retention(RetentionPolicy.RUNTIME)12public @interface HealthOperation {1314String value() default "health-check";15}
标记注解定义的注解叫元注解:
| 元注解 | 当前作用 |
|---|---|
@Target(METHOD) | 只允许标记方法 |
@Retention(RUNTIME) | 注解保留到运行期,可以被反射读取 |
@Documented | 生成 API 文档时包含这个注解 |
保留策略还包括 SOURCE 和 CLASS。SOURCE 只存在于源码,编译后丢弃;CLASS 会写入 class 文件,但默认不能通过运行时反射读取;Spring 需要在应用运行时识别的注解通常使用 RUNTIME。
注解属性只能使用 Java 允许的有限类型,例如基本类型、String、Class、枚举、注解以及这些类型的数组。属性不能保存任意运行时对象,默认值也必须是编译期可以写进注解的值。
现在创建一个独立练习类并标记它的方法:
1package cn.usehook.spring.basics;23public class HealthProbe {45@HealthOperation("default-health-check")6public String check() {7return "UP";8}9}
给方法添加这个注解不会直接改变方法行为。只有其他代码主动查找并解释 @HealthOperation,它才会参与程序逻辑。
3. Class 对象
JVM 为加载的每种类型维护一个 Class 对象,它描述类型名称、修饰符、构造方法、字段、方法和注解等信息:
1Class<HealthService> type = HealthService.class;23System.out.println(type.getName());4System.out.println(type.getSimpleName());
HealthService.class 不会创建 HealthService 实例,它只取得描述这个类型的对象。已有对象也可以通过 getClass() 取得实际运行类型:
1Object service = "spring";2Class<?> runtimeType = service.getClass();
变量的声明类型是 Object,运行对象的类型是 String。反射观察的是实际传入的 Class 对象,因此框架可以在不知道具体实现源码的情况下检查运行类型。
Class.forName("cn.usehook.spring.health.HealthService") 也能按完整类名加载类型,但字符串拼写错误只能在运行时发现。源码中已经知道类型时,优先使用 HealthService.class,可以获得编译器检查和重构支持。
4. 反射读取
反射 API 允许程序在运行时检查类型。下面读取所有带 @HealthOperation 的公开方法:
01package cn.usehook.spring.basics;0203import java.lang.reflect.Method;0405public class AnnotationInspector {0607public static void main(String[] args) {08for (Method method : HealthProbe.class.getDeclaredMethods()) {09HealthOperation operation = method.getAnnotation(HealthOperation.class);1011if (operation != null) {12System.out.println(method.getName() + ": " + operation.value());13}14}15}16}
getDeclaredMethods() 返回当前类声明的方法,getAnnotation() 在方法上查找指定运行时注解。找不到时返回 null,所以读取属性前要先检查结果。
反射还可以取得构造方法并创建对象,也可以读取或调用方法。不过,这不会消除正常的 Java 规则:构造参数仍要满足类型要求,调用仍可能抛出异常,访问非公开成员还涉及更严格的封装和模块边界。
框架使用反射是因为它需要处理应用提供的许多未知类型。普通业务代码如果在编译时已经知道要调用哪个方法,直接调用通常更清楚,也能获得完整类型检查,不应该为了显得通用而改成反射。
5. Spring 注解
Spring 会在不同阶段读取不同注解:
01应用启动02-> 扫描 @Component、@Service、@RestController03-> 读取 @Configuration 与 @Bean04-> 创建 Bean 并解析构造参数0506请求到达07-> 查找 @GetMapping、@PostMapping 等映射08-> 根据参数注解解析请求数据0910方法调用11-> 代理读取事务、缓存或自定义切点元数据
同一个注解只在理解它的处理器存在时有效。例如,单独创建一个带 @RestController 的对象不会让它成为 HTTP 控制器;它必须进入 Spring 应用上下文,MVC 基础设施也必须存在。
Spring 还支持组合注解。@RestController 自身组合了 @Controller 与 @ResponseBody,@SpringBootApplication 则组合了配置、自动配置和组件扫描相关能力。组合注解让一组经常一起使用的元数据拥有更明确的业务名称。
注解、反射和代理不是同一件事。注解保存元数据,反射负责检查类型结构,代理则可以包围对象的方法调用。Spring 会按具体功能组合这些机制,但学习时要先分清各自职责。
6. 使用边界
注解适合表达横跨许多类型且结构稳定的声明,例如组件角色、请求映射、事务边界和校验约束。普通业务分支仍应写成可以直接阅读和测试的 Java 方法,不能把所有规则隐藏在自定义注解中。
自定义注解至少需要回答三个问题:
- 允许标记哪些程序元素;
- 保留到哪个阶段;
- 由哪段代码在什么时候读取并执行相应行为。
缺少第三项的运行时注解只是没有消费者的元数据。反过来,框架能够通过类型、接口或配置文件明确完成的功能,也不一定必须再发明一个注解。
basics 包中的自定义注解、练习类型和检查器只用于观察 Java 机制,不是心跳接口运行所需的生产组件。完成练习后可以保留它们供复习,也可以删除,不会影响 Controller 和 Service。
到这里,已经可以准确理解「Spring 扫描注解」这句话:Spring 读取类上的运行时元数据,但真正需要管理的仍然是对象以及对象之间的依赖。下一篇从手动 new 出发,建立对象依赖图,再让 IoC 容器接管创建与组装。