面試官:要不你給我說說什麼是長輪詢吧?

程序員橙小梓 發佈 2022-07-26T00:18:27.732946+00:00

傳統的靜態配置方式想要修改某個配置時,必須重新啟動一次應用,如果是資料庫連接串的變更,那可能還容易接受一些,但如果變更的是一些運行時實時感知的配置,如某個功能項的開關,重啟應用就顯得有點大動干戈了。

最近在看配置中心相關的源碼,主要想看看動態推送相關的部分。

在這個過程中看到徐媽之前寫的一篇文章,很不錯,分享給你。

前言

傳統的靜態配置方式想要修改某個配置時,必須重新啟動一次應用,如果是資料庫連接串的變更,那可能還容易接受一些,但如果變更的是一些運行時實時感知的配置,如某個功能項的開關,重啟應用就顯得有點大動干戈了。

配置中心正是為了解決此類問題應運而生的,特別是在微服務架構體系中,更傾向於使用配置中心來統一管理配置。

配置中心最核心的能力就是配置的動態推送,常見的配置中心如 Nacos、Apollo 等都實現了這樣的能力。

在早期接觸配置中心時,我就很好奇,配置中心是如何做到服務端感知配置變化實時推送給客戶端的?

在沒有研究過配置中心的實現原理之前,我一度認為配置中心是通過長連接來做到配置推送的。

事實上,目前比較流行的兩款配置中心:Nacos 和 Apollo 恰恰都沒有使用長連接,而是使用的長輪詢。

本文便是介紹一下長輪詢這種聽起來好像已經是上個世紀的技術,老戲新唱,看看能不能品出別樣的韻味。

文中會有代碼示例,呈現一個簡易的配置監聽流程。

數據交互模式

眾所周知,數據交互有兩種模式:Push(推模式)和 Pull(拉模式)。

推模式指的是客戶端與服務端建立好網絡長連接,服務方有相關數據,直接通過長連接通道推送到客戶端。

其優點是及時,一旦有數據變更,客戶端立馬能感知到;另外對客戶端來說邏輯簡單,不需要關心有無數據這些邏輯處理。缺點是不知道客戶端的數據消費能力,可能導致數據積壓在客戶端,來不及處理。

拉模式指的是客戶端主動向服務端發出請求,拉取相關數據。

其優點是此過程由客戶端發起請求,故不存在推模式中數據積壓的問題。缺點是可能不夠及時,對客戶端來說需要考慮數據拉取相關邏輯,何時去拉,拉的頻率怎麼控制等等。

長輪詢與輪詢

在開頭,重點介紹一下長輪詢(Long Polling)和輪詢(Polling)的區別,兩者都是拉模式的實現。

「輪詢」是指不管服務端數據有無更新,客戶端每隔定長時間請求拉取一次數據,可能有更新數據返回,也可能什麼都沒有。

配置中心如果使用「輪詢」實現動態推送,會有以下問題:

  • 推送延遲。客戶端每隔 5s 拉取一次配置,若配置變更發生在第 6s,則配置推送的延遲會達到 4s。
  • 服務端壓力。配置一般不會發生變化,頻繁的輪詢會給服務端造成很大的壓力。
  • 推送延遲和服務端壓力無法中和。降低輪詢的間隔,延遲降低,壓力增加;增加輪詢的間隔,壓力降低,延遲增高。

「長輪詢」則不存在上述的問題。

客戶端發起長輪詢,如果服務端的數據沒有發生變更,會 hold 住請求,直到服務端的數據發生變化,或者等待一定時間超時才會返回。返回後,客戶端又會立即再次發起下一次長輪詢。

配置中心使用「長輪詢」如何解決「輪詢」遇到的問題也就顯而易見了:

  • 推送延遲。服務端數據發生變更後,長輪詢結束,立刻返迴響應給客戶端。
  • 服務端壓力。長輪詢的間隔期一般很長,例如 30s、60s,並且服務端 hold 住連接不會消耗太多服務端資源。

