系统操作日志**是用于记录系统中用户或系统本身所执行的各类操作的日志信息。这些日志通常包括操作的时间、操作的用户、具体操作内容、操作结果以及其他相关信息。

  • 安全审计:记录用户操作以防止恶意行为,确保系统的安全性。
  • 问题排查:在系统出现问题时,可以通过操作日志快速定位问题来源。

可以类比之前做过的订单状态改变快照表的功能,主要是为后期做追溯和操作留痕。
在本项目中,我们选择将日志持久化到数据库中,而不是采用生成额外的文件存储在磁盘中。
那么问题来了,我们该如何设计数据库表呢?
这里提供两种思路:

  • 统一管理:t_operation_log,比如优惠券操作、权限操作等放在一张表。适合体量不大的系统。
  • 细粒度拆分:t_coupon_template_log,操作记录随着业务隔离。适合体量较大的系统。

本项目采用第二种方式,随着业务发展,会有更多的模块引入日志功能。
按照之前的方式进行分库分表操作后,0 库中就有t_coupon_template_log_0~7 表,1 库中就有t_coupon_template_log_8~15 表。

CREATE TABLE `t_coupon_template_log_?`
(
    `id`                 bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
    `shop_number`        bigint(20) DEFAULT NULL COMMENT '店铺编号',
    `coupon_template_id` bigint(20) DEFAULT NULL COMMENT '优惠券模板ID',
    `operator_id`        bigint(20) DEFAULT NULL COMMENT '操作人',
    `operation_log`      text COMMENT '操作日志',
    `original_data`      varchar(1024) DEFAULT NULL COMMENT '原始数据',
    `modified_data`      varchar(1024) DEFAULT NULL COMMENT '修改后数据',
    `create_time`        datetime      DEFAULT NULL COMMENT '创建时间',
    PRIMARY KEY (`id`),
    KEY                  `idx_shop_number` (`shop_number`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板操作日志表';

接下来就是在业务代码中添加日志打点的业务代码。
肯定不能在每处都添加大量的操作日志表的语句,应该使用的是一种集中管理的方式,比如利用 AOP实现等。光有 AOP 的支持是不够的,我们还要针对业务模块的具体情况来操作日志。

SpEL 表达式增强日志功能

SpEL 即 Spring 表达式语言,是一种强大的表达式语言,可以在运行时评估表达式并生成值。SpEL 最常用于 Spring Framework 中的注解等场景,也可以以编程方式在 Java 应用程序中使用。
SpEL 应用场景:

  • 动态参数配置:可以通过 SpEL 将应用程序中的各种参数配置化,例如配置文件中的数据库连接信息、业务规则等。通过动态配置,可以在运行时根据不同的环境或需求来进行灵活的参数设置。
  • 运行时注入:使用SpEL,可以在运行时动态注入属性值,而不需要在编码时硬编码。这对于需要根据当前上下文动态调整属性值的场景非常有用。
  • 条件判断与业务逻辑:SpEL支持复杂的条件判断和逻辑计算,可以方便地在运行时根据条件来执行特定的代码逻辑。例如,在权限控制中,可以使用SpEL进行资源和角色的动态授权判断。

官方文档:8. Spring Expression Language (SpEL)
使用样例:

/**
 * SpEL 表达式测试类
 */
public class CouponTemplateLogSpELTests {

    /**
     * 调用静态类方法
     */
    @Test
    public void testSpELGetRandom() {
        String spELKey = "T(java.lang.Math).random()";
        ExpressionParser parser = new SpelExpressionParser();
        Expression expression = parser.parseExpression(spELKey);
        Assert.isTrue(expression.getValue() instanceof Double);
    }

    /**
     * 调用静态类方法并运算
     */
    @Test
    public void testSpELGetRandomV2() {
        String spELKey = "T(java.lang.Math).random() * 100.0";
        ExpressionParser parser = new SpelExpressionParser();
        Expression expression = parser.parseExpression(spELKey);
        Assert.isTrue(expression.getValue() instanceof Double);
    }

    /**
     * 调用当前登录用户静态类方法
     */
    @Test
    public void testSpELGetCurrentUser() {
        // 初始化数据
        String userid = "1810518709471555585";
        UserContext.setUser(new UserInfoDTO(userid, "pdd45305558318", 1810714735922956666L));

        // 调用用户上下文获取当前用户 ID
        String spELKey = "T(com.nageoffer.onecoupon.merchant.admin.common.context.UserContext).getUserId()";
        ExpressionParser parser = new SpelExpressionParser();
        Expression expression = parser.parseExpression(spELKey);
        try {
            Assert.equals(expression.getValue(), userid);
        } finally {
            UserContext.removeUser();
        }
    }

    /**
     * 调用当前登录用户静态类方法,如果为空取默认值
     */
    @Test
    public void testSpELGetCurrentUserDefaultValue() {
        // 调用用户上下文获取当前用户 ID,如果为空,取默认值
        String spELKey = "T(com.nageoffer.onecoupon.merchant.admin.common.context.UserContext).getUserId() ?: 'ding.ma'";
        ExpressionParser parser = new SpelExpressionParser();
        Expression expression = parser.parseExpression(spELKey);
        Assert.equals(expression.getValue(), "ding.ma");
    }
}

美团 mzt-biz-log 操作日志框架

框架分析见:
日志记录

<dependency>
    <groupId>io.github.mouzt</groupId>
    <artifactId>bizlog-sdk</artifactId>
    <version>3.0.6</version>
</dependency>

应用添加启动注解:

@EnableLogRecord(tenant = "MerchantAdmin")
public class MerchantAdminApplication {
    // ......
}

添加注解 @LogRecord:

@LogRecord(
        success = """
                创建优惠券:{{#requestParam.name}}, \
                优惠对象:{{#requestParam.target}}, \
                优惠类型:{{#requestParam.type}}, \
                库存数量:{{#requestParam.stock}}, \
                优惠商品编码:{{#requestParam.goods}}, \
                有效期开始时间:{{#requestParam.validStartTime}}, \
                有效期结束时间:{{#requestParam.validEndTime}}, \
                领取规则:{{#requestParam.receiveRule}}, \
                消耗规则:{{#requestParam.consumeRule}};
                """,
        type = "CouponTemplate",
        bizNo = "{{#bizNo}}",
        extra = "{{#requestParam.toString()}}"
)
@Override
public void createCouponTemplate(CouponTemplateSaveReqDTO requestParam) {
    // ......
}
  • success:方法执行成功后的日志模版。
  • type:操作日志的类型,比如:订单类型、商品类型。
  • bizNo:日志绑定的业务标识,需要是我们优惠券模板的 ID,但是目前拿不到,放一个占位符。
  • extra:日志的额外信息。

得到日志如下:

2024-08-16T23:47:46.838+08:00  INFO 16761 --- [io-10010-exec-1] c.m.l.s.i.DefaultLogRecordServiceImpl    : 【logRecord】log=LogRecord(id=null, tenant=MerchantAdmin, type=CouponTemplate, subType=, bizNo={{#bizNo}}, operator=111, action=创建优惠券:用户下单满10减3特大优惠,优惠对象:1,优惠类型:0,库存数量:20990,优惠商品编码:,有效期开始时间:Mon Jul 08 12:00:00 CST 2024,有效期结束时间:Tue Jul 08 12:00:00 CST 2025,领取规则:{"limitPerPerson":10,"usageInstructions":"3"},消耗规则:{"termsOfUse":10,"maximumDiscountAmount":3,"explanationOfUnmetConditions":"3","validityPeriod":"48"};, fail=false, createTime=Fri Aug 16 23:47:46 CST 2024, extra={"consumeRule":"{\"termsOfUse\":10,\"maximumDiscountAmount\":3,\"explanationOfUnmetConditions\":\"3\",\"validityPeriod\":\"48\"}","goods":"","name":"用户下单满10减3特大优惠","receiveRule":"{\"limitPerPerson\":10,\"usageInstructions\":\"3\"}","source":0,"stock":20990,"target":1,"type":0,"validEndTime":"2025-07-08 12:00:00","validStartTime":"2024-07-08 12:00:00"}, codeVariable={MethodName=createCouponTemplate, ClassName=class com.nageoffer.onecoupon.merchant.admin.service.impl.CouponTemplateServiceImpl})

注意到有两个值是我们在枚举类中配置的,这里显示的也是枚举类中的数字,想要直接展示具体内容,实现 IParseFunction 接口即可完成自定义函数:

@Component
public class CommonEnumParseFunction implements IParseFunction {

    public static final String DISCOUNT_TARGET_ENUM_NAME = DiscountTargetEnum.class.getSimpleName();
    private static final String DISCOUNT_TYPE_ENUM_NAME = DiscountTypeEnum.class.getSimpleName();

    @Override
    public String functionName() {
        return "COMMON_ENUM_PARSE";
    }

    @Override
    public String apply(Object value) {
        try {
            List<String> parts = StrUtil.split(value.toString(), "_");
            if (parts.size() != 2) {
                throw new IllegalArgumentException("格式错误,需要 '枚举类_具体值' 的形式。");
            }

            String enumClassName = parts.get(0);
            int enumValue = Integer.parseInt(parts.get(1));

            return findEnumValueByName(enumClassName, enumValue);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("第二个下划线后面的值需要是整数。", e);
        }
    }

    private String findEnumValueByName(String enumClassName, int enumValue) {
        if (DISCOUNT_TARGET_ENUM_NAME.equals(enumClassName)) {
            return DiscountTargetEnum.findValueByType(enumValue);
        } else if (DISCOUNT_TYPE_ENUM_NAME.equals(enumClassName)) {
            return DiscountTypeEnum.findValueByType(enumValue);
        } else {
            throw new IllegalArgumentException("未知的枚举类名: " + enumClassName);
        }
    }
}

COMMON_ENUM_PARSE 是这个函数的标识,加到 success 字符串变量中,即可自动完成解析。如果检查到 success 包含自定义函数,交由 IParseFunction#apply 方法执行。

success = """
                创建优惠券:{{#requestParam.name}}, \
                优惠对象:{COMMON_ENUM_PARSE{'DiscountTargetEnum' + '_' + #requestParam.target}}, \
                优惠类型:{COMMON_ENUM_PARSE{'DiscountTypeEnum' + '_' + #requestParam.type}}, \
                库存数量:{{#requestParam.stock}}, \
                优惠商品编码:{{#requestParam.goods}}, \
                有效期开始时间:{{#requestParam.validStartTime}}, \
                有效期结束时间:{{#requestParam.validEndTime}}, \
                领取规则:{{#requestParam.receiveRule}}, \
                消耗规则:{{#requestParam.consumeRule}};
                """,
日志上下文处理

bizNo 尚为空,但尴尬之处在于,我们的 id 是临时生成的,所以想要让注解能拿到 id,我们需要将 id 放入注解提供的 context 中:

// 因为模板 ID 是运行中生成的,@LogRecord 默认拿不到,所以我们需要手动设置
LogRecordContext.putVariable("bizNo", couponTemplateDO.getId());

LogRecordContext 会在方法结束后自动 Remove,所以不需要我们手动操作。

日志持久化

biz-log 中为我们预留了扩展接口,实现 ILogRecordService 接口就可以自定义保存方法。

@Slf4j
@Service
@RequiredArgsConstructor
public class DBLogRecordServiceImpl implements ILogRecordService {

    private final CouponTemplateLogMapper couponTemplateLogMapper;

    @Override
    public void record(LogRecord logRecord) {
        try {
            switch (logRecord.getType()) {
                case "CouponTemplate": {
                    CouponTemplateLogDO couponTemplateLogDO = CouponTemplateLogDO.builder()
                            .couponTemplateId(logRecord.getBizNo())
                            .shopNumber(UserContext.getShopNumber())
                            .operatorId(UserContext.getUserId())
                            .operationLog(logRecord.getAction())
                            .originalData(Optional.ofNullable(LogRecordContext.getVariable("originalData")).map(Object::toString).orElse(null))
                            .modifiedData(StrUtil.isBlank(logRecord.getExtra()) ? null : logRecord.getExtra())
                            .build();
                    couponTemplateLogMapper.insert(couponTemplateLogDO);
                }
            }
        } catch (Exception ex) {
            log.error("记录[{}]操作日志失败", logRecord.getType(), ex);
        }
    }

    @Override
    public List<LogRecord> queryLog(String bizNo, String type) {
        return List.of();
    }

    @Override
    public List<LogRecord> queryLogByBizNo(String bizNo, String type, String subType) {
        return List.of();
    }
}

至此,一套基本的日志系统就配置完成了。

Q:操作日志失败,需要回滚整个优惠券操作么?
A:不建议,我们是没有回滚的。如果说操作记录保存失败,应该打印异常日志以及报警。如果说对操作日志零容忍,可以选择事务。
Q:操作日志可以异步么?
A:视情况而定,如果说并发量比较小,可以同步执行。如果并发量比较大,建议在 DBLogRecordServiceImpl#record 方法中调用消息队列异步。

此作者没有提供个人介绍
最后更新于 2024-09-03