當(dāng)前位置:首頁 > 公眾號精選 > 架構(gòu)師社區(qū)
[導(dǎo)讀]操作日志幾乎存在于每個系統(tǒng)中,而這些系統(tǒng)都有記錄操作日志的一套API。操作日志和系統(tǒng)日志不一樣,操作日志必須要做到簡單易懂。所以如何讓操作日志不跟業(yè)務(wù)邏輯耦合,如何讓操作日志的內(nèi)容易于理解,如何讓操作日志的接入更加簡單?上面這些都是本文要回答的問題。我們主要圍繞著如何“優(yōu)雅”地記...

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

操作日志幾乎存在于每個系統(tǒng)中,而這些系統(tǒng)都有記錄操作日志的一套 API。操作日志和系統(tǒng)日志不一樣,操作日志必須要做到簡單易懂。所以如何讓操作日志不跟業(yè)務(wù)邏輯耦合,如何讓操作日志的內(nèi)容易于理解,如何讓操作日志的接入更加簡單?上面這些都是本文要回答的問題。我們主要圍繞著如何“優(yōu)雅”地記錄操作日志展開描述,希望對從事相關(guān)工作的同學(xué)能夠有所幫助或者啟發(fā)。
  • 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. 操作日志的使用場景

如何優(yōu)雅地記錄操作日志?
例子
系統(tǒng)日志和操作日志的區(qū)別系統(tǒng)日志:系統(tǒng)日志主要是為開發(fā)排查問題提供依據(jù),一般打印在日志文件中;系統(tǒng)日志的可讀性要求沒那么高,日志中會包含代碼的信息,比如在某個類的某一行打印了一個日志。操作日志:主要是對某個對象進行新增操作或者修改操作后記錄下這個新增或者修改,操作日志要求可讀性比較強,因為它主要是給用戶看的,比如訂單的物流信息,用戶需要知道在什么時間發(fā)生了什么事情。再比如,客服對工單的處理記錄信息。操作日志的記錄格式大概分為下面幾種:
  • 單純的文字記錄,比如: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ù)庫記錄操作日志

Canal?是一款基于 MySQL 數(shù)據(jù)庫增量日志解析,提供增量數(shù)據(jù)訂閱和消費的開源組件,通過采用監(jiān)聽數(shù)據(jù)庫 Binlog 的方式,這樣可以從底層知道是哪些數(shù)據(jù)做了修改,然后根據(jù)更改的數(shù)據(jù)記錄操作日志。這種方式的優(yōu)點是和業(yè)務(wù)邏輯完全分離。缺點也很明顯,局限性太高,只能針對數(shù)據(jù)庫的更改做操作日志記錄,如果修改涉及到其他團隊的 RPC 的調(diào)用,就沒辦法監(jiān)聽數(shù)據(jù)庫了。舉個例子:給用戶發(fā)送通知,通知服務(wù)一般都是公司內(nèi)部的公共組件,這時候只能在調(diào)用 RPC 的時候手工記錄發(fā)送通知的操作日志了。

2.2 通過日志文件的方式記錄

