Java 網路編程

  • 2020 年 10 月 4 日
  • 筆記

最近打算把Java網路編程相關的知識深入一下(IO、NIO、Socket編程、Netty)

Java網路編程主要涉及到對Socket和ServerSocket的使用上

閱讀之前最好有TCP和UDP協議的理論知識以及Java I/O流的基礎知識

Java I/O流

TCP協議之上構建網路程式

TCP協議的特點

  • TCP是面向連接的協議,通訊之前需要先建立連接

  • 提供可靠傳輸,通過TCP傳輸的數據無差錯、不丟失、不重複、並且按序到達

  • 面向位元組流(雖然應用程式和TCP的交互是一次一個數據塊,但是TCP把應用程式交下來的數據僅僅看成是一連串的無結構的位元組流

  • 點對點全雙工通訊

  • 擁塞控制 & 滑動窗口

我們使用Java構建基於TCP的網路程式時主要關心客戶端Socket和服務端ServerSocket兩個類

客戶端SOCKET

使用客戶端SOCKET的生命周期:連接遠程伺服器 –> 發送數據、接受數據… –> 關閉連接

連接遠程伺服器

通過構造函數連接

構造函數里指定遠程主機和埠, 構造函數正常返回即代表連接成功, 連接失敗會拋IOException或者UnkonwnHostException

public Socket(String host, int port)
public Socket(String host, int port, InetAddress localAddr,int localPort)

手動連接

當使用無參構造函數時,通訊前需要手動調用connect進行連接(同時可設置SOCKET選項)

Socket so = new Socket();
SocketAddress address = new InetSocketAddress("www.baidu.com", 80);
so.connect(address);

發送數據、接受數據

Java的I/O建立於流之上,讀數據用輸入流,寫數據用輸出流

下段程式碼連接本地7001埠的服務端程式,讀取一行數據並且將該行數據回寫服務端。

 try (Socket so = new Socket("127.0.0.1", 7001)) {
     BufferedReader reader = new BufferedReader(new InputStreamReader(so.getInputStream()));
     BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(so.getOutputStream()));
     //read message from server
     String recvMsg = reader.readLine();
     //write back to sever.
     writer.write(recvMsg);
     writer.newLine();
     writer.flush();
  } catch (IOException e) {
     //ignore
  }

大端模式

大端模式是指數據的高位元組保存在記憶體的低地址中(默認或者說我們閱讀習慣都是大端模式)

關閉連接

Socket對象使用之後必須關閉,以釋放底層系統資源

finally 塊中關閉連接

Socket so = null;
try {
  so = new Socket("127.0.0.1", 7001);
  //
}catch (Exception e){
	//
}finally {
  if(so != null){
    try {
      so.close();
    } catch (IOException e) {
      //
    }
  }
}

Try with resource 語法自動關閉連接

在try塊中定義的Socket對象(以及其他實現了AutoCloseable的對象)Java會自動關閉

//在try中定義的Socket對象(或其他實現了AutoCloseable的對象)Java會自動關閉
try (Socket so = new Socket("127.0.0.1", 7001)) {
		//do something
} catch(Exception e){
		//
}

服務端ServerSocket

使用ServerSocket的生命周期:綁定本地埠(服務啟動) –> 監聽客戶端連接 –> 接受客戶端連接 –> 通過該客戶端連接與客戶端進行通訊 –> 監聽客戶端連接 –> …..(loop) –> 關閉伺服器

綁定本地埠

直接在構造函數中指定埠完成綁定或者手工綁定

//構造函數中指定埠完成綁定
ServerSokect ss = new  ServerSocket(7001);

//手工調用bind函數完成綁定
ServerSokect ss = new  ServerSocket();
ss.bind(new InetSocketAddress(7001));

接受客戶端連接

accept方法返回一個Socket對象,代表與客戶端建立的一個連接

 ServerSokect ss = new ServerSocket(7001);  
 while(true){
    //阻塞等待連接建立
 		Socket so = ss.accept();
    // do something.
 }

與客戶端進行通訊

通過連接建立後的Socket對象,打開輸入流、輸出流即可與客戶端進行通訊

關閉伺服器

同客戶端Socket關閉一個道理

Demo

下段程式碼伺服器在連接建立時發送一行數據到客戶端, 然後再讀取一行客戶端返回的數據,並比較這兩行數據是否一樣。

**主執行緒只接受客戶端連接,連接建立後與客戶端的通訊在一個執行緒池中完成 **

public class BaseServer {

    private static final String MESSAGE = "hello, i am server";
    private static ExecutorService threads = Executors.newFixedThreadPool(6);

    public static void main(String[] args) {
       //try with resource 寫法綁定本地埠
        try (ServerSocket socket = new ServerSocket(7001)) {
            while (true) {
              	//接受客戶端連接
                Socket so = socket.accept();
              	//與客戶端通訊的工作放到執行緒池中非同步執行
                threads.submit(() -> handle(so));
            }
        } catch (IOException e) {
            //
        }
    }

    public static void handle(Socket so) {
       //try with resource 寫法打開輸入輸出流
        try (InputStream in = so.getInputStream(); OutputStream out = so.getOutputStream()) {
            BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out, "utf-8"));
            BufferedReader reader = new BufferedReader(new InputStreamReader(in));

            //send data to client.
            writer.write(MESSAGE);
            writer.newLine();
            writer.flush();

            //recv data from client.
            String clientResp = reader.readLine();
            System.out.println(MESSAGE.equals(clientResp));
        } catch (Exception e) {
            //ignore
        }finally {
          //關閉socket
            if(so != null){
                try {
                    so.close();
                } catch (IOException e) {
                    //
                }
            }
        }
    }
}

