切換語言為:簡體

從0到1實現一個簡易的web伺服器

  • 爱糖宝
  • 2024-08-19
  • 2055
  • 0
  • 0

正文

作為後端開發人員,在實際工作中,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 協議的請求由三部分組成:

  1. 請求行:包括請求方法、URI 和協議/版本,如 GET /index.html HTTP/1.1

  2. 請求頭部:包含各種後設資料資訊,如主機地址、使用者代理、內容型別等,用於描述客戶端和請求的相關資訊。

  3. 請求主體:用於傳輸實際資料,通常在 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 協議的請求,響應也由三部分構成:

  1. 響應行:包括協議、狀態碼和狀態描述,如 HTTP/1.1 200 OK

  2. 響應頭部:包含各種後設資料資訊,如內容型別、伺服器資訊、日期等,用於描述伺服器和響應的相關資訊。

  3. 響應主體:傳輸實際資料的部分,例如網頁內容或檔案資料。

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();
        }
    }
}

這個示例程式碼做了以下幾點:

  1. 連線到本地伺服器的 8080 埠。

  2. 透過輸出流傳送 HTTP 請求。(透過 socket.getOutputStream()方法可以傳送資料)

  3. 透過輸入流讀取伺服器響應。(透過 socket.getInputStream()方法可以讀取資料。)

  4. 關閉連線和流。

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();
        }
    }
}

這個示例程式碼完成了以下步驟:

  1. 建立 ServerSocket 例項:

    • 8080 是伺服器監聽的埠。

    • 1是連線請求佇列的長度,即最大等待連線數。

    • InetAddress.getByName("127.0.0.1") 指定了繫結的本地 IP 地址,確保伺服器只接受來自本地的連線。

  2. 等待客戶端連線:

    • 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 物件主要完成以下幾項工作:

  1. 解析請求資料:處理客戶端傳送的所有請求資料。

  2. 解析 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 伺服器其實簡單明瞭,程式碼也非常清晰!

0則評論

您的電子郵件等資訊不會被公開,以下所有項目均必填

OK! You can skip this field.