- 作者:老汪软件技巧
- 发表时间:2024-12-15 21:04
- 浏览量:
主要是对某个对象进行新增操作或者修改操作后记录下这个新增或者修改的事件,要求可读性比较强,因为它主要是给用户看的。
业务操作日志的记录格式大概分为下面几种:
实现方式
市面上常用的实现方式一般有以下几种:
1.通过日志文件的方式记录
log.info("新建订单");
log.info("新建订单,编号:{}",code);
log.info("编辑商品名称,从{}改为{}",old,new);
定下日志模板,按照模板打印,然后搜集日志分析。
2.监听数据库记录操作日志
监听数据库,从底层知道是哪些数据做了修改,然后根据更改的数据记录操作日志。典型的就是通过Canal监听数据库binlog日志
优点:和业务逻辑完全分离
缺点:只能针对数据库的更改做操作日志记录
3.方法注解实现操作日志
采用 AOP 的方式记录日志,让操作日志和业务逻辑解耦
@AutoLog("新建订单")
public Response<Boolean> save(Request request) {
doSave();
}
优点:业务逻辑和业务代码可以做到解耦
缺点:文案是静态的,没有包含动态的文案
动态的操作日志
以下是为了支持动态日志模板,扩展了方法注解aop实现方式的一个业务日志记录组件,组件结构如下:
通过AOP拦截器实现的,整体主要分为拦截模块、日志解析模块、日志保存模块。
组件提供了3个扩展点,分别是:自定义函数、默认处理人、业务保存。
一个工具:日志上下文。业务可以根据自己的业务特性定制符合自己业务的逻辑。
AOP拦截
针对 @AutoLog 注解解析出需要记录的操作日志,然后把操作日志持久化,注解的定义:
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface AutoLog {
/**
* 业务类型
* @return
*/
String bizType() default "";
/**
* 业务编码
* @return
*/
String bizNo() default "";
/**
* 操作类型
* @return
*/
String opType() default "";
/**
* 日志成功-主体信息
* @return
*/
String success() default "";;
/**
* 日志失败-主体信息
* @return
*/
String fail() default "";
/**
* 额外预留字段
* @return
*/
String extra() default "";
/**
* 操作人
* @return
*/
String operatorId() default "";
/**
* 是否异步,默认同步
* @return
*/
boolean async() default false;
/**
* 是否记录异常日志 默认不记录
* @return
*/
boolean recordFail() default false;
}
####整体流程图
操作日志的记录持久化是在方法执行完之后执行的,当方法抛出异常之后会先捕获异常
解析逻辑
如果只是上面的功能,那只是简单的aop日志,无法实现如下动态模板日志:
@ AutoLog(success = "修改{{#itemName}}商品的价格:从{fen2Yuan{#oldPrice}}, 修改到{fen2Yuan{#request.getPrice()}}",
fail = "失败信息为{{#_errorMsg}}",
operator = "{{#user.userName}}",
bizNo="{{#request.itemId}}")
public void modifyPrirce(updatePriceRequest request){
// 查询出原来的价格
LogRecordContext.putVariable("oldPrice", PriceService.queryOldPrice(request.getItemId()));
// 更新价格
doUpdate(request);
}
Spring 3 提供了一个非常强大的功能:Spring EL,是一种强大的表达式语言,支持在运行时查询和操作对象图。 虽然SpEL作为Spring产品组合中表达式评估的基础,但并不直接与Spring绑定,可以独立使用。举个例子:
SpelExpressionParser parser = new SpelExpressionParser();
/*访问变量*/
// 定义变量
String name = "Tom";
//表达式的上下文
EvaluationContext context = new StandardEvaluationContext();
context.setVariable("myName", name);
// 访问变量 Tom
System.out.println(parser.parseExpression("#myName").getValue(context));
/*访问对象*/
//表达式的上下文
EvaluationContext context2 = new StandardEvaluationContext();
AdItemInfo ad = new AdItemInfo();
ad.setBlockCode("0001");
context2.setVariable("ad", ad);
List<String> list = Lists.newArrayList("a", "b");
Map<String, String> map = Maps.newHashMap();
map.put("A", "1");
map.put("B", "2");
context2.setVariable("map", map);
context2.setVariable("list", list);
System.out.println(parser.parseExpression("#ad.blockCode").getValue(context2));
System.out.println(parser.parseExpression("#list[0]").getValue(context2));
System.out.println(parser.parseExpression("#map[A]").getValue(context2));
/*支持操作符*/
System.out.println(parser.parseExpression("1 < 2").getValue());
System.out.println( parser.parseExpression("1 gt -1").getValue());
System.out.println( parser.parseExpression("true or true").getValue());
System.out.println( parser.parseExpression("true || true").getValue());
System.out.println( parser.parseExpression("2 ^ 3").getValue());
System.out.println( parser.parseExpression("true ? true : false").getValue());
/*模板表达式*/
parser.parseExpression("版位编码#{#ad.blockCode}").getValue(context);
####日志动态模板解析
@AutoLog(success = "修改{{#itemName}}商品的价格:从{fen2Yuan{#oldPrice}}, 修改到{fen2Yuan{#request.getPrice()}}",
fail = "失败信息为{{#_errorMsg}}"
收集@AutoLog注解属性信息,使用spel解析【{{#属性}}】的模板,关键实现:
private final ExpressionParser parser = new SpelExpressionParser();
private static final Pattern pattern = Pattern.compile("\{\s*(\w*)\s*\{(.*?)}}");
private final LocalVariableTableParameterNameDiscoverer discoverer = new LocalVariableTableParameterNameDiscoverer();
/**
* 处理模板中的表达式,并返回表达式与处理结果的映射
* 该方法主要用于解析传入的模板列表中的表达式(如果存在),并根据表达式计算结果替换模板中的占位符
*
* @param spElTemplates 模板列表,可能包含表达式的字符串
* @param ret 目标方法返回值,用于表达式解析
* @param method 目标方法,用于表达式解析
* @param args 目标方法方法参数,用于表达式解析
* @param errorMsg 错误信息,用于表达式解析失败时记录或抛出
* @return 返回一个映射,键为原始模板字符串,值为处理后的结果字符串
*/
private Map<String, String> processTemplate(List<String> spElTemplates, Object ret, Method method, Object[] args,
String errorMsg) {
// 初始化一个map,用于存储处理后的表达式及其对应的结果
Map<String, String> expressionValues = new HashMap<>();
// 创建SPEL上下文,用于表达式解析
EvaluationContext evaluationContext = bindParam(method,args,ret,errorMsg);
// 遍历模板列表
for (String expressionTemplate : spElTemplates) {
// 检查模板中是否包含表达式
if (expressionTemplate.contains("{")) {
// 使用正则表达式匹配模板中的表达式和函数名
Matcher matcher = pattern.matcher(expressionTemplate);
StringBuffer parsedStr = new StringBuffer();
while (matcher.find()) {
// 获取属性
String expression = matcher.group(2);
// 获取函数名
String functionName = matcher.group(1);
// 解析属性
Object value =this.parser.parseExpression(expression).getValue(evaluationContext);
// 根据函数名处理自定义函数,得到最终解析结果
String parsedStrThis = logFunctionParser.getFunctionReturnValue(value, functionName);
// 将匹配的模板替换为解析结果,并且将替换后的子串以及其之前到上次匹配子串之后的字符串段添加到一个 StringBuffer 对象里
matcher.appendReplacement(parsedStr, Strings.nullToEmpty(parsedStrThis));
}
// 将剩余的未处理部分添加到StringBuffer 对象里
matcher.appendTail(parsedStr);
// 将处理后的模板字符串添加到结果映射中
expressionValues.put(expressionTemplate, parsedStr.toString());
} else {
// 如果模板中不包含表达式,则直接将模板字符串添加到结果映射中
expressionValues.put(expressionTemplate, expressionTemplate);
}
}
// 返回结果映射
return expressionValues;
}
/**
* 得到Spel上下文,便于后续解析模板
* 1.将方法的参数名和参数值绑定
* 2.加入上下文参数
* 3.加入方法执行结果
*
* @param method 方法,根据方法获取参数名
* @param args 方法的参数值
* @param ret 方法的执行结果
* @param errorMsg 方法执行异常信息
* @return
*/
private EvaluationContext bindParam(final Method method, final Object[] args, Object ret, String errorMsg) {
//获取方法的参数名
final String[] params = discoverer.getParameterNames(method);
//将参数名与参数值对应起来
final EvaluationContext context = new StandardEvaluationContext();
for (int len = 0; len < params.length; len++) {
context.setVariable(params[len], args[len]);
}
//加入上下文参数
Map<String, Object> variables = LogRecordContext.getVariables();
if (variables != null && variables.size() > 0) {
for (Map.Entry<String, Object> entry : variables.entrySet()) {
context.setVariable(entry.getKey(), entry.getValue());
}
}
context.setVariable("_ret", ret);
context.setVariable("_errorMsg", errorMsg);
return context;
}
日志上下文实现
除了直接写在注解上,有时日志需要记录方法参数不存在的参数,还有业务执行过程中产生的数据。
实现: 业务方法中调用LogRecordContext,把变量放到LogRecordContext 中,然后 SpEL表达式就可以顺利的解析方法上不存在的参数了。
LogRecordContext.putVariable("oldPrice", PriceService.queryOldPrice(request.getItemId()));
工具关键点: LogRecordContext 关键实现,这个类里面通过一个 ThreadLocal 变量保持了一个栈,栈里面是个 Map,Map 对应了变量的名称和变量的值。
private static final InheritableThreadLocal<Stack<Map<String, Object>>> variableMapStack = new InheritableThreadLocal<>();
问题:
为什么不直接设置一个map对象,而是要设置一个 Stack 结构呢?
回答:
当注解@AutoLog方法里嵌套调用了另一个注解@AutoLog方法,最先执行完的方法会释放掉map对象,导致后续执行完的方法再去获取上下文为空,而且如果多个方法设置了相同的变量,变量就会被相互覆盖。
利用Stack先进后出的特点,解决方法调用嵌套方法导致变量提前释放和覆盖的问题:
每执行一个方法都会压栈一个 Map,方法执行完之后会 Pop 掉这个 Map,从而避免变量共享和覆盖问题。
当然,使用了这个结构,天然就不支持嵌套注解方法是异步多线程调用,必须同步保证顺序。
默认操作人逻辑
public interface ILogOperatorService {
/**
* 获取当前登陆的用户id
*/
String getOperatorId();
}
通过实现ILogOperatorService接口获得默认操作人id。组件在解析 operatorId 的时候,就判断注解上的 operatorId 是否是空,如果注解上没有指定,我们就从 ILogOperatorService 的 getOperatorId 方法获取了
自定义函数逻辑
自定义函数的类图如下:
IFunctionService根据传入的函数名称 functionName 找到对应的 IParseFunction,然后把参数传入到 IParseFunction 的 apply 方法上最后返回函数的值。
用法:如之前的例子---》{fen2Yuan{#oldPrice}} fen2Yuan就是需要实现IParseFunction的自定义方法
日志持久化逻辑
public interface ILogRecordService {
/**
* 保存log
*
* @param logRecord 日志实体
*/
void record(LogRecord logRecord);
}
业务可以实现这个保存接口,然后把日志保存在任何存储介质上。
还支持异步或者同步,可以和业务放在一个事务中保证操作日志和业务的一致性,也可以新开辟一个事务,保证日志的错误不影响业务的事务。
###参考资料
Spring Expression Language (SpEL)
美图日志组件实现