前言
Spring框架中的AOP(面向切面編程)
????????通過上面的文章我們了解到了AOP面向切面編程的思想,接下來通過一些實踐,去更加深入的了解我們所學到的知識。
簡單回顧一下AOP的常見應用場景
-
日志記錄:記錄方法入參、返回值、執行性能等日志信息。
-
權限控制:通過自定義注解檢查用戶權限,進行基本的權限控制。
-
統一異常處理:通過捕獲Controller層的異常可以已經統一的異常響應處理。
????????接下來,將對上述場景分別進行實踐。
準備工作
1、基礎依賴
-
JDK17
-
lombok
2、梳理項目結構
aop-demo
├── pom.xml
├── aop-demo-logging
├── aop-demo-permission
└── aop-demo-exception
一、日志記錄
1、梳理一下需要記錄的信息
-
記錄當前執行方法的線程信息。
-
記錄方法參數(可選)。
-
記錄方法返回值(可選)。
-
記錄方法執行時間。
-
記錄方法執行是否超出閾值,若超出閾值進行一定提示。
-
數據脫敏。
2、實現注解
????????實現注解,通過給方法加上注解的方式進行日志記錄。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Loggable {/** 是否記錄參數(默認開啟) */boolean logParams() default true;/** 是否記錄返回值(默認開啟) */boolean logResult() default true;/** 超時警告閾值(單位:毫秒) */long warnThreshold() default 1000;
}
3、實現切面類
????????通過環繞通知的方式,記錄方法信息,并收集上面整理的信息。
@Aspect
@Component
@Slf4j
public class LoggingAspect {// 線程信息格式化模板private static final String THREAD_INFO_TEMPLATE = "Thread[ID=%d, Name=%s]";@Pointcut("@annotation(com.djhhh.annotation.Loggable)")public void loggableMethod() {}@Around("loggableMethod()")public Object logMethod(ProceedingJoinPoint joinPoint) throws Throwable {// 獲取當前線程信息Thread currentThread = Thread.currentThread();String threadInfo = String.format(THREAD_INFO_TEMPLATE,currentThread.getId(),currentThread.getName());MethodSignature signature = (MethodSignature) joinPoint.getSignature();Method method = signature.getMethod();String methodName = method.getDeclaringClass().getSimpleName() + "#" + method.getName();Loggable loggable = method.getAnnotation(Loggable.class);boolean logParams = loggable == null || loggable.logParams();boolean logResult = loggable == null || loggable.logResult();long warnThreshold = loggable != null ? loggable.warnThreshold() : 1000;// 記錄開始日志(添加線程信息)if (logParams) {log.info("{} - Method [{}] started with params: {}",threadInfo, methodName, formatParams(joinPoint.getArgs()));} else {log.info("{} - Method [{}] started", threadInfo, methodName);}long start = System.currentTimeMillis();Object result = null;try {result = joinPoint.proceed();return result;} catch (Exception e) {// 異常日志添加線程信息log.error("{} - Method [{}] failed: {} - {}",threadInfo, methodName, e.getClass().getSimpleName(), e.getMessage());throw e;} finally {long duration = System.currentTimeMillis() - start;String durationMsg = String.format("%s - Method [%s] completed in %d ms",threadInfo, methodName, duration);if (duration > warnThreshold) {log.warn("{} (超過閾值{}ms)", durationMsg, warnThreshold);} else {log.info(durationMsg);}if (logResult && result != null) {// 結果日志添加線程信息log.info("{} - Method [{}] result: {}",threadInfo, methodName, formatResult(result));}}}// 參數格式化(保持不變)private String formatParams(Object[] args) {return Arrays.stream(args).map(arg -> {if (arg instanceof String) return "String[****]";if (arg instanceof Password) return "Password[PROTECTED]";return Objects.toString(arg);}).collect(Collectors.joining(", "));}// 結果格式化優化:集合類型顯示大小private String formatResult(Object result) {return result.toString();}
}
4、實現測試服務
????????通過下列的五個的服務進行測試,詳細測試情況看下文。
@Service
@Slf4j
public class TestServiceImpl implements TestService {@Override@Loggablepublic Integer sum(ArrayList<Integer> arr) {return arr.stream().mapToInt(Integer::intValue).sum();}@Override@Loggable(warnThreshold = 5)public Integer sumMx(ArrayList<Integer> arr) {try{Thread.sleep(5000);}catch (Exception e){log.error(e.getMessage());}return arr.stream().mapToInt(Integer::intValue).sum();}@Override@Loggablepublic Boolean login(String username, Password password) {return "djhhh".equals(username)&&"123456".equals(password.getPassword());}@Override@Loggable(logResult = false,logParams = false)public void logout() {log.info("登出成功");}
}
5、測試
@SpringBootTest
@ExtendWith({SpringExtension.class, OutputCaptureExtension.class})
class LoggingAspectTest {@Autowiredprivate TestServiceImpl testService;//---- 測試業務邏輯正確性 ----@Test@DisplayName("測試sum方法-正常計算")void testSum_NormalCalculation() {ArrayList<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3));int result = testService.sum(list);assertEquals(6, result);}@Test@DisplayName("測試login方法-正確憑證")void testLogin_CorrectCredentials() {Password password = new Password("123456");boolean result = testService.login("djhhh", password);assertTrue(result);}@Test@DisplayName("測試login方法-錯誤憑證")void testLogin_WrongCredentials() {Password password = new Password("wrong");boolean result = testService.login("djhhh", password);assertFalse(result);}@Test@DisplayName("測試logout方法-無參數無返回值")void testLogout() {assertDoesNotThrow(() -> testService.logout());}//---- 驗證日志切面功能 ----@Test@DisplayName("驗證sum方法-參數和結果日志")void testSum_Logging(CapturedOutput output) {ArrayList<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3));testService.sum(list);// 驗證日志內容String logs = output.toString();assertTrue(logs.contains("Method [TestServiceImpl#sum] started with params: [1, 2, 3]"));assertTrue(logs.contains("Method [TestServiceImpl#sum] result: 6"));}@Test@DisplayName("驗證login方法-敏感參數脫敏")void testLogin_SensitiveParamMasking(CapturedOutput output) {Password password = new Password("123456");testService.login("djhhh", password);// 驗證參數脫敏String logs = output.toString();assertTrue(logs.contains("String[****], Password[PROTECTED]"), "未正確脫敏敏感參數");assertFalse(logs.contains("123456"), "密碼明文泄露");}@Test@DisplayName("驗證logout方法-關閉參數和結果日志")void testLogout_NoParamNoResultLog(CapturedOutput output) {testService.logout();String logs = output.toString();assertTrue(logs.contains("Method [TestServiceImpl#logout] started"));assertFalse(logs.contains("started with params"));assertFalse(logs.contains("result:"));}@Test@DisplayName("驗證sumMx方法-超時告警")void testSumMx_ThresholdExceeded(CapturedOutput output) throws InterruptedException {// 構造大數據量延長執行時間(根據實際性能調整)ArrayList<Integer> bigList = new ArrayList<>();for (int i = 0; i < 10; i++) {bigList.add(i);}testService.sumMx(bigList);// 驗證超時警告String logs = output.toString();assertTrue(logs.contains("(超過閾值5ms)"), "未觸發超時警告");}
}
????????測試結果如下:
????????至此通過Spring AOP實現日志記錄的實踐完畢。
實踐總結
????????通過SpringAOP實現日志記錄的解耦,將日志邏輯從業務代碼中剝離,提升了代碼的可維護性和系統運行狀態的可觀測性。
二、權限校驗
????????本實踐只進行基礎的權限身份校驗,想要更加詳細的權限校驗權限可以參考下面的文章。
權限系統設計方案實踐(Spring Security + RBAC 模型)
1、線程工具
????????用于保存用戶信息。
public class UserContext {private static final ThreadLocal<Set<String>> permissionsHolder = new ThreadLocal<>();// 設置當前用戶權限public static void setCurrentPermissions(Set<String> permissions) {permissionsHolder.set(permissions);}// 獲取當前用戶權限public static Set<String> getCurrentPermissions() {return permissionsHolder.get();}// 清除上下文public static void clear() {permissionsHolder.remove();}
}
2、實現注解和常量類
????????實現注解和常量類,為后續權限校驗進行準備工作。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Role {/** 需要的權限標識 */String[] value();/** 校驗邏輯:AND(需全部滿足)或 OR(滿足其一) */Logical logical() default Logical.AND;
}
enum class Logical {AND, OR
}
3、定義切面類
????????通過定義切面類進行權限校驗。
????????通過@Before注解,在進入方法之前進行權限校驗。
@Aspect
@Component
public class PermissionAspect {@Pointcut("@annotation(role)")public void rolePointcut(Role role) {}/*** 定義切入點:攔截所有帶 @RequiresPermission 注解的方法*/@Before("rolePointcut(role)")public void checkPermission(Role role){// 獲取當前用戶權限列表(需自行實現用戶權限獲取邏輯)Set<String> userPermissions = UserContext.getCurrentPermissions();// 校驗權限boolean hasPermission;String[] requiredPermissions = role.value();Logical logical = role.logical();if (logical == Logical.AND) {hasPermission = Arrays.stream(requiredPermissions).allMatch(userPermissions::contains);} else {hasPermission = Arrays.stream(requiredPermissions).anyMatch(userPermissions::contains);}if (!hasPermission) {throw new RuntimeException("權限不足,所需權限: " + Arrays.toString(requiredPermissions));}}
}
4、實現測試服務
????????兩個方法,分別測試滿足權限和不滿足權限。
@Service
public class TestService {@Role(value = {"order:read", "order:write"}, logical = Logical.OR)public void query(Long id) {}@Role("order:admin")public void delete(Long id) {}
}
5、測試
????????對兩種情況分別進行測試。
@SpringBootTest
public class PermissionAspectTest {@Autowiredprivate TestService testService;@Test@DisplayName("測試AND邏輯-權限滿足")void testAndLogicSuccess() {// 模擬用戶有全部權限UserContext.setCurrentPermissions(Set.of("order:read", "order:write"));assertDoesNotThrow(() -> testService.query(1L));}@Test@DisplayName("測試OR邏輯-權限不足")void testOrLogicFailure() {// 模擬用戶只有部分權限UserContext.setCurrentPermissions(Set.of("order:read"));assertThrows(RuntimeException.class,() -> testService.delete(1L),"應檢測到權限不足");}
}
????????測試結果如下:
實踐總結
????????通過AOP可以進行簡單的權限校驗工作,若項目中對權限的顆粒度需求沒有那么細的情況下,可以使用該方法進行權限校驗。
三、異常統一處理
1、準備工作
響應類
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {private int code; // 業務狀態碼private String msg; // 錯誤描述private T data; // 返回數據// 快速創建成功響應public static <T> ApiResponse<T> success(T data) {return new ApiResponse<>(200, "success", data);}// 快速創建錯誤響應public static ApiResponse<?> error(int code, String msg) {return new ApiResponse<>(code, msg, null);}
}
自定義異常類
@Getter
public class BusinessException extends RuntimeException {private final int code; // 自定義錯誤碼public BusinessException(int code, String message) {super(message);this.code = code;}
}
2、全局異常捕捉
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {/*** 處理業務異常(返回HTTP 200,通過code區分錯誤)*/@ExceptionHandler(BusinessException.class)public ApiResponse<?> handleBusinessException(BusinessException e) {log.error("業務異常: code={}, msg={}", e.getCode(), e.getMessage());return ApiResponse.error(e.getCode(), e.getMessage());}/*** 處理參數校驗異常(返回HTTP 400)*/@ResponseStatus(HttpStatus.BAD_REQUEST)@ExceptionHandler(BindException.class)public ApiResponse<?> handleValidationException(BindException e) {String errorMsg = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();log.error("參數校驗失敗: {}", errorMsg);return ApiResponse.error(400, errorMsg);}/*** 處理其他所有異常(返回HTTP 500)*/@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)@ExceptionHandler(Exception.class)public ApiResponse<?> handleGlobalException(Exception e) {log.error("系統異常: ", e);return ApiResponse.error(500, "系統繁忙,請稍后重試");}
}
3、測試類
@RestController
@RequestMapping("/api")
public class TestController {@GetMapping("/test/get")public ApiResponse<String> test(@RequestParam String id){if(id==null){throw new RuntimeException("id為空");}return ApiResponse.success(id);}
}
????????測試結果如下:
實踐總結
在項目中比較常用的一個異常捕獲方式,我們可以通過該方式,統一捕獲項目中的異常,便于項目的異常處理。
總結
?????????通過上面的三個實踐,可以加深我們對于AOP的理解和應用。通過Spring AOP對我們的服務進行抽象處理,簡化我們的開發和維護成本,寫出更加高質量的代碼。
github鏈接:https://github.com/Djhhhhhh/aop-demo