通用操作日志处理方案

why(目的理念):操作日志是什么需要做哪些事情?

摘自美团博客的操作日志的介绍

操作日志的记录格式大概分为下面几种:

* 单纯的文字记录,比如:2021-09-16 10:00 订单创建。

* 简单的动态的文本记录,比如:2021-09-16 10:00 订单创建,订单号:NO.11089999,其中涉及变量订单号“NO.11089999”。

* 修改类型的文本,包含修改前和修改后的值,比如:2021-09-16 10:00 用户小明修改了订单的配送地址:从“金灿灿小区”修改到“银盏盏小区” ,其中涉及变量配送的原地址“金灿灿小区”和新地址“银盏盏小区”。

简单总结

总结简单来说某些业务的关键操作为了流程的展示/安全/追溯详细操作记录 的需要, 记录每次操作(操作也可能是批量的)变更的值(可能不是单表)。 实际的操作类型的话常见的大致有:新增、更新、删除、导入、上传等。

希望要实现的效果

例如系统中需要实现的是这样的效果:

how(方法措施):记录操作日志的常见方案有哪些?

  • 监听数据库binlog记录操作日志

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

这种方式的优点是和业务逻辑完全分离。缺点也很明显,局限性太高,只能针对数据库的更改做操作日志记录,如果修改涉及到其他团队的 RPC 的调用,就没办法监听数据库了,举个例子:给用户发送通知,通知服务一般都是公司内部的公共组件,这时候只能在调用 RPC 的时候手工记录发送通知的操作日志了。并且也不支持跨表,并且需要处理记录的数据库字段注释名称。这种比较适合单表单纯记录数据库字段变更。

  • 打印日志的方式记录

这个意思就是直接在方法中打印日志,每个不同的操作日志的话设定不同的模板,然后在每个需要打印操作日志打印前,比对操作前和操作后的值,然后这种操作日志特殊处理到某个指定文件中,然后通过日志收集处理可以把日志保存在 Elasticsearch 或者数据库中,生成可读的操作日志。可能需要大数据或者专门处理日志文件的人员统一处理这个操作日志的开发人员。

  • 直接在代码中记录操作日志

这个和上面的操作类似,只不过就是直接比对并直接记录到数据库或者其他地方而不是打印到日志里面。 无需其他人员介入和学习成本。

  • 方法注解实现操作日志

通过方法注解,通过AOP拦截的方式记录日志,让操作日志和业务逻辑解耦。我们可以在注解的操作日志上记录固定文案,这样业务逻辑和业务代码可以做到解耦,让我们的业务代码变得纯净起来。该种方式可以自定义处理支持跨表、批量和字段注释。不过实现起来较为复杂。要考虑各种场景是否要进行处理和如何处理,比如比对两个实体时候,要不要支持实现比对实体中嵌套的实体。对象类型的不同处理。

方案对比,自己整理的有些地方可能存疑或者问题:

支持/方案

监听数据库binlog(Canal)

通过打印日志的方式

代码中记录操作日志

方法注解实现操作日志

字段注释支持(应该只能数据库字段的注释),不能自定义配置不友好支持支持支持
跨表不支持支持支持支持
批量支持支持支持支持
rpc调用的操作日志不支持支持支持支持
解耦业务支持不支持不支持支持
开发的实现复杂度/可扩展性***********
优点完全对业务系统无侵入。实现方式完全可控,可以由大数据人员处理收集生成的操作日志,也可做到弱通用性。实现方式完全可控,由需要打印日志的地方控制并记录。无需其他人员介入和学习成本。快速开发。和业务解耦无侵入。通用性和自定义较好,可以做到一次开发,其他项目也可使用。
使用场景单纯数据库单表字段变更的操作日志记录有大数据的处理开发人员记录操作日志地方较少,快速开发,记录类型单一或者业务系统有特殊规则的记录要求。记录日志较多,系统对操作日志有较强要求,类型单一或者多个都可以较好支持。

what(实践结果):操作日志的方案实践

操作日志基于方法注解方式 + SPEL 表达式 实现,

SPEL概述

Spring表达式语言全称为“Spring Expression Language”,缩写为“SpEL”,类似于Struts2x中使用的OGNL表达式语言,能在运行时构建复杂表达式、存取对象图属性、对象方法调用等等,并且能与Spring功能完美整合,如能用来配置Bean定义。

本次使用到的特性为:

Expression = "#{@CompanyManger.getCompanyOtherByCompanyId(#companyId)}"

CompanyManger.getCompanyOtherByCompanyId 是一个类的方法。 

companyId 是这个方法的方法参数。

通过表达式解析后拿到的返回值就是 这个方法的返回值。

部分示例代码思路如下: 

/**
 * @description: 操作注解,在需要记录操作日志的方法上面添加
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LogRecordAnno {

    /**
     * 旧值的表达式 oldExpression和newExpression表达式返回值类型必须一致
     * @return
     */
    String oldExpression() default "";

    /**
     * 旧值的表达式 oldExpression 执行解析是否在业务方法执行之前
     * @return
     */
    boolean oldExpressionExecBeforeFlag() default false;

    /**
     * 新值的表达式 oldExpression和newExpression表达式返回值类型必须一致
     * @return
     */
    String newExpression() default "";

    /**
     * 唯一业务标识表达式, 只限于是 旧值的表达式 或者 新值的表达式 的值是基本类型的话或者是list类型的基本类型,基础类型,比如根据id删除的场景
     * @return
     */
    String bizNoExpression() default "";


    /**
     * 操作模块细项分类枚举 如果 LogRecordParamAnno 字段注解中标识了 moduleClassify  则以 LogRecordParamAnno 字段标识的为准
     * @return
     */
    LogRecordAnnoModuleClassifyEnum moduleClassify() default LogRecordAnnoModuleClassifyEnum.NONE;

    /**
     * 操作模块细项分类表达式
     * @return
     */
    String moduleClassifyExpression() default "";

    /**
     * 操作日志所属模块 具体业务自定义
     * @return
     */
    LogRecordAnnoModuleEnum module();

    /**
     * 操作日志类型 增 删 改 查 等,具体业务自定义
     * @return
     */
    LogOperaTypeEnum type() default LogOperaTypeEnum.NONE;

    /**
     * 如果是导入、导出、文件格式的,在此处放入文件名称表达式
     * @return
     */
    String fileNameExpression() default "";

    /**
     * 集合类型排序字段
     */
    String sortFiledName() default "";
}

/**
 * @description: 字段注解
 */
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LogRecordParamAnno {

    /**
     * 字段自定义描述
     * @return
     */
    String value();

    /**
     * 是否为操作日志绑定的业务对象标识,一个实体类中必须有一个
     * @return
     */
    boolean bizNoFlag() default false;

    /**
     * 操作模块细项分类
     * @return
     */
    LogRecordAnnoModuleClassifyEnum moduleClassify() default LogRecordAnnoModuleClassifyEnum.NONE;

    /**
     * 字段值是否需要映射 例如字段为:状态0、1 需要映射为 草稿、生效
     * @return
     */
    boolean fieldMappingFlag() default false;

    /**
     * 字段值映射 {"草稿","生效"} 下标对应字段值
     * 如果是Boolean类型的,{"false对应的映射","true 对应的映射"}, 如果不填默认为{false:否、true:是}
     * @return
     */
    String[] fieldMapping() default {};
}
/**
 * @description: 字段注解
 */
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LogRecordParamAnno {

    /**
     * 字段自定义描述
     * @return
     */
    String value();

    /**
     * 是否为操作日志绑定的业务对象标识,一个实体类中必须有一个
     * @return
     */
    boolean bizNoFlag() default false;

    /**
     * 操作模块细项分类
     * @return
     */
    LogRecordAnnoModuleClassifyEnum moduleClassify() default LogRecordAnnoModuleClassifyEnum.NONE;

    /**
     * 字段值是否需要映射 例如字段为:状态0、1 需要映射为 草稿、生效
     * @return
     */
    boolean fieldMappingFlag() default false;

    /**
     * 字段值映射 {"草稿","生效"} 下标对应字段值
     * 如果是Boolean类型的,{"false对应的映射","true 对应的映射"}, 如果不填默认为{false:否、true:是}
     * @return
     */
    String[] fieldMapping() default {};
}
    // logRecordAnno 注解拦截方法伪代码(只有关键部分)
    @Around(value = "annotationPoinCut(logRecordAnno);")
    public Object around(ProceedingJoinPoint joinPoint, LogRecordAnno logRecordAnno) throws Throwable {

        // 前置操作: 放入入参到上下文变量、获取旧值
        before(joinPoint, logRecordAnno, null);
        // 执行业务被拦截方法
        Object proceed = joinPoint.proceed();
        // 记录操作日志
        saveOperateLog(logRecordAnno, oldObject, newObject);
        return proceed;
    }


