前言
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