SpringBoot集成AOP實(shí)現(xiàn)日志記錄與接口權(quán)限校驗(yàn)
摘要:想象一棟寫字樓,如果每個(gè)房間都自己配鎖、拉監(jiān)控,既費(fèi)錢又容易漏;更聰明的做法是裝一套統(tǒng)一的門禁和中控,訪客一刷卡,全樓的安全和記錄都被接管。AOP 就是這套“中央管家”——把日志與權(quán)限從每個(gè)接口中抽離,統(tǒng)一織入,既輕量又可追溯。
1. 場(chǎng)景與概念對(duì)照
- 痛點(diǎn):每個(gè)接口都寫日志與權(quán)限,像每個(gè)房間各自裝鎖和攝像頭,重復(fù)又易漏。
- AOP 角色類比:切面=管家,通知=動(dòng)作(餐前消毒/餐后清潔),連接點(diǎn)=每次上菜瞬間,切入點(diǎn)=哪些桌子需要清潔。
- 概念與代碼速查表:
- 切面 → @Aspect 類
- 通知 → @Around/@Before/@AfterReturning
- 連接點(diǎn) → 目標(biāo)方法調(diào)用
- 切入點(diǎn) → @Pointcut 表達(dá)式
2. 環(huán)境準(zhǔn)備與版本差異
spring-boot-starter-aop 與 spring-boot-starter-web 示例使用 Spring Boot 2.7.15,代碼同時(shí)兼容 1.5.x 和 3.x(3.x 需把 javax.servlet 換成 jakarta.servlet 包名即可,Boot 1.5.x 仍使用 javax.servlet,AOP 注解與用法保持一致):
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> <version>2.7.15</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>2.7.15</version> </dependency>
如果你在維護(hù)老項(xiàng)目(如 Spring Boot 1.5.x),只需把上面的 <version> 改成 1.5.x 系列(如 1.5.22.RELEASE),然后確保 JDK8 與 Spring AOP 版本匹配即可,切面與注解寫法可直接復(fù)用;如果是新項(xiàng)目(Spring Boot 3.x+),將依賴版本升級(jí)到 3.x,同時(shí)把示例中的 javax.servlet.http.HttpServletRequest 改為 jakarta.servlet.http.HttpServletRequest 即可。
包結(jié)構(gòu)建議:com.demo.aop.annotation / aspect / common / controller。
3. 注解定義(支持類與方法)
package com.demo.aop.annotation;
import java.lang.annotation.*;
// 簡(jiǎn)單日志級(jí)別枚舉,用于控制切面日志輸出級(jí)別
public enum LogLevel { TRACE, DEBUG, INFO, WARN, ERROR }
// 日志注解:可標(biāo)在類或方法上,控制是否記錄日志以及日志細(xì)節(jié)
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiLog {
// 業(yè)務(wù)含義描述,例如“查詢訂單”“用戶登錄”
String value() default "";
// 是否忽略當(dāng)前方法(即使類上加了 ApiLog)
boolean ignore() default false;
// 日志級(jí)別,默認(rèn)為 INFO
LogLevel level() default LogLevel.INFO;
// 是否隱藏響應(yīng)體(例如大對(duì)象或隱私數(shù)據(jù))
boolean hideResp() default false;
}
// 權(quán)限注解:可標(biāo)在類或方法上,聲明允許訪問的角色
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
// 支持多個(gè)角色,只要命中其中一個(gè)即可訪問
String[] value();
}
類級(jí)別生效邏輯:切面中若方法未標(biāo)注,回退檢查類上的注解。
4. 通用返回體與異常
package com.demo.aop.common;
public class ApiResponse<T> {
// 業(yè)務(wù)狀態(tài)碼,0 表示成功,其他表示失敗
private int code;
// 業(yè)務(wù)提示信息
private String message;
// 真實(shí)數(shù)據(jù)載體
private T data;
// 快捷構(gòu)造成功返回
public static <T> ApiResponse<T> ok(T d){ return of(0,"OK",d); }
// 快捷構(gòu)造失敗返回
public static <T> ApiResponse<T> fail(String msg){ return of(-1,msg,null); }
private static <T> ApiResponse<T> of(int c,String m,T d){
ApiResponse<T> r=new ApiResponse<>(); r.code=c; r.message=m; r.data=d; return r;
}
}
// 自定義權(quán)限異常,統(tǒng)一由全局異常處理器攔截
public class PermissionDeniedException extends RuntimeException {
public PermissionDeniedException(String msg){ super(msg); }
}
全局異常處理(JSON 返回):
@RestControllerAdvice
public class GlobalExceptionHandler {
// 捕獲權(quán)限異常,統(tǒng)一轉(zhuǎn)成 ApiResponse JSON 返回
@ExceptionHandler(PermissionDeniedException.class)
public ApiResponse<Void> handlePermission(PermissionDeniedException e){
return ApiResponse.fail(e.getMessage());
}
}
5. 日志切面(含 NPE 防護(hù)、脫敏、TraceId)
package com.demo.aop.aspect;
import com.demo.aop.annotation.ApiLog;
import com.demo.aop.annotation.LogLevel;
import com.demo.aop.common.ApiResponse;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.core.annotation.Order;
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.util.*;
@Aspect
@Component
@Order(2) // 權(quán)限優(yōu)先,日志在后
public class ApiLogAspect {
// Slf4j 日志器
private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(ApiLogAspect.class);
// 統(tǒng)一 JSON 序列化
private final ObjectMapper mapper = new ObjectMapper();
// 切入點(diǎn):匹配標(biāo)了 @ApiLog 的方法或類
@Pointcut("@annotation(com.demo.aop.annotation.ApiLog) || @within(com.demo.aop.annotation.ApiLog)")
public void apiLogPointcut() {}
// 環(huán)繞通知:在目標(biāo)方法前后插入日志邏輯
@Around("apiLogPointcut()")
public Object recordLog(ProceedingJoinPoint pjp) throws Throwable {
// 兼容方法級(jí) / 類級(jí)注解
ApiLog ann = findAnnotation(pjp);
if (ann == null || ann.ignore()) { return pjp.proceed(); }
// 記錄開始時(shí)間,用于計(jì)算耗時(shí)
long start = System.currentTimeMillis();
// 從請(qǐng)求上下文中獲取 HttpServletRequest,非 Web 環(huán)境直接放行
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs == null) { // 非 Web 環(huán)境兜底
return pjp.proceed();
}
HttpServletRequest req = attrs.getRequest();
// TraceId:從 header 取,沒有則生成一個(gè)新的
String traceId = Optional.ofNullable(req.getHeader("X-Trace-Id"))
.orElse(UUID.randomUUID().toString());
// 基本請(qǐng)求信息
String url = req.getRequestURI();
String method = req.getMethod();
String user = Optional.ofNullable(req.getHeader("X-User")).orElse("anonymous");
// IP:優(yōu)先使用 X-Forwarded-For 頭(兼容網(wǎng)關(guān) / 負(fù)載)
String ip = Optional.ofNullable(req.getHeader("X-Forwarded-For"))
.map(s -> s.split(",")[0].trim()).orElse(req.getRemoteAddr());
// 客戶端標(biāo)識(shí)
String ua = Optional.ofNullable(req.getHeader("User-Agent")).orElse("unknown");
// 請(qǐng)求參數(shù) Map
Map<String, String[]> params = req.getParameterMap();
// 將參數(shù)轉(zhuǎn) JSON 并脫敏
String args = mask(toJsonSafe(params));
Object result = null; Throwable ex = null;
try {
// 繼續(xù)執(zhí)行真實(shí)業(yè)務(wù)方法
result = pjp.proceed();
return result;
} catch (Throwable t) {
// 記錄異常并向上拋出
ex = t; throw t;
} finally {
long cost = System.currentTimeMillis() - start;
// 根據(jù)注解配置決定是否輸出響應(yīng)體
String resp = ann.hideResp() ? "<hidden>" : toJsonSafe(result);
// 按指定日志級(jí)別輸出
logWithLevel(ann.level(), "[API-LOG] trace={} {} {} user={} ip={} ua={} cost={}ms args={} resp={} err={}",
traceId, method, url, user, ip, ua, cost, args, resp, ex == null ? "none" : ex.getMessage());
}
}
// 查找方法 / 類上的 ApiLog 注解
private ApiLog findAnnotation(ProceedingJoinPoint pjp) {
ApiLog methodAnn = org.springframework.core.annotation.AnnotationUtils
.findAnnotation(((org.aspectj.lang.reflect.MethodSignature) pjp.getSignature()).getMethod(), ApiLog.class);
ApiLog typeAnn = org.springframework.core.annotation.AnnotationUtils
.findAnnotation(pjp.getTarget().getClass(), ApiLog.class);
return methodAnn != null ? methodAnn : typeAnn;
}
// 安全 JSON 序列化,失敗時(shí)給出占位字符串
private String toJsonSafe(Object obj){
try { return mapper.writeValueAsString(obj); }
catch (JsonProcessingException e){
log.warn("json serialize fail", e);
return "<json-error>";
}
}
// 簡(jiǎn)單脫敏:隱藏密碼與手機(jī)號(hào)中間四位
private String mask(String json){
if (json == null) return null;
// password/pwd 字段統(tǒng)一替換為 ****
return json.replaceAll("(?i)(password|pwd)\":\"[^\"]+\"", "$1\":\"****\"")
// phone 字段保留前 3 位和后 4 位,中間打 ***
.replaceAll("(\"phone\"\\s*:\\s*\")\\d{3}\\d{4}(\\d{4}\")", "$1***$2");
}
// 根據(jù)注解上的日志級(jí)別動(dòng)態(tài)選擇輸出方法
private void logWithLevel(LogLevel level, String msg, Object... args){
switch (level){
case TRACE: log.trace(msg, args); break;
case DEBUG: log.debug(msg, args); break;
case WARN: log.warn(msg, args); break;
case ERROR: log.error(msg, args); break;
default: log.info(msg, args);
}
}
}
要點(diǎn):判空防 NPE;JSON 序列化異常兜底;脫敏密碼/手機(jī)號(hào);TraceId/IP/User-Agent 記錄;支持 hideResp 與日志級(jí)別;類級(jí)別注解兼容。
6. 權(quán)限切面(多角色 + 自定義異常)
package com.demo.aop.aspect;
import com.demo.aop.annotation.RequireRole;
import com.demo.aop.common.PermissionDeniedException;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.core.annotation.Order;
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.util.Arrays;
@Aspect
@Component
@Order(1) // 先校驗(yàn)權(quán)限,再記錄日志
public class AuthAspect {
@Pointcut("@annotation(com.demo.aop.annotation.RequireRole) || @within(com.demo.aop.annotation.RequireRole)")
public void authPointcut() {}
@Around("authPointcut() && @annotation(requireRole)")
public Object checkRole(ProceedingJoinPoint pjp, RequireRole requireRole) throws Throwable {
// 獲取當(dāng)前請(qǐng)求上下文,非 Web 環(huán)境直接略過權(quán)限校驗(yàn)
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs == null) { return pjp.proceed(); } // 非 Web 環(huán)境直接放行
HttpServletRequest req = attrs.getRequest();
// 1) 從 Header 中讀取角色(演示用,真實(shí)場(chǎng)景多為 JWT / Session)
String role = req.getHeader("X-Role");
// 2) JWT 極簡(jiǎn)示例(偽代碼)
// String role = JwtUtil.parseRole(req.getHeader("Authorization"));
// 3) DB 極簡(jiǎn)示例(偽代碼)
// List<String> roles = roleRepo.findRolesByUser(req.getHeader("X-User"));
// 只要當(dāng)前角色命中注解聲明的任一角色,就視為通過
boolean ok = role != null && Arrays.stream(requireRole.value())
.anyMatch(r -> r.equalsIgnoreCase(role));
// 未通過則拋出權(quán)限異常,由全局異常處理器統(tǒng)一返回 JSON
if (!ok) { throw new PermissionDeniedException("無訪問權(quán)限,需角色: " + String.join("/", requireRole.value())); }
return pjp.proceed();
}
}
7. Controller 示例
package com.demo.aop.controller;
import com.demo.aop.annotation.ApiLog;
import com.demo.aop.annotation.LogLevel;
import com.demo.aop.annotation.RequireRole;
import com.demo.aop.common.ApiResponse;
import org.springframework.web.bind.annotation.*;
// 類級(jí)別加上 ApiLog:該 Controller 所有接口默認(rèn)記錄日志
@ApiLog(value="類級(jí)日志", level=LogLevel.DEBUG)
@RestController
@RequestMapping("/demo")
public class DemoController {
// 查詢訂單接口:?jiǎn)为?dú)指定日志描述,并要求 ADMIN/OPS 角色
@ApiLog(value="查詢訂單", hideResp=false)
@RequireRole({"ADMIN","OPS"})
@GetMapping("/order")
public ApiResponse<String> getOrder(@RequestParam String id) {
// 真實(shí)場(chǎng)景可查詢數(shù)據(jù)庫,這里僅返回一個(gè)拼接字符串
return ApiResponse.ok("order-" + id);
}
// 健康檢查接口:可被監(jiān)控系統(tǒng)頻繁調(diào)用,隱藏響應(yīng)體減少日志噪音
@ApiLog(value="健康檢查", ignore=false, hideResp=true)
@GetMapping("/ping")
public ApiResponse<String> ping() {
return ApiResponse.ok("pong");
}
}
8. 優(yōu)勢(shì)、擴(kuò)展與排查
- 優(yōu)勢(shì):業(yè)務(wù)零侵入、格式統(tǒng)一、可配置(級(jí)別/忽略/隱藏響應(yīng)),更易審計(jì)與追蹤。
- 擴(kuò)展:日志異步落盤/發(fā) MQ;權(quán)限列表支持;與 Spring Security 配合(注解轉(zhuǎn) SecurityMetadataSource);接入 ELK 關(guān)聯(lián) traceId。
- 常見問題排查:
- 切面不生效:類未被 Spring 管理或方法是
private/final;調(diào)整為public并交給容器。 - 注解寫在接口而實(shí)現(xiàn)類無代理:確保代理對(duì)象被調(diào)用,或把注解寫到實(shí)現(xiàn)類。
- 未開啟 AOP:確認(rèn)引入 starter,未手動(dòng)禁用
spring.aop.auto=true。
- 切面不生效:類未被 Spring 管理或方法是
9. 小結(jié)
把日志與權(quán)限交給 AOP 這位“統(tǒng)一管家”,再加上空指針兜底、JSON 異常防護(hù)、脫敏與多角色校驗(yàn),就能在生產(chǎn)環(huán)境穩(wěn)穩(wěn)落地。后續(xù)可平滑接入 Spring Security 或 ELK,持續(xù)進(jìn)化。
到此這篇關(guān)于SpringBoot集成AOP實(shí)現(xiàn)日志記錄與接口權(quán)限校驗(yàn)的文章就介紹到這了,更多相關(guān)SpringBoot 日志記錄與接口權(quán)限內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
java與scala數(shù)組及集合的基本操作對(duì)比
這篇文章主要介紹了java與scala數(shù)組及集合的基本操作對(duì)比,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2021-10-10
支持SpEL表達(dá)式的自定義日志注解@SysLog介紹
這篇文章主要介紹了支持SpEL表達(dá)式的自定義日志注解@SysLog,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-02-02

