實戰WEB 伺服器(JAVA編寫WEB伺服器)
一、超文字傳輸協議
1.1 HTTP請求
1.2 HTTP應答
二、Socket類
三、ServerSocket類
四、Web伺服器例項
4.1 HttpServer類
4.2 Request類
4.3 Response類
五、編譯和執行
===================
正文:
===================
Web伺服器與客戶端的通訊使用HTTP協議(超文字傳輸協議),所以也叫做HTTP伺服器。用Java構造Web伺服器主要用二個類,java.net.Socket和java.net.ServerSocket,來實現HTTP通訊。因此,本文首先要討論的是HTTP協議和這兩個類,在此基礎上實現一個簡單但完整的Web伺服器。
一、超文字傳輸協議
Web伺服器和瀏覽器通過HTTP協議在Internet上傳送和接收訊息。HTTP協議是一種請求-應答式的協議——客戶端傳送一個請求,伺服器返回該請求的應答。HTTP協議使用可靠的TCP連線,預設埠是80。HTTP的第一個版本是HTTP/0.9,後來發展到了HTTP/1.0,現在最新的版本是HTTP/1.1。HTTP/1.1由 RFC 2616 定義(pdf格式)。
本文只簡要介紹HTTP 1.1的相關知識,但應該足以讓你理解Web伺服器和瀏覽器傳送的訊息。如果你要了解更多的細節,請參考RFC 2616。
在HTTP中,客戶端/伺服器之間的會話總是由客戶端通過建立連線和傳送HTTP請求的方式初始化,伺服器不會主動聯絡客戶端或要求與客戶端建立連線。瀏覽器和伺服器都可以隨時中斷連線,例如,在瀏覽網頁時你可以隨時點選“停止”按鈕中斷當前的檔案下載過程,關閉與Web伺服器的HTTP連線。
1.1 HTTP請求
HTTP請求由三個部分構成,分別是:方法-URI-協議/版本,請求頭,請求正文。下面是一個HTTP請求的例子:
GET /servlet/default.jsp HTTP/1.1 Accept: text/plain; text/html Accept-Language: en-gb Connection: Keep-Alive Host: localhost Referer: http://localhost/ch8/SendDetails.htm User-Agent: Mozilla/4.0 (compatible; MSIE 4.01; Windows 98) Content-Length: 33 Content-Type: application/x-www-form-urlencoded Accept-Encoding: gzip, deflate userName=JavaJava&userID=javaID |
請求的第一行是“方法-URI-協議/版本”,其中GET就是請求方法,/servlet/default.jsp表示URI,HTTP/1.1是協議和協議的版本。根據HTTP標準,HTTP請求可以使用多種請求方法。例如,HTTP 1.1支援七種請求方法:GET,POST,HEAD,OPTIONS,PUT,DELETE,和TRACE。在Internet應用中,最常用的請求方法是GET和POST。
URI完整地指定了要訪問的網路資源,通常認為它相對於伺服器的根目錄而言,因此總是以“/”開頭。URL實際上是URI 一種型別。最後,協議版本聲明瞭通訊過程中使用的HTTP協議的版本。
請求頭包含許多有關客戶端環境和請求正文的有用資訊。例如,請求頭可以宣告瀏覽器所用的語言,請求正文的長度,等等,它們之間用一個回車換行符號(CRLF)分隔。
請求頭和請求正文之間是一個空行(只有CRLF符號的行),這個行非常重要,它表示請求頭已經結束,接下來的是請求的正文。一些介紹Internet程式設計的書籍把這個CRLF視為HTTP請求的第四個組成部分。
在前面的HTTP請求中,請求的正文只有一行內容。當然,在實際應用中,HTTP請求正文可以包含更多的內容。
1.2 HTTP應答
和HTTP請求相似,HTTP應答也由三個部分構成,分別是:協議-狀態程式碼-描述,應答頭,應答正文。下面是一個HTTP應答的例子:
HTTP/1.1 200 OK Server: Microsoft-IIS/4.0 Date: Mon, 3 Jan 1998 13:13:33 GMT Content-Type: text/html Last-Modified: Mon, 11 Jan 1998 13:23:42 GMT Content-Length: 112 <html> <head> <title>HTTP應答示例</title></head><body> Hello HTTP! </body> </html> |
HTTP應答的第一行類似於HTTP請求的第一行,它表示通訊所用的協議是HTTP 1.1,伺服器已經成功地處理了客戶端發出的請求(200表示成功),一切順利。
應答頭也和請求頭一樣包含許多有用的資訊,例如伺服器型別、日期時間、內容型別和長度等。應答的正文就是伺服器返回的HTML頁面。應答頭和正文之間也用CRLF分隔。
二、Socket類
Socket代表著網路連線的一個端點,應用程式通過該端點向網路傳送或從網路讀取資料。位於兩臺不同機器上的應用軟體通過網路連線傳送和接收位元組流,從而實現通訊。要把訊息傳送給另一個應用,首先要知道對方的IP地址以及其通訊端點的埠號。在Java中,通訊端點由java.net.Socket類表示。
Socket類有許多建構函式,其中一個建構函式的引數是主機名稱和埠號:
public Socket(String host, int port) |
host是遠端機器的名字或IP地址,port是遠端應用的埠號。例如,如果要連線到yahoo.com的80埠,我們可以用“new Socket("yahoo.com", 80);”語句構造一個Socket。
成功建立了Socket類的例項之後,我們就可以用它來發送和接收位元組流形式的資料。要傳送位元組流,首先要呼叫Socket類的getOutputStream方法獲得一個java.io.OutputStream物件;為了向遠端應用傳送文字資料,我們經常要從返回的OutputStream物件構造一個java.io.PrintWriter物件。要從連線的另一端接收位元組流,首先要呼叫Socket類的getInputStream方法獲得一個java.io.InputStream物件。
例如,下面的程式碼片斷建立一個與本地HTTP伺服器(127.0.0.1代表本地主機的IP地址)通訊的Socket,傳送一個HTTP請求,準備接收伺服器的應答。它建立了一個StringBuffer物件來儲存應答,然後把應答輸出到控制檯。
Socket socket = new Socket("127.0.0.1", "8080"); OutputStream os = socket.getOutputStream(); boolean autoflush = true; PrintWriter out = new PrintWriter( socket.getOutputStream(), autoflush ); BufferedReader in = new BufferedReader( new InputStreamReader( socket.getInputStream() )); // 向Web伺服器傳送一個HTTP請求 out.println("GET /index.jsp HTTP/1.1"); out.println("Host: localhost:8080"); out.println("Connection: Close"); out.println(); // 讀取伺服器的應答 boolean loop = true; StringBuffer sb = new StringBuffer(8096); while (loop) { if ( in.ready() ) { int i=0; while (i!=-1) { i = in.read(); sb.append((char) i); } loop = false; } Thread.currentThread().sleep(50); } // 把應答顯示到控制檯 System.out.println(sb.toString()); socket.close(); |
注意,為了保證Web伺服器能夠返回正確的應答,客戶端傳送的HTTP請求應該遵從雙方約定的HTTP協議版本。
三、ServerSocket類
Socket類代表的是“客戶”通訊端點,它是一個連線遠端伺服器應用時臨時建立的端點。對於伺服器應用,例如HTTP伺服器或FTP伺服器,我們需要另一種端點,因為我們不知道客戶端應用什麼時候會試圖連線伺服器,伺服器必須一直處於等待連線的狀態。
因此,對於伺服器端的通訊端點,我們要使用java.net.ServerSocker類。ServerSocket等待來自客戶端的連線請求;一旦接收到請求,ServerSocket建立一個Socket例項來處理與該客戶端的通訊。
ServerSocket提供了四個建構函式。建立ServerSocket的例項時,我們必須指定監聽客戶端訊息的IP地址(稱為“繫結地址”,Binding Address)和埠。通常情況下,這個IP地址總是127.0.0.1,也就是說伺服器端點將在本地機器上監聽。伺服器端點的另一個重要屬性是它的backlog值,這是儲存客戶端連線請求的最大佇列長度,一旦超越這個長度,伺服器端點開始拒絕客戶端的連線請求。
下面是ServerSocket類建構函式的其中一種形式:
public ServerSocket(int port, int backLog, InetAddress bindingAddress); |
這個建構函式要求繫結地址必須是一個java.net.InetAddress的例項。要構造一個InetAddress物件,一種簡單的辦法是呼叫它的靜態getByName方法,傳入一個表示主機名稱/地址的String。例如,下面的程式碼構造了一個在本地機器的8080埠監聽的ServerSocket,它的backlog值是1:
new ServerSocket(8080, 1, InetAddress.getByName("127.0.0.1")); |
建立好ServerSocket例項之後,呼叫它的accept方法,要求它等待傳入的連線請求。只有出現了連線請求時,accept方法才會返回,它的返回值是一個Socket類的例項。隨後,這個Socket物件就可以用來與客戶端應用通訊。
四、Web伺服器例項
本文的Web伺服器由三個類構成,分別是:HttpServer,Request ,Response。
應用的入口點(static main方法)在HttpServer類。main方法建立一個HttpServer例項,然後呼叫await方法。從await方法的名字也可以看出,它的功能是在指定的埠上等待HTTP請求,然後處理請求,把處理的結果返回給客戶端。除非收到了關閉伺服器的命令,否則await將一直保持等待客戶端請求的狀態。(之所以用await而不是wait作為方法名,是因為wait是System.Object類中一個用來操作執行緒的重要方法)。
本文的Web伺服器只能傳送指定目錄下的靜態資源,例如HTML和圖形檔案。它不支援頭資訊(例如日期時間、Cookie等)。
4.1 HttpServer類
HttpServer類代表一個Web伺服器,提供由WEB_ROOT變數指定的目錄及其子目錄下的靜態資源。WEB_ROOT用下面的語句初始化:
public static final String WEB_ROOT = System.getProperty("user.dir") + File.separator + "webroot"; |
本文最後的下載程式碼包中有一個webroot目錄,它裡面有一些靜態Web頁面,可用來測試本文的伺服器。要開啟webroot目錄下的靜態頁面,在瀏覽器的位址列輸入URL:http://machineName:port/staticResource。
如果執行Web伺服器的機器和瀏覽器所在的機器不同,machineName必須是Web伺服器所在機器的IP地址或名稱;如果瀏覽器和Web伺服器在同一臺機器上執行,machineName也可以是localhost。port是8080,staticResource是要請求的資源(頁面)檔名稱。
例如,假設我們在同一臺機器上執行Web伺服器和瀏覽器,如果要求HttpServer返回index.html檔案,則URL是:
http://localhost:8080/index.html |
要關閉Web伺服器,在瀏覽器的位址列輸入一個預定義的關閉命令,即在URL的“主機名稱:埠”之後,加上SHUTDOWN_COMMAND變數定義的字元。假設SHUTDOWN_COMMAND變數的值是“/SHUTDOWN”,我們可以在瀏覽器位址列輸入“http://localhost:8080/SHUTDOWN”關閉Web伺服器。
下面我們來看看await方法的程式碼,程式碼的說明隨後給出。
【HttpServer類的await方法】 public void await() { ServerSocket serverSocket = null; int port = 8080; try { serverSocket = new ServerSocket(port, 1, InetAddress.getByName("127.0.0.1")); } catch (IOException e) { e.printStackTrace(); System.exit(1); } // 迴圈,等待客戶端發來的請求 while (!shutdown) { Socket socket = null; InputStream input = null; OutputStream output = null; try { socket = serverSocket.accept(); input = socket.getInputStream(); output = socket.getOutputStream(); // 建立Request物件並予以解析 Request request = new Request(input); request.parse(); // 建立Response物件 Response response = new Response(output); response.setRequest(request); response.sendStaticResource(); // 關閉Socket socket.close(); // 檢查該URI是否為關閉伺服器的命令 shutdown = request.getUri().equals(SHUTDOWN_COMMAND); } catch (Exception e) { e.printStackTrace(); continue; } } } |
await方法首先建立一個ServerSocket例項,然後進入while迴圈等待來自客戶端的請求。while迴圈裡面的程式碼會在執行ServerSocket的accept方法時等待,直到8080埠收到一個HTTP請求。然後,從accept返回的Socket獲得一個java.io.InputStream和一個java.io.OutputStream。接下來,await方法建立一個Request物件,呼叫parse方法解析原始的HTTP請求。接著,await方法又建立一個Response物件,把前面建立的Request物件傳遞給它,呼叫它的sendStaticRessource方法。
最後,await方法關閉Socket,呼叫Request方法的getUri方法,查檢該HTTP請求的URI是否為一個關閉伺服器的命令。如果是,把shutdown變數的值設定為true,while迴圈結束。
4.2 Request類
Request類代表一個HTTP請求。建立Request類的例項時要傳入一個從負責與客戶端通訊的Socket獲得的InputStream物件。呼叫InputStream物件的其中一個read方法可獲得HTTP請求的原始資料。
Request類有兩個公用方法parse和getUri。parse方法解析HTTP請求中的原始資料,其實它的功能並不多——它唯一提取的資訊是HTTP請求的URI,通過呼叫私有的parseUri方法獲得。parseUri把Uri儲存在uri變數中。呼叫公用的getUri方法可返回HTTP請求的URI。
要理解parse和parseUri的工作原理,首先要理解HTTP請求的結構,參見本文前面內容以及RFC 2616。如前所述,HTTP請求包含三個部分,現在我們感興趣的是第一部分,即所謂的“請求行”,包括請求方法、URI和協議版本,最後是一個CRLF字元。請求行裡面的各個部分由空格分隔,例如,用GET方法請求index.html檔案的請求行是:
GET /index.html HTTP/1.1 |
parse方法讀取傳遞給Request物件的InputStream的整個位元組流,把位元組資料儲存到緩衝區,然後利用buffer位元組陣列中的內容填寫一個稱為request的StringBuffer物件,把該StringBuffer的String描述傳遞給parseUri方法。parse方法的程式碼如下所示:
【Request類的parse方法】 public void parse() { // 從Socket讀取一組資料 StringBuffer request = new StringBuffer(2048); int i; byte[] buffer = new byte[2048]; try { i = input.read(buffer); } catch (IOException e) { e.printStackTrace(); i = -1; } for (int j=0; j<i; j++) { request.append((char) buffer[j]); } System.out.print(request.toString()); uri = parseUri(request.toString()); } |
parseUri從請求行獲得URI,下面給出了parseUri方法的程式碼。parseUri方法搜尋請求中的第一、二兩個空格字元,提取出URI。
【Request類的parseUri方法】 private String parseUri(String requestString) { int index1, index2; index1 = requestString.indexOf(' '); if (index1 != -1) { index2 = requestString.indexOf(' ', index1 + 1); if (index2 > index1) return requestString.substring(index1 + 1, index2); } return null; } |
4.3 Response類
Response類代表一個HTTP應答。它的建構函式要求指定一個OutputStream物件,例如:
public Response(OutputStream output) { this.output = output; } |
Response類有兩個公用方法:setRequest和sendStaticResource。setRequest方法用來把Request物件傳遞給Response物件,很簡單,如下所示:
【Response類的setRequest方法】 public void setRequest(Request request) { this.request = request; } |
sendStaticResource方法用來發送靜態資源,如HTML檔案等。它的實現如下所示:
【Response類的sendStaticResource方法】 public void sendStaticResource() throws IOException { byte[] bytes = new byte[BUFFER_SIZE]; FileInputStream fis = null; try { File file = new File(HttpServer.WEB_ROOT, request.getUri()); if (file.exists()) { fis = new FileInputStream(file); int ch = fis.read(bytes, 0, BUFFER_SIZE); while (ch != -1) { output.write(bytes, 0, ch); ch = fis.read(bytes, 0, BUFFER_SIZE); } } else { // 找不到檔案 String errorMessage = "HTTP/1.1 404 File Not Found/r/n" + "Content-Type: text/html/r/n" + "Content-Length: 23/r/n" + "/r/n" + "<h1>File Not Found</h1>"; output.write(errorMessage.getBytes()); } } catch (Exception e) { // 如不能例項化File物件,丟擲異常。 System.out.println(e.toString() ); } finally { if (fis != null) fis.close(); } } |
sendStaticResource方法首先建立一個java.io.File類的例項,在呼叫File類建構函式時指定了Web伺服器的根目錄和請求的目標URI。然後,sendStaticResource 檢查使用者請求的檔案是否存在,如存在,它在該File物件的基礎上建立一個java.io.FileInputStream物件,然後呼叫FileInputStream的read方法,把讀取的位元組陣列寫入到OutputStream輸出。如果使用者請求的檔案不存在,sendStaticResource方法向瀏覽器傳送一個錯誤資訊。
五、編譯和執行
下載本文後面提供的zip檔案,解開壓縮。解開壓縮時你指定的目標目錄稱為“工作目錄”。工作目錄下有二個子目錄:src,webroot。webroot目錄下包含一些示例頁面。在工作目錄下執行下面的命令編譯Web伺服器:
javac -d . src/*.java |
“-d .”選項表示把編譯結果儲存到當前目錄(即工作目錄),而不是儲存到src目錄。執行java HttpServer就可以啟動Web伺服器。
假設瀏覽器和Web伺服器執行在同一臺機器上,開啟瀏覽器,輸入URL:http://localhost:8080/index.html。瀏覽器顯示出圖一所示的頁面。
結束語:本文通過開發一個簡單的JavaWeb伺服器,介紹了Web伺服器的基本工作原理。雖然本文開發的Web伺服器不具備複雜的功能,但它足以作為一個不錯的學習工具。
完整程式碼如下: