Spring Boot 中实现自定义注解记录接口日志功能

👨🏻‍💻 热爱摄影的程序员

👨🏻‍🎨 喜欢编码的设计师

🧕🏻 擅长设计的剪辑师

🧑🏻‍🏫 一位高冷无情的全栈工程师

欢迎分享 / 收藏 / 赞 / 在看!

【需求】

在 Spring Boot 项目中,我们经常需要记录接口的日志,比如请求参数、响应结果、请求时间、响应时间等。为了方便统一处理,我们可以通过自定义注解的方式来实现接口日志的记录。

【解决】

👉 项目地址:Gitee-ControllerLog

  1. 创建自定义注解 Log.java,用于标记需要记录日志的接口。

注解本质上是一个接口,它继承自 java.lang.annotation.Annotation。但是,你通常不需要显式地继承这个接口,因为当使用 @interface 关键字时,编译器会自动为你处理。

  • @Target 注解用于指定注解可以应用的 Java 元素类型,比如类、方法、字段等。详细的元素类型可以参考 ElementType 枚举类。
  • @Retention 注解用于指定注解的生命周期,有三个值可选:SOURCE、CLASS 和 RUNTIME。
    • SOURCE 表示注解仅存在于源代码中,编译时会被忽略;
    • CLASS 表示注解会被编译到 class 文件中,但在运行时会被忽略;
    • RUNTIME 表示注解会被编译到 class 文件中,并在运行时保留,因此可以通过反射读取注解信息。
  • @Documented 注解用于指定注解是否包含在 JavaDoc 中。
java 复制代码
@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Log {

    /**
     * 模块名称
     */
    String title() default "";

    /**
     * 功能
     */
    BusinessType businessType() default BusinessType.OTHER;

    /**
     * 操作人类别
     */
    OperatorType operatorType() default OperatorType.MANAGE;

    /**
     * 是否保存请求的参数
     */
    boolean isSaveRequestData() default true;

    /**
     * 是否保存响应的参数
     */
    boolean isSaveResponseData() default true;

    /**
     * 排除指定的请求参数
     */
    String[] excludeParamNames() default {};
}
  1. 创建操作日志记录处理切面 LogAspect.java,用于处理接口日志的记录。

处理注解通常涉及编写一个能够识别并响应注解的组件。这可以通过多种方式实现,比如使用 Spring AOP、自定义 BeanPostProcessor、或直接在配置类中通过反射读取注解信息。

如果你希望在不修改业务代码的情况下,通过切面(Aspect)来增强被注解的方法,可以使用 Spring AOP。

  • @Aspect 注解用于定义一个切面,它包含切入点和通知。
  • @Before 注解用于指定一个前置通知,在目标方法执行之前执行。
  • @AfterReturning 注解用于指定一个返回通知,在目标方法正常返回后执行。
  • @AfterThrowing 注解用于指定一个异常通知,在目标方法抛出异常后执行。
  • @Around 注解用于指定一个环绕通知,可以在目标方法执行前后执行自定义的行为。
  • @Pointcut 注解用于定义一个切入点,可以在多个通知中共用。
  • @AutoConfiguration 注解用于自动配置 Bean。
java 复制代码
@Slf4j
@Aspect
@AutoConfiguration
public class LogAspect {

    /**
     * 排除敏感属性字段
     */
    public static final String[] EXCLUDE_PROPERTIES = {"password", "oldPassword", "newPassword", "confirmPassword"};

    /**
     * 计时 key
     */
    private static final ThreadLocal<StopWatch> KEY_CACHE = new ThreadLocal<>();

    /**
     * 处理请求前执行
     *
     * @param joinPoint     切点
     * @param controllerLog 注解
     */
    @Before(value = "@annotation(controllerLog)")
    public void doBefore(JoinPoint joinPoint, Log controllerLog) {
        StopWatch stopWatch = new StopWatch();
        KEY_CACHE.set(stopWatch);
        stopWatch.start();
    }

