Skip to content

Latest commit

 

History

History
2312 lines (1886 loc) · 150 KB

第15章.md

File metadata and controls

2312 lines (1886 loc) · 150 KB

第15章 網絡編程

歷史上的網絡編程都傾向於困難、複雜,而且極易出錯。

程式員必須掌握與網絡有關的大量細節,有時甚至要對硬件有深刻的認識。一般地,我們需要理解連網協議中不同的“層”(Layer)。而且對於每個連網庫,一般都包含了數量眾多的函式,分別涉及信息塊的連接、打包和拆包;這些塊的來回運輸;以及握手等等。這是一項令人痛苦的工作。

但是,連網本身的概念並不是很難。我們想獲得位於其他地方某臺機器上的信息,並把它們移到這兒;或者相反。這與讀寫文件非常相似,只是文件存在於遠程機器上,而且遠程機器有權決定如何處理我們請求或者發送的數據。

Java最出色的一個地方就是它的“無痛苦連網”概念。有關連網的基層細節已被儘可能地提取出去,並隱藏在JVM以及Java的本機安裝系統裡進行控制。我們使用的編程模型是一個文件的模型;事實上,網絡連接(一個“套接字”)已被封裝到系統物件裡,所以可象對其他數據流那樣採用同樣的方法呼叫。除此以外,在我們處理另一個連網問題——同時控制多個網絡連接——的時候,Java內建的多執行緒機制也是十分方便的。

本章將用一系列易懂的例子解釋Java的連網支持。

15.1 機器的標識

當然,為了分辨來自別處的一臺機器,以及為了保證自己連接的是希望的那臺機器,必須有一種機制能獨一無二地標識出網絡內的每臺機器。早期網絡只解決瞭如何在本地網絡環境中為機器提供唯一的名字。但Java面向的是整個Internet,這要求用一種機制對來自世界各地的機器進行標識。為達到這個目的,我們採用了IP(互聯網地址)的概念。IP以兩種形式存在著:

(1) 大家最熟悉的DNS(域名服務)形式。我自己的域名是bruceeckel.com。所以假定我在自己的域內有一臺名為Opus的計算機,它的域名就可以是Opus.bruceeckel.com。這正是大家向其他人發送電子函件時採用的名字,而且通常集成到一個萬維網(WWW)地址裡。

(2) 此外,亦可採用“四點”格式,亦即由點號(.)分隔的四組數字,比如202.98.32.111。 不管哪種情況,IP地址在內部都表達成一個由32個二進制位(bit)構成的數字(註釋①),所以IP地址的每一組數字都不能超過255。利用由java.net提供的static InetAddress.getByName(),我們可以讓一個特定的Java物件表達上述任何一種形式的數字。結果是類別型為InetAddress的一個物件,可用它構成一個“套接字”(Socket),大家在後面會見到這一點。

①:這意味著最多隻能得到40億左右的數字組合,全世界的人很快就會把它用光。但根據目前正在研究的新IP編址方法,它將採用128 bit的數字,這樣得到的唯一性IP地址也許在幾百年的時間裡都不會用完。

作為運用InetAddress.getByName()一個簡單的例子,請考慮假設自己有一家撥號連接Internet服務提供者(ISP),那麼會發生什麼情況。每次撥號連接的時候,都會分配得到一個臨時IP地址。但在連接期間,那個IP地址擁有與Internet上其他IP地址一樣的有效性。如果有人按照你的IP地址連接你的機器,他們就有可能使用在你機器上運行的Web或者FTP服務器程式。當然這有個前提,對方必須準確地知道你目前分配到的IP。由於每次撥號連接獲得的IP都是隨機的,怎樣才能準確地掌握你的IP呢?

下面這個程式利用InetAddress.getByName()來產生你的IP地址。為了讓它運行起來,事先必須知道計算機的名字。該程式只在Windows 95中進行了測試,但大家可以依次進入自己的“開始”、“設置”、“控制面板”、“網絡”,然後進入“標識”卡片。其中,“計算機名稱”就是應在命令行輸入的內容。

//: WhoAmI.java
// Finds out your network address when you're
// connected to the Internet.
package c15;
import java.net.*;

public class WhoAmI {
  public static void main(String[] args)
      throws Exception {
    if(args.length != 1) {
      System.err.println(
        "Usage: WhoAmI MachineName");
      System.exit(1);
    }
    InetAddress a =
      InetAddress.getByName(args[0]);
    System.out.println(a);
  }
} ///:~

就我自己的情況來說,機器的名字叫作Colossus(來自同名電影,“巨人”的意思。我在這臺機器上有一個很大的硬盤)。所以一旦連通我的ISP,就象下面這樣執行程式:

java whoAmI Colossus

得到的結果象下面這個樣子(當然,這個地址可能每次都是不同的):

Colossus/202.98.41.151

假如我把這個地址告訴一位朋友,他就可以立即登錄到我的個人Web服務器,只需指定目標地址 http://202.98.41.151 即可(當然,我此時不能斷線)。有些時候,這是向其他人發送信息或者在自己的Web站點正式出臺以前進行測試的一種方便手段。

15.1.1 服務器和客戶端

網絡最基本的精神就是讓兩臺機器連接到一起,並相互“交談”或者“溝通”。一旦兩臺機器都發現了對方,就可以展開一次令人愉快的雙向對話。但它們怎樣才能“發現”對方呢?這就象在遊樂園裡那樣:一臺機器不得不停留在一個地方,監聽其他機器說:“嘿,你在哪裡呢?”

“停留在一個地方”的機器叫作“服務器”(Server);到處“找人”的機器則叫作“客戶端”(Client)或者“客戶”。它們之間的區別只有在客戶端試圖同服務器連接的時候才顯得非常明顯。一旦連通,就變成了一種雙向通信,誰來扮演服務器或者客戶端便顯得不那麼重要了。

所以服務器的主要任務是監聽建立連接的請求,這是由我們創建的特定服務器物件完成的。而客戶端的任務是試著與一臺服務器建立連接,這是由我們創建的特定客戶端物件完成的。一旦連接建好,那麼無論在服務器端還是客戶端端,連接只是魔術般地變成了一個IO數據流物件。從這時開始,我們可以象讀寫一個普通的文件那樣對待連接。所以一旦建好連接,我們只需象第10章那樣使用自己熟悉的IO命令即可。這正是Java連網最方便的一個地方。

(1) 在沒有網絡的前提下測試程式

由於多種潛在的原因,我們可能沒有一臺客戶端、服務器以及一個網絡來測試自己做好的程式。我們也許是在一個課堂環境中進行練習,或者寫出的是一個不十分可靠的網絡應用,還能拿到網絡上去。IP的設計者注意到了這個問題,並建立了一個特殊的地址——localhost——來滿足非網絡環境中的測試要求。在Java中產生這個地址最一般的做法是:

InetAddress addr = InetAddress.getByName(null);

如果向getByName()傳遞一個null(空)值,就默認為使用localhost。我們用InetAddress對特定的機器進行索引,而且必須在進行進一步的操作之前得到這個InetAddress(互聯網地址)。我們不可以操縱一個InetAddress的內容(但可把它打印出來,就象下一個例子要演示的那樣)。創建InetAddress的唯一途徑就是那個類別的static(靜態)成員方法getByName()(這是最常用的)、getAllByName()或者getLocalHost()

為得到本地主機地址,亦可向其直接傳遞字符串"localhost"

InetAddress.getByName("localhost");

或者使用它的保留IP地址(四點形式),就象下面這樣:

InetAddress.getByName("127.0.0.1");

這三種方法得到的結果是一樣的。

15.1.2 端口:機器內獨一無二的場所

有些時候,一個IP地址並不足以完整標識一個服務器。這是由於在一臺物理性的機器中,往往運行著多個服務器(程式)。由IP表達的每臺機器也包含了“端口”(Port)。我們設置一個客戶端或者服務器的時候,必須選擇一個無論客戶端還是服務器都認可連接的端口。就象我們去拜會某人時,IP地址是他居住的房子,而端口是他在的那個房間。

注意端口並不是機器上一個物理上存在的場所,而是一種軟件抽象(主要是為了表述的方便)。客戶程式知道如何通過機器的IP地址同它連接,但怎樣才能同自己真正需要的那種服務連接呢(一般每個端口都運行著一種服務,一臺機器可能提供了多種服務,比如HTTP和FTP等等)?端口編號在這裡扮演了重要的角色,它是必需的一種二級定址措施。也就是說,我們請求一個特定的端口,便相當於請求與那個端口編號關聯的服務。“報時”便是服務的一個典型例子。通常,每個服務都同一臺特定服務器機器上的一個獨一 無二的端口編號關聯在一起。客戶程式必須事先知道自己要求的那項服務的運行端口號。

系統服務保留了使用端口1到端口1024的權力,所以不應讓自己設計的服務佔用這些以及其他任何已知正在使用的端口。本書的第一個例子將使用端口8080(為追憶我的第一臺機器使用的老式8位Intel 8080芯片,那是一部使用CP/M操作系統的機子)。

15.2 套接字

“套接字”或者“插座”(Socket)也是一種軟件形式的抽象,用於表達兩臺機器間一個連接的“終端”。針對一個特定的連接,每臺機器上都有一個“套接字”,可以想象它們之間有一條虛擬的“線纜”。線纜的每一端都插入一個“套接字”或者“插座”裡。當然,機器之間的物理性硬件以及電纜連接都是完全未知的。抽象的基本宗旨是讓我們儘可能不必知道那些細節。

在Java中,我們創建一個套接字,用它建立與其他機器的連接。從套接字得到的結果是一個InputStream以及OutputStream(若使用恰當的轉換器,則分別是ReaderWriter),以便將連接作為一個IO流物件對待。有兩個基於數據流的套接字類別:ServerSocket,服務器用它“監聽”進入的連接;以及Socket,客戶用它初始一次連接。一旦客戶(程式)申請建立一個套接字連接,ServerSocket就會返回(通過accept()方法)一個對應的服務器端套接字,以便進行直接通信。從此時起,我們就得到了真正的“套接字-套接字”連接,可以用同樣的方式對待連接的兩端,因為它們本來就是相同的!此時可以利用getInputStream()以及getOutputStream()從每個套接字產生對應的InputStreamOutputStream物件。這些數據流必須封裝到緩衝區內。可按第10章介紹的方法對類別進行格式化,就象對待其他任何流物件那樣。

對於Java庫的命名機制,ServerSocket(服務器套接字)的使用無疑是容易產生混淆的又一個例證。大家可能認為ServerSocket最好叫作ServerConnector(服務器連接器),或者其他什麼名字,只是不要在其中安插一個Socket。也可能以為ServerSocketSocket都應從一些通用的基類別繼承。事實上,這兩種類別確實包含了幾個通用的方法,但還不夠資格把它們賦給一個通用的基類別。相反,ServerSocket的主要任務是在那裡耐心地等候其他機器同它連接,再返回一個實際的Socket。這正是ServerSocket這個命名不恰當的地方,因為它的目標不是真的成為一個Socket,而是在其他人同它連接的時候產生一個Socket物件。

然而,ServerSocket確實會在主機上創建一個物理性的“服務器”或者監聽用的套接字。這個套接字會監聽進入的連接,然後利用accept()方法返回一個“已建立”套接字(本地和遠程端點均已定義)。容易混淆的地方是這兩個套接字(監聽和已建立)都與相同的服務器套接字關聯在一起。監聽套接字只能接收新的連接請求,不能接收實際的數據包。所以儘管ServerSocket對於編程並無太大的意義,但它確實是“物理性”的。

創建一個ServerSocket時,只需為其賦予一個端口編號。不必把一個IP地址分配它,因為它已經在自己代表的那臺機器上了。但在創建一個Socket時,卻必須同時賦予IP地址以及要連接的端口編號(另一方面,從ServerSocket.accept()返回的Socket已經包含了所有這些信息)。

15.2.1 一個簡單的服務器和客戶端程式

這個例子將以最簡單的方式運用套接字對服務器和客戶端進行操作。服務器的全部工作就是等候建立一個連接,然後用那個連接產生的Socket創建一個InputStream以及一個OutputStream。在這之後,它從InputStream讀入的所有東西都會反饋給OutputStream,直到接收到行中止(END)為止,最後關閉連接。

客戶端連接與服務器的連接,然後創建一個OutputStream。文本行通過OutputStream發送。客戶端也會創建一個InputStream,用它收聽服務器說些什麼(本例只不過是反饋回來的同樣的字句)。

服務器與客戶端(程式)都使用同樣的端口號,而且客戶端利用本地主機地址連接位於同一臺機器中的服務器(程式),所以不必在一個物理性的網絡裡完成測試(在某些配置環境中,可能需要同真正的網絡建立連接,否則程式不能工作——儘管實際並不通過那個網絡通信)。

下面是服務器程式:

//: JabberServer.java
// Very simple server that just
// echoes whatever the client sends.
import java.io.*;
import java.net.*;

public class JabberServer {  
  // Choose a port outside of the range 1-1024:
  public static final int PORT = 8080;
  public static void main(String[] args)
      throws IOException {
    ServerSocket s = new ServerSocket(PORT);
    System.out.println("Started: " + s);
    try {
      // Blocks until a connection occurs:
      Socket socket = s.accept();
      try {
        System.out.println(
          "Connection accepted: "+ socket);
        BufferedReader in =
          new BufferedReader(
            new InputStreamReader(
              socket.getInputStream()));
        // Output is automatically flushed
        // by PrintWriter:
        PrintWriter out =
          new PrintWriter(
            new BufferedWriter(
              new OutputStreamWriter(
                socket.getOutputStream())),true);
        while (true) {  
          String str = in.readLine();
          if (str.equals("END")) break;
          System.out.println("Echoing: " + str);
          out.println(str);
        }
      // Always close the two sockets...
      } finally {
        System.out.println("closing...");
        socket.close();
      }
    } finally {
      s.close();
    }
  }
} ///:~

可以看到,ServerSocket需要的只是一個端口編號,不需要IP地址(因為它就在這臺機器上運行)。呼叫accept()時,方法會暫時陷入停頓狀態(堵塞),直到某個客戶嘗試同它建立連接。換言之,儘管它在那裡等候連接,但其他進程仍能正常運行(參考第14章)。建好一個連接以後,accept()就會返回一個Socket物件,它是那個連接的代表。

清除套接字的責任在這裡得到了很藝術的處理。假如ServerSocket建構子失敗,則程式簡單地退出(注意必須保證ServerSocket的建構子在失敗之後不會留下任何打開的網絡套接字)。針對這種情況,main()會“拋”出一個IOException異常,所以不必使用一個try塊。若ServerSocket建構子成功執行,則其他所有方法呼叫都必須到一個try-finally程式碼塊裡尋求保護,以確保無論塊以什麼方式留下,ServerSocket都能正確地關閉。

同樣的道理也適用於由accept()返回的Socket。若accept()失敗,那麼我們必須保證Socket不再存在或者含有任何資源,以便不必清除它們。但假若執行成功,則後續的語句必須進入一個try-finally塊內,以保障在它們失敗的情況下,Socket仍能得到正確的清除。由於套接字使用了重要的非內存資源,所以在這裡必須特別謹慎,必須自己動手將它們清除(Java中沒有提供“析構器”來幫助我們做這件事情)。

無論ServerSocket還是由accept()產生的Socket都打印到System.out裡。這意味著它們的toString方法會得到自動呼叫。這樣便產生了:

ServerSocket[addr=0.0.0.0,PORT=0,localport=8080]
Socket[addr=127.0.0.1,PORT=1077,localport=8080]

大家不久就會看到它們如何與客戶程式做的事情配合。

程式的下一部分看來似乎僅僅是打開文件,以便讀取和寫入,只是InputStreamOutputStream是從Socket物件創建的。利用兩個“轉換器”類別InputStreamReaderOutputStreamWriterInputStreamOutputStream物件已經分別轉換成為Java 1.1的ReaderWriter物件。也可以直接使用Java1.0的InputStreamOutputStream類別,但對輸出來說,使用Writer方式具有明顯的優勢。這一優勢是通過PrintWriter表現出來的,它有一個重載的建構子,能獲取第二個參數——一個布爾值標誌,指向是否在每一次println()結束的時候自動刷新輸出(但不適用於print()語句)。每次寫入了輸出內容後(寫進out),它的緩衝區必須刷新,使信息能正式通過網絡傳遞出去。對目前這個例子來說,刷新顯得尤為重要,因為客戶和服務器在採取下一步操作之前都要等待一行文本內容的到達。若刷新沒有發生,那麼信息不會進入網絡,除非緩衝區滿(溢出),這會為本例帶來許多問題。

編寫網絡應用程式時,需要特別注意自動刷新機制的使用。每次刷新緩衝區時,必須創建和發出一個數據包(數據封)。就目前的情況來說,這正是我們所希望的,因為假如包內包含了還沒有發出的文本行,服務器和客戶端之間的相互“握手”就會停止。換句話說,一行的末尾就是一則訊息的末尾。但在其他許多情況下,訊息並不是用行分隔的,所以不如不用自動刷新機制,而用內建的緩衝區判決機制來決定何時發送一個數據包。這樣一來,我們可以發出較大的數據包,而且處理進程也能加快。

注意和我們打開的幾乎所有數據流一樣,它們都要進行緩衝處理。本章末尾有一個練習,清楚展現了假如我們不對數據流進行緩衝,那麼會得到什麼樣的後果(速度會變慢)。

無限while循環從BufferedReader in內讀取文本行,並將信息寫入System.out,然後寫入PrintWriter.out。注意這可以是任何數據流,它們只是在表面上同網絡連接。

客戶程式發出包含了"END"的行後,程式會中止循環,並關閉Socket

下面是客戶程式的源碼:

//: JabberClient.java
// Very simple client that just sends
// lines to the server and reads lines
// that the server sends.
import java.net.*;
import java.io.*;

