Spring Boot - 自定义注解
# Spring Boot - 自定义注解
前言
注解 (Annotation) 是 Java 语言的一大特色,自 JDK 1.5 引入以来,极大地简化了配置和元数据处理。Spring Boot 框架本身就大量使用了注解(如 @RestController
, @Autowired
, @Value
等)来简化开发。
除了使用框架提供的注解,我们还可以创建自定义注解来封装特定的业务逻辑、标记代码元素、实现横切关注点(如日志记录、权限校验、事务管理等),从而使代码更加简洁、可读、易于维护,并实现更高级别的抽象。
# 1. 什么是注解 (Annotation)?
注解本质上是一种元数据 (Metadata),它为代码(类、方法、字段、参数等)添加额外的信息。这些信息可以在编译时被编译器读取和处理(例如 Lombok),也可以在运行时被 JVM 或框架(如 Spring)通过反射 (Reflection) 读取和使用。
注解本身不直接执行任何逻辑,它更像是一个标记或标签。需要有相应的处理器 (Processor) 来读取这些注解,并根据注解提供的信息执行特定的操作。
# 2. 为什么需要自定义注解?
虽然 Spring Boot 和其他库提供了丰富的注解,但在实际项目中,我们常常会遇到一些重复的模式或需要跨多个地方应用的通用逻辑。自定义注解可以:
- 减少样板代码: 将通用的前置/后置处理逻辑(如日志记录、权限检查、参数校验、缓存处理)封装起来,通过一个简单的注解应用到需要的地方。
- 提高代码可读性与声明性: 用一个具有明确语义的注解(如
@RequiresPermission("USER_READ")
)代替复杂的if/else
或重复的方法调用,使代码意图更清晰。 - 实现标记与分类: 为特定的类、方法或字段打上标记,方便后续通过反射或 AOP 进行统一处理(例如标记需要发布事件的方法)。
- 框架扩展与集成: 创建用于特定领域或自定义框架的注解,提供更贴合业务的配置方式。
- 强制约定: 通过注解强制开发者遵循某些编码规范或设计模式。
# 3. Java 注解的核心概念
在创建自定义注解之前,需要了解几个 Java 注解相关的核心概念:
元注解 (Meta-Annotation): 用于注解其他注解的注解。它们定义了自定义注解的行为和特性。最常用的元注解包括:
@Target
: 指定注解可以应用于哪些程序元素(类、方法、字段、参数等)。@Retention
: 指定注解的生命周期(源代码、编译期、运行时)。@Documented
: 指定注解信息是否会被包含在 Javadoc 文档中。@Inherited
: 指定注解是否可以被子类继承。
注解处理器 (Annotation Processor): 读取并处理注解的程序。可以是编译时处理器(如 Lombok),也可以是运行时处理器(通常利用反射或 AOP 实现)。
反射 (Reflection): Java 提供的在运行时检查和操作类、方法、字段等内部结构的能力。是运行时处理注解的基础。
AOP (Aspect-Oriented Programming): 面向切面编程。Spring AOP 允许我们将横切关注点(如日志、安全、事务)从核心业务逻辑中分离出来,形成独立的“切面 (Aspect)”。AOP 是在 Spring Boot 中处理自定义注解以实现特定功能最常用、最强大的方式。
# 4. 创建自定义注解
定义一个自定义注解使用 @interface
关键字。
# 4.1 定义注解接口
package com.example.myapp.annotation; // 建议将注解放在专门的包下
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 自定义注解:用于记录方法的执行时间。
*
* @interface 关键字用于声明一个注解类型。
*/
// --- 元注解配置 ---
// @Retention: 定义注解的保留策略(生命周期)
// - RetentionPolicy.SOURCE: 注解只在源代码中保留,编译时会被丢弃。 (例如 @Override, Lombok 注解)
// - RetentionPolicy.CLASS: 注解在编译后的 .class 文件中保留,但在运行时 JVM 不可见。(默认值,较少使用)
// - RetentionPolicy.RUNTIME: 注解在运行时可以通过反射访问。 **这是最常用的策略,特别是需要 AOP 或反射处理时。**
@Retention(RetentionPolicy.RUNTIME)
// @Target: 定义注解可以应用于哪些程序元素
// - ElementType.TYPE: 类、接口、枚举
// - ElementType.FIELD: 字段(成员变量)
// - ElementType.METHOD: 方法
// - ElementType.PARAMETER: 方法参数
// - ElementType.CONSTRUCTOR: 构造器
// - ElementType.LOCAL_VARIABLE: 局部变量
// - ElementType.ANNOTATION_TYPE: 应用于其他注解
// - ElementType.PACKAGE: 包
// 可以指定多个目标,例如 @Target({ElementType.METHOD, ElementType.TYPE})
@Target(ElementType.METHOD) // 此注解仅能用于方法上
// @Documented: 标记此注解应该被 javadoc 等文档工具记录。
@Documented
// @Inherited: 标记此注解可以被子类继承。
// 注意:仅对 @Target(ElementType.TYPE) 的注解有效,且只继承父类的注解,不继承接口的。
// 对于方法注解,继承通常通过 AOP 的 pointcut 表达式控制,而不是 @Inherited。
@Inherited // 意味着如果父类方法使用了此注解,子类重写该方法时也会继承此注解(AOP 可能需要额外配置来识别)
public @interface LogExecutionTime {
// --- 注解属性 (可选) ---
// 注解可以包含属性(成员变量),用于在使用注解时传递参数。
// 属性定义格式: 类型 属性名() [default 默认值];
/**
* 可选属性:用于描述此计时操作的业务含义,默认为空字符串。
*/
String description() default "";
/**
* 可选属性:设置一个执行时间阈值(毫秒),超过此阈值才记录日志,默认为 0 (总是记录)。
*/
long thresholdMillis() default 0;
/**
* 可选属性:指定时间单位,用于日志输出,默认为毫秒。
* (这里只是示例,实际处理逻辑在 Aspect 中)
*/
TimeUnit timeUnit() default TimeUnit.MILLISECONDS; // 假设 TimeUnit 是一个枚举
// 如果只有一个属性,通常命名为 value(),这样在使用注解时可以省略属性名:@LogExecutionTime("核心操作")
// String value() default "";
}
// 假设的 TimeUnit 枚举
// enum TimeUnit { MILLISECONDS, SECONDS }
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
关键解释:
@interface LogExecutionTime
: 定义了一个名为LogExecutionTime
的注解。@Retention(RetentionPolicy.RUNTIME)
: 至关重要。确保注解在运行时可见,这样 AOP 或反射才能读取到它。@Target(ElementType.METHOD)
: 规定此注解只能用于标记方法。description()
,thresholdMillis()
,timeUnit()
: 定义了注解的三个属性(或称参数、成员)。- 属性的类型可以是基本类型、String、Class、枚举、注解,以及这些类型的一维数组。
- 可以使用
default
关键字为属性指定默认值。如果属性没有默认值,则在使用注解时必须为其赋值。
# 4.2 使用自定义注解
定义好注解后,就可以在代码中使用了:
import com.example.myapp.annotation.LogExecutionTime;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class ProductService {
// 使用自定义注解,并为属性赋值
@LogExecutionTime(description = "获取产品详情", thresholdMillis = 100) // 应用注解到方法上
public Product getProductDetails(String productId) throws InterruptedException {
System.out.println("正在查询产品详情: " + productId);
// 模拟耗时操作
TimeUnit.MILLISECONDS.sleep(150);
System.out.println("产品详情查询完毕.");
return new Product(productId, "Sample Product", 99.99);
}
// 使用注解,只使用默认值
@LogExecutionTime // description="", thresholdMillis=0, timeUnit=MILLISECONDS
public void updateProductStock(String productId, int quantity) throws InterruptedException {
System.out.println("正在更新产品库存: " + productId + ", 数量: " + quantity);
TimeUnit.MILLISECONDS.sleep(50);
System.out.println("库存更新完成.");
}
// 假设的 Product 类
static class Product {
String id; String name; double price;
Product(String id, String name, double price) { this.id=id; this.name=name; this.price=price; }
// toString, getters ...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
至此,我们已经定义并使用了自定义注解。但现在这个注解还只是一个标记,没有任何实际功能。接下来我们需要编写代码来处理这个注解。
# 5. 处理自定义注解:赋予注解生命
让注解“活”起来,即根据注解执行特定逻辑,最常用且最符合 Spring 设计思想的方式是使用 AOP (Aspect-Oriented Programming)。
# 5.1 引入 AOP 依赖
首先,确保你的 Spring Boot 项目包含了 AOP 的 starter 依赖。通常 spring-boot-starter-web
会间接引入它,但显式添加更清晰:
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
2
3
4
5
或者 Gradle:
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-aop'
2
# 5.2 创建切面 (Aspect)
切面是一个普通的 Spring Bean,它包含了处理注解逻辑的代码。需要使用 @Aspect
和 @Component
(或其他 Spring Bean 注解) 来标记。
package com.example.myapp.aspect; // 建议将 Aspect 放在专门的包下
import com.example.myapp.annotation.LogExecutionTime; // 引入自定义注解
import org.aspectj.lang.ProceedingJoinPoint; // AOP 连接点接口
import org.aspectj.lang.annotation.Around; // 环绕通知注解
import org.aspectj.lang.annotation.Aspect; // 标记为切面类
import org.aspectj.lang.annotation.Pointcut; // 定义切点
import org.aspectj.lang.reflect.MethodSignature; // 获取方法签名信息
import org.slf4j.Logger; // 使用 SLF4j 进行日志记录
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component; // 标记为 Spring Bean
import org.springframework.util.StopWatch; // Spring 提供的计时工具
import java.lang.reflect.Method;
/**
* 处理 @LogExecutionTime 注解的切面类。
*/
@Aspect // 声明这是一个切面
@Component // 将其声明为 Spring Bean,以便被 Spring 容器管理和扫描
public class LogExecutionTimeAspect {
// 获取日志记录器
private static final Logger logger = LoggerFactory.getLogger(LogExecutionTimeAspect.class);
// 1. 定义切点 (Pointcut)
// 切点表达式用于指定哪些方法调用会被此切面拦截。
// execution(* com.example.myapp.service..*.*(..)) : 拦截 service 包下所有类的所有方法 (不推荐,范围太广)
// @annotation(注解的全限定名) : 拦截被指定注解标记的方法。这是处理自定义注解最常用的方式。
@Pointcut("@annotation(com.example.myapp.annotation.LogExecutionTime)") // 定义一个切点,匹配所有使用 @LogExecutionTime 注解的方法
public void logExecutionTimePointcut() {
// 这个方法体是空的,它仅用于承载 @Pointcut 注解,方便在通知中引用
}
// 2. 定义通知 (Advice)
// 通知定义了在切点(匹配的方法)执行的什么时机(之前、之后、环绕等)执行什么操作。
// @Around: 环绕通知,是最强大的通知类型,可以在目标方法执行前后自定义逻辑,甚至阻止目标方法执行。
// 参数 value 指定了此通知关联的切点(这里引用了上面定义的 pointcut 方法名)。
@Around("logExecutionTimePointcut()")
public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
// ProceedingJoinPoint 包含了被拦截方法的所有信息
// 获取方法签名
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
// 获取方法上的 @LogExecutionTime 注解实例
LogExecutionTime logAnnotation = method.getAnnotation(LogExecutionTime.class);
// 获取注解属性值
String description = logAnnotation.description();
long threshold = logAnnotation.thresholdMillis();
// TimeUnit timeUnit = logAnnotation.timeUnit(); // 如果需要使用时间单位
// 创建计时器 (可以使用 Spring 的 StopWatch 或 System.nanoTime())
StopWatch stopWatch = new StopWatch(method.getName());
Object result = null;
try {
// 开始计时
stopWatch.start();
// 执行目标方法
// !!! 必须调用 joinPoint.proceed() 来执行原始方法 !!!
result = joinPoint.proceed();
} finally { // 确保即使方法抛出异常,计时也能结束并记录
// 停止计时
stopWatch.stop();
long executionTime = stopWatch.getTotalTimeMillis(); // 获取执行时间(毫秒)
// 根据注解配置的阈值决定是否记录日志
if (executionTime >= threshold) {
// 构建日志消息
String logMessage = String.format("方法执行耗时: [%d ms] - [%s#%s]",
executionTime,
method.getDeclaringClass().getSimpleName(), // 类名
method.getName() // 方法名
);
// 如果注解中定义了 description,则添加到日志消息中
if (description != null && !description.isEmpty()) {
logMessage += " - " + description;
}
// 可以根据执行时间或其他条件选择日志级别
if (executionTime > 1000) { // 假设超过 1 秒算慢,用 WARN 级别
logger.warn(logMessage);
} else {
logger.info(logMessage);
}
}
}
// 返回目标方法的执行结果
return result;
}
// 其他类型的通知 (可选):
// @Before("logExecutionTimePointcut()")
// public void beforeExecution(JoinPoint joinPoint) { ... } // 方法执行前
// @AfterReturning(pointcut = "logExecutionTimePointcut()", returning = "result")
// public void afterReturning(JoinPoint joinPoint, Object result) { ... } // 方法正常返回后
// @AfterThrowing(pointcut = "logExecutionTimePointcut()", throwing = "e")
// public void afterThrowing(JoinPoint joinPoint, Throwable e) { ... } // 方法抛出异常后
// @After("logExecutionTimePointcut()")
// public void afterExecution(JoinPoint joinPoint) { ... } // 方法执行后 (无论正常返回还是异常)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
代码解释:
@Aspect
: 标记LogExecutionTimeAspect
是一个切面类。@Component
: 将切面类注册为 Spring Bean,使其被 Spring AOP 机制发现和管理。@Pointcut("@annotation(...)")
: 定义了一个切点,表达式@annotation(com.example.myapp.annotation.LogExecutionTime)
表示拦截所有被com.example.myapp.annotation.LogExecutionTime
注解标记的方法。请确保这里的包名和注解名与你实际定义的注解一致。@Around("logExecutionTimePointcut()")
: 定义了一个环绕通知,它关联到上面定义的切点。环绕通知方法logExecutionTime
会在匹配的方法执行前后执行。ProceedingJoinPoint joinPoint
: 这是环绕通知方法的必须参数。它代表了被拦截的方法本身,通过它可以获取方法信息(签名、参数等)并控制原方法的执行 (joinPoint.proceed()
)。MethodSignature signature = (MethodSignature) joinPoint.getSignature()
: 获取方法签名,可以从中得到Method
对象。Method method = signature.getMethod()
: 获取被拦截的Method
对象。method.getAnnotation(LogExecutionTime.class)
: 通过反射从Method
对象上获取LogExecutionTime
注解的实例。logAnnotation.description()
,logAnnotation.thresholdMillis()
: 从注解实例中读取属性值。StopWatch stopWatch = new StopWatch(...)
: 使用 Spring 的StopWatch
工具类进行计时。也可以使用System.nanoTime()
实现更精确的计时。result = joinPoint.proceed();
: 核心调用。执行原始被拦截的方法。必须调用它,否则原始方法不会执行。它的返回值就是原始方法的返回值。stopWatch.stop()
: 停止计时。stopWatch.getTotalTimeMillis()
: 获取总耗时。- 日志记录: 根据耗时和注解的
thresholdMillis
属性判断是否需要记录日志,并使用 SLF4jLogger
输出格式化的日志信息,包含了耗时、类名、方法名和注解的description
。 return result;
: 环绕通知必须返回joinPoint.proceed()
的结果,否则调用者将无法获得原始方法的返回值。
# 5.3 运行与测试
现在,当你运行 Spring Boot 应用并调用 ProductService
中被 @LogExecutionTime
注解标记的方法时,LogExecutionTimeAspect
中的环绕通知就会被触发,自动记录方法的执行时间。
// 控制台或日志文件可能输出类似信息:
INFO c.e.m.aspect.LogExecutionTimeAspect - 方法执行耗时: [152 ms] - [ProductService#getProductDetails] - 获取产品详情
INFO c.e.m.aspect.LogExecutionTimeAspect - 方法执行耗时: [51 ms] - [ProductService#updateProductStock]
2
3
# 6. 更多应用场景与进阶
# 6.1 不同目标的注解
除了方法 (ElementType.METHOD
),你还可以创建用于类 (TYPE
)、字段 (FIELD
)、参数 (PARAMETER
) 等的注解,并结合 AOP 或其他机制处理。
- 类注解: 可以用于标记某类服务需要特定的初始化、配置或代理。切点表达式可以使用
within(com.example.myapp.service..*) && @within(com.example.myapp.annotation.MyClassAnnotation)
。 - 字段注解: 可以用于标记需要特殊处理(如脱敏、加密)的字段,通常需要结合反射或自定义序列化器处理。
- 参数注解: 常用于标记需要特殊处理(如校验、注入特定值)的方法参数,可以结合 AOP 的
@Before
通知读取参数注解,或自定义HandlerMethodArgumentResolver
(Web 开发)。
# 6.2 传递注解值给处理器
在上面的 AOP 示例中,我们已经演示了如何在 Aspect 中通过 method.getAnnotation(...)
获取注解实例,并读取其属性值 (description
, thresholdMillis
),从而让注解的行为可以参数化。
# 6.3 组合注解 (Meta-Annotations)
你可以创建自己的注解,并用其他注解(包括 Spring 提供的注解和你的其他自定义注解)来注解它,形成组合注解,进一步简化配置。
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.lang.annotation.*;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Service // 组合了 @Service 注解
@Transactional // 组合了 @Transactional 注解 (整个服务类默认开启事务)
public @interface TransactionalService {
// 可以定义组合注解自己的属性
String value() default ""; // 继承 @Service 的 value 属性
}
// 使用组合注解
@TransactionalService // 等效于同时使用 @Service 和 @Transactional
public class OrderService {
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 7. 最佳实践与注意事项
- 明确目的与范围: 不要为了注解而注解。确保自定义注解解决了实际问题(如减少重复、提高可读性、实现横切逻辑)。清晰定义注解的作用范围 (
@Target
) 和生命周期 (@Retention
)。 - 命名清晰: 注解名称应清晰地反映其意图。
- 优先 AOP: 对于需要在方法执行前后或环绕执行逻辑的场景(日志、权限、事务、缓存、重试等),AOP 是处理自定义注解最自然、最强大的方式。
- 性能考虑: AOP 和反射都有一定的性能开销。虽然 Spring AOP 性能通常很好,但在极度性能敏感的代码路径上大量使用复杂的切面逻辑仍需谨慎评估。
- 避免过度使用: 过多的自定义注解和切面可能导致代码行为难以追踪和理解。保持适度。
- 文档: 为你的自定义注解编写清晰的 Javadoc 文档,说明其用途、属性含义和使用方法。
- 测试: 对处理注解的逻辑(尤其是 Aspect)编写单元测试或集成测试,确保其按预期工作。
提示
本文提供了一个创建和处理自定义注解(以方法执行时间日志为例)的完整流程。你可以根据自己的需求,创建不同功能(如权限校验 @RequiresPermission
、操作日志 @AuditLog
、缓存控制 @CacheResult
等)的自定义注解,并编写相应的 Aspect 来实现这些功能。