☰
手写简化版Tomcat:一次搞懂HTTP请求处理与Servlet容器核心原理
2026/9/30 3:09:02 网站建设 项目流程

手写Tomcat这事儿,听起来像是造轮子,但我实际做完之后觉得,它比看十遍源码解析都值。前几天公司里新来的同事问我,IDEA里那个“源服务器未能找到目标资源的表示”到底啥意思,我顺手把Tomcat处理HTTP请求的完整路径讲了一遍,他当时就愣了——原来Tomcat不是“启动就能用”的黑盒。我建议他找个周末,照着笔记手写一个简化版,把Socket监听、请求解析、Servlet映射、响应封装走一遍,很多“玄学问题”当场就通了。

这篇笔记就是我当时手写Tomcat的完整记录,核心思路是“不追求功能齐全,追求一条主链路彻底跑通”。我会拆解每一步的设计原因、实现代码、踩坑点,再顺带解决几个大家特别常见的真实问题——比如启动慢、乱码、闪退、部署前后端分离项目时静态资源加载不上。写完后你会发现,Tomcat的各种配置、日志、异常,本质上都是在跟你聊架构。

1. 手写之前,先把Tomcat的两大身份搞清楚

1.1 它是Web服务器,更是Servlet容器

很多人把Tomcat叫“Web服务器”,这不算错,但只对了一半。Apache HTTP Server、Nginx那种纯Web服务器,只管静态资源、反向代理、负载均衡;Tomcat不一样,它往下能像Nginx那样处理静态文件,往上还能执行Servlet、加载JSP、管理Session、维护线程池。“Servlet容器”才是它的核心竞争力。

手写之前如果不把这一点想透,代码写到一半就会不知道该把“解析HTTP请求”和“调用业务逻辑”之间的边界画在哪。Servlet容器这层本质上是这么一套约定:外部发来一个HTTP请求,容器负责把它变成一个HttpServletRequest对象,找到对应的Servlet,调用service方法,再把返回结果变成HttpServletResponse对象,最后序列化成HTTP响应写回客户端。

我当时给自己定了一个边界:不碰JSP的编译和class卸载,也不做完整的ClassLoader热加载,核心就是把“请求进来—容器接手—Servlet处理—响应出去”这条链路跑通。至于JSP,后期可以用简单的模板字符串替换实现,原理一样。

1.2 手写项目的验收标准是什么

写这个项目不是为了挑战官方实现,也不是要做成生产可用的服务器。我的验收标准就三条:

  • 启动后在浏览器里输入http://localhost:8080/hello,能正确调到一个自己写的Servlet,返回一段HTML。
  • 能处理静态资源请求,比如/static/demo.css能正常返回文件内容。
  • 支持多个Servlet并存,通过不同的URL路径做映射,同时把404、500这类基础错误响应处理掉。

没有Session和线程池优化这些复杂需求。等主链路跑通之后,再逐个往里面加线程池、Session管理、过滤器接口,每加一个就相当于把一个“真正Tomcat的功能模块”亲手做一遍,比盲目去啃源码要踏实得多。

2. 核心架构大前提:Connector与Container到底怎么分工

2.1 为什么Tomcat要拆成两层

Tomcat整个生命周期里有几件完全不同的事:监听端口、接受TCP连接、解析HTTP协议、维护连接状态,这些属于通信类;而加载Servlet、管理上下文、映射URL到具体处理类、调用业务代码,这些属于容器类。通信类和容器类的变化频率、技术方向都不一样,所以Tomcat设计了Connector和Container两层,各干各的。

我手写时也严格这么拆分。一个HttpConnector类专门负责ServerSocket监听和Accept循环,拿到原始Socket数据后,丢给HttpProcessor做解析;Container那侧只接收已经解析好的HttpServletRequest对象,完全不管协议细节。这样将来想从HTTP换成HTTPS,或者从BIO换成NIO,都只动Connector一侧,Container无需感知。

这个拆分直接决定了我后续代码的结构顺序。如果一开始不分层,请求解析和业务映射的代码全堆在一个类里,后期加功能会非常痛。