以 Nacos 為例的長輪詢流程如下:

可能有人會有疑問,為什麼一次長輪詢需要等待一定時間超時,超時後又發起長輪詢,為什麼不讓服務端一直 hold 住?

主要有兩個層面的考慮,一是連接穩定性的考慮,長輪詢在傳輸層本質上還是走的 TCP 協議,如果服務端假死、fullgc 等異常問題,或者是重啟等常規操作,長輪詢沒有應用層的心跳機制,僅僅依靠 TCP 層的心跳保活很難確保可用性,所以一次長輪詢設置一定的超時時間也是在確保可用性。

除此之外,在配置中心場景,還有一定的業務需求需要這麼設計。

在配置中心的使用過程中,用戶可能隨時新增配置監聽,而在此之前,長輪詢可能已經發出,新增的配置監聽無法包含在舊的長輪詢中,所以在配置中心的設計中,一般會在一次長輪詢結束後,將新增的配置監聽給捎帶上,而如果長輪詢沒有超時時間,只要配置一直不發生變化,響應就無法返回,新增的配置也就沒法設置監聽了。

配置中心長輪詢設計

上文的圖中,介紹了長輪詢的流程,本節會詳解配置中心長輪詢的設計細節。

客戶端發起長輪詢

客戶端發起一個 HTTP 請求,請求信息包含配置中心的地址,以及監聽的 dataId(本文出於簡化說明的考慮,認為 dataId 是定位配置的唯一鍵)。若配置沒有發生變化,客戶端與服務端之間一直處於連接狀態。

服務端監聽數據變化

服務端會維護 dataId 和長輪詢的映射關係,如果配置發生變化,服務端會找到對應的連接,為響應寫入更新後的配置內容。如果超時內配置未發生變化,服務端找到對應的超時長輪詢連接,寫入 304 響應。

304 在 HTTP 響應碼中代表「未改變」,並不代表錯誤。比較契合長輪詢時,配置未發生變更的場景。

客戶端接收長輪詢響應

首先查看響應碼是 200 還是 304,以判斷配置是否變更,做出相應的回調。之後再次發起下一次長輪詢。

服務端設置配置寫入的接入點

主要用配置控制台和 client 發布配置,觸發配置變更

這幾點便是配置中心實現長輪詢的核心步驟,也是指導下面章節代碼實現的關鍵。但在編碼之前,仍有一些其他的注意點需要實現闡明。

配置中心往往是為分布式的集群提供服務的,而每個機器上部署的應用,又會有多個 dataId 需要監聽,實例級別 * 配置數是一個不小的數字,配置中心服務端維護這些 dataId 的長輪詢連接顯然不能用線程一一對應,否則會導致服務端線程數爆炸式增長。

一個 Tomcat 默認也就 200 個線程,長輪詢也不應該阻塞 Tomcat 的業務線程,所以需要配置中心在實現長輪詢時,往往採用異步響應的方式來實現。

而比較方便實現異步 HTTP 的常見手段便是 Servlet3.0 提供的 AsyncContext 機制。

Servlet3.0 並不是一個特別新的規範,它跟 Java 6 是同一時期的產物。例如 SpringBoot 內嵌的 Tomcat 很早就支持了 Servlet3.0,你無需擔心 AsyncContext 機制不起作用。

SpringMVC 實現了 DeferredResult 和 Servlet3.0 提供的 AsyncContext 其實沒有多大區別,我並沒有深入研究過兩個實現背後的源碼,但從使用層面上來看,AsyncContext 更加的靈活,例如其可以自定義響應碼,而 DeferredResult 在上層做了封裝,可以快速的幫助開發者實現一個異步響應,但沒法細粒度地控制響應。

所以下文的示例中,我選擇了 AsyncContext。

配置中心長輪詢實現

客戶端實現