log.info("訂單創(chuàng)建")
log.info("訂單已經(jīng)創(chuàng)建,訂單編號:{}",?orderNo)
log.info("修改了訂單的配送地址:從“{}”修改到“{}”,?"金燦燦小區(qū)",?"銀盞盞小區(qū)")
這種方式的操作記錄需要解決三個問題。問題一:操作人如何記錄借助 SLF4J 中的 MDC 工具類,把操作人放在日志中,然后在日志中統(tǒng)一打印出來。首先在用戶的攔截器中把用戶的標(biāo)識 Put 到 MDC 中。@Component
public?class?UserInterceptor?extends?HandlerInterceptorAdapter?{
??@Override
??public?boolean?preHandle(HttpServletRequest?request,?HttpServletResponse?response,?Object?handler)?throws?Exception?{
????//獲取到用戶標(biāo)識
????String?userNo?=?getUserNo(request);
????//把用戶?ID?放到?MDC?上下文中
????MDC.put("userId",?userNo);
????return?super.preHandle(request,?response,?handler);
??}

??private?String?getUserNo(HttpServletRequest?request)?{
????//?通過?SSO?或者Cookie?或者?Auth信息獲取到?當(dāng)前登陸的用戶信息
????return?null;
??}
}
其次,把 userId 格式化到日志中,使用 %X{userId} 可以取到 MDC 中用戶標(biāo)識。"%d{yyyy-MM-dd?HH:mm:ss.SSS}?%t?%-5level?%X{userId}?%logger{30}.%method:%L?-?%msg%n"問題二:操作日志如何和系統(tǒng)日志區(qū)分開通過配置 Log 的配置文件,把有關(guān)操作日志的 Log 單獨放到一日志文件中。//不同業(yè)務(wù)日志記錄到不同的文件
<appender?name="businessLogAppender"?class="ch.qos.logback.core.rolling.RollingFileAppender">
????<File>logs/business.logFile>

????<append>trueappend>
????<filter?class="ch.qos.logback.classic.filter.LevelFilter">
????????<level>INFOlevel>
????????<onMatch>ACCEPTonMatch>
????????<onMismatch>DENYonMismatch>
????filter>
????<rollingPolicy?class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
????????<fileNamePattern>logs/業(yè)務(wù)A.%d.%i.logfileNamePattern>
????????<maxHistory>90maxHistory>
????????<timeBasedFileNamingAndTriggeringPolicy?class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
????????????<maxFileSize>10MBmaxFileSize>
????????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-8charset>
????encoder>
appender>
????????
<logger?name="businessLog"?additivity="false"?level="INFO">
????<appender-ref?ref="businessLogAppender"/>
logger>然后在 Java 代碼中單獨的記錄業(yè)務(wù)日志。//記錄特定日志的聲明
private?final?Logger?businessLog?=?LoggerFactory.getLogger("businessLog");
?
//日志存儲
businessLog.info("修改了配送地址");
問題三:如何生成可讀懂的日志文案可以采用 LogUtil 的方式,也可以采用切面的方式生成日志模板,后續(xù)內(nèi)容將會進行介紹。這樣就可以把日志單獨保存在一個文件中,然后通過日志收集可以把日志保存在 Elasticsearch 或者數(shù)據(jù)庫中,接下來我們看下如何生成可讀的操作日志。

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,因為操作日志記錄的是:某一個“時間”“誰”對“什么”做了什么“事情”。當(dāng)查詢業(yè)務(wù)的操作日志的時候,會查詢針對這個訂單的的所有操作,所以代碼中加上了 OrderNo,記錄操作日志的時候需要記錄下操作人,所以傳了操作人“小明”進來。
上面看起來問題并不大,在修改地址的業(yè)務(wù)邏輯方法中使用一行代碼記錄了操作日志,接下來再看一個更復(fù)雜的例子: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);
}
可以看到上面的例子使用了兩個方法代碼,外加一個 getLogContent 的函數(shù)實現(xiàn)了操作日志的記錄。當(dāng)業(yè)務(wù)變得復(fù)雜后,記錄操作日志放在業(yè)務(wù)代碼中會導(dǎo)致業(yè)務(wù)的邏輯比較繁雜,最后導(dǎo)致 LogUtils.logRecord() 方法的調(diào)用存在于很多業(yè)務(wù)的代碼中,而且類似 getLogContent() 這樣的方法也散落在各個業(yè)務(wù)類中,對于代碼的可讀性和可維護性來說是一個災(zāi)難。下面介紹下如何避免這個災(zāi)難。

2.4 方法注解實現(xiàn)操作日志

為了解決上面問題,一般采用 AOP 的方式記錄日志,讓操作日志和業(yè)務(wù)邏輯解耦,接下來看一個簡單的 AOP 日志的例子。@LogRecord(content="修改了配送地址")
public?void?modifyAddress(updateDeliveryRequest?request){
????//?更新派送信息?電話,收件人、地址
????doUpdate(request);
}
我們可以在注解的操作日志上記錄固定文案,這樣業(yè)務(wù)邏輯和業(yè)務(wù)代碼可以做到解耦,讓我們的業(yè)務(wù)代碼變得純凈起來??赡苡型瑢W(xué)注意到,上面的方式雖然解耦了操作日志的代碼,但是記錄的文案并不符合我們的預(yù)期,文案是靜態(tài)的,沒有包含動態(tài)的文案,因為我們需要記錄的操作日志是:用戶%s修改了訂單的配送地址,從“%s”修改到“%s”。接下來,我們介紹一下如何優(yōu)雅地使用 AOP 生成動態(tài)的操作日志。

