手写 Tomcat 步骤教程
本项目目标:不使用任何 Web 框架,纯 JDK 手写一个简化版但完整可用 的 Tomcat 服务器。
支持:Socket 监听、HTTP 协议解析、自定义注解路由、反射加载 Servlet、Servlet 生命周期、静态资源返回、线程池并发处理。
第一章 Tomcat 是什么?它是怎么工作的?
1.1 Tomcat 的作用
Tomcat 本质上是一个 Servlet 容器(Servlet Container),同时内置 JSP 引擎。它解决的核心问题是:
如果没有 Tomcat,我们每写一个 Web 应用都要自己处理:
- 监听端口、接受 TCP 连接(Socket 编程);
- 按 HTTP 协议格式解析请求(请求行、请求头、请求体);
- 把请求映射到正确的处理代码;
- 按 HTTP 格式封装响应并写回客户端;
- 用线程池并发处理大量同时到来的请求。
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()方法参数类型就是HttpRequest和HttpResponse,如果先写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 + '\'' +
'}';
}
}
这里值得细讲的知识点:
-
BufferedReader为什么可以按行读?HTTP 协议的请求头和请求体之间用
\r\n分隔,BufferedReader.readLine()正好以换行符为界,一次读一行。这是 BIO 模型下解析文本协议最自然的做法。 -
split(" ", 3)其实更安全上面写的是
split(" "),默认会把整行按空格全拆开。真实 URL 里如果有空格会被编码成%20,所以这里问题不大。更严谨的写法是requestLine.split(" ", 3),表示最多拆成 3 段,最后一段protocol即使有内部空格也不会被再拆。 -
split("=", 2)的必要性如果参数值本身带有
=(比如 base64 编码后的字符串),写成split("=")会把值再拆碎。加, 2限制最多拆两段,保证值保持原样。 -
当前只解析了 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";
};
}
}
这里值得细讲的知识点:
-
响应头最后一定要有一个空行
\r\n\r\nHTTP 协议规定:响应头结束后必须跟一个空行,空行之后才是响应体。浏览器就是靠这个空行来判断"头到此结束,后面是内容"。如果漏了这个空行,浏览器会一直等,页面空白。
-
Content-Length必须和实际字节数一致一个中文字符在 UTF-8 下占 3 个字节。所以我们先把字符串
getBytes("UTF-8")转成字节数组,再拿数组长度作为Content-Length。如果直接写content.length()(字符数),中文场景下浏览器会截断或乱码。 -
Connection: close的含义告诉浏览器:"我发完这次响应就关闭连接,你别复用了。" 这样浏览器只要读到连接关闭就知道数据接收完毕,简化了很多边界处理。真实 Tomcat 会维护连接池复用连接,但我们手写第一版用短连接更简单可靠。
-
为什么提供
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");
}
}
这里的三个关键点:
-
equalsIgnoreCase而不是==request.getMethod()返回的是String。两个字符串比较是否相等,必须用equals(或equalsIgnoreCase忽略大小写),用==比较的是内存地址,容易踩坑。 -
doGet/doPost是protected,不是public这是模板方法模式的典型设计:父类提供钩子方法,子类重写它。用户不需要、也不应该直接从外面调
doGet,容器只调service()。用protected限制访问范围,符合最小暴露原则。 -
默认返回 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);
}
}
这里值得细讲的知识点:
-
为什么是单例?
路由表是全局唯一的,如果每次请求都 new 一个
ServletContext,那每次查表都是空的。单例保证整个应用生命周期内只有一张表。 -
饿汉式单例的线程安全
private static final ServletContext INSTANCE = new ServletContext();在类加载时由 JVM 初始化,天然线程安全,不需要加synchronized。这是最简单的单例写法。 -
为什么路由表是
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);
}
}
这里值得细讲的知识点:
-
Class.forName(className)做了什么?它让 JVM 把指定类加载到内存,返回对应的
Class对象。我们平时new HelloServlet()是编译期就知道类;而扫描器运行前根本不知道包里有谁,只能运行时加载 ------ 这就是反射。 -
getAnnotation(WebServlet.class)反射读取类上的注解。前面反复强调
@Retention(RUNTIME),就是为了这一刻。如果注解生命周期是CLASS或SOURCE,这里只能拿到null。 -
isAssignableFrom是什么?Servlet.class.isAssignableFrom(clazz)意思是:"clazz这个类是不是Servlet本身或它的子类?" 等价于clazz instanceof Servlet,但是用于Class对象层面的判断。这是防御性编程:防止有人乱标注解导致强转失败。 -
为什么用
clazz.getDeclaredConstructor().newInstance()而不是newInstance()?Java 9 后
Class.newInstance()被标记为过时。getDeclaredConstructor().newInstance()是更现代的反射创建方式,能处理无参构造以外的场景(虽然这里仍然调用无参构造),并且异常信息更清晰。 -
为什么用
Thread.currentThread().getContextClassLoader()?在 Web 容器这种复杂类加载环境里,当前线程的上下文类加载器往往比普通
ClassLoader更"知道"去哪找类。真实 Tomcat 每个 Web 应用有自己的类加载器,也是靠这套机制隔离的。 -
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)
这一步会同时写两个类:
RequestHandler和FileUtil。因为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();
}
}
}
这里值得细讲的知识点:
-
RequestHandler为什么要实现Runnable?线程池
ThreadPoolExecutor.execute(Runnable task)只接受Runnable对象。把每个请求封装成一个Runnable任务,是"主线程监听、子线程干活"的关键。 -
为什么要
try-finally关闭 Socket?一个 HTTP 请求处理完后必须关闭连接(我们用了
Connection: close)。如果抛异常时没关闭,会导致连接泄漏,浏览器一直转圈,服务器文件描述符耗尽。finally保证无论成功还是异常,Socket 都会被释放。 -
SocketException为什么要单独捕获?直接关闭浏览器时,服务器往已经断开的连接里写数据会抛
SocketException。这是客户端主动断开导致的正常异常 ,不是服务器 bug。如果和其它异常一样printStackTrace(),控制台会被堆栈刷屏。单独捕获后只打印一行提示,日志更干净。 -
动态资源和静态资源的分支
- 动态资源:路由表里有,调 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";
}
}
这里值得细讲的知识点:
-
为什么静态资源放在
src/main/resources/static/?Maven/Gradle 项目在打包时会把
src/main/resources/下的文件原样复制到 classpath 根目录。所以运行时static/index.html就在 classpath 下。用getResourceAsStream("static/index.html")既能在 IDEA 里跑,也能在 jar 包里跑,比写死磁盘路径更通用。 -
Content-Type必须根据文件后缀设置浏览器靠
Content-Type决定怎么展示内容。HTML 必须是text/html,CSS 是text/css,JS 是application/javascript。如果统一写成text/html,CSS 和 JS 文件虽然能下载,但浏览器不会正确解析。 -
readAllBytes是简化版教学版把文件一次性读进内存再写出,代码好懂。真实服务器对大文件应该边读边写,避免内存爆炸。我们后面可以在此基础上优化,但先让功能跑通。
2.12 在 com.huohuo.server 包下新建 TomcatServer.java
现在在
com.huohuo.server包下新建一个普通类TomcatServer。
这是整个手写 Tomcat 的"发动机",干三件事:
- 启动时扫描 Servlet 并注册;
- 用
ServerSocket阻塞监听端口; - 每来一个连接,把它包装成
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();
}
}
这里值得细讲的知识点:
-
BIO 模型下为什么需要线程池?
serverSocket.accept()是阻塞的:没有连接时主线程一直等。如果让主线程自己去处理请求,那它在read()时又被阻塞,下一个连接来不了。所以必须主线程只负责 accept,具体 IO 和业务交给线程池里的子线程。 -
为什么用
ThreadPoolExecutor而不是Executors.newFixedThreadPool()?Executors.newFixedThreadPool(10)内部用的是无界队列LinkedBlockingQueue。如果请求蜂拥而来、处理不过来,队列会无限增长,最终撑爆内存(OOM)。我们手写版直接用ThreadPoolExecutor+ArrayBlockingQueue<>(100),队列有界,更可控,也更接近生产实践。 -
线程池参数含义
参数 值 含义 corePoolSize 10 池子里始终保持 10 个线程,即使它们闲着 maximumPoolSize 50 当队列满后,最多再创建到 50 个线程 keepAliveTime 60 秒 超过 corePoolSize 的线程,如果 60 秒没活干就回收 workQueue ArrayBlockingQueue(100) 等待执行的任务队列,最多缓存 100 个 任务来了的分配逻辑:
- 当前线程数 < 10:直接新建线程处理;
- 当前线程数 ≥ 10:任务进队列,直到队列满 100;
- 队列满后:继续创建线程,直到 50;
- 50 个线程都在忙且队列满:触发拒绝策略(默认抛异常)。
-
ServerSocket.accept()的作用这是 JDK 提供的网络监听入口。它会在指定端口上等待 TCP 连接,每来一个浏览器请求就返回一个
Socket对象。这个Socket就是服务器和浏览器之间的双向通道。 -
关闭钩子
ShutdownHookRuntime.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 参数在请求体里,需要额外扩展(见下一节"可继续完善的点")。
第三章 可继续完善的点
-
解析 POST 请求体
现在
HttpRequest只解析了 URL 参数。可以扩展:读取Content-Length头,再读请求体,解析application/x-www-form-urlencoded格式的表单参数。 -
支持 JSON / 返回 JSON
在
HttpResponse里加writeJson(Object obj),用 Jackson 或 Fastjson 把对象序列化成 JSON 返回。 -
Session / Cookie
在
HttpRequest里解析Cookie头,在HttpResponse里写Set-Cookie头,配合一个SessionManager单例维护会话。 -
Filter / 拦截器
仿照 Servlet 规范定义
Filter接口,在RequestHandler调用 Servlet 之前先执行过滤器链,实现登录校验、日志记录、编码统一设置等。 -
NIO 改造
把 BIO + 线程池升级为
ServerSocketChannel+Selector,单线程处理成千上万个连接,向真实 Tomcat 靠近一步。 -
热部署 / 类加载器隔离
自定义类加载器加载业务 Servlet,实现应用重启不停止服务器。