<pre class="prettyprint hljs cmake" style="padding: 0.5em; font-family: Menlo, Monaco, Consolas, "Courier New", monospace; color: rgb(68, 68, 68); border-radius: 4px; display: block; margin: 0px 0px 1.5em; font-size: 14px; line-height: 1.5em; word-break: break-all; overflow-wrap: break-word; white-space: pre; background-color: rgb(246, 246, 246); border: none; overflow-x: auto; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;">`@Slf4j
public class ConfigClient {

    private CloseableHttpClient httpClient;
    private requestConfig requestConfig;

    public ConfigClient() {
        this.httpClient = HttpClientBuilder.create().build();
        // ① httpClient 客戶端超時時間要大於長輪詢約定的超時時間
        this.requestConfig = RequestConfig.custom().setSocketTimeout(40000).build();
    }

    @SneakyThrows
    public void longPolling(String url, String dataId) {
        String endpoint = url + "?dataId=" + dataId;
        HttpGet request = new HttpGet(endpoint);
        CloseableHttpResponse response = httpClient.execute(request);
        switch (response.getStatusLine().getStatusCode()) {
            case 200: {
                BufferedReader rd = new BufferedReader(new InputStreamReader(response.getEntity()
                    .getContent()));
                StringBuilder result = new StringBuilder();
                String line;
                while ((line = rd.readLine()) != null) {
                    result.append(line);
                }
                response.close();
                String configInfo = result.toString();
                log.info("dataId: [{}] changed, receive configInfo: {}", dataId, configInfo);
                longPolling(url, dataId);
                break;
            }
            // ② 304 響應碼標記配置未變更
            case 304: {
                log.info("longPolling dataId: [{}] once finished, configInfo is unchanged, longPolling again", dataId);
                longPolling(url, dataId);
                break;
            }
            default: {
                throw new RuntimeException("unExcepted HTTP status code");
            }
        }

    }

    public static void main(String[] args) {
        // httpClient 會列印很多 debug 日誌,關閉掉
        Logger logger = (Logger)LoggerFactory.getLogger("org.apache.http");
        logger.setLevel(Level.INFO);
        logger.setAdditive(false);

        ConfigClient configClient = new ConfigClient();
        // ③ 對 dataId: user 進行配置監聽 
        configClient.longPolling("http://127.0.0.1:8080/listener", "user");
    }

}` </pre>

主要有三個注意點:

  • RequestConfig.custom().setSocketTimeout(40000).build() 。httpClient 客戶端超時時間要大於長輪詢約定的超時時間。很好理解,不然還沒等服務端返回,客戶端會自行斷開 HTTP 連接。
  • response.getStatusLine().getStatusCode() == 304 。前文介紹過,約定使用 304 響應碼來標識配置未發生變更,客戶端繼續發起長輪詢。
  • configClient.longPolling("http://127.0.0.1:8080/listener", "user")。在示例中,我們處於簡單考慮,僅僅啟動一個客戶端,對單一的 dataId:user 進行監聽(注意,需要先啟動 server 端)。

服務端實現