3. 優(yōu)雅地支持 AOP 生成動態(tài)的操作日志

3.1 動態(tài)模板

一提到動態(tài)模板,就會涉及到讓變量通過占位符的方式解析模板,從而達到通過注解記錄操作日志的目的。模板解析的方式有很多種,這里使用了 SpEL(Spring Expression Language,Spring表達式語言)來實現(xiàn)。我們可以先寫下期望的記錄日志的方式,然后再看看能否實現(xiàn)這樣的功能。@LogRecord(content?=?"修改了訂單的配送地址:從“#oldAddress”, 修改到“#request.address”")
public?void?modifyAddress(updateDeliveryRequest?request,?String?oldAddress){
????//?更新派送信息?電話,收件人、地址
????doUpdate(request);
}
通過 SpEL 表達式引用方法上的參數(shù),可以讓變量填充到模板中達到動態(tài)的操作日志文本內(nèi)容。但是現(xiàn)在還有幾個問題需要解決:
  • 操作日志需要知道是哪個操作人修改的訂單配送地址。
  • 修改訂單配送地址的操作日志需要綁定在配送的訂單上,從而可以根據(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);
}
修改后的代碼在注解上添加兩個參數(shù),一個是操作人,一個是操作日志需要綁定的對象。但是,在普通的 Web 應(yīng)用中用戶信息都是保存在一個線程上下文的靜態(tài)方法中,所以 operator 一般是這樣的寫法(假定獲取當(dāng)前登陸用戶的方式是 UserContext.getCurrentUser())。operator?=?"#{T(com.meituan.user.UserContext).getCurrentUser()}"這樣的話,每個 @LogRecord 的注解上的操作人都是這么長一串。為了避免過多的重復(fù)代碼,我們可以把注解上的 operator 參數(shù)設(shè)置為非必填,這樣用戶可以填寫操作人。但是,如果用戶不填寫我們就取 UserContext 的 user(下文會介紹如何取 user)。最后,最簡單的日志變成了下面的形式:@LogRecord(content?=?"修改了訂單的配送地址:從“#oldAddress”, 修改到“#request.address”",?
???????????bizNo="#request.deliveryOrderNo")
public?void?modifyAddress(updateDeliveryRequest?request,?String?oldAddress){
????//?更新派送信息?電話,收件人、地址
????doUpdate(request);
}
接下來,我們需要解決第三個問題:為了記錄業(yè)務(wù)操作記錄添加了一個 oldAddress 變量,不管怎么樣這都不是一個好的實現(xiàn)方式,所以接下來,我們需要把 oldAddress 變量從修改地址的方法簽名上去掉。但是操作日志確實需要 oldAddress 變量,怎么辦呢?要么和產(chǎn)品經(jīng)理 PK 一下,讓產(chǎn)品經(jīng)理把文案從“修改了訂單的配送地址:從 xx 修改到 yy” 改為 “修改了訂單的配送地址為:yy”。但是從用戶體驗上來看,第一種文案更人性化一些,顯然我們不會 PK 成功的。那么我們就必須要把這個 oldAddress 查詢出來然后供操作日志使用了。還有一種解決辦法是:把這個參數(shù)放到操作日志的線程上下文中,供注解上的模板使用。我們按照這個思路再改下操作日志的實現(xiàn)代碼。@LogRecord(content?=?"修改了訂單的配送地址:從“#oldAddress”, 修改到“#request.address”",
????????bizNo="#request.deliveryOrderNo")
public?void?modifyAddress(updateDeliveryRequest?request){
????//?查詢出原來的地址是什么
????LogRecordContext.putVariable("oldAddress",?DeliveryService.queryOldAddress(request.getDeliveryOrderNo()));
????//?更新派送信息?電話,收件人、地址
????doUpdate(request);
}
這時候可以看到,LogRecordContext 解決了操作日志模板上使用方法參數(shù)以外變量的問題,同時避免了為了記錄操作日志修改方法簽名的設(shè)計。雖然已經(jīng)比之前的代碼好了些,但是依然需要在業(yè)務(wù)代碼里面加了一行業(yè)務(wù)邏輯無關(guān)的代碼,如果有“強迫癥”的同學(xué)還可以繼續(xù)往下看,接下來我們會講解自定義函數(shù)的解決方案。下面再看另一個例子:@LogRecord(content?=?"修改了訂單的配送員:從“#oldDeliveryUserId”, 修改到“#request.userId”",
????????bizNo="#request.deliveryOrderNo")
public?void?modifyAddress(updateDeliveryRequest?request){
????//?查詢出原來的地址是什么
????LogRecordContext.putVariable("oldDeliveryUserId",?DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
????//?更新派送信息?電話,收件人、地址
????doUpdate(request);
}
這個操作日志的模板最后記錄的內(nèi)容是這樣的格式:修改了訂單的配送員:從 “10090”,修改到 “10099”,顯然用戶看到這樣的操作日志是不明白的。用戶對于用戶 ID 是 10090 還是 10099 并不了解,用戶期望看到的是:修改了訂單的配送員:從“張三(18910008888)”,修改到“小明(13910006666)”。用戶關(guān)心的是配送員的姓名和電話。但是我們方法中傳遞的參數(shù)只有配送員的 ID,沒有配送員的姓名可電話。我們可以通過上面的方法,把用戶的姓名和電話查詢出來,然后通過 LogRecordContext 實現(xiàn)。但是,“強迫癥”是不期望操作日志的代碼嵌入在業(yè)務(wù)邏輯中的。接下來,我們考慮另一種實現(xiàn)方式:自定義函數(shù)。如果我們可以通過自定義函數(shù)把用戶 ID 轉(zhuǎn)換為用戶姓名和電話,那么就能解決這一問題,按照這個思路,我們把模板修改為下面的形式:@LogRecord(content?=?"修改了訂單的配送員:從“{deliveryUser{#oldDeliveryUserId}}”, 修改到“{deveryUser{#request.userId}}”",
????????bizNo="#request.deliveryOrderNo")
public?void?modifyAddress(updateDeliveryRequest?request){
????//?查詢出原來的地址是什么
????LogRecordContext.putVariable("oldDeliveryUserId",?DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
????//?更新派送信息?電話,收件人、地址
????doUpdate(request);
}
其中 deliveryUser 是自定義函數(shù),使用大括號把 Spring 的 SpEL 表達式包裹起來,這樣做的好處:一是把 Spring EL 表達式和自定義函數(shù)區(qū)分開便于解析;二是如果模板中不需要 SpEL 表達式解析可以容易的識別出來,減少 SpEL 的解析提高性能。這時候我們發(fā)現(xiàn)上面代碼還可以優(yōu)化成下面的形式:@LogRecord(content?=?"修改了訂單的配送員:從“{queryOldUser{#request.deliveryOrderNo()}}”, 修改到“{deveryUser{#request.userId}}”",
????????bizNo="#request.deliveryOrderNo")
public?void?modifyAddress(updateDeliveryRequest?request){
????//?更新派送信息?電話,收件人、地址
????doUpdate(request);
}
這樣就不需要在 modifyAddress 方法中通過 LogRecordContext.putVariable() 設(shè)置老的快遞員了,通過直接新加一個自定義函數(shù) queryOldUser() 參數(shù)把派送訂單傳遞進去,就能查到之前的配送人了,只需要讓方法的解析在 modifyAddress() 方法執(zhí)行之前運行。這樣的話,我們讓業(yè)務(wù)代碼又變得純凈了起來,同時也讓“強迫癥”不再感到難受了。

4. 代碼實現(xiàn)解析

4.1 代碼結(jié)構(gòu)

如何優(yōu)雅地記錄操作日志?
上面的操作日志主要是通過一個 AOP 攔截器實現(xiàn)的,整體主要分為 AOP 模塊、日志解析模塊、日志保存模塊、Starter 模塊;組件提供了4個擴展點,分別是:自定義函數(shù)、默認處理人、業(yè)務(wù)保存和查詢;業(yè)務(wù)可以根據(jù)自己的業(yè)務(wù)特性定制符合自己業(yè)務(wù)的邏輯。

4.2 模塊介紹

有了上面的分析,已經(jīng)得出一種我們期望的操作日志記錄的方式,接下來我們看下如何實現(xiàn)上面的邏輯。實現(xiàn)主要分為下面幾個步驟:
  • AOP 攔截邏輯
  • 解析邏輯
    • 模板解析
    • LogContext 邏輯
    • 默認的 operator 邏輯
    • 自定義函數(shù)邏輯
  • 默認的日志持久化邏輯
  • Starter 封裝邏輯

4.2.1 AOP 攔截邏輯

這塊邏輯主要是一個攔截器,針對 @LogRecord 注解分析出需要記錄的操作日志,然后把操作日志持久化,這里把注解命名為 @LogRecordAnnotation。接下來,我們看下注解的定義:@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?"";
}
注解中除了上面提到參數(shù)外,還增加了 fail、category、detail、condition 等參數(shù),這幾個參數(shù)是為了滿足特定的場景,后面還會給出具體的例子。如何優(yōu)雅地記錄操作日志?

為了保持簡單,組件的必填參數(shù)就兩個。業(yè)務(wù)中的 AOP 邏輯大部分是使用 @Aspect 注解實現(xiàn)的,但是基于注解的 AOP 在 Spring boot 1.5 中兼容性是有問題的,組件為了兼容 Spring boot1.5 的版本我們手工實現(xiàn) Spring 的 AOP 邏輯。
如何優(yōu)雅地記錄操作日志?
切面選擇?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?operations?=?new?ArrayList<>();
????Map?functionNameAndReturnMap?=?new?HashMap<>();
????try?{
????????operations?=?logRecordOperationSource.computeLogRecordOperations(method,?targetClass);
????????List?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;
}
攔截邏輯的流程:如何優(yōu)雅地記錄操作日志?

可以看到,操作日志的記錄持久化是在方法執(zhí)行完之后執(zhí)行的,當(dāng)方法拋出異常之后會先捕獲異常,等操作日志持久化完成后再拋出異常。在業(yè)務(wù)的方法執(zhí)行之前,會對提前解析的自定義函數(shù)求值,解決了前面提到的需要查詢修改之前的內(nèi)容。

4.2.2 解析邏輯

模板解析Spring 3 中提供了一個非常強大的功能:SpEL,SpEL 在 Spring 產(chǎn)品中是作為表達式求值的核心基礎(chǔ)模塊,它本身是可以脫離 Spring 獨立使用的。舉個例子: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));
}
這個方法將打印 “張三”。LogRecord 解析的類圖如下:如何優(yōu)雅地記錄操作日志?

