Spring AOP + 异步任务实现日志记录(操作日志、异常日志)参考ruoyi
作者:mmseoamin日期:2023-12-21

简介

AOP(Aspect-Oriented Programming,面向切面编程)是一种编程范例,用于将横切关注点(cross-cutting concerns)从应用程序的核心逻辑中分离出来。横切关注点是那些与应用程序的核心功能无关但又散布在多个部分的关注点,如日志记录、事务管理、安全性、错误处理和性能优化。AOP的目标是提高代码的模块性、可维护性和可重用性。

1、AOP的核心

我们使用一个例子讲解AOP:假设你是一位餐厅经理。

切面(Aspect):切面就像你的服务员,负责执行一些共同的任务,如招呼客人、记录点菜、为客人提供餐巾等。这些任务是与主厨(核心业务)的工作无关的,但却是餐厅运作的一部分。在Java中对应的是@Aspect

通知(Advice):通知就像服务员在执行上述任务时要做的具体行为。通知可以是在客人进入餐厅前,服务员打招呼并引导他们到座位的行为,也可以是在客人点菜后,服务员将菜单传给厨房的行为。在Java中对应的是@Before、@AfterReturning、@AfterThrowing、@After、@Around

连接点(Join Point):连接点就像服务员执行任务的具体时刻,例如客人进入餐厅、点菜、要求账单等等。每一个这样的时刻都是一个潜在的连接点。

切点(Pointcut):切点就像你定义的规则,用于确定在哪些时刻执行通知。例如,你可以定义一个切点,只在客人点菜时执行通知。@Pointcut

引入(Introduction):引入就像在餐巾上折叠成一个精致的三角形,以提供额外的美感。这是一个额外的功能,与核心业务(食物准备和服务)无关。

具体来说:

当客人进入餐厅时,服务员会执行前置通知,即打招呼并引导客人到座位。

当客人点菜时,服务员会执行后置通知,即将点菜记录下来。

当客人要求账单时,服务员会执行通知,提供账单。

餐巾上的三角形折叠是引入,它提供了额外的美感,但与核心业务(食物准备和服务)无关。

AOP的要点是,你可以将与主要任务无关的任务提取出来,使代码更模块化,易于维护,并且可以在不改变核心业务逻辑的情况下添加或修改这些任务。这样,你的程序变得更有组织,更容易扩展和管理。

关于AOP相关注解的说明:

::: tip

@Aspect:@Aspect 注解用于定义一个切面类,它包含了一组通知和切点。在这个类中,您可以编写通知方法,以指定何时和如何在目标方法周围执行额外的逻辑。

@Before:@Before 注解用于定义前置通知,在目标方法执行前执行额外的逻辑。前置通知通常用于在方法执行前做一些准备工作,例如参数验证或日志记录。

@AfterReturning:@AfterReturning 注解用于定义后置通知,在目标方法成功执行后执行额外的逻辑。后置通知通常用于处理方法的返回值或执行清理工作。

@AfterThrowing:@AfterThrowing 注解用于定义异常通知,它在目标方法抛出异常时执行额外的逻辑。异常通知通常用于记录异常信息或处理异常情况。

@After:@After 注解用于定义最终通知,它在目标方法执行后(不论是否发生异常)执行额外的逻辑。最终通知通常用于执行清理工作,例如释放资源。

@Around:@Around 注解用于定义环绕通知,它能够完全控制目标方法的执行。环绕通知可以在方法执行前、执行中和执行后执行额外逻辑,它需要显式地调用 ProceedingJoinPoint.proceed() 方法来控制目标方法的执行。

@Pointcut:@Pointcut 注解用于定义切点,它允许您定义一个或多个切点表达式,以指定哪些方法应该被切面通知。切点表达式是AOP的核心,它指定了在哪里、何时应用通知。

:::

如果觉得难以理解的化可以试着用下面的方式来记住这些注解:

::: details

@Aspect:这是切面的“面孔”。它告诉Spring哪些类是切面,并且它们包含了一些额外的行为。

@Before:在方法前面加上一些行为,就像一个“前锋”一样,提前准备。

@AfterReturning:在方法成功完成后,就像“成功之后”的奖励。

@AfterThrowing:当方法抛出异常时,就像“糟糕的事情发生时”需要做的事情。

