Skip to content

S01-04 Servlet-手写Tomcat ​

[TOC]

基本实现 ​

Tomcat 的本质是 HTTP 服务器 + Servlet 容器。HTTP 服务器负责底层的 Socket 通信与报文解析,Servlet 容器则负责管理业务组件的生命周期并分发调用。

手写实现的核心链路分为两个核心阶段:

  • 第一阶段(核心链路原型) 实现基础网络通信、报文解析、Servlet 契约与单线程分发;
  • 第二阶段(生产特性扩展) 补充多线程并发模型与配置文件/注解的动态加载。以下为第一阶段的核心实现。

核心架构与处理流程 ​

核心架构:Web 服务器与 Servlet 之间的核心职责边界如下图所示:

image-20260913230420320

处理流程:从浏览器发起请求到接收响应,容器内部遵循以下执行流:

  1. 建立通信:服务端通过 ServerSocket 监听指定端口,接收客户端的 TCP 连接。

  2. 报文提取:从 Socket 的 InputStream 中读取原始 HTTP 请求文本。

  3. 对象封装:将原始报文解析并封装为统一的 MyRequest 和 MyResponse 对象。

  4. 路由派发:根据请求行中的 URI,在映射表中查找匹配的 MyServlet 实例。

  5. 业务执行与回写:调用 Servlet 的 service() 方法,将处理结果通过 OutputStream 遵循 HTTP 报文格式写回客户端。

核心引擎与请求分发@ ​

MyTomcat 承担连接器与容器的核心角色,维持端口监听、维护 URL 与 Servlet 的映射表,并完成请求调度。

