手写 Tomcat 步骤教程

手写 Tomcat 步骤教程

git开源仓库本文源码链接

本项目目标:不使用任何 Web 框架,纯 JDK 手写一个简化版但完整可用 的 Tomcat 服务器。

支持:Socket 监听、HTTP 协议解析、自定义注解路由、反射加载 Servlet、Servlet 生命周期、静态资源返回、线程池并发处理。


第一章 Tomcat 是什么?它是怎么工作的?

1.1 Tomcat 的作用

Tomcat 本质上是一个 Servlet 容器(Servlet Container),同时内置 JSP 引擎。它解决的核心问题是:

如果没有 Tomcat,我们每写一个 Web 应用都要自己处理:

  1. 监听端口、接受 TCP 连接(Socket 编程);
  2. 按 HTTP 协议格式解析请求(请求行、请求头、请求体);
  3. 把请求映射到正确的处理代码;
  4. 按 HTTP 格式封装响应并写回客户端;
  5. 用线程池并发处理大量同时到来的请求。

Tomcat 把这些全部接管了,而且内部职责划分得很清楚:

  • 连接器 Coyote :负责 Socket 通信、HTTP 协议解析,把字节流包装成 HttpRequest/HttpResponse 对象;
  • 容器 Catalina :负责 Servlet 的生命周期管理(init → service → destroy),通过 Mapper 组件按 Host → Context → Wrapper 逐级匹配,把请求分发到具体的 Servlet 执行。

程序员只需要专注写 Servlet(业务代码),并按规范配置映射关系。

这也是后来 SpringMVC 等框架的根基------DispatcherServlet 本身就是一个普通的 Servlet ,只不过它注册了 /* 之类的通配映射,把容器转交过来的请求再二次分发给各 Controller。所以请求链路永远是:浏览器 → Tomcat(Coyote 解析 + Catalina 分发)→ DispatcherServlet → Controller,看懂这条链路,就看懂了整个 Java Web 的地基。

jsp:允许在 HTML 中嵌入 Java 代码

jsp 复制代码
<html>
<body>
 <h1>Hello, <%= request.getAttribute("name") %></h1>
 <% for (int i = 0; i < 3; i++) { %>
     <p>第 <%= i %> 行</p>
 <% } %>
</body>
</html>

1.2 一次请求的完整旅程

浏览器输入 http://ip:8080/myweb/index.html,背后发生了什么?看下面这张图:

拆解成文字流程:

复制代码
浏览器 ──HTTP请求──> 网卡(8080端口) ──> Tomcat 的 ServerSocket 监听到连接
                                          │
                                          ▼
                                   ① 读取 Socket 输入流,拿到 HTTP 报文
                                          │
                                          ▼
                                   ② HTTP 解析:拆出 请求方式(GET/POST)、
                                      请求路径(/myweb/xxx)、请求头(cookie 等)
                                          │
                                          ▼
                                   ③ 拿着"请求路径"去 servlet 路由表(Map) 里查
                                          │
                              ┌───────────┼───────────────┐
                              ▼           ▼               ▼
                        查到了 Servlet   没查到,但是    什么都没找到
                        (动态资源)      磁盘上有这个文件   → 404
                              │        (静态资源)
                              ▼           ▼
                        反射调用         读文件成字节流
                        service()       直接写回
                              │           │
                              └─────┬─────┘
                                    ▼
                          ④ 拼接 HTTP 响应头 + 响应体,写回 Socket
                                    │
                                    ▼
                              浏览器渲染页面

几个关键概念:

概念 说明
Socket 操作系统提供的网络通信端点。Tomcat 用 ServerSocket 监听端口,每来一个浏览器连接就 accept() 出一个 Socket,它是一条双向数据通道(输入流读请求,输出流写响应)。
HTTP 协议 就是一段约定好格式的文本。请求 = 请求行 + 请求头 + 空行 + 请求体;响应 = 状态行 + 响应头 + 空行 + 响应体。Tomcat 的工作一半在"解析这段文本",一半在"拼接这段文本"。
静态资源 磁盘上现成的文件:HTML、CSS、JS、图片。Tomcat 原样读出来返回即可。
动态资源 需要运行 Java 代码才能生成的内容,由 Servlet 处理。
Servlet 路由表 Tomcat 内部维护一个 Map<路径, Servlet对象>,请求来了按路径查表,查到谁就让谁处理。这是整个 Tomcat 的中枢神经。

Tomcat 的静态资源不是裸读硬盘,而是由内置的 DefaultServlet(映射到 /,优先级最低兜底)处理。它把访问范围严格限定在 Web 应用的 docBase 内,通过路径规范化防 .. 穿越攻击、禁止直接访问 WEB-INF、默认关闭目录列表和符号链接。所以静态和动态资源殊途同归------所有请求最终都是 Servlet 在扛,只是动态资源匹配到我们写的 Servlet,静态资源落到 DefaultServlet。

1.3 BIO 与 NIO(以及我们为什么用线程池)

  • BIO(阻塞式 IO) :一个线程处理一个连接,线程在 read() 时会傻等数据到达。真实 Tomcat 早期就是这么干的,简单直观。
  • NIO(非阻塞式 IO):一个线程可以监管成千上万个连接,谁有数据就处理谁。现代 Tomcat 默认用 NIO。

我们手写版采用 BIO + 线程池 的组合:

  • 用 BIO 是因为代码简单、教学清晰;

  • 但"来一个连接就 new Thread()"是有问题的------线程创建销毁成本高,并发高时会拖垮系统。所以我们引入线程池:预先建好一批线程,连接来了从池子里领一个干活的,干完还回去。这正是"主线程监听、子线程干活"的主从分工模式:

    主线程(Acceptor):while(true) { Socket s = serverSocket.accept(); 线程池.submit(任务); }
    Worker 线程:从 Socket 读请求 → 解析 → 分发 → 写响应 → 归还线程

1.4 Servlet 的继承体系

写业务代码的人面对的只有 Servlet。它的设计是一个经典的**"接口定规范、抽象类做复用"**的模板:

复制代码
Servlet (接口)
   │  定义生命周期规范:init() / service() / destroy()
   ▼
GenericServlet (抽象类)
   │  把 init/destroy 等做了通用实现,只留 service() 抽象
   ▼
HttpServlet (抽象类)
   │  实现 service():判断是 GET 还是 POST,分发到 doGet()/doPost()
   ▼
MyXxxServlet (我们写的业务类)
      只重写 doGet() / doPost(),专注业务

为什么这样设计?

  • 接口(Servlet) :规定"任何 Servlet 都必须有生命周期方法",容器只面向接口编程,不管你具体是谁 → 多态
  • 抽象类(HttpServlet) :把"GET/POST 分发"这种所有 Servlet 都要的公共逻辑写好,子类直接继承 → 继承的本质是代码复用
  • 于是我们写业务时只需 extends HttpServlet 然后重写 doGet,非常干净。这个分层思想后来原封不动地出现在各种框架里。

1.5 我们要用到的"高级手法"

技术 用在哪 为什么需要它
自定义注解 + 元注解 @WebServlet("/hello") 标记路由路径 免去 XML 配置,启动时扫描类上的注解自动注册路由
反射 扫描 class 文件 → 读注解 → newInstance() 创建 Servlet 容器启动时并不知道你写了什么类,只能靠反射在运行时动态加载
多态 Map<String, Servlet> 统一管理所有业务 Servlet 容器不需要知道具体类型,调 service() 即可
线程池 并发处理多个浏览器连接 避免频繁创建线程,控制资源
BIO Socket 监听端口、收发数据 Web 服务器的根基
HTTP 协议解析 把请求报文拆成 HttpRequest 对象 框架的"脏活累活"

1.6 整体架构

项目骨架(包结构)预览:

复制代码
com.huohuo
├── Main.java                    # 启动入口:初始化容器 → 启动监听
├── server/
│   └── TomcatServer.java        # ServerSocket 监听 + 线程池分发
├── http/
│   ├── HttpRequest.java         # 封装 HTTP 请求(方法、路径、参数、头)
│   └── HttpResponse.java        # 封装 HTTP 响应(状态码、响应头、写数据)
├── servlet/
│   ├── Servlet.java             # 顶层接口:生命周期规范
│   ├── GenericServlet.java      # 抽象类:通用实现
│   └── HttpServlet.java         # 抽象类:GET/POST 分发
├── annotation/
│   └── WebServlet.java          # 自定义注解:标记路由路径
├── core/
│   ├── ServletContext.java      # 路由表容器(Map<路径, Servlet>)
│   └── ServletScanner.java      # 包扫描 + 反射注册
├── handler/
│   └── RequestHandler.java      # 单个请求的处理任务(Runnable)
└── util/
    └── FileUtil.java            # 静态资源读取

第二章 动手写代码

2.1 在 tomcat-build 项目根目录下新建/修改 pom.xml

打开 tomcat-build/pom.xml,把它改成下面这样。关键改动是 <properties> 块锁定 Java 8 和 UTF-8:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.huohuo</groupId>
    <artifactId>tomcat-build</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
    </properties>

    <dependencies>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>3.8.1</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <finalName>tomcat-build</finalName>
    </build>
</project>

项目里默认的 com.huohuo.Main 占位类先留着不动,后面启动入口会重写它。

2.2 在 com.huohuo 包下新建 7 个子包

在 IDEA 中右键 com.huohuo → New → Package,依次建好:

复制代码
com.huohuo
├── servlet/        # Servlet 继承体系(接口 + 两个抽象类)
├── annotation/     # 自定义注解
├── http/           # HttpRequest / HttpResponse 封装
├── core/           # 容器(路由表)+ 包扫描器
├── handler/        # 单个请求的处理任务(Runnable)
├── server/         # 主服务器(ServerSocket + 线程池)
└── util/           # 工具类(静态资源读取等)

2.3 在 com.huohuo.annotation 包下新建 WebServlet.java

现在在 com.huohuo.annotation 包下新建一个接口/注解类型 WebServlet(注意不是 class,是 @interface)。

老式 Servlet 是注册在 web.xml 里的:

xml 复制代码
<servlet>
    <servlet-class>com.qcby.webapp.myweb.MyFirstServlet</servlet-class>
</servlet>
<servlet-mapping>
    <url-pattern>/first</url-pattern>
</servlet-mapping>

写两个类要配两段 XML,新增一个 Servlet 就要回头改 web.xml ------ 配置与代码分离、容易漏配错配。我们改用注解一行解决。

完整代码如下:

java 复制代码
package com.huohuo.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 自定义 Servlet 路由注解。
 * 用法:在业务 Servlet 上标注 @WebServlet("/hello"),容器启动时
 *      会扫描该注解并把 "/hello" -> 这个 Servlet 实例 注册进路由表。
 */
