学习记录-操作日志
1.背景
系统操作日志是用于记录系统中用户或系统本身所执行的各类操作的日志信息。这些日志通常包括操作的时间、操作的用户、具体操作内容、操作结果以及其他相关信息。
安全审计:记录用户操作以防止恶意行为,确保系统的安全性。
问题排查:在系统出现问题时,可以通过操作日志快速定位问题来源。
2.数据库表设计
统一管理:t_operation_log,比如优惠券操作、权限操作等放在一张表。
细粒度拆分:t_coupon_template_log,操作记录随着业务隔离。
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 AUTO_INCREMENT=1817866003552428034 DEFAULT CHARSET=utf8mb4 COMMENT='优惠券模板操作日志表';
3.记录操作日志
@Override
public void createCouponTemplate(CouponTemplateSaveReqDTO requestParam) {
// ......
//业务代码
try {
String operationLog = String.format("%s 用户创建优惠券:%s,优惠对象:%s,优惠类型:%s,库存数量:%d,优惠商品编码:%s,有效期开始时间:%s,有效期结束时间:%s,领取规则:%s,消耗规则:%s;",
UserContext.getUsername(),
requestParam.getName(),
DiscountTargetEnum.findValueByType(requestParam.getTarget()),
DiscountTypeEnum.findValueByType(requestParam.getType()),
requestParam.getStock(),
requestParam.getGoods() == null ? "" : requestParam.getGoods(),
requestParam.getValidStartTime(),
requestParam.getValidEndTime(),
requestParam.getReceiveRule(),
requestParam.getConsumeRule());
CouponTemplateLogDO couponTemplateLogDO = CouponTemplateLogDO.builder()
.couponTemplateId(String.valueOf(couponTemplateDO.getId()))
.operatorId(UserContext.getUserId())
.shopNumber(UserContext.getShopNumber())
.operationLog(operationLog)
.modifiedData(JSON.toJSONString(couponTemplateDO))
.build();
couponTemplateLogMapper.insert(couponTemplateLogDO);
} catch (Exception ex) {
log.error("记录操作日志错误", ex);
}
}
缺点如下:
当业务变得复杂后,记录操作日志放在业务代码中会导致业务的逻辑比较繁琐。
对于代码的可读性和可维护性来说是一个灾难,因为不止这一处使用,存在大量的冗余代码。
4.通过 SpringAOP 和 SpEL 优雅记录
4.1什么是 Spring AOP (面向切面编程)
定义: Spring AOP(Aspect-Oriented Programming,面向切面编程)是 Spring 框架的一个重要特性,它允许开发人员在不修改原有业务逻辑的情况下,通过预定义的规则增强应用程序的功能。Spring AOP 使得横切关注点(如事务管理、日志记录、安全控制等)可以与业务逻辑分离,使代码更加模块化。
主要概念:
-
切面(Aspect):切面是一个关注横切关注点的模块。它是业务逻辑与横切关注点的一个结合点。例如,日志记录、事务管理等就是横切关注点。
-
连接点(JoinPoint):在应用程序执行过程中能够插入切面的地方。通常是方法的执行。
-
通知(Advice)
:切面执行的动作。它可以定义在连接点执行之前、之后或抛出异常时要做的事。常见的通知类型有:
@Before
:在方法执行之前执行@After
:在方法执行之后执行@AfterReturning
:方法正常执行后执行@AfterThrowing
:方法抛出异常后执行@Around
:围绕方法执行前后进行增强(可以自定义方法执行的逻辑)
-
切点(Pointcut):定义了通知应用在哪些连接点上,通常是某些特定方法的执行。切点通过表达式来定义。
-
目标对象(Target Object):被增强的对象,即原有业务逻辑所在的对象。
-
代理(Proxy):由 AOP 框架创建的对象,它负责拦截方法调用并在调用前后应用通知。
Spring AOP 工作原理: Spring AOP 基于代理模式,通常有两种代理方式:
- JDK 动态代理:基于接口创建代理对象,只能为实现了接口的类创建代理。
- CGLIB 代理:基于类创建代理对象,通过继承的方式生成目标对象的子类,可以为没有接口的类创建代理。
4.2什么是 Spring EL (Spring Expression Language)
定义: Spring EL(Spring Expression Language)是 Spring 框架提供的一种表达式语言,用于在 Spring 配置文件、注解或代码中动态地解析和计算字符串。它提供了对对象属性、方法调用、集合操作、逻辑运算等功能的支持,可以用于动态决策和配置。
主要特性:
- 对象操作:可以访问和操作对象的属性、调用对象的方法。
- 集合操作:支持对集合、数组、Map 等的操作。
- 运算符支持:支持算数运算符、逻辑运算符、比较运算符等。
- 条件判断:支持条件判断和逻辑控制。
- 支持函数:可以调用自定义的函数或 Spring 提供的函数。
- 内置变量:Spring EL 支持内置变量,如
#root
、#this
、#context
等,帮助在表达式中引用上下文信息。
案例:
/**
* 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");
}
}
5.美团 mzt-biz-log 操作日志框架
https://juejin.cn/post/7009116644031070244
https://github.com/mouzt/mzt-biz-log
5.1依赖引入
<dependency>
<groupId>io.github.mouzt</groupId>
<artifactId>bizlog-sdk</artifactId>
<version>3.0.6</version>
</dependency>
5.2启动类加上注解
应用启动类添加 @EnableLogRecord
注解,并配置租户。tenant 是代表租户的标识,一般一个服务或者一个业务下的多个服务都固定一个 tenant 就可以。
5.3添加注解 @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
:日志的额外信息。
问题:惠对象和优惠券类型都是(枚举) type 值(0/1),我们希望展示的时候是具体值,这种应该怎么解决?
5.4 自定义函数
实现 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 方法执行。
此时修改为
@LogRecord(
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}};
""",
type = "CouponTemplate",
bizNo = "{{#bizNo}}",
extra = "{{#requestParam.toString()}}"
)
5.5 日志记录上下文
缺少主键id,biz-log 为我们提供了日志记录上下文功能,将值放到上下文 LogRecordContext
里面,我们就能在运行时拿到。
并且LogRecordContext 会在方法结束后自动 Remove,不需要我们手动操作。
代码如下所示:
@LogRecord(
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}};
""",
type = "CouponTemplate",
bizNo = "{{#bizNo}}",
extra = "{{#requestParam.toString()}}"
)
@Override
public void createCouponTemplate(CouponTemplateSaveReqDTO requestParam) {
// ......
// 新增优惠券模板信息到数据库
CouponTemplateDO couponTemplateDO = BeanUtil.toBean(requestParam, CouponTemplateDO.class);
couponTemplateDO.setStatus(CouponTemplateStatusEnum.ACTIVE.getStatus());
couponTemplateDO.setShopNumber(UserContext.getShopNumber());
couponTemplateMapper.insert(couponTemplateDO);
// 因为模板 ID 是运行中生成的,@LogRecord 默认拿不到,所以我们需要手动设置
LogRecordContext.putVariable("bizNo", couponTemplateDO.getId());
}
5.6 保存数据库
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();
}
}
如果并发量比较小,可以同步执行。如果并发量比较大,在 DBLogRecordServiceImpl#record
方法中调用消息队列异步。