作用详解:前端如何获取Controller Model中的值)
第一次在 Controller 里看到model.addAttribute(user, user)这行代码时我盯着它看了好一会儿也没想通一个方法参数我往里丢了个对象页面怎么就凭空取到了它到底存在哪是这个请求独有的还是全应用共享的后来把 DispatcherServlet 的源码翻了一遍才明白Model 这玩意儿既不神秘也不高级它就是一次请求里被顺手递过来的一个 MapaddAttribute()干的事约等于map.put()。真正让新手卡住的地方不在后端而在“我明明 add 了前端怎么取不到”。这篇就围绕model.addAttribute() 的作用把前端获取后端 Controller 中 Model 里的值这件事从头到尾捋一遍它存在哪、生命周期多长、不同模板引擎的取法差在哪、前后端分离之后为什么突然不灵了以及我踩过的那些“代码看着没错但页面就是空的”坑。1. Model 是请求链路里被顺手递进来的一个数据篮子1.1 从敲下回车到模板拿到数据中间到底走了几步我们假设浏览器请求/product/detail?id1001最后页面把商品名渲染出来了。这条链路里Model的出生点其实非常靠后。请求先进DispatcherServlet的doDispatch()它在里面先根据 URL 找到对应的 HandlerMethod再挑一个能处理它的 HandlerAdapter然后创建一个叫ModelAndViewContainer的容器对象——注意Model 就诞生在这个容器里它是容器上的一个字段本质上是一个BindingAwareModelMap实例。接着 HandlerAdapter 去执行你的 Controller 方法执行之前有一批HandlerMethodArgumentResolver会挨个检查方法签名上的每个参数其中有个叫ModelMethodProcessor的解析器专门认Model和Map这两种类型它返回的正是mavContainer.getModel()这个现成对象。所以你方法签名上写的Model model并不是框架 new 给你的一个新东西而是把一个早就建好的 Map 的引用交给了你。你在里面addAttribute就是在往这个 Map 里塞键值对塞完方法返回容器带着这个 Map 继续往下走。随后的流程是拿到ModelAndView交给ViewResolver解析出具体的View实现最后调用view.render()。渲染发生在AbstractView.render()里它会先调用exposeModelAsRequestAttributes(model, request)这个方法做的事朴素得让人想笑——遍历 model 里的每一个 entryrequest.setAttribute(name, value)如果 value 是 null 就执行request.removeAttribute(name)。走到这里谜底就全揭开了。你那句model.addAttribute(userName, 张三)最终等价于request.setAttribute(userName, 张三)。JSP 里的${userName}取值取的就是 request 域的属性Thymeleaf 的${userName}也是从 request 属性上找Thymeleaf 3 与 Spring 集成后走WebEngineContext查找顺序里 request attribute 排在很靠前。这就是为什么两套看起来风格完全不同的模板引擎取值语法却都是${}——它们最终读的是同一个地方。1.2 Model、ModelMap、ModelAndView、Map四种写法怎么选新手看教程会看到四种不同风格的写法容易懵。它们其实都指向同一个容器区别只在于“你能不能顺手把视图名也定了”。我按实际使用感受列个表写法方法签名示例能不能设视图名适用场景我的评价ModelString detail(Model model)不能靠返回值绝大多数服务端渲染接口接口最干净首推ModelMapString detail(ModelMap map)不能需要addAll批量塞数据功能一样代码略啰嗦ModelAndViewModelAndView detail()能一个方法可能返回多个不同视图老项目常见新代码少用MapString,ObjectString detail(MapString,Object map)不能兼容老写法能跑但语义不明确不推荐Model和ModelMap的关系是接口与实现谱系Model是接口ModelMap实现了它ExtendedModelMap又继承了ModelMap并额外实现Model最后BindingAwareModelMap再继承ExtendedModelMap链路最底端是LinkedHashMapString, Object。也就是说Model 底下就是一个有序的 Map插入顺序保留遍历时按你 add 的顺序出来这一点在做“按顺序渲染菜单”之类需求时偶尔能省事。ModelAndView的取舍我有个具体建议只有在“一个方法根据条件去不同视图”时才值得用比如根据参数决定渲染 PC 页还是移动页。否则老老实实返回String视图名Model当参数代码可读性明显更好。至于Map参数Spring 确实支持ModelMethodProcessor的supportsParameter对Map也返回 true但它在 Spring 4.1 之后行为变过——曾经返回Map时会被当作模型数据现在返回Map会被当成响应体处理前提是配合消息转换器。这种历史包袱导致的隐式行为差异是排查问题时最费神的一类能不用就不用。提示如果你在方法签名上同时写了Model和HttpServletRequest两者拿到的是同一个 request往 model 里 add 的东西和直接request.setAttribute()效果一样但优先级上 model 里的值会在渲染时覆盖同名 request 属性因为exposeModelAsRequestAttributes是在渲染前执行的。2.model.addAttribute()的两个重载差别比想象中大2.1 一个参数还是两个参数决定了属性名谁来定Model接口上有两组addAttribute重载这是很多人只知其一的地方public interface Model { // 两个参数属性名自己指定 Model addAttribute(String attributeName, Nullable Object attributeValue); // 一个参数属性名由框架根据值的类型推断 Model addAttribute(Object attributeValue); // 批量 Model addAllAttributes(Collection? attributeValues); Model addAllAttributes(MapString, ? attributes); Model mergeAttributes(MapString, ? attributes); boolean containsAttribute(String attributeName); }两个参数的版本最常用行为也最直白第一个参数就是 key第二个是 value。这里有两个容易被忽略的细节。第一attributeName做了校验传null或者空串会直接抛IllegalArgumentException内部是Assert.notNull加Assert.hasText也就是说你没法用空名字塞数据。第二attributeValue传null是合法的不会报错但渲染阶段exposeModelAsRequestAttributes遇到 null 值会执行 removeAttribute等于这个属性在页面里彻底不存在。这个设计的用意是防止旧值残留——比如你先addAttribute(msg, 成功了)下次请求又addAttribute(msg, null)如果不 remove页面上可能还挂着上次的提示。理解了这一点你就能明白为什么“我明明 add 了个 null页面却报属性找不到”。一个参数的版本属性名由Conventions.getVariableName(value)推断规则大致是取对象的短类名并把首字母小写new User()得到usernew OrderDetail()得到orderDetail。如果是数组或集合还会做复数化处理。这个便利性看着美好坑却不少你塞一个String进去属性名会变成string塞一个Integer变成integer。这类名字既没有业务含义又极易和别的属性撞名而且同名覆盖是静默发生的不报错只表现为“值莫名其妙变了”。我自己的规矩是——除了在极简 demo 里永远用带名字的两参数版本多敲几个字符换来的是半年后还能看懂。2.2 底层容器长什么样验证一下比背结论靠谱光看文档说服力不够我习惯直接打个断点或者打一行日志确认。你可以在任意一个 Controller 方法里加这么一行GetMapping(/probe) public String probe(Model model) { System.out.println(实际类型: model.getClass().getName()); System.out.println(是不是 Map: (model instanceof java.util.Map)); System.out.println(是不是 LinkedHashMap: (model instanceof java.util.LinkedHashMap)); return probe; }打出来大致是这样实际类型: org.springframework.validation.support.BindingAwareModelMap 是不是 Map: true 是不是 LinkedHashMap: trueBindingAwareModelMap这个名字里的 Binding 说的是它跟数据绑定、校验结果有关——Valid校验失败后放进 model 的BindingResult就存在这里这也是为什么校验方法里那个BindingResult参数必须紧跟在被校验对象后面因为它要从同一个模型容器里取对应的结果。知道这层关系你在排查“校验错误信息怎么传到页面”时就不会一头雾水。另外提醒一个版本差异Model.asMap()这个方法是 Spring 5.3 才开始提供的早期版本只能靠强转成Map。如果你在维护一个很老的工程别直接抄新写法。3. 前端取值按视图技术逐个数3.1 JSP 加 EL最老也最直白的一路JSP 场景下取值语法就是 EL 表达式。后端model.addAttribute(user, user)页面写${user.name}就是这么直接。原因前面说过值最终落到了 request 属性上。集合的遍历配 JSTL!-- productList.jsp -- ul c:forEach items${productList} varp varStatusst li${st.index} - ${p.name} - ${p.price}/li /c:forEach /ul这里有个新手必踩的坑EL 表达式对 null 是“静默空白”。${user.name}在user为 null 时不会报错页面就显示一片空白。很多人就是被这个特性误导以为“页面没报错说明后端传过去了”其实压根没传。我排查这类问题时第一反应不是看模板而是先在页面最顶上打一行${pageContext.request.attributeNames}或者干脆${user}输出整个对象确认属性到底在不在 request 里。JSP 还有个更隐蔽的${user}输出的是toString()结果如果你的实体类没重写toString()看到的是com.example.dto.User1a2b3c这样的地址串别慌这说明对象到了只是没格式化。3.2 Thymeleaf${}从哪取th:each怎么配合Thymeleaf 是我现在的主力选择取值语法长得跟 EL 很像但内部机制不同。它靠SpringStandardDialect把表达式委托给 SpringEL 求值变量查找的上下文里Spring 集成层会把 model 里的内容以及 request attribute、session attribute都暴露成可用变量。!-- product/detail.html -- div th:text${product.name}占位文本/div div th:text|价格${product.price} 元|/div !-- 列表 -- tr th:eachp, st : ${productList} td th:text${st.count}/td td th:text${p.name}/td /tr !-- 对象绑定写法 -- form th:object${product} th:action{/product/save} methodpost input th:field*{name} / input th:field*{price} / /formth:object加*{}这一套是 Thymeleaf 的特色用起来比每次都写${product.name}干净尤其在表单回显场景。不过它有个前提th:object后面的表达式必须能解析出对象否则*{}全废。而th:field更挑它需要对象上有对应的 getter/setter否则渲染时报错。新手在 Thymeleaf 上最容易犯的错是不写th:前缀直接写${product.name}放在 HTML 文本里——那样 Thymeleaf 根本不会处理页面原样输出字符串${product.name}。看到页面上出现带花括号的原始文本基本就是这个问题。再补一个实用细节Thymeleaf 有个th:if判断属性是否存在写th:if${product ! null}而不要写th:if${product}——后者在对象非 null 但所有字段都为空时某些表达式的求值行为会让你意外虽然对象本身是 truthy但配合集合可能产生歧义。我习惯显式写! null可读性也更好。3.3 FreeMarker 这类模板别忘了空值保护FreeMarker 的语法是${product.name}看着也像但它对 null 的处理跟 EL 完全相反——null 或缺失变量会直接抛异常页面报InvalidReferenceException。所以 FreeMarker 里必须写默认值#-- 空值用感叹号给默认值 -- div${product.name!未命名}/div #-- 列表遍历 -- #list productList as p li${p.name} - ${p.price!0}/li /#list这个差异很值得记EL 和 Thymeleaf 对缺失变量容忍FreeMarker 不容忍。所以从 JSP 迁移到 FreeMarker 的项目上线后经常一片 500全是因为历史代码里那些没做空判断的表达式。3.4 前后端分离之后Model 为什么突然不灵了这是我最常被问的一个问题也是标题里这件事真正容易翻车的地方。很多同学跟着教程写了个能用model.addAttribute()的接口然后前端改成fetch调同一个 URL结果后端返回的是整段 HTML或者干脆报 404。原因就一句话Model是给视图渲染用的一旦响应走的是消息转换器Model 完全不会参与。具体判断标准很简单如果你的类上标了RestController或者方法上标了ResponseBody那返回值会被HttpMessageConverter直接序列化成 JSON 写进响应体DispatcherServlet压根不走processDispatchResult里的视图渲染分支exposeModelAsRequestAttributes自然不会执行。此时你addAttribute塞进去的东西既不在响应体里也没人读它等于白干。那分离架构下怎么把数据传给前端三条路我按推荐度排直接返回对象public ResultProductVO detail(RequestParam Long id)把数据装进统一响应体。这是标准做法语义最清晰。返回 Mappublic MapString,Object detail(...)塞多个字段时省得定义 VO适合临时接口。公共数据用ModelAttribute方法类里定义一个ModelAttribute(categories) public ListCategory categories()它会在每个handler 执行前跑一次并把结果塞进 model——注意这个注解在RestController下依然会执行但塞进 model 的值同样不会被序列化所以它只对服务端渲染的页面有用分离场景下要用ModelAttribute给方法参数做绑定那是另一回事。还有一种半分离的情况接口返回 JSON 但页面是服务端渲染的混合架构。比如列表页服务端渲染点“加载更多”走 Ajax。这种时候要明确区分页面初始加载的那个 URL 必须由Controller处理并返回视图名Ajax 调的 URL 必须是ResponseBody返回 JSON。两个映射路径最好分开比如/product/list返页面、/api/product/list返数据混用同一个路径是自找麻烦。最后说一个折中场景有时候确实想让 JSON 接口也带上一点“页面级”的数据比如枚举下拉框选项。做法是在ControllerAdvice里用ModelAttribute往 model 里放然后在服务端渲染的 Controller 里取出来再塞进返回对象。听着绕其实就是一次手动搬运别指望框架自动帮你把 model 序列化进 JSON。4. 一个商品详情页的完整实操从 Controller 到页面渲染4.1 场景设定与依赖准备我拿一个最典型的场景走一遍商品详情页需要展示商品基本信息、规格参数列表、所属分类名还要给页面顶部塞一个当前登录用户昵称。数据来源假设是一个仓储类这里用内存数据模拟重点是链路不是持久层。依赖就两个Thymeleaf 场景下不需要额外配什么!-- pom.xml 片段 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency配置里我只改两项都是开发期为了调试方便# application.yml spring: thymeleaf: cache: false # 开发期关缓存改完模板刷新即生效 prefix: classpath:/templates/ suffix: .html encoding: UTF-8 server: port: 8080cache: false这一项值得单独强调。默认情况下 Thymeleaf 会缓存已解析的模板你改完 HTML 重启都没用热部署不生效时页面还是老样子很容易怀疑“是不是 Model 没传”。我一开始学的时候在这个点上浪费过整整一个下午反复检查 Controller 有没有写错最后发现是缓存。生产环境记得改回true不然每次请求都重新解析模板性能损耗不小。4.2 Controller 层把数据分门别类塞进 ModelController RequestMapping(/product) public class ProductController { private final ProductService productService; public ProductController(ProductService productService) { this.productService productService; } GetMapping(/detail) public String detail(RequestParam(id) Long id, Model model) { ProductVO product productService.findById(id); if (product null) { // 找不到就跳错误页别让模板拿到 null 去渲染 model.addAttribute(errorMsg, 商品不存在或已下架); return error/404; } // 1. 主对象 model.addAttribute(product, product); // 2. 列表数据注意属性名要和模板里一致 model.addAttribute(specList, product.getSpecs()); // 3. 单个字符串用来做面包屑 model.addAttribute(categoryName, product.getCategoryName()); // 4. 当前登录用户简化处理实际从会话或上下文取 model.addAttribute(nickname, currentNickname()); // 5. 页面标题顺便演示 addAllAttributes 的用法 MapString, Object pageMeta new LinkedHashMap(); pageMeta.put(pageTitle, product.getName()); pageMeta.put(pageKeywords, product.getKeywords()); model.addAllAttributes(pageMeta); return product/detail; } private String currentNickname() { return 访客; } }这段代码里有几个刻意的设计值得说明理由。第一失败分支提前 return。我不让模板去处理product null的情况而是在 Controller 层就分流到错误页。原因是模板里做 null 判断会写得很啰嗦而且一旦漏判EL 的静默特性会让页面看起来“渲染成功了但啥也没有”排查成本极高。在 Java 层把空值挡掉模板只管展示职责清楚。第二属性名和模板里的变量名严格对应。这不是废话是血泪。我习惯在 Controller 里写完后立刻去模板把对应的th:text写上两边对照着看而不是先写完 Controller 再写模板。中间隔了半小时回来谁还记得自己当初写的是specList还是specs。属性名不一致的表现是页面不报错那一段就是空白因为 Thymeleaf 找不到变量时返回 nullth:text输出空串。第三addAllAttributes(Map)的使用场景。数据多的时候用它批量塞代码整齐。但要注意mergeAttributes和addAllAttributes的差别前者遇到同名 key 会覆盖已有值后者不会覆盖——addAllAttributes内部走的是putIfAbsent逻辑。如果 pageMeta 里手滑写了pageTitle而这个 key 之前已经 add 过用addAllAttributes是不会生效的。这个细节文档里写得不显眼实测过才知道。4.3 模板层把 Model 里的值一个个取出来!DOCTYPE html html xmlns:thhttp://www.thymeleaf.org head meta charsetUTF-8 title th:text${pageTitle}默认标题/title meta namekeywords th:content${pageKeywords} / /head body !-- 面包屑用 categoryName -- div classbreadcrumb span th:text${categoryName}分类/span span / /span span th:text${product.name}商品名/span /div !-- 主信息区 -- h1 th:text${product.name}商品名称/h1 p价格span th:text${product.price}0.00/span 元/p p当前用户span th:text${nickname}匿名/span/p !-- 规格列表 -- table tr th:eachspec : ${specList} td th:text${spec.key}规格名/td td th:text${spec.value}规格值/td /tr /table !-- 条件渲染库存为 0 时给出提示 -- p th:if${product.stock 0} stylecolor:#c00;该商品暂时缺货/p /body /html模板这块有个我早期特别容易搞混的点th:each里的spec是循环变量它不存在于 model 里是 Thymeleaf 在遍历时临时创建的。而${specList}才是 model 里的属性名。新手常犯的错是写成th:eachspec : ${spec}那就会去 model 里找spec找不到直接抛异常。还有th:if的写法${product.stock 0}比${product.stock eq 0}更通用后者在某些老版本表达式解析器下会有坑数字比较我建议就用。4.4 实测记录几种写法的输出结果对照我把几种常见改法都跑了一遍记录下实际现象方便你对照自己的项目改动页面表现原因分析addAttribute(specList, null)规格表格整段消失无报错渲染时 null 触发 removeAttribute属性直接不存在Thymeleaf 里写${specs}但后端存的是specList表格空白页头正常变量名不匹配Thymeleaf 返回 null类改成RestController浏览器直接显示一段 JSON 或 404走了消息转换器视图渲染被跳过返回product/detail但模板放在templates/product/之外报 500提示模板无法解析视图解析器按 prefix 视图名 suffix 拼接路径方法参数写MapString,Object map并 addAttribute服务端渲染场景正常返回 Map 时行为异常Map 参数与 Map 返回值语义不同容易混淆重定向return redirect:/product/list后取值值全丢重定向是新请求原 model 不保留最后一行值得展开说。重定向场景下取不到值是极其高频的问题正确姿势是用RedirectAttributesPostMapping(/save) public String save(ProductVO product, RedirectAttributes ra) { productService.save(product); // addFlashAttribute 会暂存到 flashMap重定向后的那次请求能取到 ra.addFlashAttribute(msg, 保存成功); // addAttribute 会拼到 URL 查询串上/product/list?msgxxx ra.addAttribute(page, 1); return redirect:/product/list; }addFlashAttribute的原理是把数据放进 session临时存储请求消费后清除所以它跨得过重定向addAttribute是拼 query string适合传简单参数。两者别用反了用addAttribute传一个复杂对象会直接抛序列化相关的异常。5. 取不到值的排查清单与踩坑实录5.1 七类高频原因按命中率排序第一类响应走了 JSON根本没渲染视图。检查类上是不是RestController方法上是不是多了ResponseBody。这两个注解会让 Model 彻底失效。判断方法很简单看浏览器直接访问该 URL 返回的是什么如果是一串 JSON 或空响应那就是走了消息转换器。第二类视图名和模板实际路径对不上。返回product/detail模板必须落在classpath:/templates/product/detail.html。多一层或少一层目录都会失败。报错信息通常会说模板不存在但如果是被全局异常处理器吞掉了前端看到的就是个白页。第三类属性名拼写不一致。这是最没技术含量但命中率极高的一类。productName和productname、specList和specs大小写差一个字母就全废。我的做法是把属性名抽成常量Controller 和模板各引用一份虽然麻烦但省事。第四类重定向导致数据丢失。前面说过用RedirectAttributes的 flash 属性解决。第五类SessionAttributes带来的脏数据。类上标了SessionAttributes(product)那么product会被提升到 session 里。好处是多步表单能保留数据坏处是你下次再进这个页面如果 Controller 里没重新 add页面上显示的是上一次的旧值。这个现象特别迷惑人——“我数据库明明改了页面还是老数据”。排查时可以在页面底部打印${session}看看有没有残留。第六类模板缓存。前面提过开发期务必关掉。判断方法改一个字刷新浏览器看变不变。不变就是缓存。第七类值确实是 null但属性名没问题。比如product.getSpecs()返回 nulladdAttribute塞进去了合法渲染时被 remove 掉。表现跟属性名错了一样。这时候要看的是数据源不是模板。5.2 排查顺序速查表我按“从外到内、从便宜到贵”的原则排了一个顺序避免上来就翻源码顺序检查项具体动作耗时1响应类型浏览器直接访问 URL看是 HTML 还是 JSON10 秒2视图是否存在对照返回值和模板物理路径30 秒3属性名一致性打开两个文件并排比对1 分钟4值是否为 nullController 里打日志输出对象2 分钟5模板缓存改一个字符刷新看是否生效30 秒6请求域内容页面顶上加一行输出${#request}或属性名集合2 分钟7会话残留检查类上有没有SessionAttributes1 分钟8拦截器/过滤器是否有人清空了 model 或包装了 request5 分钟第 6 步有个小技巧Thymeleaf 里可以这样快速把整个请求域 dump 出来看!-- 临时调试用上线前删掉 -- div th:eachattr : ${#request.attributeNames} span th:text${attr}/span span th:text${#request.getAttribute(attr)}/span /div这一坨输出能一次性告诉你属性到底在不在 request 里比瞎猜快得多。我曾经靠这个发现过一个诡异问题某拦截器在postHandle里调了request.setAttribute把同名属性覆盖成了 null导致 Model 传的值被吃掉。5.3 几条没写进文档的实操心得关于Model的作用域别指望它跨请求。它是一次请求内的临时载体请求结束就结束了。需要跨请求的数据要么放 session要么放数据库要么走 flash 属性。我见过有人把登录用户放 model 里然后在下一个页面找不着这不是 bug是设计如此。关于属性数量别把 model 当垃圾桶。有些老代码一个方法里 add 二三十个属性页面里到处取。这种写法维护起来是灾难——你改任何一处都不知道会不会影响别处。我的习惯是超过 6 个属性就考虑封装成一个页面对象比如ProductPageVO模板里统一用${page.product}这种前缀取一眼就能看出数据来源。关于格式化能放后端就别放模板。日期、金额这类需要格式化的字段我倾向在 DTO 里就转成字符串或者用DateTimeFormat处理。模板里做格式化语法又长又容易出错而且一旦格式规则变了要满模板找。不过要注意 Thymeleaf 的#temporals.format(...)是内置工具简单场景用一下也无妨。关于测试MockMvc能把 Model 断出来。这是我觉得最值的一个技巧。写单元测试时andExpect(model().attribute(product, hasProperty(name, is(测试商品))))能直接断言 Model 里有什么不用启动浏览器。我现在的习惯是写完 Controller 先跑一个 MockMvc 断言 model通过了再去调模板能把问题范围缩小一半。Test void detail_shouldPutProductIntoModel() throws Exception { mockMvc.perform(get(/product/detail).param(id, 1001)) .andExpect(status().isOk()) .andExpect(view().name(product/detail)) .andExpect(model().attributeExists(product)) .andExpect(model().attribute(nickname, 访客)); }关于命名约定统一用驼峰小写开头。这不只是风格问题。Thymeleaf 的变量名是精确匹配的${Product}和${product}是两个不同的变量。团队里如果有人习惯首字母大写早晚出事。约定比配置便宜得多。最后说一个版本提醒。从 Spring Boot 3 开始底层是 Jakarta EEjavax.servlet全部换成了jakarta.servlet。如果你照着老教程写自定义 View 或者过滤器包名不对会直接编译失败。另外前面提到的Model.asMap()也是较新版本才有的老项目里老老实实强转。这个内容后续还能往两个方向延展一是把 Model 和ModelAttribute方法参数绑定结合起来看能讲清楚“表单提交时数据怎么自动装进对象”二是把HandlerMethodArgumentResolver的扩展点走一遍你自己写一个解析器往 model 里塞公共数据比在每个 Controller 里重复 add 干净得多。我在实际项目里就是用后者的思路把租户信息、菜单树这类全局数据统一注入的Controller 里一行都不用写。