即時通信IM技術領域基礎篇

[TOC]php

即時通信IM技術領域基礎篇

即時通信IM技術領域提升篇ios

議題

  1. 準備工做(協議選型)redis

    • 網絡傳輸協議選擇 和 數據通訊協議選擇
  2. xxx項目架構算法

    • 架構優缺點
    • 架構改進之路
  3. IM 關鍵技術點 & 策略機制數據庫

    • 如何保證消息不丟/不亂序/不重複
    • 心跳策略
    • 重連策略
  4. 典型IM業務場景json

    • 用戶A發送消息給用戶B
    • 用戶A發送消息到羣C
  5. 存儲結構簡析後端

準備工做(協議選型)

選用什麼網絡傳輸協議(TCP/UDP/HTTP) ?

  1. udp協議雖然實時性更好,可是如何處理安全可靠的傳輸而且處理不一樣客戶端之間的消息交互是個難題,實現起來過於複雜. 目前大部分IM架構都不採用UDP來實現.緩存

  2. 可是爲啥還須要HTTP呢?安全

    • 核心的TCP長鏈接,用來實時收發消息,其餘資源請求不佔用此鏈接,保證明時性服務器

    • http能夠用來實現狀態協議(能夠用php開發)

      • 朋友圈
      • 用戶我的信息(好友信息,帳號,搜索等..)
      • 離線消息用拉模式,避免 tcp 通道壓力過大,影響即時消息下發效率
      • 等等...
    • IM進行圖片/語言/大塗鴉聊天的時候: http可以很方便的處理 斷點續傳和分片上傳等功能.

  3. TCP: 維護長鏈接,保證消息的實時性, 對應數據傳輸協議.

    • 目的: 及時收發消息

選用什麼數據通訊協議?

  1. IM協議選擇原則通常是:易於拓展,方便覆蓋各類業務邏輯,同時又比較節約流量。節約流量這一點的需求在移動端IM上尤爲重要 !!!

    • xmpp: 協議開源,可拓展性強,在各個端(包括服務器)有各類語言的實現,開發者接入方便。可是缺點也是很多:XML表現力弱,有太多冗餘信息,流量大,實際使用時有大量天坑。

    • MQTT: 協議簡單,流量少,可是它並非一個專門爲IM設計的協議,多使用於推送. 須要本身在業務上實現羣,好友相關等等(目前公司有用MQTT實現通用IM框架).

    • SIP: 多用於VOIP相關的模塊,是一種文本協議. sip信令控制比較複雜

    • 私有協議: 本身實現協議.大部分主流IM APP都是是使用私有協議,一個被良好設計的私有協議通常有以下優勢:高效,節約流量(通常使用二進制協議),安全性高,難以破解。 xxx項目基本屬於私有定製協議<參考了蘑菇街開源的TeamTalk>, 後期通用IM架構使用MQTT

  2. 協議設計的考量:

    • 網絡數據大小 —— 佔用帶寬,傳輸效率:雖然對單個用戶來講,數據量傳輸很小,可是對於服務器端要承受衆多的高併發數據傳輸,必需要考慮到數據佔用帶寬,儘可能不要有冗餘數據,這樣纔可以少佔用帶寬,少佔用資源,少網絡IO,提升傳輸效率;

    • 網絡數據安全性 —— 敏感數據的網絡安全:對於相關業務的部分數據傳輸都是敏感數據,因此必須考慮對部分傳輸數據進行加密(xxx項目目前提供C++的加密庫給客戶端使用)

    • 編碼複雜度 —— 序列化和反序列化複雜度,效率,數據結構的可擴展性

    • 協議通用性 —— 大衆規範:數據類型必須是跨平臺,數據格式是通用的

  3. 經常使用序列化協議

    • 提供序列化和反序列化庫的開源協議: pb,Thrift. 擴展至關方便,序列化和反序列化方便(xxx項目目前使用pb)

    • 文本化協議: xml,json. 序列化,反序列化容易,可是佔用體積大(通常http接口採用json格式).

xxx項目系統架構

前期架構

...

改進後架構

...

架構的優缺點

