ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

@RestController与@Controller的区别:源码、原理与实战避坑指南

@RestController与@Controller的区别:源码、原理与实战避坑指南

写接口这些年,我见过太多人在@RestController和@Controller中间栽跟头。有人返回一个List,前端拿到的却是 404;有人规规矩矩写了@Controller,结果字符串被当成页面去解析,直接报视图错误;还有人两个注解混着用,项目跑着跑着某个接口返回值就变了味。其实这两个注解的关系没那么玄乎。简单说,@RestController就是@Controller加@ResponseBody的合体版,专门给前后端分离的 REST 接口用;而@Controller是 Spring MVC 里最原始的控制器注解,既能配合模板引擎渲染页面,也能在方法上手动加@ResponseBody输出 JSON。搞懂它们的关系与区别,不只为了面试,更是为了让你每个接口职责清晰,排查问题时不至于瞎忙活。这篇文章我把这层关系从源码、运行机制到真实踩坑记录全部拆开讲,适合刚接触 Spring Boot 的读者,也适合写了两年代码但一直”会用不会说“的老手。

1. 核心关系先讲透:RestController 是 Controller 的“预装套餐”

1.1 看源码最快:RestController 的“真身”

把光标放到@RestController上,按住 Ctrl 点进去(我用的是现代版 IDE 的操作方式,旧版叫“Go to Declaration”),你会看到这么一段定义,源码不长,但信息量极大:

@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented @Controller @ResponseBody public @interface RestController { String value() default ""; }

关键在中间两行:@Controller和@ResponseBody同时躺在@RestController的马甲下面。也就是说,你写@RestController时,Spring 实际执行的是“它是一个控制器 + 所有方法都默认带上了响应体注解”这两件事。

有个冷知识顺带说一下:@RestController还可以接收一个value()参数,它是给 Spring 容器注册 Bean 时用的名字别名。虽然我基本没见过有人用这个参数,但知道它存在,面试时反而能显得你确实读过源码而不是背了口诀。

1.2 为什么 Spring 要给你一个“合体注解”

@RestController是 Spring 4.0 加入的。在它出现以前,写一套纯接口风格的代码,你必须在每个方法前面都手工敲一遍@ResponseBody:

@Controller public class OldStyleController { @ResponseBody @GetMapping("/hello") public String hello() { return "hello"; } @ResponseBody @GetMapping("/user") public User getUser() { return new User(); } }

接口一多,满屏都是@ResponseBody,视觉噪音特别大,还特别容易漏。漏一个,方法返回的对象就会被当成视图名去解析,运气好报 404,运气不好直接给你弹一个又长又臭的视图解析异常。@RestController把@ResponseBody提升到类级别,所有方法统一继承这个语义,既减少了重复代码,又从根本上堵住了“忘加注解”这个问题。

用生活类比来理解:@Controller像一辆手动挡的车,你想要 JSON 就得自己挂挡(每个方法加@ResponseBody);@RestController像自动挡,你只管踩油门(专心写返回逻辑),挡位系统帮你全包了。

1.3 元注解继承背后的机制

这里牵出一个 Spring 的重要知识点:元注解(Meta-Annotation)。一个注解上标着另一个注解,被标注者就自动获得标注者的语义,这就是元注解的继承机制。Spring 在扫描组件、装配 MVC 处理器时,用的不是 Java 反射里那个严格的原生注解查找,而是自己封装的AnnotatedElementUtils等工具类,专门支持这种“注解套注解”的查找逻辑。

实际意义在于:当你用@RestController标注一个类时,Spring 的HandlerMapping机制照样能识别出它是一个控制器,并且@ResponseBody的语义会应用到类里所有请求处理方法上。这就是为什么@RestController不写任何@ResponseBody,方法返回值照样直接写入响应体。理解这一层,后面所有行为差异都顺理成章了。

2. 关键区别在返回值:一个走视图,一个走消息转换器

2.1 返回 String 时,两者的命运完全不同