2.2 手写版的类关系设计

我最终定下来的类很少,但职责边界很清晰:

类名作用对应真实Tomcat中的组件
HttpServer启动入口,创建ServerSocket,维护生命周期Catalina/Server
HttpConnector接收Socket连接,转发给ProcessorConnector
HttpProcessor解析请求行/请求头,封装Request/ResponseProcessor
HttpServletRequestImpl实现请求接口,提供method、uri、参数获取Request
HttpServletResponseImpl封装响应状态、Header、BodyResponse
ServletContainer管理Servlet实例,做URL到Servlet的映射Container/Context
BaseServlet抽象类,定义doGet/doPost分发逻辑HttpServlet

实际写的时候,每个类控制在100~200行,逻辑非常直白。这个规模的代码比读源码有性价比得多,因为每一行都是自己写出来的,出了问题能立刻定位。

3. 手写主流程的四个关键环节

3.1 端口监听与请求行的解析

起步就是让服务器“活着”。我用ServerSocket监听8080端口,循环调用accept()取连接。这里有个很重要的细节:每个连接必须丢给新线程去处理,否则第一个请求没处理完,第二个请求就堵住了。

ServerSocket serverSocket = new ServerSocket(8080); while (!shutdown) { Socket socket = serverSocket.accept(); executor.submit(() -> processSocket(socket)); }

拿到Socket之后,第一件事是从输入流里读数据。HTTP请求是有格式的,第一行是请求行,比如GET /hello?name=zhang HTTP/1.1,后面是请求头,再往后可能是请求体。

我解析的时候没有用复杂的库,直接按行读,然后split出三部分:请求方法、URI、协议版本。URI再按问号拆出路径和查询参数。

String requestLine = reader.readLine(); String[] parts = requestLine.split(" "); String method = parts[0]; String uri = parts[1];

这里注意一个容易被忽略的点:请求头里Content-Length是用来标记请求体长度的,如果只是GET请求,没有请求体,随手readLine()读请求头也够用。但做POST时,必须根据Content-Length准确读取Body,否则要么粘包,要么读空。

我当时在这块栽过一次。POST提交表单之后,业务逻辑里拿不到参数,排查半天发现Body没读完整。后来老老实实先解析Header,拿到Content-Length值,再按这个长度精确读Body,问题才消失。

3.2 从URI到Servlet的映射分发

请求解析完成之后,容器就该知道让谁干活了。每个人的Servlet在启动时通过一个MappingTable注册,说白了就是个HashMap,Key是URL路径,Value是Servlet实例。

void register(String path, BaseServlet servlet) { mappingTable.put(path, servlet); }

处理请求时,取出URI路径,先去MappingTable里查。查到了就调用Servlet的service方法,查不到就进入404处理分支。这个逻辑简单到不能再简单,但真实的Tomcat分级查找Context→Wrapper→Servlet也是这个设计思路,只是多级罢了。

Servlet的service方法做的是“按Method分派”。比如定义抽象类BaseServlet,它里面有一个service方法,读到请求方法后自动调用doGet或doPost。

public void service(HttpServletRequestImpl req, HttpServletResponseImpl resp) throws IOException { if ("GET".equals(req.getMethod())) { doGet(req, resp); } else if ("POST".equals(req.getMethod())) { doPost(req, resp); } }

这个地方是理解真实Tomcat的一个关键闸口。很多人用Spring MVC时写过Controller,但不知道DispatcherServlet本身就是个Servlet,它被映射到/,所有请求先到它这里,再二次分发到具体Controller方法。我的手写版本相当于用最原始的方式复现了DispatcherServlet的内部逻辑,之后再看Spring MVC,感受完全不一样。

3.3 响应封装与HttpServletResponse的几个坑

响应这块,新手最容易犯的毛病是直接往OutputStream里写一串自拼的HTML,然后忘了写状态行和Content-Type。手写的好处就是把这些规范焊死在脑子里。

HTTP响应的结构是:状态行、响应头、空行、响应体。一个最小的200响应长这样:

HTTP/1.1 200 OK Content-Type: text/html;charset=utf-8 Content-Length: 33 <html><body>Hello Tomcat</body></html>

