1. 程式人生 > >【Socket】Java Socket程式設計基礎及深入講解

【Socket】Java Socket程式設計基礎及深入講解

Socket是Java網路程式設計的基礎,瞭解還是有好處的,

  這篇文章主要講解Socket的基礎程式設計。Socket用在哪呢,主要用在程序間,網路間通訊。本篇比較長,特別做了個目錄:

一、Socket通訊基本示例

  這種模式是基礎,必須掌握,後期對Socket的優化都是在這個基礎上的,也是為以後學習NIO做鋪墊。

package yiwangzhibujian.onlysend;

import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class SocketServer {
public static void main(String[] args) throws Exception {
// 監聽指定的埠
int port = 55533;
ServerSocket server = new ServerSocket(port);

// server將一直等待連線的到來
System.out.println("server將一直等待連線的到來");
Socket socket = server.accept();
// 建立好連線後,從socket中獲取輸入流,並建立緩衝區進行讀取
InputStream inputStream = socket.getInputStream();
byte[] bytes = new byte[1024];
int len;
StringBuilder sb = new StringBuilder();
while ((len = inputStream.read(bytes)) != -1) {
//注意指定編碼格式,傳送方和接收方一定要統一,建議使用UTF-8
sb.append(new String(bytes, 0, len,"UTF-8"));
}
System.out.println("get message from client: " + sb);
inputStream.close();
socket.close();
server.close();
}
}

  服務端監聽一個埠,等待連線的到來。

package yiwangzhibujian.onlysend;

import java.io.OutputStream;
import java.net.Socket;

public class SocketClient {
  public static void main(String args[]) throws Exception {
    // 要連線的服務端IP地址和埠
    String host = "127.0.0.1"; 
    int port = 55533;
    // 與服務端建立連線
    Socket socket = new Socket(host, port);
    // 建立連線後獲得輸出流
    OutputStream outputStream = socket.getOutputStream();
    String message="你好  yiwangzhibujian";
    socket.getOutputStream().write(message.getBytes("UTF-8"));
    outputStream.close();
    socket.close();
  }
}

  客戶端通過ip和埠,連線到指定的server,然後通過Socket獲得輸出流,並向其輸出內容,伺服器會獲得訊息。最終服務端控制檯列印如下:

server將一直等待連線的到來
get message from client: 你好  yiwangzhibujian

  通過這個例子應該掌握並瞭解:

  • Socket服務端和客戶端的基本程式設計
  • 傳輸編碼統一指定,防止亂碼

  這個例子做為學習的基本例子,實際開發中會有各種變形,比如客戶端在傳送完訊息後,需要服務端進行處理並返回,如下。

二、訊息通訊優化

2.1 雙向通訊,傳送訊息並接受訊息

  這個也是做為Socket程式設計的基本,應該掌握,例子如下:

package yiwangzhibujian.waitreceive;

import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class SocketServer {
  public static void main(String[] args) throws Exception {
    // 監聽指定的埠
    int port = 55533;
    ServerSocket server = new ServerSocket(port);
    
    // server將一直等待連線的到來
    System.out.println("server將一直等待連線的到來");
    Socket socket = server.accept();
    // 建立好連線後,從socket中獲取輸入流,並建立緩衝區進行讀取
    InputStream inputStream = socket.getInputStream();
    byte[] bytes = new byte[1024];
    int len;
    StringBuilder sb = new StringBuilder();
    //只有當客戶端關閉它的輸出流的時候,服務端才能取得結尾的-1
    while ((len = inputStream.read(bytes)) != -1) {
      // 注意指定編碼格式,傳送方和接收方一定要統一,建議使用UTF-8
      sb.append(new String(bytes, 0, len, "UTF-8"));
    }
    System.out.println("get message from client: " + sb);

    OutputStream outputStream = socket.getOutputStream();
    outputStream.write("Hello Client,I get the message.".getBytes("UTF-8"));

    inputStream.close();
    outputStream.close();
    socket.close();
    server.close();
  }
}

  與之前server的不同在於,當讀取完客戶端的訊息後,開啟輸出流,將指定訊息傳送回客戶端,客戶端程式為:

package yiwangzhibujian.waitreceive;

import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;

