SpringBoot日志配置全過(guò)程
SpringBoot日志配置
如果使用Spring Boot Starters,那么默認(rèn)使用的日志框架是Logback。Spring Boot底層對(duì)Java Util Logging、Commons Logging、Log4J及SLF4J日志框架也進(jìn)行了適配,只需相關(guān)配置就可以實(shí)現(xiàn)日志框架的相互切換。
SpringBoot默認(rèn)日志事打印在console控制臺(tái)中,不會(huì)保存到文件中。
實(shí)際項(xiàng)目中必須保存到文件中進(jìn)行日志分析
根據(jù)不同的日志系統(tǒng),可以按如下規(guī)則組織配置文件名,就能被正確加載:
- Spring Boot官方推薦優(yōu)先使用帶有-spring的文件名作為定義的日志配置(使用logback-spring.xml而不是logback.xml名稱(chēng))
- 若命名為logback-spring.xml的日志配置文件,Spring Boot可以為它添加一些Spring Boot特有的配置項(xiàng)
- 建議盡可能不使用Java Util Logging方式,因?yàn)镴ava Util Logging從可執(zhí)行jar運(yùn)行時(shí)會(huì)導(dǎo)致一些已知的類(lèi)加載問(wèn)題
自定義日志配置:
- 通過(guò)將相應(yīng)的庫(kù)添加到classpath可以激活各種日志系統(tǒng)
- 在classpath根目錄下提供合適的配置文件可以進(jìn)一步定制日志系統(tǒng)
- 配置文件也可以通過(guò)Spring Environment的logging.config屬性指定
日志分級(jí):(TRACE < DEBUG < INFO< WARN < ERROR < FATAL)從低到高
- TRACE,最低級(jí)別的日志記錄,用于輸出最詳細(xì)的調(diào)試信息,通常用于開(kāi)發(fā)調(diào)試目的。在生產(chǎn)環(huán)境中,應(yīng)該關(guān)閉 TRACE 級(jí)別的日志記錄,以避免輸出過(guò)多無(wú)用信息
- DEBUG,是用于輸出程序中的一些調(diào)試信息,通常用于開(kāi)發(fā)過(guò)程中。像 TRACE 一樣,在生產(chǎn)環(huán)境中應(yīng)該關(guān)閉 DEBUG 級(jí)別的日志記錄。
- INFO,用于輸出程序正常運(yùn)行時(shí)的一些關(guān)鍵信息,比如程序的啟動(dòng)、運(yùn)行日志等。通常在生產(chǎn)環(huán)境中開(kāi)啟 INFO 級(jí)別的日志記錄。
- WARN,是用于輸出一些警告信息,提示程序可能會(huì)出現(xiàn)一些異常或者錯(cuò)誤。在應(yīng)用程序中,WARN 級(jí)別的日志記錄通常用于記錄一些非致命性異常信息,以便能夠及時(shí)發(fā)現(xiàn)并處理這些問(wèn)題。
- ERROR,是用于輸出程序運(yùn)行時(shí)的一些錯(cuò)誤信息,通常表示程序出現(xiàn)了一些不可預(yù)料的錯(cuò)誤。在應(yīng)用程序中,ERROR 級(jí)別的日志記錄通常用于記錄一些致命性的異常信息,以便能夠及時(shí)發(fā)現(xiàn)并處理這些問(wèn)題。
Logback日志不提供FATAL級(jí)別,它被映射到ERROR級(jí)別。Spring Boot只會(huì)輸出比當(dāng)前級(jí)別高的日志,默認(rèn)的日志級(jí)別是INFO,因此低于INFO級(jí)別的日志記錄都不輸出
Spring Boot中默認(rèn)配置ERROR、WARN和INFO級(jí)別的日志輸出到控制臺(tái)。
通過(guò)啟動(dòng)您的應(yīng)用程序—debug標(biāo)志來(lái)啟用“調(diào)試”模式(開(kāi)發(fā)時(shí)推薦開(kāi)啟),以下兩種方式皆可:
- 在運(yùn)行命令后加入–debug標(biāo)志,例如:java -jar springTest.jar --debug
- 在application.properties中配置debug=true,該屬性置為true的時(shí)候,核心Logger(包含嵌入式容器、hibernate、spring)會(huì)輸出更多內(nèi)容,但是你自己應(yīng)用的日志并不會(huì)輸出為DEBUG級(jí)別。
除了這五種級(jí)別以外,還有一些日志框架定義了其他級(jí)別,例如 Python 中的 CRITICAL、PHP 中的 FATAL 等。CRITICAL 和 FATAL 都是用于表示程序出現(xiàn)了致命性錯(cuò)誤或者異常,即不可恢復(fù)的錯(cuò)誤。
使用xml配置日志保存
(并不需要pom配置slf4j依賴(lài),使用這個(gè)默認(rèn)不用配置pom依賴(lài),最新的spring-boot-starter-web中已經(jīng)集成了)
啟動(dòng)一個(gè)項(xiàng)目,直接將logback-spring.xml文件復(fù)制到resources目錄下就可以實(shí)現(xiàn)日志文件記錄。
步驟如下:
- 在項(xiàng)目resources目錄下創(chuàng)建一個(gè)logback-spring.xml日志配置文件
名稱(chēng)只要是logback開(kāi)頭
備注:要配置logback-spring.xml,springboot會(huì)默認(rèn)加載此文件,為什么不配置logback.xml,因?yàn)閘ogback.xml會(huì)先application.properties加載,而logback-spring.xml會(huì)后于application.properties加載,這樣我們?cè)赼pplication.properties文中設(shè)置日志文件名稱(chēng)和文件路徑才能生效。
- 內(nèi)容如下
Spring Boot 默認(rèn)日志輸出如下:
上述輸出的日志信息,從左往右含義解釋如下:
- 日期時(shí)間:精確到毫秒
- 日志級(jí)別:ERROR,WARN,INFO,DEBUG or TRACE
- 進(jìn)程:id
- 分割符:用于區(qū)分實(shí)際的日志記錄
- 線程名:括在方括號(hào)中
- 日志名字:通常是源類(lèi)名
- 日志信息說(shuō)明
依賴(lài):
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-logging</artifactId> </dependency>
<?xml version="1.0" encoding="UTF-8"?> <configuration scan="true" scanPeriod="60 seconds" debug="false"> <contextName>logback</contextName> <!--輸出到控制臺(tái)--> <appender name="console" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <!--<pattern>%d{HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>--> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n</pattern> <!-- <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} -%5p ${PID:-} [%15.15t] %-30.30C{1.} : %m%n</pattern>--> </encoder> </appender> <!--按天生成日志--> <appender name="logFile" class="ch.qos.logback.core.rolling.RollingFileAppender"> <Prudent>true</Prudent> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <FileNamePattern> poslog/%d{yyyy-MM-dd}/%d{yyyy-MM-dd}.log </FileNamePattern> <maxHistory>7</maxHistory> </rollingPolicy> <layout class="ch.qos.logback.classic.PatternLayout"> <Pattern> %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n </Pattern> </layout> </appender> <root level="INFO"> <appender-ref ref="console" /> <appender-ref ref="logFile" /> </root> </configuration>
- 編寫(xiě)打印日志
@SpringBootTest public class LoggerTest { private static final Logger logger = LoggerFactory.getLogger(LoggerTest.class); @Test public void test() { logger.trace("trace 級(jí)別的日志"); logger.debug("debug 級(jí)別的日志"); logger.info("info 級(jí)別的日志"); logger.warn("warn 級(jí)別的日志"); logger.error("error 級(jí)別的日志"); } }
- 啟動(dòng)測(cè)試
在當(dāng)前文件夾下會(huì)創(chuàng)建一個(gè)【poslog/2020-10/22】的文件夾,里面會(huì)按天生成日志:【2020-10-22.log】,例如:
控制臺(tái)輸出:
分類(lèi)logback.xml配置
需在application.properties中設(shè)置logging.file.name或logging.file.path屬性
1)logging.file.name,設(shè)置文件,可以是絕對(duì)路徑,也可以是相對(duì)路徑。例如:
logging.file.name=info.log
2)logging.file.path,設(shè)置目錄,會(huì)在該目錄下創(chuàng)建spring.log文件,并寫(xiě)入日志內(nèi)容,例如:
logging.file.path=/workspace/log
如果只配置logging.file.name,會(huì)在項(xiàng)目的當(dāng)前路徑下生成一個(gè)xxx.log日志文件。如果只配置logging.file.path,在/workspace/log文件夾生成一個(gè)為spring.log日志文件。
二者不能同時(shí)使用,如若同時(shí)使用,則只有l(wèi)ogging.file.name生效。默認(rèn)情況下,日志文件的大小達(dá)到10MB時(shí)會(huì)切分一次,產(chǎn)生新的日志文件,默認(rèn)級(jí)別為:ERROR、WARN、INFO。
所有支持的日志記錄系統(tǒng)都可以在Spring環(huán)境中設(shè)置記錄級(jí)別,格式為:“logging.level.* = LEVEL”。
雖然Spring Boot中application.properties配置文件提供了日志的配置,但是個(gè)人更傾向于logback.xml的配置方式。
日志配置到d盤(pán)了:
根節(jié)點(diǎn)包含的屬性
- scan:當(dāng)此屬性設(shè)置為true時(shí),配置文件如果發(fā)生改變,將會(huì)被重新加載,默認(rèn)值為true
- scanPeriod:設(shè)置監(jiān)測(cè)配置文件是否有修改的時(shí)間間隔,如果沒(méi)有給出時(shí)間單位,默認(rèn)單位是毫秒。當(dāng)scan為true時(shí),此屬性生效。默認(rèn)的時(shí)間間隔為1分鐘
- debug:當(dāng)此屬性設(shè)置為true時(shí),將打印出logback內(nèi)部日志信息,實(shí)時(shí)查看logback運(yùn)行狀態(tài)。默認(rèn)值為false
子節(jié)點(diǎn)
- root節(jié)點(diǎn)是必選節(jié)點(diǎn),用來(lái)指定最基礎(chǔ)的日志輸出級(jí)別,只有一個(gè)level屬性。
- level:用來(lái)設(shè)置打印級(jí)別,大小寫(xiě)無(wú)關(guān),其值包含如下:TRACE、DEBUG、INFO、WARN、ERROR、ALL和OFF
- level不能設(shè)置為INHERITED或者同義詞NULL,默認(rèn)是DEBUG。
- root節(jié)點(diǎn)中可以包含零個(gè)或多個(gè)元素,標(biāo)識(shí)這個(gè)appender將會(huì)添加到這個(gè)loger
子節(jié)點(diǎn)設(shè)置上下文名稱(chēng)
每個(gè)logger都關(guān)聯(lián)到logger上下文,默認(rèn)上下文名稱(chēng)為“default”。但可以使用設(shè)置成其他名字,用于區(qū)分不同應(yīng)用程序的記錄。
設(shè)置后不能修改,通過(guò)%contextName設(shè)置來(lái)打印日志上下文名稱(chēng),一般來(lái)說(shuō)不用這個(gè)屬性
子節(jié)點(diǎn)
appender用來(lái)格式化日志輸出節(jié)點(diǎn),有兩個(gè)屬性name和class,class用來(lái)指定哪種輸出策略,常用就是控制臺(tái)輸出策略和文件輸出策略。
<?xml version="1.0" encoding="UTF-8"?> <configuration> <!-- 日志存放路徑 --> <property name="log.path" value="d:/logback" /> <!-- 日志輸出格式 --> <property name="log.pattern" value="%d{HH:mm:ss.SSS} [%thread] %-5level %logger{20} - [%method,%line] - %msg%n" /> <!-- 控制臺(tái)輸出 --> <appender name="console" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>${log.pattern}</pattern> </encoder> </appender> <!-- 系統(tǒng)日志輸出 --> <appender name="file_info" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${log.path}/sys-info.log</file> <!-- 循環(huán)政策:基于時(shí)間創(chuàng)建日志文件 --> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <!-- 日志文件名格式 --> <fileNamePattern>${log.path}/sys-info.%d{yyyy-MM-dd}.log</fileNamePattern> <!-- 日志最大的歷史 60天 --> <maxHistory>60</maxHistory> </rollingPolicy> <encoder> <pattern>${log.pattern}</pattern> </encoder> <filter class="ch.qos.logback.classic.filter.LevelFilter"> <!-- 過(guò)濾的級(jí)別 只會(huì)打印debug不會(huì)有info日志--> <!-- <level>DEBUG</level>--> <!-- 匹配時(shí)的操作:接收(記錄) --> <onMatch>ACCEPT</onMatch> <!-- 不匹配時(shí)的操作:拒絕(不記錄) --> <onMismatch>DENY</onMismatch> </filter> </appender> <appender name="file_error" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${log.path}/sys-error.log</file> <!-- 循環(huán)政策:基于時(shí)間創(chuàng)建日志文件 --> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <!-- 日志文件名格式 --> <fileNamePattern>${log.path}/sys-error.%d{yyyy-MM-dd}.log</fileNamePattern> <!-- 日志最大的歷史 60天 --> <maxHistory>60</maxHistory> </rollingPolicy> <encoder> <pattern>${log.pattern}</pattern> </encoder> <filter class="ch.qos.logback.classic.filter.LevelFilter"> <!-- 過(guò)濾的級(jí)別 --> <level>ERROR</level> <!-- 匹配時(shí)的操作:接收(記錄) --> <onMatch>ACCEPT</onMatch> <!-- 不匹配時(shí)的操作:拒絕(不記錄) --> <onMismatch>DENY</onMismatch> </filter> </appender> <!-- 用戶(hù)訪問(wèn)日志輸出 --> <appender name="sys-user" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${log.path}/sys-user.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <!-- 按天回滾 daily --> <fileNamePattern>${log.path}/sys-user.%d{yyyy-MM-dd}.log</fileNamePattern> <!-- 日志最大的歷史 60天 --> <maxHistory>60</maxHistory> </rollingPolicy> <encoder> <pattern>${log.pattern}</pattern> </encoder> </appender> <!-- 系統(tǒng)模塊日志級(jí)別控制 --> <logger name="com.example" level="debug" /> <!-- Spring日志級(jí)別控制 --> <logger name="org.springframework" level="warn" /> <root level="info"> <appender-ref ref="console" /> </root> <!--系統(tǒng)操作日志--> <root level="info"> <appender-ref ref="file_info" /> <appender-ref ref=&##34;file_error" /> </root> <!--系統(tǒng)用戶(hù)操作日志--> <logger name="sys-user" level="info"> <appender-ref ref="sys-user"/> </logger> </configuration>
注:1)控制臺(tái)和日志文件的字符集;2)日志文件的存放位置,須要遵守Linux的命名規(guī)則。
在application.yml中進(jìn)行設(shè)置日志級(jí)別
如果com.example: debug,那么項(xiàng)目com.example包里面的debug以上的日志也會(huì)輸出
logging: level: com.example: info org.springframework: warn
或者properties方式
#com.yoodb.study.demo04包下所有class以DEBUG級(jí)別輸出 logging.level.com.yoodb.study=DEBUG #用來(lái)指定自己創(chuàng)建的日志文件 logging.config=classpath:logback-spring.xml #指定輸出文件位置 logging.file.path=D://workspace/log
Controller
注:在添加引用時(shí),日志的包一定是org.slf4j.Logger、org.slf4j.LoggerFactory類(lèi)
@RestController public class HelloWorldController { protected static Logger logger=LoggerFactory.getLogger(HelloWorldController.class); @RequestMapping("/") public String helloworld(){ logger.debug("關(guān)注微信公眾號(hào)“Java精選”,Spring Boot系列文章持續(xù)更新中,帶你從入門(mén)到精通,玩轉(zhuǎn)Spring Boot框架。"); return "Hello world!"; } @RequestMapping("/hello/{name}") public String helloName(@PathVariable String name){ logger.debug("訪問(wèn) helloName,Name={}",name); return "Hello "+name; } }
要解決的核心問(wèn)題:「誰(shuí)」在「什么時(shí)間」對(duì)「什么」做了「什么事」
方案 1:AOP 切面 + 注解
①、定義日志注解,用于標(biāo)記哪些方法需要記錄業(yè)務(wù)操作日志
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface Loggable{ String value() default ""; //可以添加更多的配置屬性,如操作類(lèi)型、級(jí)別 }
②、創(chuàng)建AOP切面
@Aspect @Component public class LoggingAspect{ @Autowired private Logger logger;//SLF4j獲取 @Around("@annotation(loggable)") public Object logBusinessOperation(Proceeding joinPoint,Loggable loggable)throws Throwable{ //方法執(zhí)行前的邏輯,例如記錄開(kāi)始事件、方法參數(shù)等 long start = System.currentTimeMillis(); try{ Object result = jointPoint.proceed();//執(zhí)行目標(biāo)方法 //方法執(zhí)行后的邏輯,例如記錄結(jié)束時(shí)間、返回值等 return result; }catch(Exception e){ // 異常處理邏輯,如記錄異常信息 throw e; }finally{ long executionTime = System.currentTimeMillis() - start; // 構(gòu)建日志信息并記錄 logger.info("{} executed in {} ms", joinPoint.getSignature(), executionTime); } } }
③、配置SpringAOP+標(biāo)記注解
@Configuration @EnableAspectJAutoProxy public class AopConfig{ //可能還需要其他的配置或bean }
④、業(yè)務(wù)中使用注解
public class SomeService{ @Loggable public void someBusinessMethod(Object someParam){ //業(yè)務(wù)邏輯 } }
缺點(diǎn):
- 日志粒度和詳細(xì)度:切面雖然攔截了我們目標(biāo)方法,但其中能拿到的信息上下文有限,無(wú)法構(gòu)成一條操作日志所需的數(shù)據(jù)信息
- 業(yè)務(wù)操作場(chǎng)景劃分:切面的定義和使用都是非業(yè)務(wù)化的,所以無(wú)法感知到新的業(yè)務(wù)操作范圍和業(yè)務(wù)的定義劃分邊界是如何處理
- 級(jí)聯(lián)操作斷檔:當(dāng)業(yè)務(wù)操作是設(shè)計(jì)多表或者多個(gè)服務(wù)間的調(diào)用串聯(lián)時(shí),切面只能單獨(dú)記錄每個(gè)服務(wù)方法級(jí)別的數(shù)據(jù)信息,無(wú)法對(duì)調(diào)用鏈的部分進(jìn)行業(yè)務(wù)串聯(lián)
記錄到的日志數(shù)據(jù)都是固定的模板數(shù)據(jù),如:_XXX 修改了項(xiàng)目,XXX 新建了問(wèn)題數(shù)據(jù),XXX 刪除了風(fēng)險(xiǎn)問(wèn)題,因?yàn)槲覀儫o(wú)法通過(guò)每個(gè)切面對(duì)具體參數(shù)內(nèi)容和業(yè)務(wù)場(chǎng)景進(jìn)行捕獲。那么_如果我們想要在日志內(nèi)容中添加更多的業(yè)務(wù)上下文信息,如:XXX 修改了項(xiàng)目 ID=001 的數(shù)據(jù),XXX 刪除了產(chǎn)品 ID=002 的數(shù)據(jù),這時(shí)候就可以通過(guò)使用 AOP + SpEL 表達(dá)式來(lái)實(shí)現(xiàn)。
方案2:AOP 切面 + SpEL
①、對(duì)方案1的注解進(jìn)行內(nèi)容擴(kuò)展
@Repeatable(LogRecords.class) @Target({ElementType.METHOD,ElmenetType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Inherited @Documented public @interface LogRecord{ String success(); String fail() default ""; String operator() default ""; //業(yè)務(wù)操作場(chǎng)景人 String type(); // 業(yè)務(wù)場(chǎng)景 模塊范圍 String subType() default ""; //業(yè)務(wù)子場(chǎng)景,主要是模塊下的功能范圍 String bizNo(); //業(yè)務(wù)場(chǎng)景的業(yè)務(wù)編號(hào), String extra() default "";//一些操作的擴(kuò)展操作 String actionType(); //業(yè)務(wù)操作類(lèi)型,比如編輯、新增、刪除 }
②、基于注解進(jìn)行定義SpEL的解析器來(lái)對(duì)注解中的字段進(jìn)行解析和使用
public class LogRecordParser{ public static Map<String,Object> parseLogRecord(Annotation logRecordAnnotation){ Map<String,Object> result = new HashMap<>(); ExpressionParser parser = new ExpressionParser(new SpelFunction("parseLogRecord", LogRecordParser.class, "parseLogRecord")); for(String attribute : logRecordAnnotation.getAttributeNames()){ Object value = logRecordAnnotation.getAttribute(attribute); Expression expression = parser.parseExpression(attribute); TypeResolutionContext typeResolutionContext = new TypeResolutionContext(); typeResolutionContext.setMethod(new Method(null, null, null)); Object parsedValue = expression.getValue(typeResolutionContext); result.put(attribute, parsedValue); } return result; } }
③、表達(dá)式使用
系統(tǒng)中實(shí)際業(yè)務(wù)操作的使用場(chǎng)景,在注解的內(nèi)容中填充了很多業(yè)務(wù)操作場(chǎng)景的數(shù)據(jù),如果需要涉及操作前后數(shù)據(jù)的內(nèi)容記錄,還可以再次進(jìn)行擴(kuò)充 SpEL 的字段及解析邏輯,可以說(shuō)是有了它,我們可以做的更多了?。ǖ亲⒔庖苍絹?lái)越長(zhǎng)了)
優(yōu)缺點(diǎn):
- 解決了方案1中冗余重復(fù)代碼層面的侵入,但會(huì)出現(xiàn)大量注解定義的出現(xiàn),也帶有一定的侵入性
- 日志內(nèi)容還是需要系統(tǒng)自身根據(jù)上報(bào)場(chǎng)景進(jìn)行封裝,需要從產(chǎn)品的業(yè)務(wù)定義到研發(fā)編碼達(dá)成統(tǒng)一共識(shí)
- 與方案1相比簡(jiǎn)化了一部分代碼集成的復(fù)雜度,只需編寫(xiě)自定義注解即可
- 與方案1相比擴(kuò)展了對(duì)操作的業(yè)務(wù)數(shù)據(jù)廣度,數(shù)據(jù)范圍大大增加,而且還可根據(jù)自身業(yè)務(wù)定義無(wú)限擴(kuò)展
解決了業(yè)務(wù)操作日志的一個(gè)收集問(wèn)題,能夠清晰的記錄各類(lèi)操作場(chǎng)景、動(dòng)作、數(shù)據(jù)前后的內(nèi)容等
方案3:Binlog + 時(shí)間窗口
怎么從應(yīng)用層對(duì)操作場(chǎng)景、數(shù)據(jù)進(jìn)行抓包、處理邏輯、保存,所以復(fù)雜度都會(huì)集中到應(yīng)用層。既然是這樣我們能不能直接基于底層的 MySQL 本身來(lái)處理這件事兒呢?
Binlog 是數(shù)據(jù)庫(kù)中二進(jìn)制格式的文件,用于記錄用戶(hù)對(duì)數(shù)據(jù)庫(kù)更新的 SQL 語(yǔ)句信息,例如更改數(shù)據(jù)庫(kù)表和更改內(nèi)容的 SQL 語(yǔ)句都會(huì)記錄到 binlog 里。那么 Binlog 能用來(lái)記錄業(yè)務(wù)層面的數(shù)據(jù)變化內(nèi)容嗎?
問(wèn)題 1:無(wú)法對(duì)多表存在級(jí)聯(lián)保存和更新的數(shù)據(jù)進(jìn)行非常好的兼容支持,因?yàn)楸旧韇inlog數(shù)據(jù)是無(wú)序的,并且如果上游數(shù)據(jù)的操作不是包裹在一個(gè)事務(wù)中,也很難處理
解決問(wèn)題 1:由于本身 binlog 的無(wú)序性,所以無(wú)法對(duì)大量 binlog 進(jìn)行有序組合,如果本身是一個(gè)事務(wù)提交的還可以根據(jù)事務(wù) KEY 進(jìn)行組合,如果不是呢?這里可以考慮借鑒 Flink 的時(shí)間窗口機(jī)制:滾動(dòng)的時(shí)間窗口將每個(gè)元素指定給指定窗口大小的窗口,滾動(dòng)窗口具有固定大小,且不重疊。
例如,我們指定一個(gè)大小為 1 分鐘的滾動(dòng)窗口,在這種情況下,我們將每隔 1 分鐘開(kāi)啟一個(gè)新的窗口,其中每一條數(shù)都會(huì)劃分到唯一一個(gè) 1 分鐘的窗口中,如下圖所示:
基于以上的窗口機(jī)制,我們就可以對(duì)數(shù)據(jù)先進(jìn)行范圍的框定,通過(guò)窗口的滑動(dòng)機(jī)制和補(bǔ)償機(jī)制對(duì)窗口中的數(shù)據(jù)進(jìn)行關(guān)聯(lián)處理。但光靠時(shí)間窗口還是無(wú)法對(duì) binlog 進(jìn)行關(guān)聯(lián),那我們就從關(guān)聯(lián)數(shù)據(jù)本身下手,這類(lèi)數(shù)據(jù)關(guān)聯(lián)復(fù)雜主要是涉及表之間的引用關(guān)系,那我們?cè)谶M(jìn)行定義 binlog 解析時(shí)就把前后數(shù)據(jù) + 表之間的引用字段都進(jìn)行指定,這樣在窗口中進(jìn)行滑動(dòng)關(guān)聯(lián)時(shí),就可以進(jìn)行子表的引用字段關(guān)聯(lián)了!這樣關(guān)聯(lián)字段補(bǔ)償更新的機(jī)制就可以解決問(wèn)題 1 了。
//部分的 binlog 數(shù)據(jù)變動(dòng)結(jié)構(gòu)的 RowChange 定義如下: @Data public static class RowChange { private int tableId; private List<RowDatas> rowDatas; private String eventType; private boolean isDdl; } @Data public static class RowDatas { private List<DataColumn> afterColumns; private List<DataColumn> beforeColumns; } @Data public static class DataColumn { private int sqlType; private boolean isNull; private String mysqlType; private String name; private boolean isKey; private int index; private boolean updated; private String value; }
問(wèn)題 2:關(guān)于更新人的問(wèn)題,系統(tǒng)進(jìn)行更新時(shí)如果未手動(dòng)更新對(duì)應(yīng)操作人,則系統(tǒng)無(wú)法識(shí)別,需要上游做對(duì)應(yīng)場(chǎng)景的統(tǒng)一改造,但從系統(tǒng)承接來(lái)看,本身系統(tǒng)的操作人就是要跟著業(yè)務(wù)操作一起進(jìn)行聯(lián)動(dòng)的
解決問(wèn)題 2:關(guān)于更新人的問(wèn)題其實(shí)是各系統(tǒng)需要自己排除解決的問(wèn)題,因?yàn)楸旧順I(yè)務(wù)在進(jìn)行數(shù)據(jù)操作時(shí)就是需要留痕更新人信息,比較統(tǒng)一的方案就是基于底層的 ORM 框架來(lái)統(tǒng)一進(jìn)行攔截處理,大家可以自行 GPT。
總結(jié):
- 基于 binlog 后,我們對(duì)底層的數(shù)據(jù)變動(dòng)感知更明顯了,但是 binlog 的數(shù)據(jù)來(lái)源除了系統(tǒng)應(yīng)用層還有很多其他來(lái)源,比如我們的數(shù)據(jù)庫(kù)工單,日常跑批刷數(shù)等場(chǎng)景,這類(lèi)的數(shù)據(jù)變動(dòng)范圍可能較大,而且感知較弱。
- 方案 3 的設(shè)計(jì)把方案 2 中的業(yè)務(wù)場(chǎng)景(也就是 actiontype subtype 等)弱化了,所以并不能很好的感知到很細(xì)顆粒度。
項(xiàng)目中應(yīng)用日志
①、bootstrap.yml配置文件
mybatis-plus: type-aliases-package: quick.pager.shop.model configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto logging: pattern: console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [${spring.application.name}] [traceId:%X{X-B3-TraceId}][spanId:%X{X-B3-SpanId}][parentSpanId:%X{X-B3-ParentSpanId}] --- [%t] - [%class:%method: %line] - %msg%n" file: "%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [${spring.application.name}] [traceId:%X{X-B3-TraceId}][spanId:%X{X-B3-SpanId}][parentSpanId:%X{X-B3-ParentSpanId}] --- [%t] - [%class:%method: %line] - %msg%n" level: org.springframework: error com.alibaba: error org.apache.ibatis: error io.seata: error file: path: ./logs/${spring.application.name} max-size: 50MB name: ${spring.application.name}
②、service實(shí)現(xiàn)類(lèi)中的使用日志
@Service @Slf4j //lombok:1.18.12 public class GoodsSpuServiceImpl extends ServiceImpl<GoodsSpuMapper, GoodsSpu> implements GoodsSpuService { @Autowired private GoodsClassMapper goodsClassMapper; @Autowired private BannerClient bannerClient; @Override public Response<Long> create(GoodsSpuSaveRequest request){ if(StringUtils.isBlank(request.getSpuName())){ return Response.toError(ResponseStatus.Code.FAIL_CODE, "spu名稱(chēng)不能為空!"); } if(checkName(request.getSpuName(), null)){ return Response.toError(ResponseStatus.Code.FAIL_CODE, "spu名稱(chēng)已存在!"); } GoodsSpu spu = this.conv(request); spu.setCreateTime(DateUtils.dateTime()); spu.setDeleteStatus(Boolean.FALSE); if (this.baseMapper.insert(spu) > 0) { return Response.toResponse(spu.getId()); } //添加日志 log.error("新增SPU失敗 result = {}",JSON.toJSONString(request)); return Response.toError(ResponseStatus.Code.FAIL_CODE, "新增SPU失敗"); } @Override public Response<Long> delete(final Long id){ int delete = this.baseMapper.deleteById(id); if(delete>0){ return Response.toResponse(id); } //添加日志 log.error("刪除SPU失敗 id={}",id); return Response.toError(ResponseStatus.Code.FAIL_CODE, "刪除SPU失敗"); } }
校驗(yàn)名稱(chēng)的唯一性
private Boolean checkName(final String name,final Long id){ List<GoodsSpu> spus = this.baseMapper.selectList(new LambdaQueryWrapper<GoodsSpu>() .eq(GoodsSpu::getSpuName, name)); if(CollectionUtils.isEmpty(spus)){ return Boolean.FALSE; } return spus.stream() .filter(item->Objects.isNull(id)?Boolean.TRUE:IConsts.ZERO!=item.getId().compareTo(id)) .anyMatch(item->item.getSpuName().equals(name)); }
項(xiàng)目中注解和日志的結(jié)合
①、注解
/** 自定義操作日志記錄注解 */ @Target({ElementType.PARAMETER,ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface OperLog{ //模塊 public String title() default ""; //功能 public BusinessType businessType() default BusinessType.OTHER; //操作人類(lèi)別 public OperatorType operatorType() default OperatorType.MANAGE; //是否保存請(qǐng)求的參數(shù) public boolean isSaveRequestData() default true; }
/** * 業(yè)務(wù)操作類(lèi)型 * * @author ruoyi */ public enum BusinessType { /** * 其它 */ OTHER, /** * 新增 */ INSERT, /** * 修改 */ UPDATE, /** * 刪除 */ DELETE, /** * 授權(quán) */ GRANT, /** * 導(dǎo)出 */ EXPORT, /** * 導(dǎo)入 */ IMPORT, /** * 強(qiáng)退 */ FORCE, /** * 生成代碼 */ GENCODE, /** * 清空 */ CLEAN, }
/** * 操作人類(lèi)別 * * @author ruoyi */ public enum OperatorType { /** * 其它 */ OTHER, /** * 后臺(tái)用戶(hù) */ MANAGE, /** * 手機(jī)端用戶(hù) */ MOBILE }
②、切面
@Aspect @Slf4j @Document public class OperLogAspect{ //配置織入點(diǎn)(注解) @Pointcut("@annotation(com.ruoyi.system.log.annotation.OperLog)") public void logPointCut(){} //處理完請(qǐng)求后執(zhí)行 @AfterReturning(pointcut = "logPointCut") public void doAfterReturning(JoinPoint joinPoint){ handleLog(joinPoint,null); } //攔截異常操作 @AfterThrowing(value = "logPointCut()",throwing = "e") public void doAfterThrowing(JoinPoint joinPoint,Exception e){ handleLog(joinPoint,e); } protected void handleLog(final JoinPoint joinPoint,final Exception e){ try{ // 獲得注解 com.ruoyi.system.log.annotation.OperLog controllerLog = getAnnotationLog(joinPoint); if (controllerLog == null) { return; } // *========數(shù)據(jù)庫(kù)日志=========*// OperLog operLog = new OperLog(); operLog.setStatus(BusinessStatus.SUCCESS.ordinal()); // 請(qǐng)求的地址 HttpServletRequest request = ServletUtils.getRequest(); String ip = IpUtils.getIpAddr(request); operLog.setOperIp(ip); operLog.setOperUrl(request.getRequestURI()); operLog.setOperLocation(AddressUtils.getRealAddressByIP(ip)); String username = request.getHeader(Constants.CURRENT_USERNAME); operLog.setOperName(username); if (e != null) { operLog.setStatus(BusinessStatus.FAIL.ordinal()); operLog.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 2000)); } //設(shè)置方法名稱(chēng) String className = joinPoint.getTarget().getClass().getName(); Strng methodName = joinPoint.getSignature().getName(); operLog.setMethod(className + "." + methodName + "()"); //設(shè)置請(qǐng)求方式 operLog.setRequestMethod(request.getMethod()); //處理設(shè)置注解上的參數(shù) Object[] args = joinPoint.getArgs(); getControllerMethodDescription(controllerLog, operLog, args); //發(fā)布事件 SpringContextHolder.publishEvent(new OperLogEvent(operLog)); }catch(Exception exp){ //記錄本地異常日志 log.error("==前置通知異常=="); log.error("異常信息:{}", exp.getMessage()); exp.printStackTrace(); } } //是否存在注解,如果存在就獲取 private com.ruoyi.system.log.annotation.OperLog getAnnotationLog(JoinPoint joinPoint) throws Exception { Signature signature = joinPoint.getSignature(); MethodSignature methodSignature = (MethodSignature) signature; Method method = methodSignature.getMethod(); if (method != null) { return method.getAnnotation(com.ruoyi.system.log.annotation.OperLog.class); } return null; } //獲取注解中對(duì)方法的描述信息,用于Controller層注解 public void getControllerMethodDescription(com.ruoyi.system.log.annotation.OperLog log, OperLog operLog, Object[] args) throws Exception { // 設(shè)置action動(dòng)作 operLog.setBusinessType(log.businessType().ordinal()); // 設(shè)置標(biāo)題 operLog.setTitle(log.title()); // 設(shè)置操作人類(lèi)別 operLog.setOperatorType(log.operatorType().ordinal()); // 是否需要保存request,參數(shù)和值 if (log.isSaveRequestData()) { // 獲取參數(shù)的信息,傳入到數(shù)據(jù)庫(kù)中。 setRequestValue(operLog, args); } } //獲取請(qǐng)求的參數(shù),放到log中 private void setRequestValue(OperLog operLog, Object[] args) throws Exception { List<?> param = new ArrayList<>(Arrays.asList(args)).stream().filter(p -> !(p instanceof ServletResponse)) .collect(Collectors.toList()); log.debug("args:{}", param); String params = JSON.toJSONString(param, true); operLog.setOperParam(StringUtils.substring(params, 0, 2000)); } }
工具類(lèi)
/** * 客戶(hù)端工具類(lèi) * * @author ruoyi */ public class ServletUtils { /** * 獲取String參數(shù) */ public static String getParameter(String name) { return getRequest().getParameter(name); } /** * 獲取String參數(shù) */ public static String getParameter(String name, String defaultValue) { return Convert.toStr(getRequest().getParameter(name), defaultValue); } /** * 獲取Integer參數(shù) */ public static Integer getParameterToInt(String name) { return Convert.toInt(getRequest().getParameter(name)); } /** * 獲取Integer參數(shù) */ public static Integer getParameterToInt(String name, Integer defaultValue) { return Convert.toInt(getRequest().getParameter(name), defaultValue); } /** * 獲取request */ public static HttpServletRequest getRequest() { return getRequestAttributes().getRequest(); } /** * 獲取response */ public static HttpServletResponse getResponse() { return getRequestAttributes().getResponse(); } /** * 獲取session */ public static HttpSession getSession() { return getRequest().getSession(); } public static ServletRequestAttributes getRequestAttributes() { RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); return (ServletRequestAttributes) attributes; } /** * 將字符串渲染到客戶(hù)端 * * @param response 渲染對(duì)象 * @param string 待渲染的字符串 * @return null */ public static String renderString(HttpServletResponse response, String string) { try { response.setContentType("application/json"); response.setCharacterEncoding("utf-8"); response.getWriter().print(string); } catch (IOException e) { e.printStackTrace(); } return null; } /** * 是否是Ajax異步請(qǐng)求 * * @param request */ public static boolean isAjaxRequest(HttpServletRequest request) { String accept = request.getHeader("accept"); if (accept != null && accept.indexOf("application/json") != -1) { return true; } String xRequestedWith = request.getHeader("X-Requested-With"); if (xRequestedWith != null && xRequestedWith.indexOf("XMLHttpRequest") != -1) { return true; } String uri = request.getRequestURI(); if (StringUtils.inStringIgnoreCase(uri, ".json", ".xml")) { return true; } String ajax = request.getParameter("__ajax"); if (StringUtils.inStringIgnoreCase(ajax, "json", "xml")) { return true; } return false; } }
/** * 獲取IP方法 * * @author ruoyi */ public class IpUtils { public static String getIpAddr(HttpServletRequest request) { if (request == null) { return "unknown"; } String ip = request.getHeader("x-forwarded-for"); if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("Proxy-Client-IP"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("X-Forwarded-For"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("WL-Proxy-Client-IP"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("X-Real-IP"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } return "0:0:0:0:0:0:0:1".equals(ip) ? "127.0.0.1" : ip.split(",")[0]; } public static boolean internalIp(String ip) { byte[] addr = textToNumericFormatV4(ip); if (null != addr) { return internalIp(addr) || "127.0.0.1".equals(ip); } return false; } private static boolean internalIp(byte[] addr) { final byte b0 = addr[0]; final byte b1 = addr[1]; // 10.x.x.x/8 final byte SECTION_1 = 0x0A; // 172.16.x.x/12 final byte SECTION_2 = (byte) 0xAC; final byte SECTION_3 = (byte) 0x10; final byte SECTION_4 = (byte) 0x1F; // 192.168.x.x/16 final byte SECTION_5 = (byte) 0xC0; final byte SECTION_6 = (byte) 0xA8; switch (b0) { case SECTION_1: return true; case SECTION_2: if (b1 >= SECTION_3 && b1 <= SECTION_4) { return true; } case SECTION_5: switch (b1) { case SECTION_6: return true; } default: return false; } } /** * 將IPv4地址轉(zhuǎn)換成字節(jié) * * @param text IPv4地址 * @return byte 字節(jié) */ public static byte[] textToNumericFormatV4(String text) { if (text.length() == 0) { return null; } byte[] bytes = new byte[4]; String[] elements = text.split("\\.", -1); try { long l; int i; switch (elements.length) { case 1: l = Long.parseLong(elements[0]); if ((l < 0L) || (l > 4294967295L)) { return null; } bytes[0] = (byte) (int) (l >> 24 & 0xFF); bytes[1] = (byte) (int) ((l & 0xFFFFFF) >> 16 & 0xFF); bytes[2] = (byte) (int) ((l & 0xFFFF) >> 8 & 0xFF); bytes[3] = (byte) (int) (l & 0xFF); break; case 2: l = Integer.parseInt(elements[0]); if ((l < 0L) || (l > 255L)) { return null; } bytes[0] = (byte) (int) (l & 0xFF); l = Integer.parseInt(elements[1]); if ((l < 0L) || (l > 16777215L)) { return null; } bytes[1] = (byte) (int) (l >> 16 & 0xFF); bytes[2] = (byte) (int) ((l & 0xFFFF) >> 8 & 0xFF); bytes[3] = (byte) (int) (l & 0xFF); break; case 3: for (i = 0; i < 2; ++i) { l = Integer.parseInt(elements[i]); if ((l < 0L) || (l > 255L)) { return null; } bytes[i] = (byte) (int) (l & 0xFF); } l = Integer.parseInt(elements[2]); if ((l < 0L) || (l > 65535L)) { return null; } bytes[2] = (byte) (int) (l >> 8 & 0xFF); bytes[3] = (byte) (int) (l & 0xFF); break; case 4: for (i = 0; i < 4; ++i) { l = Integer.parseInt(elements[i]); if ((l < 0L) || (l > 255L)) { return null; } bytes[i] = (byte) (int) (l & 0xFF); } break; default: return null; } } catch (NumberFormatException e) { return null; } return bytes; } public static String getHostIp() { try { return InetAddress.getLocalHost().getHostAddress(); } catch (UnknownHostException e) { } return "127.0.0.1"; } public static String getHostName() { try { return InetAddress.getLocalHost().getHostName(); } catch (UnknownHostException e) { } return "未知"; } }
/** * 獲取地址類(lèi) * * @author ruoyi */ public class AddressUtils { private static final Logger log = LoggerFactory.getLogger(AddressUtils.class); public static final String IP_URL = "http://ip-api.com/json/%s?lang=zh-CN"; public static String getRealAddressByIP(String ip) { String address = "XX XX"; // 內(nèi)網(wǎng)不查詢(xún) if (IpUtils.internalIp(ip)) { return "內(nèi)網(wǎng)IP"; } String rspStr = HttpUtil.get(String.format(IP_URL, ip)); if (StringUtils.isEmpty(rspStr)) { log.error("獲取地理位置異常 {}", ip); return address; } JSONObject obj; try { obj = JSON.unmarshal(rspStr, JSONObject.class); address = obj.getStr("country") + "," + obj.getStr("regionName") + "," + obj.getStr("city"); } catch (Exception e) { log.error("獲取地理位置異常 {}", ip); } return address; } }
系統(tǒng)日志事件
public class OperLogEvent extends ApplicationEvent { private static final long serialVersionUID = 8905017895058642111L; public OperLogEvent(OperLog source) { super(source); } }
@Slf4j @Service @Lazy(false) public class SpringContextHolder implements ApplicationContextAware,DisposableBean{ private static ApplicationContext applicationContext = null; //取得存在在靜態(tài)變量中的ApplicationContext public static ApplicationCotnext getApplicationCotnext(){ return applicationContext; } //實(shí)現(xiàn)ApplicationContextAware接口, 注入Context到靜態(tài)變量中 @Override public void setApplicationContext(ApplicationContext applicationContext) { SpringContextHolder.applicationContext = applicationContext; } //清除SpringContextHolder中的ApplicationContext為Null public static void clearHolder() { if (log.isDebugEnabled()) { log.debug("清除SpringContextHolder中的ApplicationContext:" + applicationContext); } applicationContext = null; } //發(fā)布事件 SpringContextHolder.publishEvent(new OperLogEvent(operLog)); public static void publishEvent(ApplicationEvent event) { if (applicationContext == null) { return; } applicationContext.publishEvent(event); } //實(shí)現(xiàn)DisposableBean接口, 在Context關(guān)閉時(shí)清理靜態(tài)變量. @Override @SneakyThrows public void destroy() { SpringContextHolder.clearHolder(); } //獲取運(yùn)行環(huán)境 public static String getActiveProfile() { return applicationContext.getEnvironment().getActiveProfiles()[0]; } }
③、使用
//新增保存通知公告 @HasPermissions("system:notice:add") @OperLog(title = "通知公告", businessType = BusinessType.INSERT) @PostMapping("save") public R addSave(@ReqeustBody Notice notice){ notice.setParkId(getParkId()); notice.setCreateBy(getLoginName()); return toAjax(noticeService.insertNotice(notice)); }
總結(jié)
以上為個(gè)人經(jīng)驗(yàn),希望能給大家一個(gè)參考,也希望大家多多支持腳本之家。
相關(guān)文章
詳解Spring Boot 自定義PropertySourceLoader
這篇文章主要介紹了詳解Spring Boot 自定義PropertySourceLoader,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2017-05-05Spring?MVC?請(qǐng)求映射路徑的配置實(shí)現(xiàn)前后端交互
在Spring?MVC中,請(qǐng)求映射路徑是指與特定的請(qǐng)求處理方法關(guān)聯(lián)的URL路徑,這篇文章主要介紹了Spring?MVC?請(qǐng)求映射路徑的配置,實(shí)現(xiàn)前后端交互,需要的朋友可以參考下2023-09-09Spring實(shí)戰(zhàn)之Bean的后處理器操作示例
這篇文章主要介紹了Spring實(shí)戰(zhàn)之Bean的后處理器操作,結(jié)合實(shí)例形式詳細(xì)分析了Bean的后處理器相關(guān)配置、操作方法及使用注意事項(xiàng),需要的朋友可以參考下2019-12-12淺試仿?mapstruct實(shí)現(xiàn)微服務(wù)編排框架詳解
這篇文章主要為大家介紹了淺試仿?mapstruct實(shí)現(xiàn)微服務(wù)編排框架詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-08-08Java中jdk1.8和jdk17相互切換實(shí)戰(zhàn)步驟
之前做Java項(xiàng)目時(shí)一直用的是jdk1.8,現(xiàn)在想下載另一個(gè)jdk版本17,并且在之后的使用中可以進(jìn)行相互切換,下面這篇文章主要給大家介紹了關(guān)于Java中jdk1.8和jdk17相互切換的相關(guān)資料,需要的朋友可以參考下2023-05-05Java判斷一個(gè)時(shí)間是否在當(dāng)前時(shí)間區(qū)間代碼示例
這篇文章主要給大家介紹了關(guān)于使用Java判斷一個(gè)時(shí)間是否在當(dāng)前時(shí)間區(qū)間的相關(guān)資料,在日常開(kāi)發(fā)中我們經(jīng)常會(huì)涉及到時(shí)間的大小比較或者是判斷某個(gè)時(shí)間是否在某個(gè)時(shí)間段內(nèi),需要的朋友可以參考下2023-07-07Springboot視圖解析器ViewResolver使用實(shí)例
這篇文章主要介紹了Springboot視圖解析器ViewResolver使用實(shí)例,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2020-04-04