Java 網路程式設計 —— Socket 詳解

2023-04-30 18:01:00

構造 Socket

在【使用者端/伺服器端】的通訊模式中,使用者端需要主動構造與伺服器連線的 Socket,構造方法有以下幾種過載形式:

Socket()
Socket(InetAddress address, int port) throws UnknownHostException,IOException
Socket(InetAddress address, int port, InetAddress localAddr, int localPort) throws IOException
Socket(String host, int port) throws UnknownHostException,IOException
Socket(String host, int port, InetAddress localAddr, int localPort) throws IOException
Socket(Proxy proxy)

除了第一個不帶引數的構造方法,其他構造方法都會試圖建立與伺服器的連線,一旦連線成功,就返回 Socket 物件,否則丟擲異常

1. 設定等待建立連線的超時時間

當用戶端的 Socket 構造方法請求與伺服器連線時,可能要等待一段時間。在預設情況下,Socket 構造方法會一直等待下去,直到連線成功,或者出現異常。Socket 構造方法請求連線時,受底層網路的傳輸速度的影響,可能會處於長時間的等待狀態。如果希望限定等待連線的時間,就需要使用第一個不帶引數的構造方法

Socket socket = new Socket();
SocketAddress remoteAddr = new InetSocketAddress("1ocalhostn", 8000);
// 引數endpoint指定伺服器的地址,引數timeout設定的超時時間(ms)
// 如果引數timeout被設為0則表示永遠不會超時
socket.connect(remoteAddr, 60000);

以上程式碼用於連線到本地機器上的監聽 8000 埠的伺服器程式,等待連線的最長時間為一分鐘。如果在一分鐘內連線成功,則 connect() 方法順利返回,如果在一分鐘內出現某種異常則丟擲該異常,如果在一分鐘後既沒有連線成功,也沒有出現異常,那麼會丟擲 SocketTimeoutException

2. 設定伺服器的地址

除了不帶引數的構造方法,其他構造方法都需要在引數中設定伺服器的地城,包括伺服器的 IP 或主機名,以及埠

// address表示主機的IP地址
Socket(InetAddress address, int port)
// address表示主機的名字
Socket(String host, int port)

InetAddress 類表示主機的P地址,提供了一系列靜態工廠方法用於構造自身範例

// 返回本地主機的IP地址、
InetAddress addr1 = inetAddress.getLocalHost();
// 返回代表 "222.34.57」 的 IPv4 地址
InetAddress addr2 = InetAddress.getByName("222.34.5.7");
// 返同代表 」2001:DB8:2DE::E13" 的 IPv6 地址
InetAddress addr3 = InetAddress.getByName("2001:DB8:2DE::E13");
// 返回主機名為 "www.javathinker.net" 的 IP 地址
InetAddress addr4 = InetAddress.getByName ("www.javathinker.net");

3. 設定使用者端的地址

在一個 Socket 物件中既包含遠端伺服器的 IP 地址和埠資訊,也包含本地使用者端的 IP 地址和埠資訊。在預設情況下,使用者端的 IP 地址來自客戶程式所在的主機,使用者端的埠則由作業系統隨機分配。Socket 類還有兩個構造方法允許顯式地設定使用者端的 IP 地址和埠

Socket(InetAddress address, int port, InetAddress localAddr, int localPort) throws IOException
Socket(String host, int port, InetAddress localAddr, int localPort) throws IOException

如果一個主機同時屬於兩個以上的網路,它就可能擁有兩個以上 IP 地址,例如一個主機在 Internet 網路中的 IP 地址為 「222.67,1.34」,在一個區域網中的 IP 地址為 「1125.4.3",假定這個主機上的客戶程式希望和同一個局城網上的一個地址為 「112.5.4.4:8000」 的伺服器程式通訊,使用者端可按照如下方式構造 Socket 物件

InetAddress remoteAddr = InetAddress.getByName("112.5,4.45");
InetAddress localAddr = InetAddress.getByName("112.5.4.3");
//使用者端使用口2345
Socket socket = new Socket(remoteAddr, 8000, localAddr, 2345);

4. 客戶連線伺服器時可能丟擲的異常

當 Socket 的構造方法請求連線伺服器時,可能會丟擲以下異常:

  • UnknownHostException:無法識別主機的名字或 IP 地址
  • ConnectException:沒有伺服器程序監聽指定的埠,或者伺服器程序拒絕連線
  • SocketTimeoutException:等待連線超時
  • BindException:無法把Socket 物件與指定的本地 IP 地址或埠繫結

5. 使用代理伺服器