優勢

  1. 同時支持TCP 和 HTTP 方式, 關聯性不大的業務服務獨立開來

    • php server
    • router server
    • user center
    • Access server
    • oracle server
  2. 服務支持平行擴展,平行擴展方便且對用戶無感知

  3. cache db層的封裝,業務調用方直接調用接口便可.

  4. 除了Access server是有狀態的,其餘服務無狀態

  5. 各個服務之間,經過rpc通訊,能夠跨機器.

  6. oracle裏面都是模塊化,有點相似MVC模式, 代碼解耦, 功能解耦.

缺點

  1. oracle 太過龐大, 能夠把某些業務抽取出來

    • 缺點

      • 業務太龐大,多人開發不方便,容易引發code衝突
      • 若是某個小功能有異常,可能致使整個服務不可用
    • 改進

      • oracle裏面耦合了apns server, 能夠把apns 單獨抽取出來. (xxx項目目前已經開始接入通用push推送系統了,相似把apns抽取出來).
  2. push server 沒有業務,僅僅是轉發Access和oracle之間的請求

    • 缺點

      • 須要單獨維護一個比較雞肋的服務,增長運維成本
    • 改進

      • 把push server合併到Access中,減小一層rpc調用中間環節.減小運維成本還能提升效率(xxx項目新架構已經把push server幹掉融合到Access裏面)
  3. Access server和用戶緊密鏈接,維持長鏈接的同時,還有部分業務

    • 缺點

      • 維持着長鏈接,若是升級更新的話,勢必會影響在線用戶的鏈接狀態
      • 偶爾部分業務,下降長鏈接的穩定性
    • 改進:

      • 把Access server 中維持長鏈接部分抽取出來一個connd server:
        • 僅僅維持長鏈接,收發包. 不耦合任何業務(xxx項目目前正在改進這個架構,還未上線)

IM 關鍵技術點

技術點一之: 如何保證消息可達(不丟)/惟一(不重複)/保序(不亂序)

最簡單的保序(不亂序)

  1. 爲何有可能會亂序?

    • 對於在線消息, 一發一收,正常狀況固然不會有問題

      • 可是,若是收到消息的時候,忽然網絡異常了,收不到消息了呢?
        • 服務端就會重發或者轉離線存儲(xxx項目的機制當即轉離線存儲)
    • 對於離線消息, 可能有不少條.

      • 拉取的時候,通常會把離線的消息都一次性的拉取過來
        • 多條消息的時候,就要保證收取到的消息的順序性.
  2. 怎麼保證不亂序?

    • 每條消息到服務端後,都會生成一個全局惟一的msgid, 這個msgid必定都是遞增增加的(msgid的生成會有同步機制保證併發時的惟一性)

    • 針對每條消息,會有消息的生成時間,精確到毫秒

    • 拉取多條消息的時候,取出數據後,再根據msgid的大小進行排序便可.

保證惟一性(不重複)

  1. 消息爲何可能會重複呢?

    • 移動網絡的不穩定性,可能致使某天消息發送不出去,或者發送出去了,迴應ack沒有收到.
      • 這種狀況下,就可能會須要有重發機制. 客戶端和服務端均可能須要有這種機制.

      • 既然有重複機制,就有可能收到的消息是重複的.

  2. 怎麼解決呢? 保證不重複最好是客戶端和服務端相關處理

    • 消息meta結構裏面增長一個字段isResend. 客戶端重複發送的時候置位此字段,標識這個是重複的,服務端用來後續判斷

    • 服務端爲每一個用戶緩存一批最近的msgids(所謂的localMsgId),如緩存50條

    • 服務端收到消息後, 經過判斷isResend和此msgid是否在localMsgId list中. 若是重複發送,則服務端不作後續處理.

    • 由於僅僅靠isResend不可以準備判斷,由於可能客戶端確實resend,可是服務端確實就是沒有收到......

保證可達(不丟且不重)

  1. 最簡單的就是服務端每傳遞一條消息到接收方都須要一個ack來確保可達

    • 可是ack也有可能在弱網環境下丟失.
  2. 服務端返回給客戶端的數據,有可能客戶端沒有收到,或者客戶端收到了沒有迴應.

    • 所以,就必定要有完善的確認機制來告知客戶端確實收到了. 有且僅有一次.
  3. 考慮一個帳號在不一樣終端登陸後的狀況.

    • 消息要可以發送到當前登陸的終端,並且又不能重複發送或者拉取以前已經拉取過的數據.

技術點二之: msgID機制

這裏提供兩種方案供參考(本質思想同樣,實現方式不一樣)