public class SocketClient {
  public static void main(String args[]) throws Exception {
    // 要連線的服務端IP地址和埠
    String host = "127.0.0.1";
    int port = 55533;
    // 與服務端建立連線
    Socket socket = new Socket(host, port);
    // 建立連線後獲得輸出流
    OutputStream outputStream = socket.getOutputStream();
    String message = "你好  yiwangzhibujian";
    socket.getOutputStream().write(message.getBytes("UTF-8"));
    //通過shutdownOutput高速伺服器已經發送完資料,後續只能接受資料
    socket.shutdownOutput();
    
    InputStream inputStream = socket.getInputStream();
    byte[] bytes = new byte[1024];
    int len;
    StringBuilder sb = new StringBuilder();
    while ((len = inputStream.read(bytes)) != -1) {
      //注意指定編碼格式,傳送方和接收方一定要統一,建議使用UTF-8
      sb.append(new String(bytes, 0, len,"UTF-8"));
    }
    System.out.println("get message from server: " + sb);
    
    inputStream.close();
    outputStream.close();
    socket.close();
  }
}

  客戶端也有相應的變化,在傳送完訊息時,呼叫關閉輸出流方法,然後開啟輸出流,等候服務端的訊息。

2.2 使用場景

  這個模式的使用場景一般用在,客戶端傳送命令給伺服器,然後伺服器相應指定的命令,如果只是客戶端傳送訊息給伺服器,然後讓伺服器返回收到訊息的訊息,這就有點過分了,這就是完全不相信Socket的傳輸安全性,要知道它的底層可是TCP,如果沒有傳送到伺服器端是會拋異常的,這點完全不用擔心。

2.3 如何告知對方已傳送完命令

  其實這個問題還是比較重要的,正常來說,客戶端開啟一個輸出流,如果不做約定,也不關閉它,那麼服務端永遠不知道客戶端是否傳送完訊息,那麼服務端會一直等待下去,直到讀取超時。所以怎麼告知服務端已經發送完訊息就顯得特別重要。

2.3.1 通過Socket關閉

  這個是第一章介紹的方式,當Socket關閉的時候,服務端就會收到響應的關閉訊號,那麼服務端也就知道流已經關閉了,這個時候讀取操作完成,就可以繼續後續工作。

  但是這種方式有一些缺點

  • 客戶端Socket關閉後,將不能接受服務端傳送的訊息,也不能再次傳送訊息
  • 如果客戶端想再次傳送訊息,需要重現建立Socket連線

2.3.2 通過Socket關閉輸出流的方式

  這種方式呼叫的方法是:

socket.shutdownOutput();

  而不是(outputStream為傳送訊息到服務端開啟的輸出流):

outputStream.close();

  如果關閉了輸出流,那麼相應的Socket也將關閉,和直接關閉Socket一個性質。

  呼叫Socket的shutdownOutput()方法,底層會告知服務端我這邊已經寫完了,那麼服務端收到訊息後,就能知道已經讀取完訊息,如果服務端有要返回給客戶的訊息那麼就可以通過服務端的輸出流傳送給客戶端,如果沒有,直接關閉Socket。

  這種方式通過關閉客戶端的輸出流,告知服務端已經寫完了,雖然可以讀到服務端傳送的訊息,但是還是有一點點缺點:

  • 不能再次傳送訊息給服務端,如果再次傳送,需要重新建立Socket連線

  這個缺點,在訪問頻率比較高的情況下將是一個需要優化的地方。

2.3.3 通過約定符號

  這種方式的用法,就是雙方約定一個字元或者一個短語,來當做訊息傳送完成的標識,通常這麼做就需要改造讀取方法。

  假如約定單端的一行為end,代表傳送完成,例如下面的訊息,end則代表訊息傳送完成:

hello yiwangzhibujian
end

  那麼服務端響應的讀取操作需要進行如下改造:

Socket socket = server.accept();
// 建立好連線後,從socket中獲取輸入流,並建立緩衝區進行讀取
BufferedReader read=new BufferedReader(new InputStreamReader(socket.getInputStream(),"UTF-8"));
String line;
StringBuilder sb = new StringBuilder();
while ((line = read.readLine()) != null && "end".equals(line)) {
  //注意指定編碼格式,傳送方和接收方一定要統一,建議使用UTF-8
  sb.append(line);
}

  可以看見,服務端不僅判斷是否讀到了流的末尾,還判斷了是否讀到了約定的末尾。

  這麼做的優缺點如下:

  • 優點:不需要關閉流,當傳送完一條命令(訊息)後可以再次傳送新的命令(訊息)
  • 缺點:需要額外的約定結束標誌,太簡單的容易出現在要傳送的訊息中,誤被結束,太複雜的不好處理,還佔頻寬

  經過了這麼多的優化還是有缺點,難道就沒有完美的解決方案嗎,答案是有的,看接下來的內容。

