SpringBoot AOP 优雅的记录日志信息

1,823 阅读7分钟

一、 简介

  • 在SpringBoot项目中如何优雅的记录日志,其实在很多开源项目上已经有一块功能,但是有些日志信息并不能满足我们的需要,前台和后台的日志信息也不一样。为了解决这个问题编写一个统一的日志拦截器。
  • 这里先看最终返回结果

使用方式

@Log(title = "测试", businessType = BusinessTypeEnum.TEST, excludeParamNames = {"password"})

返回结果

{
  "code": "00000",
  "msg": "操作成功",
  "data": [
    {
      "id": 1722180686858776577,
      "title": "测试",
      "businessType": "测试",
      "method": "com.yifei.controller.system.SysOperateLogController.testAaa()",
      "requestMethod": "GET",
      "operatorType": "后台",
      "operatorName": "游客",
      "operatorUrl": "/log/test/log",
      "operatorIp": "ip",
      "operatorLocation": "XX市",
      "operatorParam": "{\"username\":[\"username\"]}",
      "operatorBrowser": "Chrome 11",
      "operatorOs": "Windows 10",
      "jsonResult": "{\"code\":\"00000\",\"msg\":\"操作成功\",\"data\":[]}",
      "status": "正常",
      "errorMsg": "",
      "costTime": 153,
      "createTime": "2023-00-00 00:00:00"
    }
  ]
}
  • 也不用重复造轮子,只在优秀的代码上进行更改即可这里采用若依的记录日志代码进行重构,目前未使用FastJson

二、编写代码

1. 定义枚举类( 通用枚举类接口 , 日志中使用到的枚举类)

  • 通用枚举类接口
public interface IBaseEnum<T> {

    T getValue();

    String getLabel();

    /**
     * 根据值获取枚举
     *
     * @param value
     * @param clazz
     * @param <E>   枚举
     * @return
     */
    static <E extends Enum<E> & IBaseEnum> E getEnumByValue(Object value, Class<E> clazz) {
        Objects.requireNonNull(value);
        EnumSet<E> allEnums = EnumSet.allOf(clazz); // 获取类型下的所有枚举
        E matchEnum = allEnums.stream()
                .filter(e -> ObjectUtil.equal(e.getValue(), value))
                .findFirst()
                .orElse(null);
        return matchEnum;
    }

    /**
     * 根据文本标签获取值
     *
     * @param value
     * @param clazz
     * @param <E>
     * @return
     */
    static <E extends Enum<E> & IBaseEnum> String getLabelByValue(Object value, Class<E> clazz) {
        Objects.requireNonNull(value);
        EnumSet<E> allEnums = EnumSet.allOf(clazz); // 获取类型下的所有枚举
        E matchEnum = allEnums.stream()
                .filter(e -> ObjectUtil.equal(e.getValue(), value))
                .findFirst()
                .orElse(null);

        String label = null;
        if (matchEnum != null) {
            label = matchEnum.getLabel();
        }
        return label;
    }


    /**
     * 根据文本标签获取值
     *
     * @param label
     * @param clazz
     * @param <E>
     * @return
     */
    static <E extends Enum<E> & IBaseEnum> Object getValueByLabel(String label, Class<E> clazz) {
        Objects.requireNonNull(label);
        EnumSet<E> allEnums = EnumSet.allOf(clazz); // 获取类型下的所有枚举
        String finalLabel = label;
        E matchEnum = allEnums.stream()
                .filter(e -> ObjectUtil.equal(e.getLabel(), finalLabel))
                .findFirst()
                .orElse(null);

        Object value = null;
        if (matchEnum != null) {
            value = matchEnum.getValue();
        }
        return value;
    }


}
  • 其余的枚举类就不过多介绍了,编写差不多只是内容不一样,这里提供项目地址
public enum BusinessTypeEnum implements IBaseEnum<Integer> {

    OTHER(0, "其他"),
    INSERT(1, "新增"),
    UPDATE(2, "修改"),
    DELETE(3, "删除"),
    EXPORT(4, "导出"),
    IMPORT(5, "导入"),
    UPLOAD(6, "上传文件"),
    LOGIN(9, "登录"),
    LOGOUT(10, "退出"),
    TEST(999, "测试");

