如何優雅的記錄操作日誌?

王不二發表於2022-04-07

1. 背景

日誌幾乎存在於所有系統中,開發除錯日誌的記錄我們有log4j,logback等來實現,但對於要展示給使用者看的日誌,我並沒有發現一個簡單通用的實現方案。所以決定為之後的開發專案提供一個通用的操作日誌元件。

2. 系統日誌和操作日誌

所有系統都會有日誌,但我們區分了 系統日誌操作日誌

  • 系統日誌:主要用於開發者除錯排查系統問題的,不要求固定格式和可讀性
  • 操作日誌:主要面向使用者的,要求簡單易懂,反映出使用者所做的動作。

通過操作日誌可追溯到 某人在某時幹了某事情,如:

租戶操作人時間操作內容
A租戶小明2022/2/27 20:15:00新增新增了一個使用者:Mr.Wang
B租戶大米2022/2/28 10:35:00更新修改訂單 [xxxxxx] 價格為 xx 元
C租戶老王2022/2/28 22:55:00查詢查詢了名為: [xx] 的所有交易

3. 需要哪些功能

3.1 訴求:

  1. 基於SpringBoot能夠快速接入
  2. 對業務程式碼具有低入侵性

3.2 解決思路:

基於以上兩點,我們想想如何實現。

spingboot快速接入,需要我們來自定義spring boot starter;

業務入侵性低,首先想到了AOP,一般操作日誌都是在增刪改查的方法中,所以我們可以使用註解在這些方法上,通過AOP攔截這些方法。

3.3 待實現:

因此,我們需要實現以下功能:

  • 自定義spring boot starter
  • 定義日誌註解
  • AOP攔截日誌註解方法
  • 定義日誌動態內容模板

模板中又需要實現:

  • 動態模板表示式解析:用強大的SpEL來解析表示式
  • 自定義函式:支援目標方法前置/後置的自定義函式

3.4 展現

所以我們最終期望的大概是這樣:

@EasyLog(module = "使用者模組", type = "新增",
        content = "測試 {functionName{#userDto.name}}",
        condition = "#userDto.name == 'easylog'")
public String test(UserDto userDto) {
    return "test";
}

4. 實現步驟

4.1 定義日誌註解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface EasyLog {
    String tenant() default "";
    String operator() default "";
    String module() default "";
    String type() default "";
    String bizNo() default "";
    String content();
    String fail() default "";
    String detail() default "";
    String condition() default "";
}
欄位意義支援SpEl表示式必填
tenant租戶,SAAS系統中區分不同租戶
operator操作者
module模組,區分不同業務模組
type操作型別,形如:增刪改查
bizNo業務編號,便於查詢
content日誌模板內容
fail操作失敗時的模板內容
detail額外的記錄資訊
condition是否記錄的條件 (預設:true 記錄)

4.2 自定義函式

這裡的自定義函式,並不是指SpEL中的自定義函式,因為SpEL中的自定義函式必須是靜態方法才可以註冊到其中,因為靜態方法使用中並沒有我們自己定義方法來的方便,所以這裡的自定義函式僅僅指代我們定義的一個普通方法。

public interface ICustomFunction {
    /**
     * 目標方法執行前 執行自定義函式
     * @return 是否是前置函式
     */
    boolean executeBefore();

    /**
     * 自定義函式名
     * @return 自定義函式名
     */
    String functionName();

    /**
     * 自定義函式
     * @param param 引數
     * @return 執行結果
     */
    String apply(String param);
}

我們定義好自定義函式介面,實現交給使用者。使用者將實現類交給Spring容器管理,我們解析的時候從Spring容器中獲取即可。

4.3 SpEL表示式解析

主要牽涉下面幾個核心類:

  • 解析器ExpressionParser,用於將字串表示式轉換為Expression表示式物件。
  • 表示式Expression,最後通過它的getValute方法對錶達式進行計算取值。
  • 上下文EvaluationContext,通過上下文物件結合表示式來計算最後的結果。
ExpressionParser parser =new SpelExpressionParser(); // 建立一個表示式解析器
StandardEvaluationContext ex = new StandardEvaluationContext(); // 建立上下文
ex.setVariables("name", "easylog"); // 將自定義引數新增到上下文
Expression exp = parser.parseExpression("'歡迎你! '+ #name"); //模板解析
String val = exp.getValue(ex,String.class); //獲取值

我們只需要拿到日誌註解中的動態模板即可通過SpEL來解析。

4.4 自定義函式的解析

我們採用 { functionName { param }} 的形式在模板中展示自定義函式,解析整個模板前,我們先來解析下自定義函式,將解析後的值替換掉模板中的字串即可。

if (template.contains("{")) {
   Matcher matcher = PATTERN.matcher(template);
   while (matcher.find()) {
       String funcName = matcher.group(1);
       String param = matcher.group(2);
       if (customFunctionService.executeBefore(funcName)) {
          String apply = customFunctionService.apply(funcName, param);
       }
   }
}

4.5 獲取操作者資訊

一般我們都是將登入者資訊存入應用上下文中,所以我們不必每次都在日誌註解中指出,我們可統一設定,定義一個獲取操作者介面,由使用者實現。

public interface IOperatorService {
    // 獲取當前操作者
    String getOperator();
    // 當前租戶
    String getTenant();
}

4.6 定義日誌內容接收

我們要將解析完成後的日誌內容實體資訊傳送給我們的使用者,所以我們需要定義一個日誌接收的介面,具體的實現交給使用者來實現,無論他接收到日誌儲存在資料庫,MQ還是哪裡,讓使用者來決定。

public interface ILogRecordService {
    /**
     * 儲存 log
     * @param easyLogInfo 日誌實體
     */
    void record(EasyLogInfo easyLogInfo);
}

4.7 定義AOP攔截

@Aspect
@Component
@AllArgsConstructor
public class EasyLogAspect {

    @Pointcut("@annotation(**.EasyLog)")
    public void pointCut() {}

    // 環繞通知
    @Around("pointCut() && @annotation(easyLog)")
    public Object around(ProceedingJoinPoint joinPoint, EasyLog easyLog) throws Throwable {

        //前置自定義函式解析
        try {
            result = joinPoint.proceed();
        } catch (Throwable e) {
        }
        //SpEL解析
        //後置自定義函式解析
        return result;
    }
}

4.8 自定義 spring boot starter

建立自動配置類,將定義的一些來交給Spring容器管理:

@Configuration
@ComponentScan("**")
public class EasyLogAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean(ICustomFunction.class)
    @Role(BeanDefinition.ROLE_APPLICATION)
    public ICustomFunction customFunction(){
        return new DefaultCustomFunction();
    }

    @Bean
    @ConditionalOnMissingBean(IOperatorService.class)
    @Role(BeanDefinition.ROLE_APPLICATION)
    public IOperatorService operatorGetService() {
        return new DefaultOperatorServiceImpl();
    }

    @Bean
    @ConditionalOnMissingBean(ILogRecordService.class)
    @Role(BeanDefinition.ROLE_APPLICATION)
    public ILogRecordService recordService() {
        return new DefaultLogRecordServiceImpl();
    }
}

上一篇我已經完整的介紹瞭如何自定義 spring boot starter ,可去參考:
如何自定義 spring boot starter ?

5. 我們可以學到什麼?

你可以拉取easy-log原始碼,用於學習,通過easy-log你可以學到:

  • 註解的定義及使用
  • AOP的應用
  • SpEL表示式的解析
  • 自定義 Spring boot starter
  • 設計模式

6. 原始碼

相關文章