Files
operation-log-parent/README.md
2022-03-15 11:04:30 +08:00

315 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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-plusage: 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项目
持续补充
# 使用手册
持续补充