在客戶/伺服器通信模式中,伺服器端需要建立監聽特定端口的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客戶在匿名端口上監聽資料連接配接
以上第二種方式就使用了匿名端口,并且是在用戶端使用的,用于和伺服器建立臨時的資料連接配接。在實際應用中,在伺服器端也可以使用匿名端口。