@After:无论如何,无论是否成功或失败,都要做一些清理工作,就像“最后一刻”的整理。

@Around:掌控一切,像是方法的“大领袖”,可以在方法执行前、执行中和执行后加入额外的行为。

@Pointcut:这是选择切入点的“定位器”。它定义了哪些方法应该受到切面的影响,就像一把"望远镜"可以帮你找到要关注的方法。

:::


2、如何使用AOP实现日志处理(操作日志)

1、创建自定义日志注解首先创建一个自定义注解用于标记需要记录日志的方法。例如,你可以创建一个OperationLog注解

@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperationLog {
    /**
     * @return 操作描述
     */
    String value() default "";
}

2、创建日志切面:创建一个切面类,用于定义日志记录的行为。这个类需要使用@Aspect注解标记,同时使用@Component注解将其交给Spring容器管理。在切面类中,定义通知来记录方法的执行情况。

@Aspect
@Component
public class OperationLogAspect {
    /**
     * 请求开始时间
     */
    ThreadLocal startTime = new ThreadLocal<>();
    /**
     * 设置操作日志切入点,记录操作日志,在注解(@OperationLogger)的位置切入代码
     */
    @Pointcut("@annotation(com.he.annotation.OperationLogger)")
    public void optLogPointCut() {
    }
    /**
     * 前置通知,在切入点(optLogPointCut方法)之前执行,用于记录请求开始时间。
     */
    @Before("optLogPointCut()")
    public void doBefore() {
        // 记录请求开始时间
        startTime.set(System.currentTimeMillis());
    }
    /**
     *  AfterReturning通知方法,当带有@OptLogger注解的方法成功执行后,拦截并记录用户操作的细节。
     *
     * @param joinPoint 切面方法的信息
     * @param result    返回结果
     */
    @AfterReturning(value = "optLogPointCut()", returning = "result")
    public void doAfterReturning(JoinPoint joinPoint, Object result) {
        // 从切面织入点处通过反射机制获取织入点处的方法
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        // 获取切入点所在的方法
        Method method = signature.getMethod();
        // 获取操作
        Api api = (Api) signature.getDeclaringType().getAnnotation(Api.class);
        ApiOperation apiOperation = method.getAnnotation(ApiOperation.class);
        OperationLogger optLogger = method.getAnnotation(OperationLogger.class);
        // 获取request
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = Objects.requireNonNull(attributes).getRequest();
        // 日志保存到数据库
        OperationLog operationLog = new OperationLog();
        // 操作模块
        operationLog.setModule(api.tags()[0]);
        // 操作类型
        operationLog.setType(optLogger.value());
        // 请求URI
        operationLog.setUri(request.getRequestURI());
        // 获取请求的类名
        String className = joinPoint.getTarget().getClass().getName();
        // 获取请求的方法名
        String methodName = method.getName();
        methodName = className + "." + methodName;
        // 请求方法
        operationLog.setName(methodName);
        // 操作描述
        operationLog.setDescription(apiOperation.value());
        // 请求参数
        if (joinPoint.getArgs()[0] instanceof MultipartFile) {
            operationLog.setParams(((MultipartFile) joinPoint.getArgs()[0]).getOriginalFilename());
        } else {
            operationLog.setParams(JSON.toJSONString(joinPoint.getArgs()));
        }
        // 请求方式
        operationLog.setMethod(Objects.requireNonNull(request).getMethod());
        // 返回数据
        operationLog.setData(JSON.toJSONString(result));
        // 请求用户ID
        operationLog.setUserId(SecurityUtils.getUserId());
        // 请求用户昵称
        operationLog.setNickname(SecurityUtils.getLoginUser().getUsername());
        // 操作ip和操作地址
        String ip = IpUtils.getIpAddress(request);
        operationLog.setIpAddress(ip);
        operationLog.setIpSource(IpUtils.getIpSource(ip));
        // 执行耗时
        operationLog.setTimes(System.currentTimeMillis() - startTime.get());
        startTime.remove();
        // 保存到数据库,使用异步方式确保不阻塞主程序
        AsyncManager.getInstance().execute(AsyncFactory.recordOperation(operationLog));
    }
}

上述代码使用Spring AOP实现了一个基本的日志实现,创建了一个名为 OperationLogAspect 的类,它使用了 @Aspect 和 @Component 注解。@Aspect 表示这是一个切面类,用于定义操作日志的切面逻辑,而 @Component 注解将它注册为Spring的组件。

使用 ThreadLocal 来存储请求开始时间,以便后续计算请求的执行耗时。接着,定义了一个切点 optLogPointCut(),它使用 @Pointcut 注解,用于匹配带有 @OperationLogger 注解的方法。这个切点表示在带有 @OperationLogger 注解的方法执行时,切面将会介入。

最后使用@AfterReturning注解标注doAfterReturning方法,只有@OperationLogger标注的方法成功执行之后才会执行这部分代码。其中AsyncManager.getInstance().execute(AsyncFactory.recordOperation(operationLog));我们可以自定义自己的操作日志记录入库,这里不在赘述。


3、如何使用AOP实现日志处理(异常日志)

@Aspect
@Component
public class ExceptionLogAspect {
    /**
     * 设置操作异常日志切入点,扫描所有controller包下的操作
     */
    @Pointcut("execution(* com.ican.controller..*.*(..))")
    public void exceptionLogPointCut() {
    }
    /**
     * 异常通知,只有连接点异常后才会执行
     *
     * @param joinPoint 切面方法的信息
     * @param e         异常
     */
    @AfterThrowing(pointcut = "exceptionLogPointCut()", throwing = "e")
    public void doAfterThrowing(JoinPoint joinPoint, Throwable e) {
        // 从切面织入点处通过反射机制获取织入点处的方法
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        // 获取切入点所在的方法
        Method method = signature.getMethod();
        // 获取request
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = Objects.requireNonNull(attributes).getRequest();
        // 获取操作
        Api api = (Api) signature.getDeclaringType().getAnnotation(Api.class);
        ApiOperation apiOperation = method.getAnnotation(ApiOperation.class);
        ExceptionLog exceptionLog = new ExceptionLog();
        // 异常模块
        exceptionLog.setModule(api.tags()[0]);
        // 请求URI
        exceptionLog.setUri(request.getRequestURI());
        // 异常名称
        exceptionLog.setName(e.getClass().getName());
        // 操作描述
        exceptionLog.setDescription(apiOperation.value());
        // 获取请求的类名
        String className = joinPoint.getTarget().getClass().getName();
        // 获取请求的方法名
        String methodName = method.getName();
        methodName = className + "." + methodName;
        // 异常方法名称
        exceptionLog.setErrorMethod(methodName);
        // 异常信息
        exceptionLog.setMessage(stackTraceToString(e.getClass().getName(), e.getMessage(), e.getStackTrace()));
        // 请求参数
        if (joinPoint.getArgs()[0] instanceof MultipartFile) {
            exceptionLog.setParams(((MultipartFile) joinPoint.getArgs()[0]).getOriginalFilename());
        } else {
            exceptionLog.setParams(JSON.toJSONString(joinPoint.getArgs()));
        }
        // 请求方式
        exceptionLog.setRequestMethod(Objects.requireNonNull(request).getMethod());
        // 操作ip和操作地址
        String ip = IpUtils.getIpAddress(request);
        exceptionLog.setIpAddress(ip);
        exceptionLog.setIpSource(IpUtils.getIpSource(ip));
        // 操作系统和浏览器
        Map userAgentMap = UserAgentUtils.parseOsAndBrowser(request.getHeader("User-Agent"));
        exceptionLog.setOs(userAgentMap.get("os"));
        exceptionLog.setBrowser(userAgentMap.get("browser"));
        // 保存到数据库
        AsyncManager.getInstance().execute(AsyncFactory.recordException(exceptionLog));
    }
    public String stackTraceToString(String exceptionName, String exceptionMessage, StackTraceElement[] elements) {
        StringBuilder stringBuilder = new StringBuilder();
        for (StackTraceElement stet : elements) {
            stringBuilder.append(stet).append("\n");
        }
        return exceptionName + ":" + exceptionMessage + "\n" + stringBuilder;
    }
}

ExceptionLogAspect 是一个被 @Aspect 和 @Component 注解标记的类,表明它是一个切面并且可以被Spring容器管理。