private void saveOperateLog(LogRecordAnno logRecordAnno, Object oldObject, Object newObject) {
        // 把LogRecordContext 中的变量都放到 RootObject 中
        Map<String, Object> variables = LogRecordContext.getVariables();
        if (variables != null && variables.size() > 0) {
            for (Map.Entry<String, Object> entry : variables.entrySet()) {
                evaluationContext.setVariable(entry.getKey(), entry.getValue());
            }
        }
      
        // 解析自定义表达式并赋值
        parseCustomExpression(logRecordAnno, logRecordBO);

      
        // 根据不同的操作类型(修改、删除、新增、上传等)比对转换为最终存储用的操作日志对象
        LogRecordResultBO logRecordResultBO = logRecordExecuteHelper.getLogRecordResultBO(logRecordBO);
        logRecordSaveService.saveLog(logRecordResultBO);
    }

// 最终比对的有变化的对象都会存到此对象中
public static class ChangeObject {
  /**
   * 字段名称
   */
  private String fieldName;
  /**
   * 字段描述
   */
  private String fieldDesc;
  /**
   * 操作类型
   */
  private Integer type;
  /**
   * 操作模块
   */
  private Integer moudle;
  /**
   * 模块细项
   */
  private Integer moudleClassify;
  /**
   * 操作前旧值
   */
  private Object fieldOldO;
  /**
   * 操作前新值
   */
  private Object fieldNewO;
  /**
   * 业务唯一标识id
   */
  private String bizNo;
}

//删除的操作日志记录
@LogRecordAnno(oldExpression = "#{@CompanyManger.getCompanyOtherByCompanyId(#companyId)}", module = LogRecordAnnoModuleEnum.COMPANY_OTHER_INFO, type = LogOperaTypeEnum.DELETE)
private void deleteCompanyOther(String companyId);

//批量插入操作日志示例
@LogRecordAnno(newExpression = "#{#insertCompanyOtherInfoList}", module = LogRecordAnnoModuleEnum.COMPANY_OTHER_INFO, moduleClassifyExpression = "4#{#type}")
    public void insertBatchOther(List<CompanyOtherInfo> insertCompanyOtherInfoList, Integer type);


// 更新示例
@LogRecordAnno(oldExpression = "#{#oldUpdateCompanyShareholderList}", newExpression = "#{#updateCompanyShareholderList}", module = LogRecordAnnoModuleEnum.COMPANY_SHAREHOLDER_INFO, sortFiledName = "id")
    public void batchUpdate(List<CompanyShareholder> updateCompanyShareholderList);
		// 查询原值
        List<CompanyShareholder> companyShareholderByIdList = companyShareholderManager.selectByIdList(idList);
        LogRecordContext.putVariable("oldUpdateCompanyShareholderList", companyShareholderByIdList);


if (StringUtils.hasText(logRecordAnno.oldExpression()) && !logRecordAnno.oldExpressionExecBeforeFlag()) {
  // 解析旧值表达式
  oldObject = PARSER.parseExpression(logRecordAnno.oldExpression(), PARSER_CONTEXT)
    .getValue(this.evaluationContext, evaluationContext.getRootObject());
  logRecordBO.setOldObject(oldObject);
}

if (StringUtils.hasText(logRecordAnno.newExpression())) {
  // 新值表达式执行返回的对象
  newObject = PARSER.parseExpression(logRecordAnno.newExpression(), PARSER_CONTEXT)
    .getValue(this.evaluationContext, evaluationContext.getRootObject());
  logRecordBO.setNewObject(newObject);
}
// 更改值的对象
public static class ChangeObject {
  /**
   * 字段名称
   */
  private String fieldName;
  /**
   * 字段描述
   */
  private String fieldDesc;
  /**
   * 操作类型
   */
  private Integer type;
  /**
   * 操作模块
   */
  private Integer moudle;
  /**
   * 模块细项
   */
  private Integer moudleClassify;
  /**
   * 操作前旧值
   */
  private Object fieldOldO;
  /**
   * 操作前新值
   */
  private Object fieldNewO;
  /**
   * 业务唯一标识id
   */
  private String bizNo;
}

大致流程图:

参考资料

