如何優(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. 操作日志的使用場景

例子

系統(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

publicclassUserInterceptorextendsHandlerInterceptorAdapter{

@Override

publicbooleanpreHandle(HttpServletRequest?request,?HttpServletResponse?response,?Object?handler)throwsException{

//獲取到用戶標(biāo)識

String?userNo?=?getUserNo(request);

//把用戶?ID?放到?MDC?上下文中

MDC.put("userId",?userNo);

returnsuper.preHandle(request,?response,?handler);

}

privateStringgetUserNo(HttpServletRequest?request){

//?通過?SSO?或者Cookie?或者?Auth信息獲取到?當(dāng)前登陸的用戶信息

returnnull;

}

}

其次洼哎,把 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ù)日志記錄到不同的文件

logs/business.log

true

INFO

ACCEPT

DENY

logs/業(yè)務(wù)A.%d.%i.log

90

10MB

"%d{yyyy-MM-dd?HH:mm:ss.SSS}?%t?%-5level?%X{userId}?%logger{30}.%method:%L?-?%msg%n"

UTF-8

然后在 Java 代碼中單獨的記錄業(yè)務(wù)日志抽兆。

//記錄特定日志的聲明

privatefinalLogger?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ù)雜的例子:

privateOnesIssueDOupdateAddress(updateDeliveryRequest?request){

DeliveryOrder?deliveryOrder?=?deliveryQueryService.queryOldAddress(request.getDeliveryOrderNo());

//?更新派送信息,電話熙涤,收件人阁苞,地址

doUpdate(request);

String?logContent?=?getLogContent(request,?deliveryOrder);

LogUtils.logRecord(request.getOrderNo(),?logContent,?request.getOperator);

returnonesIssueDO;

}

privateStringgetLogContent(updateDeliveryRequest?request,?DeliveryOrder?deliveryOrder){

String?template?="用戶%s修改了訂單的配送地址:從“%s”修改到“%s”";

returnString.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="修改了配送地址")

publicvoidmodifyAddress(updateDeliveryRequest?request){

//?更新派送信息?電話非驮,收件人、地址

doUpdate(request);

}

