系统操作日志**是用于记录系统中用户或系统本身所执行的各类操作的日志信息。这些日志通常包括操作的时间、操作的用户、具体操作内容、操作结果以及其他相关信息。
- 安全审计:记录用户操作以防止恶意行为,确保系统的安全性。
- 问题排查:在系统出现问题时,可以通过操作日志快速定位问题来源。
可以类比之前做过的订单状态改变快照表的功能,主要是为后期做追溯和操作留痕。
在本项目中,我们选择将日志持久化到数据库中,而不是采用生成额外的文件存储在磁盘中。
那么问题来了,我们该如何设计数据库表呢?
这里提供两种思路:
- 统一管理: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
方法中调用消息队列异步。
Comments NOTHING