小白也能看得懂!日志審計插件從入門到實戰
1. 前言
1.1. 背景
測試同學火急火燎說系統出問題了,一點一個不吱聲??墒俏疫@明明操作得像德芙一樣絲滑,究竟是誰想要謀害朕?業務說數據對不上,數據被誰給操作了?又是什么時候操作的?產品同學反應某個頁面會發生“隨機性”卡頓,是哪一臺有“問題”的服務器響應了請求?研發同學向你求助,能不能找到某個特殊參數相關聯的請求信息,幫他找出異常所在?你是否也碰到過上面的問題,讓你抓耳撓腮,寢食難安。 那么,是時候需要一款日志插件,來幫你解決上述的所有問題。
1.2. 概覽
乾數據系統作為轉轉廣告投放的基礎服務,雖然系統并發量不像 C 端系統動輒數十上百萬,但是每一次業務操作背后,都影響著廣告投放的穩定性以及資金結算的準確性。 因此,我們基于AOP切面技術,開發了一款日志審計插件,用于乾數據系統的操作審計以及研發人員的異常排查工作,業務項目通過引入插件對應的 Maven-GAV 坐標,即可自動集成插件。并且插件通過集成消息隊列,還可支持一些特殊的實時分析功能。以下是日志插件的基礎架構圖。
圖片
2. 實現
2.1. “好東西”
2.1.1. git-commit-id-maven-plugin 插件
為了在開發過程中,特別是和研發小伙伴聯合調試的過程中,更好的定位到問題所在,避免插件使用版本不一致帶來的各種問題,可以使用git-commit-id-maven-plugin插件。git-commit-id-maven-plugin 是一個 Maven 插件,在 Maven 構建過程中,插件會生成一個名為 git-commit-id.properties 的文件。這個文件通常包含有關當前構建的 Git 提交哈希、分支名稱、提交時間等信息。
<groupId>io.github.git-commit-id</groupId>
<artifactId>git-commit-id-maven-plugin</artifactId>
<version>${git-commit-id-maven-plugin.version}</version>
<executions>
<execution>
<id>get-the-git-infos</id>
<goals>
<goal>revision</goal>
</goals>
</execution>
</executions>
<configuration>
<validationProperties>
<!-- verify that the current repository is not dirty -->
<validationProperty>
<name>validating git dirty</name>
<value>${git.dirty}</value>
<shouldMatchTo>false</shouldMatchTo>
</validationProperty>
</validationProperties>
<generateGitPropertiesFile>true</generateGitPropertiesFile>
<generateGitPropertiesFilename>${project.build.outputDirectory}/META-INF/scm/${project.groupId}/${project.artifactId}/git.properties</generateGitPropertiesFilename>
</configuration>
git信息文件
private static final Properties GIT_PROPERTIES;
static {
try {
GIT_PROPERTIES = new Properties();
//讀取插件生成的GIT信息文件
GIT_PROPERTIES.load(ResourceUtil.getResourceObj("META-INF/scm/com.bj58.zhuanzhuan/qianshuju_log_plugin/git.properties").getStream());
} catch (IOException e) {
throw new RuntimeException(e);
}
}
2.1.2. hibernate-validator
對于自定義配置,用戶可能有意或無意會輸入一些奇奇怪怪的東西,輕則導致項目無法啟動,重則產生不可估量的影響。因此,對于屬性的校驗,可以引入hibernate-validator框架,然后利用@Validated 配套的校驗注解,自定義校驗規則。
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
</dependency>
/**
* 日志-線程池核心數大小(默認CPU*2)
* 對于IO密集型,可設置線程數為 cpu核心數*2,并根據情況可適當增加。
*
*/
@Min(value = 1, message = "線程池核心數大小不能小于0!")
private int corePoolSize = Runtime.getRuntime().availableProcessors() * 2 + 1;
/**
* 最大線程池數量
*/
@Min(value = 1, message = "線程池最大數大小不能小于0!")
private int maxPoolSize=128;
2.1.3. spring-boot-configuration-processor 插件
如果想給用戶更好的使用體驗,可以引入spring-boot-configuration-processor插件,該插件在打包的時候,會生成 target/classes/META-INF/spring-configuration-metadata.json文件,該文件被 IDEA 讀取到后,在用戶配置屬性的時候,會有自動提示的效果。下面為摘取的/spring-configuration-metadata.json 文件中的部分內容。
[{
"name": "qianshuju.logplugin.core-pool-size",
"type": "java.lang.Integer",
"description": "日志-線程池核心數大?。JCPU*2) 對于IO密集型,可設置線程數為 cpu核心數*2,并根據情況可適當增加。 @see <a href=\"https:\/\/dashen.zhuanspirit.com\/x\/YYVxCQ\">轉轉大神-線程池的使用<\/a>",
"sourceType": "com.bj58.zhuanzhuan.qianshuju.logPlugin.config.LogPluginProperties",
"defaultValue": 0
},
{
"name": "qianshuju.logplugin.max-pool-size",
"type": "java.lang.Integer",
"description": "最大線程池數量",
"sourceType": "com.bj58.zhuanzhuan.qianshuju.logPlugin.config.LogPluginProperties",
"defaultValue": 128
}]
圖片
2.1.4. maven-source-plugin 插件
如果想讓用戶 import 插件包后,能看到源碼,最簡單的方法就是讓用戶利用 IDEA 的反編譯功能,反編譯出代碼,但是會丟失很多的注釋信息,因此我們可以使用maven-source-plugin插件,順帶打出一個源碼包,即在 pom.xml 中加上如下配置:
<plugin>
<!-- https://mvnrepository.com/artifact/org.apache.maven.plugins/maven-source-plugin -->
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<version>3.2.1</version>
<executions>
<execution>
<goals>
<goal>
jar-no-fork
</goal>
</goals>
</execution>
</executions>
</plugin>
2.1.5. 依賴版本問題
Springboot 中有很多版本沖突問題,有些高版本的依賴包改動很大,刪代碼,改方法,比比皆是,因此不向下兼容,對使用者來說,最好使用統一的包版本管理,在 pom 中加入如下配置:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>2.7.18</version>
<scope>import</scope>
<type>pom</type>
</dependency>
</dependencies>
</dependencyManagement>
如果仍然出現版本沖突問題,建議 IDEA 下載Maven Helper插件后,查閱對應版本 spring 官方文檔,以下就是從springboot 2.7.18文檔中,摘錄的部分對其他依賴要求的最低版本:
圖片
2.2. 底層工具
因篇幅有限,本篇文章不會細致到每一個代碼細節,而是挑取核心的重點模塊和易錯模塊進行闡述。因此針對本文中涉及到的一些基礎工具類實現,將不再贅述,僅僅提供實現思路。
代碼涉及的工具類 | 作用 | 建議 |
WebUtil | 獲取當前 servlet 請求,解析請求參數,請求 header 等 | 參考 org.springframework.web.util.WebUtils |
UrlUtil | 獲取 Url 中的 path 路徑 | 參考 org.springframework.web.util.UriUtils |
LogPluginGitUtil | 代碼 git 版本工具 | 參考 git-commit-id-maven-plugin 插件 |
LogPluginSpringUtils | Spring 容器工具 | 參考 org.springframework.beans.factory.config.BeanFactoryPostProcessor |
LogPluginNetUtil | 獲取服務網絡狀態 | 參考 org.springframework.boot.web.context.WebServerInitializedEvent |
SicUtil | 轉轉信息管理平臺工具類,用于獲取工程相關信息 | 內部框架,暫無參考 |
CommonConstant | 配置常量,用于插件部分默認配置 | 例如默認線程池名稱 |
2.3. 整體概覽
如果你問我,本日志插件最核心的地方是什么?我認為不是 AOP 切面,也不是線程池,而應該是自動配置模塊,即 spring.factories 中配置的EnableAutoConfiguration屬性值,因為它涵蓋了整個日志插件被容器管理的各個相關 Bean,每個 Bean 都各司其職,“堅守”著自己的崗位,完成著部分功能。因此,要想對本插件有一個整體的認知,我覺得有必要好好講一講 AutoConfiguration 所涉及到的那些組件們。
圖片
組件 | 作用 |
LogPluginProperties | 用戶自定義配置所映射的實體類,基于本類的配置來進行后續其他組件的“加工” |
ICreatedByService | 租戶服務,用戶獲取當前請求對應的“租戶”標識,即操作者用戶標識 |
Server | 用于獲取當前“宿主”服務的狀態信息,例如 IP,環境信息等 |
ThreadPoolTaskExecutor | 插件核心線程池,用于執行日志數據傳輸任務 |
IlogPersistenceService | 日志數據持久層服務,用于將數據傳遞至下游存儲引擎 |
IDataStreamService | 消息隊列服務,用于將數據投遞至下游 MQ 進行數據分析,本插件目前僅支持轉轉架構部自研組件 |
LogRelayTask | 封裝的日志任務,提交給線程池執行 |
2.4. 具體實現
2.4.1. 插件屬性配置類:LogPluginProperties
為了實現讓用戶能夠根據自身環境,自定義做一些配置,我們抽取了LogPluginProperties類來作為用戶的統一配置類入口,該配置類中包含了插件線程池,Stream 流配置等。最后通過利用afterPropertiesSet()鉤子,可以對部分設置進行缺省配置,以及執行部分依賴檢查工作。
LogPluginProperties依賴
提示:DataStreamType.ZZ_MQ 中的ZZMQ是基于早期的RocketMQ,加入了許多轉轉自己的特性,獨立于社區版本,由架構團隊負責維護、開發與運維的消息中間件。當前因為篇幅有限,只展示 ZZMQ 的配置樣例,如需使用 Kafka 或 RabbitMQ,可自行改造。
@Data
@EqualsAndHashCode
@ToString
@ConfigurationProperties("qianshuju.logplugin")
@Validated
@Slf4j
public class LogPluginProperties implements InitializingBean {
/** 數據流類型 */
private String dataStreamType = DataStreamType.ZZ_MQ;
/** 啟用流 */
private Boolean enableStream = false;
/** zzmq屬性 */
private ZZMQProperties zzmqProperties;
/**
* 宿主項目名稱(即當前項目名,用于區分日志 )
*/
private String renter;
@Override
public void afterPropertiesSet() throws Exception {
if (StrUtil.isBlank(renter)) {
//用戶未主動配置項目名稱,降級為使用SIC封裝的應用名
renter = SicUtil.getCurrentSicInfo().getAppName();
}
checkEnv(); //檢查環境變量
}
@Data
@EqualsAndHashCode
@ToString
public static class ZZMQProperties {
/** zzmq-topic */
private String topic = "qianshuju-log";
/** zzmq-tag */
private String tag = "";
/** zzmq.producer.group的名稱 必填*/
private String producerName = "";
}
private void checkEnv() {
if (enableStream) {
if (ObjectUtil.equal(dataStreamType, DataStreamType.ZZ_MQ)) {
try {
Class.forName("com.alibaba.rocketmq.client.producer.DefaultMQProducer");
} catch (ClassNotFoundException e) {
log.error("checkEnv fail: ", e);
throw new RuntimeException("The streaming service has been enabled and the configuration item is ZZMQ, but the corresponding dependency is missing!");
}
}
}
}
}
2.4.2. 定義統一的日志信息實體 PluginLogDto
因篇幅有限,僅展示部分關鍵字段
@Data
@EqualsAndHashCode
@ToString
public class PluginLogDto implements Serializable {
/**
* 日志標題
*/
private String title;
/**
* 服務器地址
*/
private String serverIp;
/**
* 服務器名字
*/
private String serverName;
/**
* 客戶端地址
*/
private String clientIp;
/**
* 請求地址
*/
private String requestUri;
/**
* 請求參數
*/
private String requestParam;
/**
* 方法名
*/
private String methodName;
}
2.4.3. 日志持久服務 IlogPersistenceService
為了調試方便,我們配置了一個默認的日志持久化服務,直接把日志信息打印到控制臺上。當然,用戶可以實現自己的持久化服務,例如存儲到 ES 當中,方便后續的檢索。
/**控制臺日志默認持久化實現,僅供本地簡單調試使用,請勿直接用于生產環境
* @author liuyangjun@zhuanzhuan.com
* * @date 2024/3/28
*/
public class DefaultLogPersistenceServiceImpl implements IlogPersistenceService {
@Override
public void saveApiLog(PluginLogDto pluginLogDto) {
System.out.println(JSON.toJSONString(pluginLogDto));
}
@Override
public void saveErrorLog(PluginLogDto pluginLogDto) {
System.out.println(JSON.toJSONString(pluginLogDto));
}
}
2.4.4. 租戶配置 ICreatedByService
插件使用者可以實現自己的ICreatedByService實現類,來提供給插件獲取當前操作用戶的標識,例如我們可以從當前“安全上下文”中獲取當登錄用戶信息。
@Component
public class DefaultCreatedByServiceImpl implements ICreatedByService {
@Override
public String getCreatedBy() {
return Optional.ofNullable(UserContext.getLoginUserInfo()).map(UserLoginInfo::getRealName).orElse("null");
}
}
2.4.5. 日志數據流服務 IDataStreamService
通過日志數據流服務,將日志數據推送至消息隊列中,下游的實時分析服務可以做一些分析服務。利用 Springboot 的@ConditionalOn這一套組件,來完成對應消息服務組件的自動配置。
當前僅支持ZZMQ組件的自動配置,也可改造成支持Kafka或者RocketMQ。
@Service
@Slf4j
@ConditionalOnProperty(name = "qianshuju.logplugin.dataStreamType", havingValue = "zzmq")
@ConditionalOnClass(DefaultMQProducer.class)
public class ZZMQDataStreamServiceImpl implements IDataStreamService {
@Autowired
private LogPluginProperties logPluginProperties;
private DefaultMQProducer defaultMqProducer;
@Override
public boolean sendToStream(PluginLogDto pluginLogDto) {
ZZMQProperties zzmqProperties = logPluginProperties.getZzmqProperties();
Message message = new Message(zzmqProperties.getTopic(), zzmqProperties.getTag(), JsonUtil.silentObject2String(pluginLogDto).getBytes());
try {
SendResult send = defaultMqProducer.send(message);
return ObjectUtil.equal(send.getSendStatus(), SendStatus.SEND_OK);
} catch (MQClientException | RemotingException | MQBrokerException | InterruptedException e) {
log.error("sendToStream fail: ", e);
return false;
}
}
@PostConstruct
public void init() {
String producerName = logPluginProperties.getZzmqProperties().getProducerName();
this.defaultMqProducer = SpringUtil.getBean(producerName, DefaultMQProducer.class);
}
}
通過如下簡單的配置,就能夠“激活”我們的日志數據流服務了。
key | value | remark |
qianshuju.logplugin.dataStreamType | ZZMQ/kafka/RabbitMQ | 啟動的流式組件,當前僅支持 ZZMQ |
qianshuju.logplugin.enableStream | true/false | 是否開啟流式服務 |
2.4.6. 線程池服務
為了不影響“業務”性能,我們將日志數據的分發邏輯,放到了線程池中去執行。在此,有兩種推薦的線程池,一種是帶監控功能的線程池,例如轉轉架構部提供的MonitoredThreadPoolExecutor,能夠監控到日志線程池中的狀態。
圖片
當然,如果你手頭上沒有這樣的“武器”,那么你也可以使用 Spring 提供的ThreadPoolTaskExecutor線程池,該線程池繼承自 Spring-ExecutorConfigurationSupport,實現了 destroy in interface DisposableBean接口,能夠保證服務停止的時候,解決任務丟失的問題。
@Bean(name = CommonConstant.LOG_PLUGIN_EXECUTOR, autowireCandidate = false)
public ThreadPoolTaskExecutor logPluginExecutor() {
ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
threadPoolTaskExecutor.setCorePoolSize(logPluginProperties.getCorePoolSize());
threadPoolTaskExecutor.setMaxPoolSize(logPluginProperties.getMaxPoolSize());
threadPoolTaskExecutor.setKeepAliveSeconds(60);
threadPoolTaskExecutor.setQueueCapacity(logPluginProperties.getQueueCapacity());
threadPoolTaskExecutor.setAllowCoreThreadTimeOut(false);
BasicThreadFactory threadFactory = new BasicThreadFactory.Builder()
.namingPattern(logPluginProperties.getThreadNamingPattern())
.daemon(false)
.uncaughtExceptionHandler((t, e) -> {
log.warn("日志線程執行任務失敗", e);
})
.build();
threadPoolTaskExecutor.setThreadFactory(threadFactory);
threadPoolTaskExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
//注意,此處有大坑,如果設置了setWaitForTasksToCompleteOnShutdown為true,即容器需要等待線程池停止,
// 則必須設置setAwaitTerminationSeconds具體的秒數!否則setWaitForTasksToCompleteOnShutdown將不生效!
threadPoolTaskExecutor.setWaitForTasksToCompleteOnShutdown(true);
// private long awaitTerminationMillis = 0;
// 默認值為0,如果自己不設置,相當于setWaitForTasksToCompleteOnShutdown=true白設置了
threadPoolTaskExecutor.setAwaitTerminationSeconds(CommonConstant.LOGPLUGIN_EXECUTOR_AWAIT_TERMINATION_SECONDS);
threadPoolTaskExecutor.setThreadPriority(Thread.MIN_PRIORITY);
threadPoolTaskExecutor.setDaemon(false);
return threadPoolTaskExecutor;
}
2.4.7. 最后的最后:切面!
終于,我們已經了解了所有的組件以及它們對應的“職責”,那么,切面就是最后將把他們組合起來,實現最終日志邏輯的大 Boss。通過注解切點,我們把相關的切面邏輯織入進去。獲取到注解標注的部分信息,再結合請求參數,方法信息,報錯信息,拼裝成我們最后的日志數據。
Aspect結構圖
@Aspect
@Component
@Slf4j
public class LogApiLogAspect {
@Autowired
private Server server;
@Autowired
private ICreatedByService iCreatedByService;
@Autowired
private LogPluginProperties logPluginProperties;
@Resource(name = CommonConstant.LOG_PLUGIN_EXECUTOR)
private ThreadPoolTaskExecutor executor;
@Autowired
private IlogPersistenceService ilogPersistenceService;
@Autowired(required = false)
private IDataStreamService dataStreamService;
/**
* 配置織入點
**/
@Pointcut("@annotation(com.bj58.zhuanzhuan.qianshuju.logPlugin.annotation.ApiLog)")
public void logPointCut() {
}
@AfterReturning("@annotation(apiLog)")
public void doAround(JoinPoint point, ApiLog apiLog) {
handleUsualLog(point);
}
@AfterThrowing(value = "logPointCut()", throwing = "exception")
public void afterThrowing(JoinPoint joinPoint, Exception exception) {
handleExceptionLog(joinPoint, exception);
}
/**
* 處理正常日志
*/
protected void handleUsualLog(JoinPoint point) {
ApiLog apiLog = null;
try {
apiLog = getAnnotationLog(point);
} catch (Exception ex) {
log.warn("updateMediumWorkerOrderInfo", ex);
throw ex;
}
if (ObjectUtil.isNull(apiLog)) {
return;
}
String className = point.getTarget().getClass().getSimpleName();
String methodName = point.getSignature().getName();
String params = getRequestValue(point);
try {
HttpServletRequest request = WebUtil.getRequest();
LogApi logApi = new LogApi();
logApi.setTitle(apiLog.value());
logApi.setClazzName(className);
logApi.setMethodName(methodName);
logApi.setRequestParam(params);
logApi.setCreateBy(iCreatedByService.getCreatedBy());
logApi.setRequestType(request.getMethod());
logApi.setServerIp(this.server.getIp() + ":" + LogPluginNetUtil.getPort());
logApi.setClientIp(IpUtil.getIpAddr(request));
logApi.setRequestUri(UrlUtil.getPath(request.getRequestURI()));
logApi.setEnv(LogPluginNetUtil.getEnv());
logApi.setServerName(logPluginProperties.getRenter());
executor.execute(new LogRelayTask(logApi, ilogPersistenceService, dataStreamService));
} catch (Throwable throwable) {
log.warn("處理正常日志發生異常", throwable);
}
}
/**
* 是否存在注解,如果存在就獲取
*/
private @Nullable ApiLog getAnnotationLog(JoinPoint joinPoint) {
Signature signature = joinPoint.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method method = methodSignature.getMethod();
if (method != null) {
return method.getAnnotation(ApiLog.class);
}
return null;
}
}
2.5. 結果呈現
最終,我們就實現了如下圖的效果,通過檢索引擎,能夠快速根據指定參數找到對應的接口,進而找到相關聯的時間,服務器地址,參數,創建人等信息,結合這些信息,極大提升了我們排查問題的效率。
3. 寫在最后
3.1. 未來思考
當前,我們已經實現了一個簡單的日志審計插件,然而,要想把插件做得更加完善,道阻且長,我們還有很多地方需要思考。
- 當前注解 ApiLog 的 value 值,即業務操作名稱為寫死的字面值,是否可通過SpringEl表達式,配合方法參數,動態生成業務操作名稱?
- 當前注解只能標注在 Controller 上,是否可以做成標注在 service 方法上,甚至任意方法上,即實現類似事務嵌套機制一樣的日志注解嵌套?利用棧是否可以實現?
- 當前插件可兼容的 JDK21 的 Springboot 版本為 2.7.18~3 之間,當 springboot 升級到 3.X 之后,SpringBoot3.x 移除spring.factories,只支持使用 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 增加自動配置,此時插件該如何兼容?是否需要把工程結構再細拆分下去?
- 轉轉架構部提供了很好的鏈路追蹤工具:天網,是否可以集成天網鏈路追蹤,關聯TraceId,不僅可以通過天網可視化查詢服務之間的調用鏈路,還可以利用 TraceId 查出鏈路相關聯的日志信息。
3.2. 總結
本文介紹了一款基于 AOP 切面技術的日志審計插件,旨在解決系統操作審計和異常排查的問題。插件能夠自動集成并支持實時分析功能。文章首先闡述了插件的背景和重要性,接著詳細介紹了插件的實現,包括多個有用的 Maven 插件和框架,如git-commit-id-maven-plugin、hibernate-validator和spring-boot-configuration-processor,以提升開發效率和用戶體驗。
插件的核心在于自動配置模塊,涵蓋了多個組件的協作,如日志信息實體、日志持久化服務和數據流服務。通過線程池處理日志數據,確保不影響業務性能。最后,文章展示了切面邏輯的實現,結合請求參數和方法信息,生成最終的日志數據。
整體而言,本文不僅提供了日志插件的實現細節,還分享了在工程中使用的“好東西”,為開發者在日志管理和異常排查方面提供了實用的解決方案。
3.3. 參考文檔
- [美團技術團隊-如何優雅地記錄操作日志?]https://tech.meituan.com/2021/09/16/operational-logbook.html
- [動態代理—攔截器—責任鏈—AOP 面向切面編程底層原理]https://liuyangjun.blog.csdn.net/article/details/83277344
- [Spring2.7.18 官方文檔]https://docs.spring.io/spring-boot/docs/2.7.18/reference/pdf/spring-boot-reference.pdf
關于作者
劉揚俊,Java 后端開發工程師,CSDN 百萬訪問量博主,目前負責轉轉廣告投放相關業務。