这是两者最直观的分水岭。同样是返回"userPage"或"hello",注解不同,结局完全不同。

@Controller方法返回一个String,Spring 默认把它当成视图名(View Name),交给ViewResolver去解析。如果你的项目用的是 Thymeleaf,它会去classpath:/templates/下找userPage.html;如果用的是 JSP,它会找userPage.jsp。找到就渲染页面返回 HTML,找不到就抛异常或者 404。

@RestController方法返回一个String,Spring 直接把这个字符串作为响应体写出去,内容协商(Content Negotiation)默认会交给StringHttpMessageConverter,前端拿到的是纯文本,比如hello,注意是裸字符串,没有任何引号包裹。

这个差异在实操中很坑:有人把@Controller改成@RestController想让接口返回 JSON,结果原本返回的对象虽然正常序列化了,但原本返回字符串的方法,从“渲染成页面”变成了“输出一段裸文本”。前端要是拿这段文本直接当 JSON 去解析,直接报错。

2.2 返回对象时,谁来帮你序列化成 JSON

当方法返回的是一个 POJO、Map、List这些对象时,情况更值得说道。@Controller在没有@ResponseBody的情况下,返回对象是没办法直接处理的——它既不是视图名前缀,也没有消息转换器愿意接手。Spring MVC 处理不了,就会沿视图解析的逻辑去找,最终大概率给你一个 404 或 500。

而@RestController(或者说任意类或方法带@ResponseBody)返回对象时,Spring 会把它丢给HttpMessageConverter体系。Spring Boot 的spring-boot-starter-web里只要引入了 Jackson,就会自动配置MappingJackson2HttpMessageConverter,把对象序列化成 JSON 字符串写入响应体。整个过程对业务代码透明,你只管 new 一个对象返回,序列化细节框架全包了。

这里有个容易误解的点:@RestController本身并不“负责”做 JSON 序列化,它只是激活了“返回值写入响应体”这条链路。具体用什么格式输出,由 classpath 下的消息转换器决定。Jackson 在就输出 JSON,如果项目里换成别的库,照样能接上。

2.3 一张对比表把差异钉死

为了让你看得清楚,我直接放一张对照表,这是平时排查问题最常用到的:

对比项@Controller@RestController
本质独立注解,来自 Spring MVC由 @Controller + @ResponseBody 组合而来的派生注解
返回 String当作视图名解析,返回 HTML 页面作为纯文本写入响应体
返回对象默认无法处理,会走视图解析导致 404/500由 HttpMessageConverter 序列化为 JSON
返回 ModelAndView正常渲染视图依然会走视图渲染(后面细说)
每个方法写 @ResponseBody需要,否则按视图处理不需要,类级别已默认
典型应用场景服务端渲染、表单提交、MVC 页面前后端分离接口、REST API、移动端接口
能否混用可以,方法级手动加 @ResponseBody不建议同时在一个类里混用其他控制语义

2.4 特殊情况:ModelAndView 在 RestController 里也能渲染

很多博客会告诉你“RestController 永远不解析视图”,这说法不全对。当方法签名返回类型是ModelAndView时,Spring MVC 的处理顺序里有一个专门处理ModelAndView的处理器,它排在消息转换器前面,照样会执行视图渲染。所以你其实能在@RestController里通过返回ModelAndView渲染出一个页面,只是这种写法极度反直觉,我强烈建议不要这么干。它既破坏了@RestController的语义统一性,也让看代码的人一头雾水。属于“技术上能跑但设计上别碰”的典型。

3. 实操对比:同一个业务,两种注解怎么选

3.1 场景一:纯 JSON 接口(前后端分离)

这是大多数人的日常。前端用 Vue 或 React,后端只出数据,接口路径统一带/api前缀。直接上@RestController:

@RestController @RequestMapping("/api/users") public class UserApiController { @GetMapping("/{id}") public User getUser(@PathVariable Long id) { return new User(id, "张三", "zhangsan@example.com"); } @GetMapping("/list") public List<User> listUsers() { return userService.findAll(); } }

