在实际项目中,操作日志记录、权限校验、接口限流这三类需求几乎每个系统都会遇到。它们的共同点在于:属于横切关注点,散落在各个方法中会导致大量重复代码。本节通过自定义注解与 AOP 切面的组合,提炼出一套可复用的通用方案,让业务代码只需一个注解即可自动获得这些能力。
9.4.1 整体设计思路
方案的核心步骤分为三部分:
- 定义注解:声明元数据,例如操作类型、模块名称、需要的权限标识、限流阈值等。
- 编写切面:用
@Aspect声明切面,根据注解中的元数据执行具体的增强逻辑(记录日志、检查权限、令牌桶限流等)。 - 在控制器或服务方法上标注注解:业务方法只需关注自身逻辑,横切功能由切面透明织入。
下面分别给出三种典型场景的实现,示例基于 Spring Boot + AOP,部分依赖于真实可用的中间件(Redis 用于限流)。
9.4.2 操作日志切面
操作日志的需求通常包括:记录操作人、操作时间、操作模块、操作动作、请求参数、耗时等。通过自定义 @OperationLog 注解,可以在任何需要记录的方法上快速启用。
1. 定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperationLog {
/** 操作模块,比如 "用户管理" */
String module() default "";
/** 操作动作,比如 "新增用户" */
String action() default "";
/** 是否记录请求参数 */
boolean recordParams() default true;
/** 是否记录响应结果(敏感信息慎用) */
boolean recordResult() default false;
}
2. 切面实现
@Aspect
@Component
@Slf4j
public class OperationLogAspect {
@Around("@annotation(operationLog)")
public Object around(ProceedingJoinPoint point, OperationLog operationLog) throws Throwable {
// 获取当前登录用户(通常从 SecurityContext 或自定义上下文中获取)
String operator = getCurrentUsername();
// 记录开始时间
long start = System.currentTimeMillis();
// 获取方法参数(可进行脱敏处理)
Object[] args = point.getArgs();
String params = operationLog.recordParams() ? Arrays.toString(args) : "已隐藏";
// 执行目标方法
Object result = null;
Throwable exception = null;
try {
result = point.proceed();
return result;
} catch (Throwable e) {
exception = e;
throw e;
} finally {
long cost = System.currentTimeMillis() - start;
// 只在执行成功或异常时均记录日志(根据实际需求调整)
if (operationLog.recordResult() && exception == null) {
log.info("[操作日志] 操作人={} 模块={} 动作={} 参数={} 结果={} 耗时={}ms",
operator, operationLog.module(), operationLog.action(), params, result, cost);
} else {
log.error("[操作日志] 操作人={} 模块={} 动作={} 参数={} 异常={} 耗时={}ms",
operator, operationLog.module(), operationLog.action(), params, exception, cost);
}
}
}
private String getCurrentUsername() {
// 例如从 Spring Security 获取
// Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// return auth == null ? "anonymous" : auth.getName();
return "system"; // 示例,实际使用时替换为真实获取方式
}
}
3. 使用示例
@RestController
@RequestMapping("/user")
public class UserController {
@PostMapping
@OperationLog(module = "用户管理", action = "新增用户")
public R<Void> addUser(@RequestBody @Validated UserDTO user) {
userService.addUser(user);
return R.ok();
}
}
这样,新增用户操作会被自动记录日志,无需在每个方法内重复编写日志代码。
9.4.3 权限校验切面
传统的权限校验通常写在方法开头,使用 if-else 判断用户角色或权限标识,代码耦合度高且容易遗漏。通过 @RequirePermission 注解配合切面,可以实现声明式权限控制。
1. 定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequirePermission {
/** 需要的权限标识,如 "sys:user:add" */
String value();
/** 校验失败时抛出的消息 */
String message() default "没有访问权限";
}
2. 切面实现
@Aspect
@Component
public class PermissionAspect {
@Around("@annotation(requirePermission)")
public Object around(ProceedingJoinPoint point, RequirePermission requirePermission) throws Throwable {
// 获取当前用户权限集合(从认证信息中获取)
Set<String> userPermissions = getCurrentUserPermissions();
String requiredPerm = requirePermission.value();
if (!userPermissions.contains(requiredPerm)) {
throw new SecurityException(requirePermission.message());
}
return point.proceed();
}
private Set<String> getCurrentUserPermissions() {
// 示例:通常从 SecurityContext 或 Token 中提取权限列表
return new HashSet<>(Arrays.asList("sys:user:add", "sys:user:delete"));
}
}
3. 全局异常捕获
当权限不足时,切面抛出 SecurityException,由全局异常处理器统一转换为标准 HTTP 响应:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(SecurityException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public R<Void> handleSecurityException(SecurityException e) {
return R.fail(403, e.getMessage());
}
}
4. 使用示例
@DeleteMapping("/{id}")
@RequirePermission(value = "sys:user:delete", message = "您没有删除用户的权限")
public R<Void> deleteUser(@PathVariable Long id) {
userService.removeById(id);
return R.ok();
}
代码语义清晰,安全检查从业务逻辑中完全剥离。
9.4.4 接口限流切面
面对大流量或防刷场景,接口限流必不可少。这里选择基于 滑动窗口算法 + Redis 的实现,通过自定义 @RateLimiter 注解标识需要限流的方法。
1. 定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimiter {
/** 限流 key,支持 SpEL 表达式,如 "'sms:' + #phone" */
String key() default "";
/** 时间窗口,单位秒 */
int window() default 60;
/** 窗口内最大请求数 */
int limit() default 10;
/** 限流提示信息 */
String message() default "请求过于频繁,请稍后再试";
}
2. 切面实现
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class RateLimiterAspect {
private final StringRedisTemplate redisTemplate;
@Around("@annotation(rateLimiter)")
public Object around(ProceedingJoinPoint point, RateLimiter rateLimiter) throws Throwable {
// 解析 key,支持简单 SpEL 表达式
String key = parseKey(rateLimiter.key(), point);
int window = rateLimiter.window();
int limit = rateLimiter.limit();
// 滑动窗口限流逻辑(基于 Redis ZSET)
long now = System.currentTimeMillis();
long windowStart = now - window * 1000L;
// 删除窗口外的记录
redisTemplate.opsForZSet().removeRangeByScore(key, 0, windowStart);
// 统计当前窗口内的请求数
Long count = redisTemplate.opsForZSet().count(key, windowStart, now);
if (count != null && count >= limit) {
log.warn("接口限流触发,key: {}, 当前请求数: {}", key, count);
throw new RateLimitException(rateLimiter.message());
}
// 记录本次请求
redisTemplate.opsForZSet().add(key, String.valueOf(now), now);
// 设置 key 的过期时间,避免僵尸数据
redisTemplate.expire(key, Duration.ofSeconds(window));
return point.proceed();
}
private String parseKey(String keyExpr, ProceedingJoinPoint point) {
// 简单实现:如果 key 为空,使用方法签名作为默认 key
if (!StringUtils.hasText(keyExpr)) {
return point.getSignature().toShortString();
}
// 生产中可集成 SpEL 解析器,此处略
return keyExpr;
}
}
3. 自定义限流异常
public class RateLimitException extends RuntimeException {
public RateLimitException(String message) {
super(message);
}
}
全局异常处理器照常捕获并返回 429 Too Many Requests 或自定义状态码。
4. 使用示例
@PostMapping("/send-sms")
@RateLimiter(key = "'sms:' + #phone", window = 60, limit = 5, message = "短信发送过于频繁,请1分钟后再试")
public R<Void> sendSms(@RequestParam String phone) {
smsService.send(phone);
return R.ok();
}
同一个手机号在 60 秒内最多触发 5 次短信发送,超出即被拦截。
9.4.5 组合与注意事项
这三个切面既可以单独使用,也可以同时标注在同一个方法上。Spring AOP 的执行顺序由切面的优先级决定,可通过 @Order 控制。建议将权限校验放在最前,限流次之,日志记录最后,确保:
- 没有权限的请求直接拒绝,不占用限流计数;
- 限流过滤后,只记录真正执行业务的请求日志。
此外,在实现时需注意:
- 异常处理一致:所有切面抛出的业务异常应由统一的全局异常处理器捕获,返回标准 API 响应。
- 性能开销:限流切面涉及 Redis 网络调用,对高敏感接口可考虑异步记录日志,但权限校验必须同步执行。
- 可测试性:切面与业务代码解耦,单元测试时甚至可以单独 Mock 切面逻辑或直接忽略注解。
通过自定义注解与 AOP 的结合,我们将操作日志、权限校验、接口限流拆解为独立、可复用的切面,让业务代码回归纯粹,极大提升了代码的可读性与扩展性。