自研一套通俗易用的操作日誌元件

不送花的程式猿發表於2021-03-04

原文連結:自研一套通俗易用的操作日誌元件

背景

不管是軟體,應用還是網站,只要有使用者使用,就有使用者的操作行為。而在那些需要多使用者互相協作,或者是多使用者共同使用的系統或者網站,使用者是會非常關心對於別人的操作。因為別人的操作很有可能會影響到他自己所擁有的一些財產。例如一個電商網站,商家弄了幾個管理員來打理店鋪:管理員可以一定程度上管理使用者、可以管理商品、管理訂單等等;因為這都是涉及到商家的財產,所以商家肯定會非常注意管理員的操作,避免管理員的一些誤操而導致店鋪的金錢損失。

那麼我們怎樣提供使用者操作呢?那肯定是要用到日誌了,而我們往往在研發的時候,都會在一些重要步驟上面打上log,然後記錄在日誌檔案中;那麼,使用這些日誌給使用者提供操作檢視合適嗎?

我覺得不合適。

  • 首先,日誌檔案中記錄的是整個系統或者整個服務的所有日誌,我們需要自己進一步提取關心的業務日誌。
  • 對於上面的日誌提取,我們不但需要找,而且需要處理成通俗易懂的操作日誌;因為研發記錄的log一般都不是使用者可讀的log,所以還需要再進一步提取然後處理。
  • 對於最後處理好的日誌,還需要入庫,畢竟我們不可能一直都到日誌檔案裡面找;因為日誌檔案是會每天遞增的,我們難以定位使用者檢視的日誌操作在哪個日誌檔案中。

因此,我們需要自研一個操作日誌元件。

1 架構介紹

操作日誌元件主要分為兩個部分:

第一個是SDK,主要提供給需要使用操作日誌功能的服務,服務只需要引入sdk依賴即可開始使用,sdk裡面提供了基本的註解和切面功能,切面裡面會進行操作日誌的處理,並往操作日誌服務傳送請求用以儲存操作日誌;

第二個是操作日誌元件的服務,我們需要單獨部署一個服務作為操作日誌元件的後勤,主要對外提供新增操作日誌和查詢操作日誌的介面。

之所以我們需要單獨部署一個操作日誌服務,是因為我們要遵守單一職責的原則,不需要每個服務都在自己的庫裡面建立表來儲存操作日誌。而是由操作日誌服務統一對外提供新增和查詢的能力。當然了,這一版我只是做了 HTTP 的請求方式,如果大家的系統是微服務架構,服務之間使用的是 Dubbo 來通訊的話,可以在 SDK 和 Server 中進行增強。

2 使用介紹

2.1 配置開啟操作日誌功能

# 開啟操作日誌元件功能
log.record.enabled=true
# 操作日誌服務地址
log.record.url=http://ip:port

關於操作日誌元件的配置還是比較少的,因為主要的配置在註解那,這裡只負責配置是否啟用。

但是要注意的是:如果開啟了操作日誌元件功能,那麼一定要配置操作日誌服務地址,因為 SDK 中,會呼叫操作日誌服務的介面來新增操作日誌,和提供了查詢操作日誌列表的介面

2.2 加入註解配置

開啟操作日誌元件功能後,我們接著在需要記錄操作日誌的類方法上加上@LogRecordAnno註解,然後配置我們需要記錄的日誌型別和日誌內容。

下面是我自己提供的簡單例子:

/**
 *
 * @author winfun
 * @date 2021/2/25 3:58 下午
 **/
@Service
public class UserServiceImpl implements UserService {

    @Resource
    private UserMapper userMapper;
    /**
     * 新增使用者記錄
     * @param user
     * @return
     */
    @LogRecordAnno(logType = LogRecordConstant.LOG_TYPE_MESSAGE,
            sqlType = LogRecordConstant.SQL_TYPE_INSERT,
            businessName = "userBusiness",
            successMsg = "成功新增使用者「{{#user.name}}」",
            errorMsg = "新增使用者失敗,錯誤資訊:「{{#_errorMsg}}」",
            operator = "#operator")
    @Override
    public String insert(User user,String operator) {
        if (StringUtils.isEmpty(user.getName())){
            throw new RuntimeException("使用者名稱不能為空");
        }
        this.userMapper.insert(user);
        return user.getId();
    }

    /**
     * 更新使用者記錄
     * @param user
     * @return
     */
    @LogRecordAnno(logType = LogRecordConstant.LOG_TYPE_RECORD,
            sqlType = LogRecordConstant.SQL_TYPE_UPDATE,
            businessName = "userBusiness",
            mapperName = UserMapper.class,
            id = "#user.id",
            operator = "#operator")
    @Override
    public Boolean update(User user,String operator) {
        return this.userMapper.updateById(user) > 0;
    }

