前言
不久前,因為需求的原因,需要實作一個記錄檔。幾乎每一個接口被調用後,都要記錄一條跟這個參數挂鈎的特定的日志到資料庫。舉個例子,就比如禁言操作,日志中需要記錄因為什麼禁言,被禁言的人的id和各種資訊。友善後期查詢。
這樣的接口有很多個,而且大部分接口的參數都不一樣。可能大家很容易想到的一個思路就是,實作一個日志記錄的工具類,然後在需要記錄日志的接口中,添加一行代碼。由這個日志工具類去判斷此時應該處理哪些參數。
但是這樣有很大的問題。如果需要記日志的接口數量非常多,先不讨論這個工具類中需要做多少的類型判斷,僅僅是給所有接口添加這樣一行代碼在我個人看來都是不能接受的行為。首先,這樣對代碼的侵入性太大。其次,後期萬一有改動,維護的人将會十分難受。想象一下,全局搜尋相同的代碼,再一一進行修改。
是以我放棄了這個略顯原始的方法。我最終采用了Aop的方式,采取攔截的請求的方式,來記錄日志。但是即使采用這個方法,仍然面臨一個問題,那就是如何處理大量的參數。以及如何對應到每一個接口上。
我最終沒有攔截所有的controller,而是自定義了一個日志注解。所有打上了這個注解的方法,将會記錄日志。同時,注解中會帶有類型,來為目前的接口指定特定的日志内容以及參數。
那麼如何從衆多可能的參數中,為目前的日志指定對應的參數呢。我的解決方案是維護一個參數類,裡面列舉了所有需要記錄在日志中的參數名。然後在攔截請求時,通過反射,擷取到該請求的request和response中的所有參數和值,如果該參數存在于我維護的param類中,則将對應的值指派進去。
然後在請求結束後,将模闆中的所有預留的參數全部用賦了值的參數替換掉。這樣一來,在不大量的侵入業務的前提下,滿足了需求,同時也保證了代碼的可維護性。
下面我将會把詳細的實作過程列舉出來。
開始操作前
文章結尾我會給出這個demo項目的所有源碼。是以不想看過程的兄台可移步到末尾,直接看源碼。(聽說和源碼搭配,看文章更美味...)
開始操作
建立項目
大家可以參考我之前寫的另一篇文章,
手把手教你從零開始搭建SpringBoot後端項目架構。隻要能請求簡單的接口就可以了。本項目的依賴如下。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.1.1.RELEASE</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.aspectj/aspectjrt -->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjrt</artifactId>
<version>1.9.2</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.aspectj/aspectjweaver -->
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.2</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.2</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>4.1.14</version>
</dependency>
建立Aop類
建立
LogAspect
類。代碼如下。
package spring.aop.log.demo.api.util;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
/**
* LogAspect
*
* @author Lunhao Hu
* @date 2019-01-30 16:21
**/
@Aspect
@Component
public class LogAspect {
/**
* 定義切入點
*/
@Pointcut("@annotation(spring.aop.log.demo.api.util.Log)")
public void operationLog() {
}
/**
* 新增結果傳回後觸發
*
* @param point
* @param returnValue
*/
@AfterReturning(returning = "returnValue", pointcut = "operationLog() && @annotation(log)")
public void doAfterReturning(JoinPoint point, Object returnValue, Log log) {
System.out.println("test");
}
}
Pointcut
中傳入了一個注解,表示凡是打上了這個注解的方法,都會觸發由
Pointcut
修飾的
operationLog
函數。而
AfterReturning
則是在請求傳回之後觸發。
自定義注解
上一步提到了自定義注解,這個自定義注解将打在controller的每個方法上。建立一個
annotation
的類。代碼如下。
package spring.aop.log.demo.api.util;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Log
*
* @author Lunhao Hu
* @date 2019-01-30 16:19
**/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Log {
String type() default "";
}
Target
和
Retention
都屬于元注解。共有4種,分别是
@Retention
、
@Target
@Document
@Inherited
。
Target
注解說明了該Annotation所修飾的範圍。可以傳入很多類型,參數為
ElementType
。例如
TYPE
,用于描述類、接口或者枚舉類;
FIELD
用于描述屬性;
METHOD
用于描述方法;
PARAMETER
用于描述參數;
CONSTRUCTOR
用于描述構造函數;
LOCAL_VARIABLE
用于描述局部變量;
ANNOTATION_TYPE
用于描述注解;
PACKAGE
用于描述包等。
Retention
注解定義了該Annotation被保留的時間長短。參數為
RetentionPolicy
SOURCE
表示隻在源碼中存在,不會在編譯後的class檔案存在;
CLASS
是該注解的預設選項。 即存在于源碼,也存在于編譯後的class檔案,但不會被加載到虛拟機中去;
RUNTIME
存在于源碼、class檔案以及虛拟機中,通俗一點講就是可以在運作的時候通過反射擷取到。
加上普通注解
給需要記錄日志的接口加上
Log
注解。
package spring.aop.log.demo.api.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import spring.aop.log.demo.api.util.Log;
/**
* HelloController
*
* @author Lunhao Hu
* @date 2019-01-30 15:52
**/
@RestController
public class HelloController {
@Log
@GetMapping("test/{id}")
public String test(@PathVariable(name = "id") Integer id) {
return "Hello" + id;
}
}
加上之後,每一次調用
test/{id}
這個接口,都會觸發攔截器中的
doAfterReturning
方法中的代碼。
加上帶類型注解
上面介紹了記錄普通日志的方法,接下來要介紹記錄特定日志的方法。什麼特定日志呢,就是每個接口要記錄的資訊不同。為了實作這個,我們需要實作一個操作類型的枚舉類。代碼如下。
操作類型模闆枚舉
建立一個枚舉類
Type
。代碼如下。
package spring.aop.log.demo.api.util;
/**
* Type
*
* @author Lunhao Hu
* @date 2019-01-30 17:12
**/
public enum Type {
/**
* 操作類型
*/
WARNING("警告", "因被其他玩家舉報,警告玩家");
/**
* 類型
*/
private String type;
/**
* 執行操作
*/
private String operation;
Type(String type, String operation) {
this.type = type;
this.operation = operation;
}
public String getType() { return type; }
public String getOperation() { return operation; }
}
給注解加上類型
給上面的controller中的注解加上type。代碼如下。
package spring.aop.log.demo.api.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import spring.aop.log.demo.api.util.Log;
/**
* HelloController
*
* @author Lunhao Hu
* @date 2019-01-30 15:52
**/
@RestController
public class HelloController {
@Log(type = "WARNING")
@GetMapping("test/{id}")
public String test(@PathVariable(name = "id") Integer id) {
return "Hello" + id;
}
}
修改aop類
将aop類中的
doAfterReturning
為如下。
@AfterReturning(returning = "returnValue", pointcut = "operationLog() && @annotation(log)")
public void doAfterReturning(JoinPoint point, Object returnValue, Log log) {
// 注解中的類型
String enumKey = log.type();
System.out.println(Type.valueOf(enumKey).getOperation());
}
加上之後,每一次調用加了
@Log(type = "WARNING")
這個注解的接口,都會列印這個接口所指定的日志。例如上述代碼就會列印出如下代碼。
因被其他玩家舉報,警告玩家
擷取aop攔截的請求參數
為每個接口指定一個日志并不困難,隻需要為每個接口指定一個類型即可。但是大家應該也注意到了,一個接口日志,隻記錄
因被其他玩家舉報,警告玩家
這樣的資訊沒有任何意義。
記錄日志的人倒不覺得,而最後去檢視日志的人就要吾日三省吾身了,被誰舉報了?因為什麼舉報了?我警告的誰?
這樣的日志做了太多的無用功,根本沒有辦法在出現問題之後溯源。是以我們下一步的操作就是給每個接口加上特定的參數。那麼大家可能會有問題,如果每個接口的參數幾乎都不一樣,那這個工具類豈不是要傳入很多參數,要怎麼實作呢,甚至還要組織參數,這樣會大量的侵入業務代碼,并且會大量的增加備援代碼。
大家可能會想到,實作一個記錄日志的方法,在要記日志的接口中調用,把參數傳進去。如果類型很多的話,參數也會随之增多,每個接口的參數都不一樣。處理起來十分麻煩,而且對業務的侵入性太高。幾乎每個地方都要嵌入日志相關代碼。一旦涉及到修改,将會變得十分難維護。
是以我直接利用反射擷取aop攔截到的請求中的所有參數,如果我的參數類(所有要記錄的參數)裡面有請求中的參數,那麼我就将參數的值寫入參數類中。最後将日志模版中參數預留字段替換成請求中的參數。
流程圖如下所示。
request.png

