手写迷你Tomcat:从ServerSocket到Servlet映射与线程池的完整实现

发布时间:2026/10/13 5:43:53
手写迷你Tomcat:从ServerSocket到Servlet映射与线程池的完整实现 为什么要手写一个Tomcat我最早的出发点非常朴素用了好几年Tomcat每次部署都是往webapps里丢一个war包然后点启动按钮它就跑起来了。直到有一天被项目里的小朋友问Tomcat到底怎么把请求交给Servlet的我发现自己除了会背Connector和Container这两个词根本讲不出细节。于是我用一个周末从零手写了一个能跑Servlet的迷你Tomcat大概四百行代码。这件事做完之后再回去看Tomcat源码很多以前觉得高深的概念突然就变得顺理成章。这篇内容就是那次实践的完整复盘。我会带你从ServerSocket开始一步步实现HTTP报文解析、Request/Response抽象、Servlet映射、静态资源处理和线程池改造最终得到一个可以真正处理浏览器请求的迷你容器。适合那些已经会用Tomcat部署项目、但想搞懂内部原理的Java开发者也适合准备面试时被问到Tomcat工作原理却心里发虚的朋友。1. 手写前的定位Tomcat到底干了哪些活在动笔之前先想清楚一个核心问题Tomcat那么多功能哪些是它的灵魂哪些只是锦上添花如果什么都想写一个周末根本不够而且容易迷失在细节里。1.1 拆分Tomcat的两大核心板块Tomcat的架构可以用一句话概括连接器Connector负责网络通信容器Container负责处理业务。连接器监听端口、接收TCP连接、解析HTTP报文、构造Request和Response对象容器则负责管理Servlet生命周期、根据URL找到对应的Servlet并调用它。这两个板块之间通过HttpServletRequest和HttpServletResponse两个接口解耦这也是整个Java Web生态的基石。我们手写时不需要复制Tomcat的全部功能但这两个核心逻辑缺一不可。我给自己定了一个最小目标清单监听8080端口能接受浏览器的HTTP请求正确解析请求行、请求头和请求体抽象出自己的Request和Response对象实现一个简易Servlet接口支持init、service、destroy生命周期建立URL到Servlet的映射表实现请求分发支持静态资源HTML、CSS、JS的返回用线程池处理并发请求至于Session管理、JSP解析、管道阀门Pipeline-Valve机制、异步Servlet这些第一版全部砍掉。理由很直接它们都是建立在基础请求处理之上的上层建筑先把地基打牢后面想加哪块都容易。1.2 需要的工具与前置知识这个项目只需要JDK 8以上不需要任何第三方依赖。我对依赖的态度是越少越好——手写容器的目的就是摆脱框架如果还引一堆工具库就失去意义了。比如JSON解析不用Jackson请求体就是普通字符串自己split一下就行日志不用SLF4J直接System.out加时间戳。前置知识方面你需要具备三个基础Java网络编程知道ServerSocket和Socket的基本用法HTTP协议基础理解请求行、请求头、请求体的结构知道GET和POST的区别多线程基础了解Thread和ExecutorService的基本概念如果这些还不熟建议先花半小时把这三个点过一遍再来看代码不然会在细节里卡住。1.3 目录结构与设计思路我采用Maven标准目录结构包名就用com.demo.minitomcat整体设计照搬Tomcat的分层思路但不做过度设计minitomcat ├── pom.xml └── src/main/java └── com/demo/minitomcat ├── Bootstrap.java // 启动入口 ├── connector │ ├── HttpConnector.java // 连接器监听端口、接收请求 │ └── RequestProcessor.java // 请求处理任务 ├── container │ ├── ServletProcessor.java // 调用Servlet的核心逻辑 │ └── StaticResourceProcessor.java // 静态资源处理 ├── http │ ├── Request.java // 请求对象封装 │ └── Response.java // 响应对象封装 └── servlet ├── MinitServlet.java // 自定义Servlet接口 └── SimpleServlet.java // 测试用Servlet实现这个分包方式对应了Tomcat的源码结构思路connector包放网络层的东西container包放业务容器的东西http包放协议相关的数据对象。以后你去看Tomcat源码会发现它的org.apache.catalina.connector和org.apache.catalina.core也是类似的职责划分。2. 网络层ServerSocket监听与HTTP报文解析网络层是整个容器的最底层对应Tomcat的Coyote连接器。这一层要解决的问题是如何从TCP字节流里正确还原出HTTP请求。2.1 搭建基础的ServerSocket监听第一步代码很简单就是一个标准的TCP服务端套路绑定端口、循环调用accept()等待连接、为每个连接创建处理线程。Bootstrap类负责启动整个容器public class Bootstrap { public static void main(String[] args) throws IOException { HttpConnector connector new HttpConnector(); connector.start(); } }public class HttpConnector implements Runnable { private final int port 8080; private volatile boolean running true; public void start() { Thread thread new Thread(this, MiniTomcat-Connector); thread.start(); } Override public void run() { try (ServerSocket serverSocket new ServerSocket(port)) { System.out.println(MiniTomcat started on port port); while (running) { Socket socket serverSocket.accept(); // 每个连接交给一个独立线程处理 new Thread(new RequestProcessor(socket)).start(); } } catch (IOException e) { e.printStackTrace(); } } }这里有个新手容易忽略的点accept()是阻塞的它会一直等在那里直到有客户端连接进来才返回一个Socket对象。所以这个while (running)循环不能去掉否则程序会直接退出。使用try-with-resources关闭ServerSocket是必须的但注意不要在这里关掉整个连接器的线程——我最初犯过这个错把close()放在循环里结果连接完第一个请求后端口就被释放了。2.2 HTTP请求报文的完整解析RequestProcessor拿到Socket之后首先要从输入流里读数据。这里最关键的是理解HTTP请求的格式一个标准的GET请求长这样GET /hello?namezhangsan HTTP/1.1 Host: localhost:8080 Connection: keep-alive User-Agent: Mozilla/5.0 Accept: text/html Content-Length: 0请求行是第一行格式是方法 路径 协议版本以空格分隔。接着是若干请求头每行一个键值对。请求头和请求体之间有一个空行。POST请求的话空行后面就是请求体。解析的逻辑并不复杂但要小心边界处理public class Request { private String method; // GET / POST private String url; // 请求路径包含查询参数 private String protocol; // HTTP/1.1 private final MapString, String headers new HashMap(); private String body; // POST请求体 public Request(InputStream inputStream) throws IOException { BufferedReader reader new BufferedReader(new InputStreamReader(inputStream, UTF-8)); // 1. 解析请求行 String requestLine reader.readLine(); if (requestLine null || requestLine.isEmpty()) { return; } String[] parts requestLine.split( ); this.method parts[0]; this.url parts[1]; this.protocol parts[2]; // 2. 解析请求头 String line; while ((line reader.readLine()) ! null !line.isEmpty()) { int colonIndex line.indexOf(:); if (colonIndex 0) { String key line.substring(0, colonIndex).trim().toLowerCase(); String value line.substring(colonIndex 1).trim(); headers.put(key, value); } } // 3. 解析请求体如果有Content-Length String contentLength headers.get(content-length); if (contentLength ! null Integer.parseInt(contentLength) 0) { char[] bodyChars new char[Integer.parseInt(contentLength)]; reader.read(bodyChars); this.body new String(bodyChars); } } public String getParameter(String name) { // 从url的查询字符串中提取参数 if (url null || !url.contains(?)) { return null; } String queryString url.substring(url.indexOf(?) 1); for (String pair : queryString.split()) { String[] kv pair.split(); if (kv.length 2 kv[0].equals(name)) { return kv[1]; } } return null; } // getter方法省略 }这里有几个容易踩坑的地方我逐个说**第一不能用available()方法判断流结束。**很多初学者喜欢写while (inputStream.available() 0)来循环读数据这在网络流上是完全不可靠的。available()返回的是当前缓冲区里能读到的字节数不代表整个请求体的长度。正确做法是靠Content-Length头来确定请求体有多长读取指定数量的字节。第二请求头解析的循环边界条件。while ((line reader.readLine()) ! null !line.isEmpty())这个条件的意思是读到空行就停止。HTTP协议规定请求头结束的标志就是空行这个空行是\r\n但readLine()会把它过滤掉直接返回空字符串所以判断isEmpty()是对的。我在第一版代码里写成了line ! null结果把所有请求头都读完了还继续往下读把请求体也吞进了头部解析逻辑里。**第三不要阻塞等待请求体。**如果浏览器发了一个不带请求体的GET请求使用read()方法永远读不到数据。这也是为什么必须依赖Content-Length头来判断要不要读请求体。2.3 响应报文的构造有请求就有响应。HTTP响应的格式是状态行、响应头、空行、响应体。为了让浏览器正确显示中文Content-Type头必须带上字符集否则默认会用ISO-8859-1解码中文直接乱码。public class Response { private final OutputStream outputStream; private final String protocol; public Response(OutputStream outputStream, String protocol) { this.outputStream outputStream; this.protocol protocol; } public void send(String body, String contentType) throws IOException { StringBuilder sb new StringBuilder(); sb.append(protocol).append( 200 OK\r\n); sb.append(Content-Type: ).append(contentType).append(; charsetUTF-8\r\n); sb.append(Content-Length: ).append(body.getBytes(UTF-8).length).append(\r\n); sb.append(Connection: close\r\n); sb.append(\r\n); sb.append(body); outputStream.write(sb.toString().getBytes(UTF-8)); outputStream.flush(); } public void sendError(int code, String message) throws IOException { StringBuilder sb new StringBuilder(); sb.append(protocol).append( ).append(code).append( ).append(message).append(\r\n); sb.append(Content-Type: text/html; charsetUTF-8\r\n); sb.append(Content-Length: ).append(message.getBytes(UTF-8).length).append(\r\n); sb.append(\r\n); sb.append(h1).append(code).append( ).append(message).append(/h1); outputStream.write(sb.toString().getBytes(UTF-8)); outputStream.flush(); } }响应里最容易出错的是Content-Length。它必须跟实际写入的响应体字节数一致否则浏览器会一直等后面的数据。我特意用body.getBytes(UTF-8).length而不是body.length()因为中文字符在UTF-8编码下占3个字节但Java String的length()返回的是字符个数两者不一致。提示Connection: close这个头在开发阶段建议带上让每次请求处理完就关闭连接避免连接复用带来的各种状态残留问题。等你想做keep-alive优化时再去掉不迟。3. 容器层Request/Response抽象与Servlet映射网络层解决的是字节流变成对象的问题容器层解决的是对象如何驱动业务代码的问题。3.1 定义自己的Servlet接口Servlet规范的核心是一个接口容器负责实现网络层和对象管理业务开发者只需要实现这个接口里的方法。我定义的迷你版接口是这样的public interface MinitServlet { void init(); void service(Request request, Response response) throws IOException; void destroy(); }对比真实的javax.servlet.Servlet接口你会发现核心结构一致init在Servlet首次加载时调用一次service在每个请求到来时调用destroy在容器关闭时调用。真实规范里还有doGet、doPost的分派逻辑那是HttpServlet这个抽象类做的事我们现在简化到service里自己判断方法类型。这个接口的存在意味着什么意味着业务代码不再需要接触任何Socket、InputStream、OutputStream这些底层概念。开发者看到的只有两个对象Request请求数据和Response响应出口。这就是分层最大的价值网络细节被封装在容器内部业务代码保持干净。3.2 Servlet实例的加载与生命周期管理有了接口接下来要考虑的问题是Servlet实现类从哪里来配置怎么告诉容器哪个URL交给哪个Servlet最原始的做法是硬编码映射关系但既然要模仿Tomcat就应该做一个简易的配置机制。Tomcat用web.xml我先用一个简单的配置文件minitomcat.properties# URL映射规则/hellocom.demo.minitomcat.servlet.HelloServlet /hellocom.demo.minitomcat.servlet.HelloServlet /datecom.demo.minitomcat.servlet.DateServlet加载Servlet实例的代码本质上就是一个Class.forName加反射调用的过程public class ServletProcessor { private final MapString, MinitServlet servletInstances new ConcurrentHashMap(); private final MapString, String urlMappings new ConcurrentHashMap(); public void loadMappings(String configFile) throws IOException { Properties props new Properties(); try (InputStream in new FileInputStream(configFile)) { props.load(in); props.forEach((url, className) - urlMappings.put((String) url, (String) className) ); } } public MinitServlet getServlet(String url) throws Exception { // 先查缓存避免每个请求都反射创建新实例 if (servletInstances.containsKey(url)) { return servletInstances.get(url); } // 从映射表找到类名反射创建实例 String className urlMappings.get(url); if (className null) { return null; } Class? clazz Class.forName(className); MinitServlet servlet (MinitServlet) clazz.getDeclaredConstructor().newInstance(); servlet.init(); servletInstances.put(url, servlet); return servlet; } }这里有一个非常重要的设计决策Servlet实例是单例的同一个Servlet实例会被多个请求线程共享。这也是为什么Servlet规范强调不要在Servlet里用非线程安全的成员变量。我第一次写的时候没做缓存每个请求都new一个Servlet出来虽然功能上能跑但完全违背了Servlet的设计初衷。真实Tomcat里一个Servlet类默认只有一个实例所有请求共用。后来我专门写了一个带计数器的Servlet去验证这个行为开三个线程同时访问计数器从0加到3三个线程看到的是同一个变量。这个知识点面试的时候经常被问手写一遍印象完全不一样。3.3 请求分发的完整链路请求处理的核心逻辑在RequestProcessor里它的职责链是读取Socket流构造Request → 判断请求的是静态资源还是Servlet → 分别交给对应的处理器public class RequestProcessor implements Runnable { private final Socket socket; public RequestProcessor(Socket socket) { this.socket socket; } Override public void run() { try { Request request new Request(socket.getInputStream()); Response response new Response(socket.getOutputStream(), request.getProtocol()); if (request.getUrl() null) { socket.close(); return; } String path request.getUrl().contains(?) ? request.getUrl().substring(0, request.getUrl().indexOf(?)) : request.getUrl(); // 静态资源路径对应webroot目录下的文件 if (StaticResourceProcessor.isStaticResource(path)) { StaticResourceProcessor.process(path, response); } else { // 动态资源查找Servlet映射 MinitServlet servlet ServletProcessor.getInstance().getServlet(path); if (servlet ! null) { servlet.service(request, response); } else { response.sendError(404, Not Found); } } socket.close(); } catch (Exception e) { e.printStackTrace(); } finally { try { socket.close(); } catch (IOException ignored) { } } } }这个判断逻辑对应了Tomcat里的标准流程请求先进EngineEngine通过Host、Context逐级找到对应的WrapperWrapper调用Servlet的service方法。我们的迷你版省掉了Engine、Host、Context这些层级直接做了一次最简映射但静态资源走文件处理、动态资源走Servlet处理的路线跟Tomcat是一致的。4. 并发层线程模型演进与线程池改造如果你之前写过一个最简单的Socket服务器你可能会发现上面的代码虽然能处理请求但性能非常糟糕。问题出在HttpConnector的run方法里每来一个请求就new Thread(...).start()。4.1 线程频繁创建的代价每次请求都创建一个新线程这个方式在开发环境测试时感觉不到问题一旦拿压测工具模拟几十个并发请求就会暴露两个问题。第一线程创建和销毁的开销非常大。创建一个线程涉及操作系统分配内核资源、创建栈空间、注册调度器等操作一个普通的线程创建大概需要几十微秒到几百微秒在高并发下这些开销会被放大。第二线程数量不受控制。如果同时来了一万个请求就会创建一万个线程这个数字直接压垮操作系统的调度器。我实测过当同时请求数量超过几百时系统响应时间会急剧恶化甚至出现OutOfMemoryError。4.2 引入线程池解决方案就是复用线程。Tomcat本身也经历了类似演进早期的BIO模式下用线程池ThreadPoolExecutor管理连接处理线程后来引入了NIO模式进一步减少线程数量。我们写在第一版里直接用JDK的ThreadPoolExecutor实现一个最简的请求处理线程池public class HttpConnector implements Runnable { private final ExecutorService threadPool; public HttpConnector() { // 核心线程5个最大线程20个队列容量100 this.threadPool new ThreadPoolExecutor( 5, 20, 60, TimeUnit.SECONDS, new LinkedBlockingQueue(100), new ThreadFactory() { private final AtomicInteger count new AtomicInteger(1); Override public Thread newThread(Runnable r) { return new Thread(r, MiniTomcat-Worker- count.getAndIncrement()); } }, new ThreadPoolExecutor.CallerRunsPolicy() ); } Override public void run() { try (ServerSocket serverSocket new ServerSocket(port)) { while (running) { Socket socket serverSocket.accept(); threadPool.execute(new RequestProcessor(socket)); } } catch (IOException e) { e.printStackTrace(); } finally { threadPool.shutdown(); } } }这个线程池的每个参数选择都值得说清楚核心线程数设为5连接器本身还有另一个线程在跑accept()5个核心线程足够处理开发阶段的并发量。Tomcat默认的minSpareThreads是10我们这里小一点没关系。最大线程数设为20上限要设置不然线程池会无限创建线程。这里有100个排队任务的缓冲如果请求超过队列100 线程20的容量就会触发拒绝策略。队列用LinkedBlockingQueue(100)这是一个有界队列。Tomcat里对应的是maxConnections的概念防止突发流量直接击穿系统。拒绝策略用CallerRunsPolicy当队列满且线程数达到上限时新任务会直接由提交任务的线程也就是accept()所在线程来执行。这样做的效果是accept()被阻塞从而间接限制新连接的建立速度让系统有时间处理积压任务。这是最简单的流量控制手段。从单线程到多线程再到线程池这个演进过程值得亲手做一遍。你会很直观地感受到同样一个请求处理逻辑换了并发模型之后压测数据能差一个数量级。4.3 线程安全问题初现改成线程池之后容器层面的第一个线程安全bug出现了。原因是我在ServletProcessor里用普通的HashMap存Servlet实例缓存两个并发请求同时第一次访问同一个URL都发现缓存里没有实例然后同时反射创建了两个Servlet实例后创建的覆盖了先创建的导致其中一个请求调用的init()和service()不是同一个实例。解决方案有两种一是把HashMap换成ConcurrentHashMap并在创建实例时加锁保证只有一个线程执行创建逻辑二是直接用双检锁或者ConcurrentHashMap.computeIfAbsent()。我在代码里用了ConcurrentHashMap加computeIfAbsentpublic MinitServlet getServlet(String url) throws Exception { return servletInstances.computeIfAbsent(url, u - { try { String className urlMappings.get(u); Class? clazz Class.forName(className); MinitServlet servlet (MinitServlet) clazz.getDeclaredConstructor().newInstance(); servlet.init(); return servlet; } catch (Exception e) { throw new RuntimeException(e); } }); }这个坑让我意识到只要从单线程切到多线程很多以前没问题的代码全会出问题。手写容器最大的价值之一就是让你在真实场景里踩一遍并发的坑比背十遍线程安全理论都管用。5. 进阶层类加载器与配置化改造请求处理链路跑通之后我已经有一个能用的迷你Tomcat了。但有两个问题始终让我不舒服一是Servlet类都在classpath里跟容器代码混在一起二是每次改映射关系都要改配置文件然后重启。这两个问题分别对应Tomcat的两个经典机制Web应用类加载器和Context容器。5.1 最简单的Web应用类加载器Tomcat一个非常核心的设计是每个Web应用一个类加载器。这样做的目的是隔离不同应用可以依赖同一个库的不同版本互不干扰同时应用可以加载自己WEB-INF/classes目录下的类而不影响容器自身的类。迷你版不需要做到完整隔离但至少要实现从指定目录加载Servlet类的能力。做法是写一个自定义的URLClassLoader把webapps目录加进加载路径public class WebappClassLoader extends URLClassLoader { public WebappClassLoader(String webappDir) throws MalformedURLException { super(new URL[]{ new File(webappDir, classes).toURI().toURL(), new File(webappDir, lib).toURI().toURL() }, Thread.currentThread().getContextClassLoader()); } Override public Class? loadClass(String name) throws ClassNotFoundException { // 容器核心类交给父加载器应用类自己加载 synchronized (getClassLoadingLock(name)) { Class? clazz findLoadedClass(name); if (clazz null) { if (name.startsWith(com.demo.minitomcat)) { clazz super.loadClass(name); } else { try { clazz findClass(name); } catch (ClassNotFoundException e) { clazz super.loadClass(name); } } } return clazz; } } }这个类加载器的核心逻辑是双亲委派和逆向委派并存容器自身的类要交给应用类加载器的父加载器去加载避免重复加载应用类则优先自己从webapps目录加载。Tomcat的真实现比这个复杂得多涉及findClass的缓存、JAR包索引、热部署时的销毁重建等但核心思路是一致的。5.2 生命周期管理与优雅关闭一个容器除了能启动还要能优雅关闭。Tomcat的组件都实现了Lifecycle接口有init、start、stop、destroy四个阶段。迷你版不需要那么复杂的状态机但至少要做到关闭时能把线程池停掉、把已经创建的Servlet的destroy方法调用一遍、把Socket资源释放干净。我在HttpConnector里增加了一个stop()方法public void stop() { running false; threadPool.shutdown(); try { if (!threadPool.awaitTermination(5, TimeUnit.SECONDS)) { threadPool.shutdownNow(); } } catch (InterruptedException e) { threadPool.shutdownNow(); } }然后ServletProcessor里加一个destroyAll()循环调用所有已加载Servlet的destroy()。这两个方法配合JVM的ShutdownHook就能实现按CtrlC时的平滑退出Runtime.getRuntime().addShutdownHook(new Thread(() - { System.out.println(Shutting down MiniTomcat...); connector.stop(); ServletProcessor.getInstance().destroyAll(); }));优雅关闭这件事开发环境很容易忽略但放到生产环境就是大事。如果直接kill -9正在处理的请求会被硬生生切断数据库连接池里的连接也可能泄漏。虽然迷你容器不需要那么严谨但通过这个设计能理解Tomcat为什么要煞费苦心设计一套生命周期管理机制。5.3 配置化的成熟度提升配置文件方面我最初用properties文件做URL映射后来参考web.xml的设计改成了XML风格因为在表达复杂的映射规则时XML的结构化能力更强。一个简化版web.xml长这样?xml version1.0 encodingUTF-8? web-app servlet servlet-namehelloServlet/servlet-name servlet-classcom.demo.minitomcat.servlet.HelloServlet/servlet-class /servlet servlet-mapping servlet-namehelloServlet/servlet-name url-pattern/hello/url-pattern /servlet-mapping /web-app解析这个XML需要引入DOM解析的代码。如果不想引第三方库可以用JDK内置的DocumentBuilderFactory五十行左右就能把servlet-name到servlet-class、url-pattern到servlet-name两组映射解析出来。这一步做完你的迷你容器就从写死的代码变成了配置驱动的容器形态上和Tomcat更接近了。6. 实测验证与踩坑记录把迷你Tomcat跑在真实环境代码写完之后最大的问题是它真的能像Tomcat一样工作吗我决定用真实场景做一轮完整验证这轮验证也确实暴露了好几个只有实测才能发现的bug。6.1 第一个测试浏览器直接访问我写了一个HelloServletservice方法里直接输出一段HTMLpublic class HelloServlet implements MinitServlet { Override public void init() { System.out.println(HelloServlet init...); } Override public void service(Request request, Response response) throws IOException { String name request.getParameter(name); String message (name null || name.isEmpty()) ? World : name; response.send(htmlbodyh1Hello, message !/h1/body/html, text/html); } Override public void destroy() { System.out.println(HelloServlet destroy...); } }启动Bootstrap浏览器访问http://localhost:8080/hello?namezhangsan。第一次看到浏览器正常渲染出Hello, zhangsan的时候那种感觉跟当年第一次跑通Spring Boot完全不一样——因为这一次从端口监听到HTTP解析到请求分发到Servlet调用每一行代码都是自己写的。6.2 第二个测试并发压测用Apache Benchab工具做了简单的并发测试命令是ab -n 1000 -c 50 http://localhost:8080/hello。第一次跑完结果里出现了大量非200状态码的请求。排查后发现是Request解析时没有处理请求头里的Accept-Encoding: gzip——浏览器或压测工具发的请求头里带了压缩协商字段我第一版代码没有做任何过滤但也不至于报错。真正的问题出在Content-Length计算上。我在响应里写的是sb.append(Content-Length: ).append(body.getBytes().length).append(\r\n);问题在于body.getBytes()用了默认编码。如果服务器环境的默认编码不是UTF-8比如Windows中文环境是GBK那中文响应体的字节数就会被算错。我暂时改成显式指定UTF-8解决了但更深层的教训是写网络协议相关的代码永远不要依赖运行环境的默认编码所有编码转换都要显式声明。这也解释了为什么Tomcat的Connector里有URIEncoding这个专门配置。编码问题类的问题具体表象如下症状可能原因解决方式中文乱码响应头未声明charsetUTF-8在Content-Type里加; charsetUTF-8响应体不完整Content-Length小于实际字节数用getBytes(UTF-8).length计算长度浏览器一直转圈Content-Length大于实际字节数同左必须精确计算字节长度请求乱码URL参数编码与解码不一致URL参数建议用UTF-8编码传输6.3 第三个测试POST请求与请求体用Postman发了一个POST请求Content-Type: application/x-www-form-urlencodedbody是usernameadminpassword123。解析请求体的代码在本地测试正常但放到Linux服务器上部署测试时出现了请求体读取不完整的问题。排查了一个小时最后发现是BufferedReader的read(char[], int, int)方法的问题。它是一个尽量读取的方法一次调用可能只读部分数据。当请求体数据较大且网络分片时一次read不一定能读满Content-Length的字节数。正确做法是循环读取直到累计读过指定长度的字节数if (contentLength ! null Integer.parseInt(contentLength) 0) { int length Integer.parseInt(contentLength); char[] bodyChars new char[length]; int totalRead 0; while (totalRead length) { int read reader.read(bodyChars, totalRead, length - totalRead); if (read -1) { break; } totalRead read; } this.body new String(bodyChars, 0, totalRead); }这个坑非常典型IO流里read()的语义是最多读取指定的字节数而不是保证读满。文件IO通常一次能读满但网络IO因为数据分片极大概率读不满。这也是为什么Netty里会有ByteToMessageDecoder专门做粘包拆包处理。6.4 踩坑之外的收获调试Tomcat内部手写容器还有一个隐形收益以后遇到Tomcat报错能更快定位问题。比如那个常见的报错源服务器未能找到目标资源的表示404以前我只能猜测是URL写错了或者文件没放对位置。现在我知道容器查找Servlet映射的完整路径首先会检查web.xml里的url-pattern和实际访问路径是否完全匹配其次检查servlet-class是否写了完整的包名类名再检查类是否真的编译到了WEB-INF/classes目录下。还有那个经典的Tomcat启动闪退问题。如果你手写过一遍Bootstrap就会知道闪退的常见原因是端口被占用和找不到启动类排查思路会很自然地从双击startup.bat弹出黑框又消失变成用catalina.bat run在前台运行看日志。7. 后续还能扩展的方向四百行代码完成之后这个迷你容器已经可以作为一个教学项目存在了。如果你也想继续扩展我列几个性价比比较高的方向也说说我自己的扩展优先级Session管理用Cookie里的JSESSIONID做会话跟踪实现一个简单的HttpSession内存存储。这个功能最贴近日常开发做一遍能理解的很多坑。管道机制在Servlet调用前后加过滤器链模拟Tomcat的Filter。这个涉及责任链设计模式扩展完你的设计模式理解会上一个台阶。热部署监听webapps目录里的文件变化触发类加载器的销毁和重建。这个功能做完你就明白为什么生产环境改代码要重启Tomcat也明白热部署的原理和风险。NIO改造把BIO换成一主多从的Reactor模型。这是Tomcat从BIO升级到NIO的核心路径也是理解现代网络框架的跳板。我个人最推荐先做Session管理因为它是普通Web开发里最常接触的容器能力做完之后你会彻底明白Session为什么不能跨服务共享这个分布式开发的基础问题。手写一个Tomcat框架这件事技术上并不复杂难的是把每一步的为什么想清楚。当你亲手写完连接器、容器、Servlet映射和线程池之后再回头看Tomcat那几万行源码你会发现自己已经能读懂它的主干路径了不会再被庞大的架构吓退。如果这篇文章能帮你把Tomcat从黑盒变成白盒那我觉得这几百行代码就没白写。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询