在實際應用中,有的客戶程式會通過代理伺服器來存取遠端伺服器。代理伺服器有許多功能,比如能作為防火牆進行安全防範,或者提高存取速度,或者具有存取特定遠端伺服器的許可權

String proxyIP = "myproxy.abc.oom"; // 代理伺服器地址
int proxyPort = 1080; // 代理伺服器埠
// 建立代理物件
Proxy proxy = new Proxy(Proxy.Type.SOCKS, new InetSocketAddress(proxyIP, proxyPort));
Socket socket  new Socket(proxy);
//連線到遠端伺服器
socket.connect(new InetSocketAddress("www.javathinker.net", 80));

ProxyType 類表示代理伺服器的型別,有以下可選值:

  • Proxy.Type.SOCKS:在分層的網路結構中,SOCKS 是位於對談層的代理型別
  • Proxy.Type.HTTP:在分層的網路結構中,HTTP 是位於應用層的代理型別
  • Proxy.Type.DIRECT:不使用代理,直接連線遠端伺服器

6. InetAddress 地址類的用法

InetAddress 類表示主機的IP 地址,InetAddress 類的靜態工廠方法給 getByName() 用於構造自身的範例

// 返回代表 "222.34.5.7" 的 IPv4 地址
InetAddress addr2 = InetAddress,getByName("222.34.5.7");
// 返回主機名為 "www.javathinker.net" 的 IP 地址
InetAddress addr4 = InetAddress.getByName("www.javathinker.net");

InetAddress 還提供了獲取相應的主機名的兩種方法:

  • getHostname():首先從 DNS 快取中查詢與 IP 地址匹配的主機名,如果不存在,再通過 DNS 伺服器查詢,如果找到,則返回主機名,否則返回 IP 地址
  • getCanonicalHostName():通過 DNS 伺服器查詢與 IP 地址匹配的主機名,如果找到則返回主機名,否則返問 IP 地址

以上兩種方法的區別在於 getHostname() 會先查詢 DNS 快取,減少查詢 DNS 伺服器的概率,提高查詢效能。而 getCanonicalHostName() 總是查詢 DNS 伺服器,確保獲得當前最新版本的主機名

InetAddress 類還提供了兩個測試能否從本地主機連線到特定主機的方法:

public boolean isReachable(int timeout) throws IOException
public boolean isReachable(NefworkInterface interface, int ttl, int timeout) throws IOException

如果遠端主機在引數 timeout(ms)指定的時間內做出迴應,以上方法返回true,否則返回 false,如果出現網路錯誤則丟擲 IOException。第二種方法還允許從引數指定的本地網路介面建立連線,以及 TTL(IP 封包被丟棄前允許存在的時間)

7. NetworkInterface 類的用法

NetworkInterfiace 類表示物理上的網路介面,它有兩種構造自身範例的靜態工廠方法,這兩種方法都宣告丟擲 SocketException

// 引數 name 指定網路介面的名字,如果不存在與名字對應的網路介面,就返回 null
getByName(String name)
// 引數 address 指定網路介面的 IP 地址,如果不存在與 IP 地址對應的網路介面,就返回 null
getByInetAddress(InetAddress address)

NetworkInterface 類的以下方法用於獲取網路介面的資訊

// 返回網路介面的名字
public String getName()
// 返回和網路介面繫結的所有 IP 地址,返回值為 Enumeration 型別,裡面存放了表示 IP 地址的 InetAddress 物件
public Enumeration getInetAddresses()

獲取 Socket 的資訊

在一個 Socket 物件中同時包含了遠端伺服器的 IP 地址和埠資訊,以及客戶原生的 IP 地址和埠資訊。此外,從 Socket 物件中還可以獲得輸出流和輸入流,分別用於向伺服器傳送資料,以及接收從伺服器端發來的資料

以下方法用於獲取 Socket 的有關資訊

// 獲得遠端被連線程序的IP地址
getInetAddress()
// 獲得遠端被連線程序的埠
getPort()
// 獲得原生的IP地址
getLocalAddress()
// 獲得原生的埠
getLocalPort()
// 獲得輸入流,如果Socket還沒有連線,或者已經關團,或者已經通過shutdownInput()方法關閉輸入流,那麼此方法會丟擲IOException
getInputStream()
// 獲得輸出流,如果Socket還沒有連線,或者已經關閉,或者已經通過shutdownOutput()方法關閉輸出流,那麼此方法會丟擲 IOException
getOutputStream()

關閉 Socket

