Tomcat源码分析 (一)----- 手撕Java Web服务器需要准备哪些工作

本文涉及的产品
云数据库 RDS MySQL,集群系列 2核4GB
推荐场景:
搭建个人博客
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
RDS MySQL Serverless 基础系列,0.5-2RCU 50GB
简介: 本文探讨了后端开发中Web服务器的重要性,特别是Tomcat框架的地位与作用。通过解析Tomcat的内部机制,文章引导读者理解其复杂性,并提出了一种实践方式——手工构建简易Web服务器,以此加深对Web服务器运作原理的认识。文章还详细介绍了HTTP协议的工作流程,包括请求与响应的具体格式,并通过Socket编程在Java中的应用实例,展示了客户端与服务器间的数据交换过程。最后,通过一个简单的Java Web服务器实现案例,说明了如何处理HTTP请求及响应,强调虽然构建基本的Web服务器相对直接,但诸如Tomcat这样的成熟框架提供了更为丰富和必要的功能。

正文

作为后端开发人员,在实际工作中,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 经典面试题

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 服务器其实简单明了,代码也非常清晰!

既然我们能够如此轻松地实现一个 Web 服务器,那为何还需要 Tomcat 呢?它为我们提供了哪些组件和特性?这些组件又是如何组装起来的?后续章节将逐层解析这些问题。

让我们共同期待接下来的深入分析!

好了,本章节到此告一段落。希望对你有所帮助,祝学习顺利。

相关文章
|
5天前
|
安全 Java 调度
Java编程时多线程操作单核服务器可以不加锁吗?
Java编程时多线程操作单核服务器可以不加锁吗?
18 2
|
3天前
|
Java Linux
java读取linux服务器下某文档的内容
java读取linux服务器下某文档的内容
14 3
java读取linux服务器下某文档的内容
|
14天前
|
监控 网络协议 应用服务中间件
【Tomcat源码分析】从零开始理解 HTTP 请求处理 (第一篇)
本文详细解析了Tomcat架构中复杂的`Connector`组件。作为客户端与服务器间沟通的桥梁,`Connector`负责接收请求、封装为`Request`和`Response`对象,并传递给`Container`处理。文章通过四个关键问题逐步剖析了`Connector`的工作原理,并深入探讨了其构造方法、`init()`与`start()`方法。通过分析`ProtocolHandler`、`Endpoint`等核心组件,揭示了`Connector`初始化及启动的全过程。本文适合希望深入了解Tomcat内部机制的读者。欢迎关注并点赞,持续更新中。如有问题,可搜索【码上遇见你】交流。
【Tomcat源码分析】从零开始理解 HTTP 请求处理 (第一篇)
|
16天前
|
人工智能 前端开发 Java
【Tomcat源码分析】启动过程深度解析 (二)
本文深入探讨了Tomcat启动Web应用的过程,重点解析了其加载ServletContextListener及Servlet的机制。文章从Bootstrap反射调用Catalina的start方法开始,逐步介绍了StandardServer、StandardService、StandardEngine、StandardHost、StandardContext和StandardWrapper的启动流程。每个组件通过Lifecycle接口协调启动,子容器逐层启动,直至整个服务器完全启动。此外,还详细分析了Pipeline及其Valve组件的作用,展示了Tomcat内部组件间的协作机制。
【Tomcat源码分析】启动过程深度解析 (二)
|
5天前
|
Java
java小工具util系列5:java文件相关操作工具,包括读取服务器路径下文件,删除文件及子文件,删除文件夹等方法
java小工具util系列5:java文件相关操作工具,包括读取服务器路径下文件,删除文件及子文件,删除文件夹等方法
16 4
|
29天前
|
前端开发 Java 应用服务中间件
【Tomcat源码分析 】"深入探索:Tomcat 类加载机制揭秘"
本文详细介绍了Java类加载机制及其在Tomcat中的应用。首先回顾了Java默认的类加载器,包括启动类加载器、扩展类加载器和应用程序类加载器,并解释了双亲委派模型的工作原理及其重要性。接着,文章分析了Tomcat为何不能使用默认类加载机制,因为它需要解决多个应用程序共存时的类库版本冲突、资源共享、类库隔离及JSP文件热更新等问题。最后,详细展示了Tomcat独特的类加载器设计,包括Common、Catalina、Shared、WebApp和Jsp类加载器,确保了系统的稳定性和安全性。通过这种设计,Tomcat实现了不同应用程序间的类库隔离与共享,同时支持JSP文件的热插拔。
【Tomcat源码分析 】"深入探索:Tomcat 类加载机制揭秘"
|
12天前
|
Java 应用服务中间件 Windows
windows服务器重装系统之后,Tomcat服务如何恢复?
windows服务器重装系统之后,Tomcat服务如何恢复?
27 10
|
14天前
|
Kubernetes Java Maven
揭秘无服务器革命:Quarkus如何让Java应用在云端“零”负担起飞?
本文介绍如何使用Quarkus从零开始开发无服务器应用,通过示例代码和详细步骤引导读者掌握这一技术。无服务器架构让开发者无需管理服务器,具有自动扩展和成本效益等优势。Quarkus作为Kubernetes Native Java框架,优化了Java应用的启动速度和内存使用,适合无服务器环境。文章涵盖环境搭建、项目创建及部署全流程,并介绍了Quarkus的扩展性和监控工具,助力高效开发与应用性能提升。
24 9
|
1月前
|
设计模式 应用服务中间件 容器
【Tomcat源码分析】Pipeline 与 Valve 的秘密花园
本文深入剖析了Tomcat中的Pipeline和Valve组件。Valve作为请求处理链中的核心组件,通过接口定义了关键方法;ValveBase为其基类,提供了通用实现。Pipeline则作为Valve容器,通过首尾相连的Valve链完成业务处理。StandardPipeline实现了Pipeline接口,提供了详细的Valve管理逻辑。通过对代码的详细分析,揭示了模板方法模式和责任链模式的应用,展示了系统的扩展性和模块化设计。
【Tomcat源码分析】Pipeline 与 Valve 的秘密花园
下一篇
无影云桌面