exceptionLogPointCut() 方法使用 @Pointcut 注解定义了切入点,它匹配所有 com.ican.controller 包下的方法执行,用于捕获控制器层的异常。

doAfterThrowing(JoinPoint joinPoint, Throwable e) 方法使用 @AfterThrowing 注解,表示在切入点方法抛出异常后执行。这个方法用于处理捕获的异常信息。

在 doAfterThrowing 方法中,通过反射机制获取了异常发生的方法的相关信息,包括类名、方法名、异常名称等。

使用 ServletRequestAttributes 获取了HTTP请求的相关信息,如URI、请求方式、IP地址等。

创建了一个 ExceptionLog 对象,用于封装异常日志的信息,包括异常模块、请求URI、异常名称、操作描述等。

使用 stackTraceToString 方法将异常的堆栈轨迹转化为字符串,以便记录在异常日志中。

最后,将异常日志信息保存到数据库中,使用了异步方式以确保不阻塞主程序的执行。这部分逻辑通过 AsyncManager 和 AsyncFactory 实现。

4、使用异步任务记录日志

想象一下你是一名忙碌的餐厅厨师,你负责做各种美食。但同时,你也需要记录每份订单和使用的食材,以便管理餐厅的运营。

现在,如果你选择同步方式处理日志,那么每当有订单或使用食材时,你必须立刻停下手头的工作,拿出笔和纸,然后记录下来。这会导致你不断中断烹饪,耽误时间,而且可能会让食物煮得不那么美味。

但如果你选择异步方式处理日志,你可以雇佣一个专门的记录员,他会坐在一旁,专门负责记录订单和使用的食材。这样,你可以专注于烹饪,不必再分心去记录。记录员会在后台默默地工作,而你不会因此而受到干扰。这使得你能够更专注、更高效地做出美味的菜肴。

在应用程序中,这类似于主线程负责核心任务,而异步方式用于处理日志记录。通过异步处理,应用程序的主线程不会被日志记录操作所拖慢,可以更专注于处理用户请求和核心功能,从而提高性能和用户体验。这就是为什么要使用异步方式处理日志的原因。

1、创建线程池

# 线程池配置
thread:
  pool:
    core-pool-size: 5
    max-pool-size: 10
    queue-capacity: 50
    keep-alive-seconds: 60

线程池参数:

@Data
@Configuration
@ConfigurationProperties(prefix = "thread.pool")
public class ThreadPoolProperties {
    /**
     * 核心线程池大小
     */
    private int corePoolSize;
    /**
     * 最大可创建的线程数
     */
    private int maxPoolSize;
    /**
     * 队列最大长度
     */
    private int queueCapacity;
    /**
     * 线程池维护线程所允许的空闲时间
     */
    private int keepAliveSeconds;
}

线程池配置

@Configuration
public class ThreadPoolConfig {
    /**
     * 线程池配置
     */
    @Autowired
    private ThreadPoolProperties threadPoolProperties;
    /**
     * 创建线程池
     *
     * @return 线程池
     */
    @Bean
    public ThreadPoolTaskExecutor threadPoolTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        // 核心线程池大小
        executor.setCorePoolSize(threadPoolProperties.getCorePoolSize());
        // 最大可创建的线程数
        executor.setMaxPoolSize(threadPoolProperties.getMaxPoolSize());
        // 等待队列最大长度
        executor.setQueueCapacity(threadPoolProperties.getQueueCapacity());
        // 线程池维护线程所允许的空闲时间
        executor.setKeepAliveSeconds(threadPoolProperties.getKeepAliveSeconds());
        // 线程池对拒绝任务(无线程可用)的处理策略
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        return executor;
    }
    /**
     * 执行周期性或定时任务
     */
    @Bean(name = "scheduledExecutorService")
    protected ScheduledExecutorService scheduledExecutorService() {
        return new ScheduledThreadPoolExecutor(threadPoolProperties.getCorePoolSize(),
                new BasicThreadFactory.Builder().namingPattern("schedule-pool-%d").daemon(true).build(),
                new ThreadPoolExecutor.CallerRunsPolicy()) {
            @Override
            protected void afterExecute(Runnable r, Throwable t) {
                super.afterExecute(r, t);
                ThreadUtils.printException(r, t);
            }
        };
    }
}