當客戶與伺服器的通訊結束時,應該及時關閉 Socket,以釋放 Socket 佔用的包括埠在內的各種資源。Socket 的 close() 方法負責關閉 Socket,如果一個 socket 物件被關閉,就不能再通過它的輸入流和輸出流進行 IO 操作,否則會導致 IOException

Socket 類提供了三個狀態測試方法

// 如果Socket沒有關閉,則返回false,否則返回true
isClosed()
// 如果Socket曾經連線到遠端主機,不管當前是否已經關閉,都返回true。如果Socket從未連線到遠端主機,就返回false
isConnected()
// 如果Socket已經與一個本地埠繫結,則返回true,否則返回false
isBound()

如果要判斷一個 Socket 物件當前是否處於連線狀態,可採用以下方式

String isConnected = socket.isConnected() && !socket.isClosed();

半關閉 Socket

程序 A 與程序 B 通過 Socket 通訊,假定程序 A 輸出資料,程序 B 讀入資料,程序 A 如何告訴程序 B 所有資料已經輸出完畢呢?有幾種處理辦法:

  • 如果程序 A 與程序 B 交換的是字元流,並且都一行一行地讀寫資料,那麼可以事先約定以一個特殊的標誌作為結束標誌,例如以字串 「bye」 作為結束標誌,當程序 A 向程序 B 傳送一行字串 「bye」,程序 B 讀到這一行資料後,就停止讀取資料

  • 程序 A 先傳送一個訊息,告訴程序 B 所傳送的正文的長度,然後傳送正文。程序 B 先獲知程序 A 將傳送的正文的長度,接下來只要讀取該長度的字元或者位元組,就停止讀取資料

  • 程序 A 發完所有資料後,關閉 Socket,當程序 B 讀入了程序 A 傳送的所有資料後,再次執行輸入流的 read() 方法時,該方法返回 「-1」,如果執行 BufferedReader 的 readLine() 方法,那麼該方法返回 null

    ByteArrayOutputstream bufferenew = ByteArrayOutputstream();
    byte[] buff = new byte[1024);
    int len = -1;
    while((len = socketIn.read(buff)) != -1) {
        buffer.write(buff, 0, len);   
    }
    
  • 當呼叫 Socke t的 close() 方法關閉 Socket 後,它的輸出流和輸入流也都被關閉。有的時候,可能僅僅希望關閉輸出流或輸入流之一,此時可以採用 Socket 類提供的半關閉方法

    shutdownInput() // 關閉輸入流
    shutdownOutput() // 關團輸出流
    

    假定程序 A 執行以下程式碼,先向程序 B 傳送一個字串,等到程序 B 接收到這個字串後,程序 A 再呼叫 Socket 的 shutdownOutput() 方法關閉輸出流,接下來程序 A 不允許再輸出資料,但是仍可以通過輸入流讀入資料

    // 發出請求資訊
    String data = ...;
    OutputStream socketOut = socket.getOutputStream();
    socketOut.write(data.getBytes());
    socketOut.flush();
    // 讀取響應
    InputStream socketIn = socket.getInputStream();
    if(伺服器端返回提示資訊,表明已經接收到使用者端的所有請求資料)
        socket.shutdownOutput(); //關閉輸出流
    //繼續通過socketIn讀取資料
    ...
    

    值得注意的是,先後呼叫 Socket 的 shutdownInput() 和 shutdownOutput() 方法,僅僅關閉了輸入流和輸出流,並不等價於呼叫 Socket 的 close() 方法。在通訊結束後,仍然要呼叫 Socket 的 close() 方法,因為只有該方法才會釋放 Socket 佔用的資源,比如佔用的本地埠等

    Socket 類還提供了兩種狀態測試方法,用來判斷輸入流和輸出流是否關閉

    public boolean isInputShutdown() // 如果輸入流關閉,則返回true,否則返回false
    public boolean isOutputShutdown() // 如果輸出流關閉,則返回true,否則返回false
    

設定 Socket 的選項

1. TCP_NODELAY

表示立即傳送資料。在預設情況,下傳送資料採用 Negale 演演算法,傳送方傳送的資料不會立刻被髮出,而是先放在緩衝區內,等緩衝區滿了再發出。傳送完一批資料後,會等待接收方對這批資料的迴應,然後傳送下一批資料。此演演算法法適用於傳送方需要傳送大批次資料並且接收方會及時做出迴應的場合,這種演演算法通過減少傳輸資料的次數來提高通訊效率

如巢傳送方持續地傳送小批次的資料。並且接收方不一定會立即傳送響應資料,那麼 Negale 演演算法會使傳送方執行得很慢,對於GU程式,比如網路遊戲程式(伺服器需要實時跟蹤使用者端滑鼠的移動),這個問題尤其突出

TCP_NODEALY 的預設值為 false,表示採用 Negale 演演算法,如果呼叫 setTcpNoDelay(true) 方法,就會關閉 Socket 的緩衝,確保資料被及時傳送

if(!socket.getTcpNoDelay())
    socket.setTcpNoDelay(true);

2. SO_RESUSEADDR

表示是否允許重用 Socket 所繫結的本地地址。當接收方通過 Socket 的 close() 方法關閉 Socket 時,如果網路上還有傳送到這個 Socket 的資料,那麼底層的 Socket 不會立刻釋放本地埠,而是會等待一段時間,確保接收到了網路上傳送過來的延遲資料,再釋放埠。Socket 接收到延遲資料後,不會對這些資料做任何處理。Socket 接收延遲資料的目的是,確保這些資料不會被其他碰巧繫結到同樣埠的新程序接收到

客戶程式一般採用隨機埠,因此出現兩個客戶程式繫結到同樣埠的可能性不大。許多伺服器程式都使用固定的埠。當伺服器程式被關閉後,有可能它的埠還會被佔用一段時間,如果此時立刻在同一臺主機上重啟伺服器程式,由於埠已經被佔用,使得服務感程式無法系結到該埠,導致啟動失敗

為了確保當一個程序關閉了 Socket 後,即便它還沒釋放埠,同一臺主機上的其他進要也可以立刻重用該埠,可以呼叫 Socke 的 setResuseAddress(ture) 方法

if(!socket.getResuseAddress())
    socket.setResuseAddress(true);

值得注意的是 socket.setResuseAddress(true) 方法必須在 Socket 還沒有被繫結到一個本地埠之前呼叫,否則執行無效

3. SO_TIMEOUT

表示接收資料時的等待超時時間。當通過 Socket 的輸入流讀資料時,如果還沒有資料,就會等待。Socket 類的SO_TIMEOUT 選項用於設定接收資料的等待超時時間,單位為 ms,它的預設值為0,表示會無限等待,永遠不會超時

以下程式碼把接收資料的等待超時時間設為三分鐘

if(socket.getTimeout() == 0)
    socket.setTimeout(60000 * 3);

Socket 的 setTimeout() 方法必須在接收資料之前執行才有效

4. SO_LINGER

表示與執行 Socket 的 close() 方法時,是否立即關閉底層的 Socket。在預設情況下執行 Socket 的 close() 方法,該方法會立即返回,但底層的 Socket 實際上並不立即關閉,它會延遲一段時間,直到傳送完所有剩餘的資料,才會真正關閉 Socket

如果執行以下方法

socket.setSoLinger(true,0);

那麼執行 Socket 的 close() 方法,該方法也會立即返回而且底層的 Socket 也會立即關閉,所有未傳送完的資料被丟棄

如果執行以下方法

socket.setSoLinger(true,3600);

那麼執行 Socket 的 close() 方法,該方法不會立即返回,而是進入阻塞狀態,同時,底層的 Socket 會嘗試傳送剩餘的資料。只有滿足以下兩個條件之一,close() 方法才返回:

  • 底層的 Socket 已經傳送完所有的剩餘資料
  • 儘管底層的 Socket 還沒有傳送完所有的剩餘資料,但己經阻塞了 3600s,此時 close() 也會返回,未傳送的資料被丟棄

5. SO_RCVBUF

表示接收資料的緩衝區的大小。一般說來,傳輸大的連續的資料塊,比如基於 HTTP 或 FTP 的通訊,可以使用較大的緩衝區,這可以減少傳輸資料的次數,提高傳輸資料的效率。而對於互動式的通訊方式,比如 Telnet 和網路遊戲,則應該採用小的緩衝區,確保小批次的資料能及時傳送給對方

6. SO_SNDBUF

表示傳送資料的緩衝區的大小

7. SO_KEEPALIVE

表示對於長時間處於空閒狀態的 Socket,是否要自動把它關團。當 SO_KEEPALIVE 選項為 tue 時,表示底層的 TCP 實現會監視該連線是否有效連線處於空閒狀態,即連線的兩端沒有互相傳送資料超過了 2 小時,原生的 TCP 實現傳送一個封包給遠端的 Socket,如果遠端 Socke 沒有返回響應,TCP 實現就會持續嘗試傳送 11 分鐘,直到接收到響應為止。如果在 12 分鐘內未收到響應,TCP 實現就會自動關閉本地 Socket,斷開連線