public class JabberClient {
  public static void main(String[] args)
      throws IOException {
    // Passing null to getByName() produces the
    // special "Local Loopback" IP address, for
    // testing on one machine w/o a network:
    InetAddress addr =
      InetAddress.getByName(null);
    // Alternatively, you can use
    // the address or name:
    // InetAddress addr =
    //    InetAddress.getByName("127.0.0.1");
    // InetAddress addr =
    //    InetAddress.getByName("localhost");
    System.out.println("addr = " + addr);
    Socket socket =
      new Socket(addr, JabberServer.PORT);
    // Guard everything in a try-finally to make
    // sure that the socket is closed:
    try {
      System.out.println("socket = " + socket);
      BufferedReader in =
        new BufferedReader(
          new InputStreamReader(
            socket.getInputStream()));
      // Output is automatically flushed
      // by PrintWriter:
      PrintWriter out =
        new PrintWriter(
          new BufferedWriter(
            new OutputStreamWriter(
              socket.getOutputStream())),true);
      for(int i = 0; i < 10; i ++) {
        out.println("howdy " + i);
        String str = in.readLine();
        System.out.println(str);
      }
      out.println("END");
    } finally {
      System.out.println("closing...");
      socket.close();
    }
  }
} ///:~

main()中,大家可看到獲得本地主機IP地址的InetAddress的三種途徑:使用null,使用localhost,或者直接使用保留地址127.0.0.1。當然,如果想通過網絡同一臺遠程主機連接,也可以換用那臺機器的IP地址。打印出InetAddress addr後(通過對toString()方法的自動呼叫),結果如下:

localhost/127.0.0.1

通過向getByName()傳遞一個null,它會默認尋找localhost,並生成特殊的保留地址127.0.0.1。注意在名為socket的套接字創建時,同時使用了InetAddress以及端口號。打印這樣的某個Socket物件時,為了真正理解它的含義,請記住一次獨一無二的Internet連接是用下述四種數據標識的:clientHost(客戶主機)、clientPortNumber(客戶端口號)、serverHost(服務主機)以及serverPortNumber(服務端口號)。服務程式啟動後,會在本地主機(127.0.0.1)上建立為它分配的端口(8080)。一旦客戶程式發出請求,機器上下一個可用的端口就會分配給它(這種情況下是1077),這一行動也在與服務程式相同的機器(127.0.0.1)上進行。現在,為了使數據能在客戶及服務程式之間來回傳送,每一端都需要知道把數據發到哪裡。所以在同一個“已知”服務程式連接的時候,客戶會發出一個“返回地址”,使服務器程式知道將自己的數據發到哪兒。我們在服務器端的示範輸出中可以體會到這一情況:

Socket[addr=127.0.0.1,port=1077,localport=8080]

這意味著服務器剛才已接受了來自127.0.0.1這臺機器的端口1077的連接,同時監聽自己的本地端口(8080)。而在客戶端:

Socket[addr=localhost/127.0.0.1,PORT=8080,localport=1077]

這意味著客戶已用自己的本地端口1077與127.0.0.1機器上的端口8080建立了 連接。

大家會注意到每次重新啟動客戶程式的時候,本地端口的編號都會增加。這個編號從1025(剛好在系統保留的1-1024之外)開始,並會一直增加下去,除非我們重啟機器。若重新啟動機器,端口號仍然會從1025開始自增(在Unix機器中,一旦超過保留的套按字範圍,數字就會再次從最小的可用數字開始)。

創建好Socket物件後,將其轉換成BufferedReaderPrintWriter的過程便與在服務器中相同(同樣地,兩種情況下都要從一個Socket開始)。在這裡,客戶通過發出字符串"howdy",並在後面跟隨一個數字,從而初始化通信。注意緩衝區必須再次刷新(這是自動發生的,通過傳遞給PrintWriter建構子的第二個參數)。若緩衝區沒有刷新,那麼整個會話(通信)都會被掛起,因為用於初始化的"howdy"永遠不會發送出去(緩衝區不夠滿,不足以造成發送動作的自動進行)。從服務器返回的每一行都會寫入System.out,以驗證一切都在正常運轉。為中止會話,需要發出一個"END"。若客戶程式簡單地掛起,那麼服務器會“拋”出一個異常。

大家在這裡可以看到我們採用了同樣的措施來確保由Socket代表的網絡資源得到正確的清除,這是用一個try-finally塊實現的。

套接字建立了一個“專用”連接,它會一直持續到明確斷開連接為止(專用連接也可能間接性地斷開,前提是某一端或者中間的某條鏈路出現故障而崩潰)。這意味著參與連接的雙方都被鎖定在通信中,而且無論是否有數據傳遞,連接都會連續處於開放狀態。從表面看,這似乎是一種合理的連網方式。然而,它也為網絡帶來了額外的開銷。本章後面會介紹進行連網的另一種方式。採用那種方式,連接的建立只是暫時的。

15.3 服務多個客戶

JabberServer可以正常工作,但每次只能為一個客戶程式提供服務。在典型的服務器中,我們希望同時能處理多個客戶的請求。解決這個問題的關鍵就是多執行緒處理機制。而對於那些本身不支持多執行緒的語言,達到這個要求無疑是異常困難的。通過第14章的學習,大家已經知道Java已對多執行緒的處理進行了儘可能的簡化。由於Java的執行緒處理方式非常直接,所以讓服務器控制多名客戶並不是件難事。

最基本的方法是在服務器(程式)裡創建單個ServerSocket,並呼叫accept()來等候一個新連接。一旦accept()返回,我們就取得結果獲得的Socket,並用它新建一個執行緒,令其只為那個特定的客戶服務。然後再呼叫accept(),等候下一次新的連接請求。

對於下面這段服務器程式碼,大家可發現它與JabberServer.java例子非常相似,只是為一個特定的客戶提供服務的所有操作都已移入一個獨立的執行緒類別中:

//: MultiJabberServer.java
// A server that uses multithreading to handle
// any number of clients.
import java.io.*;
import java.net.*;

class ServeOneJabber extends Thread {
  private Socket socket;
  private BufferedReader in;
  private PrintWriter out;
  public ServeOneJabber(Socket s)
      throws IOException {
    socket = s;
    in =
      new BufferedReader(
        new InputStreamReader(
          socket.getInputStream()));
    // Enable auto-flush:
    out =
      new PrintWriter(
        new BufferedWriter(
          new OutputStreamWriter(
            socket.getOutputStream())), true);
    // If any of the above calls throw an
    // exception, the caller is responsible for
    // closing the socket. Otherwise the thread
    // will close it.
    start(); // Calls run()
  }
  public void run() {
    try {
      while (true) {  
        String str = in.readLine();
        if (str.equals("END")) break;
        System.out.println("Echoing: " + str);
        out.println(str);
      }
      System.out.println("closing...");
    } catch (IOException e) {
    } finally {
      try {
        socket.close();
      } catch(IOException e) {}
    }
  }
}

public class MultiJabberServer {  
  static final int PORT = 8080;
  public static void main(String[] args)
      throws IOException {
    ServerSocket s = new ServerSocket(PORT);
    System.out.println("Server Started");
    try {
      while(true) {
        // Blocks until a connection occurs:
        Socket socket = s.accept();
        try {
          new ServeOneJabber(socket);
        } catch(IOException e) {
          // If it fails, close the socket,
          // otherwise the thread will close it:
          socket.close();
        }
      }
    } finally {
      s.close();
    }
  }
} ///:~

每次有新客戶請求建立一個連接時,ServeOneJabber執行緒都會取得由accept()main()中生成的Socket物件。然後和往常一樣,它創建一個BufferedReader,並用Socket自動刷新PrintWriter物件。最後,它呼叫Thread的特殊方法start(),令其進行執行緒的初始化,然後呼叫run()。這裡採取的操作與前例是一樣的:從套掃字讀入某些東西,然後把它原樣反饋回去,直到遇到一個特殊的"END"結束標誌為止。

同樣地,套接字的清除必須進行謹慎的設計。就目前這種情況來說,套接字是在ServeOneJabber外部創建的,所以清除工作可以“共享”。若ServeOneJabber建構子失敗,那麼只需向呼叫者“拋”出一個異常即可,然後由呼叫者負責執行緒的清除。但假如建構子成功,那麼必須由ServeOneJabber物件負責執行緒的清除,這是在它的run()裡進行的。

請注意MultiJabberServer有多麼簡單。和以前一樣,我們創建一個ServerSocket,並呼叫accept()允許一個新連接的建立。但這一次,accept()的返回值(一個套接字)將傳遞給用於ServeOneJabber的建構子,由它創建一個新執行緒,並對那個連接進行控制。連接中斷後,執行緒便可簡單地消失。

如果ServerSocket創建失敗,則再一次通過main()拋出異常。如果成功,則位於外層的try-finally程式碼塊可以擔保正確的清除。位於內層的try-catch塊只負責防範ServeOneJabber建構子的失敗;若建構子成功,則ServeOneJabber執行緒會將對應的套接字關掉。

為了證實服務器程式碼確實能為多名客戶提供服務,下面這個程式將創建許多客戶(使用執行緒),並同相同的服務器建立連接。每個執行緒的“生命週期”都是有限的。一旦到期,就留出空間以便創建一個新執行緒。允許創建的執行緒的最大數量是由final int maxthreads決定的。大家會注意到這個值非常關鍵,因為假如把它設得很大,執行緒便有可能耗盡資源,併產生不可預知的程式錯誤。

//: MultiJabberClient.java
// Client that tests the MultiJabberServer
// by starting up multiple clients.
import java.net.*;
import java.io.*;

class JabberClientThread extends Thread {
  private Socket socket;
  private BufferedReader in;
  private PrintWriter out;
  private static int counter = 0;
  private int id = counter++;
  private static int threadcount = 0;
  public static int threadCount() {
    return threadcount;
  }
  public JabberClientThread(InetAddress addr) {
    System.out.println("Making client " + id);
    threadcount++;
    try {
      socket =
        new Socket(addr, MultiJabberServer.PORT);
    } catch(IOException e) {
      // If the creation of the socket fails,
      // nothing needs to be cleaned up.
    }
    try {    
      in =
        new BufferedReader(
          new InputStreamReader(
            socket.getInputStream()));
      // Enable auto-flush:
      out =
        new PrintWriter(
          new BufferedWriter(
            new OutputStreamWriter(
              socket.getOutputStream())), true);
      start();
    } catch(IOException e) {
      // The socket should be closed on any
      // failures other than the socket
      // constructor:
      try {
        socket.close();
      } catch(IOException e2) {}
    }
    // Otherwise the socket will be closed by
    // the run() method of the thread.
  }
  public void run() {
    try {
      for(int i = 0; i < 25; i++) {
        out.println("Client " + id + ": " + i);
        String str = in.readLine();
        System.out.println(str);
      }
      out.println("END");
    } catch(IOException e) {
    } finally {
      // Always close it:
      try {
        socket.close();
      } catch(IOException e) {}
      threadcount--; // Ending this thread
    }
  }
}

public class MultiJabberClient {
  static final int MAX_THREADS = 40;
  public static void main(String[] args)
      throws IOException, InterruptedException {
    InetAddress addr =
      InetAddress.getByName(null);
    while(true) {
      if(JabberClientThread.threadCount()
         < MAX_THREADS)
        new JabberClientThread(addr);
      Thread.currentThread().sleep(100);
    }
  }
} ///:~

JabberClientThread建構子獲取一個InetAddress,並用它打開一個套接字。大家可能已看出了這樣的一個套路:Socket肯定用於創建某種Reader以及/或者Writer(或者InputStream和/或OutputStream)物件,這是運用Socket的唯一方式(當然,我們可考慮編寫一、兩個類別,令其自動完成這些操作,避免大量重複的程式碼編寫工作)。同樣地,start()執行執行緒的初始化,並呼叫run()。在這裡,訊息發送給服務器,而來自服務器的信息則在屏幕上回顯出來。然而,執行緒的“生命週期”是有限的,最終都會結束。注意在套接字創建好以後,但在建構子完成之前,假若建構子失敗,套接字會被清除。否則,為套接字呼叫close()的責任便落到了run()方法的頭上。

threadcount跟蹤計算目前存在的JabberClientThread物件的數量。它將作為建構子的一部分自增,並在run()退出時自減(run()退出意味著執行緒中止)。在MultiJabberClient.main()中,大家可以看到執行緒的數量會得到檢查。若數量太多,則多餘的暫時不創建。方法隨後進入“休眠”狀態。這樣一來,一旦部分執行緒最後被中止,多作的那些執行緒就可以創建了。大家可試驗一下逐漸增大MAX_THREADS,看看對於你使用的系統來說,建立多少執行緒(連接)才會使您的系統資源降低到危險程度。

15.4 數據報

大家迄今看到的例子使用的都是“傳輸控制協議”(TCP),亦稱作“基於數據流的套接字”。根據該協議的設計宗旨,它具有高度的可靠性,而且能保證數據順利抵達目的地。換言之,它允許重傳那些由於各種原因半路“走失”的數據。而且收到字節的順序與它們發出來時是一樣的。當然,這種控制與可靠性需要我們付出一些代價:TCP具有非常高的開銷。

還有另一種協議,名為“用戶數據報協議”(UDP),它並不刻意追求數據包會完全發送出去,也不能擔保它們抵達的順序與它們發出時一樣。我們認為這是一種“不可靠協議”(TCP當然是“可靠協議”)。聽起來似乎很糟,但由於它的速度快得多,所以經常還是有用武之地的。對某些應用來說,比如聲音信號的傳輸,如果少量數據包在半路上丟失了,那麼用不著太在意,因為傳輸的速度顯得更重要一些。大多數互聯網遊戲,如Diablo,採用的也是UDP協議通信,因為網絡通信的快慢是遊戲是否流暢的決定性因素。也可以想想一臺報時服務器,如果某則訊息丟失了,那麼也真的不必過份緊張。另外,有些應用也許能向服務器傳回一條UDP訊息,以便以後能夠恢復。如果在適當的時間裡沒有響應,訊息就會丟失。

Java對數據報的支持與它對TCP套接字的支持大致相同,但也存在一個明顯的區別。對數據報來說,我們在客戶和服務器程式都可以放置一個DatagramSocket(數據報套接字),但與ServerSocket不同,前者不會幹巴巴地等待建立一個連接的請求。這是由於不再存在“連接”,取而代之的是一個數據報陳列出來。另一項本質的區別的是對TCP套接字來說,一旦我們建好了連接,便不再需要關心誰向誰“說話”——只需通過會話流來回傳送數據即可。但對數據報來說,它的數據包必須知道自己來自何處,以及打算去哪裡。這意味著我們必須知道每個數據報包的這些信息,否則信息就不能正常地傳遞。

DatagramSocket用於收發數據包,而DatagramPacket包含了具體的信息。準備接收一個數據報時,只需提供一個緩衝區,以便安置接收到的數據。數據包抵達時,通過DatagramSocket,作為信息起源地的Internet地址以及端口編號會自動得到初化。所以一個用於接收數據報的DatagramPacket建構子是:

DatagramPacket(buf, buf.length)

其中,buf是一個字節數組。既然buf是個數組,大家可能會奇怪為什麼建構子自己不能調查出數組的長度呢?實際上我也有同感,唯一能猜到的原因就是C風格的編程使然,那裡的數組不能自己告訴我們它有多大。

可以重複使用數據報的接收程式碼,不必每次都建一個新的。每次用它的時候(複用),緩衝區內的數據都會被覆蓋。

緩衝區的最大容量僅受限於允許的數據報包大小,這個限制位於比64KB稍小的地方。但在許多應用程式中,我們都寧願它變得還要小一些,特別是在發送數據的時候。具體選擇的數據包大小取決於應用程式的特定要求。

發出一個數據報時,DatagramPacket不僅需要包含正式的數據,也要包含Internet地址以及端口號,以決定它的目的地。所以用於輸出DatagramPacket的建構子是:

DatagramPacket(buf, length, inetAddress, port)

這一次,buf(一個字節數組)已經包含了我們想發出的數據。length可以是buf的長度,但也可以更短一些,意味著我們只想發出那麼多的字節。另兩個參數分別代表數據包要到達的Internet地址以及目標機器的一個目標端口(註釋②)。

②:我們認為TCP和UDP端口是相互獨立的。也就是說,可以在端口8080同時運行一個TCP和UDP服務程式,兩者之間不會產生衝突。

大家也許認為兩個建構子創建了兩個不同的物件:一個用於接收數據報,另一個用於發送它們。如果是好的物件導向的設計模式,會建議把它們創建成兩個不同的類別,而不是具有不同的行為的一個類別(具體行為取決於我們如何建構物件)。這也許會成為一個嚴重的問題,但幸運的是,DatagramPacket的使用相當簡單,我們不需要在這個問題上糾纏不清。這一點在下例裡將有很明確的說明。該例類別似於前面針對TCP套接字的MultiJabberServerMultiJabberClient例子。多個客戶都會將數據報發給服務器,後者會將其反饋回最初發出訊息的同樣的客戶。

為簡化從一個String裡創建DatagramPacket的工作(或者從DatagramPacket裡創建String),這個例子首先用到了一個工具類別,名為Dgram

//: Dgram.java
// A utility class to convert back and forth
// Between Strings and DataGramPackets.
import java.net.*;

public class Dgram {
  public static DatagramPacket toDatagram(
    String s, InetAddress destIA, int destPort) {
    // Deprecated in Java 1.1, but it works:
    byte[] buf = new byte[s.length() + 1];
    s.getBytes(0, s.length(), buf, 0);
    // The correct Java 1.1 approach, but it's
    // Broken (it truncates the String):
    // byte[] buf = s.getBytes();
    return new DatagramPacket(buf, buf.length,
      destIA, destPort);
  }
  public static String toString(DatagramPacket p){
    // The Java 1.0 approach:
    // return new String(p.getData(),
    //  0, 0, p.getLength());
    // The Java 1.1 approach:
    return
      new String(p.getData(), 0, p.getLength());
  }
} ///:~

