☰
Tomcat报错排查与手写Mini Tomcat:从HTTP到Servlet
2026/9/30 8:10:38 网站建设 项目流程

大多数人对 Tomcat 的认知停在两个动作上:下载解压,把 war 包丢进 webapps,然后双击 startup。直到某天日志里刷出一屏 BindException,或者页面上飘着一堆问号乱码,才开始翻 server.xml 找配置。更尴尬的场面是面试或者做内部分享时被问一句"一个请求进来,Tomcat 内部到底做了什么",脑子里只剩一个模糊的 Connector、Engine,说不出细节。

这篇内容就干两件事。一是把 Tomcat 常见报错按"日志现象、根因、定位手段"三个层次拆开,给出能直接照着敲命令的排查路径;二是从零手写一个 Mini Tomcat,用几百行代码把 Socket 接入、HTTP 报文解析、Servlet 映射和生命周期串起来。手写过一遍之后再回头看那些报错,你会发现它们指向的往往是同一个东西:容器的真实状态和你以为的状态不一致。

适合谁看?写过 Java Web、平时用 Tomcat 部署项目但没深究过的同学,以及想把这块内容讲明白的人。前提不高,知道 Servlet 是什么、能写最基础的 Java 就行,不需要提前读 Tomcat 源码。

1. 报错排查的通用套路:先分清是 JVM 层、容器层还是应用层

Tomcat 的日志之所以让人头大,是因为它把三个不同层次的错误混在同一个 catalina.out 里输出。JVM 层的错误(JDK 版本不匹配、内存参数、字符集)在 Tomcat 启动横幅出现之前就可能崩掉;容器层的错误(端口、Connector、部署目录)通常发生在 "Starting ProtocolHandler" 前后;应用层的错误(数据源、Servlet 初始化、依赖冲突)则出现在 "Deployment of web application archive" 之后。

分清这三层的最大好处是排查顺序明确了。看到报错先问自己一句:这个错误是 Tomcat 自己抛的,还是我部署的应用抛的?如果是 Tomcat 自己抛的,改 server.xml 和启动脚本就够了;如果是应用抛的,改 server.xml 改到天亮也没用。

1.1 三个层次的日志锚点

下面这张表是我自己排查时最常用的对照,先找到日志里那行关键锚点,再去对应层次里找原因,能省掉大量瞎翻的时间。

层次日志锚点关键字典型错误优先检查项
JVM 层无横幅输出 / Unrecognized VM optionUnsupportedClassVersionError、Could not reserve enough spaceJDK 版本、JAVA_HOME、-Xmx 参数
容器层Starting ProtocolHandler / Server startupBindException、LifecycleException端口占用、server.xml 语法、部署目录权限
应用层Deployment of web application / Context startupClassNotFoundException、SQLException、Error listenerStartWEB-INF/lib、数据源配置、web.xml 监听器

有一个经验值得单独说:第一次启动失败,一定要看 catalina.out,不要只看 localhost.2024-xx-xx.log。后者只记录应用层的请求和错误,容器还没起来的时候它根本不会写内容。而 catalina.out 里混着 System.out 和 System.err,虽然乱,但信息最全。

1.2 排查顺序反过来会浪费多少时间

我见过太多人踩这个坑:页面报 404,第一反应是去改 server.xml 里的 Context 配置,改完重启还是 404。其实真正的原因是 war 包解压失败,Deployment of web application archive那一行日志里明确写着 deployment failed,应用压根就没部署成功,容器自然只能返回 404。

正确的顺序是这样走的:先用ps -ef | grep tomcat确认进程在不在,在的话用netstat -ano | findstr 8080(Linux 用lsof -i:8080或ss -lntp)确认端口是不是这个进程占的,然后到日志里搜Deployment看应用是否成功部署,最后才去应用日志里找业务异常。这四步走完,基本能定位到 90% 的问题。

顺带提一句关停。Tomcat 的 shutdown.sh 是靠连接 8005 端口发送 SHUTDOWN 命令来触发的,如果你在 server.xml 里把 8005 那行注释掉了,shutdown.sh 就会静默失效,只能kill。这时很多人直接kill -9,进程是没了,但如果应用里有非守护线程还在跑、或者正在写文件,就很容易留下脏数据。我自己的习惯是kill普通信号等三秒,实在不退再kill -9。

