ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

手写简化版Tomcat:一次搞懂HTTP请求处理与Servlet容器核心原理

手写简化版Tomcat:一次搞懂HTTP请求处理与Servlet容器核心原理 手写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/ServerHttpConnector接收Socket连接转发给ProcessorConnectorHttpProcessor解析请求行/请求头封装Request/ResponseProcessorHttpServletRequestImpl实现请求接口提供method、uri、参数获取RequestHttpServletResponseImpl封装响应状态、Header、BodyResponseServletContainer管理Servlet实例做URL到Servlet的映射Container/ContextBaseServlet抽象类定义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?namezhang 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注册说白了就是个HashMapKey是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;charsetutf-8 Content-Length: 33 htmlbodyHello Tomcat/body/html我封装了HttpServletResponseImpl内部维护一个ByteArrayOutputStream业务代码通过getWriter().write()写入Body最终在完成时统一组装状态行和Header。组装时仍然要遵守一个细节Content-Length必须等于Body字节数组长度。如果不相等浏览器会一直等后续数据特别是长连接时容易造成页面加载卡住。用bodyBytes.length而不是字符串的length()因为中文字符涉及编码问题字符串长度和字节长度是两个概念。Content-Type设置也很有讲究。默认我会写text/html;charsetutf-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()里注册所有Servletstart()里启动ServerSocket线程。关闭时不能直接System.exit()因为正在处理中的请求会被强行中断。我用了volatile boolean shutdown标记在主循环里检查到标记后先关闭ServerSocket再等线程池里的任务执行完。Runtime.getRuntime().addShutdownHook(new Thread(() - { shutdown true; try { serverSocket.close(); } catch (IOException ignored) { } executor.shutdown(); }));这样CtrlC退出时能保证当前请求正常收尾。真实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里配port8080在我手写代码里就是new ServerSocket(8080)这个位置的参数配protocolHTTP/1.1就是我在Connector里指定的解析方式配URIEncodingUTF-8对应我在解析URl时选择的编码字符集。4.2 自启动和JVM参数配置的实操记录Linux下部署Tomcat最常见的是想做成开机自启。我用的是Systemd方式步骤很简单sudo vim /etc/systemd/system/tomcat.service文件内容大致是[Unit] DescriptionApache Tomcat Web Application Container Afternetwork.target [Service] Typeforking EnvironmentJAVA_HOME/usr/lib/jvm/java-17-openjdk EnvironmentCATALINA_PID/opt/tomcat/temp/tomcat.pid ExecStart/opt/tomcat/bin/startup.sh ExecStop/opt/tomcat/bin/shutdown.sh [Install] WantedBymulti-user.target写完执行sudo systemctl daemon-reload sudo systemctl enable tomcat sudo systemctl start tomcat这里有个容易踩的坑Tomcat的startup.sh默认是forking类型如果用TypesimpleSystemd会认为主进程就是startup.sh本身而这个脚本启动后会返回于是Systemd就认为服务结束了把Tomcat整个杀掉。我一开始就踩了服务起来后过两秒自动变dead改成Typeforking才稳定。JVM参数这块修改bin/catalina.sh在CATALINA_OPTS里加或者更规范地放在setenv.shexport CATALINA_OPTS-Xms512m -Xmx1024m -Dfile.encodingUTF-8内存参数是-Xms和-Xmx分别代表堆初始大小和最大大小。生产项目里我通常建议-Xms和-Xmx设成一样避免运行时频繁扩容这是吞吐优先的常规做法。JDK 25配合较新版本Tomcat跑时模块化系统对启动参数要求更严格某些非标准参数会导致启动失败这点要特别注意。4.3 部署前后端分离项目时的根源问题前后端分离部署到Tomcat十有八九会出现“前端能打开但调后端接口404”的现象。本质原因是前端的Vue项目在打包后生成的是静态资源它属于Web层后端Spring Boot项目是另一个权限。常见做法是把后端打成war包丢进webapps目录再把前端dist目录里的东西复制到webapps/ROOT下。这个做法有个极其常见的坑前端访问/api/user/listNginx把请求转给TomcatTomcat里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配置加一个ContextContext 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.preferIPv4Stacktrue某些情况下能减少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启动有问题或接口异常看到404Check请求路径和Context Path。系统日志排查异常时要习惯看堆栈的第一行“Caused by”因为上面经常是一堆无关紧要的包装信息真正的错误原因在最底部。比如字段里出现了ClassNotFoundException先确认lib目录下有没有对应jar包再确认是不是JDK版本变了导致JAXB这类模块缺失。5.3 乱码问题的分位置治理乱码问题手写版会遇到真实Tomcat更常见而且乱码位置不同解决方法完全不同页面输出乱码设置Content-Type: text/html;charsetutf-8同时确保JSP页面pageEncoding一致。请求参数乱码在Connector上配置URIEncodingUTF-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没有配置或指向了错误的JDKTomcat找不到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包都依赖同名的不同版本jarTomcat能通过各自的WebappClassLoader隔离。手写项目没有走到那一步但至少让我知道ClassLoader不只和应用服务器有关它是整个Java服务器体系的基础设施。最后再分享一个我做笔记的习惯每个关键类都配一张它的“请求到达路线图”。拿到请求后Connector怎么处理Processor怎么解析Container怎么分发Servlet怎么执行Response怎么回流全画在一张A4纸上。后面配置Tomcat、排查生产问题时脑子里随时能调出这张图很多看似无解的“怪问题”都能迅速定位到具体某个环节。这就是手写流程笔记真正留下来的价值。
返回列表