Dgram的第一個方法採用一個String、一個InetAddress以及一個端口號作為自己的參數,將String的內容複製到一個字節緩衝區,再將緩衝區傳遞進入DatagramPacket建構子,從而建構一個DatagramPacket。注意緩衝區分配時的"+1"——這對防止截尾現象是非常重要的。StringgetByte()方法屬於一種特殊操作,能將一個字符串包含的char複製進入一個字節緩衝。該方法現在已被“反對”使用;Java 1.1有一個“更好”的辦法來做這個工作,但在這裡卻被當作註釋屏蔽掉了,因為它會截掉String的部分內容。所以儘管我們在Java 1.1下編譯該程式時會得到一條“反對”訊息,但它的行為仍然是正確無誤的(這個錯誤應該在你讀到這裡的時候修正了)。

Dgram.toString()方法同時展示了Java 1.0的方法和Java 1.1的方法(兩者是不同的,因為有一種新類別型的String建構子)。

下面是用於數據報演示的服務器程式碼:

//: ChatterServer.java
// A server that echoes datagrams
import java.net.*;
import java.io.*;
import java.util.*;

public class ChatterServer {
  static final int INPORT = 1711;
  private byte[] buf = new byte[1000];
  private DatagramPacket dp =
    new DatagramPacket(buf, buf.length);
  // Can listen & send on the same socket:
  private DatagramSocket socket;

  public ChatterServer() {
    try {
      socket = new DatagramSocket(INPORT);
      System.out.println("Server started");
      while(true) {
        // Block until a datagram appears:
        socket.receive(dp);
        String rcvd = Dgram.toString(dp) +
          ", from address: " + dp.getAddress() +
          ", port: " + dp.getPort();
        System.out.println(rcvd);
        String echoString =
          "Echoed: " + rcvd;
        // Extract the address and port from the
        // received datagram to find out where to
        // send it back:
        DatagramPacket echo =
          Dgram.toDatagram(echoString,
            dp.getAddress(), dp.getPort());
        socket.send(echo);
      }
    } catch(SocketException e) {
      System.err.println("Can't open socket");
      System.exit(1);
    } catch(IOException e) {
      System.err.println("Communication error");
      e.printStackTrace();
    }
  }
  public static void main(String[] args) {
    new ChatterServer();
  }
} ///:~

ChatterServer創建了一個用來接收訊息的DatagramSocket(數據報套接字),而不是在我們每次準備接收一條新訊息時都新建一個。這個單一的DatagramSocket可以重複使用。它有一個端口號,因為這屬於服務器,客戶必須確切知道自己把數據報發到哪個地址。儘管有一個端口號,但沒有為它分配Internet地址,因為它就駐留在“這”臺機器內,所以知道自己的Internet地址是什麼(目前是默認的localhost)。在無限while循環中,套接字被告知接收數據(receive())。然後暫時掛起,直到一個數據報出現,再把它反饋回我們希望的接收人——DatagramPacket dp——裡面。數據包(Packet)會被轉換成一個字符串,同時插入的還有數據包的起源Internet地址及套接字。這些信息會顯示出來,然後添加一個額外的字符串,指出自己已從服務器反饋回來了。

大家可能會覺得有點兒迷惑。正如大家會看到的那樣,許多不同的Internet地址和端口號都可能是訊息的起源地——換言之,客戶程式可能駐留在任何一臺機器裡(就這一次演示來說,它們都駐留在localhost裡,但每個客戶使用的端口編號是不同的)。為了將一則訊息送回它真正的始發客戶,需要知道那個客戶的Internet地址以及端口號。幸運的是,所有這些資料均已非常周到地封裝到發出訊息的DatagramPacket內部,所以我們要做的全部事情就是用getAddress()getPort()把它們取出來。利用這些資料,可以建構DatagramPacket echo——它通過與接收用的相同的套接字發送回來。除此以外,一旦套接字發出數據報,就會添加“這”臺機器的Internet地址及端口信息,所以當客戶接收訊息時,它可以利用getAddress()getPort()瞭解數據報來自何處。事實上,getAddress()getPort()唯一不能告訴我們數據報來自何處的前提是:我們創建一個待發送的數據報,並在正式發出之前呼叫了getAddress()getPort()。到數據報正式發送的時候,這臺機器的地址以及端口才會寫入數據報。所以我們得到了運用數據報時一項重要的原則:不必跟蹤一則訊息的來源地!因為它肯定保存在數據報裡。事實上,對程式來說,最可靠的做法是我們不要試圖跟蹤,而是無論如何都從目標數據報裡提取出地址以及端口信息(就象這裡做的那樣)。

為測試服務器的運轉是否正常,下面這程式將創建大量客戶(執行緒),它們都會將數據報包發給服務器,並等候服務器把它們原樣反饋回來。

//: ChatterServer.java
// A server that echoes datagrams
import java.net.*;
import java.io.*;
import java.util.*;

public class ChatterServer {
  static final int INPORT = 1711;
  private byte[] buf = new byte[1000];
  private DatagramPacket dp =
    new DatagramPacket(buf, buf.length);
  // Can listen & send on the same socket:
  private DatagramSocket socket;

  public ChatterServer() {
    try {
      socket = new DatagramSocket(INPORT);
      System.out.println("Server started");
      while(true) {
        // Block until a datagram appears:
        socket.receive(dp);
        String rcvd = Dgram.toString(dp) +
          ", from address: " + dp.getAddress() +
          ", port: " + dp.getPort();
        System.out.println(rcvd);
        String echoString =
          "Echoed: " + rcvd;
        // Extract the address and port from the
        // received datagram to find out where to
        // send it back:
        DatagramPacket echo =
          Dgram.toDatagram(echoString,
            dp.getAddress(), dp.getPort());
        socket.send(echo);
      }
    } catch(SocketException e) {
      System.err.println("Can't open socket");
      System.exit(1);
    } catch(IOException e) {
      System.err.println("Communication error");
      e.printStackTrace();
    }
  }
  public static void main(String[] args) {
    new ChatterServer();
  }
} ///:~

ChatterClient被創建成一個執行緒(Thread),所以可以用多個客戶來“騷擾”服務器。從中可以看到,用於接收的DatagramPacket和用於ChatterServer的那個是相似的。在建構子中,創建DatagramPacket時沒有附帶任何參數,因為它不需要明確指出自己位於哪個特定編號的端口裡。用於這個套接字的Internet地址將成為“這臺機器”(比如localhost),而且會自動分配端口編號,這從輸出結果即可看出。同用於服務器的那個一樣,這個DatagramPacket將同時用於發送和接收。

hostAddress是我們想與之通信的那臺機器的Internet地址。在程式中,如果需要創建一個準備傳出去的DatagramPacket,那麼必須知道一個準確的Internet地址和端口號。可以肯定的是,主機必須位於一個已知的地址和端口號上,使客戶能啟動與主機的“會話”。

每個執行緒都有自己獨一無二的標識號(儘管自動分配給執行緒的端口號是也會提供一個唯一的標識符)。在run()中,我們創建了一個String訊息,其中包含了執行緒的標識編號以及該執行緒準備發送的訊息編號。我們用這個字符串創建一個數據報,發到主機上的指定地址;端口編號則直接從ChatterServer內的一個常數取得。一旦訊息發出,receive()就會暫時被“堵塞”起來,直到服務器回覆了這則訊息。與訊息附在一起的所有信息使我們知道回到這個特定執行緒的東西正是從始發訊息中投遞出去的。在這個例子中,儘管是一種“不可靠”協議,但仍然能夠檢查數據報是否到去過了它們該去的地方(這在localhost和LAN環境中是成立的,但在非本地連接中卻可能出現一些錯誤)。

運行該程式時,大家會發現每個執行緒都會結束。這意味著發送到服務器的每個數據報包都會迴轉,並反饋回正確的接收者。如果不是這樣,一個或更多的執行緒就會掛起並進入“堵塞”狀態,直到它們的輸入被顯露出來。

大家或許認為將文件從一臺機器傳到另一臺的唯一正確方式是通過TCP套接字,因為它們是“可靠”的。然而,由於數據報的速度非常快,所以它才是一種更好的選擇。我們只需將文件分割成多個數據報,併為每個包編號。接收機器會取得這些數據包,並重新“組裝”它們;一個“標題包”會告訴機器應該接收多少個包,以及組裝所需的另一些重要信息。如果一個包在半路“走丟”了,接收機器會返回一個數據報,告訴發送者重傳。

15.5 一個Web應用

現在讓我們想想如何創建一個應用,令其在真實的Web環境中運行,它將把Java的優勢表現得淋漓盡致。這個應用的一部分是在Web服務器上運行的一個Java程式,另一部分則是一個“程式片”或“小應用程式”(Applet),從服務器下載至瀏覽器(即“客戶”)。這個程式片從用戶那裡收集信息,並將其傳回Web服務器上運行的應用程式。程式的任務非常簡單:程式片會詢問用戶的E-mail地址,並在驗證這個地址合格後(沒有包含空格,而且有一個@符號),將該E-mail發送給Web服務器。服務器上運行的程式則會捕獲傳回的數據,檢查一個包含了所有E-mail地址的數據文件。如果那個地址已包含在文件裡,則向瀏覽器反饋一則訊息,說明這一情況。該訊息由程式片負責顯示。若是一個新地址,則將其置入列表,並通知程式片已成功添加了電子函件地址。

若採用傳統方式來解決這個問題,我們要創建一個包含了文本字段及一個“提交”(Submit)按鈕的HTML頁。用戶可在文本字段裡鍵入自己喜歡的任何內容,並毫無阻礙地提交給服務器(在客戶端不進行任何檢查)。提交數據的同時,Web頁也會告訴服務器應對數據採取什麼樣的操作——知會“通用網關界面”(CGI)程式,收到這些數據後立即運行服務器。這種CGI程式通常是用Perl或C寫的(有時也用C++,但要求服務器支持),而且必須能控制一切可能出現的情況。它首先會檢查數據,判斷是否採用了正確的格式。若答案是否定的,則CGI程式必須創建一個HTML頁,對遇到的問題進行描述。這個頁會轉交給服務器,再由服務器反饋回用戶。用戶看到出錯提示後,必須再試一遍提交,直到通過為止。若數據正確,CGI程式會打開數據文件,要麼把電子函件地址加入文件,要麼指出該地址已在數據文件裡了。無論哪種情況,都必須格式化一個恰當的HTML頁,以便服務器返回給用戶。

作為Java程式員,上述解決問題的方法顯得非常笨拙。而且很自然地,我們希望一切工作都用Java完成。首先,我們會用一個Java程式片負責客戶端的數據有效性校驗,避免數據在服務器和客戶之間傳來傳去,浪費時間和帶寬,同時減輕服務器額外建構HTML頁的負擔。然後跳過Perl CGI腳本,換成在服務器上運行一個Java應用。事實上,我們在這兒已完全跳過了Web服務器,僅僅需要從程式片到服務器上運行的Java應用之間建立一個連接即可。

正如大家不久就會體驗到的那樣,儘管看起來非常簡單,但實際上有一些意想不到的問題使局面顯得稍微有些複雜。用Java 1.1寫程式片是最理想的,但實際上卻經常行不通。到本書寫作的時候,擁有Java 1.1能力的瀏覽器仍為數不多,而且即使這類別瀏覽器現在非常流行,仍需考慮照顧一下那些升級緩慢的人。所以從安全的角度看,程式片程式碼最好只用Java 1.0編寫。基於這一前提,我們不能用JAR文件來合併(壓縮)程式片中的.class文件。所以,我們應儘可能減少.class文件的使用數量,以縮短下載時間。

好了,再來說說我用的Web服務器(寫這個示範程式時用的就是它)。它確實支持Java,但僅限於Java 1.0!所以服務器應用也必須用Java 1.0編寫。

15.5.1 服務器應用

現在討論一下服務器應用(程式)的問題,我把它叫作NameCollecor(名字收集器)。假如多名用戶同時嘗試提交他們的E-mail地址,那麼會發生什麼情況呢?若NameCollector使用TCP/IP套接字,那麼必須運用早先介紹的多執行緒機制來實現對多個客戶的併發控制。但所有這些執行緒都試圖把數據寫到同一個文件裡,其中保存了所有E-mail地址。這便要求我們設立一種鎖定機制,保證多個執行緒不會同時訪問那個文件。一個“信號機”可在這裡幫助我們達到目的,但或許還有一種更簡單的方式。

如果我們換用數據報,就不必使用多執行緒了。用單個數據報即可“監聽”進入的所有數據報。一旦監視到有進入的訊息,程式就會進行適當的處理,並將答覆數據作為一個數據報傳回原先發出請求的那名接收者。若數據報半路上丟失了,則用戶會注意到沒有答覆數據傳回,所以可以重新提交請求。

服務器應用收到一個數據報,並對它進行解讀的時候,必須提取出其中的電子函件地址,並檢查本機保存的數據文件,看看裡面是否已經包含了那個地址(如果沒有,則添加之)。所以我們現在遇到了一個新的問題。Java 1.0似乎沒有足夠的能力來方便地處理包含了電子函件地址的文件(Java 1.1則不然)。但是,用C輕易就可以解決這個問題。因此,我們在這兒有機會學習將一個非Java程式同Java程式連接的最簡便方式。程式使用的Runtime物件包含了一個名為exec()的方法,它會獨立機器上一個獨立的程式,並返回一個Process(進程)物件。我們可以取得一個OutputStream,它同這個單獨程式的標準輸入連接在一起;並取得一個InputStream,它則同標準輸出連接到一起。要做的全部事情就是用任何語言寫一個程式,只要它能從標準輸入中取得自己的輸入數據,並將輸出結果寫入標準輸出即可。如果有些問題不能用Java簡便與快速地解決(或者想利用原有程式碼,不想改寫),就可以考慮採用這種方法。亦可使用Java的“固有方法”(Native Method),但那要求更多的技巧,大家可以參考一下附錄A。

(1) C程式

這個非Java應用是用C寫成,因為Java不適合作CGI編程;起碼啟動的時間不能讓人滿意。它的任務是管理電子函件(E-mail)地址的一個列表。標準輸入會接受一個E-mail地址,程式會檢查列表中的名字,判斷是否存在那個地址。若不存在,就將其加入,並報告操作成功。但假如名字已在列表裡了,就需要指出這一點,避免重複加入。大家不必擔心自己不能完全理解下列程式碼的含義。它僅僅是一個演示程式,告訴你如何用其他語言寫一個程式,並從Java中呼叫它。在這裡具體採用何種語言並不重要,只要能夠從標準輸入中讀取數據,並能寫入標準輸出即可。

//: Listmgr.c
// Used by NameCollector.java to manage
// the email list file on the server
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define BSIZE 250

int alreadyInList(FILE* list, char* name) {
  char lbuf[BSIZE];
  // Go to the beginning of the list:
  fseek(list, 0, SEEK_SET);
  // Read each line in the list:
  while(fgets(lbuf, BSIZE, list)) {
    // Strip off the newline:
    char * newline = strchr(lbuf, '\n');
    if(newline != 0)
      *newline = '\0';
    if(strcmp(lbuf, name) == 0)
      return 1;
  }
  return 0;
}

int main() {
  char buf[BSIZE];
  FILE* list = fopen("emlist.txt", "a+t");
  if(list == 0) {
    perror("could not open emlist.txt");
    exit(1);
  }
  while(1) {
    gets(buf); /* From stdin */
    if(alreadyInList(list, buf)) {
      printf("Already in list: %s", buf);
      fflush(stdout);
    }
    else {
      fseek(list, 0, SEEK_END);
      fprintf(list, "%s\n", buf);
      fflush(list);
      printf("%s added to list", buf);
      fflush(stdout);
    }
  }
} ///:~

該程式假設C編譯器能接受//樣式註釋(許多編譯器都能,亦可換用一個C++編譯器來編譯這個程式)。如果你的編譯器不能接受,則簡單地將那些註釋刪掉即可。

文件中的第一個函式檢查我們作為第二個參數(指向一個char的指針)傳遞給它的名字是否已在文件中。在這兒,我們將文件作為一個FILE指針傳遞,它指向一個已打開的文件(文件是在main()中打開的)。函式fseek()在文件中遍歷;我們在這兒用它移至文件開頭。fgets()從文件list中讀入一行內容,並將其置入緩衝區lbuf——不會超過規定的緩衝區長度BSIZE。所有這些工作都在一個while循環中進行,所以文件中的每一行都會讀入。接下來,用strchr()找到新行字符,以便將其刪掉。最後,用strcmp()比較我們傳遞給函式的名字與文件中的當前行。若找到一致的內容,strcmp()會返回0。函式隨後會退出,並返回一個1,指出該名字已經在文件裡了(注意這個函式找到相符內容後會立即返回,不會把時間浪費在檢查列表剩餘內容的上面)。如果找遍列表都沒有發現相符的內容,則函式返回0。

main()中,我們用fopen()打開文件。第一個參數是文件名,第二個是打開文件的方式;a+表示“追加”,以及“打開”(或“創建”,假若文件尚不存在),以便到文件的末尾進行更新。fopen()函式返回的是一個FILE指針;若為0,表示打開操作失敗。此時需要用perror()打印一條出錯提示訊息,並用exit()中止程式運行。

如果文件成功打開,程式就會進入一個無限循環。呼叫gets(buf)的函式會從標準輸入中取出一行(記住標準輸入會與Java程式連接到一起),並將其置入緩衝區buf中。緩衝區的內容隨後會簡單地傳遞給alreadyInList()函式,如內容已在列表中,printf()就會將那則訊息發給標準輸出(Java程式正在監視它)。fflush()用於對輸出緩衝區進行刷新。

如果名字不在列表中,就用fseek()移到列表末尾,並用fprintf()將名字“打印”到列表末尾。隨後,用printf()指出名字已成功加入列表(同樣需要刷新標準輸出),無限循環返回,繼續等候一個新名字的進入。

