不久前,由於需求的緣由,須要實現一個操做日誌。幾乎每個接口被調用後,都要記錄一條跟這個參數掛鉤的特定的日誌到數據庫。舉個例子,就好比禁言操做,日誌中須要記錄由於什麼禁言,被禁言的人的id和各類信息。方便後期查詢。java
這樣的接口有不少個,並且大部分接口的參數都不同。可能你們很容易想到的一個思路就是,實現一個日誌記錄的工具類,而後在須要記錄日誌的接口中,添加一行代碼。由這個日誌工具類去判斷此時應該處理哪些參數。git
可是這樣有很大的問題。若是須要記日誌的接口數量很是多,先不討論這個工具類中須要作多少的類型判斷,僅僅是給全部接口添加這樣一行代碼在我我的看來都是不能接受的行爲。首先,這樣對代碼的侵入性太大。其次,後期萬一有改動,維護的人將會十分難受。想象一下,全局搜索相同的代碼,再一一進行修改。github
因此我放棄了這個略顯原始的方法。我最終採用了Aop的方式,採起攔截的請求的方式,來記錄日誌。可是即便採用這個方法,仍然面臨一個問題,那就是如何處理大量的參數。以及如何對應到每個接口上。web
我最終沒有攔截全部的controller,而是自定義了一個日誌註解。全部打上了這個註解的方法,將會記錄日誌。同時,註解中會帶有類型,來爲當前的接口指定特定的日誌內容以及參數。spring
那麼如何從衆多可能的參數中,爲當前的日誌指定對應的參數呢。個人解決方案是維護一個參數類,裏面列舉了全部須要記錄在日誌中的參數名。而後在攔截請求時,經過反射,獲取到該請求的request和response中的全部參數和值,若是該參數存在於我維護的param類中,則將對應的值賦值進去。數據庫
而後在請求結束後,將模板中的全部預留的參數所有用賦了值的參數替換掉。這樣一來,在不大量的侵入業務的前提下,知足了需求,同時也保證了代碼的可維護性。後端
下面我將會把詳細的實現過程列舉出來。api
文章結尾我會給出這個demo項目的全部源碼。因此不想看過程的兄臺可移步到末尾,直接看源碼。(據說和源碼搭配,看文章更美味...)數組
你們能夠參考我以前寫的另外一篇文章,手把手教你從零開始搭建SpringBoot後端項目框架。只要能請求簡單的接口就能夠了。本項目的依賴以下。bash
<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>
新建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類中的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攔截到的請求中的全部參數,若是個人蔘數類(全部要記錄的參數)裏面有請求中的參數,那麼我就將參數的值寫入參數類中。最後將日誌模版中參數預留字段替換成請求中的參數。
流程圖以下所示。
新建一個類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加上上述模板中國呢的參數。部分代碼以下。
@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
方法。
/** * 獲取攔截的請求中的參數 * @param point */ private void getRequestParam() { // 獲取簡單參數類型 this.getSimpleParam(); }
/** * 獲取簡單參數類型的值 */ private void getSimpleParam() { // 遍歷請求中的參數名 for (String reqParam : this.paramNames) { // 判斷該參數在參數類中是否存在 if (this.isExist(reqParam)) { this.setRequestParamValueIntoParam(reqParam); } } }
上述代碼中,遍歷請求所傳入的參數名,而後咱們實現isExist
方法, 來判斷這個參數在咱們的Param
類中是否存在,若是存在咱們就再調用setRequestParamValueIntoParam
方法,將這個參數名所對應的參數值寫入到Param
類的實例中。
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
方法,來將咱們傳入的參數名的首字母變成大寫。
代碼以下。
/** * 將字符串的首字母大寫 * * @param str * @return */ private String setFirstLetterUpperCase(String str) { if (str == null) { return null; } return str.substring(0, 1).toUpperCase() + str.substring(1); }
代碼以下。
/** * 從參數中獲取 * @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
中的一個工具函數。用來判斷在一個元素在數組中的下標。
代碼以下。
/** * 將數據寫入參數類的實例中 * @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
方法。
代碼以下。
/** * 將模板中的預留字段所有替換爲攔截到的參數 * @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。
代碼以下。
/** * 將模板中的參數轉換成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,獲取對應的值。
代碼以下。
/** * 經過反射獲取傳入的類中對應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
類中,所有定義成字符串類型。
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; }
/** * 獲取攔截的請求中的參數 * @param point */ private void getRequestParam() { // 獲取簡單參數類型 this.getSimpleParam(); // 獲取複雜參數類型 this.getComplexParam(); }
接下來實現getComplexParam
方法。
/** * 獲取複雜參數類型的值 */ private void getComplexParam() { for (Object arg : this.args) { // 跳過簡單類型的值 if (arg != null && !this.isBasicType(arg)) { this.getFieldsParam(arg); } } }
/** * 遍歷一個複雜類型,獲取值並賦值給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); } }
這樣一來,就能夠保證只有在請求成功的前提下,纔會記錄日誌。
在一個項目中,咱們用一個類來統一返回值。
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": "test@test.com" }
而控制檯的輸出以下。
因 工單號 [39424] /舉報 ID [空] 警告玩家 [748327843], 遊戲名 [tom], 年齡 [12]
能夠看到,id
沒有被獲取到。因此咱們還須要添加一個函數,從返回值中獲取id的數據。
在getRequestParam
後,添加方法getResponseParam
,直接調用以前寫好的函數。代碼以下。
/** * 從返回值從獲取數據 */ private void getResponseParam(Object value) { this.getFieldsParam(value); }
再次發起POST請求,能夠發現控制檯的輸出以下。
因 工單號 [39424] /舉報 ID [1] 警告玩家 [748327843], 遊戲名 [tom], 年齡 [12]
一旦獲得了這條信息,咱們就能夠把它記錄到任何咱們想記錄的地方。
想要參考源碼的大佬請戳 ->這裏<-