Socket選項

TCP_NODELAY

默認tcp緩衝是打開的,小數據包在發送之前會組合成更大的數據包發送, 在發送另一個包之前,本地主機需要等待對前一個包的確認– Nagle演算法

但是這種緩衝模式有可能導致某些應用程式響應太慢(比如一個簡單的打字程式)

tcp_nodelay 設置為true關閉tcp緩衝, 所有的包一就緒就會發送

 public void setTcpNoDelay(boolean on) 

SO_LINGER(linger是緩慢消失、徘徊的意思)

so_linger選項指定socket關閉時如何處理尚未發送的數據報,默認是close()方法立即返回,但是系統仍會將數據的數據發送

Linger 設置為0時,socket關閉時會丟棄所有未發送的數據

如果so_linger 打開且linger為正數,close()會阻塞指定的秒數,等待發送數據和接受確認,直到指定的秒數過去。

public void setSoLinger(boolean on, int linger)

SO_TIMEOUT

默認情況,嘗試從socket讀取數據時,read()會阻塞儘可能長的時間來獲得足夠多的位元組

so_timeout 用於設置這個阻塞的時間,當時間到期拋出一個InterruptedException異常。

public synchronized void setSoTimeout(int timeout)//毫秒,默認為0一直阻塞

SO_KEEPLIVE

so_keeplive打開後,客戶端每隔一段時間就發送一個報文到服務端已確保與服務端的連接還正常(TCP層面提供的心跳機制)

public void setKeepAlive(boolean on)

SO_RCVBUF 和SO_SNDBUF

設置tcp接受和發送緩衝區大小(內核層面的緩衝區大小)

對於傳輸大的數據塊時(HTTP、FTP),可以從大緩衝區中受益;對於互動式會話的小數據量傳輸(Telnet和很多遊戲),大緩衝區沒啥幫助

緩衝區最大大小 = 頻寬 * 時延 (如果頻寬為2Mb/s, 時延為500ms, 則緩衝區最大大小為128KB左右)

如果應用程式不能充分利用頻寬,可以適當增加緩衝區大小,如果存在丟包和擁塞現象,則要減小緩衝區大小

UDP協議之上構建網路程式

UDP協議的特點

  • 無連接。發送數據之前不需要建立連接,省去了建立連接的開銷

  • 儘力最大努力交付。數據報可能丟失、亂序到達

  • 面向報文(UDP對應用層交下來的報文,既不合併,也不拆分,而是保留這些報文的邊界

  • UDP沒有擁塞控制

  • UDP支援一對一、一對多、多對一和多對多的交互通訊

  • UDP的首部開銷小,只有8個位元組,比TCP的20個位元組的首部還要短。

    構建UDP協議的網路程式時, 我們關係DatagramSocket和DatagramPacket兩個類

數據報

UDP是面向報文傳輸的,對應用層交下來的報文不合併也不拆分(TCP就存在拆包和粘包的問題)

數據報關心兩個事:存儲報文的底層位元組數組 和 通訊對端地址(對端主機和埠)

//發送數據報指定發送的數據和對端地址
DatagramPacket sendPacket = new DatagramPacket(new byte[0], 0, InetAddress.getByName("127.0.0.1"), 7002);

//接受數據報只需要指定底層位元組數組以及其大小
DatagramPacket recvPacket = new DatagramPacket(new byte[1024], 1024);

UDP客戶端

因為UDP是無連接的,所以構造DatagramSocket的時候只需要指定本地埠, 不需要指定遠程主機和埠

遠程主機的主機和埠是指定在數據報中的,所以UDP可以實現一對一、一對多、多對多傳輸

 try (DatagramSocket so = new DatagramSocket(0)) {
   //數據報中指定對端地址(服務端地址)
   DatagramPacket sendPacket = new DatagramPacket(new byte[0], 0,
                                                  InetAddress.getByName("127.0.0.1"), 7002);
   //發送數據報
   so.send(sendPacket);

   //阻塞接受數據報
   DatagramPacket recvPacket = new DatagramPacket(new byte[1024], 1024);
   so.receive(recvPacket);
   //列印對端返回的數據
   System.out.println(new String(recvPacket.getData(), 0, recvPacket.getLength()));
 } catch (Exception e) {
   e.printStackTrace();
 }

UDP服務端

UDP服務端同客戶端一樣使用的是DatagramSocket, 區別在於綁帶的本地埠需要顯示申明

下面的UDP服務端程式接受客戶端的報文,從報文中獲取請求主機和埠,然後返回固定的數據內容 “received”

byte[] data = "received".getBytes();
try (DatagramSocket so = new DatagramSocket(7002)) {
  while (true) {
    try {
      DatagramPacket recvPacket = new DatagramPacket(new byte[1024], 1024);
      so.receive(recvPacket);
      DatagramPacket sendPacket = new DatagramPacket(data, data.length,
                                                     recvPacket.getAddress(), recvPacket.getPort());
      so.send(sendPacket);
    } catch (Exception e) {
      //
    }
  }

} catch (SocketException e) {
  //
}

連接

UDP是無連接的, 但是DatagramSocket提供了連接功能對通訊對端進行限制(並不是真的連接)

連接之後只能向指定的主機和埠發送數據報, 否則會拋出異常。

連接之後只能接收到指定主機和埠發送的數據報, 其他數據報會被直接拋棄。

 public void connect(InetAddress address, int port)
 public void disconnect() 

總結

Java 中TCP編程依賴於 Socket和ServerSocket,UDP編程依賴於DatagramSocket和DatagramPacket