<pre class="prettyprint hljs less" style="padding: 0.5em; font-family: Menlo, Monaco, Consolas, "Courier New", monospace; color: rgb(68, 68, 68); border-radius: 4px; display: block; margin: 0px 0px 1.5em; font-size: 14px; line-height: 1.5em; word-break: break-all; overflow-wrap: break-word; white-space: pre; background-color: rgb(246, 246, 246); border: none; overflow-x: auto; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;">`@RestController
@Slf4j
@SpringBootApplication
public class ConfigServer {

    @Data
    private static class AsyncTask {
        // 長輪詢請求的上下文,包含請求和響應體
 private AsyncContext asyncContext;
 // 超時標記
 private boolean timeout;

 public AsyncTask(AsyncContext asyncContext, boolean timeout) {
 this.asyncContext = asyncContext;
 this.timeout = timeout;
 }
 }

 // guava 提供的多值 Map,一個 key 可以對應多個 value
 private Multimap<String, AsyncTask> dataIdContext = Multimaps.synchronizedSetMultimap(HashMultimap.create());

 private ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("longPolling-timeout-checker-%d")
 .build();
 private ScheduledExecutorService timeoutChecker = new ScheduledThreadPoolExecutor(1, threadFactory);

 // 配置監聽接入點
 @RequestMapping("/listener")
 public void addListener(HttpServletRequest request, HttpServletResponse response) {

 String dataId = request.getParameter("dataId");

 // 開啟異步
 AsyncContext asyncContext = request.startAsync(request, response);
 AsyncTask asyncTask = new AsyncTask(asyncContext, true);

 // 維護 dataId 和異步請求上下文的關聯
 dataIdContext.put(dataId, asyncTask);

 // 啟動定時器,30s 後寫入 304 響應
 timeoutChecker.schedule(() -> {
  if (asyncTask.isTimeout()) {
 dataIdContext.remove(dataId, asyncTask);
 response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
 asyncContext.complete();
 }
 }, 30000, TimeUnit.MILLISECONDS);
 }

 // 配置發布接入點
 @RequestMapping("/publishConfig")
 @SneakyThrows
 public String publishConfig(String dataId, String configInfo) {
 log.info("publish configInfo dataId: [{}], configInfo: {}", dataId, configInfo);
 Collection<AsyncTask> asyncTasks = dataIdContext.removeAll(dataId);
  for (AsyncTask asyncTask : asyncTasks) {
 asyncTask.setTimeout(false);
 HttpServletResponse response = (HttpServletResponse)asyncTask.getAsyncContext().getResponse();
 response.setStatus(HttpServletResponse.SC_OK);
 response.getWriter().println(configInfo);
 asyncTask.getAsyncContext().complete();
 }
  return  "success";
 }

 public static void main(String[] args) {
 SpringApplication.run(ConfigServer.class, args);
 }

}` </pre>

對上述實現的一些說明:

@RequestMapping("/listener") ,配置監聽接入點,也是長輪詢的入口。在獲取 dataId 之後,使用 request.startAsync 將請求設置為異步,這樣在方法結束後,不會占用 Tomcat 的線程池。

接著 dataIdContext.put(dataId, asyncTask) 會將 dataId 和異步請求上下文給關聯起來,方便配置發布時,拿到對應的上下文。

注意這裡使用了一個 guava 提供的數據結構 Multimap<String, AsyncTask> dataIdContext ,它是一個多值 Map,一個 key 可以對應多個 value,你也可以理解為 Map<String,List <asynctask>> ,但使用 Multimap 維護起來可以更方便地處理一些並發邏輯。</asynctask>

至於為什麼會有多值,很好理解,因為配置中心的 Server 端會接受來自多個客戶端對同一個 dataId 的監聽。

timeoutChecker.schedule() 啟動定時器,30s 後寫入 304 響應。再結合之前客戶端的邏輯,接收到 304 之後,會重新發起長輪詢,形成一個循環。

@RequestMapping("/publishConfig") ,配置發布的入口。配置變更後,根據 dataId 一次拿出所有的長輪詢,為之寫入變更的響應,同時不要忘記取消定時任務。至此,完成了一個配置變更後推送的流程。

啟動配置監聽

先啟動 ConfigServer,再啟動 ConfigClient。客戶端列印長輪詢的日誌如下:

<pre class="prettyprint hljs css" style="padding: 0.5em; font-family: Menlo, Monaco, Consolas, "Courier New", monospace; color: rgb(68, 68, 68); border-radius: 4px; display: block; margin: 0px 0px 1.5em; font-size: 14px; line-height: 1.5em; word-break: break-all; overflow-wrap: break-word; white-space: pre; background-color: rgb(246, 246, 246); border: none; overflow-x: auto; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;">`22:18:09.185 [main] INFO moe.cnkirito.demo.ConfigClient - longPolling dataId: [user] once finished, configInfo is unchanged, longPolling again
22:18:39.197 [main] INFO moe.cnkirito.demo.ConfigClient - longPolling dataId: [user] once finished, configInfo is unchanged, longPolling again` </pre>

