在用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.約定結束標誌,當讀到該結束標誌時退出不再read。
3.設定逾時,會在設定的逾時時間到達後拋出SocketTimeoutException異常而不再阻塞。
4.在頭部約定好資料的長度。當讀取到的長度等於這個長度時就不再繼續調用read方法。
總之tcp方式會經常由於阻塞函數等read/readLine和流處理的函數如重新整理緩衝導致代碼出現問題。一定要小心。
方式一一般用在通訊雙方均由開發人員掌控。方式二有一定的局限,並且雙方還要溝通好標結束志。方式三總感覺不好,逾時應該用在其他更有意義的地方,如網路不好時的時間限制。方式四應該是最好的方式,並且大多數的情況都是這樣做的。
顯然我們這裡不能使用方式一。
於是我立刻想到了一個問題:HTTP協議的結束標誌是什麼。
貌似就搜到了幾個地方有人討論該問題,見:
1.主題:學習Spring必學的Java基礎知識(9)—-HTTP報文(系列全) 裡面提到的結束標誌我測試了也不對。
2.http包結束的標誌
我沒有研究過HTTP協議的具體細節,只知道它是對Socket的封裝和一些協議的格式,其他的還不太清楚,不過就目前看到的來看應該沒有讓伺服器端知道資料結束的標誌。
於是另一個問題又在我腦海產生了:tomcat原始碼是怎麼解析HTTP協議的頭資訊呢。
我最初猜想應該是通過第四種方式因為包含了Content-Length欄位,很容易能得到總的大小。大致翻看了一下原始碼,貌似還不是這樣,其採用的是NIO Socket實現的,
在解析HTTP的頭時是一個位元組一個位元組解析的,不過代碼太長,只是看了個大概,比較瞭解的可以和我交流學習,不勝感激。