ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

手写简易Tomcat:从HTTP协议到Servlet映射的容器核心链路

手写简易Tomcat:从HTTP协议到Servlet映射的容器核心链路 上个月团队里的同学问了我一句“Tomcat 到底是怎么找到 Servlet 的”我当场能背出“根据 URL 匹配 web.xml 里的 servlet-mapping”但被追问了一句“那 HTTP 请求是先从哪个端口进来的请求行又是怎么变成一个 request 对象的”我发现自己其实也说不太透。为了把这块补上我花了一个周末手写了一个只有几百行的简易 Tomcat。它没有热部署、没有 NIO、没有 JSP 引擎但完整跑通了“端口监听 → HTTP 解析 → Servlet 映射 → 实例化 → service 调用 → 响应返回”这条 Servlet 容器核心链路。写完以后再去看真实 Tomcat 的 Coyote/Catalina 分层、web.xml 加载逻辑、甚至部署时常见的 404、乱码、GET 参数不对这些坑都会通透很多。这篇文章就记录整个过程适合已经在用 Tomcat 部署项目、但想往底层再走一步的后端开发者。1. 为什么我要折腾一个“玩具 Tomcat”先搞清楚容器到底在替我们做什么1.1 一个面试追问暴露出来的认知断层平时我们从 IDE 里启动 Tomcat把 war 包丢进 webapps访问一个 URL 就能调用到 Servlet。对大多数人来说Tomcat 就是个“能跑 Java Web 项目”的黑盒。可如果真的把一个 HTTP 请求从头到尾拆开Tomcat 至少替你干了六件事监听 8080 端口接受 TCP 连接按照 HTTP 协议读取报文解析出请求行、请求头、请求体根据请求 URL去 servlet 映射表里找到对应的 Servlet 类如果这个 Servlet 还没实例化就反射创建它然后调用 init() 做初始化在一个多线程模型里调用 Servlet 的 service() 方法把封装好的 request/response 传进去把 response 按 HTTP 响应报文格式写回客户端。你会发现这六件事和你的业务代码完全无关。Servlet 里的 doGet()、doPost() 只是最后一步里的“业务钩子”。很多人的知识断层就断在这里会用 WebServlet会用 HttpServletRequest但脑子里对“Servlet 容器”四个字的理解只是“一个运行环境”。我决定手写简易 Tomcat就是想把这六件事逐行用代码落地。1.2 学习型项目该模仿什么、砍掉什么手写的时候要控制边界。真实 Tomcat 是一个非常庞大的软件它除了 Servlet 容器还包含 JSP 引擎、集群会话复制、安全管理器、JMX 管理、WebSocket 支持等等。如果你一上来就想把这些都复刻一遍大概率会在某个下午放弃。我给自己划了两条线。要模仿的核心链路是ServerSocket 接收连接、HTTP 报文解析、URL 到 Servlet 的映射表、Servlet 实例缓存、初见即 init、按请求调用 service、把 HTTP 响应写回。这些是“容器”之所以为容器的骨架。暂时砍掉的有NIO 连接器、ClassLoader 隔离和热部署、Filter 链的完整实现、Session 的持久化、异步 Servlet、静态资源处理、虚拟主机。砍掉它们不是不学而是先把主链路跑通后续在“单点扩展”阶段再逐个加回来。必须模仿可以大胆砍掉Socket 监听与 acceptNIO/APR 连接器HTTP 请求行/头/体解析复杂协议升级HTTP/2、WebSocketServlet 映射与懒加载热部署与类加载器隔离单例 Servlet 的多线程调用JSP 引擎响应报文拼接集群会话复制这样一画项目范围立刻小了很多。我当时的目标很明确能用浏览器或 curl 访问到/hello让它返回一个动态生成的页面就算成功。2. 第一勺代码用 ServerSocket 撑起一个会说话的 HTTP 端口2.1 先验证一个底层事实HTTP 就是按规矩换行的纯文本很多人对 HTTP 有误解觉得它像 RPC 一样是某种“高级协议”。其实你完全可以用最原始的方式和 Tomcat 对话开一个终端输入curl -v http://localhost:8080/就能看到客户端实际发出去的字节。它大概长这样GET /hello HTTP/1.1 Host: localhost:8080 User-Agent: curl/8.0.1 Accept: */*看到没有请求第一行是“请求方法 空格 URL 空格 HTTP 版本”后面是若干个“键: 值”格式的请求头再一个空行最后是请求体。HTTP 协议本质上是“按规矩换行”的文本协议。所以手写简易 Tomcat 的第一步不是引入任何框架而是开一个 ServerSocket一行行读这些文本。2.2 accept 循环 手写 parseRequest拆解第一棵代码骨架我先把最原始的循环写出来故意不用线程池方便理解主脉络public class MiniTomcat { private int port 8080; public void start() throws Exception { try (ServerSocket serverSocket new ServerSocket(port)) { System.out.println(MiniTomcat listening on port port); while (!serverSocket.isClosed()) { Socket socket serverSocket.accept(); handleSocket(socket); } } } }handleSocket里要干两件事从 socket 的 InputStream 解析出请求对象再调用后面的容器逻辑。解析我是纯手写的没有引用任何第三方 HTTP 库。核心思路是先用BufferedReader读出请求行按空格切分再循环读取请求头直到空行最后根据Content-Length读取请求体。public HttpRequest parseRequest(InputStream input) throws IOException { BufferedReader reader new BufferedReader(new InputStreamReader(input, UTF-8)); String requestLine reader.readLine(); if (requestLine null || requestLine.length() 0) { throw new IOException(Empty request line); } String[] parts requestLine.split( ); String method parts[0]; String rawUri parts[1]; // 拆分 URI 和 QueryString例如 /hello?nameTom - uri/hello, querynameTom int queryIdx rawUri.indexOf(?); String uri queryIdx 0 ? rawUri.substring(0, queryIdx) : rawUri; String queryString queryIdx 0 ? rawUri.substring(queryIdx 1) : ; MapString, String headers new HashMap(); String line; while ((line reader.readLine()) ! null !line.isEmpty()) { int colon line.indexOf(:); if (colon 0) { String key line.substring(0, colon).trim(); String value line.substring(colon 1).trim(); headers.put(key.toLowerCase(), value); } } int contentLength 0; String contentLengthHeader headers.get(content-length); if (contentLengthHeader ! null) { contentLength Integer.parseInt(contentLengthHeader); } char[] bodyChars new char[contentLength]; if (contentLength 0) { int read reader.read(bodyChars); if (read contentLength) { // 简化处理一次未读完时继续读完整版应循环读取 } } return new HttpRequest(method, uri, queryString, headers, new String(bodyChars)); }注意这里用BufferedReader去读 body 其实有隐患因为readLine()会把缓冲区里可能属于 body 的字节一起读走导致reader.read()读不到。我后面会在踩坑部分专门讲这个。对于第一版你只要知道 request 对象长什么样子就够了method、uri、queryString、headers、body是后续所有处理的基础。2.3 先别急着接 Servlet能返回 HTML 才是完整闭环很多教程一上来就让 Servlet 介入但我觉得第一步应该先什么都不管直接把一个写死的 HTML 页面写回客户端。为什么因为 HTTP 响应也是一个有固定格式的文本你如果连响应都拼不对浏览器根本不会显示内容。一个最小响应长这样HTTP/1.1 200 OK\r\n Content-Type: text/html; charsetUTF-8\r\n Content-Length: 13\r\n \r\n Hello, World!这里最关键的是\r\n。HTTP 规范要求所有头部行必须以回车换行结束很多人只用\n结果浏览器解析行为变得很怪。我的 HttpResponse 是这样封装的public class HttpResponse { private OutputStream output; private String contentType text/html; charsetUTF-8; private int status 200; private final ByteArrayOutputStream bodyBuffer new ByteArrayOutputStream(); public HttpResponse(OutputStream output) { this.output output; } public PrintWriter getWriter() { return new PrintWriter(new OutputStreamWriter(bodyBuffer, StandardCharsets.UTF_8)); } public void send() throws IOException { byte[] body bodyBuffer.toByteArray(); StringBuilder head new StringBuilder(); head.append(HTTP/1.1 ).append(status).append( ).append(statusText()).append(\r\n); head.append(Content-Type: ).append(contentType).append(\r\n); head.append(Content-Length: ).append(body.length).append(\r\n); head.append(Connection: close\r\n); head.append(\r\n); output.write(head.toString().getBytes(StandardCharsets.ISO_8859_1)); output.write(body); output.flush(); } }Content-Length必须是字节数不能用字符串长度。中文在 UTF-8 下通常一个字符占三个字节如果直接写bodyBuffer.toString().length()响应头会告诉浏览器一个错误的长度轻则内容截断重则一直转圈。我从这里踩出的第一个坑就是Content-Length永远要拿byte[]的 length 来算。这一步做完你的“简易 Tomcat”已经能用浏览器看到静态页面了。接着才开始加 Servlet这是从“HTTP 服务器”升级成“Servlet 容器”的关键一步。3. 让容器认路Servlet 映射、实例化与生命周期调用3.1 映射表就是容器内部的“电话簿”Servlet 容器之所以能从 URL 找到代码是因为有一张映射表。真实 Tomcat 里你在 web.xml 或注解里写的WebServlet(/hello)最终都会被解析成类似MapString, Class?的结构。Tomcat 拿到请求后查表找到类名然后反射创建实例。我的简易版直接用一个注册方法完成映射public class ServletRegistry { // uri - Servlet 类 private final MapString, Class? extends Servlet mappings new HashMap(); // uri - 唯一实例Servlet 单例 private final MapString, Servlet instances new HashMap(); public void register(String urlPattern, Class? extends Servlet servletClass) { mappings.put(urlPattern, servletClass); } public Servlet getServlet(String uri) { Class? extends Servlet clazz mappings.get(uri); if (clazz null) { return null; } // 首次访问才实例化并 init return instances.computeIfAbsent(uri, key - { try { Servlet servlet clazz.getDeclaredConstructor().newInstance(); servlet.init(); return servlet; } catch (Exception e) { throw new RuntimeException(init servlet failed: uri, e); } }); } }computeIfAbsent这个 API 很省事它天然保证了同一个 URL 的 Servlet 只有一份实例。不过真实项目里要小心如果init()里抛了异常这个实例不会被放入 map下一次请求又会重新尝试实例化。你这是“懒加载”的自然结果。3.2 懒加载和 load-on-startup实例化时机决定了启动速度真实 Tomcat 中Servlet 默认是第一次被请求时才加载的这叫懒加载。你启动 Tomcat 时它不会立刻把每个 Servlet 都 new 一遍除非你在 web.xml 里配置了servlet servlet-namestartupServlet/servlet-name servlet-classcom.example.StartupServlet/servlet-class load-on-startup1/load-on-startup /servletload-on-startup的数字越小越先启动。这样做的好处是把初始化比较重的 Servlet 提前加载避免第一个用户访问时等很久。但我发现很多初学者有个误区以为 Servlet 是在 Tomcat 启动时创建的。其实大多数时候不是它只是把类文件挂载到了 webapp 的 ClassLoader 里真正实例化要等第一个请求。我手写时也实现了这两个机制。默认懒加载getServlet里computeIfAbsent另外加一个prepareStartupServlets(ListString uris)在 start 方法里遍历列表提前调用getServlet(uri)模拟 load-on-startup。这样你就能亲眼看到如果不调它日志里只有请求到来时才打印init调了它启动阶段就会打印。理解了这个差异排查“为什么一个 Servlet 初始化了两遍”这类问题会容易很多。3.3 service 方法里的“模板方法”doGet/doPost 是怎么被调到的Servlet 接口的核心方法是service(ServletRequest, ServletResponse)。你在业务里写的是doGet()、doPost()那谁来调用它们答案是 HttpServlet 基类。我自己也写了一个简化版 HttpServlet它就是一个模板方法模式的教科书案例public abstract class HttpServlet implements Servlet { Override public void init() { // 子类可以覆盖 } Override public void service(HttpRequest request, HttpResponse response) throws IOException { if (GET.equals(request.getMethod())) { doGet(request, response); } else if (POST.equals(request.getMethod())) { doPost(request, response); } else { response.setStatus(405); response.getWriter().print(Method Not Allowed); } } protected void doGet(HttpRequest request, HttpResponse response) throws IOException { response.setStatus(405); response.getWriter().print(GET not supported); } protected void doPost(HttpRequest request, HttpResponse response) throws IOException { response.setStatus(405); response.getWriter().print(POST not supported); } }真实 Tomcat 更复杂一点它还会有 doPut、doDelete、doHead还会处理If-Modified-Since、Content-Type等语义但主流程完全一样。所以你只要理解了 service 作为一个分发器存在就理解了为什么一个 Servlet 是“单例多线程”的容器创建它一次后续所有请求都调用同一个实例的 service再由 service 根据 HTTP 方法分发到不同的 doXxx。这一步做完你的简易 Tomcat 已经能够运行一个真正的 Servlet 了。我用一个 HelloServlet 测试访问/hello?nameTom就能返回“Hello, Tom”。接下来要解决的是如果同时来了 100 个请求它会不会崩。4. 从串行到并发连接器和容器为什么要分开4.1 单线程版瓶颈一个慢请求拖死所有用户如果继续用while handleSocket那么所有请求都是串行处理的。假设第一个用户的 Servlet 里休眠了 3 秒第二个用户在这 3 秒内发起的请求只能在 accept 队列里排队。浏览器里的表现是“一直在转圈”服务端日志里则能看到第二个请求的时间戳很晚才出现。这个问题足够让人意识到Servlet 容器天然要面对多线程。真实 Tomcat 的连接器Connector和容器Container是两个独立的层次连接器负责接收 socket、解析请求、管理连接容器负责执行 Servlet、管理生命周期。两边通过一个标准接口交互。手写时我也把代码组织成了这两层而不是把所有逻辑堆在一个 while 循环里。4.2 用线程池改造Connector 负责接客Container 负责干活改造后的结构大概是这样的public class MiniTomcat { private final ExecutorService executor Executors.newFixedThreadPool(20); private final ServletRegistry registry new ServletRegistry(); public void start() throws Exception { try (ServerSocket serverSocket new ServerSocket(port)) { System.out.println(MiniTomcat listening on port port); while (!serverSocket.isClosed()) { Socket socket serverSocket.accept(); executor.submit(() - { try { HttpRequest request new HttpRequestParser().parse(socket.getInputStream()); HttpResponse response new HttpResponse(socket.getOutputStream()); String uri request.getUri(); Servlet servlet registry.getServlet(uri); if (servlet null) { response.setStatus(404); response.getWriter().print(No Servlet mapped for uri); } else { servlet.service(request, response); } response.send(); } catch (Exception e) { log(Handle request failed, e); } finally { try { socket.close(); } catch (IOException ignored) { } } }); } } finally { executor.shutdown(); } } }这就是连接器/容器分离思想的雏形ServerSocket.accept()和executor.submit()是连接器的活ServletRegistry.getServlet()和servlet.service()是容器的活。真实 Tomcat 里 Connector 有多种实现BIO、NIO、APR但容器层的东西基本一样。你改连接器可以不碰容器代码这就是分层的好处。线程池参数方面真实 Tomcat 默认maxThreads200队列容量是Int.MAX_VALUE。手写版用固定 20 线程足以做实验也能逼你遇到“线程池满”的问题。4.3 一个很容易翻车的并发陷阱Servlet 实例变量接入了线程池之后新的问题马上浮现Servlet 是单例却被多个线程同时调用。这时候如果你在 Servlet 里写了普通实例变量就会出现数据竞争。举个例子public class CountServlet extends HttpServlet { private int count 0; Override protected void doGet(HttpRequest request, HttpResponse response) { count; response.getWriter().print(count count); } }并发请求一多count 会丢失更新。你预期是 1000实际可能是 997。真实 Tomcat 下的 Servlet 也是这样默认所有请求共享同一个实例除非实现SingleThreadModel已废弃。所以最佳实践永远是Servlet 里不要放可变状态如果一定要统计次数用AtomicInteger或把状态放到外部存储。这一步让我意识到线程安全不是 Spring 特有的问题容器本身就到处是线程。后来我再去看真实 Tomcat 的线程池配置、maxThreads、acceptCount时脑子里就有画面了。5. 简易版和真实 Tomcat 的距离那些我没写但你必须知道的机制5.1 类加载器与应用隔离为什么 Tomcat 能部署多个不冲突的 war我的简易版用Class.forName()直接加载 Servlet 类加载完就永远留在 JVM 里也没法卸载。真实 Tomcat 不是这么干的每个 Web 应用会有一个独立的 WebappClassLoader它负责加载/WEB-INF/classes和/WEB-INF/lib下的类。这样做有两个好处。第一是隔离两个 war 包即使用了同一个类名的类也不会冲突第二是热部署Tomcat 检测到 class 文件变化后会丢弃旧类加载器、创建新类加载器用新的加载器重新加载应用。这其实就是热部署的底层原理。如果你想动手实验可以在手写版里做一个简化版 ClassLoader只重写findClass()从一个指定目录加载 .class 文件。这样你就能看到修改 Servlet 源码并重新编译后容器是如何加载新版本的。这个练习比只看文档有用得多。5.2 FilterChain请求在进入 Servlet 之前要过几道闸真实 Tomcat 里请求不会直接到达 Servlet它要先经过一个过滤器链。每个 Filter 可以修改请求和响应然后调用chain.doFilter()把请求传给下一个。我后来给简易版补了一个非常粗糙的 Filter 链public interface Filter { void doFilter(HttpRequest request, HttpResponse response, FilterChain chain) throws IOException; } public class FilterChain { private final ListFilter filters; private int index 0; public void doFilter(HttpRequest request, HttpResponse response) throws IOException { if (index filters.size()) { return; // 到达链路末尾后续交给 Servlet } Filter filter filters.get(index); filter.doFilter(request, response, this); } }注意这里有个非常经典的实现细节必须用index让每个 Filter 只能被调用一次。如果你直接遍历 List 并逐个调用不借助 chain 对象Filter 将无法决定“要不要让请求继续往下走”。真实 Tomcat 的 ApplicationFilterChain 就是这个思路它内部维护了一个数组和一个定位指针。理解了这套机制你排查中文乱码、登录状态校验、跨域配置的实现原理时就一通百通了。5.3 还没实现的协议细节与真实部署时的高频坑简易版能跑通核心链路但真实场景里还有一堆细节容易踩坑。我在手写过程中对标着解决了不少网上高频问题。先说字符编码。手写版最开始没有强制用 UTF-8 解析请求行HTTP 项目一出现中文参数就乱码。真实 Tomcat 的乱码问题也大多出在两端编码不一致响应端没有设置Content-Type的 charset或者请求参数没按统一编码解析。解决办法并不复杂在 Filter 里设置请求和响应编码或者统一在HttpResponse.setContentType()的时候带上charsetUTF-8。但如果你不懂“编码发生在哪一层”配置写烂了也无济于事。再说 URL 参数。热搜里常见“tomcat get 链接不能用”很多情况下是 URL 里有特殊字符没有被 URLDecoder 处理。浏览器会对中文和特殊字符做 URL 编码比如%E4%BD%A0%E5%A5%BD服务端拿到的 rawUri 是编码后的字符串。我的 getParameter 实现里必须加一句public static String urlDecode(String value) { return URLDecoder.decode(value, UTF-8); }否则name参数就会带着百分号编码原形输出。真实 Web 应用里如果前端和后端没有统一编码或者容器对 URL 的解析配置不一致就会出现“GET 链接拿不到正确参数”的诡异现象。还有部署层面的 404。真实 Tomcat 如果访问一个不存在的资源返回 404 的原因常见有三种webapps 下没有对应应用contextPath 没拼对Servlet 映射路径写错。手写版对应的则是“mappings 里根本没有这个 uri”。我在简易版里故意保留了一个/notfound未注册路径来验证 404 页面。这个对照关系会让你以后看日志时少一点迷茫。6. 手写容器的验收报告测试用例、踩坑过程和后续建议6.1 用 curl 和 JUnit 验证这个“聊天式”HTTP 服务写完代码别急着说“完成”要能通过测试。我当时的验证方式很简单就是开着 MiniTomcat然后用 curl 模拟真实请求。常用的命令# 验证 GET 请求 curl -v http://localhost:8080/hello?nameTom # 验证 POST 请求 curl -X POST -d userlucyage18 -v http://localhost:8080/hello # 验证 404 curl -i http://localhost:8080/nope # 验证静态手工响应未接入 Servlet 前 curl -i http://localhost:8080/如果是更自动化的做法我会写一个简单的 JUnit 测试用Socket直接发送一段构造好的 HTTP 报文然后断言响应里包含关键内容。这样每次改动后跑一次测试心里很有底。HTTP 协议报文都是文本这让测试变得异常轻松——你根本不需要什么重型框架一个PrintWriter就能伪造客户端。6.2 我实际踩过的几个坑手写过程中我记录了一份“踩坑清单”每个都是真实遇到的也都对得上真实 Tomcat 的问题现象根因解决办法浏览器一直转圈响应不结束响应没有Content-Length客户端不知道何时读完先输出 body再计算byte[]长度拼到响应头或使用Connection: close请求中文字符全是乱码请求/响应两端编码不一致解析和输出统一用 UTF-8响应头加charsetUTF-8GET 参数带中文解析异常没做 URLDecoder对 queryString 按 URL 解码第一次请求很慢随后变快Servlet 懒加载第一次要反射创建并 init用 load-on-startup 预热并发访问计数不对Servlet 单例多线程下普通变量非原子使用 AtomicInteger 或外部存储请求体读不全用BufferedReader.readLine后再readbody缓冲被提前消费直接用InputStream按 Content-Length 循环读取最后这条“请求体读不全”很隐蔽。因为BufferedReader在第一次readLine()时很可能把后面的 body 的一部分也读进自己的缓冲区了你再用同一个 reader 去读 body会被缓冲区挡在外面。真实 Tomcat 的 Coyote 对字节流的处理分了层就是为了避免这种串扰。手写版最简单的做法是解析请求头时用一个不含 body 的边界读取或者干脆自己实现一个按字节读取、内部不做缓冲的读取器。我第一次没注意用 POST 传了 50 字节的 body结果读出来只有一小截。这种问题在纯理论分析时想不到但不亲手写一遍根本不会碰见。6.3 再往下可以练的几个方向如果这个迷你 Tomcat 你已经跑通了我建议按顺序尝试这几个扩展给 ServletRegistry 加一个/*精确匹配之外的通配符逻辑模拟 web.xml 里的路径匹配规则实现一个简单的 Session用 Cookie 里的 JSESSIONID 作为 key把 HttpSession 对象缓存到内存 Map 里把固定线程池换成可配置的maxThreads和队列长度观察超过阈值后的行为加一个静态资源处理器如果 URL 对应一个存在的文件直接返回文件内容而不是 404把响应体的拼装从ByteArrayOutputStream改成直接向 socket 输出但要小心 Content-Length 的计算。这四个练习做完你对 Tomcat 主链路、线程模型和 HTTP 细节的理解会比看十篇“架构解析”都扎实。尤其是第四项能让你理解为什么真实 Tomcat 同时具备静态资源服务器和 Servlet 容器两种身份。最后分享一个我实操中的心得手写容器最大的价值不是真的让你去实现一个 Tomcat 替代品而是把那些平时“理所当然”的细节变成“可排查的问题”。之后再遇到 Tomcat 启动闪退、404、乱码、GET 参数不对、日志分析无从下手的时候你会知道它们分别对应底层链路的哪一环而不是像无头苍蝇一样乱改配置。如果你也想试试关掉 IDE 里的自动部署打开一个终端从ServerSocket开始吧。
返回列表