    /**
     * 刪除使用者記錄
     * @param id
     * @return
     */
    @LogRecordAnno(logType = LogRecordConstant.LOG_TYPE_MESSAGE,
            sqlType = LogRecordConstant.SQL_TYPE_DELETE,
            businessName = "userBusiness",
            operator = "#operator",
            successMsg = "成功刪除使用者,使用者ID「{{#id}}」",
            errorMsg = "刪除使用者失敗,錯誤資訊:「{{#_errorMsg}}」")
    @Override
    public Boolean delete(Serializable id,String operator) {
        return this.userMapper.deleteById(id) > 0;
    }
}

在上面的例子中,其中的新增和刪除使用者,我們只關心新增了或刪除了哪個使用者;而更新使用者,我們更加關心更新了什麼資訊;所以新增和刪除方法,我們都直接記錄了成功資訊,而更新方法我們記錄了更新前後的實體記錄資訊。

這裡有幾個需要注意的點:

  • 關於操作者和主鍵,我們建議在方法裡面提供,然後利用spel表示式來獲取;特別是ID,一定要這麼做,不然會出現異常。
  • 關於成功資訊和失敗資訊,我們可以看到,在spel表示式外面我們會套多一層{{}},那是因為在成功資訊和失敗資訊中,我們支援多個spel表示式,所以需要利用一定規則來進行讀取,一定要按照這個規則寫。還有就是失敗資訊,統一使用{{#_errorMsg}},因為失敗資訊是讀取異常棧中的異常資訊,所以都是統一填寫統一獲取。

3 簡單介紹操作日誌元件的實現

我們可以直接從註解入手:

/**
 * LogRecord 註解
 * @author winfun
 * @date 2021/2/25 4:32 下午
 **/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface LogRecordAnno {

    /**
     * 操作日誌型別
     * @return
     */
    String logType() default LogRecordContants.LOG_TYPE_MESSAGE;

    /**
     * sql型別:增刪改
     */
    String sqlType() default LogRecordContants.SQL_TYPE_INSERT;

    /**
     * 業務名稱
     * @return
     */
    String businessName() default "";

    /**
     * 日誌型別一:記錄記錄實體
     * Mapper Class,需要配合 MybatisPlus 使用
     */
    Class mapperName() default BaseMapper.class;

    /**
     * 日誌型別一:記錄記錄實體
     * 主鍵
     */
    String id() default "";

    /**
     * 操作者
     */
    String operator() default "";

    /**
     * 日誌型別二:記錄日誌資訊
     * 成功資訊
     */
    String successMsg() default "";

    /**
     * 日誌型別二:記錄日誌資訊
     * 失敗資訊
     */
    String errorMsg() default "";
}

3.1 日誌型別

首先,操作日誌元件支援兩種操作日誌型別:第一種是記錄操作前後的實體內容,這個會記錄完整的資訊,但是需要配合 MybatisPlus 使用,有一定的限制,並且最後顯示的操作日誌需要使用方做一定的處理;第二種是直接記錄成功日誌和失敗日誌,比較通用,適用方查詢後直接回顯即可。

3.1.1 記錄實體內容

上面也說到,記錄實體資訊需要配合 MyBatisPlus 使用,並且需要讀取到 ID,即主鍵資訊;然後利用 BaseMapper 和日誌操作型別,進行操作日誌的記錄。

詳細可看下面程式碼:

// 記錄實體記錄
if (LogRecordContants.LOG_TYPE_RECORD.equals(logType)){
    final Class mapperClass = logRecordAnno.mapperName();
    if (mapperClass.isAssignableFrom(BaseMapper.class)){
        throw new RuntimeException("mapperClass 屬性傳入 Class 不是 BaseMapper 的子類");
    }
    final BaseMapper mapper = (BaseMapper) this.applicationContext.getBean(mapperClass);
    //根據spel表示式獲取id
    final String id = (String) this.getId(logRecordAnno.id(), context);
    final Object beforeRecord;
    final Object afterRecord;
    switch (sqlType){
        // 新增
        case LogRecordContants.SQL_TYPE_INSERT:
            proceedResult = point.proceed();
            final Object result = mapper.selectById(id);
            logRecord.setBeforeRecord("");
            logRecord.setAfterRecord(JSON.toJSONString(result));
            break;
        // 更新
        case LogRecordContants.SQL_TYPE_UPDATE:
            beforeRecord = mapper.selectById(id);
            proceedResult = point.proceed();
            afterRecord = mapper.selectById(id);
            logRecord.setBeforeRecord(JSON.toJSONString(beforeRecord));
            logRecord.setAfterRecord(JSON.toJSONString(afterRecord));
            break;
        // 刪除
        case LogRecordContants.SQL_TYPE_DELETE:
            beforeRecord = mapper.selectById(id);
            proceedResult = point.proceed();
            logRecord.setBeforeRecord(JSON.toJSONString(beforeRecord));
            logRecord.setAfterRecord("");
            break;
        default:
            break;
    }
}

3.1.2 記錄成功/失敗資訊

我們如果不關心實體變更前後的內容,我們可以自定義介面呼叫成功後和失敗後的資訊。
主要是利用規則{{spel表示式}},我們在記錄自定義操作日誌資訊時,如果使用到spel表示式,一定要用{{}}給包著。

詳細看如下程式碼:

// 規則正規表示式
private static final Pattern PATTERN = Pattern.compile("(?<=\\{\\{)(.+?)(?=}})");

// 記錄資訊
}else if (LogRecordContants.LOG_TYPE_MESSAGE.equals(logType)){
    try {
        proceedResult = point.proceed();
        String successMsg = logRecordAnno.successMsg();
        // 對成功資訊做表示式提取
        final Matcher successMatcher = PATTERN.matcher(successMsg);
        while(successMatcher.find()){
            String temp = successMatcher.group();
            final Expression tempExpression = this.parser.parseExpression(temp);
            final String result = (String) tempExpression.getValue(context);
            temp = "{{"+temp+"}}";
            successMsg = successMsg.replace(temp,result);
        }
        logRecord.setSuccessMsg(successMsg);
    }catch (final Exception e){
        String errorMsg = logRecordAnno.errorMsg();
        final String exceptionMsg = e.getMessage();
        errorMsg = errorMsg.replace(LogRecordContants.ERROR_MSG_PATTERN,exceptionMsg);
        logRecord.setSuccessMsg(errorMsg);
        // 插入記錄
        logRecord.setCreateTime(LocalDateTime.now());
        this.logRecordSDKService.insertLogRecord(logRecord);
        // 回拋異常
        throw new Exception(errorMsg);
    }
}

3.2 記錄操作者

為了更方便獲取到此操作是誰來執行的,操作日誌元件也提供了操作者的儲存功能,我們只需要在註解中新增 operator 屬性即可,一般是利用spel表示式從方法傳參中獲取,否則直接讀取屬性值。

程式碼如下:

/**
 * 獲取操作者
 * @param expressionStr
 * @param context
 * @return
 */
private String getOperator(final String expressionStr, final EvaluationContext context){
    try {
        if (expressionStr.startsWith("#")){
            final Expression idExpression = this.parser.parseExpression(expressionStr);
            return (String) idExpression.getValue(context);
        }else {
            return expressionStr;
        }
    }catch (final Exception e){
        log.error("Log-Record-SDK 獲取操作者失敗!,錯誤資訊:{}",e.getMessage());
        return "default";
    }
}

3.3 業務名

關於業務名,大家使用起來一定要配置,因為後續如果要提供操作日誌列表給使用者檢視,是根據業務名查詢的,也就是說,大家一定要保證業務名之間都是具有一定含義的,並且每個業務的操作日誌的業務名都保持唯一,這樣才不會查到別的業務的操作日誌。

業務名在 sdk 中不做任何特殊處理,直接獲取屬性值儲存。

3.4 呼叫儲存操作日誌記錄介面

上面我們說到,操作日誌元件由兩部分組成:sdk&server,我們需要單獨部署一套操作日誌元件的服務,對外提供統一的儲存和查詢操作日誌功能。

在上面介紹的 LogRecordAspect 中,在最後會呼叫 server 的介面來儲存操作日誌;這個儲存動作是非同步的,利用的是自定義執行緒池,保證不影響主業務的執行。

程式碼如下:

/***
 * 增加日誌記錄->非同步執行,不影響主業務的執行
 * @author winfun
 * @param logRecord logRecord
 * @return {@link Integer }
 **/
@Async("AsyncTaskThreadExecutor")
@Override
public ApiResult<Integer> insertLogRecord(LogRecord logRecord) {
    // 發起HTTP請求
    return this.restTemplate.postForObject(url+"/log/insert",logRecord,ApiResult.class);
}

3.4 使用操作日誌查詢介面

在 sdk 中,我們已經在 LogRecordSDKService 中提供了根據 businessName 查詢操作日誌的介面,大家只需要在 controller 層或者 serivce 引入 LogRecordSDKService 然後呼叫方法即可。如果不需要任何處理則直接返回,否則遍歷列表再做進一步的處理。

使用例子:

@Autowired
private LogRecordSDKService logRecordSDKService;

@GetMapping("/query/{businessName}")
public ApiResult<List<LogRecord>> query(@PathVariable("businessName") String businessName){
    return this.logRecordSDKService.queryLogRecord(businessName);
}

4 優化點

當然了,元件還有很多的優化點:

  • 記錄實體資訊的時候,我們其實只需要記錄有變更的欄位值,而不是整個實體記錄下來。
  • sdk 中的新增和查詢操作日誌都是發起 HTTP 請求,但是每次 HTTP 請求都需要進行三次握手和四次揮手,這些都是操作都是耗時的;所以如果系統使用的是微服務架構,可以將此改為 dubbo 呼叫來避免頻繁的三次握手和四次揮手。

詳細程式碼可看:https://github.com/Howinfun/winfun-log-record

當然了,如果大家有更好的設計,歡迎大家一起來優化!

相關文章