簡介
網絡編程中,客戶端-服務端模式是一種常見的模式。
兩者之間建立的 TCP 連接,是一種雙向連接,兩者經過三次握手之后就可以互相發送數據。
java.net.ServerSocket 服務端
public class TcpSocketServer {
public static void main(String[] args) throws IOException, InterruptedException {
ServerSocket serverSocket = new ServerSocket();
// 綁定端口
serverSocket.bind(new InetSocketAddress(8080));
System.out.println("服務器等待連接... 127.0.0.1:8080");
// 阻塞,等待客戶機連接
Socket clientSocket = serverSocket.accept();
InetSocketAddress clientAddress = (InetSocketAddress) clientSocket.getRemoteSocketAddress();
System.out.println("接收到客戶端連接" + clientAddress.getHostString() + ":" + clientAddress.getPort());
OutputStream out = clientSocket.getOutputStream();
InputStream in = clientSocket.getInputStream();
byte[] readBytes = new byte[1024];
// 阻塞,等待讀取客戶機數據
while (in.read(readBytes) != -1) {
System.out.println(new String(readBytes));
// 阻塞,代表服務器業務處理
Thread.sleep(200);
// 服務器向客戶端回寫數據
out.write(String.valueOf(System.currentTimeMillis()).getBytes());
// 結尾回寫回車+換行
out.write("\r\n".getBytes());
// 手動刷新輸出流
out.flush();
}
// 關閉連接
clientSocket.close();
serverSocket.close();
}
}
我們看到上面的服務器端有以下四大主要功能:
- 綁定端口
ServerSocket#bind
- 接收連接
ServerSocket#accept
- 讀寫數據
- 關閉連接
API:java.net.ServerSocket 1.0
- ServerSocket()
創建一個未建立監聽的服務器套接字 - ServerSocket(int port)
創建一個監聽端口的服務器套接字 - void bind(SocketAddress endpoint)
綁定服務器到指定的套接字地址 - Socket accept()
等待連接。該方法阻塞(即使之空閑)當前線程直到建立連接為知。該方法返回一個 Socket 對象,程序可以通過這個對象與連接中的客戶端進行通信。 - void close()
關閉服務器套接字
我們啟動服務器,控制台輸出如下:
正如開篇的圖片所示,服務端在等待連接時,發生阻塞。導致主線程 main 雖然 runnable,但是無法進行其他操作。
我們點擊了左邊欄被框住的照相機,獲取此時虛擬機的 dump 文件。main 線程此時仍然是 RUNNABLE 的狀態,但是阻塞在 ServerSocket 的 accept 操作上。
截圖中所用的工具是 IDEA Intellij
java.net.Socket 客戶端
public class TcpSocketClient {
public static void main(String[] args) throws IOException {
// 創建一個還未被連接的套接字
Socket socket = new Socket();
System.out.println("客戶機的套接字是否已被連接?" + socket.isConnected());
// 為本地主機創建一個 InetAddress 對象
InetAddress localHost = InetAddress.getLocalHost();
// 主動連接服務器
socket.connect(new InetSocketAddress(localHost, 8080));
System.out.println("客戶機的套接字是否已被連接?" + socket.isConnected());
OutputStream outStream = socket.getOutputStream();
InputStream inStream = socket.getInputStream();
// 用 PrintWriter 裝飾輸出流
PrintWriter out = new PrintWriter(outStream, true /*autoFlush*/);
// 用 Scanner 裝飾輸入流
Scanner in = new Scanner(inStream);
Scanner console = new Scanner(System.in);
long sendTime;
long returnTime;
// 阻塞,等待控制台輸入數據
while (console.hasNextLine()) {
// 將輸入台數據傳輸給服務器
out.println(console.nextLine());
sendTime = System.currentTimeMillis();
// 阻塞,等待讀取服務器回寫數據
while (in.hasNextLine()) {
returnTime = System.currentTimeMillis();
System.out.println("共計等待" + (returnTime-sendTime) +" ms 獲取服務器數據");
// 將服務器回傳數據輸出到控制台
System.out.println(in.nextLine());
}
}
console.close();
in.close();
out.close();
}
}
這段代碼實現的客戶端可以通過不斷向控制台寫入數據來達到和服務器交換數據的功能。
注意:
- 原先,在類 TcpSocketClient 中,我在創建 PrintWriter 對象時犯過一個錯誤,我直接使用
PrintWriter out = new PrintWriter(outStream)
,這段代碼默認是不開啟自動刷新功能的,需要在out.println()
之后手動調用out.flush()
,否則服務器會一直處於等待客戶機發送數據的狀態。 - 另外,在類 TcpSocketServer 中,起初沒有加上
out.write("\r\n".getBytes());
和out.flush()
這兩句代碼,分別導致客戶機識別不到行結尾字節和接收不到數據,最終都會導致客戶機仍然阻塞在in.hasNextLine()
處。
接着,我們繼續啟動客戶端:
API:java.net.Socket 1.0
- boolean isConnected()
如果該套接字已被連接,則返回 true - Socket()
創建一個還未連接的套接字 - void connect(SocketAddress address) 1.4
將該套接字連接到給定的地址 - void connect(SocketAddress address, int timeoutInMilliseconds) 1.4
將套接字連接到給定的地址。如果在給定時間內沒有響應,則返回。
在啟動過客戶端之后,我們可以再觀察一下服務器的控制台輸出:
此時服務器建立了連接,並且輸出了客戶機的套接字信息
API:java.net.Socket 1.0
- SocketAddress getRemoteSocketAddress() 1.4
獲取遠程套接字地址
API:java.net.InetSocketAddress 1.4
- int getPort()
獲取套接字地址的端口號 - String getHostString() 1.7
返回主機名或者字符串形式的地址,主機名例如‘www.baidu.com’,字符串形式的地址例如‘132.163.4.102’
我們接着來看一下服務端的 Dump 信息,首先是關於主線程 main 的信息:
主線程中沒有明顯的 locked 標識。我們接着看看其他的:
Monitor Ctrl-Break 監聽中斷信號
這個地方我也請教了一些人,但是始終也沒辦法很好地從底層來解釋 SocketInputStream 的 socketRead 到底是如何阻塞住的。如果有大佬看到的話,可以指點我一二。
總而言之,服務端阻塞在讀取數據的代碼上了。接着我們向客戶端控制台輸入要發送的數據內容(nihao)
由於本地客戶端和服務端的網絡通信幾乎是0延遲的,所以客戶端 in.readLine()
等待時間看上去就剛好服務端 Thread.sleep(200);
的時間。
BIO 線程模型
按照上面的寫法,Server 的 main 線程僅能‘接待’第一個建立連接的 Client,之后的所有 Client 都無法正常與 Server 通信。這個肯定不符合我們服務器一對多的需求,因此我們想到多線程來處理多個客戶端。
Java 服務端
改造了一下 TcpSocketServer 的代碼,現在就是為每個連接分配一個單獨的線程來進行處理的 BIO 模型了:
public class TcpSocketServer {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket();
// 綁定端口
serverSocket.bind(new InetSocketAddress(8080));
System.out.println("服務器等待連接... 127.0.0.1:8080");
int i = 0;
while (true) {
// 阻塞,等待客戶機連接
Socket clientSocket = serverSocket.accept();
i++;
new Thread(() -> {
try {
handleConnection(clientSocket);
} catch (Exception e) {
e.printStackTrace();
}
}, "線程" + i).start();
}
}
private static void handleConnection(Socket clientSocket) throws IOException, InterruptedException {
InetSocketAddress clientAddress = (InetSocketAddress) clientSocket.getRemoteSocketAddress();
System.out.println(Thread.currentThread().getName() + " 接收到客戶端連接" + clientAddress.getHostString() + ":" + clientAddress.getPort());
OutputStream out = clientSocket.getOutputStream();
InputStream inStream = clientSocket.getInputStream();
Scanner in = new Scanner(inStream);
// 阻塞,等待讀取客戶機數據
while (in.hasNextLine()) {
System.out.println(Thread.currentThread().getName() + " >> " + in.nextLine());
// 阻塞,代表服務器業務處理
Thread.sleep(200);
// 服務器向客戶端回寫數據
out.write(String.valueOf(System.currentTimeMillis()).getBytes());
// 結尾回寫回車+換行
out.write("\r\n".getBytes());
// 手動刷新輸出流
out.flush();
}
// 關閉連接
clientSocket.close();
}
}
Windows Telnet 客戶端
Win + R
打開命令行提示符- 輸入
telnet 127.0.0.1 8080
並且回車
- 然后先按住
Ctrl
,再按下]
- 最后再按下 回車,現在可以正常看到輸入的字符了
總結
本文介紹了如何使用 Socket 和 ServerSocket 分別實現 Java 客戶端和 Java 服務端,同時還詳細介紹了用到的 API。
另外還使用 Socket 和 ServerSocket 實現了一條線程處理一個 Socket 連接的 BIO 模型。