2.3.4 通過指定長度

  如果你瞭解一點class檔案的結構(後續會寫,敬請期待),那麼你就會佩服這麼設計方式,也就是說我們可以在此找靈感,就是我們可以先指定後續命令的長度,然後讀取指定長度的內容做為客戶端傳送的訊息。

  現在首要的問題就是用幾個位元組指定長度呢,我們可以算一算:

  • 1個位元組:最大256,表示256B
  • 2個位元組:最大65536,表示64K
  • 3個位元組:最大16777216,表示16M
  • 4個位元組:最大4294967296,表示4G
  • 依次類推

  這個時候是不是很糾結,最大的當然是最保險的,但是真的有必要選擇最大的嗎,其實如果你稍微瞭解一點UTF-8的編碼方式(字元編碼後續會寫,敬請期待),那麼你就應該能想到為什麼一定要固定表示長度位元組的長度呢,我們可以使用變長方式來表示長度的表示,比如:

  • 第一個位元組首位為0:即0XXXXXXX,表示長度就一個位元組,最大128,表示128B
  • 第一個位元組首位為110,那麼附帶後面一個位元組表示長度:即110XXXXX 10XXXXXX,最大2048,表示2K
  • 第一個位元組首位為1110,那麼附帶後面二個位元組表示長度:即110XXXXX 10XXXXXX 10XXXXXX,最大131072,表示128K
  • 依次類推

  上面提到的這種用法適合高富帥的程式設計師使用,一般呢,如果用作命名傳送,兩個位元組就夠了,如果還不放心4個位元組基本就能滿足你的所有要求,下面的例子我們將採用2個位元組表示長度,目的只是給你一種思路,讓你知道有這種方式來獲取訊息的結尾:

  服務端程式:

package yiwangzhibujian.waitreceive2;

import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class SocketServer {
  public static void main(String[] args) throws Exception {
    // 監聽指定的埠
    int port = 55533;
    ServerSocket server = new ServerSocket(port);

    // server將一直等待連線的到來
    System.out.println("server將一直等待連線的到來");
    Socket socket = server.accept();
    // 建立好連線後,從socket中獲取輸入流,並建立緩衝區進行讀取
    InputStream inputStream = socket.getInputStream();
    byte[] bytes;
    // 因為可以複用Socket且能判斷長度,所以可以一個Socket用到底
    while (true) {
      // 首先讀取兩個位元組表示的長度
      int first = inputStream.read();
      //如果讀取的值為-1 說明到了流的末尾,Socket已經被關閉了,此時將不能再去讀取
      if(first==-1){
        break;
      }
      int second = inputStream.read();
      int length = (first << 8) + second;
      // 然後構造一個指定長的byte陣列
      bytes = new byte[length];
      // 然後讀取指定長度的訊息即可
      inputStream.read(bytes);
      System.out.println("get message from client: " + new String(bytes, "UTF-8"));
    }
    inputStream.close();
    socket.close();
    server.close();
  }
}

  此處的讀取步驟為,先讀取兩個位元組的長度,然後讀取訊息,客戶端為:

package yiwangzhibujian.waitreceive2;

import java.io.OutputStream;
import java.net.Socket;

public class SocketClient {
  public static void main(String args[]) throws Exception {
    // 要連線的服務端IP地址和埠
    String host = "127.0.0.1";
    int port = 55533;
    // 與服務端建立連線
    Socket socket = new Socket(host, port);
    // 建立連線後獲得輸出流
    OutputStream outputStream = socket.getOutputStream();
    String message = "你好  yiwangzhibujian";
    //首先需要計算得知訊息的長度
    byte[] sendBytes = message.getBytes("UTF-8");
    //然後將訊息的長度優先發送出去
    outputStream.write(sendBytes.length >>8);
    outputStream.write(sendBytes.length);
    //然後將訊息再次傳送出去
    outputStream.write(sendBytes);
    outputStream.flush();
    //==========此處重複傳送一次,實際專案中為多個命名,此處只為展示用法
    message = "第二條訊息";
    sendBytes = message.getBytes("UTF-8");
    outputStream.write(sendBytes.length >>8);
    outputStream.write(sendBytes.length);
    outputStream.write(sendBytes);
    outputStream.flush();
    //==========此處重複傳送一次,實際專案中為多個命名,此處只為展示用法
    message = "the third message!";
    sendBytes = message.getBytes("UTF-8");
    outputStream.write(sendBytes.length >>8);
    outputStream.write(sendBytes.length);
    outputStream.write(sendBytes);    
    
    outputStream.close();
    socket.close();
  }
}

  客戶端要多做的是,在傳送訊息之前先把訊息的長度傳送過去。

  這種事先約定好長度的做法解決了之前提到的種種問題,Redis的Java客戶端Jedis就是用這種方式實現的這種方式的缺點:

  • 暫時還沒發現

  當然如果是需要伺服器返回結果,那麼也依然使用這種方式,服務端也是先發送結果的長度,然後客戶端進行讀取。當然現在流行的就是,長度+型別+資料模式的傳輸方式。

三、服務端優化

