日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當(dāng)前位置: 首頁 > 运维知识 > windows >内容正文

windows

美团的系统是如何记录操作日志?

發(fā)布時(shí)間:2023/12/20 windows 72 豆豆
生活随笔 收集整理的這篇文章主要介紹了 美团的系统是如何记录操作日志? 小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,幫大家做個(gè)參考.

來源:美團(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)容,希望文章能夠幫你解決所遇到的問題。

如果覺得生活随笔網(wǎng)站內(nèi)容還不錯(cuò),歡迎將生活随笔推薦給好友。