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);
}
相关推荐
丶小鱼丶4 分钟前
链表算法之【合并两个有序链表】
java·算法·链表
张先shen33 分钟前
Elasticsearch RESTful API入门:全文搜索实战(Java版)
java·大数据·elasticsearch·搜索引擎·全文检索·restful
舒一笑42 分钟前
PandaCoder重大产品更新-引入Jenkinsfile文件支持
后端·程序员·intellij idea
PetterHillWater1 小时前
AI编程之CodeBuddy的小试
后端·aigc
天河归来1 小时前
springboot框架redis开启管道批量写入数据
java·spring boot·redis
没有了遇见1 小时前
Android 通过 SO 库安全存储敏感数据,解决接口劫持问题
android
hsx6661 小时前
使用一个 RecyclerView 构建复杂多类型布局
android
hsx6661 小时前
利用 onMeasure、onLayout、onDraw 创建自定义 View
android
合作小小程序员小小店1 小时前
web网页,在线%食谱推荐系统%分析系统demo,基于vscode,uniapp,vue,java,jdk,springboot,mysql数据库
vue.js·spring boot·vscode·spring·uni-app
守城小轩1 小时前
Chromium 136 编译指南 - Android 篇:开发工具安装(三)
android·数据库·redis