天天看點

ServerSocket 用法詳解

在客戶/伺服器通信模式中,伺服器端需要建立監聽特定端口的ServerSocket,ServerSocket負責接收客戶連接配接請求。本章首先介紹ServerSocket類的各個構造方法,以及成員方法的用法,接着介紹伺服器如何用多線程來處理與多個客戶的通信任務。

本章提供線程池的一種實作方式。線程池包括一個工作隊列和若幹工作線程。伺服器程式向工作隊列中加入與客戶通信的任務,工作線程不斷從工作隊列中取出任務并執行它。本章還介紹了java.util.concurrent包中的線程池類的用法,在伺服器程式中可以直接使用它們。

3.1  構造ServerSocket

ServerSocket的構造方法有以下幾種重載形式:

l  ServerSocket()throws IOException

l  ServerSocket(int port) throws IOException

l  ServerSocket(int port, int backlog) throws IOException

l  ServerSocket(int port, int backlog, InetAddress bindAddr) throws IOException   

在以上構造方法中,參數port指定伺服器要綁定的端口(伺服器要監聽的端口),參數backlog指定客戶連接配接請求隊列的長度,參數bindAddr指定伺服器要綁定的IP位址。

3.1.1  綁定端口

除了第一個不帶參數的構造方法以外,其他構造方法都會使伺服器與特定端口綁定,該端口由參數port指定。例如,以下代碼建立了一個與80端口綁定的伺服器:

ServerSocket serverSocket=new ServerSocket(80);

如果運作時無法綁定到80端口,以上代碼會抛出IOException,更确切地說,是抛出BindException,它是IOException的子類。BindException一般是由以下原因造成的:

l  端口已經被其他伺服器程序占用;

l  在某些作業系統中,如果沒有以超級使用者的身份來運作伺服器程式,那麼作業系統不允許伺服器綁定到1~1023之間的端口。

如果把參數port設為0,表示由作業系統來為伺服器配置設定一個任意可用的端口。由作業系統配置設定的端口也稱為匿名端口。對于多數伺服器,會使用明确的端口,而不會使用匿名端口,因為客戶程式需要事先知道伺服器的端口,才能友善地通路伺服器。在某些場合,匿名端口有着特殊的用途,本章3.4節會對此作介紹。

3.1.2  設定客戶連接配接請求隊列的長度

當伺服器程序運作時,可能會同時監聽到多個客戶的連接配接請求。例如,每當一個客戶程序執行以下代碼:

Socket socket=new Socket(www.javathinker.org,80);

就意味着在遠端www.javathinker.org主機的80端口上,監聽到了一個客戶的連接配接請求。管理客戶連接配接請求的任務是由作業系統來完成的。作業系統把這些連接配接請求存儲在一個先進先出的隊列中。許多作業系統限定了隊列的最大長度,一般為50。當隊列中的連接配接請求達到了隊列的最大容量時,伺服器程序所在的主機會拒絕新的連接配接請求。隻有當伺服器程序通過ServerSocket的accept()方法從隊列中取出連接配接請求,使隊列騰出空位時,隊列才能繼續加入新的連接配接請求。

對于客戶程序,如果它發出的連接配接請求被加入到伺服器的隊列中,就意味着客戶與伺服器的連接配接建立成功,客戶程序從Socket構造方法中正常傳回。如果客戶程序發出的連接配接請求被伺服器拒絕,Socket構造方法就會抛出ConnectionException。

ServerSocket構造方法的backlog參數用來顯式設定連接配接請求隊列的長度,它将覆寫作業系統限定的隊列的最大長度。值得注意的是,在以下幾種情況中,仍然會采用作業系統限定的隊列的最大長度:

l  backlog參數的值大于作業系統限定的隊列的最大長度;

l  backlog參數的值小于或等于0;

l  在ServerSocket構造方法中沒有設定backlog參數。

以下例程3-1的Client.java和例程3-2的Server.java用來示範伺服器的連接配接請求隊列的特性。

例程3-1  Client.java

import java.net.*;

public class Client {

