前言
不久前,因爲需求的原因,需要實現一個操作日誌。幾乎每一個接口被調用後,都要記錄一條跟這個參數掛鉤的特定的日誌到數據庫。舉個例子,就比如禁言操作,日誌中需要記錄因爲什麼禁言,被禁言的人的id和各種信息。方便後期查詢。
這樣的接口有很多個,而且大部分接口的參數都不一樣。可能大家很容易想到的一個思路就是,實現一個日誌記錄的工具類,然後在需要記錄日誌的接口中,添加一行代碼。由這個日誌工具類去判斷此時應該處理哪些參數。
但是這樣有很大的問題。如果需要記日誌的接口數量非常多,先不討論這個工具類中需要做多少的類型判斷,僅僅是給所有接口添加這樣一行代碼在我個人看來都是不能接受的行爲。首先,這樣對代碼的侵入性太大。其次,後期萬一有改動,維護的人將會十分難受。想象一下,全局搜索相同的代碼,再一一進行修改。
所以我放棄了這個略顯原始的方法。我最終採用了Aop的方式,採取攔截的請求的方式,來記錄日誌。但是即使採用這個方法,仍然面臨一個問題,那就是如何處理大量的參數。以及如何對應到每一個接口上。
我最終沒有攔截所有的controller,而是自定義了一個日誌註解。所有打上了這個註解的方法,將會記錄日誌。同時,註解中會帶有類型,來爲當前的接口指定特定的日誌內容以及參數。
<!--more-->
那麼如何從衆多可能的參數中,爲當前的日誌指定對應的參數呢。我的解決方案是維護一個參數類,裏面列舉了所有需要記錄在日誌中的參數名。然後在攔截請求時,通過反射,獲取到該請求的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": "[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]
一旦得到了這條信息,我們就可以把它記錄到任何我們想記錄的地方。
項目源碼地址
想要參考源碼的大佬請戳 ->這裏<-