系统全局日志模型设计

我做的项目中,日志分为两类:登录日志和操作日志
登录日志,顾名思义:记录登录登出的日志内容
操作日志:记录用户对数据的新增,更改,删除的日志记录

登录日志

在登录和登出的界面使用异步线程做日志记录。

操作日志

原理是用过Aop(Aspect Oriented Programming)面相切面编程,使用异步线程,打注解的方式进行操作记录的。

流程图

功能划分

**LogAspect:**日志切面注解逻辑类,该类是在对标记了@Log注解的controller控制层进行逻辑处理,包括对注解的解析,以及日志的插入。
**ThreadPoolConfig:**线程池配置类,日志操作本身为非业务代码,为了不影响性能,并且应该与任务线程可以并行执行的,需要开启异步线程执行。
**AsyncManager:**异步任务管理器,为工具类,用于指定线程池的使用,停止等操作。
**AsyncFactory:**异步任务工厂,就是对于异步任务进行抽象出方法,并使用工厂模式,方便使用。

日志类的设计

在涉及到业务时,需要设计日志对象,SysOperLog为日志类

/**
 * 操作日志记录表 oper_log
 *
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName(TablePool.SYS_OPER_LOG)
public class SysOperLog implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 日志主键
     */
    @TableId(value = "oper_id", type = IdType.AUTO)
    private Long operId;

    /**
     * 操作模块
     */
    @TableField(value = "title")
    private String title;

    /**
     * 业务类型(0其它 1新增 2修改 3删除)
     */
    @TableField(value = "business_type")
    private Integer businessType;

    /**
     * 业务类型数组
     */
    @TableField(value = "business_type")
    private Integer[] businessTypes;

    /**
     * 请求方法
     */
    @TableField(value = "method")
    private String method;

    /**
     * 请求方式
     */
    @TableField(value = "request_method")
    private String requestMethod;

    /**
     * 操作类别(0其它 1后台用户 2手机端用户)
     */
    @TableField(value = "operator_type")
    private Integer operatorType;

    /**
     * 操作人员
     */
    @TableField(value = "oper_name")
    private String operName;

    /**
     * 部门名称
     */
    @TableField(value = "dept_name")
    private String deptName;

    /**
     * 请求url
     */
    @TableField(value = "oper_url")
    private String operUrl;

    /**
     * 操作地址
     */
    @TableField(value = "oper_ip")
    private String operIp;

    /**
     * 操作地点
     */
    @TableField(value = "oper_location")
    private String operLocation;

    /**
     * 请求参数
     */
    @TableField(value = "oper_param")
    private String operParam;

    /**
     * 返回参数
     */
    @TableField(value = "json_result")
    private String jsonResult;

    /**
     * 操作状态(0正常 1异常)
     */
    @TableField(value = "status")
    private Integer status;

    /**
     * 错误消息
     */
    @TableField(value = "error_msg")
    private String errorMsg;

    /**
     * 操作时间
     */
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    @TableField(value = "oper_time")
    private LocalDateTime operTime;

    /**
     * 消耗时间
     */
    @TableField(value = "cost_time")
    private Long costTime;

    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    @TableField(exist = false)
    private Map<String, Object> params = new HashMap<>();

}

线程池配置

线程池配置代码:

/**
 * 线程池配置
 **/
@Configuration
public class ThreadPoolConfig {
    // 核心线程池大小
    private int corePoolSize = 50;

    // 最大可创建的线程数
    private int maxPoolSize = 200;

    // 队列最大长度
    private int queueCapacity = 1000;

    // 线程池维护线程所允许的空闲时间
    private int keepAliveSeconds = 300;

    // .......
    // 其他bean注入,暂时没有用到

    
    /**
     * 执行周期性或定时任务
     */
    @Bean(name = "scheduledExecutorService")
    protected ScheduledExecutorService scheduledExecutorService() {
        return new ScheduledThreadPoolExecutor(corePoolSize,
                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);
                Threads.printException(r, t);
            }
        };
    }
}

配置的是scheduledExecutorService一个线程池,专门用来执行定时任务/周期任务的,防止日志的插入影响主线程跑任务的性能。
注意:配置时线程池拒绝策略是CallerRunsPolicy(),呼叫线程执行策略,即 当执行的任务数量已经超过最大等待线程数时,就会通过执行异步线程的线程(我这个项目中我是使用main主线程的,虽然可能会影响性能,但日志安全得到保障)来执行

异步任务管理器

代码

/**
 * 异步任务管理器
 *
 */
