去年我用两个周末,从零手写了一个能跑静态页面、能部署Servlet的迷你Tomcat。做完那一刻再回头看那些“IDEA配置Tomcat”“VSCode配置Tomcat”的教程,才终于明白一个道理:大多数配置教程只是在教你怎么当一个操作员,而手写一遍Tomcat,才能让你真正拥有回答“Tomcat干嘛的”的底气。
这个项目最大的收获不是代码量,而是把Tomcat这层“中间件黑盒”彻底拆开了。你日常部署war包、改端口、配web.xml时遇到的每一个诡异问题,追到根上基本都逃不出这几件事:HTTP协议报文怎么解析、Servlet类从哪里加载、请求映射到哪个方法、连接怎么复用。如果你正在学Java Web底层、准备面试被问Servlet容器原理,或者每次配Tomcat总遇到似懂非懂的报错,我强烈建议跟我一样,动手写一个简化版。
1. 网上一堆Tomcat配置教程,我为什么反而选择手写一遍
1.1 在回答“Tomcat干嘛的”之前,先别急着搜配置教程
先给结论:Tomcat不只是一个“能跑Java Web应用的软件”,它本质上是两个东西叠在一起——一个HTTP服务器,一个Servlet容器。
HTTP服务器这层,负责监听Tomcat端口(默认8080),把浏览器发过来的TCP字节流解析成符合HTTP协议的请求,把服务器返回的数据封装成HTTP响应写回Socket。Servlet容器这层,负责加载你写的Servlet类、解析web.xml里的映射配置、按请求路径找到对应的类并调用它的service/doGet/doPost方法,然后把结果交回HTTP服务器层。
很多开发干了几年,每天在用Tomcat,却说不出这两个层次。你问“Tomcat干嘛的”,他们只能回答“部署war包的”。这不是他们的锅,因为日常开发里Tomcat被IDE藏得太好了:点一下启动按钮,日志一刷,应用就起来了,中间发生了什么完全不可见。手写一遍,就是强制自己把这层包装撕掉,去看真正的报文、真正的类加载、真正的Socket读写。
我给自己定的目标是:写一个MiniTomcat,能做到——启动后监听指定端口;访问静态HTML和图片能正确返回;能解析web.xml;能加载并调用Servlet;能模拟Tomcat的类加载器隔离机制。难度不大,但每一步都要亲手造轮子,这比读十遍源码都有用。
1.2 一个人手写Tomcat,需要先划清三大边界
写之前最容易犯的错误是“什么都要做”。Session、Filter、WebSocket、HTTPS、NIO、War热部署……如果第一版就把这些全塞进去,你会陷入无穷无尽的细节,最后什么都写不完。
我划分的边界是:第一版只做“通信与协议层”,也就是Socket监听、HTTP请求解析、HTTP响应封装;第二层做“容器与路由层”,也就是Servlet映射、反射调用、静态资源分发;第三层做“类加载与部署层”,也就是WebAppClassLoader、web.xml加载、热部署的简化实现。
这三层恰好对应Tomcat源码里的Connector和Container两大核心组件,也对应JVM类加载体系。先跑通这三层,MiniTomcat已经能回答“Tomcat启动后发生了什么”这个问题。Session、Filter这些后面想要,都是在Servlet调用链上加拦截器或上下文对象,不是第一版的核心矛盾。
2. 动手前的关键决策:把功能拆成Connector、Container和Loader三块
2.1 功能模块拆解:谁来监听端口,谁来处理请求,谁来加载类
我设计的第一版类结构非常简单,总共分四组,互相之间的依赖关系也清晰:
| 模块 | 职责 | 核心类 |
|---|---|---|
| 启动入口 | 初始化配置、绑定端口、开启事件循环 | Bootstrap |
| Connector层 | 把Socket字节流转成HttpRequest对象,把处理结果写回Socket | HttpRequest、HttpResponse、RequestParser |
| Container层 | 判断请求是静态资源还是Servlet,分别处理 | StaticResourceProcessor、ServletProcessor |
| Loader层 | 解析web.xml、加载Servlet类、实现类加载隔离 | WebConfig、WebAppClassLoader |
这个分组其实就是Tomcat源码结构的缩小版。Tomcat里Connector负责协议解析和网络通信,Container负责调用链处理,WebappClassLoader负责应用隔离。我不需要引入Spring那套容器概念,一个请求从Socket进来,落到对应的Processor上,就已经能解释Tomcat八成的运行逻辑了。
2.2 工程骨架:哪些类放在哪里
我建议工程结构长这样:
mini-tomcat/ ├── src/main/java/com/example/minitomcat/ │ ├── Bootstrap.java │ ├── connector/ │ │ ├── HttpRequest.java │ │ ├── HttpResponse.java │ │ └── RequestParser.java │ ├── container/ │ │ ├── StaticResourceProcessor.java │ │ └── ServletProcessor.java │ └── loader/ │ ├── WebAppClassLoader.java │ └── WebConfig.java └── webroot/ ├── conf/web.xml ├── classes/ └── static/Bootstrap是入口,类似于Tomcat的catalina.sh脚本加上Server组件。webroot是应用根目录,里面三个子目录分别放web.xml、Servlet编译后的class文件、静态资源。这里有一个容易被忽略的设计点:webroot一定要做成可以外部配置的参数,而不是在代码里写死“D:/webroot”。Tomcat之所以有CATALINA_HOME和webapps的区分,就是因为容器安装目录和部署目录要解耦,手写版哪怕只有两个参数,也要把这个习惯保留下来。
2.3 为什么第一版不做Session和Filter
我在设计时故意把Session、Filter、Listener从第一版里砍掉了,只保留了Servlet调用链。原因很实际:滤器的核心是在Servlet前后插入一段代码,Session的核心是在请求里维护一个客户端标识,它们在Tomcat架构里属于Context组件上的附属设施,而不是地基。真正的地基是“Socket变成请求对象,请求路径变成类调用”这条主线。如果第一版就纠缠Session的过期策略、Cookie写到哪个域,你会烦躁到怀疑人生。先把地基夯好,Filter和Session都是往调用链上挂一段逻辑而已。
3. 请求解析:拿Socket流还原出真实的HTTP请求
3.1 HttpRequest解析:请求行、请求头、请求体
Tomcat处理请求的第一件事,就是把Socket输入流里的字节还原成一个结构化对象。HTTP请求文本的格式非常固定,依次是请求行、多个请求头、空行、请求体。比如浏览器访问http://localhost:8080/hello时,发出的原始请求长这样:
GET /hello HTTP/1.1 Host: localhost:8080 User-Agent: Mozilla/5.0 Accept: text/html最后那个空行是HTTP协议里请求头和请求体的分隔标志,解析任何HTTP报文都要靠它切分阶段。我写了RequestParser做这件事,核心解析函数如下:
public void parse() throws IOException { parseRequestLine(); parseHeaders(); parseBody(); } private void parseRequestLine() throws IOException { String line = readLine(); if (line == null || line.isEmpty()) { throw new IOException("empty request line"); } String[] parts = line.split(" "); this.method = parts[0]; String fullPath = parts[1]; int idx = fullPath.indexOf('?'); if (idx >= 0) { this.uri = fullPath.substring(0, idx); this.queryString = fullPath.substring(idx + 1); } else { this.uri = fullPath; } } private void parseHeaders() throws IOException { String line; while ((line = readLine()) != null && !line.isEmpty()) { int colon = line.indexOf(':'); if (colon > 0) { headers.put(line.substring(0, colon).trim().toLowerCase(), line.substring(colon + 1).trim()); } } } private void parseBody() throws IOException { String len = headers.get("content-length"); if (len != null) { int length = Integer.parseInt(len); body = new byte[length]; int read = 0; while (read < length) { int n = input.read(body, read, length - read); if (n == -1) break; read += n; } } }这里有一个拿捏细节:注释中的Host头在许多HTTP/1.1请求里是必需的,Tomcat处理虚拟主机时要靠它区分站点。手写版即使不实现虚拟主机,也应当在解析请求头时统一把小写化的Header名存进Map,否则后面想加功能还得回头改。
3.2 为什么手写解析时要避开BufferedReader
这是我自己踩过的一个大坑。刚开始我用BufferedReader.readLine()逐行读请求头和请求行,看着很省事,但一旦请求里有POST请求体,就会出现数据丢失或阻塞。
原因很简单:BufferedReader内部维护了一个8KB缓冲区,读第一行时它可能已经把后面的请求头甚至请求体全部读进了缓冲区。你再去从Socket的InputStream里读body时,数据已经被BufferedReader截走了,读到的是null或者残缺数据。
Tomcat底层的Connector之所以写得很复杂,一个重要原因就是在字节层面精确控制“哪些字节属于请求头,哪些字节属于请求体”,绝对不能依赖带缓冲的Reader越界读字节。所以我后来改成了逐字节手动读取:
private String readLine() throws IOException { ByteArrayOutputStream bos = new ByteArrayOutputStream(); int ch; while ((ch = input.read()) != -1) { if (ch == '\r') { int next = input.read(); if (next == '\n' || next == -1) { break; } bos.write(ch); bos.write(next); } else if (ch == '\n') { break; } else { bos.write(ch); } } return bos.toString(StandardCharsets.ISO_8859_1); }注意这里用了ISO_8859_1而不是UTF-8。请求行和请求头里的ASCII字符用Latin-1解码是最安全的,因为后续URL解码和请求体解码可以按具体字符集处理。一旦这里用了UTF-8,遇到某些特殊字节会产生奇奇怪怪的乱码。
3.3 HttpResponse:把状态行、Content-Length和响应体写对
请求解析完,就得设计响应对象。最基础的HTTP响应用几行就能说明白:
HTTP/1.1 200 OK Content-Type: text/html; charset=UTF-8 Content-Length: 55 <html><body><h1>Welcome</h1></body></html>第一行是状态行,包含协议版本、状态码、原因短语。请求头里最关键的是Content-Length,它告诉浏览器响应体有多少字节。很多人手写HTTP响应的时候会漏掉它,结果浏览器一直转圈,因为不知道响应体在哪里结束。
我的HttpResponse封装了下面的核心能力:
public void sendError(int status, String reason) throws IOException { setBody("<html><body><h1>" + status + " " + reason + "</h1></body></html>"); } public void setBody(String content) throws IOException { byte[] data = content.getBytes(StandardCharsets.UTF_8); OutputStream out = socket.getOutputStream(); out.write(("HTTP/1.1 " + status + " " + reason + "\r\n").getBytes(StandardCharsets.ISO_8859_1)); out.write(("Content-Type: " + contentType + "; charset=UTF-8\r\n").getBytes(StandardCharsets.ISO_8859_1)); out.write(("Content-Length: " + data.length + "\r\n").getBytes(StandardCharsets.ISO_8859_1)); out.write("\r\n".getBytes(StandardCharsets.ISO_8859_1)); out.write(data); out.flush(); }注意两个细节:一是\r\n绝不能只写\n,HTTP协议规定行分隔符是CRLF,很多客户端和网关对裸换行非常敏感;二是所有文本统一先转字节再写OutputStream,不要用Writer去写,否则字符编码会截断字节。
3.4 静态资源解析与路径穿越拦截
静态资源是最容易先跑通的部分。StaticResourceProcessor做的事很简单:把URI映射到webroot/static目录下的文件,存在的文件返回内容,不存在的返回404。但这里必须加一个安全检查——过滤..路径穿越。如果不拦截,用户请求/../conf/web.xml就能直接读到你的配置文件,这在真实Tomcat里同样是禁忌。
我写了一个最小检查:
public void process(HttpRequest request, HttpResponse response) throws IOException { String path = request.getUri(); if (path.contains("..")) { response.sendError(403, "Forbidden"); return; } File file = new File(WebConfig.getWebRoot(), "static" + path); if (!file.exists()) { response.sendError(404, "Not Found: " + path); return; } response.setContentType(Files.probeContentType(file.toPath())); response.writeFile(file); }另外,静态资源的Content-Type在真实环境里要求很严格,text/html、application/javascript、image/png必须正确设置,否则浏览器会乱码或拒绝执行脚本。Java的Files.probeContentType可能返回null,手写版建议维护一个简单的扩展名映射表兜底。
4. Servlet映射与调用:反射拿到类,执行完整的Servlet生命周期
4.1 用web.xml描述路由,容器按图找类
真实Tomcat通过web.xml把URL路径和Servlet类绑定起来。手写版我直接用JDK内置的DOM解析,不需要引入第三方库。最简web.xml长这样:
<?xml version="1.0" encoding="UTF-8"?> <web-app> <servlet> <servlet-name>hello</servlet-name> <servlet-class>com.demo.HelloServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>hello</servlet-name> <url-pattern>/hello</url-pattern> </servlet-mapping> </web-app>WebConfig解析该文件时,先建立servlet-name到servlet-class的映射,再建立url-pattern到servlet-name的映射,最终得到一个Map<String, String>:路径到类名。
这里我要特别提一下路径匹配的优先级。真实Tomcat的url-pattern匹配规则很复杂,支持精确匹配、前缀匹配(/hello/*)、扩展名匹配(*.do),精确匹配优先级最高。手写版第一版只支持精确匹配是合理的,但设计Map数据结构时就要把“路径可能带通配符”考虑进去,不要等第二版重构结构。
4.2 ServletProcessor:反射实例化与service调用
我现在定义一个极简Servlet接口,避免引入完整Servlet API的依赖:
public interface MiniServlet { void service(HttpRequest request, HttpResponse response) throws IOException; }ServletProcessor拿到路径后,从WebConfig找Servlet类名,再加载、实例化、调用service。关键点在于加载这一步必须用WebAppClassLoader而不是默认的应用类加载器,这是整个手写项目里最贴近Tomcat灵魂的地方:
public class ServletProcessor { public void process(HttpRequest request, HttpResponse response) throws Exception { String path = request.getUri(); String className = WebConfig.findServletClass(path); if (className == null) { response.sendError(404, "No servlet mapped for: " + path); return; } MiniServlet servlet = WebConfig.getServletInstance(className); servlet.service(request, response); } }WebConfig内部维护一个Map<String, MiniServlet>实例缓存,同一个Servlet类只加载一次、只实例化一次。这和Tomcat的行为一致:Servlet默认是单实例多线程模型,所有请求共享同一个Servlet对象。而线程安全问题自然降临到Servlet开发者身上,容器不负责加锁。
反射创建实例的代码比较简单:
public static MiniServlet loadServlet(String className) throws Exception { WebAppClassLoader loader = new WebAppClassLoader(WebConfig.getWebRoot(), Thread.currentThread().getContextClassLoader()); Class<?> clazz = loader.loadClass(className); Object instance = clazz.getDeclaredConstructor().newInstance(); if (!(instance instanceof MiniServlet)) { throw new IllegalArgumentException(className + " is not a MiniServlet"); } return (MiniServlet) instance; }这个getDeclaredConstructor().newInstance()看似不起眼,实际上对应Tomcat里ClassLoader加载完成后、容器调用无参构造器创建Servlet实例的过程。如果类构造函数抛异常,或者类没有无参构造器,这里的异常链会非常长,排查时要从根因看Caused by,而不是盯着最外层。
4.3 生命周期、异常与500响应
Tomcat里Servlet有完整生命周期:init初始化、service处理请求、destroy销毁。手写版我在加载实例后立刻调用一次init(),虽然接口里没体现,但在实现类里可以约定:如果需要初始化资源,就重写init()。
还有一个非常容易忽略的异常处理分支:Servlet的service方法可能抛异常,尤其是数据库连接超时、空指针这类。此时容器不能把异常原样吞掉,也不应该把堆栈打到控制台就完事,而要给客户端一个明确的500响应。我加了一个简单的兜底:
try { servlet.service(request, response); } catch (Exception e) { response.sendError(500, "Internal Server Error: " + e.getMessage()); }这段代码虽然少,但代表了Tomcat里StandardWrapperValve处理异常的思路:容器负责捕获异常,并转换为标准错误响应,Servlet开发者不需要在业务代码里到处处理HTTP状态码。
5. 类加载器:手写版也要把双亲委派机制拆了重装
5.1 双亲委派机制:JVM为什么要父优先
这是整个手写Tomcat里面试最爱考的点,也是很多人看源码时最难理解的一环。先说JVM默认的双亲委派机制:当应用类加载器(AppClassLoader)需要加载一个类时,它不先自己找,而是先委托给父加载器——扩展类加载器(JDK 9之后叫PlatformClassLoader),扩展类加载器再委托给启动类加载器(Bootstrap ClassLoader)。父加载器加载不了,才轮到子加载器自己找。
这个机制的安全意义在于:确保核心类库永远是同一个版本。比如java.lang.String只能由启动类加载器加载,即使你在自己的classpath里塞一个同名同包名的伪造类,双亲委派也会阻止它进入JVM,从而防止核心API被篡改。
5.2 Tomcat为什么反着来:先看本地类,再找父加载器
Tomcat的WebappClassLoader却把默认逻辑倒过来了:加载一个类时,先在自己负责的类路径里找,也就是应用目录下的WEB-INF/classes和WEB-INF/lib;本地找不到,才委托给父加载器。
为什么要这么做?因为一个JVM里可能同时跑多个Web应用,每个应用可以依赖不同版本的Spring、MyBatis。如果用默认的双亲委派,Spring的类一旦被父加载器加载,所有应用就共享同一个版本,没法做应用隔离。更麻烦的是,类一但被加载后很难卸载,热部署时旧版类会一直杵在JVM里。
手写MiniTomcat的WebAppClassLoader,核心代码其实很短:
public class WebAppClassLoader extends ClassLoader { private final Path webAppRoot; public WebAppClassLoader(String webAppRoot, ClassLoader parent) { super(parent); this.webAppRoot = Paths.get(webAppRoot); } @Override protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { Class<?> loaded = findLoadedClass(name); if (loaded != null) { return loaded; } if (name.startsWith("java.") || name.startsWith("javax.")) { return super.loadClass(name, resolve); } try { Path classFile = webAppRoot.resolve("classes") .resolve(name.replace('.', '/') + ".class"); byte[] data = Files.readAllBytes(classFile); loaded = defineClass(name, data, 0, data.length); if (resolve) { resolveClass(loaded); } return loaded; } catch (IOException e) { return super.loadClass(name, resolve); } } } }注意这里的两个关键动作:java.*和javax.*开头的类必须强制交给父加载器,绝对不能本地优先,否则你自己写的javax.servlet.Servlet可能破坏整个体系;本地找不到类时,最后调super.loadClass走默认双亲委派,保证容器自身的类(比如MiniServlet接口)还是由系统类加载器加载。
5.3 热部署原理:换一个ClassLoader的生命周期
热部署为什么一定要和类加载器绑在一起?因为JVM不允许在运行时替换一个已被加载的类,但允许你新建一个ClassLoader,用新的ClassLoader重新加载同名类。旧的ClassLoader和它加载的类对象,只要没有外部引用,就会被GC回收。Tomcat的reloadable="true"就是这样一个循环过程:监控WEB-INF/classes和WEB-INF/lib里的文件时间戳,发现有变化,就把当前Web应用上下文整个停掉,创建新的WebappClassLoader,重新加载所有类。
手写版的简化实现可以做得很直接:
if (classFile.lastModified() > loadedTime) { WebAppClassLoader newLoader = new WebAppClassLoader(webRoot, parentClassLoader); WebConfig.clearServletCache(); WebConfig.setClassLoader(newLoader); }这里踩过的坑是:热部署时如果旧的Servlet实例还被全局Map引用,新Loader和旧Loader的类就同时存在于JVM里,容易造成ClassCastException。所以换Loader的同时必须清空实例缓存,Tomcat重启Context也是同样的道理——不是JVM重启,而是应用级的状态全部清空重建。
6. 启动与排查:手写容器第一次跑起来,哪些坑必须先知道
6.1 Bootstrap的启动流程:端口、根目录、事件循环
Bootstrap的main方法其实相当朴素:
public class Bootstrap { public static void main(String[] args) throws Exception { int port = args.length > 0 ? Integer.parseInt(args[0]) : 8080; WebConfig.init(Paths.get("webroot")); ExecutorService pool = Executors.newFixedThreadPool(16); try (ServerSocket serverSocket = new ServerSocket(port)) { while (!Thread.currentThread().isInterrupted()) { Socket socket = serverSocket.accept(); pool.submit(() -> handle(socket)); } } } private static void handle(Socket socket) { try (socket) { HttpRequest request = new HttpRequest(socket.getInputStream()); request.parse(); HttpResponse response = new HttpResponse(socket); if (request.getUri().startsWith("/servlet/")) { new ServletProcessor().process(request, response); } else { new StaticResourceProcessor().process(request, response); } } catch (IOException e) { // 客户端断开等场景,忽略并关闭连接即可 } } }这段代码已经能体现Tomcat启动的核心流程:解析配置、绑定端口、循环接收连接、将连接交给处理器。你在IDEA里配置Tomcat时填的port、deployment路径,本质上就是告诉这个启动类:端口是多少,webroot在哪。手写版跑通之后,再回去看那些配置界面,你会觉得每个选项都极其熟悉。
6.2 启动阶段三类报错及排查链路
我实际运行mini-tomcat时,第一天就撞了三堵墙,每一堵都很有代表性:
第一类是端口冲突。启动时报BindException: Address already in use,通常就是8080被其他进程占了。排查命令建议直接背下来:Windows用netstat -ano | findstr :8080,Linux用ss -lntp | grep 8080,macOS用lsof -i :8080。找到PID后,要么杀掉占用进程,要么换一个端口启动。
第二类是静态资源404。这个问题几乎都是webroot路径不对导致的。我一开始把静态资源放在了工程根目录下的static,但Bootstrap启动时的工作目录如果是另一个模块,Paths.get("webroot")就找不到。解决方法是把webroot作为启动参数传进来,并且用绝对路径验证:
Path root = Paths.get("webroot").toAbsolutePath(); if (!Files.isDirectory(root)) { throw new IllegalStateException("webroot not found: " + root); }第三类是ClassNotFoundException。这锅甩给JDK之前先检查两件事:web.xml里的servlet-class类名是不是全限定名,以及class文件是否真的存在于webroot/classes对应的包路径下。注意,用自定义WebAppClassLoader时,类文件的位置不是看classpath,而是看Loader里定义的webAppRoot/classes,这两个概念一开始非常容易混淆。
6.3 高版本JDK的隐藏坑
最近几个JDK大版本(包括我写的JDK 17、21,以及更往后的版本)在模块化上做得越来越严格,Socket和ServerSocket这套API倒是没变,但反射访问私有成员的限制触发了很多旧中间件的问题。以前很多框架靠setAccessible(true)强行突破封装,高版本JDK会输出类似“Illegal reflective access operation”的警告甚至直接抛异常。
手写Tomcat时我建议从现在开始就养成一个习惯:ServletRequest、Servlet响应这类对象里不需要用反射去拿私有字段,尽量走公开接口。如果实在要反射,启动时准备好--add-opens java.base/java.lang=ALL-UNNAMED这类参数,但能避开就避开。这不是让你抵制新版本,而是旧习惯在JDK高版本下已经不值钱了。
7. 并发接入与后台安全:从单线程到线程池,再到上传war被限制IP
7.1 从单线程到线程池:别让一个慢请求堵死全局
第一版我偷懒,用单线程for循环处理每个Socket,结果发现一个明显问题:如果一个请求处理耗时很久,比如Servlet里去访问了一个慢数据库,后面的请求全部排队,整个服务相当于卡死。
后来改成每连接一线程,问题又来了:线程数量没有上限,几百个并发就把内存打爆。最终采用固定大小线程池,也就是你在Bootstrap里看到的Executors.newFixedThreadPool(16)。Tomcat真正生产环境的线程池参数更复杂:要配核心线程数、最大线程数、队列容量、拒绝策略。手写版用固定线程池虽然粗糙,但足够说明“连接接入和业务处理要解耦”这个核心思想。
这里有个值得注意的小知识:线程池不是越大越好。每个线程要占约1MB左右的栈空间,如果TCP连接都是长连接,线程池会被根本不发请求的空闲连接长期占满。这也是Tomcat后来转向NIO的重要原因——用更少的线程处理更多的并发连接。
7.2 HTTP/1.1与keep-alive:线程复用的两面
HTTP/1.1默认开启keep-alive,意思是同一个TCP连接可以连续发多个HTTP请求,不用每次请求都重新建立连接。手写版为了简单直接处理完一个请求就关闭Socket也能跑,但如果你想往上靠,就得在循环里连续解析请求:
boolean keepAlive = true; while (keepAlive && !socket.isClosed()) { HttpRequest request = new HttpRequest(socket.getInputStream()); request.parse(); HttpResponse response = new HttpResponse(socket); process(request, response); response.flush(); keepAlive = "keep-alive".equalsIgnoreCase(request.getHeader("connection")); }这个循环看起来简单,它埋了一个隐患:如果客户端连接保活但迟迟不发下一个请求,服务端线程会阻塞在readLine()上,大量keep-alive空闲连接会白白占住线程资源。Tomcat 8之后默认用NIO,核心就是解决“线程阻塞在空连接上”的问题。手写版你能把这个矛盾亲手复现一遍,再去看NIO和Reactor模型,理解会完全不同。
7.3 上传war被限制IP:后台管理权限的最小实现
顺便聊聊网上经常搜到的问题:“tomcat后台页面上传war被限制ip”。这个现象根因是Tomcat默认的manager应用出于安全考虑,只允许本机访问。它通过Context里的Valve做IP过滤,配置文件里常看到RemoteAddrValve或者RemoteHostValve,只放行127\.0\.0\.1或者内网IP段。所以你在本地访问manager通常正常,从远程一上传war就403。
这个安全策略背后的道理值得细想:上传war包等于允许执行任意代码。如果你只靠一个用户名密码来保护上传接口,密码一旦泄露,或者口令爆破成功,攻击者直接传一个webshell,是整台服务器的权限。所以Tomcat用最底层、最不容易被绕过的网络层IP限制来兜底,而不是把安全全押在认证上。
手写版要模拟这个行为,可以在Bootstrap的accept逻辑后加一段判断:如果是部署或管理接口的请求,先校验来源IP,不是白名单就直接返回403:
InetAddress remote = socket.getInetAddress(); if (!isAllowedManagerIp(remote)) { HttpResponse response = new HttpResponse(socket); response.sendError(403, "manager access forbidden from " + remote.getHostAddress()); socket.close(); return; }生产环境更严格的方案是:管理端口和业务端口彻底分开,管理端口只绑定在127.0.0.1或内网网卡上,不暴露到外网。Tomcat的做法也是类似的思路——manager接口和业务接口同居一个端口,但用Valve做第一层拦截。理解了这个,你就知道“限制IP”不是Tomcat在给用户添麻烦,而是在做正确的事。
如果你也想照着这个思路手写一遍,我的建议是从静态文件开始,不要一上来就写类加载器。先用最简单的Socket解析一个GET请求,把静态页面返回成功,再逐步加Servlet映射、web.xml、ClassLoader,最后再碰并发和部署管理。调试时多用curl -v、telnet 8080手工发HTTP报文看原始流量,能帮你肉眼确认每一行响应头是否正确。先跑通再说别的——这句话放在几乎所有中间件学习上,都成立。