threadPoolTaskExecutor 线程池:这是一个通用的线程池,用于执行异步任务。

scheduledExecutorService配置了一个定时任务线程池,其中包括线程池的大小、线程工厂、守护线程设置和拒绝策略。这个线程池用于执行周期性或定时任务,例如在应用程序中执行定时的操作。

2、创建异步工厂AsyncFactory

异步工厂(AsyncFactory)的作用是创建不同类型的异步任务。在异步编程中,任务通常是在后台线程中执行的,而异步工厂的主要责任是生成这些任务的实例。

首先我们需要创建一个异步任务工厂,这个工厂用来专门产生异步任务实例使用,这个工厂将包含创建各种类型日志记录任务的方法。

public class AsyncFactory {
    /**
     * 记录操作日志
     *
     * @param operationLog 操作日志信息
     * @return 任务task
     */
    public static TimerTask recordOperation(OperationLog operationLog) {
        return new TimerTask() {
            @Override
            public void run() {
                SpringUtils.getBean(OperationLogService.class).saveOperationLog(operationLog);
            }
        };
    }
    /**
     * 记录异常日志
     *
     * @param exceptionLog 异常日志信息
     * @return 任务task
     */
    public static TimerTask recordException(ExceptionLog exceptionLog) {
        return new TimerTask() {
            @Override
            public void run() {
                SpringUtils.getBean(ExceptionLogService.class).saveExceptionLog(exceptionLog);
            }
        };
    }
    /**
     * 记录访问日志
     *
     * @param visitLog 访问日志信息
     * @return 任务task
     */
    public static TimerTask recordVisit(VisitLog visitLog) {
        return new TimerTask() {
            @Override
            public void run() {
                SpringUtils.getBean(VisitLogService.class).saveVisitLog(visitLog);
            }
        };
    }
}

在上面的代码示例中,AsyncFactory 类用于创建不同类型的异步任务,用于记录操作日志、异常日志和访问日志。

3、异步任务管理器AsyncManager

AsyncManager负责管理应用程序中的异步任务,包括但不限于记录日志、定时任务、后台处理等。它提供了一个标准的接口来提交异步任务,并负责将这些任务分配给后台线程池执行。

public class AsyncManager {
    /**
     * 单例模式,确保类只有一个实例
     */
    private AsyncManager() {
    }
    /**
     * 饿汉式,在类加载的时候立刻进行实例化
     */
    private static final AsyncManager INSTANCE = new AsyncManager();
    public static AsyncManager getInstance() {
        return INSTANCE;
    }
    /**
     * 异步操作任务调度线程池
     */
    private final ScheduledExecutorService executor = SpringUtils.getBean("scheduledExecutorService");
    /**
     * 执行任务
     *
     * @param task 任务
     */
    public void execute(TimerTask task) {
        executor.schedule(task, 10, TimeUnit.MILLISECONDS);
    }
    /**
     * 停止任务线程池
     */
    public void shutdown() {
        ThreadUtils.shutdownAndAwaitTermination(executor);
    }
}

上述代码解释:

AsyncManager 被设计成一个单例类,这意味着在应用程序中只有一个 AsyncManager 的实例。这通过私有的构造函数和一个私有的静态实例 INSTANCE 来实现。这确保了全局只有一个任务管理器,避免了多个管理器之间的竞争和混乱。

通过 private static final AsyncManager INSTANCE = new AsyncManager(); 这行代码,使用了饿汉式单例模式。这表示在类加载时,实例会立即创建,确保了线程安全。

AsyncManager 包含一个名为 executor 的成员变量,它是一个 ScheduledExecutorService 类型的对象。这个线程池用于执行异步任务,包括记录日志等。线程池的创建是通过调用 SpringUtils.getBean(“scheduledExecutorService”) 来实现的,从Spring上下文中获取相应的线程池实例。

execute 方法接受一个 TimerTask 对象作为参数,将任务提交给线程池以异步执行。任务将在10毫秒后执行。这确保了异步任务不会阻塞主线程。

停止线程池:

shutdown 方法用于停止任务线程池,以释放资源。这是一个可选的操作,通常在应用程序关闭时执行以确保线程池的资源被正确释放。