原創分佈式即時通信(IM)系統理論架構方案

不管是IM消息通訊系統仍是客戶消息系統,其本質都是一套消息發送與投遞系統,或者說是一套網絡通訊系統,其本質兩個詞:存儲與轉發。html

watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=

watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=

上圖所示顯示了攜程家的消息系統的初期架構,圖中架構直接用mongodb做爲消息隊列,而後就把系統開發出來了,圖中中能夠見到一個常見IT系統的接口層。html5

京東咚咚初期架構

watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=

上圖揭示了京東家的消息系統的初期架構,其特色是「爲了業務的快速上線,1.0 版本的技術架構實現是很是直接且簡單粗暴的」,並且後臺系統使用.net基於Redis就把一個IM系統開發出來了。java

兩家系統的初期架構說明,一套消息系統對提高自家的服務質量是多麼的重要,能夠認爲現代的服務型的互聯網公司成長過程就是一套IM系統的進化史。mysql

二 、本次方案的總體思路


本文結合鄙人對IM系統的瞭解,也給出一套初具IM系統系統特色的消息系統架構模型。本文只考慮IM系統的在線消息模型,不考慮其離線消息系統[可以存儲IM消息的系統]。ios

一、根據我的理解,其應有的feature以下
  • A 整個系統中Server端提供存儲轉發能力,不管總體架構是B/S仍是C/S;web

  • B 消息發送者可以成功發送消息給後端,且獲得後端地確認;面試

  • C 接收端可以不重不漏地接收Server端轉發來的沒有超過消息生命週期和系統承載能力的消息;算法

  • D 整個系統只考慮文本短消息[即限制其長度];sql

  • E 每條消息都有生命週期,如一天,且有長度限制如1440B【儘可能不要超過一個frame的size】,只考慮在線消息的處理,不管是超時的消息仍是超出系統承載能力的消息[如鍵盤狂人或者鍵盤狂機器人發出的消息]都被認爲是"垃圾消息";mongodb

  • F 爲簡單起見,不給消息不少類型,如我的對我的消息,羣消息,討論組消息等,都認爲是一種羣[下文用channel替代之,也有人用Room這個詞]消息類型;

  • G 爲簡單起見,這個羣的創建與銷燬流程本文不述及,也即消息流程開始的時候各個消息羣都已經組建完畢,且流程中沒有成員的增減;

  • H 帳戶申請、用戶鑑權和天朝獨有的黃反詞檢查等IM安全層等暫不考慮。

2根據以上系統特色,先給出一套稍微完備的IM系統的框架圖

watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=

3系統名詞解釋
  • 1 PC: 單機型客戶端,如windows端和mac端等等;

  • 2 Web/h5: 網頁客戶端;

  • 3 Android:手機移動端,取其典型Android端,固然也有ios端[可是考慮到各家開發App都是安卓客戶端最早上系統新版本,故用Android表明之];

  • 4 broker:文本消息的有線或者無線接口端,考慮到攜程採用了這個詞,我也姑且先用之,它提供了消息的接收與投遞功能;

  • 5 Relay:圖片/語音/視頻 轉發接口端,其後端能夠是自家的服務也能夠是第三方服務(如提供圖片存儲服務的七牛、提供雲視頻解決方案的騰訊雲等);

  • 6 msg chat server:消息邏輯處理端;

  • 7 Router: 在線狀態服務端,存儲在線的用戶以及其登陸的broker接口機的id以及一些心跳包時間等數據;

  • 8 Counter: 消息計數器,爲每一個text等類型的消息分配MSG id;

  • 9 Msg Queue: 每一個channel消息的msg id隊列,存儲每一個client未接收的且未超時的且未超出隊列大小的msg id集合;

  • 10 Mysql/mongodb: 消息存儲服務、用戶資料數據、以及channel成員列表服務數據庫,由於兩者比較典型,因此取用了這個名字,固然你能夠在其上部署一層cache服務;

  • 11 Client:客戶端層;

  • 12 Interface/If(下文簡稱If):服務接口層;

  • 13 Logic:消息邏輯處理層,[這層其實應該有系統最多的模塊];

  • 14 DB:存儲層,存儲了在線狀態、消息id以及msg id隊列和消息內容等;

  • 15 http: 消息發送和接收協議,IM協議中通常理解爲long polling消息處理方式,在web端多采用這種協議;

  • 16 Websocket: 另外一種消息發送和接收協議,在移動環境或者採用html5開發的系統多采用這種協議;

  • 17 TCP: 另外一種消息發送和接收協議,在環境或者採用html5開發的系統多采用這種協議;

  • 18 UDP: 另外一種消息發送和接收協議,某個不保證提供穩定消息傳輸服務的廠家採用的協議,也許也是用戶最多使用的協議,它的優勢是不管是無線仍是有線環境下都很是快,又因爲http/Websocket的基礎都是tcp協議,UDP協議在環境擁塞狀況下因爲不提供擁塞控制等退讓算法,反而會去爭用網絡通道,因此在網絡複雜的特別是發生網絡風暴的狀況下它會顯得更快^ _ ^ & ^ _ ^【呵呵噠】;

  • 19 RPC: 一種遠程過程調用協議,提供分佈式環境下的函數調用能力;

  • 20 Restful: 一種遠程服務提供的架構風格,跟RPC比起來貌似更高級點。