SPEL表达式相关文章:玩转Spring中强大的spel表达式! - 知乎 (zhihu.com)

可参考美团的实现方案,当时开发操作日志之前也是看到美团的文章了解到使用SPEL表达式来实现更具扩展性的实现思路,文章中实现给出的是大致思想,复杂度和实现毕竟他们也考虑了自身的业务和需求:如何优雅地记录操作日志?

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/17835.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

springboot+dubbo+zookeeper 项目实战

现在有一段代码再前台&#xff0c;后台系统中都存在&#xff0c;都需要这段代码&#xff0c;存在这种情况&#xff0c;我们可以选择将这段代码提取出来作为一个服务&#xff0c;让前台和后台系统作为消费者远程调用这段代码&#xff0c;提高了代码的复用性。 springboot集成dub…

【软考备战·希赛网每日一练】2023年5月4日

文章目录 一、今日成绩二、错题总结第一题第二题第三题第四题三、知识查缺 题目及解析来源&#xff1a;2023年05月04日软件设计师每日一练 一、今日成绩 二、错题总结 第一题 解析&#xff1a; 修改Linux文件权限命令&#xff1a;chmod。 第二题 解析&#xff1a; 第三题 解析…

坚持伙伴优先,共创数据存储新生态

4 月 26 日&#xff0c;2023 阿里云合作伙伴大会上&#xff0c;阿里巴巴集团董事会主席兼 CEO、阿里云智能集团 CEO 张勇表示&#xff0c;阿里云的核心定位是一家云计算产品公司&#xff0c;生态是阿里云的根基。让被集成说到做到的核心&#xff0c;是要坚定走向“产品被集成”…

【OpenSSH】无需公网IP使用SSH远程连接服务器

文章目录 前言视频教程1、安装OpenSSH2、vscode配置ssh3. 局域网测试连接远程服务器4. 公网远程连接4.1 ubuntu安装cpolar内网穿透4.2 创建隧道映射4.3 测试公网远程连接 5. 配置固定TCP端口地址5.1 保留一个固定TCP端口地址5.2 配置固定TCP端口地址5.3 测试固定公网地址远程 转…

Winform从入门到精通(36)——ColorDialog(史上最全)

文章目录 前言一、属性1、AllowFullOpen2、AnyColor3、Color4、FullOpen5、ShowHelp6、SolidColorOnly7、Tag二、事件1、HelpRequest前言 当我们需要设置某个控件的颜色时,并且需要弹出一个可以选择颜色的对话框时,这时候就需要使用ColorDialog 一、属性 1、AllowFullOpen…

Java设计模式-建造者模式

简介 建造者模式是一种创建型设计模式&#xff0c;用于将复杂对象的构建过程与其表示分离&#xff0c;使得同样的构建过程可以创建不同的表示。建造者模式通过将复杂对象的构建过程分解为多个简单的步骤来实现。 与其他创建型模式不同&#xff0c;建造者模式强调的是将构建过…

BadUsb使用

1 IDE下载 地址&#xff1a;Software | Arduino 2 开发版驱动安装 linux和mac版本会自动识别提示你安装开发板&#xff0c;驱动貌似不需要额外安装 win需要根据板子型号去下载安装驱动 如 Arduino驱动的安装教程-DFRobot产品资料库 默认会提示你根据你插入的设备进行提示…

Shell+VCS学习3---VCS-lint