記住一般不能先在自己的計算機上編譯此程式,再把編譯好的內容上載到Web服務器,因為那臺機器使用的可能是不同類別的處理器和操作系統。例如,我的Web服務器安裝的是Intel的CPU,但操作系統是Linux,所以必須先下載源碼,再用遠程命令(通過telnet)指揮Linux自帶的C編譯器,令其在服務器端編譯好程式。

(2) Java程式

這個程式先啟動上述的C程式,再建立必要的連接,以便同它“交談”。隨後,它創建一個數據報套接字,用它“監視”或者“監聽”來自程式片的數據報包。

//: NameCollector.java
// Extracts email names from datagrams and stores
// them inside a file, using Java 1.02.
import java.net.*;
import java.io.*;
import java.util.*;

public class NameCollector {
  final static int COLLECTOR_PORT = 8080;
  final static int BUFFER_SIZE = 1000;
  byte[] buf = new byte[BUFFER_SIZE];
  DatagramPacket dp =
    new DatagramPacket(buf, buf.length);
  // Can listen & send on the same socket:
  DatagramSocket socket;
  Process listmgr;
  PrintStream nameList;
  DataInputStream addResult;
  public NameCollector() {
    try {
      listmgr =
        Runtime.getRuntime().exec("listmgr.exe");
      nameList = new PrintStream(
        new BufferedOutputStream(
          listmgr.getOutputStream()));
      addResult = new DataInputStream(
        new BufferedInputStream(
          listmgr.getInputStream()));

    } catch(IOException e) {
      System.err.println(
        "Cannot start listmgr.exe");
      System.exit(1);
    }
    try {
      socket =
        new DatagramSocket(COLLECTOR_PORT);
      System.out.println(
        "NameCollector Server started");
      while(true) {
        // Block until a datagram appears:
        socket.receive(dp);
        String rcvd = new String(dp.getData(),
            0, 0, dp.getLength());
        // Send to listmgr.exe standard input:
        nameList.println(rcvd.trim());
        nameList.flush();
        byte[] resultBuf = new byte[BUFFER_SIZE];
        int byteCount =
          addResult.read(resultBuf);
        if(byteCount != -1) {
          String result =
            new String(resultBuf, 0).trim();
          // Extract the address and port from
          // the received datagram to find out
          // where to send the reply:
          InetAddress senderAddress =
            dp.getAddress();
          int senderPort = dp.getPort();
          byte[] echoBuf = new byte[BUFFER_SIZE];
          result.getBytes(
            0, byteCount, echoBuf, 0);
          DatagramPacket echo =
            new DatagramPacket(
              echoBuf, echoBuf.length,
              senderAddress, senderPort);
          socket.send(echo);
        }
        else
          System.out.println(
            "Unexpected lack of result from " +
            "listmgr.exe");
      }
    } catch(SocketException e) {
      System.err.println("Can't open socket");
      System.exit(1);
    } catch(IOException e) {
      System.err.println("Communication error");
      e.printStackTrace();
    }
  }
  public static void main(String[] args) {
    new NameCollector();
  }
} ///:~

NameCollector中的第一個定義應該是大家所熟悉的:選定端口,創建一個數據報包,然後創建指向一個DatagramSocket的引用。接下來的三個定義負責與C程式的連接:一個Process物件是C程式由Java程式啟動之後返回的,而且那個Process物件產生了InputStreamOutputStream,分別代表C程式的標準輸出和標準輸入。和Java IO一樣,它們理所當然地需要“封裝”起來,所以我們最後得到的是一個PrintStreamDataInputStream

這個程式的所有工作都是在建構子內進行的。為啟動C程式,需要取得當前的Runtime物件。我們用它呼叫exec(),再由後者返回Process物件。在Process物件中,大家可看到通過一簡單的呼叫即可生成數據流:getOutputStream()getInputStream()。從這個時候開始,我們需要考慮的全部事情就是將數據傳給數據流nameList,並從addResult中取得結果。

和往常一樣,我們將DatagramSocket同一個端口連接到一起。在無限while循環中,程式會呼叫receive()——除非一個數據報到來,否則receive()會一起處於“堵塞”狀態。數據報出現以後,它的內容會提取到String rcvd裡。我們首先將該字符串兩頭的空格剔除(trim),再將其發給C程式。如下所示:

nameList.println(rcvd.trim());

之所以能這樣編碼,是因為Java的exec()允許我們訪問任何可執行模塊,只要它能從標準輸入中讀,並能向標準輸出中寫。還有另一些方式可與非Java程式碼“交談”,這將在附錄A中討論。

從C程式中捕獲結果就顯得稍微麻煩一些。我們必須呼叫read(),並提供一個緩衝區,以便保存結果。read()的返回值是來自C程式的字節數。若這個值為-1,意味著某個地方出現了問題。否則,我們就將resultBuf(結果緩衝區)轉換成一個字符串,然後同樣清除多餘的空格。隨後,這個字符串會象往常一樣進入一個DatagramPacket,並傳回當初發出請求的那個同樣的地址。注意發送方的地址也是我們接收到的DatagramPacket的一部分。

記住儘管C程式必須在Web服務器上編譯,但Java程式的編譯場所可以是任意的。這是由於不管使用的是什麼硬件平臺和操作系統,編譯得到的字節碼都是一樣的。就就是Java的“跨平臺”兼容能力。

15.5.2 NameSender程式片

正如早先指出的那樣,程式片必須用Java 1.0編寫,使其能與絕大多數的瀏覽器適應。也正是由於這個原因,我們產生的類別數量應儘可能地少。所以我們在這兒不考慮使用前面設計好的Dgram類別,而將數據報的所有維護工作都轉到程式碼行中進行。此外,程式片要用一個執行緒監視由服務器傳回的響應信息,而非實現Runnable界面,用集成到程式片的一個獨立執行緒來做這件事情。當然,這樣做對程式碼的可讀性不利,但卻能產生一個單類別(以及單個服務器請求)程式片:

//: NameSender.java
// An applet that sends an email address
// as a datagram, using Java 1.02.
import java.awt.*;
import java.applet.*;
import java.net.*;
import java.io.*;

public class NameSender extends Applet
    implements Runnable {
  private Thread pl = null;
  private Button send = new Button(
    "Add email address to mailing list");
  private TextField t = new TextField(
    "type your email address here", 40);
  private String str = new String();
  private Label
    l = new Label(), l2 = new Label();
  private DatagramSocket s;
  private InetAddress hostAddress;
  private byte[] buf =
    new byte[NameCollector.BUFFER_SIZE];
  private DatagramPacket dp =
    new DatagramPacket(buf, buf.length);
  private int vcount = 0;
  public void init() {
    setLayout(new BorderLayout());
    Panel p = new Panel();
    p.setLayout(new GridLayout(2, 1));
    p.add(t);
    p.add(send);
    add("North", p);
    Panel labels = new Panel();
    labels.setLayout(new GridLayout(2, 1));
    labels.add(l);
    labels.add(l2);
    add("Center", labels);
    try {
      // Auto-assign port number:
      s = new DatagramSocket();
      hostAddress = InetAddress.getByName(
        getCodeBase().getHost());
    } catch(UnknownHostException e) {
      l.setText("Cannot find host");
    } catch(SocketException e) {
      l.setText("Can't open socket");
    }
    l.setText("Ready to send your email address");
  }
  public boolean action (Event evt, Object arg) {
    if(evt.target.equals(send)) {
      if(pl != null) {
        // pl.stop(); Deprecated in Java 1.2
        Thread remove = pl;
        pl = null;
        remove.interrupt();
      }
      l2.setText("");
      // Check for errors in email name:
      str = t.getText().toLowerCase().trim();
      if(str.indexOf(' ') != -1) {
        l.setText("Spaces not allowed in name");
        return true;
      }
      if(str.indexOf(',') != -1) {
        l.setText("Commas not allowed in name");
        return true;
      }
      if(str.indexOf('@') == -1) {
        l.setText("Name must include '@'");
        l2.setText("");
        return true;
      }
      if(str.indexOf('@') == 0) {
        l.setText("Name must preceed '@'");
        l2.setText("");
        return true;
      }
      String end =
        str.substring(str.indexOf('@'));
      if(end.indexOf('.') == -1) {
        l.setText("Portion after '@' must " +
          "have an extension, such as '.com'");
        l2.setText("");
        return true;
      }
      // Everything's OK, so send the name. Get a
      // fresh buffer, so it's zeroed. For some
      // reason you must use a fixed size rather
      // than calculating the size dynamically:
      byte[] sbuf =
        new byte[NameCollector.BUFFER_SIZE];
      str.getBytes(0, str.length(), sbuf, 0);
      DatagramPacket toSend =
        new DatagramPacket(
          sbuf, 100, hostAddress,
          NameCollector.COLLECTOR_PORT);
      try {
        s.send(toSend);
      } catch(Exception e) {
        l.setText("Couldn't send datagram");
        return true;
      }
      l.setText("Sent: " + str);
      send.setLabel("Re-send");
      pl = new Thread(this);
      pl.start();
      l2.setText(
        "Waiting for verification " + ++vcount);
    }
    else return super.action(evt, arg);
    return true;
  }
  // The thread portion of the applet watches for
  // the reply to come back from the server:
  public void run() {
    try {
      s.receive(dp);
    } catch(Exception e) {
      l2.setText("Couldn't receive datagram");
      return;
    }
    l2.setText(new String(dp.getData(),
      0, 0, dp.getLength()));
  }
} ///:~

程式片的UI(用戶界面)非常簡單。它包含了一個TestField(文本字段),以便我們鍵入一個電子函件地址;以及一個Button(按鈕),用於將地址發給服務器。兩個Label(標籤)用於向用戶報告狀態信息。

到現在為止,大家已能判斷出DatagramSocketInetAddress、緩衝區以及DatagramPacket都屬於網絡連接中比較麻煩的部分。最後,大家可看到run()方法實現了執行緒部分,使程式片能夠“監聽”由服務器傳回的響應信息。

init()方法用大家熟悉的佈局工具設置GUI,然後創建DatagramSocket,它將同時用於數據報的收發。

action()方法只負責監視我們是否按下了“發送”(send)按鈕。記住,我們已被限制在Java 1.0上面,所以不能再用較靈活的內部類別了。按鈕按下以後,採取的第一項行動便是檢查執行緒pl,看看它是否為null(空)。如果不為null,表明有一個活動執行緒正在運行。訊息首次發出時,會啟動一個新執行緒,用它監視來自服務器的迴應。所以假若有個執行緒正在運行,就意味著這並非用戶第一次發送訊息。pl引用被設為null,同時中止原來的監視者(這是最合理的一種做法,因為stop()已被Java 1.2“反對”,這在前一章已解釋過了)。

無論這是否按鈕被第一次按下,I2中的文字都會清除。

下一組語句將檢查E-mail名字是否合格。String.indexOf()方法的作用是搜索其中的非法字符。如果找到一個,就把情況報告給用戶。注意進行所有這些工作時,都不必涉及網絡通信,所以速度非常快,而且不會影響帶寬和服務器的性能。

名字校驗通過以後,它會打包到一個數據報裡,然後採用與前面那個數據報示例一樣的方式發到主機地址和端口編號。第一個標籤會發生變化,指出已成功發送出去。而且按鈕上的文字也會改變,變成“重發”(resend)。這時會啟動執行緒,第二個標籤則會告訴我們程式片正在等候來自服務器的迴應。

執行緒的run()方法會利用NameSender中包含的DatagramSocket來接收數據(receive()),除非出現來自服務器的數據報包,否則receive()會暫時處於“堵塞”或者“暫停”狀態。結果得到的數據包會放進NameSenderDatagramPacketdp中。數據會從包中提取出來,並置入NameSender的第二個標籤。隨後,執行緒的執行將中斷,成為一個“死”執行緒。若某段時間裡沒有收到來自服務器的迴應,用戶可能變得不耐煩,再次按下按鈕。這樣做會中斷當前執行緒(數據發出以後,會再建一個新的)。由於用一個執行緒來監視迴應數據,所以用戶在監視期間仍然可以自由使用UI。

(1) Web頁

當然,程式片必須放到一個Web頁裡。下面列出完整的Web頁源碼;稍微研究一下就可看出,我用它從自己開辦的郵寄列表(Mailling List)裡自動收集名字。

<HTML>
<HEAD>
<META CONTENT="text/html">
<TITLE>
Add Yourself to Bruce Eckel's Java Mailing List
</TITLE>
</HEAD>
<BODY LINK="#0000ff" VLINK="#800080" BGCOLOR="#ffffff">
<FONT SIZE=6><P>
Add Yourself to Bruce Eckel's Java Mailing List
</P></FONT>
The applet on this page will automatically add your email address to the mailing list, so you will receive update information about changes to the online version of "Thinking in Java," notification when the book is in print, information about upcoming Java seminars, and notification about the “Hands-on Java Seminar” Multimedia CD. Type in your email address and press the button to automatically add yourself to this mailing list. <HR>
<applet code=NameSender width=400 height=100>
</applet>
<HR>
If after several tries, you do not get verification it means that the Java application on the server is having problems. In this case, you can add yourself to the list by sending email to
<A HREF="mailto:[email protected]">
[email protected]</A>
</BODY>
</HTML>

程式片標記(<applet>)的使用非常簡單,和第13章展示的那一個並沒有什麼區別。

15.5.3 要注意的問題

前面採取的似乎是一種完美的方法。沒有CGI編程,所以在服務器啟動一個CGI程式時不會出現延遲。數據報方式似乎能產生非常快的響應。此外,一旦Java 1.1得到絕大多數人的採納,服務器端的那一部分就可完全用Java編寫(儘管利用標準輸入和輸出同一個非Java程式連接也非常容易)。

但必須注意到一些問題。其中一個特別容易忽略:由於Java應用在服務器上是連續運行的,而且會把大多數時間花在Datagram.receive()方法的等候上面,這樣便為CPU帶來了額外的開銷。至少,我在自己的服務器上便發現了這個問題。另一方面,那個服務器上不會發生其他更多的事情。而且假如我們使用一個任務更為繁重的服務器,啟動程式用nice(一個Unix程式,用於防止進程貪吃CPU資源)或其他等價程式即可解決問題。在許多情況下,都有必要留意象這樣的一些應用——一個堵塞的receive()完全可能造成CPU的癱瘓。

第二個問題涉及防火牆。可將防火牆理解成自己的本地網與Internet之間的一道牆(實際是一個專用機器或防火牆軟件)。它監視進出Internet的所有通信,確保這些通信不違背預設的規則。

防火牆顯得多少有些保守,要求嚴格遵守所有規則。假如沒有遵守,它們會無情地把它們拒之門外。例如,假設我們位於防火牆後面的一個網絡中,開始用Web瀏覽器同Internet連接,防火牆要求所有傳輸都用可以接受的http端口同服務器連接,這個端口是80。現在來了這個Java程式片NameSender,它試圖將一個數據報傳到端口8080,這是為了越過“受保護”的端口範圍0-1024而設置的。防火牆很自然地把它想象成最壞的情況——有人使用病毒或者非法掃描端口——根本不允許傳輸的繼續進行。

只要我們的客戶建立的是與Internet的原始連接(比如通過典型的ISP接駁Internet),就不會出現此類別防火牆問題。但也可能有一些重要的客戶隱藏在防火牆後,他們便不能使用我們設計的程式。

在學過有關Java的這麼多東西以後,這是一件使人相當沮喪的事情,因為看來必須放棄在服務器上使用Java,改為學習如何編寫C或Perl腳本程式。但請大家不要絕望。

一個出色方法是由Sun公司提出的。如一切按計劃進行,Web服務器最終都裝備“小服務程式”或者“服務程式片”(Servlet)。它們負責接收來自客戶的請求(經過防火牆允許的80端口)。而且不再是啟動一個CGI程式,它們會啟動小服務程式。根據Sun的設想,這些小服務程式都是用Java編寫的,而且只能在服務器上運行。運行這種小程式的服務器會自動啟動它們,令其對客戶的請求進行處理。這意味著我們的所有程式都可以用Java寫成(100%純咖啡)。這顯然是一種非常吸引人的想法:一旦習慣了Java,就不必換用其他語言在服務器上處理客戶請求。

由於只能在服務器上控制請求,所以小服務程式API沒有提供GUI功能。這對NameCollector.java來說非常適合,它本來就不需要任何圖形界面。

在本書寫作時,java.sun.com已提供了一個非常廉價的小服務程式專用服務器。Sun鼓勵其他Web服務器開發者為他們的服務器軟件產品加入對小服務程式的支持。

15.6 Java與CGI的溝通

Java程式可向一個服務器發出一個CGI請求,這與HTML表單頁沒什麼兩樣。而且和HTML頁一樣,這個請求既可以設為GET(下載),亦可設為POST(上傳)。除此以外,Java程式還可攔截CGI程式的輸出,所以不必依賴程式來格式化一個新頁,也不必在出錯的時候強迫用戶從一個頁迴轉到另一個頁。事實上,程式的外觀可以做得跟以前的版本別無二致。

程式碼也要簡單一些,畢竟用CGI也不是很難就能寫出來(前提是真正地理解它)。所以在這一節裡,我們準備辦個CGI編程速成班。為解決常規問題,將用C++創建一些CGI工具,以便我們編寫一個能解決所有問題的CGI程式。這樣做的好處是移植能力特別強——即將看到的例子能在支持CGI的任何系統上運行,而且不存在防火牆的問題。

這個例子也闡示瞭如何在程式片(Applet)和CGI程式之間建立連接,以便將其方便地改編到自己的項目中。

15.6.1 CGI數據的編碼

在這個版本中,我們將收集名字和電子函件地址,並用下述形式將其保存到文件中:

First Last <[email protected]>;

這對任何E-mail程式來說都是一種非常方便的格式。由於只需收集兩個字段,而且CGI為字段中的編碼採用了一種特殊的格式,所以這裡沒有簡便的方法。如果自己動手編制一個原始的HTML頁,並加入下述程式碼行,即可正確地理解這一點:

<Form method="GET" ACTION="/cgi-bin/Listmgr2.exe">
<P>Name: <INPUT TYPE = "text" NAME = "name"
VALUE = "" size = "40"></p>
<P>Email Address: <INPUT TYPE = "text"
NAME = "email" VALUE = "" size = "40"></p>
<p><input type = "submit" name = "submit" > </p>
</Form>

上述程式碼創建了兩個數據輸入字段(區),名為nameemail。另外還有一個submit(提交)按鈕,用於收集數據,並將其發給CGI程式。Listmgr2.exe是駐留在特殊程式目錄中的一個可執行文件。在我們的Web服務器上,該目錄一般都叫作cgi-bin(註釋③)。如果在那個目錄裡找不到該程式,結果就無法出現。填好這個表單,然後按下提交按鈕,即可在瀏覽器的URL地址窗口裡看到象下面這樣的內容:

http://www.myhome.com/cgi-bin/Listmgr2.exe?name=First+Last&[email protected]&submit=Submit

③:在Windows32平臺下,可利用與Microsoft Office 97或其他產品配套提供的Microsoft Personal Web Server(微軟個人Web服務器)進行測試。這是進行試驗的最好方法,因為不必正式連入網絡,可在本地環境中完成測試(速度也非常快)。如果使用的是不同的平臺,或者沒有Office 97或者FrontPage 98那樣的產品,可到網上找一個免費的Web服務器供自己測試。

當然,上述URL實際顯示時是不會拆行的。從中可稍微看出如何對數據編碼並傳給CGI。至少有一件事情能夠肯定——空格是不允許的(因為它通常用於分隔命令行參數)。所有必需的空格都用“+”號替代,每個字段都包含了字段名(具體由HTML頁決定),後面跟隨一個=號以及正式的字段數據,最後用一個&結束。

到這時,大家也許會對+=以及&的使用產生疑惑。假如必須在字段裡使用這些字符,那麼該如何聲明呢?例如,我們可能使用“John & MarshaSmith”這個名字,其中的&代表“And”。事實上,它會編碼成下面這個樣子:

John+%26+Marsha+Smith

也就是說,特殊字符會轉換成一個%,並在後面跟上它的十六進制ASCII編碼。

幸運的是,Java有一個工具來幫助我們進行這種編碼。這是URLEncoder類別的一個靜態方法,名為encode()。可用下述程式來試驗這個方法:

//: EncodeDemo.java
// Demonstration of URLEncoder.encode()
import java.net.*;

public class EncodeDemo {
  public static void main(String[] args) {
    String s = "";
    for(int i = 0; i < args.length; i++)
      s += args[i] + " ";
    s = URLEncoder.encode(s.trim());
    System.out.println(s);
  }
} ///:~

該程式將獲取一些命令行參數,把它們合併成一個由多個詞構成的字符串,各詞之間用空格分隔(最後一個空格用String.trim()剔除了)。隨後對它們進行編碼,並打印出來。

為呼叫一個CGI程式,程式片要做的全部事情就是從自己的字段或其他地方收集數據,將所有數據都編碼成正確的URL樣式,然後彙編到單獨一個字符串裡。每個字段名後面都加上一個=符號,緊跟正式數據,再緊跟一個&。為建構完整的CGI命令,我們將這個字符串置於CGI程式的URL以及一個?後。這是呼叫所有CGI程式的標準方法。大家馬上就會看到,用一個程式片能夠很輕鬆地完成所有這些編碼與合併。

15.6.2 程式片

程式片實際要比NameSender.java簡單一些。這部分是由於很容易即可發出一個GET請求。此外,也不必等候回覆信息。現在有兩個字段,而非一個,但大家會發現許多程式片都是熟悉的,請比較NameSender.java

//: NameSender2.java
// An applet that sends an email address
// via a CGI GET, using Java 1.02.
import java.awt.*;
import java.applet.*;
import java.net.*;
import java.io.*;

public class NameSender2 extends Applet {
  final String CGIProgram = "Listmgr2.exe";
  Button send = new Button(
    "Add email address to mailing list");
  TextField name = new TextField(
    "type your name here", 40),
    email = new TextField(
    "type your email address here", 40);
  String str = new String();
  Label l = new Label(), l2 = new Label();
  int vcount = 0;
  public void init() {
    setLayout(new BorderLayout());
    Panel p = new Panel();
    p.setLayout(new GridLayout(3, 1));
    p.add(name);
    p.add(email);
    p.add(send);
    add("North", p);
    Panel labels = new Panel();
    labels.setLayout(new GridLayout(2, 1));
    labels.add(l);
    labels.add(l2);
    add("Center", labels);
    l.setText("Ready to send email address");
  }
  public boolean action (Event evt, Object arg) {
    if(evt.target.equals(send)) {
      l2.setText("");
      // Check for errors in data:
      if(name.getText().trim()
         .indexOf(' ') == -1) {
        l.setText(
          "Please give first and last name");
        l2.setText("");
        return true;
      }
      str = email.getText().trim();
      if(str.indexOf(' ') != -1) {
        l.setText(
          "Spaces not allowed in email name");
        l2.setText("");
        return true;
      }
      if(str.indexOf(',') != -1) {
        l.setText(
          "Commas not allowed in email name");
        return true;
      }
      if(str.indexOf('@') == -1) {
        l.setText("Email name must include '@'");
        l2.setText("");
        return true;
      }
      if(str.indexOf('@') == 0) {
        l.setText(
          "Name must preceed '@' in email name");
        l2.setText("");
        return true;
      }
      String end =
        str.substring(str.indexOf('@'));
      if(end.indexOf('.') == -1) {
        l.setText("Portion after '@' must " +
          "have an extension, such as '.com'");
        l2.setText("");
        return true;
      }
      // Build and encode the email data:
      String emailData =
        "name=" + URLEncoder.encode(
          name.getText().trim()) +
        "&email=" + URLEncoder.encode(
          email.getText().trim().toLowerCase()) +
        "&submit=Submit";
      // Send the name using CGI's GET process:
      try {
        l.setText("Sending...");
        URL u = new URL(
          getDocumentBase(), "cgi-bin/" +
          CGIProgram + "?" + emailData);
        l.setText("Sent: " + email.getText());
        send.setLabel("Re-send");
        l2.setText(
          "Waiting for reply " + ++vcount);
        DataInputStream server =
          new DataInputStream(u.openStream());
        String line;
        while((line = server.readLine()) != null)
          l2.setText(line);
      } catch(MalformedURLException e) {
        l.setText("Bad URl");
      } catch(IOException e) {
        l.setText("IO Exception");
      }
    }
    else return super.action(evt, arg);
    return true;
  }
} ///:~

CGI程式(不久即可看到)的名字是Listmgr2.exe。許多Web服務器都在Unix機器上運行(Linux也越來越受到青睞)。根據傳統,它們一般不為自己的可執行程式採用.exe擴展名。但在Unix操作系統中,可以把自己的程式稱呼為自己希望的任何東西。若使用的是.exe擴展名,程式毋需任何修改即可通過Unix和Win32的運行測試。

