🎯导读:
mzt-biz-log
是一个用于记录操作日志的通用组件,旨在追踪系统中“谁”在“何时”对“何事”执行了“何种操作”。该组件通过简单的注解配置,如@LogRecord
,即可实现接口调用的日志记录,支持成功与失败场景下的差异化日志描述。它还提供了丰富的功能,包括但不限于租户隔离、日志子类型划分、条件性日志记录以及枚举值解析等。此外,mzt-biz-log
支持自定义日志存储逻辑,允许开发者根据业务需求将日志持久化到数据库或其他存储媒介。整体设计简洁高效,适用于微服务架构中的日志管理需求。
文章目录
- mzt-biz-log介绍
- 具体实现
- 依赖
- 添加注解
- 枚举类型转化为具体值
- 枚举类
- 实现解析器类
- 使用
- 日志子类型划分
- 日志过滤
- 日志持久化
- 数据库
- 继承存储接口
mzt-biz-log介绍
mzt-biz-log
:一套通用操作日志组件,用来记录「谁」在「什么时间」对「什么」做了「什么事」
- github仓库:https://github.com/mouzt/mzt-biz-log
具体实现
依赖
<dependency>
<groupId>io.github.mouzt</groupId>
<artifactId>bizlog-sdk</artifactId>
<version>3.0.6</version>
</dependency>
添加注解
首先需要在具体的服务启动类中添加注解@EnableLogRecord(tenant = "venue")
,其中tenant
是租户标识,我这里设置为了服务的名称,一般一个服务或者一个业务下的多个服务都用一个 tenant 就可以了
然后在具体的接口添加注解@LogRecord
,在调用相应的接口之后,就会触发日志
@Repeatable(LogRecords.class)
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface LogRecord {
String success();
String fail() default "";
String operator() default "";
String type();
String subType() default "";
String bizNo();
String extra() default "";
String condition() default "";
String successCondition() default "";
}
type
:日志类型,可以用来区分不同的接口,我这里直接设置为接口名称,方便辨识subType
:日志子类型,可以用来区分不同的操作者身份bizNo
:日志ID,可以设置为具体的数据的ID,这样查询日志的时候,直接使用相应数据的ID来查询,例如说bizNo存储的是订单ID,后面可以凭借这个来查询该订单相关的日志success
:接口调用成功之后,action
存放什么数据(action
字段是什么,看日志持久化就知道了),一般通过描述语言拼接字段值来实现快速让用户知道日志的内容fail
:接口调用异常之后,action
存放什么数据extra
:需要记录的额外信息,如直接将用户提交的数据的 json 进行存储,因为action
存储的是简略的信息operator
:存储操作人信息,需要用户的系统已经实现了用户上下文
【成功调用示例】
/**
* 增添数据
*/
@PostMapping("/save")
@LogRecord(
bizNo = "{{#id}}",
type = "新增分区",
success = """
场馆ID:{{#partitionDO.venueId}}, \
分区名称:{{#partitionDO.name}}, \
分区类型:{{#partitionDO.type}}, \
描述:{{#partitionDO.description}}, \
场区拥有的场数量:{{#partitionDO.num}}, \
场区状态:{{#partitionDO.status}}; \
结果:{{#_ret}}
""",
fail = "接口调用失败,失败原因:{{#_errorMsg}}",
extra = "{{#partitionDO.toString()}}",
operator = "{{T(com.vrs.common.context.UserContext).getUsername()}}"
)
public Result save(@Validated({AddGroup.class}) @RequestBody PartitionDO partitionDO) {
partitionService.save(partitionDO);
// 因为 ID 是存储到数据库中才生成的,@LogRecord 默认拿不到,需要我们将信息手动设置到上下文中
LogRecordContext.putVariable("id", partitionDO.getId());
return Results.success();
}
注意:
- 获取接口返回的结果:
{{#_ret}}
- 通过日志上下文记录信息:因为 id 是存储到数据库中才生成的,@LogRecord 一开始拿不到,需要我们将信息手动设置到上下文中。可以通过
LogRecordContext.putVariable("id", partitionDO.getId());
来设置键值对,然后在注解中凭借键来获取值就可以,如bizNo = "{{#id}}"
接口调用成功的日志内容如下:
【logRecord】log=LogRecord(id=null, tenant=venue, type=新增分区, subType=, bizNo=1868205198032568320, operator=admin, action=场馆ID:12345, 分区名称:篮球场A区, 分区类型:营业中, 描述:提供标准篮球设施,包括篮球和球架。, 场区拥有的场数量:4, 场区状态:篮球; 结果:Result(code=0, message=null, data=null, requestId=null)
, fail=false, createTime=Sun Dec 15 16:03:23 CST 2024, extra=PartitionDO(venueId=1865271207637635072, name=篮球场A区, type=1, description=提供标准篮球设施,包括篮球和球架。, num=4, status=1), codeVariable={MethodName=save, ClassName=class com.vrs.controller.PartitionController})
【失败调用示例】
首先在接口中模拟一个除以 0 异常,即System.
out
.println(1/0);
。然后在注解中添加fail = "接口调用失败,失败原因:{{#_errorMsg}}"
,其中#_errorMsg
获取的是异常的信息
@PostMapping("/save")
@LogRecord(
bizNo = "{{#id}}",
type = "新增分区",
success = """
场馆ID:{{#partitionDO.venueId}}, \
分区名称:{{#partitionDO.name}}, \
分区类型:{{#partitionDO.type}}, \
描述:{{#partitionDO.description}}, \
场区拥有的场数量:{{#partitionDO.num}}, \
场区状态:{{#partitionDO.status}}; \
结果:{{#_ret}}
""",
fail = "接口调用失败,失败原因:{{#_errorMsg}}",
extra = "{{#partitionDO.toString()}}",
operator = "{{T(com.vrs.common.context.UserContext).getUsername()}}"
)
public Result save(@Validated({AddGroup.class}) @RequestBody PartitionDO partitionDO) {
partitionService.save(partitionDO);
// 因为 ID 是存储到数据库中才生成的,@LogRecord 默认拿不到,需要我们将信息手动设置到上下文中
LogRecordContext.putVariable("id", partitionDO.getId());
System.out.println(1/0);
return Results.success();
}
调用失败之后的日志如下:
【logRecord】log=LogRecord(id=null, tenant=venue, type=新增分区, subType=, bizNo=1868210725902950400, operator=admin, action=接口调用失败,失败原因:/ by zero, fail=true, createTime=Sun Dec 15 16:25:21 CST 2024, extra=PartitionDO(venueId=1865271207637635072, name=篮球场A区, type=1, description=提供标准篮球设施,包括篮球和球架。, num=4, status=1), codeVariable={MethodName=save, ClassName=class com.vrs.controller.PartitionController})
枚举类型转化为具体值
上面日志输出中,分区类型和场区状态的是具体的数字值
如果说想要将类型对应为具体的值,应该如何实现呢?
枚举类
【场馆类型枚举】
package com.vrs.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 场馆类型枚举
*/
@RequiredArgsConstructor
public enum PartitionStatusEnum {
BASKET_BALL(1, "篮球"),
FOOT_BALL(2, "足球"),
BADMINTON(3, "羽毛球"),
VOLLEYBALL(4, "排球"),
TABLE_TENNIS(5, "乒乓球"),
TENNIS(6, "网球"),
SWIMMING(7, "游泳"),
GYMNASTICS(8, "体操"),
FITNESS_CENTER(9, "健身房"),
HANDBALL(10, "手球"),
ICE_SKATING(11, "滑冰"),
SKATEBOARDING(12, "滑板"),
CLIMBING(13, "攀岩"),
CYCLING_INDOOR(14, "室内自行车"),
YOGA(15, "瑜伽");
@Getter
private final int type;
@Getter
private final String value;
/**
* 根据 type 找到对应的 value
*
* @param type 要查找的类型代码
* @return 对应的描述值,如果没有找到抛异常
*/
public static String findValueByType(int type) {
for (PartitionStatusEnum target : PartitionStatusEnum.values()) {
if (target.getType() == type) {
return target.getValue();
}
}
throw new IllegalArgumentException();
}
}
【场区状态枚举】
package com.vrs.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 场区状态枚举
*/
@RequiredArgsConstructor
public enum VenueTypeEnum {
CLOSED(0, "已关闭"),
OPEN(1, "营业中"),
MAINTAIN(2, "维护中");
@Getter
private final int type;
@Getter
private final String value;
/**
* 根据 type 找到对应的 value
*
* @param type 要查找的类型代码
* @return 对应的描述值,如果没有找到抛异常
*/
public static String findValueByType(int type) {
for (VenueTypeEnum target : VenueTypeEnum.values()) {
if (target.getType() == type) {
return target.getValue();
}
}
throw new IllegalArgumentException();
}
}
实现解析器类
转换类需要继承IParseFunction
接口,然后实现两个方法
- functionName:返回解析器的标识,后面需要在注解中使用来辨识不同的解析器
- apply:主要用来实现解析工作,如将枚举类型转化为具体的值
package com.vrs.biglog;
import com.mzt.logapi.service.IParseFunction;
import com.vrs.enums.PartitionStatusEnum;
import org.springframework.stereotype.Component;
/**
* @Author dam
* @create 2024/12/15 16:43
*/
@Component
public class PartitionStatusEnumParse implements IParseFunction {
@Override
public String functionName() {
return "PartitionStatusEnumParse";
}
@Override
public String apply(Object value) {
return PartitionStatusEnum.findValueByType(Integer.parseInt(value.toString()));
}
}
package com.vrs.biglog;
import com.mzt.logapi.service.IParseFunction;
import com.vrs.enums.VenueTypeEnum;
import org.springframework.stereotype.Component;
/**
* @Author dam
* @create 2024/12/15 16:43
*/
@Component
public class VenueTypeEnumParse implements IParseFunction {
@Override
public String functionName() {
return "VenueTypeEnumParse";
}
@Override
public String apply(Object value) {
return VenueTypeEnum.findValueByType(Integer.parseInt(value.toString()));
}
}
使用
@PostMapping("/save")
@LogRecord(
bizNo = "{{#id}}",
type = "新增分区",
success = """
场馆ID:{{#partitionDO.venueId}}, \
分区名称:{{#partitionDO.name}}, \
分区类型:{VenueTypeEnumParse{#partitionDO.type}}, \
描述:{{#partitionDO.description}}, \
场区拥有的场数量:{{#partitionDO.num}}, \
场区状态:{PartitionStatusEnumParse{#partitionDO.type}};\
结果:{{#_ret}}
""",
fail = "接口调用失败,失败原因:{{#_errorMsg}}",
extra = "{{#partitionDO.toString()}}",
operator = "{{T(com.vrs.common.context.UserContext).getUsername()}}"
)
public Result save(@Validated({AddGroup.class}) @RequestBody PartitionDO partitionDO) {
partitionService.save(partitionDO);
// 因为 ID 是存储到数据库中才生成的,@LogRecord 默认拿不到,需要我们将信息手动设置到上下文中
LogRecordContext.putVariable("id", partitionDO.getId());
return Results.success();
}
注意分区类型:{VenueTypeEnumParse{#partitionDO.type}}
中使用了解析器的标识
重新运行之后,发现枚举类型已经转化了具体值
日志子类型划分
日志子类型划分为了区分日志的所属身份,比如说普通用户修改了数据,管理员也修改了数据。但通常指允许管理员查看用户的操作日志,不允许普通用户查看管理员的操作日志。因此可以使用subType
字段来做一些区分,后面实现日志查询的时候,针对用户的身份对该字段做一些处理即可
@LogRecord(
bizNo = "{{#id}}",
type = "新增分区",
subType = "{{T(com.vrs.common.context.UserContext).getUserType()}}",
success = """
场馆ID:{{#partitionDO.venueId}}, \
分区名称:{{#partitionDO.name}}, \
分区类型:{VenueTypeEnumParse{#partitionDO.type}}, \
描述:{{#partitionDO.description}}, \
场区拥有的场数量:{{#partitionDO.num}}, \
场区状态:{PartitionStatusEnumParse{#partitionDO.type}}; \
结果:{{#_ret}}
""",
fail = "接口调用失败,失败原因:{{#_errorMsg}}",
extra = "{{#partitionDO.toString()}}",
operator = "{{T(com.vrs.common.context.UserContext).getUsername()}}"
)
日志过滤
只有在满足一定条件的时候,才记录日志,可以使用condition
字段,比如说用户提交的数量为null,才记录日志
@LogRecord(
bizNo = "{{#id}}",
type = "新增分区",
subType = "{{T(com.vrs.common.context.UserContext).getUserType()}}",
success = """
场馆ID:{{#partitionDO.venueId}}, \
分区名称:{{#partitionDO.name}}, \
分区类型:{VenueTypeEnumParse{#partitionDO.type}}, \
描述:{{#partitionDO.description}}, \
场区拥有的场数量:{{#partitionDO.num}}, \
场区状态:{PartitionStatusEnumParse{#partitionDO.type}}; \
结果:{{#_ret}}
""",
fail = "接口调用失败,失败原因:{{#_errorMsg}}",
extra = "{{#partitionDO.toString()}}",
operator = "{{T(com.vrs.common.context.UserContext).getUsername()}}",
condition = "{{#partitionDO.num == null}}"
)
日志持久化
数据库
DROP TABLE IF EXISTS `mt_biz_log`;
CREATE TABLE `mt_biz_log` (
`id` bigint NOT NULL COMMENT 'ID',
`create_time` datetime,
`update_time` datetime,
`is_deleted` tinyint default 0 COMMENT '逻辑删除 0:没删除 1:已删除',
`tenant` varchar(50) DEFAULT NULL COMMENT '租户',
`type` varchar(50) DEFAULT NULL COMMENT '类型',
`sub_type` varchar(50) DEFAULT NULL COMMENT '子类型',
`class_name` varchar(100) DEFAULT NULL COMMENT '方法名称',
`method_name` varchar(100) DEFAULT NULL COMMENT '方法名称',
`operator` varchar(50) DEFAULT NULL COMMENT '操作人员',
`action` longtext COMMENT '操作',
`extra` longtext COMMENT '其他补充',
`status` tinyint DEFAULT NULL COMMENT '操作状态 (0正常 1异常)',
PRIMARY KEY (`id`) USING BTREE
) COMMENT='操作日志表';
【实体类】
package com.vrs.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.vrs.domain.base.BaseEntity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* 操作日志表
* @TableName mt_biz_log
*/
@TableName(value ="mt_biz_log")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MtBizLog extends BaseEntity implements Serializable {
/**
* 租户
*/
private String tenant;
/**
* 类型
*/
private String type;
/**
* 子类型
*/
private String subType;
/**
* 方法名称
*/
private String className;
/**
* 方法名称
*/
private String methodName;
/**
* 操作人员
*/
private String operator;
/**
* 操作
*/
private String action;
/**
* 其他补充
*/
private String extra;
/**
* 操作状态 (0正常 1异常)
*/
private Integer status;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}
增删改查方法我这里就不再介绍了,请大家自行实现
继承存储接口
只需要实现ILogRecordService
接口,然后重写record
方法,然后在该方法里面调用持久化方法即可
我这里统一将所有日志记录到一个表中,如果想要根据业务分表存储,可以根据logRecord.getTenant()
和logRecord.getType()
来判断存储到哪个表即可
package com.vrs.service.impl;
import com.mzt.logapi.beans.CodeVariableType;
import com.mzt.logapi.beans.LogRecord;
import com.mzt.logapi.service.ILogRecordService;
import com.vrs.entity.MtBizLog;
import com.vrs.service.MtBizLogService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* @Author dam
* @create 2024/12/15 21:02
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class BizlogStoreService implements ILogRecordService {
private final MtBizLogService mtBizLogService;
@Override
public void record(LogRecord logRecord) {
mtBizLogService.save(MtBizLog.builder()
.tenant(logRecord.getTenant())
.type(logRecord.getType())
.subType(logRecord.getSubType())
.className(logRecord.getCodeVariable().get(CodeVariableType.ClassName).toString())
.methodName(logRecord.getCodeVariable().get(CodeVariableType.MethodName).toString())
.operator(logRecord.getOperator())
.action(logRecord.getAction())
.extra(logRecord.getExtra())
.status(logRecord.isFail() ? 1 : 0)
.build());
}
@Override
public List<LogRecord> queryLog(String bizNo, String type) {
return null;
}
@Override
public List<LogRecord> queryLogByBizNo(String bizNo, String type, String subType) {
return null;
}
}