發布一條配置,curl -X GET "localhost:8080/publishConfig?dataId=user&configInfo=helloworld"

服務端列印日誌如下:

<pre class="prettyprint hljs css" style="padding: 0.5em; font-family: Menlo, Monaco, Consolas, "Courier New", monospace; color: rgb(68, 68, 68); border-radius: 4px; display: block; margin: 0px 0px 1.5em; font-size: 14px; line-height: 1.5em; word-break: break-all; overflow-wrap: break-word; white-space: pre; background-color: rgb(246, 246, 246); border: none; overflow-x: auto; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;">`22:18:50.801  INFO 73301 --- [nio-8080-exec-6] moe.cnkirito.demo.ConfigServer           : publish configInfo dataId: [user], configInfo: helloworld` </pre>

客戶端接受配置推送:

<pre class="prettyprint hljs css" style="padding: 0.5em; font-family: Menlo, Monaco, Consolas, "Courier New", monospace; color: rgb(68, 68, 68); border-radius: 4px; display: block; margin: 0px 0px 1.5em; font-size: 14px; line-height: 1.5em; word-break: break-all; overflow-wrap: break-word; white-space: pre; background-color: rgb(246, 246, 246); border: none; overflow-x: auto; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;">`22:18:50.806 [main] INFO moe.cnkirito.demo.ConfigClient - dataId: [user] changed, receive configInfo: helloworld` </pre>

實現細節思考

為什麼需要定時器返回 304

上述的實現中,服務端採用了一個定時器,在配置未發生變更時,定時返回 304,客戶端接收到 304 之後,重新發起長輪詢。

在前文,已經解釋過了為什麼需要超時後重新發起長輪詢,而不是由服務端一直 hold,直到配置變更再返回。

但可能有讀者還會有疑問,為什麼不由客戶端控制超時,服務端去除掉定時器,這樣客戶端超時後重新發起下一次長輪詢,這樣的設計不是更簡單嗎?

無論是 Nacos 還是 Apollo 都有這樣的定時器,而不是靠客戶端控制超時,這樣做主要有兩點考慮:

  • 和真正的客戶端超時區分開。
  • 僅僅使用異常(Exception)來表達異常流,而不應該用異常來表達正常的業務流。304 不是超時異常,而是長輪詢中配置未變更的一種正常流程,不應該使用超時異常來表達。

客戶端超時需要單獨配置,且需要比服務端長輪詢的超時要長。

正如上述的 demo 中客戶端超時設置的是 40s,服務端判斷一次長輪詢超時是 30s。

這兩個值在 Nacos 中默認是 30s 和 29.5s,在 Apollo 中默認是是 90s 和 60s。

長輪詢包含多組 dataId

在上述的 demo 中,一個 dataId 會發起一次長輪詢,在實際配置中心的設計中肯定不能這樣設計,一般的優化方式是,一批 dataId 組成一個組批量包含在一個長輪詢任務中。

在 Nacos 中,按照 3000 個 dataId 為一組包裝成一個長輪詢任務。

長輪詢和長連接

講完實現細節,本文最核心的部分已經介紹完了。

再回到最前面提到的數據交互模式上提到的推模型和拉模型,其實在寫這篇文章時,我曾經問過交流群中的小夥伴們「配置中心實現動態推送的原理」,他們中絕大多數人認為是長連接的推模型。

然而事實上,主流的配置中心幾乎都是使用了本文介紹的長輪詢方案,這又是為什麼呢?

我也翻閱了不少博客,顯然他們給出的理由並不能說服我,我嘗試著從自己的角度分析了一下這個既定的事實。

  • 長輪詢實現起來比較容易,完全依賴於 HTTP 便可以實現全部邏輯,而 HTTP 是最能夠被大眾接受的通信方式。
  • 長輪詢使用 HTTP,便於多語言客戶端的編寫,大多數語言都有 HTTP 的客戶端。

那麼長連接是不是真的就不適合用於配置中心場景呢?

