讀Socket流時產生阻塞的解決方案(粘包拆包問題)

轉自:https://www.cnblogs.com/qhyuan1992/p/5385289.html

其實最終討論的是TCP通信過程中的粘包拆包(半包)問題。

在用socket寫一個服務器時遇到了問題於是將主要的問題抽了出來,代碼如下,由於代碼很簡單于是也沒有註釋。

public class Main {
    private static ServerSocket serverSocket;
    private final static ExecutorService exec = Executors.newFixedThreadPool(30);
    public static void main(String[] args) {
        try {
            serverSocket = new ServerSocket(8888);
            while (true) {
                Socket socket = serverSocket.accept();
                exec.execute(new ServerRunnable(socket));
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

class ServerRunnable implements Runnable {
    private Socket socket;
    private InputStream is;
    private OutputStream out;
    private String reqStr;
    private String resContent;
    public ServerRunnable(Socket socket) {
        this.socket = socket;
    }
    @Override
    public void run() {
        handleSocket(socket);
    }
    private void handleSocket(Socket socket) {
        try {
            byte[] buffer = new byte[1024];
            is = socket.getInputStream();
            System.out.println(is);
            out = socket.getOutputStream();
            int len = 0;
            StringBuilder sb = new StringBuilder();
            while ((len = is.read(buffer)) != -1) {
                String str = new String(buffer, 0, len);
                sb.append(str);
            }
            reqStr = sb.toString();
            System.out.println(reqStr);
            resContent = "Welcome!";
            StringBuilder resBuilder  = new StringBuilder();
            resBuilder.append("HTTP/1.1 200 OK").append("\r\n").
            append("Date:").append(new Date()).append("\r\n").
            append("Content-Type:").append("text/plain;charset=UTF-8").append("\r\n").
            append("Content-Length:").append(resContent.getBytes().length).append("\r\n").
            append("\r\n");
            resBuilder.append(resContent);
            out.write(resBuilder.toString().getBytes());
            socket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

代碼很簡單,就是寫了一個Socket的服務器,通過瀏覽器來訪問localhost:8888會返回Welcome! 
可是在實際工作時,死活不能達到效果。

我想到過可能是out根本就沒把數據寫進去,然後斷點調試,但就是因爲斷點調試才導致很長時間沒能把錯誤找出來。

1.在測試的時候有這樣一個現象一直沒引起我的注意:服務器端打印的瀏覽器發過來的數據在點擊停止加載網頁/刷新時纔會打印!!(知道真相後明白了是因爲斷開連接另一端就會跳出阻塞繼續執行下去) 
而我在測試的時候由於瀏覽器一直收不到服務器端發的數據而處於不停地等待狀態,我就會再次刷新或者再訪問一次,而恰恰由於這樣愚蠢的操作,服務器端打印了數據,斷點調試也進去了,於是我好長時間沒有懷疑是因爲壓根就沒走到這一步。而懷疑是我的電腦哪裏或者瀏覽器哪裏沒設置好。

2.屏蔽了handleSocket裏面接收客戶端的輸入代碼,僅僅加上給客戶端發的數據,發現可以收到數據,明確了數據沒有寫錯,最後在發現上面的問題後在while循環處打斷點,最終發現程序阻塞在那裏。

剛開始感到很奇怪,大文件的複製不都是這樣做的麼,怎麼還會出錯,在網上搜了一下,socket在close後,纔會發送給另一端結束符EOF,從而纔會read到流結尾信息而返回-1。 
以前寫java聊天功能的時候其實遇到過這樣的問題的,要退出聊天發一個特定的字符,然後在break出循環,接着會close掉socket,這樣另一端的會由於這端的socket被close掉也跳出循環。只是現在由於只寫服務端就沒想到。

因爲無法知道遠程的socket是否還有沒有東西要發送。所以read一直不會返回。 
read的文檔說明大致是:如果因已到達流末尾而沒有可用的字節,則返回值 -1。在輸入數據可用、檢測到流的末尾或者拋出異常前,此方法一直阻塞。 
socket和文件不一樣,從文件中讀,讀到末尾就到達流的結尾了,所以會返回-1或null,循環結束,但是socket是連接兩個主機的橋樑,一端無法知道另一端到底還有沒有數據要傳輸。 
socket如果不關閉的話,read之類的阻塞函數會一直等待它發送數據,就是所謂的阻塞。

當然這裏我們可以將緩衝buffer調整的大一點,這樣不用while循環,只讀一次即可,然而其他的場景比如發送的數據很大一次讀不完那麼就只能while循環來處理了。這種場景下的解決方案方案見下面。

四種途徑解決:

1.調用socke的shutdownOutput方法關閉輸出流,該方法的文檔說明爲,將此套接字的輸出流置於“流的末尾”,這樣另一端的輸入流上的read操作就會返回-1。不能調用socket.getInputStream().close()。這樣會導致socket被關閉。 
2.設置超時,會在設置的超時時間到達後拋出SocketTimeoutException異常而不再阻塞。 
3.約定結束標誌,當讀到該結束標誌時退出不再read。 
4.約定數據長度,數據長度不夠則補齊,每次read約定好的長度即可。
5.在頭部約定好數據的長度。當讀取到的長度等於這個長度時就不再繼續調用read方法。

總之tcp方式會經常由於阻塞函數等read/readLine和流處理的函數如刷新緩衝導致代碼出現問題。一定要小心!

方式1一般用在通信雙方均由開發者掌控。方式2總感覺不好,超時應該用在其他更有意義的地方,如網絡不好時的時間限制。方式3有一定的侷限,並且雙方還要溝通好標結束志。方式4由於補齊會造成浪費。方式5應該是最好的方式,並且大多數的情況都是這樣做的。

顯然我們這裏不能使用方式1。 
於是我立刻想到了一個問題:HTTP協議的結束標誌是什麼? 
貌似就搜到了幾個地方有人討論該問題,見: 
1.主題:學習Spring必學的Java基礎知識(9)—-HTTP報文(系列全) 裏面提到的結束標誌我測試了也不對。 
2.http包結束的標誌 
我沒有研究過HTTP協議的具體細節,只知道它是對Socket的封裝和一些協議的格式,其他的還不太清楚,不過就目前看到的來看應該沒有讓服務器端知道數據結束的標誌。

於是另一個問題又在我腦海產生了:tomcat源代碼是怎麼解析HTTP協議的頭信息呢? 
我最初猜想應該是通過第5種方式因爲包含了Content-Length字段,很容易能得到總的大小。大致翻看了一下源代碼,貌似還不是這樣,其採用的是NIO Socket實現的, 
在解析HTTP的頭時是一個字節一個字節解析的,不過代碼太長,只是看了個大概,比較瞭解的可以和我交流學習,不勝感激。

 

最後討論下半包和粘包問題

什麼是TCP粘包半包?

å¨è¿éæå¥å¾çæè¿°

假設客戶端分別發送了兩個數據包D1和D2給服務端,由於服務端一次讀取到的字節數是不確定的,故可能存在以下4種情況。
(1)服務端分兩次讀取到了兩個獨立的數據包,分別是D1和D2,沒有粘包和拆包;
(2)服務端一次接收到了兩個數據包,D1和D2粘合在一起,被稱爲TCP粘包
(3)服務端分兩次讀取到了兩個數據包,第一次讀取到了完整的D1包和D2包的部分內容,第二次讀取到了D2包的剩餘內容,這被稱爲TCP拆包(半包)
(4)服務端分兩次讀取到了兩個數據包,第一次讀取到了D1包的部分內容D1_1,第二次讀取到了D1包的剩餘內容D1_2和D2包的整包。
如果此時服務端TCP接收滑窗非常小,而數據包D1和D2比較大,很有可能會發生第五種可能,即服務端分多次才能將D1和D2包接收完全,期間發生多次拆包(半包)。

解決粘包拆包(半包)問題

由於底層的TCP無法理解上層的業務數據,所以在底層是無法保證數據包不被拆分和重組的,這個問題只能通過上層的應用協議棧設計來解決,根據業界的主流協議的解決方案,可以歸納如下。
(1)在包尾增加分割符,比如回車換行符進行分割,例如FTP協議;如使用netty的LineBasedFrameDecoder和DelimiterBasedFrameDecoder,如果超過規定字節長度,會報錯。
(2)消息定長,例如每個報文的大小爲固定長度200字節,如果不夠,空位補空格;如使用netty的FixedLengthFrameDecoder
(3)將消息分爲消息頭和消息體,消息頭中包含表示消息總長度(或者消息體長度)的字段,通常設計思路爲消息頭的第 一個字段使用int32來表示消息的總長度,如netty的LengthFieldBasedFrameDecoder。

參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章