三:具體消息發送流程

在介紹消息發送流程以前,先介紹一些基本概念。

1pub/sub、UIN和session

一個消息系統,從宏觀上來講,就是一個PUB/SUB系統,有消息生成者publisher[or producer],有消息中轉者broker,有消息處理者msg server,以及消息消費者subscriber[or consumer]。消息消費者能夠是一我的,也能夠是一羣人,在pub/sub系統之中producer&consumer一塊兒構成了一個channel,或者稱之爲room,或者稱之爲group。


不管是producer仍是consumer,每一個具體單位都要由系統分配給一個id,稱之爲UIN[名詞來源於icq]。


後端的if層的broker機器能夠在全球或者某個區域分佈多個,UIN依據dns系統能夠獲得if層全部的機器列表,若是dns層因爲機器壞掉或者是被***時不能服務,那麼客戶端應該根據記憶[不管是上次成功登錄的機器仍是被廠家內置的機器列表]知道某些機器的ip&port地址,而後根據測速結果來選擇一個離其最近的broker。


UIN在於broker之間進行一段時間內有效的會話服務,稱之爲一個session。這個session存活於一個長鏈接裏,也能夠橫跨幾個長鏈接或者短鏈接,即session自身依賴的網絡連接是不穩定的。session有效期間內,Server認爲UIN在線,session有效期內客戶端要定時地給broker發送心跳包。本文認爲的session能夠是不穩定的,即session有效期內下發給客戶端的消息能夠丟失,可是能夠經過一些其餘手段保證消息被投遞給客戶端。

四: 發送流程

消息的製造者[producer]通常是IM系統的最基本單元UIN[即一個天然人],既然是一個天然人,就認爲其發送能力有限,不可能一秒內發出多於一條的消息,即其消息頻率最高爲:1條msg / s。高於這個頻率,都被認爲是鍵盤狂人或者狂躁機器人,客戶端或者服務端應該具備拒絕給這種人提供服務或者丟棄其因爲發狂而發出的消息。

基於上面這個假設,producer發出的消息請求被稱爲msg req,服務器給客戶端返回的消息響應稱爲msg ack。整個消息流程爲:

  • A client以阻塞方式發出msg req,req = {producer uin, channel name, msg device id, msg time, msg content};

  • B broker收到消息後,以uin爲hash或者經過其餘hash方式把消息轉發給某個msg chat server;

  • C msg chat server收到消息後以key = Hash{producer uin【發送者id】 + msg device id【設備id】+ msg time【消息發送時間,精確到秒】}到本地消息緩存中查詢消息是否已經存在,若是存在則終止消息流程,給broker返回"duplicate msg"這個msg ack,不然繼續;

  • D msg chat server到Counter模塊以channel name爲key查詢其最新的msg id,把msg id自增一後做爲這條消息的id;

  • E msg chat server把分配好id的消息插入本地msg cache和msg DB[mysql/mongoDB]中;

  • F msg chat server給broker返回msg ack, ack = {producer uin, channel name, msg device id, msg time, msg id};

  • G broker把msg ack下發給producer;

  • H producer收到ack包後終止消息流程,若是在發送流程超時後仍未收到消息則轉到步驟1進行重試,並計算重試次數;

  • I 若是重試次數超過兩次依然失敗則提示「系統繁忙」 or 「網絡環境不佳,請主人稍後再嘗試發送」等,終止消息發送流程。

