NIO,一本難唸的經——分散式系統基礎

博文視點發表於2017-09-25

如果沒有網路,就沒有所謂的分散式系統,但有趣的是,我們中的大多數軟體工程師和系統架構師,甚至於公司裡最強的技術達人,都缺乏必要的網路知識和技能,也很少有人真正重視它們。今天我們就從NIO開始,完善一下我們的網路知識。

  我們知道,分散式系統的基礎是網路。因此,網路程式設計始終是分散式軟體工程師和架構師的必備高階基礎技能之一,而且隨著當前大資料和實時計算技術的興起,高效能 RPC 框架與網路程式設計技術再次成為焦點。不管是 RPC 領域的 ZeroC Ice、Thrift,還是經典分散式框架 Actor 模型中的 Akka,或者實時流領域的 Storm、Spark,又或者開源分散式資料庫中的 Mycat、VoltDB,這些高大上產品的底層通訊技術都採用了 NIO(非阻塞通訊)通訊技術。而 Java 領域裡大名鼎鼎的 NIO 框架——Netty,則被眾多的開源專案或商業軟體所採用。

  相對於它的老前輩 BIO(阻塞通訊)來說,NIO 模型非常複雜,以至於苦學了很久以後也很少有人能夠精通它,難以編寫出一個沒有缺陷、高效且適應各種意外情況的穩定的 NIO 通訊模組。之所以會出現這樣的問題,是因為 NIO 程式設計不是單純的一個技術點,而是涵蓋了一系列相關的技術、專業知識、程式設計經驗和程式設計技巧的複雜工程,所以即使你程式設計很多年,也仍然不大懂 NIO,這不怪你,只怪當初 Sun 那幫精英架構師高估了我們這幫“混飯吃”的普通程式設計師。於是後來,Sun 又來了一波架構師,弄了一套先進的 AIO(非同步通訊)模型,這套程式設計框架看起來很美,真正體現了“簡單即美”的設計理念。但很可惜的是 Linux 裡的 AIO 模組半死不活地停滯在那裡好多年,所以,這麼美好又簡單還能體現我們程式設計師高水平的新技術,就這麼遺憾地待字閨中。

  Java NIO 雖然提供了非阻塞的網路通訊程式設計框架,但它的設計帶來了很多程式設計難題。

1 難懂的 ByteBuffer

  Java NIO 拋棄了我們所熟悉的 Stream、byte[]等資料結構,設計了一個全新的資料結構—— ByteBuffer,ByteBuffer 的主要使用場景是儲存從 Socket 中讀取的輸入位元組流,並迴圈利用,以降低 GC 的壓力。第一眼看到它的廣告介紹後,你會感覺它功能強大,美不勝收,作為一個單純的程式設計師,你可能會瞬間愛上它,但當你認真地去學習它的 API 時,你可能就把自己搞暈了。以經典的 Echo 伺服器為例,其核心是讀入客戶端發來的資料,並且回寫給客戶端,這段程式碼用 ByteBuffer 來實現,大致就是下面的邏輯:

  1 byteBuffer = ByteBuffer.allocate(N);

  2 //讀取資料,寫入 byteBuffer

  3 readableByteChannel.read(byteBuffer);

  6 //讀取 byteBuffer,寫入 Channel

  7 writableByteChannel.write(byteBuffer);

  如果你一眼就發現了上述程式碼中存在一個嚴重缺陷且無法正常工作,那麼說明你可能的確精通了 ByteBuffer 的用法。這段程式碼的缺陷是在第 6 行之前少了一個 byteBuffer.flip()呼叫。之所以 ByteBuffer 會設計一個這樣奇怪名字的 Method,是因為它與我們所熟悉的 InputStream & OutStream 分別操作輸入輸出流的傳統 I/O 設計方式不同,它是“二合一”的設計方式。我們可以把 ByteBuffer 設想成內部擁有一個固定長度 byte 陣列的物件,屬性 capacity 為陣列的長度(不可變),position 變數儲存當前讀(或寫)的位置,limit 變數為當前可讀或可寫的位置上限。當byte 被寫入到 ByteBuffer 中的時候,position++,而 0 到 position 之間的字元就是已經寫入的字元。如果後面要讀取之前寫入的這些字元,則需要將 position 重置為 0,而 limit 則被設定為之前 position 的值,這個操作恰好就是 flip 要做的事情,這樣一來,從 postion 到 limit 之間的字元剛好是要讀的全部資料。

  ByteBuffer 有三種實現方式:第一種是堆記憶體儲資料的 HeapByteBuffer;第二種是堆外儲存資料的 DirectByteBuffer;最後一種是檔案對映(資料儲存到檔案中)的 MappedByteBuffer。 HeapByteBuffer 是將資料儲存在 JVM 堆記憶體中,我們知道 64 位 JVM 的堆記憶體最大為 32GB 時候的記憶體利用率最高,一旦堆超過了 32GB,你就進入到 64 位的世界裡了,應用程式的可用堆的空間就會減小。另外,過大的 JVM 堆記憶體也容易導致複雜的 GC 問題,因此最好的辦法是採用堆外記憶體,堆外記憶體的管理由程式設計師自己控制,類似 C 語言的直接記憶體管理,而DirectByteBuffer 就是採用堆外記憶體來存放資料的,因此在訪問效能提升的同時帶來了複雜的動態記憶體管理問題。而動態記憶體管理是一項高階程式設計技術,涵蓋了記憶體分配效能、記憶體回收、記憶體碎片化、記憶體利用率等一系列複雜問題。

  面對記憶體分配效能這個問題,我們通常會在 Java 裡採用 ThreadLocal 物件來實現多執行緒本地化分配的思路,即每個執行緒擁有一個 ThreadLocal 型別的 ByteBufferPool,然後每個執行緒管理各自的記憶體分配和回收問題,避免共享資源導致的競爭問題。下面這段來自大名鼎鼎的 GrizzyNIO 框架中的 ByteBufferThreadLocalPool,就採用了 ThreadLocal 結合 ByteBuffer 檢視的動態記憶體管理技術。

  上面的程式碼很簡單也很經典,可以分配任意大小的記憶體塊,但存在一個問題,即它只能從 pool 的當前位置持續往下分配空間,而中間被回收的記憶體塊是無法立即被分配的,因此記憶體利用率不高。另外,當後面分配的記憶體沒有被及時釋放的時候,會發生記憶體溢位,即使前面分配的記憶體早已釋放大半。其實上述問題可以通過一個環狀的結構(Ring)來解決,即分配到頭以後,回頭重新繼續分配,但程式碼會稍微複雜點。

  Netty 則採用了另外一種思路。首先,Netty 的作者認為 JDK 的 ByteBuffer 設計得並不好,其中 ByteBuffer 不能繼承,以及 API 難用、容易出錯是最大的兩個問題,於是他重新設計了一個介面 ByteBuf 來替代官方的 ByteBuffer。如下所示是 ByteBuffer 的設計示意圖,它通過分離讀寫的位置變數(reader index 及 writer index)簡單、有效地解決了 ByteBuffer 難懂的 flip 操作問題,這樣一來 ByteBuf 也可以實現同時讀與寫的功能了。

  由於 ByteBuf 是一個介面,所以可以繼承與擴充套件,為了實現分配任意長度的 Buffer,Netty設計了一個 CompositeByteBuf 實現類,它通過組合多個 ByteBuf 例項的方式巧妙實現了動態擴容能力,這種組合擴容的方式存在一個讀寫效率問題,即要判斷當前的讀寫位置是否要移到下一個 ByteBuf 例項上。

  Netty 的 ByteBuf 例項還有一個很重要的特徵,即記錄了被引用的次數,所有例項都繼承自AbstractReferenceCountedByteBuf。這點非常重要,因為我們在實現 ByteBuf Pool 時,需要確保ByteBuf 被正確地釋放和回收,由於官方的 ByteBuffer 缺乏這一特徵,因此很容易因為使用不當導致記憶體洩露或者記憶體訪問錯誤的嚴重 Bug。

  由於使用 ByteBuffer 時用得最多的是堆外 DirectByteBuffer,因此一個功能齊全、高效的Buffer Pool 對於 NIO 來說相當重要,官方 JDK 並沒有提供這樣的工具包,於是 Netty 的作者順便也把這部分功能實現了,基於 ByteBuf 實現了一套可以在 Netty 之外單獨使用的 Buffer Pool框架,如下圖所示。

  我們再來說說 MappedByteBuffer,說得通俗一點就是 Map 把一個磁碟檔案(整體或部分內容)對映到計算機虛擬記憶體的一塊區域,這樣就可以直接操作記憶體當中的資料,而無須每次都通過 I/O 從物理硬碟上讀取檔案,所以在效率上有很大提升。想要真正理解 MappedByteBuffer的原理和價值,需要掌握一點作業系統記憶體、檔案系統、記憶體頁與記憶體交換的基本知識。如下圖所示,每個程式有一個虛擬地址空間,也被稱為邏輯記憶體地址,其大小由該系統上的地址大小規定,比如 32 位 Windows 的單程式可定址空間是 4GB,虛擬地址空間也使用分頁機制,即我們所說的記憶體頁面。當一個程式嘗試使用虛擬地址訪問記憶體時,作業系統連同硬體會將該分頁的虛擬地址對映到某個具體的物理位置,這個位置可以是物理 RAM、頁面檔案(Page file 是Windows 的說法,對應 Linux 下的 swap)或檔案系統中的一個普通檔案。儘管每個程式都有其自己的地址空間,但程式通常無法使用所有這些空間,因為地址空間被劃分為核心空間和使用者空間。大部分作業系統將每個程式地址空間的一部分對映到一個通用的核心記憶體區域。被對映來供核心使用的地址空間部分被稱為核心空間,其餘部分被稱為使用者空間,可供使用者應用程式使用。

  MappedByteBuffer 使用 mmap 系統呼叫來實現檔案記憶體對映過程,如下圖中的過程 1 所示。此外,在記憶體對映的過程中,只是邏輯上被放入了記憶體,具體到程式碼,就是建立並初始化了相關的資料結構(struct address_space),並沒有實際的資料複製,檔案沒有被載入記憶體,所以建立記憶體對映的效率很高。僅僅當此檔案的內容要被訪問的時候,才會觸發作業系統載入記憶體頁,這個過程中可能涉及當實體記憶體不足時記憶體交換的問題,即過程 4。

  通過上面的原理分析,我們就不難理解 JDK 中關於 MappedByteBuffer 的一些方法的作用了。

  • fore():當緩衝區是 READ_WRITE 模式時,此方法對緩衝區內容的修改強行寫入檔案。
  • load():將緩衝區的內容載入記憶體,並返回該緩衝區的引用。
  • isLoaded():如果緩衝區的內容在實體記憶體中,則返回真,否則返回假。
      MappedByteBuffer 的主要使用場景有如下兩個。

  • 基於檔案共享的高效能程式間通訊(IPC)。

  • 大檔案高效能讀寫訪問。

      正因為上述兩個獨特的使用場景,MappedByteBuffer 有很多高階應用,比如 Kafka 採用MappedByteBuffer 來處理訊息日誌檔案,而來自伯克利分校的 AMPLab 開發的分散式檔案系統 Tachyon 也採用了 MappedByteBuffer 加速檔案讀寫。高效能 IPC 通訊技術在當前大資料和實時計算方面越來越重要,原因很簡單,當前伺服器的核心數越來越多,而且都支援 NUMA 技術,在這種情況下,單機上的多程式架構能最大地提升系統的整體吞吐量。於是,國外有人基於MappedByteBuffer 實現了一個 DEMO 性質的高效能 IPC 通訊例子,專案地址為 https://github. com/caplogic/Mappedbus,其作者受到了 Java 高效能程式設計領域的大神 peter-lawrey 的著名專案Java Chronicle 的啟發,也採用了記憶體對映檔案來實現 Java 多程式間的資料通訊,其原理圖如下所示。

      一個程式負責寫入資料到記憶體對映檔案中,其他程式(不限於 Java)則從此對映檔案中讀取資料,經筆者測試,採用這種方式的效能極高,在筆者的筆記本計算機上可以達到每秒 4000萬的傳輸速度,訊息延遲僅僅只有 25ns。受此專案的啟發,筆者也發起了一個更為完善的 IPC開源框架,專案地址為 https://github.com/MyCATApache/Mycat-IPC,此專案的關鍵點在於用一個 MappedByteBuffer 模擬了 N 組環形佇列的資料結構,用來表示一個程式傳送或者讀取的訊息佇列。如下所示是 MappedByteBuffer 記憶體結構圖,記憶體起始位置記錄了當前定義的幾個RingQueue,隨後記錄每個 RingQueue 的長度以確定其開始記憶體地址與結束記憶體地址, RingQueue 類似 ByteBuffer 的設計,有記錄讀寫記憶體位置的變數,而放入佇列的每個“訊息”都有兩個位元組的長度、訊息體本身,以及下個訊息的開始位置 Flag(繼續當前位置還是已經掉頭、從頭開始)。筆者計劃未來將 Mycat 拆成多程式的架構,一個程式負責接收客戶端的Socket 請求,然後把資料通過 IPC 框架分發給後面幾個獨立的程式去處理,處理完的響應再通過 IPC 傳回給 Socket 監聽程式,最終寫入客戶端。

      MappedByteBuffer 還有另外一個奇妙的特性,“零複製”傳輸資料,它的 transferTo 方法能節省一次緩衝區的複製過程,直接寫入另外一個 Channel 通道上,如下圖所示。