我封装了HttpServletResponseImpl,内部维护一个ByteArrayOutputStream,业务代码通过getWriter().write()写入Body,最终在完成时统一组装状态行和Header。

组装时仍然要遵守一个细节:Content-Length必须等于Body字节数组长度。如果不相等,浏览器会一直等后续数据,特别是长连接时容易造成页面加载卡住。用bodyBytes.length而不是字符串的length(),因为中文字符涉及编码问题,字符串长度和字节长度是两个概念。

Content-Type设置也很有讲究。默认我会写text/html;charset=utf-8,如果不加charset,中文按系统默认编码输出,Windows上容易出现乱码。这个问题在真实Tomcat里也同样存在,属于编码问题里的“高发事故”。

3.4 静态资源处理与读取方式选择

动态请求走Servlet之后,静态资源也得能处理。我在实现里加了一个判断:如果URI以/static/开头,就直接从项目目录下的webapp目录找文件,找到就用Files.readAllBytes()读出来,根据扩展名设置Content-Type,再写回响应。

选readAllBytes()还是FileInputStream逐块读,我对比过两者的适用场景:

  • 小文件用readAllBytes(),代码简单,性能也没问题。
  • 大文件用readAllBytes()会把整个文件加载进内存,并发高时内存压力不小,真实Tomcat里静态资源也是用NIO或零拷贝方式优化过的。

手写阶段小文件够用,但笔记里要留这个意识,将来做性能优化时知道瓶颈在哪。

另一个静态资源的坑是路径拼接。不能用new File(uri)去读,因为URI是/static/demo.css,前面带斜杠,直接拼到项目根目录会变成绝对路径的错误姿势。正确做法是去掉开头的/,用Paths.get("webapp", uri)这样的形式,或者用URI.getPath().substring(1)先做一次清洗。

3.5 生命周期管理与优雅关闭

写完主逻辑再补生命周期,这也是Tomcat架构里很有价值的一环。真正Tomcat的启动流程是:先初始化Server,再初始化Service,再初始化Connector和Engine,最后启动Host和Context。层层递进,任何一层失败都可能导致启动失败,这也是为什么Tomcat启动时报错有时候会特别长——原因在深层。

我手写版的生命周期就简化成两步:init()里注册所有Servlet,start()里启动ServerSocket线程。关闭时不能直接System.exit(),因为正在处理中的请求会被强行中断。我用了volatile boolean shutdown标记,在主循环里检查到标记后,先关闭ServerSocket,再等线程池里的任务执行完。

Runtime.getRuntime().addShutdownHook(new Thread(() -> { shutdown = true; try { serverSocket.close(); } catch (IOException ignored) { } executor.shutdown(); }));

这样Ctrl+C退出时能保证当前请求正常收尾。真实Tomcat的优雅关闭也是这个思路,只不过它做得更完整,会触发JSP重新编译、Session钝化、Context停止监听等一堆钩子。

4. 手写之后回看真实Tomcat的配置细节

4.1 目录结构与安装配置的对应关系

手写版的webapp目录对应真实Tomcat的结构很自然。真实Tomcat解压之后主要用到这几个目录:

目录作用手写版对应物
bin启动/关闭脚本HttpServer主类
confserver.xml、web.xml等配置手写版里写死的注册表
lib依赖jar包ClassPath里的小工具类
webapps部署的应用目录webapp静态资源目录
logs日志输出代码里System.out

这样一对照,任何一条配置指令都变得很直观。比如server.xml里配port="8080",在我手写代码里就是new ServerSocket(8080)这个位置的参数;配protocol="HTTP/1.1",就是我在Connector里指定的解析方式;配URIEncoding="UTF-8",对应我在解析URl时选择的编码字符集。

4.2 自启动和JVM参数配置的实操记录

Linux下部署Tomcat,最常见的是想做成开机自启。我用的是Systemd方式,步骤很简单:

sudo vim /etc/systemd/system/tomcat.service

文件内容大致是:

[Unit] Description=Apache Tomcat Web Application Container After=network.target [Service] Type=forking Environment=JAVA_HOME=/usr/lib/jvm/java-17-openjdk Environment=CATALINA_PID=/opt/tomcat/temp/tomcat.pid ExecStart=/opt/tomcat/bin/startup.sh ExecStop=/opt/tomcat/bin/shutdown.sh [Install] WantedBy=multi-user.target

写完执行:

sudo systemctl daemon-reload sudo systemctl enable tomcat sudo systemctl start tomcat

这里有个容易踩的坑:Tomcat的startup.sh默认是forking类型,如果用Type=simple,Systemd会认为主进程就是startup.sh本身,而这个脚本启动后会返回,于是Systemd就认为服务结束了,把Tomcat整个杀掉。我一开始就踩了,服务起来后过两秒自动变dead,改成Type=forking才稳定。

JVM参数这块,修改bin/catalina.sh在CATALINA_OPTS里加,或者更规范地放在setenv.sh:

export CATALINA_OPTS="-Xms512m -Xmx1024m -Dfile.encoding=UTF-8"

内存参数是-Xms和-Xmx,分别代表堆初始大小和最大大小。生产项目里我通常建议-Xms和-Xmx设成一样,避免运行时频繁扩容,这是吞吐优先的常规做法。JDK 25配合较新版本Tomcat跑时,模块化系统对启动参数要求更严格,某些非标准参数会导致启动失败,这点要特别注意。

4.3 部署前后端分离项目时的根源问题

前后端分离部署到Tomcat,十有八九会出现“前端能打开,但调后端接口404”的现象。本质原因是前端的Vue项目在打包后生成的是静态资源,它属于Web层;后端Spring Boot项目是另一个权限。常见做法是把后端打成war包,丢进webapps目录,再把前端dist目录里的东西复制到webapps/ROOT下。

这个做法有个极其常见的坑:前端访问/api/user/list,Nginx把请求转给Tomcat,Tomcat里war包部署的Context Path如果是/backend,那么实际请求路径变成了/backend/api/user/list,前后端联调时对不上。

