2023-01-18
实战设计
0

目录

操作留痕
操作留痕的理论基础
实现方式
数据采集方式
代码实现

对于全局日志的记录,大多数人都能立马想到使用aop环绕通知进行记录,如果现在有需求需要对用户的操作记录留痕呢?此文章就是在全局日志的基础上实现操作留痕

操作留痕

操作留痕的理论基础

信息安全的基本原则之一是“可追溯性”,即任何操作都应有迹可循。通过记录员工的操作行为,可以确保在发生数据泄露或其他安全事件时,能够迅速定位问题源头,采取相应措施。

行为分析是通过对员工日常操作行为的记录和分析,识别异常行为,从而预防潜在风险。管理层面则需要根据这些数据制定相应的管理策略,确保员工行为符合公司规范。

法律法规对数据保护和信息安全有明确要求。员工操作留痕不仅是企业内部管理的需要,也是满足外部合规要求的必要手段。

实现方式

实现的方式多种多样,常见的有2种方式:

  • 后端提供接口,前端每次操作都异步调用接口进行留痕
  • 由后端在接口调用的时候自动留痕

本文就是基于后端实现一套【高性能的操作日志留痕】,基于后端去实现操作留痕都会不可避免地对原代码有侵入性,但是我们要想出入侵性最低的方案 - 基于注解去实现。

对于不熟悉 AOP 的伙伴,可以先到官方文档上看下基本的使用 - Spring AOP 官方文档

数据采集方式

1、通过注解声明功能模块操作,由后端controller接口上声明

优点:便于后端问题排查
缺点:存在代码冗余,由于接口的复用性太多,定义功能模块与操作(新增编辑删除)等无法说明页面操作,例如app与erp都调用同一个接口查询列表,或者新增提交,一个接口就无法区分,只能重写的controller接口进行区分。

