在后台管理系统中,日志是不可或缺的,通常,在开发的时候我们会设置日志级别为debug
或info
,上线后再设置为error
,这些系统日志在帮助我们排查系统问题时作用非常大。但还有一类日志也是非常重要的,那就是操作日志,也就是记录用户在某个时间点干了什么事的日志。这篇文章就简单聊聊如何更好的记录操作日志。
在参与过不少项目的开发后,大概总结出了下面这么几种记录日志的方式。
第一种就是直接调用日志方法,类似下面这种,这类代码在以前的老系统中非常常见,好处是代码清晰,但太冗余了,总不可能每次要记录日志都复制一遍,如果不需要记录日志了,那又得挨个删除,特别麻烦。
void doSomething() {
String userName = WebUtil.getUserName();
logService.saveLog(userName, "${userName} did something at ${date}...");
}
第二种就是目前非常流行的注解加切面的方式,也是今天要介绍的。但我发现,在不少项目中,日志的功能都比较单一,记录的结果大都是“[xxx]执行了[xxx]操作”之类的,但在执行这个操作的时候,参数以及目标数据的信息等,日志中是看不出来的,这时候可能又会怀念上面的第一种方式,但实则大可不必。
用过spring缓存的应该都记得spring缓存中的非常常用的几个注解,如@Cacheable
、@Cacheput
等,在使用这些注解的时候,我们都会活用SpringEL
表达式来操作缓存,如下面的代码,通过key = #id
来动态设置key的值
@Cacheable(value = "user", key = "#id")
public UserInfo getById(Long id) {
return userMapper.selectById(id);
}
那么在日志记录的时候,我们也可以模仿spring缓存,让日志注解支持SpringEL
表达式,如此一来就可以非常方便的记录我们需要的日志了。
首先,新建日志注解LogRecord
,代码如下
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface LogRecord {
/**
* 日志内容,支持SpEL表达式
*/
String content();
/**
* 业务标识,支持SpEL表达式
*/
String bizNo();
/**
* 类别
*/
String category() default "";
/**
* 日志记录条件,支持SpEL表达式
*/
String condition() default "true";
/**
* 是否保存入参
*/
String saveParams() default "true";
}
然后是最重要的切面
@RequiredArgsConstructor
@Aspect
@Component
public class LogRecordAspect {
private final ExpressionParser parser = new SpelExpressionParser();
private final String spElFlag = "#";
@Pointcut("@annotation(com.ygr.modules.sys.aspect.LogRecord)")
public void pointcut() {
}
@Around("pointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
Object proceed = joinPoint.proceed();
if (proceed instanceof ApiResult) {
if (((ApiResult) proceed).isOk()) {
recordLog(joinPoint, (ApiResult) proceed);
}
}
return proceed;
}
private void recordLog(ProceedingJoinPoint joinPoint, ApiResult apiResult) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
LogRecord logRecord = method.getAnnotation(LogRecord.class);
// 获取方法入参,key为参数名,value为参数值
LinkedHashMap<String, Object> params = resolveParams(joinPoint);
// 求值上下文
StandardEvaluationContext context = new StandardEvaluationContext();
if (params.size() == 1) {
// 当参数只有一个时,设置根对象,例如入参为对象,则此时可以使用 #root.id 来获取对象的id
params.forEach((k, v) -> context.setRootObject(v));
}
int i = 0;
for (Map.Entry<String, Object> entry : params.entrySet()) {
context.setVariable(entry.getKey(), entry.getValue());
// 设置参数别名,按顺序,可使用 #a0 或 #p0 来获取第一个入参
context.setVariable("a" + i, entry.getValue());
context.setVariable("p" + i, entry.getValue());
i++;
}
// ApiResult是自定义的web返回格式类,将真正执行结果也放入上下文
// 因为有些日志是需要获取执行结果的,比如新增数据,新增成功之后才会有id
context.setVariable("result", apiResult);
String condition = resolveValue(logRecord.condition(), context);
// 如果不满足记录日志条件,则返回
if (!Boolean.parseBoolean(condition)) {
return;
}
boolean saveParams = Boolean.parseBoolean(resolveValue(logRecord.saveParams(), context));
if (saveParams) {
// TODO 如果需要保存日志到数据库
}
}
private LinkedHashMap<String, Object> resolveParams(ProceedingJoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Object[] arguments = joinPoint.getArgs();
String[] paramNames = getParameterNames(method);
LinkedHashMap<String, Object> params = new LinkedHashMap<>();
for (int i = 0; i < arguments.length; i++) {
params.put(paramNames[i], arguments[i]);
}
return params;
}
private String[] getParameterNames(Method method) {
ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
return parameterNameDiscoverer.getParameterNames(method);
}
private String resolveValue(String exp, EvaluationContext context) {
String value;
// 如果包含#字符,则使用SpringEL表达式进行解析
if (exp.contains(spElFlag)) {
value = resolveValueByExpression(exp, context);
} else {
// 否则不处理
value = exp;
}
return value;
}
private String resolveValueByExpression(String spELString, EvaluationContext context) {
// 构建表达式
Expression expression = parser.parseExpression(spELString);
// 解析
return expression.getValue(context, String.class);
}
}
代码很简单,注释很详细了,就不再多说了。使用也很简单,直接加注解就行,比如新增配置参数
@LogRecord(category = "SysConfigParam", bizNo = "#result.data.id", content = "'新增配置参数,code:' + #entity.code + ',name:' + #entity.name")
@PostMapping("/sys-config-param")
public ApiResult<SysConfigParam> insert(@RequestBody SysConfigParam entity) {
this.service.save(entity);
return ApiResult.ok(entity);
}
这其实还算比较简单的类型了,对于一些更新操作,有些时候可能希望日志记录得更详细。客户管理员在查看日志的时候,相比于[xxx]修改手机号为[15888888888]
,肯定是更希望看到[xxx]将手机号由[15877777777]修改为了[15888888888]
。这就要求在记录日志时,不仅需要入参数据,还需要查询修改前的数据,最后才拼接出需要的日志。
这一点,SpringEL
也能很好的支持,因为SpringEL
是支持方法作为表达式,比如最常见的SpringSecurity
中权限校验注解@PreAuthorize("hasRole('ROLE_xxx')" )
,就是一个很好的例子。当然也可以借助线程上下文,将参数设置到上下文中进行参数传递。
至于自定义方法的解析,等下次有空了再做记录。
最后一种就是使用Canal
监听数据库的增量日志,虽说解耦,但局限性太大,目前只支持mysql,且一旦操作涉及RPC,这种方式就几乎没用了。