- 文档
- 教程
- 知识库
【免费下载链接】source-code-hunter
😱 从源码层面,剖析挖掘互联网行业主流技术的底层实现原理,为广大开发者 “提升技术深度” 提供便利。目前开放 Spring 全家桶,Mybatis、Netty、Dubbo 框架,及 Redis、Tomcat 中间件等
导读
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):初始化 Servlet;service(ServletRequest, ServletResponse):提供服务;destroy():销毁 Servlet;getServletConfig()、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 条目(ZipEntry),getNextEntry()返回 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-name>HelloServlet</servlet-name> <servlet-class>com.example.HelloServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>HelloServlet</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 可以加载到项目的 class,
Class.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 Set<String> classFileSet = new HashSet<>(); Files.walkFileTree(Paths.get(this.webProjectPath, WEB_CLASSES_PATH), new SimpleFileVisitor<Path>() { @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); List<AnnotationNode> visibleAnnotations = classNode.visibleAnnotations; for (AnnotationNode visibleAnnotation : visibleAnnotations) { if ("Ljavax/servlet/annotation/WebServlet;".equalsIgnoreCase(visibleAnnotation.desc)) { Map<String, 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);参数说明:
HttpServer:Java 中用于创建 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 端口的请求都会触发该回调; - 根据请求 URI(
httpExchange.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(Set<Class<?>>, 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 并回写响应 | servletMap+HttpExchange |
这个闭环的背后,是 Servlet 规范对接口的抽象(servlet-api源码赏析)、SpringMVC 对DispatcherServlet的扩展(SpringMVC 的设计与实现),以及真实容器 Tomcat 在类加载、请求映射、会话管理上的大量工程化细节。理解了 JerryCat 的骨架,再去看 Tomcat 的源码,就会多一分"原来如此"的熟悉感。
- 文档
- 教程
- 知识库
【免费下载链接】source-code-hunter
😱 从源码层面,剖析挖掘互联网行业主流技术的底层实现原理,为广大开发者 “提升技术深度” 提供便利。目前开放 Spring 全家桶,Mybatis、Netty、Dubbo 框架,及 Redis、Tomcat 中间件等
相关推荐
从零实现一个简易 Servlet 容器:JerryCat 解压 war、收集注解、启动服务器与请求映射的四步核心设计
从零实现一个简易 Servlet 容器:JerryCat 解压 war、收集注解、启动服务器与请求映射的四步核心设计 Servlet 是 Java Web 请求
文档教程技术博客知识库革命性源码分析source-code-hunter:Tomcat Servlet容器架构设计
革命性源码分析source code hunter:Tomcat Servlet容器架构设计 引言:为什么你需要深入理解Servlet容器? 你是否曾经遇到过这
文档教程技术博客知识库Nano ID教程:从零开始实现迷你版Nano ID
Nano ID教程:从零开始实现迷你版Nano ID 你还在为生成唯一ID而烦恼吗?想知道如何用不到110字节的代码创建一个安全、URL友好的ID生成器吗?本文
开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考