前阵子整理一个老项目的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 |
| 谁决定这个View | InternalResourceViewResolver | BeanNameViewResolver等 | 返回值里的redirect:前缀 |
| 地址栏变化 | 不变 | 一般是直接输出 | 变化 |
| 请求次数 | 一次(服务器内部转发) | 一次 | 两次(浏览器重新请求) |
| Model数据传递范围 | request作用域 | 直接传给render方法 | URL参数或FlashMap |
看这张表就明白了,三者不是互斥的绝招,而是同一个View体系里的三个分支。理解这条链路之后,很多面试题和配置问题都能变成常识:为什么redirect:前缀不会被InternalResourceViewResolver处理后缀拼接?因为Spring在处理返回值时,检测到redirect:就直接构造RedirectView了,压根没走到解析器那一步。为什么自定义视图会被莫名其妙解析成JSP路径?因为视图解析器有多个,且顺序不对时,默认的JSP解析器会“截胡”。
这个底层逻辑先放在这,后面每个部分都会反复用到。
2. 直接页面转发:绕开视图解析器的三种常见写法
“直接页面转发”这个说法,其实包含两种情况:一种是常规的return "视图名"交给InternalResourceViewResolver拼出JSP路径,另一种是用forward:前缀显式指定要转发的完整资源路径。两者最终都落在服务器内部转发这个语义上,也就是RequestDispatcher.forward。
2.1return "逻辑视图名":最常见的转发写法
这是最普通的用法。先配置视图解析器:
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean>Controller里返回逻辑视图名:
@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)把请求转交给这个JSP,JSP执行后的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里还带着上一轮的orderId,@ModelAttribute能够取到。
容易忽略的点是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导出,只需再注册一个视图bean,Controller里改一行返回值。
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(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws Exception { response.setContentType("text/csv; charset=UTF-8"); response.setHeader("Content-Disposition", "attachment; filename=\"users.csv\""); @SuppressWarnings("unchecked") List<User> userList = (List<User>) 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返回"usersCsvView",BeanNameViewResolver按名字找到usersCsvView这个bean,调用它的render()方法完成输出。
这个order设置是整个自定义视图成败的关键。如果项目里同时配了InternalResourceViewResolver,且它的order更靠前,SpringMVC会先尝试用JSP解析器处理usersCsvView,拼成/WEB-INF/views/usersCsvView.jsp,结果404。反过来,像我上面的配置把BeanNameViewResolver放在order=1,它先匹配,匹配到就直接返回自定义视图,不再往下找其他解析器。多个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:..."到RedirectView
Controller里的写法很简单:
@GetMapping("/save") public String save(User user, RedirectAttributes attributes) { userService.save(user); return "redirect:/user/list"; }Spring在处理返回值时,检测到redirect:前缀,直接构造一个RedirectView,URL设为冒号后面的部分。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?status=success";简单但遇到中文、特殊字符时要自己操心编码。
更好的方式是RedirectAttributes:
@PostMapping("/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里的属性会被自动合并到Model,JSP里直接${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/list,Controller里再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和userList,BeanNameViewResolver处理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; charset=UTF-8,否则Excel打开会乱码。
还有一个高频坑必须单独说:FlashMap里的消息在转发时取不到。我之前有次排查,表单保存成功后返回的是转发而非重定向,页面里${msg}一直是空的。原因就是addFlashAttribute依赖重定向过程的FlashMap机制,走转发时没有第二次请求,FlashMap根本没有生效逻辑。教训是:用addFlashAttribute就坚持配重定向,别半路改成转发。
5.3 关于三者边界的记忆方法
最后分享一个我个人一直在用的记忆方式。
把Controller方法返回值想象成一张“路由便签”:
- 啥也不加,写
"userList",意思是“让视图解析器帮我解析一个页面,服务器内部转过去”。 - 前面加
"redirect:",意思是“告诉浏览器,重新请求这个地址”。 - 前面加
"forward:",意思是“服务器内部,直接把这个请求交给指定资源或另一个Controller”。 - 返回一个自定义视图bean的名字,意思是“去容器里找名字对应的View渲染器来输出”。
判断代码行为时,按“前缀 → 解析器 → View实现”这个顺序一层层看,基本不会跑偏。这也是我在这篇文章开头说“三种写法共用一条链路”的实战含义——它们只是在不同层次做了不同分支处理,理解了ViewResolver和View的分工,很多配置问题就不需要再背了。