正文
作為後端開發人員,在實際工作中,Web 伺服器的使用頻率極高,而在眾多 Web 伺服器中,Tomcat 作為不可或缺的重要框架,理應成為我們必須學習和掌握的重點。
Tomcat 本質上是一個 Web 框架,那麼它的內部機制究竟是如何運作的呢?若不依賴 Tomcat,我們是否有能力自行構建一個 Web 伺服器呢?
首先,Tomcat 的內部實現極為複雜,涵蓋眾多元件。我們將在後續章節中對這些細節展開深入探討。 其次,本章將帶領大家親手構建一個 Web 伺服器。
接下來,讓我們一起動手,實現一個簡易的 Web 伺服器吧。
(【注】:參考自《How Tomcat Works》一書)
什麼是 Http
HTTP 是一種協議,全稱為超文字傳輸協議,它使得 Web 伺服器與瀏覽器能夠透過網際網路傳輸與接收資料,屬於一種請求/響應的通訊機制。HTTP 協議的底層依賴於 TCP 協議進行數據傳輸。目前,HTTP 已經演進至 2.x 版本,歷經從 0.9、1.0、1.1 到如今的 2.x,每次迭代都為協議增加了許多新功能。
在 HTTP 的通訊模式中,始終由客戶端發起請求,伺服器接收到請求後處理相應的邏輯,並在處理完成後返回響應資料。客戶端接收完資料後,請求流程結束。在此過程中,客戶端和伺服器均可以對已建立的連線進行中斷操作,譬如透過瀏覽器的停止按鈕來終止連線。
Http 請求
一個 HTTP 協議的請求由三部分組成:
請求行:包括請求方法、URI 和協議/版本,如
GET /index.html HTTP/1.1
。請求頭部:包含各種後設資料資訊,如主機地址、使用者代理、內容型別等,用於描述客戶端和請求的相關資訊。
請求主體:用於傳輸實際資料,通常在 POST 或 PUT 請求中包含,如表單資料或檔案內容。
例如:
POST /api/gateway/test HTTP/1.1 Accept: application/json Accept-Encoding: gzip, deflate, br, zstd Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6 Authorization: Bearer eyJhbGiOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMywidXNlcl9uYW1lIjoicWluZ3l1Iiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTcyMzkyMzgyMywiYXV0aG9yaXRpZXMiOlsiNDQiLCIzOCJdLCJqdGkiOiIwMzBlMjJlOC0xYTk2LTRkOWQtOTY5ZC0zYzA4ZGNjOTVkNTQiLCJjbGllbnRfaWQiOiJxbXMtYWRtaW4iLCJ1c2VybmFtZSI6InFpbmd5dSJ9.EAlw27ZlHSULReScmD3Au740bNDc0zP8r4FfrDswUMLBheEzfEDp68skbhdqn3LWm3o6wpAcYq6lIOsZn2n6SLyPTh2MrhyiU4v6og6UasJ-DnajPyQ8f1RvM-YjLIlXira3KxSFR0QITsc7IH_XQJKJOI5ipYt3hwb44FITRqyAZk7usnTmWaTvuzTGKCkhO05Yi1b-U8N-6y22Gn6AkGBgABkiXceiq6Uv9ZXj7E2dPGBEpyASrr-Zop2wPCgpl8BxHp0adoBcEophMakEj7btRhXh7f4vXMxdnO6MqT3gZI94y8c-Hp44hZlhnkzs7EA2JyG8vf22TDDLiLTCxg Connection: keep-alive Content-Length: 64 Content-Type: application/json; charset=UTF-8 Cookie: JSESSIONID=8757AA1D1D00449F8B37FFFE3C50F00A Host: note.clubsea.cn Origin: https://note.clubsea.cn Referer: https://note.clubsea.cn/ Sec-Fetch-Dest: empty Sec-Fetch-Mode: cors Sec-Fetch-Site: same-origin User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0 access-control-allow-credentials: true lang: zh-cn sec-ch-ua: "Not)A;Brand";v="99", "Microsoft Edge";v="127", "Chromium";v="127" sec-ch-ua-mobile: ?0 sec-ch-ua-platform: "macOS"
資料的第一行包含請求方法、URI、協議和版本。在此例中,方法為 POST,URI 為/api/gateway/test
,協議為HTTP/1.1
,協議版本為 1.1。各部分透過空格進行分隔。
請求頭部從第二行開始,採用英文冒號(:)分隔鍵和值。請求頭部與主體內容之間透過一個空行隔開。在此例中,請求主體為表單資料。
http 協議-響應
類似於 HTTP 協議的請求,響應也由三部分構成:
響應行:包括協議、狀態碼和狀態描述,如
HTTP/1.1 200 OK
。響應頭部:包含各種後設資料資訊,如內容型別、伺服器資訊、日期等,用於描述伺服器和響應的相關資訊。
響應主體:傳輸實際資料的部分,例如網頁內容或檔案資料。
HTTP/1.1 200 OK Content-Type: application/json Transfer-Encoding: chunked Connection: keep-alive Server: nginx Date: Sat, 17 Aug 2024 15:44:03 GMT Access-Control-Allow-Origin: https://note.clubsea.cn Access-Control-Allow-Credentials: true Access-Control-Expose-Headers: * Access-Control-Max-Age: 18000L X-Content-Type-Options: nosniff X-XSS-Protection: 1; mode=block Cache-Control: no-cache, no-store, max-age=0, must-revalidate Pragma: no-cache Expires: 0 X-Frame-Options: DENY Referrer-Policy: no-referrer Access-Control-Allow-Origin: * Access-Control-Allow-Credentials: true Access-Control-Allow-Methods: GET, POST, OPTIONS Access-Control-Allow-Headers: token,DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,XRequested-With Strict-Transport-Security: max-age=15768000
第一行 HTTP/1.1 200 OK
表示協議、狀態碼和狀態描述。隨後是響應頭部部分。響應頭部與主體內容之間由一個空行分隔。
什麼是 Socket
Socket,即套接字,是網路連線中的一個端點(end point),它使得應用程式能夠在網路上讀取和寫入資料。透過連線,不同計算機上的不同程序能夠互相傳送和接收資料。如果應用 A 希望向應用 B 傳送資料,A 應用需要知道 B 應用的 IP 地址以及 B 應用開放的套接字埠。在 Java 中,java.net.Socket
類用來表示一個套接字。
java.net.Socket
最常用的構造方法為:public Socket(String host, int port);
,其中 host
表示主機名或 IP 地址,port
表示套接字埠。接下來,我們來看一個具體的例子:
import java.io.*; import java.net.Socket; public class SocketExample { public static void main(String[] args) { try { // 建立Socket連線到本地伺服器,埠號為8080 Socket socket = new Socket("127.0.0.1", 8080); // 獲取輸出流以傳送資料 OutputStream os = socket.getOutputStream(); PrintWriter out = new PrintWriter(new OutputStreamWriter(os), true); // 獲取輸入流以接收資料 BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); // 傳送HTTP請求 out.println("GET /index.jsp HTTP/1.1"); out.println("Host: localhost:8080"); out.println("Connection: Close"); out.println(); // 結束請求頭 // 讀取並輸出響應 StringBuilder response = new StringBuilder(); String line; while ((line = in.readLine()) != null) { response.append(line).append("\n"); } // 輸出響應內容 System.out.println(response.toString()); // 關閉流和socket連線 in.close(); out.close(); socket.close(); } catch (IOException e) { e.printStackTrace(); } } }
這個示例程式碼做了以下幾點:
連線到本地伺服器的 8080 埠。
透過輸出流傳送 HTTP 請求。(透過
socket.getOutputStream()
方法可以傳送資料)透過輸入流讀取伺服器響應。(透過
socket.getInputStream()
方法可以讀取資料。)關閉連線和流。
ServerSocket
Socket 表示一個客戶端套接字,每次需要傳送或接收資料時,都需要建立一個新的 Socket。相對而言,伺服器端的應用程式需要考慮更多因素,因為伺服器需要隨時待命,無法預測何時會有客戶端連線。為此,在 Java 中,我們使用 java.net.ServerSocket
來表示伺服器端的套接字。
與 Socket 不同,ServerSocket 需要等待客戶端的連線請求。一旦有客戶端連線,ServerSocket 會建立一個新的 Socket 與客戶端進行通訊。
ServerSocket 提供了多種構造方法,我們可以舉一個常用的例子。
import java.io.*; import java.net.*; public class ServerSocketExample { public static void main(String[] args) { try { // 建立ServerSocket物件,繫結到埠8080,連線請求佇列長度為1,僅繫結到指定的本地IP地址 InetAddress bindAddress = InetAddress.getByName("127.0.0.1"); ServerSocket serverSocket = new ServerSocket(8080, 1, bindAddress); System.out.println("Server is listening on port 8080, bound to " + bindAddress); // 等待客戶端連線 Socket clientSocket = serverSocket.accept(); System.out.println("Client connected!"); // 獲取輸入流以接收客戶端資料 BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); // 獲取輸出流以傳送資料到客戶端 PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true); // 讀取客戶端傳送的請求 String inputLine; while ((inputLine = in.readLine()) != null) { System.out.println("Received: " + inputLine); if (inputLine.isEmpty()) { break; // 請求頭結束,退出迴圈 } } // 傳送HTTP響應到客戶端 out.println("HTTP/1.1 200 OK"); out.println("Content-Type: text/plain"); out.println("Connection: close"); out.println(); // 結束響應頭 out.println("Hello, client!"); // 響應體內容 // 關閉流和socket連線 in.close(); out.close(); clientSocket.close(); serverSocket.close(); } catch (IOException e) { e.printStackTrace(); } } }
這個示例程式碼完成了以下步驟:
建立
ServerSocket
例項:8080
是伺服器監聽的埠。1
是連線請求佇列的長度,即最大等待連線數。InetAddress.getByName("127.0.0.1")
指定了繫結的本地 IP 地址,確保伺服器只接受來自本地的連線。等待客戶端連線:
serverSocket.accept()
方法阻塞,直到有客戶端連線進來。處理客戶端連線:
讀取客戶端請求並列印。
傳送一個簡單的 HTTP 響應回客戶端。
清理資源:
關閉流和套接字以釋放資源。
HttpServer
我們來看一個具體的例子:
HttpServer
表示一個伺服器端的入口,它提供了一個 main
方法,並在 8080 埠上持續監聽,直到有客戶端建立連線。當客戶端連線到伺服器時,伺服器透過生成一個 Socket 來處理該連線。
import java.io.*; import java.net.*; public class HttpServer { /** * WEB_ROOT 是存放 HTML 和其他檔案的目錄。 * 對於這個包,WEB_ROOT 是工作目錄下的 "webroot" 目錄。 * 工作目錄是從執行 `java` 命令時的檔案系統位置。 */ public static final String WEB_ROOT = System.getProperty("user.dir") + File.separator + "webroot"; // 關閉命令的標識 private static final String SHUTDOWN_COMMAND = "/SHUTDOWN"; // 標記是否接收到關閉命令 private boolean shutdown = false; public static void main(String[] args) { // 建立 HttpServer 例項並啟動等待請求 HttpServer server = new HttpServer(); server.await(); } /** * 等待客戶端連線並處理請求 */ public void await() { ServerSocket serverSocket = null; int port = 8080; // 伺服器監聽的埠號 try { // 建立 ServerSocket 繫結到指定的埠和 IP 地址 serverSocket = new ServerSocket(port, 1, InetAddress.getByName("127.0.0.1")); } catch (IOException e) { e.printStackTrace(); System.exit(1); // 如果建立 ServerSocket 失敗,則退出程式 } // 迴圈等待並處理請求 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.close(); // 檢查請求的 URI 是否為關閉命令 shutdown = request.getUri().equals(SHUTDOWN_COMMAND); } catch (Exception e) { e.printStackTrace(); // 處理異常並繼續等待下一個請求 continue; } } // 關閉伺服器套接字 try { serverSocket.close(); } catch (IOException e) { e.printStackTrace(); } } }
Request 物件
Request
物件主要完成以下幾項工作:
解析請求資料:處理客戶端傳送的所有請求資料。
解析 URI:從請求資料的第一行中提取和解析 URI。
import java.io.*; public class Request { // 輸入流,用於讀取客戶端傳送的請求資料 private InputStream input; // 儲存請求的 URI(統一資源識別符號) private String uri; /** * 建構函式,初始化 Request 物件 * @param input 輸入流,用於讀取客戶端請求資料 */ public Request(InputStream input) { this.input = input; } /** * 解析客戶端請求 */ public void parse() { // 建立一個 StringBuffer 用於儲存從輸入流讀取的請求資料 StringBuffer request = new StringBuffer(2048); int i; byte[] buffer = new byte[2048]; // 緩衝區大小為2048位元組 try { // 從輸入流讀取資料到緩衝區 i = input.read(buffer); } catch (IOException e) { e.printStackTrace(); // 處理讀取錯誤 i = -1; // 讀取失敗 } // 將緩衝區中的位元組轉換為字元,並追加到 request 中 for (int j = 0; j < i; j++) { request.append((char) buffer[j]); } // 輸出請求內容到控制檯 System.out.print(request.toString()); // 從請求內容中解析 URI uri = parseUri(request.toString()); } /** * 從請求字串中提取 URI * @param requestString 請求的字串 * @return 提取的 URI */ private String parseUri(String requestString) { int index1, index2; // 查詢第一個空格的位置,標記請求方法的結束 index1 = requestString.indexOf(' '); if (index1 != -1) { // 查詢第二個空格的位置,標記請求 URI 的結束 index2 = requestString.indexOf(' ', index1 + 1); if (index2 > index1) { // 提取 URI 部分 return requestString.substring(index1 + 1, index2); } } // 如果未找到有效的 URI,返回 null return null; } /** * 獲取解析出的 URI * @return 請求的 URI */ public String getUri() { return uri; } }
Response 物件
Response
主要負責向客戶端傳送檔案內容(如果請求的 URI 指向的檔案存在)。
import java.io.*; public class Response { // 緩衝區的大小,用於讀取檔案內容 private static final int BUFFER_SIZE = 1024; // 請求物件 Request request; // 輸出流,用於將響應資料寫入客戶端 OutputStream output; /** * 建構函式,初始化 Response 物件 * @param output 輸出流,用於傳送響應資料到客戶端 */ public Response(OutputStream output) { this.output = output; } /** * 設定請求物件 * @param request 請求物件 */ public void setRequest(Request request) { this.request = request; } /** * 傳送靜態資源(如 HTML 檔案)的響應 * @throws IOException 如果發生 I/O 錯誤 */ public void sendStaticResource() throws IOException { byte[] bytes = new byte[BUFFER_SIZE]; // 建立緩衝區 FileInputStream fis = null; // 檔案輸入流 try { // 獲取請求 URI 對應的檔案 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 { // 如果檔案不存在,傳送404錯誤響應 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) { // 捕獲並列印異常 System.out.println(e.toString()); } finally { // 確保檔案輸入流被關閉 if (fis != null) { fis.close(); } } } }
總結
透過上述例子,我們驚喜地發現,在 Java 中實現一個 Web 伺服器其實簡單明瞭,程式碼也非常清晰!