Netty 傳輸檔案的邏輯就用到了 transferTo 這一特性,下面的程式碼片段給出了真相:

2 晦澀的“非阻塞”

  NIO 裡“非阻塞”(None Blocking)這個否定式的新名稱對於大多數程式設計師來說的確很難理解,就好像他們很難理解妹子們多變的心情一樣。即便如此,我們還是要硬著頭皮去弄懂它,否則,高薪是很難有的,而沒有高薪,再容易理解的妹子也會離開。不去想詩與遠方了,在筆者解釋“非阻塞”這個概念之前,讓我們先來惡補一下 TCP/IP 通訊的基礎知識。

  首先,對於 TCP 通訊來說,每個 TCP Socket 在核心中都有一個傳送緩衝區和一個接收緩衝區,TCP 的全雙工的工作模式及 TCP 的滑動視窗便依賴於這兩個獨立的 Buffer 及此 Buffer 的填充狀態。接收緩衝區把資料快取入核心,若應用程式一直沒有呼叫 Socket 的 read 方法進行讀取的話,則此資料會一直被快取在接收緩衝區內。不管程式是否讀取 Socket,對端發來的資料都會經由核心接收並且快取到 Socket 的核心接收緩衝區中。read 所做的工作,就是把核心接收緩衝區中的資料複製到應用層使用者的 Buffer 裡面,僅此而已。程式呼叫 Socket 的 send 傳送資料的時候,最簡單的情況(也是一般情況)是將資料從應用層使用者的 Buffer 裡複製到 Socket 的核心傳送緩衝區中,然後 send 便會在上層返回。換句話說,send 返回時,資料不一定會被髮送到對端(和 write寫檔案有點類似),send 僅僅是把應用層 Buffer 的資料複製到 Socket 的核心傳送 Buffer 中。而對於 UDP 通訊來說,每個 UDP Socket 都有一個接收緩衝區,而沒有傳送緩衝區,從概念上來說就是隻要有資料就發,不管對方是否可以正確接收,所以不緩衝,不需要傳送緩衝區。

  其次,我們來說說 TCP/IP 的滑動視窗和流量控制機制,前面我們提到,Socket 的接收緩衝區被 TCP 和 UDP 用來快取網路上收到的資料,一直儲存到應用程式讀走為止。對於 TCP 來說,如果應用程式一直沒有讀取,則 Buffer 滿了之後,發生的動作是:通知對端 TCP 協議中的視窗關閉,保證 TCP 套介面接收緩衝區不會溢位,保證了 TCP 是可靠傳輸的,這個便是滑動視窗的實現。因為對方不允許發出超過通告視窗大小的資料,所以如果對方無視視窗大小而發出了超過視窗大小的資料,則接收方 TCP 將丟棄它,這就是 TCP 的流量控制原理。而對於 UDP 來說,當接收方的 Socket 接收緩衝區滿時,新來的資料包無法進入接收緩衝區,此資料包就會被丟棄,UDP 是沒有流量控制的,快的傳送者可以很容易地淹沒慢的接收者,導致接收方的 UDP丟棄資料包。

  明白了 Socket 讀寫資料的底層原理,我們就容易理解傳統的“阻塞模式”了:對於讀取 Socket資料的過程而言,如果接收緩衝區為空,則呼叫 Socket 的 read 方法的執行緒會阻塞,直到有資料進入接收緩衝區中;而對於寫資料到 Socket 中的執行緒而言,如果待傳送的資料長度大於傳送緩衝區的空餘長度,則會阻塞在 write 方法上,等待傳送緩衝區的報文被髮送到網路上,然後繼續傳送下一段資料,迴圈上述過程直到資料都被寫入到傳送緩衝區為止。

  從上述的程來看,傳統的 Socket 阻塞模式直接導致每個 Socket 都必須繫結一個執行緒來運算元據,參與通訊的任意一方如果處理資料的速度較慢,則都會直接拖累另一方,導致另一方的執行緒不得不浪費大量的時間在 I/O 等待上,所以,每個 Socket 要繫結一個單獨的執行緒正是傳統Socket 阻塞模式的根本“缺陷”。之所以這裡加了“缺陷”兩個字,是因為這種模式在一些特定場合下效果是最好的,比如只有少量的 TCP 連線通訊,雙方都非常快速地傳輸資料,此時這種模式的效能最高。

  現在我們可以開始分析“非阻塞”模式了,它就是要解決 I/O 執行緒與 Socket 解耦的問題,因此,它引入了事件機制來達到解耦的目的。我們可以認為 NIO 底層中存在一個 I/O 排程執行緒,它不斷掃描每個 Socket 的緩衝區,當發現寫入緩衝區為空(或者不滿)的時候,它會產生一個Socket 可寫事件,此時程式就可以把資料寫入 Socket 裡,如果一次寫不完,則等待下次可寫事件的通知;而當發現讀取緩衝區裡有資料的時候,它會產生一個 Socket 可讀事件,程式收到這個通知事件時,就可以從 Socket 讀取資料了。

  上述原理聽起來很簡單,但實際上有很多容易陷入的“坑”,如下所述。

  收到可寫事件時,想要一次性地寫入全部資料,而不是將剩餘資料放入 Session 中,等待下次可寫事件的到來。

  寫完資料並且沒有可寫資料的時候,在應答資料包文已經全部傳送給客戶端的情況下,需要取消對可寫事件的“訂閱”,否則 NIO 排程執行緒總是報告 Socket 可寫事件,導致 CPU 使用率狂飆。因此,如果沒有資料可寫,就不要訂閱可寫事件。

  如果來不及處理髮送的資料,就需要暫時“取消訂閱”可讀事件,否則資料從 Socket 裡讀取以後,下次還會很快傳送過來,而來不及處理的資料積壓到記憶體佇列中,最終會導致記憶體溢位。

  此外,NIO 裡還有一個容易被忽略的高階問題,即業務資料處理邏輯是使用 NIO 排程執行緒來執行還是用另外執行緒池裡的執行緒來執行?關於這個問題,沒有絕對的答案,在 Mycat 的研發過程中,我們經過大量測試和研究得出以下結論:

  如果資料包文的處理邏輯比較簡單,不存在耗時和阻塞的情況,則可以直接用 NIO 排程執行緒來執行這段邏輯,避免執行緒上下文切換帶來的損耗;如果資料包文的處理邏輯比較複雜,耗時比較多,而且可能存在阻塞和執行時間不確定的情況,則建議放入執行緒池裡去非同步執行,防止 I/O 排程執行緒被阻塞。

  如下所示是 Mycat 裡相關設計的示意圖。

