/operation-log-parent

操作日志生成组件、系统变更日志生成组件、审计日志生成组件

Primary LanguageJava

operation-log-parent

操作日志生成组件(操作日志又称系统变更日志、审计日志等)

场景

不管是B端还是C端系统,在用户使用过程中,都会涉及到对相关资源进行更新或者删除的操作,如电商系统中商家修改商品售价,OA系统中管理员修改用户的权限等,数据库中一般记录的都是资源的最后修改时间和修改人。第一是可读性比较差,只能是程序员能够查询使用,第二是缺少修改前的值,无法对数据进行追溯。

问题

  1. 如何生成可读性高的操作日志
  2. 操作日志内容包含修改前后的值,方便后期的数据追溯

如何生成可读性高的操作日志

一个可读性高的操作日志,应包含下面几部分

  • 操作人:张三
  • 操作时间:2022-03-23 19:00:00
  • 业务模块:商品
  • 业务标识号:100878(这里是操作的资源对象id,当前案例是商品id)
  • 操作内容:修改了商品价格,xxxx

操作日志内容包含修改前后的值

  • 操作内容:修改了商品价格,从12¥调整到0.1¥
  • 操作内容:修改了商品价格,从14¥调整到0.01¥

理想的操作日志列表展示

操作人 操作时间 业务模块 业务标识号 操作内容
张三 2022-03-23 19:00:00 商品 100878 修改了商品价格,从12¥调整到0.1¥
李四 2022-03-24 19:00:00 商品 200878 修改了商品价格,从14¥调整到0.01¥

如何优雅的生成操作日志

方案一:手动在业务代码中记录(不够优雅)

所有的埋点在业务代码里面手动埋入,在变更事件触发之前,查询一次资源对应的状态并记录在内存中,变更之后再记录变更后资源的状态,最后将前后状态、操作人、变更时间一起写到数据库埋点的代码中。

public void updateApp(SmtApp newApp){
   
     //操作日志实体对象
    OperateLog operateLog = new OperateLog();
    operateLog.setUserId("当前用户ID");
    //业务对象标识-这里是应用id
    operateLog.setBizNo(newApp.id);
    //操作发生时间
    operateLog.setCreatedTime(new Date);
    //操作类别,这里是应用变更
    operateLog.setCategory("应用变更");
    //操作日志详情,记录操作的业务对象的变更信息
    operateLog.setDetail("更新了应用信息,应用名称:oldName --> newName");
    //保存操作日志
    operateLogService.save(operateLog);
    
    /******忽略后续的应用更新代码************/
}

优点:

  • 方案简单,没有难度,堆人堆时间就能完成,简单明了

缺点:

  • 业务侵入性大,完全耦合正常的业务
  • 扩展性非常差,想增加其他维度的埋点时,需要修改所有埋点的业务代码

方案二:使用 Canal 监听数据库记录操作日志(不够优雅)

canal 是一款基于 MySQL 数据库增量日志解析,提供增量数据订阅和消费的开源组件,通过采用监听数据库 Binlog 的方式,这样可以从底层知道是哪些数据做了修改,然后根据更改的数据记录操作日志。

优点:

  • 和业务逻辑完全分离

缺点:

  • 难以记录操作前的内容
  • 只能针对数据库的更改做操作记录,如涉及到和外部交互的部分,无法记录,如发送邮件、短信、RPC调用
  • 记录的操作结果内容只适合开发人员看,无法给到产品和运营人员使用

方案三:基于AOP方法注解实现操作日志

为了解决上面几个方案所带来的问题,一般采用 AOP 的方式记录日志,让操作日志和业务逻辑解耦,接下来看一个简单的 AOP 日志的例子。伪代码如下:

@LogRecordAnnotation(detail = "更新了用户名称,从{#oldName}改为{#newName}", bizNo = "#userId", category = "用户更改")
public void updateNameById(Long userId, Sting oldName,String newName) {
    // do update action
}

使用AOP方案优雅的记录操作日志

结合上一节如何生成可读性高的操作日志,那么AOP注解接口需要包含以下几个关键属性:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface LogRecordAnnotation {

    String operator() default "";

    String bizNo();

    String category();

    String detail() default "";

    String condition() default "true";
}
属性 是否必须 说明
operator 操作人
bizNo 操作的业务模块
category 操作的业务资源标识
detail 操作日志详情
condition 操作日志记录条件,表达式返回boolean类型

conditon属性我们稍后再展开说明

operator属性设置成非必填主要是为了减少冗余的赋值操作,正常的项目中,都会保存当前的用户信息到一个请求上下文中,如UserContext,操作日志组件提供统一的OperatorGetService接口,由使用方实现,返回当前用户信息

//组件提供接口
public interface OperatorGetService {

    Operator getUser();

    @Data
    class Operator {

        private String id;

        private String name;
    }
}
//使用方实现
public static class MarketOperatorGetServiceImpl implements OperatorGetService {
    @Override
    public Operator getUser() {
        Long userId = UserContext.getUserId();
        String userName = UserContext.getRealName();
        return Operator.builder()
                .id(String.valueOf(userId))
                .name(userName)
                .build();
    }
}

下面的讲解统一使用更新用户信息为例子展开

    @Data
    public class User {
        //用户id
        private Integer id;
        //用户所属部门id
        private Long departmentId;
        //用户名称
        private String name;
        //用户年龄
        private Integer age;
        //用户状态,0-禁用,1-启用  
        private Integer status;
        
        private Date createdTime;
        
        private Date updatedTime;
    }

