model.addAttribute()这行代码看着不起眼,却是 Spring MVC 里最容易让人误判的一环。写的时候觉得数据已经塞进去了,跑起来页面上一片空白,回头翻代码发现 Controller 明明执行了;或者本地跑得好好的,换个环境、换种返回方式就全没了。这篇东西不打算复述官方文档,而是把 Model 这个概念从接口定义、实际对象、传递链路一直到模板渲染这一整条线拆开,重点回答三个问题:addAttribute 到底把数据放到了哪里,前端有哪几种方式能把它取出来,以及为什么有些写法注定取不到。适合刚接触 MVC 分层、被视图渲染折磨过一阵子的同学,也适合已经能写 CRUD 但说不清“数据怎么就出现在 JSP 上”的开发者对照排查。
1. 把 Model 这件事讲透:它到底是个什么东西
1.1 一次白屏引发的疑问:数据为什么没到页面
假设场景很普通:一个列表页,Controller 里查出一批记录,model.addAttribute("items", list),返回视图名,打开浏览器,页面上什么都没有,也不报错。这时候大部分人第一反应是“数据没查到”,于是在 DAO 层加日志,发现 list 有 20 条。第二反应是“模板变量名写错了”,改成${list}还是空。第三反应开始怀疑人生,因为这行代码在别的接口里明明能用。
问题往往不出在 addAttribute,而出在“你以为数据走到了视图,其实它走在半路就被截断了”。常见的截断点有这么几个:类上误加了@RestController或方法上误加了@ResponseBody,返回值被当成响应体直接写出去,视图解析根本没发生;返回的是redirect:前缀,重定向会新起一次请求,Model 里的东西默认不会跟过去;视图解析器前缀后缀配错,请求打到了另一个模板上;或者 Thymeleaf 缓存没关,改了模板看到的还是旧页面。
这些问题的共同点是:addAttribute本身没错,错在对它“作用范围”的理解。它塞进去的数据既不是全局的,也不是永久的,它只活在一次请求的视图渲染阶段。搞清楚这个前提,后面所有的坑基本都能自己对上号。
1.2 Model、ModelMap、ModelAndView 的血缘关系
很多人写了大半年 Controller,还是分不清 Model、ModelMap、ModelAndView 的区别,其实它们的关系很朴素。
Model是一个接口,定义在org.springframework.ui包下,核心方法就三个:addAttribute(String, Object)、addAttribute(Object)、mergeAttributes(Map)。它是个纯粹的“数据容器约定”,不关心你怎么实现。
ModelMap是Model的一个实现,而且它直接继承了LinkedHashMap<String, Object>。这一点很关键——继承 LinkedHashMap 意味着两点:一是有序,你按什么顺序放进去,遍历出来就是什么顺序,调试时方便看;二是同名 key 会覆盖,后放的会顶掉先放的,不存在“追加”这种语义。
ExtendedModelMap又继承了ModelMap,同时实现了Model接口,等于把“Map 的存储能力”和“Model 的接口约定”合并到一起。你在 Controller 方法签名里写的那个Model,运行时塞给你的实际对象并不是简单的 ModelMap,而是BindingAwareModelMap,它继承自 ExtendedModelMap,额外多了对数据绑定结果的处理能力,BindingResult能自动挂到同一个模型上,靠的就是它。
ModelAndView则是另一种思路:它是一个“值对象”,把视图名和模型数据打包成一个整体返回。用 Model 参数是“把数据填进框架给的容器”,用 ModelAndView 是“我自己造一个容器连视图名一起交出去”,效果等价,风格不同。我的习惯是:只传数据就写在方法参数里,需要动态决定视图名(比如根据条件跳不同页面)才用 ModelAndView。
一句话总结这层关系:Model 是接口约定,ModelMap 是 Map 存储,ExtendedModelMap 是两者合体,BindingAwareModelMap 是框架实际给你的那个,ModelAndView 是打包返回的另一种选择。
1.3 addAttribute 与 request.setAttribute 的差别在哪
这个问题我被问过不止一次:“既然最后都是 setAttribute 到 request,那我直接在 Controller 里注入 HttpServletRequest 调 setAttribute 不就完了?”
功能上确实能跑通,但两者不在一个抽象层级上,差别比想象中大。
第一层差别是可测试性。方法签名里写Model,单元测试时你new ExtendedModelMap()传进去就能断言里面的内容,完全不需要 Servlet 容器。写HttpServletRequest就得引入MockHttpServletRequest,测试体积立刻变大。对于只关心“数据有没有放进模型”的测试,前者干净得多。
第二层差别是与框架其他机制的协同。@ModelAttribute标注的方法、@SessionAttributes、数据绑定结果的自动挂载、RedirectAttributes的 flash 机制,这些全部围绕 Model 抽象在工作。你绕过它直接操作 request,等于放弃了这些联动。举个具体的:@Valid校验失败后,BindingResult会被自动放进模型里,模板上用${#fields.hasErrors('user.name')}就能取到错误信息。这套东西的入口就是 Model,手写 request.setAttribute 接不上。
第三层差别是语义清晰度。request.setAttribute 是 Servlet 规范的概念,Model 是 MVC 的概念。Controller 层用 Model,表达的是“我往视图模型里放数据”,而不是“我操作 HTTP 请求对象”。一个是业务语义,一个是传输层语义,混用会让代码越来越难读。
当然,这并不是说 request 就不能用。有些场景必须用它,比如拦截器里要往请求链路上下游传东西,或者需要设置HttpServletRequest原生属性给某些老组件读。但正常的视图数据传递,走 Model 是更规矩的做法。
2. addAttribute 的底层链路:从方法调用到 request 域
2.1 请求进入 DispatcherServlet 之后发生了什么
要理解为什么“有的地方能取到、有的地方取不到”,必须知道一次请求在框架内部的行走路线。下面这条链路是排坑的基础,值得多看几遍。
请求先到DispatcherServlet.doDispatch(),它做的第一件事是getHandler(),根据 URL 找到对应的 HandlerMethod 和一组拦截器。接着进入HandlerAdapter.handle(),对于注解式 Controller,实际干活的是RequestMappingHandlerAdapter。
在真正调用你的方法之前,适配器会做一件事:ModelFactory.initModel()。这一步会创建一个BindingAwareModelMap实例,再把@SessionAttributes声明的属性、@ModelAttribute方法产出的数据统统合并进去。你在方法签名里拿到的那个 Model,就是在这里被创建出来的。
然后是InvocableHandlerMethod.invokeForRequest(),反射调用你的 Controller 方法,参数解析器挨个把参数填好,你的model.addAttribute(...)就在这个阶段执行,数据进入了这个 Map。
方法返回后,ModelFactory.updateModel()会把容器里积累的模型合并进ModelAndViewContainer,同时处理SessionStatus、清空 session 属性这些收尾工作。最后getModelAndView()把视图名和模型打包成ModelAndView,交回DispatcherServlet。
DispatcherServlet拿到 ModelAndView 后分两步:先resolveViewName()找到具体的 View 对象,再view.render(mv.getModelInternal(), request, response)。
2.2 BindingAwareModelMap:真正被塞给你的那个对象
上面提到的BindingAwareModelMap值得单独说,因为它的设计解释了为什么有时候数据“莫名其妙多出来一条”。
它继承自 ExtendedModelMap,重写了get()和put()等方法。重写的目的是:当你在视图里按某个 key 取一个对象时,如果这个 key 在模型里不存在,它会去 check 一下有没有对应的BindingResult。为什么要这样?因为数据绑定失败时,BindingResult是跟目标对象分开存的,key 通常是org.springframework.validation.BindingResult.目标对象名,模板里直接写目标对象名取不到错误信息。重写 get 之后,框架帮你做了这层映射。
这个细节平时感知不到,但遇到“我明明没往 model 里放 BindingResult,为什么遍历 model 会多出几个 key”这种困惑时,答案就在这里。调试的时候把model.asMap()打出来,看到一堆BindingResult.xxx的键,不用慌,这是正常现象。
顺带说一个实用的点:因为ModelMap继承了LinkedHashMap,所以model.asMap()返回的就是一个有序 Map,遍历打日志时顺序和你 add 的顺序一致,排查“后放的覆盖了先放的”这类问题很直观。
2.3 视图渲染前的那一步 setAttribute
这是整条链路里最关键、也是最多人忽略的一步。
AbstractView.render()方法里会先调createMergedOutputModel()把静态属性和动态模型合并,然后调renderMergedOutputModel()交给子类实际渲染。对于 JSP 这种服务端模板,子类InternalResourceView在渲染前会执行exposeModelAsRequestAttributes(model, request),这个方法做的事情非常朴素:遍历模型里的每一个键值对,逐个调用request.setAttribute(key, value)。
所以,“Model 里的数据最终出现在 request 域”这句话,准确的理解是:框架在渲染视图之前,把模型整个复制进了 request attributes。前端模板取${items},本质上取的是request.getAttribute("items")。
这解释了好几件事。第一,为什么 JSP 里既可以用${items}又可以用${requestScope.items},两者指向同一个东西。第二,为什么模型数据的作用域是“一次请求”,因为 request 的生命周期就是一次请求。第三,为什么重定向取不到,因为重定向是两次独立请求,新的 request 对象里什么都没有。
Thymeleaf 的处理方式略有不同但殊途同归:SpringWebContext在构建时会同时暴露 request attributes 和 Model,所以模板里${items}依然可用。理解到“模型最终落到 request 域”这一层,换成 FreeMarker、Velocity 还是别的引擎,你都能自己推理出取值方式。
3. 前端取值的四种姿势:不同模板怎么接
3.1 JSP + EL:最原始也最直接
JSP 是最容易验证 Model 链路的载体,因为 EL 表达式直接对应 request 属性,中间没有额外转换层。
假设 Controller 里放了三种数据:一个字符串标题、一个对象、一个列表。
@GetMapping("/detail") public String detail(Model model) { model.addAttribute("pageTitle", "订单详情"); model.addAttribute("user", userService.find(1001L)); model.addAttribute("items", itemService.listByUser(1001L)); return "detail"; }JSP 里取值就三行:
<h1>${pageTitle}</h1> <p>下单人:${user.name}</p> <c:forEach items="${items}" var="it"> <li>${it.skuName} × ${it.qty}</li> </c:forEach>几个细节必须提。第一,EL 表达式${user.name}调用的是对象的 getter,不是直接读字段,所以实体类必须有getName(),用 Lombok 的@Data记得确认生成成功。第二,如果user是 null,${user.name}不会抛 NPE,而是渲染成空字符串——这个特性看着友好,实际是排查噩梦,因为页面不报错你根本不知道是数据没进来还是对象为空。第三,<c:forEach>需要引入 JSTL 标签库,<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>,漏了会直接抛出解析异常。第四,Spring Boot 项目默认不支持 JSP,要打成 war 包并引入tomcat-embed-jasper,否则视图解析会报找不到模板。这一条坑了太多从传统 SSM 项目迁过来的人。
3.2 Thymeleaf:属性优先,别写错前缀
Thymeleaf 的取值语法是${...}放在th:*属性里,而不是直接写在 HTML 文本里。这是新手最容易犯的错:把th:text写成text,或者把${}直接写在标签内容里,结果页面显示的是字面量。
<h1 th:text="${pageTitle}">默认标题</h1> <p th:text="'下单人:' + ${user.name}">下单人:</p> <ul> <li th:each="it : ${items}" th:text="${it.skuName} + ' × ' + ${it.qty}"></li> </ul>th:text和th:utext的区别要说清楚:前者会做 HTML 转义,后者不转义。数据来自用户输入时必须用th:text,用th:utext等于主动开了一个 XSS 口子。这个选择不是风格问题,是安全问题。
条件渲染用th:if,判空可以用${#lists.isEmpty(items)}。这里有个反直觉的点:th:if="${items}"在列表为空时是 true,因为空集合本身不是 null,判断的是对象是否存在而不是内容是否为空。想要“有数据才显示”,得写${not #lists.isEmpty(items)}。
还有一个高频误判:Thymeleaf 模板里取不到值时,th:text会把标签原本的内容替换成空,而th:if判断失败会保留标签里写死的静态内容。所以如果你发现自己写的占位文字被渲染出来了,说明那个表达式为 false 或者变量不存在,这其实是个很好用的调试信号。
3.3 在 JS 里拿后端数据:内联脚本与 JSON
很多页面需要把后端数据交给前端脚本处理,比如渲染图表、初始化下拉框。这时候直接在<script>里写${user.name}会出问题:一是 JSP 里 JS 字符串引号容易和 EL 冲突,二是没有转义,用户名里带个引号就把脚本整崩了。
Thymeleaf 提供了th:inline="javascript",能在脚本块里安全地序列化对象:
<script th:inline="javascript"> var user = /*[[${user}]]*/ {}; var items = /*[[${items}]]*/ []; console.log(user.name, items.length); </script>注释包裹的写法看起来怪,但它有个好处:模板被当作静态文件直接打开时,/*[[...]]*/是注释,后面的{}和[]是合法的 JS 默认值,页面不会报错。Thymeleaf 渲染时会把整段替换成真实的 JSON。
JSP 里没有这层便利,只能自己拼。稳妥一点的做法是先在后端把数据序列化成 JSON 字符串再放进 model,或者用<c:out>做转义。手写var name = "${user.name}"这种,一旦数据里出现双引号就是一地鸡毛,我在老项目里见过太多次这种事故。
3.4 前后端分离时的边界:@ResponseBody 面前 Model 是无效的
这是必须单独拎出来讲的一条,因为它是“addAttribute 完全不起作用”的最常见原因。
当方法上标了@ResponseBody,或者类上标了@RestController,请求会走RequestResponseBodyMethodProcessor这条分支。它拿到你的返回值后,直接用HttpMessageConverter序列化成 JSON 写进响应体,整个过程不经过视图解析,也不会执行exposeModelAsRequestAttributes。
也就是说:
@RestController public class ApiController { @GetMapping("/api/user") public String get(Model model) { // 这里一样能拿到 Model 对象 model.addAttribute("user", user); // 执行了,但没人消费 return "ok"; // 返回的是字符串 "ok",不是视图名 } }这段代码不会报错,Model 对象也确实存在、数据也确实放进去了,但它永远不会被渲染到任何地方。更麻烦的是,如果这个类本来应该返回视图,你误加了@RestController,那么原本return "detail"的地方会直接把字符串detail输出到浏览器,页面显示一个白底黑字的“detail”,很多人第一次遇到会以为路由配错了。
前后端分离下的正确做法是:接口只返回 DTO,数据通过响应体传递,前端用 fetch 或 axios 拿 JSON 自己渲染。Model 这一套是服务端渲染时代的产物,两条路线不要混着走。判断标准很简单:返回值要经过模板引擎,就用 Model;返回值要直接变成 JSON,就别碰 Model。
4. 从零跑通一条完整链路的实操记录
4.1 依赖与基础配置
先说环境,Spring Boot 3.x + JDK 17,模板引擎选 Thymeleaf,因为它零配置、开箱即用,适合验证链路。JSP 那一套我会在最后单独说明差异。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency>配置文件里只需要确认几项,其余走默认:
spring.thymeleaf.prefix=classpath:/templates/ spring.thymeleaf.suffix=.html spring.thymeleaf.mode=HTML spring.thymeleaf.encoding=UTF-8 spring.thymeleaf.cache=false最后一行是开发期的关键。cache=false意味着每次请求都重新解析模板,改完 HTML 刷新浏览器就能看到效果。线上要改回true,否则每次请求都走一遍解析,QPS 高的时候这部分开销不小。这个参数切换的成本很低,但收益不小,我的习惯是开发环境配置在application-dev.properties里,和生产配置彻底分开。
prefix和suffix决定了return "detail"最终解析到哪个文件。默认值就是上面的配置,所以return "detail"对应classpath:/templates/detail.html。如果你想按模块分目录,比如return "order/detail",那模板就要放在templates/order/detail.html,视图名里的斜杠直接对应目录层级。这一点搞不清楚,就会反复出现“模板明明存在却 404”的情况。
4.2 Controller 层:三种签名写法对照
同一个需求,用三种写法实现,对比着看能最快理解它们的等价性。
第一种,方法参数注入 Model,这也是最推荐的写法:
@Controller @RequestMapping("/order") public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService = orderService; } @GetMapping("/detail/{id}") public String detail(@PathVariable Long id, Model model) { Order order = orderService.getById(id); model.addAttribute("pageTitle", "订单详情"); model.addAttribute("order", order); model.addAttribute("items", orderService.listItems(id)); return "order/detail"; } }第二种,返回 ModelAndView,适合需要动态决定视图名的场景:
@GetMapping("/detail2/{id}") public ModelAndView detail2(@PathVariable Long id) { ModelAndView mv = new ModelAndView(); Order order = orderService.getById(id); if (order == null) { mv.setViewName("order/not-found"); } else { mv.setViewName("order/detail"); mv.addObject("order", order); mv.addObject("items", orderService.listItems(id)); } return mv; }第三种,@ModelAttribute方法级注解,适合多个处理方法共享数据:
@ModelAttribute("categories") public List<Category> loadCategories() { return categoryService.listAll(); }第三种写法容易被滥用。它在同一个 Controller 的每个请求方法执行前都会被调用一次,如果你在这里面做了数据库查询,等于每个接口都多一次查库。共享的是常量型数据(比如字典项)还好,如果是跟当前用户相关的数据,建议还是老老实实写在具体方法里,或者加缓存。
关于addAttribute(Object)这个重载,属性名是框架根据类型自动推出来的:取类的简名,首字母小写。User类型的对象放进去,模板里用${user}取;List<User>放进去,属性名是userList。规则能记住就用,记不住就老老实实写两参数版本,显式声明名字,永远不会错。
4.3 模板层:把值渲染出来并验证
模板放在src/main/resources/templates/order/detail.html:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title th:text="${pageTitle}">订单详情</title> </head> <body> <h1 th:text="${pageTitle}">标题占位</h1> <div th:if="${order != null}"> <p th:text="'订单号:' + ${order.orderNo}">订单号</p> <p th:text="'下单时间:' + ${order.createTime}">时间</p> </div> <ul th:if="${not #lists.isEmpty(items)}"> <li th:each="it, stat : ${items}" th:text="${stat.index + 1} + '. ' + ${it.skuName} + ' × ' + ${it.qty}"> 明细 </li> </ul> <p th:if="${#lists.isEmpty(items)}">暂无明细</p> </body> </html>几个实测下来值得记住的点。th:each的第二个变量stat是状态对象,index从 0 开始,count从 1 开始,分页展示序号时用count更省事。字符串拼接必须整体放在${}里或者用|...|字面量替换语法,'前缀' + ${var}这种写法在 Thymeleaf 3 里是合法的,但在 2.x 版本会报错,换版本时要注意。
th:if里的 null 判断用!= null而不是not empty,因为empty在 Thymeleaf 里对字符串、集合、数组的判断规则不完全一致,容易踩坑。判断集合是否为空,#lists.isEmpty()是最稳的。
4.4 验证清单:五个必须确认的点
写完代码别急着开浏览器,按这五条过一遍,能省掉八成返工。
第一,类注解是@Controller不是@RestController。这一条单独列出来,因为它导致的“页面显示返回值字符串”是最典型的症状。
第二,返回值是视图名,不带前缀后缀。写return "order/detail",不要写return "/templates/order/detail.html"。前缀后缀是配置项的事,写进返回值会导致解析出/templates/templates/order/detail.html.html。
第三,模板文件路径和视图名严格对应。templates/order/detail.html对应order/detail,大小写敏感,Windows 上可能不报错,部署到 Linux 就 404。
第四,模型 key 和模板表达式完全一致。addAttribute("orderList", ...)配${order}取,取不到是必然的。这种错误肉眼极难发现,尤其是下单和取值写在两个文件里的时候。
第五,方法确实被执行了。加一行日志,或者打个断点。很多“数据没到页面”的问题,实际是请求压根没进这个方法——路径不匹配、被安全拦截器挡了、被别的映射抢先匹配了,都有可能。
5. 踩坑实录与排查速查表
5.1 十个高频问题与对应解法
下面这张表是我这些年反复遇到、也反复帮别人排查过的问题集合,按现象归类,对着查基本能定位。
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 页面显示的是视图名字符串 | 类上误用@RestController或方法上加了@ResponseBody | 改回@Controller,去掉@ResponseBody |
| 404,提示找不到模板 | 视图名与模板路径不对应,或前缀后缀配置冲突 | 打印解析后的完整路径,逐段核对 |
| 页面能打开但变量位置空白 | key 拼写不一致,或对象为 null | 在 Controller 打印model.asMap() |
| 集合遍历没有输出 | 集合为空,或th:each变量名写错 | 先用${#lists.size(items)}输出长度验证 |
| 改了模板刷新没变化 | 模板缓存未关闭 | 开发环境设spring.thymeleaf.cache=false |
| 重定向后数据消失 | Model 只在当前请求有效 | 改用RedirectAttributes.addFlashAttribute |
数字被渲染成1,000 | 模板引擎的数字格式化默认行为 | 用#numbers.formatInteger()显式控制 |
| 中文乱码 | 编码配置不一致 | 统一 UTF-8,检查响应头与模板编码 |
| 静态资源 404 | 资源被前端控制器拦截 | 配置静态资源路径放行规则 |
| 偶发取到旧数据 | 对象被复用或被 session 属性覆盖 | 检查@SessionAttributes的声明范围 |
表格里最值得展开的是数字格式化那条。Thymeleaf 对数字类型有一套默认的本地化处理,一个Integer值 1000 渲染出来可能是1,000,如果这个值要作为参数回传或者拼进 ID,就会直接出错。解决办法是明确指定格式,别依赖默认行为。这类“看起来不是 bug 的 bug”最消耗时间,因为页面不报错,只是数据悄悄变了样。
5.2 重定向场景:为什么 Model 里的值蒸发了
这是问得最多的一个场景,值得单独讲透。
@PostMapping("/save") public String save(OrderForm form, Model model) { orderService.save(form); model.addAttribute("msg", "保存成功"); return "redirect:/order/list"; }这段代码跑起来,列表页上${msg}永远是空。原因回到第 2 节讲过的链路:redirect:前缀会触发RedirectView,浏览器收到 302 后发起一次全新的请求,新的 request 对象是干净的,上一个 request 的 attributes 自然不存在。
那为什么有时候又“看起来能用”?因为RedirectView有个appendQueryString行为(默认 true),它会把模型里基本类型和字符串的值拼到重定向 URL 的查询串上。所以msg=保存成功会变成/order/list?msg=...,页面上如果手动从参数里取,居然能拿到。但这个行为有严重的副作用:复杂对象会被toString()后拼进 URL,一个是难看,另一个是长度不可控。
算一笔账:浏览器对 URL 的实用长度限制大约在 2000 字符上下(不同实现有差异),扣掉协议、域名、路径,留给参数的空间可能只剩一千多。如果模型里有 20 个字段、每个平均 30 字符,加上 URL 编码后中文会膨胀到 3 倍长度(一个汉字编码成%XX%XX%XX是 9 个字符),很容易就顶到上限,表现是重定向后参数被截断,甚至部分服务器直接返回 414。
正规解法是RedirectAttributes:
@PostMapping("/save") public String save(OrderForm form, RedirectAttributes ra) { orderService.save(form); ra.addFlashAttribute("msg", "保存成功"); ra.addAttribute("page", 1); // 拼到 URL 上 return "redirect:/order/list"; }addFlashAttribute的机制是把数据临时存到 session 里(准确说是FlashMap,默认存在 session 中),重定向后的第一次请求把它取出来并清除,用完即焚。所以它既能跨请求传递,又不会长期占着 session。默认的闪存有效期是一次请求,如果中间隔了一次重定向没消费掉,数据就过期了。这个机制在集群环境下有个隐藏问题:session 不同步时,重定向落到另一台机器上就取不到。这一点在排查“偶发丢提示”时经常被忽略。
5.3 排查手法:确认数据到底有没有进 Model
浏览器开发者工具看不到 Model,因为服务端渲染完成后它就消失了,页面上只有渲染结果。所以排查必须回到服务端,我常用的有三招。
第一招,在 Controller 里直接打印:
model.asMap().forEach((k, v) -> log.info("model key={}, type={}", k, v == null ? "null" : v.getClass().getSimpleName()));注意不要直接log.info("model={}", model),因为 ModelMap 的toString()会递归打印内容,集合大了会刷屏,而且遇到循环引用可能报错。按 key 和类型打,信息量足够定位问题。
第二招,在模板里输出调试信息。Thymeleaf 里可以临时加一段:
<div th:text="${#request.getAttribute('order')}"></div>直接读 request 属性,如果这里能出来而${order}出不来,说明问题在 Thymeleaf 的变量解析层,不在数据传递层。这个区分很有用,能把排查范围砍掉一半。
第三招,在视图解析处打断点。DispatcherServlet.resolveViewName()返回值里能看到最终解析到的 View 对象和模型内容,IDE 里展开看一眼,视图名对不对、模型里有没有你要的 key,一目了然。这一招比任何日志都快,缺点是只在本地调试可用。
还有一个小技巧:临时在模板最前面加一行<!-- model keys: ${#vars} -->(Thymeleaf 的#vars包含所有可用变量),渲染后用浏览器查看源码就能看到全部变量名。查完之后记得删掉,别留在代码里。
我在实际项目里养成的习惯是:只要新写一个视图接口,第一件事不是写完整页面,而是先写一个最小模板,把模型里的每个 key 都th:text出来看一遍,确认全部到位再往下写业务界面。这个动作多花两分钟,能省掉后面半小时的“到底哪一步丢了”的排查。踩过几次这种坑之后会发现,视图渲染这条链路上的问题,绝大多数都不是逻辑错误,而是路径、命名、注解这三类机械性错误,提前验证一遍比事后调试划算得多。