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