如何優(yōu)雅地記錄操作日志?

1. 操作日志的使用場景
2. 實現(xiàn)方式
2.1 使用 Canal 監(jiān)聽數(shù)據(jù)庫記錄操作日志
2.2 通過日志文件的方式記錄
2.3 通過 LogUtil 的方式記錄日志
2.4 方法注解實現(xiàn)操作日志
3. 優(yōu)雅地支持 AOP 生成動態(tài)的操作日志
3.1 動態(tài)模板
4. 代碼實現(xiàn)解析
4.1 代碼結(jié)構(gòu)
4.2 模塊介紹
5. 總結(jié)
1. 操作日志的使用場景

單純的文字記錄,比如:2021-09-16 10:00 訂單創(chuàng)建。 簡單的動態(tài)的文本記錄,比如:2021-09-16 10:00 訂單創(chuàng)建,訂單號:NO.11089999,其中涉及變量訂單號“NO.11089999”。 修改類型的文本,包含修改前和修改后的值,比如:2021-09-16 10:00 用戶小明修改了訂單的配送地址:從“金燦燦小區(qū)”修改到“銀盞盞小區(qū)” ,其中涉及變量配送的原地址“金燦燦小區(qū)”和新地址“銀盞盞小區(qū)”。 修改表單,一次會修改多個字段。
2. 實現(xiàn)方式
2.1 使用 Canal 監(jiān)聽數(shù)據(jù)庫記錄操作日志
2.2 通過日志文件的方式記錄
log.info("訂單創(chuàng)建")
log.info("訂單已經(jīng)創(chuàng)建,訂單編號:{}", orderNo)
log.info("修改了訂單的配送地址:從“{}”修改到“{}”, "金燦燦小區(qū)", "銀盞盞小區(qū)")
@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;
}
}
<pattern>"%d{yyyy-MM-dd HH:mm:ss.SSS} %t %-5level %X{userId} %logger{30}.%method:%L - %msg%n"</pattern>
//不同業(yè)務(wù)日志記錄到不同的文件
<appender name="businessLogAppender" class="ch.qos.logback.core.rolling.RollingFileAppender">
<File>logs/business.log</File>
<append>true</append>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>INFO</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/業(yè)務(wù)A.%d.%i.log</fileNamePattern>
<maxHistory>90</maxHistory>
<timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
<maxFileSize>10MB</maxFileSize>
</timeBasedFileNamingAndTriggeringPolicy>
</rollingPolicy>
<encoder>
<pattern>"%d{yyyy-MM-dd HH:mm:ss.SSS} %t %-5level %X{userId} %logger{30}.%method:%L - %msg%n"</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<logger name="businessLog" additivity="false" level="INFO">
<appender-ref ref="businessLogAppender"/>
</logger>
//記錄特定日志的聲明
private final Logger businessLog = LoggerFactory.getLogger("businessLog");
//日志存儲
businessLog.info("修改了配送地址");
2.3 通過 LogUtil 的方式記錄日志
LogUtil.log(orderNo, "訂單創(chuàng)建", "小明")
LogUtil.log(orderNo, "訂單創(chuàng)建,訂單號"+"NO.11089999", "小明")
String template = "用戶%s修改了訂單的配送地址:從“%s”修改到“%s”"
LogUtil.log(orderNo, String.format(tempalte, "小明", "金燦燦小區(qū)", "銀盞盞小區(qū)"), "小明")
這里解釋下為什么記錄操作日志的時候都綁定了一個 OrderNo,因為操作日志記錄的是:某一個“時間”“誰”對“什么”做了什么“事情”。當查詢業(yè)務(wù)的操作日志的時候,會查詢針對這個訂單的的所有操作,所以代碼中加上了 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);
}
2.4 方法注解實現(xiàn)操作日志
@LogRecord(content="修改了配送地址")
public void modifyAddress(updateDeliveryRequest request){
// 更新派送信息 電話,收件人、地址
doUpdate(request);
}
3. 優(yōu)雅地支持 AOP 生成動態(tài)的操作日志
3.1 動態(tài)模板
@LogRecord(content = "修改了訂單的配送地址:從“#oldAddress”, 修改到“#request.address”")
public void modifyAddress(updateDeliveryRequest request, String oldAddress){
// 更新派送信息 電話,收件人、地址
doUpdate(request);
}
操作日志需要知道是哪個操作人修改的訂單配送地址。 修改訂單配送地址的操作日志需要綁定在配送的訂單上,從而可以根據(jù)配送訂單號查詢出對這個配送訂單的所有操作。 為了在注解上記錄之前的配送地址是什么,在方法簽名上添加了一個和業(yè)務(wù)無關(guān)的 oldAddress 的變量,這樣就不優(yōu)雅了。
@LogRecord(
content = "修改了訂單的配送地址:從“#oldAddress”, 修改到“#request.address”",
operator = "#request.userName", bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request, String oldAddress){
// 更新派送信息 電話,收件人、地址
doUpdate(request);
}
operator = "#{T(com.meituan.user.UserContext).getCurrentUser()}"
@LogRecord(content = "修改了訂單的配送地址:從“#oldAddress”, 修改到“#request.address”",
bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request, String oldAddress){
// 更新派送信息 電話,收件人、地址
doUpdate(request);
}
@LogRecord(content = "修改了訂單的配送地址:從“#oldAddress”, 修改到“#request.address”",
bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request){
// 查詢出原來的地址是什么
LogRecordContext.putVariable("oldAddress", DeliveryService.queryOldAddress(request.getDeliveryOrderNo()));
// 更新派送信息 電話,收件人、地址
doUpdate(request);
}
@LogRecord(content = "修改了訂單的配送員:從“#oldDeliveryUserId”, 修改到“#request.userId”",
bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request){
// 查詢出原來的地址是什么
LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
// 更新派送信息 電話,收件人、地址
doUpdate(request);
}
@LogRecord(content = "修改了訂單的配送員:從“{deliveryUser{#oldDeliveryUserId}}”, 修改到“{deveryUser{#request.userId}}”",
bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request){
// 查詢出原來的地址是什么
LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
// 更新派送信息 電話,收件人、地址
doUpdate(request);
}
@LogRecord(content = "修改了訂單的配送員:從“{queryOldUser{#request.deliveryOrderNo()}}”, 修改到“{deveryUser{#request.userId}}”",
bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request){
// 更新派送信息 電話,收件人、地址
doUpdate(request);
}
4. 代碼實現(xiàn)解析
4.1 代碼結(jié)構(gòu)

