美团的系统是如何记录操作日志?
來源:美團(tuán)技術(shù)團(tuán)隊(duì)
操作日志幾乎存在于每個(gè)系統(tǒng)中,而這些系統(tǒng)都有記錄操作日志的一套 API。操作日志和系統(tǒng)日志不一樣,操作日志必須要做到簡單易懂。所以如何讓操作日志不跟業(yè)務(wù)邏輯耦合,如何讓操作日志的內(nèi)容易于理解,如何讓操作日志的接入更加簡單?上面這些都是本文要回答的問題。我們主要圍繞著如何“優(yōu)雅”地記錄操作日志展開描述,希望對從事相關(guān)工作的同學(xué)能夠有所幫助或者啟發(fā)。
1. 操作日志的使用場景
2. 實(shí)現(xiàn)方式
2.1 使用 Canal 監(jiān)聽數(shù)據(jù)庫記錄操作日志
2.2 通過日志文件的方式記錄
2.3 通過 LogUtil 的方式記錄日志
2.4 方法注解實(shí)現(xiàn)操作日志
3. 優(yōu)雅地支持 AOP 生成動態(tài)的操作日志
3.1 動態(tài)模板
4. 代碼實(shí)現(xiàn)解析
4.1 代碼結(jié)構(gòu)
4.2 模塊介紹
5. 總結(jié)
1. 操作日志的使用場景
例子系統(tǒng)日志和操作日志的區(qū)別
系統(tǒng)日志:系統(tǒng)日志主要是為開發(fā)排查問題提供依據(jù),一般打印在日志文件中;系統(tǒng)日志的可讀性要求沒那么高,日志中會包含代碼的信息,比如在某個(gè)類的某一行打印了一個(gè)日志。
操作日志:主要是對某個(gè)對象進(jìn)行新增操作或者修改操作后記錄下這個(gè)新增或者修改,操作日志要求可讀性比較強(qiáng),因?yàn)樗饕墙o用戶看的,比如訂單的物流信息,用戶需要知道在什么時(shí)間發(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ū)”。
修改表單,一次會修改多個(gè)字段。
2. 實(shí)現(xiàn)方式
2.1 使用 Canal 監(jiān)聽數(shù)據(jù)庫記錄操作日志
Canal 是一款基于 MySQL 數(shù)據(jù)庫增量日志解析,提供增量數(shù)據(jù)訂閱和消費(fèi)的開源組件,通過采用監(jiān)聽數(shù)據(jù)庫 Binlog 的方式,這樣可以從底層知道是哪些數(shù)據(jù)做了修改,然后根據(jù)更改的數(shù)據(jù)記錄操作日志。
這種方式的優(yōu)點(diǎn)是和業(yè)務(wù)邏輯完全分離。缺點(diǎn)也很明顯,局限性太高,只能針對數(shù)據(jù)庫的更改做操作日志記錄,如果修改涉及到其他團(tuán)隊(duì)的 RPC 的調(diào)用,就沒辦法監(jiān)聽數(shù)據(jù)庫了。舉個(gè)例子:給用戶發(fā)送通知,通知服務(wù)一般都是公司內(nèi)部的公共組件,這時(shí)候只能在調(diào)用 RPC 的時(shí)候手工記錄發(fā)送通知的操作日志了。
2.2 通過日志文件的方式記錄
log.info("訂單創(chuàng)建") log.info("訂單已經(jīng)創(chuàng)建,訂單編號:{}",?orderNo) log.info("修改了訂單的配送地址:從“{}”修改到“{}”,?"金燦燦小區(qū)",?"銀盞盞小區(qū)")這種方式的操作記錄需要解決三個(gè)問題。
問題一:操作人如何記錄
借助 SLF4J 中的 MDC 工具類,把操作人放在日志中,然后在日志中統(tǒng)一打印出來。首先在用戶的攔截器中把用戶的標(biāo)識 Put 到 MDC 中。
@Component public?class?UserInterceptor?extends?HandlerInterceptorAdapter?{@Overridepublic?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)識。
<pattern>"%d{yyyy-MM-dd?HH:mm:ss.SSS}?%t?%-5level?%X{userId}?%logger{30}.%method:%L?-?%msg%n"</pattern>問題二:操作日志如何和系統(tǒng)日志區(qū)分開
通過配置 Log 的配置文件,把有關(guān)操作日志的 Log 單獨(dú)放到一日志文件中。
//不同業(yè)務(wù)日志記錄到不同的文件 <appender?name="businessLogAppender"?class="ch.qos.logback.core.rolling.RollingFileAppender"><File>logs/business.log</File><append>true</append><filter?class="ch.qos.logback.classic.filter.LevelFilter"><level>INFO</level><onMatch>ACCEPT</onMatch><onMismatch>DENY</onMismatch></filter><rollingPolicy?class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"><fileNamePattern>logs/業(yè)務(wù)A.%d.%i.log</fileNamePattern><maxHistory>90</maxHistory><timeBasedFileNamingAndTriggeringPolicy?class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP"><maxFileSize>10MB</maxFileSize></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-8</charset></encoder> </appender><logger?name="businessLog"?additivity="false"?level="INFO"><appender-ref?ref="businessLogAppender"/> </logger>然后在 Java 代碼中單獨(dú)的記錄業(yè)務(wù)日志。
//記錄特定日志的聲明 private?final?Logger?businessLog?=?LoggerFactory.getLogger("businessLog");//日志存儲 businessLog.info("修改了配送地址");問題三:如何生成可讀懂的日志文案
可以采用 LogUtil 的方式,也可以采用切面的方式生成日志模板,后續(xù)內(nèi)容將會進(jìn)行介紹。這樣就可以把日志單獨(dú)保存在一個(gè)文件中,然后通過日志收集可以把日志保存在 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ū)"),??"小明")這里解釋下為什么記錄操作日志的時(shí)候都綁定了一個(gè) OrderNo,因?yàn)椴僮魅罩居涗浀氖?#xff1a;某一個(gè)“時(shí)間”“誰”對“什么”做了什么“事情”。當(dāng)查詢業(yè)務(wù)的操作日志的時(shí)候,會查詢針對這個(gè)訂單的的所有操作,所以代碼中加上了 OrderNo,記錄操作日志的時(shí)候需要記錄下操作人,所以傳了操作人“小明”進(jìn)來。
上面看起來問題并不大,在修改地址的業(yè)務(wù)邏輯方法中使用一行代碼記錄了操作日志,接下來再看一個(gè)更復(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); }可以看到上面的例子使用了兩個(gè)方法代碼,外加一個(gè) getLogContent 的函數(shù)實(shí)現(xiàn)了操作日志的記錄。當(dāng)業(yè)務(wù)變得復(fù)雜后,記錄操作日志放在業(yè)務(wù)代碼中會導(dǎo)致業(yè)務(wù)的邏輯比較繁雜,最后導(dǎo)致 LogUtils.logRecord() 方法的調(diào)用存在于很多業(yè)務(wù)的代碼中,而且類似 getLogContent() 這樣的方法也散落在各個(gè)業(yè)務(wù)類中,對于代碼的可讀性和可維護(hù)性來說是一個(gè)災(zāi)難。下面介紹下如何避免這個(gè)災(zāi)難。
2.4 方法注解實(shí)現(xiàn)操作日志
為了解決上面問題,一般采用 AOP 的方式記錄日志,讓操作日志和業(yè)務(wù)邏輯解耦,接下來看一個(gè)簡單的 AOP 日志的例子。
@LogRecord(content="修改了配送地址") public?void?modifyAddress(updateDeliveryRequest?request){//?更新派送信息?電話,收件人、地址doUpdate(request); }我們可以在注解的操作日志上記錄固定文案,這樣業(yè)務(wù)邏輯和業(yè)務(wù)代碼可以做到解耦,讓我們的業(yè)務(wù)代碼變得純凈起來。可能有同學(xué)注意到,上面的方式雖然解耦了操作日志的代碼,但是記錄的文案并不符合我們的預(yù)期,文案是靜態(tài)的,沒有包含動態(tài)的文案,因?yàn)槲覀冃枰涗浀牟僮魅罩臼?#xff1a;用戶%s修改了訂單的配送地址,從“%s”修改到“%s”。接下來,我們介紹一下如何優(yōu)雅地使用 AOP 生成動態(tài)的操作日志。
3. 優(yōu)雅地支持 AOP 生成動態(tài)的操作日志
3.1 動態(tài)模板
一提到動態(tài)模板,就會涉及到讓變量通過占位符的方式解析模板,從而達(dá)到通過注解記錄操作日志的目的。模板解析的方式有很多種,這里使用了 SpEL(Spring Expression Language,Spring表達(dá)式語言)來實(shí)現(xiàn)。我們可以先寫下期望的記錄日志的方式,然后再看看能否實(shí)現(xiàn)這樣的功能。
@LogRecord(content?=?"修改了訂單的配送地址:從“#oldAddress”, 修改到“#request.address”") public?void?modifyAddress(updateDeliveryRequest?request,?String?oldAddress){//?更新派送信息?電話,收件人、地址doUpdate(request); }通過 SpEL 表達(dá)式引用方法上的參數(shù),可以讓變量填充到模板中達(dá)到動態(tài)的操作日志文本內(nèi)容。但是現(xiàn)在還有幾個(gè)問題需要解決:
操作日志需要知道是哪個(gè)操作人修改的訂單配送地址。
修改訂單配送地址的操作日志需要綁定在配送的訂單上,從而可以根據(jù)配送訂單號查詢出對這個(gè)配送訂單的所有操作。
為了在注解上記錄之前的配送地址是什么,在方法簽名上添加了一個(gè)和業(yè)務(wù)無關(guān)的 oldAddress 的變量,這樣就不優(yōu)雅了。
為了解決前兩個(gè)問題,我們需要把期望的操作日志使用形式改成下面的方式:
@LogRecord(content?=?"修改了訂單的配送地址:從“#oldAddress”, 修改到“#request.address”",operator?=?"#request.userName",?bizNo="#request.deliveryOrderNo") public?void?modifyAddress(updateDeliveryRequest?request,?String?oldAddress){//?更新派送信息?電話,收件人、地址doUpdate(request); }修改后的代碼在注解上添加兩個(gè)參數(shù),一個(gè)是操作人,一個(gè)是操作日志需要綁定的對象。但是,在普通的 Web 應(yīng)用中用戶信息都是保存在一個(gè)線程上下文的靜態(tài)方法中,所以 operator 一般是這樣的寫法(假定獲取當(dāng)前登陸用戶的方式是 UserContext.getCurrentUser())。
operator?=?"#{T(com.meituan.user.UserContext).getCurrentUser()}"這樣的話,每個(gè) @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); }接下來,我們需要解決第三個(gè)問題:為了記錄業(yè)務(wù)操作記錄添加了一個(gè) oldAddress 變量,不管怎么樣這都不是一個(gè)好的實(shí)現(xiàn)方式,所以接下來,我們需要把 oldAddress 變量從修改地址的方法簽名上去掉。但是操作日志確實(shí)需要 oldAddress 變量,怎么辦呢?
要么和產(chǎn)品經(jīng)理 PK 一下,讓產(chǎn)品經(jīng)理把文案從“修改了訂單的配送地址:從 xx 修改到 yy” 改為 “修改了訂單的配送地址為:yy”。但是從用戶體驗(yàn)上來看,第一種文案更人性化一些,顯然我們不會 PK 成功的。那么我們就必須要把這個(gè) oldAddress 查詢出來然后供操作日志使用了。還有一種解決辦法是:把這個(gè)參數(shù)放到操作日志的線程上下文中,供注解上的模板使用。我們按照這個(gè)思路再改下操作日志的實(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); }這時(shí)候可以看到,LogRecordContext 解決了操作日志模板上使用方法參數(shù)以外變量的問題,同時(shí)避免了為了記錄操作日志修改方法簽名的設(shè)計(jì)。雖然已經(jīng)比之前的代碼好了些,但是依然需要在業(yè)務(wù)代碼里面加了一行業(yè)務(wù)邏輯無關(guān)的代碼,如果有“強(qiáng)迫癥”的同學(xué)還可以繼續(xù)往下看,接下來我們會講解自定義函數(shù)的解決方案。下面再看另一個(gè)例子:
@LogRecord(content?=?"修改了訂單的配送員:從“#oldDeliveryUserId”, 修改到“#request.userId”",bizNo="#request.deliveryOrderNo") public?void?modifyAddress(updateDeliveryRequest?request){//?查詢出原來的地址是什么LogRecordContext.putVariable("oldDeliveryUserId",?DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));//?更新派送信息?電話,收件人、地址doUpdate(request); }這個(gè)操作日志的模板最后記錄的內(nèi)容是這樣的格式:修改了訂單的配送員:從 “10090”,修改到 “10099”,顯然用戶看到這樣的操作日志是不明白的。用戶對于用戶 ID 是 10090 還是 10099 并不了解,用戶期望看到的是:修改了訂單的配送員:從“張三(18910008888)”,修改到“小明(13910006666)”。用戶關(guān)心的是配送員的姓名和電話。但是我們方法中傳遞的參數(shù)只有配送員的 ID,沒有配送員的姓名可電話。我們可以通過上面的方法,把用戶的姓名和電話查詢出來,然后通過 LogRecordContext 實(shí)現(xiàn)。
但是,“強(qiáng)迫癥”是不期望操作日志的代碼嵌入在業(yè)務(wù)邏輯中的。接下來,我們考慮另一種實(shí)現(xiàn)方式:自定義函數(shù)。如果我們可以通過自定義函數(shù)把用戶 ID 轉(zhuǎn)換為用戶姓名和電話,那么就能解決這一問題,按照這個(gè)思路,我們把模板修改為下面的形式:
@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 表達(dá)式包裹起來,這樣做的好處:一是把 Spring EL 表達(dá)式和自定義函數(shù)區(qū)分開便于解析;二是如果模板中不需要 SpEL 表達(dá)式解析可以容易的識別出來,減少 SpEL 的解析提高性能。這時(shí)候我們發(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è)置老的快遞員了,通過直接新加一個(gè)自定義函數(shù) queryOldUser() 參數(shù)把派送訂單傳遞進(jìn)去,就能查到之前的配送人了,只需要讓方法的解析在 modifyAddress() 方法執(zhí)行之前運(yùn)行。這樣的話,我們讓業(yè)務(wù)代碼又變得純凈了起來,同時(shí)也讓“強(qiáng)迫癥”不再感到難受了。
4. 代碼實(shí)現(xiàn)解析
4.1 代碼結(jié)構(gòu)
上面的操作日志主要是通過一個(gè) AOP 攔截器實(shí)現(xiàn)的,整體主要分為 AOP 模塊、日志解析模塊、日志保存模塊、Starter 模塊;組件提供了4個(gè)擴(kuò)展點(diǎn),分別是:自定義函數(shù)、默認(rèn)處理人、業(yè)務(wù)保存和查詢;業(yè)務(wù)可以根據(jù)自己的業(yè)務(wù)特性定制符合自己業(yè)務(wù)的邏輯。
4.2 模塊介紹
有了上面的分析,已經(jīng)得出一種我們期望的操作日志記錄的方式,接下來我們看下如何實(shí)現(xiàn)上面的邏輯。實(shí)現(xiàn)主要分為下面幾個(gè)步驟:
AOP 攔截邏輯
解析邏輯
模板解析
LogContext 邏輯
默認(rèn)的 operator 邏輯
自定義函數(shù)邏輯
默認(rèn)的日志持久化邏輯
Starter 封裝邏輯
4.2.1 AOP 攔截邏輯
這塊邏輯主要是一個(gè)攔截器,針對 @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ù),這幾個(gè)參數(shù)是為了滿足特定的場景,后面還會給出具體的例子。
為了保持簡單,組件的必填參數(shù)就兩個(gè)。業(yè)務(wù)中的 AOP 邏輯大部分是使用 @Aspect 注解實(shí)現(xiàn)的,但是基于注解的 AOP 在 Spring boot 1.5 中兼容性是有問題的,組件為了兼容 Spring boot1.5 的版本我們手工實(shí)現(xiàn) Spring 的 AOP 邏輯。
切面選擇 AbstractBeanFactoryPointcutAdvisor 實(shí)現(xiàn),切點(diǎn)是通過 StaticMethodMatcherPointcut 匹配包含 LogRecordAnnotation 注解的方法。通過實(shí)現(xiàn) MethodInterceptor 接口實(shí)現(xiàn)操作日志的增強(qiáng)邏輯。
下面是攔截器的切點(diǎn)邏輯:
public?class?LogRecordPointcut?extends?StaticMethodMatcherPointcut?implements?Serializable?{//?LogRecord的解析類private?LogRecordOperationSource?logRecordOperationSource;@Overridepublic?boolean?matches(@NonNull?Method?method,?@NonNull?Class<?>?targetClass)?{//?解析?這個(gè)?method?上有沒有?@LogRecordAnnotation?注解,有的話會解析出來注解上的各個(gè)參數(shù)return?!CollectionUtils.isEmpty(logRecordOperationSource.computeLogRecordOperations(method,?targetClass));}void?setLogRecordOperationSource(LogRecordOperationSource?logRecordOperationSource)?{this.logRecordOperationSource?=?logRecordOperationSource;} }切面的增強(qiáng)邏輯主要代碼如下:
@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<LogRecordOps>?operations?=?new?ArrayList<>();Map<String,?String>?functionNameAndReturnMap?=?new?HashMap<>();try?{operations?=?logRecordOperationSource.computeLogRecordOperations(method,?targetClass);List<String>?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)?{//記錄日志錯(cuò)誤不要影響業(yè)務(wù)log.error("log?record?parse?exception",?t);}?finally?{LogRecordContext.clear();}if?(methodExecuteResult.throwable?!=?null)?{throw?methodExecuteResult.throwable;}return?ret; }攔截邏輯的流程:
可以看到,操作日志的記錄持久化是在方法執(zhí)行完之后執(zhí)行的,當(dāng)方法拋出異常之后會先捕獲異常,等操作日志持久化完成后再拋出異常。在業(yè)務(wù)的方法執(zhí)行之前,會對提前解析的自定義函數(shù)求值,解決了前面提到的需要查詢修改之前的內(nèi)容。
4.2.2 解析邏輯
模板解析
Spring 3 中提供了一個(gè)非常強(qiáng)大的功能:SpEL,SpEL 在 Spring 產(chǎn)品中是作為表達(dá)式求值的核心基礎(chǔ)模塊,它本身是可以脫離 Spring 獨(dú)立使用的。舉個(gè)例子:
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)); }這個(gè)方法將打印 “張三”。LogRecord 解析的類圖如下:
解析核心類:LogRecordValueParser 里面封裝了自定義函數(shù)和 SpEL 解析類 LogRecordExpressionEvaluator。
public?class?LogRecordExpressionEvaluator?extends?CachedExpressionEvaluator?{private?Map<ExpressionKey,?Expression>?expressionCache?=?new?ConcurrentHashMap<>(64);private?final?Map<AnnotatedElementKey,?Method>?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 類,這個(gè)類里面有兩個(gè) Map,一個(gè)是 expressionCache 一個(gè)是 targetMethodCache。在上面的例子中可以看到,SpEL 會解析成一個(gè) Expression 表達(dá)式,然后根據(jù)傳入的 Object 獲取到對應(yīng)的值,所以 expressionCache 是為了緩存方法、表達(dá)式和 SpEL 的 Expression 的對應(yīng)關(guān)系,讓方法注解上添加的 SpEL 表達(dá)式只解析一次。下面的 targetMethodCache 是為了緩存?zhèn)魅氲?Expression 表達(dá)式的 Object。核心的解析邏輯是上面最后一行代碼。
getExpression(this.expressionCache,?methodKey,?conditionExpression).getValue(evalContext,?String.class);getExpression 方法會從 expressionCache 中獲取到 @LogRecordAnnotation 注解上的表達(dá)式的解析 Expression 的實(shí)例,然后調(diào)用 getValue 方法,getValue 傳入一個(gè) evalContext 就是類似上面例子中的 order 對象。其中 Context 的實(shí)現(xiàn)將會在下文介紹。
日志上下文實(shí)現(xiàn)
下面的例子把變量放到了 LogRecordContext 中,然后 SpEL 表達(dá)式就可以順利的解析方法上不存在的參數(shù)了,通過上面的 SpEL 的例子可以看出,要把方法的參數(shù)和 LogRecordContext 中的變量都放到 SpEL 的 getValue 方法的 Object 中才可以順利的解析表達(dá)式的值。下面看看如何實(shí)現(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)建了一個(gè) EvaluationContext,用來給 SpEL 解析方法參數(shù)和 Context 中的變量。相關(guān)代碼如下:
EvaluationContext?evaluationContext?=?expressionEvaluator.createEvaluationContext(method,?args,?targetClass,?ret,?errorMsg,?beanFactory);在解析的時(shí)候調(diào)用 getValue 方法傳入的參數(shù) evalContext,就是上面這個(gè) EvaluationContext 對象。下面是 LogRecordEvaluationContext 對象的繼承體系:
LogRecordEvaluationContext 做了三個(gè)事情:
把方法的參數(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<String,?Object>?variables?=?LogRecordContext.getVariables();if?(variables?!=?null?&&?variables.size()?>?0)?{for?(Map.Entry<String,?Object>?entry?:?variables.entrySet())?{setVariable(entry.getKey(),?entry.getValue());}}//把方法的返回值和?ErrorMsg?都放到?RootObject?中setVariable("_ret",?ret);setVariable("_errorMsg",?errorMsg);} }下面是 LogRecordContext 的實(shí)現(xiàn),這個(gè)類里面通過一個(gè) ThreadLocal 變量保持了一個(gè)棧,棧里面是個(gè) Map,Map 對應(yīng)了變量的名稱和變量的值。
public?class?LogRecordContext?{private?static?final?InheritableThreadLocal<Stack<Map<String,?Object>>>?variableMapStack?=?new?InheritableThreadLocal<>();//其他省略.... }上面使用了 InheritableThreadLocal,所以在線程池的場景下使用 LogRecordContext 會出現(xiàn)問題,如果支持線程池可以使用阿里巴巴開源的 TTL 框架。那這里為什么不直接設(shè)置一個(gè) ThreadLocal<Map<String, Object>> 對象,而是要設(shè)置一個(gè) Stack 結(jié)構(gòu)呢?我們看一下這么做的原因是什么。
@LogRecord(content?=?"修改了訂單的配送員:從“{deveryUser{#oldDeliveryUserId}}”, 修改到“{deveryUser{#request.getUserId()}}”",bizNo="#request.getDeliveryOrderNo()") public?void?modifyAddress(updateDeliveryRequest?request){//?查詢出原來的地址是什么LogRecordContext.putVariable("oldDeliveryUserId",?DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));//?更新派送信息?電話,收件人、地址doUpdate(request); }上面代碼的執(zhí)行流程如下:
看起來沒有什么問題,但是使用 LogRecordAnnotation 的方法里面嵌套了另一個(gè)使用 LogRecordAnnotation 方法的時(shí)候,流程就變成下面的形式:
可以看到,當(dāng)方法二執(zhí)行了釋放變量后,繼續(xù)執(zhí)行方法一的 logRecord 邏輯,此時(shí)解析的時(shí)候 ThreadLocal<Map<String, Object>>的 Map 已經(jīng)被釋放掉,所以方法一就獲取不到對應(yīng)的變量了。方法一和方法二共用一個(gè)變量 Map 還有個(gè)問題是:如果方法二設(shè)置了和方法一相同的變量兩個(gè)方法的變量就會被相互覆蓋。所以最終 LogRecordContext 的變量的生命周期需要是下面的形式:
LogRecordContext 每執(zhí)行一個(gè)方法都會壓棧一個(gè) Map,方法執(zhí)行完之后會 Pop 掉這個(gè) Map,從而避免變量共享和覆蓋問題。
默認(rèn)操作人邏輯
在 LogRecordInterceptor 中 IOperatorGetService 接口,這個(gè)接口可以獲取到當(dāng)前的用戶。下面是接口的定義:
public?interface?IOperatorGetService?{/***?可以在里面外部的獲取當(dāng)前登陸的用戶,比如?UserContext.getCurrentUser()**?@return?轉(zhuǎn)換成Operator返回*/Operator?getUser(); }下面給出了從用戶上下文中獲取用戶的例子:
public?class?DefaultOperatorGetServiceImpl?implements?IOperatorGetService?{@Overridepublic?Operator?getUser()?{//UserUtils?是獲取用戶上下文的方法return?Optional.ofNullable(UserUtils.getUser()).map(a?->?new?Operator(a.getName(),?a.getLogin())).orElseThrow(()->new?IllegalArgumentException("user?is?null"));} }組件在解析 operator 的時(shí)候,就判斷注解上的 operator 是否是空,如果注解上沒有指定,我們就從 IOperatorGetService 的 getUser 方法獲取了。如果都獲取不到,就會報(bào)錯(cuò)。
String?realOperatorId?=?""; if?(StringUtils.isEmpty(operatorId))?{if?(operatorGetService.getUser()?==?null?||?StringUtils.isEmpty(operatorGetService.getUser().getOperatorId()))?{throw?new?IllegalArgumentException("user?is?null");}realOperatorId?=?operatorGetService.getUser().getOperatorId(); }?else?{spElTemplates?=?Lists.newArrayList(bizKey,?bizNo,?action,?operatorId,?detail); }自定義函數(shù)邏輯
自定義函數(shù)的類圖如下:
下面是 IParseFunction 的接口定義:executeBefore 函數(shù)代表了自定義函數(shù)是否在業(yè)務(wù)代碼執(zhí)行之前解析,上面提到的查詢修改之前的內(nèi)容。
public?interface?IParseFunction?{default?boolean?executeBefore(){return?false;}String?functionName();String?apply(String?value); }ParseFunctionFactory 的代碼比較簡單,它的功能是把所有的 IParseFunction 注入到函數(shù)工廠中。
public?class?ParseFunctionFactory?{private?Map<String,?IParseFunction>?allFunctionMap;public?ParseFunctionFactory(List<IParseFunction>?parseFunctions)?{if?(CollectionUtils.isEmpty(parseFunctions))?{return;}allFunctionMap?=?new?HashMap<>();for?(IParseFunction?parseFunction?:?parseFunctions)?{if?(StringUtils.isEmpty(parseFunction.functionName()))?{continue;}allFunctionMap.put(parseFunction.functionName(),?parseFunction);}}public?IParseFunction?getFunction(String?functionName)?{return?allFunctionMap.get(functionName);}public?boolean?isBeforeFunction(String?functionName)?{return?allFunctionMap.get(functionName)?!=?null?&&?allFunctionMap.get(functionName).executeBefore();} }DefaultFunctionServiceImpl 的邏輯就是根據(jù)傳入的函數(shù)名稱 functionName 找到對應(yīng)的 IParseFunction,然后把參數(shù)傳入到 IParseFunction 的 apply 方法上最后返回函數(shù)的值。
public?class?DefaultFunctionServiceImpl?implements?IFunctionService?{private?final?ParseFunctionFactory?parseFunctionFactory;public?DefaultFunctionServiceImpl(ParseFunctionFactory?parseFunctionFactory)?{this.parseFunctionFactory?=?parseFunctionFactory;}@Overridepublic?String?apply(String?functionName,?String?value)?{IParseFunction?function?=?parseFunctionFactory.getFunction(functionName);if?(function?==?null)?{return?value;}return?function.apply(value);}@Overridepublic?boolean?beforeFunction(String?functionName)?{return?parseFunctionFactory.isBeforeFunction(functionName);} }4.2.3 日志持久化邏輯
同樣在 LogRecordInterceptor 的代碼中引用了 ILogRecordService,這個(gè) Service 主要包含了日志記錄的接口。
public?interface?ILogRecordService?{/***?保存?log**?@param?logRecord?日志實(shí)體*/void?record(LogRecord?logRecord);}業(yè)務(wù)可以實(shí)現(xiàn)這個(gè)保存接口,然后把日志保存在任何存儲介質(zhì)上。這里給了一個(gè) 2.2 節(jié)介紹的通過 log.info 保存在日志文件中的例子,業(yè)務(wù)可以把保存設(shè)置成異步或者同步,可以和業(yè)務(wù)放在一個(gè)事務(wù)中保證操作日志和業(yè)務(wù)的一致性,也可以新開辟一個(gè)事務(wù),保證日志的錯(cuò)誤不影響業(yè)務(wù)的事務(wù)。業(yè)務(wù)可以保存在 Elasticsearch、數(shù)據(jù)庫或者文件中,用戶可以根據(jù)日志結(jié)構(gòu)和日志的存儲實(shí)現(xiàn)相應(yīng)的查詢邏輯。
@Slf4j public?class?DefaultLogRecordServiceImpl?implements?ILogRecordService?{@Override //????@Transactional(propagation?=?Propagation.REQUIRES_NEW)public?void?record(LogRecord?logRecord)?{log.info("【logRecord】log={}",?logRecord);} }4.2.4 Starter 邏輯封裝
上面邏輯代碼已經(jīng)介紹完畢,那么接下來需要把這些組件組裝起來,然后讓用戶去使用。在使用這個(gè)組件的時(shí)候只需要在 Springboot 的入口上添加一個(gè)注解 @EnableLogRecord(tenant = "com.mzt.test")。其中 tenant 代表租戶,是為了多租戶使用的。
@SpringBootApplication(exclude?=?DataSourceAutoConfiguration.class) @EnableTransactionManagement @EnableLogRecord(tenant?=?"com.mzt.test") public?class?Main?{public?static?void?main(String[]?args)?{SpringApplication.run(Main.class,?args);} }我們再看下 EnableLogRecord 的代碼,代碼中 Import 了 LogRecordConfigureSelector.class,在 LogRecordConfigureSelector 類中暴露了 LogRecordProxyAutoConfiguration 類。
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented @Import(LogRecordConfigureSelector.class) public?@interface?EnableLogRecord?{String?tenant();AdviceMode?mode()?default?AdviceMode.PROXY; }LogRecordProxyAutoConfiguration 就是裝配上面組件的核心類了,代碼如下:
@Configuration @Slf4j public?class?LogRecordProxyAutoConfiguration?implements?ImportAware?{private?AnnotationAttributes?enableLogRecord;@Bean@Role(BeanDefinition.ROLE_INFRASTRUCTURE)public?LogRecordOperationSource?logRecordOperationSource()?{return?new?LogRecordOperationSource();}@Bean@ConditionalOnMissingBean(IFunctionService.class)public?IFunctionService?functionService(ParseFunctionFactory?parseFunctionFactory)?{return?new?DefaultFunctionServiceImpl(parseFunctionFactory);}@Beanpublic?ParseFunctionFactory?parseFunctionFactory(@Autowired?List<IParseFunction>?parseFunctions)?{return?new?ParseFunctionFactory(parseFunctions);}@Bean@ConditionalOnMissingBean(IParseFunction.class)public?DefaultParseFunction?parseFunction()?{return?new?DefaultParseFunction();}@Bean@Role(BeanDefinition.ROLE_INFRASTRUCTURE)public?BeanFactoryLogRecordAdvisor?logRecordAdvisor(IFunctionService?functionService)?{BeanFactoryLogRecordAdvisor?advisor?=new?BeanFactoryLogRecordAdvisor();advisor.setLogRecordOperationSource(logRecordOperationSource());advisor.setAdvice(logRecordInterceptor(functionService));return?advisor;}@Bean@Role(BeanDefinition.ROLE_INFRASTRUCTURE)public?LogRecordInterceptor?logRecordInterceptor(IFunctionService?functionService)?{LogRecordInterceptor?interceptor?=?new?LogRecordInterceptor();interceptor.setLogRecordOperationSource(logRecordOperationSource());interceptor.setTenant(enableLogRecord.getString("tenant"));interceptor.setFunctionService(functionService);return?interceptor;}@Bean@ConditionalOnMissingBean(IOperatorGetService.class)@Role(BeanDefinition.ROLE_APPLICATION)public?IOperatorGetService?operatorGetService()?{return?new?DefaultOperatorGetServiceImpl();}@Bean@ConditionalOnMissingBean(ILogRecordService.class)@Role(BeanDefinition.ROLE_APPLICATION)public?ILogRecordService?recordService()?{return?new?DefaultLogRecordServiceImpl();}@Overridepublic?void?setImportMetadata(AnnotationMetadata?importMetadata)?{this.enableLogRecord?=?AnnotationAttributes.fromMap(importMetadata.getAnnotationAttributes(EnableLogRecord.class.getName(),?false));if?(this.enableLogRecord?==?null)?{log.info("@EnableCaching?is?not?present?on?importing?class");}} }這個(gè)類繼承 ImportAware 是為了拿到 EnableLogRecord 上的租戶屬性,這個(gè)類使用變量 logRecordAdvisor 和 logRecordInterceptor 裝配了 AOP,同時(shí)把自定義函數(shù)注入到了 logRecordAdvisor 中。
對外擴(kuò)展類:分別是IOperatorGetService、ILogRecordService、IParseFunction。業(yè)務(wù)可以自己實(shí)現(xiàn)相應(yīng)的接口,因?yàn)榕渲昧?@ConditionalOnMissingBean,所以用戶的實(shí)現(xiàn)類會覆蓋組件內(nèi)的默認(rèn)實(shí)現(xiàn)。
5. 總結(jié)
這篇文章介紹了操作日志的常見寫法,以及如何讓操作日志的實(shí)現(xiàn)更加簡單、易懂,通過組件的四個(gè)模塊,介紹了組件的具體實(shí)現(xiàn)。對于上面的組件介紹,大家如果有疑問,也歡迎在文末留言,我們會進(jìn)行答疑。
6. 作者簡介
站通,2020年加入美團(tuán),基礎(chǔ)研發(fā)平臺/研發(fā)質(zhì)量及效率部工程師。
7. 參考資料
Canal
Spring-Framework
Spring Expression Language (SpEL)
ThreadLocal、InheritableThreadLocal、TransmittableThreadLocal三者之間區(qū)別
職場發(fā)展遇到困惑,想問問K哥?快關(guān)注視頻號,到K哥直播間交流吧。
?-END-?
放下面子掙錢,是成年人最大的體面!
請關(guān)注副業(yè)怎么搞
有高人指點(diǎn),是人生莫大的幸事!
請關(guān)注,AI熊貓教授
大家在看:
1.低代碼干掉65%工作,40%程序員
2.被勸退了
3.被渣女騙了
4.如何用敏捷搞垮一個(gè)團(tuán)隊(duì)?
5.為什么CTO不寫代碼,還這么牛逼?
6.如何快速降低一個(gè)員工的積極性
總結(jié)
以上是生活随笔為你收集整理的美团的系统是如何记录操作日志?的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: DOM及DOM查找
- 下一篇: iOS中,系统相册的那些事