序列號msgid機制 & msgid確認機制(方案一):

  • 每一個用戶的每條消息都必定會分配一個惟一的msgid

  • 服務端會存儲每一個用戶的msgid 列表

  • 客戶端存儲已經收到的最大msgid

image.png

優勢:

  1. 根據服務器和手機端之間sequence的差別,能夠很輕鬆的實現增量下發手機端未收取下去的消息

  2. 對於在弱網絡環境差的狀況,丟包狀況發生機率是比較高的,此時常常會出現服務器的回包不能到達手機端的現象。因爲手機端只會在確切的收取到消息後纔會更新本地的sequence,因此即便服務器的回包丟了,手機端等待超時後從新拿舊的sequence上服務器收取消息,一樣是能夠正確的收取未下發的消息。

  3. 因爲手機端存儲的sequence是確認收到消息的最大sequence,因此對於手機端每次到服務器來收取消息也能夠認爲是對上一次收取消息的確認。一個賬號在多個手機端輪流登陸的狀況下,只要服務器存儲手機端已確認的sequence,那就能夠簡單的實現已確認下發的消息不會重複下發,不一樣手機端之間輪流登陸不會收到其餘手機端已經收取到的消息。

用戶在不一樣終端登陸的狀況下獲取消息狀況

image.png

假如手機A拿Seq_cli = 100 上服務器收取消息,此時服務器的Seq_svr = 150,那手機A能夠將sequence爲[101 - 150]的消息收取下去,同時手機A會將本地的Seq_cli 置爲150

image.png
手機A在下一次再次上來服務器收取消息,此時Seq_cli = 150,服務器的 Seq_svr = 200,那手機A能夠將sequence爲[151 - 200]的消息收取下去.

image.png
假如原手機A用戶換到手機B登陸,並使用Seq_cli = 120上服務器收取消息,因爲服務器已經確認sequence <= 150的消息已經被手機收取下去了,故不會再返回sequence爲[121 - 150]的消息給手機B,而是將sequence爲[151 - 200]的消息下發給手機B。

序列號msgid機制 & msgid確認機制(方案二: xxx項目目前方案):

  • 每一個用戶的每條消息都必定會分配一個惟一的msgid

  • 服務端會存儲每一個用戶的msgid 列表

  • 客戶端存儲已經收到的最大msgid

    • 對於單聊,羣聊,匿名分別存儲(某人對應的id,某羣對應的id).

image.png

思考

這兩種方式的優缺點?

  1. 方式二中,確認機制都是多一次http請求. 可是可以保證及時淘汰數據

  2. 方式一中,確認機制是等到下一次拉取數據的時候進行肯定, 不額外增長請求, 可是淘汰數據不及時.

技術點三之: 心跳策略

心跳功能: 維護TCP長鏈接,保證長鏈接穩定性, 對於移動網絡, 僅僅只有這個功能嗎?

  1. 心跳其實有兩個做用

    • 心跳保證客戶端和服務端的鏈接保活功能,服務端以此來判斷客戶端是否還在線

    • 心跳還須要維持移動網絡的GGSN

      • 運營商經過NAT(network adddress translation)來轉換移動內網ip和外網ip,從而最終實現連上Internet,其中GGSN(gateway GPRS support Node)模塊就是來實現NAT的過程,可是大部分運營商爲了減小網關NAT的映射表的負荷,若一個鏈路有一段時間沒有通訊就會刪除其對應表,形成鏈路中斷,所以運營商採起的是刻意縮短空閒鏈接的釋放超時,來節省信道資源,可是這種刻意釋放的行爲就可能會致使咱們的鏈接被動斷開(xxx項目以前心跳有被運營商斷開鏈接的狀況,後面改進了心跳策略,後續還將繼續改進心跳策略)

      • NAT方案說白了就是將過去每一個寬帶用戶獨立分配公網IP的方式改成分配內網IP給每一個用戶,運營商再對接入的用戶統一部署NAT設備,NAT的做用就是將用戶網絡鏈接發起的內網IP,以端口鏈接的形式翻譯成公網IP,再對外網資源進行鏈接。

      • 從mobile 到GGSN都是一個內網,而後在GGSN上作地址轉換NAT/PAT,轉換成GGSN公網地址池的地址,因此你的手機在Internet 上呈現的地址就是這個地址池的公網地址

  2. 最多見的就是每隔固定時間(如4分半)發送心跳,可是這樣不夠智能.

    • 4分半的緣由就是綜合了各家移動運營商的NAT超時時間

    • 心跳時間過短,消耗流量/電量,增長服務器壓力.

    • 心跳時間太長,可能會被由於運營商的策略淘汰NAT表中的對應項而被動斷開鏈接

  3. 智能心跳策略

    • 維護移動網GGSN(網關GPRS支持節點)

      • 大部分移動無線網絡運營商都在鏈路一段時間沒有數據通信時,會淘汰 NAT 表中的對應項,形成鏈路中斷。NAT超時是影響TCP鏈接壽命的一個重要因素(尤爲是國內),因此客戶端自動測算NAT超時時間,來動態調整心跳間隔,是一個很重要的優化點。
    • 參考微信的一套自適應心跳算法:

      • 爲了保證收消息及時性的體驗,當app處於前臺活躍狀態時,使用固定心跳。

      • app進入後臺(或者前臺關屏)時,先用幾回最當心跳維持長連接。而後進入後臺自適應心跳計算。這樣作的目的是儘可能選擇用戶不活躍的時間段,來減小心跳計算可能產生的消息不及時收取影響。

  4. 精簡心跳包,保證一個心跳包大小在10字節以內, 根據APP先後臺狀態調整心跳包間隔 (主要是安卓)

