1. 背景
日志幾乎存在于所有系統(tǒng)中,開(kāi)發(fā)調(diào)試日志的記錄我們有l(wèi)og4j,logback等來(lái)實(shí)現(xiàn),但對(duì)于要展示給用戶看的日志,我并沒(méi)有發(fā)現(xiàn)一個(gè)簡(jiǎn)單通用的實(shí)現(xiàn)方案。所以決定為之后的開(kāi)發(fā)項(xiàng)目提供一個(gè)通用的操作日志組件。
2. 系統(tǒng)日志和操作日志
所有系統(tǒng)都會(huì)有日志,但我們區(qū)分了 系統(tǒng)日志 和 操作日志
- 系統(tǒng)日志:主要用于開(kāi)發(fā)者調(diào)試排查系統(tǒng)問(wèn)題的,不要求固定格式和可讀性
- 操作日志:主要面向用戶的,要求簡(jiǎn)單易懂,反映出用戶所做的動(dòng)作。
通過(guò)操作日志可追溯到 某人在某時(shí)干了某事情,如:
| 租戶 | 操作人 | 時(shí)間 | 操作 | 內(nèi)容 |
|---|---|---|---|---|
| A租戶 | 小明 | 2022/2/27 20:15:00 | 新增 | 新增了一個(gè)用戶:Mr.Wang |
| B租戶 | 大米 | 2022/2/28 10:35:00 | 更新 | 修改訂單 [xxxxxx] 價(jià)格為 xx 元 |
| C租戶 | 老王 | 2022/2/28 22:55:00 | 查詢 | 查詢了名為: [xx] 的所有交易 |
3. 需要哪些功能
3.1 訴求:
- 基于SpringBoot能夠快速接入
- 對(duì)業(yè)務(wù)代碼具有低入侵性
3.2 解決思路:
基于以上兩點(diǎn),我們想想如何實(shí)現(xiàn)。
spingboot快速接入,需要我們來(lái)自定義spring boot starter;
業(yè)務(wù)入侵性低,首先想到了AOP,一般操作日志都是在增刪改查的方法中,所以我們可以使用注解在這些方法上,通過(guò)AOP攔截這些方法。
3.3 待實(shí)現(xiàn):
因此,我們需要實(shí)現(xiàn)以下功能:
- 自定義spring boot starter
- 定義日志注解
- AOP攔截日志注解方法
- 定義日志動(dòng)態(tài)內(nèi)容模板
模板中又需要實(shí)現(xiàn):
- 動(dòng)態(tài)模板表達(dá)式解析:用強(qiáng)大的SpEL來(lái)解析表達(dá)式
- 自定義函數(shù):支持目標(biāo)方法前置/后置的自定義函數(shù)
3.4 展現(xiàn)
所以我們最終期望的大概是這樣:
@EasyLog(module = "用戶模塊", type = "新增",
content = "測(cè)試 {functionName{#userDto.name}}",
condition = "#userDto.name == 'easylog'")
public String test(UserDto userDto) {
return "test";
}
4. 實(shí)現(xiàn)步驟
4.1 定義日志注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface EasyLog {
String tenant() default "";
String operator() default "";
String module() default "";
String type() default "";
String bizNo() default "";
String content();
String fail() default "";
String detail() default "";
String condition() default "";
}
| 字段 | 意義 | 支持SpEl表達(dá)式 | 必填 |
|---|---|---|---|
| tenant | 租戶,SAAS系統(tǒng)中區(qū)分不同租戶 | 是 | 否 |
| operator | 操作者 | 是 | 否 |
| module | 模塊,區(qū)分不同業(yè)務(wù)模塊 | 否 | 否 |
| type | 操作類型,形如:增刪改查 | 否 | 否 |
| bizNo | 業(yè)務(wù)編號(hào),便于查詢 | 是 | 否 |
| content | 日志模板內(nèi)容 | 是 | 是 |
| fail | 操作失敗時(shí)的模板內(nèi)容 | 是 | 否 |
| detail | 額外的記錄信息 | 是 | 否 |
| condition | 是否記錄的條件 (默認(rèn):true 記錄) | 是 | 否 |
4.2 自定義函數(shù)
這里的自定義函數(shù),并不是指SpEL中的自定義函數(shù),因?yàn)镾pEL中的自定義函數(shù)必須是靜態(tài)方法才可以注冊(cè)到其中,因?yàn)殪o態(tài)方法使用中并沒(méi)有我們自己定義方法來(lái)的方便,所以這里的自定義函數(shù)僅僅指代我們定義的一個(gè)普通方法。
public interface ICustomFunction {
/**
* 目標(biāo)方法執(zhí)行前 執(zhí)行自定義函數(shù)
* @return 是否是前置函數(shù)
*/
boolean executeBefore();
/**
* 自定義函數(shù)名
* @return 自定義函數(shù)名
*/
String functionName();
/**
* 自定義函數(shù)
* @param param 參數(shù)
* @return 執(zhí)行結(jié)果
*/
String apply(String param);
}
我們定義好自定義函數(shù)接口,實(shí)現(xiàn)交給使用者。使用者將實(shí)現(xiàn)類交給Spring容器管理,我們解析的時(shí)候從Spring容器中獲取即可。
4.3 SpEL表達(dá)式解析
主要牽涉下面幾個(gè)核心類:
- 解析器ExpressionParser,用于將字符串表達(dá)式轉(zhuǎn)換為Expression表達(dá)式對(duì)象。
- 表達(dá)式Expression,最后通過(guò)它的getValute方法對(duì)表達(dá)式進(jìn)行計(jì)算取值。
- 上下文EvaluationContext,通過(guò)上下文對(duì)象結(jié)合表達(dá)式來(lái)計(jì)算最后的結(jié)果。
ExpressionParser parser =new SpelExpressionParser(); // 創(chuàng)建一個(gè)表達(dá)式解析器
StandardEvaluationContext ex = new StandardEvaluationContext(); // 創(chuàng)建上下文
ex.setVariables("name", "easylog"); // 將自定義參數(shù)添加到上下文
Expression exp = parser.parseExpression("'歡迎你! '+ #name"); //模板解析
String val = exp.getValue(ex,String.class); //獲取值
我們只需要拿到日志注解中的動(dòng)態(tài)模板即可通過(guò)SpEL來(lái)解析。
4.4 自定義函數(shù)的解析
我們采用 { functionName { param }} 的形式在模板中展示自定義函數(shù),解析整個(gè)模板前,我們先來(lái)解析下自定義函數(shù),將解析后的值替換掉模板中的字符串即可。
if (template.contains("{")) {
Matcher matcher = PATTERN.matcher(template);
while (matcher.find()) {
String funcName = matcher.group(1);
String param = matcher.group(2);
if (customFunctionService.executeBefore(funcName)) {
String apply = customFunctionService.apply(funcName, param);
}
}
}
4.5 獲取操作者信息
一般我們都是將登錄者信息存入應(yīng)用上下文中,所以我們不必每次都在日志注解中指出,我們可統(tǒng)一設(shè)置,定義一個(gè)獲取操作者接口,由使用者實(shí)現(xiàn)。
public interface IOperatorService {
// 獲取當(dāng)前操作者
String getOperator();
// 當(dāng)前租戶
String getTenant();
}
4.6 定義日志內(nèi)容接收
我們要將解析完成后的日志內(nèi)容實(shí)體信息發(fā)送給我們的使用者,所以我們需要定義一個(gè)日志接收的接口,具體的實(shí)現(xiàn)交給使用者來(lái)實(shí)現(xiàn),無(wú)論他接收到日志存儲(chǔ)在數(shù)據(jù)庫(kù),MQ還是哪里,讓使用者來(lái)決定。
public interface ILogRecordService {
/**
* 保存 log
* @param easyLogInfo 日志實(shí)體
*/
void record(EasyLogInfo easyLogInfo);
}
4.7 定義AOP攔截
@Aspect
@Component
@AllArgsConstructor
public class EasyLogAspect {
@Pointcut("@annotation(**.EasyLog)")
public void pointCut() {}
// 環(huán)繞通知
@Around("pointCut() && @annotation(easyLog)")
public Object around(ProceedingJoinPoint joinPoint, EasyLog easyLog) throws Throwable {
//前置自定義函數(shù)解析
try {
result = joinPoint.proceed();
} catch (Throwable e) {
}
//SpEL解析
//后置自定義函數(shù)解析
return result;
}
}
4.8 自定義 spring boot starter
創(chuàng)建自動(dòng)配置類,將定義的一些來(lái)交給Spring容器管理:
@Configuration
@ComponentScan("**")
public class EasyLogAutoConfiguration {
@Bean
@ConditionalOnMissingBean(ICustomFunction.class)
@Role(BeanDefinition.ROLE_APPLICATION)
public ICustomFunction customFunction(){
return new DefaultCustomFunction();
}
@Bean
@ConditionalOnMissingBean(IOperatorService.class)
@Role(BeanDefinition.ROLE_APPLICATION)
public IOperatorService operatorGetService() {
return new DefaultOperatorServiceImpl();
}
@Bean
@ConditionalOnMissingBean(ILogRecordService.class)
@Role(BeanDefinition.ROLE_APPLICATION)
public ILogRecordService recordService() {
return new DefaultLogRecordServiceImpl();
}
}
上一篇我已經(jīng)完整的介紹了如何自定義 spring boot starter ,可去參考:
如何自定義 spring boot starter ?
5. 我們可以學(xué)到什么?
你可以拉取easy-log源碼,用于學(xué)習(xí),通過(guò)easy-log你可以學(xué)到:
- 注解的定義及使用
- AOP的應(yīng)用
- SpEL表達(dá)式的解析
- 自定義 Spring boot starter
- 設(shè)計(jì)模式