4.2 模塊介紹
AOP 攔截邏輯 解析邏輯 模板解析 LogContext 邏輯 默認的 operator 邏輯 自定義函數(shù)邏輯 默認的日志持久化邏輯 Starter 封裝邏輯
4.2.1 AOP 攔截邏輯
@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 "";
}


AbstractBeanFactoryPointcutAdvisor 實現(xiàn),切點是通過 StaticMethodMatcherPointcut 匹配包含 LogRecordAnnotation 注解的方法。通過實現(xiàn) MethodInterceptor 接口實現(xiàn)操作日志的增強邏輯。public class LogRecordPointcut extends StaticMethodMatcherPointcut implements Serializable {
// LogRecord的解析類
private LogRecordOperationSource logRecordOperationSource;
@Override
public boolean matches(@NonNull Method method, @NonNull Class<?> targetClass) {
// 解析 這個 method 上有沒有 @LogRecordAnnotation 注解,有的話會解析出來注解上的各個參數(shù)
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<LogRecordOps> operations = new ArrayList<>();
Map<String, String> functionNameAndReturnMap = new HashMap<>();
try {
operations = logRecordOperationSource.computeLogRecordOperations(method, targetClass);
List<String> spElTemplates = getBeforeExecuteFunctionTemplate(operations);
//業(yè)務(wù)邏輯執(zhí)行前的自定義函數(shù)解析
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) {
//記錄日志錯誤不要影響業(yè)務(wù)
log.error("log record parse exception", t);
} finally {
LogRecordContext.clear();
}
if (methodExecuteResult.throwable != null) {
throw methodExecuteResult.throwable;
}
return ret;
}

4.2.2 解析邏輯
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));
}