3 複雜的 Reactor 模型

  Java NIO 框架比較原始,目前主流的 Java 網路程式都在其上設計實現了 Reactor 模型,隱藏了 NIO 底層的複雜細節,大大簡化了 NIO 程式設計,其原理和架構如下圖所示,Acceptor 負責接收客戶端 Socket 發起的新建連線請求,並把該 Socket 繫結到一個 Reactor 執行緒上,於是這個Socket 隨後的讀寫事件都交給此 Reactor 執行緒來處理。Reactor 執行緒讀取資料後,交給使用者程式中的具體 Handler 實現類來完成特定的業務邏輯處理。為了不影響 Reactor 執行緒,我們通常使用一個單獨的執行緒池來非同步執行 Handler 的介面方法。

  如果僅僅到此為止,則 NIO 裡的 Reactor 模型還不算是很複雜,但實際上,我們的伺服器是多核心的,而且需要高速併發處理大量的客戶端連線,單執行緒的 Reactor 模型就滿足不了需求了,因此我們需要多執行緒的 Reactor。一般原則是 Reactor(執行緒)的數量與 CPU 核心數(邏輯CPU)保持一致,即每個 CPU 執行一個 Reactor 執行緒,而客戶端的 Socket 連線則隨機均分到這些 Reactor 執行緒上去處理,如果有 8000 個連線,而 CPU 核心數為 8,則平均每個 CPU 核心承擔 1000 個連線。

  多執行緒 Reactor 模型下可能帶來另外一個問題,即負載不均衡的問題,雖然每個 Reactor 執行緒服務的 Socket 數量是均衡的,但每個 Socket 的 I/O 事件可能是不均衡的,某些 Socket 的 I/O事件可能大大多於其他 Socket,從而導致某些 Reactor 執行緒負載更高,此時是否需要重新分配Socket 到不同的 Reactor 執行緒呢?這的確是一個問題,因為如果要切換 Socket 到另外的 Reactor 執行緒,則意味著 Socket 相關的 Connection 物件、Session 物件等必須是執行緒安全的,這本身就帶來一定的效能損耗,另外需要對 I/O 事件做統計分析,啟動額外的定時執行緒在合適的時機完成 Socket 重分配,這本身就是很複雜的事情。

  由於 Netty 的程式碼過於複雜,我們下面以 Mycat NIO Framework 為例,來說說應該怎樣設計一個基於多執行緒 Reactor 模式的高效能 NIO 框架。

  如下圖所示,我們先要有一個基礎類 NetSystem,它負責 NIO 框架中基礎引數與基礎元件的建立,其中常用的基礎引數如下。

  • Socket 快取區的大小
  • TCP_NODELAY 標記
  • Reactor 個數
  • ByteBuffer Pool 的引數
  • 業務執行緒池大小。基礎元件如下
  • NameableExecutor:業務執行緒池
  • NIOAcceptor:負責接收客戶端的新建連線請求
  • NIOConnector:負責發起客戶端連線(NIO 模式)

  考慮到不同的應用需要建立自己的 Connection 例項來實現應用特定的網路協議,而且一個程式裡可能會有幾種網路協議,因此框架裡設計了 Connection 抽象類,採用的是工廠模式,即由不同的 ConnectionFactory 來建立不同的 Connection 實現類。不管是作為 NIO Server 還是作為NIO Client,應用程式都可以採用這套機制來實現自己的 Connection。當收到 Socket 報文(及相關事件)時,框架會呼叫繫結在此 Connection 上的 NIO Handler 來處理報文,而 Connection 要傳送的資料被放入一個 WriteQueue 佇列裡,框架實現具體的無阻塞傳送邏輯。

  為了更好地使用有限的記憶體,Mycat NIO 設計了一個“雙層”的 ByteBuffer Pool 模型,全域性的 ByteBufferPool 被所有 Connection 共享,而每個 Reactor 執行緒則在本地保留了一份區域性佔用ByteBuffer Pool——ThreadLocalBufferPool,我們可以設定 80%的 ByteBuffer 被 N 個 Reactor執行緒從全域性 Pool 裡取出並放到本地的 ThreadLocalBufferPool 裡,這樣一來,可以避免過多的全域性 Pool 的鎖搶佔操作,提升 NIO 效能。

  NIOAcceptor 收到客戶端發起的新連線事件後,會新建一個 Connection 物件,然後隨機找到一個 NIOReactor,並把此 Connection 物件放入該 NIOReactor 的 Register 佇列中等待處理,NIOReactor 會在下一次的 Selector 迴圈事件處理之前,先處理所有新的連線請求。下面兩段來自 NIOReactor 中的程式碼表明瞭這一邏輯過程:

  NIOConnector 屬於 NIO 客戶端框架的一部分,與 NIOAcceptor 類似,當需要發起一個 NIO連線的時候,程式呼叫下面的方法將連線放入“待連線佇列”中並喚醒 Selector:

  隨後,NIOConnector 的執行緒會先處理“待連線佇列”,發起真正的 NIO 連線並非同步等待響應:

  最後,在 NIOConnector 的執行緒 Run 方法裡,對收到連線完成事件的 Connection,回撥應用的通知介面,應用得知連線已經建立時,可以在介面裡主動發資料或者請求讀資料:

  想及時獲得更多精彩文章,可在微信中搜尋“博文視點”或者掃描下方二維碼並關注。
                    圖片描述

相關文章