SO_KEEPALIVE 選項的預設值為 false,表示 TCP 不會監視連線是否有效,不活動的使用者端可能會永久存在下去,而不會注意到伺服器已經崩潰

8. IP 服務型別選項

當用戶通過郵局傳送普通訊、掛號信或者快件時,實際上選擇了郵局提供的不同的服務。傳送普通訊的價格最低,但傳送速度慢,並且可靠性沒有保證。傳送掛號信的價格稚高,但可靠性有保證。傳送快件的價格最高,傳送速度最快,並且可靠性有保證

在 Internet 上傳輸資料也分為不同的服務型別,它們有不同的定價。使用者可以根據自己的需求,選擇不同的服務型別。例如傳送視訊需要較高的頻寬,快速到達目的地,以保證接收方看到連續的畫面,而傳送電子郵件可以使用較低的頻寬,延遲幾個小時到達目的地也沒關係

IP 規定了一些服務型別,用來定性地描述服務的質量,舉例如下:

  • 低成本:傳送成本低
  • 高可靠性:保證把資料可靠地送達目的地
  • 最高吞吐量:一次可以接收或傳送大批次的資料
  • 最小延遲:傳輸資料的速度快,把資料快速送達目的地

這些服務型別還可以進行組合,例如,可以同時要求獲得高可靠性和最小延遲。服務型別儲存在 IP 封包頭部的名為 IP_TOS 的 8 位欄位中,Socket 類中提供了設定和讀取服務型別的方法

// 設定服務型別
public void setTrafficClass(int trafficClass) throws SocketException
// 讀取服務型別
public int getTrafficClass() throws SocketException

服務型別用 1 位元組來表示,取值範圍是 0 到 255 之間的整數。這個服務型別資料也會被複制到 TCP 封包頭部的 8 位欄位中。,在目前的網路協定中,對這個表示服務型別的位元組又做了進一步的細分:

  • 高六位:表示 DSCP 值,即表示不同的服務型別程式碼號。DSCP 允許最多有 64 種服務型別
  • 低兩位:表示 ECN 值,即顯式擁塞通知資訊

64 個 DSCP 值到底表示什麼含義,這是由具體的網路和路由器決定的。下面是比較常見的 DCSP 值:

  • 預設服務型別:000000

  • 加速轉發型別:101110,特點是低損耗、低延遲、低抖動

  • 保證轉發型別:共 12 個取值,保證以指定速率傳送,見下表

    型別 第1類(最低轉發優先) 第2類 第3類 第4類(最高轉發優先)
    低丟包率 001010 010010 011010 100010
    中丟包率 001100 010100 011100 100100
    高丟包率 001110 010110 011110 100110

    其中第 1 類有最低轉發優先順序,第 4 類有最高轉發優先順序。也就是說,當網路出現阻塞時,第 4 類的封包被優先轉發。每一類又包含了 3 個取值,其中低丟包率的服務型別丟棄封包的概率小,而高丟包率的服務型別丟棄封包的概率大

加速轉發型別比其他服務型別有更高的優先順序,例如以下程式碼使得 Socket 採用加速轉發型別來收發資料:

Socket socket = new Socket("www.javathinker.net", 80);
// 0xB8 對應二進位制資料 10111000
// 低兩位表示顯式擁塞通知,取值為 00
socket.setTrafficClass(0xB8);

值得注意的是,DCSP 值僅僅為底層的網路實現提供一個參考,有些底層 Socket 實現會忽略 DCSP 值,對它不進行任何處理

9. 設定連線時間、延遲和頻寬的相對重要性

從 JDK1.5 開始,為 Socket 類提供了一個 setPerformancePreferences() 方法

public vold setPerformancePreferences (int connectionTime, int latency, int bandwidth)

以上方法的 3 個參數列示網路傳輸資料的 3 項指標:

  • connectionTime:表示用最少時間建立連線
  • latency:表示最小廷遲
  • bandwidth:表示最高頻寬

setPerformancePreferences() 方法被用來設定這 3 項指標之間的相對要性。可以為這些引數賦予任意的整數。這些整數之間的相對大小就決定了相應引數的相對重要性。例如,如果引數 connectionTime 為 2,引數 latency 為 1,而引數 bandwidth 為 3,就表示最高頻寬最重要,其次是最少連線時間,最後是最小延遲

值得注意的是 setPerformancePreferences() 方法所做的設定僅僅為底層的網路實現提供一個參考,有些底層 Socket 實現會忽略這一設定,對它不進行任何處理