轉:https://www.jianshu.com/p/cde27461c226
一:socket通信基本原理。
首先socket 通信是基於TCP/IP 網絡層上的一種傳送方式,我們通常把TCP和UDP稱爲傳輸層。
如上圖,在七個層級關係中,我們將的socket屬於傳輸層,其中UDP是一種面向無連接的傳輸層協議。UDP不關心對端是否真正收到了傳送過去的數據。如果需要檢查對端是否收到分組數據包,或者對端是否連接到網絡,則需要在應用程序中實現。UDP常用在分組數據較少或多播、廣播通信以及視頻通信等多媒體領域。在這裏我們不進行詳細討論,這裏主要講解的是基於TCP/IP協議下的socket通信。
socket是基於應用服務與TCP/IP通信之間的一個抽象,他將TCP/IP協議裏面複雜的通信邏輯進行分裝,對用戶來說,只要通過一組簡單的API就可以實現網絡的連接。借用網絡上一組socket通信圖給大家進行詳細講解:
首先,服務端初始化ServerSocket,然後對指定的端口進行綁定,接着對端口及進行監聽,通過調用accept方法阻塞,此時,如果客戶端有一個socket連接到服務端,那麼服務端通過監聽和accept方法可以與客戶端進行連接。
二:socket通信基本示例:
在對socket通信基本原理明白後,那我們就寫一個最簡單的示例,展示我們常遇到的第一個問題:客戶端發送消息後,服務端無法收到消息。
服務端:
package socket.socket1.socket;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
public class ServerSocketTest {
public static void main(String[] args) {
try {
// 初始化服務端socket並且綁定9999端口
ServerSocket serverSocket =new ServerSocket(9999);
//等待客戶端的連接
Socket socket = serverSocket.accept();
//獲取輸入流
BufferedReader bufferedReader =
new BufferedReader(new InputStreamReader(socket.getInputStream()));
//讀取一行數據
String str = bufferedReader.readLine();
//輸出打印
System.out.println(str);
}catch (IOException e) {
e.printStackTrace();
}
}
}
客戶端:
package socket.socket1.socket;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.net.Socket;
public class ClientSocket {
public static void main(String[] args) {
try{
Socket socket =new Socket("127.0.0.1",9999);
BufferedWriter bufferedWriter =
new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
String str="你好,這是我的第一個socket";
bufferedWriter.write(str);
}catch (IOException e) {
e.printStackTrace();
}
}
}
啓動服務端:
發現正常,等待客戶端的的連接
啓動客戶端:
發現客戶端啓動正常後,馬上執行完後關閉。同時服務端控制檯報錯:
服務端控制檯報錯:
拷貝這個java.net.SocketException: Connection reset上網查異常,查詢解決方案,搞了半天都不知道怎麼回事。解決這個問題我們首先要明白,socket通信是阻塞的,他會在以下幾個地方進行阻塞。第一個是accept方法,調用這個方法後,服務端一直阻塞在哪裏,直到有客戶端連接進來。第二個是read方法,調用read方法也會進行阻塞。通過上面的示例我們可以發現,該問題發生在read方法中。有朋友說是Client沒有發送成功,其實不是的,我們可以通debug跟蹤一下,發現客戶端發送了,並且沒有問題。而是發生在服務端中,當服務端調用read方法後,他一直阻塞在哪裏,因爲客戶端沒有給他一個標識,告訴是否消息發送完成,所以服務端還在一直等待接受客戶端的數據,結果客戶端此時已經關閉了,就是在服務端報錯:java.net.SocketException: Connection reset
那麼理解上面的原理後,我們就能明白,客戶端發送完消息後,需要給服務端一個標識,告訴服務端,我已經發送完成了,服務端就可以將接受的消息打印出來。
通常大家會用以下方法進行進行結束:
socket.close() 或者調用socket.shutdownOutput();方法。調用這倆個方法,都會結束客戶端socket。但是有本質的區別。socket.close() 將socket關閉連接,那邊如果有服務端給客戶端反饋信息,此時客戶端是收不到的。而socket.shutdownOutput()是將輸出流關閉,此時,如果服務端有信息返回,則客戶端是可以正常接受的。現在我們將上面的客戶端示例修改一下,增加一個標識告訴流已經輸出完畢:
客戶端2:
package socket.socket1.socket;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.net.Socket;
public class ClientSocket {
public static void main(String[] args) {
try {
Socket socket =new Socket("127.0.0.1",9999);
BufferedWriter bufferedWriter =
new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
String str="你好,這是我的第一個socket";
bufferedWriter.write(str);
//刷新輸入流
bufferedWriter.flush();
//關閉socket的輸出流
socket.shutdownOutput();
}catch (IOException e) {
e.printStackTrace();
}
}
}
再看服務端控制檯:
服務端在接受到客戶端關閉流的信息後,知道信息輸入已經完畢,就能正常讀取到客戶端傳過來的數據。通過上面示例,我們可以基本瞭解socket通信原理,掌握了一些socket通信的基本api和方法,實際應用中,都是通過此處進行實現變通的。
三:while循環連續接受客戶端信息:
上面的示例中scoket客戶端和服務端固然可以通信,但是客戶端每次發送信息後socket就需要關閉,下次如果需要發送信息,需要socket從新啓動,這顯然是無法適應生產環境的需要。比如在我們是實際應用中QQ,如果每次發送一條信息,就需要重新登陸QQ,我估計這程序不是給人設計的,那麼如何讓服務可以連續給服務端發送消息?下面我們通過while循環進行簡單展示:
服務端:
package socket.socket1.socket;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
public class ServerSocketTest {
public static void main(String[] args) {
try {
// 初始化服務端socket並且綁定9999端口
ServerSocket serverSocket =new ServerSocket(9999);
//等待客戶端的連接
Socket socket = serverSocket.accept();
//獲取輸入流,並且指定統一的編碼格式
BufferedReader bufferedReader =
new BufferedReader(new InputStreamReader(socket.getInputStream(),"UTF-8")); //讀取一行數據
String str;
//通過while循環不斷讀取信息
while ((str = bufferedReader.readLine())!=null){
//輸出打印
System.out.println(str);
}
}catch (IOException e) {
e.printStackTrace();
}
}
}
客戶端:
package socket.socket1.socket;
import java.io.*;
import java.net.Socket;
public class ClientSocket {
public static void main(String[] args) {
try {
//初始化一個socket
Socket socket =new Socket("127.0.0.1",9999);
//通過socket獲取字符流
BufferedWriter bufferedWriter =
new BufferedWriter(new OutputStreamWriter(socket.getOutputStream())); //通過標準輸入流獲取字符流
BufferedReader bufferedReader =
new BufferedReader(new InputStreamReader(System.in,"UTF-8"));
while (true){
String str = bufferedReader.readLine();
bufferedWriter.write(str);
bufferedWriter.write("\n");
bufferedWriter.flush();
}
}catch (IOException e) {
e.printStackTrace();
}
}
}
客戶端控制中心:
服務端控制中心:
大家可以看到,通過一個while 循環,就可以實現客戶端不間斷的通過標準輸入流讀取來的消息,發送給服務端。在這裏有個細節,大家看到沒有,我客戶端沒有寫socket.close() 或者調用socket.shutdownOutput();服務端是如何知道客戶端已經輸入完成了?服務端接受數據的時候是如何判斷客戶端已經輸入完成呢?這就是一個核心點,雙方約定一個標識,當客戶端發送一個標識給服務端時,表明客戶端端已經完成一個數據的載入。而服務端在結束數據的時候,也通過這個標識進行判斷,如果接受到這個標識,表明數據已經傳入完成,那麼服務端就可以將數據度入後顯示出來。
在上面的示例中,客戶端端在循環發送數據時候,每發送一行,添加一個換行標識“\n”標識,在告訴服務端我數據已經發送完成了。而服務端在讀取客戶數據時,通過while ((str = bufferedReader.readLine())!=null)去判斷是否讀到了流的結尾,負責服務端將會一直阻塞在哪裏,等待客戶端的輸入。
通過while方式,我們可以實現多個客戶端和服務端進行聊天。但是,下面敲黑板,劃重點。由於socket通信是阻塞式的,假設我現在有A和B倆個客戶端同時連接到服務端的上,當客戶端A發送信息給服務端後,那麼服務端將一直阻塞在A的客戶端上,不同的通過while循環從A客戶端讀取信息,此時如果B給服務端發送信息時,將進入阻塞隊列,直到A客戶端發送完畢,並且退出後,B纔可以和服務端進行通信。簡單地說,我們現在實現的功能,雖然可以讓客戶端不間斷的和服務端進行通信,與其說是一對一的功能,因爲只有當客戶端A關閉後,客戶端B纔可以真正和服務端進行通信,這顯然不是我們想要的。 下面我們通過多線程的方式給大家實現正常人類的思維。
四:多線程下socket編程
服務端:
package socket.socket1.socket;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
public class ServerSocketTest {
public static void main(String[] args)throws IOException {
// 初始化服務端socket並且綁定9999端口
ServerSocket serverSocket =new ServerSocket(9999);
while (true){
//等待客戶端的連接
Socket socket = serverSocket.accept();
//每當有一個客戶端連接進來後,就啓動一個單獨的線程進行處理
new Thread(new Runnable() {
@Override
public void run() {
//獲取輸入流,並且指定統一的編碼格式
BufferedReader bufferedReader =null;
try {
bufferedReader =new BufferedReader(new
InputStreamReader(socket.getInputStream(),"UTF-8"));
//讀取一行數據
String str;
//通過while循環不斷讀取信息
while ((str = bufferedReader.readLine())!=null){
//輸出打印
System.out.println("客戶端說:"+str);
}
}catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}
}
}
客戶端:
package socket.socket1.socket;
import java.io.*;
import java.net.Socket;
public class ClientSocket {
public static void main(String[] args) {
try {
//初始化一個socket
Socket socket =new Socket("127.0.0.1",9999);
//通過socket獲取字符流
BufferedWriter bufferedWriter =new BufferedWriter(new
OutputStreamWriter(socket.getOutputStream())); //通過標準輸入流獲取字符流
BufferedReader bufferedReader =new BufferedReader(new
InputStreamReader(System.in,"UTF-8"));
while (true){
String str = bufferedReader.readLine();
bufferedWriter.write(str);
bufferedWriter.write("\n");
bufferedWriter.flush();
}
}catch (IOException e) {
e.printStackTrace();
}
}
}
通過客戶端A控制檯輸入:
通過客戶端B控制檯輸入:
服務端控制檯:
通過這裏我們可以發現,客戶端A和客戶端B同時連接到服務端後,都可以和服務端進行通信,也不會出現前面講到使用while(true)時候客戶端A連接時客戶端B不能與服務端進行交互的情況。在這裏我們看到,主要是通過服務端的 new Thread(new Runnable() {}實現的,每一個客戶端連接進來後,服務端都會單獨起個一線程,與客戶端進行數據交互,這樣就保證了每個客戶端處理的數據是單獨的,不會出現相互阻塞的情況,這樣就基本是實現了QQ程序的基本聊天原理。
但是實際生產環境中,這種寫法對於客戶端連接少的的情況下是沒有問題,但是如果有大批量的客戶端連接進行,那我們服務端估計就要歇菜了。假如有上萬個socket連接進來,服務端就是新建這麼多進程,反正樓主是不敢想,而且socket 的回收機制又不是很及時,這麼多線程被new 出來,就發送一句話,然後就沒有然後了,導致服務端被大量的無用線程暫用,對性能是非常大的消耗,在實際生產過程中,我們可以通過線程池技術,保證線程的複用,下面請看改良後的服務端程序。
改良後的服務端:
package socket.socket1.socket;
import java.beans.Encoder;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ServerSocketTest {
public static void main(String[] args)throws IOException {
// 初始化服務端socket並且綁定9999端口
ServerSocket serverSocket =new ServerSocket(9999);
//創建一個線程池
ExecutorService executorService = Executors.newFixedThreadPool(100);
while (true) {
//等待客戶端的連接
Socket socket = serverSocket.accept();
Runnable runnable = () -> {
BufferedReader bufferedReader =null;
try {
bufferedReader =new BufferedReader(new
InputStreamReader(socket.getInputStream(), "UTF-8")); //讀取一行數據
String str;
//通過while循環不斷讀取信息
while ((str = bufferedReader.readLine()) !=null) {
//輸出打印
System.out.println("客戶端說:" + str);
}
}catch (IOException e) {
e.printStackTrace();
}
};
executorService.submit(runnable);
}
}
}
運行後服務端控制檯:
通過線程池技術,我們可以實現線程的複用。其實在這裏executorService.submit在併發時,如果要求當前執行完畢的線程有返回結果時,這裏面有一個大坑,在這裏我就不一一詳細說明,具體我在我的另一篇文章中《把多線程說個透》裏面詳細介紹。本章主要講述socket相關內容。
在實際應用中,socket發送的數據並不是按照一行一行發送的,比如我們常見的報文,那麼我們就不能要求每發送一次數據,都在增加一個“\n”標識,這是及其不專業的,在實際應用中,通過是採用數據長度+類型+數據的方式,在我們常接觸的熱Redis就是採用這種方式