2. 端口、乱码、元数据连接:三类高频报错的逐层排查

2.1 端口占用:从 BindException 到 8005 关停端口

启动日志里出现这句:

java.net.BindException: Address already in use: JVM_Bind

意思非常直白,某个端口已经被别的进程占了。Tomcat 默认会占用三个端口:8080(HTTP 连接器)、8005(关停端口)、8009(AJP 连接器)。这三个里任意一个被占用都会导致启动失败,而最容易被忽略的是 8005,因为它不对外提供服务,平时根本没人注意。

定位命令按系统分:

# Linux lsof -i:8080 ss -lntp | grep 8080 ps -ef | grep tomcat # Windows netstat -ano | findstr :8080 tasklist | findstr <上一步拿到的 PID>

如果确认是残留的 Tomcat 进程,kill <pid>走正常关停流程。如果确认是别的程序(比如另一个 JDK 自带的 HTTP 服务、某些开发工具的内置服务),那就改 Tomcat 端口。改的时候注意三处一起改,别只改 8080:

<Server port="8005" shutdown="SHUTDOWN"> <Service name="Catalina"> <Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" /> <Connector port="8009" protocol="AJP/1.3" redirectPort="8443" /> </Service> </Server>

还有一个隐蔽场景:从 IDE 里点启动、同时又从命令行启动了一份,两份用的是同一个 CATALINA_BASE 或者同一个端口。这种情况进程列表里会有两个 java 进程,日志里却只有一个在报错,因为另一个可能已经绑成功了。排查的时候把ps -ef | grep tomcat的全量输出看完,不要只看第一行。

2.2 乱码的四个位置,改动的地方完全不同

乱码是提问频率最高的问题,但没有一个统一的"改这里就好"的答案,因为乱码可能出现在四个完全不同的位置,每个位置的解法都不一样。先把位置分清,再动手。

位置现象根因处理方式
控制台/日志启动日志里一片问号平台默认字符集与控制台代码页不一致启动参数加-Dfile.encoding=UTF-8;Windows 控制台chcp 65001
URL 参数GET 请求中文参数变乱码Connector 的 URI 解码字符集不对Connector 上加URIEncoding="UTF-8"
请求体POST 表单中文变乱码请求体解码字符集未指定request.setCharacterEncoding("UTF-8")或统一 Filter
响应体页面中文显示为乱码响应头没带 charsetresponse.setContentType("text/html;charset=UTF-8")

先说控制台乱码。这是启动阶段就能看到的,日志文件里的中文变成???。原因是 JVM 默认字符集跟终端代码页对不上。最稳的做法是在启动参数里显式指定:

set "JAVA_OPTS=%JAVA_OPTS% -Dfile.encoding=UTF-8"

或者在 logging.properties 里把控制台 handler 的编码固定下来:

java.util.logging.ConsoleHandler.encoding = UTF-8

再说请求体乱码。这里有个特别容易踩的坑:request.setCharacterEncoding("UTF-8")必须在第一次调用 getParameter 之前执行,因为一旦参数被解析过,后续再设编码就无效了。实际项目里的做法是写一个 Filter,放在过滤器链最前面:

public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding("UTF-8"); resp.setCharacterEncoding("UTF-8"); chain.doFilter(req, resp); } }

最后是响应体。response.setCharacterEncoding和setContentType只设一个是不够的,浏览器判断编码主要看响应头的 Content-Type。直接写response.setContentType("text/html;charset=UTF-8")最省事。

2.3 Could not obtain connection to query metadata 到底在报什么

这个报错完整版本大概长这样:

Could not obtain connection to query metadata : Cannot create PoolableConnectionFactory

它出现在应用启动阶段,是连接池在初始化时尝试从数据库拿一条连接来做元数据探测,结果失败了。注意,这个报错跟 Tomcat 本身没关系,是数据源配置或者网络的问题。但因为它出现在 Tomcat 的启动日志里,很多人第一反应是去改 server.xml,方向一开始就错了。

我整理过一份排查清单,按命中率从高到低排:

  1. JDBC URL 本身就写错了。库名拼错、端口写错、高版本驱动没带时区参数导致连接被拒,都会走到这一步。
  2. 驱动 jar 没放进WEB-INF/lib,或者版本跟 JDK 不匹配。经典表现是ClassNotFoundException: com.mysql.cj.jdbc.Driver,现在驱动类名多了cj这一段,老配置里写的是com.mysql.jdbc.Driver。
  3. 数据库服务没起来,或者容器所在机器到数据库的网络不通。用telnet <host> <port>或者nc -vz <host> <port>先测一下最直接。
  4. 账号密码错、账号没有目标库的权限,或者连接数已经打满。
  5. 连接池的初始连接数、最大连接数配得比数据库允许的上限还大。

最快的验证方式是把容器这个变量剥离掉:写一个带 main 方法的类,用完全相同的 URL、账号、密码、驱动去连一次数据库。能连上,问题就在容器的配置加载或者 jar 打包上;连不上,问题就在数据库或网络,跟 Tomcat 无关。这一招我在排查线上问题时用过很多次,比反复重启容器快得多。

3. 动手之前:先搞清楚一个 HTTP 请求在字节层面长什么样

要手写 Tomcat,第一步不是写代码,而是把"浏览器发过来的到底是一串什么东西"看明白。Tomcat 做的事情本质上就是:从 Socket 里读字节,按 HTTP 协议解析成结构化对象,交给业务代码处理,再把结果拼成字节写回去。协议这一层理解透了,代码只是翻译。

3.1 请求行、请求头、请求体的边界在哪

一个典型的 POST 请求,在 Socket 里读出来是这样:

POST /user/login HTTP/1.1 Host: localhost:8080 Content-Type: application/x-www-form-urlencoded Content-Length: 27 User-Agent: curl/8.0 username=root&password=123

结构非常规整:第一行是请求行,包含方法、URI、协议版本,三段用空格分开;接下来若干行是请求头,每行key: value的形式,以冒号分隔;然后是一个空行,标志着头结束;空行之后是请求体,长度由 Content-Length 决定。

响应也是同样的结构:

HTTP/1.1 200 OK Content-Type: text/html;charset=UTF-8 Content-Length: 12 Hello World!

关键点在于:每一行结尾都是\r\n,头和体之间用一个单独的空行分隔。手写解析器的时候,这个\r\n处理不好就会出现各种诡异问题。

还有两个规范细节值得记一下。请求头和响应头里的字节默认按 ISO-8859-1 解码,这是 HTTP 规范历史遗留的结果,中文出现在头里很少见但确实存在。请求体的编码则由 Content-Type 里的 charset 决定,没写的话就靠应用自己指定了——这也是上一节 POST 乱码的根源。

3.2 用 ServerSocket 直接 readLine 会踩的两个坑

我第一版实现是这么写的:包一层BufferedReader,然后循环readLine()读请求行和请求头。看起来没问题,跑起来单测也全过,但接上带请求体的 POST 就出问题了——请求体读不到,或者只读到一半。

原因在于 BufferedReader 内部有自己的字符缓冲区。它在调用 readLine 读请求头的时候,会一次性从底层 InputStream 预读一大块数据(默认 8192 字符),这里面很可能已经把请求体的内容也读进来了。等你再用原始的 InputStream 去读 body,数据早就被 BufferedReader 吞掉放进自己的缓冲区了,读出来自然是空。

第二个坑是readLine()按\r、\n、\r\n任意一种换行符切分,而 HTTP 规范里头部行分隔符要求是 CRLF。对于头部解析来说这个差异通常无害,但如果请求头里出现了裸露的\r或者某些老客户端用了单独的\n,解析结果就会飘。

解决办法是自己写一个按字节读行的工具方法,不预读、不缓冲,边界完全可控:

private static String readLine(InputStream in) throws IOException { ByteArrayOutputStream buf = new ByteArrayOutputStream(128); int b; while ((b = in.read()) != -1) { if (b == '\n') { break; } if (b == '\r') { continue; } buf.write(b); if (buf.size() > 8192) { throw new IOException("header line too long"); } } if (b == -1 && buf.size() == 0) { return null; } return buf.toString("ISO-8859-1"); }

注意:这个方法读满一行就停,绝不预读后面的字节,所以请求体一定还在流里等着。用 ISO-8859-1 拼接是 HTTP 头的标准做法,它能把任意字节无损地映射成字符,后面要做 URL 解码也不会丢信息。

4. 接入层落地:从 ServerSocket 到可用的 HTTP 响应

4.1 线程模型:为什么不能一连接一线程

最朴素的写法是 accept 到一个连接就new Thread(() -> handle(socket)).start()。这在本地测试没问题,但放到稍微有点并发的场景就不行了:线程的创建和销毁有实打实的开销,而且没有任何上限,几百个并发请求就能把线程数拉到几千,栈内存直接吃满,最后 OutOfMemoryError。

Tomcat 的做法是用线程池,对应两个关键参数:maxThreads 控制最大工作线程数,acceptCount 控制等待队列长度。手写版本我用 ThreadPoolExecutor 把这两个语义都还原出来:

this.workers = new ThreadPoolExecutor( 20, 20, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(100), new NamedThreadFactory("mini-worker"), new ThreadPoolExecutor.AbortPolicy());

这里 20 大致对应 maxThreads,队列容量 100 大致对应 acceptCount。拒绝策略用 AbortPolicy 是有意的——当队列也满了,说明系统已经过载,这时候直接把连接关掉比无限堆积、最后把内存吃干净要安全。

另外 socket 一定要设超时:socket.setSoTimeout(5000)。不设的话,一个只建立连接却不发数据的客户端就能把一个工作线程永久占住,攒够 20 个这样的连接,整个服务就假死了。这个坑在真实环境里被扫描器触发过,很隐蔽。

4.2 请求解析的完整实现

解析的目标是把一串字节变成好用的对象。我的做法是先做最小可用的 HttpRequest,只保留方法、URI、头、参数、请求体五样东西。

public class HttpRequest { private String method; private String uri; private String path; private String queryString; private final Map<String, String> headers = new LinkedHashMap<>(); private final Map<String, String> parameters = new HashMap<>(); private byte[] body = new byte[0]; public void setUri(String uri) { this.uri = uri; int q = uri.indexOf('?'); if (q >= 0) { this.path = uri.substring(0, q); this.queryString = uri.substring(q + 1); parseQuery(this.queryString); } else { this.path = uri; } } private void parseQuery(String query) { for (String pair : query.split("&")) { if (pair.isEmpty()) { continue; } int eq = pair.indexOf('='); String k = eq > 0 ? pair.substring(0, eq) : pair; String v = eq > 0 ? pair.substring(eq + 1) : ""; try { parameters.put(URLDecoder.decode(k, "UTF-8"), URLDecoder.decode(v, "UTF-8")); } catch (UnsupportedEncodingException ignored) { } } } public int getContentLength() { String v = headers.get("content-length"); return v == null ? -1 : Integer.parseInt(v.trim()); } }

有几个细节是踩过坑才加上的。头名字统一转小写存,因为 HTTP 头名不区分大小写,客户端可能发Content-Length,也可能发content-length,不统一的话取值时就要写一堆兼容逻辑。参数值一定要 URL 解码,中文和特殊字符都是百分号编码过的,不解码拿到手就是%E4%B8%AD这种字符串。

请求体的读取必须严格按 Content-Length 来,不能"读到流结束为止",因为 HTTP 长连接下流是不会结束的:

int contentLength = request.getContentLength(); if (contentLength > 0) { byte[] body = new byte[contentLength]; int read = 0; while (read < contentLength) { int n = in.read(body, read, contentLength - read); if (n == -1) { break; } read += n; } request.setBody(body); }

4.3 响应写出与 Content-Length 的坑

响应这边最容易错的就是 Content-Length。它是字节数,不是字符数。写Hello的时候字符串长度和字节长度一样,看不出问题;一旦响应里有中文,"你好"两个字符是 6 个字节,按字符串长度写 2,浏览器就会只显示前两个字节,也就是一个乱码字符。

我的做法是内部统一持有 byte[],写成字节之后长度自然就是对的:

public void flush() throws IOException { StringBuilder head = new StringBuilder(); head.append("HTTP/1.1 ").append(status).append(' ') .append(reason(status)).append("\r\n"); headers.put("Content-Length", String.valueOf(body.length)); for (Map.Entry<String, String> e : headers.entrySet()) { head.append(e.getKey()).append(": ").append(e.getValue()).append("\r\n"); } head.append("\r\n"); out.write(head.toString().getBytes(StandardCharsets.ISO_8859_1)); out.write(body); out.flush(); }

还有两点必须记住:头部结束的空行不能省,少了它浏览器会一直等,直到超时;写完一定要 flush,OutputStream 有没有缓冲取决于具体实现,不 flush 的话小响应可能一直卡在缓冲区里。至于长连接,只有在 Content-Length 或者 Transfer-Encoding 明确给出边界的情况下才能保持,两个都没有就只能写完就关,这也是 HTTP/1.0 时代必须带 Content-Length 的原因。

5. 把 Servlet 塞进去:映射、装载与生命周期

到这里 Mini Tomcat 还只能返回写死的字符串,要让它认识 Servlet,得补上三件事:接口定义、路由映射、生命周期管理。

5.1 注解扫描与 URL 到实例的映射

先定义一个极简的 Servlet 接口,把 init、service、destroy 三个方法留住,因为这三个方法正是 Servlet 规范的核心约定:

public interface MiniServlet { void init(); void service(HttpRequest req, HttpResponse resp) throws IOException; void destroy(); }

再定义一个注解,用来标注 URL:

@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface WebServlet { String value(); }

扫描的逻辑就是遍历包下面的 class 文件,反射加载,看到带注解的就实例化注册进 Map。这里我特意用了clazz.getDeclaredConstructor().newInstance()而不是已经废弃的newInstance(),后者会把构造器抛出的受检异常包装成InstantiationException,排查起来特别费劲,拿不到真实的异常栈。

public void scan(String packageName) throws Exception { String path = packageName.replace('.', '/'); URL url = Thread.currentThread().getContextClassLoader().getResource(path); if (url == null) { return; } File dir = new File(url.toURI()); for (File file : dir.listFiles()) { if (file.isDirectory()) { scan(packageName + "." + file.getName()); } else if (file.getName().endsWith(".class")) { String className = packageName + "." + file.getName() .replace(".class", ""); Class<?> clazz = Class.forName(className); WebServlet ann = clazz.getAnnotation(WebServlet.class); if (ann != null && MiniServlet.class.isAssignableFrom(clazz)) { register(ann.value(), (MiniServlet) clazz.getDeclaredConstructor().newInstance()); } } } }

提示:这段扫描只适用于 IDE 里跑、class 文件散落在目录中的情况。打成 jar 之后,getResource返回的不是 file 协议,new File(url.toURI())会直接抛异常。真实容器要处理 file 和 jar 两种协议,这也是很多人自己写扫描时最容易翻车的地方。

5.2 静态资源处理与 404 兜底

路由的匹配顺序很关键:先查 Servlet 映射,没命中再当作静态资源找,都找不到才返回 404。顺序反过来的话,静态资源目录下如果恰好有个跟 Servlet 路径同名的目录,就会互相干扰。

public void service(HttpRequest req, HttpResponse resp) throws IOException { MiniServlet servlet = servletMap.get(req.getPath()); if (servlet != null) { servlet.service(req, resp); return; } StaticResourceHandler.handle(webRoot, req, resp); }

静态资源处理里有个必须做的安全校验——路径穿越防护。请求/../../etc/passwd这种路径,如果直接拼到 webRoot 后面,就能读到服务器上的任意文件。用getCanonicalPath()规范化之后判断是否还在 webRoot 之内,是最简单可靠的做法:

File file = new File(webRoot, path); if (!file.getCanonicalPath().startsWith(new File(webRoot).getCanonicalPath())) { resp.setStatus(403); resp.write("<h1>403 Forbidden</h1>", "UTF-8"); return; }

MIME 类型也要给对,不然 css 和 js 会被浏览器拒绝执行。用一张小的映射表就够覆盖日常开发:

扩展名Content-Type
htmltext/html;charset=UTF-8
csstext/css;charset=UTF-8
jsapplication/javascript;charset=UTF-8
jsonapplication/json;charset=UTF-8
png / jpgimage/png / image/jpeg

5.3 init/service/destroy 的调用时机

Servlet 的生命周期这三个方法是容器调用的,不是我们自己调的。init 在注册时调用一次,service 每次请求调用,destroy 在容器关闭时调用一次。这个"一次"和"每次"的差别是很多 bug 的来源:把有状态的变量写在 Servlet 实例字段上,在并发请求下就会出现数据串台,因为容器默认只创建一个 Servlet 实例,多个线程共享它。

destroy 的触发在 Mini Tomcat 里靠 JVM 的关停钩子:

Runtime.getRuntime().addShutdownHook(new Thread(() -> { container.destroyAll(); workers.shutdown(); }));

顺带说一个之前很困惑我、后来才想明白的点:Tomcat 里load-on-startup配置为负数或者不配的时候,Servlet 是第一次被访问时才 init,而不是启动时就初始化。所以启动日志里看不到某个 Servlet 的初始化信息,不代表它有问题,可能只是还没被访问过。要让它随容器启动,就把 load-on-startup 设成 0 或者正整数,数字越小越先加载。

6. 手写完之后再看 Tomcat:几个设计选择的答案

代码写完跑通之后,回头再看 Tomcat 的架构图,那些原本抽象的概念会突然变得具体。

6.1 Connector 与 Container 拆开的意义

我一开始是把手写版本的"收字节"和"跑 Servlet"放在一个类里的,后来发现换协议的时候要改的地方太多,才理解 Tomcat 为什么要把 Connector 和 Container 彻底拆开。

Connector 只负责一件事:把网络字节流翻译成标准的 Request/Response 对象,再把 Response 写回字节流。它不关心 Servlet 是什么、URL 怎么映射。Container(Engine、Host、Context、Wrapper 层层嵌套)只负责 Servlet 语义:路由、生命周期、会话、安全约束,它不关心字节是从 HTTP 还是别的协议来的。这样拆开之后,换一个协议只需要换 Connector,容器部分一行不动。协议版本演进和容器逻辑解耦,这是这个设计的核心收益。

6.2 类加载器隔离解决的真实问题

每个 web 应用都会被分配一个独立的 WebappClassLoader,这个设计解决的是依赖冲突:A 应用用某个库的 1.0 版本,B 应用用 2.0 版本,如果共用一个类加载器,同名的类只能加载一次,必然有一方拿到错误版本。每个应用一个加载器,同名类就被隔离开,互不影响。

另一个作用是热部署。重新部署应用时,容器把旧的 ClassLoader 整个丢掉,新建一个重新加载,类就被卸载了。但这也是为什么反复热部署容易出现 Metaspace 溢出——只要有任何一个地方还持有旧 ClassLoader 的强引用(比如一个没关掉的线程、一个静态注册的回调),整个加载器就回收不掉,它加载的所有类元数据都留在 Metaspace 里。生产环境我一般会在启动参数里显式加上-XX:MaxMetaspaceSize,让它早点报错而不是慢慢把机器拖死。

6.3 BIO 到 NIO 的线程模型演进

我手写的那版是典型的 BIO:一个线程处理一个连接,线程读不到数据就一直阻塞。并发上来之后线程数直接爆炸。Tomcat 早期也是这个模型,后来换成了 NIO。

NIO 的核心改动在于把"等待"这件事从线程身上拿走了。少量 Acceptor 线程负责接收连接,Poller 线程用多路复用监听大量连接上的可读可写事件,真正的读写和业务处理才交给 Worker 线程池。带来的直接效果是:几万个空闲连接可能只占用几十个线程,而这些线程大部分时间在做实际工作,而不是傻等。