    /**
     * 处理请求后执行
     *
     * @param joinPoint     切点
     * @param controllerLog 注解
     * @param jsonResult    返回结果
     */
    @AfterReturning(pointcut = "@annotation(controllerLog)", returning = "jsonResult")
    public void doAfterReturning(JoinPoint joinPoint, Log controllerLog, Object jsonResult) {
        handleLog(joinPoint, controllerLog, null, jsonResult);
    }

    /**
     * 处理日志,持久化到数据库
     *
     * @param joinPoint     切点
     * @param controllerLog 注解
     * @param e             异常
     * @param jsonResult    返回结果
     */
    protected void handleLog(final JoinPoint joinPoint, Log controllerLog, final Exception e, Object jsonResult) {
        try {
            Logs operLog = new Logs();
            operLog.setStatus(BusinessStatus.SUCCESS.ordinal());
            // 请求的地址
            String ip = ServletUtils.getClientIP();
            operLog.setOperIp(ip);
            operLog.setOperUrl(StringUtils.substring(ServletUtils.getRequest().getRequestURI(), 0, 255));

            if (e != null) {
                operLog.setStatus(BusinessStatus.FAIL.ordinal());
                operLog.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 2000));
            }

            // 设置方法名称
            String className = joinPoint.getTarget().getClass().getName();
            String methodName = joinPoint.getSignature().getName();
            operLog.setMethod(className + "." + methodName + "()");
            // 设置请求方式
            operLog.setRequestMethod(ServletUtils.getRequest().getMethod());
            // 处理设置注解上的参数
            getControllerMethodDescription(joinPoint, controllerLog, operLog, jsonResult);
            // 设置消耗时间
            StopWatch stopWatch = KEY_CACHE.get();
            stopWatch.stop();
            operLog.setCostTime(stopWatch.getTime());
            // 发布事件保存数据库
            SpringUtils.context().publishEvent(operLog);
        } catch (Exception exp) {
            // 记录本地异常日志
            log.error("异常信息:{}", exp.getMessage());
            exp.printStackTrace();
        } finally {
            KEY_CACHE.remove();
        }
    }

    /**
     * 获取注解中对方法的描述信息 用于Controller层注解
     *
     * @param joinPoint  切点
     * @param log        注解
     * @param operLog    操作日志
     * @param jsonResult 返回结果
     * @throws Exception 异常
     */
    public void getControllerMethodDescription(JoinPoint joinPoint, Log log, Logs operLog, Object jsonResult) throws Exception {
        // 设置action动作
        operLog.setBusinessType(log.businessType().ordinal());
        // 设置标题
        operLog.setTitle(log.title());
        // 设置操作人类别
        operLog.setOperatorType(log.operatorType().ordinal());
        // 是否需要保存request,参数和值
        if (log.isSaveRequestData()) {
            // 获取参数的信息,传入到数据库中。
            setRequestValue(joinPoint, operLog, log.excludeParamNames());
        }
        // 是否需要保存response,参数和值
        if (log.isSaveResponseData() && ObjectUtil.isNotNull(jsonResult)) {
            operLog.setJsonResult(StringUtils.substring(JsonUtils.toJsonString(jsonResult), 0, 2000));
        }
    }

    /**
     * 获取请求的参数,放到log中
     *
     * @param joinPoint         切点
     * @param operLog           操作日志
     * @param excludeParamNames 排除字段
     * @throws Exception 异常
     */
    private void setRequestValue(JoinPoint joinPoint, Logs operLog, String[] excludeParamNames) throws Exception {
        Map<String, String> paramsMap = ServletUtils.getParamMap(ServletUtils.getRequest());
        String requestMethod = operLog.getRequestMethod();
        if (MapUtil.isEmpty(paramsMap) && HttpMethod.PUT.name().equals(requestMethod) || HttpMethod.POST.name().equals(requestMethod)) {
            String params = argsArrayToString(joinPoint.getArgs(), excludeParamNames);
            operLog.setOperParam(StringUtils.substring(params, 0, 2000));
        } else {
            MapUtil.removeAny(paramsMap, EXCLUDE_PROPERTIES);
            MapUtil.removeAny(paramsMap, excludeParamNames);
            operLog.setOperParam(StringUtils.substring(JsonUtils.toJsonString(paramsMap), 0, 2000));
        }
    }

    /**
     * 参数拼装
     *
     * @param paramsArray       参数数组
     * @param excludeParamNames 排除字段
     * @return 操作参数
     */
    private String argsArrayToString(Object[] paramsArray, String[] excludeParamNames) {
        StringJoiner params = new StringJoiner(" ");
        if (ArrayUtil.isEmpty(paramsArray)) {
            return params.toString();
        }
        for (Object o : paramsArray) {
            if (ObjectUtil.isNotNull(o) && !isFilterObject(o)) {
                String str = JsonUtils.toJsonString(o);
                Dict dict = JsonUtils.parseMap(str);
                if (MapUtil.isNotEmpty(dict)) {
                    MapUtil.removeAny(dict, EXCLUDE_PROPERTIES);
                    MapUtil.removeAny(dict, excludeParamNames);
                    str = JsonUtils.toJsonString(dict);
                }
                params.add(str);
            }
        }
        return params.toString();
    }

    /**
     * 判断是否需要过滤
     *
     * @param o 对象
     * @return 是否过滤
     */
    @SuppressWarnings("rawtypes")
    public boolean isFilterObject(final Object o) {
        Class<?> clazz = o.getClass();
        if (clazz.isArray()) {
            return MultipartFile.class.isAssignableFrom(clazz.getComponentType());
        } else if (Collection.class.isAssignableFrom(clazz)) {
            Collection collection = (Collection) o;
            for (Object value : collection) {
                return value instanceof MultipartFile;
            }
        } else if (Map.class.isAssignableFrom(clazz)) {
            Map map = (Map) o;
            for (Object value : map.values()) {
                return value instanceof MultipartFile;
            }
        }
        return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse || o instanceof BindingResult;
    }
}