2、注解标记记录留痕,由前端请求头中声明功能模块操作,后端进行采集 (⭐️ 推荐

优点:操作留痕更精确
缺点:多负担一些网络IO的传输

相关信息

UTF-8编码中一个汉字也就是3-4个字节,一次接口调用一般在10个汉字左右,按照多的来算,平均每次调用40字节,对于消耗的那点网络IO可以忽略不计,推荐采用第二种方案进行数据采集

代码实现

既然是无侵入式的,那注解少不了,先定义好注解。

@Retention(RetentionPolicy.RUNTIME) @Target({ElementType.METHOD, ElementType.TYPE}) public @interface PassLog { }

操作留痕的表,可自行进行扩展,此处 browser 对于app的操作存储手机的唯一标识

sql
create table sys_log ( id bigint auto_increment primary key, operate_user_id int not null comment '操作人ID', operate_user_name varchar(64) not null comment '操作人Name', operate_module varchar(64) null comment '操作模块', operate_item varchar(128) null comment '操作项', interface_path varchar(512) not null comment '接口路径', method varchar(64) null comment '请求方式(get、post等)', ip varchar(128) not null comment 'ip', browser varchar(512) null comment '浏览器', method_name varchar(512) not null comment '方法名', result varchar(512) not null comment '响应结果', result_time bigint null comment '响应时间(单位:ms)', create_time datetime not null comment '创建时间' ) comment '系统日志';

实体类

java
@Data @Accessors(chain = true) @TableName("sys_log") public class SysLog { @TableId(value = "id", type = IdType.AUTO) private Long id; /** * 操作人ID */ @TableField(value = "operate_user_id") private Long operateUserId; /** * 操作人Name */ @TableField(value = "operate_user_name") private String operateUserName; /** * 操作模块 */ @TableField(value = "operate_module") private String operateModule; /** * 操作项 */ @TableField(value = "operate_item") private String operateItem; /** * 接口路径 */ @TableField(value = "interface_path") private String interfacePath; /** * 请求方式 */ @TableField(value = "method") private String method; /** * ip */ @TableField(value = "ip") private String ip; /** * 浏览器 */ @TableField(value = "browser") private String browser; /** * 方法名 */ @TableField(value = "method_name") private String methodName; /** * 响应结果 */ @TableField(value = "result") private String result; /** * 响应时间 */ @TableField(value = "result_time") private Long resultTime; /** * 创建时间 */ @TableField(value = "create_time") private LocalDateTime createTime; }

具体的代码实现逻辑如下:

  • 将所有controller接口的路径作为切点
  • 全局日志记录格式为 包名.类名#方法名 ,可通过快捷键进行快速定位

image.png

  • 实现 @AroundAfterThrowing 方法
  • 校验是否存在 PassLog 注解,存在则进行操作留痕
  • 记录接口执行成功或异常原因
  • 从请求头中获取User-AgentOperate_ModuleOperate_Item 记录
  • 从当前登录的用户中记录上操作人
  • 留痕使用线程池异步处理
  • 单例线程池
java
@Aspect @Slf4j @Component public class LogAspect implements Ordered, DisposableBean { @Resource private SysLogService sysLogService; /** * Get the order value of this object. * <p>Higher values are interpreted as lower priority. As a consequence, * the object with the lowest value has the highest priority (somewhat * analogous to Servlet {@code load-on-startup} values). * <p>Same order values will result in arbitrary sort positions for the * affected objects. * * @return the order value * @see #HIGHEST_PRECEDENCE * @see #LOWEST_PRECEDENCE */ @Override public int getOrder() { return LOWEST_PRECEDENCE; } @Pointcut("execution(* cn.liushigong.echoim.controller.*.*(..))") private void pointcutToLog() {} /** * 日志切面 */ @Around("pointcutToLog()") public Object log(ProceedingJoinPoint joinPoint) throws Throwable { // 方法签名 MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); // 类名+方法名 String methodName = signature.getDeclaringTypeName() + "#" + method.getName(); log.info("{}, params = {}", methodName, JSONUtil.toJsonStr(joinPoint.getArgs())); // 计时器 StopWatch stopWatch = new StopWatch(); stopWatch.start(); Object result = joinPoint.proceed(); stopWatch.stop(); // 构建日志 buildLog(method, stopWatch.getTotalTimeMillis(), methodName, JSONUtil.toJsonStr(result), null); return result; } @AfterThrowing(pointcut="pointcutToLog()", throwing="ex") public void doRecoveryActions(JoinPoint joinPoint, Exception ex) throws Throwable { // 方法签名 MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); // 类名+方法名 String methodName = signature.getDeclaringTypeName() + "#" + method.getName(); // 构建日志 buildLog(method, -1, methodName, null, ex); } public void buildLog(Method method, long takeTime, String methodName, String result, Throwable ex){ // 记录系统日志 boolean hasAnnotation = AnnotationUtil.hasAnnotation(method, PassLog.class); if (hasAnnotation) { SysLog sysLog = new SysLog(); sysLog.setResultTime(takeTime); sysLog.setResult(ex != null ? ex.getMessage() : "success"); HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest(); sysLog.setInterfacePath(request.getRequestURI()); sysLog.setMethod(request.getMethod()); sysLog.setIp(request.getRemoteAddr()); sysLog.setBrowser(request.getHeader("User-Agent")); sysLog.setOperateModule(request.getHeader("Operate_Module")); sysLog.setOperateItem(request.getHeader("Operate_Item")); sysLog.setMethodName(methodName); sysLog.setOperateUserId(0L); sysLog.setOperateUserName(""); sysLog.setCreateTime(LocalDateTime.now()); // 异步记录日志 LogThreadPool.execute(() -> { sysLogService.save(sysLog); }); } // 记录异常日志 if (ex != null) { log.info("{}, error = {}", methodName, ex.getMessage(), ex); } else { log.info("{}, result = {}, takeTime [{}] ms", methodName, result, takeTime); } } @Override public void destroy() throws Exception { LogThreadPool.shutdown(false); } /** * 日志线程池 */ static class LogThreadPool { /** * 执行器 */ private static ThreadPoolExecutor executor; /** * 私有构造器 拒绝new */ private LogThreadPool() { } static { init(); } /** * 初始化 */ public static synchronized void init() { //线程池已存在则立即关闭 if (null != executor) { executor.shutdown(); } //创建线程池 executor = createThreadPool(); } /** * 初始化线程池 */ private static ThreadPoolExecutor createThreadPool() { // 原子类 AtomicInteger threadNumber = new AtomicInteger(1); // CPU核心数 int coreNum = Runtime.getRuntime().availableProcessors(); // 创建线程池 return new ThreadPoolExecutor( coreNum + 1, coreNum * 2, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(2000), r -> new Thread(r, "Log-thread-" + threadNumber.getAndIncrement()), new ThreadPoolExecutor.CallerRunsPolicy()); } /** * 关闭线程池 * * @param isNow 是否立即关闭 */ public static synchronized void shutdown(boolean isNow) { if (null != executor) { if (isNow) { executor.shutdownNow(); } else { executor.shutdown(); } } } /** * 执行线程 * * @param runnable runnable */ public static void execute(Runnable runnable) { executor.execute(runnable); } /** * 执行线程 * * @param task task * @param <T> T * @return Future */ public static <T> Future<T> submit(Callable<T> task) { return executor.submit(task); } /** * 执行线程 * * @param runnable runnable * @return Future */ public static Future<?> submit(Runnable runnable) { return executor.submit(runnable); } } }

全局日志在结合TraceId,可以参考全链路日志TraceId穿透异步线程与MQ消息,这才是完整的一套高性能日志的解决方案,最终效果如下

image.png

image.png

本文作者:柳始恭

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!