首页 / 资讯中心 / 文章详情

SpringMVC视图解析:深入理解转发、重定向与自定义视图

SpringMVC视图解析:深入理解转发、重定向与自定义视图 ★ FEATURED ARTICLE
前阵子整理一个老项目的Controller层看到一屏的return语句让我停了很久——同一个类里有的方法返回success请求结束地址栏没变有的方法返回redirect:/list浏览器立刻跳走还有一个方法返回usersCsvView直接往客户端推了一个CSV下载文件。这三种写法在SpringMVC里正好对应标题里的三件事直接页面转发、页面重定向、自定义视图。它们看着完全是三种路数但底层共用同一条视图解析链路只是分叉点不同。这篇是“重温Spring框架”系列的SpringMVC之十我把这三块放一起彻底捋一遍。适合正在看SpringMVC源码、或者被“forward和redirect到底谁先被处理”“自定义视图为什么老是被JSP解析器吃掉”这类问题卡过的同学。我会从视图解析链路讲起再把三种写法的原理、代码和坑位逐个拆开。1. 视图解析链路一次请求最终是怎么“落到页面”上的先看一个完整的SpringMVC请求路径。客户端发来请求DispatcherServlet先通过HandlerMapping找到能处理这个请求的Controller方法再交给HandlerAdapter去调用。方法执行完不管返回String还是ModelAndView最终都会变成一个ModelAndView对象里面装着模型数据和逻辑视图名。接着DispatcherServlet拿着这个逻辑视图名去找ViewResolver解析器返回一个View对象最后由View.render()方法把模型数据渲染到响应里。这里有一个关键认知View不是一个页面它是“渲染器”。SpringMVC里所有的输出最终都要经过某个View实现类。用JSP时你看到的是InternalResourceView它内部借助RequestDispatcher把请求转发给JSP文件用重定向时是RedirectView它做的事情是response.sendRedirect()用JSON接口返回时是MappingJackson2JsonView把对象序列化成JSON字符串。可以用一个简化流程来表示Controller 返回 String/ModelAndView ↓ DispatcherServlet 得到 ModelAndView ↓ ViewResolver 根据视图名解析出 View 对象 ↓ View.render(model, request, response) 执行 ↓ 响应输出HTML / 重定向指令 / 文件流从架构上大致是这样源码里围绕DispatcherServlet.processDispatchResult转了一圈核心逻辑就是这四步。那么“直接页面转发、自定义视图、页面重定向”各自落在链路的哪一段特性直接页面转发自定义视图页面重定向最终使用的View实现InternalResourceView你写的View实现类RedirectView谁决定这个ViewInternalResourceViewResolverBeanNameViewResolver等返回值里的redirect:前缀地址栏变化不变一般是直接输出变化请求次数一次服务器内部转发一次两次浏览器重新请求Model数据传递范围request作用域直接传给render方法URL参数或FlashMap看这张表就明白了三者不是互斥的绝招而是同一个View体系里的三个分支。理解这条链路之后很多面试题和配置问题都能变成常识为什么redirect:前缀不会被InternalResourceViewResolver处理后缀拼接因为Spring在处理返回值时检测到redirect:就直接构造RedirectView了压根没走到解析器那一步。为什么自定义视图会被莫名其妙解析成JSP路径因为视图解析器有多个且顺序不对时默认的JSP解析器会“截胡”。这个底层逻辑先放在这后面每个部分都会反复用到。2. 直接页面转发绕开视图解析器的三种常见写法“直接页面转发”这个说法其实包含两种情况一种是常规的return 视图名交给InternalResourceViewResolver拼出JSP路径另一种是用forward:前缀显式指定要转发的完整资源路径。两者最终都落在服务器内部转发这个语义上也就是RequestDispatcher.forward。2.1return 逻辑视图名最常见的转发写法这是最普通的用法。先配置视图解析器bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views// property namesuffix value.jsp/ /beanController里返回逻辑视图名Controller RequestMapping(/page) public class PageController { GetMapping(/welcome) public String welcome(Model model) { model.addAttribute(username, zhangsan); return welcome; } }当方法返回welcome时InternalResourceViewResolver会把前后缀拼成/WEB-INF/views/welcome.jsp得到InternalResourceView。在render()阶段它通过RequestDispatcher.forward(request, response)把请求转交给这个JSPJSP执行后的HTML响应直接写回客户端。注意一点JSP放在WEB-INF目录下客户端是没法直接通过URL访问的只能靠服务器内部转发。这正是“直接页面转发”最典型的应用场景页面不想暴露给外部浏览器地址栏始终是/page/welcome看不到/WEB-INF/views/welcome.jsp。2.2forward:前缀完全绕开视图解析器forward:前缀的作用是告诉Spring别再让ViewResolver解析了我直接给完整路径。这一点很多初学者会搞混以为forward:也要拼前后缀。Controller RequestMapping(/forward) public class ForwardController { GetMapping(/toPage) public String toPage() { return forward:/WEB-INF/views/welcome.jsp; } GetMapping(/toAnother) public String toAnother() { // 转发到另一个Controller的映射地址 return forward:/page/welcome; } }这里的路径是完整的不需要prefix/suffix参与。Spring在DispatcherServlet得到返回值后会先检查有没有redirect:前缀再检查有没有forward:前缀。发现forward:就构造一个以该路径为目标的InternalResourceView后面的处理和普通转发一致——服务器内部把请求转交出去整个过程只有一次网络请求。为什么推荐能写逻辑视图名就别写forward:因为渲染逻辑名字以后想加前缀后缀、想换模板引擎都方便写死forward:路径之后页面一旦挪了位置这里也要跟着改。我在维护老项目时见过大量forward:/xxx.jsp这种写法基本都是早期没配好InternalResourceViewResolver时应急写下的后来就成了一堆随时会断的路径硬编码。2.3 forward的数据传递与一个容易忽略的点转发是一次请求所以Model里放的数据会作为request属性传递到目标页面或目标Controller。用${username}直接取就行也可以用ModelAttribute接收。GetMapping(/confirm) public String confirm(Model model) { model.addAttribute(orderId, SO2024001); return forward:/order/show; } GetMapping(/show) public String show(ModelAttribute(orderId) String orderId, Model model) { model.addAttribute(msg, 当前订单 orderId); return orderDetail; }forward:/order/show会再经过DispatcherServlet重新匹配一次HandlerMapping所以能进入另一个Controller方法。这时候request里还带着上一轮的orderIdModelAttribute能够取到。容易忽略的点是forward:后的路径要以/开头。写forward:order/show这种相对路径看起来能跑但在某些Servlet容器下会基于当前请求的URL计算很容易拼出一个意料之外的地址。我之前排查过一个线上问题页面在某些二级路径下能打开换个入口就404最后就是forward:后漏了/导致的。统一写成/开头让路径从应用上下文根开始行为就稳定了。3. 自定义视图实战继承AbstractView做CSV导出自定义视图本质上就是你自己实现View接口或它的子类然后在Controller里返回一个能被解析器识别的视图名。这个技术在老项目里最常见的需求是导出Excel、CSV、PDF或者输出动态图片、验证码等非HTML内容。3.1 为什么不直接在Controller里response.getWriter()刚接触SpringMVC的人遇到导出需求的第一反应是在Controller方法里直接写response.setContentType(text/csv); response.getWriter().write(...);然后方法返回值设为void或null。这种方式能跑但有两个问题。一是输出逻辑和业务逻辑堆在一起Controller越来越脏二是如果以后要给同一份数据做HTML列表、CSV导出、Excel导出三种输出你得写三份Controller逻辑而不是只换一个视图。自定义视图的思路是把“渲染”这件事单独抽出来Controller只负责准备数据GetMapping(/export) public String export(Model model) { model.addAttribute(userList, userService.listAll()); return usersCsvView; }Controller返回视图名usersCsvView对应一个自定义View对象渲染逻辑全在那个View里。后续如果要多一个PDF导出只需再注册一个视图beanController里改一行返回值。3.2 继承AbstractView实现CSV下载虽然可以直接实现View接口但Spring里更推荐继承AbstractView。AbstractView已经处理了ContentType设置、响应状态等公共逻辑我们只需要重写renderMergedOutputModel方法把模型数据渲染到响应流中。import org.springframework.web.servlet.view.AbstractView; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.util.List; import java.util.Map; public class CsvView extends AbstractView { Override protected void renderMergedOutputModel(MapString, Object model, HttpServletRequest request, HttpServletResponse response) throws Exception { response.setContentType(text/csv; charsetUTF-8); response.setHeader(Content-Disposition, attachment; filename\users.csv\); SuppressWarnings(unchecked) ListUser userList (ListUser) model.get(userList); StringBuilder sb new StringBuilder(姓名,年龄,城市\n); for (User user : userList) { sb.append(user.getName()).append(,) .append(user.getAge()).append(,) .append(user.getCity()).append(\n); } response.getWriter().write(sb.toString()); } }核心逻辑就一句话从model里取数据组织好内容写进response的输出流。Content-Disposition里设置attachment会让浏览器把它当下载文件处理而不是直接打开。3.3 用BeanNameViewResolver把bean名变成视图名有了CsvView类还不够得让SpringMVC找到它。一种简单方式是注册BeanNameViewResolver它会把Controller返回的字符串当成bean的名字去容器里找名字匹配的Viewbean。Configuration public class ViewConfig { Bean(name usersCsvView) public View usersCsvView() { return new CsvView(); } Bean public ViewResolver viewResolver() { BeanNameViewResolver resolver new BeanNameViewResolver(); // 关键order要小于InternalResourceViewResolver resolver.setOrder(1); return resolver; } }Controller返回usersCsvViewBeanNameViewResolver按名字找到usersCsvView这个bean调用它的render()方法完成输出。这个order设置是整个自定义视图成败的关键。如果项目里同时配了InternalResourceViewResolver且它的order更靠前SpringMVC会先尝试用JSP解析器处理usersCsvView拼成/WEB-INF/views/usersCsvView.jsp结果404。反过来像我上面的配置把BeanNameViewResolver放在order1它先匹配匹配到就直接返回自定义视图不再往下找其他解析器。多个ViewResolver是按order升序依次调用的order越小优先级越高。3.4 自定义视图还能做什么更复杂的CSV只是一个最简单的例子。基于同样的套路换ContentType和输出格式就能实现Excel导出用POI生成Workbook后写流、PDF导出用PDF库渲染、验证码图片BufferedImage转输出流。输出逻辑全都被收拢到各自的View实现类里Controller保持干净。这个思路还是理解“视图”这个概念的入门石——视图就是一个能拿模型数据并产出响应内容的组件。有了这个认知像ContentNegotiatingViewResolver这类按请求头协商返回JSON还是HTML的机制也就变得容易理解了。4. 页面重定向redirect前缀、RedirectView与Flash参数传递页面重定向是三者里行为最好观察的一个浏览器地址栏会变。平时写response.sendRedirect()也是这个效果SpringMVC只是用RedirectView把这层包装成了可配置的视图组件。4.1 从return redirect:...到RedirectViewController里的写法很简单GetMapping(/save) public String save(User user, RedirectAttributes attributes) { userService.save(user); return redirect:/user/list; }Spring在处理返回值时检测到redirect:前缀直接构造一个RedirectViewURL设为冒号后面的部分。RedirectView在render()阶段做的事情可以简单理解成组装最终URL、调用response.sendRedirect()让浏览器发起一个新的GET请求。转发和重定向的区别在这个阶段体现得最清楚转发一次请求服务器内部转交地址栏不变模型数据可以直接作为request属性传递。重定向两次请求第一次返回302告诉浏览器“你去别处取”浏览器重新访问新地址模型数据没法直接进request作用域需要走URL参数或FlashMap。为什么表单提交经常用重定向而非转发因为F5刷新是个大坑。如果提交用转发刷新页面时浏览器会重新提交表单可能造成重复插入。重定向之后地址栏变成/user/list刷新只是再次GET列表页安全得多。这是实践中很重要的一条经验。4.2 RedirectAttributes与FlashMap传参重定向是两次请求第一次请求里放在model里的数据到了第二次请求就没了。要传参有两种主流方式。一种是手动拼接URL参数return redirect:/user/list?statussuccess;简单但遇到中文、特殊字符时要自己操心编码。更好的方式是RedirectAttributesPostMapping(/save) public String save(User user, RedirectAttributes attributes) { userService.save(user); // addAttribute会拼接到重定向URL上 attributes.addAttribute(type, user); // addFlashAttribute放FlashMap里重定向后的页面可以取一次 attributes.addFlashAttribute(msg, 保存成功); return redirect:/user/list; }FlashMap的机制值得说透重定向前把addFlashAttribute的数据放在session的FlashMap里重定向后的那次GET请求拿到这个FlashMap用完之后立即删除。所以它天然适合“一次性提示消息”不会刷新后还残留。在目标Controller里接收GetMapping(/list) public String list(RequestParam(required false) String type, Model model) { // 从FlashMap里取出的msg已被合并进Model return userList; }FlashMap里的属性会被自动合并到ModelJSP里直接${msg}就能显示不需要手动从session里取。4.3 重定向路径的几个细节redirect:后面的路径写法规则比想象中复杂一点。redirect:/user/list以/开头基于应用上下文根解析。如果项目部署在/myapp下跳转目标就是/myapp/user/list上下文路径会被正确处理。redirect:user/list不带/视为相对当前请求URL的路径通常不是我们想要的结果。redirect:http://example.com/xxx带协议头的完整URL视为跳转到外部站点。我见过不少新人踩过第二种坑项目部署在带contextPath的环境里写了redirect:user/list结果在某个深层次页面下提交后跳到一条奇奇怪怪的URL直接404。统一写redirect:/开头让Spring和Servlet容器处理上下文路径是最稳的写法。另外一个容易被忽略的规则重定向的目标不能是WEB-INF下的页面。因为重定向是浏览器重新发请求浏览器无法直接访问WEB-INF目录这是Servlet规范的限制。所以常见的做法是重定向到一个公开的Controller映射或静态资源路径如果需要渲染内部JSP就再通过Controller内部转发。这也解释了为什么很多项目里会出现“重定向到/xxx/listController里再return list转发到JSP”这种组合。5. 三个功能组合使用的完整示例与踩坑记录前面分别讲了三者各自的机制实际项目里它们经常组合出场。下面用一个典型场景串起来表单提交、列表页、CSV导出、错误回显。5.1 一个典型的“表单列表导出”联动场景Controller RequestMapping(/user) public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService userService; } // 打开新增页面直接页面转发到WEB-INF下的表单JSP GetMapping(/add) public String add() { return userAdd; } // 保存表单成功后重定向到列表页并带Flash提示 PostMapping(/save) public String save(User user, RedirectAttributes attributes) { userService.save(user); attributes.addFlashAttribute(msg, 新增成功); return redirect:/user/list; } // 列表页重定向目标的入口 GetMapping(/list) public String list(Model model) { model.addAttribute(users, userService.listAll()); return userList; } // CSV导出返回自定义视图名 GetMapping(/export) public String export(Model model) { model.addAttribute(userList, userService.listAll()); return usersCsvView; } }这个例子把这篇文章的三个主角全串起来了return userAdd是常规转发return redirect:/user/list是重定向return usersCsvView是自定义视图。配置上InternalResourceViewResolver处理userAdd和userListBeanNameViewResolver处理usersCsvView只要把BeanNameViewResolver的order设为更小就不会冲突。5.2 踩坑记录视图问题排查的三条主线结合我自己维护老项目的经验视图相关的报错九成都能归到下面三条主线。第一条线返回值里有没有前缀。看到404之前先看Controller方法返回值字符串开头是不是带redirect:或forward:。如果带了redirect:路径出问题八成是上文说的路径写法问题如果带了forward:检查路径是否以/开头、资源是否存在。第二条线视图解析器的顺序。自定义视图被解析成JSP路径是BeanNameViewResolver没排到前面的典型症状。排查方法也简单把Controller返回的字符串改成容器中不存在的视图名如果还在报/WEB-INF/views/不存在.jsp说明是InternalResourceViewResolver在兜底如果报的是自定义视图名找不到说明解析器自身没配置好。多个解析器并存时ейorder必须显式规划。第三条线Content-Type与响应头。自定义视图导出CSV浏览器却显示HTML或下载文件名乱码基本是Content-Type或Content-Disposition的charset设置问题。CSV带中文时Content-Type一定要写text/csv; charsetUTF-8否则Excel打开会乱码。还有一个高频坑必须单独说FlashMap里的消息在转发时取不到。我之前有次排查表单保存成功后返回的是转发而非重定向页面里${msg}一直是空的。原因就是addFlashAttribute依赖重定向过程的FlashMap机制走转发时没有第二次请求FlashMap根本没有生效逻辑。教训是用addFlashAttribute就坚持配重定向别半路改成转发。5.3 关于三者边界的记忆方法最后分享一个我个人一直在用的记忆方式。把Controller方法返回值想象成一张“路由便签”啥也不加写userList意思是“让视图解析器帮我解析一个页面服务器内部转过去”。前面加redirect:意思是“告诉浏览器重新请求这个地址”。前面加forward:意思是“服务器内部直接把这个请求交给指定资源或另一个Controller”。返回一个自定义视图bean的名字意思是“去容器里找名字对应的View渲染器来输出”。判断代码行为时按“前缀 → 解析器 → View实现”这个顺序一层层看基本不会跑偏。这也是我在这篇文章开头说“三种写法共用一条链路”的实战含义——它们只是在不同层次做了不同分支处理理解了ViewResolver和View的分工很多配置问题就不需要再背了。
阅读完成 · 觉得有帮助?
咨询建站