    @Getter
    @EnumValue //  Mybatis-Plus 提供注解表示插入数据库时插入该值
    private Integer value;

    @Getter
    @JsonValue //  表示对枚举序列化时返回此字段
    private String label;

    BusinessTypeEnum(Integer value, String label) {
        this.value = value;
        this.label = label;
    }
}

2. 编写日志注解

  • 此注解和若依一样,重点是对于枚举类的展示有所不一样👆
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface Log {

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

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

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

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

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

    /**
     * 排除指定的请求参数
     */
    String[] excludeParamNames() default {};

}

3. 导入需要的依赖

<!-- 浏览器信息解析 -->
<dependency>
    <groupId>eu.bitwalker</groupId>
    <artifactId>UserAgentUtils</artifactId>
    <version>${user.agent.version}</version>
</dependency>

<!-- hutool-all -->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>${hutool-all.version}</version>
</dependency>

4. 编写 AOP 对注解进行拦截

4.1 确定思路

  • 我们需要获取浏览器和设备信息 (User Agent)
  • 我们需要解析用户IP信息 (离线获取真实IP地址或者在线请求真实IP地址)
  • 我们需要获取到请求相关信息 (request)
  • 我们需要确定调用的方法名 (JoinPoint)
  • 我们不能影响用户体验让接口速度降低太多(异步处理耗时任务)
  • 我们需要过滤一些敏感信息(反射解析属性拦截)
  • 额外补充: 如果拦截 MultipartFile 等类时,会报错。
  • 额外补充: 请求参数和响应内容有时会过长,所以需要限定长度

4.2 先编写对应工具类

这里就不占用太多内容,方便大家阅读主要信息,工具类会写出作用并且附上链接

  • 提前确定好思路,先把能编写的工具类编写好,这样在写主体类的时候更加方便

4.2.3 解析IP地址的工具类

IpUtil.java

4.2.3 解析IP的真实地址

Address.java

4.2.4 异步线程

ThreadPoolConfig

4.2.5 截取字符串

  • 使用默认字符串截取,可以查看到Java源码中是直接抛出异常的,所以我们要对字符串长度进行判断
/**
 * 截取字符串
 *
 * @param str   字符串
 * @param start 开始
 * @param end   结束
 * @return 结果
 */
private String substring(final String str, int start, int end) {
    if (str == null) {
        return "";
    }
    if (end < 0) {
        end = str.length() + end;
    }
    if (start < 0) {
        start = str.length() + start;
    }
    if (end > str.length()) {
        end = str.length();
    }
    if (start > end) {
        return "";
    }
    if (start < 0) {
        start = 0;
    }
    if (end < 0) {
        end = 0;
    }
    return str.substring(start, end);
}

4.2.6 反射拦截排除过的字段

/**
 * 过滤敏感字段
 *
 * @param args              参数对象
 * @param excludeParamNames 需要排除的参数名
 */
private String excludeParam(Object[] args, String[] excludeParamNames) {
    // 1. 对象为空直接返回
    if (ObjectUtils.isEmpty(args)) return "";
    // 2. 遍历对象排除对应属性
    for (int i = 0; i < args.length; i++) {
        // 2.1 解析对象字段
        Class<?> aClass = args[i].getClass();
        if (isFilterObject(args[i])) {
            // 2.1.1 不能进行解析的属性置空
            args[i] = null;
            continue;
        }
        Field[] declaredFields = aClass.getDeclaredFields();
        // 2.2 记录每个对象的排除数量
        int count = excludeParamNames.length;
        for (Field declaredField : declaredFields) {
            // 2.3 判断对象字段名是否被排除   ArrayUtils.contains也可以
            if (ArrayUtil.contains(excludeParamNames, declaredField.getName())) {
                --count;
                declaredField.setAccessible(true);
                try {
                    declaredField.set(args[i], null);
                } catch (IllegalAccessException e) {
                    log1.error("排除字段发生错误 {}", declaredField.getName());
                }
            }
            // 2.4 排除完字段终止本次循环
            if (count == 0) break;
        }
    }
    // 3. 返回 json 字符串
    return JSONUtil.toJsonStr(args);
}