技術點四之: 斷線重連策略

掉線後,根據不一樣的狀態須要選擇不一樣的重連間隔。若是是本地網絡出錯,並不須要定時去重連,這時只須要監聽網絡狀態,等到網絡恢復後重連便可。若是網絡變化很是頻繁,特別是 App 處在後臺運行時,對於重連也能夠加上必定的頻率控制,在保證必定消息實時性的同時,避免形成過多的電量消耗。

  1. 斷線重連的最短間隔時間按單位秒(s)以四、八、16...(最大不超過30)數列執行,以免頻繁的斷線重連,從而減輕服務器負擔。當服務端收到正確的包時,此策略重置

  2. 有網絡但鏈接失敗的狀況下,按單位秒(s)以間隔時間爲二、二、四、四、八、八、1六、16...(最大不超過120)的數列不斷重試

  3. 爲了防止雪崩效應的出現,咱們在檢測到socket失效(服務器異常),並非立馬進行重連,而是讓客戶端隨機Sleep一段時間(或者上述其餘策略)再去鏈接服務端,這樣就可使不一樣的客戶端在服務端重啓的時候不會同時去鏈接,從而形成雪崩效應。

典型IM業務場景流程

  1. 用戶A發送消息給用戶B

    • A 經過帳號密碼獲取token.
    • A 拿着token進行login
    • 服務端緩存用戶信息並維持登陸狀態
    • A 打包數據發送給服務端
    • 服務端檢測A用戶是否風險用戶
    • 服務端對消息進行敏感詞檢查(這個重要)
    • 服務端生成msgid
    • 服務端進行好友檢測(A/B)
    • 服務端進行重複發送檢測
    • 服務端獲取B的鏈接信息,並判斷在線狀態
    • 若是在線,直接發送給B,併入cache和db
    • 若是不在線,直接存儲.若是是ios,則進行apns.
    • 在線的B,收到消息後迴應ack進行確認.
  2. 用戶A發送消息到羣C

存儲結構

未讀索引列表

  • 未讀消息索引存在的意義在於保證消息的可靠性以及做爲離線用戶獲取未讀消息列表的一個索引結構。

  • 未讀消息索引由兩部分構成,都存在redis中:

    • 記錄用戶每一個好友的未讀數的hash結構
    • 每一個好友對應一個zset結構,裏面存着全部未讀消息的id。
  • 假設A有三個好友B,C,D。A離線。B給A發了1條消息,C給A發了2條消息,D給A發了3條消息,那麼此時A的未讀索引結構爲:

  • hash結構

    • B-1
    • C-2
    • D-3
  • zset結構

User MsgId 1 MsgId 2 MsgId 3
B 1 - -
C 4 7 -
D 8 9 10
  • 消息上行以及隊列更新未讀消息索引是指,hash結構對應的field加1,而後將消息id追加到相應好友的zset結構中。

  • 接收ack維護未讀消息索引則相反,hash結構對應的field減1,而後將消息id從相應好友中的zset結構中刪除。

消息下行(未讀消息的獲取)

該流程用戶在離線狀態的未讀消息獲取。

該流程主要由sessions/recent接口提供服務。流程以下:

  • hgetall讀取未讀消息索引中的hash結構。
  • 遍歷hash結構,若未讀數不爲0,則讀取相應好友的zset結構,取出未讀消息id列表。
  • 經過消息id列表到緩存(或穿透到數據庫)讀取消息內容,下發給客戶端。

和在線的流程相同,離線客戶端讀取了未讀消息後也要發送接收ack到業務端,告訴它未讀消息已經下發成功,業務端負責維護該用戶的未讀消息索引。

和在線流程不一樣的是,這個接收ack是經過調用messages/lastAccessedId接口來實現的。客戶端須要傳一個hash結構到服務端,key爲經過sessions/recent接口下發的好友id,value爲sessions/recent接口的未讀消息列表中對應好友的最大一條消息id。

服務端收到這個hash結構後,遍歷它

  • 清空相應緩存
  • 經過zremrangebyscore操做清空相應好友的zset結構
  • 將未讀消息索引中的hash結構減掉zremrangebyscore的返回值

這樣就完成了離線流程中未讀消息索引的維護。

隊列處理流程

  • 若是消息標記爲offline,則將消息入庫,寫緩存(只有離線消息才寫緩存),更新未讀消息索引,而後調用apns進行推送。

  • 若是消息標記爲online,則直接將消息入庫便可,由於B已經收到這條消息。

  • 若是消息標記爲redeliver,則將消息寫入緩存,而後調用apns進行推送。

討論後的疑問

把鏈接層Access拆一層connd server出來的考量和目的,到底有沒有必要?

  1. 拆分出來的目的:

    • 鏈接層更穩定
    • 減小重啓,方便Access服務升級
  2. 真的可以起到這樣的效果麼?

    • 鏈接層更穩定 - - - 須要有硬性指標來判斷才能肯定更穩定,由於Access的服務不重,目前也不是瓶頸點.

      • 目前Access服務不重, 拆分出來真有必要嗎?

      • 真要拆分, 那也不是這麼拆分, 是在Oracle上作拆分, 相似微服務的那種概念

      • 穩定性不是這麼體現,原來 connd 的設計,更薄不承擔業務,而如今的 access 仍是有一些業務邏輯,那麼它升級的可能性就比較高。

      • access 拆分,目的就是讓保持鏈接的那一層足夠薄,薄到怎麼改業務,它都不用升級代碼(tcp 不會斷)。

    • 減小重啓,方便Access服務升級 - - - 不能經過增長一層服務來實現重啓升級,須要有其餘機制來確保服務端進行升級而不影響TCP長鏈接上的用戶

      • 拆分出來的connd server 仍是有可能會須要重啓的, 這時候怎麼辦呢 ?關鍵性問題仍是沒有解決

      • 加一層服務,是打算經過共享內存的方式,connd 只管理鏈接。access 更新升級的時候,用戶不會掉線。

    • 增長一個服務,就多了一條鏈路, 就可能會致使服務鏈路過長,請求通過更多的服務,會致使服務更加不可用. 由於要保證每一個服務的可用性都到99.999%(5個9)是很難的,增長一個服務,就會下降整個服務的可用性.

  3. 架構改進必定要有數據支撐, 要確實起到效果, 要有數據輸出才能證實這個改進是有效果的,要否則花了二個月時間作改進,結果沒有用,浪費人力和時間,還下降開發效率

    • 每一個階段的架構可能都不同,根據當前階段的用戶量和熱度來決定

怎麼保證接入層服務重啓升級? 服務擴/縮容?

  1. 方案: 增長一條信令交互,服務端若是要重啓/縮容, 告知鏈接在此Access上的全部客戶端,服務端要升級了,客戶端須要重連其餘節點

    • 這實際上是屬於一種主動遷移的策略,這樣客戶端雖然仍是有重連,比咱們直接斷鏈接會好一些.
  2. 等肯定當前Access節點上的全部客戶端都鏈接到其餘節點後, 當前Access節點再進行重啓/下線/縮容.

  3. 怎麼擴容? 若是須要擴容,則增長新的節點後,經過etcd進行服務發現註冊.客戶端經過router server請求數據後,拉取到相關節點.

  4. 若是當前3個節點扛不住了,增長2個節點, 這個時候,要可以立刻緩解當前3個節點壓力,須要怎麼作?

    • 按照以前的方式,客戶端從新登陸請求router server,而後再進行鏈接的話,這是不可以立刻緩解壓力的,由於新增的節點後, 當前壓力仍是在以前幾個節點

    • 因此, 服務端須要有更好的機制,來由服務端控制

      • 服務端發送命令給當前節點上的客戶端,讓客戶端鏈接到新增節點上.

      • 服務端還須要肯定是否有部分鏈接到其餘節點了,而後再有相應的策略.

