Socket基礎(chǔ)
在說到計算機網(wǎng)絡(luò)模型的時候一定都會提到這兩個模型:OSI七層網(wǎng)絡(luò)模型和TCP/IP四層網(wǎng)絡(luò)模型,因為OSI七層過于復雜,現(xiàn)在普遍采用的是TCP/IP的四層網(wǎng)絡(luò)模型。
tcp/udp協(xié)議位于TCP/IP協(xié)議棧的傳輸層,tcp是一個面向連接、可靠的協(xié)議,而udp協(xié)議是一個不可靠的、無連接協(xié)議。剛開始我有點疑惑,什么樣的場景才需要udp協(xié)議呢?因為udp協(xié)議并不保證數(shù)據(jù)一定傳輸?shù)侥康牡兀惺裁磮鼍澳苋萑炭赡馨l(fā)生丟包的情況呢?后來在項目的某個解決方案的討論中,聽到了老大的解釋。當時我們可以選用udp和tcp方案,問我該選用什么方案。我當時也了解了一些tcp和udp,記得在網(wǎng)上看到過這么一句話,如果你需要用額外的操作保證數(shù)據(jù)準確的傳遞到目的地,那不如直接采用tcp協(xié)議,所以我說udp并不保證數(shù)據(jù)一定傳達,而tcp是可靠的,所以應(yīng)該采用tcp協(xié)議。然后老大說在局域網(wǎng)(當時要解決的就是局域網(wǎng)中的一個問題)可以認為udp是可靠的,不需要考慮丟包的情況,不過數(shù)據(jù)處理難一點,后來還是用了TCP……
Socket可以說是對TCP協(xié)議的封裝,可以理解成TCP的API,在TCP/IP協(xié)議棧中應(yīng)當屬于應(yīng)用層和傳輸層之間的抽象。那么屬于應(yīng)用層的HTTP協(xié)議,和Socket有何異同呢?HTTP協(xié)議一般來說是短連接(當然,可以指定長連接),每次請求都會建立和斷開TCP連接,而Socket默認就是長連接,兩者在傳輸層都是建立和斷開TCP連接。相比于Socket,HTTP協(xié)議顯得更加的高級。二者都是基于TCP的,Socket可以用來編寫一個HTTP框架。
Socket Client
在Java中想要使用Socket只要用Socket這個類就可以了,而且得益于Java方便的IO,入門的成本不會非常高,但是前提是你熟悉Java的IO和基本的網(wǎng)絡(luò)知識。當然了,如果你的追求更高還可以去用非阻塞的NIO玩玩……我這是不會介紹的……
接下來將會創(chuàng)建一個Socket來獲取時間(要看到結(jié)果得翻墻……)
首先創(chuàng)建一個Socket
Socket socket = new Socket("time.nist.gov",13);
第一個參數(shù)可以是主機域名也可以是ip,第二個參數(shù)是端口,這種方式建立的Socket會直接開始嘗試連接遠程服務(wù)器的端口。服務(wù)器監(jiān)聽端口的服務(wù)在和客戶端建立連接之后,會按照一定的協(xié)定發(fā)送、接收數(shù)據(jù)。而這里建立連接之后就會直接把時間返回給客戶端,這里用Java中的字符流處理,簡單~
InputStream in = socket.getInputStream();
BufferedReader br = new BufferedReader(new InputStreamReader(in,"ASCII"));
byte[] data = new byte[1024];
int length = data.length;
StringBuilder sb = new StringBuilder();
String info;
while((info = br.readLine()) != null){
sb.append(info);
}
System.out.println(sb.toString());
完整代碼:
public class TimeSocket {
public static void main(String... args){
// 創(chuàng)建客戶端指定主機和端口
try(Socket socket = new Socket("time.nist.gov",13)){
socket.setSoTimeout(15000);
InputStream in = socket.getInputStream();
BufferedReader br = new BufferedReader(new InputStreamReader(in,"ASCII"));
byte[] data = new byte[1024];
int length = data.length;
StringBuilder sb = new StringBuilder();
String info;
while((info = br.readLine()) != null){
sb.append(info);
}
System.out.println(sb.toString());
// socket.shutdownOutput();
}catch (IOException e){
e.printStackTrace();
}
}
}
Socket Client & Socket Server
接下來的例子會建立一個TCP Client 和 TCP Server,通過互相發(fā)送字符串來模擬服務(wù)器和客戶端的通信。在Java中想要使用TCP Server,可以使用ServerSocket。我們首先建立一個TCP Server:
/**
* 1.用指定的端口實例化一個SeverSocket對象。服務(wù)器就可以用這個端口監(jiān)聽從客戶端發(fā)來的連接請求。
* 2.調(diào)用ServerSocket的accept()方法,以在等待連接期間造成阻塞,監(jiān)聽連接從端口上發(fā)來的連接請求。
* 3.利用accept方法返回的客戶端的Socket對象,進行讀寫IO的操作
* 4.關(guān)閉打開的流和Socket對象
*/
// 1.創(chuàng)建一個服務(wù)端Socket,ServerSocket,指定綁定端口,并監(jiān)聽此端口
ServerSocket serverSocket = new ServerSocket(10086);// 1024 - 65535的某個端口
// 2.調(diào)用accept方法開始監(jiān)聽,等待客戶端的連接
Socket socket = serverSocket.accept();
// 3.獲取輸入流,并讀取客戶端信息
InputStream is = socket.getInputStream();
InputStreamReader isr = new InputStreamReader(is);
BufferedReader br = new BufferedReader(isr);
String info = null;
try {
while ((info = br.readLine()) != null) {
System.out.println("server: " + info);
}
socket.shutdownInput();
// 4.獲取輸出流,響應(yīng)客戶端的請e求
OutputStream os = socket.getOutputStream();
PrintWriter pw = new PrintWriter(os);
// 5.關(guān)閉資源
pw.close();
os.close();
br.close();
isr.close();
is.close();
socket.close();
serverSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
注釋寫的很清楚了,這里還要提醒一下,accept()這個方法是一個阻塞方法,也就是說如果沒有socket連接,代碼會一直阻塞在這里。而接下來則是拿到輸入流,讀入客戶端的輸入。拿到客戶端的輸入之后,再獲取輸出流返回響應(yīng)給客戶端。
接下來是建立客戶端,上面的服務(wù)端監(jiān)聽了10086端口,所以下面的服務(wù)端也要連10086端口,套路跟之前的Socket差不多:
// 客戶端
// 1.創(chuàng)建客戶端Socket,指定服務(wù)器地址和端口
Socket socket = new Socket("127.0.0.1", 10086);
// 2.獲取輸出流,向服務(wù)器端發(fā)送信息
OutputStream os = socket.getOutputStream();// 字節(jié)輸出流
PrintWriter pw = new PrintWriter(os);// 將輸出來包裝成打印流
pw.write("用戶名:admin;密碼:admin");
pw.flush();
socket.shutdownOutput();
// 3.獲取輸入流,并讀取到服務(wù)器端的響應(yīng)信息
InputStream is = socket.getInputStream();
BufferedReader br = new BufferedReader(new InputStreamReader(is));
String info = null;
while ((info = br.readLine()) != null) {
System.out.println("Hello,我是客戶端,服務(wù)器說:" + info);
}
br.close();
is.close();
pw.close();
os.close();
socket.close();
熟悉的配方,熟悉的味道,這里就不再多做介紹了。注釋寫的很詳細,運行的時候注意先運行服務(wù)端,再運行客戶端。因為服務(wù)端的代碼會阻塞在accept方法,而客戶端在服務(wù)端沒有啟動的情況下,會出現(xiàn)連接失敗的情況。
看一下server和client各自的輸出:
這里server和client的代碼我是寫在兩個類各自的main方法中的,這代碼我也是跟著網(wǎng)上找到的博客一行一行敲的,敲完之后運行,恩,成功了。后來我感覺到有點不對……不對在哪呢?因為我這里是兩個main方法運行,可以理解成是兩個進程,這兩個進程竟然驚人的出現(xiàn)了順序性!你連接我,我監(jiān)聽到,拿到你發(fā)的內(nèi)容,然后我響應(yīng)你,你再拿到我的響應(yīng)。符合邏輯,合乎情理,但是憑什么啊?多線程都要為一些個順序性撓破頭,何況是多進程?后來我仔細看了代碼,發(fā)現(xiàn)這些表現(xiàn)出來的順序性是因為:阻塞。
首先是服務(wù)端的代碼,第一次阻塞發(fā)生在accept()方法:
Socket socket = serverSocket.accept();
這里會阻塞進程,等待Socket連接。而在客戶端連接上之后,會繼續(xù)執(zhí)行代碼,代碼會在哪里阻塞呢?第二次阻塞發(fā)生在讀取客戶端輸入的時候:
while ((info = br.readLine()) != null) {
System.out.println("Hello,我是客戶端,服務(wù)器說:" + info);
}
是的,就是阻塞在這個br.readLine()了,首先我們可以從代碼的現(xiàn)象來分析:從代碼來說,能正確的打印客戶端傳來的信息,那么這個while循環(huán)一定是能正常的執(zhí)行的。因為如果不滿足條件會跳出,滿足則會一直打印,即使沒讀到信息,那么只能說明一件事,br.readLine()阻塞了程序。這里的io是阻塞式io,不作更多的介紹,有興趣可以了解Java的IO模型。在讀到信息之后將之打印出來,那么后來為什么又不阻塞跳出了循環(huán)呢?這里可以看一下這個方法的注釋:
/**
* Reads a line of text. A line is considered to be terminated by any one
* of a line feed ('\n'), a carriage return ('\r'), or a carriage return
* followed immediately by a linefeed.
*
* @return A String containing the contents of the line, not including
* any line-termination characters, or null if the end of the
* stream has been reached
*
* @exception IOException If an I/O error occurs
*
* @see java.nio.file.Files#readAllLines
*/
注釋中關(guān)于返回值寫的非常清楚: or null if the end of the stream has been reached,如果到了流的末尾會返回null。怎么判斷到了流的末尾呢?這里我認為是這個流結(jié)束了,不會再有任何后續(xù)輸出了,這個在代碼中是怎么體現(xiàn)的呢?在Client中的這句代碼就是解釋的體現(xiàn):
socket.shutdownOutput();
終結(jié)了輸出流,這樣在Server中就跳出了while循環(huán),而不是阻塞在br.readLine。如果注釋這句shutdownOutput(),程序則會阻塞住,現(xiàn)象如下:
Server沒有跟上次一樣最后有顯示退出了程序,而是一直在運行,是的阻塞住了。而Client則是沒有任何輸出,因為Client也有一個讀取的操作,也阻塞住了。
簡單的流程就是這樣,這是一個簡單的TCP Server和TCP Client的阻塞模型。接下來介紹幾種TCP Server模型。
TCP Server 的幾種模型
TCP Server有阻塞式、并發(fā)式以及異步服務(wù)器。其中阻塞式服務(wù)器最好實現(xiàn),同時也是問題比較多的。因為客戶端發(fā)送到服務(wù)器的請求,服務(wù)器會依次處理,只要碰到一個處理時間特別長的請求,其余請求就無法得到及時的處理,同時可能會有部分客戶端認為連接已經(jīng)超時了。并發(fā)式服務(wù)器在處理請求時會為連接開啟一個線程,在線程中完成各自的讀取響應(yīng)操作,這樣就不會阻塞服務(wù)器對于其他請求的訪問了,缺點無疑是服務(wù)器資源有限,不可能為每一個連接開辟新的線程,通常會配合線程池一起處理。異步服務(wù)器需要Java中的NIO配合,這里不做介紹。
阻塞式的實現(xiàn)就是上面的代碼,不過上面的代碼通過shutdown來跳出阻塞,通常來說是不會這樣的,因為shutdown之后無法再次使用輸入輸出流,一般來說會自定義數(shù)據(jù)邊界,在拿到一個完整的數(shù)據(jù)之后將數(shù)據(jù)傳遞出去,讓上層處理,然后就阻塞在那,等待下一次的數(shù)據(jù)到來。這種實現(xiàn)起來也不是非常難,跟人配合的時候協(xié)商好協(xié)議就可以了。下面要實現(xiàn)的是并發(fā)式服務(wù)器,至于異步服務(wù)器,我自己也不是非常熟,各位感興趣可以自己查閱資料。
客戶端還是使用上面的代碼,不做改變,而Server則做一些并發(fā)的處理:
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
/**
* Created by luojun on 2017/6/5.
* desc:熟悉Socket
*/
public class Server {
private static Executor executor;
private static int count = 0;
public static void main(String... args) throws IOException {
executor = Executors.newFixedThreadPool(100);
/**
* 1.用指定的端口實例化一個SeverSocket對象。服務(wù)器就可以用這個端口監(jiān)聽從客戶端發(fā)來的連接請求。
* 2.調(diào)用ServerSocket的accept()方法,以在等待連接期間造成阻塞,監(jiān)聽連接從端口上發(fā)來的連接請求。
* 3.利用accept方法返回的客戶端的Socket對象,進行讀寫IO的操作
* 4.關(guān)閉打開的流和Socket對象
*/
// 1.創(chuàng)建一個服務(wù)端Socket,ServerSocket,指定綁定端口,并監(jiān)聽此端口
ServerSocket serverSocket = new ServerSocket(10086);// 1024 - 65535的某個端口
// 2.調(diào)用accept方法開始監(jiān)聽,等待客戶端的連接
new Thread(() -> {
while (true) {
try {
Socket socket = serverSocket.accept();
count++;
System.out.println("有 " + count + " 個Socket連接");
handleSocket(socket);
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}
public static void handleSocket(Socket socket) {
executor.execute(() -> {
InputStream is = null;
try {
is = socket.getInputStream();
InputStreamReader isr = new InputStreamReader(is);
BufferedReader br = new BufferedReader(isr);
String info = null;
while ((info = br.readLine()) != null) {
System.out.println("server: " + info);
handleData(info);
}
socket.shutdownInput();
// 4.獲取輸出流,響應(yīng)客戶端的請e求
OutputStream os = socket.getOutputStream();
PrintWriter pw = new PrintWriter(os);
// if(info.equals("我還活著,你好嗎?")){
// pw.write("我也很好,你放心。");
// pw.flush();
// }else {
pw.write("歡迎您!");
pw.flush();
// }
// 5.在合適的時機關(guān)閉資源
pw.close();
os.close();
br.close();
isr.close();
is.close();
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
});
}
/**
* 處理數(shù)據(jù)
*
* @param data 數(shù)據(jù)
*/
public static synchronized void handleData(String data) {
}
}
當然,這里我偷懶也沒有設(shè)置個數(shù)據(jù)邊界什么的,只是簡單的讓服務(wù)器讀取數(shù)據(jù)然后傳遞給處理方法。關(guān)于并發(fā),坑也是比較多的,只能說各位量力而行吧……下面看一下啟動多個Socket連接并發(fā)服務(wù)器時的輸出吧:
這里都是比較簡單的例子,希望各位看官能有耐心的看完。接下來要寫的是關(guān)于Socket失效的問題。
Socket失效
在使用Socket的過程中,有的時候會發(fā)現(xiàn)無論怎么發(fā)送數(shù)據(jù),服務(wù)器都收不到,而且Java本地也沒有任何異常提示,這個時候就需要一個機制來讓開發(fā)者判斷Socket是否失效。目前我了解的方式有兩個:心跳包和超時重發(fā),主要原理都是客戶端向服務(wù)端發(fā)送數(shù)據(jù),心跳是如果超過一段時間服務(wù)器無響應(yīng)則判定Socket失效,而超時重發(fā)可以設(shè)置為累計重發(fā)滿一定次數(shù)無響應(yīng)判斷Socket失效。心跳機制的好處是相對比較穩(wěn)定,客戶端很容易就能知道Socket是否斷開,一般來說也不是特別耗費資源,有的心跳可能會設(shè)置成十幾分鐘發(fā)送一次數(shù)據(jù)包。但是對于一些特殊的場景,需要保證Socket一定要穩(wěn)定的(比如通過中間層硬件和底層交互),那么心跳包可能會十分的頻繁,這個時候超時重發(fā)更加的適合。不過超時重發(fā)的前提是客戶端每一個數(shù)據(jù)發(fā)送都需要有服務(wù)器的響應(yīng),不然客戶端是無法判斷數(shù)據(jù)發(fā)送是否成功的。