@Target(ElementType.TYPE)          // 元注解1:只能贴在"类/接口/枚举"上
@Retention(RetentionPolicy.RUNTIME) // 元注解2:保留到运行期,反射才能读到
public @interface WebServlet {
    String value();
}

关键在两个元注解("修饰注解的注解"):

元注解 取值 含义
@Target ElementType.TYPE 这个注解只能贴在类/接口/枚举 上。贴到方法上会编译报错,保护你不出低级错误。可选值还有 FIELD(字段)、METHOD(方法)等。
@Retention RetentionPolicy.RUNTIME 注解信息要保留到运行期 。默认是 CLASS(只保留到字节码、反射读不到),我们必须升级到 RUNTIME,因为容器要在启动时用反射扫描类上的注解。

String value() 是注解里唯一的属性。Java 语法规定:当注解只写一个属性且名字叫 value 时,可以省略名字写成 @WebServlet("/hello"),等价于 @WebServlet(value="/hello")。这是为了让使用者少打字。

📌 设计要点:注解本身什么都不做,它只是贴标签。真正让注解"起作用"的是容器里的反射扫描代码(后面会写)。很多人写完注解发现没效果,就是因为在等"谁来读我"------必须有反射代码主动去读它。
⚠️ 顺序说明 :下面先写 HttpRequest / HttpResponse,再回头写 Servlet 接口。因为 Servlet 接口里的 service() 方法参数类型就是 HttpRequestHttpResponse,如果先写 Servlet,IDEA 会报红说类型不存在。跟着文档顺序走,每一步都能编译通过。

2.4 在 com.huohuo.http 包下新建 HttpRequest.java

现在在 com.huohuo.http 包下新建一个普通类 HttpRequest

这个类的核心任务:从 Socket 的输入流里读取浏览器发过来的 HTTP 报文,解析出请求方法、请求路径、请求参数、请求头等,供后面的 Servlet 使用。

完整代码如下:

java 复制代码
package com.huohuo.http;


import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.HashMap;
import java.util.Map;

/**
 * HttpRequest ------ 封装一个 HTTP 请求。
 * 它相当于"把浏览器发过来的那段 HTTP 文本,翻译成 Java 对象"。
 * 后面容器只需要调用 getMethod()、getPath()、getParameter() 等 API,
 * 不用再和原始字节流打交道。
 */
public class HttpRequest {

    private String method; // 请求方法 Get / Post
    private String path;  // 请求路径
    private String url;  // 请求 URL
    private String protocol; // 请求协议 HTTP/1.1
    private final Map<String, String> headers = new HashMap<>();
    private final Map<String, String> parameters = new HashMap<>();

    public HttpRequest(InputStream inputStream) throws IOException {
        parse(inputStream);
    }

    /**
     * 解析 HTTP 请求:
     *   1. 读第一行 "GET /hello?name=Tom HTTP/1.1"
     *   2. 拆分出 method、url、protocol
     *   3. 把 URL 里的 ?name=Tom 解析成参数表
     *   4. 继续读请求头,直到空行
     */
    private void parse(InputStream inputStream) throws IOException {
        BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));

        String requestLine = reader.readLine();
        if(requestLine == null || requestLine.trim().isEmpty()){
            return;
        }
        String[] parts = requestLine.split(" ");
        if(parts.length < 3){
            return;
        }
        method = parts[0];
        url = parts[1];
        protocol = parts[2];

        int questionIndex = url.indexOf("?");
        if(questionIndex != -1){
            path = url.substring(0, questionIndex);
            parseQueryString(url.substring(questionIndex + 1));
        }else {
            path = url;
        }

        String line;
        while ((line = reader.readLine()) != null && !line.trim().isEmpty()) {
            int colonIndex = line.indexOf(":");
            if(colonIndex != -1){
                String name = line.substring(0, colonIndex).trim();
                String value = line.substring(colonIndex + 1).trim();
                headers.put(name, value);
            }
        }
    }

    /**
     * 解析查询字符串:name=Tom&age=18 → {name=Tom, age=18}
     */
    private void parseQueryString(String queryString) {
        if (queryString == null || queryString.isEmpty()) {
            return;
        }
        for (String pair : queryString.split("&")) {
            String[] kv = pair.split("=", 2);
            String key = kv[0];
            String value = kv.length > 1 ? kv[1] : "";
            parameters.put(key, value);
        }
    }

    // ---------- getter ----------
    public String getMethod() { return method; }
    public String getUrl() { return url; }
    public String getPath() { return path; }
    public String getProtocol() { return protocol; }
    public String getHeader(String name) { return headers.get(name); }
    public String getParameter(String name) { return parameters.get(name); }

    @Override
    public String toString() {
        return "HttpRequest{" +
                "method='" + method + '\'' +
                ", url='" + url + '\'' +
                ", path='" + path + '\'' +
                '}';
    }
}

这里值得细讲的知识点:

  1. BufferedReader 为什么可以按行读?

    HTTP 协议的请求头和请求体之间用 \r\n 分隔,BufferedReader.readLine() 正好以换行符为界,一次读一行。这是 BIO 模型下解析文本协议最自然的做法。

  2. split(" ", 3) 其实更安全

    上面写的是 split(" "),默认会把整行按空格全拆开。真实 URL 里如果有空格会被编码成 %20,所以这里问题不大。更严谨的写法是 requestLine.split(" ", 3),表示最多拆成 3 段,最后一段 protocol 即使有内部空格也不会被再拆。

  3. split("=", 2) 的必要性

    如果参数值本身带有 =(比如 base64 编码后的字符串),写成 split("=") 会把值再拆碎。加 , 2 限制最多拆两段,保证值保持原样。

  4. 当前只解析了 GET 参数

    POST 的 form 参数在请求体里,后面根据进度再扩展。现在这个版本足够先让 GET 请求跑起来。

2.5 在 com.huohuo.http 包下新建 HttpResponse.java

现在在 com.huohuo.http 包下新建一个普通类 HttpResponse

这个类的核心任务:帮 Servlet 生成标准的 HTTP 响应报文,然后写回 Socket 的输出流。

完整代码如下:

java 复制代码
package com.huohuo.http;

import java.io.OutputStream;
import java.nio.charset.StandardCharsets;

/**
 * HttpResponse ------ 封装一个 HTTP 响应。
 * 使用方式:
 *   response.setStatus(200);
 *   response.write("<h1>Hello</h1>");
 * 一行调用就会把 HTTP 响应头 + 响应体拼好发出去。
 */
public class HttpResponse {

    private final OutputStream outputStream;
    private int status = 200;
    private String contentType = "text/html;charset=UTF-8";

    public HttpResponse(OutputStream outputStream) {
        this.outputStream = outputStream;
    }
    public void setStatus(int status) {
        this.status = status;
    }
    public void setContentType(String contentType) {
        this.contentType = contentType;
    }
    public OutputStream getOutputStream() {
        return outputStream;
    }

    /**
     * 向浏览器输出一段字符串,并自动拼好 HTTP 响应头。
     */
    public void write(String content) throws Exception {
        byte[] data = content.getBytes(StandardCharsets.UTF_8);

        String responseHeader = getStatusLine(status) + "\r\n" +
                "Content-Type: " + contentType + "\r\n" +
                "Content-Length: " + data.length + "\r\n" +
                "Connection: close\r\n" +
                "\r\n";           // 空行:头与体的分界线

        outputStream.write(responseHeader.getBytes(StandardCharsets.UTF_8));
        outputStream.write(data);
        outputStream.flush();
    }

    private String getStatusLine(int status) {
        return switch (status) {
            case 200 -> "HTTP/1.1 200 OK";
            case 404 -> "HTTP/1.1 404 Not Found";
            case 405 -> "HTTP/1.1 405 Method Not Allowed";
            default -> "HTTP/1.1 " + status + " Unknown";
        };
    }
}

这里值得细讲的知识点:

  1. 响应头最后一定要有一个空行 \r\n\r\n

    HTTP 协议规定:响应头结束后必须跟一个空行,空行之后才是响应体。浏览器就是靠这个空行来判断"头到此结束,后面是内容"。如果漏了这个空行,浏览器会一直等,页面空白。

  2. Content-Length 必须和实际字节数一致

    一个中文字符在 UTF-8 下占 3 个字节。所以我们先把字符串 getBytes("UTF-8") 转成字节数组,再拿数组长度作为 Content-Length。如果直接写 content.length()(字符数),中文场景下浏览器会截断或乱码。

  3. Connection: close 的含义

    告诉浏览器:"我发完这次响应就关闭连接,你别复用了。" 这样浏览器只要读到连接关闭就知道数据接收完毕,简化了很多边界处理。真实 Tomcat 会维护连接池复用连接,但我们手写第一版用短连接更简单可靠。

  4. 为什么提供 getOutputStream()

    write(String) 适合返回动态字符串。后面返回静态文件时,我们需要手动写头,然后直接把文件字节流 copyTo 输出流,这时就用 getOutputStream()

2.6 在 com.huohuo.servlet 包下新建 Servlet.java 接口

现在在 com.huohuo.servlet 包下新建一个接口 Servlet(注意是 interface 不是 class)。