注意,在处理日志,持久化到数据库时,使用了 SpringUtils.context().publishEvent(operLog); 发布事件保存数据库,即 Spring 的事件发布机制来异步地保存操作日志到数据库。

当这个事件被发布后,Spring 会寻找所有注册的并且能处理这个事件的监听器。这些监听器通常会实现 ApplicationListener 接口,并且在其 onApplicationEvent 方法中处理这个事件。

这种方式的好处是,事件的发布和处理是异步的,不会阻塞主线程的执行,从而提高了程序的性能。

  • @Async 注解用于指定异步方法,被注解的方法会在新的线程中执行。
  • @EventListener 注解用于指定一个事件监听器,它会监听所有被 Spring 发布的事件。
java 复制代码
@Async
@EventListener
public void recordLogs(Logs logs) {
   // 将日志保存到数据库
   insertLogs(logs);
}
  1. 在需要记录日志的接口上添加 @Log 注解。

定义好注解后,你可以在任何符合 @Target 元注解指定的 Java 元素上使用它。

java 复制代码
@Log(title = "小程序管理员", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(SysAdministratorBo bo, HttpServletResponse response) {
    List<SysAdministratorVo> list = sysAdministratorService.queryList(bo);
    ExcelUtil.exportExcel(list, "小程序管理员", SysAdministratorVo.class, response);
}
相关推荐
weixin_SAG4 分钟前
21天掌握javaweb-->第19天:Spring Boot后端优化与部署
java·spring boot·后端
m0_748247557 分钟前
SpringMVC跨域问题解决方案
java
Elcker8 分钟前
KOI技术-事件驱动编程(Sping后端)
java·spring·架构
GitNohup11 分钟前
Spring boot处理跨域问题
java·spring boot·跨域
Just_Paranoid22 分钟前
使用 IDE生成 Java Doc
java·开发语言·ide
西海天际蔚蓝37 分钟前
递归查询全量分页数据问题
java
SomeB1oody43 分钟前
【Rust自学】7.4. use关键字 Pt.2 :重导入与换国内镜像源教程
开发语言·后端·rust
新知图书1 小时前
Rust编程与项目实战-箱
开发语言·后端·rust
俎树振1 小时前
深入理解与优化Java二维数组:从定义到性能提升的全面指南
java·算法
SomeB1oody1 小时前
【Rust自学】7.3. use关键字 Pt.1:use的使用与as关键字
开发语言·后端·rust