基于SpringAOP面向切面编程的一些实践(日志记录、权限控制、统一异常处理)

前言

Spring框架中的AOP(面向切面编程)

通过上面的文章我们了解到了AOP面向切面编程的思想,接下来通过一些实践,去更加深入的了解我们所学到的知识。


简单回顾一下AOP的常见应用场景

  • 日志记录:记录方法入参、返回值、执行性能等日志信息。

  • 权限控制:通过自定义注解检查用户权限,进行基本的权限控制。

  • 统一异常处理:通过捕获Controller层的异常可以已经统一的异常响应处理。

接下来,将对上述场景分别进行实践。


准备工作

1、基础依赖

  • JDK17

  • lombok

2、梳理项目结构

XML 复制代码
aop-demo
├── pom.xml
├── aop-demo-logging
├── aop-demo-permission
└── aop-demo-exception

一、日志记录

1、梳理一下需要记录的信息

  • 记录当前执行方法的线程信息。

  • 记录方法参数(可选)。

  • 记录方法返回值(可选)。

  • 记录方法执行时间。

  • 记录方法执行是否超出阈值,若超出阈值进行一定提示。

  • 数据脱敏。

2、实现注解

实现注解,通过给方法加上注解的方式进行日志记录。

java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Loggable {
    /** 是否记录参数(默认开启) */
    boolean logParams() default true;
    /** 是否记录返回值(默认开启) */
    boolean logResult() default true;
    /** 超时警告阈值(单位:毫秒) */
    long warnThreshold() default 1000;
}

3、实现切面类

通过环绕通知的方式,记录方法信息,并收集上面整理的信息。

java 复制代码
@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、实现测试服务

通过下列的五个的服务进行测试,详细测试情况看下文。

java 复制代码
@Service
@Slf4j
public class TestServiceImpl implements TestService {
    @Override
    @Loggable
    public 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
    @Loggable
    public 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、测试

java 复制代码
@SpringBootTest
@ExtendWith({SpringExtension.class, OutputCaptureExtension.class})
class LoggingAspectTest {

    @Autowired
    private 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、线程工具

用于保存用户信息。

java 复制代码
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、实现注解和常量类

实现注解和常量类,为后续权限校验进行准备工作。

java 复制代码
@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注解,在进入方法之前进行权限校验。

java 复制代码
@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、实现测试服务

两个方法,分别测试满足权限和不满足权限。

java 复制代码
@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、测试

对两种情况分别进行测试。

java 复制代码
@SpringBootTest
public class PermissionAspectTest {

    @Autowired
    private 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、准备工作

响应类

java 复制代码
@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);
    }
}

自定义异常类

java 复制代码
@Getter
public class BusinessException extends RuntimeException {
    private final int code;  // 自定义错误码

    public BusinessException(int code, String message) {
        super(message);
        this.code = code;
    }
}

2、全局异常捕捉

java 复制代码
@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、测试类

java 复制代码
@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

相关推荐
lzjava202422 分钟前
Redis数据结构之List
java·redis
爱的叹息32 分钟前
Spring MVC 框架 的核心概念、组件关系及流程的详细说明,并附表格总结
java·spring·mvc
骑牛小道士2 小时前
java基础 迭代Iterable接口以及迭代器Iterator
java
代码吐槽菌2 小时前
基于微信小程序的智慧乡村旅游服务平台【附源码】
java·开发语言·数据库·后端·微信小程序·小程序·毕业设计
界面开发小八哥2 小时前
企业级Java开发工具MyEclipse v2025.1——支持AI编码辅助
java·ide·人工智能·myeclipse
可问 可问春风3 小时前
Java中的ArrayList方法
java
大苏打seven3 小时前
Java学习笔记(多线程):ReentrantLock 源码分析
java·笔记·学习
白舟的博客3 小时前
做好一个测试开发工程师第二阶段:java入门:idea新建一个project后默认生成的.idea/src/out文件文件夹代表什么意思?
java·开发语言·intellij-idea
凌辰揽月3 小时前
眨眼睛查看密码工具类
java·开发语言·数据库
张张张3123 小时前
4.8学习总结 贪心算法+Stream流
java·学习