前言
不久前,因為需求的原因,需要實現一個操作日誌。幾乎每一個介面被呼叫後,都要記錄一條跟這個引數掛鉤的特定的日誌到資料庫。舉個例子,就比如禁言操作,日誌中需要記錄因為什麼禁言,被禁言的人的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攔截到的請求中的所有引數,如果我的引數類(所有要記錄的引數)裡面有請求中的引數,那麼我就將引數的值寫入引數類中。最後將日誌模版中引數預留欄位替換成請求中的引數。
流程圖如下所示。
新建引數類
新建一個類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)]");
修改controller
@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;
}
修改controller
@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": "test@test.com"
}
而控制檯的輸出如下。
因 工單號 [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]
一旦得到了這條資訊,我們就可以把它記錄到任何我們想記錄的地方。
專案原始碼地址
想要參考原始碼的大佬請戳 ->這裡<-