【最佳實踐】如何優雅的進行重試
- 2019 年 10 月 3 日
- 筆記
本文口味:冰鎮楊梅 預計閱讀:20分鐘
說明
最近公司在搞活動,需要依賴一個第三方介面,測試階段並沒有什麼異常狀況,但上線後發現依賴的介面有時候會因為內部錯誤而返回系統異常,雖然概率不大,但總因為這個而報警總是不好的,何況死信隊列的消息還需要麻煩運維進行重新投遞,所以加上重試機制勢在必行。
重試機制可以保護系統減少因網路波動、依賴服務短暫性不可用帶來的影響,讓系統能更穩定的運行的一種保護機制。讓你原本就穩如狗的系統更是穩上加穩。
為了方便說明,先假設我們想要進行重試的方法如下:
@Slf4j @Component public class HelloService { private static AtomicLong helloTimes = new AtomicLong(); public String hello(){ long times = helloTimes.incrementAndGet(); if (times % 4 != 0){ log.warn("發生異常,time:{}", LocalTime.now() ); throw new HelloRetryException("發生Hello異常"); } return "hello"; } }
調用處:
@Slf4j @Service public class HelloRetryService implements IHelloService{ @Autowired private HelloService helloService; public String hello(){ return helloService.hello(); } }
也就是說,這個介面每調4次才會成功一次。
手動重試
先來用最簡單的方法,直接在調用的時候進重試:
// 手動重試 public String hello(){ int maxRetryTimes = 4; String s = ""; for (int retry = 1; retry <= maxRetryTimes; retry++) { try { s = helloService.hello(); log.info("helloService返回:{}", s); return s; } catch (HelloRetryException e) { log.info("helloService.hello() 調用失敗,準備重試"); } } throw new HelloRetryException("重試次數耗盡"); }
輸出如下:
發生異常,time:10:17:21.079413300 helloService.hello() 調用失敗,準備重試 發生異常,time:10:17:21.085861800 helloService.hello() 調用失敗,準備重試 發生異常,time:10:17:21.085861800 helloService.hello() 調用失敗,準備重試 helloService返回:hello service.helloRetry():hello
程式在極短的時間內進行了4次重試,然後成功返回。
這樣雖然看起來可以解決問題,但實踐上,由於沒有重試間隔,很可能當時依賴的服務尚未從網路異常中恢復過來,所以極有可能接下來的幾次調用都是失敗的。
而且,這樣需要對程式碼進行大量的侵入式修改,顯然,不優雅。
代理模式
上面的處理方式由於需要對業務程式碼進行大量修改,雖然實現了功能,但是對原有程式碼的侵入性太強,可維護性差。
所以需要使用一種更優雅一點的方式,不直接修改業務程式碼,那要怎麼做呢?
其實很簡單,直接在業務程式碼的外面再包一層就行了,代理模式在這裡就有用武之地了。
@Slf4j public class HelloRetryProxyService implements IHelloService{ @Autowired private HelloRetryService helloRetryService; @Override public String hello() { int maxRetryTimes = 4; String s = ""; for (int retry = 1; retry <= maxRetryTimes; retry++) { try { s = helloRetryService.hello(); log.info("helloRetryService 返回:{}", s); return s; } catch (HelloRetryException e) { log.info("helloRetryService.hello() 調用失敗,準備重試"); } } throw new HelloRetryException("重試次數耗盡"); } }
這樣,重試邏輯就都由代理類來完成,原業務類的邏輯就不需要修改了,以後想修改重試邏輯也只需要修改這個類就行了,分工明確。比如,現在想要在重試之間加上一個延遲,只需要做一點點修改即可:
@Override public String hello() { int maxRetryTimes = 4; String s = ""; for (int retry = 1; retry <= maxRetryTimes; retry++) { try { s = helloRetryService.hello(); log.info("helloRetryService 返回:{}", s); return s; } catch (HelloRetryException e) { log.info("helloRetryService.hello() 調用失敗,準備重試"); } // 延時一秒 try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } throw new HelloRetryException("重試次數耗盡"); }
代理模式雖然要更加優雅,但是如果依賴的服務很多的時候,要為每個服務都創建一個代理類,顯然過於麻煩,而且其實重試的邏輯都大同小異,無非就是重試的次數和延時不一樣而已。如果每個類都寫這麼一長串類似的程式碼,顯然,不優雅!
JDK動態代理
這時候,動態代理就閃亮登場了。只需要寫一個代理處理類,就可以開局一條狗,砍到九十九。
@Slf4j public class RetryInvocationHandler implements InvocationHandler { private final Object subject; public RetryInvocationHandler(Object subject) { this.subject = subject; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { int times = 0; while (times < RetryConstant.MAX_TIMES) { try { return method.invoke(subject, args); } catch (Exception e) { times++; log.info("times:{},time:{}", times, LocalTime.now()); if (times >= RetryConstant.MAX_TIMES) { throw new RuntimeException(e); } } // 延時一秒 try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } return null; } /** * 獲取動態代理 * * @param realSubject 代理對象 */ public static Object getProxy(Object realSubject) { InvocationHandler handler = new RetryInvocationHandler(realSubject); return Proxy.newProxyInstance(handler.getClass().getClassLoader(), realSubject.getClass().getInterfaces(), handler); } }
來一發單元測:
@Test public void helloDynamicProxy() { IHelloService realService = new HelloService(); IHelloService proxyService = (IHelloService)RetryInvocationHandler.getProxy(realService); String hello = proxyService.hello(); log.info("hello:{}", hello); }
輸出如下:
hello times:1 發生異常,time:11:22:20.727586700 times:1,time:11:22:20.728083 hello times:2 發生異常,time:11:22:21.728858700 times:2,time:11:22:21.729343700 hello times:3 發生異常,time:11:22:22.729706600 times:3,time:11:22:22.729706600 hello times:4 hello:hello
在重試了4次之後輸出了Hello
,符合預期。
動態代理可以將重試邏輯都放到一塊,顯然比直接使用代理類要方便很多,也更加優雅。
不過不要高興的太早,這裡因為被代理的HelloService是一個簡單的類,沒有依賴其它類,所以直接創建是沒有問題的,但如果被代理的類依賴了其它被Spring容器管理的類,則這種方式會拋出異常,因為沒有把被依賴的實例注入到創建的代理實例中。
這種情況下,就比較複雜了,需要從Spring容器中獲取已經裝配好的,需要被代理的實例,然後為其創建代理類實例,並交給Spring容器來管理,這樣就不用每次都重新創建新的代理類實例了。
話不多說,擼起袖子就是干。
新建一個工具類,用來獲取代理實例:
@Component public class RetryProxyHandler { @Autowired private ConfigurableApplicationContext context; public Object getProxy(Class clazz) { // 1. 從Bean中獲取對象 DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory)context.getAutowireCapableBeanFactory(); Map<String, Object> beans = beanFactory.getBeansOfType(clazz); Set<Map.Entry<String, Object>> entries = beans.entrySet(); if (entries.size() <= 0){ throw new ProxyBeanNotFoundException(); } // 如果有多個候選bean, 判斷其中是否有代理bean Object bean = null; if (entries.size() > 1){ for (Map.Entry<String, Object> entry : entries) { if (entry.getKey().contains(PROXY_BEAN_SUFFIX)){ bean = entry.getValue(); } }; if (bean != null){ return bean; } throw new ProxyBeanNotSingleException(); } Object source = beans.entrySet().iterator().next().getValue(); Object source = beans.entrySet().iterator().next().getValue(); // 2. 判斷該對象的代理對象是否存在 String proxyBeanName = clazz.getSimpleName() + PROXY_BEAN_SUFFIX; Boolean exist = beanFactory.containsBean(proxyBeanName); if (exist) { bean = beanFactory.getBean(proxyBeanName); return bean; } // 3. 不存在則生成代理對象 bean = RetryInvocationHandler.getProxy(source); // 4. 將bean注入spring容器 beanFactory.registerSingleton(proxyBeanName, bean); return bean; } }
使用的是JDK動態代理:
@Slf4j public class RetryInvocationHandler implements InvocationHandler { private final Object subject; public RetryInvocationHandler(Object subject) { this.subject = subject; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { int times = 0; while (times < RetryConstant.MAX_TIMES) { try { return method.invoke(subject, args); } catch (Exception e) { times++; log.info("retry times:{},time:{}", times, LocalTime.now()); if (times >= RetryConstant.MAX_TIMES) { throw new RuntimeException(e); } } // 延時一秒 try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } return null; } /** * 獲取動態代理 * * @param realSubject 代理對象 */ public static Object getProxy(Object realSubject) { InvocationHandler handler = new RetryInvocationHandler(realSubject); return Proxy.newProxyInstance(handler.getClass().getClassLoader(), realSubject.getClass().getInterfaces(), handler); } }
至此,主要程式碼就完成了,修改一下HelloService類,增加一個依賴:
@Slf4j @Component public class HelloService implements IHelloService{ private static AtomicLong helloTimes = new AtomicLong(); @Autowired private NameService nameService; public String hello(){ long times = helloTimes.incrementAndGet(); log.info("hello times:{}", times); if (times % 4 != 0){ log.warn("發生異常,time:{}", LocalTime.now() ); throw new HelloRetryException("發生Hello異常"); } return "hello " + nameService.getName(); } }
NameService其實很簡單,創建的目的僅在於測試依賴注入的Bean能否正常運行。
@Service public class NameService { public String getName(){ return "Frank"; } }
來一發測試:
@Test public void helloJdkProxy() throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException { IHelloService proxy = (IHelloService) retryProxyHandler.getProxy(HelloService.class); String hello = proxy.hello(); log.info("hello:{}", hello); }
hello times:1 發生異常,time:14:40:27.540672200 retry times:1,time:14:40:27.541167400 hello times:2 發生異常,time:14:40:28.541584600 retry times:2,time:14:40:28.542033500 hello times:3 發生異常,time:14:40:29.542161500 retry times:3,time:14:40:29.542161500 hello times:4 hello:hello Frank
完美,這樣就不用擔心依賴注入的問題了,因為從Spring容器中拿到的Bean對象都是已經注入配置好的。當然,這裡僅考慮了單例Bean的情況,可以考慮的更加完善一點,判斷一下容器中Bean的類型是Singleton還是Prototype,如果是Singleton則像上面這樣進行操作,如果是Prototype則每次都新建代理類對象。
另外,這裡使用的是JDK動態代理,因此就存在一個天然的缺陷,如果想要被代理的類,沒有實現任何介面,那麼就無法為其創建代理對象,這種方式就行不通了。
CGLib 動態代理
既然已經說到了JDK動態代理,那就不得不提CGLib動態代理了。使用JDK動態代理對被代理的類有要求,不是所有的類都能被代理,而CGLib動態代理則剛好解決了這個問題。
創建一個CGLib動態代理類:
@Slf4j public class CGLibRetryProxyHandler implements MethodInterceptor { private Object target;//需要代理的目標對象 //重寫攔截方法 @Override public Object intercept(Object obj, Method method, Object[] arr, MethodProxy proxy) throws Throwable { int times = 0; while (times < RetryConstant.MAX_TIMES) { try { return method.invoke(target, arr); } catch (Exception e) { times++; log.info("cglib retry :{},time:{}", times, LocalTime.now()); if (times >= RetryConstant.MAX_TIMES) { throw new RuntimeException(e); } } // 延時一秒 try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } return null; } //定義獲取代理對象方法 public Object getCglibProxy(Object objectTarget){ this.target = objectTarget; Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(objectTarget.getClass()); enhancer.setCallback(this); Object result = enhancer.create(); return result; } }
想要換用CGLib動態代理,替換一下這兩行程式碼即可:
// 3. 不存在則生成代理對象 // bean = RetryInvocationHandler.getProxy(source); CGLibRetryProxyHandler proxyHandler = new CGLibRetryProxyHandler(); bean = proxyHandler.getCglibProxy(source);
開始測試:
@Test public void helloCGLibProxy() { IHelloService proxy = (IHelloService) retryProxyHandler.getProxy(HelloService.class); String hello = proxy.hello(); log.info("hello:{}", hello); hello = proxy.hello(); log.info("hello:{}", hello); }
hello times:1 發生異常,time:15:06:00.799679100 cglib retry :1,time:15:06:00.800175400 hello times:2 發生異常,time:15:06:01.800848600 cglib retry :2,time:15:06:01.801343100 hello times:3 發生異常,time:15:06:02.802180 cglib retry :3,time:15:06:02.802180 hello times:4 hello:hello Frank hello times:5 發生異常,time:15:06:03.803933800 cglib retry :1,time:15:06:03.803933800 hello times:6 發生異常,time:15:06:04.804945400 cglib retry :2,time:15:06:04.805442 hello times:7 發生異常,time:15:06:05.806886500 cglib retry :3,time:15:06:05.807881300 hello times:8 hello:hello Frank
這樣就很棒了,完美的解決了JDK動態代理帶來的缺陷。優雅指數上漲了不少。
但這個方案仍舊存在一個問題,那就是需要對原來的邏輯進行侵入式修改,在每個被代理實例被調用的地方都需要進行調整,這樣仍然會對原有程式碼帶來較多修改。
Spring AOP
想要無侵入式的修改原有邏輯?想要一個註解就實現重試?用Spring AOP不就能完美實現嗎?使用AOP來為目標調用設置切面,即可在目標方法調用前後添加一些額外的邏輯。
先創建一個註解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Retryable { int retryTimes() default 3; int retryInterval() default 1; }
有兩個參數,retryTimes 代表最大重試次數,retryInterval代表重試間隔。
然後在需要重試的方法上加上註解:
@Retryable(retryTimes = 4, retryInterval = 2) public String hello(){ long times = helloTimes.incrementAndGet(); log.info("hello times:{}", times); if (times % 4 != 0){ log.warn("發生異常,time:{}", LocalTime.now() ); throw new HelloRetryException("發生Hello異常"); } return "hello " + nameService.getName(); }
接著,進行最後一步,編寫AOP切面:
@Slf4j @Aspect @Component public class RetryAspect { @Pointcut("@annotation(com.mfrank.springboot.retry.demo.annotation.Retryable)") private void retryMethodCall(){} @Around("retryMethodCall()") public Object retry(ProceedingJoinPoint joinPoint) throws InterruptedException { // 獲取重試次數和重試間隔 Retryable retry = ((MethodSignature)joinPoint.getSignature()).getMethod().getAnnotation(Retryable.class); int maxRetryTimes = retry.retryTimes(); int retryInterval = retry.retryInterval(); Throwable error = new RuntimeException(); for (int retryTimes = 1; retryTimes <= maxRetryTimes; retryTimes++){ try { Object result = joinPoint.proceed(); return result; } catch (Throwable throwable) { error = throwable; log.warn("調用發生異常,開始重試,retryTimes:{}", retryTimes); } Thread.sleep(retryInterval * 1000); } throw new RetryExhaustedException("重試次數耗盡", error); } }
開始測試:
@Autowired private HelloService helloService; @Test public void helloAOP(){ String hello = helloService.hello(); log.info("hello:{}", hello); }
輸出如下:
hello times:1 發生異常,time:16:49:30.224649800 調用發生異常,開始重試,retryTimes:1 hello times:2 發生異常,time:16:49:32.225230800 調用發生異常,開始重試,retryTimes:2 hello times:3 發生異常,time:16:49:34.225968900 調用發生異常,開始重試,retryTimes:3 hello times:4 hello:hello Frank
這樣就相當優雅了,一個註解就能搞定重試,簡直不要更棒。
Spring 的重試註解
實際上Spring中就有比較完善的重試機制,比上面的切面更加好用,還不需要自己動手重新造輪子。
那讓我們先來看看這個輪子究竟好不好使。
先引入重試所需的jar包:
<dependency> <groupId>org.springframework.retry</groupId> <artifactId>spring-retry</artifactId> </dependency> <dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjweaver</artifactId> </dependency>
然後在啟動類或者配置類上添加@EnableRetry註解,接下來在需要重試的方法上添加@Retryable註解(嗯?好像跟我自定義的註解一樣?竟然抄襲我的註解! [手動滑稽] )
@Retryable public String hello(){ long times = helloTimes.incrementAndGet(); log.info("hello times:{}", times); if (times % 4 != 0){ log.warn("發生異常,time:{}", LocalTime.now() ); throw new HelloRetryException("發生Hello異常"); } return "hello " + nameService.getName(); }
默認情況下,會重試三次,重試間隔為1秒。當然我們也可以自定義重試次數和間隔。這樣就跟我前面實現的功能是一毛一樣的了。
但Spring里的重試機制還支援很多很有用的特性,比如說,可以指定只對特定類型的異常進行重試,這樣如果拋出的是其它類型的異常則不會進行重試,就可以對重試進行更細粒度的控制。默認為空,會對所有異常都重試。
@Retryable{value = {HelloRetryException.class}} public String hello(){2 ... }
也可以使用include和exclude來指定包含或者排除哪些異常進行重試。
可以用maxAttemps指定最大重試次數,默認為3次。
可以用interceptor設置重試攔截器的bean名稱。
可以通過label設置該重試的唯一標誌,用於統計輸出。
可以使用exceptionExpression來添加異常表達式,在拋出異常後執行,以判斷後續是否進行重試。
此外,Spring中的重試機制還支援使用backoff來設置重試補償機制,可以設置重試間隔,並且支援設置重試延遲倍數。
舉個例子:
@Retryable(value = {HelloRetryException.class}, maxAttempts = 5, backoff = @Backoff(delay = 1000, multiplier = 2)) public String hello(){ ... }
該方法調用將會在拋出HelloRetryException異常後進行重試,最大重試次數為5,第一次重試間隔為1s,之後以2倍大小進行遞增,第二次重試間隔為2s,第三次為4s,第四次為8s。
重試機制還支援使用@Recover 註解來進行善後工作,當重試達到指定次數之後,將會調用該方法,可以在該方法中進行日誌記錄等操作。
這裡值得注意的是,想要@Recover 註解生效的話,需要跟被@Retryable 標記的方法在同一個類中,且被@Retryable 標記的方法不能有返回值,否則不會生效。
並且如果使用了@Recover註解的話,重試次數達到最大次數後,如果在@Recover標記的方法中無異常拋出,是不會拋出原異常的。
@Recover public boolean recover(Exception e) { log.error("達到最大重試次數",e); return false; }
除了使用註解外,Spring Retry 也支援直接在調用時使用程式碼進行重試:
@Test public void normalSpringRetry() { // 表示哪些異常需要重試,key表示異常的位元組碼,value為true表示需要重試 Map<Class<? extends Throwable>, Boolean> exceptionMap = new HashMap<>(); exceptionMap.put(HelloRetryException.class, true); // 構建重試模板實例 RetryTemplate retryTemplate = new RetryTemplate(); // 設置重試回退操作策略,主要設置重試間隔時間 FixedBackOffPolicy backOffPolicy = new FixedBackOffPolicy(); long fixedPeriodTime = 1000L; backOffPolicy.setBackOffPeriod(fixedPeriodTime); // 設置重試策略,主要設置重試次數 int maxRetryTimes = 3; SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy(maxRetryTimes, exceptionMap); retryTemplate.setRetryPolicy(retryPolicy); retryTemplate.setBackOffPolicy(backOffPolicy); Boolean execute = retryTemplate.execute( //RetryCallback retryContext -> { String hello = helloService.hello(); log.info("調用的結果:{}", hello); return true; }, // RecoverCallBack retryContext -> { //RecoveryCallback log.info("已達到最大重試次數"); return false; } ); }
此時唯一的好處是可以設置多種重試策略:
NeverRetryPolicy:只允許調用RetryCallback一次,不允許重試 AlwaysRetryPolicy:允許無限重試,直到成功,此方式邏輯不當會導致死循環 SimpleRetryPolicy:固定次數重試策略,默認重試最大次數為3次,RetryTemplate默認使用的策略 TimeoutRetryPolicy:超時時間重試策略,默認超時時間為1秒,在指定的超時時間內允許重試 ExceptionClassifierRetryPolicy:設置不同異常的重試策略,類似組合重試策略,區別在於這裡只區分不同異常的重試 CircuitBreakerRetryPolicy:有熔斷功能的重試策略,需設置3個參數openTimeout、resetTimeout和delegate CompositeRetryPolicy:組合重試策略,有兩種組合方式,樂觀組合重試策略是指只要有一個策略允許即可以重試, 悲觀組合重試策略是指只要有一個策略不允許即可以重試,但不管哪種組合方式,組合中的每一個策略都會執行
可以看出,Spring中的重試機制還是相當完善的,比上面自己寫的AOP切面功能更加強大。
這裡還需要再提醒的一點是,由於Spring Retry用到了Aspect增強,所以就會有使用Aspect不可避免的坑——方法內部調用,如果被 @Retryable 註解的方法的調用方和被調用方處於同一個類中,那麼重試將會失效。
但也還是存在一定的不足,Spring的重試機制只支援對異常進行捕獲,而無法對返回值進行校驗。
Guava Retry
最後,再介紹另一個重試利器——Guava Retry。
相比Spring Retry,Guava Retry具有更強的靈活性,可以根據返回值校驗來判斷是否需要進行重試。
先來看一個小栗子:
先引入jar包:
<dependency> <groupId>com.github.rholder</groupId> <artifactId>guava-retrying</artifactId> <version>2.0.0</version> </dependency>
然後用一個小Demo來感受一下:
@Test public void guavaRetry() { Retryer<String> retryer = RetryerBuilder.<String>newBuilder() .retryIfExceptionOfType(HelloRetryException.class) .retryIfResult(StringUtils::isEmpty) .withWaitStrategy(WaitStrategies.fixedWait(3, TimeUnit.SECONDS)) .withStopStrategy(StopStrategies.stopAfterAttempt(3)) .build(); try { retryer.call(() -> helloService.hello()); } catch (Exception e){ e.printStackTrace(); } }
先創建一個Retryer實例,然後使用這個實例對需要重試的方法進行調用,可以通過很多方法來設置重試機制,比如使用retryIfException來對所有異常進行重試,使用retryIfExceptionOfType方法來設置對指定異常進行重試,使用retryIfResult來對不符合預期的返回結果進行重試,使用retryIfRuntimeException方法來對所有RuntimeException進行重試。
還有五個以with開頭的方法,用來對重試策略/等待策略/阻塞策略/單次任務執行時間限制/自定義監聽器進行設置,以實現更加強大的異常處理。
通過跟Spring AOP的結合,可以實現比Spring Retry更加強大的重試功能。
仔細對比之下,Guava Retry可以提供的特性有:
- 可以設置任務單次執行的時間限制,如果超時則拋出異常。
- 可以設置重試監聽器,用來執行額外的處理工作。
- 可以設置任務阻塞策略,即可以設置當前重試完成,下次重試開始前的這段時間做什麼事情。
- 可以通過停止重試策略和等待策略結合使用來設置更加靈活的策略,比如指數等待時長並最多10次調用,隨機等待時長並永不停止等等。
總結
本文由淺入深的對多種重試的姿勢進行了360度無死角教學,從最簡單的手動重試,到使用靜態代理,再到JDK動態代理和CGLib動態代理,再到Spring AOP,都是手工造輪子的過程,最後介紹了兩種目前比較好用的輪子,一個是Spring Retry,使用起來簡單粗暴,與Spring框架天生搭配,一個註解搞定所有事情,另一個便是Guava Retry,不依賴於Spring框架,自成體系,使用起來更加靈活強大。
個人認為,大部分場景下,Spring Retry提供的重試機制已經足夠強大,如果不需要Guava Retry提供的額外靈活性,使用Spring Retry就很棒了。當然,具體情況具體分析,但沒有必要的情況下,不鼓勵重複造輪子,先把別人的輪子研究清楚再想想還用不用自己動手。
本文到此就告一段落了,又用了一天的時間完成了完成了一篇文章,寫作的目的在於總結和分享,我相信最佳實踐是可以總結和積累下來的,在大多數場景下都是適用的,這些最佳實踐會在逐漸的積累過程中,成為比經驗更為重要的東西。因為經驗不總結就會忘記,而總結出來的內容卻不會被丟失。
如果對於重試你有更好的想法,歡迎提出交流探討,也歡迎關注我的公眾號進行留言交流。