控制器方法的返回值最终需要转换成客户端能够理解的格式——通常是 JSON 字符串,或者渲染后的 HTML 页面。@ResponseBody 和 @RestController 让 JSON 响应变得极其简单,但真实项目中远不止“返回一个对象”这么直白。你还需要考虑 序列化风格的一致性、异常与空值的处理、统一响应格式的封装,以及 传统视图渲染的场景。本节将这些高频需求逐一讲透。
12.4.1 JSON 序列化的配置与定制
Spring Boot 默认使用 Jackson 作为 JSON 处理器,并自动注册了 MappingJackson2HttpMessageConverter。通常情况下,返回一个 POJO 就能自动输出 JSON:
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
return userService.findById(id);
}
但默认的序列化行为往往不符合团队规范。你需要掌控以下方面。
1. 常用 Jackson 配置
在 application.yml 中即可完成最常见的定制:
spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss # 全局日期格式
time-zone: GMT+8 # 时区
serialization:
write-dates-as-timestamps: false # 不输出时间戳
default-property-inclusion: non_null # 忽略 null 值
property-naming-strategy: SNAKE_CASE # 下划线风格(需 Jackson 2.12+)
这些配置背后,Spring Boot 会自动调整 ObjectMapper 的行为。只需引入 spring-boot-starter-web,无需额外代码。
2. 使用注解精确控制序列化
当全局配置无法满足某个实体的特殊需求时,Jackson 提供了丰富的注解:
public class User {
@JsonIgnore // 永远不序列化密码
private String password;
@JsonProperty("user_name") // 重命名字段
private String username;
@JsonFormat(pattern = "yyyy/MM/dd")
private LocalDate birthday;
@JsonInclude(JsonInclude.Include.NON_EMPTY) // 仅非空输出
private String email;
}
3. 自定义 ObjectMapper 扩展
当需要全局添加序列化特性或模块时(例如支持 Java 8 时间类型、长整型转字符串防止前端精度丢失),可以在配置类中定制 Jackson2ObjectMapperBuilder:
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
return builder -> {
builder.simpleDateFormat("yyyy-MM-dd HH:mm:ss");
builder.modules(new JavaTimeModule()); // 支持 LocalDateTime
builder.serializationInclusion(JsonInclude.Include.NON_NULL);
// Long 转 String,避免 JS 精度丢失
builder.serializerByType(Long.class, ToStringSerializer.instance);
builder.serializerByType(Long.TYPE, ToStringSerializer.instance);
};
}
}
4. 处理循环引用与懒加载
JPA 实体双向关联时,序列化可能引发无限递归(StackOverflowError)。解决方案有多种:
- 在一方标注
@JsonIgnore,切断序列化路径。 - 使用
@JsonManagedReference和@JsonBackReference配合。 - 引入
jackson-datatype-hibernate5模块,它会自动处理未初始化的懒加载代理,输出null而不是抛出异常。
在大规模项目中,更推荐将实体与 VO/DTO 分离,序列化永远只针对 DTO 进行,彻底规避实体关联带来的陷阱。
12.4.2 统一返回封装:让 API 风格一致
真实项目中,几乎没有服务会裸奔一个实体出去。统一返回格式能够极大地降低前端对接成本,也便于全局异常处理和日志记录。业界常见的封装格式如下:
{
"code": 200,
"message": "success",
"data": { ... }
}
1. 定义通用返回体
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
private int code;
private String message;
private T data;
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(200, "success", data);
}
public static <T> ApiResponse<T> error(int code, String message) {
return new ApiResponse<>(code, message, null);
}
}
然后在控制器中手动包装:
@GetMapping("/users/{id}")
public ApiResponse<User> getUser(@PathVariable Long id) {
return ApiResponse.success(userService.findById(id));
}
这样写确实统一了格式,但每处方法都要手动包装,很繁琐,而且容易遗忘。
2. 使用 ResponseBodyAdvice 自动包装
Spring 提供的 ResponseBodyAdvice 可以在响应体写出之前进行拦截和替换,实现无侵入的统一封装:
@RestControllerAdvice
public class ApiResponseAdvice implements ResponseBodyAdvice<Object> {
@Override
public boolean supports(MethodParameter returnType,
Class<? extends HttpMessageConverter<?>> converterType) {
// 如果已经封装过,或者返回的是 String(避免类型转换异常),则跳过
return !returnType.getParameterType().equals(ApiResponse.class)
&& !returnType.getParameterType().equals(String.class);
}
@Override
public Object beforeBodyWrite(Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response) {
// 如果已经是 ApiResponse 类型则不处理(支持)
if (body instanceof ApiResponse) {
return body;
}
// 否则自动包裹为成功结果
return ApiResponse.success(body);
}
}
要点说明:
supports方法判断是否需要拦截,这里避免对已经是ApiResponse的返回值重复包装,也避免干扰String返回值(因为String会被StringHttpMessageConverter处理,直接转成 JSON 会出问题)。beforeBodyWrite进行实际的包装。@RestControllerAdvice使得该 Advice 对所有@Controller(含@RestController)生效。
3. 特殊情况处理
- String 返回值:如果控制器直接返回
String且想包装,需要在 Advice 中对 String 特殊处理(将ApiResponse转为 JSON 字符串后再返回,注意设置正确的 Content-Type)。 - 文件下载:返回
ResponseEntity<Resource>或byte[]时不应包装,可在supports中增加类型判断。 - 异常响应:可以结合前文全局异常处理,在异常处理器中直接返回
ApiResponse.error(...),此时 Advice 的supports会将其排除,不再进行二次包装。
经过这样的设计,任何控制器方法只需返回业务数据本身,响应都会被自动纳入统一的 ApiResponse 信封,风格极度一致。
12.4.3 视图解析与页面渲染
虽然前后端分离已成主流,但仍有不少场景需要在服务端渲染页面(例如管理后台、邮件模板、或某些传统项目)。Spring MVC 对视图层的支持同样完善。
1. 视图解析器的工作原理
当控制器方法没有标注 @ResponseBody,而是返回一个 视图名称(String)或 ModelAndView 对象时,Spring MVC 会使用 视图解析器(ViewResolver) 去寻找真正的视图资源:
@Controller
public class HomeController {
@GetMapping("/home")
public String home(Model model) {
model.addAttribute("message", "欢迎回来");
return "home"; // 逻辑视图名
}
}
InternalResourceViewResolver 默认会把 "home" 解析为 /WEB-INF/views/home.jsp。但在 Spring Boot 中,更推荐使用模板引擎。
2. Thymeleaf 模板引擎的集成
添加 spring-boot-starter-thymeleaf 后,Spring Boot 会自动配置 Thymeleaf 视图解析器,默认视图前缀为 classpath:/templates/,后缀为 .html。上面的 "home" 就会对应到 templates/home.html。
Thymeleaf 页面示例:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><title>首页</title></head>
<body>
<h1 th:text="${message}"></h1>
</body>
</html>
3. 转发与重定向
在控制器返回值中,可以使用特殊前缀来控制跳转行为:
return "forward:/api/users"— 服务器内部转发,浏览器地址栏不变。return "redirect:/home"— 302 重定向,浏览器地址栏变为新 URL。
这在表单提交后的 Post-Redirect-Get 模式中非常实用,避免表单重复提交。
4. 同时支持 JSON 与视图
一个控制器类中,可以混用 @ResponseBody 和视图返回。只需在方法上按需标注即可,无需拆分两个 Controller。但建议按职责拆分为 @RestController(纯 API)和 @Controller(页面),使边界更清晰。
从 JSON 序列化的细节打磨,到统一返回封装的设计,再到传统视图渲染的平滑支持,Spring MVC 的返回值处理机制给予了开发者极大的自由度。当你把这些手段组合在一起,就能构建出风格一致、前后端协作流畅、同时兼顾页面与 API 的稳健 Web 层。