目錄
?一、TCP的特點
二、API介紹?
1.ServerSocket
?2.Socket
三、實現服務器
四、實現客戶端
五、測試解決bug
1.客戶端發送了數據之后,并沒有響應
2.clientSocket沒有執行close()操作
3.嘗試使用多個客戶端同時連接服務器
六、優化
1.短時間有大量客戶端訪問并斷開連接
2.有大量的客戶端長時間在線訪問
七、源碼
引言:
這篇文章主要是用TCP構造的回顯服務器,也就是客戶端發什么,就返回什么。用實現這個過程方式來學會TCP套接字的使用。
?一、TCP的特點
- TCP是可靠的:這個需要去了解TCP的機制,這是一個大工程,博主后面寫好了把連接附上
- TCP是面向字節流的
- TCP是全雙工的
- TCP是有連接的
除了可靠性,在編程中無法體會到,其他特性我都會一 一講解。
二、API介紹?
1.ServerSocket
ServerSocket 是創建TCP服務端Socket的API
ServerSocket 構造?法:
方法簽名 | 方法說明 |
---|---|
ServerSocket(int port) | 創建一個服務端流套接字Socket,并綁定到指定端口 |
?法簽名 | ?法說明 |
---|---|
Socket accpet() | 開始監聽指定端口(創建時綁定端口),有客戶端連接后,返回一個服務端Socket對象,并基于Socket建立與客戶端的連接,否則阻塞等待 |
void close() | 關閉此套接字 |
?2.Socket
Socket 是客?端Socket,或服務端中接收到客?端建?連接(accept?法)的請求后,返回的服務端Socket。不管是客?端還是服務端Socket,都是雙?建?連接以后,保存的對方信息,及?來與對?收發數據的。
?Socket構造方法:
方法簽名 | 方法說明 |
---|---|
Socket(String host, int port) | 創建一個客戶端流套接字Socket,并與對應IP的主機上,對應端口的進程建立連接。 host:IP地址 prot:端口號 |
?這里new出來就是和對方建立完成了。如果建立失敗,就會在構造對象的時候拋出異常。
Socket方法:
方法簽名 | 方法說明 |
---|---|
InetAddress getInetAddress() | 返回套接字所連接的地址 |
InputStream getInputStream() | 返回此套接字的輸?流 |
OutputStream getOutputStream() | 返回此套接字的輸出流 |
三、實現服務器
服務器需要指定端口號:
public class TcpEchoServer {private ServerSocket serverSocket = null;// 需要指定服務器的端口 處理ServerSocket拋出的異常public TcpEchoServer(int port) throws IOException {// 指定服務器的端口serverSocket = new ServerSocket(port);} }
注意處理拋出的異常
和客戶端建立連接:
public class TcpEchoServer {private ServerSocket serverSocket = null;// 需要指定服務器的端口 處理ServerSocket拋出的異常public TcpEchoServer(int port) throws IOException {// 指定服務器的端口serverSocket = new ServerSocket(port);}public void start() throws IOException {//服務器需要不停的執行while (true) {//開始監聽指定端口,當有客戶端連接后,返回一個保存對方信息的SocketSocket clientSocket = serverSocket.accept();//處理邏輯processConnection(clientSocket);}}//針對一個連接,提供處理邏輯private void processConnection(Socket clientSocket) {} }
這里的accept()就體現了TCP的有連接
當連接成功后,需要處理的邏輯:
//針對一個連接,提供處理邏輯private void processConnection(Socket clientSocket) {//打印客戶端的信息 返回IP地址 返回端口號System.out.printf("[%s : %d]客戶端上線\n",clientSocket.getInetAddress(), clientSocket.getPort());//獲取到socket中持有的流對象try(InputStream inputStream = clientSocket.getInputStream();OutputStream outputStream = clientSocket.getOutputStream()) {while (true) {//1.獲取請求//2.處理請求//3.返回響應//4.打印日志}}catch (IOException e) {}}
全雙工的意思:通信的雙方(如客戶端和服務器)可以在同一時間內同時進行數據的發送和接收,即兩個方向的數據流可以同時傳輸,互不干擾。
這里的getInputStream、getOutputStream就體現了全雙工和面向字節流。
不了解這兩個接口的可以去看我這篇文章:
JAVA如何操作文件?(超級詳細)_java操作文件-CSDN博客
實現處理邏輯:
//針對一個連接,提供處理邏輯private void processConnection(Socket clientSocket) {//打印客戶端的信息 返回IP地址 返回端口號System.out.printf("[%s : %d]客戶端上線\n",clientSocket.getInetAddress(), clientSocket.getPort());//獲取到socket中持有的流對象try(InputStream inputStream = clientSocket.getInputStream();OutputStream outputStream = clientSocket.getOutputStream()) {//因為我們用字符串來做為數據傳輸,用Scanner就可以更方便的傳輸了Scanner scanner = new Scanner(inputStream);//包裝輸出流,主要是用println()會在數據之后加上\nPrintWriter printWriter = new PrintWriter(outputStream);while (true) {//1.獲取請求if (!scanner.hasNext()) {//如果scanner無法讀取出數據,說明客戶端斷開了連接,導致服務器這邊讀取到”末尾“break;}//2.處理請求//接收客戶端的請求//如果遇到 空白字符 就會停止輸入String request = scanner.next();//處理請求String response = process(request);//3.返回響應//此處可以按字節數組的形式,但是我們要輸入的是字符串,這個就不太方便//outputStream.write(response.getBytes());//此方法在寫入之后會自動加上\nprintWriter.println(response);//4.打印日志System.out.printf("[%s : %d] 請求 = %s 響應 = %s \n",clientSocket.getInetAddress(),clientSocket.getPort(),request,response);}}catch (IOException e) {throw new RuntimeException();}}private String process(String request) {//由于我們是回顯服務器這里直接返回就可以了return request;}
注意里面使用了兩個接口包裝了一下輸入輸出流,最主要的是可以在用\n做為分割。
注意里面的:
發送字符串給客戶端,最后會自動加上 \n 做為結尾
println(response);
接收客戶端信息,以空白符做為結尾。
空白符:包括不限于 空格、回車、制表符……
scanner.next();
如果是nextLine()就比較嚴格,必須是\n做為結尾
這里的服務器處理邏輯就寫完了,但其實這里還有三個錯誤,后面再單獨講解:
public class TcpEchoServer {private ServerSocket serverSocket = null;// 需要指定服務器的端口 處理ServerSocket拋出的異常public TcpEchoServer(int port) throws IOException {// 指定服務器的端口serverSocket = new ServerSocket(port);}public void start() throws IOException {//服務器需要不停的執行while (true) {//開始監聽指定端口,當有客戶端連接后,返回一個保存對方信息的SocketSocket clientSocket = serverSocket.accept();//處理邏輯processConnection(clientSocket);}}//針對一個連接,提供處理邏輯private void processConnection(Socket clientSocket) {//打印客戶端的信息 返回IP地址 返回端口號System.out.printf("[%s : %d]客戶端上線\n",clientSocket.getInetAddress(), clientSocket.getPort());//獲取到socket中持有的流對象try(InputStream inputStream = clientSocket.getInputStream();OutputStream outputStream = clientSocket.getOutputStream()) {//因為我們用字符串來做為數據傳輸,用Scanner就可以更方便的傳輸了Scanner scanner = new Scanner(inputStream);//包裝輸出流,主要是用println()會在數據之后加上\nPrintWriter printWriter = new PrintWriter(outputStream);while (true) {//1.獲取請求if (!scanner.hasNext()) {//如果scanner無法讀取出數據,說明客戶端斷開了連接,導致服務器這邊讀取到”末尾“break;}//2.處理請求//接收客戶端的請求//如果遇到 空白字符 就會停止輸入String request = scanner.next();//處理請求String response = process(request);//3.返回響應//此處可以按字節數組的形式,但是我們要輸入的是字符串,這個就不太方便//outputStream.write(response.getBytes());//此方法在寫入之后會自動加上\nprintWriter.println(response);//4.打印日志System.out.printf("[%s : %d] 請求 = %s 響應 = %s \n",clientSocket.getInetAddress(),clientSocket.getPort(),request,response);}}catch (IOException e) {throw new RuntimeException();}}private String process(String request) {//由于我們是回顯服務器這里直接返回就可以了return request;}public static void main(String[] args) throws IOException {TcpEchoServer server = new TcpEchoServer(8080);server.start();} }
四、實現客戶端
指定服務器的IP和端口號:
public class TcpEchoClient {private Socket socket = null;public TcpEchoClient(String serverIP, int serverPort) throws IOException {//這里只要建立實例,就是和服務端的accept()建立了連接//socket也就保存了服務器的IP和端口號等//需要傳入服務器的 IP地址 和 端口號socket = new Socket(serverIP, serverPort);}public void start() {System.out.println("客戶端啟動!");}}
整體邏輯:
public class TcpEchoClient {private Socket socket = null;public TcpEchoClient(String serverIP, int serverPort) throws IOException {//需要傳入服務器的 IP地址 和 端口號//這里只要建立實例,就是和服務端的accept()建立了連接socket = new Socket(serverIP, serverPort);}public void start() {System.out.println("客戶端啟動!");try(OutputStream outputStream = socket.getOutputStream();InputStream inputStream = socket.getInputStream()) {while (true) {//1.從控制臺獲取數據//2.將數據發送給服務器//3.接收服務器響應//4.打印相關結果}}catch (IOException e) {throw new RuntimeException();}} }
整體邏輯實現:
public class TcpEchoClient {private Socket socket = null;public TcpEchoClient(String serverIP, int serverPort) throws IOException {//這里只要建立實例,就是和服務端的accept()建立了連接//socket也就保存了服務器的IP和端口號等//需要傳入服務器的 IP地址 和 端口號socket = new Socket(serverIP, serverPort);}public void start() {System.out.println("客戶端啟動!");try(OutputStream outputStream = socket.getOutputStream();InputStream inputStream = socket.getInputStream()) {//用來接收服務器的信息Scanner scanner = new Scanner(inputStream);//用于接收用戶輸入Scanner scannerIn = new Scanner(System.in);//用于輸出數據給服務器PrintWriter printWriter = new PrintWriter(outputStream);while (true) {//1.從控制臺獲取數據System.out.print("->");String request = scannerIn.next();//2.將數據發送給服務器printWriter.println(request);//3.接收服務器響應//判斷服務端是否還有信息if (!scanner.hasNext()) {break;}//接收服務端信息String response = scanner.next();//4.打印相關結果System.out.println(response);}}catch (IOException e) {throw new RuntimeException();}}public static void main(String[] args) throws IOException { // 127.0.0.1是專門用來訪問自己的TcpEchoClient client = new TcpEchoClient("127.0.0.1",8080);client.start();} }
這里仍然純在一個問題,一會和服務器的問題一起將
五、測試解決bug
最后我會把所有的問題解決了,再把源附上
1.客戶端發送了數據之后,并沒有響應
?先運行服務器,再運行客戶端
可以看到目前還是成功的,那么我們來輸入數據。
我們在客戶端輸入了消息,但是沒有任何反應了!
此處的情況是,客戶端并沒有真正把請求發出去:
PrintWriter這樣的類,以及很多IO流中的類,都是 “自帶緩沖區” 的。
此方法就帶有緩沖區:
printWriter.println(request);
引入緩沖區之后,進行寫入數據的操作,并不會馬上觸發IO,而是先放到內存緩沖區中,等到緩沖區里攢了一波之后,再統一進行發送。
為什么引入緩沖區的機制?
因為IO操作其實是不小的開銷,如果數據量較少,那么每一次都進行IO,就有很大一部分開銷是IO操作。如果積累到一定數據量再進行IO操作,那么一次IO就傳輸了這么多數據。
我們可以使用flush方法,主動“刷新緩沖區”:
注意:
服務器 和 客戶端 都需要在printWriter.println();后面加上flush()方法。
再來測試:
此時就可以接收到了
2.clientSocket沒有執行close()操作
這個問題比較隱蔽,這些ServerSocket 和 Socket 每一次都會在“文件描述符”中創建一個新的表項。
文件描述符:描述了該進程都要操作哪些文件。數組的每個元素就是一個struct file對象,每個結構體就描述了對應的文件信息,數組的小標就稱為“文件描述符”。
每次打開一個文件,就想當于在數組上占用了一個位置,而這個數組又是不能擴容的,如果數組滿了就會打開文件失敗。除非主動調用close才會關閉文件,或者這個進程直接結束了這個數組也被帶走了。
那么我們就需要處理一下clientSocket:
?3.嘗試使用多個客戶端同時連接服務器
要對同一代碼啟動多個進程,需要設置一下步驟:
分別啟動客戶端1 和 客戶端2 ,可以看到服務器上根本沒有第二個客戶端啟動的信息:
原因:
我們可以用多線程去執行專門執行每一個客戶端的請求:
public void start() throws IOException {//服務器需要不停的執行while (true) {//開始監聽指定端口,當有客戶端連接后,返回一個保存對方信息的SocketSocket clientSocket = serverSocket.accept();//讓一個線程去對應一個客戶端Thread thread = new Thread(() -> {//處理邏輯processConnection(clientSocket);});thread.start();}}
結果:
bug問題解決了,但還有一些場景,可能會把服務器干崩潰
六、優化
1.短時間有大量客戶端訪問并斷開連接
一旦短時間內有大量的客戶端,并且每個客戶端請求都是很快的連接之后并退出的,這個時候對于服務器來說,就會有比較大的壓力。這個時候,就算是進程比線程更加的輕量,但是短時間內有大量的線程創建銷毀,就無法忽略它的開銷了。
我們可以引入線程池,這樣就解決了這個問題:
public void start() throws IOException {//服務器需要不停的執行while (true) {//開始監聽指定端口,當有客戶端連接后,返回一個保存對方信息的SocketSocket clientSocket = serverSocket.accept();ExecutorService service = Executors.newCachedThreadPool();// //讓一個線程去對應一個客戶端 // Thread thread = new Thread(() -> { // //處理邏輯 // try { // processConnection(clientSocket); // } catch (IOException e) { // e.printStackTrace(); // } // }); // thread.start();service.submit(() -> {try {processConnection(clientSocket);} catch (IOException e) {e.printStackTrace();}});}}
這個線程可創建的線程數是很大的:
2.有大量的客戶端長時間在線訪問
例如直播這樣的情況,每個客戶端分配一個線程,對于一個系統來說,這里搞幾百個線程壓力就非常大了。所以這里 線程池/線程 都不太適用了。
可以使用 IO多路復用 ,也就是一個線程分配多個客戶端進行服務,因為大部分時間線程都是在等待狀態,就能夠讓線程分配多個客戶端,這樣的機制我們做為java程序員不需要過多了解,這樣的機制以及被大佬們,裝進各種框架中了。
七、源碼
服務器源碼:
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;public class TcpEchoServer {private ServerSocket serverSocket = null;// 需要指定服務器的端口 處理ServerSocket拋出的異常public TcpEchoServer(int port) throws IOException {// 指定服務器的端口serverSocket = new ServerSocket(port);}public void start() throws IOException {//服務器需要不停的執行while (true) {//開始監聽指定端口,當有客戶端連接后,返回一個保存對方信息的SocketSocket clientSocket = serverSocket.accept();ExecutorService service = Executors.newCachedThreadPool();// //讓一個線程去對應一個客戶端
// Thread thread = new Thread(() -> {
// //處理邏輯
// try {
// processConnection(clientSocket);
// } catch (IOException e) {
// e.printStackTrace();
// }
// });
// thread.start();service.submit(() -> {try {processConnection(clientSocket);} catch (IOException e) {e.printStackTrace();}});}}//針對一個連接,提供處理邏輯private void processConnection(Socket clientSocket) throws IOException {//打印客戶端的信息 返回IP地址 返回端口號System.out.printf("[%s : %d]客戶端上線\n",clientSocket.getInetAddress(), clientSocket.getPort());//獲取到socket中持有的流對象try(InputStream inputStream = clientSocket.getInputStream();OutputStream outputStream = clientSocket.getOutputStream()) {//因為我們用字符串來做為數據傳輸,用Scanner就可以更方便的傳輸了Scanner scanner = new Scanner(inputStream);//包裝輸出流,主要是用println()會在數據之后加上\nPrintWriter printWriter = new PrintWriter(outputStream);while (true) {//1.獲取請求if (!scanner.hasNext()) {//如果scanner無法讀取出數據,說明客戶端斷開了連接,導致服務器這邊讀取到”末尾“break;}//2.處理請求//接收客戶端的請求//如果遇到 空白字符 就會停止輸入String request = scanner.next();//處理請求String response = process(request);//3.返回響應//此處可以按字節數組的形式,但是我們要輸入的是字符串,這個就不太方便//outputStream.write(response.getBytes());//此方法在寫入之后會自動加上\nprintWriter.println(response);//刷新緩沖區printWriter.flush();//4.打印日志System.out.printf("[%s : %d] 請求 = %s 響應 = %s \n",clientSocket.getInetAddress(),clientSocket.getPort(),request,response);}}catch (IOException e) {throw new RuntimeException();} finally {System.out.printf("[%s : %d]客戶端下線\n",clientSocket.getInetAddress(), clientSocket.getPort());clientSocket.close();}}private String process(String request) {//由于我們是回顯服務器這里直接返回就可以了return request;}public static void main(String[] args) throws IOException {TcpEchoServer server = new TcpEchoServer(8080);server.start();}
}
客戶端源碼:
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Scanner;public class TcpEchoClient {private Socket socket = null;public TcpEchoClient(String serverIP, int serverPort) throws IOException {//這里只要建立實例,就是和服務端的accept()建立了連接//socket也就保存了服務器的IP和端口號等//需要傳入服務器的 IP地址 和 端口號socket = new Socket(serverIP, serverPort);}public void start() {System.out.println("客戶端啟動!");try(OutputStream outputStream = socket.getOutputStream();InputStream inputStream = socket.getInputStream()) {//用來接收服務器的信息Scanner scanner = new Scanner(inputStream);//用于接收用戶輸入Scanner scannerIn = new Scanner(System.in);//用于輸出數據給服務器PrintWriter printWriter = new PrintWriter(outputStream);while (true) {//1.從控制臺獲取數據System.out.print("->");String request = scannerIn.next();//2.將數據發送給服務器printWriter.println(request);//刷新緩沖區printWriter.flush();//3.接收服務器響應//判斷服務端是否還有信息if (!scanner.hasNext()) {break;}//接收服務端信息String response = scanner.next();//4.打印相關結果System.out.println(response);}}catch (IOException e) {throw new RuntimeException();}}public static void main(String[] args) throws IOException {TcpEchoClient client = new TcpEchoClient("127.0.0.1",8080);client.start();}
}