lint lintTFIPC-L LINTPCWMlintTFIPC-L(如果有的模块的端口定义了&#xff0c;但是没有连接&#xff0c;用这个选项&#xff0c;编译器会给出哪些端口没有连接) 其中CAWM貌似直接写成lintCAWM&#xff0c;vcs是不认的&#xff0c;得写成lintCAWM-L 不过CAWM的检查规则有点奇怪…

maven从入门到精通 第三章 Maven中形成web对Java工程的依赖

这里写自定义目录标题 一 war永远依赖于jar1. 在web工程的项目2中,加入项目1的路径依赖2 在web工程中&#xff0c;加入测试代码2.1 创建目录2.2 确认依赖junit2.3创建测试类2.4 运行测试2.5 打包2.6 查看依赖列表2.7 树形结构查看 二 测试依赖的范围1 compile的编译过程1.1 com…

快速排序算法

文章目录 一、前言二、快速排序算法的基本思想三、快速排序算法流程四、实现快速排序算法的Java代码五、分析代码实现过程1.partition方法2.quickSort方法3.swap方法4.main方法5.整体串讲 六、对快速排序算法的时间复杂度和稳定性进行讨论七、对快速排序算法的空间复杂度分析比…

USB2.0(一):基础

一、总线标准 USB1.1&#xff1a;支持12Mbps全速率&#xff08;FullSpeed&#xff09;和1.5Mbps低速率&#xff08; HalfSpeed&#xff09;USB2.0&#xff1a;支持480Mbps高速率&#xff08;High Speed&#xff09;&#xff0c;兼容1.1USB3.0&#xff1a;支持5Gbps超高速率&am…

asp.net+sqlserver学生学籍管理系统

1.系统登录模块&#xff1a;为了保证系统的安全性和保密性&#xff0c;便于用户的管理&#xff0c;对用户设置权限。 界面上需要输入用户名、密码、验证码以及用户类型。 用户类型&#xff1a;普通用户和管理员用户。 2.用户信息管理模块&…

HarmonyOS版的“抖音”长啥样?有图有真相

“鸿蒙系统实战短视频App 从0到1掌握HarmonyOS”系列课程是面向HarmonyOS实战的视频教程&#xff0c;该课程会通过构建一个真实的短视频App来向读者展示HarmonyOS的全过程。 本节将演示基于HarmonyOS短视频App的核心功能。通过了解该App的功能&#xff0c;也能初步对本课程的内…

Android-实现一个登录页面(kotlin)

准备工作 首先&#xff0c;确保你已经安装了 Android Studio。如果还没有安装&#xff0c;请访问 Android Studio 官网 下载并安装。 前提条件 - 安装并配置好 Android Studio Android Studio Electric Eel | 2022.1.1 Patch 2 Build #AI-221.6008.13.2211.9619390, built …

5---最长回文字串

给你一个字符串 s&#xff0c;找到 s 中最长的回文子串。 如果字符串的反序与原始字符串相同&#xff0c;则该字符串称为回文字符串。 示例 1&#xff1a; 输入&#xff1a;s “babad” 输出&#xff1a;“bab” 解释&#xff1a;“aba” 同样是符合题意的答案。 示例 2&…

自行车和电动自行车上亚马逊标准有什么区别?UL2849,16CFR1512

自行车 自行车是一种两轮的或三轮的交通工具&#xff0c;完全靠人力驱动后轮前进。本政策所涵盖的自行车包括当座位调整到最高位置时&#xff0c;座位离地面超过 25 英寸的自行车&#xff0c;以及座位高度为 25 英寸或以下的人行道自行车。本政策也适用于公路使用的卧式自行车…

2023-05-04:用go语言重写ffmpeg的scaling_video.c示例,用于实现视频缩放(Scaling)功能。

2023-05-04&#xff1a;用go语言重写ffmpeg的scaling_video.c示例&#xff0c;用于实现视频缩放&#xff08;Scaling&#xff09;功能。 答案2023-05-04&#xff1a; 这段代码实现了使用 libswscale 库进行视频缩放的功能。下面是程序的主要流程&#xff1a; 1.获取命令行参…

MySQL事务

1、事务的概念 事务是一种机制、一个操作序列&#xff0c;包含了一组数据库操作命令&#xff0c;并且把所有的命令作为一个整体一起向系统提交或撤销操作请求&#xff0c;即这一组数据库命令要么都执行&#xff0c;要么都不执行。 事务是一个不可分割的工作逻辑单元&#xff…

推荐一些非常好用的DNS服务器

推荐一些非常好用的DNS服务器 1、114公共DNS服务器 1&#xff09; 老牌的114DNS&#xff0c;全国三网通用高速&#xff0c;纯净无劫持无需再忍受被强扭去看广告或粗俗网站之痛苦 DNS地址为&#xff1a;114.114.114.114 和 114.114.115.115 2&#xff09;拦截 钓鱼病毒木马网…

【目标检测论文阅读笔记】Dynamic Head: Unifying Object Detection Heads with Attentions

Abstract 在目标检测中结合定位和分类的复杂性导致了方法的蓬勃发展。以前的工作试图提高各种目标检测头的性能&#xff0c;但未能提出统一的观点。在本文中&#xff0c;我们提出了一种新颖的动态头部框架 来统一目标检测头部和注意力。通过在用于尺度感知的特征级别之间、用于…