摘要:幾乎每一個接口被調用后,都要記錄一條跟這個參數掛鉤的特定的日志到數據庫。我最終采用了的方式,采取攔截的請求的方式,來記錄日志。所有打上了這個注解的方法,將會記錄日志。那么如何從眾多可能的參數中,為當前的日志指定對應的參數呢。
前言
不久前,因為需求的原因,需要實現一個操作日志。幾乎每一個接口被調用后,都要記錄一條跟這個參數掛鉤的特定的日志到數據庫。舉個例子,就比如禁言操作,日志中需要記錄因為什么禁言,被禁言的人的id和各種信息。方便后期查詢。
這樣的接口有很多個,而且大部分接口的參數都不一樣。可能大家很容易想到的一個思路就是,實現一個日志記錄的工具類,然后在需要記錄日志的接口中,添加一行代碼。由這個日志工具類去判斷此時應該處理哪些參數。
但是這樣有很大的問題。如果需要記日志的接口數量非常多,先不討論這個工具類中需要做多少的類型判斷,僅僅是給所有接口添加這樣一行代碼在我個人看來都是不能接受的行為。首先,這樣對代碼的侵入性太大。其次,后期萬一有改動,維護的人將會十分難受。想象一下,全局搜索相同的代碼,再一一進行修改。
所以我放棄了這個略顯原始的方法。我最終采用了Aop的方式,采取攔截的請求的方式,來記錄日志。但是即使采用這個方法,仍然面臨一個問題,那就是如何處理大量的參數。以及如何對應到每一個接口上。
我最終沒有攔截所有的controller,而是自定義了一個日志注解。所有打上了這個注解的方法,將會記錄日志。同時,注解中會帶有類型,來為當前的接口指定特定的日志內容以及參數。
那么如何從眾多可能的參數中,為當前的日志指定對應的參數呢。我的解決方案是維護一個參數類,里面列舉了所有需要記錄在日志中的參數名。然后在攔截請求時,通過反射,獲取到該請求的request和response中的所有參數和值,如果該參數存在于我維護的param類中,則將對應的值賦值進去。
然后在請求結束后,將模板中的所有預留的參數全部用賦了值的參數替換掉。這樣一來,在不大量的侵入業務的前提下,滿足了需求,同時也保證了代碼的可維護性。
下面我將會把詳細的實現過程列舉出來。
開始操作前文章結尾我會給出這個demo項目的所有源碼。所以不想看過程的兄臺可移步到末尾,直接看源碼。(聽說和源碼搭配,看文章更美味...)
開始操作 新建項目大家可以參考我之前寫的另一篇文章,手把手教你從零開始搭建SpringBoot后端項目框架。只要能請求簡單的接口就可以了。本項目的依賴如下。
新建Aop類org.springframework.boot spring-boot-starter-web 2.1.1.RELEASE org.aspectj aspectjrt 1.9.2 org.aspectj aspectjweaver 1.9.2 org.projectlombok lombok 1.18.2 cn.hutool hutool-all 4.1.14
新建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類的實例中。
isExistisExist的代碼如下。
/** * 判斷該參數在參數類中是否存在(是否是需要記錄的參數) * @param targetClass * @param name * @param* @return */ private 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*/ private 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 MapparamsMap = 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 MapconvertToMap(String template) { Map 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再次運行*/ private 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運行*/ private 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]
一旦得到了這條信息,我們就可以把它記錄到任何我們想記錄的地方。
項目源碼地址想要參考源碼的大佬請戳 ->這里<-
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/77415.html
摘要:在領域,有兩大主流的安全框架,和。角色角色是一組權限的集合。安全框架的實現注解的實現本套安全框架一共定義了四個注解。該注解用來告訴安全框架,本項目中所有類所在的包,從而能夠幫助安全框架快速找到類,避免了所有類的掃描。 寫在最前 本文是《手把手項目實戰系列》的第三篇文章,預告一下,整個系列會介紹如下內容: 《手把手0基礎項目實戰(一)——教你搭建一套可自動化構建的微服務框架(Sprin...
摘要:可以通過傳入待刪除數組元素組成的數組進行一次性刪除。如果后臺返回的為表示登錄的已失效,需要重新執行。等所有的異步執行完畢后,再執行回調函數。回調函數的參數是每個函數返回數據組成的數組。 其實在早之前,就做過立馬理財的銷售額統計,只不過是用前端js寫的,需要在首頁的console調試面板里粘貼一段代碼執行,點擊這里。主要是通過定時爬取https://www.lmlc.com/s/web/...
摘要:從使用到原理學習線程池關于線程池的使用,及原理分析分析角度新穎面向切面編程的基本用法基于注解的實現在軟件開發中,分散于應用中多出的功能被稱為橫切關注點如事務安全緩存等。 Java 程序媛手把手教你設計模式中的撩妹神技 -- 上篇 遇一人白首,擇一城終老,是多么美好的人生境界,她和他歷經風雨慢慢變老,回首走過的點點滴滴,依然清楚的記得當初愛情萌芽的模樣…… Java 進階面試問題列表 -...
閱讀 1923·2021-11-19 09:40
閱讀 2132·2021-10-09 09:43
閱讀 3293·2021-09-06 15:00
閱讀 2809·2019-08-29 13:04
閱讀 2766·2019-08-26 11:53
閱讀 3512·2019-08-26 11:46
閱讀 2319·2019-08-26 11:38
閱讀 390·2019-08-26 11:27