上面設計到了一個模塊圖中沒有的概念:msg cache,之因此沒有繪製出來,是由於msg cache的大小是可預估的,它只是用於消息去重判斷,因此只需存下去重msg key便可。假設msg chat server的服務人數是40 000人,消息發送頻率是1條/s,消息的生命週期是24 hour,消息key長度是64B,那麼這個cache大小 = 64B * (24 * 3600)s * 40000 = 221 184 000 000B,這個數字可能有點恐怖,若是是真實商業環境這個數字只會更小,由於沒有人一天一晚上不吃不喝不停發消息嘛。其本質是一個hashset(C++中對應的是unordered_set),物理存儲介質固然是共享內存了。

[2016/03/10日:通過思考,msg cache只需存下某個UIN在某個device上的最新的消息時間便可,msg cache的結構應爲hashtable,以{UIN + device id}爲key,以其最新的消息的發送時間(客戶端發送消息的時間)爲value,再也不考慮消息的生命週期。msg chat server每收到一條新消息就把新消息中記錄的發送時間與緩存中記錄的消息時間比較便可,若是新消息的時間小於這個msg pool記錄的時間即說明其爲重複消息,大於則爲新消息,並用新消息的msg time做爲msg cache中對應kv的value的最新值。假設UIN爲4B,device id爲4B,時間爲4B,則msg cache的數據的size(不計算hashtable數據結構自己佔用的內存size)爲12B * 40000 = 480 000B,新msg pool徹底與每條消息的lifetime無關,這就大大降低了其內存佔用。

那麼還有一個問題,若是用戶修改了手機的本地時間怎麼辦?那就換作另外一個參數:本地手機時鐘累計運行時長,手機出廠後其運行累計時長只會一直增長不會減少。

這個流程牽涉到一個比較重要的模塊:Counter,這個模塊其實均可以用Redis充當,怎麼作你本身想^ _ ^。這個模塊自身的實現就是一個分佈式的計數器,直接使用Redis也沒什麼問題,可是最好的方法是採用消息id批發器的方式,msg chat server到Counter每次批發一批id回來,而後分配給每一個msg,當使用完畢的時候再接着去Counter申請一批迴來,以減輕Counter的壓力,具體的設計請參考專利《即時消息的處理方法和裝置》[參考文檔9]。

上面還有一個概念未敘述到:發送端的消息郵箱{有人稱爲消息盒子,或者某大廠稱之爲客戶端消息db},它存儲了全部本地發送出去的消息,其中沒有服務端分配的msg id的消息都被認爲是發送失敗的消息,待用戶主動嘗試發送或者網絡環境從新穩定後能夠有客戶端嘗試從新發送流程。

用戶查看消息郵箱中的本地歷史消息的時候,就要依據msg id把消息排序好展示給用戶。至於用戶發送過程當中看到的消息能夠認爲是本地消息的一個cache,每一個channel最多給他展示100條,這100條消息的排序要依照每條消息的發出時間或者是消息的接收時間[這個接收到的消息時間以消息到達本機時的本地時鐘爲依據]。當用戶要查看超出數目如100條消息以外的消息,客戶端要引導用戶去走歷史消息查看流程。

3消息狀態部分流程

在進行消息的發送流程中,msg chat server充當了消息的處理者,其實消息的發送流程就能夠認爲是一次客戶端與服務端進行簡單的「心跳邏輯」的過程,這個過程msg chat server[實際上就是下面提到的heartbeat server]還要完成以下部分消息狀態處理邏輯:

  • 1 heartbeat server到Router中直接修改producer的狀態爲在線;

  • 2 heartbeat server要把client鏈接的broker的id以及其最新登陸時間更新至Router中;

至於Router具體的構造,下一章節會敘述到。

4關於長文本消息

還有一個問題,若是消息超過服務端規定的短文本消息的最大長度怎麼辦?
一種方法是乾脆丟棄,拒絕給客戶端發送出去,貌似用戶體驗沒那麼好。

還有另外一種方法,分片。用分片的方法拆成若干條短消息,每條短消息由客戶端或者服務端本身給他分配好序列號,待用戶收到的時候再拼裝起來。其本質跟tcp層處理大package時拆分若干個子packet道理同樣。

長文本若是能借用第二種方法處理,發送圖片是否是也能夠這麼幹?其本質都是數據嘛,語音和視頻數據的處理亦不外乎如是。

四 消息處理以及消息投遞流程

上述的消息發送流程中,msg chat server把分配的msg id的消息返回給producer後,還要繼續進行消息的投遞。消息的投遞涉及到一系列的技巧,涉及到消息的訂閱者可否不重不漏地在消息還「活着」的消息,這些技巧其實也沒什麼神祕之處,下面的流程會詳細地描述到。

1消息投遞流程

消息投遞,顧名思義,就是消息的下發而已,有人美其名曰消息Push流程。
若是說消息的發送 = msg req + msg ack, 那麼消息的投遞就簡單多了:

  • A msg chat server到channel成員列表服務數據庫拉取成員列表;

  • B msg chat server循環到Router中查看每一個成員是否在線,若是在線則獲取成員鏈接的broker接口機地址;

  • C msg chat server發送消息到broker;

  • D broker接收到消息後就把msg下發給客戶端;

  • E msg chat server循環給在線的成員發送完消息後,把msg id放入其channel在msg queue中的msg id list的末尾;

  • F 若是msg queue的msg id list超過長度限制,則要刪除掉鏈表的head部分的若干id,以保證list長度不超過系統規定的參數;

  • G 流程結束。

消息的投遞是否是顯得輕鬆多了,至於"被認爲在線"客戶端有沒有收到msg,msg chat server壓根就無論!

這個流程牽涉到另外一個比較重要的模塊:router,它其實也能夠用Redis充當,利用Redis的bitmap記錄全部用戶的狀態,0標示離線,1表示在線,而後再利用hashtable存儲每一個用戶登陸的broker的id和最新登陸時間。

至於msg queue模塊,其實也是一個hashtable,key爲channel的name或者id,value就是一個msg id list。

據說Redis最近要添加Bloom Filter,那就更好玩了,關鍵就看其可否應對刪除操做,若是有刪除接口,把它當作bitmap玩玩倒也無妨。

五 心跳流程

一個客戶端要維持與服務端的session有效,就須與其broker維持一個心跳流程,以被認爲是處於在線狀態。那麼,最基本的問題就是:心跳時長。
這個問題會讓不少移動開發者頭疼許久,最基本的要根據網絡環境來設計不一樣的心跳時長:譬若有線環境把頻率設置爲10s,wifi環境下這個頻率設計爲30s,在3G或者4G環境下設置爲1.5分鐘,在2G環境下設置爲4分鐘。總之其原則是:網絡環境越差勁,心跳時間間隔越長。

心跳時間間隔長那麼其心跳頻率就低,其消息收發速度就慢。

進一步,無線環境下這個心跳時間長度不是固定不變的,具體時長要由服務端進行判斷,若是無線環境下假設起始心跳間隔是4分鐘,客戶端連續最近3次心跳有一次失敗,那就把時長修改成2分鐘,若是有兩次失敗就修改成1分鐘,若是連續3次超時未上報心跳,就認爲客戶端離線!

(2016/03/10): 通過今日思考,以爲上面這一段的例子中參數是錯誤的,它違背了上上段敘述的原則,當出現心跳超時的狀況後就說明網絡環境發生了變化,可是僅僅憑藉一次超時還不足以說明網絡環境變好仍是變壞。其實把心跳時長的問題轉換一個角度進行思考:當知道了前三次或者前兩次實際心跳時間間隔,怎麼預測接下來的心跳時間間隔?其本質就是一個拉格朗日外插法的應用而已。我這裏很少敘述,僅僅給出一種方法:若是已經知道最近兩次心跳時間間隔爲iv1和iv2,則接下來的給客戶端返回的iv3 = k * ((iv1 + iv2) / 2),若是iv1 > iv2,則k = 0.8,不然k = 1.2,這兩個值也僅僅是經驗值而已,具體怎麼取值需系統設計者本身權衡,但足以自適應一些複雜的網絡環境,如坐在火車上使用移動網絡的APP。

若是系統設計者以爲麻煩,就能夠把上面的值修改成經驗參數值,如無線環境下假設起始心跳間隔是4分鐘,客戶端連續最近3次心跳有一次失敗,那就把時長修改成4.5分鐘,若是有兩次失敗就修改成5.5分鐘,若是連續3次超時未上報心跳,就認爲客戶端離線!

解決了心跳時長問題,再來看看具體的心跳流程:

  • A 客戶端發送心跳包hearbeat,heartbeat = {uin, device id, network type, list{channel name:newest channel msg id},other info},即heartbeat包要上報uin所在的全部channel,以及本地歷史消息記錄中每一個channel最新的消息的id;

  • B broker把心跳包轉給專門處理心跳邏輯的msg chat server[如下稱爲heartbeat server];

  • C heartbeat server到Router中更新client的在線狀態以及登陸的broker的id和最新登陸時間;

  • D heartbeat server到Counter服務器循環查詢每一個channel的最新消息id,若是客戶端上報的id與這個id不等,就發送一條msg通知msg chat server,msg = {uin, channel name, client newest msg id of channel};

  • E msg chat server收到這條消息後,從新啓動消息下發邏輯,到msg queue中取出全部的大於{client newest msg id of channel}的id列表;

  • F msg chat server依據list中的id到消息存儲服務器中依次取出每一個msg[取不到也就表示這個消息由於超時而被消息存儲服務器刪除了];

  • G msg chat server把這些消息做爲"未讀消息"下發給客戶端;

  • H heartbeat server根據Router存儲的客戶端的最近三次的登陸時間,調整session的心跳時間間隔,做爲心跳回包的一部分參數值給客戶端下發heartbeat ack包,其餘數據包括其所在的每一個channel的最新消息的msg id;

  • I heartbeat server定時地到Router中檢查全部客戶端的最新登陸時間,若是超過其session有效時間,就把其state置爲「離線」,並刪除其登陸服務id等數據;

  • J 客戶端收到heartbeat ack包後,修改下次心跳時間,並依據每一個channel的最新的msg id與本地消息郵箱中對應的channel的最新消息id作對比,若是id不等,客戶端能夠啓動拉取消息流程或者等待server端把這些消息下發過來。

上面提到的一個詞:newest channel id 或者 client newest msg id of channel,其意思就是消息接收者所在的channel的所擁有的本地消息的最新id。通常地,若是server端的Counter可以穩定地提供服務,channel中的msg id應該是連續的,若是客戶端檢測到msg id不連續,能夠把不連續處的id做爲newest channel id,要求server端再把這個msg id之後的消息重發下來,這就要求client有消息去重判斷的功能。

每次收到server端下發的消息後,用戶必須更新local newest channel msg id,把消息id窗口往前推動,不要由於id不連續而一直不更新這個值,由於服務端的服務也不必定超級穩定。

上面的一段我寫的稍嫌「囋」一些,其實其思想相似於tcp的滑動窗口思想,本身作對比去理解之。

step H要求router至少要存儲client最新四次的登陸時間,而後根據這三次時間間隔以及網絡類型修改下次心跳時間間隔有效時長。我這裏已經很明瞭的寫出了原理了,至於怎麼取值能夠依據上面提到的原理修改相關參數[這個得須要測試才能得出一些關鍵數據,可是這個參數值應該跟我本文提到的參數值相差無幾]。

至於step J敘述到的client是否啓用消息拉取邏輯,取決於你的服務類型。具體場景分別對待,本文不會再設計消息的pull流程。

其實結合第4章節以及本章節,用流行的術語來講,消息的下發就是微信所謂的"是參考Activesyec,SYNC協議"[參考文檔7]流程,江湖人稱推拉相結合的過程。
這個過程能夠用一副流程圖作參考:

watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=注意上圖與本文一些名詞的用法不一樣,它的所謂的「離線消息」,咱本文中被稱爲"未讀消息"。隨着本章節的結束,IM的主要流程就描述完畢。

六 消息存儲服務

因爲本文敘述的消息系統是一個在線消息模型,因此msg db中存儲的超時消息必須被刪除。首先db的大小能夠根據服務人數的數目以及每條消息的時長估算出來。

其次,簡單的im系統中不考慮用戶的等級的話,能夠認爲全部的msg都是平等的有相同的lifetime。可是若是區分了用戶優先級,則其消息lifetime也就不等,就得有服務等級不一樣用戶的msg db[其實優先級越高,其消息存儲越久,企業付出了存儲成本,某種神祕的力量也就越容易獲取到其聊天數據]。

最後,啓動一個定時消息刪除模塊,它定時啓動刪除msg db中超時的msg便可。

七 其餘類型消息

因爲本文只是描述文本型短消息服務的相關流程,若是還要考慮圖片、聲音和視頻流服務,這些消息就會被稱爲富媒體消息。最基本的富媒體消息應該有一個文本消息與之對應,文本消息中包含了這些富媒體文件的url地址或者其餘方式定義的地址。消費者拉倒這樣類型的消息,就能夠根據消息地址去拉取富媒體文件。

至於富媒體文件怎麼存儲,我的建議能夠藉助目前成熟的第三方服務平臺,如藉助七牛的雲圖片服務[我舉個栗子而已,沒收任何費用,無作廣告的嫌疑^ _ ^]存儲服務存儲圖片,藉助騰訊雲的視頻服務能力處理語音和視頻消息。

富媒體消息拉取和上傳都要通過你的Relay接口,這個服務接口由於邏輯與正常的文本消息差異很大,因此建議獨立作一個接口叫作Relay模塊,以與broker做區分,也爲之後更換第三方服務廠商打好基礎。

若是你廠有錢又有人,那就考慮本身作富媒體文件的存儲吧,此時在邏輯層應該有個對應的模塊叫作rich text msg server[下面簡稱爲rich server],其邏輯應該爲:

  • A 無論是語音仍是視頻,client採用合適的文件格式格式化後壓縮好,而後再分片上傳到relay,每一個分片要分好分片序號;

  • B Relay收到這些分片後把數據透傳給rich server;

  • C rich server先把分片數據存儲在cache中,當收到最後一個分片的時候查收缺失的分片;

  • D rich server若是發現了缺失分片,就把缺失分片列表告知客戶端,讓其重傳便可;

  • E 待全部分片都收集好,rich server就能夠再次把數據拼裝好放入mongodb或者其餘什麼db中。

整個邏輯就完成了,是否是也很easy的^ _ ^。

八 方案總結

這套IM系統,整體有如下特色:

  • 1 其完備的IM系統設計;

  • 2 以Counter做爲系統的心臟驅動整個系統的流程設計;

  • 3 客戶端的消息流程方案有所涉及;

  • 4 保證服務質量的狀況下保障消息不重不漏;

  • 5 詳細敘述了消息下發的技術流程;

  • 6 給出了本身設計的智能心跳方案;

  • 7 對長消息、圖片、語音和視頻等「長數據」的處理給出了本身的解決方法;

  • 8 天生的分佈式能力,保證其多IDC的部署能力;

  • 9 盡我的能力,不斷優化中......

 

watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=往期精彩推薦

騰訊、阿里、滴滴後臺面試題彙總總結 — (含答案)

面試:史上最全多線程面試題 !

最新阿里內推Java後端面試題

JVM難學?那是由於你沒認真看完這篇文章

 

watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=—END—

 

關注做者微信公衆號 —《JAVA爛豬皮》

 

瞭解更多java後端架構知識以及最新面試寶典

相關文章
相關標籤/搜索