解析核心類LogRecordValueParser?里面封裝了自定義函數(shù)和 SpEL 解析類?LogRecordExpressionEvaluator。public?class?LogRecordExpressionEvaluator?extends?CachedExpressionEvaluator?{

????private?Map?expressionCache?=?new?ConcurrentHashMap<>(64);

????private?final?Map?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 獲取到對應(yīng)的值,所以 expressionCache 是為了緩存方法、表達式和 SpEL 的 Expression 的對應(yīng)關(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)將會在下文介紹。日志上下文實現(xiàn)下面的例子把變量放到了 LogRecordContext 中,然后 SpEL 表達式就可以順利的解析方法上不存在的參數(shù)了,通過上面的 SpEL 的例子可以看出,要把方法的參數(shù)和 LogRecordContext 中的變量都放到 SpEL 的?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);
}
在 LogRecordValueParser 中創(chuàng)建了一個 EvaluationContext,用來給 SpEL 解析方法參數(shù)和 Context 中的變量。相關(guān)代碼如下:EvaluationContext?evaluationContext?=?expressionEvaluator.createEvaluationContext(method,?args,?targetClass,?ret,?errorMsg,?beanFactory);在解析的時候調(diào)用?getValue?方法傳入的參數(shù) evalContext,就是上面這個 EvaluationContext 對象。下面是 LogRecordEvaluationContext 對象的繼承體系:
如何優(yōu)雅地記錄操作日志?
LogRecordEvaluationContext 做了三個事情:
  • 把方法的參數(shù)都放到 SpEL 解析的 RootObject 中。
  • 把 LogRecordContext 中的變量都放到 RootObject 中。
  • 把方法的返回值和 ErrorMsg 都放到 RootObject 中。
LogRecordEvaluationContext 的代碼如下: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?variables?=?LogRecordContext.getVariables();
????????if?(variables?!=?null?
本站聲明: 本文章由作者或相關(guān)機構(gòu)授權(quán)發(fā)布,目的在于傳遞更多信息,并不代表本站贊同其觀點,本站亦不保證或承諾內(nèi)容真實性等。需要轉(zhuǎn)載請聯(lián)系該專欄作者,如若文章內(nèi)容侵犯您的權(quán)益,請及時聯(lián)系本站刪除。
關(guān)閉
關(guān)閉