public class AsyncManager {
    private static AsyncManager ME = new AsyncManager();
    /**
     * 操作延迟10毫秒
     */
    private final int OPERATE_DELAY_TIME = 10;
    /**
     * 异步操作任务调度线程池
     */
    private ScheduledExecutorService executor = SpringUtils.getBean("scheduledExecutorService");

    /**
     * 单例模式
     */
    private AsyncManager() {
    }

    public static AsyncManager me() {
        return ME;
    }

    /**
     * 执行任务
     *
     * @param task 任务
     */
    public void execute(TimerTask task) {
        executor.schedule(task, OPERATE_DELAY_TIME, TimeUnit.MILLISECONDS);
    }

    /**
     * 停止任务线程池
     */
    public void shutdown() {
        Threads.shutdownAndAwaitTermination(executor);
    }
}

异步任务工厂

当产生一个操作日志的时候,就调用该工厂类的recordOper()方法

/**
 * 异步工厂(产生任务用)
 */
@Slf4j
public class AsyncFactory {

    /**
     * 操作日志记录
     *
     * @param operLog 操作日志信息
     * @return 任务task
     */
    public static TimerTask recordOper(final SysOperLog operLog) {
        return new TimerTask() {
            @Override
            public void run() {
                // 通过工具类获取操作请求的IP地址信息
                operLog.setOperLocation(AddressTool.getRealAddressByIP(operLog.getOperIp()));
                // 操作时间
                operLog.setOperTime(LocalDateTime.now());
                // 插入到操作日志表中
                SpringUtils.getBean(SysOperLogMapper.class).insertSysOperLog(operLog);
            }
        };
    }
}

日志切面注解逻辑类

注意:在写注解逻辑类之前,pom需要引入aspect-j 或者 aspectjweaver的依赖,否则会报错A

image.png
若是有starter-aop包含了也是可以的。

创建一个Log注解:

/**
 * 自定义操作日志记录注解
 */
@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Log {
    /**
     * 模块
     */
    public String title() default "";

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

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

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

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

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

在某一个controller上进行@Log()注解,如下:
image.png

在此处就是一个切点,当请求该接口时,会触发该切点,从而执行LogAspect.java日志切面的逻辑:

/**

 * 操作日志记录处理
   */
   @Aspect
   @Component
   public class LogAspect {
   /**

    * 排除敏感属性字段
      */
      public static final String[] EXCLUDE_PROPERTIES = {"password", "oldPassword", "newPassword", "confirmPassword"};
      private static final Logger log = LoggerFactory.getLogger(LogAspect.class);
      /**
    * 计算操作消耗时间
      */
      private static final ThreadLocal<Long> TIME_THREADLOCAL = new NamedThreadLocal<Long>("Cost Time");

   /**

    * 处理请求前执行
      */
      @Before(value = "@annotation(controllerLog)")
      public void boBefore(JoinPoint joinPoint, Log controllerLog) {
      TIME_THREADLOCAL.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);
      }

   protected void handleLog(final JoinPoint joinPoint, Log controllerLog, final Exception e, Object jsonResult) {
       try {
           // 获取当前的用户
           LoginUser loginUser = SecurityUtils.getLoginUser();

           // *========数据库日志=========*//
           SysOperLog operLog = new SysOperLog();
           operLog.setStatus(BusinessStatus.SUCCESS.ordinal());
           // 请求的地址
           String ip = IpUtils.getIpAddr();
           operLog.setOperIp(ip);
           operLog.setOperUrl(StringUtils.substring(ServletUtils.getRequest().getRequestURI(), 0, 255));
           if (loginUser != null) {
               operLog.setOperName(loginUser.getUsername());
           }
           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);
           // 设置消耗时间
           operLog.setCostTime(System.currentTimeMillis() - TIME_THREADLOCAL.get());
           // 保存数据库
           AsyncManager.me().execute(AsyncFactory.recordOper(operLog));
       } catch (Exception exp) {
           // 记录本地异常日志
           log.error("异常信息:{}", exp.getMessage());
       } finally {
           TIME_THREADLOCAL.remove();
       }

   }

   /**

    * 获取注解中对方法的描述信息 用于Controller层注解
      *
    * @param log     日志
    * @param operLog 操作日志
    * @throws Exception
      */
      public void getControllerMethodDescription(JoinPoint joinPoint, Log log, SysOperLog 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() && Objects.nonNull(jsonResult)) {
          operLog.setJsonResult(StringUtils.substring(JSON.toJSONString(jsonResult), 0, 2000));
      }
      }

   /**

    * 获取请求的参数,放到log中
      *
    * @param operLog 操作日志
    * @throws Exception 异常
      */
      private void setRequestValue(JoinPoint joinPoint, SysOperLog operLog, String[] excludeParamNames) throws Exception {
      Map<?, ?> paramsMap = ServletUtils.getParamMap(ServletUtils.getRequest());
      String requestMethod = operLog.getRequestMethod();
      if (MapUtils.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 {
          operLog.setOperParam(StringUtils.substring(JSON.toJSONString(paramsMap, excludePropertyPreFilter(excludeParamNames)), 0, 2000));
      }
      }

   /**

    * 参数拼装
      */
      private String argsArrayToString(Object[] paramsArray, String[] excludeParamNames) {
      String params = "";
      if (ArrayUtils.isNotEmpty(paramsArray)) {
          for (Object o : paramsArray) {
              if (Objects.nonNull(o) && !isFilterObject(o)) {
                  try {
      // 对于敏感词汇进行过滤,如密码,重置密码登数据,是不记录在日志上的
                      String jsonObj = JSON.toJSONString(o, excludePropertyPreFilter(excludeParamNames));
                      params += jsonObj.toString() + " ";
                  } catch (Exception e) {
                  }
              }
          }
      }
      return params.trim();
      }

   /**

    * 忽略敏感属性
      */
      public PropertyPreExcludeFilter excludePropertyPreFilter(String[] excludeParamNames) {
      return new PropertyPreExcludeFilter().addExcludes(ArrayUtils.addAll(EXCLUDE_PROPERTIES, excludeParamNames));
      }

   /**

    * 判断是否需要过滤的对象。
      *
    * @param o 对象信息。
    * @return 如果是需要过滤的对象,则返回true;否则返回false。
      */
      @SuppressWarnings("rawtypes")
      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;
      }
      }