完整代码如下:

java 复制代码
package com.huohuo.servlet;

import com.huohuo.http.HttpRequest;
import com.huohuo.http.HttpResponse;

public interface Servlet {
    void init();
    void service(HttpRequest request, HttpResponse response) throws Exception;
    void destroy();
}

三个方法正好对应 Servlet 的生命周期三阶段:

方法 谁调用 何时调用 调用几次
init() 容器 Servlet 被首次加载进路由表时 一次(单例)
service() 容器 每来一个匹配的请求 每请求一次
destroy() 容器 容器关闭 一次

为什么用接口而不是直接写抽象类?

  • 接口 = 纯契约 ,连字段都不能有。它定义"任何 Servlet 都必须是这样一份契约",方便容器以 Servlet 类型统一引用所有业务类 ------多态的支点
  • 抽象类是留给下一步的 GenericServlet 做的事:把 init/destroy 这些所有 Servlet 都一样的逻辑实现掉,只把 service 留给子类 ------代码复用的支点

这就是"接口定规范、抽象类做复用"的经典分层设计。

2.7 在 com.huohuo.servlet 包下新建 GenericServlet.java 抽象类

现在在 com.huohuo.servlet 包下新建一个抽象类 GenericServlet,让它 implements Servlet

完整代码如下:

java 复制代码
package com.huohuo.servlet;

import com.huohuo.http.HttpRequest;
import com.huohuo.http.HttpResponse;

/**
 * GenericServlet 抽象类 ------ 所有 Servlet 的通用实现层。
 * 设计思路:
 *   Servlet 接口已经定好了"必须会什么";
 *   GenericServlet 负责把"大家都会、而且实现方式差不多"的事情先做好,
 *   让后面的 HttpServlet 和业务类专注于自己不同的部分。
 */
public abstract class GenericServlet implements Servlet {
    @Override
    public void init() {

    }

    /**
     * service() 留给子类 HttpServlet 去实现。
     */
    @Override
    public abstract void service(HttpRequest request, HttpResponse response) throws Exception;

    @Override
    public void destroy() {
        // 默认空实现
    }
}

为什么要抽象类?

接口里 service() 没有方法体,每个实现类都得写。但 init()destroy() 在大多数情况下就是空着------如果每个业务 Servlet 都手动写两个空方法,纯属复制粘贴噪音。

抽象类的作用就是:把"大多数子类都长一样"的实现默认写好,把"每个子类必须自己决定"的留给抽象方法 。这里 service() 仍然抽象,因为 GenericServlet 这一层还不知道请求该怎么分发;等 HttpServlet 继承它时,再把 service() 实现成 GET/POST 分发。

2.8 在 com.huohuo.servlet 包下新建 HttpServlet.java 抽象类

现在在 com.huohuo.servlet 包下新建一个抽象类 HttpServlet,让它 extends GenericServlet

完整代码如下:

java 复制代码
package com.huohuo.servlet;

import com.huohuo.http.HttpRequest;
import com.huohuo.http.HttpResponse;

/**
 * HttpServlet 抽象类 ------ 专门针对 HTTP 协议的 Servlet 父类。
 *
 * 核心职责:
 *   重写 GenericServlet 的 service(),根据 HttpRequest 里的请求方法(Method)
 *   把请求分发到 doGet() 或 doPost()。
 *
 * 这样业务类只需要关心 doGet()/doPost() 即可,不用自己写 if-else 判断方法类型。
 * 这正是"继承的本质是代码复用"------把公共分发逻辑抽到父类里。
 */
public abstract class HttpServlet extends GenericServlet {

    @Override
    public void service(HttpRequest request, HttpResponse response) throws Exception {
        String method = request.getMethod();

        if ("GET".equalsIgnoreCase(method)) {
            doGet(request, response);
        } else if ("POST".equalsIgnoreCase(method)) {
            doPost(request, response);
        } else {
            // 其他方法直接返回 405 Method Not Allowed
            response.setStatus(405);
            response.write("HTTP method not supported: " + method);
        }
    }

    /**
     * 处理 GET 请求。子类如果不支持 GET 可保持默认,调用时返回 405。
     */
    protected void doGet(HttpRequest request, HttpResponse response) throws Exception {
        response.setStatus(405);
        response.write("GET method not supported");
    }

    /**
     * 处理 POST 请求。子类如果不支持 POST 可保持默认,调用时返回 405。
     */
    protected void doPost(HttpRequest request, HttpResponse response) throws Exception {
        response.setStatus(405);
        response.write("POST method not supported");
    }
}

这里的三个关键点:

  1. equalsIgnoreCase 而不是 ==

    request.getMethod() 返回的是 String。两个字符串比较是否相等,必须用 equals(或 equalsIgnoreCase 忽略大小写),用 == 比较的是内存地址,容易踩坑。

  2. doGet/doPostprotected,不是 public

    这是模板方法模式的典型设计:父类提供钩子方法,子类重写它。用户不需要、也不应该直接从外面调 doGet,容器只调 service()。用 protected 限制访问范围,符合最小暴露原则。

  3. 默认返回 405

    如果某个业务 Servlet 只支持 POST,却收到了 GET,返回 405 Method Not Allowed 是标准 HTTP 行为,告诉客户端"资源存在,但你的方法不对"。这比直接抛异常友好得多。


2.9 在 com.huohuo.core 包下新建 ServletContext.java

现在在 com.huohuo.core 包下新建一个普通类 ServletContext

这个类就是 Tomcat 里的"Servlet 容器",本质上是一张路由表Map<请求路径, Servlet 实例>。请求来了,根据路径查这张表,找到对应的 Servlet 去处理。

完整代码如下:

java 复制代码
package com.huohuo.core;

import com.huohuo.servlet.Servlet;

import java.util.HashMap;
import java.util.Map;

/**
 * ServletContext ------ Servlet 路由表容器(单例)。
 * 作用:
 *   1. 存储 "路径 -> Servlet 实例" 的映射;
 *   2. 请求到达时,根据路径返回对应的 Servlet;
 *   3. 整个 JVM 里只有一份,避免路由表被重复创建。
 */
public class ServletContext {

    // 饿汉式单例:类加载时就创建唯一实例,线程安全且实现简单
    private static final ServletContext INSTANCE = new ServletContext();

    // 路由表:key 是请求路径,例如 "/hello"
    private final Map<String, Servlet> servletMap = new HashMap<>();

    private ServletContext() {
        // 私有构造,防止外面 new
    }

    public static ServletContext getInstance() {
        return INSTANCE;
    }

    /**
     * 注册 Servlet
     */
    public void register(String urlPattern, Servlet servlet){
        servletMap.put(urlPattern, servlet);
    }

    /**
     * 根据路径获取对应的 Servlet
     */
    public Servlet getServlet(String urlPattern) {
        return servletMap.get(urlPattern);
    }
}

这里值得细讲的知识点:

  1. 为什么是单例?

    路由表是全局唯一的,如果每次请求都 new 一个 ServletContext,那每次查表都是空的。单例保证整个应用生命周期内只有一张表。

  2. 饿汉式单例的线程安全

    private static final ServletContext INSTANCE = new ServletContext(); 在类加载时由 JVM 初始化,天然线程安全,不需要加 synchronized。这是最简单的单例写法。

  3. 为什么路由表是 Map<String, Servlet> 而不是 Map<String, HttpServlet>

    Servlet 是顶层接口,HttpServlet 只是它的一个实现。容器只关心"某个路径对应一个 Servlet",不关心具体是哪种 Servlet。用接口作为 value 类型,后面 getServlet() 返回 Servlet,直接调 service() 即可,多态会自动定位到具体子类的实现。这就是面向接口编程

2.10 在 com.huohuo.core 包下新建 ServletScanner.java

现在在 com.huohuo.core 包下新建一个普通类 ServletScanner

这个类是反射实战的核心 :启动时扫描指定包下的所有 .class 文件,找到带 @WebServlet 注解的类,用反射创建实例,再调用 init(),最后注册进 ServletContext

完整代码如下:

java 复制代码
package com.huohuo.core;

import com.huohuo.annotation.WebServlet;
import com.huohuo.servlet.Servlet;

import java.io.File;
import java.net.URL;
import java.net.URLDecoder;
import java.util.Enumeration;

/**
 * ServletScanner ------ 包扫描器。
 *
 * 职责:
 *   1. 遍历指定包(如 com.huohuo.webapp)下的所有 .class 文件;
 *   2. 用反射加载类,读取 @WebServlet 注解;
 *   3. 实例化 Servlet、调用 init()、注册到 ServletContext。
 */
public class ServletScanner {

    /**
     * 扫描指定包。
     *
     * @param basePackage 要扫描的包名,例如 "com.huohuo.webapp"
     */
    public void scan(String basePackage) throws Exception {
        // 把包名转成目录路径:com.huohuo.webapp -> com/huohuo/webapp
        String packagePath = basePackage.replace(".", "/");

        // 获取当前线程的类加载器,它能从 classpath 中找到资源
        ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
        Enumeration<URL> resources = classLoader.getResources(packagePath);

        while (resources.hasMoreElements()) {
            URL url = resources.nextElement();
            // 关键:URL.getFile() 对中文/空格会进行 URL 编码,必须先解码再转成 File
            File dir = new File(URLDecoder.decode(url.getFile(), "UTF-8"));
            scanPackage(dir, basePackage);
        }
    }

    /**
     * 递归扫描目录下的 .class 文件。
     */
    private void scanPackage(File dir, String packageName) throws Exception {
        File[] files = dir.listFiles();
        if (files == null) {
            System.err.println("目录不存在或无法读取:" + dir.getAbsolutePath());
            return;
        }

        for (File file : files) {
            if (file.isDirectory()) {
                // 递归子目录
                scanPackage(file, packageName + "." + file.getName());
            } else if (file.getName().endsWith(".class")) {
                // 拿到完整类名:包名 + 类名
                String className = packageName + "." + file.getName().replace(".class", "");
                processClass(className);
            }
        }
    }

    /**
     * 处理一个类:加载 -> 读注解 -> 实例化 -> 注册。
     */
    private void processClass(String className) throws Exception {
        // 1. 反射加载类
        Class<?> clazz = Class.forName(className);

        // 2. 读取类上的 @WebServlet 注解
        WebServlet annotation = clazz.getAnnotation(WebServlet.class);
        if (annotation == null) {
            return; // 没有注解,跳过
        }

        // 3. 校验:必须是 Servlet 类型,否则无法处理请求
        if (!Servlet.class.isAssignableFrom(clazz)) {
            System.err.println("类 " + className + " 标注了 @WebServlet,但没有实现 Servlet,已跳过");
            return;
        }

        // 4. 反射创建实例(推荐写法,比 newInstance() 更现代)
        Servlet servlet = (Servlet) clazz.getDeclaredConstructor().newInstance();

        // 5. 调用生命周期 init()
        servlet.init();

        // 6. 注册到路由表
        String urlPattern = annotation.value();
        ServletContext.getInstance().register(urlPattern, servlet);

        System.out.println("Registered Servlet: " + urlPattern + " -> " + className);
    }
}

这里值得细讲的知识点:

  1. Class.forName(className) 做了什么?

    它让 JVM 把指定类加载到内存,返回对应的 Class 对象。我们平时 new HelloServlet() 是编译期就知道类;而扫描器运行前根本不知道包里有谁,只能运行时加载 ------ 这就是反射

  2. getAnnotation(WebServlet.class)

    反射读取类上的注解。前面反复强调 @Retention(RUNTIME),就是为了这一刻。如果注解生命周期是 CLASSSOURCE,这里只能拿到 null

  3. isAssignableFrom 是什么?

    Servlet.class.isAssignableFrom(clazz) 意思是:"clazz 这个类是不是 Servlet 本身或它的子类?" 等价于 clazz instanceof Servlet,但是用于 Class 对象层面的判断。这是防御性编程:防止有人乱标注解导致强转失败。

  4. 为什么用 clazz.getDeclaredConstructor().newInstance() 而不是 newInstance()

    Java 9 后 Class.newInstance() 被标记为过时。getDeclaredConstructor().newInstance() 是更现代的反射创建方式,能处理无参构造以外的场景(虽然这里仍然调用无参构造),并且异常信息更清晰。

  5. 为什么用 Thread.currentThread().getContextClassLoader()

    在 Web 容器这种复杂类加载环境里,当前线程的上下文类加载器往往比普通 ClassLoader 更"知道"去哪找类。真实 Tomcat 每个 Web 应用有自己的类加载器,也是靠这套机制隔离的。

  6. URLDecoder.decode(url.getFile(), "UTF-8") 是干什么用的?

    URL.getFile() 返回的路径中,中文、空格等特殊字符会被编码成 %xx 形式。如果项目路径里有中文(比如 暑期java高级),直接 new File(url.getFile()) 会找不到目录。必须先 URLDecoder.decode 解码,再转 File。这是一个非常真实的踩坑点。


本步完成 :在 com.huohuo.core 下新建 ServletContext(路由表)和 ServletScanner(反射扫描器)。

现在容器已经具备"自动发现 Servlet"的能力了 :启动时扫包 → 读 @WebServlet → 反射 new 对象 → 调 init() → 放进路由表。