4.2.7 对无法记录的对象过滤

public boolean isFilterObject(final Object o) {
    Class<?> clazz = o.getClass();
    if (clazz.isArray()) {
        return clazz.getComponentType().isAssignableFrom(MultipartFile.class);
    } 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.entrySet()) {
            Map.Entry entry = (Map.Entry) value;
            return entry.getValue() instanceof MultipartFile;
        }
    }
    return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse
            || o instanceof BindingResult;
}

4.3 开始 AOP 编写

4.3.1 AOP 的配置信息以及需要用到的参数

// scheduledExecutorService 任务延时毫秒数
private static final long OPERATE_DELAY_TIME = 60;

@Autowired
@Qualifier("scheduledExecutorService")
private ScheduledExecutorService scheduledExecutorService;

@Autowired
private SysOperateLogService sysOperateLogService;

/**
 * 计算操作消耗时间
 */
private static final ThreadLocal<Long> TIME_THREAD_LOCAL = new NamedThreadLocal<>("Cost Time");
/**
 * 未登录用户名
 */
public static final String OPERATOR_DEFAULT_NAME = "游客";


/**
 * 处理请求前执行
 */
@Before(value = "@annotation(controllerLog)")
public void boBefore(JoinPoint joinPoint, Log controllerLog) {
    TIME_THREAD_LOCAL.set(System.currentTimeMillis());
}

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

/**
 * 拦截异常操作
 *
 * @param joinPoint 切点
 * @param e         异常
 */
@AfterThrowing(value = "@annotation(controllerLog)", throwing = "e")
public void doAfterThrowing(JoinPoint joinPoint, Log controllerLog, Exception e) {
    handleLog(joinPoint, controllerLog, e, null);
}

4.3.2 编写具体需要保存的日志信息 ( 重点 )

private void handleLog(JoinPoint joinPoint, Log controllerLog, Exception e, Object jsonResult) {
    try {
        SysOperateLog sysOperateLog = new SysOperateLog();
        // 1. 计算消耗时间
        sysOperateLog.setCostTime(System.currentTimeMillis() - TIME_THREAD_LOCAL.get());
        // 2. 填写请求头信息
        HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
        // 3. 获取当前线程的用户
        SysUserDetails user = SecurityUtil.getUser();
        // ===============  根据以上信息填写日志对象  ===============
        // 1.1 获取请求方式
        sysOperateLog.setRequestMethod(request.getMethod());
        // 1.2 获取请求 ip
        sysOperateLog.setOperatorIp(IpUtil.getIpAddr(request));
        // 1.3 获取请求 url
        sysOperateLog.setOperatorUrl(substring(request.getRequestURI(), 0, 255));
        // 2. 解析方法名
        sysOperateLog.setMethod(joinPoint.getTarget().getClass().getName() + "." + joinPoint.getSignature().getName() + "()");
        // 3. 获取 user-agent 方便后续获取 os,browser
        String headerUserAgent = request.getHeader("User-Agent");
        // 3.1 解析 userAgent
        UserAgent userAgent = UserAgent.parseUserAgentString(headerUserAgent);
        // 3.2 填写 userAgent 信息 (浏览器及操作系统)
        sysOperateLog.setOperatorBrowser(userAgent.getBrowser().getName());
        sysOperateLog.setOperatorOs(userAgent.getOperatingSystem().getName());
        // 4. 解析@Log参数
        getControllerMethodDescription(joinPoint, request, controllerLog, sysOperateLog, jsonResult);
        // 5. 记录是否操作成功
        if (ObjectUtils.isEmpty(e)) {
            sysOperateLog.setStatus(OperatorStatusEnum.OK);
        } else {
            sysOperateLog.setStatus(OperatorStatusEnum.ERROR);
            sysOperateLog.setErrorMsg(substring(e.getMessage(), 0, 2000));
        }
        // 6. 解析当前线程操作的用户
        if (ObjectUtils.isEmpty(user)) {
            // 6.1 查看是否为访问接口
            if (request.getRequestURI().equals(SecurityConstants.SECURITY_LOGIN_PATH)) {
                // 6.1.1 记录尝试的登录用户名
                sysOperateLog.setOperatorName(((LoginFrom) joinPoint.getArgs()[0]).getUsername());
            } else {
                // 6.1.2 未到登录接口表示为游客
                sysOperateLog.setOperatorName(OPERATOR_DEFAULT_NAME);
            }
        } else {
            // 6.2 设置当前线程操作的用户名
            sysOperateLog.setOperatorName(user.getUsername());
        }
        scheduledExecutorService.schedule(saveLogTask(sysOperateLog), OPERATE_DELAY_TIME, TimeUnit.MILLISECONDS);
    } catch (Exception error) {
        log1.error("记录日志时发生错误 : ", error);
    } finally {
        TIME_THREAD_LOCAL.remove();
    }
}

