1. 程式人生 > >自研一套通俗易用的操作日誌元件

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

# 原文連結:[自研一套通俗易用的操作日誌元件](https://blog.csdn.net/Howinfun/article/details/114368019) # 背景 不管是軟體,應用還是網站,只要有使用者使用,就有使用者的操作行為。而在那些需要多使用者互相協作,或者是多使用者共同使用的系統或者網站,使用者是會非常關心對於別人的操作。因為別人的操作很有可能會影響到他自己所擁有的一些財產。例如一個電商網站,商家弄了幾個管理員來打理店鋪:管理員可以一定程度上管理使用者、可以管理商品、管理訂單等等;因為這都是涉及到商家的財產,所以商家肯定會非常注意管理員的操作,避免管理員的一些誤操而導致店鋪的金錢損失。 那麼我們怎樣提供使用者操作呢?那肯定是要用到日誌了,而我們往往在研發的時候,都會在一些重要步驟上面打上log,然後記錄在日誌檔案中;那麼,使用這些日誌給使用者提供操作檢視合適嗎? 我覺得不合適。 - 首先,日誌檔案中記錄的是整個系統或者整個服務的所有日誌,我們需要自己進一步提取關心的業務日誌。 - 對於上面的日誌提取,我們不但需要找,而且需要處理成通俗易懂的操作日誌;因為研發記錄的log一般都不是使用者可讀的log,所以還需要再進一步提取然後處理。 - 對於最後處理好的日誌,還需要入庫,畢竟我們不可能一直都到日誌檔案裡面找;因為日誌檔案是會每天遞增的,我們難以定位使用者檢視的日誌操作在哪個日誌檔案中。 因此,我們需要自研一個操作日誌元件。 # 1 架構介紹 **操作日誌元件主要分為兩個部分:** 第一個是SDK,主要提供給需要使用操作日誌功能的服務,服務只需要引入sdk依賴即可開始使用,sdk裡面提供了基本的註解和切面功能,切面裡面會進行操作日誌的處理,並往操作日誌服務傳送請求用以儲存操作日誌; 第二個是操作日誌元件的服務,我們需要單獨部署一個服務作為操作日誌元件的後勤,主要對外提供新增操作日誌和查詢操作日誌的介面。 之所以我們需要單獨部署一個操作日誌服務,是因為我們要遵守單一職責的原則,不需要每個服務都在自己的庫裡面建立表來儲存操作日誌。而是由操作日誌服務統一對外提供新增和查詢的能力。當然了,這一版我只是做了 HTTP 的請求方式,如果大家的系統是微服務架構,服務之間使用的是 Dubbo 來通訊的話,可以在 SDK 和 Server 中進行增強。 # 2 使用介紹 ## 2.1 配置開啟操作日誌功能 ```properties # 開啟操作日誌元件功能 log.record.enabled=true # 操作日誌服務地址 log.record.url=http://ip:port ``` 關於操作日誌元件的配置還是比較少的,因為主要的配置在註解那,這裡只負責配置是否啟用。 但是要注意的是:如果開啟了操作日誌元件功能,那麼一定要配置操作日誌服務地址,因為 SDK 中,會呼叫操作日誌服務的介面來新增操作日誌,和提供了查詢操作日誌列表的介面 # 2.2 加入註解配置 開啟操作日誌元件功能後,我們接著在需要記錄操作日誌的類方法上加上@LogRecordAnno註解,然後配置我們需要記錄的日誌型別和日誌內容。 下面是我自己提供的簡單例子: ```java /** * * @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 簡單介紹操作日誌元件的實現 我們可以直接從註解入手: ```java /** * 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 和日誌操作型別,進行操作日誌的記錄。 詳細可看下面程式碼: ```java // 記錄實體記錄 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表示式,一定要用`{{}}`給包著。 詳細看如下程式碼: ```java // 規則正則表示式 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表示式從方法傳參中獲取,否則直接讀取屬性值。 程式碼如下: ```java /** * 獲取操作者 * @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 的介面來儲存操作日誌;這個儲存動作是非同步的,利用的是自定義執行緒池,保證不影響主業務的執行。 程式碼如下: ```java /*** * 增加日誌記錄->
非同步執行,不影響主業務的執行 * @author winfun * @param logRecord logRecord * @return {@link Integer } **/ @Async("AsyncTaskThreadExecutor") @Override public ApiResult insertLogRecord(LogRecord logRecord) { // 發起HTTP請求 return this.restTemplate.postForObject(url+"/log/insert",logRecord,ApiResult.class); } ``` ## 3.4 使用操作日誌查詢介面 在 sdk 中,我們已經在 LogRecordSDKService 中提供了根據 businessName 查詢操作日誌的介面,大家只需要在 controller 層或者 serivce 引入 LogRecordSDKService 然後呼叫方法即可。如果不需要任何處理則直接返回,否則遍歷列表再做進一步的處理。 使用例子: ```java @Autowired private LogRecordSDKService logRecordSDKService; @GetMapping("/query/{businessName}") public ApiResult> query(@PathVariable("businessName") String businessName){ return this.logRecordSDKService.queryLogRecord(businessName); } ``` # 4 優化點 當然了,元件還有很多的優化點: - 記錄實體資訊的時候,我們其實只需要記錄有變更的欄位值,而不是整個實體記錄下來。 - sdk 中的新增和查詢操作日誌都是發起 HTTP 請求,但是每次 HTTP 請求都需要進行三次握手和四次揮手,這些都是操作都是耗時的;所以如果系統使用的是微服務架構,可以將此改為 dubbo 呼叫來避免頻繁的三次握手和四次揮手。 詳細程式碼可看:https://github.com/Howinfun/winfun-log-record 當然了,如果大家有更好的設計,歡迎大家一起來