下一步就是 :在 com.huohuo.handler 包下新建 RequestHandler(实现 Runnable),负责处理单个请求的完整流程(查路由表 / 调 Servlet / 返回静态资源 / 404)。准备好了说"下一步"。

2.11 在 com.huohuo.handler 包下新建 RequestHandler.java(含 com.huohuo.util.FileUtil

这一步会同时写两个类:RequestHandlerFileUtil。因为 RequestHandler 在处理静态资源时必须调用 FileUtil,分开写会导致中间步骤编译不过。我们的原则是:每一步结束时代码都能编译

① 在 com.huohuo.handler 包下新建 RequestHandler.java

现在在 com.huohuo.handler 包下新建一个普通类 RequestHandler,让它实现 Runnable

RequestHandler单个请求的处理流水线 。线程池里的每个线程拿到一个 RequestHandler 后,会执行它的 run() 方法:

复制代码
读取 Socket 输入流 → 封装成 HttpRequest
创建 HttpResponse → 查路由表
   ├─ 查到 Servlet → 调用 service()
   └─ 没查到       → 尝试返回静态资源(调用 FileUtil)
最后关闭 Socket

完整代码如下:

java 复制代码
package com.huohuo.handler;

import com.huohuo.core.ServletContext;
import com.huohuo.http.HttpRequest;
import com.huohuo.http.HttpResponse;
import com.huohuo.servlet.Servlet;
import com.huohuo.util.FileUtil;

import java.net.Socket;

/**
 * RequestHandler ------ 单个请求的处理任务。
 *
 * 它会被提交到线程池里执行,因此实现 Runnable。
 * 一个 RequestHandler 对应一个 Socket 连接、一个 HTTP 请求。
 */
public class RequestHandler implements Runnable {

    private Socket socket;

    public RequestHandler(Socket socket) {
        this.socket = socket;
    }

    @Override
    public void run() {
        try {
            // 1. 从 Socket 输入流构建 HttpRequest
            HttpRequest request = new HttpRequest(socket.getInputStream());
            // 2. 从 Socket 输出流构建 HttpResponse
            HttpResponse response = new HttpResponse(socket.getOutputStream());

            // 3. 打印请求信息(method 为空说明客户端已经断开,不打印)
            if (request.getMethod() != null) {
                System.out.println("收到请求: " + request.getMethod() + " " + request.getUrl());
            }

            // 4. 根据请求路径去路由表查 Servlet
            Servlet servlet = ServletContext.getInstance().getServlet(request.getPath());

            if (servlet != null) {
                // 动态资源:交给 Servlet 处理
                servlet.service(request, response);
            } else {
                // 静态资源:交给 FileUtil 读取并返回
                FileUtil.writeStaticResource(request.getPath(), response);
            }
        } catch (java.net.SocketException e) {
            // 客户端主动断开(关闭浏览器/标签页),属于正常情况,不打印堆栈
            System.out.println("客户端已断开连接: " + e.getMessage());
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            // 5. 无论成功失败,最后都要关闭 Socket
            closeSocket();
        }
    }

    private void closeSocket() {
        try {
            if (socket != null && !socket.isClosed()) {
                socket.close();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

这里值得细讲的知识点:

  1. RequestHandler 为什么要实现 Runnable

    线程池 ThreadPoolExecutor.execute(Runnable task) 只接受 Runnable 对象。把每个请求封装成一个 Runnable 任务,是"主线程监听、子线程干活"的关键。

  2. 为什么要 try-finally 关闭 Socket?

    一个 HTTP 请求处理完后必须关闭连接(我们用了 Connection: close)。如果抛异常时没关闭,会导致连接泄漏,浏览器一直转圈,服务器文件描述符耗尽。finally 保证无论成功还是异常,Socket 都会被释放。

  3. SocketException 为什么要单独捕获?

    直接关闭浏览器时,服务器往已经断开的连接里写数据会抛 SocketException。这是客户端主动断开导致的正常异常 ,不是服务器 bug。如果和其它异常一样 printStackTrace(),控制台会被堆栈刷屏。单独捕获后只打印一行提示,日志更干净。

  4. 动态资源和静态资源的分支

    • 动态资源:路由表里有,调 Servlet;
    • 静态资源:路由表里没有,尝试从磁盘/classpath 读文件;
    • 如果文件也没有,FileUtil 内部会返回 404。
② 在 com.huohuo.util 包下新建 FileUtil.java

现在在 com.huohuo.util 包下新建一个普通类 FileUtil

这个类负责静态资源服务 :根据请求路径,从 src/main/resources/static/ 目录下找对应的文件,拼好 HTTP 响应头发回去。如果文件不存在,返回 404。

完整代码如下:

java 复制代码
package com.huohuo.util;

import com.huohuo.http.HttpResponse;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

/**
 * FileUtil ------ 静态资源读取工具。
 *
 * 静态资源约定放在 classpath 下的 static/ 目录中:
 *   src/main/resources/static/index.html
 *   src/main/resources/static/css/style.css
 * 这样无论是 IDEA 直接运行还是打成 jar 包,都能读到。
 */
public class FileUtil {

    private static final String STATIC_ROOT = "static";

    /**
     * 根据请求路径返回静态资源。
     *
     * @param path     请求路径,例如 "/index.html"
     * @param response 响应对象
     */
    public static void writeStaticResource(String path, HttpResponse response) throws Exception {
        // 默认首页
        if ("/".equals(path)) {
            path = "/index.html";
        }

        // classpath 下的完整资源路径:static/index.html
        String resourcePath = STATIC_ROOT + path;

        // 从 classpath 读取资源
        InputStream inputStream = FileUtil.class.getClassLoader().getResourceAsStream(resourcePath);

        if (inputStream == null) {
            // 文件不存在,返回 404
            response.setStatus(404);
            response.write("<h1>404 Not Found</h1>");
            return;
        }

        // 根据后缀设置 Content-Type
        String contentType = getContentType(resourcePath);
        response.setContentType(contentType);

        // 一次性读取文件全部字节(教学版简单处理,大文件可改为流式)
        byte[] data = readAllBytes(inputStream);

        // 手动写响应头 + 文件体
        OutputStream outputStream = response.getOutputStream();
        String header = "HTTP/1.1 200 OK\r\n" +
                "Content-Type: " + contentType + "\r\n" +
                "Content-Length: " + data.length + "\r\n" +
                "Connection: close\r\n" +
                "\r\n";
        outputStream.write(header.getBytes("UTF-8"));
        outputStream.write(data);
        outputStream.flush();
    }

    private static byte[] readAllBytes(InputStream inputStream) throws IOException {
        ByteArrayOutputStream buffer = new ByteArrayOutputStream();
        byte[] data = new byte[1024];
        int len;
        while ((len = inputStream.read(data)) != -1) {
            buffer.write(data, 0, len);
        }
        return buffer.toByteArray();
    }

    private static String getContentType(String resourcePath) {
        if (resourcePath.endsWith(".html")) return "text/html;charset=UTF-8";
        if (resourcePath.endsWith(".css")) return "text/css;charset=UTF-8";
        if (resourcePath.endsWith(".js")) return "application/javascript;charset=UTF-8";
        if (resourcePath.endsWith(".png")) return "image/png";
        if (resourcePath.endsWith(".jpg") || resourcePath.endsWith(".jpeg")) return "image/jpeg";
        return "application/octet-stream";
    }
}

这里值得细讲的知识点:

  1. 为什么静态资源放在 src/main/resources/static/

    Maven/Gradle 项目在打包时会把 src/main/resources/ 下的文件原样复制到 classpath 根目录。所以运行时 static/index.html 就在 classpath 下。用 getResourceAsStream("static/index.html") 既能在 IDEA 里跑,也能在 jar 包里跑,比写死磁盘路径更通用。

  2. Content-Type 必须根据文件后缀设置

    浏览器靠 Content-Type 决定怎么展示内容。HTML 必须是 text/html,CSS 是 text/css,JS 是 application/javascript。如果统一写成 text/html,CSS 和 JS 文件虽然能下载,但浏览器不会正确解析。

  3. readAllBytes 是简化版

    教学版把文件一次性读进内存再写出,代码好懂。真实服务器对大文件应该边读边写,避免内存爆炸。我们后面可以在此基础上优化,但先让功能跑通。

2.12 在 com.huohuo.server 包下新建 TomcatServer.java

现在在 com.huohuo.server 包下新建一个普通类 TomcatServer

这是整个手写 Tomcat 的"发动机",干三件事:

  1. 启动时扫描 Servlet 并注册;
  2. ServerSocket 阻塞监听端口;
  3. 每来一个连接,把它包装成 RequestHandler,丢进线程池并发处理。

完整代码如下:

java 复制代码
package com.huohuo.server;


import com.huohuo.core.ServletScanner;
import com.huohuo.handler.RequestHandler;

import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/**
 * TomcatServer ------ 手写 Tomcat 的核心服务器。
 * 职责:
 *   1. 启动阶段扫描并注册所有 Servlet;
 *   2. 监听端口,等待浏览器连接;
 *   3. 把每个连接交给线程池里的 Worker 线程处理。
 */
public class TomcatServer {

    private int port;
    private String basePackage;
    private ServerSocket serverSocket;
    private ThreadPoolExecutor threadPool;

    public TomcatServer(int port, String basePackage) {
        this.port = port;
        this.basePackage = basePackage;

        this.threadPool = new ThreadPoolExecutor(
                10,                           // 核心线程数
                50,                           // 最大线程数
                60L,                          // 空闲线程存活时间
                TimeUnit.SECONDS,             // 存活时间单位
                new ArrayBlockingQueue<>(100) // 有界任务队列
        );
    }

    /**
     * 启动 Tomcat 服务器
     */
    public void start() throws Exception {
        // 启动阶段扫描并注册所有 Servlet
        System.out.println("开始扫描 Servlet,包路径:" + basePackage);
        new ServletScanner().scan(basePackage);

        // 创建 ServerSocket 并绑定端口
        serverSocket = new ServerSocket(port);
        System.out.println("启动成功,监听端口:" + port);

        // 注册JVM退出钩子,在JVM退出时关闭ServerSocket
        Runtime.getRuntime().addShutdownHook(new Thread(this::stop));

        // 主循环,阻塞等待浏览器连接
        while (true) {
            Socket socket = serverSocket.accept();
            System.out.println("收到浏览器连接:" + socket.getInetAddress());
            threadPool.execute(new RequestHandler(socket));
        }
    }

    public void stop(){
        System.out.println("正在关闭Tomcat...");
        try{
            if (serverSocket != null && !serverSocket.isClosed()){
                serverSocket.close();
            }
        }catch (Exception e){
            e.printStackTrace();
        }
        threadPool.shutdown();
    }
}

这里值得细讲的知识点:

  1. BIO 模型下为什么需要线程池?

    serverSocket.accept() 是阻塞的:没有连接时主线程一直等。如果让主线程自己去处理请求,那它在 read() 时又被阻塞,下一个连接来不了。所以必须主线程只负责 accept,具体 IO 和业务交给线程池里的子线程

  2. 为什么用 ThreadPoolExecutor 而不是 Executors.newFixedThreadPool()

    Executors.newFixedThreadPool(10) 内部用的是无界队列 LinkedBlockingQueue。如果请求蜂拥而来、处理不过来,队列会无限增长,最终撑爆内存(OOM)。我们手写版直接用 ThreadPoolExecutor + ArrayBlockingQueue<>(100),队列有界,更可控,也更接近生产实践。

  3. 线程池参数含义

    参数 含义
    corePoolSize 10 池子里始终保持 10 个线程,即使它们闲着
    maximumPoolSize 50 当队列满后,最多再创建到 50 个线程
    keepAliveTime 60 秒 超过 corePoolSize 的线程,如果 60 秒没活干就回收
    workQueue ArrayBlockingQueue(100) 等待执行的任务队列,最多缓存 100 个

    任务来了的分配逻辑:

    • 当前线程数 < 10:直接新建线程处理;
    • 当前线程数 ≥ 10:任务进队列,直到队列满 100;
    • 队列满后:继续创建线程,直到 50;
    • 50 个线程都在忙且队列满:触发拒绝策略(默认抛异常)。
  4. ServerSocket.accept() 的作用

    这是 JDK 提供的网络监听入口。它会在指定端口上等待 TCP 连接,每来一个浏览器请求就返回一个 Socket 对象。这个 Socket 就是服务器和浏览器之间的双向通道。

  5. 关闭钩子 ShutdownHook

    Runtime.getRuntime().addShutdownHook(...) 会在 JVM 关闭前(比如按 Ctrl+C)执行一段代码。我们在这里关闭 ServerSocket 和线程池,避免端口被占用、线程池没释放。

2.13 在 com.huohuo 包下重写 Main.java,并写测试 Servlet + 静态页面

最后一步:把占位用的 Main.java 替换成启动入口,再写两个业务 Servlet 和首页,用浏览器验证。

① 在 com.huohuo 包下重写 Main.java

现在打开项目里默认生成的 com.huohuo.Main,把它整个替换掉。

完整代码如下:

java 复制代码
package com.huohuo;

import com.huohuo.server.TomcatServer;

/**
 * 手写 Tomcat 启动入口。
 *
 * 端口号:8080
 * 扫描包:com.huohuo.webapp(业务 Servlet 放这里)
 */
public class Main {
    public static void main(String[] args) throws Exception {
        TomcatServer server = new TomcatServer(8080, "com.huohuo.webapp");
        server.start();
    }
}
② 在 com.huohuo.webapp 包下新建 HelloServlet.java

现在新建包 com.huohuo.webapp,并在其下新建 HelloServlet

完整代码如下:

java 复制代码
package com.huohuo.webapp;

import com.huohuo.annotation.WebServlet;
import com.huohuo.http.HttpRequest;
import com.huohuo.http.HttpResponse;
import com.huohuo.servlet.HttpServlet;

/**
 * 测试 Servlet:演示 GET 请求 + URL 参数解析。
 *
 * 访问:http://localhost:8080/hello?name=Tom
 */
@WebServlet("/hello")
public class HelloServlet extends HttpServlet {

    @Override
    protected void doGet(HttpRequest request, HttpResponse response) throws Exception {
        // 从 URL 参数中取 name,没有就默认 "World"
        String name = request.getParameter("name");
        if (name == null || name.isEmpty()) {
            name = "World";
        }

        response.setContentType("text/html;charset=UTF-8");
        response.write("<h1>Hello, " + name + "!</h1>");
    }
}
③ 在 com.huohuo.webapp 包下新建 UserServlet.java

同样在 com.huohuo.webapp 包下新建 UserServlet

完整代码如下:

java 复制代码
package com.huohuo.webapp;

import com.huohuo.annotation.WebServlet;
import com.huohuo.http.HttpRequest;
import com.huohuo.http.HttpResponse;
import com.huohuo.servlet.HttpServlet;

/**
 * 测试 Servlet:演示 GET 和 POST 分发。
 * 访问:http://localhost:8080/user
 */
@WebServlet("/user")
public class UserServlet extends HttpServlet {

    @Override
    protected void doGet(HttpRequest request, HttpResponse response) throws Exception {
        response.setContentType("text/html;charset=UTF-8");
        response.write("<h1>User Info</h1><p>这是 GET 请求返回的内容</p>");
    }

    @Override
    protected void doPost(HttpRequest request, HttpResponse response) throws Exception {
        response.setContentType("text/html;charset=UTF-8");
        response.write("<h1>User Info</h1><p>收到 POST 请求</p>");
    }
}
④ 在 src/main/resources/static/ 下新建 index.html

完整代码如下:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>手写 Tomcat</title>
</head>
<body>
    <h1>欢迎访问手写 Tomcat</h1>
    <p><a href="/hello?name=Tomcat">测试 HelloServlet(带参数)</a></p>
    <p><a href="/user">测试 UserServlet(GET)</a></p>
    <form action="/user" method="post">
        <button type="submit">测试 UserServlet(POST)</button>
    </form>
</body>
</html>

2.14 运行与验证

① 运行

在 IDEA 中右键 com.huohuo.Main → Run。如果控制台输出类似下面内容,说明启动成功:

复制代码
开始扫描 Servlet,包路径:com.huohuo.webapp
Registered Servlet: /hello -> com.huohuo.webapp.HelloServlet
Registered Servlet: /user -> com.huohuo.webapp.UserServlet
Tomcat 启动成功,监听端口:8080
② 浏览器验证

打开浏览器,依次访问:

URL 预期结果
http://localhost:8080/ 显示 index.html 首页
http://localhost:8080/index.html 显示首页
http://localhost:8080/hello 显示 Hello, World!
http://localhost:8080/hello?name=Tom 显示 Hello, Tom!
http://localhost:8080/user 显示 GET 请求内容
http://localhost:8080/notexist 显示 404 Not Found
③ 常见排查
  • 端口被占用 :把 Main.java 里的 8080 改成 8888 或其他端口。
  • 404 但文件存在 :检查 index.html 是否放在 src/main/resources/static/ 下,而不是 src/main/webapp/
  • 控制台不打印注册信息 :检查 HelloServlet 是否加了 @WebServlet("/hello"),以及是否继承了 HttpServlet
  • POST 请求取不到表单参数 :当前 HttpRequest 只解析了 URL 里的 GET 参数,POST 的 form 参数在请求体里,需要额外扩展(见下一节"可继续完善的点")。

第三章 可继续完善的点

  1. 解析 POST 请求体

    现在 HttpRequest 只解析了 URL 参数。可以扩展:读取 Content-Length 头,再读请求体,解析 application/x-www-form-urlencoded 格式的表单参数。

  2. 支持 JSON / 返回 JSON

    HttpResponse 里加 writeJson(Object obj),用 Jackson 或 Fastjson 把对象序列化成 JSON 返回。

  3. Session / Cookie

    HttpRequest 里解析 Cookie 头,在 HttpResponse 里写 Set-Cookie 头,配合一个 SessionManager 单例维护会话。

  4. Filter / 拦截器

    仿照 Servlet 规范定义 Filter 接口,在 RequestHandler 调用 Servlet 之前先执行过滤器链,实现登录校验、日志记录、编码统一设置等。

  5. NIO 改造

    把 BIO + 线程池升级为 ServerSocketChannel + Selector,单线程处理成千上万个连接,向真实 Tomcat 靠近一步。

  6. 热部署 / 类加载器隔离

    自定义类加载器加载业务 Servlet,实现应用重启不停止服务器。

相关推荐
snow@li9 小时前
Java:Lombok 完整讲解
java
Shadow(⊙o⊙)9 小时前
高并发内存池:Part-1——定长内存池
java·前端·javascript
国服第二切图仔9 小时前
17-config命令 - 配置管理系统
java·前端·javascript
架构源启18 小时前
文档接入与智能解析:基于 Spring AI 1.1.x 的多格式解析、版面理解与结构化抽取
java·人工智能·spring
JoyCong199821 小时前
打破远程协助的安全信任困局,ToDesk AI审计功能自动操作留痕
网络·人工智能·科技·安全·电脑·远程工作
Mr.HeBoYan1 天前
一次持续三天才出现的丢包故障——深入解析 DPDK Memory Ordering、rte_ring 与 CPU Memory Barrier (下)
linux·网络·算法·架构·dpdk
大模型码小白1 天前
JAVA 集合框架进阶:List 与 Set 的深度解析与实战
java·开发语言·人工智能·windows·语言模型·list·ai编程
名字还没想好☜1 天前
Go 的 time.Ticker 陷阱:定时任务里被忽略的内存泄漏与正确关闭
java·数据库·golang·go·定时器