3.1 服務端併發處理能力

  在上面的例子中,服務端僅僅只是接受了一個Socket請求,並處理了它,然後就結束了,但是在實際開發中,一個Socket服務往往需要服務大量的Socket請求,那麼就不能再服務完一個Socket的時候就關閉了,這時候可以採用迴圈接受請求並處理的邏輯:

package yiwangzhibujian.multiserver;

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class SocketServer {
  public static void main(String args[]) throws IOException {
    // 監聽指定的埠
    int port = 55533;
    ServerSocket server = new ServerSocket(port);
    // server將一直等待連線的到來
    System.out.println("server將一直等待連線的到來");
    
    while(true){
      Socket socket = server.accept();
      // 建立好連線後,從socket中獲取輸入流,並建立緩衝區進行讀取
      InputStream inputStream = socket.getInputStream();
      byte[] bytes = new byte[1024];
      int len;
      StringBuilder sb = new StringBuilder();
      while ((len = inputStream.read(bytes)) != -1) {
        // 注意指定編碼格式,傳送方和接收方一定要統一,建議使用UTF-8
        sb.append(new String(bytes, 0, len, "UTF-8"));
      }
      System.out.println("get message from client: " + sb);
      inputStream.close();
      socket.close();
    }
    
  }
}

  這種一般也是新手寫法,但是能夠迴圈處理多個Socket請求,不過當一個請求的處理比較耗時的時候,後面的請求將被阻塞,所以一般都是用多執行緒的方式來處理Socket,即每有一個Socket請求的時候,就建立一個執行緒來處理它。

  不過在實際生產中,建立的執行緒會交給執行緒池來處理,為了:

  • 執行緒複用,建立執行緒耗時,回收執行緒慢
  • 防止短時間內高併發,指定執行緒池大小,超過數量將等待,方式短時間建立大量執行緒導致資源耗盡,服務掛掉
package yiwangzhibujian.threadserver;

import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SocketServer {
  public static void main(String args[]) throws Exception {
    // 監聽指定的埠
    int port = 55533;
    ServerSocket server = new ServerSocket(port);
    // server將一直等待連線的到來
    System.out.println("server將一直等待連線的到來");

    //如果使用多執行緒,那就需要執行緒池,防止併發過高時建立過多執行緒耗盡資源
    ExecutorService threadPool = Executors.newFixedThreadPool(100);
    
    while (true) {
      Socket socket = server.accept();
      
      Runnable runnable=()->{
        try {
          // 建立好連線後,從socket中獲取輸入流,並建立緩衝區進行讀取
          InputStream inputStream = socket.getInputStream();
          byte[] bytes = new byte[1024];
          int len;
          StringBuilder sb = new StringBuilder();
          while ((len = inputStream.read(bytes)) != -1) {
            // 注意指定編碼格式,傳送方和接收方一定要統一,建議使用UTF-8
            sb.append(new String(bytes, 0, len, "UTF-8"));
          }
          System.out.println("get message from client: " + sb);
          inputStream.close();
          socket.close();
        } catch (Exception e) {
          e.printStackTrace();
        }
      };
      threadPool.submit(runnable);
    }

  }
}

  使用執行緒池的方式,算是一種成熟的方式。可以應用在生產中。

3.2 服務端其他屬性

  ServerSocket有以下3個屬性。

  • SO_TIMEOUT:表示等待客戶連線的超時時間。一般不設定,會持續等待。
  • SO_REUSEADDR:表示是否允許重用伺服器所繫結的地址。一般不設定,經我的測試沒必要,下面會進行詳解。
  • SO_RCVBUF:表示接收資料的緩衝區的大小。一般不設定,用系統預設就可以了。

  具體詳細的解釋可以參照下面。

3.3 效能再次提升

  當現在的效能還不能滿足需求的時候,就需要考慮使用NIO,這不是本篇的內容,後續會貼出。