4.3.3 拆分对注解的解析

/**
 * 获取注解中对方法的描述信息 用于Controller层注解
 *
 * @param request
 * @param controllerLog 日志注解
 * @param sysOperateLog 操作日志
 */
private void getControllerMethodDescription(JoinPoint joinPoint, HttpServletRequest request, Log controllerLog, SysOperateLog sysOperateLog, Object jsonResult) {
    // 1. 解析 @Log 信息
    sysOperateLog.setTitle(controllerLog.title());
    sysOperateLog.setBusinessType(controllerLog.businessType());
    sysOperateLog.setOperatorType(controllerLog.operatorType());
    // 2. 是否保存请求参数
    if (controllerLog.isSaveRequestData()) {
        // 2.1. 获取 request 中的参数
        Map<String, String[]> parameterMap = request.getParameterMap();
        String method = request.getMethod();
        // 2.2. 对不同请求方式进行参数过滤
        if (CollectionUtils.isEmpty(parameterMap)
                && (HttpMethod.PUT.name().equals(method)
                || HttpMethod.POST.name().equals(method))
        ) {
            // 2.1.1 排除 joinPoint.getArgs() 含有的敏感字段
            String params = excludeParam(joinPoint.getArgs(), controllerLog.excludeParamNames());
            sysOperateLog.setOperatorParam(substring(params, 0, 2000));
        } else {
            // 2.1.2 排除 其他请求 含有的敏感字段
            HashMap<String, String[]> params = new HashMap<>(parameterMap);
            for (String excludeParam : controllerLog.excludeParamNames()) {
                params.remove(excludeParam);
            }
            sysOperateLog.setOperatorParam(substring(JSONUtil.toJsonStr(params), 0, 2000));
        }
    }
    // 3. 是否保存响应参数
    if (controllerLog.isSaveResponseData()) {
        // 3.1 响应参数设置(0 , 2000 ,防止超出数据库最大限制)
        sysOperateLog.setJsonResult(substring(JSONUtil.toJsonStr(jsonResult), 0, 2000));
    }
}

4.3.4 异步任务的调用

/**
 * 保存日志信息,以及获取ip真实地址的耗时任务
 *
 * @param sysOperateLog 日志信息
 * @return 任务
 */
private Runnable saveLogTask(final SysOperateLog sysOperateLog) {
    return () -> {
        // 7. 获取 ip 真实地址
        sysOperateLog.setOperatorLocation(AddressUtil.getRealAddressByIP(sysOperateLog.getOperatorIp()));
        // 8. 保存日志信息到数据库
        sysOperateLogService.save(sysOperateLog);
    };
}

三、进阶思考

1. 是否可以通过 User Agent对一些爬虫请求进行最简单层次的拦截

比较简单,可自行思考

1.1 附上多规则限流的文章地址

SpringBoot AOP 通过Redis对接口进行多规则限流

2. 是否可以将错误分等级,严重的日志通过异步邮件的方式发送给开发者

后续更新...