这里每个方法都不需要加@ResponseBody,返回值自动序列化成 JSON。下面是等价写法——如果偏要用@Controller,代码看起来就是这样:

@Controller @RequestMapping("/api/users") public class UserApiController { @GetMapping("/{id}") @ResponseBody public User getUser(@PathVariable Long id) { return new User(id, "张三", "zhangsan@example.com"); } @GetMapping("/list") @ResponseBody public List<User> listUsers() { return userService.findAll(); } }

代码量差不多翻了一倍,而且每次都要检查“有没有漏掉 @ResponseBody”。纯粹的数据接口,没有理由不用@RestController。

3.2 场景二:服务端渲染页面

项目需要直接返回 HTML(比如后台管理系统用 Thymeleaf 渲染、或者一些老项目还在用 JSP),这时候@RestController就不合适了,老老实实用@Controller:

@Controller public class PageController { @GetMapping("/admin/index") public String index(Model model) { model.addAttribute("username", "管理员"); return "admin/index"; // 解析 templates/admin/index.html } @GetMapping("/admin/users") public String userList(Model model) { model.addAttribute("users", userService.findAll()); return "admin/users"; } }

返回的字符串是模板名,Model里的数据会被填充到页面里。这种情况下你绝不会想让 Spring 把这个字符串当 JSON 输出,所以@Controller是正确选择。

3.3 场景三:一个 Controller 里页面和接口并存

实际业务里经常出现一个模块既要渲染页面,又要提供一个局部刷新的数据接口。这种场景@Controller配合方法级@ResponseBody是最好的方案:

@Controller @RequestMapping("/system") public class SystemController { @GetMapping("/page") public String page() { return "system/page"; } @GetMapping("/data") @ResponseBody public Map<String, Object> data() { Map<String, Object> result = new HashMap<>(); result.put("code", 200); result.put("data", "xxx"); return result; } }

一个类里两种职责共存,全靠方法级注解控制。但是反过来——如果类上已经标注了@RestController,就没法“退回去”让某个方法渲染页面了。你可以在方法上再标注一个@ResponseBody,但这毫无意义,因为类级别已经具备了这个语义。想让某个方法输出 HTML,得用ResponseEntity手动指定Content-Type,或者干脆拆成两个控制器。我的建议很简单:页面和接口混着用的类,统一用@Controller+ 方法级@ResponseBody;纯接口类,直接用@RestController。这样代码的“外部表现”和类注解是自洽的,别人接手也好懂。

3.4 接口返回类型的规范建议

实操中另一个频繁出现的问题不是注解选错,而是返回类型不统一。我见过太多接口,一会儿返回裸String,一会儿返回Map,一会儿又返回一个封装的响应体对象。这里给出我的习惯:

  • 封装一个统一响应体,比如Result<T>,里面包含code、message、data三个字段;
  • 所有接口方法返回这个Result<T>,成功失败都用它表达;
  • 避免直接返回裸String或裸Map,否则前端解析格式漂移,联调成本失控。

统一响应体和注解选择是配套的:既然@RestController默认走消息转换器,那你把所有返回值都约束成同一种结构,Jackson 序列化出来的 JSON 形状也一致,前端就能写一套统一拦截逻辑。

4. 原理拆解:HttpMessageConverter 是怎么接管返回值的

4.1 一次请求后的完整处理链路

搞懂原理最有效的方式是跟着一次请求走一遍。前端发一个GET /api/users/1,请求进入DispatcherServlet,它会走下面这条链:

  1. 根据 URL 找到对应的HandlerMethod,也就是UserApiController.getUser这个方法;
  2. 调用方法前,Spring 先解析方法的参数,比如@PathVariable Long id,从 URL 里取1绑定进去;
  3. 方法执行完,拿到返回值User对象;
  4. 这一步开始分岔。如果方法(或类)带有@ResponseBody语义,Spring 会用一个叫RequestResponseBodyMethodProcessor的返回值处理器接管结果;
  5. 这个处理器会遍历注册好的HttpMessageConverter列表,找到能处理User类型、且能匹配当前请求希望的内容类型(通常是application/json)的那个转换器;
  6. 转换器把对象序列化成 JSON 字符串,写进HttpServletResponse的输出流,请求结束。

如果方法不带@ResponseBody且返回的是String,第 4 步就会换成另一个处理器:ViewNameMethodReturnValueHandler,它把返回值当视图名,交给ViewResolver解析。这就是两者行为差异的本质——不是“返回值内容不同”,而是“哪个处理器肯接活”不同。

4.2 内容协商:Accept 头决定 JSON 还是文本

你可能遇到过这种事:同一个返回String的@RestController接口,有的客户端拿到hello,有的客户端拿到"hello"(带引号)。这不是玄学,是内容协商在起作用。

当客户端请求头带Accept: text/plain,StringHttpMessageConverter会接单,输出裸文本hello。当客户端带Accept: application/json,StringHttpMessageConverter不支持application/json,挑来挑去轮到MappingJackson2HttpMessageConverter,它能把String也序列化成 JSON 字符串——“hello” 外面多了一层引号。

大多数浏览器在地址栏直接访问时发的是Accept: text/html,*/*;q=0.8这类头,这时候优先级会落在StringHttpMessageConverter上,所以你平时直接浏览器验证接口,看到的是裸文本。而 Axios、Postman 这类客户端默认带Accept: application/json,看到的就是带引号的 JSON 字符串。这个差异坑过不少人:明明一个接口,浏览器看是hello,前端代码里拿到的却是"hello"。如果你希望稳一点,给接口方法显式加produces = "application/json",把格式锁死,不要依赖客户端心情。

4.3 produces 属性与编码细节

在注解里可以把produces写得更细,这是约束返回类型的正规手段:

@GetMapping(value = "/user", produces = "application/json;charset=UTF-8") public User getUser() { ... }

produces本质上是在声明“这个方法只能产出这种媒体类型”。一旦声明,Spring 在内容协商时就会优先选择能输出该媒体类型的转换器。charset=UTF-8主要解决中文乱码问题,特别是接口返回String类型时,裸文本的中文编码很容易翻车。对象序列化成 JSON 时,Jackson 默认已经输出 UTF-8,乱码概率低,但字符串文本的转换器默认编码是ISO-8859-1,所以该写就得写。

4.4 ResponseEntity:另一种绕开视图解析的方式

除了在注解层面折腾,你还可以直接用ResponseEntity作为返回类型。它不依赖@ResponseBody,因为HttpEntityMethodProcessor在处理器列表里排在很靠前的位置,无论类上是@Controller还是@RestController,只要方法返回ResponseEntity,就会按照 HTTP 实体来处理。

@GetMapping("/html") public ResponseEntity<String> html() { String content = "<html><body>hello</body></html>"; return ResponseEntity.ok() .contentType(MediaType.TEXT_HTML) .body(content); }

这个方法如果放在@RestController里,依然能够输出一段text/html类型的内容,原因就是ResponseEntity这条路径绕开了@ResponseBody的消息转换器默认选择逻辑,或者说它自带控制权。日常写接口时,我习惯在需要自定义HTTP状态码、响应头、Content-Type的场景用ResponseEntity,其他普通成功返回统一用Result<T>。

5. 实战避坑:常见异常与排查实录

5.1 接口返回了纯字符串,前端拿到不是 JSON

现象:前端请求/api/config/name,期望拿到{"name":"xxx"},结果拿到xxx或者"xxx",解析直接报错。

原因:方法返回String,@RestController默认走文本转换器,输出裸字符串或 JSON 字符串,取决于Accept头。很多人误以为“RestController 会把任何返回值都变成 JSON”,这个认知是错的——String类型根本不会二次 JSON 序列化。

解决:把返回值改成对象,比如用一个Map或者Result<String>包装;或者如果你就是要返回 JSON 格式的字符串,先用ObjectMapper把对象转成 JSON 字符串再返回,但这样又可能带来双重转义问题,所以统一推荐用对象包装。

5.2 @Controller 返回对象,结果 404

现象:类上写了@Controller,方法返回一个User对象,前端请求后浏览器显示 404,控制台有时候会有异常,有时候没异常。

原因:方法没有@ResponseBody,URL 对应的处理器正常执行了,但返回值处理器把User当成了一个要解析的视图名。视图解析器找不到名为User(aaa, bbb...)的模板,最终请求就坏在视图解析环节,表现为 404。

解决:方法加@ResponseBody,或者把类改成@RestController。我实际排查中见过不少把类改成@RestController后又出现第 5.1 节问题的案例,所以改完一定要把所有返回String的方法也检查一遍。

5.3 换成 fastjson 后注解全失效

现象:项目里把默认的 Jackson 换成 fastjson,结果实体上原来用@JsonFormat控制日期格式、用@JsonIgnore忽略字段的配置全部失效,序列化结果乱七八糟。

原因:fastjson 有自己的注解体系,@JSONField才对得上。Spring Boot 默认帮 Jackson 做的自动配置不会自动迁移到 fastjson 上,你需要在WebMvcConfigurer里手动替换消息转换器,并且把实体里的注解都改过来。

解决:如果为了统一团队已有 fastjson 习惯而替换,一定记得在配置类里注册FastJsonHttpMessageConverter,同时清理实体类上的 Jackson 注解。更稳妥的办法其实是维持 Jackson,用@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")和@JsonIgnoreProperties(ignoreUnknown = true)这些原生注解就够了。

5.4 循环引用导致序列化崩溃

现象:两个实体互相引用,比如User里有List<Order>,Order里又有User。接口一返回用户列表,控制台直接报Infinite recursion (StackOverflowError)或定位到Could not write JSON。

原因:Jackson 在序列化对象时会沿 getter 递归访问属性,两个对象互相引用就没有出口,栈直接打爆。这个问题在@RestController下特别常见,因为对象自动序列化,开发时根本意识不到实体关系已经被挖出来了。

解决:在实体类的关联字段上加@JsonIgnore,或者用@JsonIgnoreProperties指定忽略某侧关系;更推荐的做法是不要直接返回实体,而是转成 DTO(Data Transfer Object),把关联关系留在数据库层,接口层只输出前端真正需要的字段。DTO 方案不仅能解决循环引用,还能顺带避免把实体里不该暴露的字段漏出去。

5.5 常见问题速查表

现象可能原因处理建议
返回 String 被当成页面解析用了 @Controller 且没加 @ResponseBody接口方法加 @ResponseBody 或改用 @RestController
返回对象但接口 404@Controller 默认走视图解析类改 @RestController,或方法加 @ResponseBody
RestController 返回 String 不带引号内容协商选中文本转换器返回对象包装,或显式 produces
中文乱码文本转换器默认 ISO-8859-1produces 里加 charset=UTF-8
日期格式变成时间戳全局或字段缺少日期格式配置@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss")
序列化无限递归实体双向引用@JsonIgnore 或改用 DTO
换了 JSON 库注解失效注解体系不兼容 Jackson统一注解体系,或回归 Jackson
需要输出 HTML 片段RestController 默认输出文本/JSON返回 ResponseEntity 并指定 text/html

最后再分享一个我自己项目里贯彻很久的习惯:新建控制器之前,先问自己一个问题——“这个类里有没有方法要返回页面?”有页面,写@Controller,接口方法单独挂@ResponseBody;百分百纯数据,写@RestController,返回值统一走Result<T>。这个简单规则我用了很多年,几乎没有再因为注解选错踩过坑。如果你现在正被某个 “接口时好时坏、返回值时而是 JSON 时而是字符串” 的问题折磨,先别改业务逻辑,停下来看一眼注解和返回类型,八成问题就出在这两个词的组合上。

RELATED READING

延伸阅读

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