四、Socket的其它知識

  其實如果經常看有關網路程式設計的原始碼的話,就會發現Socket還是有很多設定的,可以學著用,但是還是要有一些基本的瞭解比較好。下面就對Socket的Java API中涉及到的進行簡單講解。首先呢Socket有哪些可以設定的選項,其實在SocketOptions介面中已經都列出來了:

  • int TCP_NODELAY = 0x0001:對此連線禁用 Nagle 演算法。
  • int SO_BINDADDR = 0x000F:此選項為 TCP 或 UDP 套接字在 IP 地址頭中設定服務型別或流量類欄位。
  • int SO_REUSEADDR = 0x04:設定套接字的 SO_REUSEADDR。
  • int SO_BROADCAST = 0x0020:此選項啟用和禁用傳送廣播訊息的處理能力。
  • int IP_MULTICAST_IF = 0x10:設定用於傳送多播包的傳出介面。
  • int IP_MULTICAST_IF2 = 0x1f:設定用於傳送多播包的傳出介面。
  • int IP_MULTICAST_LOOP = 0x12:此選項啟用或禁用多播資料報的本地回送。
  • int IP_TOS = 0x3:此選項為 TCP 或 UDP 套接字在 IP 地址頭中設定服務型別或流量類欄位。
  • int SO_LINGER = 0x0080:指定關閉時逗留的超時值。
  • int SO_TIMEOUT = 0x1006:設定阻塞 Socket 操作的超時值: ServerSocket.accept(); SocketInputStream.read(); DatagramSocket.receive(); 選項必須在進入阻塞操作前設定才能生效。
  • int SO_SNDBUF = 0x1001:設定傳出網路 I/O 的平臺所使用的基礎緩衝區大小的提示。
  • int SO_RCVBUF = 0x1002:設定傳入網路 I/O 的平臺所使用基礎緩衝區的大小的提示。
  • int SO_KEEPALIVE = 0x0008:為 TCP 套接字設定 keepalive 選項時
  • int SO_OOBINLINE = 0x1003:置 OOBINLINE 選項時,在套接字上接收的所有 TCP 緊急資料都將通過套接字輸入流接收。

  上面只是簡單介紹了下(來源Java API),下面有對其中的某些的詳細講解,沒講到的後續如果用到會補上。

4.1 客戶端繫結埠

  服務端繫結埠是可以理解的,因為要監聽指定的埠,但是客戶端為什麼要繫結埠,說實話我覺得這麼做的人有點2,或許有的網路安全策略配置了埠訪出,使使用者只能使用指定的埠,那麼這樣的配置也是挺2的,直接說就可以不要留面子。

  當然首先要理解的是,如果沒有指定埠的話,Socket會自動選取一個可以用的埠,不用瞎操心的。

  但是你非得指定一個埠也是可以的,做法如下,這時候就不能用Socket的構造方法了,要一步一步來:

// 要連線的服務端IP地址和埠
String host = "localhost"; 
int port = 55533;
// 與服務端建立連線
Socket socket = new Socket();
socket.bind(new InetSocketAddress(55534));
socket.connect(new InetSocketAddress(host, port));

  這樣做就可以了,但是當這個程式執行完成以後,再次執行就會報,端口占用異常:

java.net.BindException: Address already in use: connect

  明明上一個Socket已經關閉了,為什麼再次使用還會說已經被佔用了呢?如果你是用netstat 命令來檢視埠的使用情況:

netstat -n|findstr "55533"
TCP 127.0.0.1:55534 127.0.0.1:55533 TIME_WAIT

  就會發現埠的使用狀態為TIME_WAIT,說到這你需要有一點TCP連線的基本常識,建議看《》,這是其中的一點摘抄筆記,或許對理解有一些幫助。

  簡單來說,當連線主動關閉後,埠狀態變為TIME_WAIT,其他程式依然不能使用這個埠,防止服務端因為超時重新發送的確認連線斷開對新連線的程式造成影響。

  TIME_WAIT的時間一般有底層決定,一般是2分鐘,還有1分鐘和30秒的。

  所以,客戶端不要繫結埠,不要繫結埠,不要繫結埠。

4.2 讀超時SO_TIMEOUT

  讀超時這個屬性還是比較重要的,當Socket優化到最後的時候,往往一個Socket連線會一直用下去,那麼當一端因為異常導致連線沒有關閉,另一方是不應該持續等下去的,所以應該設定一個讀取的超時時間,當超過指定的時間後,還沒有讀到資料,就假定這個連線無用,然後拋異常,捕獲異常後關閉連線就可以了,呼叫方法為:

public void setSoTimeout(int timeout)
throws SocketException

  timeout - 指定的以毫秒為單位的超時值。設定0為持續等待下去。建議根據網路環境和實際生產環境選擇。

  這個選項設定的值將對以下操作有影響:

  • ServerSocket.accept()
  • SocketInputStream.read()
  • DatagramSocket.receive()

4.3 設定連線超時

  這個連線超時和上面說的讀超時不一樣,讀超時是在建立連線以後,讀資料時使用的,而連線超時是在進行連線的時候,等待的時間。

4.4 判斷Socket是否可用

  當需要判斷一個Socket是否可用的時候,不能簡簡單單判斷是否為null,是否關閉,下面給出一個比較全面的判斷Socket是否可用的表示式,這是根據Socket自身的一些狀態進行判斷的,它的狀態有:

  • bound:是否繫結
  • closed:是否關閉
  • connected:是否連線
  • shutIn:是否關閉輸入流
  • shutOut:是否關閉輸出流