我們可以在注解的操作日志上記錄固定文案雏赦,這樣業(yè)務(wù)邏輯和業(yè)務(wù)代碼可以做到解耦劫笙,讓我們的業(yè)務(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”")

publicvoidmodifyAddress(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")

publicvoidmodifyAddress(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")

publicvoidmodifyAddress(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")

publicvoidmodifyAddress(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")

publicvoidmodifyAddress(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")

publicvoidmodifyAddress(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")

publicvoidmodifyAddress(updateDeliveryRequest?request){

//?更新派送信息?電話,收件人影涉、地址

doUpdate(request);

}

這樣就不需要在 modifyAddress 方法中通過 LogRecordContext.putVariable() 設(shè)置老的快遞員了变隔,通過直接新加一個自定義函數(shù) queryOldUser() 參數(shù)把派送訂單傳遞進去,就能查到之前的配送人了蟹倾,只需要讓方法的解析在 modifyAddress() 方法執(zhí)行之前運行匣缘。這樣的話猖闪,我們讓業(yè)務(wù)代碼又變得純凈了起來,同時也讓“強迫癥”不再感到難受了肌厨。


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

4.1 代碼結(jié)構(gò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@interfaceLogRecordAnnotation?{

Stringsuccess();

Stringfail()default"";

Stringoperator()default"";

StringbizNo();

Stringcategory()default"";

Stringdetail()default"";

Stringcondition()default"";

}

注解中除了上面提到參數(shù)外,還增加了 fail谒亦、category竭宰、detail、condition 等參數(shù)份招,這幾個參數(shù)是為了滿足特定的場景切揭,后面還會給出具體的例子。

為了保持簡單锁摔,組件的必填參數(shù)就兩個廓旬。業(yè)務(wù)中的 AOP 邏輯大部分是使用 @Aspect 注解實現(xiàn)的,但是基于注解的 AOP 在 Spring boot 1.5 中兼容性是有問題的谐腰,組件為了兼容 Spring boot1.5 的版本我們手工實現(xiàn) Spring 的 AOP 邏輯孕豹。

切面選擇?AbstractBeanFactoryPointcutAdvisor?實現(xiàn),切點是通過?StaticMethodMatcherPointcut?匹配包含?LogRecordAnnotation?注解的方法怔蚌。通過實現(xiàn)?MethodInterceptor?接口實現(xiàn)操作日志的增強邏輯巩步。

下面是攔截器的切點邏輯:

public class LogRecordPointcutextendsStaticMethodMatcherPointcut 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));

}

voidsetLogRecordOperationSource(LogRecordOperationSource?logRecordOperationSource){

this.logRecordOperationSource?=?logRecordOperationSource;

}

}

切面的增強邏輯主要代碼如下:

@Override

publicObjectinvoke(MethodInvocation?invocation)throwsThrowable{

Method?method?=?invocation.getMethod();

//?記錄日志

returnexecute(invocation,?invocation.getThis(),?method,?invocation.getArguments());

}

privateObjectexecute(MethodInvocation?invoker,?Object?target,?Method?method,?Object[]?args)throwsThrowable{

Class?targetClass?=?getTargetClass(target);

Object?ret?=null;

MethodExecuteResult?methodExecuteResult?=newMethodExecuteResult(true,null,"");

LogRecordContext.putEmptySpan();

Collection?operations?=newArrayList<>();

Map?functionNameAndReturnMap?=newHashMap<>();

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?=newMethodExecuteResult(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)?{

throwmethodExecuteResult.throwable;

}

returnret;

}

攔截邏輯的流程:

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

4.2.2 解析邏輯

模板解析

Spring 3 中提供了一個非常強大的功能:SpEL炼蛤,SpEL 在 Spring 產(chǎn)品中是作為表達式求值的核心基礎(chǔ)模塊妖爷,它本身是可以脫離 Spring 獨立使用的。舉個例子:

publicstaticvoidmain(String[]?args){

SpelExpressionParser?parser?=newSpelExpressionParser();

Expression?expression?=?parser.parseExpression("#root.purchaseName");

Order?order?=newOrder();

order.setPurchaseName("張三");

System.out.println(expression.getValue(order));

}

這個方法將打印 “張三”理朋。LogRecord 解析的類圖如下:

解析核心類:LogRecordValueParser?里面封裝了自定義函數(shù)和 SpEL 解析類?LogRecordExpressionEvaluator絮识。

publicclassLogRecordExpressionEvaluatorextendsCachedExpressionEvaluator{

privateMap?expressionCache?=newConcurrentHashMap<>(64);

privatefinalMap?targetMethodCache?=newConcurrentHashMap<>(64);

publicStringparseExpression(String?conditionExpression,?AnnotatedElementKey?methodKey,?EvaluationContext?evalContext){

returngetExpression(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()")

publicvoidmodifyAddress(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 對象的繼承體系:

LogRecordEvaluationContext 做了三個事情:

把方法的參數(shù)都放到 SpEL 解析的 RootObject 中何暇。

把 LogRecordContext 中的變量都放到 RootObject 中陶夜。

把方法的返回值和 ErrorMsg 都放到 RootObject 中。

LogRecordEvaluationContext 的代碼如下:

publicclassLogRecordEvaluationContextextendsMethodBasedEvaluationContext{

publicLogRecordEvaluationContext(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&&?variables.size()?>0)?{

for(Map.Entry?entry?:?variables.entrySet())?{

setVariable(entry.getKey(),?entry.getValue());

}

}

//把方法的返回值和?ErrorMsg?都放到?RootObject?中

setVariable("_ret",?ret);

setVariable("_errorMsg",?errorMsg);

}

}

下面是 LogRecordContext 的實現(xiàn)裆站,這個類里面通過一個 ThreadLocal 變量保持了一個棧条辟,棧里面是個 Map黔夭,Map 對應(yīng)了變量的名稱和變量的值。

publicclassLogRecordContext{

privatestaticfinalInheritableThreadLocal>>?variableMapStack?=newInheritableThreadLocal<>();

//其他省略....

}

上面使用了 InheritableThreadLocal羽嫡,所以在線程池的場景下使用 LogRecordContext 會出現(xiàn)問題本姥,如果支持線程池可以使用阿里巴巴開源的 TTL 框架。那這里為什么不直接設(shè)置一個 ThreadLocal<Map<String, Object>> 對象杭棵,而是要設(shè)置一個 Stack 結(jié)構(gòu)呢婚惫?我們看一下這么做的原因是什么。

@LogRecord(content?="修改了訂單的配送員:從“{deveryUser{#oldDeliveryUserId}}”, 修改到“{deveryUser{#request.getUserId()}}”",

bizNo="#request.getDeliveryOrderNo()")

publicvoidmodifyAddress(updateDeliveryRequest?request){

//?查詢出原來的地址是什么

LogRecordContext.putVariable("oldDeliveryUserId",?DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));

//?更新派送信息?電話魂爪,收件人先舷、地址

doUpdate(request);

}

上面代碼的執(zhí)行流程如下:

看起來沒有什么問題,但是使用 LogRecordAnnotation 的方法里面嵌套了另一個使用 LogRecordAnnotation 方法的時候滓侍,流程就變成下面的形式:

可以看到密浑,當(dāng)方法二執(zhí)行了釋放變量后,繼續(xù)執(zhí)行方法一的 logRecord 邏輯粗井,此時解析的時候 ThreadLocal<Map<String, Object>>的 Map 已經(jīng)被釋放掉,所以方法一就獲取不到對應(yīng)的變量了街图。方法一和方法二共用一個變量 Map 還有個問題是:如果方法二設(shè)置了和方法一相同的變量兩個方法的變量就會被相互覆蓋浇衬。所以最終 LogRecordContext 的變量的生命周期需要是下面的形式:

LogRecordContext 每執(zhí)行一個方法都會壓棧一個 Map,方法執(zhí)行完之后會 Pop 掉這個 Map餐济,從而避免變量共享和覆蓋問題耘擂。

默認操作人邏輯

在 LogRecordInterceptor 中 IOperatorGetService 接口,這個接口可以獲取到當(dāng)前的用戶絮姆。下面是接口的定義:

publicinterfaceIOperatorGetService{

/**

*?可以在里面外部的獲取當(dāng)前登陸的用戶醉冤,比如?UserContext.getCurrentUser()

*

*@return轉(zhuǎn)換成Operator返回

*/

OperatorgetUser();

}

下面給出了從用戶上下文中獲取用戶的例子:

publicclassDefaultOperatorGetServiceImplimplementsIOperatorGetService{

@Override

publicOperatorgetUser(){

//UserUtils?是獲取用戶上下文的方法

returnOptional.ofNullable(UserUtils.getUser())

.map(a?->newOperator(a.getName(),?a.getLogin()))

.orElseThrow(()->newIllegalArgumentException("user?is?null"));

}

}

組件在解析 operator 的時候,就判斷注解上的 operator 是否是空篙悯,如果注解上沒有指定蚁阳,我們就從 IOperatorGetService 的 getUser 方法獲取了。如果都獲取不到鸽照,就會報錯螺捐。

String?realOperatorId?="";

if(StringUtils.isEmpty(operatorId))?{

if(operatorGetService.getUser()?==null||?StringUtils.isEmpty(operatorGetService.getUser().getOperatorId()))?{

thrownewIllegalArgumentException("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)容矮燎。

publicinterfaceIParseFunction{

defaultbooleanexecuteBefore(){

returnfalse;

}

StringfunctionName();

Stringapply(String?value);

}

ParseFunctionFactory 的代碼比較簡單定血,它的功能是把所有的 IParseFunction 注入到函數(shù)工廠中。

publicclassParseFunctionFactory{

privateMap?allFunctionMap;

publicParseFunctionFactory(List<IParseFunction>?parseFunctions){

if(CollectionUtils.isEmpty(parseFunctions))?{

return;

}

allFunctionMap?=newHashMap<>();

for(IParseFunction?parseFunction?:?parseFunctions)?{

if(StringUtils.isEmpty(parseFunction.functionName()))?{

continue;

}

allFunctionMap.put(parseFunction.functionName(),?parseFunction);

}

}

publicIParseFunctiongetFunction(String?functionName){

returnallFunctionMap.get(functionName);

}

publicbooleanisBeforeFunction(String?functionName){

returnallFunctionMap.get(functionName)?!=null&&?allFunctionMap.get(functionName).executeBefore();

}

}

DefaultFunctionServiceImpl 的邏輯就是根據(jù)傳入的函數(shù)名稱 functionName 找到對應(yīng)的 IParseFunction诞外,然后把參數(shù)傳入到 IParseFunction 的?apply?方法上最后返回函數(shù)的值澜沟。

publicclassDefaultFunctionServiceImplimplementsIFunctionService{

privatefinalParseFunctionFactory?parseFunctionFactory;

publicDefaultFunctionServiceImpl(ParseFunctionFactory?parseFunctionFactory){

this.parseFunctionFactory?=?parseFunctionFactory;

}

@Override

publicStringapply(String?functionName,?String?value){

IParseFunction?function?=?parseFunctionFactory.getFunction(functionName);

if(function?==null)?{

returnvalue;

}

returnfunction.apply(value);

}

@Override

publicbooleanbeforeFunction(String?functionName){

returnparseFunctionFactory.isBeforeFunction(functionName);

}

}

4.2.3 日志持久化邏輯

同樣在 LogRecordInterceptor 的代碼中引用了 ILogRecordService,這個 Service 主要包含了日志記錄的接口峡谊。

publicinterfaceILogRecordService{

/**

*?保存?log

*

*@paramlogRecord?日志實體

*/

voidrecord(LogRecord?logRecord);

}

業(yè)務(wù)可以實現(xiàn)這個保存接口茫虽,然后把日志保存在任何存儲介質(zhì)上刊苍。這里給了一個 2.2 節(jié)介紹的通過 log.info 保存在日志文件中的例子搜立,業(yè)務(wù)可以把保存設(shè)置成異步或者同步墙杯,可以和業(yè)務(wù)放在一個事務(wù)中保證操作日志和業(yè)務(wù)的一致性,也可以新開辟一個事務(wù)蜘矢,保證日志的錯誤不影響業(yè)務(wù)的事務(wù)悼枢。業(yè)務(wù)可以保存在 Elasticsearch埠忘、數(shù)據(jù)庫或者文件中,用戶可以根據(jù)日志結(jié)構(gòu)和日志的存儲實現(xiàn)相應(yīng)的查詢邏輯馒索。

@Slf4j

publicclassDefaultLogRecordServiceImplimplementsILogRecordService{

@Override

//????@Transactional(propagation?=?Propagation.REQUIRES_NEW)

publicvoidrecord(LogRecord?logRecord){

log.info("【logRecord】log={}",?logRecord);

}

}

4.2.4 Starter 邏輯封裝

上面邏輯代碼已經(jīng)介紹完畢莹妒,那么接下來需要把這些組件組裝起來,然后讓用戶去使用绰上。在使用這個組件的時候只需要在 Springboot 的入口上添加一個注解 @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

{

Stringtenant();

AdviceModemode()defaultAdviceMode.PROXY;

}

LogRecordProxyAutoConfiguration?就是裝配上面組件的核心類了爽哎,代碼如下:

@Configuration

@Slf4j

publicclassLogRecordProxyAutoConfigurationimplementsImportAware{

privateAnnotationAttributes?enableLogRecord;

@Bean

@Role(BeanDefinition.ROLE_INFRASTRUCTURE)

publicLogRecordOperationSourcelogRecordOperationSource(){

returnnewLogRecordOperationSource();

}

@Bean

@ConditionalOnMissingBean(IFunctionService.class)

publicIFunctionServicefunctionService(ParseFunctionFactoryparseFunctionFactory)

{

returnnewDefaultFunctionServiceImpl(parseFunctionFactory);

}

@Bean

publicParseFunctionFactoryparseFunctionFactory(@Autowired?List<IParseFunction>?parseFunctions){

returnnewParseFunctionFactory(parseFunctions);

}

@Bean

@ConditionalOnMissingBean(IParseFunction.class)

publicDefaultParseFunctionparseFunction()

{

returnnewDefaultParseFunction();

}

@Bean

@Role(BeanDefinition.ROLE_INFRASTRUCTURE)

publicBeanFactoryLogRecordAdvisorlogRecordAdvisor(IFunctionService?functionService){

BeanFactoryLogRecordAdvisor?advisor?=

newBeanFactoryLogRecordAdvisor();

advisor.setLogRecordOperationSource(logRecordOperationSource());

advisor.setAdvice(logRecordInterceptor(functionService));

returnadvisor;

}

@Bean

@Role(BeanDefinition.ROLE_INFRASTRUCTURE)

publicLogRecordInterceptorlogRecordInterceptor(IFunctionService?functionService){

LogRecordInterceptor?interceptor?=newLogRecordInterceptor();

interceptor.setLogRecordOperationSource(logRecordOperationSource());

interceptor.setTenant(enableLogRecord.getString("tenant"));

interceptor.setFunctionService(functionService);

returninterceptor;

}

@Bean

@ConditionalOnMissingBean(IOperatorGetService.class)

@Role(BeanDefinition.ROLE_APPLICATION)

publicIOperatorGetServiceoperatorGetService()

{

returnnewDefaultOperatorGetServiceImpl();

}

@Bean

@ConditionalOnMissingBean(ILogRecordService.class)

@Role(BeanDefinition.ROLE_APPLICATION)

publicILogRecordServicerecordService()

{

returnnewDefaultLogRecordServiceImpl();

}

@Override

publicvoidsetImportMetadata(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");

}

}

}

這個類繼承 ImportAware 是為了拿到 EnableLogRecord 上的租戶屬性,這個類使用變量 logRecordAdvisor 和 logRecordInterceptor 裝配了 AOP器一,同時把自定義函數(shù)注入到了 logRecordAdvisor 中课锌。

對外擴展類:分別是IOperatorGetService、ILogRecordService祈秕、IParseFunction渺贤。業(yè)務(wù)可以自己實現(xiàn)相應(yīng)的接口,因為配置了 @ConditionalOnMissingBean请毛,所以用戶的實現(xiàn)類會覆蓋組件內(nèi)的默認實現(xiàn)志鞍。


5. 總結(jié)

這篇文章介紹了操作日志的常見寫法,以及如何讓操作日志的實現(xiàn)更加簡單方仿、易懂述雾,通過組件的四個模塊,介紹了組件的具體實現(xiàn)兼丰。對于上面的組件介紹玻孟,大家如果有疑問,也歡迎在文末留言鳍征,我們會進行答疑黍翎。


6. 作者簡介

站通,2020年加入美團艳丛,基礎(chǔ)研發(fā)平臺/研發(fā)質(zhì)量及效率部工程師匣掸。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末趟紊,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子碰酝,更是在濱河造成了極大的恐慌霎匈,老刑警劉巖,帶你破解...
    沈念sama閱讀 221,273評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件送爸,死亡現(xiàn)場離奇詭異铛嘱,居然都是意外死亡,警方通過查閱死者的電腦和手機袭厂,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,349評論 3 398
  • 文/潘曉璐 我一進店門墨吓,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人纹磺,你說我怎么就攤上這事帖烘。” “怎么了橄杨?”我有些...
    開封第一講書人閱讀 167,709評論 0 360
  • 文/不壞的土叔 我叫張陵秘症,是天一觀的道長。 經(jīng)常有香客問我式矫,道長历极,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 59,520評論 1 296
  • 正文 為了忘掉前任衷佃,我火速辦了婚禮,結(jié)果婚禮上蹄葱,老公的妹妹穿的比我還像新娘氏义。我一直安慰自己,他們只是感情好图云,可當(dāng)我...
    茶點故事閱讀 68,515評論 6 397
  • 文/花漫 我一把揭開白布惯悠。 她就那樣靜靜地躺著,像睡著了一般竣况。 火紅的嫁衣襯著肌膚如雪克婶。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,158評論 1 308
  • 那天丹泉,我揣著相機與錄音情萤,去河邊找鬼。 笑死摹恨,一個胖子當(dāng)著我的面吹牛筋岛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播晒哄,決...
    沈念sama閱讀 40,755評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼睁宰,長吁一口氣:“原來是場噩夢啊……” “哼肪获!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起柒傻,我...
    開封第一講書人閱讀 39,660評論 0 276
  • 序言:老撾萬榮一對情侶失蹤孝赫,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后红符,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體青柄,經(jīng)...
    沈念sama閱讀 46,203評論 1 319
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,287評論 3 340
  • 正文 我和宋清朗相戀三年违孝,在試婚紗的時候發(fā)現(xiàn)自己被綠了刹前。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,427評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡雌桑,死狀恐怖喇喉,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情校坑,我是刑警寧澤拣技,帶...
    沈念sama閱讀 36,122評論 5 349
  • 正文 年R本政府宣布,位于F島的核電站耍目,受9級特大地震影響膏斤,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜邪驮,卻給世界環(huán)境...
    茶點故事閱讀 41,801評論 3 333
  • 文/蒙蒙 一莫辨、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧毅访,春花似錦沮榜、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,272評論 0 23
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至守呜,卻和暖如春型酥,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背查乒。 一陣腳步聲響...
    開封第一講書人閱讀 33,393評論 1 272
  • 我被黑心中介騙來泰國打工弥喉, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人玛迄。 一個月前我還...
    沈念sama閱讀 48,808評論 3 376
  • 正文 我出身青樓档桃,卻偏偏與公主長得像,于是被迫代替她去往敵國和親憔晒。 傳聞我的和親對象是個殘疾皇子藻肄,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,440評論 2 359

推薦閱讀更多精彩內(nèi)容