手写一个迷你 Servlet 容器:JerryCat 的设计与实现全解析

发布时间:2026/9/21 14:34:47
手写一个迷你 Servlet 容器:JerryCat 的设计与实现全解析 文档教程知识库【免费下载链接】source-code-hunter 从源码层面剖析挖掘互联网行业主流技术的底层实现原理为广大开发者 “提升技术深度” 提供便利。目前开放 Spring 全家桶Mybatis、Netty、Dubbo 框架及 Redis、Tomcat 中间件等项目地址https://gitcode.com/doocs/source-code-hunter点击查看免费下载导读Servlet 是 Java Web 请求链路中最底层的技术之一日常开发中我们只需在类上标注WebServlet客户端请求便会自动路由到对应 Servlet——而这背后真正干活的是Servlet 容器。本文以 JerryCat 这个小巧的 Servlet 容器为例完整还原其四大核心步骤解压 war 包、收集 Servlet 映射信息、启动 Web 服务器、请求映射与结果返回并结合本仓库中 Servlet 规范源码与 Tomcat 相关的解析文档带你从零理解请求如何进入 Servlet这一底层原理。读完本文你将掌握 Servlet 容器的骨架设计、类加载器与注解扫描的取舍、HttpServer的用法以及一个完整容器离生产级实现还差哪些规范。一、背景Servlet、Web 服务器与 Servlet 容器在 Java Web 开发中客户端发起请求后请求到达服务器内部首先进入的就是 Servlet这里不讨论更底层的网络链路。SpringMVC 的请求分发核心本身也是一个 Servlet名叫DispatcherServlet一个请求首先进入该 Servlet再经由 SpringMVC 的机制分发到对应的 Controller。关于 DispatcherServlet 如何在 Web 容器中初始化并处理请求可以参阅仓库中的 SpringMVC 的设计与实现 一文其继承链DispatcherServlet → FrameworkServlet → HttpServletBean → HttpServlet正是建立在 Servlet 规范之上的。普通开发人员通常不会关心 Servlet 是如何被调用的只要写一个WebServlet注解运行后请求就会自动进入对应的 Servlet。而做这些事的叫Servlet 容器。需要厘清一个概念Servlet 容器一定是一个 Web 服务器但 Web 服务器反过来可不一定是 Servlet 容器。举例来说Nginx 是 Web 服务器但不是 Servlet 容器而 Tomcat 既是 Web 服务器又是 Servlet 容器。在 JavaWeb 的开发世界很多规范JDBC、Servlet 规范都是Java 只做接口定义具体实现交给各大厂商Servlet 容器的一个典型实现就是 Tomcat。Tomcat 的实现非常复杂这里不做研究我们只搞清楚一个小型 Servlet 容器实现的步骤。我们给这个容器起名为JerryCat它的核心功能只有一个将请求交给对应的 Servlet并将其处理结果返回给客户端。实现步骤如下解压 war 文件收集 Servlet 信息启动 web 服务器请求映射 返回结果在深入实现之前先建立对 Servlet 规范本身的认知。仓库中的 servlet-api源码赏析 一文对规范接口做了完整梳理下图即为该文中的 Servlet 主要类图展示了Servlet、ServletConfig、ServletContext、ServletRequest、ServletResponse及抽象类GenericServlet之间的层次关系。其中核心的Servlet接口只定义了 5 个方法init(ServletConfig)初始化 Servletservice(ServletRequest, ServletResponse)提供服务destroy()销毁 ServletgetServletConfig()、getServletInfo()获取配置与描述信息。而开发者常用的HttpServlet是GenericServlet的子类它在service(HttpServletRequest, HttpServletResponse)中根据请求方法GET/HEAD/POST/PUT/DELETE/OPTIONS/TRACE自动分发到doGet、doPost等方法——这正是本文第七节中容器只调用service即可的依据所在。Servlet 的生命周期理解 Servlet 容器之前先回顾 Servlet 的生命周期详见 servlet-api源码赏析加载客户端第一次访问该 Servlet 时容器创建该 Servlet 的实例一般只创建 1 次因此 Servlet 对象在容器中是单例的初始化容器调用该 Servlet 的init()方法进行初始化服务每次客户端访问该 Servlet 时容器就调用一次service()方法处理请求销毁容器关闭时调用这些 Servlet 的destroy()方法释放资源。此外web.xml中的load-on-startup元素可以控制容器是否在应用启动时就加载并初始化 Servlet值为正整数或 0 时应用启动即加载值越小优先级越高为负数或未设置时则延迟到首次被请求时才加载。二、第一步解压 war 文件当你在 Tomcat 的webapps目录下放入一个 war 文件启动 Tomcat 后Tomcat 会自动把这个 war 文件解压后续所有操作都将针对这个解压后的目录。war 文件本质上是 zip 格式因此解压一个 war 文件很简单public static void unzipWar(String warFilePath, String outputFolder) throws IOException { byte[] buffer new byte[1024]; try (ZipInputStream zis new ZipInputStream(Files.newInputStream(Paths.get(warFilePath)))) { ZipEntry zipEntry; while ((zipEntry zis.getNextEntry()) ! null) { String entryName zipEntry.getName(); File newFile new File(outputFolder File.separator entryName); if (zipEntry.isDirectory()) { newFile.mkdirs(); } else { new File(newFile.getParent()).mkdirs(); try (FileOutputStream fos new FileOutputStream(newFile)) { int len; while ((len zis.read(buffer)) 0) { fos.write(buffer, 0, len); } } } zis.closeEntry(); } } }代码要点使用ZipInputStream逐条读取 zip 条目ZipEntrygetNextEntry()返回 null 表示读取完毕目录条目直接mkdirs()创建目录文件条目则先创建父目录再写入内容try-with-resources确保输入输出流自动关闭解压完成后war 内的目录结构即成为容器后续工作的基础尤其是WEB-INF/classes/class 文件与WEB-INF/lib第三方 jar 包这两个规范目录。三、第二步收集 Servlet 信息核心这一步是整个容器的核心Servlet 容器一定要知道一个 war 项目中所有 Servlet 的信息也就是请求路径与 Servlet 类的映射关系当请求进来时才能根据映射关系调用到对应的 Servlet。3.1 映射关系的两种来源方式一web.xml 声明式配置Servlet 3.0 规范以前在 Servlet 3.0 规范以前所有映射关系需要在web.xml中配置容器只需要读取一个配置文件即可servlet servlet-nameHelloServlet/servlet-name servlet-classcom.example.HelloServlet/servlet-class /servlet servlet-mapping servlet-nameHelloServlet/servlet-name url-pattern/hello/url-pattern /servlet-mapping该配置告诉容器将/hello的请求映射到com.example.HelloServlet。方式二注解驱动Servlet 3.0 起从 Servlet 3.0 规范开始增加了WebServlet等注解WebServlet(/hello) public class HelloServlet extends HttpServlet {}同样是告诉容器这个类的请求路径是/hello但这给容器实现增加了负担——容器必须遍历所有 class找出标有WebServlet的类并收集起来。3.2 为什么不能用反射问题来了怎么找到这些符合条件的类首先不能通过反射因为有两个问题类加载器问题容器的类加载器是不能加载 war 项目中的 class 的。即使能加载在收集信息阶段容器也不可能提前知道有哪些类名虽然可以通过 web.xml 直接告诉容器但这又绕回了配置方式若直接尝试Class.forName()会抛出ClassNotFoundException。真正的容器实现都会自定义一个 ClassLoader专门去加载项目的 class 和资源。静态代码块副作用就算有了自定义的 ClassLoader 可以加载到项目的 classClass.forName()会触发 static 代码块。如果项目中的 Servlet 正好写了 static 代码块它就会被提前调用——虽然最终这个代码块都会被调用但不应该在这个收集阶段执行否则会引发一系列初始化时序问题。因此正确的做法是直接读取二进制的 class 文件从 class 文件规范中找到这个类是否带有WebServlet注解。这是唯一稳妥的办法。Spring 扫描注解时也是这样做的Tomcat 同样如此——Tomcat 纯手写实现了一个 class 文件解析器org.apache.tomcat.util.bcel.classfile.ClassParser。如果你熟悉 class 文件格式手写解析器并不难而 JerryCat 选择借助现成框架org.ow2.asm额外知识Spring 也是靠第三方库来读取 class 文件的。3.3 基于 ASM 的注解收集实现private void collectorServlet() { try { final SetString classFileSet new HashSet(); Files.walkFileTree(Paths.get(this.webProjectPath, WEB_CLASSES_PATH), new SimpleFileVisitorPath() { Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { if (file.toString().endsWith(.class)) classFileSet.add(file.toString()); return super.visitFile(file, attrs); } }); ClassNode classNode new ClassNode(); for (String classFile : classFileSet) { ClassReader classReader new ClassReader(Files.newInputStream(Paths.get(classFile))); classReader.accept(classNode, ClassReader.EXPAND_FRAMES); ListAnnotationNode visibleAnnotations classNode.visibleAnnotations; for (AnnotationNode visibleAnnotation : visibleAnnotations) { if (Ljavax/servlet/annotation/WebServlet;.equalsIgnoreCase(visibleAnnotation.desc)) { MapString, Object annotationValues ClassUtils.getAnnotationValues(visibleAnnotation.values); Object o loaderClass(classReader.getClassName()); servletMap.put(annotationValues.get(value).toString(), ((HttpServlet) o)); } } } } catch (IOException e) { throw new RuntimeException(e); } } private Object loaderClass(String name) { try { Class? aClass appClassloader.loadClass(name); return aClass.newInstance(); } catch (ClassNotFoundException | InstantiationException | IllegalAccessException e) { throw new RuntimeException(e); } }逻辑拆解遍历 class 文件通过Files.walkFileTree递归遍历WEB-INF/classes/目录WEB_CLASSES_PATH即WEB-INF/classes/收集所有.class文件解析字节码用ClassReader读取 class 文件的字节流accept(classNode, ClassReader.EXPAND_FRAMES)将二进制内容解析为ClassNode结构visibleAnnotations即为类上可见的注解运行时可见即Retention(RUNTIME)注解判断注解检查注解描述符desc是否为Ljavax/servlet/annotation/WebServlet;这是 JVM 描述符写法L 全限定类名点号换斜杠;加载并实例化命中注解后通过自定义类加载器appClassloader.loadClass(name)加载类并newInstance()实例化随后将注解中的value即 URL 路径作为 key、Servlet 实例作为 value 存入servletMap映射表。3.4 自定义类加载器收集阶段自定义的类加载器主要作用是根据给定的类名从WEB-INF/classes/加载类如果给定类不存在则交给父类加载器。此外Tomcat 有公共的类区域可以把所有项目用到的公共库提取出来放到统一目录war 规范中/WEB-INF/lib目录存放第三方 jar 文件库类加载器也需要考虑这个目录。那么类加载器的加载路径依次如下/WEB-INF/classes/目录/WEB-INF/lib目录公共区域父类加载器如果最后一个也加载不到则抛出异常。拥有一个公共区域其实很有必要通常我们都会依赖大量第三方库自己的代码和资源可能不到 10M但大量第三方库可能占到上百 M部署传输很不方便。正确的做法是把用到的第三方库一次性上传到公共区域部署时只传自己的代码。另外类加载器还需要重写getResource、getResourceAsStream等方法用于在项目的类路径下查找资源如配置文件、静态资源等。四、第三步启动 Web 服务器上面说到Servlet 容器也是一个 Web 服务器只有启动一个 Web 服务器后收到请求才能传递给 Servlet并且它还能处理静态资源。实现一个 Web 服务器重要的是解析 HTTP 报文并且根据响应结果生成 HTTP 报文。这部分可以使用 Java 自带的现成库HttpServer httpServer HttpServer.create(new InetSocketAddress(4040), 10);参数说明HttpServerJava 中用于创建 HTTP 服务器的类由 Java SE 6 引入用于支持简单的 HTTP 服务端功能HttpServer.create用于创建一个新的 HTTP 服务器实例new InetSocketAddress(4040)InetSocketAddress表示 IP 地址和端口号的类这里的4040是端口号表示 HTTP 服务器将在本地计算机的 4040 端口上监听传入的 HTTP 请求10服务器等待队列的最大长度。当 HTTP 服务器在处理传入请求时如果同时有更多请求到达它们会被放入等待队列10表示最多允许同时有 10 个请求在等待处理。五、第四步请求映射 返回结果这一步比较麻烦我们知道doGet和doPost的参数是HttpServletRequest、HttpServletResponse容器需要实现这两个接口并提供请求参数。这里 JerryCat 偷个懒使用mockito这个库来构造一个请求。下面代码中createContext用来监听某个请求路径当有请求过来时HttpServer会把请求对象封装为HttpExchange而我们做的事是把它转换为HttpServletRequest。当调用service时javax.servlet.http.HttpServlet会自动根据请求方式调用doGet或doPost等。try { HttpServer httpServer HttpServer.create(new InetSocketAddress(4040), 10); httpServer.createContext(/, httpExchange - { Servlet servlet servletMap.get(httpExchange.getRequestURI().toString()); JerryCatHttpServletResponse httpServletResponse new JerryCatHttpServletResponse(Mockito.mock(HttpServletResponse.class)); HttpServletRequest httpServletRequest createHttpServletRequest(httpExchange); if (servlet ! null) { try { servlet.service(httpServletRequest, httpServletResponse); byte[] responseByte httpServletResponse.getResponseByte(); httpExchange.sendResponseHeaders(200, responseByte.length); httpExchange.getResponseBody().write(responseByte); httpExchange.getResponseBody().flush(); } catch (ServletException e) { e.printStackTrace(); } } }); httpServer.start(); } catch (IOException e) { throw new RuntimeException(e); }流程要点以/为 context 根路径注册一个处理器所有进入 4040 端口的请求都会触发该回调根据请求 URIhttpExchange.getRequestURI()从servletMap中查表得到对应的 Servlet 实例构造HttpServletRequest从HttpExchange转换而来与HttpServletResponse这里用JerryCatHttpServletResponse包装 mock 对象调用servlet.service(request, response)——HttpServlet.service内部会根据 HTTP 方法自动分发到doGet/doPost等其分发逻辑可参考 servlet-api源码赏析 中HttpServlet的service(HttpServletRequest, HttpServletResponse)实现Servlet 处理完成后从响应包装对象取出字节数组通过sendResponseHeaders(200, length)发送响应头再写入响应体并 flush最终返回给客户端。到这里就结束了容器的任务只需要等待 Servlet 处理完成将结果返回给客户端即可。不过这里的请求映射显得有点简单——因为我们少了处理通配符的情况如url-pattern中的*、/*、*.do等匹配规则。一个完整的映射器需要支持精确匹配、路径前缀匹配、扩展名匹配等多级规则真实容器如 Tomcat的 Mapper 组件正是为此设计的。六、其余规范还差什么其他特性这里不再展开但属于 Servlet 规范的容器一定要实现的还有不少比如ServletContainerInitializer这是一个很有用的机制SpringBoot 打包成 war 后就依靠它去启动。容器在启动阶段会通过ServiceLoader机制发现META-INF/services/javax.servlet.ServletContainerInitializer中声明的实现类并调用其onStartup(SetClass?, ServletContext)方法从而让框架代码在容器启动时拿到ServletContext并注册自己的 Servlet/Filter/Listener。Filter过滤器实现方式和 Servlet 注解收集类似也是通过ClassReader读取 class 文件、扫描WebFilter注解并在调用 service 之前一步先调用 Filter从而形成过滤链FilterChain实现请求的预处理与后处理。七、结束从雏形到真正的容器这里只实现了一个容器雏形中的核心。一个完整的容器至少要做到提供完整的HttpServletRequest实现和HttpServletResponse实现。JerryCat 只做了演示没有做太多处理比如最重要的Cookie 管理、Session 管理否则应用程序就无法实现用户登录状态维护。从规范层面看HttpServletRequest继承自ServletRequest二者定义的方法加起来共有 70 多个需要一一实现才能给用户提供完整的请求信息否则用户想拿一个请求头都拿不到也没办法继续开发。完整的请求/响应接口清单可参考 servlet-api源码赏析 中ServletRequest、ServletResponse、HttpServletRequest、HttpServletResponse的接口方法定义。有完整的信息提供后就可以做额外的功能开发了比如WebSocket当请求过来时如果发现是一个 WebSocket 握手请求就需要做协议升级HTTP Upgrade转换为 WebSocket 协议。另外一个容器进程是可以加载多个 war 项目的就像 Tomcat 一样。久而久之支持的东西多了就成了真正的容器。八、总结与延伸阅读JerryCat 用最少的代码串起了 Servlet 容器的核心闭环步骤核心任务关键技术解压 war将 war 还原为可操作目录ZipInputStream收集 Servlet 信息建立 URL → Servlet 映射ASM 字节码解析 自定义类加载器启动 Web 服务器监听端口、解析/生成 HTTP 报文JDKHttpServer请求映射 返回结果查表调用 Servlet 并回写响应servletMapHttpExchange这个闭环的背后是 Servlet 规范对接口的抽象servlet-api源码赏析、SpringMVC 对DispatcherServlet的扩展SpringMVC 的设计与实现以及真实容器 Tomcat 在类加载、请求映射、会话管理上的大量工程化细节。理解了 JerryCat 的骨架再去看 Tomcat 的源码就会多一分原来如此的熟悉感。赞分享文档教程知识库【免费下载链接】source-code-hunter 从源码层面剖析挖掘互联网行业主流技术的底层实现原理为广大开发者 “提升技术深度” 提供便利。目前开放 Spring 全家桶Mybatis、Netty、Dubbo 框架及 Redis、Tomcat 中间件等项目地址https://gitcode.com/doocs/source-code-hunter点击查看免费下载相关推荐从零实现一个简易 Servlet 容器JerryCat 解压 war、收集注解、启动服务器与请求映射的四步核心设计从零实现一个简易 Servlet 容器JerryCat 解压 war、收集注解、启动服务器与请求映射的四步核心设计 Servlet 是 Java Web 请求文档教程技术博客知识库革命性源码分析source-code-hunterTomcat Servlet容器架构设计革命性源码分析source code hunterTomcat Servlet容器架构设计 引言为什么你需要深入理解Servlet容器 你是否曾经遇到过这文档教程技术博客知识库Nano ID教程从零开始实现迷你版Nano IDNano ID教程从零开始实现迷你版Nano ID 你还在为生成唯一ID而烦恼吗想知道如何用不到110字节的代码创建一个安全、URL友好的ID生成器吗本文开发工具创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考