Java socket詳解

轉: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就是採用這種方式

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章