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表达式来实现更具扩展性的实现思路,文章中实现给出的是大致思想,复杂度和实现毕竟他们也考虑了自身的业务和需求:如何优雅地记录操作日志?