更新用户单个属性(名称)

常规的更新用户名称的业务方法如下

void updateNameById(Integer id, String newName) {
    //doUpdate
}

在方法上面加上我们定义的注解

 @LogRecordAnnotation(detail = "更新了用户名称,改为{#newName}", bizNo = "#id", category = "用户更改")
void updateNameById(Integer id, String newName) {
    //doUpdate
}

很明显,操作日志详情缺少旧的用户名,无法满足使用需要,解决方法有两种:

第一种是让开发在方法参数中加上旧的用户名称

 @LogRecordAnnotation(detail = "更新了用户名称,从{#oldName}改为{#newName}", bizNo = "#id", category = "用户更改")
void updateNameById(Integer id,String oldName, String newName) {
    //doUpdate
}

这种方式在原有方法上面强行增加了一个也业务无关的参数,既不符合相关设计原则,也无法说服带有强迫症的开发,毕竟这种方式违反开发常识了。

第二种是使用自定义模板表达式,来获取旧的用户名称:

 @LogRecordAnnotation(detail = "更新了用户名称,从{getUserNameById(#id)}改为{#newName}", bizNo = "#id", category = "用户更改")
void updateNameById(Integer id,String newName) {
    //doUpdate
}

String getUserNameById(Integer id){
    User user =; //get user by select db 
    return user.getName();
}
  • 模板表达式{getUserNameById(#id)}用来获取旧的用户名称,代表调用getUserNameById(#id) 方法来获取用户名称,其中的参数使用spel表达式引用了原方法中的参数id(用户id)
  • 模板表达式{#newName} 用来获取新用户名称,#newName使用spel表达式引用了原方法中的参数newName
  • 外层使用大括号{}括起来是为了方便和spel表达式作区分

自定义模板相关的详细说明将放在代码实现章节讲解

更新用户多个属性

上一节我们讲解了在更新单个属性的时候,如何去记录操作日志,如果是多个属性的情况,我们又改如何去记录呢?如下面的业务方法:

/**
* 通过用户id更新用户信息
* @param userId  用户id
* @param newUser 新的用户信息
*/
void updateById(Integer userId, User newUser) {
    //doUpdate
}

用户信息的更新场景包含下面几种情形:

  • 只是更新了单个属性
  • 同时更新了多个属性
  • 不需要记录updatedTime属性的变更,因为操作日志本身包含了操作时间

那么我们如何在不修改原有业务代码的情况下实现上面3中情形呢?

第一需要使用到对象的diff操作

对象diff操作说明:对比两个同类型对象的属性值差异,并得出差异结果,如更改了用户的多个属性,那么期望得到的操作内容:name: yyq-old --> yyq-plus,age: 28 --> 29

我们将自定义模板中的diff声音定义如下:

diff({oldObject},{newObject})

使用约定大于配置的理论,将需要diff的两个对象使用diff()表达式包起来,其中的{oldObject}{newObject}两个子表达式用法和上一节中的用法一样,可以调用方法,也可以直接引用方法参数

第二需要定义一个注解来标识目标对象有哪些属性是需要进行diff操作:

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD})
public @interface OpLogField {

    String fieldName() default "";
   
    String fieldMapping() default "{}";

    String dateFormat() default "yyyy-MM-dd HH:mm:ss";

    String decimalFormat() default "";
    
    //先讲关键属性,暂时跳过其他属性
属性 是否必须 说明
fieldName diff结果中显示属性别名,起到易读作用,如name显示为名称
fieldMapping diff结果中显示属性值别名,起到易读作用,json格式,如status可设置为{"0": "禁用", "1": "启用"}
dateFormat diff结果中的时间类型属性值格式化样式,起到易读作用
decimalFormat diff结果中的数值类型格式化样式,默认不格式化,如设置为#,###.##则Double d = 554545.4545454的数值将被显示为 554,545.45

使用该注解对User相关属性进行标识:

    @Data
    private static class User {
       
        private Integer id;
        
        @OpLogField(fieldName="部门id")
        private Long departmentId;

        @OpLogField(fieldName="名称")
        private String name;
        
        @OpLogField(fieldName="年龄")
        private Integer age;
        
        @OpLogField(fieldName="状态",="{"0": "禁用", "1": "启用"}")
        private Integer status;
        
        private Date createdTime;
        
        pricate Date updatedTime;
    }

接着使用操作日志注解标注该方法

/**
* 通过用户id更新用户信息
* @param userId  用户id
* @param newUser 新的用户信息
*/
@LogRecordAnnotation(detail = "更新了用户名称,diff({getUserById(#userId)},{#newUser})", bizNo = "#id", category = "用户更改")
void updateById(Integer userId, User newUser) {
    //doUpdate
}

String getUserById(Integer id){
    User user =; //get user by select db 
    return user;
}

假定oldUser(id=1, departmentId=1, name=yyq-old, age=28, status=0) 假定newUser(id=1, departmentId=1, name=yyq-plus, age=29, status=1) 执行该方法将得到的操作日志内容:

更改用户信息,名称: yyq-old --> yyq-plus,年龄: 28 --> 29,状态:禁用 --> 启用

代码实现架构

仓库模块说明

  • operation-log 操作日志组件核心代码
  • operation-log-starter 操作日志starter模块,支持spring-boot和普通spring项目

持续补充

使用手册

持续补充