java
// 初始化路由映射表并监听指定端口接收网络连接
public class MyTomcat {
  private final int port;
  private final Map<String, MyServlet> servletMapping = new HashMap<>();
  public MyTomcat(int port) { this.port = port; }
  // 注册请求路径与Servlet实例的映射关系
  private void initServletMapping() {
    servletMapping.put("/user", new UserServlet());
  }
  // 启动Socket服务监听并按请求路径分发到具体Servlet
  public void start() {
    initServletMapping();
    try (ServerSocket serverSocket = new ServerSocket(port)) {
      System.out.println("Tomcat running on port " + port);
      while (true) {
        Socket socket = serverSocket.accept();
        InputStream inputStream = socket.getInputStream();
        OutputStream outputStream = socket.getOutputStream();
        MyRequest request = new MyRequest(inputStream);
        MyResponse response = new MyResponse(outputStream);
        MyServlet servlet = servletMapping.get(request.getUrl());
        if (servlet != null) {
          servlet.service(request, response);
        } else {
          response.write("<h1>404 Not Found</h1>");
        }
        socket.close();
      }
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}

Servlet 规范定义 ​

Servlet 是容器与具体业务逻辑之间的契约规范。通过抽象类定义通用生命周期方法,并依据 HTTP Method 执行方法分发。

java
// 定义Servlet生命周期与请求处理标准规范
public abstract class MyServlet {
  public void init() {}
  public void destroy() {}
  // 根据请求方法类型分发至对应的doGet或doPost
  public void service(MyRequest request, MyResponse response) throws Exception {
    if ("POST".equalsIgnoreCase(request.getMethod())) {
      doPost(request, response);
    } else if ("GET".equalsIgnoreCase(request.getMethod())) {
      doGet(request, response);
    }
  }
  public abstract void doGet(MyRequest request, MyResponse response) throws Exception;
  public abstract void doPost(MyRequest request, MyResponse response) throws Exception;
}

HTTP 请求解析 ​

MyRequest 负责从网络输入流中提取字节数据,解析出 HTTP 请求行中的请求方法(Method)与请求路径(URI)。

java
// 从输入流中读取HTTP原生报文并提取请求行信息
public class MyRequest {
  private String method;
  private String url;
  public MyRequest(InputStream inputStream) throws IOException {
    // 读取网络字节流并截取解析首行请求参数
    byte[] buffer = new byte[1024];
    int len = inputStream.read(buffer);
    if (len > 0) {
      String content = new String(buffer, 0, len);
      String[] lines = content.split("\\n");
      if (lines.length > 0) {
        String[] requestLine = lines[0].split(" ");
        if (requestLine.length >= 2) {
          this.method = requestLine[0];
          this.url = requestLine[1];
        }
      }
    }
  }
  public String getMethod() { return method; }
  public String getUrl() { return url; }
}

HTTP 响应封装 ​

MyResponse 负责封装 HTTP 协议的响应规范,自动追加状态行与必要的消息头,再将响应体通过网络输出流回传。

java
// 组装符合HTTP协议格式的响应头与响应体数据
public class MyResponse {
  private final OutputStream outputStream;
  public MyResponse(OutputStream outputStream) {
    this.outputStream = outputStream;
  }
  // 拼接HTTP状态行与响应头后将内容回写给客户端浏览器
  public void write(String content) throws IOException {
    String responseText = "HTTP/1.1 200 OK\r\n"
        + "Content-Type: text/html;charset=UTF-8\r\n"
        + "Content-Length: " + content.getBytes(StandardCharsets.UTF_8).length + "\r\n"
        + "\r\n"
        + content;
    outputStream.write(responseText.getBytes(StandardCharsets.UTF_8));
    outputStream.flush();
  }
}

业务实现与测试验证 ​

业务 Servlet 实现 ​

业务开发者只需继承 MyServlet,重写对应的业务处理逻辑:

java
// 继承Servlet基类实现具体业务逻辑
public class UserServlet extends MyServlet {
  @Override
  public void doGet(MyRequest request, MyResponse response) throws Exception {
    // 处理GET请求并返回响应页面
    response.write("<h1>Hello from UserServlet!</h1>");
  }
  @Override
  public void doPost(MyRequest request, MyResponse response) throws Exception {
    doGet(request, response);
  }
}

服务启动与验证 ​

编写主程序入口实例化容器并启动监听:

java
// 创建Tomcat实例并启动服务
public class Application {
  public static void main(String[] args) {
    MyTomcat tomcat = new MyTomcat(8080);
    // 启动容器接收请求
    tomcat.start();
  }
}

启动程序后,在浏览器访问 http://localhost:8080/user,页面将输出 Hello from UserServlet!;访问未配置的地址如 http://localhost:8080/test,则会收到 404 Not Found 响应。

多线程与 Servlet ​

并发模型设计 ​

在第一阶段的单线程模型中,主线程调用 socket.getInputStream().read() 和 servlet.service() 时处于完全阻塞状态,后续到来的 TCP 连接只能在操作系统内核队列中排队。

为解决并发请求处理问题,第二阶段引入经典 BIO 的 一连接一线程(Thread-per-connection) 模型。监听主线程仅负责通过 accept() 接收客户端 Socket,每当有新连接建立,立即为其创建一个独立的子线程执行 HTTP 报文解析与业务调度。

如上图所示,服务端为每个客户端 Socket 分配专属线程。在各自的独立线程内完成读取、业务处理与写回操作,多个客户端之间的网络等待与计算相互隔离,实现了基础的并发处理能力。

配置文件规范 ​

为了避免在 Java 代码中硬编码 URL 与具体实现类的绑定关系,引入标准的 web.xml 配置文件。通过解耦别名与路径,建立灵活的路由映射机制。

xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app>
  <servlet>
    <servlet-name>userServlet</servlet-name>
    <servlet-class>com.example.UserServlet</servlet-class>
  </servlet>
  <servlet-mapping>
    <servlet-name>userServlet</servlet-name>
    <url-pattern>/user</url-pattern>
  </servlet-mapping>
</web-app>

配置映射分为两步协同生效:

  1. 组件注册:在 <servlet> 标签中指定组件逻辑名称 servlet-name 与对应的全限定类名 servlet-class。

  2. 路由关联:在 <servlet-mapping> 标签中通过相同的 servlet-name 将外部访问路径 url-pattern 绑定到该组件。

解析与反射加载 ​

容器启动时,利用 JDK 自带的 JAXP DOM 解析器读取 web.xml 文件,提取标签信息并使用 Java 反射机制实例化 MyServlet,注册到内存映射表中。

java
// 解析web.xml并利用反射动态构建Servlet映射表
public class WebXmlConfig {
  public static Map<String, MyServlet> load(String path) {
    Map<String, MyServlet> servletMapping = new HashMap<>();
    Map<String, String> nameToClass = new HashMap<>();
    Map<String, String> urlToName = new HashMap<>();
    try (InputStream is = WebXmlConfig.class.getResourceAsStream(path)) {
      // 初始化XML解析器并读取配置文档
      Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(is);
      NodeList servletNodes = doc.getElementsByTagName("servlet");
      for (int i = 0; i < servletNodes.getLength(); i++) {
        Element ele = (Element) servletNodes.item(i);
        String name = ele.getElementsByTagName("servlet-name").item(0).getTextContent();
        String clazz = ele.getElementsByTagName("servlet-class").item(0).getTextContent();
        nameToClass.put(name, clazz);
      }
      NodeList mappingNodes = doc.getElementsByTagName("servlet-mapping");
      for (int i = 0; i < mappingNodes.getLength(); i++) {
        Element ele = (Element) mappingNodes.item(i);
        String name = ele.getElementsByTagName("servlet-name").item(0).getTextContent();
        String url = ele.getElementsByTagName("url-pattern").item(0).getTextContent();
        urlToName.put(url, name);
      }
      // 根据映射关系通过反射创建Servlet单例并注册
      for (Map.Entry<String, String> entry : urlToName.entrySet()) {
        String className = nameToClass.get(entry.getValue());
        Class<?> clazz = Class.forName(className);
        MyServlet servlet = (MyServlet) clazz.getDeclaredConstructor().newInstance();
        servletMapping.put(entry.getKey(), servlet);
      }
    } catch (Exception e) {
      e.printStackTrace();
    }
    return servletMapping;
  }
}

独立任务封装 ​

为了让工作线程承载单次请求的完整生命周期,将 Socket 的 IO 读取、业务分发及资源关闭动作统一封装为一个实现 Runnable 接口的任务类 SocketProcessor。

java
// 封装单次客户端Socket请求的处理任务
public class SocketProcessor implements Runnable {
  private final Socket socket;
  private final Map<String, MyServlet> servletMapping;
  public SocketProcessor(Socket socket, Map<String, MyServlet> servletMapping) {
    this.socket = socket;
    this.servletMapping = servletMapping;
  }
  @Override
  public void run() {
    // 在独立线程中完成HTTP解析与Servlet调度
    try (
      InputStream inputStream = socket.getInputStream();
      OutputStream outputStream = socket.getOutputStream()
    ) {
      MyRequest request = new MyRequest(inputStream);
      MyResponse response = new MyResponse(outputStream);
      MyServlet servlet = servletMapping.get(request.getUrl());
      if (servlet != null) {
        servlet.service(request, response);
      } else {
        response.write("<h1>404 Not Found</h1>");
      }
    } catch (Exception e) {
      e.printStackTrace();
    } finally {
      // 确保处理完成后释放当前Socket网络连接资源
      try {
        socket.close();
      } catch (IOException e) {
        e.printStackTrace();
      }
    }
  }
}

容器引擎改造@ ​

重构 MyTomcat 的启动流程:在服务启动时调用 WebXmlConfig.load() 完成配置预加载;在主监听循环中,每当调用 accept() 获取到新连接,直接提交给 new Thread(...) 启动异步处理。

java
// 支持BIO多线程并发与动态配置加载的Tomcat引擎
public class MyTomcat {
  private final int port;
  private Map<String, MyServlet> servletMapping = new HashMap<>();
  public MyTomcat(int port) { this.port = port; }
  public void start() {
    // 启动阶段通过反射加载web.xml配置
    servletMapping = WebXmlConfig.load("/web.xml");
    try (ServerSocket serverSocket = new ServerSocket(port)) {
      System.out.println("Tomcat BIO Server started on port " + port);
      while (true) {
        Socket socket = serverSocket.accept();
        // 为每个新连接创建并启动专属工作线程
        new Thread(new SocketProcessor(socket, servletMapping)).start();
      }
    } catch (Exception e) {
      e.printStackTrace();
    }
  }
}

架构对比分析 ​

引入多线程与配置文件后,容器在解耦度与并发能力上有了质的提升:

对比维度第一阶段(原型)第二阶段(BIO 多线程 + web.xml)
并发处理能力单线程串行阻塞,后续请求排队一连接一线程,多客户端并行处理
路由绑定方式源代码硬编码实例,改动需重编译web.xml 声明式配置,全限定名反射解耦
Servlet 生命周期容器启动时固定创建对象启动时解析 XML 动态按需或批量反射实例化
资源隔离性任意单次慢请求导致全局不可用慢请求仅阻塞自身子线程,不影响新连接接入

BIO 并发瓶颈分析 ​

虽然 new Thread(...) 满足了多客户端同时请求的基本诉求,但在高并发场景下暴露出严重的架构弊端:

  1. 线程创建开销巨大:操作系统创建和销毁内核线程的系统调用代价很高,请求激增时 CPU 消耗主要浪费在线程生命周期管理上。

  2. 上下文切换开销剧增:并发线程数远超 CPU 核心数时,线程调度器频繁保存与恢复寄存器上下文,导致系统有效吞吐量骤降。

  3. 内存与系统崩溃风险:Java 线程栈默认占用较大内存(如 1MB),无上限地创建线程极易耗尽系统内存导致 OutOfMemoryError,或触发操作系统的最大进程数/文件描述符限制。

上述缺陷促使 Tomcat 在后续演进中采用线程池模型(伪异步 IO),并最终全面转向基于 NIO 的多路复用模型(NioEndpoint)。