通用操作日志组件biz-log 及基本使用

2,406 阅读5分钟

前言:

为什么要用biz-log,接口需要记录谁在什么时间做了什么事,并在日志模块进行展示,每个接口的接口描述不一样。需要接口写不同描述。

提前了解:SpEL(Spring Expression Language)语句

注意:该文档只是记录作者使用过程中用到的!!!

biz-log基本使用

Gitee地址:mzt-biz-log: Springboot-注解-通用操作日志组件美团技术博客:https://tech.meituan.com/2021/09/16/operational-logbook.html源码实现

  • 导包(最新3.0.6)

        <!-- https://mvnrepository.com/artifact/io.github.mouzt/bizlog-sdk -->
        <dependency>
            <groupId>io.github.mouzt</groupId>
            <artifactId>bizlog-sdk</artifactId>
            <version>3.0.6</version>
        </dependency>
    
  • 启动类添加 @EnableLogRecord注解

tenant可以作为系统的标识。

@EnableLogRecord(tenant = "XXX系统")
  • 注解@LogRecord 简单使用

    接口上添加@LogRecord注解 (源码里面截取)

    @LogRecord(success = "更新了订单{{#newOrder}}", type = LogRecordType.ORDER, bizNo = "{{#newOrder.orderNo}}", extra = "{{#newOrder.toString()}}") boolean diff2(Order newOrder);

注解字段解释

success:执行成功后后的日志模版

type: 类型

bizNo:业务标识

extra:补充说明,额外信息

如果方法执行成功,日志系统会根据 success 属性的值来记录日志。

在记录日志时,日志系统会解析字符串模板,并替换其中的占位符为实际的值。{{#newOrder}} 可能会被替换为 newOrder 对象的某个属性或其字符串表示形式,{{#newOrder.orderNo}} 会被替换为 newOrder 对象的 orderNo 属性值。

日志系统会记录一条包含实际值的日志消息。

看完这部分其实就可以简单使用了。接下来就是存储 以及自定义复杂日志
1. 日志存储
a. 添加 LogRecordPO,LogRecordDao

LogRecordPO字段包含源码里面的LogRecord类(com.mzt.logapi.beans) 添加部分业务字段

@Data
@NoArgsConstructor
@TableName(value = "tab_log_record")
@Accessors(chain = true)
public class LogRecordPO {
    /**
     * id
     */
    @TableId(type = IdType.AUTO)
    private Long id;
    /**
     * 租户
     */
    private String tenant;
    
    /**
     * 保存的操作日志的类型,比如:订单类型、商品类型
     *
     * @since 2.0.0 从 prefix 修改为了type
     */
    @NotBlank(message = "type required")
    @Length(max = 200, message = "type max length is 200")
    private String type;
    /**
     * 日志的子类型,比如订单的C端日志,和订单的B端日志,type都是订单类型,但是子类型不一样
     * @since 2.0.0 从 category 修改为 subtype
     */
    private String subType;
    
    /**
     * 日志绑定的业务标识
     */
    @NotBlank(message = "bizNo required")
    @Length(max = 200, message = "bizNo max length is 200")
    private String bizNo;
    /**
     * 操作人
     */
    @NotBlank(message = "operator required")
    @Length(max = 63, message = "operator max length 63")
    private String operator;
    
    private String ip;
    
    private String orgId;
    private String orgName;
    /**
     * 日志内容
     */
    @NotBlank(message = "opAction required")
    @Length(max = 511, message = "operator max length 511")
    private String action;
    /**
     * 记录是否是操作失败的日志
     */
    private boolean fail;
    /**
     * 日志的创建时间
     */
    private LocalDateTime createTime;
    /**
     * 日志的额外信息
     *
     * @since 2.0.0 从detail 修改为extra
     */
    private String extra;
    
  
}

@Mapper
public interface LogRecordDao extends BaseMapper<LogRecordPO> {
}
b.实现ILogRecordService 重写record,获取到LogRecord 并入库
//ILogRecordService源码
public interface ILogRecordService {
    void record(LogRecord logRecord);

    List<LogRecord> queryLog(String bizNo, String type);

    List<LogRecord> queryLogByBizNo(String bizNo, String type, String subType);
}

@Service
@RequiredArgsConstructor
public class LogRecordServiceImpl implements ILogRecordService{
    
    private final LogRecordConvert logRecordConvert;
    
    private final LogRecordDao logRecordDao;   
     
    @Override
    public void record(LogRecord logRecord) {
        LogRecordPO po = logRecordConvert.convertToPo(logRecord);
        //需要处理的业务字段
    
        logRecordDao.insert(po);
    }
        @Override
    public List<LogRecord> queryLog(String bizNo, String type) {
        return null;
    }
    
    @Override
    public List<LogRecord> queryLogByBizNo(String bizNo, String type, String subType) {
        return null;
    }
}
c.指定操作人 实现IOperatorGetService

LogRecord中有operator 用来标识操作人,可以实现IOperatorGetService

@Service
public class DefaultOperatorGetServiceImpl implements IOperatorGetService {

    @Override
    public Operator getUser() {
        //获取用户
        UserBean userInfo = UserThreadLocal.getUserBean();
        Operator operatorDO = new Operator();
        operatorDO.setOperatorId(userInfo.getUsername());
        return operatorDO;
    }
}
2.复杂日志
a.三元表达式
@LogRecord(
            fail = "统计模块,{{#timeRangeParam.receiverType==0? '测试' : '测试1'}}完成率导出失败。",
            success = "统计模块,{{#timeRangeParam.receiverType==0? '测试' : '测试1'}}完成率导出。共{{#timeRangeParam.size}}条" 
            type = "统计模块",
            subType = "{{#timeRangeParam.receiverType==0? '测试' : '测试1'}}完成率导出",
            bizNo = "",
            extra = "入参:{{#timeRangeParam.toJson()}}")
List<StatisticsExportRt> finishStateExport(TimeRangeParam timeRangeParam);

type: 用来标识模块

subType: 模块某个接口

extra: 入参信息 TimeRangeParam中写了一个toJson

所有SpEL语句 获取到的TimeRangeParam 里面的参数 都是整个方法执行完后的。

假如:TimeRangeParam 里面 有一个size ,最开始赋值为0. 方法内部 使size=10,则

成功的描述里面 共10条,而不是0条。

b.模版中使用方法参数之外的变量

接口:导出用户信息

日志描述:用户模块,导出用户信息从2024年X月X日到2024年X月X日,共X条

时间一般为LocalDateTime形式 与产品所需2024年X月X日格式不同。

解决思路:

第一种:size也可以加到param里面查出来之后 set一下。这样的话 我们需要什么字段就需要添加到param中 如果需要记录日志的接口过多,改动了很大

第二种:使用 LogRecordContext.

putVariable 添加变量。

代码如下:

添加InnerLogInfoDTO类
@Data
@NoArgsConstructor
public class InnerLogInfoDTO {
    /**
     * 数量
     */
    private Integer size;
    /**
     * 开始时间
     */
    private String startTimeStr;
    /**
     * 结束时间
     */
    private String endTimeStr;
    /**
     * 开始时间
     */
    private LocalDateTime startTime;
    /**
     * 结束时间
     */
    private LocalDateTime endTime;
}
添加 @LogRecord注解 部分使用innerLogInfoDTO
@LogRecord(
            fail = "用户模块,导出失败。",
            success = "用户模块,导出用户信息," +
                    "{{#innerLogInfoDTO.startTimeStr != null && #innerLogInfoDTO.endTimeStr != null ? #innerLogInfoDTO.startTimeStr +'到'+#innerLogInfoDTO.endTimeStr+',' :''}}" +
                    "共{{#innerLogInfoDTO.size}}条",
            type = "用户模块",
            subType = "用户信息导出",
            bizNo = "",
            extra = "入参:{{#param.toJson()}}")
    File exportUserInfo(Param param) throws Exception;
exportUserInfo接口添加如下代码
@Override
    public File exportUserInfo(Param param) throws Exception {
       //处理日志描述
        InnerLogInfoDTO innerLogInfoDTO=new InnerLogInfoDTO();
        innerLogInfoDTO.setSize(size);
        if(Objects.nonNull(commandParam.getStartTime())){
            innerLogInfoDTO.setStartTime(commandParam.getStartTime());
        }
        if(Objects.nonNull(commandParam.getEndTime())){
            innerLogInfoDTO.setEndTime(commandParam.getStartTime());
        }
        innerLogInfoManagement.handleInnerLogInfo(innerLogInfoDTO);
}

public void handleInnerLogInfo(InnerLogInfoDTO innerLogInfoDTO) {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy年MM月dd日");
        if(Objects.nonNull(innerLogInfoDTO.getStartTime())){
            String startTime = innerLogInfoDTO.getStartTime().format(formatter);
            innerLogInfoDTO.setStartTimeStr(startTime);
        }
        if(Objects.nonNull(innerLogInfoDTO.getEndTime())){
            String endTime = innerLogInfoDTO.getEndTime().format(formatter);
            innerLogInfoDTO.setEndTimeStr(endTime);
        }
        //使用LogRecordContext.putVariable添加变量
        LogRecordContext.putVariable("innerLogInfoDTO", innerLogInfoDTO);
    }

通过 LogRecordContext.putVariable(variableName, Object) 方法添加变量,第一个对象为变量名称,后面为变量的对象, 使用

SpEL

使用这个变量了

@LogRecord 里面{{#innerLogInfoDTO.size}} 中的innerLogInfoDTO为putVariable的variableName。

跨方法使用

通过LogRecordContext.putGlobalVariable(variableName, Object) 放入上下文中,此优先级为最低,若方法上下文中存在相同的变量,则会覆盖

@Override
    @LogRecord(
            success = "{{#order.purchaseName}}下了一个订单,购买商品「{{#order.productName}}」,测试变量「{{#innerOrder.productName}}」,下单结果:{{#_ret}}",
            type = LogRecordType.ORDER, bizNo = "{{#order.orderNo}}")
    public boolean createOrder(Order order) {
        log.info("【创建订单】orderNo={}", order.getOrderNo());
        // db insert order
        Order order1 = new Order();
        order1.setProductName("内部变量测试");
        LogRecordContext.putVariable("innerOrder", order1);
        return true;
    }

------------2025.06更新------------

6. 需求:记录部分接口的变更记录,变更字段更新前的值,以及更新后的值

实现:接口添加注解@LogRecord

@LogRecord(            
    //标识操作模块            
    type = LogRecordConstants.OPINION_GATHER_MODULE,            
    //标识操作类型            
    subType = LogRecordConstants.UPDATE_OPINION_INFO,            
    //自定函数            
    extra = "{opinionInfoUpdate{#oldInfo.toString()}}",            
    success = LogRecordConstants.UPDATE_OPINION_INFO,            
    bizNo = "{{#opinionLevelParam.opinionId}}"@Override    
public void update(LevelParam param){        
    //获取详情        
    DetailRt rt=getInfo(param.getId());        
    //旧数据放入日志上下文,方便后续对比更新后的数据信息        
    LogRecordContext.putVariable("oldInfo", rt);        
    //业务操作 更新。。。    
}
a. 自定义注解ForUpdate(标识需要记录的变更字段)
@Target(ElementType.FIELD)@Retention(RetentionPolicy.RUNTIME)@Documentedpublic @interface ForUpdate {        String fieldName() default "";    String fieldComment() default "";}
b. 对应实体类字段添加注解@ForUpdate

filedName,主要是为了在后面入库存储,变更前,变更后字段。方便前展示

 @ApiModelProperty("级别") @ForUpdate(fieldName = "level") private Integer level;
c. 自定义函数opinionInfoUpdate

第一步中的@LogRecord 中有一个参数为

extra = "{update{#oldInfo.toString()}}" 其中update 是定义的一个函数标识符。

OpinionInfoUpdateParseFunction类

@Componentpublic class OpinionInfoUpdateParseFunction implements IParseFunction {    @Override    //false 标识该函数目标方法执行之后运行    public boolean executeBefore() {        return false;    }    @Override    public String functionName() {        //定义函数标识符,函数标识符必须唯一        return "opinionInfoUpdate";    }        @Override    public String apply(Object value) {        //从日志上下文获取oldInfo        ReportRt oldInfo = (ReportRt) LogRecordContext.getVariable("oldInfo");        Long id = oldInfo.getId();        //业务处理获取更新后的数据信息        ReportRt newInfo=getUpdateInfo(id);        //将需要的字段放入context        LogRecordContext.putVariable(LogRecordConstants.TITLE, newInfo.getTitle());        //ChangeFiledUtil 获取更新后的字段信息;        String changedFields = ChangeFiledUtil.getChangedFields(oldInfo, newInfo);        LogRecordContext.putVariable("changedFields", changedFields);        LogRecordContext.putVariable(LogRecordConstants.DETAIL, changedFields);        return changedFields;    }}

ChangeFiledUtil获取更新后的字段信息

public class ChangeFiledUtil {        public static final Logger logger = Logger.getLogger(ChangeFiledUtil.class);        /**     * 获取字段改变记录     *     * @param beforeBean 更改前bean     * @param afterBean  更改后bean     * @param <T>        泛型     * @return String     */    public static <T> String getChangedFields(T beforeBean, T afterBean) {        Field[] fields = beforeBean.getClass().getDeclaredFields();        JSONArray arr = new JSONArray();        for (Field field : fields) {            field.setAccessible(true);            if (field.isAnnotationPresent(ForUpdate.class)) {                try {                    Object beforeValue = field.get(beforeBean);                    Object afterValue = field.get(afterBean);                    if (!Objects.equals(beforeValue, afterValue)) {                        //获取字段名称                        JSONObject jsonObject = new JSONObject();                        jsonObject.set("name", field.getAnnotation(ForUpdate.class).fieldName());                        jsonObject.set("beforeValue", beforeValue);                        jsonObject.set("afterValue", afterValue);                        arr.add(jsonObject);                    }                } catch (Exception e) {                    logger.error("操作失败");                }            }        }        if (arr.isEmpty()) {            return null;        }        return JSONUtil.toJsonStr(arr);    }}

定义实体类ChangeFileRt

@Datapublic class ChangeFileRt {    private String name;    private String beforeValue;    private String afterValue;}
d. 日志信息入库处理

LogRecordServiceImpl 操作日志持久化

public class LogRecordServiceImpl implements ILogRecordService {         /**     * 操作日志记录方法抛出异常情况下, 事务建议使用REQUIRES_NEW,重新创建事务,否则会随着主任务回滚.     *     * @param logRecord     */    @Override    @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)    public void record(LogRecord logRecord) {        //从上下文获取相关字段        String title = (String) LogRecordContext.getVariable(LogRecordConstants.TITLE);        String detailValue = (String) LogRecordContext.getVariable(LogRecordConstants.DETAIL);        String changedFields = (String) LogRecordContext.getVariable(LogRecordConstants.CHANGEDFIELDS);        //未修改字段,不记录日志        if (StringUtils.isBlank(changedFields)){            return;        }        List<ChangeFileRt> changeFileRts = JSON.parseArray(changedFields, ChangeFileRt.class);        JSONObject beforeJson=new JSONObject();        JSONObject afterJson=new JSONObject();        changeFileRts.forEach(changeFileRt -> {            String name = changeFileRt.getName();            String beforeValue = changeFileRt.getBeforeValue();            String afterValue = changeFileRt.getAfterValue();            beforeJson.set(name,beforeValue);            afterJson.set(name,afterValue);        });        String beforeValue = beforeJson.toString();        String afterValue = afterJson.toString();        String operator = UserService.currentUserName();        String type = logRecord.getType();        String subType = logRecord.getSubType();        //获取IP地址        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();        assert attributes != null;        HttpServletRequest request = attributes.getRequest();        String ipAddress = IpUtil.getIpAddress(request);        // 业务保存日志   }        @Override    public List<LogRecord> queryLog(String bizNo, String type) {        return Lists.newArrayList();    }        @Override    public List<LogRecord> queryLogByBizNo(String bizNo, String type, String subType) {        return Lists.newArrayList();    }}