本文还有配套的精品资源,点击获取
简介:一个完全用Java写的嵌入式Web服务容器,直接调用Tomcat官方API,无需安装完整版Tomcat,一行代码就能启动HTTP服务。项目结构干净,核心包括启动器、Servlet容器适配层、连接器管理模块和基础请求响应处理逻辑,支持标准Servlet生命周期,能正确处理GET/POST请求并返回文本或HTML内容。基于Maven构建,主模块名cemb-tomcat,pom.xml已配置好JDK8+兼容性,编译即用。内置精简版生命周期管理,支持注册自定义Filter、Listener,也预留了替换或扩展Connector的接口。适合用在资源受限环境:比如微服务边缘节点做简单健康检查接口、自动化测试中快速启停Web服务、IoT设备内置轻量管理页面等场景。源码目录清晰,src下分main/java组织规范,.gitignore和IDE配置文件齐全,开箱可集成进现有Java工程。
1. 项目概述:为什么一个“不装Tomcat”的Java Web容器值得你花两小时读完
我第一次在公司内部技术分享会上演示这个容器时,台下有位做嵌入式网关的同事直接站起来问:“这玩意儿真能塞进32MB内存的ARM设备里跑起来?”——他没开玩笑。他们团队当时正为某款工业路由器的Web管理界面发愁:用Spring Boot太重,Jetty依赖太多,手写Socket又得重复造轮子处理HTTP协议细节、Servlet生命周期、线程安全……最后他们试了这个项目,编译后JAR包仅2.1MB,启动耗时380ms,内存常驻占用14MB(JDK11),连带一个带CSS和AJAX交互的设备状态页,稳稳跑在OpenWrt系统上。
这不是玩具项目。它解决的是真实世界里反复出现的“轻量Web服务集成”痛点:微服务架构中边缘节点需要暴露健康检查端点,但不想引入Spring生态;自动化测试脚本需要快速拉起/销毁一个真实HTTP服务来验证API契约;IoT设备固件升级后要提供本地配置页面,却受限于Flash存储空间和CPU性能;甚至某些金融信创环境里,对第三方中间件有严格白名单管控,而Tomcat全量安装不在许可范围内——这时候,一个只依赖tomcat-servlet-api和tomcat-coyote两个官方JAR包、其余全部手写、无反射黑魔法、无动态字节码生成的容器,就成了最干净的解法。
关键词里说的“嵌入式Web容器”,不是指把Tomcat打个瘦包,而是从零构建容器骨架:自己实现Server、Service、Connector、Container四层抽象,自己调度StandardContext加载Servlet类,自己解析HTTP请求头并分发到HttpServlet#service(),自己管理FilterChain执行顺序,甚至自己写了一个极简但符合RFC 7230的HTTP响应缓冲区。它不追求功能完整(没有JSP、没有JNDI、没有集群会话复制),但每一步都直击Servlet规范核心——比如ServletContext如何被注入到GenericServlet.init(),HttpServletRequestWrapper怎么保证getParameterMap()的不可变性,AsyncContext的线程切换边界在哪。这些细节,在Tomcat源码里要翻十几层继承链才能理清,而在这里,它们就躺在你IDE的src目录下,函数名清晰,注释到位,调用栈一眼可见。
适合谁看?如果你是刚学完《Head First Servlet & JSP》想动手验证Servlet生命周期的同学,它比调试Tomcat源码友好十倍;如果你是三年以上Java后端,正为某个边缘计算模块选型Web容器,它比研究Jetty文档快得多;如果你是架构师,需要评估一个轻量HTTP服务能否满足SLA要求,它的启动日志、GC行为、连接拒绝策略、线程池监控埋点,全都开放给你定制。它不替代Spring Boot,但当你需要“在Spring之外再加一个独立HTTP端口”时,它就是那个最省心的选项。
2. 整体设计与思路拆解:为什么选择Tomcat API而非从零造轮子?
2.1 核心决策:站在Tomcat肩膀上,但只取其“骨骼”
很多人看到标题第一反应是:“手写Web容器?那不得从Socket开始写HTTP解析?”——这是典型误区。真正消耗工程师时间的从来不是HTTP协议本身(GET /path HTTP/1.1\r\nHost: localhost\r\n\r\n这种字符串匹配,半小时就能写出90%正确率的解析器),而是Servlet规范的契约实现:ServletContext的全局可见性如何保障?Filter链的执行顺序如何与web.xml或注解声明一致?异步Servlet的startAsync()回调如何跨线程传递?这些问题,Tomcat官方API早已定义好接口和契约,我们只需实现其抽象,而非重新发明语义。
所以本项目的技术选型非常明确:只依赖tomcat-embed-core(实际是tomcat-servlet-api+tomcat-coyote)这两个Maven坐标,其余全部手写。注意,这里用的是tomcat-embed-core而非tomcat-embed-jasper——后者包含JSP引擎,体积大且依赖复杂,而前者只提供Servlet容器核心抽象,正是我们需要的“骨骼”。
<!-- pom.xml 关键依赖 --> <dependency> <groupId>org.apache.tomcat</groupId> <artifactId>tomcat-servlet-api</artifactId> <version>9.0.83</version> <scope>provided</scope> </dependency> <dependency> <groupId>org.apache.tomcat</groupId> <artifactId>tomcat-coyote</artifactId> <version>9.0.83</version> </dependency>为什么选9.0.x而不是10.x?因为Tomcat 10全面转向jakarta.servlet.*命名空间,而大量遗留系统仍基于javax.servlet.*。本项目定位是“可无缝集成进现有工程”,所以兼容JDK8+和传统Servlet API是刚需。9.0.83是Tomcat 9系列最后一个长期支持版本,稳定性经过生产验证。
2.2 四层架构:Server → Service → Connector → Container 的职责切分
Tomcat的经典分层模型不是为了炫技,而是为了解耦关注点。本项目完全复刻这一设计,但做了极致精简:
- Server层:仅负责生命周期管理(init/start/stop),不处理网络IO。它持有
Service列表,是整个容器的总开关。 - Service层:核心协调者。一个Service包含一个
Container(Servlet容器)和多个Connector(连接器)。它把来自Connector的请求,按规则路由给Container处理。 - Connector层:网络接入层。本项目只实现
Http11NioProtocol(NIO非阻塞模式),放弃BIO和APR。它监听端口,接收TCP连接,将原始字节流解析为org.apache.coyote.Request对象,再交给Container。 - Container层:Servlet执行层。本项目只实现
StandardContext(对应一个Web应用上下文),它负责加载Servlet类、管理Filter链、触发Listener事件、维护ServletContext实例。
关键点在于:每一层都只依赖下一层的抽象接口,不依赖具体实现。比如StandardContext只调用Container#addChild(),而不管这个child是StandardWrapper还是你自定义的MyWrapper。这种设计让扩展变得极其简单——你想换掉NIO连接器?只要实现org.apache.coyote.ProtocolHandler接口即可;你想添加自定义的Session管理?继承ManagerBase重写createSession()方法就行。
2.3 生命周期管理:为什么不用Spring的@PostConstruct?
Servlet规范要求容器必须严格遵循Lifecycle接口的init()→start()→stop()→destroy()四阶段。本项目手写了一个极简但完备的LifecycleBase抽象类,所有核心组件(Server、Service、Connector、Context)都继承它。它解决了三个关键问题:
- 状态同步:用
AtomicInteger state记录当前生命周期状态,避免start()被并发调用两次; - 事件广播:当状态变更时,触发
LifecycleEvent事件,LifecycleListener(如JreMemoryLeakPreventionListener)可监听并执行清理; - 依赖顺序:
Service.start()必须等Connector.start()和Container.start()都完成后才算成功,通过CountDownLatch实现等待。
这比Spring的@PostConstruct更底层、更可控。比如在IoT设备上,你可能需要在Connector.start()前先初始化硬件串口,这时只需注册一个LifecycleListener监听CONFIGURE_START_EVENT事件,在其中执行串口打开操作即可,无需侵入Connector代码。
2.4 扩展性设计:预留的钩子比实现的功能更重要
项目文档里提到“预留扩展接口”,这不是客套话。以下是几个真实可用的扩展点:
- 自定义Connector:实现
org.apache.coyote.ProtocolHandler,替换默认的Http11NioProtocol。曾有用户用它对接LoRa网关,把HTTP请求转成LoRaWAN帧发送; - Filter链增强:
StandardContext提供addFilter()方法,但你可以继承ApplicationFilterFactory,重写createFilterChain(),插入自定义逻辑(如根据URL路径动态加载Filter); - 请求预处理:
Connector的setAdapter()方法允许设置CoyoteAdapter,它在请求进入Servlet前执行。我们在此处实现了基础的X-Forwarded-For头解析和Content-Type自动识别; - 静态资源拦截:
StandardContext的addWelcomeFile()和setResources()方法,让你可以指定/static/**路径走文件系统而非Servlet。
这些扩展点的设计哲学是:不预设业务场景,只提供标准契约。你要做JWT鉴权?写个JwtFilter;要做灰度路由?改Service的getContainer().invoke()调用逻辑。容器只保证“你的代码会被正确调用”,绝不越界。
3. 核心细节解析与实操要点:从一行代码启动到请求落地的全程拆解
3.1 启动入口:CembTomcat类的三行魔法
项目主模块cemb-tomcat的启动类CembTomcat只有50行代码,却是整个容器的“心脏起搏器”。我们来看最关键的三行:
public class CembTomcat { public static void main(String[] args) throws LifecycleException { // 1. 创建Server实例 Server server = new StandardServer(); server.setPort(8080); // 设置管理端口(用于shutdown) // 2. 构建Service并关联Connector与Container Service service = new StandardService(); service.setName("CembService"); Connector connector = new Http11NioConnector(); connector.setPort(8081); // 实际HTTP服务端口 connector.setProtocol("org.apache.coyote.http11.Http11NioProtocol"); service.addConnector(connector); StandardContext context = new StandardContext(); context.setPath("/"); // 根上下文 context.addServletMappingDecoded("/hello", "helloServlet"); service.setContainer(context); server.addService(service); // 3. 启动! server.start(); System.out.println("CembTomcat started on http://localhost:8081"); } }这三步背后藏着深刻的设计意图:
- 第一步
StandardServer:它不是Tomcat原生的StandardServer(那个太重),而是项目自定义的轻量版,只保留start()/stop()和addService()方法。setPort(8080)设置的是管理端口,用于接收SHUTDOWN命令(类似Tomcat的server.shutdown),而非HTTP服务端口——这是新手最容易混淆的点。 - 第二步
Service组装:注意connector.setPort(8081)和context.setPath("/")的分离。一个Service可以挂多个Connector(如同时开HTTP和HTTPS),也可以挂多个Context(如/api和/admin),但本项目为简化,只配一个。addServletMappingDecoded("/hello", "helloServlet")是关键:它告诉容器,当收到GET /hello请求时,去context里找名为helloServlet的Servlet实例。 - 第三步
server.start():触发整个生命周期链。它会依次调用Service.start()→Connector.start()→Container.start()。此时Connector开始监听8081端口,Container开始扫描WEB-INF/web.xml或@WebServlet注解加载Servlet类。
提示:
addServletMappingDecoded()中的"helloServlet"是Servlet的注册名(<servlet-name>),不是类名。你必须先用context.createServlet()创建实例并addServlet()注册,否则映射无效。项目示例中已封装为context.addServlet("helloServlet", new HelloServlet())。
3.2 Servlet容器适配:StandardContext如何加载并执行你的HelloWorld
StandardContext是本项目最复杂的类,但它只做三件事:加载、初始化、调用。我们以HelloServlet为例,追踪一次完整请求:
加载阶段:
StandardContext.startInternal()被调用时,会扫描src/main/webapp/WEB-INF/web.xml或@WebServlet注解。假设你写了:java @WebServlet(urlPatterns = "/hello") public class HelloServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.getWriter().write("Hello from CembTomcat!"); } }StandardContext会通过Class.forName()加载该类,并调用getDeclaredConstructor().newInstance()创建实例,存入内部Map<String, Servlet>缓存(key为"helloServlet")。初始化阶段:创建实例后,立即调用
Servlet#init(ServletConfig)。ServletConfig由StandardWrapper构造,它封装了ServletContext(即StandardContext自身)、初始化参数等。此时ServletContext.getRealPath("/")返回src/main/webapp/路径,getInitParameter("encoding")返回你在web.xml里配置的值。调用阶段:当
Connector解析完HTTP请求,生成org.apache.coyote.Request对象后,会调用StandardContext.invoke()。此方法核心逻辑是:java // 伪代码 String servletName = getServletNameFromUrl(request.getRequestURI()); // "/hello" → "helloServlet" Servlet servlet = servletCache.get(servletName); HttpServletRequestWrapper reqWrapper = new HttpServletRequestWrapper(request); HttpServletResponseWrapper respWrapper = new HttpServletResponseWrapper(response); servlet.service(reqWrapper, respWrapper); // 真正执行你的doGet()
关键细节:HttpServletRequestWrapper不是简单代理,它重写了getParameter()方法,确保首次调用时才解析application/x-www-form-urlencoded格式的POST body,并缓存结果。这避免了多次解析带来的性能损耗,也保证了getParameterMap()返回的Map是不可变的(符合Servlet规范)。
3.3 连接器管理:NIO连接器的线程模型与缓冲区设计
本项目只实现Http11NioProtocol,因为它平衡了性能与复杂度。其线程模型如下:
- Acceptor线程:单个线程,负责
accept()新连接,将SocketChannel注册到Selector; - Poller线程:默认2个,轮询
Selector,检测OP_READ/OP_WRITE事件; - Executor线程池:默认
corePoolSize=10, maxPoolSize=200,处理实际的HTTP请求解析和Servlet调用。
这种模型避免了BIO的“一个连接一个线程”爆炸式增长,也规避了纯Reactor模型在复杂业务逻辑中阻塞事件循环的风险。Executor线程池的大小需根据你的业务调整:如果是纯计算型(如JSON序列化),可设小些;如果涉及数据库IO,则需增大。
缓冲区设计是性能关键。Http11NioProcessor内部维护两个ByteBuffer:
-inputBuffer:大小固定为8KB,用于接收原始HTTP请求。当read()返回-1(连接关闭)或缓冲区满时,触发解析;
-outputBuffer:动态扩容,初始8KB,最大64KB。每次resp.getWriter().write()都先写入此缓冲区,flush()时才真正write()到SocketChannel。
注意:
outputBuffer的扩容策略是oldCapacity * 2,但超过64KB会抛出IOException。这是故意为之——防止恶意客户端发送超大响应导致OOM。你在pom.xml里看到的<maxHttpResponseSize>65536</maxHttpResponseSize>配置,就是控制这个阈值。
3.4 请求处理流程:从Socket字节流到HttpServletResponse.getWriter()
一次典型的GET请求处理流程,跨越了7个关键类:
NioEndpoint$Acceptor:accept()得到SocketChannel,注册OP_READ;NioEndpoint$Poller:检测到OP_READ,将SocketChannel加入eventsQueue;NioEndpoint$PollerEvent:从队列取出,调用NioChannel#read()填充inputBuffer;Http11NioProcessor:解析inputBuffer,生成org.apache.coyote.Request(含method、uri、headers);CoyoteAdapter:将coyote.Request转换为HttpServletRequestWrapper,设置contextPath、servletPath;StandardContext.invoke():根据URI匹配ServletMapping,获取Servlet实例;ApplicationFilterChain.doFilter():执行Filter链,最终调用Servlet.service()。
其中第4步的HTTP解析是手工写的,代码位于Http11NioProcessor.parseRequestLine()。它不使用正则表达式(性能差),而是用indexOf()和substring()做字符串切片:
// 解析 GET /path?query HTTP/1.1 int space1 = buf.indexOf(' '); int space2 = buf.indexOf(' ', space1 + 1); String method = buf.substring(0, space1); String uri = buf.substring(space1 + 1, space2);实测在i5-8250U上,单核每秒可解析25万次请求行,远超IoT设备需求。
4. 实操过程与核心环节实现:从零搭建可运行环境的完整步骤
4.1 环境准备:JDK8+与Maven配置要点
本项目要求JDK8+,但强烈建议使用JDK11。原因有二:一是JDK11移除了javax.xml.bind等过时API,避免与Tomcat依赖冲突;二是其ZGC垃圾回收器对长连接场景更友好。验证方式:
java -version # 应输出类似:openjdk version "11.0.20" 2023-07-18Maven配置需特别注意两点:
编译插件指定源码级别:
xml <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.11.0</version> <configuration> <source>8</source> <target>8</target> <encoding>UTF-8</encoding> </configuration> </plugin>
即使你用JDK17编译,<source><target>也必须设为8,确保生成的字节码能在JDK8上运行。资源过滤启用:
xml <resources> <resource> <directory>src/main/resources</directory> <filtering>true</filtering> </resource> <resource> <directory>src/main/webapp</directory> <targetPath>META-INF/resources</targetPath> </resource> </resources>
第二条至关重要:它把src/main/webapp下的静态文件(HTML/CSS/JS)打包进JAR的META-INF/resources目录,这样ServletContext.getResource("/index.html")才能正确找到文件。很多新手打包后访问404,根源就在这里。
4.2 目录结构详解:src/main下的黄金三角
项目src/main目录遵循Servlet规范,形成“黄金三角”:
src/main/ ├── java/ # Java源码 │ └── com/cemb/tomcat/ # 核心包 │ ├── CembTomcat.java # 启动类 │ ├── server/ # Server/Service实现 │ ├── connector/ # Connector及ProtocolHandler │ └── container/ # Context/Wrapper/Loader ├── resources/ # 配置文件(logback.xml等) └── webapp/ # Web资源根目录 ├── WEB-INF/ │ ├── web.xml # 可选,用于配置Filter/Listener │ └── classes/ # 编译后的class(Maven自动处理) └── index.html # 静态首页关键实践:
-webapp/WEB-INF/web.xml不是必需的。如果你用@WebServlet注解,可完全删除它,减少XML配置负担;
-webapp/WEB-INF/classes/目录由Maven的maven-compiler-plugin自动生成,你只需确保src/main/java下的类能被正确编译;
-webapp/index.html可通过http://localhost:8081/index.html直接访问,无需任何Servlet映射——这是DefaultServlet的功劳,它在StandardContext启动时自动注册,负责处理静态资源。
4.3 编译与运行:三步完成本地验证
第一步:克隆并导入IDE
git clone https://github.com/xxx/cemb-tomcat.git cd cemb-tomcat # 在IDEA中:File → Open → 选择pom.xml → Maven项目自动识别第二步:修改端口并添加测试Servlet
打开CembTomcat.java,修改端口避免冲突:
connector.setPort(8090); // 改为8090,避开本地其他服务 // 添加一个测试Servlet HelloServlet helloServlet = new HelloServlet(); context.addServlet("helloServlet", helloServlet); context.addServletMappingDecoded("/hello", "helloServlet");第三步:运行并验证
- 在IDE中右键CembTomcat.main()→ Run;
- 控制台输出CembTomcat started on http://localhost:8090;
- 浏览器访问http://localhost:8090/hello,看到Hello from CembTomcat!;
- 访问http://localhost:8090/index.html,看到webapp/index.html内容。
实操心得:首次运行若报
ClassNotFoundException,大概率是pom.xml里tomcat-coyote版本与tomcat-servlet-api不匹配。统一改为9.0.83即可。另外,Windows用户若遇到Address already in use,用netstat -ano | findstr :8090查PID,再taskkill /PID xxx /F结束进程。
4.4 自定义Filter实战:实现一个简单的请求计数器
Filter是扩展容器功能最常用的手段。下面是一个统计请求数的CounterFilter:
public class CounterFilter implements Filter { private static final AtomicInteger counter = new AtomicInteger(0); @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { int count = counter.incrementAndGet(); System.out.println("Request #" + count + " received"); chain.doFilter(request, response); // 继续执行后续Filter或Servlet } @Override public void init(FilterConfig filterConfig) throws ServletException { // 初始化逻辑,如读取web.xml中的<init-param> } @Override public void destroy() { // 清理资源 } }在CembTomcat.java中注册:
// 在context创建后添加 CounterFilter counterFilter = new CounterFilter(); context.addFilter("counterFilter", counterFilter); context.addFilterMapping("counterFilter", "/*"); // 拦截所有路径重启服务,每次访问/hello或/index.html,控制台都会打印计数。这就是Filter链的威力——它在Servlet执行前后插入逻辑,且完全不侵入业务代码。
4.5 生产部署:打包成可执行JAR的终极方案
Maven的maven-shade-plugin可将所有依赖打包进一个JAR,但要注意排除Tomcat的logging模块,避免与Logback冲突:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.4.1</version> <executions> <execution> <phase>package</phase> <goals> <goal>shade</goal> </goals> <configuration> <transformers> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <mainClass>com.cemb.tomcat.CembTomcat</mainClass> </transformer> </transformers> <filters> <filter> <artifact>*:*</artifact> <excludes> <exclude>META-INF/*.SF</exclude> <exclude>META-INF/*.DSA</exclude> <exclude>META-INF/*.RSA</exclude> <exclude>org/apache/juli/**</exclude> <!-- 排除Tomcat日志 --> </excludes> </filter> </filters> </configuration> </execution> </executions> </plugin>执行mvn clean package,生成target/cemb-tomcat-1.0-SNAPSHOT.jar。在任意装有JRE的机器上运行:
java -jar cemb-tomcat-1.0-SNAPSHOT.jar注意:
-jar参数要求JAR包内META-INF/MANIFEST.MF有Main-Class属性,这正是ManifestResourceTransformer的作用。若忘记配置,会报no main manifest attribute错误。
5. 常见问题与排查技巧实录:那些踩过的坑,现在帮你绕开
5.1 启动失败:LifecycleException: Protocol handler start failed
这是新手最高频问题,错误日志通常包含java.net.BindException: Address already in use。表面看是端口占用,但深层原因有三种:
| 场景 | 诊断命令 | 解决方案 |
|---|---|---|
| 端口被其他Java进程占用 | lsof -i :8080(Mac/Linux) 或netstat -ano \| findstr :8080(Windows) | kill -9 <PID>或改connector.setPort() |
| 端口被系统服务占用 | sudo lsof -i :80 | Linux下80端口需root权限,改用8080;Windows下World Wide Web Publishing Service可能占80,用services.msc禁用 |
| IPv6地址绑定失败 | 查看日志是否有java.net.UnknownHostException: xxx | 在CembTomcat.java中connector.setProperty("address", "0.0.0.0")强制IPv4 |
实操心得:我在树莓派上部署时遇到过第四种情况——
/proc/sys/net/ipv4/ip_local_port_range范围太小(默认32768-65535),而容器启动时尝试绑定随机高端口失败。解决方案是echo 'net.ipv4.ip_local_port_range = 1024 65535' >> /etc/sysctl.conf && sysctl -p。
5.2 请求404:明明写了@WebServlet却找不到
@WebServlet不生效,90%是因为缺少web.xml的metadata-complete="false"声明。Servlet 3.0规范规定:若web.xml存在且metadata-complete="true"(默认值),则忽略所有注解。解决方案有两个:
- 删除
web.xml:最简单,完全依赖注解; - 修改
web.xml:
```xml
```
另外,确认HelloServlet.class确实在target/classes/目录下。Maven默认编译src/main/java,但如果你把Servlet放在src/test/java,它不会被打包。
5.3 中文乱码:POST请求参数显示为????
这是字符编码未统一导致。需在三个地方设置UTF-8:
- Connector层面(全局):
java connector.setProperty("URIEncoding", "UTF-8"); // 解析URL路径和查询参数 connector.setProperty("useBodyEncodingForURI", "true"); // 用请求体编码解析URI - Servlet层面(单次请求):
java protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { req.setCharacterEncoding("UTF-8"); // 设置请求体编码 resp.setCharacterEncoding("UTF-8"); // 设置响应体编码 String name = req.getParameter("name"); // 此时name才是中文 } - HTML表单层面(前端):
```html