LogRecordValueParser 里面封裝了自定義函數(shù)和 SpEL 解析類 LogRecordExpressionEvaluator。public class LogRecordExpressionEvaluator extends CachedExpressionEvaluator {
private Map<ExpressionKey, Expression> expressionCache = new ConcurrentHashMap<>(64);
private final Map<AnnotatedElementKey, Method> 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 表達式,然后根據(jù)傳入的 Object 獲取到對應的值,所以 expressionCache 是為了緩存方法、表達式和 SpEL 的 Expression 的對應關(guān)系,讓方法注解上添加的 SpEL 表達式只解析一次。下面的 targetMethodCache 是為了緩存?zhèn)魅氲?Expression 表達式的 Object。核心的解析邏輯是上面最后一行代碼。getExpression(this.expressionCache, methodKey, conditionExpression).getValue(evalContext, String.class);
getExpression 方法會從 expressionCache 中獲取到 @LogRecordAnnotation 注解上的表達式的解析 Expression 的實例,然后調(diào)用 getValue 方法,getValue 傳入一個 evalContext 就是類似上面例子中的 order 對象。其中 Context 的實現(xiàn)將會在下文介紹。getValue 方法的 Object 中才可以順利的解析表達式的值。下面看看如何實現(xiàn):@LogRecord(content = "修改了訂單的配送員:從“{deveryUser{#oldDeliveryUserId}}”, 修改到“{deveryUser{#request.getUserId()}}”",
bizNo="#request.getDeliveryOrderNo()")
public void modifyAddress(updateDeliveryRequest request){
// 查詢出原來的地址是什么
LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
// 更新派送信息 電話,收件人、地址
doUpdate(request);
}
EvaluationContext evaluationContext = expressionEvaluator.createEvaluationContext(method, args, targetClass, ret, errorMsg, beanFactory);
getValue 方法傳入的參數(shù) evalContext,就是上面這個 EvaluationContext 對象。下面是 LogRecordEvaluationContext 對象的繼承體系:
把方法的參數(shù)都放到 SpEL 解析的 RootObject 中。 把 LogRecordContext 中的變量都放到 RootObject 中。 把方法的返回值和 ErrorMsg 都放到 RootObject 中。
public class LogRecordEvaluationContext extends MethodBasedEvaluationContext {
public LogRecordEvaluationContext(Object rootObject, Method method, Object[] arguments,
ParameterNameDiscoverer parameterNameDiscoverer, Object ret, String errorMsg) {
//把方法的參數(shù)都放到 SpEL 解析的 RootObject 中
super(rootObject, method, arguments, parameterNameDiscoverer);
//把 LogRecordContext 中的變量都放到 RootObject 中
Map<String, Object> variables = LogRecordContext.getVariables();
if (variables != null && variables.size() > 0) {
for (Map.Entry<String, Object> entry : variables.entrySet()) {
setVariable(entry.getKey(), entry.getValue());
}
}
//把方法的返回值和 ErrorMsg 都放到 RootObject 中
setVariable("_ret", ret);
setVariable("_errorMsg", errorMsg);
}
}
public class LogRecordContext {
private static final InheritableThreadLocal<Stack<Map<String, Object>>> variableMapStack = new InheritableThreadLocal<>();
//其他省略....
}
@LogRecord(content = "修改了訂單的配送員:從“{deveryUser{#oldDeliveryUserId}}”, 修改到“{deveryUser{#request.getUserId()}}”",
bizNo="#request.getDeliveryOrderNo()")
public void modifyAddress(updateDeliveryRequest request){
// 查詢出原來的地址是什么
LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
// 更新派送信息 電話,收件人、地址
doUpdate(request);
}



public interface IOperatorGetService {
/**
* 可以在里面外部的獲取當前登陸的用戶,比如 UserContext.getCurrentUser()
*
* @return 轉(zhuǎn)換成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"));
}
}
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);
}

executeBefore 函數(shù)代表了自定義函數(shù)是否在業(yè)務(wù)代碼執(zhí)行之前解析,上面提到的查詢修改之前的內(nèi)容。public interface IParseFunction {
default boolean executeBefore(){
return false;
}
String functionName();
String apply(String value);
}
public class ParseFunctionFactory {
private Map<String, IParseFunction> allFunctionMap;
public ParseFunctionFactory(List<IParseFunction> 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();
}
}
apply 方法上最后返回函數(shù)的值。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 日志持久化邏輯
public interface ILogRecordService {
/**
* 保存 log
*
* @param logRecord 日志實體
*/
void record(LogRecord logRecord);
}
@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 邏輯封裝
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
@EnableTransactionManagement
@EnableLogRecord(tenant = "com.mzt.test")
public class Main {
public static void main(String[] args) {
SpringApplication.run(Main.class, args);
}
}
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<IParseFunction> 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");
}
}
}
IOperatorGetService、ILogRecordService、IParseFunction。業(yè)務(wù)可以自己實現(xiàn)相應的接口,因為配置了 @ConditionalOnMissingBean,所以用戶的實現(xiàn)類會覆蓋組件內(nèi)的默認實現(xiàn)。5. 總結(jié)
6. 作者簡介
7. 參考資料
Canal Spring-Framework Spring Expression Language (SpEL) ThreadLocal、InheritableThreadLocal、TransmittableThreadLocal三者之間區(qū)別
評論
圖片
表情
