純Socket(BIO)長連結編程的常見的坑和填坑套路,socketbio
本文章純屬個人經驗總結,虛擬碼也是寫文章的時候順便白板編碼的,可能有邏輯問題,請幫忙指正,謝謝。
Internet(全球互連網)是無數台機器基於TCP/IP協議族相互連信產生的。TCP/IP協議族分了四層實現,鏈路層、網路層、傳輸層、應用程式層。
與我們應用開發人員接觸最多的應該是應用程式層了,例如web應用普遍使用HTTP協議,HTTP協議協助我們開發人員做了非常多的事情,通過HTTP足以完成大部分的通訊工作了,但是有時候會有一些特殊的情境出現,使得HTTP協議並不能得心應手的完成工作,這個時候就需要我們尋找其他的應用程式層協議去適應情境了。 在項目啟動初期就要基於業務情境和運行環境選擇適當的傳輸協議,例如常見的發布/訂閱情境也就是推送業務可以使用MQTT等協議,檔案傳輸可以用FTP等協議。不過我們這次要說的不是如何選擇通訊協議,而是如何自己實現一套自定的通訊協議。 上面囉嗦了那麼多,現在開始步入正題了
本文 實現自訂的應用程式層協議,也就是意味著要針對傳輸層協議進行開發,傳輸層有TCP、UDP兩種協議,兩者的區別和適用情境請自行seach,TCP傳輸具有可靠性,UDP傳輸不管資料是否送達,一般選擇TCP,這篇文章也是講的TCP方式。 上面說過了TCP/IP是一種協議,也就是一種約定的東西,那怎麼針對這個約定編程呢?其實作業系統已經做了這件事了,並且很有風度的為我們提供了方便的使用方式(Socket API),也就是我們常說的Socket。用C/C++可以直接叫用作業系統的API進行操作,JAVA等需要虛擬機器的語言可調用SDK提供的API進行開發。 嗯不說沒用的了,相信很多人已經不耐煩了。我們先來講講套路。用到代碼的地方用虛擬碼描述,虛擬碼可以方便的傳遞思想邏輯等,嗯,好吧其實是懶(寫博文新手詢問:此處是不是需要賣個萌?)。 真正本文
常見的坑 1、收到的資訊不完整,或者比預期更多(半包,粘包) 2、BIO讀寫阻塞導致線程掛起 3、物理鏈路意外斷開,程式不能發覺異常導致掛起 4、多線程共用同一socket導致資料錯亂 5、長時間佔用大量空閑socket 使用阻塞式的Socket通訊有一些弊端,因此我總結了幾個套路,應該也是一個通訊中介軟體應該做的事情。
常用套路 1、制定訊息格式(解決半粘包) 2、規定通訊工作流程(合理使用隊列、線程池、串連池) 3、加入心跳檢測機制(解決異常斷開導致串連不可用) 4、加入連結回收機制(按空閑或逾時時間等規則終止連結) 5、異常處理(不可複用異常發生時及時關閉串連) 6、即時重發(串連不可用時,即時選用另一條串連重發) 我們針對每一步套路進行構思設計,充分考慮其中潛在的問題和可擴充性。
1、制定訊息格式(報文) 說到通訊,就必須要有協議,就像人或者其他動物溝通一樣,人有語言規則,其他動物也有他們的語言規則,機器溝通也需要“語言”規則。 制定報文就是制定機器的語言規則。機器溝通的目的無非就是:擷取資料、發送資料、指令,這兩者都是一方發起請求,一方處理並響應。首先,機器不是人,他們不是那麼智能,不能理解你啥時候能說完話,所以要讓機器B知道機器A的資料發送結束了沒有,就需要讓他們先約定好,說話前,先告訴對方要說多少內容。然後機器A先告訴機器B,我要說一句話(一行),或者是我要說10個字母(10位元組),這個時候機器B就可以根據機器A告知的長度去接受機器A的訊息了,
可解決半包粘包問題。 上面說到了他們要先約定好的一件事情,就是要告訴對方這次要說多少內容。這個約定的規則之一,就是訊息長度。 既然有長度這一個資訊了,有一就有二,我們順便約定個其他的東西吧。比如,我這次要找你擷取資料,還是發送給你一點資料,還是要讓你執行一個指令還是其他事情。那麼這個約定的規則中又加入一個資訊,就是行為標識。 訊息長度、行為標識、都是一條資訊的基本屬性,那麼還有其他屬性嗎?當然有,這個就看想把這份規則制定的多詳細了,不過也不是越長越好,而是在能解決基本事情後,越簡單越好,畢竟東西多了,一是解析慢,二是資料包也會變大。 說到這裡大家應該也都明白了報文應該怎麼制定,下面給出一個簡單的Socket通訊基本報文格式,大家參考一下。可同時用於請求和響應。
順序 |
欄位名 |
長度(位元組) |
欄位類型 |
描述 |
1 |
訊息長度 |
4(32bit) |
int |
socket報文的長度最長2^31-1位元組,大檔案傳輸不使用此欄位 |
2 |
行為標識 |
1(8bit) |
byte |
用於分支處理資料1位元組可標識256種行為,一般夠用 |
3 |
加密標識 |
1(8bit) |
byte |
區分加密方式0不加密 |
4 |
時間戳記 |
8(64bit) |
long |
訊息時間戳記,其實也沒啥用,加著玩的,忽視掉吧 |
5 |
訊息體 |
|
String |
長度為訊息長度-10位元組,建議使用json,具體解析行為由行為識別欄位定義 |
2、規定通訊工作流程
因為BIO通訊不是那麼靈活,所以我建議使用的時候一個Socket串連同時只被一個線程操作,並且同一個ServerSocket只做主動請求或者只做被動接收,這樣能減少網路因素帶來的一些亂七八糟我也不知道會變成啥玩意的¥@U!%1#% fa23 &%3 9&+……事情發生。其實也可以用訊息分包或對socket對象加鎖來用於多線程使用,但我是懶得去處理這樣的事情,沒必要嘛(好吧,其實還是懶)。
工作流程如下:1、主動端發送資料,發送完後進入讀取狀態,等待響應。2、被動端線程阻塞等待資料,讀取到長度等前14個位元組後進行初步解析,並根據行為標識或加密標識等欄位進行處理,處理結束後,響應一個報文,然後繼續等待資料。
代碼思路如下: 關於效能方面可以 使用隊列+線程池+串連池相互配合,這次先不討論這些,想要討論的可以私信我或評論,一起討論。
1、基本封裝
/** 訊息包(報文) **/class SocketPackage { int length;// 長度 byte action;// 行為標識 byte encryption;// 加密標識 long timestamp;// 時間戳記 String data;// 訊息體 /** TODO:將此訊息包轉換為適當的byte數組 **/ byte[] toBytes() { byte[] lengthBytes = int2bytes(length); // ...將各個欄位都做了轉換成bytes的操作後,合并byte數組並返回 } /** TODO:讀取輸入資料流轉換成一個訊息包 **/ static SocketPackage parse(InputStream in) throws IOException { SocketPackage sp = new SocketPackage(); byte[] lengthBytes = new byte[4]; in.read(lengthBytes);// 未收到資訊時此步將會阻塞 sp.length = bytes2int(lengthBytes); // .....其他欄位讀取就不寫了,這裡要控制好異常,不要隨意catch住,如果發生異常,不是socket壞了就是報文異常了,應當採用拒絕串連的形式向對方跑出異常 }}
/** 封裝下socket,使其可以儲存更多的串連資訊,不要糾結名字,我糾結了好一會兒不知道怎麼命名,反正是虛擬碼,就這樣寫著吧 **/class NiuxzSocket { Socket socket; volatile long lastUse;// 上次使用時間 // ...這裡還可以再加其他屬性,比如是否是寫狀態,寫操作開始時間,上次非心跳包時間等 NiuxzSocket(Socket socket) { this.socket = socket; this.lastUse = System.currentTimeMillis(); } InputStream getIn() { return socket.getInputStream(); } void write(byte[] bytes) throws IOException { this.socket.getOutputStream().write(bytes); }}
2、主動端:主動端的核心是串連池SocketPool和SocketClient服務大概流程是調用SocketClient發送資料包,SocketClient從串連池中擷取一個可用串連,如果沒有可用串連,就建立一個。SocketClient根據業務類型或訊息類型分別對NiuxzSocket進行操作。
/** 封裝一個發送資訊的介面,提供常用的發送資訊方法。 **/interface SocketClient { SocketPackage sendData(SocketPackage sp);// 發送一個訊息包,並等待返回的訊息包 // TODO:還可以根據雙方的業務和協議添加幾個更方便使用的介面方法。比如只返回訊息體欄位,或者直接返回json內容的 void sendHeartBeat(NiuxzSocket socket);// 發送一個心跳包,這個方法後面講心跳包時會用到}class DefaultSocketClient implements SocketClient { SocketPool socketPool;// 先假裝有一個socket串連池,用來管理socket。不使用串連池的話,在這裡直接注入一個NiuxzSocket就可以了。下面代碼中也直接使用socket,但是一定要在使用時進行加鎖操作。否則就會造成多線程訪問同一個socket導致資料錯亂了。 /** 此方法就是主動端工作入口了,業務代碼可以直接調用這裡進行發送資料 **/ SocketPackage sendData(SocketPackage sp){ NiuxzSocket niuxzSocket = socketPool.get();//擷取一個socket,這裡可以看到擷取的socket並不是原生的socket,其實是我們自己封裝後的socket try{ niuxzSocket.write(sp.toBytes());//阻塞持續寫到緩衝中 niuxzSocket.lastUse = System.currentTimeMillis();//根據業務方法更新socket的狀態資訊 SocketPackage sp = SocketPackage.parse(niuxzSocket.getIn());//阻塞讀,等待訊息的返回,因為是單線程操作socket所以不存在訊息插隊的情況。 return sp; }catch(Exception e){ LOG.error("發送訊息包失敗",e); socketPool.destroy(niuxzSocket) //在發生不可複用的異常時才關閉socket,並銷毀這個NiuxzSocke。不可複用異常意思是IO操作到了一半不知道具體到哪了所以整個socket都不可用了。 } finally{ if(socketPool!=null){ socketPool.recycle(niuxzSocket );//使用完這個socket後我們不要關閉,因為還要複用,讓串連池回收這個socket。recycle內要判斷socket是否是銷毀狀態。 } } }}
/** 定義一個串連池介面SocketPool **/interface SocketPool { /** 擷取一個串連 **/ NiuxzSocket get(); /** 回收Socket **/ void recycle(NiuxzSocket ns); /** 銷毀Socket **/ void destroy(NiuxzSocket ns);}/** 實現串連池 **/class DefaultSocketPool implements SocketPool { BlockingQueue<NiuxzSocket> sockets;// 存放socket的容器,也可以使用數組 NiuxzSocket get() { // TODO:池裡有就擷取,沒有就開一個線程去建立 並且等待建立完成,可使用synchronized/wait或Lock/condition } // TODO:實現socketPool,實現串連池是屬於效能可靠性最佳化,要做的事情會比較多。偷個懶,大家懂就好,具體實現,等有時間我把我的串連池代碼整理後再寫一篇文章,有想瞭解的可以給我評論討論下。}
3、被動端被動端的核心是NiuxzServer和Worker和SocketHandler大概流程是開啟連接埠等待串連、接受串連建立線程、達到線程最大數,拒絕串連、串連進入開始讀取資料、讀取到資料後進行分支處理,處理完後把結果響應到主動端,完成一次互動。繼續讀取。
/**開啟一個ServerSocket並等待串連,聯入後開啟一個線程進行處理**/class NiuxzServer{ ServerSocket serverSocket; HashMap<NiuxzSocket> sockets = new HashMap<NiuxzSocket>(); public static AtomicInteger workerCount = 0; public Object waitLock = new Object(); int maxWorkerCount = 100;//允許100個串連進入 int port;//配置一個連接埠號碼 /**工作入口**/ void work(){ serverSocket = new ServerSocket(port); while(true){ Socket socekt = serverSocket.accept();//阻塞等待串連 NiuxzSocket niuxzSocket = new NiuxzSocket(socket); sockets.put(niuxzSocket ,1);//將串連放入map中 Worker worker = new Worker(niuxzSocket );//建立一個背景工作執行緒 worker.start();//開始線程 while(true){ if(workerCount.incrementAndGet()>=maxWorkerCount){//如果超過了規定的最大線程數,就進入等待,等待其他串連銷毀 synchronized(waitLock){ if(workerCount.incrementAndGet()>=maxWorkerCount){//double check 確定進入等待前沒有正在斷開的socket waitLock.wait(); }else{ break; } } }else{ break; } } } } /**銷毀一個串連**/ void destroy(NiuxzSocket socket){ synchronized(waitLock){ sockets.remove(socket);//從池子裡刪除 workerCount.decrementAndGet();//當前串連數減一 waitLock.notify();//通知work方法 可以繼續接受請求了 } } /**建立一個工作者線程類,處理連入的socket**/ class Worker extends Thread{ HashMap<Integer,SocketHandler> handlers;//針對每種行為標識做的訊息處理器。 NiuxzSocket socket; Worker(NiuxzSocketsocket){//建構函式 this.socket = socket; } void run(){ try{ while(true){ SocketPackage sp = SocketPackage.parse(socket.getIn());//阻塞讀,直到讀完一個訊息包未知,這樣可以解決粘包或半包的問題 SocketHandler handler = handlers.get(sp.getAction());//根據行為標識擷取響應的處理器 handler.handle(sp,socket);//處理結果和響應資訊都在handler中回寫 } }cache(Exception e){ LOG.error("串連異常中斷",e); NiuxzServer.destroy(socket); } } }}
/** 建立一個訊息處理器 SocketHandler 接收所有內容後 回顯 **/class EchoSocketHandler implements SocketHandler { /** 處理socket請求 **/ void handle(SocketPackage sp, NiuxzSocket socket) { sp.setAction(10);// 比如協議中的行為標識10是響應成功的意思 socket.write(sp.toBytes());// 直接回寫 }}
至此兩端的工作代碼已經初步完成。socket可以按照相互制定的通訊方式進行通訊了。
3、心跳機制:
心跳機制socket長連結通訊中不可或缺的一個機制。主動端可以檢測socket是否存活,被動端可以檢測對方是否還線上。因為有時候網路並不一定那麼完美,會出現鏈路上的異常,此時應用程式層可能並不能發現問題,等下次再用這個串連的時候就會拋出異常了,如果是被動端,還會白白佔用著一個線程,不如在那之前就發現一部分異常,並銷毀串連,下次通訊時出錯的機率就降低了很多,被動端也會釋放線程,釋放資源。
代碼可以這樣實現:主動端: 做一個定時任務遍曆判斷串連池中所有串連的上次使用時間是否超過心跳包間隔時間,超過了就取出這個socket並開啟一個線程(最好使用使用線程池),線上程中發送一個心跳包。
@Scheduled(fixedDelay=30*1000)//延時30秒執行一次void HeartBeat(){ for(NiuxzSocket socket:socketPool.getAllSocket()){ if(System.curTime() - socket.getLastUse() > 30*1000){//如果系統時間減上次使用時間大於30秒 //開啟線程,從串連池中取出這個串連remove(socket)移除成功再繼續操作,保證不會有其他線程同時使用這個socket。發送一個SocketPackage,socketClient.sendHeartBeat() if(socketPool.remove(socket)){ socketClient.snedHeartBeat(socket);//socketClient.snedHeartBeat這個方法實現:行為標識設定為心跳包,比如規定1就是心跳包。完事回收這個連結socketPool.recycle(socket),但當中間反生異常,則代表這個串連不可用了,就銷毀socketPool.destroy(socket)。 } } }}
被動端: 跟主動端一樣,定時掃描串連池,但是發現超過規定的空閑逾時時間的串連時不發送心跳包而是直接銷毀,關閉socket後,正在read的線程就會讀取到EOF(-1),停止線程。規定的逾時時間一定要大於約定的心跳包的間隔時間。
4、即時重發: 最佳化SocketClient,在每次發送的時候,如果發生異常,銷毀當前socket後,再次執行一次或兩次即可。重試幾次後如果不行再把異常拋出。
5、完善填坑: 通過上面的工作,我們其實已經解決了問題1、3、4了。通過報文制定資訊長度解決半包粘包問題,通過用戶端的串連池或操作socket加鎖的方式解決多線程訪問socket時會造成資料錯亂的問題(好吧,加鎖誰不會呢。。所以推薦使用串連池的方式,提高輸送量)。 還有問題2、5。其實我們可以通過一個Sokcet健康檢測任務(也可與心跳檢測任務合并,把心跳任務的延遲時間改為100ms或者更低)去遍曆串連池,判斷每個串連的資訊,挨個判斷每個狀態是否異常,然後再決定要不要關閉socket。 比如問題1,可能在讀寫操作時對方卡死,導致很久不處理任務或者對方掛起了,壓根不會繼續接收或回寫資訊了,這時如果有一個逾時機制就比較好了,幸運的是java的socket是有setSoTimeout方法的,可以設定read的逾時時間,給主動端設定個30s,被動端遲遲不響應,就會拋出逾時異常,這時候我們就銷毀這個socket了。 但是,java的socket沒有提供write的逾時設定,那給被動端寫資料時,被動端接收巨緩慢或者出了什麼問題導致壓根不接收資料了,就會導致這個寫入線程一直掛起。我們當然不希望發生這樣的事情,那麼我們可以在write之前記錄下目前時間並把socket變為正在寫出狀態,然後在Sokcet健康檢測任務中判斷這個socket是否是寫出狀態並且時間是否超過xx秒,來決定是否關閉這個socket。 空閑socket關閉就更簡單了,在NiuxzSocket再加一個上次非心跳包發送時間,然後在健康檢測任務中進行判斷就可以了。
以上便是我用同步socket實現第一版Distributed File System時總結的經驗,有些問題其實在NIO中變得不是問題了。NIO和AIO更適合會持有大量已連線的服務器端。