Java 網路程式設計

油多壞不了菜發表於2020-10-04

最近打算把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

相關文章