有人可能會認為維護一條長連接會消耗大量資源,而長輪詢可以提升系統的吞吐量,而在配置中心場景,這一假設並沒有實際的壓測數據能夠論證,benchmark everything!please~

另外,翻閱了一下 Nacos 2.0 的 milestone,我發現了一個有意思的規劃,Nacos 的註冊中心(目前是短輪詢 + udp 推送)和配置中心(目前是長輪詢)都有計劃改造為長連接模式。

再回過頭來看,長輪詢實現已經將配置中心這個組件支撐的足夠好了,替換成長連接,一定需要找到合適的理由才行。

總結

  • 本文介紹了長輪詢、輪詢、長連接這幾種數據交互模型的差異性。
  • 分析了 Nacos 和 Apollo 等主流配置中心均是通過長輪詢的方式實現配置的實時推送的。實時感知建立在客戶端拉的基礎上,因為本質上還是通過 HTTP 進行的數據交互,之所以有「推」的感覺,是因為服務端 hold 住了客戶端的響應體,並且在配置變更後主動寫入了返回 response 對象再進行返回。
  • 通過一個簡單的 demo,實現了長輪詢配置實時推送的過程演示,本文的 demo 示例存放在:
<pre class="hljs less" style="padding: 0.5em; font-family: Menlo, Monaco, Consolas, "Courier New", monospace; color: rgb(68, 68, 68); border-radius: 4px; display: block; margin: 0px 0px 0.75em; font-size: 14px; line-height: 1.5em; word-break: break-all; overflow-wrap: break-word; white-space: pre; background-color: rgb(246, 246, 246); border: none; overflow-x: auto; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;">https://github.com/lexburner/longPolling-demo
</pre>

荒腔走板

回想了一下上周一周是真的忙的飛起。

寫下這句話的時候是剛剛過去的周日的凌晨 2 點,我在公司。

為什麼凌晨 2 點還在公司呢?

因為公司剛剛完成了一個非常重要的災備演練,需要在凌晨業務低峰期的時候進行相關操作。這是一個為了保障業務連續性的一個非常必要的、強有力的措施。非常多的團隊為了它付出了很多很多的精力。

這次演練,這讓我想起 2017 年還在北京的時候,那個時候我才剛剛工作一年時間。

當時是參與了公司的一個服務上雲的戰役,為了把影響減小到最低,也是選擇在凌晨開始操作,連續戰鬥了 35 小時。

說來也巧,當時也是 7 月下旬。時間是個圈,讓我想起了那個相似的夜晚,不一樣的是,這次並不需要通宵,而我也只是晚上過來進行業務驗證即可。

還有點不一樣的是,當年的我覺得熬個通宵也不是什麼大事,甚至有一絲絲的興奮。現在如果需要讓我幹個通宵,我覺得還是得緩上幾天才行。

我也想起了那天晚上為什麼領導看到我搞完之後,第二天還是生龍活虎的樣子感嘆的那句:年輕真是好呀。

我已經忘記那天具體幹了一些什麼事情了,但是我記得那是我進入職場以來幹過的第一個通宵。

遷移完成之後,北京的天已經亮了。

7 月的北京天亮的很早,那天早上很早就在公司樓下吃完了一碗拉麵,從麵館出來的時候還能感受到一絲絲涼意。

上午進行了業務驗證,得知一切正常,中午我還發了一個說說:

通宵工作,當然是一件非常非常非常不好的事情。

但是我還記得看到遷移之後,得知業務一切順利之後的愉悅和激動。

業務上雲,對於公司來說是里程碑式的一步,作為一個參加工作不久的年輕人,我是親歷者和見證者。也算是一份難得的經歷。

回想起來的時候我是嘴角帶笑的,總體來說就是年輕真好。

來源: https://mp.weixin.qq.com/s?__biz=Mzg3NjU3NTkwMQ==&mid=2247542843&idx=1&sn=cd37fe6e5cdf2da1540c5b6d57962c8e

關鍵字: