原文連結:自研一套通俗易用的操作日誌元件
背景
不管是軟體,應用還是網站,只要有使用者使用,就有使用者的操作行為。而在那些需要多使用者互相協作,或者是多使用者共同使用的系統或者網站,使用者是會非常關心對於別人的操作。因為別人的操作很有可能會影響到他自己所擁有的一些財產。例如一個電商網站,商家弄了幾個管理員來打理店鋪:管理員可以一定程度上管理使用者、可以管理商品、管理訂單等等;因為這都是涉及到商家的財產,所以商家肯定會非常注意管理員的操作,避免管理員的一些誤操而導致店鋪的金錢損失。
那麼我們怎樣提供使用者操作呢?那肯定是要用到日誌了,而我們往往在研發的時候,都會在一些重要步驟上面打上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
當然了,如果大家有更好的設計,歡迎大家一起來優化!