  public static void main(String args[])throws Exception{

    final int length=100;

    String host="localhost";

    int port=8000;

    Socket[] sockets=new Socket[length];

    for(int i=0;i<length;i++){                                //試圖建立100次連接配接

      sockets[i]=new Socket(host, port);

      System.out.println("第"+(i+1)+"次連接配接成功");

    }

    Thread.sleep(3000);

    for(int i=0;i<length;i++){

      sockets[i].close();                                        //斷開連接配接

    }

  }

}

例程3-2  Server.java

import java.io.*;

import java.net.*;

public class Server {

  private int port=8000;

  private ServerSocket serverSocket;

  public Server() throws IOException {

    serverSocket = new ServerSocket(port,3);                      //連接配接請求隊列的長度為3

    System.out.println("伺服器啟動");

  }

  public void service() {

    while (true) {

      Socket socket=null;

      try {

        socket = serverSocket.accept();                              //從連接配接請求隊列中取出一個連接配接

        System.out.println("New connection accepted " +

        socket.getInetAddress() + ":" +socket.getPort());

      }catch (IOException e) {

         e.printStackTrace();

      }finally {

         try{

           if(socket!=null)socket.close();

         }catch (IOException e) {e.printStackTrace();}

      }

    }

  }

  public static void main(String args[])throws Exception {

    Server server=new Server();

    Thread.sleep(60000*10);                                             //睡眠10分鐘

    //server.service();

  }

}

Client試圖與Server進行100次連接配接。在Server類中,把連接配接請求隊列的長度設為3。這意味着當隊列中有了3個連接配接請求時,如果Client再請求連接配接,就會被Server拒絕。下面按照以下步驟運作Server和Client程式。

(1)把Server類的main()方法中的“server.service();”這行程式代碼注釋掉。這使得伺服器與8 000端口綁定後,永遠不會執行serverSocket.accept()方法。這意味着隊列中的連接配接請求永遠不會被取出。先運作Server程式,然後再運作Client程式,Client程式的列印結果如下:

第1次連接配接成功

第2次連接配接成功

第3次連接配接成功

Exception in thread "main" java.net.ConnectException: Connection refused: connect

        at java.net.PlainSocketImpl.socketConnect(Native Method)

        at java.net.PlainSocketImpl.doConnect(Unknown Source)

        at java.net.PlainSocketImpl.connectToAddress(Unknown Source)

        at java.net.PlainSocketImpl.connect(Unknown Source)

        at java.net.SocksSocketImpl.connect(Unknown Source)

        at java.net.Socket.connect(Unknown Source)

        at java.net.Socket.connect(Unknown Source)

        at java.net.Socket.<init>(Unknown Source)

        at java.net.Socket.<init>(Unknown Source)

        at Client.main(Client.java:10)

從以上列印結果可以看出,Client與Server在成功地建立了3個連接配接後,就無法再建立其餘的連接配接了,因為伺服器的隊列已經滿了。

(2)把Server類的main()方法按如下方式修改:

public static void main(String args[])throws Exception {

    Server server=new Server();

    //Thread.sleep(60000*10);  //睡眠10分鐘

    server.service();

  }

作了以上修改,伺服器與8 000端口綁定後,就會在一個while循環中不斷執行serverSocket.accept()方法,該方法從隊列中取出連接配接請求,使得隊列能及時騰出空位,以容納新的連接配接請求。先運作Server程式,然後再運作Client程式,Client程式的列印結果如下:

第1次連接配接成功

第2次連接配接成功

第3次連接配接成功

第100次連接配接成功

從以上列印結果可以看出,此時Client能順利與Server建立100次連接配接。

3.1.3  設定綁定的IP位址

如果主機隻有一個IP位址,那麼預設情況下,伺服器程式就與該IP位址綁定。ServerSocket的第4個構造方法ServerSocket(int port, int backlog, InetAddress bindAddr)有一個bindAddr參數,它顯式指定伺服器要綁定的IP位址,該構造方法适用于具有多個IP位址的主機。假定一個主機有兩個網卡,一個網卡用于連接配接到Internet, IP位址為222.67.5.94,還有一個網卡用于連接配接到本地區域網路,IP位址為192.168.3.4。如果伺服器僅僅被本地區域網路中的客戶通路,那麼可以按如下方式建立ServerSocket:

ServerSocket serverSocket=new ServerSocket(8000,10,InetAddress.getByName ("192.168.3.4"));

3.1.4  預設構造方法的作用

ServerSocket有一個不帶參數的預設構造方法。通過該方法建立的ServerSocket不與任何端口綁定,接下來還需要通過bind()方法與特定端口綁定。

這個預設構造方法的用途是,允許伺服器在綁定到特定端口之前,先設定ServerSocket的一些選項。因為一旦伺服器與特定端口綁定,有些選項就不能再改變了。

在以下代碼中,先把ServerSocket的SO_REUSEADDR選項設為true,然後再把它與8000端口綁定:

ServerSocket serverSocket=new ServerSocket();

serverSocket.setReuseAddress(true);                                  //設定ServerSocket的選項

serverSocket.bind(new InetSocketAddress(8000));                //與8000端口綁定

如果把以上程式代碼改為:

ServerSocket serverSocket=new ServerSocket(8000);

serverSocket.setReuseAddress(true);                                 //設定ServerSocket的選項

那麼serverSocket.setReuseAddress(true)方法就不起任何作用了,因為SO_ REUSEADDR選項必須在伺服器綁定端口之前設定才有效。

3.2  接收和關閉與客戶的連接配接

ServerSocket的accept()方法從連接配接請求隊列中取出一個客戶的連接配接請求,然後建立與客戶連接配接的Socket對象,并将它傳回。如果隊列中沒有連接配接請求,accept()方法就會一直等待,直到接收到了連接配接請求才傳回。

接下來,伺服器從Socket對象中獲得輸入流和輸出流,就能與客戶交換資料。當伺服器正在進行發送資料的操作時,如果用戶端斷開了連接配接,那麼伺服器端會抛出一個IOException的子類SocketException異常:

java.net.SocketException: Connection reset by peer

這隻是伺服器與單個客戶通信中出現的異常,這種異常應該被捕獲,使得伺服器能繼續與其他客戶通信。

以下程式顯示了單線程伺服器采用的通信流程:

public void service() {

  while (true) {

    Socket socket=null;

    try {

      socket = serverSocket.accept();                       //從連接配接請求隊列中取出一個連接配接

      System.out.println("New connection accepted " +

      socket.getInetAddress() + ":" +socket.getPort());

      //接收和發送資料

      …

    }catch (IOException e) {

      //這隻是與單個客戶通信時遇到的異常,可能是由于用戶端過早斷開連接配接引起的

      //這種異常不應該中斷整個while循環

       e.printStackTrace();

    }finally {

       try{

         if(socket!=null)socket.close();                 //與一個客戶通信結束後,要關閉Socket

       }catch (IOException e) {e.printStackTrace();}

    }

  }

}

與單個客戶通信的代碼放在一個try代碼塊中,如果遇到異常,該異常被catch代碼塊捕獲。try代碼塊後面還有一個finally代碼塊,它保證不管與客戶通信正常結束還是異常結束,最後都會關閉Socket,斷開與這個客戶的連接配接。

3.3  關閉ServerSocket

ServerSocket的close()方法使伺服器釋放占用的端口,并且斷開與所有客戶的連接配接。當一個伺服器程式運作結束時,即使沒有執行ServerSocket的close()方法,作業系統也會釋放這個伺服器占用的端口。是以,伺服器程式并不一定要在結束之前執行ServerSocket的close()方法。

在某些情況下,如果希望及時釋放伺服器的端口,以便讓其他程式能占用該端口,則可以顯式調用ServerSocket的close()方法。例如,以下代碼用于掃描1~65535之間的端口号。如果ServerSocket成功建立,意味着該端口未被其他伺服器程序綁定,否者說明該端口已經被其他程序占用:

for(int port=1;port<=65535;port++){

  try{

ServerSocket serverSocket=new ServerSocket(port);

serverSocket.close();          //及時關閉ServerSocket

  }catch(IOException e){

    System.out.println("端口"+port+" 已經被其他伺服器程序占用");

}

}

以上程式代碼建立了一個ServerSocket對象後,就馬上關閉它,以便及時釋放它占用的端口,進而避免程式臨時占用系統的大多數端口。

ServerSocket的isClosed()方法判斷ServerSocket是否關閉,隻有執行了ServerSocket的close()方法,isClosed()方法才傳回true;否則,即使ServerSocket還沒有和特定端口綁定,isClosed()方法也會傳回false。

ServerSocket的isBound()方法判斷ServerSocket是否已經與一個端口綁定,隻要ServerSocket已經與一個端口綁定,即使它已經被關閉,isBound()方法也會傳回true。

如果需要确定一個ServerSocket已經與特定端口綁定,并且還沒有被關閉,則可以采用以下方式:

boolean isOpen=serverSocket.isBound() && !serverSocket.isClosed();

3.4  擷取ServerSocket的資訊

ServerSocket的以下兩個get方法可分别獲得伺服器綁定的IP位址,以及綁定的端口:

l  public InetAddress getInetAddress()

l  public int getLocalPort()

前面已經講到,在構造ServerSocket時,如果把端口設為0,那麼将由作業系統為伺服器配置設定一個端口(稱為匿名端口),程式隻要調用getLocalPort()方法就能獲知這個端口号。如例程3-3所示的RandomPort建立了一個ServerSocket,它使用的就是匿名端口。

例程3-3  RandomPort.java

import java.io.*;

import java.net.*;

public class RandomPort{

  public static void main(String args[])throws IOException{

    ServerSocket serverSocket=new ServerSocket(0);

    System.out.println("監聽的端口為:"+serverSocket.getLocalPort());

  }

}

多次運作RandomPort程式,可能會得到如下運作結果:

C:/chapter03/classes>java RandomPort

監聽的端口為:3000

C:/chapter03/classes>java RandomPort

監聽的端口為:3004

C:/chapter03/classes>java RandomPort

監聽的端口為:3005

多數伺服器會監聽固定的端口,這樣才便于客戶程式通路伺服器。匿名端口一般适用于伺服器與客戶之間的臨時通信,通信結束,就斷開連接配接,并且ServerSocket占用的臨時端口也被釋放。

FTP(檔案傳輸)協定就使用了匿名端口。如圖3-1所示,FTP協定用于在本地檔案系統與遠端檔案系統之間傳送檔案。

圖3-1  FTP協定用于在本地檔案系統與遠端檔案系統之間傳送檔案

FTP使用兩個并行的TCP連接配接:一個是控制連接配接,一個是資料連接配接。控制連接配接用于在客戶和伺服器之間發送控制資訊,如使用者名和密碼、改變遠端目錄的指令或上傳和下載下傳檔案的指令。資料連接配接用于傳送檔案。TCP伺服器在21端口上監聽控制連接配接,如果有客戶要求上傳或下載下傳檔案,就另外建立一個資料連接配接,通過它來傳送檔案。資料連接配接的建立有兩種方式。

(1)如圖3-2所示,TCP伺服器在20端口上監聽資料連接配接,TCP客戶主動請求建立與該端口的連接配接。

圖3-2  TCP伺服器在20端口上監聽資料連接配接

(2)如圖3-3所示,首先由TCP客戶建立一個監聽匿名端口的ServerSocket,再把這個ServerSocket監聽的端口号(調用ServerSocket的getLocalPort()方法就能得到端口号)發送給TCP伺服器,然後由TCP伺服器主動請求建立與用戶端的連接配接。

圖3-3  TCP客戶在匿名端口上監聽資料連接配接

以上第二種方式就使用了匿名端口,并且是在用戶端使用的,用于和伺服器建立臨時的資料連接配接。在實際應用中,在伺服器端也可以使用匿名端口。