Spring MVC Controller 返回值类型全解

5 阅读5分钟

Spring MVC Controller 返回值类型全解

一、底层机制先说清楚

Controller 方法的返回值由 HandlerMethodReturnValueHandler 链决定怎么处理。是否需要视图解析、是否序列化成 JSON,取决于:

  • 方法/类上有没有 @ResponseBody(或 @RestController)
  • 返回类型本身是否被某个 handler 支持(如 ResponseEntity、Callable、View)

理解这一点后,所有返回类型可以分成四类:视图类、数据类、异步类、特殊类。


二、视图类(传统 MVC / 服务端渲染)

1. String —— 返回逻辑视图名

@GetMapping("/user")
public String user(Model model) {
    model.addAttribute("user", userService.get(1));
    return "user/detail";          // → /WEB-INF/views/user/detail.jsp
}

场景:JSP / Thymeleaf / FreeMarker 页面渲染,最常见的传统写法。

注意:加 @ResponseBody 后含义完全变了 —— 变成直接把字符串写进响应体:

@ResponseBody
@GetMapping("/ping")
public String ping() { return "pong"; }   // HTTP body = pong,不解析视图

2. ModelAndView —— 视图名 + 数据一步到位

@GetMapping("/user")
public ModelAndView user() {
    ModelAndView mv = new ModelAndView("user/detail");
    mv.addObject("user", userService.get(1));
    mv.setStatus(HttpStatus.OK);
    return mv;
}

场景:需要在方法内部动态决定视图名(如根据参数选择模板),或想把视图和模型封装成一个对象传递时。

3. Model / ModelMap / Map —— 只返回数据,视图名由请求路径推断

@GetMapping("/user/list")
public Map<String, Object> list() {
    return Map.of("users", userService.list());   // 视图名推断为 "user/list"
}

场景:不想硬编码视图名,依赖 RequestToViewNameTranslator 用 URL 推断模板路径。REST 场景下配合 @ResponseBody 就是返回 JSON。

4. void —— 自己操作 HttpServletResponse

@GetMapping("/export")
public void export(HttpServletResponse resp) throws IOException {
    resp.setContentType("text/csv");
    resp.getWriter().write(csv);
}

场景:需要手动完全控制响应(流式写出、自定义 header、老式 Servlet 风格)。可读性差,现代代码不推荐。

5. View 实现类 —— 显式指定渲染方式

@GetMapping("/old")
public View redirect() {
    return new RedirectView("/new-url");        // 302
}
@GetMapping("/json")
public View jsonView() {
    MappingJackson2JsonView v = new MappingJackson2JsonView();
    v.setAttributesMap(Map.of("code", 0));      // 直接输出 JSON 视图
    return v;
}

场景:框架内部/特殊渲染需求,业务代码少见。

6. 重定向与 Flash 属性

@PostMapping("/user")
public String create(User user, RedirectAttributes ra) {
    userService.save(user);
    ra.addFlashAttribute("msg", "保存成功");     // 存 session,重定向后取一次
    ra.addAttribute("id", user.getId());        // 拼到 URL 上 ?id=1
    return "redirect:/user/list";
}

场景:表单提交后防重复提交(PRG 模式)。forward: 前缀则是不改 URL 的服务器内部转发。


三、数据类(REST / JSON API)

1. @ResponseBody + 任意对象 / @RestController

@RestController                       // = @Controller + @ResponseBody
public class UserController {
    @GetMapping("/api/users/{id}")
    public User get(@PathVariable Long id) {
        return userService.get(id);   // → Jackson 序列化成 JSON
    }
}

场景:前后端分离的主流写法。返回 DTO/VO/统一响应包装类(如 Result<T>)。

2. ResponseEntity<T> —— 数据 + 状态码 + 响应头

@GetMapping("/api/users/{id}")
public ResponseEntity<User> get(@PathVariable Long id) {
    return userService.find(id)
        .map(ResponseEntity::ok)
        .orElseGet(() -> ResponseEntity.notFound().build());
}

场景:需要精确控制 HTTP 状态码和 Header 时的首选。创建资源推荐:

@PostMapping("/api/users")
public ResponseEntity<User> create(@RequestBody User user) {
    User saved = userService.save(user);
    URI loc = ServletUriComponentsBuilder.fromCurrentRequest()
                 .path("/{id}").buildAndExpand(saved.getId()).toUri();
    return ResponseEntity.created(loc).body(saved);   // 201 + Location
}

3. ResponseEntity<Void> —— 只要状态码,不要 body

@DeleteMapping("/api/users/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
    userService.delete(id);
    return ResponseEntity.noContent().build();       // 204
}

场景:DELETE / 无返回内容的操作。

4. HttpHeaders / HttpEntity<T>

@GetMapping("/api/headers")
public HttpHeaders headers() {                     // 只返回响应头
    HttpHeaders h = new HttpHeaders();
    h.set("X-Trace-Id", "abc");
    return h;
}

场景:HttpEntity 是 ResponseEntity 的父类,当状态码由框架/异常决定、你只想控制 body 和 header 时使用。实际业务里几乎都用 ResponseEntity。

