🎯导读:
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;
    }
}












![[机器学习]XGBoost(3)——确定树的结构](https://i-blog.csdnimg.cn/direct/99fb4a07aeda47a4ba1777a4383b677a.png)