socket != null && socket.isBound() && !socket.isClosed() && socket.isConnected()&& !socket.isInputShutdown() && !socket.isOutputShutdown()

  建議如此使用,但這只是第一步,保證Socket自身的狀態是可用的,但是當連線正常建立後,上面的屬性如果不呼叫本方相應的方法是不會改變的,也就是說如果網路斷開、伺服器主動斷開,Java底層是不會檢測到連線斷開並改變Socket的狀態,所以,真實的檢測連線狀態還是得通過額外的手段,有兩種方式。

4.4.1 自定義心跳包

  雙方需要約定,什麼樣的訊息屬於心跳包,什麼樣的訊息屬於正常訊息,假設你看了上面的章節現在說就容易理解了,我們定義前兩個位元組為訊息的長度,那麼我們就可以定義第3個位元組為訊息的屬性,可以指定一位為訊息的型別,1為心跳,0為正常訊息。那麼要做的有如下:

  • 客戶端傳送心跳包
  • 服務端獲取訊息判斷是否是心跳包,若是丟棄
  • 當客戶端傳送心跳包失敗時,就可以斷定連線不可用

  具體的編碼不再貼出,自己實現即可。

4.4.2 通過傳送緊急資料

  Socket自帶一種模式,那就是傳送緊急資料,這有一個前提,那就是服務端的OOBINLINE不能設定為true,它的預設值是false。

  OOBINLINE的true和false影響了什麼:

  • 對客戶端沒有影響
  • 對服務端,如果設定為true,那麼服務端將會捕獲緊急資料,這會對接收資料造成混淆,需要額外判斷

  傳送緊急資料通過呼叫Socket的方法:

socket.sendUrgentData(0);

  傳送資料任意即可,因為OOBINLINE為false的時候,服務端會丟棄掉緊急資料。

  當傳送緊急資料報錯以後,我們就會知道連線不通了。

4.4.3 真的需要判斷連線斷開嗎

  通過上面的兩種方式已經可以判斷出連線是否可用,然後我們就可以進行後續操作,可是請大家認真考慮下面的問題:

  1. 傳送心跳成功時確認連線可用,當再次傳送訊息時能保證連線還可用嗎?即便中間的間隔很短
  2. 如果連線不可用了,你會怎麼做?重新建立連線再次傳送資料?還是說單單只是記錄日誌?
  3. 如果你打算重新建立連線,那麼傳送心跳包的意義何在?為何不在傳送異常時再新建連線?

  如果你認真考慮了上面的問題,那麼你就會覺得傳送心跳包完全是沒有必要的操作,通過傳送心跳包來判斷連線是否可用是通過捕獲異常來判斷的。那麼我們完全可以在傳送訊息報出IO異常的時候,在異常中重新發送一次即可,這兩種方式的編碼有什麼不同呢,下面寫一寫虛擬碼。

  提前檢測連線是否可用:

//有一個連線中的socket
Socket socket=...
//要傳送的資料
String data="";
try{
    //傳送心跳包或者緊急資料,來檢測連線的可用性
}catch (Excetption e){
    //列印日誌,並重連Socket
    socket=new Socket(host,port);
}
socket.write(data);

  直接傳送資料,出異常後重新連線再次傳送:

//有一個連線中的socket
Socket socket=...
//要傳送的資料
String data="";
try{
    socket.write(data);
}catch (Excetption e){
    //列印日誌,並重連Socket
    socket=new Socket(host,port);
    socket.write(data);
}

  通過比較可以發現兩種方式的特點,現在簡單介紹下:

  • 兩種方式均可實現連線斷開重新連線併發送
  • 提前檢測,再每次傳送訊息的時候都要檢測,影響效率,佔用頻寬

  希望大家認真考慮,做出自己的選擇。

4.5 設定埠重用SO_REUSEADDR 

  首先,建立Socket時,預設是禁止的,設定true有什麼作用呢,Java API中是這麼介紹的:

關閉 TCP 連線時,該連線可能在關閉後的一段時間內保持超時狀態(通常稱為 TIME_WAIT 狀態或 2MSL 等待狀態)。對於使用已知套接字地址或埠的應用程式而言,如果存在處於超時狀態的連線(包括地址和埠),可能不能將套接字繫結到所需的 SocketAddress 上。

使用 bind(SocketAddress) 繫結套接字前啟用 SO_REUSEADDR 允許在上一個連線處於超時狀態時繫結套接字。

  一般是用在繫結埠的時候使用,但是經過我的測試建議如下:

  • 服務端繫結埠後,關閉服務端,重新啟動後不會提示端口占用
  • 客戶端繫結埠後,關閉,即便設定ReuseAddress為true,即便能繫結埠,連線的時候還是會報端口占用異常

  綜上所述,不建議繫結埠,也沒必要設定ReuseAddress,當然ReuseAddress的底層還是和硬體有關係的,或許在你的機器上測試結果和我不一樣,若是如此和平臺相關性差異這麼大配置更是不建議使用了。

