欢迎你来读这篇博客。
本文围绕 Spring Web 中三个经常一起出现、却又容易相互混淆的知识点展开:
- Spring MVC 如何通过内容协商确定请求与响应的数据格式;
HttpMessageConverter 如何完成 HTTP 消息体与 Java 对象之间的转换;
- Spring Boot 如何根据客户端语言偏好完成国际化。
序言
在日常开发中,我们经常会写出下面这样的接口:
1 2 3 4 5 6 7 8 9
| @RestController @RequestMapping("/users") public class UserController {
@PostMapping public UserResponse create(@RequestBody UserRequest request) { return new UserResponse(1001L, request.name()); } }
|
代码看起来很简单,但 Spring 在背后完成了不少工作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| HTTP 请求 ↓ 根据 URL、HTTP Method、consumes 匹配 Controller ↓ 根据 Content-Type 选择消息转换器 ↓ HTTP Body → Java 对象 ↓ 执行 Controller ↓ 根据 Accept 和 produces 确定响应类型 ↓ Java 对象 → HTTP Body ↓ 根据 Accept-Language 解析国际化消息
|
理解这条链路后,很多常见问题就不再神秘:
- 为什么有时返回
400,有时返回 406 或 415?
- 为什么返回
String 时没有走 Jackson?
- 为什么自定义转换器后,JSON 接口突然全部失效?
- 为什么已经创建了
messages_zh_CN.properties,国际化仍然不生效?
Converter、Formatter 和 HttpMessageConverter 到底有什么区别?
下面从内容协商开始,把整个流程串起来。
正文
一、内容协商是什么
内容协商,英文为 Content Negotiation。
它解决的核心问题是:
当同一个资源可以用多种形式表示时,服务端应该使用哪一种格式读取请求,又应该使用哪一种格式返回响应?
例如,同一个用户资源既可以表示为 JSON:
1 2 3 4
| { "id": 1001, "name": "SuperMario" }
|
也可以表示为 XML:
1 2 3 4
| <user> <id>1001</id> <name>SuperMario</name> </user>
|
客户端可以通过 HTTP 请求头表达自己的需求:
1 2 3 4
| POST /users Content-Type: application/json Accept: application/xml Accept-Language: zh-CN
|
这三个请求头分别表达:
| 请求头 |
含义 |
Content-Type |
当前发送给服务端的请求体是什么格式 |
Accept |
客户端希望服务端返回什么格式 |
Accept-Language |
客户端希望响应消息使用什么语言 |
因此,上面的请求可以理解为:
1 2 3
| 我发送的是 JSON; 我希望你返回 XML; 提示信息请使用简体中文。
|
需要注意:
Content-Type 主要影响请求体读取;
Accept 主要影响响应体写出;
Accept-Language 主要影响国际化语言选择;
- 内容类型协商和语言国际化是两套机制,只是经常在同一个请求中同时出现。
二、consumes 与 produces
Spring MVC 可以通过 @RequestMapping、@GetMapping、@PostMapping 等注解中的 consumes 和 produces 限制接口支持的媒体类型。
1 2 3 4 5 6 7
| @PostMapping( consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE ) public UserResponse create(@RequestBody UserRequest request) { return new UserResponse(1001L, request.name()); }
|
1. consumes
consumes 表示接口能够消费的请求体格式。
1
| consumes = MediaType.APPLICATION_JSON_VALUE
|
客户端应发送:
1
| Content-Type: application/json
|
如果客户端发送:
1
| Content-Type: application/xml
|
而接口不支持 XML,通常会得到:
1
| 415 Unsupported Media Type
|
2. produces
produces 表示接口能够生成的响应格式。
1
| produces = MediaType.APPLICATION_JSON_VALUE
|
如果客户端要求:
但接口只能生成 JSON,通常会得到:
3. 它们并不负责序列化
consumes 和 produces 只负责约束请求映射与媒体类型匹配。
真正执行下面这些工作的,是 HttpMessageConverter:
1 2 3 4
| JSON → Java 对象 Java 对象 → JSON XML → Java 对象 Java 对象 → XML
|
换句话说:
1 2
| consumes / produces:负责声明和筛选 HttpMessageConverter:负责真正转换
|
三、内容协商的执行过程
假设接口支持 JSON 和 XML:
1 2 3 4 5 6 7 8 9 10 11 12 13
| @PostMapping( consumes = { MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE }, produces = { MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE } ) public UserResponse create(@RequestBody UserRequest request) { return new UserResponse(1001L, request.name()); }
|
客户端请求:
1 2 3
| POST /users Content-Type: application/json Accept: application/xml
|
1. 请求读取阶段
大致流程如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| DispatcherServlet 接收请求 ↓ HandlerMapping 匹配 Controller ↓ 检查 URL、HTTP Method 和 consumes ↓ RequestResponseBodyMethodProcessor 处理 @RequestBody ↓ 读取 Content-Type:application/json ↓ 遍历 HttpMessageConverter ↓ 调用 canRead(...) ↓ 找到 JSON 转换器 ↓ JSON → UserRequest ↓ 调用 Controller
|
2. 响应写出阶段
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| Controller 返回 UserResponse ↓ RequestResponseBodyMethodProcessor 处理 @ResponseBody ↓ 读取客户端 Accept:application/xml ↓ 结合 produces 计算可用媒体类型 ↓ 遍历 HttpMessageConverter ↓ 调用 canWrite(...) ↓ 找到 XML 转换器 ↓ UserResponse → XML ↓ 写入 HTTP Response
|
最终选择条件可以简化为:
1 2 3 4 5 6 7 8 9 10
| 读取请求体: 目标 Java 类型 + 请求 Content-Type + converter.canRead(...)
写出响应体: Java 返回值类型 + 客户端 Accept + Controller produces + converter.canWrite(...)
|
四、常见状态码与异常
理解内容协商后,可以更快地区分 400、406 和 415。
| 场景 |
常见状态码 |
请求的 Content-Type 不被接口或转换器支持 |
415 |
| 客户端要求的响应格式无法生成 |
406 |
| 格式受支持,但请求体内容无法反序列化 |
400 |
@RequestBody 必填但请求体为空 |
400 |
| Bean Validation 参数校验失败 |
400 |
例如,请求头是:
1
| Content-Type: application/json
|
但请求体是非法 JSON:
此时 JSON 格式本身是被支持的,因此不是 415。
问题在于 Jackson 无法把 abc 转换成目标字段类型,通常会抛出:
1
| HttpMessageNotReadableException
|
最终返回:
可以这样记:
1 2 3
| 415:这种格式我不认识; 400:格式我认识,但内容读不懂; 406:你要的返回格式我给不了。
|
五、Spring MVC 默认如何进行内容协商
Spring MVC 默认主要根据 Accept 请求头确定客户端期望的响应媒体类型。
例如:
1
| Accept: application/json
|
服务端会尝试返回 JSON。
客户端也可以发送带权重的请求头:
1
| Accept: application/xml;q=0.9, application/json;q=0.8
|
其中 q 表示偏好权重,数值越大优先级越高。
也可以使用通配符:
或者:
当客户端发送 */* 时,服务端会根据 Controller 的 produces、转换器支持类型以及默认策略选择响应类型。
Spring MVC 还支持通过查询参数等方式进行内容协商,例如:
1
| GET /users/1001?format=json
|
配置示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @Configuration public class WebMvcContentNegotiationConfiguration implements WebMvcConfigurer {
@Override public void configureContentNegotiation( ContentNegotiationConfigurer configurer) {
configurer .favorParameter(true) .parameterName("format") .mediaType("json", MediaType.APPLICATION_JSON) .mediaType("xml", MediaType.APPLICATION_XML) .defaultContentType(MediaType.APPLICATION_JSON); } }
|
不过对于 REST API,通常更推荐使用标准的 Accept 请求头。
不建议把 URL 后缀作为主要协商机制,例如:
1 2
| /users/1001.json /users/1001.xml
|
一方面会增加路由与缓存复杂度,另一方面还涉及后缀匹配及安全边界问题。
六、什么是 HttpMessageConverter
HttpMessageConverter<T> 是 Spring 对 HTTP 消息体转换过程的抽象。
它主要完成两个方向的转换:
1 2
| read:HTTP 请求体 → Java 对象 write:Java 对象 → HTTP 响应体
|
核心接口可以简化为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| public interface HttpMessageConverter<T> {
boolean canRead(Class<?> clazz, MediaType mediaType);
boolean canWrite(Class<?> clazz, MediaType mediaType);
List<MediaType> getSupportedMediaTypes();
T read( Class<? extends T> clazz, HttpInputMessage inputMessage );
void write( T value, MediaType contentType, HttpOutputMessage outputMessage ); }
|
Spring 在选择转换器时,不是只看媒体类型,也不是只看 Java 类型,而是综合判断:
1
| Java 类型 + MediaType + 转换器顺序
|
七、哪些场景会使用消息转换器
下面这些参数或返回值通常会经过 HttpMessageConverter:
1 2 3 4 5 6 7
| @RequestBody @ResponseBody @RestController HttpEntity<T> RequestEntity<T> ResponseEntity<T> @RequestPart
|
例如:
1 2 3 4 5 6 7 8 9
| @PostMapping public ResponseEntity<UserResponse> create( @RequestBody UserRequest request) {
UserResponse response = new UserResponse(1001L, request.name());
return ResponseEntity.ok(response); }
|
处理过程:
1 2 3 4 5 6 7 8 9 10 11 12 13
| JSON 请求体 ↓ MappingJackson2HttpMessageConverter ↓ UserRequest ↓ Controller ↓ UserResponse ↓ MappingJackson2HttpMessageConverter ↓ JSON 响应体
|
八、哪些场景不使用消息转换器
下面这些参数通常不会使用 HttpMessageConverter:
1 2 3 4 5
| @RequestParam @PathVariable @RequestHeader @CookieValue @ModelAttribute
|
例如:
1 2 3 4 5 6
| @GetMapping("/{id}") public UserResponse get( @PathVariable Long id, @RequestParam LocalDate date) { }
|
这里的转换类似:
1 2
| "1001" → Long "2026-07-14" → LocalDate
|
通常由以下机制负责:
1 2 3 4 5
| Converter Formatter ConversionService DataBinder HandlerMethodArgumentResolver
|
因此要区分两个名字相近的概念:
| 类型 |
作用 |
Converter<S, T> |
单个 Java 值或属性之间的类型转换 |
Formatter<T> |
字符串与 Java 类型之间的格式化转换 |
HttpMessageConverter<T> |
整个 HTTP 请求体或响应体的转换 |
Spring 的命名在这里很像,面试时也很喜欢拿它们玩“大家来找茬”。
九、Spring 内置消息转换器的“5+1”
很多教程会把 Spring MVC 常见的基础消息转换器概括为“5+1”。
这里需要先说明:
“5+1”是便于教学和记忆的概括,不代表所有 Spring 版本、所有依赖组合下永远只有六个转换器。
转换器列表会受到以下因素影响:
- Spring Framework 版本;
- Spring Boot 自动配置;
- classpath 中是否存在 Jackson、JAXB、Gson、Protobuf 等依赖;
- 开发者是否自定义 MVC 配置。
在现代 Spring MVC 的常见基础配置中,可以把“5+1”理解为:
| 分类 |
转换器 |
| 基础 1 |
ByteArrayHttpMessageConverter |
| 基础 2 |
StringHttpMessageConverter |
| 基础 3 |
ResourceHttpMessageConverter |
| 基础 4 |
ResourceRegionHttpMessageConverter |
| 基础 5 |
AllEncompassingFormHttpMessageConverter |
+1 |
MappingJackson2HttpMessageConverter |
下面分别说明。
1. ByteArrayHttpMessageConverter
主要处理:
示例:
1 2 3 4 5 6 7
| @GetMapping( value = "/image", produces = MediaType.IMAGE_PNG_VALUE ) public byte[] image() { return imageBytes; }
|
适用于:
- 小型二进制内容;
- 图片;
- 加密后的字节数据;
- 已经存在于内存中的文件内容。
不建议使用 byte[] 返回超大文件,因为整个文件通常需要进入 JVM 堆内存。
大文件下载更适合使用:
1 2
| Resource StreamingResponseBody
|
2. StringHttpMessageConverter
主要处理:
示例:
1 2 3 4 5 6 7
| @GetMapping( value = "/text", produces = MediaType.TEXT_PLAIN_VALUE ) public String text() { return "hello"; }
|
需要注意普通 @Controller 和 @RestController 的区别。
在 @RestController 中:
1 2 3 4 5 6 7 8
| @RestController public class TextController {
@GetMapping("/hello") public String hello() { return "hello"; } }
|
"hello" 会作为响应体写出。
在普通 @Controller 中:
1 2 3 4 5 6 7 8
| @Controller public class PageController {
@GetMapping("/home") public String home() { return "home"; } }
|
若没有 @ResponseBody,"home" 通常表示视图名称。
3. ResourceHttpMessageConverter
主要处理:
常用于文件下载:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| @GetMapping("/download") public ResponseEntity<Resource> download() {
Resource resource = new FileSystemResource("/data/report.pdf");
return ResponseEntity.ok() .contentType(MediaType.APPLICATION_PDF) .header( HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"report.pdf\"" ) .body(resource); }
|
相比直接返回 byte[],Resource 更适合表达文件、类路径资源和流式资源。
4. ResourceRegionHttpMessageConverter
主要处理:
它用于 HTTP Range 请求,例如:
常见场景:
- 视频拖动播放;
- 音频分段加载;
- 文件部分读取;
- 断点续传。
服务端可能返回:
主要处理:
1 2 3
| application/x-www-form-urlencoded multipart/form-data multipart/mixed
|
常见 Java 类型:
1 2
| MultiValueMap<String, String> MultiValueMap<String, Object>
|
它可以处理普通表单,也可以处理 multipart 中的文本、文件和其他 part。
例如:
1 2 3 4 5 6 7 8 9
| @PostMapping( value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE ) public UploadResponse upload( @RequestPart("file") MultipartFile file, @RequestPart("metadata") UploadMetadata metadata) { }
|
其中不同 part 仍可能由不同消息转换器处理。
6. MappingJackson2HttpMessageConverter
它就是常说的“+1”,负责 Jackson JSON 转换:
1 2
| JSON → Java 对象 Java 对象 → JSON
|
例如:
1 2 3 4 5
| public record UserRequest( String name, Integer age ) { }
|
请求体:
1 2 3 4
| { "name": "SuperMario", "age": 18 }
|
会被转换为:
1
| new UserRequest("SuperMario", 18)
|
它的核心依赖是 Jackson 的:
7. 为什么有些资料写“6+1”
在 Spring Framework 5.x 等历史版本中,默认列表中还可能看到:
1
| SourceHttpMessageConverter
|
它主要处理 XML Source 类型。
因此,不同版本的资料中可能出现:
这些说法不一定互相矛盾,关键要结合:
1 2 3
| Spring 版本 依赖环境 最终注册的转换器列表
|
真正排查问题时,可以直接打印当前应用中的转换器,而不是死背数量。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| @Component @RequiredArgsConstructor public class MessageConverterPrinter implements ApplicationRunner {
private final RequestMappingHandlerAdapter handlerAdapter;
@Override public void run(ApplicationArguments args) { handlerAdapter.getMessageConverters() .forEach(converter -> System.out.println( converter.getClass().getName() + " -> " + converter.getSupportedMediaTypes() ) ); } }
|
这比背一个固定数字可靠得多。源码不会照顾你的记忆口诀,但日志会。
十、转换器的选择顺序
Spring 会按照转换器列表的顺序进行匹配。
简化后的逻辑可以理解为:
1 2 3 4 5 6 7 8 9 10
| for (HttpMessageConverter<?> converter : converters) { if (converter.canWrite(returnType, selectedMediaType)) { converter.write( body, selectedMediaType, outputMessage ); return; } }
|
因此,转换器顺序很重要。
假设自定义转换器支持:
并声明媒体类型:
那么它可能会抢先处理大量本应交给 Jackson、String 或 Resource 转换器的响应。
自定义转换器应尽量缩小匹配范围:
1 2 3 4 5
| 限制 Java 类型; 限制 MediaType; 明确只读、只写或双向; 避免 Object.class; 避免 MediaType.ALL。
|
十一、自定义 HttpMessageConverter
下面实现一种简单的自定义协议:
1
| Content-Type: application/x-user
|
数据格式:
对应 Java 对象:
1 2 3 4 5
| public record UserPayload( Long id, String name ) { }
|
1. 实现转换器
通常不需要直接实现全部接口,可以继承:
1
| AbstractHttpMessageConverter<T>
|
完整示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77
| package top.egon.example.web.converter;
import java.io.IOException; import java.nio.charset.StandardCharsets;
import org.springframework.http.HttpInputMessage; import org.springframework.http.HttpOutputMessage; import org.springframework.http.MediaType; import org.springframework.http.converter.AbstractHttpMessageConverter; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.util.StreamUtils;
public final class UserTextHttpMessageConverter extends AbstractHttpMessageConverter<UserPayload> {
public static final MediaType USER_MEDIA_TYPE = MediaType.parseMediaType("application/x-user");
public UserTextHttpMessageConverter() { super(StandardCharsets.UTF_8, USER_MEDIA_TYPE); }
@Override protected boolean supports(Class<?> clazz) { return UserPayload.class.isAssignableFrom(clazz); }
@Override protected UserPayload readInternal( Class<? extends UserPayload> clazz, HttpInputMessage inputMessage) throws IOException {
String body = StreamUtils.copyToString( inputMessage.getBody(), StandardCharsets.UTF_8 );
String[] parts = body.split("\\|", 2);
if (parts.length != 2) { throw new HttpMessageNotReadableException( "User 数据格式错误,正确格式应为:id|name", inputMessage ); }
try { return new UserPayload( Long.valueOf(parts[0]), parts[1] ); } catch (NumberFormatException exception) { throw new HttpMessageNotReadableException( "User id 必须是 Long 类型", exception, inputMessage ); } }
@Override protected void writeInternal( UserPayload user, HttpOutputMessage outputMessage) throws IOException {
String body = user.id() + "|" + user.name();
StreamUtils.copy( body, StandardCharsets.UTF_8, outputMessage.getBody() ); } }
|
2. 注册转换器
推荐使用:
1
| extendMessageConverters(...)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| package top.egon.example.web.config;
import java.util.List;
import org.springframework.context.annotation.Configuration; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import top.egon.example.web.converter.UserTextHttpMessageConverter;
@Configuration public class WebMvcMessageConverterConfiguration implements WebMvcConfigurer {
@Override public void extendMessageConverters( List<HttpMessageConverter<?>> converters) {
converters.add( 0, new UserTextHttpMessageConverter() ); } }
|
这里添加到下标 0,表示优先尝试。
因为该转换器只支持:
且只处理:
所以即使优先级较高,也不应该抢占普通 JSON 请求。
3. Controller 使用自定义格式
1 2 3 4 5 6 7 8 9 10 11 12 13
| @RestController @RequestMapping("/custom-users") public class CustomUserController {
@PostMapping( consumes = "application/x-user", produces = "application/x-user" ) public UserPayload create( @RequestBody UserPayload request) { return request; } }
|
请求:
1 2 3 4 5
| POST /custom-users Content-Type: application/x-user Accept: application/x-user
1001|SuperMario
|
响应:
1 2 3
| Content-Type: application/x-user;charset=UTF-8
1001|SuperMario
|
这是自定义消息转换器时最容易踩的坑之一。
1. extendMessageConverters
1 2 3 4 5 6 7 8
| @Override public void extendMessageConverters( List<HttpMessageConverter<?>> converters) {
converters.add( new UserTextHttpMessageConverter() ); }
|
它的语义是:
1 2
| 保留框架已经配置好的转换器; 在现有列表基础上增加、删除或调整。
|
适合绝大多数业务项目。
1 2 3 4 5 6 7 8
| @Override public void configureMessageConverters( List<HttpMessageConverter<?>> converters) {
converters.add( new UserTextHttpMessageConverter() ); }
|
它更接近“自行配置消息转换器列表”。
如果使用方式不当,可能导致默认转换器没有按预期注册,随后出现:
1 2 3 4
| JSON 不能反序列化; String 不能直接返回; 文件下载失败; multipart 行为异常。
|
因此,一般建议:
1 2 3 4 5
| 增加一个自定义转换器: 优先使用 extendMessageConverters
完全接管转换器列表: 才考虑 configureMessageConverters
|
Spring Boot 项目中,还应谨慎使用:
因为它会切换到更显式的 Spring MVC 配置模式,可能改变 Boot 自动配置行为。
如果只是增加拦截器、格式化器或消息转换器,通常实现:
即可,不必急着加 @EnableWebMvc。
十三、什么时候不应该自定义消息转换器
很多所谓“自定义转换器需求”,本质上仍然是 JSON,只是 JSON 规则需要调整。
这类需求应优先定制 Jackson,而不是创建新的 HttpMessageConverter。
1. 日期格式
1 2
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime createTime;
|
2. 字段重命名
1 2
| @JsonProperty("user_name") private String userName;
|
3. 忽略字段
1 2
| @JsonIgnore private String internalToken;
|
4. 枚举序列化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| public enum UserStatus {
ENABLED(1), DISABLED(0);
private final int code;
UserStatus(int code) { this.code = code; }
@JsonValue public int getCode() { return code; } }
|
5. 全局 Jackson 配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @Configuration public class JacksonConfiguration {
@Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
return builder -> builder .featuresToDisable( SerializationFeature.WRITE_DATES_AS_TIMESTAMPS ) .simpleDateFormat( "yyyy-MM-dd HH:mm:ss" ); } }
|
可以用一句话判断:
1 2 3 4 5
| 仍然是 JSON,只是序列化规则不同 → 定制 Jackson
新增了一种真正不同的 HTTP 数据格式 → 自定义 HttpMessageConverter
|
十四、Spring Boot 国际化的核心组件
Spring Boot 国际化主要围绕两个核心组件:
1 2
| MessageSource LocaleResolver
|
它们分别负责:
| 组件 |
职责 |
MessageSource |
根据消息码和 Locale 查找对应文案 |
LocaleResolver |
确定当前请求使用什么 Locale |
例如:
1 2 3 4 5
| messageSource.getMessage( "user.not-found", new Object[]{1001L}, Locale.SIMPLIFIED_CHINESE );
|
得到:
切换到:
则可以得到:
1
| User 1001 does not exist
|
十五、国际化资源文件
推荐目录:
1 2 3 4 5
| src/main/resources/ └── i18n/ ├── messages.properties ├── messages_zh_CN.properties └── messages_en_US.properties
|
1. 默认资源文件
messages.properties:
1 2 3
| user.not-found=User {0} does not exist user.name.required=User name cannot be empty system.error=System error
|
2. 中文资源文件
messages_zh_CN.properties:
1 2 3
| user.not-found=用户 {0} 不存在 user.name.required=用户名不能为空 system.error=系统异常
|
3. 英文资源文件
messages_en_US.properties:
1 2 3
| user.not-found=User {0} does not exist user.name.required=User name cannot be empty system.error=System error
|
4. 为什么默认文件不能省略
Spring Boot 的国际化自动配置会检查默认资源束。
即使已经存在:
1 2
| messages_zh_CN.properties messages_en_US.properties
|
也建议保留:
否则可能没有自动配置预期的 MessageSource。
默认文件同时承担最终兜底文案的职责。
十六、Spring Boot 国际化配置
application.yml:
1 2 3 4 5 6
| spring: messages: basename: i18n/messages encoding: UTF-8 fallback-to-system-locale: false use-code-as-default-message: false
|
配置说明:
| 配置项 |
作用 |
basename |
资源束基础路径,不包含语言后缀和 .properties |
encoding |
国际化资源文件编码 |
fallback-to-system-locale |
找不到语言文件时是否回退到操作系统 Locale |
use-code-as-default-message |
找不到文案时是否直接返回消息码 |
生产环境通常建议:
1
| fallback-to-system-locale: false
|
否则,同一个服务部署在不同系统 Locale 的机器上,兜底语言可能不一致。
use-code-as-default-message 在开发阶段有时比较方便,但生产环境应根据错误处理规范决定是否开启。
十七、使用 Accept-Language 选择语言
REST API 通常推荐通过标准请求头传递语言偏好:
或者:
可以注册 AcceptHeaderLocaleResolver:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| package top.egon.example.i18n;
import java.util.List; import java.util.Locale;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.LocaleResolver; import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
@Configuration public class LocaleConfiguration {
@Bean public LocaleResolver localeResolver() {
AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver();
resolver.setDefaultLocale( Locale.SIMPLIFIED_CHINESE );
resolver.setSupportedLocales( List.of( Locale.SIMPLIFIED_CHINESE, Locale.US ) );
return resolver; } }
|
请求示例:
1 2 3 4
| curl \ -H 'Accept: application/json' \ -H 'Accept-Language: en-US' \ http://localhost:8080/users/1001
|
这里同时进行了两种协商:
1 2 3 4 5
| Accept → 返回 JSON
Accept-Language → 使用英语文案
|
十八、通过查询参数切换语言
传统 Web 应用有时会使用:
1
| GET /users/1001?lang=en_US
|
可以配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| @Configuration public class LocaleConfiguration implements WebMvcConfigurer {
@Bean public LocaleResolver localeResolver() {
SessionLocaleResolver resolver = new SessionLocaleResolver();
resolver.setDefaultLocale( Locale.SIMPLIFIED_CHINESE );
return resolver; }
@Override public void addInterceptors( InterceptorRegistry registry) {
LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor();
interceptor.setParamName("lang");
registry.addInterceptor(interceptor); } }
|
这种方案会把语言选择保存在 Session 中。
适合:
- 传统 MVC 页面;
- 后台管理系统;
- 用户在页面上主动切换语言。
对于无状态 REST API,更推荐使用:
如果使用 JWT,也可以把用户语言偏好保存在用户配置中,然后实现自定义 LocaleResolver。
十九、封装国际化消息解析器
可以封装统一组件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| package top.egon.example.i18n;
import lombok.RequiredArgsConstructor;
import org.springframework.context.MessageSource; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.stereotype.Component;
@Component @RequiredArgsConstructor public class I18nMessageResolver {
private final MessageSource messageSource;
public String resolve( String code, Object... args) {
return messageSource.getMessage( code, args, code, LocaleContextHolder.getLocale() ); } }
|
调用:
1 2 3 4
| String message = i18nMessageResolver.resolve( "user.not-found", 1001L );
|
客户端传入:
返回:
客户端传入:
返回:
1
| User 1001 does not exist
|
二十、业务异常国际化
业务异常不建议直接保存中文文案:
1 2 3
| throw new BusinessException( "用户不存在" );
|
更合理的方式是保存:
1. 定义业务异常
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| package top.egon.example.exception;
import lombok.Getter;
@Getter public class BusinessException extends RuntimeException {
private final int code; private final String messageKey; private final Object[] messageArgs;
public BusinessException( int code, String messageKey, Object... messageArgs) {
super(messageKey); this.code = code; this.messageKey = messageKey; this.messageArgs = messageArgs; } }
|
抛出异常:
1 2 3 4 5
| throw new BusinessException( 1001001, "user.not-found", userId );
|
2. 全局异常处理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| package top.egon.example.web;
import lombok.RequiredArgsConstructor;
import org.springframework.context.MessageSource; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice @RequiredArgsConstructor public class GlobalExceptionHandler {
private final MessageSource messageSource;
@ExceptionHandler(BusinessException.class) public ResponseEntity<ApiError> handle( BusinessException exception) {
String message = messageSource.getMessage( exception.getMessageKey(), exception.getMessageArgs(), exception.getMessageKey(), LocaleContextHolder.getLocale() );
ApiError body = new ApiError( exception.getCode(), exception.getMessageKey(), message );
return ResponseEntity .badRequest() .body(body); }
public record ApiError( int code, String messageKey, String message ) { } }
|
中文请求返回:
1 2 3 4 5
| { "code": 1001001, "messageKey": "user.not-found", "message": "用户 1001 不存在" }
|
英文请求返回:
1 2 3 4 5
| { "code": 1001001, "messageKey": "user.not-found", "message": "User 1001 does not exist" }
|
这个响应对象最终仍会交给:
1
| MappingJackson2HttpMessageConverter
|
序列化为 JSON。
因此,完整链路是:
1 2 3 4 5 6 7 8 9 10 11 12 13
| LocaleResolver 确定当前 Locale ↓ MessageSource 解析消息码 ↓ 构造 ApiError Java 对象 ↓ 内容协商 确定 application/json ↓ MappingJackson2HttpMessageConverter Java 对象 → JSON
|
二十一、参数校验国际化
Bean Validation 同样可以使用国际化消息码。
DTO:
1 2 3 4 5 6 7
| public record CreateUserRequest(
@NotBlank(message = "{user.name.required}") String name
) { }
|
中文资源:
1
| user.name.required=用户名不能为空
|
英文资源:
1
| user.name.required=User name cannot be empty
|
这里需要注意:
1
| message = "{user.name.required}"
|
花括号表示这是一个消息码,需要从资源文件解析。
如果写成:
1
| message = "user.name.required"
|
它通常只会被当作普通文本。
全局异常处理器可以统一处理:
1
| MethodArgumentNotValidException
|
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ApiError> handleValidation( MethodArgumentNotValidException exception) {
String message = exception .getBindingResult() .getFieldErrors() .stream() .findFirst() .map(FieldError::getDefaultMessage) .orElse("Validation failed");
return ResponseEntity.badRequest().body( new ApiError( 400001, "validation.failed", message ) ); }
|
实际企业项目中,通常还会返回字段名和全部校验错误。
二十二、整洁架构中的国际化边界
在分层架构中,国际化最好放在系统边界,而不是侵入领域层。
推荐分工:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| domain 定义业务错误语义、错误码和参数 不依赖 MessageSource 不直接保存中文或英文文案
application 编排用例 抛出业务异常
adapter/web 读取 Locale 调用 MessageSource 组装对外错误响应
infrastructure 保存资源文件 对接配置中心或远程文案系统
|
错误码可以定义为枚举:
1 2 3 4 5 6 7 8 9 10 11 12
| @Getter @RequiredArgsConstructor public enum UserErrorCode {
USER_NOT_FOUND( 1001001, "user.not-found" );
private final int code; private final String messageKey; }
|
使用:
1 2 3 4 5
| throw new BusinessException( UserErrorCode.USER_NOT_FOUND.getCode(), UserErrorCode.USER_NOT_FOUND.getMessageKey(), userId );
|
这样可以避免领域层依赖:
1 2 3
| MessageSource LocaleContextHolder HttpServletRequest
|
二十三、微服务之间如何传递国际化错误
微服务内部调用不建议只传已经翻译好的中文:
更稳妥的结构是:
1 2 3 4 5 6 7 8
| { "code": 1001001, "messageKey": "user.not-found", "messageArgs": [ 1001 ], "message": "用户 1001 不存在" }
|
其中:
code:稳定的机器可读错误码;
messageKey:国际化消息键;
messageArgs:消息参数;
message:当前服务解析出的展示文案,可选。
这样做的优点:
1 2 3 4 5
| 调用方可以按自己的 Locale 重新解析; 日志可以稳定检索错误码; 文案修改不会影响程序判断; 网关可以统一处理国际化; 客户端不需要通过字符串判断业务错误。
|
对外 API 应以错误码作为稳定契约,文案只是给人看的,不能承担程序分支判断职责。
二十四、把三套机制串起来
请求:
1 2 3 4
| POST /users Content-Type: application/json Accept: application/json Accept-Language: zh-CN
|
请求体:
Spring 的处理流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| 1. DispatcherServlet 接收请求
2. HandlerMapping 根据 URL 和 HTTP Method 匹配接口
3. consumes 检查 Content-Type application/json 可以接受
4. MappingJackson2HttpMessageConverter JSON → CreateUserRequest
5. Bean Validation 执行校验 name 不能为空
6. MessageSource 根据 zh-CN 解析 user.name.required
7. 全局异常处理器构造 ApiError
8. 内容协商读取 Accept 选择 application/json
9. MappingJackson2HttpMessageConverter ApiError → JSON
10. 写入 HTTP Response
|
最终响应:
1 2 3 4 5
| { "code": 400001, "messageKey": "validation.failed", "message": "用户名不能为空" }
|
可以把全文压缩成四句话:
1 2 3 4 5 6 7
| Content-Type 决定如何读取请求体。
Accept 决定使用什么格式写出响应体。
HttpMessageConverter 负责 HTTP Body 与 Java 对象互相转换。
Accept-Language、LocaleResolver 和 MessageSource 负责国际化。
|
二十五、常见问题排查清单
检查:
1 2 3 4 5
| 请求是否携带 Content-Type; Content-Type 是否正确; Controller 的 consumes 是否允许; 是否存在能 canRead 的消息转换器; 请求体是否真的属于该 Content-Type。
|
2. 返回 406 Not Acceptable
检查:
1 2 3 4 5
| 客户端 Accept; Controller 的 produces; 是否存在能写目标返回类型的转换器; 是否错误地返回了特殊 Java 类型; 自定义转换器是否破坏默认顺序。
|
3. JSON 请求返回 400
检查:
1 2 3 4 5 6 7 8 9
| JSON 语法; 字段类型; 日期格式; 枚举值; 构造器和 getter/setter; record 参数; Jackson 注解; 是否缺少无参构造器; 请求体是否为空。
|
4. 自定义转换器不生效
检查:
1 2 3 4 5 6
| 是否注册到 MVC; supports 是否匹配 Java 类型; supportedMediaTypes 是否匹配 Content-Type 或 Accept; 转换器顺序; Controller consumes / produces; 是否被另一个更靠前的转换器抢先处理。
|
5. 自定义转换器后 JSON 全部失效
优先检查:
1 2 3 4
| 是否重写了 configureMessageConverters; 是否添加了 @EnableWebMvc; 是否把默认转换器列表替换掉; 自定义转换器是否声明 Object.class 和 */*。
|
6. 国际化资源文件不生效
检查:
1 2 3 4 5 6 7 8
| 是否存在默认 messages.properties; basename 是否正确; 目录是否在 classpath; 文件名 Locale 后缀是否正确; 请求是否携带 Accept-Language; LocaleResolver 是否按预期工作; 消息码是否完全一致; Bean Validation 是否使用了 {message.key}。
|
7. 返回 String 为什么不是 JSON 字符串
例如:
1 2 3 4
| @GetMapping public String hello() { return "hello"; }
|
在 @RestController 中,StringHttpMessageConverter 可能直接处理该返回值。
如果希望返回标准 JSON 对象,建议返回 DTO:
1 2 3 4
| public record MessageResponse( String message ) { }
|
1 2 3 4
| @GetMapping public MessageResponse hello() { return new MessageResponse("hello"); }
|
响应:
不要为了强迫一个 String 走 Jackson 而粗暴修改所有转换器顺序,那通常会制造更多问题。
二十六、推荐的企业级实践
对于 Spring Boot REST API,可以采用下面的组合:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| 1. 使用 Accept 和 Content-Type 进行标准内容协商;
2. 接口明确声明必要的 consumes 和 produces;
3. JSON 规则优先通过 Jackson 定制;
4. 只有真正引入新媒体格式时才自定义 HttpMessageConverter;
5. 使用 extendMessageConverters 扩展默认列表;
6. 使用 Accept-Language 传递语言偏好;
7. 业务异常保存错误码、messageKey 和参数;
8. 在 Web Adapter 或异常处理器中解析国际化文案;
9. 微服务之间以错误码为稳定契约;
10. 不让领域层依赖 LocaleContextHolder 和 MessageSource。
|
参考资料
Spring Framework Reference:Content Types
https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-config/content-negotiation.html
Spring Framework Reference:HTTP Message Conversion
https://docs.spring.io/spring-framework/reference/web/webmvc/message-converters.html
Spring Framework Reference:Message Converters Configuration
https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-config/message-converters.html
Spring Boot Reference:Internationalization
https://docs.spring.io/spring-boot/reference/features/internationalization.html
Spring Framework API:MessageSource
https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/MessageSource.html
Spring Framework Reference:LocaleResolver
https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-servlet/localeresolver.html
启示录
内容协商、消息转换器和国际化看似是三个独立知识点,实际上共同组成了 Spring Web 输入与输出边界的重要部分。
真正值得掌握的不是“默认到底有几个转换器”,而是:
1 2 3 4 5
| Spring 如何确定目标媒体类型; Spring 如何筛选 canRead 和 canWrite; 转换器顺序为什么重要; 国际化应该放在哪一层; 错误码与展示文案为什么必须分离。
|
当系统规模逐渐增大时,清晰的边界比“能运行”更重要。
富贵岂由人,时会高志须酬。
能成功于千载者,必以近察远。