Spring Boot自定义注解实战:从原理到AOP防重、脱敏与日志
- 发布时间:2025-12-29 20:56:26
- 本文热度:浏览 11 赞 0 评论 0
- 文章标签: SpringBoot Java AOP
- 全文共1字,阅读约需1分钟
在 Java 开发领域,尤其是使用 Spring Boot 框架进行企业级应用构建时,注解(Annotation) 是一种极具魔力的技术。它就像给代码贴上的“便利贴”,不仅能标识代码的某种特征,配合 AOP(面向切面编程)更能实现业务逻辑的解耦。
很多初学者对注解的理解仅停留在“使用”层面,比如 @Controller、@Service、@Autowired。但作为一名进阶工程师,能够自定义注解并结合 AOP 解决实际业务中的通用问题(如日志记录、权限校验、防重提交、接口限流等),是迈向高级开发者的必经之路。
本文将从零开始,深入剖析 Java 注解的底层原理,结合 Spring AOP,通过三个高频实战案例,带你彻底掌握 Spring Boot 自定义注解的开发与应用。
第一章:拨云见日——透视 Java 注解的本质
1.1 什么是注解?
在 Java 中,注解(Annotation)本质上是一种元数据(Metadata)。 打个比方:如果代码是“商品”,那么注解就是贴在商品上的“标签”。
- 商品本身(类、方法、字段)实现了具体功能。
- 标签(注解)说明了商品的属性(例如:保质期、注意事项、特价商品)。
注解本身不直接影响代码的逻辑执行。如果没有代码去“读取”并“处理”这些标签,它们就没有任何意义。这就引出了注解生效的两种主要方式:
- 编译期扫描:编译器(javac)在编译时处理,例如
@Override检查方法签名,Lombok 的@Data生成 Getter/Setter。 - 运行期反射:程序运行时通过反射机制读取注解信息,并执行相应逻辑。这是 Spring Boot 中最常见的模式。
1.2 打造工具的工具——元注解
要自定义一个注解,首先需要了解“元注解”(Meta-Annotation)。元注解是“修饰注解的注解”,用于定义我们自己注解的行为。
JDK 提供了 4 个最核心的元注解,必须熟记:
| 元注解 | 作用 | 常用选项 | 解释 |
|---|---|---|---|
| @Target | 限制注解能用在哪里 | ElementType.METHODElementType.TYPEElementType.FIELD |
指定该注解是贴在方法上、类上,还是字段上。 |
| @Retention | 指定注解保留到什么时候 | RetentionPolicy.RUNTIMERetentionPolicy.SOURCE |
RUNTIME:运行时可通过反射获取(最常用); SOURCE:仅在源码中存在,编译后丢弃。 |
| @Documented | 是否包含在 JavaDoc 中 | 无 | 标记性元注解,生成文档时包含该注解信息。 |
| @Inherited | 是否允许子类继承 | 无 | 如果父类有了这个注解,子类是否默认也拥有。 |
核心提示:在 Spring Boot 自定义注解实战中,99% 的情况我们需要将
@Retention设置为RUNTIME,因为 AOP 需要在程序运行时读取注解来增强逻辑。
第二章:灵魂伴侣——Spring AOP 核心概念
光有注解(标签)是不够的,我们需要一个“扫描枪”来识别标签并触发动作。在 Spring Boot 中,这个扫描枪就是 AspectJ(AOP)。
为了让小白也能看懂,我们通过一个生活场景来理解 AOP 的术语:
- 连接点(JoinPoint):程序执行过程中的任意一个点(比如方法调用时、抛出异常时)。
- 比喻:你每天经过的所有路口。
- 切点(Pointcut):匹配连接点的表达式,定义了哪些连接点需要被处理。
- 比喻:只有红绿灯的路口(我们要在这里做动作)。
- 通知(Advice):在切点处执行的代码逻辑(前置、后置、环绕)。
- 比喻:在红绿灯路口,你需要“停车”或“观察”。
- 切面(Aspect):切点 + 通知。
- 比喻:在这个特定路口停车观察的完整规则。
Spring AOP 通知的执行顺序
了解执行顺序对于处理事务或异常至关重要:
@Around(环绕通知 - 开始)@Before(前置通知)- 执行目标方法
@AfterReturning(返回通知 - 只有成功才执行) 或@AfterThrowing(异常通知)@After(后置通知 - 无论成功失败都执行)@Around(环绕通知 - 结束)
第三章:实战案例 I —— 接口访问日志记录(入门篇)
在微服务架构中,记录每个接口的入参、出参、耗时和操作人是必不可少的。如果每个 Controller 方法里都写一遍日志代码,简直是灾难。
我们使用自定义注解 @LogRecord 来解决这个问题。
3.1 第一步:引入依赖
在 pom.xml 中引入 AOP 依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.43</version>
</dependency>
3.2 第二步:定义注解
我们创建一个名为 LogRecord 的注解,包含一个 description 属性用于描述接口功能。
package com.example.demo.annotation;
import java.lang.annotation.*;
/**
* 自定义操作日志注解
*/
@Target(ElementType.METHOD) // 作用于方法上
@Retention(RetentionPolicy.RUNTIME) // 运行时有效
@Documented
public @interface LogRecord {
/**
* 接口描述
*/
String value() default "";
}
3.3 第三步:编写切面处理逻辑
这是最关键的一步。我们创建一个切面类 LogAspect。
package com.example.demo.aspect;
import com.alibaba.fastjson2.JSON;
import com.example.demo.annotation.LogRecord;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
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.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
@Aspect // 标记为切面类
@Component // 交给 Spring 管理
@Slf4j
public class LogAspect {
// 定义切点:所有被 @LogRecord 注解修饰的方法
@Pointcut("@annotation(com.example.demo.annotation.LogRecord)")
public void logPointCut() {
}
/**
* 环绕通知:最强大的通知类型,可以控制目标方法的执行
*/
@Around("logPointCut()")
public Object around(ProceedingJoinPoint point) throws Throwable {
long startTime = System.currentTimeMillis();
// 执行目标方法,result 是接口的返回值
Object result = point.proceed();
long timeCost = System.currentTimeMillis() - startTime;
// 异步记录日志(实际生产中建议使用线程池或消息队列,避免阻塞主线程)
saveLog(point, result, timeCost);
return result;
}
private void saveLog(ProceedingJoinPoint point, Object result, long timeCost) {
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
LogRecord logRecord = method.getAnnotation(LogRecord.class);
String description = logRecord != null ? logRecord.value() : "";
// 获取请求信息
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
log.info("===========================================");
log.info("接口描述 : {}", description);
log.info("请求 URL : {}", request.getRequestURL().toString());
log.info("请求方式 : {}", request.getMethod());
log.info("请求方法 : {}.{}", signature.getDeclaringTypeName(), signature.getName());
log.info("请求参数 : {}", JSON.toJSONString(point.getArgs())); // 注意:文件上传流不可序列化,需特殊处理
log.info("返回结果 : {}", JSON.toJSONString(result));
log.info("执行耗时 : {} ms", timeCost);
log.info("===========================================");
}
}
3.4 第四步:测试应用
在 Controller 中使用:
@RestController
@RequestMapping("/user")
public class UserController {
@LogRecord("获取用户信息详情")
@GetMapping("/{id}")
public String getUser(@PathVariable("id") Integer id) {
// 模拟业务逻辑
return "User Data: " + id;
}
}
效果: 当你访问接口时,控制台会打印出整齐划一的请求日志,且业务代码中没有任何日志逻辑。
第四章:实战案例 II —— 接口防重提交(进阶篇)
在网络波动或用户快速点击时,可能会导致同一请求发送多次(例如重复下单)。我们需要一个机制:在指定时间内,相同的参数禁止重复提交。
这个场景需要结合 Redis 来实现。
4.1 技术原理
- Key 的生成规则:
固定前缀+用户ID/Token+请求URL+请求参数的 MD5 值。 - 锁的机制:使用 Redis 的
setIfAbsent(SETNX),设置过期时间(例如 5 秒)。 - 逻辑:
- 请求进来,尝试 SETNX。
- 成功:说明是第一次请求,放行。
- 失败:说明 Key 已存在且未过期,视为重复提交,抛出异常或返回错误提示。
4.2 定义注解 @NoRepeatSubmit
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface NoRepeatSubmit {
/**
* 锁过期时间,默认 5000 毫秒
*/
long timeout() default 5000;
}
4.3 编写防重切面
这里假设你已经配置好了 StringRedisTemplate。
@Aspect
@Component
@Slf4j
public class NoRepeatSubmitAspect {
@Autowired
private StringRedisTemplate redisTemplate;
@Pointcut("@annotation(com.example.demo.annotation.NoRepeatSubmit)")
public void pointCut() {}
@Around("pointCut()")
public Object around(ProceedingJoinPoint point) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
// 1. 获取注解信息
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
NoRepeatSubmit annotation = method.getAnnotation(NoRepeatSubmit.class);
// 2. 构建 Redis Key (这里简化处理,实际项目中建议包含 UserID)
// Key 格式: repeat:submit:全类名:方法名:参数MD5
String className = signature.getDeclaringTypeName();
String methodName = signature.getName();
String params = JSON.toJSONString(point.getArgs());
String key = "repeat:submit:" + className + ":" + methodName + ":" + params.hashCode();
// 3. 尝试加锁
// SET key value PX milliseconds NX
Boolean isSuccess = redisTemplate.opsForValue().setIfAbsent(
key,
"1",
annotation.timeout(),
TimeUnit.MILLISECONDS
);
if (isSuccess != null && isSuccess) {
// 加锁成功,执行业务
return point.proceed();
} else {
// 加锁失败,抛出自定义异常,由全局异常处理器捕获返回给前端
throw new RuntimeException("请勿频繁重复提交,请稍后再试!");
}
}
}
注意:参数的 MD5 或者是 HashCode 可能会有冲突,但对于防重场景通常可接受。严格场景下建议对 JSON 字符串进行 MD5 加密。此外,如果参数包含 HttpServletRequest/Response 对象,序列化会报错,需要过滤掉这些参数。
第五章:实战案例 III —— 敏感数据脱敏(高级篇)
这是一个特殊的场景。我们希望在返回 JSON 给前端时,自动将手机号、身份证号中间几位变成 *。 这通常不使用 AOP(因为 AOP 修改返回值比较麻烦,需要递归遍历对象),而是使用 Jackson 的序列化器 配合注解。这展示了注解的另一种用法。
5.1 定义注解 @Desensitize
@Target(ElementType.FIELD) // 作用在字段上
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside // Jackson 的元注解
@JsonSerialize(using = DesensitizeSerializer.class) // 指定自定义序列化器
public @interface Desensitize {
/**
* 脱敏策略(枚举:手机号、身份证、邮箱等)
*/
DesensitizeType type();
}
5.2 自定义序列化器
public class DesensitizeSerializer extends JsonSerializer<String> implements ContextualSerializer {
private DesensitizeType type;
@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
if (value == null || value.isEmpty()) {
gen.writeString("");
return;
}
// 根据 type 进行具体的字符串替换逻辑
String result = DesensitizeUtil.desensitize(value, type);
gen.writeString(result);
}
/**
* 这一步是为了获取字段上的注解信息
*/
@Override
public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) {
if (property != null) {
Desensitize annotation = property.getAnnotation(Desensitize.class);
if (annotation != null) {
this.type = annotation.type();
return this;
}
}
return prov.findNullValueSerializer(property);
}
}
5.3 使用方式
public class UserVO {
private String username;
@Desensitize(type = DesensitizeType.MOBILE)
private String mobile; // 输出时变成 138****1234
}
第六章:避坑指南与最佳实践
在实战中使用自定义注解和 AOP,有几个“深坑”必须注意:
6.1 内部调用失效(最经典的问题)
现象:
@Service
public class OrderService {
public void createOrder() {
// 这是一个普通方法,调用了内部带注解的方法
this.checkStock();
}
@LogRecord("检查库存")
public void checkStock() {
// ...
}
}
结果:createOrder 调用 checkStock 时,@LogRecord 不会生效。
原因:Spring AOP 基于动态代理(CGLIB 或 JDK 代理)。当你使用 this.checkStock() 时,调用的是对象本身的方法,而不是代理对象的方法,因此跳过了切面逻辑。
解决方案:
- 注入自身(推荐):
@Autowired @Lazy // 避免循环依赖 private OrderService self; public void createOrder() { self.checkStock(); // 通过代理对象调用 } - 使用 AopContext:
((OrderService) AopContext.currentProxy()).checkStock();
6.2 多个切面的执行顺序
如果一个方法上同时加了 @LogRecord 和 @Transaction,谁先执行?
- 默认情况顺序是不确定的。
- 使用
@Order(int)注解控制切面顺序。数字越小,优先级越高(越先进入 Before,越后退出 After)。 - 通常建议:事务切面应该在最内层,日志/监控切面在最外层。
6.3 性能损耗
虽然 AOP 很方便,但大量使用反射和动态代理会有微小的性能损耗(纳秒级)。
- 对于超高并发(如 QPS > 10万)的核心链路,尽量减少复杂的切面逻辑。
- 尽量缩小切点的范围,不要写成
execution(* com.example..*.*(..))扫描整个项目。
第七章:总结
Spring Boot 自定义注解 + AOP 是一套极具扩展性的组合拳。
- 定义:通过
@Interface定义元数据。 - 解析:通过 AOP 切面在运行时拦截方法,提取注解信息。
- 逻辑:在
Around、Before等通知中实现非业务核心功能的解耦。
掌握这项技术,你不仅能写出更优雅的代码,还能在遇到诸如“全局权限控制”、“动态数据源切换”、“分布式锁”等复杂需求时,从容地设计出通用的解决方案。希望本文的实战案例能成为你架构工具箱中的利器。