4.6 設定關閉等待SO_LINGER

  Java API的介紹是:啟用/禁用具有指定逗留時間(以秒為單位)的 SO_LINGER。最大超時值是特定於平臺的。 該設定僅影響套接字關閉。 

  大家都是這麼說的,當呼叫Socket的close方法後,沒有傳送的資料將不再發送,設定這個值的話,Socket會等待指定的時間傳送完資料包。說實話,經過我簡單的測試,對於一般資料量來說,幾十K左右,即便直接關閉Socket的連線,服務端也是可以收到資料的。

  所以對於一般應用沒必要設定這個值,當資料量傳送過大丟擲異常時,再來設定這個值也不晚。那麼到達逗留超時值時,套接字將通過 TCP RST 強制性 關閉。啟用超時值為零的選項將立即強制關閉。如果指定的超時值大於 65,535,則其將被減少到 65,535。 

4.7 設定傳送延遲策略TCP_NODELAY

  一般來說當客戶端想伺服器傳送資料的時候,會根據當前資料量來決定是否傳送,如果資料量過小,那麼系統將會根據Nagle 演算法(暫時還沒研究),來決定傳送包的合併,也就是說傳送會有延遲,這在有時候是致命的,比如說對實時性要求很高的訊息傳送,線上對戰遊戲等,即便資料量很小也要求立即傳送,如果稍有延遲就會感覺到卡頓,預設情況下Nagle 演算法是開啟的,所以如果不打算有延遲,最好關閉它。這樣一旦有資料將會立即傳送而不會寫入緩衝區。

  但是對延遲要求不是特別高下還是可以使用的,還是可以提升網路傳輸效率的。

4.8 設定輸出輸出緩衝區大小SO_RCVBUF/SO_SNDBUF

  • SO_SNDBUF:傳送緩衝
  • SO_RCVBUF:接收緩衝

  預設都是8K,如果有需要可以修改,通過相應的set方法。不建議修改的太小,設定太小資料傳輸將過於頻繁。太大了將會造成訊息停留。

  不過我對這個經過測試後有以下結論:

  • 當資料填滿緩衝區時,一定會發送
  • 當資料沒有填滿緩衝區時也會發送,這個演算法還是上面說的Nagle 演算法

4.9 設定保持連線存活SO_KEEPALIVE

  雖然說當設定連線連線的讀超時為0,即無限等待時,Socket不會被主動關閉,但是總會有莫名其妙的軟體來檢測你的連線是否有資料傳送,長時間沒有資料傳輸的連線會被它們關閉掉。

  因此通過設定這個選項為true,可以有如下效果:當2個小時(具體的實現而不同)內在任意方向上都沒有跨越套接字交換資料,則 TCP 會自動傳送一個保持存活的訊息到對面。將會有以下三種響應:

  1. 返回期望的ACK。那麼不通知應用程式(因為一切正常),2 小時的不活動時間過後,TCP 將傳送另一個探頭。
  2. 對面返回RST,表明對面掛了,但是又好了,Socket依然要關閉
  3. 沒有響應,說明對面掛了,這時候關閉Socket

  所以對於構建長時間連線的Socket還是配置上SO_KEEPALIVE比較好。

4.10 異常:java.net.SocketException: Connection reset by peer

  這個異常的含義是,我正在寫資料的時候,你把連線給關閉了。這個異常在一般正常的編碼是不會出現這個異常的,因為使用者通常會判斷是否讀到流的末尾了,讀到末尾才會進行關閉操作,如果出現這個異常,那就檢查一下判斷是否讀到流的末尾邏輯是否正確。

五、關於Socket的理解

5.1 Socket和TCP/IP

  最近在看《TCP/IP詳解 卷1:協議》,關於TCP/IP我覺得講解的非常詳細,我做了點摘抄,可以大致看看,非常建議大家閱讀下這本書。通常TCP/IP分為四層:

  也就是說Socket實際上是歸屬於應用層,使用的事運輸層的TCP,使用SocketServer監聽的埠,也是可以被Telnet連線的。可以看下面兩行程式碼:

ServerSocket server = new ServerSocket(port);
Socket socket = server.accept();

  在什麼情況獲取到這個Socket呢,通過理論加測試,結論是在三次握手操作後,系統才會將這個連線交給應用層,ServerSocket 才知道有一個連線過來了。那麼系統當接收到一個TCP連線請求後,如果上層還沒有接受它(假如SocketServer迴圈處理Socket,一次一個),那麼系統將快取這個連線請求,既然是快取那麼就是有限度的,書上介紹的是快取3個,但是經過我的本機測試是50個,也就是說,系統將會為應用層的Socket快取50和TCP連線(這是和系統底層有關係的),當超過指定數量後,系統將會拒絕連線。

  假如快取的TCP連線請求傳送來資料,那麼系統也會快取這些資料,等待SocketServer獲得這個連線的時候一併交給它,這個會在後期學習NIO進行詳解。

  換句話說,系統接收TCP連線請求放入快取佇列,而SocketServer從快取佇列獲取Socket。

  而上面例子中的為了讓服務端知道傳送完訊息的,關閉輸出流的操作:

socket.shutdownOutput();

  其實是對應著四次揮手的第一次:

  也就是上面說的主動關閉,FIN_WAIT_1,這樣服務端就能得知客戶端傳送完訊息,此時服務端可以選擇關閉連線,也可以選擇傳送資料後關閉連線:

  這就是TCP所說的半關閉。其實很多知識都是想通的,多學點基礎知識還是有必要的。

5.2 Socket和RMI

  RMI基礎知識就不多介紹了(後續會寫,敬請期待),現在假定你對RMI有所瞭解,那麼一般就會對這兩種技術有所比較。或者說在應用的時候就會想用那種技術比較好。

  RMI全稱:Remote Method Invocation-遠端方法呼叫,通過名字其實就能對這種技術有個初步的瞭解。現在我就簡單說說我對這兩種技術的想法。

  這個待寫,等我寫完RMI部落格的時候補上,那時候會更細緻的瞭解下。

5.3 DatagramSocket與Socket

  這一段涉及到UDP,依然和上面一樣,後續會補上。

5.4 拆包和黏包

  使用Socket通訊的時候,或多或少都聽過拆包和黏包,如果沒聽過而去貿然程式設計那麼偶爾就會碰到一些莫名其妙的問題,所有有這方面的知識還是比較重要的,至少知道怎麼發生,怎麼防範。

  現在先簡單說明下拆包和黏包的原因:

  • 拆包:當一次傳送(Socket)的資料量過大,而底層(TCP/IP)不支援一次傳送那麼大的資料量,則會發生拆包現象。
  • 黏包:當在短時間內傳送(Socket)很多資料量小的包時,底層(TCP/IP)會根據一定的演算法(指Nagle)把一些包合作為一個包傳送。

  首先可以明確的是,大部分情況下我們是不希望發生拆包和黏包的(如果希望發生,什麼都去做即可),那麼怎麼去避免呢,下面進行詳解?

5.4.1 黏包

  首先我們應該正確看待黏包,黏包實際上是對網路通訊的一種優化,假如說上層只發送一個位元組資料,而底層卻傳送了41個位元組,其中20位元組的I P首部、 20位元組的T C P首部和1個位元組的資料,而且傳送完後還需要確認,這麼做浪費了頻寬,量大時還會造成網路擁堵。當然它還是有一定的缺點的,就是因為它會合並一些包會導致資料不能立即傳送出去,會造成延遲,如果能接受(一般延遲為200ms),那麼還是不建議關閉這種優化,如果因為黏包會造成業務上的錯誤,那麼請改正你的服務端讀取演算法(協議),因為即便不發生黏包,在服務端快取區也可能會合並起來一起提交給上層,推薦使用長度+型別+資料模式。

  如果不希望發生黏包,那麼通過禁用TCP_NODELAY即可,Socket中也有相應的方法:

void setTcpNoDelay(boolean on) 

  通過設定為true即可防止在傳送的時候黏包,但是當傳送的速率大於讀取的速率時,在服務端也會發生黏包,即因服務端讀取過慢,導致它一次可能讀取多個包。

5.4.2 拆包

  這個問題應該引起重視,在TCP/IP詳解中說過:最大報文段長度(MSS)表示TCP傳往另一端的最大塊資料的長度。當一個連線建立時,連線的雙方都要通告各自的 MSS。客戶端會盡量滿足服務端的要求且不能大於服務端的MSS值,當沒有協商時,會使用值536位元組。雖然看起來MSS值越大越好,但是考慮到一些其他情況,這個值還是不太好確定,具體詳見《TCP/IP詳解 卷1:協議》。

  如何應對拆包,其實在上面2.3節已經介紹過了,那就是如何表明傳送完一條訊息了,對於已知資料長度的模式,可以構造相同大小的陣列,迴圈讀取,示例程式碼如下:

int length=1024;//這個是讀取的到資料長度,現假定1024
byte[] data=new byte[1024];
int readLength=0;
while(readLength<length){
    int read = inputStream.read(data, readLength, length-readLength);
    readLength+=read;
}

  這樣當迴圈結束後,就能讀取到完整的一條資料,而不需要考慮拆包了。