ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

手写迷你Tomcat:拆解HTTP服务器与Servlet容器核心原理

手写迷你Tomcat:拆解HTTP服务器与Servlet容器核心原理 去年我用两个周末从零手写了一个能跑静态页面、能部署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 功能模块拆解谁来监听端口谁来处理请求谁来加载类我设计的第一版类结构非常简单总共分四组互相之间的依赖关系也清晰模块职责核心类启动入口初始化配置、绑定端口、开启事件循环BootstrapConnector层把Socket字节流转成HttpRequest对象把处理结果写回SocketHttpRequest、HttpResponse、RequestParserContainer层判断请求是静态资源还是Servlet分别处理StaticResourceProcessor、ServletProcessorLoader层解析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; charsetUTF-8 Content-Length: 55 htmlbodyh1Welcome/h1/body/html第一行是状态行包含协议版本、状态码、原因短语。请求头里最关键的是Content-Length它告诉浏览器响应体有多少字节。很多人手写HTTP响应的时候会漏掉它结果浏览器一直转圈因为不知道响应体在哪里结束。我的HttpResponse封装了下面的核心能力public void sendError(int status, String reason) throws IOException { setBody(htmlbodyh1 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 ; charsetUTF-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绝不能只写\nHTTP协议规定行分隔符是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 version1.0 encodingUTF-8? web-app servlet servlet-namehello/servlet-name servlet-classcom.demo.HelloServlet/servlet-class /servlet servlet-mapping servlet-namehello/servlet-name url-pattern/hello/url-pattern /servlet-mapping /web-appWebConfig解析该文件时先建立servlet-name到servlet-class的映射再建立url-pattern到servlet-name的映射最终得到一个MapString, 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内部维护一个MapString, 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的reloadabletrue就是这样一个循环过程监控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 :8080Linux用ss -lntp | grep 8080macOS用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.langALL-UNNAMED这类参数但能避开就避开。这不是让你抵制新版本而是旧习惯在JDK高版本下已经不值钱了。7. 并发接入与后台安全从单线程到线程池再到上传war被限制IP7.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不是白名单就直接返回403InetAddress 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报文看原始流量能帮你肉眼确认每一行响应头是否正确。先跑通再说别的——这句话放在几乎所有中间件学习上都成立。
返回列表