5. ProblemDetail(Spring 6 / Boot 3,RFC 7807)

@ExceptionHandler(NotFoundException.class)
public ProblemDetail handle(NotFoundException e) {
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
    pd.setTitle("资源不存在");
    pd.setProperty("traceId", MDC.get("traceId"));
    return pd;   // Content-Type: application/problem+json
}

场景:标准化的 API 错误响应,替代自定义错误结构。

6. 文件下载 / 流式输出

// 一次性输出(小文件,会被完整读入内存)
@GetMapping("/api/file")
public ResponseEntity<byte[]> download() {
    byte[] data = service.load();
    return ResponseEntity.ok()
        .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"a.pdf\"")
        .contentType(MediaType.APPLICATION_PDF)
        .body(data);
}

// 大文件推荐:Resource + 复用 Content-Length / Range 支持
@GetMapping("/api/file2")
public ResponseEntity<Resource> download2() {
    Resource r = new FileSystemResource("/data/big.zip");
    return ResponseEntity.ok()
        .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"big.zip\"")
        .body(r);
}

场景:导出 Excel/PDF、图片、附件下载。


四、异步类(长耗时 / 推送)

返回类型特点场景
Callable<T>由 Spring 的 AsyncTaskExecutor 执行,自动处理超时简单异步,线程由框架管理
DeferredResult<T>结果由其他线程写入,不占用容器线程长轮询、等待 MQ/回调结果
WebAsyncTask<T>Callable 增强版,可设超时和回调需要精细控制超时/异常
StreamingResponseBody直接在响应流上写,不占 Servlet 线程大文件、CSV 导出
SseEmittertext/event-stream 单向推送进度条、实时通知
Mono<T> / Flux<T>WebFlux 响应式响应式技术栈(注意:Spring MVC 也支持返回,但语义不同)
@GetMapping("/api/async")
public DeferredResult<String> async() {
    DeferredResult<String> dr = new DeferredResult<>(5000L, "timeout");
    executor.execute(() -> dr.setResult(service.slowWork()));
    return dr;
}

@GetMapping(value = "/api/progress", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter progress() {
    SseEmitter emitter = new SseEmitter();
    executor.execute(() -> {
        for (int i = 0; i <= 100; i += 10) emitter.send(i + "%");
        emitter.complete();
    });
    return emitter;
}

场景:接口耗时超过几秒(避免占满 Tomcat 线程池)、AI 流式输出、实时进度推送。


五、特殊 / 补充写法

@ResponseStatus 声明状态码

@PostMapping("/api/users")
@ResponseStatus(HttpStatus.CREATED)
public User create(@RequestBody User u) { return service.save(u); }

场景:状态码固定、不需要动态 header 时,比 ResponseEntity 更简洁。

异常处理返回值

@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(BizException.class)
    public ResponseEntity<Result<Void>> handle(BizException e) {
        return ResponseEntity.badRequest().body(Result.fail(e.getMessage()));
    }
}

场景:统一错误响应,Controller 里就可以放心直接返回业务数据。

@ModelAttribute 方法

@ModelAttribute("cities")
public List<String> cities() { return cityService.all(); }

场景:为同一 Controller 的所有视图预置公共数据,不参与 URL 映射。


六、怎么选:决策清单

你的需求推荐返回类型
渲染 HTML 页面String(视图名) / ModelAndView
返回 JSON,状态码固定 200@RestController + 业务对象 / Result<T>
返回 JSON,需控制状态码 / HeaderResponseEntity<T>
只返回状态码(204/201 无内容)ResponseEntity<Void> + @ResponseStatus
文件下载ResponseEntity<Resource> / StreamingResponseBody
接口耗时长Callable / DeferredResult
实时推送SseEmitter / Flux
手动操控响应void + HttpServletResponse(尽量少用)
标准化错误体ProblemDetail + @RestControllerAdvice
重定向"redirect:/path" + RedirectAttributes

一句话原则:传统 MVC 优先 String/ModelAndView;REST API 默认 @RestController 直接返回对象,一旦需要控制状态码或 Header 就升级为 ResponseEntity<T>,耗时接口用异步类型,文件/推送用流式类型。

七、常见坑

  1. String 加不加 @ResponseBody 语义完全不同 —— 一个走视图解析,一个写 body。类上用了 @RestController 就全是后者。
  2. void 返回但什么都没写 —— Spring 会当成"视图已由方法处理",结果 404 或空响应。用 void 必须自己写 response。
  3. Model/Map 不加 @ResponseBody 会去找视图模板 —— 会报 Circular view path 或 404,别想当然当 JSON 用。
  4. byte[] 大文件会全量进内存 —— 用 Resource/StreamingResponseBody 替代。
  5. ResponseEntity 和 @ResponseStatus 同时用 —— ResponseEntity 优先级更高,@ResponseStatus 被忽略。
  6. 异步返回类型里抛异常 —— 不会被普通的 @ExceptionHandler 捕获,需要 DeferredResult.setErrorResult() 或 AsyncRequestTimeoutException 处理。