Spring Boot自定义注解实战:从原理到AOP防重、脱敏与日志

在 Java 开发领域,尤其是使用 Spring Boot 框架进行企业级应用构建时,注解(Annotation) 是一种极具魔力的技术。它就像给代码贴上的“便利贴”,不仅能标识代码的某种特征,配合 AOP(面向切面编程)更能实现业务逻辑的解耦。

很多初学者对注解的理解仅停留在“使用”层面,比如 @Controller@Service@Autowired。但作为一名进阶工程师,能够自定义注解并结合 AOP 解决实际业务中的通用问题(如日志记录、权限校验、防重提交、接口限流等),是迈向高级开发者的必经之路。

本文将从零开始,深入剖析 Java 注解的底层原理,结合 Spring AOP,通过三个高频实战案例,带你彻底掌握 Spring Boot 自定义注解的开发与应用。


第一章:拨云见日——透视 Java 注解的本质

1.1 什么是注解?

在 Java 中,注解(Annotation)本质上是一种元数据(Metadata)。 打个比方:如果代码是“商品”,那么注解就是贴在商品上的“标签”。

  • 商品本身(类、方法、字段)实现了具体功能。
  • 标签(注解)说明了商品的属性(例如:保质期、注意事项、特价商品)。

注解本身不直接影响代码的逻辑执行。如果没有代码去“读取”并“处理”这些标签,它们就没有任何意义。这就引出了注解生效的两种主要方式:

  1. 编译期扫描:编译器(javac)在编译时处理,例如 @Override 检查方法签名,Lombok 的 @Data 生成 Getter/Setter。
  2. 运行期反射:程序运行时通过反射机制读取注解信息,并执行相应逻辑。这是 Spring Boot 中最常见的模式。

1.2 打造工具的工具——元注解

要自定义一个注解,首先需要了解“元注解”(Meta-Annotation)。元注解是“修饰注解的注解”,用于定义我们自己注解的行为。

JDK 提供了 4 个最核心的元注解,必须熟记:

元注解 作用 常用选项 解释
@Target 限制注解能用在哪里 ElementType.METHOD
ElementType.TYPE
ElementType.FIELD
指定该注解是贴在方法上、类上,还是字段上。
@Retention 指定注解保留到什么时候 RetentionPolicy.RUNTIME
RetentionPolicy.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 通知的执行顺序

了解执行顺序对于处理事务或异常至关重要:

  1. @Around (环绕通知 - 开始)
  2. @Before (前置通知)
  3. 执行目标方法
  4. @AfterReturning (返回通知 - 只有成功才执行) 或 @AfterThrowing (异常通知)
  5. @After (后置通知 - 无论成功失败都执行)
  6. @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 技术原理

  1. Key 的生成规则固定前缀 + 用户ID/Token + 请求URL + 请求参数的 MD5 值
  2. 锁的机制:使用 Redis 的 setIfAbsent (SETNX),设置过期时间(例如 5 秒)。
  3. 逻辑
    • 请求进来,尝试 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() 时,调用的是对象本身的方法,而不是代理对象的方法,因此跳过了切面逻辑。

解决方案

  1. 注入自身(推荐):
    @Autowired
    @Lazy // 避免循环依赖
    private OrderService self;
    
    public void createOrder() {
        self.checkStock(); // 通过代理对象调用
    }
    
  2. 使用 AopContext
    ((OrderService) AopContext.currentProxy()).checkStock();
    

6.2 多个切面的执行顺序

如果一个方法上同时加了 @LogRecord@Transaction,谁先执行?

  • 默认情况顺序是不确定的。
  • 使用 @Order(int) 注解控制切面顺序。数字越小,优先级越高(越先进入 Before,越后退出 After)。
  • 通常建议:事务切面应该在最内层,日志/监控切面在最外层。

6.3 性能损耗

虽然 AOP 很方便,但大量使用反射和动态代理会有微小的性能损耗(纳秒级)。

  • 对于超高并发(如 QPS > 10万)的核心链路,尽量减少复杂的切面逻辑。
  • 尽量缩小切点的范围,不要写成 execution(* com.example..*.*(..)) 扫描整个项目。

第七章:总结

Spring Boot 自定义注解 + AOP 是一套极具扩展性的组合拳。

  1. 定义:通过 @Interface 定义元数据。
  2. 解析:通过 AOP 切面在运行时拦截方法,提取注解信息。
  3. 逻辑:在 AroundBefore 等通知中实现非业务核心功能的解耦。

掌握这项技术,你不仅能写出更优雅的代码,还能在遇到诸如“全局权限控制”、“动态数据源切换”、“分布式锁”等复杂需求时,从容地设计出通用的解决方案。希望本文的实战案例能成为你架构工具箱中的利器。

正文到此结束
评论插件初始化中...
Loading...
本文目录