在用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的頭時是一個字節一個字節解析的,不過代碼太長,只是看了個大概,比較了解的可以和我交流學習,不勝感激。