和往常一樣,程式片設置了自己的用戶界面(這次是兩個輸入字段,不是一個)。唯一顯著的區別是在action()方法內產生的。該方法的作用是對按鈕按下事件進行控制。名字檢查過以後,大家會發現下述程式碼行:

      String emailData =
        "name=" + URLEncoder.encode(
          name.getText().trim()) +
        "&email=" + URLEncoder.encode(
          email.getText().trim().toLowerCase()) +
        "&submit=Submit";
      // Send the name using CGI's GET process:
      try {
        l.setText("Sending...");
        URL u = new URL(
          getDocumentBase(), "cgi-bin/" +
          CGIProgram + "?" + emailData);
        l.setText("Sent: " + email.getText());
        send.setLabel("Re-send");
        l2.setText(
          "Waiting for reply " + ++vcount);
        DataInputStream server =
          new DataInputStream(u.openStream());
        String line;
        while((line = server.readLine()) != null)
          l2.setText(line);
        // ...

nameemail數據都是它們對應的文字框裡提取出來,而且兩端多餘的空格都用trim()剔去了。為了進入列表,email名字被強制換成小寫形式,以便能夠準確地對比(防止基於大小寫形式的錯誤判斷)。來自每個字段的數據都編碼為URL形式,隨後採用與HTML頁中一樣的方式彙編GET字符串(這樣一來,我們可將Java程式片與現有的任何CGI程式結合使用,以滿足常規的HTML GET請求)。

到這時,一些Java的魔力已經開始發揮作用了:如果想同任何URL連接,只需創建一個URL物件,並將地址傳遞給建構子即可。建構子會負責建立同服務器的連接(對Web服務器來說,所有連接行動都是根據作為URL使用的字符串來判斷的)。就目前這種情況來說,URL指向的是當前Web站點的cgi-bin目錄(當前Web站點的基礎地址是用getDocumentBase()設定的)。一旦Web服務器在URL中看到了一個cgi-bin,會接著希望在它後面跟隨了cgi-bin目錄內的某個程式的名字,那是我們要運行的目標程式。程式名後面是一個問號以及CGI程式會在QUERY_STRING環境變量中查找的一個參數字符串(馬上就要學到)。

我們發出任何形式的請求後,一般都會得到一個迴應的HTML頁。但若使用Java的URL物件,我們可以攔截自CGI程式傳回的任何東西,只需從URL物件裡取得一個InputStream(輸入數據流)即可。這是用URL物件的openStream()方法實現,它要封裝到一個DataInputStream裡。隨後就可以讀取數據行,若readLine()返回一個null(空值),就表明CGI程式已結束了它的輸出。 我們即將看到的CGI程式返回的僅僅是一行,它是用於標誌成功與否(以及失敗的具體原因)的一個字符串。這一行會被捕獲並置放第二個Label字段裡,使用戶看到具體發生了什麼事情。

(1) 從程式片裡顯示一個Web頁

程式亦可將CGI程式的結果作為一個Web頁顯示出來,就象它們在普通HTML模式中運行那樣。可用下述程式碼做到這一點:

getAppletContext().showDocument(u);

其中,u代表URL物件。這是將我們重新定向於另一個Web頁的一個簡單例子。那個頁湊巧是一個CGI程式的輸出,但可以非常方便地進入一個原始的HTML頁,所以可以建構這個程式片,令其產生一個由密碼保護的網關,通過它進入自己Web站點的特殊部分:

//: ShowHTML.java
import java.awt.*;
import java.applet.*;
import java.net.*;
import java.io.*;

public class ShowHTML extends Applet {
  static final String CGIProgram = "MyCGIProgram";
  Button send = new Button("Go");
  Label l = new Label();
  public void init() {
    add(send);
    add(l);
  }
  public boolean action (Event evt, Object arg) {
    if(evt.target.equals(send)) {
      try {
        // This could be an HTML page instead of
        // a CGI program. Notice that this CGI
        // program doesn't use arguments, but
        // you can add them in the usual way.
        URL u = new URL(
          getDocumentBase(),
          "cgi-bin/" + CGIProgram);
        // Display the output of the URL using
        // the Web browser, as an ordinary page:
        getAppletContext().showDocument(u);
      } catch(Exception e) {
        l.setText(e.toString());
      }
    }
    else return super.action(evt, arg);
    return true;
  }
} ///:~

URL類別的最大的特點就是有效地保護了我們的安全。可以同一個Web服務器建立連接,毋需知道幕後的任何東西。

15.6.3 用C++寫的CGI程式

經過前面的學習,大家應該能夠根據例子用ANSI C為自己的服務器寫出CGI程式。之所以選用ANSI C,是因為它幾乎隨處可見,是最流行的C語言標準。當然,現在的C++也非常流行了,特別是採用GNU C++編譯器(g++)形式的那一些(註釋④)。可從網上許多地方免費下載g++,而且可選用幾乎所有平臺的版本(通常與Linux那樣的操作系統配套提供,且已預先安裝好)。正如大家即將看到的那樣,從CGI程式可獲得物件導向程式設計的許多好處。

④:GNU的全稱是“Gnu's Not Unix”。這最早是由“自由軟件基金會”(FSF)負責開發的一個項目,致力於用一個免費的版本取代原有的Unix操作系統。現在的Linux似乎正在做前人沒有做到的事情。但GNU工具在Linux的開發中扮演了至關重要的角色。事實上,Linux的整套軟件包附帶了數量非常多的GNU組件。

為避免第一次就提出過多的新概念,這個程式並未打算成為一個“純”C++程式;有些程式碼是用普通C寫成的——儘管還可選用C++的一些替用形式。但這並不是個突出的問題,因為該程式用C++製作最大的好處就是能夠創建類別。在解析CGI信息的時候,由於我們最關心的是字段的“名稱/值”對,所以要用一個類別(Pair)來代表單個名稱/值對;另一個類別(CGI_vector)則將CGI字符串自動解析到它會容納的Pair物件裡(作為一個vector),這樣即可在有空的時候把每個Pair(對)都取出來。

這個程式同時也非常有趣,因為它演示了C++與Java相比的許多優缺點。大家會看到一些相似的東西;比如class關鍵字。訪問控制使用的是完全相同的關鍵字publicprivate,但用法卻有所不同。它們控制的是一個塊,而非單個方法或字段(也就是說,如果指定private:,後續的每個定義都具有private屬性,直到我們再指定public:為止)。另外在創建一個類別的時候,所有定義都自動默認為private

在這兒使用C++的一個原因是要利用C++“標準模板庫”(STL)提供的便利。至少,STL包含了一個vector類別。這是一個C++模板,可在編譯期間進行配置,令其只容納一種特定類別型的物件(這裡是Pair物件)。和Java的Vector不同,如果我們試圖將除Pair物件之外的任何東西置入vector,C++的vector模板都會造成一個編譯期錯誤;而Java的Vector能夠照單全收。而且從vector裡取出什麼東西的時候,它會自動成為一個Pair物件,毋需進行轉換處理。所以檢查在編譯期進行,這使程式顯得更為“健壯”。此外,程式的運行速度也可以加快,因為沒有必要進行運行期間的轉換。vector也會重載operator[],所以可以利用非常方便的語法來提取Pair物件。vector模板將在CGI_vector創建時使用;在那時,大家就可以體會到如此簡短的一個定義居然蘊藏有那麼巨大的能量。

若提到缺點,就一定不要忘記Pair在下列程式碼中定義時的複雜程度。與我們在Java程式碼中看到的相比,Pair的方法定義要多得多。這是由於C++的程式員必須提前知道如何用副本建構子控制複製過程,而且要用重載的operator=完成賦值。正如第12章解釋的那樣,我們有時也要在Java中考慮同樣的事情。但在C++中,幾乎一刻都不能放鬆對這些問題的關注。

這個項目首先創建一個可以重複使用的部分,由C++頭文件中的PairCGI_vector構成。從技術角度看,確實不應把這些東西都塞到一個頭文件裡。但就目前的例子來說,這樣做不會造成任何方面的損害,而且更具有Java風格,所以大家閱讀理解程式碼時要顯得輕鬆一些:

//: CGITools.h
// Automatically extracts and decodes data
// from CGI GETs and POSTs. Tested with GNU C++
// (available for most server machines).
#include <string.h>
#include <vector> // STL vector
using namespace std;

// A class to hold a single name-value pair from
// a CGI query. CGI_vector holds Pair objects and
// returns them from its operator[].
class Pair {
  char* nm;
  char* val;
public:
  Pair() { nm = val = 0; }
  Pair(char* name, char* value) {
    // Creates new memory:
    nm = decodeURLString(name);
    val = decodeURLString(value);
  }
  const char* name() const { return nm; }
  const char* value() const { return val; }
  // Test for "emptiness"
  bool empty() const {
    return (nm == 0) || (val == 0);
  }
  // Automatic type conversion for boolean test:
  operator bool() const {
    return (nm != 0) && (val != 0);
  }
  // The following constructors & destructor are
  // necessary for bookkeeping in C++.
  // Copy-constructor:
  Pair(const Pair& p) {
    if(p.nm == 0 || p.val == 0) {
      nm = val = 0;
    } else {
      // Create storage & copy rhs values:
      nm = new char[strlen(p.nm) + 1];
      strcpy(nm, p.nm);
      val = new char[strlen(p.val) + 1];
      strcpy(val, p.val);
    }
  }
  // Assignment operator:
  Pair& operator=(const Pair& p) {
    // Clean up old lvalues:
    delete nm;
    delete val;
    if(p.nm == 0 || p.val == 0) {
      nm = val = 0;
    } else {
      // Create storage & copy rhs values:
      nm = new char[strlen(p.nm) + 1];
      strcpy(nm, p.nm);
      val = new char[strlen(p.val) + 1];
      strcpy(val, p.val);
    }
    return *this;
  }
  ~Pair() { // Destructor
    delete nm; // 0 value OK
    delete val;
  }
  // If you use this method outide this class,
  // you're responsible for calling 'delete' on
  // the pointer that's returned:
  static char*
  decodeURLString(const char* URLstr) {
    int len = strlen(URLstr);
    char* result = new char[len + 1];
    memset(result, len + 1, 0);
    for(int i = 0, j = 0; i <= len; i++, j++) {
      if(URLstr[i] == '+')
        result[j] = ' ';
      else if(URLstr[i] == '%') {
        result[j] =
          translateHex(URLstr[i + 1]) * 16 +
          translateHex(URLstr[i + 2]);
        i += 2; // Move past hex code
      } else // An ordinary character
        result[j] = URLstr[i];
    }
    return result;
  }
  // Translate a single hex character; used by
  // decodeURLString():
  static char translateHex(char hex) {
    if(hex >= 'A')
      return (hex & 0xdf) - 'A' + 10;
    else
      return hex - '0';
  }
};

// Parses any CGI query and turns it
// into an STL vector of Pair objects:
class CGI_vector : public vector<Pair> {
  char* qry;
  const char* start; // Save starting position
  // Prevent assignment and copy-construction:
  void operator=(CGI_vector&);
  CGI_vector(CGI_vector&);
public:
  // const fields must be initialized in the C++
  // "Constructor initializer list":
  CGI_vector(char* query) :
      start(new char[strlen(query) + 1]) {
    qry = (char*)start; // Cast to non-const
    strcpy(qry, query);
    Pair p;
    while((p = nextPair()) != 0)
      push_back(p);
  }
  // Destructor:
  ~CGI_vector() { delete start; }
private:
  // Produces name-value pairs from the query
  // string. Returns an empty Pair when there's
  // no more query string left:
  Pair nextPair() {
    char* name = qry;
    if(name == 0 || *name == '\0')
      return Pair(); // End, return null Pair
    char* value = strchr(name, '=');
    if(value == 0)
      return Pair(); // Error, return null Pair
    // Null-terminate name, move value to start
    // of its set of characters:
    *value = '\0';
    value++;
    // Look for end of value, marked by '&':
    qry = strchr(value, '&');
    if(qry == 0) qry = ""; // Last pair found
    else {
      *qry = '\0'; // Terminate value string
      qry++; // Move to next pair
    }
    return Pair(name, value);
  }
}; ///:~

#include語句後,可看到有一行是:

using namespace std;

C++中的“命名空間”(Namespace)解決了由Java的package負責的一個問題:將庫名隱藏起來。std命名空間引用的是標準C++庫,而vector就在這個庫中,所以這一行是必需的。

Pair類別表面看異常簡單,只是容納了兩個(private)字符指針而已——一個用於名字,另一個用於值。默認建構子將這兩個指針簡單地設為零。這是由於在C++中,物件的內存不會自動置零。第二個建構子呼叫方法decodeURLString(),在新分配的堆內存中生成一個解碼過後的字符串。這個內存區域必須由物件負責管理及清除,這與“析構器”中見到的相同。name()value()方法為相關的字段產生只讀指針。利用empty()方法,我們查詢Pair物件它的某個字段是否為空;返回的結果是一個bool——C++內建的基本布爾資料型態。operator bool()使用的是C++“運算子重載”的一種特殊形式。它允許我們控制自動類別型轉換。如果有一個名為pPair物件,而且在一個本來希望是布爾結果的表達式中使用,比如if(p){//...,那麼編譯器能辨別出它有一個Pair,而且需要的是個布爾值,所以自動呼叫operator bool(),進行必要的轉換。

接下來的三個方法屬於常規編碼,在C++中創建類別時必須用到它們。根據C++類別採用的所謂“經典形式”,我們必須定義必要的“原始”建構子,以及一個副本建構子和賦值運算子——operator=(以及析構器,用於清除內存)。之所以要作這樣的定義,是由於編譯器會“默默”地呼叫它們。在物件傳入、傳出一個函式的時候,需要呼叫副本建構子;而在分配物件時,需要呼叫賦值運算子。只有真正掌握了副本建構子和賦值運算子的工作原理,才能在C++裡寫出真正“健壯”的類別,但這需要需要一個比較艱苦的過程(註釋⑤)。

⑤:我的《Thinking in C++》(Prentice-Hall,1995)用了一整章的地方來討論這個主題。若需更多的幫助,請務必看看那一章。

只要將一個物件按值傳入或傳出函式,就會自動呼叫副本建構子Pair(const Pair&)。也就是說,對於準備為其製作一個完整副本的那個物件,我們不準備在函式框架中傳遞它的地址。這並不是Java提供的一個選項,由於我們只能傳遞引用,所以在Java裡沒有所謂的副本建構子(如果想製作一個本地副本,可以“克隆”那個物件——使用clone(),參見第12章)。類別似地,如果在Java裡分配一個引用,它會簡單地複製。但C++中的賦值意味著整個物件都會複製。在副本建構子中,我們創建新的存儲空間,並複製原始數據。但對於賦值運算子,我們必須在分配新存儲空間之前釋放老存儲空間。我們要見到的也許是C++類別最複雜的一種情況,但那正是Java的支持者們論證Java比C++簡單得多的有力證據。在Java中,我們可以自由傳遞引用,善後工作則由垃圾收集器負責,所以可以輕鬆許多。

但事情並沒有完。Pair類別為nmval使用的是char*,最複雜的情況主要是圍繞指針展開的。如果用較時髦的C++ string類別來代替 char* ,事情就要變得簡單得多(當然,並不是所有編譯器都提供了對string的支持)。那麼,Pair的第一部分看起來就象下面這樣:

class Pair {
  string nm;
  string val;
public:
  Pair() { }
  Pair(char* name, char* value) {
    nm = decodeURLString(name);
    val = decodeURLString(value);
  }
  const char* name() const { return nm.c_str(); }
  const char* value() const {
    return val.c_str();
  }
  // Test for "emptiness"
  bool empty() const {
    return (nm.length() == 0)
      || (val.length() == 0);
  }
  // Automatic type conversion for boolean test:
  operator bool() const {
    return (nm.length() != 0)
      && (val.length() != 0);
  }

(此外,對這個類別decodeURLString()會返回一個string,而不是一個char*)。我們不必定義副本建構子、operator=或者析構器,因為編譯器已幫我們做了,而且做得非常好。但即使有些事情是自動進行的,C++程式員也必須瞭解副本建構以及賦值的細節。

Pair類別剩下的部分由兩個方法構成:decodeURLString()以及一個“幫助器”方法translateHex()——將由decodeURLString()使用。注意translateHex()並不能防範用戶的惡意輸入,比如%1H。分配好足夠的存儲空間後(必須由析構器釋放),decodeURLString()就會其中遍歷,將所有+都換成一個空格;將所有十六進制程式碼(以一個%打頭)換成對應的字符。

CGI_vector用於解析和容納整個CGI GET命令。它是從STL vector裡繼承的,後者例示為容納Pair。C++中的繼承是用一個冒號表示,在Java中則要用extends。此外,繼承默認為private屬性,所以幾乎肯定需要用到public關鍵字,就象這樣做的那樣。大家也會發現CGI_vector有一個副本建構子以及一個operator=,但它們都聲明成private。這樣做是為了防止編譯器同步兩個函式(如果不自己聲明它們,兩者就會同步)。但這同時也禁止了客戶程式員按值或者通過賦值傳遞一個CGI_vector

CGI_vector的工作是獲取QUERY_STRING,並把它解析成“名稱/值”對,這需要在Pair的幫助下完成。它首先將字符串複製到本地分配的內存,並用常數指針start跟蹤起始地址(稍後會在析構器中用於釋放內存)。隨後,它用自己的nextPair()方法將字符串解析成原始的“名稱/值”對,各個對之間用一個=&符號分隔。這些對由nextPair()傳遞給Pair建構子,所以nextPair()返回的是一個Pair物件。隨後用push_back()將該物件加入vectornextPair()遍歷完整個QUERY_STRING後,會返回一個零值。

現在基本工具已定義好,它們可以簡單地在一個CGI程式中使用,就象下面這樣:

//: Listmgr2.cpp
// CGI version of Listmgr.c in C++, which
// extracts its input via the GET submission
// from the associated applet. Also works as
// an ordinary CGI program with HTML forms.
#include <stdio.h>
#include "CGITools.h"
const char* dataFile = "list2.txt";
const char* notify = "[email protected]";
#undef DEBUG

// Similar code as before, except that it looks
// for the email name inside of '<>':
int inList(FILE* list, const char* emailName) {
  const int BSIZE = 255;
  char lbuf[BSIZE];
  char emname[BSIZE];
  // Put the email name in '<>' so there's no
  // possibility of a match within another name:
  sprintf(emname, "<%s>", emailName);
  // Go to the beginning of the list:
  fseek(list, 0, SEEK_SET);
  // Read each line in the list:
  while(fgets(lbuf, BSIZE, list)) {
    // Strip off the newline:
    char * newline = strchr(lbuf, '\n');
    if(newline != 0)
      *newline = '\0';
    if(strstr(lbuf, emname) != 0)
      return 1;
  }
  return 0;
}

void main() {
  // You MUST print this out, otherwise the
  // server will not send the response:
  printf("Content-type: text/plain\n\n");
  FILE* list = fopen(dataFile, "a+t");
  if(list == 0) {
    printf("error: could not open database. ");
    printf("Notify %s", notify);
    return;
  }
  // For a CGI "GET," the server puts the data
  // in the environment variable QUERY_STRING:
  CGI_vector query(getenv("QUERY_STRING"));
  #if defined(DEBUG)
  // Test: dump all names and values
  for(int i = 0; i < query.size(); i++) {
    printf("query[%d].name() = [%s], ",
      i, query[i].name());
    printf("query[%d].value() = [%s]\n",
      i, query[i].value());
  }
  #endif(DEBUG)
  Pair name = query[0];
  Pair email = query[1];
  if(name.empty() || email.empty()) {
    printf("error: null name or email");
    return;
  }
  if(inList(list, email.value())) {
    printf("Already in list: %s", email.value());
    return;
  }
  // It's not in the list, add it:
  fseek(list, 0, SEEK_END);
  fprintf(list, "%s <%s>;\n",
    name.value(), email.value());
  fflush(list);
  fclose(list);
  printf("%s <%s> added to list\n",
    name.value(), email.value());
} ///:~

alreadyInList()函式與前一個版本幾乎是完全相同的,只是它假定所有電子函件地址都在一個<>內。

在使用GET方法時(通過在FORM引導命令的METHOD標記內部設置,但這在這裡由數據發送的方式控制),Web服務器會收集位於?後面的所有信息,並把它們置入環境變量QUERY_STRING(查詢字符串)裡。所以為了讀取那些信息,必須獲得QUERY_STRING的值,這是用標準的C庫函式getenv()完成的。在main()中,注意對QUERY_STRING的解析有多麼容易:只需把它傳遞給用於CGI_vector物件的建構子(名為query),剩下的所有工作都會自動進行。從這時開始,我們就可以從query中取出名稱和值,把它們當作數組看待(這是由於operator[]vector裡已經重載了)。在調試程式碼中,大家可看到這一切是如何運作的;調試程式碼封裝在預處理器引導命令#if defined(DEBUG)#endif(DEBUG)之間。

現在,我們迫切需要掌握一些與CGI有關的東西。CGI程式用兩個方式之一傳遞它們的輸入:在GET執行期間通過QUERY_STRING傳遞(目前用的這種方式),或者在POST期間通過標準輸入。但CGI程式通過標準輸出發送自己的輸出,這通常是用C程式的printf()命令實現的。那麼這個輸出到哪裡去了呢?它回到了Web服務器,由服務器決定該如何處理它。服務器作出決定的依據是content-type(內容類別型)頭數據。這意味著假如content-type頭不是它看到的第一件東西,就不知道該如何處理收到的數據。因此,我們無論如何也要使所有CGI程式都從content-type頭開始輸出。

在目前這種情況下,我們希望服務器將所有信息都直接反饋回客戶程式(亦即我們的程式片,它們正在等候給自己的回覆)。信息應該原封不動,所以content-type設為text/plain(純文本)。一旦服務器看到這個頭,就會將所有字符串都直接發還給客戶。所以每個字符串(三個用於出錯條件,一個用於成功的加入)都會返回程式片。

我們用相同的程式碼添加電子函件名稱(用戶的姓名)。但在CGI腳本的情況下,並不存在無限循環——程式只是簡單地響應,然後就中斷。每次有一個CGI請求抵達時,程式都會啟動,對那個請求作出反應,然後自行關閉。所以CPU不可能陷入空等待的尷尬境地,只有啟動程式和打開文件時才存在性能上的隱患。Web服務器對CGI請求進行控制時,它的開銷會將這種隱患減輕到最低程度。

這種設計的另一個好處是由於PairCGI_vector都得到了定義,大多數工作都幫我們自動完成了,所以只需修改main()即可輕鬆創建自己的CGI程式。儘管小服務程式(Servlet)最終會變得越來越流行,但為了創建快速的CGI程式,C++仍然顯得非常方便。

15.6.4 POST的概念

在許多應用程式中使用GET都沒有問題。但是,GET要求通過一個環境變量將自己的數據傳遞給CGI程式。但假如GET字符串過長,有些Web服務器可能用光自己的環境空間(若字符串長度超過200字符,就應開始關心這方面的問題)。CGI為此提供了一個解決方法:POST。通過POST,數據可以編碼,並按與GET相同的方法連結起來。但POST利用標準輸入將編碼過後的查詢字符串傳遞給CGI程式。我們要做的全部事情就是判斷查詢字符串的長度,而這個長度已在環境變量CONTENT_LENGTH中保存好了。一旦知道了長度,就可自由分配存儲空間,並從標準輸入中讀入指定數量的字符。

對一個用來控制POST的CGI程式,由CGITools.h提供的PairCGI_vector均可不加絲毫改變地使用。下面這段程式揭示了寫這樣的一個CGI程式有多麼簡單。這個例子將採用“純”C++,所以studio.h庫被iostream(IO數據流)代替。對於iostream,我們可以使用兩個預先定義好的物件:cin,用於同標準輸入連接;以及cout,用於同標準輸出連接。有幾個辦法可從cin中讀入數據以及向cout中寫入。但下面這個程式準備採用標準方法:用<<將信息發給cout,並用一個成員函式(此時是read())從cin中讀入數據:

//: POSTtest.cpp
// CGI_vector works as easily with POST as it
// does with GET. Written in "pure" C++.
#include <iostream.h>
#include "CGITools.h"

void main() {
  cout << "Content-type: text/plain\n" << endl;
  // For a CGI "POST," the server puts the length
  // of the content string in the environment
  // variable CONTENT_LENGTH:
  char* clen = getenv("CONTENT_LENGTH");
  if(clen == 0) {
    cout << "Zero CONTENT_LENGTH" << endl;
    return;
  }
  int len = atoi(clen);
  char* query_str = new char[len + 1];
  cin.read(query_str, len);
  query_str[len] = '\0';
  CGI_vector query(query_str);
  // Test: dump all names and values
  for(int i = 0; i < query.size(); i++)
    cout << "query[" << i << "].name() = [" <<
      query[i].name() << "], " <<
      "query[" << i << "].value() = [" <<
      query[i].value() << "]" << endl;
  delete query_str; // Release storage
} ///:~

getenv()函式返回指向一個字符串的指針,那個字符串指示著內容的長度。若指針為零,表明CONTENT_LENGTH環境變量尚未設置,所以肯定某個地方出了問題。否則就必須用ANSI C庫函式atoi()將字符串轉換成一個整數。這個長度將與new一起運用,分配足夠的存儲空間,以便容納查詢字符串(另加它的空中止符)。隨後為cin()呼叫read()read()函式需要取得指向目標緩衝區的一個指針以及要讀入的字節數。隨後用空字符(null)中止query_str,指出已經抵達字符串的末尾,這就叫作“空中止”。

到這個時候,我們得到的查詢字符串與GET查詢字符串已經沒有什麼區別,所以把它傳遞給用於CGI_vector的建構子。隨後便和前例一樣,我們可以自由·內不同的字段。

為測試這個程式,必須把它編譯到主機Web服務器的cgi-bin目錄下。然後就可以寫一個簡單的HTML頁進行測試,就象下面這樣:

<HTML>
<HEAD>
<META CONTENT="text/html">
<TITLE>A test of standard HTML POST</TITLE>
</HEAD>
Test, uses standard html POST
<Form method="POST" ACTION="/cgi-bin/POSTtest">
<P>Field1: <INPUT TYPE = "text" NAME = "Field1"
VALUE = "" size = "40"></p>
<P>Field2: <INPUT TYPE = "text" NAME = "Field2"
VALUE = "" size = "40"></p>
<P>Field3: <INPUT TYPE = "text" NAME = "Field3"
VALUE = "" size = "40"></p>
<P>Field4: <INPUT TYPE = "text" NAME = "Field4"
VALUE = "" size = "40"></p>
<P>Field5: <INPUT TYPE = "text" NAME = "Field5"
VALUE = "" size = "40"></p>
<P>Field6: <INPUT TYPE = "text" NAME = "Field6"
VALUE = "" size = "40"></p>
<p><input type = "submit" name = "submit" > </p>
</Form>
</HTML>

填好這個表單並提交出去以後,會得到一個簡單的文本頁,其中包含了解析出來的結果。從中可知道CGI程式是否在正常工作。

當然,用一個程式片來提交數據顯得更有趣一些。然而,POST數據的提交屬於一個不同的過程。在用常規方式呼叫了CGI程式以後,必須另行建立與服務器的一個連接,以便將查詢字符串反饋給它。服務器隨後會進行一番處理,再通過標準輸入將查詢字符串反饋回CGI程式。

為建立與服務器的一個直接連接,必須取得自己創建的URL,然後呼叫openConnection()創建一個URLConnection。但是,由於URLConnection一般不允許我們把數據發給它,所以必須很可笑地呼叫setDoOutput(true)函式,同時呼叫的還包括setDoInput(true)以及setAllowUserInteraction(false)——註釋⑥。最後,可呼叫getOutputStream()來創建一個OutputStream(輸出數據流),並把它封裝到一個DataOutputStream裡,以便能按傳統方式同它通信。下面列出的便是一個用於完成上述工作的程式片,必須在從它的各個字段裡收集了數據之後再執行它:

//: POSTtest.java
// An applet that sends its data via a CGI POST
import java.awt.*;
import java.applet.*;
import java.net.*;
import java.io.*;

public class POSTtest extends Applet {
  final static int SIZE = 10;
  Button submit = new Button("Submit");
  TextField[] t = new TextField[SIZE];
  String query = "";
  Label l = new Label();
  TextArea ta = new TextArea(15, 60);
  public void init() {
    Panel p = new Panel();
    p.setLayout(new GridLayout(t.length + 2, 2));
    for(int i = 0; i < t.length; i++) {
      p.add(new Label(
        "Field " + i + "  ", Label.RIGHT));
      p.add(t[i] = new TextField(30));
    }
    p.add(l);
    p.add(submit);
    add("North", p);
    add("South", ta);
  }
  public boolean action (Event evt, Object arg) {
    if(evt.target.equals(submit)) {
      query = "";
      ta.setText("");
      // Encode the query from the field data:
      for(int i = 0; i < t.length; i++)
         query += "Field" + i + "=" +
           URLEncoder.encode(
             t[i].getText().trim()) +
           "&";
      query += "submit=Submit";
      // Send the name using CGI's POST process:
      try {
        URL u = new URL(
          getDocumentBase(), "cgi-bin/POSTtest");
        URLConnection urlc = u.openConnection();
        urlc.setDoOutput(true);
        urlc.setDoInput(true);
        urlc.setAllowUserInteraction(false);
        DataOutputStream server =
          new DataOutputStream(
            urlc.getOutputStream());
        // Send the data
        server.writeBytes(query);
        server.close();
        // Read and display the response. You
        // cannot use
        // getAppletContext().showDocument(u);
        // to display the results as a Web page!
        DataInputStream in =
          new DataInputStream(
            urlc.getInputStream());
        String s;
        while((s = in.readLine()) != null) {
          ta.appendText(s + "\n");
        }
        in.close();
      }
      catch (Exception e) {
        l.setText(e.toString());
      }
    }
    else return super.action(evt, arg);
    return true;
  }
} ///:~

⑥:我不得不說自己並沒有真正理解這兒都發生了什麼事情,這些概念都是從Elliotte Rusty Harold編著的《Java Network Programming》裡得來的,該書由O'Reilly於1997年出版。他在書中提到了Java連網函式庫中出現的許多令人迷惑的Bug。所以一旦涉足這些領域,事情就不是編寫程式碼,然後讓它自己運行那麼簡單。一定要警惕潛在的陷阱!

信息發送到服務器後,我們呼叫getInputStream(),並把返回值封裝到一個DataInputStream裡,以便自己能讀取結果。要注意的一件事情是結果以文本行的形式顯示在一個TextArea(文本區域)中。為什麼不簡單地使用getAppletContext().showDocument(u)呢?事實上,這正是那些陷阱中的一個。上述程式碼可以很好地工作,但假如試圖換用showDocument(),幾乎一切都會停止運行。也就是說,showDocument()確實可以運行,但從POSTtest得到的返回結果是Zero CONTENT_LENGTH(內容長度為零)。所以不知道為什麼原因,showDocument()阻止了POST查詢向CGI程式的傳遞。我很難判斷這到底是一個在以後版本里會修復的Bug,還是由於我的理解不夠(我看過的書對此講得都很模糊)。但無論在哪種情況下,只要能堅持在文本區域裡觀看自CGI程式返回的內容,上述程式片運行時就沒有問題。

15.7 用JDBC連接數據庫

據估算,將近一半的軟件開發都要涉及客戶(機)/服務器方面的操作。Java為自己保證的一項出色能力就是建構與平臺無關的客戶端/服務器數據庫應用。在Java 1.1中,這一保證通過Java數據庫連接(JDBC)實現了。

數據庫最主要的一個問題就是各家公司之間的規格大戰。確實存在一種“標準”數據庫語言,即“結構查詢語言”(SQL-92),但通常都必須確切知道自己要和哪家數據庫公司打交道,否則極易出問題,儘管存在所謂的“標準”。JDBC是面向“與平臺無關”設計的,所以在編程的時候不必關心自己要使用的是什麼數據庫產品。然而,從JDBC裡仍有可能發出對某些數據庫公司專用功能的呼叫,所以仍然不可任性妄為。

和Java中的許多API一樣,JDBC也做到了儘量的簡化。我們發出的方法呼叫對應於從數據庫收集數據時想當然的做法:同數據庫連接,創建一個語句並執行查詢,然後處理結果集。

為實現這一“與平臺無關”的特點,JDBC為我們提供了一個“驅動程式管理器”,它能動態維護數據庫查詢所需的所有驅動程式物件。所以假如要連接由三家公司開發的不同種類別的數據庫,就需要三個單獨的驅動程式物件。驅動程式物件會在裝載時由“驅動程式管理器”自動註冊,並可用Class.forName()強行裝載。

為打開一個數據庫,必須創建一個“數據庫URL”,它要指定下述三方面的內容:

(1) 用jdbc指出要使用JDBC。

(2) “子協議”:驅動程式的名字或者一種數據庫連接機制的名稱。由於JDBC的設計從ODBC吸收了許多靈感,所以可以選用的第一種子協議就是“jdbc-odbc橋”,它用odbc關鍵字即可指定。

(3) 數據庫標識符:隨使用的數據庫驅動程式的不同而變化,但一般都提供了一個比較符合邏輯的名稱,由數據庫管理軟件映射(對應)到保存了數據表的一個物理目錄。為使自己的數據庫標識符具有任何含義,必須用自己的數據庫管理軟件為自己喜歡的名字註冊(註冊的具體過程又隨運行平臺的不同而變化)。

所有這些信息都統一編譯到一個字符串裡,即“數據庫URL”。舉個例子來說,若想通過ODBC子協議同一個標識為people的數據庫連接,相應的數據庫URL可設為:

String dbUrl = "jdbc:odbc:people"

如果通過一個網絡連接,數據庫URL也需要包含對遠程機器進行標識的信息。

準備好同數據庫連接後,可呼叫靜態方法DriverManager.getConnection(),將數據庫的URL以及進入那個數據庫所需的用戶名密碼傳遞給它。得到的返回結果是一個Connection物件,利用它即可查詢和操縱數據庫。

下面這個例子將打開一個聯絡信息數據庫,並根據命令行提供的參數查詢一個人的姓(Last Name)。它只選擇那些有E-mail地址的人的名字,然後列印出符合查詢條件的所有人:

//: Lookup.java
// Looks up email addresses in a
// local database using JDBC
import java.sql.*;

public class Lookup {
  public static void main(String[] args) {
    String dbUrl = "jdbc:odbc:people";
    String user = "";
    String password = "";
    try {
      // Load the driver (registers itself)
      Class.forName(
        "sun.jdbc.odbc.JdbcOdbcDriver");
      Connection c = DriverManager.getConnection(
        dbUrl, user, password);
      Statement s = c.createStatement();
      // SQL code:
      ResultSet r =
        s.executeQuery(
          "SELECT FIRST, LAST, EMAIL " +
          "FROM people.csv people " +
          "WHERE " +
          "(LAST='" + args[0] + "') " +
          " AND (EMAIL Is Not Null) " +
          "ORDER BY FIRST");
      while(r.next()) {
        // Capitalization doesn't matter:
        System.out.println(
          r.getString("Last") + ", "
          + r.getString("fIRST")
          + ": " + r.getString("EMAIL") );
      }
      s.close(); // Also closes ResultSet
    } catch(Exception e) {
      e.printStackTrace();
    }
  }
} ///:~

可以看到,數據庫URL的創建過程與我們前面講述的完全一樣。在該例中,數據庫未設密碼保護,所以用戶名和密碼都是空串。

DriverManager.getConnection()建好連接後,接下來可根據結果Connection物件創建一個Statement(語句)物件,這是用createStatement()方法實現的。根據結果Statement,我們可呼叫executeQuery(),向其傳遞包含了SQL-92標準SQL語句的一個字符串(不久就會看到如何自動創建這類別語句,所以沒必要在這裡知道關於SQL更多的東西)。

executeQuery()方法會返回一個ResultSet(結果集)物件,它與迭代器非常相似:next()方法將迭代器移至語句中的下一條記錄;如果已抵達結果集的末尾,則返回null。我們肯定能從executeQuery()返回一個ResultSet物件,即使查詢結果是個空集(也就是說,不會產生一個異常)。注意在試圖讀取任何記錄數據之前,都必須呼叫一次next()。若結果集為空,那麼對next()的這個首次呼叫就會返回false。對於結果集中的每條記錄,都可將字段名作為字符串使用(當然還有其他方法),從而選擇不同的字段。另外要注意的是字段名的大小寫是無關緊要的——SQL數據庫不在乎這個問題。為決定返回的類別型,可呼叫getString()getFloat()等等。到這個時候,我們已經用Java的原始格式得到了自己的數據庫數據,接下去可用Java程式碼做自己想做的任何事情了。

15.7.1 讓示例運行起來

就JDBC來說,程式碼本身是很容易理解的。最令人迷惑的部分是如何使它在自己特定的系統上運行起來。之所以會感到迷惑,是由於它要求我們掌握如何才能使JDBC驅動程式正確裝載,以及如何用我們的數據庫管理軟件來設置一個數據庫。 當然,具體的操作過程在不同的機器上也會有所區別。但這兒提供的在32位Windows環境下操作過程可有效幫助大家理解在其他平臺上的操作。

(1) 步驟1:尋找JDBC驅動程式

上述程式包含了下面這條語句:

Class.forName("sun.jdbc.odbc.JdbcOdbcDriver");

這似乎暗示著一個目錄結構,但大家不要被它矇騙了。在我手上這個JDK 1.1安裝版本中,根本不存在叫作JdbcOdbcDriver.class的一個文件。所以假如在看了這個例子後去尋找它,那麼必然會徒勞而返。另一些人提供的例子使用的是一個假名字,如myDriver.ClassName,但人們從字面上得不到任何幫助。事實上,上述用於裝載jdbc-odbc驅動程式(實際是與JDK 1.1配套提供的唯一驅動)的語句在聯機文檔的多處地方均有出現(特別是在一個標記為“JDBC-ODBC Bridge Driver”的頁內)。若上面的裝載語句不能工作,那麼它的名字可能已隨著Java新版本的發佈而改變了;此時應到聯機文檔裡尋找新的表述方式。

若裝載語句出錯,會在這個時候得到一個異常。為了檢驗驅動程式裝載語句是不是能正常工作,請將該語句後面直到catch從句之間的程式碼暫時設為註釋。如果程式運行時未出現異常,表明驅動程式的裝載是正確的。

(2) 步驟2:配置數據庫

同樣地,我們只限於在32位Windows環境中工作;您可能需要研究一下自己的操作系統,找出適合自己平臺的配置方法。

首先打開控制面板。其中可能有兩個圖標都含有“ODBC”字樣,必須選擇那個“32位ODBC”,因為另一個是為了保持與16位軟件的向後兼容而設置的,和JDBC混用沒有任何結果。雙擊“32位ODBC”圖標後,看到的應該是一個卡片式對話框,上面一排有多個卡片標籤,其中包括“用戶DSN”、“系統DSN”、“文件DSN”等等。其中,“DSN”代表“數據源名稱”(Data Source Name)。它們都與JDBC-ODBC橋有關,但設置數據庫時唯一重要的地方“系統DSN”。儘管如此,由於需要測試自己的配置以及創建查詢,所以也需要在“文件DSN”中設置自己的數據庫。這樣便可讓Microsoft Query工具(與Microsoft Office配套提供)正確地找到數據庫。注意一些軟件公司也設計了自己的查詢工具。

最有趣的數據庫是我們已經使用過的一個。標準ODBC支持多種文件格式,其中包括由不同公司專用的一些格式,如dBASE。然而,它也包括了簡單的“逗號分隔ASCII”格式,它幾乎是每種數據工具都能夠生成的。就目前的例子來說,我只選擇自己的people數據庫。這是我多年來一直在維護的一個數據庫,中間使用了各種聯絡管理工具。我把它導出成為一個逗號分隔的ASCII文件(一般有個.csv擴展名,用Outlook Express導出通信簿時亦可選用同樣的文件格式)。在“文件DSN”區域,我按下“添加”按鈕,選擇用於控制逗號分隔ASCII文件的文本驅動程式(Microsoft Text Driver),然後撤消對“使用當前目錄”的選擇,以便導出數據文件時可以自行指定目錄。

大家會注意到在進行這些工作的時候,並沒有實際指定一個文件,只是一個目錄。那是因為數據庫通常是由某個目錄下的一系列文件構成的(儘管也可能採用其他形式)。每個文件一般都包含了單個“數據表”,而且SQL語句可以產生從數據庫中多個表摘取出來的結果(這叫作“聯合”,或者join)只包含了單張表的數據庫(就象目前這個)通常叫作“平面文件數據庫”。對於大多數問題,如果已經超過了簡單的數據存儲與獲取力所能及的範圍,那麼必須使用多個數據表。通過“聯合”,從而獲得希望的結果。我們把這些叫作“關係型”數據庫。

(3) 步驟3:測試配置

為了對配置進行測試,需用一種方式核實數據庫是否可由查詢它的一個程式“見到”。當然,可以簡單地運行上述的JDBC示範程式,並加入下述語句:

Connection c = DriverManager.getConnection(
dbUrl, user, password);

若拋出一個異常,表明你的配置有誤。

然而,此時很有必要使用一個自動化的查詢生成工具。我使用的是與Microsoft Office配套提供的Microsoft Query,但你完全可以自行選擇一個。查詢工具必須知道數據庫在什麼地方,而Microsoft Query要求我進入ODBC Administrator的“文件DSN”卡片,並在那裡新添一個條目。同樣指定文本驅動程式以及保存數據庫的目錄。雖然可將這個條目命名為自己喜歡的任何東西,但最好還是使用與“系統DSN”中相同的名字。

做完這些工作後,再用查詢工具創建一個新查詢時,便會發現自己的數據庫可以使用了。

(4) 步驟4:建立自己的SQL查詢

我用Microsoft Query創建的查詢不僅指出目標數據庫存在且次序良好,也會自動生成SQL程式碼,以便將其插入我自己的Java程式。我希望這個查詢能夠檢查記錄中是否存在與啟動Java程式時在命令行鍵入的相同的“姓”(Last Name)。所以作為一個起點,我搜索自己的姓Eckel。另外,我希望只顯示出有對應E-mail地址的那些名字。創建這個查詢的步驟如下:

(1) 啟動一個新查詢,並使用查詢嚮導(Query Wizard)。選擇people數據庫(等價於用適應的數據庫URL打開數據庫連接)。

(2) 選擇數據庫中的people表。從這張數據表中,選擇FIRSTLASTEMAIL列。

(3) 在“Filter Data”(過濾器數據庫)下,選擇LAST,並選擇equals(等於),加上參數Eckel。點選“And”單選鈕。

(4) 選擇EMAIL,並選中“Is not Null”(不為空)。

(5) 在“Sort By”下,選擇FIRST

查詢結果會向我們展示出是否能得到自己希望的東西。 現在可以按下SQL按鈕。不需要我們任何方面的介入,正確的SQL程式碼會立即彈現出來,以便我們粘貼和複製。對於這個查詢,相應的SQL程式碼如下:

SELECT people.FIRST, people.LAST, people.EMAIL
FROM people.csv people
WHERE (people.LAST='Eckel') AND
(people.EMAIL Is Not Null)
ORDER BY people.FIRST

若查詢比較複雜,手工編碼極易出錯。但利用一個查詢工具,就可以交互式地測試自己的查詢,並自動獲得正確的程式碼。事實上,親手為這些事情編碼是難以讓人接受的。

(5) 步驟5:在自己的查詢中修改和粘貼

我們注意到上述程式碼與程式中使用的程式碼是有所區別的。那是由於查詢工具對所有名字都進行了限定,即便涉及的僅有一個數據表(若真的涉及多個數據表,這種限定可避免來自不同表的同名數據列發生衝突)。由於這個查詢只需要用到一個數據表,所以可考慮從大多數名字中刪除“people”限定符,就象下面這樣:

SELECT FIRST, LAST, EMAIL
FROM people.csv people
WHERE (LAST='Eckel') AND
(EMAIL Is Not Null)
ORDER BY FIRST

此外,我們不希望“硬編碼”這個程式,從而只能查找一個特定的名字。相反,它應該能查找我們在命令行動態提供的一個名字。所以還要進行必要的修改,並將SQL語句轉換成一個動態生成的字符串。如下所示:

"SELECT FIRST, LAST, EMAIL " +
"FROM people.csv people " +
"WHERE " +
"(LAST='" + args[0] + "') " +
" AND (EMAIL Is Not Null) " +
"ORDER BY FIRST");

SQL還有一種方式可將名字插入一個查詢,名為“過程”(Procedures),它的速度非常快。但對於我們的大多數實驗性數據庫操作,以及一些初級應用,用Java建構查詢字符串已經很不錯了。

從這個例子可以看出,利用目前找得到的工具——特別是查詢建構工具——涉及SQL及JDBC的數據庫編程是非常簡單和直觀的。

15.7.2 查找程式的GUI版本

最好的方法是讓查找程式一直保持運行,要查找什麼東西時只需簡單地切換到它,並鍵入要查找的名字即可。下面這個程式將查找程式作為一個“application/applet”創建,且添加了名字自動填寫功能,所以不必鍵入完整的姓,即可看到數據:

//: VLookup.java
// GUI version of Lookup.java
import java.awt.*;
import java.awt.event.*;
import java.applet.*;
import java.sql.*;

public class VLookup extends Applet {
  String dbUrl = "jdbc:odbc:people";
  String user = "";
  String password = "";
  Statement s;
  TextField searchFor = new TextField(20);
  Label completion =
    new Label("                        ");
  TextArea results = new TextArea(40, 20);
  public void init() {
    searchFor.addTextListener(new SearchForL());
    Panel p = new Panel();
    p.add(new Label("Last name to search for:"));
    p.add(searchFor);
    p.add(completion);
    setLayout(new BorderLayout());
    add(p, BorderLayout.NORTH);
    add(results, BorderLayout.CENTER);
    try {
      // Load the driver (registers itself)
      Class.forName(
        "sun.jdbc.odbc.JdbcOdbcDriver");
      Connection c = DriverManager.getConnection(
        dbUrl, user, password);
      s = c.createStatement();
    } catch(Exception e) {
      results.setText(e.getMessage());
    }
  }
  class SearchForL implements TextListener {
    public void textValueChanged(TextEvent te) {
      ResultSet r;
      if(searchFor.getText().length() == 0) {
        completion.setText("");
        results.setText("");
        return;
      }
      try {
        // Name completion:
        r = s.executeQuery(
          "SELECT LAST FROM people.csv people " +
          "WHERE (LAST Like '" +
          searchFor.getText()  +
          "%') ORDER BY LAST");
        if(r.next())
          completion.setText(
            r.getString("last"));
        r = s.executeQuery(
          "SELECT FIRST, LAST, EMAIL " +
          "FROM people.csv people " +
          "WHERE (LAST='" +
          completion.getText() +
          "') AND (EMAIL Is Not Null) " +
          "ORDER BY FIRST");
      } catch(Exception e) {
        results.setText(
          searchFor.getText() + "\n");
        results.append(e.getMessage());
        return;
      }
      results.setText("");
      try {
        while(r.next()) {
          results.append(
            r.getString("Last") + ", "
            + r.getString("fIRST") +
            ": " + r.getString("EMAIL") + "\n");
        }
      } catch(Exception e) {
        results.setText(e.getMessage());
      }
    }
  }
  public static void main(String[] args) {
    VLookup applet = new VLookup();
    Frame aFrame = new Frame("Email lookup");
    aFrame.addWindowListener(
      new WindowAdapter() {
        public void windowClosing(WindowEvent e) {
          System.exit(0);
        }
      });
    aFrame.add(applet, BorderLayout.CENTER);
    aFrame.setSize(500,200);
    applet.init();
    applet.start();
    aFrame.setVisible(true);
  }
} ///:~

數據庫的許多邏輯都是相同的,但大家可看到這裡添加了一個TextListener,用於監視在TextField(文本字段)的輸入。所以只要鍵入一個新字符,它首先就會試著查找數據庫中的“姓”,並顯示出與當前輸入相符的第一條記錄(將其置入completion Label,並用它作為要查找的文本)。因此,只要我們鍵入了足夠的字符,使程式能找到與之相符的唯一一條記錄,就可以停手了。

15.7.3 JDBC API為何如何複雜

閱覽JDBC的聯機幫助文檔時,我們往往會產生畏難情緒。特別是DatabaseMetaData界面——與Java中看到的大多數界面相反,它的體積顯得非常龐大——存在著數量眾多的方法,比如dataDefinitionCausesTransactionCommit()getMaxColumnNameLength()getMaxStatementLength()storesMixedCaseQuotedIdentifiers()supportsANSI92IntermediateSQL()supportsLimitedOuterJoins()等等。它們有這兒有什麼意義嗎?

正如早先指出的那樣,數據庫起初一直處於一種混亂狀態。這主要是由於各種數據庫應用提出的要求造成的,所以數據庫工具顯得非常“強大”——換言之,“龐大”。只是近幾年才湧現出了SQL的通用語言(常用的還有其他許多數據庫語言)。但即便象SQL這樣的“標準”,也存在無數的變種,所以JDBC必須提供一個巨大的DatabaseMetaData界面,使我們的程式碼能真正利用當前要連接的一種“標準”SQL數據庫的能力。簡言之,我們可編寫出簡單的、能移植的SQL。但如果想優化程式碼的執行速度,那麼為了適應不同數據庫類別型的特點,我們的編寫程式碼的麻煩就大了。

當然,這並不是Java的缺陷。數據庫產品之間的差異是我們和JDBC都要面對的一個現實。但是,如果能編寫通用的查詢,而不必太關心性能,那麼事情就要簡單得多。即使必須對性能作一番調整,只要知道最終面向的平臺,也不必針對每一種情況都編寫不同的優化程式碼。

在Sun發佈的Java 1.1產品中,配套提供了一系列電子文檔,其中有對JDBC更全面的介紹。此外,在由Hamilton Cattel和Fisher編著、Addison-Wesley於1997年出版的《JDBC Database Access with Java》中,也提供了有關這一主題的許多有用資料。同時,書店裡也經常出現一些有關JDBC的新書。

15.8 遠程方法

為通過網絡執行其他機器上的程式碼,傳統的方法不僅難以學習和掌握,也極易出錯。思考這個問題最佳的方式是:某些物件正好位於另一臺機器,我們可向它們發送一則訊息,並獲得返回結果,就象那些物件位於自己的本地機器一樣。Java 1.1的“遠程方法呼叫”(RMI)採用的正是這種抽象。本節將引導大家經歷一些必要的步驟,創建自己的RMI物件。

15.8.1 遠程界面概念

RMI對界面有著強烈的依賴。在需要創建一個遠程物件的時候,我們通過傳遞一個界面來隱藏基層的實現細節。所以客戶得到遠程物件的一個引用時,它們真正得到的是界面引用。這個引用正好同一些本地的根程式碼連接,由後者負責通過網絡通信。但我們並不關心這些事情,只需通過自己的界面引用發送訊息即可。

創建一個遠程界面時,必須遵守下列規則:

(1) 遠程界面必須為public屬性(不能有“包訪問”;也就是說,它不能是“友好的”)。否則,一旦客戶試圖裝載一個實現了遠程界面的遠程物件,就會得到一個錯誤。

(2) 遠程界面必須擴展界面java.rmi.Remote

(3) 除與應用程式本身有關的異常之外,遠程界面中的每個方法都必須在自己的throws從句中聲明java.rmi.RemoteException

(4) 作為參數或返回值傳遞的一個遠程物件(不管是直接的,還是在本地物件中嵌入)必須聲明為遠程界面,不可聲明為實現類別。

下面是一個簡單的遠程界面示例,它代表的是一個精確計時服務:

//: PerfectTimeI.java
// The PerfectTime remote interface
package c15.ptime;
import java.rmi.*;

interface PerfectTimeI extends Remote {
  long getPerfectTime() throws RemoteException;
} ///:~

它表面上與其他界面是類別似的,只是對Remote進行了擴展,而且它的所有方法都會“拋”出RemoteException(遠程異常)。記住界面和它所有的方法都是public的。

15.8.2 遠程界面的實現

服務器必須包含一個擴展了UnicastRemoteObject的類別,並實現遠程界面。這個類別也可以含有附加的方法,但客戶只能使用遠程界面中的方法。這是顯然的,因為客戶得到的只是指向界面的一個引用,而非實現它的那個類別。

必須為遠程物件明確定義建構子,即使只准備定義一個默認建構子,用它呼叫基類別建構子。必須把它明確地編寫出來,因為它必須“拋”出RemoteException異常。

下面列出遠程界面PerfectTime的實現過程:

//: PerfectTime.java
// The implementation of the PerfectTime
// remote object
package c15.ptime;
import java.rmi.*;
import java.rmi.server.*;
import java.rmi.registry.*;
import java.net.*;

public class PerfectTime
    extends UnicastRemoteObject
    implements PerfectTimeI {
  // Implementation of the interface:
  public long getPerfectTime()
      throws RemoteException {
    return System.currentTimeMillis();
  }
  // Must implement constructor to throw
  // RemoteException:
  public PerfectTime() throws RemoteException {
    // super(); // Called automatically
  }
  // Registration for RMI serving:
  public static void main(String[] args) {
    System.setSecurityManager(
      new RMISecurityManager());
    try {
      PerfectTime pt = new PerfectTime();
      Naming.bind(
        "//colossus:2005/PerfectTime", pt);
      System.out.println("Ready to do time");
    } catch(Exception e) {
      e.printStackTrace();
    }
  }
} ///:~

在這裡,main()控制著設置服務器的全部細節。保存RMI物件時,必須在程式的某個地方採取下述操作:

(1) 創建和安裝一個安全管理器,令其支持RMI。作為Java發行包的一部分,適用於RMI唯一一個是RMISecurityManager

(2) 創建遠程物件的一個或多個實例。在這裡,大家可看到創建的是PerfectTime物件。

(3) 向RMI遠程物件註冊表註冊至少一個遠程物件。一個遠程物件擁有的方法可生成指向其他遠程物件的引用。這樣一來,客戶只需到註冊表裡訪問一次,得到第一個遠程物件即可。

(1) 設置註冊表

在這兒,大家可看到對靜態方法Naming.bind()的一個呼叫。然而,這個呼叫要求註冊表作為計算機上的一個獨立進程運行。註冊表服務器的名字是rmiregistry。在32位Windows環境中,可使用:

start rmiregistry

令其在後臺運行。在Unix中,使用:

rmiregistry &

和許多網絡程式一樣,rmiregistry位於機器啟動它所在的某個IP地址處,但它也必須監視一個端口。如果象上面那樣呼叫rmiregistry,不使用參數,註冊表的端口就會默認為1099。若希望它位於其他某個端口,只需在命令行添加一個參數,指定那個端口編號即可。對這個例子來說,端口將位於2005,所以rmiregistry應該象下面這樣啟動(對於32位Windows):

start rmiregistry 2005

對於Unix,則使用下述命令:

rmiregistry 2005 &

與端口有關的信息必須傳送給bind()命令,同時傳送的還有註冊表所在的那臺機器的IP地址。但假若我們想在本地測試RMI程式,就象本章的網絡程式一直測試的那樣,這樣做就會帶來問題。在JDK 1.1.1版本中,存在著下述兩方面的問題(註釋⑦):

(1) localhost不能隨RMI工作。所以為了在單獨一臺機器上完成對RMI的測試,必須提供機器的名字。為了在32位Windows環境中調查自己機器的名字,可進入控制面板,選擇“網絡”,選擇“標識”卡片,其中列出了計算機的名字。就我自己的情況來說,我的機器叫作Colossus(因為我用幾個大容量的硬盤保存各種不同的開發系統——Clossus是“巨人”的意思)。似乎大寫形式會被忽略。

(2) 除非計算機有一個活動的TCP/IP連接,否則RMI不能工作,即使所有組件都只需要在本地機器裡互相通信。這意味著在試圖運行程式之前,必須連接到自己的ISP(Internet服務提供者),否則會得到一些含義模糊的異常訊息。

⑦:為找出這些信息,我不知損傷了多少個腦細胞。

考慮到這些因素,bind()命令變成了下面這個樣子:

Naming.bind("//colossus:2005/PerfectTime", pt);

若使用默認端口1099,就沒有必要指定一個端口,所以可以使用:

Naming.bind("//colossus/PerfectTime", pt);

在JDK未來的版本中(1.1之後),一旦改正了localhost的問題,就能正常地進行本地測試,去掉IP地址,只使用標識符:

Naming.bind("PerfectTime", pt);

服務名是任意的;它在這裡正好為PerfectTime,和類別名一樣,但你可以根據情況任意修改。最重要的是確保它在註冊表裡是個獨一無二的名字,以便客戶正常地獲取遠程物件。若這個名字已在註冊表裡了,就會得到一個AlreadyBoundException異常。為防止這個問題,可考慮堅持使用rebind(),放棄bind()。這是由於rebind()要麼會添加一個新條目,要麼將同名的條目替換掉。

儘管main()退出,我們的物件已經創建並註冊,所以會由註冊表一直保持活動狀態,等候客戶到達併發出對它的請求。只要rmiregistry處於運行狀態,而且我們沒有為名字呼叫Naming.unbind()方法,物件就肯定位於那個地方。考慮到這個原因,在我們設計自己的程式碼時,需要先關閉rmiregistry,並在編譯遠程物件的一個新版本時重新啟動它。

並不一定要將rmiregistry作為一個外部進程啟動。若事前知道自己的是要求用以註冊表的唯一一個應用,就可在程式內部啟動它,使用下述程式碼:

LocateRegistry.createRegistry(2005);

和前面一樣,2005代表我們在這個例子裡選用的端口號。這等價於在命令行執行rmiregistry 2005。但在設計RMI程式碼時,這種做法往往顯得更加方便,因為它取消了啟動和中止註冊表所需的額外步驟。一旦執行完這個程式碼,就可象以前一樣使用Naming進行“綁定”——bind()

15.8.3 創建根與幹

若編譯和運行PerfectTime.java,即使rmiregistry正確運行,它也無法工作。這是由於RMI的框架尚未就位。首先必須創建根和幹,以便提供網絡連接操作,並使我們將遠程物件偽裝成自己機器內的某個本地物件。

所有這些幕後的工作都是相當複雜的。我們從遠程物件傳入、傳出的任何物件都必須implement Serializable(如果想傳遞遠程引用,而非整個物件,物件的參數就可以implement Remote)。因此可以想象,當根和幹通過網絡“彙集”所有參數並返回結果的時候,會自動進行序列化以及數據的重新裝配。幸運的是,我們根本沒必要了解這些方面的任何細節,但根和幹卻是必須創建的。一個簡單的過程如下:在編譯好的程式碼中呼叫rmic,它會創建必需的一些文件。所以唯一要做的事情就是為編譯過程新添一個步驟。

然而,rmic工具與特定的包和類別路徑有很大的關聯。PerfectTime.java位於包c15.Ptime中,即使我們呼叫與PerfectTime.class同一目錄內的rmicrmic都無法找到文件。這是由於它搜索的是類別路徑。因此,我們必須同時指定類別路徑,就象下面這樣:

rmic c15.PTime.PerfectTime

執行這個命令時,並不一定非要在包含了PerfectTime.class的目錄中,但結果會置於當前目錄。 若rmic成功運行,目錄裡就會多出兩個新類別:

PerfectTime_Stub.class
PerfectTime_Skel.class

它們分別對應根(Stub)和幹(Skeleton)。現在,我們已準備好讓服務器與客戶互相溝通了。

15.8.4 使用遠程物件

RMI全部的宗旨就是儘可能簡化遠程物件的使用。我們在客戶程式中要做的唯一一件額外的事情就是查找並從服務器取回遠程界面。自此以後,剩下的事情就是普通的Java編程:將訊息發給物件。下面是使用PerfectTime的程式:

//: DisplayPerfectTime.java
// Uses remote object PerfectTime
package c15.ptime;
import java.rmi.*;
import java.rmi.registry.*;

public class DisplayPerfectTime {
  public static void main(String[] args) {
    System.setSecurityManager(
      new RMISecurityManager());
    try {
      PerfectTimeI t =
        (PerfectTimeI)Naming.lookup(
          "//colossus:2005/PerfectTime");
      for(int i = 0; i < 10; i++)
        System.out.println("Perfect time = " +
          t.getPerfectTime());
    } catch(Exception e) {
      e.printStackTrace();
    }
  }
} ///:~

ID字符串與那個用Naming註冊物件的那個字符串是相同的,第一部分指出了URL和端口號。由於我們準備使用一個URL,所以也可以指定Internet上的一臺機器。

Naming.lookup()返回的必須轉換到遠程界面,而不是到類別。若換用類別,會得到一個異常提示。 在下述方法呼叫中:

t.getPerfectTime( )

我們可看到一旦獲得遠程物件的引用,用它進行的編程與用本地物件的編程是非常相似(僅有一個區別:遠程方法會“拋”出一個RemoteException異常)。

15.8.5 RMI的替選方法

RMI只是一種創建特殊物件的方式,它創建的物件可通過網絡發佈。它最大的優點就是提供了一種“純Java”方法,但假如已經有許多用其他語言編寫的程式碼,則RMI可能無法滿足我們的要求。目前,兩種最具競爭力的替選方法是微軟的DCOM(根據微軟的計劃,它最終會移植到除Windows以外的其他平臺)以及CORBA。CORBA自Java 1.1便開始支持,是一種全新設計的概念,面向跨平臺應用。在由Orfali和Harkey編著的《Client/Server Programming with Java and CORBA》一書中(John Wiley&Sons 1997年出版),大家可獲得對Java中的分佈式物件的全面介紹(該書似乎對CORBA似乎有些偏見)。為CORBA賦予一個較公正的對待的一本書是由Andreas Vogel和Keith Duddy編寫的《Java Programming with CORBA》,John Wiley&Sons於1997年出版。

15.9 總結

由於篇幅所限,還有其他許多涉及連網的概念沒有介紹給大家。Java也為URL提供了相當全面的支持,包括為Internet上不同類別型的客戶提供協議控制器等等。

除此以外,一種正在逐步流行的技術叫作Servlet Server。它是一種Internet服務器應用,通過Java控制客戶請求,而非使用以前那種速度很慢、且相當麻煩的CGI(通用網關界面)協議。這意味著為了在服務器那一端提供服務,我們可以用Java編程,不必使用自己不熟悉的其他語言。由於Java具有優秀的移植能力,所以不必關心具體容納這個服務器是什麼平臺。

所有這些以及其他特性都在《Java Network Programming》一書中得到了詳細講述。該書由Elliotte Rusty Harold編著,O'Reilly於1997年出版。

15.10 練習

(1) 編譯和運行本章中的JabberServerJabberClient程式。接著編輯一下程式,刪去為輸入和輸出設計的所有緩衝機制,然後再次編譯和運行,觀察一下結果。

(2) 創建一個服務器,用它請求用戶輸入密碼,然後打開一個文件,並將文件通過網絡連接傳送出去。創建一個同該服務器連接的客戶,為其分配適當的密碼,然後捕獲和保存文件。在自己的機器上用localhost(通過呼叫InetAddress.getByName(null)生成本地IP地址127.0.0.1)測試這兩個程式。

(3) 修改練習2中的程式,令其用多執行緒機制對多個客戶進行控制。

(4) 修改JabberClient,禁止輸出刷新,並觀察結果。

(5) 以ShowHTML.java為基礎,創建一個程式片,令其成為對自己Web站點的特定部分進行密碼保護的大門。

(6) (可能有些難度)創建一對客戶/服務器程式,利用數據報(Datagram)將一個文件從一臺機器傳到另一臺(參見本章數據報小節末尾的敘述)。

(7) (可能有些難度)對VLookup.java程式作一番修改,使我們能點擊得到的結果名字,然後程式會自動取得那個名字,並把它複製到剪貼板(以便我們方便地粘貼到自己的E-mail)。可能要回過頭去研究一下IO數據流的那一章,回憶該如何使用Java 1.1剪貼板。