资讯动态

手写简易Tomcat:从Socket到Servlet容器的核心原理

发布时间:2026/9/24 18:34:25 来源:尧图企业网站定制
在我刚把 Java Web 玩明白那阵子Tomcat 对我就是个黑盒子——部署的时候点几下按钮请求进来返回个 HTML仅此而已。面试被问到“Servlet 容器如何工作”的时候我背了一堆 Connector、Container、Lifecycle 的概念但面试官一句“你从 Socket 收到数据到 Servlet 执行完返回中间发生了什么”就能把我问住。后来我决定花两个晚上用纯 Java 手写一个极简版的 Tomcat来把这个过程彻底捋清楚。这篇文章就是我当时整个项目的复盘总结完整经历了从 Socket 监听、HTTP 报文解析、Servlet 生命周期管理到静态资源返回的全过程最后实现了一个能跑基本 Servlet 的小容器。这个“手写简易 Tomcat”本质上不是让你去替代真正的 Tomcat而是理解 Servlet 容器核心原理的一把钥匙。它帮你回答几个非常实际的问题HTTP 请求是怎么从浏览器到达你的 Servlet 的Servlet 接口为什么是那么几个方法为什么要写 web.xml 或者用注解配置映射为什么说 Servlet 是单实例多线程的如果你已经被这些问题困扰或者正在准备面试、想弄懂 Java Web 底层机制那么这篇文章非常适合你。它不需要你有多深的网络基础只要你会用 Socket、懂基本的 Java IO就能跟着把整个容器搭出来。1. 内容整体设计与思路拆解1.1 Servlet 容器到底在干什么我们用最朴素的话说web 服务器接收 HTTP 请求然后调用你写的代码处理请求最后返回 HTTP 响应。问题是HTTP 请求是一串文本你怎么让某个 Java 方法去处理这串文本Tomcat 这类 Servlet 容器就是干这个的它把“接收网络数据”“解析 HTTP 报文”“封装成请求对象”“调用对应组件”“序列化响应”这些脏活累活全包了你只需要实现特定的接口和类剩下的生命周期由容器帮你管理。所以一个 Servlet 容器的职责可以拆成四块网络层监听端口接收 TCP 连接读取字节流。协议层把字节流解析成符合 HTTP 规范的请求比如请求行、请求头、请求体。应用层根据请求的 URI 找到对应的 Servlet创建请求和响应对象调用 Servlet 的业务逻辑。资源管理负责 Servlet 的加载、初始化、调用和销毁也就是生命周期管理。真正的 Tomcat 里前两部分由 Connector连接器完成第三部分由 Container容器完成两者通过 Processor 和 Adapter 衔接。手写版不需要这么精细但职责边界要清楚否则代码写出来就是一坨什么都干的循环后面想扩展会很难受。1.2 把目标拆小MiniTomcat 的边界与范围刚开始动手的时候我差点陷入“我要做一个 Tomcat 全集”的冲动——想着把 JSP、Filter、Session、类加载器全塞进去结果写了一晚上发现连 HTTP 解析都还没跑通。回头我冷静下来把一个最小可用版本限定成这几个能力只支持 GET 和 POST 请求其他方法返回 405 或直接忽略。支持请求行 / 请求头 / 请求体解析能识别 URI 和查询字符串。支持将 URI 映射到 Servlet并实现 Servlet 的 init / service / destroy 生命周期。支持返回静态文件HTML、CSS、JS、图片不支持也不管什么压缩、缓存策略。多线程处理并发请求每个连接独立线程用线程池限制资源占用。中文情况必须正确处理至少返回的 HTML 不能乱码。不要小看这个范围的划定。边界清晰这件事最大的好处是每写一个类之前我都能说清楚它解决什么问题。后面我加的 Session、Filter都是在这些核心类之上做扩展而不是推倒重来。2. 核心细节解析与实操要点2.1 先从 Socket 说起连接是如何变成请求的学习的时候最容易被忽略的一点HTTP 本身不是一种独立的协议它跑在 TCP 之上。也就是说浏览器发请求本质上是往一个 TCP 连接里写入一段符合 HTTP 格式的文本然后从同一个连接里读取返回的文本。所以要手写容器第一步就是用 Java 的 ServerSocket 在指定端口监听accept 到客户端连接后从 Socket 的输入流里读取字节。这里有一个非常关键的细节HTTP 请求的格式是固定的一行行文本由\r\n回车换行分隔。第一行是请求行比如GET /index.html HTTP/1.1接着是若干请求头比如Host: localhost:8080、Content-Type: application/x-www-form-urlencoded。请求头和请求体之间有一个空行也就是一个单独的\r\n。请求体出现在 POST 请求中长度由Content-Length头决定。所以最简单的解析策略是先读请求行再一行行读请求头直到遇到空行然后根据Content-Length读取请求体。这个方法不严谨但理解起来非常直观。真正 Tomcat 的做法要复杂得多它需要处理分块传输、管道化请求等手段但原理都是先按行解析头部再根据已知长度读取内容。2.2 封装 Request 和 Response给业务一个干净的视图如果我们直接拿 Socket 的输入流让开发者在 Servlet 里去解析 HTTP 报文那太折磨人了。因此容器要做一层封装把解析完的请求行、请求头、参数全部整理好组装成一个 Request 对象把输出流包装成 Response 对象开发者只需要往 Response 里写入 HTML 内容容器负责把响应头和内容一起发送回去。在设计 Request 类时我用了几个 Map 分别存请求头、请求参数和属性attribute。请求参数的来源有两种URL 上的查询字符串query string和 POST 表单提交的 body。这里有个很容易踩的坑URL 编码问题。浏览器提交过来的中文参数会用百分号编码比如“你好”会变成%E4%BD%A0%E5%A5%BD需要调用URLDecoder.decode(value, UTF-8)才能还原不然你的页面上就是一串乱码或者号。Response 的核心是输出流。我在 write 方法里把响应行、响应头和响应体按顺序写入。很多人第一次写的时候会故意忽略响应头直接把 HTML 字符串写到流里结果浏览器也能渲染——这是因为浏览器对不规范的响应有一定的容错能力。但这并不是好习惯遇到跨域、文件下载、缓存控制时没有响应头就寸步难行了。2.3 Servlet 生命周期单实例多线程到底怎么实现Servlet 规范里一个最重要的设计是同一个 Servlet 在容器中默认只有一个实例所有的并发请求共用这一个实例。这个设计是从性能和状态共享角度出发的。如果每个请求都 new 一个 Servlet在高并发下对象创建和回收本身就是一个大的性能开销但共用实例也带来了一个经典问题——实例变量是共享的多个线程同时读写就会产生数据竞争。在我手写的容格里生命周期是这样管理的启动时扫描映射配置把xxx.servlet com.example.HelloServlet这样的配置读进来。第一次调用或启动时按需实例化反射调用newInstance()然后调用init()方法。请求到来时从 Map 里取出这个 Servlet 实例调用它的service()方法。容器关闭时依次调用所有 Servlet 的destroy()方法释放资源。为了让这个过程可感知我写了一个Servlet接口四个方法init、service、destroy外加一个用于获取配置信息的 getServletConfig。后面为了简化我把它拆成了接口 抽象类让 GET、POST 的分发逻辑放在抽象类里业务逻辑只覆盖 doGet / doPost 就行这样用起来基本就跟标准 Servlet 一样了。这里提醒一句每个 Servlet 实例用线程池并发调用 service 方法但实例变量并不能保证线程安全你必须在写业务代码时自己加锁或者用 ThreadLocal。我手写版里专门放了一个有并发问题的 Servlet 做演示跑压测的时候数据错乱得很明显这种方式比干看文档印象深多了。3. 实操过程与核心环节实现3.1 项目结构与基础设施准备我用的环境是 JDK 8 纯 Java 标准库没有任何第三方依赖这样子整个项目的依赖关系完全是可解释的。项目结构如下mini-tomcat/ ├── src/main/java/ │ ├── com/mimi/ │ │ ├── server/ │ │ │ ├── MiniTomcat.java # 启动入口负责端口监听 │ │ │ ├── HttpProcessor.java # 请求处理线程解析并分发 │ │ │ ├── MappingHandler.java # servlet 映射管理 │ │ │ └── StaticResourceHandler.java # 静态资源处理 │ │ ├── servlet/ │ │ │ ├── Servlet.java # 顶层接口 │ │ │ ├── HttpServlet.java # 抽象类实现 doGet/doPost 分发 │ │ │ ├── Request.java # 请求封装 │ │ │ └── Response.java # 响应封装 │ │ └── demo/ │ │ ├── HelloServlet.java # 演示用 Servlet │ │ └── LoginServlet.java # 演示 POST 参数解析 ├── webapp/ │ ├── index.html │ └── static/style.css └── conf/ └── server.properties # 端口和映射配置现在我要特别说下配置文件的格式。我没有发明新格式直接用了 properties每项配置一行解析起来简单又不会因为写 XML 而把笔记的篇幅占掉一大半server.port8080 servlet.hello/hello servlet.hello.classcom.mimi.demo.HelloServlet servlet.login/login servlet.login.classcom.mimi.demo.LoginServlet static.resource.path./webapp虽然格式简单但已经能表达两个信息哪个路径映射到哪个 Servlet以及这个 Servlet 的类名是什么。后面如果加 Filter用同样的思路加一份filter.xxx配置即可。3.2 启动入口与线程池如何支撑并发访问MiniTomcat 启动类是整个容器的心脏。我用死循环去 accept 连接每接受一个 Socket 就丢给线程池处理。这里选线程池而不是每次 new Thread 是经过考量的每次 new Thread 在低并发时看起来没问题但连接一多线程的创建销毁开销就会反过来拖垮性能甚至直接把 JVM 内存打爆。固定线程池可以限制最大并发数让多余请求排队线上 Tomcat 的 maxThreads 本质上就是干这个的。public class MiniTomcat { private int port 8080; private ExecutorService pool; private MapString, Servlet servletMap new ConcurrentHashMap(); private Properties conf; public static void main(String[] args) { new MiniTomcat().start(); } public void start() { loadConfig(); int corePoolSize Math.max(4, Runtime.getRuntime().availableProcessors() * 2); pool Executors.newFixedThreadPool(corePoolSize); try (ServerSocket serverSocket new ServerSocket(port)) { System.out.println([MiniTomcat] 启动成功监听端口 - port); while (true) { Socket socket serverSocket.accept(); pool.execute(new HttpProcessor(socket, this)); } } catch (IOException e) { e.printStackTrace(); } } }这一段里有个小细节端口绑定成功之后才打印“启动成功”因为 ServerSocket 构造函数会主动做 bind 操作如果端口被占用这里会直接抛异常。调试的时候最怕看到日志打了一堆“启动成功”但 bind 失败所以我刻意把打印放在 try 块里。线程池的大小选择上我没有拍脑袋取固定值而是先取 CPU 核心数再乘以 2。这只是个粗略估算真实环境需要结合业务量、IO 密集程度调整但这种动态计算的写法至少让代码在不同机器上不至于差得太离谱。IO 密集型的请求处理任务线程数可以适当多于 CPU 核心数因为大部分时间线程都在等 Socket 数据。3.3 请求解析HTTP 报文的逐行读取与参数提取HttpProcessor 是每个请求的入口它的 run 方法只做四件事读取输入流、解析请求、分发处理、关闭连接。读取输入流的时候我建议用 BufferedReader 按行读而不是一次读一个字符因为性能差距非常明显。但注意读取请求体的时候不能再用 readLine因为 body 里的内容可能含换行必须改用 Content-Length 指定长度的字节读取。public void run() { try { socket.setSoTimeout(3000); BufferedInputStream input new BufferedInputStream(socket.getInputStream()); Request request new Request(input); if (!request.parse()) { socket.close(); return; } Response response new Response(socket.getOutputStream(), request); dispatch(request, response); response.flush(); } catch (Exception e) { e.printStackTrace(); } finally { try { socket.close(); } catch (IOException ignored) { } } }Request 的 parse 方法是重点。我先把输入流包装成 BufferedReader然后读取第一行按空格拆成 method、uri、protocol。这里有一个非常隐蔽的坑uri 可能带有查询字符串比如/hello?nametom如果直接把整个 uri 拿去找 Servlet会永远匹配不上。所以解析完请求行之后我要立刻把 uri 和 queryString 拆分public boolean parse() throws IOException { String requestLine reader.readLine(); if (requestLine null || requestLine.isEmpty()) { return false; } String[] parts requestLine.split( ); if (parts.length 3) { return false; } this.method parts[0]; this.uri parts[1]; this.protocol parts[2]; int questionIdx uri.indexOf(?); if (questionIdx 0) { this.queryString uri.substring(questionIdx 1); this.uri uri.substring(0, questionIdx); } String line; while ((line reader.readLine()) ! null !line.isEmpty()) { int colonIdx line.indexOf(:); if (colonIdx 0) { String key line.substring(0, colonIdx).trim(); String value line.substring(colonIdx 1).trim(); headers.put(key.toLowerCase(), value); } } // 读取请求体 String contentLength headers.get(content-length); if (contentLength ! null) { int len Integer.parseInt(contentLength.trim()); char[] bodyChars new char[len]; reader.read(bodyChars, 0, len); this.body new String(bodyChars); } parseParameters(); return true; }读取请求体时还有一个坑我明明用的是 BufferedReader.readLine读取 body 却用 read 方法。原因在于 readLine 会读取到换行符之前的内容如果 body 里本身就包含换行那 readLine 就会把请求体截断。正确做法是根据 Content-Length 精确读取 N 个字符不多不少。这样既能拿到完整 body又不会因为多读字符而破坏下一个请求的数据虽然 HTTP/1.1 通常是长连接但我们在简易版里可以忽略这个细节。解析参数的部分GET 的参数来自查询字符串POST 的参数来自 body。两者的编码都是keyvaluekey2value2的形式差别只在于来源不同。我把参数解析抽成一个方法统一放进一个 Mapprivate void parseParameters() { String raw null; if (GET.equals(method) queryString ! null) { raw queryString; } else if (POST.equals(method) body ! null) { raw body; } if (raw null || raw.isEmpty()) { return; } String[] pairs raw.split(); for (String pair : pairs) { int eqIdx pair.indexOf(); if (eqIdx 0) { String key decode(pair.substring(0, eqIdx)); String value decode(pair.substring(eqIdx 1)); parameters.put(key, value); } } } private String decode(String value) { try { return URLDecoder.decode(value.replace(, %20), UTF-8); } catch (UnsupportedEncodingException e) { return value; } }这里处理加号是因为 URL 编码规范里空格在表单里是用表示的。用URLDecoder.decode前先把替换成%20这样能避免解码结果里出现异常字符。这个细节不处理的话输入带空格的名字时就会变成奇怪的字符。3.4 Servlet 接口与抽象类如何优雅地分发 GET/POST容器要管理 Servlet必须有一个统一的接口。我定义了下边这个四个方法public interface Servlet { void init(); void service(Request request, Response response); void destroy(); }只有三个抽象方法的原因是Servlet 规范里还有 getServletConfig、getServletInfo 这些方法但对理解核心原理没帮助反而增加了干扰。我把她们从接口里去掉等需要了再加这也是手写代码的好处——你可以完全控制复杂度。HttpServlet 抽象类不直接实现业务逻辑而是做请求方法的分发public abstract class HttpServlet implements Servlet { Override public void service(Request request, Response response) throws IOException { if (GET.equalsIgnoreCase(request.getMethod())) { doGet(request, response); } else if (POST.equalsIgnoreCase(request.getMethod())) { doPost(request, response); } else { response.sendError(405, Method Not Allowed); } } protected abstract void doGet(Request request, Response response) throws IOException; protected abstract void doPost(Request request, Response response) throws IOException; }这样设计之后演示用的业务 Servlet 只需要继承 HttpServlet重写 doGet/doPost 就行写法跟平时的 Servlet 基本一模一样。从框架设计的角度这叫模板方法模式父类把流程固定的部分实现掉了把变化的部分留给子类去覆盖。分发完成后容器还要负责调用 Servlet。但这里有个顺序问题init 方法什么时候调用最直观的做法是请求第一次访问某个 URI 时容器才去实例化这个 Servlet 并调用 init这就是懒加载。另外有一种是在启动时预加载Tomcat 的 load-on-startup 配置就是干这个的。我选择了懒加载因为这样可以减少启动时间而且“第一次访问时初始化”这个语义更接近初学者对 Servlet 生命周期的理解。public Servlet mapServlet(String uri) { Servlet servlet servletMap.get(uri); if (servlet ! null) { return servlet; } String className configMap.get(uri); if (className null) { return null; } try { Class? clazz Class.forName(className); Servlet instance (Servlet) clazz.getDeclaredConstructor().newInstance(); instance.init(); servletMap.put(uri, instance); return instance; } catch (Exception e) { throw new RuntimeException(Servlet 实例化失败: className, e); } }注意这段代码是线程不安全的两个线程同时首次访问同一个 URI 时可能会创建两个 Servlet 实例然后后面的 put 会覆盖前面的。这个问题在真实 Tomcat 中由生命周期管理器保证我在这里用 ConcurrentHashMap 也不能完全避免重复初始化。后来我加了一个synchronized块保证同一时间只有一个线程做加载或者用双重检查锁。这个点也可以作为面试引申讲一下双重检查锁的用途。3.5 响应封装与静态资源让浏览器能看到东西响应这块的核心是写响应头。首先要写响应行HTTP/1.1 200 OK然后是 Content-Type、Content-Length最后空一行写响应体。顺序绝对不能乱因为 HTTP 协议规定头部结束以空行为标志如果先写 body 再写 header浏览器会认为整段内容都是响应体。public void sendHtml(String html) throws IOException { byte[] data html.getBytes(UTF-8); sendHeader(Content-Type, text/html; charsetUTF-8); sendHeader(Content-Length, String.valueOf(data.length)); output.write(data); output.flush(); } private void sendHeader(String key, String value) throws IOException { output.write((key : value \r\n).getBytes(UTF-8)); }值得注意的一点一定要手动指定 Content-Type 里的 charset否则浏览器可能按照平台默认编码解析中文很容易乱码。这在真机上尤其明显而不少初学者写 Socket 程序时只写text/html结果测试时中文全乱。静态资源的处理相对简单把 uri 当作文件路径在 webapp 根目录下去找对应的文件找到就返回找不到就 404。不过这里必须做安全的路径校验防止../跳出根目录这是 Web 安全里最经典的路径穿越漏洞。我是用File.getCanonicalPath()来处理的public boolean handleStaticResource(Request request, Response response) throws IOException { String uri request.getUri(); if (/.equals(uri)) { uri /index.html; } File file new File(StaticRoot, uri); String canonical file.getCanonicalPath(); String rootCanonical new File(StaticRoot).getCanonicalPath(); if (!canonical.startsWith(rootCanonical)) { return false; } if (!file.exists() || file.isDirectory()) { return false; } String contentType Files.probeContentType(file.toPath()); response.sendFile(file, contentType null ? application/octet-stream : contentType); return true; }路径穿越年年都有漏洞报告虽然现在主流容器已经很少出这种低级错误了但手写代码的时候必须把这个安全意识刻在骨子里。任何从请求里拿到的路径都要先校验再使用。4. 常见问题与排查技巧实录4.1 为什么老是 404映射与路径的坑我手写版上线测试后遇到的第一个问题就是访问/hello一直返回 404。排查之后发现原因有两类。第一类是配置文件没加载成功类名写错或者文件位置不对反射的时候直接 ClassNotFoundException 了。第二类是 uri 带查询参数比如访问/hello?nametom如果我没有提前把 queryString 从 uri 里拆出来mapServlet 就永远匹配不上。这是最容易犯的错强烈建议在解析请求行后立即拆分 uri 和 queryString。还有一种非常隐蔽的情况浏览器请求的路径可能包含一些特殊字符比如/hello%20world。真实 Tomcat 会先把 uri 解码再路由手写版如果直接拿编码后的 uri 去查 Map就会出现匹配失败。后来我加入了 URLDecode 处理但要注意只解码一次不能反复解码导致%2520这类双重编码问题。4.2 中文乱码一个坑带来三个修复点中文乱码问题的根源在于字符集不一致。我遇到的情况有三种响应内容乱码、URL 参数乱码、POST 表单乱码。第一次只修响应乱码时我发现 GET 参数还是乱因为浏览器 URL 编码用的是 UTF-8我服务器解码时如果用了 ISO-8859-1 就全完了。最后总结出的黄金规则是整个容器内部统一使用 UTF-8包括文件读写、IO 流构造、HTTP 头里的 charset、URLDecoder 的编码参数。只要有一处不一致乱码就很可能出现。顺带说一个调试技巧出现乱码时先用浏览器开发者工具看响应头里的 Content-Type 是不是带上了charsetUTF-8再看源码里new String(bytes, UTF-8)是否指定了编码。把这两处修好80% 的乱码问题都能解决。4.3 并发场景下的数据错乱理解单实例多线程的代价我的容器在单线程模式下跑得很正常一换成并发请求就出现数据错乱。后来定位到是我在 LoginServlet 里用一个成员变量currentUser存当前登录用户多个线程同时写这个字段自然就互相覆盖了。这个问题的本质就是 Servlet 单实例多线程模型。Web 开发不是通过创建对象来隔离状态的而是通过请求上下文来隔离的。Request 对象就是天然的隔离边界因为每个请求都会有独立的 Request 实例。修复方法有两种一种是把可变状态从实例字段移到方法局部变量另一种是使用 ThreadLocal让每个线程持有自己的副本。在真实项目中Spring MVC 的 Controller 默认是单例的很多人写代码时习惯在 Controller 里写无状态方法原因就在这里。手写一遍之后我对“为什么 Controller 要无状态”理解得比看十篇博客都深刻。4.4 端口被占用与连接异常最容易被忽略的边界情况java.net.BindException: Address already in use是我调试时遇到最多的启动错误。原因可能是上一个进程没有完全退出也可能是 IDE 的控制台没有释放端口。解决方法是启动前用lsof -i :8080查占用进程或者换一个端口测试。另外Socket 的读写有个特点readLine()读取请求头时如果客户端迟迟不发数据线程会一直阻塞在那里所以我设置了 SO_TIMEOUT避免池中的线程被“挂死”的请求耗尽。这里面其实也掺杂了 HTTP 连接的管理问题真实 Tomcat 还涉及 keep-alive 和连接超时回收手写版只要能保证资源释放即可。4.5 排查小技巧用 telnet 手工模拟 HTTP 请求这里单独分享一个我认为最有价值的调试技巧不用浏览器直接用命令行 telnet 来模拟 HTTP 请求。比如telnet localhost 8080连接上之后手动输入请求内容GET /hello HTTP/1.1 Host: localhost:8080然后按两次回车就能看到容器的原始响应。这样做最大的好处是你能完全控制请求内容什么乱七八糟的编码问题、空行问题在 telnet 里一眼就能看出来有没有往服务器发送正确的数据。排查容器问题时优先用 telnet比浏览器自带的调试工具更直观。还有一个技巧是在 HttpProcessor 的 run 方法里加日志把每次请求的 method 和 uri 打印出来。看起来原始但因为它是整个请求流程的第一个环节只要这里没有输出就是连接建立都没成功之后的所有排查都不用做。日志加在正确的位置比写一百行 log4j 配置都管用。5. 进阶扩展方向Session、Filter 与类加载器5.1 Session 的实现思路Cookie 加 Map 的配合Servlet 的 Session 机制本质上是让容器在无状态的 HTTP 协议上模拟出有状态的会话。如果你的应用需要记住用户登录状态就得靠 Session。我在手写版里加了一个非常简陋的 SessionManager客户端第一次请求时容器生成一个唯一的 sessionId通过Set-Cookie: JSESSIONIDxxx返回给浏览器浏览器后续请求会带上Cookie: JSESSIONIDxxx容器根据这个值找到对应的 Session 对象。public class SessionManager { private MapString, StandardSession sessions new ConcurrentHashMap(); public StandardSession getSession(String sessionId) { if (sessionId null) return null; return sessions.get(sessionId); } public StandardSession createSession() { String id UUID.randomUUID().toString().replace(-, ); StandardSession session new StandardSession(id, System.currentTimeMillis()); sessions.put(id, session); return session; } }核心难点不在 Map 本身而在 session 的过期回收。真实 Tomcat 里Session 由管理器定期扫描并销毁那些超过 maxInactiveInterval 的会话防止内存泄漏。手写版里我简化成了“访问时惰性检查是否过期”虽然不精确但已经能说明问题。如果你想做完整版可以再加一个后台定时任务做扫描。5.2 Filter 的模型请求处理链路中的拦截器Filter过滤器是 Servlet 规范里一个极其重要的扩展点它可以在 Servlet 处理之前和之后插入自定义逻辑——权限校验、日志记录、请求体修改等等。它的调用模型是一个链条请求先经过若干个 Filter最后才到达 Servlet。我在这里采用了一个责任链模式的变体用一个 FilterChain 对象链上每个节点都持有 filter 和下一个节点。public class FilterChain { private ListFilter filters new ArrayList(); private int index 0; public void doFilter(Request request, Response response) throws IOException { if (index filters.size()) { Filter filter filters.get(index); index; filter.doFilter(request, response, this); } } }这一段代码很短但把责任链模式的精髓表达清楚了每个 Filter 在完成前置逻辑之后必须主动调用chain.doFilter()让请求继续向后传递如果不调用请求就在这个 Filter 处被拦截住了。这个模型能解释为什么 Spring MVC 的 HandlerInterceptor 是在 DispatcherServlet 前后层层嵌套地执行。5.3 类加载器与“为什么普通 Java 程序不能热更”有关的底层逻辑Tomcat 最值得一提的设计之一就是自定义类加载器。默认的 JVM 类加载器是双亲委派模型类加载请求先交给父加载器只有父加载器找不到时才自己加载。但在 Web 容器里不同 Web 应用可能包含同名不同版本的类库如果都用默认的 AppClassLoader 加载就会互相冲突。所以 Tomcat 为每个 Web 应用创建一个独立的 WebAppClassLoader它的加载顺序是反向的先自己加载 classes 和 lib 下的类找不到时才交给父加载器。手写版里我一开始是用Class.forName(className)直接加载 Servlet 类这样类只会在内存中保留一份。后面为了支持热部署改用 URLClassLoader从webapp/WEB-INF/classes目录加载类当类文件变更时重建一个 ClassLoader旧的 ClassLoader 连同它加载的类一起被回收。这个过程其实不复杂但足以回答“热部署原理是什么”和“为什么两个应用用同一个 Tomcat 能隔离类版本”这类问题。6. 我踩过的坑和你可能也会遇到的事最后聊点实操中最真实的感受。写这个手写版容器我花的时间比预想的多很多主要是因为 HTTP 解析这个看似简单的环节暗坑太多。请求头的解析我用过String.split(:)结果遇到Content-Type: text/html; charsetUTF-8这种冒号可能出现在值里的情况就出错了后来才改成indexOf(:)再截取才能正确保留冒号后面的完整值。这个细节看起来不起眼但在解析Host: localhost:8080这种带端口号的值时真的会让人怀疑人生。再有就是流关闭的顺序。写 Socket 程序时很多人习惯在 finally 里把 InputStream 和 OutputStream 一起关掉。但如果你先用包装流比如 BufferedWriter写了数据直接关闭底层 Socket可能出现数据没真正发送完就被截断的情况。我的处理方式是先 flush再关闭 Socket让 Socket 的关闭负责把两侧流一并清理。而且要注意不要在循环里频繁开关连接一个请求一个连接的模型虽然最简单但性能并不好想要优化就得上 keep-alive。写完之后再看 Tomcat 的官方文档很多之前觉得生涩的概念一下就通了。比如 Catalina 的分层容器设计、Host 和 Context 的关系、Pipeline 与 Valve 的工作方式这些在真正动手写过之后都有了具象的参照物。这种感觉很奇妙明明只是写了一个“玩具级”的容器但对整个 Java Web 技术栈的理解却拔高了一个层次。如果你也想验证自己对 Servlet 容器原理的理解我建议你按自己手写一遍别只是看这篇博客。关键步骤并不复杂一个 ServerSocket、一个线程池、一个 Request 解析、一个 Servlet 路由。等你自己写完并成功让一个 Servlet 返回 “Hello World” 的时候那种成就感绝对比背十遍面试题来得扎实。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价