建立參數類
建立一個類
Param
,其中包含所有在記錄檔中,可能會出現的參數。為什麼要這麼做?因為每個接口需要的參數都有可能完全不一樣,與其去維護大量的判斷邏輯,還不如
貪心
一點,直接傳入所有的可能參數。當然後期如果有新的參數需要記錄,則需要修改代碼。
package spring.aop.log.demo.api.util;
import lombok.Data;
/**
* Param
*
* @author Lunhao Hu
* @date 2019-01-30 17:14
**/
@Data
public class Param {
/**
* 所有可能參數
*/
private String id;
private String workOrderNumber;
private String userId;
}
修改模闆
将模闆枚舉類中的
WARNING
修改為如下。
WARNING("警告", "因 工單号 [(%workOrderNumber)] /舉報 ID [(%id)] 警告玩家 [(%userId)]");
其中的參數,就是要在aop攔截階段擷取并且替換掉的參數。
修改controller
我們給之前的controller加上上述模闆中國呢的參數。部分代碼如下。
@Log(type = "WARNING")
@GetMapping("test/{id}")
public String test(
@PathVariable(name = "id") Integer id,
@RequestParam(name = "workOrderNumber") String workOrderNumber,
@RequestParam(name = "userId") String userId,
@RequestParam(name = "name") String name
) {
return "Hello" + id;
}
通過反射擷取請求的參數
在此處分兩種情況,一種是簡單參數類型,另外一種是複雜參數類型,也就是參數中帶了請求DTO的情況。
擷取簡單參數類型
給aop類添加幾個私有變量。
/**
* 請求中的所有參數
*/
private Object[] args;
/**
* 請求中的所有參數名
*/
private String[] paramNames;
/**
* 參數類
*/
private Param params;
然後将
doAfterReturning
中的代碼改成如下。
try {
// 擷取請求詳情
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
HttpServletResponse response = attributes.getResponse();
// 擷取所有請求參數
Signature signature = point.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
this.paramNames = methodSignature.getParameterNames();
this.args = point.getArgs();
// 執行個體化參數類
this.params = new Param();
// 注解中的類型
String enumKey = log.type();
String logDetail = Type.valueOf(enumKey).getOperation();
// 從請求傳入參數中擷取資料
this.getRequestParam();
} catch (Exception e) {
System.out.println(e.getMessage());
}
首先要做的就是攔截打上了自定義注解的請求。我們可以擷取到請求的詳情,以及請求中的所有的參數名,以及參數。下面我們就來實作上述代碼中的
getRequestParam
方法。
getRequestParam
/**
* 擷取攔截的請求中的參數
* @param point
*/
private void getRequestParam() {
// 擷取簡單參數類型
this.getSimpleParam();
}
getSimpleParam
/**
* 擷取簡單參數類型的值
*/
private void getSimpleParam() {
// 周遊請求中的參數名
for (String reqParam : this.paramNames) {
// 判斷該參數在參數類中是否存在
if (this.isExist(reqParam)) {
this.setRequestParamValueIntoParam(reqParam);
}
}
}
上述代碼中,周遊請求所傳入的參數名,然後我們實作
isExist
方法, 來判斷這個參數在我們的
Param
類中是否存在,如果存在我們就再調用
setRequestParamValueIntoParam
方法,将這個參數名所對應的參數值寫入到
Param
類的執行個體中。
isExist
isExist
的代碼如下。
/**
* 判斷該參數在參數類中是否存在(是否是需要記錄的參數)
* @param targetClass
* @param name
* @param <T>
* @return
*/
private <T> Boolean isExist(String name) {
boolean exist = true;
try {
String key = this.setFirstLetterUpperCase(name);
Method targetClassGetMethod = this.params.getClass().getMethod("get" + key);
} catch (NoSuchMethodException e) {
exist = false;
}
return exist;
}
在上面我們也提到過,在編譯的時候會加上getter和setter,是以參數名的首字母都會變成大寫,是以我們需要自己實作一個
setFirstLetterUpperCase
方法,來将我們傳入的參數名的首字母變成大寫。
setFirstLetterUpperCase
代碼如下。
/**
* 将字元串的首字母大寫
*
* @param str
* @return
*/
private String setFirstLetterUpperCase(String str) {
if (str == null) {
return null;
}
return str.substring(0, 1).toUpperCase() + str.substring(1);
}
setRequestParamValueIntoParam
/**
* 從參數中擷取
* @param paramName
* @return
*/
private void setRequestParamValueIntoParam(String paramName) {
int index = ArrayUtil.indexOf(this.paramNames, paramName);
if (index != -1) {
String value = String.valueOf(this.args[index]);
this.setParam(this.params, paramName, value);
}
}
ArrayUtil
是
hutool
中的一個工具函數。用來判斷在一個元素在數組中的下标。
setParam
/**
* 将資料寫入參數類的執行個體中
* @param targetClass
* @param key
* @param value
* @param <T>
*/
private <T> void setParam(T targetClass, String key, String value) {
try {
Method targetClassParamSetMethod = targetClass.getClass().getMethod("set" + this.setFirstLetterUpperCase(key), String.class);
targetClassParamSetMethod.invoke(targetClass, value);
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
e.printStackTrace();
}
}
該函數使用反射的方法,擷取該參數的set方法,将
Param
類中對應的參數設定成傳入的值。
運作
啟動項目,并且請求controller中的方法。并且傳入定義好的參數。
http://localhost:8080/test/8?workOrderNumber=3231732&userId=748327843&name=testName
該
GET
請求總共傳入了4個參數,分别是
id
,
workOrderNumber
userId
name
。大家可以看到,在
Param
類中并沒有定義
name
這個字段。這是特意加了一個不需要記錄的參數,來驗證我們接口的健壯性的。
運作之後,可以看到控制台列印的資訊如下。
Param(id=8, workOrderNumber=3231732, userId=748327843)
我們想讓aop記錄的參數全部記錄到
Param
類中的執行個體中,而傳入了意料之外的參數也沒有讓程式崩潰。接下裡我們隻需要将這些參數,将之前定義好的模闆的參數預留字段替換掉即可。
替換參數
在
doAfterReturning
中的
getRequestParam
函數後,加入以下代碼。
if (!logDetail.isEmpty()) {
// 将模闆中的參數全部替換掉
logDetail = this.replaceParam(logDetail);
}
System.out.println(logDetail);
下面我們實作
replaceParam
replaceParam
/**
* 将模闆中的預留字段全部替換為攔截到的參數
* @param template
* @return
*/
private String replaceParam(String template) {
// 将模闆中的需要替換的參數轉化成map
Map<String, String> paramsMap = this.convertToMap(template);
for (String key : paramsMap.keySet()) {
template = template.replace("%" + key, paramsMap.get(key)).replace("(", "").replace(")", "");
}
return template;
}
convertToMap
方法将模闆中的所有預留字段全部提取出來,當作一個Map的Key。
convertToMap
/**
* 将模闆中的參數轉換成map的key-value形式
* @param template
* @return
*/
private Map<String, String> convertToMap(String template) {
Map<String, String> map = new HashMap<>();
String[] arr = template.split("\\(");
for (String s : arr) {
if (s.contains("%")) {
String key = s.substring(s.indexOf("%"), s.indexOf(")")).replace("%", "").replace(")", "").replace("-", "").replace("]", "");
String value = this.getParam(this.params, key);
map.put(key, "null".equals(value) ? "(空)" : value);
}
}
return map;
}
其中的
getParam
方法,類似于
setParam
,也是利用反射的方法,通過傳入的Class和Key,擷取對應的值。
getParam
/**
* 通過反射擷取傳入的類中對應key的值
* @param targetClass
* @param key
* @param <T>
*/
private <T> String getParam(T targetClass, String key) {
String value = "";
try {
Method targetClassParamGetMethod = targetClass.getClass().getMethod("get" + this.setFirstLetterUpperCase(key));
value = String.valueOf(targetClassParamGetMethod.invoke(targetClass));
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
e.printStackTrace();
}
return value;
}
再次運作
再次請求上述的url,則可以看到控制台的輸出如下。
因 工單号 [3231732] /舉報 ID [8] 警告玩家 [748327843]
可以看到,我們需要記錄的所有的參數,都被正确的替換了。而不需要記錄的參數,同樣也沒有對程式造成影響。
讓我們試試傳入不傳入非必選參數,會是什麼樣。修改controller如下,把workOrderNumber改成非必須按參數。
@Log(type = "WARNING")
@GetMapping("test/{id}")
public String test(
@PathVariable(name = "id") Integer id,
@RequestParam(name = "workOrderNumber", required = false) String workOrderNumber,
@RequestParam(name = "userId") String userId,
@RequestParam(name = "name") String name
) {
return "Hello" + id;
}
請求如下url。
http://localhost:8080/test/8?userId=748327843&name=testName
然後可以看到,控制台的輸出如下。
因 工單号 [空] /舉報 ID [8] 警告玩家 [748327843]
并不會影響程式的正常運作。
擷取複雜參數類型
接下來要介紹的是如何記錄複雜參數類型的日志。其實,大緻的思路是不變的。我們看傳入的類中的參數,有沒有需要記錄的。有的話就按照上面記錄簡單參數的方法來替換記錄參數。
定義測試複雜類型
TestDTO
package spring.aop.log.demo.api.util;
import lombok.Data;
/**
* TestDto
*
* @author Lunhao Hu
* @date 2019-02-01 15:02
**/
@Data
public class TestDTO {
private String name;
private Integer age;
private String email;
}
修改Param
将上面的所有的參數全部添加到
Param
類中,全部定義成字元串類型。
package spring.aop.log.demo.api.util;
import lombok.Data;
/**
* Param
*
* @author Lunhao Hu
* @date 2019-01-30 17:14
**/
@Data
public class Param {
/**
* 所有可能參數
*/
private String id;
private String age;
private String workOrderNumber;
private String userId;
private String name;
private String email;
}
将
WARNING
模闆修改如下。
/**
* 操作類型
*/
WARNING("警告", "因 工單号 [(%workOrderNumber)] /舉報 ID [(%id)] 警告玩家 [(%userId)], 遊戲名 [(%name)], 年齡 [(%age)]");
@Log(type = "WARNING")
@PostMapping("test/{id}")
public String test(
@PathVariable(name = "id") Integer id,
@RequestParam(name = "workOrderNumber", required = false) String workOrderNumber,
@RequestParam(name = "userId") String userId,
@RequestBody TestDTO testDTO
) {
return "Hello" + id;
}
修改getRequestParam
/**
* 擷取攔截的請求中的參數
* @param point
*/
private void getRequestParam() {
// 擷取簡單參數類型
this.getSimpleParam();
// 擷取複雜參數類型
this.getComplexParam();
}
接下來實作
getComplexParam
getComplexParam
/**
* 擷取複雜參數類型的值
*/
private void getComplexParam() {
for (Object arg : this.args) {
// 跳過簡單類型的值
if (arg != null && !this.isBasicType(arg)) {
this.getFieldsParam(arg);
}
}
}
getFieldsParam
/**
* 周遊一個複雜類型,擷取值并指派給param
* @param target
* @param <T>
*/
private <T> void getFieldsParam(T target) {
Field[] fields = target.getClass().getDeclaredFields();
for (Field field : fields) {
String paramName = field.getName();
if (this.isExist(paramName)) {
String value = this.getParam(target, paramName);
this.setParam(this.params, paramName, value);
}
}
}
啟動項目。使用postman對上面的url發起POST請求。請求body中帶上
TestDTO
中的參數。請求成功傳回後就會看到控制台輸出如下。
因 工單号 [空] /舉報 ID [8] 警告玩家 [748327843], 遊戲名 [tom], 年齡 [12]
然後就可以根據需求,将上面的日志記錄到相應的地方。
到這可能有些哥們就覺得行了,萬事具備,隻欠東風。但其實這樣的實作方式,還存在幾個問題。
比如,如果請求失敗了怎麼辦?請求失敗,在需求上将,是根本不需要記錄記錄檔的,但是即使請求失敗也會有傳回值,就代表日志也會成功的記錄。這就給後期檢視日志帶來了很大的困擾。
再比如,如果我需要的參數在傳回值中怎麼辦?如果你沒有用統一的生成唯一id的服務,就會遇到這個問題。就比如我需要往資料庫中插入一條新的資料,我需要得到資料庫自增id,而我們的日志攔截隻攔截了請求中的參數。是以這就是我們接下來要解決的問題。
判斷請求是否成功
實作
success
函數,代碼如下。
/**
* 根據http狀态碼判斷請求是否成功
*
* @param response
* @return
*/
private Boolean success(HttpServletResponse response) {
return response.getStatus() == 200;
}
getRequestParam
之後的所有操作,包括
getRequestParam
本身,用
success
包裹起來。如下。
if (this.success(response)) {
// 從請求傳入參數中擷取資料
this.getRequestParam();
if (!logDetail.isEmpty()) {
// 将模闆中的參數全部替換掉
logDetail = this.replaceParam(logDetail);
}
}
這樣一來,就可以保證隻有在請求成功的前提下,才會記錄日志。
通過反射擷取傳回的參數
建立Result類
在一個項目中,我們用一個類來統一傳回值。
package spring.aop.log.demo.api.util;
import lombok.Data;
/**
* Result
*
* @author Lunhao Hu
* @date 2019-02-01 16:47
**/
@Data
public class Result {
private Integer id;
private String name;
private Integer age;
private String email;
}
@Log(type = "WARNING")
@PostMapping("test")
public Result test(
@RequestParam(name = "workOrderNumber", required = false) String workOrderNumber,
@RequestParam(name = "userId") String userId,
@RequestBody TestDTO testDTO
) {
Result result = new Result();
result.setId(1);
result.setAge(testDTO.getAge());
result.setName(testDTO.getName());
result.setEmail(testDTO.getEmail());
return result;
}
啟動項目,發起POST請求會發現,傳回值如下。
{
"id": 1,
"name": "tom",
"age": 12,
"email": "[email protected]"
}
而控制台的輸出如下。
因 工單号 [39424] /舉報 ID [空] 警告玩家 [748327843], 遊戲名 [tom], 年齡 [12]
可以看到,
id
沒有被擷取到。是以我們還需要添加一個函數,從傳回值中擷取id的資料。
getResponseParam
getRequestParam
後,添加方法
getResponseParam
,直接調用之前寫好的函數。代碼如下。
/**
* 從傳回值從擷取資料
*/
private void getResponseParam(Object value) {
this.getFieldsParam(value);
}
再次發起POST請求,可以發現控制台的輸出如下。
因 工單号 [39424] /舉報 ID [1] 警告玩家 [748327843], 遊戲名 [tom], 年齡 [12]
一旦得到了這條資訊,我們就可以把它記錄到任何我們想記錄的地方。
項目源碼位址
想要參考源碼的大佬請戳
->這裡<-