怎麼防止攻擊

  1. 線上機器都有防火牆策略(包括硬件防火牆/軟件防火牆)

    • 硬件防火牆: 硬件防火牆設備,很貴,目前有采購,可是用的少

    • 軟件防火牆: 軟件層面上的如iptable, 設置iptable的防火牆策略

  2. TCP 通道層面上

    • socket建連速度的頻率控制, 不能讓別人一直創建socket鏈接,要否則socket很容易就爆滿了,撐不住了

      • 目前設置的是獨立ip建連速度超過100/s,則認爲被攻擊了,封禁此ip
    • 收發消息頻率控制, 不能讓別人一直可以發送消息,要否則整個服務就掛掉了

      • 要可以發送消息, 必需要先登陸

      • 要登陸, 必須有token,有祕鑰

      • 收發消息也能夠設置頻率控制

目前市面上的開源/通用協議的比較選型

  1. 爲啥xmpp不適合,僅僅是由於xml數據量大嗎 ?

    • 目前也有方案是針對xmpp進行優化處理的. 所以流量大並非主要缺點

    • 還有一點就是消息不可靠,它的請求及應答機制也是主要爲穩定長連網絡環境所設計,對於帶寬偏窄及長連不穩定的移動網絡並非特別優化

    • 所以設計成支持多終端狀態的XMPP在移動領域並非擅長之地

  2. 爲啥mqtt不適合? 爲啥xxx項目沒有用mqtt ?

    • mqtt 適合推送,不適合IM, 須要業務層面上額外多作處理, 目前已經開始再用

    • xxx項目不用mqtt是歷史遺留問題,由於剛開始要迅速開展,迅速搭建架構實現,所以用來蘑菇街的teamtalk.

    • 若是後續選型的話, 若是沒有歷史遺留問題,那麼就會選擇使用mqtt

  3. 除了數據量大, 還要考慮協議的複雜度, 客戶端和服務端處理協議的複雜度?

    • 協議要考慮容易擴展, 方便後續新增字段, 支持多平臺

    • 要考慮客戶端和服務端的實現是否簡單

    • 編解碼的效率

跨機房, 多機房容災

  1. 服務須要可以跨機房,尤爲是有狀態的節點.

  2. 須要儲備多機房容災,防止整個機房掛掉.

剛討論說到接入層有哪些功能的:

  1. 維持TCP長鏈接,包括心跳/超時檢測

  2. 收包解包

  3. 防攻擊機制

  4. 等待接收消息迴應(這個以前沒有說到,就是把消息發送給接收方後還須要接收方迴應)

思考點(考覈關鍵點)

  1. 消息爲何可能會亂序? 怎麼保證消息不亂序?

    • 考慮離線
    • 考慮網絡異常
  2. 對於離線消息,存儲方式/存儲結構要怎麼設計?

    • 考慮會有多我的發送消息
    • 考慮緩存+db的方式
  3. 如何保證消息不丟,不重? 怎麼設計消息防丟失機制?

    • 考慮同一帳號可能會多終端登陸
    • 考慮弱網環境下,ACK也可能會丟失
  4. 對於長鏈接, 怎管理這些長鏈接?

    • 考慮快速查找
      • 後端數據來了, 怎麼快速找到這個請求對應的鏈接呢
  5. 接入層節點有多個,並且是有狀態的.經過什麼機制保證從節點1下發的請求,其對應的響應仍是會回到節點1呢?

    • 或者說若是響應不回到節點1,而是回到節點2了會有什麼弊端?

【"歡迎關注個人微信公衆號:Linux 服務端系統研發,後面會大力經過微信公衆號發送優質文章"】

個人微信公衆號
相關文章
相關標籤/搜索