handlelog()方法业务逻辑方法,对进入doAfterReturning(),插入数据库的日志状态为成功,若进入doAfterThrowing()的状态为失败。

注意:在记录日志的时候,需要排除敏感属性字段,橙色标注的代码即为关键

测试

即拿重置密码的接口为例:
controller层:
image.png

1、对【重置密码】功能验证

image.png

2、断点进入前置通知

请求接口,首先触发切点,进入LogAspect.java的boBefore()前置通知方法中,
image.png
保存方法开始的执行时间

3、进入controller的业务方法

进入controller层,执行重置密码的业务方法
image.png

4、进入后置通知

业务执行完毕后,执行LogAspect 的后置通知方法
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XYIKqAQl-1692945104272)(https://cdn.nlark.com/yuque/0/2023/png/28496166/1692869461262-0f9a3d6f-d085-4a7e-870b-5e538bcf5d5a.png#averageHue=%231c202e&clientId=u746b4af5-9691-4&from=paste&height=283&id=u0b6669df&originHeight=283&originWidth=801&originalType=binary&ratio=1&rotation=0&showTitle=false&size=36178&status=done&style=none&taskId=uff68d167-12e0-4489-9987-fc0021b6a65&title=&width=801)]

进入日志处理逻辑方法
image.png
红色框住的是对在controller层@Log注解上参数的处理,里面还有对敏感词汇的过滤处理

进入getControllerMethodDescription()方法,发现joinPoint中含有password,需要进行**脱敏**操作,进入setRe
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hmwFYbc1-1692945104272)(https://cdn.nlark.com/yuque/0/2023/png/28496166/1692870158257-df4a8b7e-ec30-4678-88aa-78de0e268399.png#averageHue=%23242633&clientId=u746b4af5-9691-4&from=paste&height=826&id=uf4c844e6&originHeight=826&originWidth=1107&originalType=binary&ratio=1&rotation=0&showTitle=false&size=142295&status=done&style=none&taskId=u0f36e26f-e749-4698-bffd-dc7bda98613&title=&width=1107)]

进入setRequestValue() 方法image.png

进入argsArrayToString()传入一个在主街上手动设置的过滤字段数组
image.png
第一个红框中的JSON.toJSONString(o, excludePropertyPreFilter(excludeParamNames))方法,是对字段的拼接,其中excludePropertyPreFilter()方法中是包含默认过滤的属性字段的:
image.png

JSON.toJSONString()第二个参数就是过滤参数:
image.png

jsonObj中没有password的值,对指定字段脱敏成功:
image.png

5、回看查看操作日志记录

看到操作日志中,显示信息**成功由于我打了断点,故消耗时间很长**
image.png

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值