需要提醒的是,NIO 只解决"连接空闲时不占线程"的问题。如果业务代码本身很慢(比如一个查询要几百毫秒),Worker 线程还是会被占满,此时瓶颈在业务处理而不是 IO 模型。所以调优的时候先分清是连接数问题还是处理速度问题,再决定是调线程池参数还是优化业务代码,方向搞反了怎么调都没用。

7. 部署与配置里那些容易忽略的细节

7.1 context path 与 docBase 的对应关系

访问路径和文件目录的对应关系,本质上是 Context 的 path 属性和 docBase 属性决定的。把 war 包丢进 webapps,Tomcat 会自动解压并注册一个路径等于 war 文件名的 Context,比如app.war对应访问路径/app。如果你想让它变成根路径,就把 war 重命名为ROOT.war。

有个点现在很多人不知道:直接在 server.xml 里写<Context>标签,Tomcat 官方已经不推荐了,因为改这个文件不会触发热部署,而且每次改都容易把整个文件写坏。推荐做法是在conf/Catalina/localhost/下建一个以路径命名的 xml 文件,比如app.xml:

<Context docBase="/data/apps/myapp" reloadable="false" />

这样配置的 Context 独立成文件,改完自动生效,出问题也只影响这一个应用。

7.2 指定主页:welcome-file-list 的查找顺序

访问一个目录路径时返回哪个文件,由 welcome-file-list 决定:

<welcome-file-list> <welcome-file>index.html</welcome-file> <welcome-file>index.htm</welcome-file> <welcome-file>index.jsp</welcome-file> </welcome-file-list>

这里的匹配顺序是按列表逐个去目标目录下找文件,第一个存在的就返回。所以如果你同时放了 index.html 和 index.jsp,而想优先走 jsp,就必须把 jsp 往前排。一个小细节是,如果这个列表里所有文件都不存在,容器返回的是 404 而不是目录列表——目录列表默认是关闭的,这本身也是个安全上的好处。

7.3 SSL 双向认证的配置要点

普通 HTTPS 只验证服务端,双向认证(mutual TLS)要求客户端也出示证书。配置全在 Connector 上:

<Connector port="8443" protocol="HTTP/1.1" SSLEnabled="true" scheme="https" secure="true" clientAuth="true" keystoreFile="/data/certs/server.keystore" keystorePass="changeit" truststoreFile="/data/certs/trust.keystore" truststorePass="changeit" sslProtocol="TLS" />

几个容易配错的地方:keystore 放服务端自己的私钥和证书,truststore 放用来验证对方的 CA 证书,两者职责不能混;clientAuth有三个取值,false 表示不要求,want 表示请求客户端证书但拿不到也放行,true 表示必须且校验失败就断开,生产上双向认证要用 true;证书用 keytool 生成时注意有效期,内部系统里见过太多过期证书导致的握手失败,日志只会写一句 handshake failed,不主动去看证书有效期很容易绕远路。

排查握手问题时,浏览器只给一句模糊的提示,这时候用命令行工具带详细输出去测会清楚很多:

curl -v --cert client.crt --key client.key --cacert ca.crt https://localhost:8443/

输出里会明确指出是证书链不完整、还是客户端证书没被信任、还是协议版本不匹配,比在浏览器里猜快得多。

从把这些东西一件件踩过去,到现在能对着报错大致判断是哪一层的问题,中间最大的转变其实不是记住了多少配置项,而是养成了一个习惯:遇到报错先问"这是哪个层次的问题",然后按顺序把 JVM、容器、应用三层逐个排除。手写一遍 Mini Tomcat 的价值也在这里,它让你知道那些看起来神秘的机制——连接接入、报文解析、路由匹配、生命周期回调——拆开之后都只是些普通的代码逻辑,出错的地方也就那么几个。真要动手的话,建议别一上来就追求功能完整,先把一个 GET 请求跑通、能正确返回中文,再往上叠 Servlet 和静态资源,这样每一步出问题都容易定位。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询