ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Servlet + Thymeleaf 构建 Jakarta EE Web 应用实战:从 JSP 迁移到模板引擎

Servlet + Thymeleaf 构建 Jakarta EE Web 应用实战:从 JSP 迁移到模板引擎

前两天我把一个跑在 Java EE 8 上的老项目迁到了 Jakarta EE 10,顺手把原来的 JSP 页面全部换成了 Thymeleaf 模板。折腾一圈下来最大的感受是:Servlet + Thymeleaf 这套组合远没有想象中那么“老古董”,反而能把 Java Web 最核心的请求-响应流程看得明明白白。如果你正准备学 Jakarta EE,或者工作中要维护一个不依赖 Spring Boot 的 Web 模块,这篇东西应该能帮你省下不少踩坑时间。

标题里这个组合我多说一句:Servlet 负责接住 HTTP 请求、做路由和流程控制,Thymeleaf 负责把数据填进 HTML 模板并返回给浏览器。它们俩配合起来很像一个手工打造的 MVC 框架,但没有任何黑魔法。IDEA 2025 对 Jakarta EE 的支持已经很顺手,新建 Maven 工程、配置本地容器、断点调试模板渲染都不需要额外插件。这篇文章我会按从零搭项目、写控制器、套模板、排查问题的顺序走一遍,所有代码都是能直接复制运行的。

1. 环境准备与工程骨架

1.1 版本选型:JDK、容器、Jakarta EE 版本怎么搭

先说一个容易卡住新手的点:Jakarta EE 是 Java EE 改名后的规范,从 9.0 开始,包名从javax.*改成了jakarta.*。这意味着老一套 Tomcat 9 对应的是 Servlet 4.0,只能用javax.servlet.http.HttpServlet;而新版的 Tomcat 10.1 及以上对应 Servlet 6.0,必须引入jakarta.servlet.http.HttpServlet。你要是拿着旧代码直接放到新容器里跑,大概率会看到一个ClassNotFoundException: javax.servlet...,就是这个包名迁移造成的。

我在本机用的是这个组合:JDK 17 + Tomcat 10.1 + Jakarta EE 10 + Thymeleaf 3.1。模板引擎不依赖 Servlet 版本,但 Thymeleaf 3.1 专门提供了一套适配 Jakarta 的 API,老教程里常见的WebContext用法在新版里已经标记为过时,所以要挑新版本配套写法。选择 JDK 17 而不是 21,纯粹是因为当前很多生产环境云厂商镜像还是 17 为主,避免给项目埋不必要的兼容性隐患。

Maven 工程的pom.xml核心依赖长这样:

<properties> <maven.compiler.release>17</maven.compiler.release> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <dependency> <groupId>jakarta.servlet</groupId> <artifactId>jakarta.servlet-api</artifactId> <version>6.0.0</version> <scope>provided</scope> </dependency> <dependency> <groupId>org.thymeleaf</groupId> <artifactId>thymeleaf</artifactId> <version>3.1.2.RELEASE</version> </dependency> </dependencies> <build> <finalName>servlet-thymeleaf-demo</finalName> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-war-plugin</artifactId> <version>3.4.0</version> </plugin> </plugins> </build>

这里有一个特别重要的小细节:jakarta.servlet-api的 scope 必须是provided。因为这个 jar 是本地容器已经提供的,编译时需要它,打包进 WAR 里反而会和 Tomcat 自带的实现冲突。如果你在运行时遇到NoSuchMethodError或者奇怪的类加载问题,先检查自己有没有把 servlet-api 打进WEB-INF/lib。

1.2 在 IDEA 2025 里配置本地容器

IDEA 2025 自带 Tomcat/Jetty 的集成支持,不需要额外装插件。项目结构最好直接用普通 Maven 工程,不要选 Spring Initializr,因为我们要手动感受 Servlet 的创建过程。打开 Run Configuration,添加一个 Tomcat Server Local,指定本机 Tomcat 10.1 的安装目录,然后在 Deployment 标签页里把当前项目的 war exploded 加进去,Application context 填/demo。

为什么我推荐 war exploded 而不是 war?开发阶段用 exploded 目录,IDEA 可以直接把更新后的页面资源和 class 文件热同步到解压目录,省掉重新打 war 包的时间。虽然首次部署会稍微慢一点,但后续迭代的体验要好得多。

另外建议在 Tomcat 配置的 VM options 里加一行:

-Dfile.encoding=UTF-8

这一步是为了防止 Windows 控制台和 Tomcat 日志出现乱码,也避免某些环境下模板文件读取编码不一致。只要后面代码里所有涉及输入输出的地方都统一用 UTF-8,中文界面基本不会出幺蛾子。

2. 核心思路:为什么选 Servlet + Thymeleaf

2.1 从 JSP 迁移到模板引擎的理由

很多老项目从 Java EE 8 迁移到 Jakarta EE 后,JSP 依然能跑,但如果你打算新写一个轻量模块,我更推荐 Thymeleaf。JSP 在真实开发里常见的问题有三个:第一,JSP 页面里可以嵌入任意 Java 代码,团队里总有人忍不住写一堆业务逻辑,页面和 Controller 的边界变得很模糊;第二,JSP 需要容器在运行时编译成 Servlet,IDE 对复杂脚本的提示和重构能力很有限,报错信息也不直观;第三,前端设计师根本没法直接打开 JSP 看效果,因为浏览器认不出<% %>标签。

Thymeleaf 走的是另一个路线:它本身就是一个 HTML 文件,浏览器直接打开可以看到静态原型效果,服务端渲染时才把th:text、th:each这些属性替换成真实数据。用一句话概括就是“浏览器能看到什么,模板里就是什么”。这带来的直接好处是前后端并行开发的时候,前端可以拿同一份模板写样式,后端再动态填数据,沟通成本低很多。

2.2 Servlet 做控制,Thymeleaf 做视图

如果从 MVC 分层看,Servlet 扮演的是 Controller。浏览器发来请求后,Servlet 的doGet或doPost方法被容器调用,我们在这个方法里拿到请求参数、调用背后的 Service 层、组装数据,然后把数据塞给 Thymeleaf 的 Context,最后由 TemplateEngine 渲染出完整 HTML,用响应输出流写回浏览器。整个过程非常直接,没有任何框架的自动映射,所以非常适合理解 Java Web 最基础的链路。

这里要特别区分“转发”和“重定向”。在 JSP 时代,我们常用request.getRequestDispatcher("/xxx.jsp").forward(req, resp),这是服务端内部转发,浏览器 URL 不会变,而且整个请求生命周期里共享同一个 Request 对象。Thymeleaf 的渲染更像是“看起来像在转发”:你带着数据直接调用模板引擎输出页面内容,浏览器地址栏还是最初请求的地址。但是遇到表单提交后的场景,建议用response.sendRedirect()重定向到另一个 URL,然后再让那个 URL 对应的 Servlet 渲染列表页。这背后的逻辑是防重复提交:如果提交后直接渲染提交结果页,用户刷新时表单数据会被再次提交。用重定向加 Post/Redirect/Get 模式,刷新时只是重新 GET 一下,安全得多。

3. 动手搭建:Servlet + Thymeleaf 第一个可运行项目

3.1 项目结构:模板放在哪最合适

先看目录结构。模板文件我建议放在src/main/webapp/WEB-INF/templates/,不要放在src/main/resources/。原因很简单:WEB-INF目录是 Servlet 容器约定禁止浏览器直接访问的目录,放在里面的 HTML 只能由服务端代码读取和渲染。这就保证了模板不会作为静态资源被用户直接下载或预览。

src/main/java/ com/example/demo/ servlet/ UserListServlet.java UserFormServlet.java service/ UserService.java web/ ThymeleafUtil.java src/main/webapp/ WEB-INF/ templates/ user-list.html user-form.html static/ css/ app.css js/ app.js

静态资源 CSS/JS 可以放在webapp/static/下,容器会直接按文件路径处理。注意模板文件与静态文件分开,别都堆在webapp根目录,后面打包部署时你会感谢这个结构。

3.2 封装 Thymeleaf 引擎工具类

Thymeleaf 的初始化不是说每次渲染都 new 一个 TemplateEngine,那样既慢又浪费。比较标准的做法是把 TemplateEngine 做成单例,在第一次访问时基于 ServletContext 创建。ServletContext 能帮我们拿到 webapp 的物理路径信息,这样模板解析器才知道去WEB-INF/templates/找文件。

在 Thymeleaf 3.1 里,推荐这样写:

package com.example.demo.web; import jakarta.servlet.ServletContext; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.thymeleaf.TemplateEngine; import org.thymeleaf.context.WebContext; import org.thymeleaf.templateresolver.ServletContextTemplateResolver; import org.thymeleaf.web.servlet.JakartaServletWebApplication; import java.io.IOException; import java.util.Map; public class ThymeleafUtil { private static TemplateEngine engine; private ThymeleafUtil() { } public static synchronized TemplateEngine getEngine(ServletContext context) { if (engine == null) { ServletContextTemplateResolver resolver = new ServletContextTemplateResolver(context); resolver.setPrefix("/WEB-INF/templates/"); resolver.setSuffix(".html"); resolver.setTemplateMode("HTML"); resolver.setCharacterEncoding("UTF-8"); resolver.setCacheable(false); engine = new TemplateEngine(); engine.setTemplateResolver(resolver); } return engine; } public static void render(String template, HttpServletRequest request, HttpServletResponse response, Map<String, Object> variables) throws IOException { response.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); ServletContext servletContext = request.getServletContext(); JakartaServletWebApplication application = JakartaServletWebApplication.buildApplication(servletContext); WebContext context = new WebContext( application.buildExchange(request, response), request.getLocale() ); context.setVariables(variables); getEngine(servletContext).process(template, context, response.getWriter()); } }

这段代码解决了三个痛点:单例引擎避免重复创建;统一了响应编码;把 HttpServletRequest 和 HttpServletResponse 包装成 Thymeleaf 的交换对象,兼容 Jakarta 命名空间。老教程里那种直接用new WebContext(req, resp, servletContext)的写法在 3.1 里还有,但已经过时,建议别混用旧 API。

3.3 写一个能渲染数据的 Servlet

接下来用一个简单的“模拟客户管理系统”用户列表举例。假设我们已经有一个UserService,它返回一组用户对象。在 Servlet 里只需要取数据、放变量、渲染模板:

package com.example.demo.servlet; import com.example.demo.service.UserService; import com.example.demo.web.ThymeleafUtil; import jakarta.servlet.annotation.WebServlet; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.List; @WebServlet("/user/list") public class UserListServlet extends HttpServlet { private final UserService userService = new UserService(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { List<String> users = userService.listNames(); ThymeleafUtil.render("user-list", req, resp, Map.of( "users", users, "title", "用户列表" )); } }

这里有一个新手常踩的坑:@WebServlet("/user/list")写的是虚拟路径,而不是模板名称。模板名称由 Thymeleaf 的 prefix/suffix 拼出来,也就是user-list会映射到/WEB-INF/templates/user-list.html。很多同学第一次写完,访问/demo/user/list报 404,却以为是 Servlet 路径写错了,实际上可能是模板文件名写成了userList.html,和 Engine 配置的-list对不上。

3.4 表单提交与重定向处理

光有列表不够,再加一个新增用户的表单。Servlet 的doPost接收参数后调用 Service,然后重定向回列表页:

@WebServlet("/user/add") public class UserAddServlet extends HttpServlet { private final UserService userService = new UserService(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { ThymeleafUtil.render("user-form", req, resp, Map.of( "action", req.getContextPath() + "/user/add", "user", null )); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { String name = req.getParameter("name"); if (name != null && !name.isBlank()) { userService.addName(name.trim()); } resp.sendRedirect(req.getContextPath() + "/user/list"); } }

重定向 URL 一定要用req.getContextPath()拼接,不要写死/demo/user/list。因为部署到生产环境时 context path 可能变成某个 war 包名,甚至改成一个自定义路径,写死就会 404。

4. 页面模板语法与实际渲染场景

4.1 必须掌握的核心属性

Thymeleaf 属性很多,但纯 Servlet 项目里最常用的就几个:th:text输出文本,th:each循环,th:if条件判断,th:href动态链接,th:value回显表单值。比如用户列表页的核心结构:

<!DOCTYPE html> <html lang="zh-CN" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title th:text="${title}">默认标题</title> <link rel="stylesheet" th:href="@{/static/css/app.css}" href="/static/css/app.css"> </head> <body> <h1 th:text="${title}">用户列表</h1> <table> <thead> <tr> <th>ID</th> <th>姓名</th> </tr> </thead> <tbody> <tr th:each="u, iter : ${users}"> <td th:text="${iter.count}"></td> <td th:text="${u}"></td> </tr> </tbody> </table> <a th:href="@{/user/add}">新增用户</a> </body> </html>

th:each="u, iter : ${users}"里的iter是循环状态对象,iter.count从 1 开始计数,适合做序号。注意${u}如果是字符串,直接输出;如果是对象,模板里通常写${u.name}访问属性。这个过程有自动 HTML 转义,如果用户输入了<script>标签,输出时会变成&lt;script&gt;,这是默认的安全行为。不要轻易用th:utext,除非你非常确定内容的来源绝对可控。

4.2 用 @{} 处理所有路径问题

前面已经踩过路径写死的坑,模板里同样要注意。th:href="@{/static/css/app.css}"最终解析时会自动带上当前应用的 context path,所以就算应用部署成/demo,上面的链接也能变成/demo/static/css/app.css。用@{}包裹的 URL 就是 Thymeleaf 的 URL 表达式,它可以安全生成带 query 参数的链接:

<a th:href="@{/user/detail(id=${user.id})}">查看详情</a>

上面会生成类似/demo/user/detail?id=5的地址。如果不用@{}而直接写字符串拼接,一旦 context path 变化,页面里的链接就全断了。我建议在模板中凡是需要指向项目内资源的href、src、action,一律用@{}。

4.3 编辑表单回显与常见坑

表单纯 Servlet 实现时,th:field用不了,因为th:field是给 Spring MVC 绑定表单用的,普通 Servlet 环境没有那个数据绑定器。所以回显要手动用th:value和th:selected。比如编辑用户页面:

<input type="text" name="name" th:value="${user != null ? user.name : ''}"> <select name="role"> <option value="admin" th:selected="${user != null && user.role == 'admin'}">管理员</option> <option value="normal" th:selected="${user != null && user.role == 'normal'}">普通用户</option> </select>

这里有个细节:${user != null ? user.name : ''}是 Thymeleaf 的可选链表达式。因为新增页面传入的user变量是null,如果不做判空,渲染时会直接报错。很多初学者一打开编辑页就出现 SpelEvaluationException 或类似的模板错误,八成是变量为空没有处理。

5. 调试经验与常见问题排查

5.1 自己记一份报错速查表

我用这套组合时整理过一张排查表,基本覆盖了最常见的几类问题:

现象可能原因解决方案
访问路径 404Servlet 注解路径和模板名字不匹配检查@WebServlet路径与 Engine prefix/suffix 拼接结果
模板文件 404模板不在WEB-INF/templates/下确认文件在 war exploded 目录里存在
报错jakarta.servlet找不到类容器版本是 Tomcat 9 或以下换成 Tomcat 10.1+,或改回javax.*
中文乱码response.getWriter()写入前没设置编码在 render 方法里先setCharacterEncoding("UTF-8")
页面显示源码Content-Type 不是text/html设置resp.setContentType("text/html;charset=UTF-8")
改了模板没有效果开发期模板缓存没关resolver.setCacheable(false)

其中模板缓存这一点最隐蔽。Thymeleaf 默认在生产模式下会缓存解析后的模板文件,如果你在 IDEA 里改了 HTML 但刷新页面还是老样子,第一反应就是缓存。开发期用我前面的setCacheable(false),部署到生产再打开缓存。

5.2 静态资源被 Servlet 拦截怎么办

有同学为了省事把 Servlet 路径写成@WebServlet("/*"),之后发现 CSS、JS、图片全部加载失败。原因很简单:/*把整个应用的所有 URL 都接管了,Tomcat 内置的 DefaultServlet 不再有机会处理静态资源。不要用这种写法,推荐更精确的映射:列表页用/user/list,新增用/user/add,表单提交用/user/save。如果确实需要做统一的登录拦截,用 Filter 而不是用根路径 Servlet。

如果已经有大量静态资源被一个路径前缀统一管理,可以单独写一个 Filter,在请求路径以/static/开头时直接放行,或者把静态资源拆出去交给 Nginx 处理。小型项目里最简单粗暴的办法就是把静态文件路径改成/assets/*并让 Web 容器直接处理,确保 Servlet 映射别碰到它。

5.3 IDEA 开发期热更新的实际体验

IDEA 2025 配合 war exploded 部署时,修改 Thymeleaf 模板后按一下Ctrl+F10,选择 Update resources,就能把模板文件同步到 Tomcat 的解压目录,刷新浏览器立即生效。Java 类的方法体修改也可以热替换,但如果你增加了新的字段、方法,或者改了 Servlet 的@WebServlet注解,就需要选择 Update classes and resources 触发重新加载。

有一点要特别提醒:热更新并不等于 Tomcat 重启,有时候 ThreadLocal 或者静态变量的状态会残留在旧 classloader 里,导致诡异的问题。遇到改代码后状态不对,别纠结,直接重启 Tomcat,再对比看是否稳定复现。

6. 测试、打包上线与后续演进

6.1 让 Servlet 逻辑薄到可以测

Servlet 本身依赖 Request/Response 对象,不太好单测。一个实用的做法是:把“取参数、调服务、组装数据”这一步尽量控制在 Servlet 里只做几行,真正的业务判断放在一个独立的 Service 类中。Service 不依赖 Servlet API,就可以直接用 JUnit 测试。用户列表的 Service 大概是这样:

public class UserService { public List<String> listNames() { return List.of("张三", "李四", "王五"); } }

这个类没有HttpServletRequest的痕迹,测试时直接 new 出来调用方法即可。如果项目后续需要写更完整的集成测试,可以考虑在 Maven 里加 Jetty 插件或 Tomcat 插件,启动嵌入式容器后发真实的 HTTP 请求验证端到端流程。那时候 Servlet 更像一扇门,真正值得测的是门后面的业务类。

6.2 打包部署:war exploded 到生产环境

开发阶段用 war exploded,交付时还是要打成标准 war 包。在 IDEA 右侧 Maven 面板执行mvn clean package,最终在target/servlet-thymeleaf-demo.war会生成一个可直接部署的文件。把这个 war 放到 Tomcat 的webapps目录下,启动后访问路径就是 war 包名对应的 context path。

上线前记得把ThymeleafUtil.getEngine里的setCacheable(false)改成setCacheable(true),或者通过环境参数动态控制,否则每个请求都重新读模板文件,白白浪费 IO。日志方面也建议在 Servlet 入口加一行访问日志,记录请求路径、耗时和状态码,排查线上问题时会方便很多。

6.3 再往后能演进成什么样

这套 Servlet + Thymeleaf 是了解 Jakarta Web 基础的极佳入口。如果项目继续变大,可以逐步引入 Jakarta CDI 管理 Servlet 里的依赖,用 Jakarta Persistence 做数据库访问,再配合 Bean Validation 校验请求参数。这些规范组合在一起基本就是一个轻量但完整的 Jakarta EE 应用。

当然,实际开发中很多人最后会投入到 Spring Boot 的怀抱。但有一点我可以负责任地说:理解了 Servlet 的请求生命周期和模板引擎的渲染机制之后,再看 Spring MVC 的 DispatcherServlet、视图解析器、ModelAndView这些概念,你会觉得它们不过是把今天这套手工流程框架化、工程化了而已。基本功扎实了,后面学任何框架都快。

最后分享一个个人习惯:我会在 IDEA 里同时打开模板文件和浏览器预览窗口,先用静态假数据把页面样式调好,再写 Servlet 数据填充代码。因为 Thymeleaf 模板天然支持浏览器直开,前端调界面真的可以完全不用启动后端服务。单独跑 Servlet 时,优先在一个页面里把列表和表单都做出来,然后再去拆文件、加权限、接数据库。等你在这种小而美的结构里把请求、响应、渲染、重定向都摸透了,再回去看 Java Web 这个领域,会发现很多东西一下子就通了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进