如何优雅地记录操作日志。
操作日记险些存在于每个体系中,而这些体系都有记载操作日记的一套 API。操作日记和体系日记纷歧样,操作日记必需要做到简单易懂。以是若何让操作日记不跟营业逻辑耦合,若何让操作日记的内容易于懂得,若何让操作日记的接入加倍简单。上面这些都是本文要答复的问题。我们主要环抱着若何“优雅”地记载操作日记睁开描写,愿望对从事相关事情的同窗可以或许有所赞助或者启迪。
1. 操作日记的使用处景

2. 实现方式
2.1 使用 Canal 监听数据库记载操作日记
2.2 经由过程日记文件的方式记载
2.3 经由过程 LogUtil 的方式记载日记
2.4 办法表明实现操作日记
3. 优雅地支撑 AOP 天生动态的操作日记
3.1 动态模板
4. 代码实现解析
4.1 代码布局
4.2 模块先容
5. 总结
1. 操作日记的使用处景
例子
体系日记和操作日记的区别
体系日记:体系日记主要是为开发排盘问题提供根据,一样平常打印在日记文件中;体系日记的可读性要求没那么高,日记中会包括代码的信息,好比在某个类的某一行打印了一个日记。
操作日记:主要是对某个工具进行新增操作或者改动操作跋文录下这个新增或者改动,操作日记要求可读性比拟强,由于它主要是给用户看的,好比订单的物流信息,用户必要知道在什么光阴产生了什么工作。再好比,客服对工单的处置记载信息。
操作日记的记载格局年夜概分为下面几种:
单纯的笔墨记载,好比:2021-09-16 10:00 订单创立。
简单的动态的文本记载,好比:2021-09-16 10:00 订单创立,订单号:NO.11089999,此中涉及变量订单号“NO.11089999”。
改动类型的文本,包括改动前和改动后的值,好比:2021-09-16 10:00 用户小明改动了订单的配送地址:从“黄灿灿小区”改动到“银盏盏小区” ,此中涉及变量配送的原地址“黄灿灿小区”和新地址“银盏盏小区”。
改动表单,一次会改动多个字段。
2. 实现方式 2.1 使用 Canal 监听数据库记载操作日记
Canal 是一款基于 MySQL 数据库增量日记解析,提供增量数据订阅和花费的开源组件,经由过程采纳监听数据库 Binlog 的方式,如许可以从底层知道是哪些数据做了改动,然后依据变动的数据记载操作日记。
这种方式的长处是和营业逻辑完全分别。毛病也很显著,局限性太高,只能针对数据库的变动做操作日记记载,假如改动涉及到其他团队的 RPC 的挪用,就没方法监听数据库了。举个例子:给用户发送关照,关照服务一样平常都是公司内部的公共组件,这时刻只能在挪用 RPC 的时刻手工记载发送关照的操作日记了。
2.2 经由过程日记文件的方式记载
log.info("大众订单创立"大众)
log.info("大众订单已经创立,订单编号:{}"大众, orderNo)
log.info("大众改动了订单的配送地址:从“{}”改动到“{}”, "大众黄灿灿小区"大众, "大众银盏盏小区"大众)
这种方式的操作记载必要办理三个问题。
问题一:操作职员若何记载
借助 SLF4J 中的 MDC 对象类,把操作人放在日记中,然后在日记中同一打印出来。起首在用户的拦阻器中把用户的标识 Put 到 MDC 中。
@Component
public class UserInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//获取到用户标识
String userNo = getUserNo(request);
//把用户 ID 放到 MDC 上下文中
MDC.put("大众userId"大众, userNo);
return super.preHandle(request, response, handler);
}
private String getUserNo(HttpServletRequest request) {
// 经由过程 SSO 或者Cookie 或者 Auth信息获取到 当前登岸的用户信息
return null;
}
}
其次,把 userId 格局化到日记中,使用 %X{userId} 可以取到 MDC 顶用户标识。
"大众%d{yyyy-MM-dd HH:mm:ss.SSS} %t %-5level %X{userId} %logger{30}.%method:%L - %msg%n"大众
问题二:操作日记若何和体系日记区离开
经由过程设置装备摆设 Log 的设置装备摆设文件,把有关操作日记的 Log 零丁放到一日记文件中。
//分歧营业日记记载到分歧的文件
logs/business.logFile>
trueappend>
INFOlevel>
ACCEPTonMatch>
DENYonMismatch>
filter>
logs/营业A.%d.%i.logfileNamePattern>
90maxHistory>
10MBmaxFileSize>
timeBasedFileNamingAndTriggeringPolicy>
rollingPolicy>
公众%d{yyyy-MM-dd HH:mm:ss.SSS} %t %-5level %X{userId} %logger{30}.%method:%L - %msg%n"大众pattern>
UTF-8charset>
encoder>
appender>
logger>
然后在 Java 代码中零丁的记载营业日记。
//记载特定日记的声明
private final Logger businessLog = LoggerFactory.getLogger("大众businessLog"大众);
//日记存储
businessLog.info("大众改动了配送地址"大众);
问题三:若何天生可读懂的日记案牍
可以采纳 LogUtil 的方式,也可以采纳切面的方式天生日记模板,后续内容将会进行先容。如许就可以把日记零丁保留在一个文件中,然后经由过程日记网络可以把日记保留在 Elasticsearch 或者数据库中,接下来我们看下若何天生可读的操作日记。
2.3 经由过程 LogUtil 的方式记载日记
LogUtil.log(orderNo, "大众订单创立公众, 公众小明公众)
LogUtil.log(orderNo, 公众订单创立,订单号"大众+"大众NO.11089999公众, 公众小明"大众)
String template = "大众用户%s改动了订单的配送地址:从“%s”改动到“%s”"大众
LogUtil.log(orderNo, String.format(tempalte, 公众小明公众, "大众黄灿灿小区"大众, "大众银盏盏小区"大众), 公众小明"大众)
这里解释下为什么记载操作日记的时刻都绑定了一个 OrderNo,由于操作日记记载的是:某一个“光阴”“谁”对“什么”做了什么“工作”。当查询营业的操作日记的时刻,会查询针对这个订单的的所有操作,以是代码中加上了 OrderNo,记载操作日记的时刻必要记载下操作人,以是传了操作人“小明”进来。
上面看起来问题并不年夜,在改动地址的营业逻辑办法中使用一行代码记载了操作日记,接下来再看一个更繁杂的例子:
private OnesIssueDO updateAddress(updateDeliveryRequest request) {
DeliveryOrder deliveryOrder = deliveryQueryService.queryOldAddress(request.getDeliveryOrderNo());
// 更新派送信息,德律风,收件人,地址
doUpdate(request);
String logContent = getLogContent(request, deliveryOrder);
LogUtils.logRecord(request.getOrderNo(), logContent, request.getOperator);
return onesIssueDO;
}
private String getLogContent(updateDeliveryRequest request, DeliveryOrder deliveryOrder) {
String template = "大众用户%s改动了订单的配送地址:从“%s”改动到“%s”"大众;
return String.format(tempalte, request.getUserName(), deliveryOrder.getAddress(), request.getAddress);
}
可以看到上面的例子使用了两个办法代码,外加一个 getLogContent 的函数实现了操作日记的记载。当营业变得繁杂后,记载操作日记放在营业代码中会导致营业的逻辑比拟复杂,末了导致 LogUtils.logRecord() 办法的挪用存在于许多营业的代码中,并且相似 getLogContent() 如许的办法也散落在各个营业类中,对付代码的可读性和可维护性来说是一个劫难。下面先容下若何避免这个劫难。
2.4 办法表明实现操作日记
为相识决上面问题,一样平常采纳 AOP 的方式记载日记,让操作日记和营业逻辑解耦,接下来看一个简单的 AOP 日记的例子。
@LogRecord(content="大众改动了配送地址公众)
public void modifyAddress(updateDeliveryRequest request){
// 更新派送信息 德律风,收件人、地址
doUpdate(request);
}
我们可以在表明的操作日记上记载固定案牍,如许营业逻辑和营业代码可以做到解耦,让我们的营业代码变得纯净起来。可能有同窗注意到,上面的方式固然解耦了操作日记的代码,然则记载的案牍并不相符我们的预期,案牍是静态的,没有包括动态的案牍,由于我们必要记载的操作日记是:用户%s改动了订单的配送地址,从“%s”改动到“%s”。接下来,我们先容一下若何优雅地使用 AOP 天生动态的操作日记。
3. 优雅地支撑 AOP 天生动态的操作日记 3.1 动态模板
一提到动态模板,就会涉及到让变量经由过程占位符的方式解析模板,从而到达经由过程表明记载操作日记的目标。模板解析的方式有许多种,这里使用了 SpEL(Spring Expression Language,Spring表达式语言)来实现。我们可以先写下期望的记载日记的方式,然后再看看可否实现如许的功效。
@LogRecord(content = "大众改动了订单的配送地址:从“#oldAddress”, 改动到“#request.address”"大众)
public void modifyAddress(updateDeliveryRequest request, String oldAddress){
// 更新派送信息 德律风,收件人、地址
doUpdate(request);
}
经由过程 SpEL 表达式引用办法上的参数,可以让变量添补到模板中到达动态的操作日记文本内容。然则如今还有几个问题必要办理:
操作日记必要知道是哪个操作人改动的订单配送地址。
改动订单配送地址的操作日记必要绑定在配送的订单上,从而可以依据配送订单号查询出对这个配送订单的所有操作。
为了在表明上记载之前的配送地址是什么,在办法署名上添加了一个和营业无关的 oldAddress 的变量,如许就不优雅了。
为相识决前两个问题,我们必要把期望的操作日记使用情势改成下面的方式:
@LogRecord(
content = "大众改动了订单的配送地址:从“#oldAddress”, 改动到“#request.address”公众,
operator = 公众#request.userName"大众, bizNo="大众#request.deliveryOrderNo"大众)
public void modifyAddress(updateDeliveryRequest request, String oldAddress){
// 更新派送信息 德律风,收件人、地址
doUpdate(request);
}
改动后的代码在表明上添加两个参数,一个是操作人,一个是操作日记必要绑定的工具。然则,在通俗的 Web 利用顶用户信息都是保留在一个线程上下文的静态办法中,以是 operator 一样平常是如许的写法(假定获取当前登岸用户的方式是 UserContext.getCurrentUser())。
operator = "大众#{T(com.meituan.user.UserContext).getCurrentUser()}"大众
如许的话,每个 @LogRecord 的表明上的操作人都是这么长一串。为了避免过多的反复代码,我们可以把表明上的 operator 参数设置为非必填,如许用户可以填写操作人。然则,假如用户不填写我们就取 UserContext 的 user(下文会先容若何取 user)。末了,最简单的日记酿成了下面的情势:
@LogRecord(content = "大众改动了订单的配送地址:从“#oldAddress”, 改动到“#request.address”"大众,
bizNo="大众#request.deliveryOrderNo"大众)
public void modifyAddress(updateDeliveryRequest request, String oldAddress){
// 更新派送信息 德律风,收件人、地址
doUpdate(request);
}
接下来,我们必要办理第三个问题:为了记载营业操作记载添加了一个 oldAddress 变量,不管怎么样这都不是一个好的实现方式,以是接下来,我们必要把 oldAddress 变量从改动地址的办法署名上去失落。然则操作日记确切必要 oldAddress 变量,怎么办呢。
要么和产物司理 PK 一下,让产物司理把案牍从“改动了订单的配送地址:从 xx 改动到 yy” 改为 “改动了订单的配送地址为:yy”。然则从用户体验上来看,第一种案牍更人道化一些,显然我们不会 PK 胜利的。那么我们就必需要把这个 oldAddress 查询出来然后供操作日记使用了。还有一种办理方法是:把这个参数放到操作日记的线程上下文中,供表明上的模板使用。我们依照这个思绪再改下操作日记的实当代码。
@LogRecord(content = 公众改动了订单的配送地址:从“#oldAddress”, 改动到“#request.address”"大众,
bizNo="大众#request.deliveryOrderNo"大众)
public void modifyAddress(updateDeliveryRequest request){
// 查询出本来的地址是什么
LogRecordContext.putVariable("大众oldAddress"大众, DeliveryService.queryOldAddress(request.getDeliveryOrderNo()));
// 更新派送信息 德律风,收件人、地址
doUpdate(request);
}
这时刻可以看到,LogRecordContext 办理了操作日记模板上使用办法参数以外变量的问题,同时避免了为了记载操作日记改动办法署名的设计。固然已经比之前的代码好了些,然则依然必要在营业代码里面加了一行营业逻辑无关的代码,假如有“逼迫症”的同窗还可以继续往下看,接下来我们会讲授自界说函数的办理计划。下面再看另一个例子:
@LogRecord(content = 公众改动了订单的配送员:从“#oldDeliveryUserId”, 改动到“#request.userId”公众,
bizNo="大众#request.deliveryOrderNo"大众)
public void modifyAddress(updateDeliveryRequest request){
// 查询出本来的地址是什么
LogRecordContext.putVariable("大众oldDeliveryUserId公众, DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
// 更新派送信息 德律风,收件人、地址
doUpdate(request);
}
这个操作日记的模板末了记载的内容是如许的格局:改动了订单的配送员:从 “10090”,改动到 “10099”,显然用户看到如许的操作日记是不明确的。用户对付用户 ID 是 10090 照样 10099 并不相识,用户期望看到的是:改动了订单的配送员:从“张三(18910008888)”,改动到“小明(13910006666)”。用户关怀的是配送员的姓名和德律风。然则我们办法中通报的参数只有配送员的 ID,没有配送员的姓名可德律风。我们可以经由过程上面的办法,把用户的姓名和德律风查询出来,然后经由过程 LogRecordContext 实现。
然则,“逼迫症”是不期望操作日记的代码嵌入在营业逻辑中的。接下来,我们斟酌另一种实现方式:自界说函数。假如我们可以经由过程自界说函数把用户 ID 转换为用户姓名和德律风,那么就能办理这一问题,依照这个思绪,我们把模板改动为下面的情势:
@LogRecord(content = "大众改动了订单的配送员:从“{deliveryUser{#oldDeliveryUserId}}”, 改动到“{deveryUser{#request.userId}}”公众,
bizNo="大众#request.deliveryOrderNo"大众)
public void modifyAddress(updateDeliveryRequest request){
// 查询出本来的地址是什么
LogRecordContext.putVariable("大众oldDeliveryUserId"大众, DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
// 更新派送信息 德律风,收件人、地址
doUpdate(request);
}
此中 deliveryUser 是自界说函数,使用年夜括号把 Spring 的 SpEL 表达式包裹起来,如许做的利益:一是把 Spring EL 表达式和自界说函数区离开便于解析;二是假如模板中不必要 SpEL 表达式解析可以容易的辨认出来,削减 SpEL 的解析进步机能。这时刻我们发现上面代码还可以优化成下面的情势:
@LogRecord(content = "大众改动了订单的配送员:从“{queryOldUser{#request.deliveryOrderNo()}}”, 改动到“{deveryUser{#request.userId}}”公众,
bizNo=公众#request.deliveryOrderNo公众)
public void modifyAddress(updateDeliveryRequest request){
// 更新派送信息 德律风,收件人、地址
doUpdate(request);
}
如许就不必要在 modifyAddress 办法中经由过程 LogRecordContext.putVariable() 设置老的快递员了,经由过程直接新加一个自界说函数 queryOldUser() 参数把派送订单通报进去,就能查到之前的配送人了,只必要让办法的解析在 modifyAddress() 办法执行之前运行。如许的话,我们让营业代码又变得纯净了起来,同时也让“逼迫症”不再觉得难熬难过了。
4. 代码实现解析 4.1 代码布局
上面的操作日记主要是经由过程一个 AOP 拦阻器实现的,整体主要分为 AOP 模块、日记解析模块、日记保留模块、Starter 模块;组件提供了4个扩大点,分离是:自界说函数、默认处置人、营业保留和查询;营业可以依据本身的营业特征定制相符本身营业的逻辑。
4.2 模块先容
有了上面的阐发,已经得出一种我们期望的操作日记记载的方式,接下来我们看下若何实现上面的逻辑。实现主要分为下面几个步调:
AOP 拦阻逻辑
解析逻辑
模板解析
LogContext 逻辑
默认的 operator 逻辑
自界说函数逻辑
默认的日记持久化逻辑
Starter 封装逻辑
4.2.1 AOP 拦阻逻辑
这块逻辑主要是一个拦阻器,针对 @LogRecord 表明阐发出必要记载的操作日记,然后把操作日记持久化,这里把表明定名为 @LogRecordAnnotation。接下来,我们看下表明的界说:
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface LogRecordAnnotation {
String success();
String fail() default "大众"大众;
String operator() default 公众"大众;
String bizNo();
String category() default "大众"大众;
String detail() default "大众"大众;
String condition() default "大众"大众;
}
表明中除了上面提到参数外,还增长了 fail、category、detail、condition 等参数,这几个参数是为了满意特定的场景,后面还会给出详细的例子。
为了坚持简单,组件的必填参数就两个。营业中的 AOP 逻辑年夜部门是使用 @Aspect 表明实现的,然则基于表明的 AOP 在 Spring boot 1.5 中兼容性是有问题的,组件为了兼容 Spring boot1.5 的版本我们手工实现 Spring 的 AOP 逻辑。
切面选择AbstractBeanFactoryPointcutAdvisor
实现,切点是经由过程StaticMethodMatcherPointcut
匹配包括LogRecordAnnotation
表明的办法。经由过程实现MethodInterceptor
接话柄现操作日记的加强逻辑。
下面是拦阻器的切点逻辑:
public class LogRecordPointcut extends StaticMethodMatcherPointcut implements Serializable {
// LogRecord的解析类
private LogRecordOperationSource logRecordOperationSource;
@Override
public boolean matches(@NonNull Method method, @NonNull Class targetClass) {
// 解析 这个 method 上有没有 @LogRecordAnnotation 表明,有的话会解析出来表明上的各个参数
return !CollectionUtils.isEmpty(logRecordOperationSource.computeLogRecordOperations(method, targetClass));
}
void setLogRecordOperationSource(LogRecordOperationSource logRecordOperationSource) {
this.logRecordOperationSource = logRecordOperationSource;
}
}
切面的加强逻辑主要代码如下:
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
Method method = invocation.getMethod();
// 日记记载
return execute(invocation, invocation.getThis(), method, invocation.getArguments());
}
private Object execute(MethodInvocation invoker, Object target, Method method, Object[] args) throws Throwable {
Class targetClass = getTargetClass(target);
Object ret = null;
MethodExecuteResult methodExecuteResult = new MethodExecuteResult(true, null, "大众"大众);
LogRecordContext.putEmptySpan();
Collection operations = new ArrayList<>();
Map functionNameAndReturnMap = new HashMap<>();
try {
operations = logRecordOperationSource.computeLogRecordOperations(method, targetClass);
List spElTemplates = getBeforeExecuteFunctionTemplate(operations);
//营业逻辑执行前的自界说函数解析
functionNameAndReturnMap = processBeforeExecuteFunctionTemplate(spElTemplates, targetClass, method, args);
} catch (Exception e) {
log.error(公众log record parse before function exception"大众, e);
}
try {
ret = invoker.proceed();
} catch (Exception e) {
methodExecuteResult = new MethodExecuteResult(false, e, e.getMessage());
}
try {
if (!CollectionUtils.isEmpty(operations)) {
recordExecute(ret, method, args, operations, targetClass,
methodExecuteResult.isSuccess(), methodExecuteResult.getErrorMsg(), functionNameAndReturnMap);
}
} catch (Exception t) {
//记载日记差错不要影响营业
log.error(公众log record parse exception"大众, t);
} finally {
LogRecordContext.clear();
}
if (methodExecuteResult.throwable != null) {
throw methodExecuteResult.throwable;
}
return ret;
}
拦阻逻辑的流程:
可以看到,操作日记的记载持久化是在办法执行完之后执行的,当办法抛出非常之后会先捕捉非常,等操作日记持久化完成后再抛出非常。在营业的办法执行之前,会对提前解析的自界说函数求值,办理了前面提到的必要查询改动之前的内容。
4.2.2 解析逻辑
模板解析
Spring 3 中提供了一个异常壮大的功效:SpEL,SpEL 在 Spring 产物中是作为表达式求值的焦点根基模块,它自己是可以脱离 Spring 自力使用的。举个例子:
public static void main(String[] args) {
SpelExpressionParser parser = new SpelExpressionParser();
Expression expression = parser.parseExpression("大众#root.purchaseName公众);
Order order = new Order();
order.setPurchaseName(公众张三"大众);
System.out.println(expression.getValue(order));
}
这个办法将打印 “张三”。LogRecord 解析的类图如下:
解析焦点类:LogRecordValueParser
里面封装了自界说函数和 SpEL 解析类LogRecordExpressionEvaluator
。
public class LogRecordExpressionEvaluator extends CachedExpressionEvaluator {
private Map expressionCache = new ConcurrentHashMap<>(64);
private final Map targetMethodCache = new ConcurrentHashMap<>(64);
public String parseExpression(String conditionExpression, AnnotatedElementKey methodKey, EvaluationContext evalContext) {
return getExpression(this.expressionCache, methodKey, conditionExpression).getValue(evalContext, String.class);
}
}
LogRecordExpressionEvaluator
承继自CachedExpressionEvaluator
类,这个类里面有两个 Map,一个是 expressionCache 一个是 targetMethodCache。在上面的例子中可以看到,SpEL 会解析成一个 Expression 表达式,然后依据传入的 Object 获取到对应的值,以是 expressionCache 是为了缓存办法、表达式和 SpEL 的 Expression 的对应关系,让办法表明上添加的 SpEL 表达式只解析一次。下面的 targetMethodCache 是为了缓存传入到 Expression 表达式的 Object。焦点的解析逻辑是上面末了一行代码。
getExpression(this.expressionCache, methodKey, conditionExpression).getValue(evalContext, String.class);
getExpression
办法会从 expressionCache 中获取到 @LogRecordAnnotation 表明上的表达式的解析 Expression 的实例,然后挪用getValue
办法,getValue
传入一个 evalContext 便是相似上面例子中的 order 工具。此中 Context 的实现将会在下文先容。
日记上下文实现
下面的例子把变量放到了 LogRecordContext 中,然后 SpEL 表达式就可以顺遂的解析办法上不存在的参数了,经由过程上面的 SpEL 的例子可以看出,要把办法的参数和 LogRecordContext 中的变量都放到 SpEL 的getValue
办法的 Object 中才可以顺遂的解析表达式的值。下面看看若何实现:
@LogRecord(content = 公众改动了订单的配送员:从“{deveryUser{#oldDeliveryUserId}}”, 改动到“{deveryUser{#request.getUserId()}}”公众,
bizNo="大众#request.getDeliveryOrderNo()公众)
public void modifyAddress(updateDeliveryRequest request){
// 查询出本来的地址是什么
LogRecordContext.putVariable("大众oldDeliveryUserId公众, DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
// 更新派送信息 德律风,收件人、地址
doUpdate(request);
}
在 LogRecordValueParser 中创立了一个 EvaluationContext,用来给 SpEL 解析办法参数和 Context 中的变量。相关代码如下:
EvaluationContext evaluationContext = expressionEvaluator.createEvaluationContext(method, args, targetClass, ret, errorMsg, beanFactory);
在解析的时刻挪用getValue
办法传入的参数 evalContext,便是上面这个 EvaluationContext 工具。下面是 LogRecordEvaluationContext 工具的承继系统:
LogRecordEvaluationContext 做了三个工作:
把办法的参数都放到 SpEL 解析的 RootObject 中。
把 LogRecordContext 中的变量都放到 RootObject 中。
把办法的返回值和 ErrorMsg 都放到 RootObject 中。
LogRecordEvaluationContext 的代码如下:
public class LogRecordEvaluationContext extends MethodBasedEvaluationContext {
public LogRecordEvaluationContext(Object rootObject, Method method, Object[] arguments,
ParameterNameDiscoverer parameterNameDiscoverer, Object ret, String errorMsg) {
//把办法的参数都放到 SpEL 解析的 RootObject 中
super(rootObject, method, arguments, parameterNameDiscoverer);
//把 LogRecordContext 中的变量都放到 RootObject 中
Map variables = LogRecordContext.getVariables();
if (variables != null && variables.size() > 0) {
for (Map.Entry entry : variables.entrySet()) {
setVariable(entry.getKey(), entry.getValue());
}
}
//把办法的返回值和 ErrorMsg 都放到 RootObject 中
setVariable("大众_ret"大众, ret);
setVariable(公众_errorMsg公众, errorMsg);
}
}
下面是 LogRecordContext 的实现,这个类里面经由过程一个 ThreadLocal 变量坚持了一个栈,栈里面是个 Map,Map 对应了变量的名称和变量的值。
public class LogRecordContext {
private static final InheritableThreadLocal>> variableMapStack = new InheritableThreadLocal<>();
//其他省略....
}
上面使用了 InheritableThreadLocal,以是在线程池的场景下使用 LogRecordContext 会呈现问题,假如支撑线程池可以使用阿里巴巴开源的 TTL 框架。那这里为什么不直接设置一个 ThreadLocal> 工具,而是要设置一个 Stack 布局呢。我们看一下这么做的缘故原由是什么。
@LogRecord(content = 公众改动了订单的配送员:从“{deveryUser{#oldDeliveryUserId}}”, 改动到“{deveryUser{#request.getUserId()}}”"大众,
bizNo=公众#request.getDeliveryOrderNo()"大众)
public void modifyAddress(updateDeliveryRequest request){
// 查询出本来的地址是什么
LogRecordContext.putVariable("大众oldDeliveryUserId公众, DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
// 更新派送信息 德律风,收件人、地址
doUpdate(request);
}
上面代码的执行流程如下:
看起来没有什么问题,然则使用 LogRecordAnnotation 的办法里面嵌套了另一个使用 LogRecordAnnotation 办法的时刻,流程就酿成下面的情势:
可以看到,当办法二执行了开释变量后,继续执行办法一的 logRecord 逻辑,此时解析的时刻 ThreadLocal>的 Map 已经被开释失落,以是办法一就获取不到对应的变量了。办法一和办法二共用一个变量 Map 还有个问题是:假如办法二设置了和办法一雷同的变量两个办法的变量就会被互相笼罩。以是终极 LogRecordContext 的变量的性命周期必要是下面的情势:
LogRecordContext 每执行一个办法都邑压栈一个 Map,办法执行完之后会 Pop 失落这个 Map,从而避免变量共享和笼罩问题。
默认操作人逻辑
在 LogRecordInterceptor 中 IOperatorGetService 接口,这个接口可以获取到当前的用户。下面是接口的界说:
public interface IOperatorGetService {
/**
* 可以在里面外部的获取当前登岸的用户,好比 UserContext.getCurrentUser()
*
* @return 转换成Operator返回
*/
Operator getUser();
}
下面给出了从用户上下文中获取用户的例子:
public class DefaultOperatorGetServiceImpl implements IOperatorGetService {
@Override
public Operator getUser() {
//UserUtils 是获取用户上下文的办法
return Optional.ofNullable(UserUtils.getUser())
.map(a -> new Operator(a.getName(), a.getLogin()))
.orElseThrow(()->new IllegalArgumentException("大众user is null"大众));
组件在解析 operator 的时刻,就断定表明上的 operator 是否是空,假如表明上没有指定,我们就从 IOperatorGetService 的 getUser 办法获取了。假如都获取不到,就会报错。
String realOperatorId = 公众公众;
if (StringUtils.isEmpty(operatorId)) {
if (operatorGetService.getUser() == null || StringUtils.isEmpty(operatorGetService.getUser().getOperatorId())) {
throw new IllegalArgumentException(公众user is null"大众);
}
realOperatorId = operatorGetService.getUser().getOperatorId();
} else {
spElTemplates = Lists.newArrayList(bizKey, bizNo, action, operatorId, detail);
}
自界说函数逻辑
自界说函数的类图如下:
下面是 IParseFunction 的接口界说:executeBefore
函数代表了自界说函数是否在营业代码执行之前解析,上面提到的查询改动之前的内容。
public interface IParseFunction {
default boolean executeBefore(){
return false;
}
String functionName();
String apply(String value);
}
ParseFunctionFactory 的代码比拟简单,它的功效是把所有的 IParseFunction 注入到函数工场中。
public class ParseFunctionFactory {
private Map allFunctionMap;
public ParseFunctionFactory(List parseFunctions) {
if (CollectionUtils.isEmpty(parseFunctions)) {
return;
}
allFunctionMap = new HashMap<>();
for (IParseFunction parseFunction : parseFunctions) {
if (StringUtils.isEmpty(parseFunction.functionName())) {
continue;
}
allFunctionMap.put(parseFunction.functionName(), parseFunction);
}
}
public IParseFunction getFunction(String functionName) {
return allFunctionMap.get(functionName);
}
public boolean isBeforeFunction(String functionName) {
return allFunctionMap.get(functionName) != null && allFunctionMap.get(functionName).executeBefore();
}
}
DefaultFunctionServiceImpl 的逻辑便是依据传入的函数名称 functionName 找到对应的 IParseFunction,然后把参数传入到 IParseFunction 的apply
办法上末了返回函数的值。
public class DefaultFunctionServiceImpl implements IFunctionService {
private final ParseFunctionFactory parseFunctionFactory;
public DefaultFunctionServiceImpl(ParseFunctionFactory parseFunctionFactory) {
this.parseFunctionFactory = parseFunctionFactory;
}
@Override
public String apply(String functionName, String value) {
IParseFunction function = parseFunctionFactory.getFunction(functionName);
if (function == null) {
return value;
}
return function.apply(value);
}
@Override
public boolean beforeFunction(String functionName) {
return parseFunctionFactory.isBeforeFunction(functionName);
}
}
4.2.3 日记持久化逻辑
同样在 LogRecordInterceptor 的代码中引用了 ILogRecordService,这个 Service 主要包括了日记记载的接口。
public interface ILogRecordService {
/**
* 保留 log
*
* @param logRecord 日记实体
*/
void record(LogRecord logRecord);
营业可以实现这个保留接口,然后把日记保留在任何存储介质上。这里给了一个 2.2 节先容的经由过程 log.info 保留在日记文件中的例子,营业可以把保留设置成异步或者同步,可以和营业放在一个事务中保证操作日记和营业的同等性,也可以新开拓一个事务,保证日记的差错不影响营业的事务。营业可以保留在 Elasticsearch、数据库或者文件中,用户可以依据日记布局和日记的存储实现响应的查询逻辑。
@Slf4j
public class DefaultLogRecordServiceImpl implements ILogRecordService {
@Override
// @Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(LogRecord logRecord) {
log.info("大众【logRecord】log={}"大众, logRecord);
}
}
4.2.4 Starter 逻辑封装
上面逻辑代码已经先容完毕,那么接下来必要把这些组件组装起来,然后让用户去使用。在使用这个组件的时刻只必要在 Springboot 的进口上添加一个表明 @EnableLogRecord(tenant = 公众com.mzt.test"大众)。此中 tenant 代表租户,是为了多租户使用的。
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
@EnableTransactionManagement
@EnableLogRecord(tenant = "大众com.mzt.test"大众)
public class Main {
public static void main(String[] args) {
SpringApplication.run(Main.class, args);
}
}
我们再看下 EnableLogRecord 的代码,代码中 Import LogRecordConfigureSelector.class
,在LogRecordConfigureSelector
类中裸露了LogRecordProxyAutoConfiguration
类。
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(LogRecordConfigureSelector.class)
public @interface EnableLogRecord {
String tenant();
AdviceMode mode() default AdviceMode.PROXY;
}
LogRecordProxyAutoConfiguration
便是装置上面组件的焦点类了,代码如下:
@Configuration
@Slf4j
public class LogRecordProxyAutoConfiguration implements ImportAware {
private AnnotationAttributes enableLogRecord;
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public LogRecordOperationSource logRecordOperationSource() {
return new LogRecordOperationSource();
}
@Bean
@ConditionalOnMissingBean(IFunctionService.class)
public IFunctionService functionService(ParseFunctionFactory parseFunctionFactory) {
return new DefaultFunctionServiceImpl(parseFunctionFactory);
}
@Bean
public ParseFunctionFactory parseFunctionFactory(@Autowired List parseFunctions) {
return new ParseFunctionFactory(parseFunctions);
}
@Bean
@ConditionalOnMissingBean(IParseFunction.class)
public DefaultParseFunction parseFunction() {
return new DefaultParseFunction();
}
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public BeanFactoryLogRecordAdvisor logRecordAdvisor(IFunctionService functionService) {
BeanFactoryLogRecordAdvisor advisor =
new BeanFactoryLogRecordAdvisor();
advisor.setLogRecordOperationSource(logRecordOperationSource());
advisor.setAdvice(logRecordInterceptor(functionService));
return advisor;
}
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
public LogRecordInterceptor logRecordInterceptor(IFunctionService functionService) {
LogRecordInterceptor interceptor = new LogRecordInterceptor();
interceptor.setLogRecordOperationSource(logRecordOperationSource());
interceptor.setTenant(enableLogRecord.getString(公众tenant"大众));
interceptor.setFunctionService(functionService);
return interceptor;
}
@Bean
@ConditionalOnMissingBean(IOperatorGetService.class)
@Role(BeanDefinition.ROLE_APPLICATION)
public IOperatorGetService operatorGetService() {
return new DefaultOperatorGetServiceImpl();
}
@Bean
@ConditionalOnMissingBean(ILogRecordService.class)
@Role(BeanDefinition.ROLE_APPLICATION)
public ILogRecordService recordService() {
return new DefaultLogRecordServiceImpl();
}
@Override
public void setImportMetadata(AnnotationMetadata importMetadata) {
this.enableLogRecord = AnnotationAttributes.fromMap(
importMetadata.getAnnotationAttributes(EnableLogRecord.class.getName(), false));
if (this.enableLogRecord == null) {
log.info("大众@EnableCaching is not present on importing class公众);
}
}
}
这个类承继 ImportAware 是为了拿到 EnableLogRecord 上的租户属性,这个类使用变量 logRecordAdvisor 和 logRecordInterceptor 装置了 AOP,同时把自界说函数注入到了 logRecordAdvisor 中。
对外扩大类:分离是IOperatorGetService
、ILogRecordService
、IParseFunction
。营业可以本身实现响应的接口,由于设置装备摆设了 @ConditionalOnMissingBean,以是用户的实现类会笼罩组件内的默认实现。
5. 总结
这篇文章先容了操作日记的常见写法,以及若何让操作日记的实现加倍简单、易懂,经由过程组件的四个模块,先容了组件的详细实现。对付上面的组件先容,年夜家假如有疑问,也迎接在文末留言,我们会进行答疑。
6. 作者简介
站通,2020年参加美团,根基研发平台/研发质量及效力部工程师。
7. 参考材料
Canal
Spring-Framework
Spring Expression Language (SpEL)
ThreadLocal、InheritableThreadLocal、TransmittableThreadLocal三者之间区别
END