解决方案有两种。第一种是在Nginx里配置location /api/ { proxy_pass http://tomcat地址/backend/api/; },注意proxy_pass末尾的路径会替换掉location匹配的部分。第二种是把后端war直接部署成ROOT.war,让它没有Context Path,这样请求路径就能对齐。

如果还想在Tomcat层面解决,可以改conf/server.xml里的Host配置,加一个Context:

<Context path="" docBase="/opt/frontend/dist" />

把前端静态资源挂到根路径,后端war保持自己名字,通过不同路径区分。

5. 高频问题的真实排查思路

5.1 启动时in-addr.arpa反向域名解析卡顿

这个坑我在手写版里没遇到,但在公司Linux服务器上启动Tomcat时遇到过:启动日志卡在某个地方好几十秒才继续。排查之后发现是Tomcat在做in-addr.arpa反向域名解析——当它拿到一个IP地址,会尝试反查域名,如果DNS配置不当,反查超时,启动就变慢。

解决办法有几个。最常用的是编辑/etc/hosts,把本机主机名映射到127.0.0.1,让Tomcat不需要请求外部DNS就能完成反查。我当时是这么干的:

echo "127.0.0.1 myhostname" >> /etc/hosts

另一个办法是在启动参数里开启-Djava.net.preferIPv4Stack=true,某些情况下能减少IP栈初始化带来的延迟。这问题不是Tomcat独有,只要基于Java且涉及网络连接的中间件都可能遇到。

5.2 Tomcat日志分析的正确姿势

Tomcat日志分为两路:系统日志catalina.out和访问日志localhost_access_log。系统日志管的是启动异常、Servlet报错、内存溢出;访问日志管的是谁在什么时间请求了什么路径,返回什么状态码。

分析访问日志时,我最常用的是直接统计状态码分布:

awk '{print $9}' logs/localhost_access_log.txt | sort | uniq -c | sort -rn

这条命令的意思是:取第9列(状态码),排序统计数量,按降序排列。如果看到大量502、500的记录,说明部署的war启动有问题或接口异常;看到404,Check请求路径和Context Path。

系统日志排查异常时,要习惯看堆栈的第一行“Caused by”,因为上面经常是一堆无关紧要的包装信息,真正的错误原因在最底部。比如字段里出现了ClassNotFoundException,先确认lib目录下有没有对应jar包,再确认是不是JDK版本变了导致JAXB这类模块缺失。

5.3 乱码问题的分位置治理

乱码问题手写版会遇到,真实Tomcat更常见,而且乱码位置不同,解决方法完全不同:

  • 页面输出乱码:设置Content-Type: text/html;charset=utf-8,同时确保JSP页面pageEncoding一致。
  • 请求参数乱码:在Connector上配置URIEncoding="UTF-8"。
  • POST表单乱码:添加CharacterEncodingFilter,强制性设置request.setCharacterEncoding("UTF-8")。
  • 控制台日志乱码:改logging.properties里java.util.logging.ConsoleHandler.encoding为UTF-8。

我遇到过最诡异的一回是Windows下开发环境控制台乱码,但浏览器访问页面完全正常。原因就是控制台的默认编码是GBK,而Tomcat输出UTF-8,两套编码不匹配。这个不算故障,只要把IDEA或控制台编码调成UTF-8就行。

5.4 Tomcat闪退和端口占用问题处理

Tomcat闪退分两种情况。Windows下双击startup.bat,窗口一闪而过,多半是启动失败。这时不能用窗口方式看日志,直接命令行运行:

catalina.bat run

这样日志会直接打到控制台,错误信息一目了然。常见的一个原因是JAVA_HOME没有配置或指向了错误的JDK,Tomcat找不到Java运行时。另一个是JDK版本过新,与老版本Tomcat不兼容,比如JDK 25配合非常老的Tomcat 8时期项目,可能会出现模块权限问题。这时优先升级Tomcat主版本,而不是硬扛。

端口占用也比较典型。8080端口被其他进程占用会导致启动失败。Linux下用ss -lntp | grep 8080或lsof -i:8080找到占用进程。Windows下用netstat -ano | findstr 8080,然后taskkill /PID xxx /F杀掉。

手写版遇到端口占用时同样要处理,不过排查思路是一样的——要么换端口,要么解决占用。真实Tomcat直接在server.xml里改端口即可。

6. 手写一遍给我留下的几个强烈感受

因为亲手写过一遍,再看IDEA里“源服务器未能找到目标资源的表示”这个报错,理解就完全不同了。这个报错本质是HTTP 404的一种表达方式,IDEA把它翻译成了更友好的提示。出现时,优先检查IDEA中配置的Application context和Tomcat的webapps路径是否正确,再看请求URL路径和Servlet映射是否匹配。

另一个改变是我对线程池的理解。手写版本用了ExecutorService,多并发时一切正常,但一旦某个Servlet里写了死循环,整个线程池就会被占满,后续请求全部堆积。真实Tomcat默认情况下每个Connector都维护自己的线程池,配置里有maxThreads和acceptCount两个参数,前者决定同时处理请求的线程数,后者决定队列里还能堆多少。理解了手写版里的阻塞点,再看这些配置,参数的意义就落地了。

还有一点是关于ClassLoader的。手写版所有类都在同一个ClassPath里,不存在隔离问题。但真实Tomcat的webapps目录下的每个应用都有自己的ClassLoader,目的是让不同应用可以依赖不同版本的库而不互相干扰。如果两个war包都依赖同名的不同版本jar,Tomcat能通过各自的WebappClassLoader隔离。

手写项目没有走到那一步,但至少让我知道,ClassLoader不只和应用服务器有关,它是整个Java服务器体系的基础设施。

最后再分享一个我做笔记的习惯:每个关键类都配一张它的“请求到达路线图”。拿到请求后,Connector怎么处理,Processor怎么解析,Container怎么分发,Servlet怎么执行,Response怎么回流,全画在一张A4纸上。后面配置Tomcat、排查生产问题时,脑子里随时能调出这张图,很多看似无解的“怪问题”,都能迅速定位到具体某个环节。这就是手写流程笔记真正留下来的价值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询