Spring Web 内容协商、消息转换器与 Spring Boot 国际化

欢迎你来读这篇博客。

本文围绕 Spring Web 中三个经常一起出现、却又容易相互混淆的知识点展开:

  1. Spring MVC 如何通过内容协商确定请求与响应的数据格式;
  2. HttpMessageConverter 如何完成 HTTP 消息体与 Java 对象之间的转换;
  3. 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,有时返回 406415
  • 为什么返回 String 时没有走 Jackson?
  • 为什么自定义转换器后,JSON 接口突然全部失效?
  • 为什么已经创建了 messages_zh_CN.properties,国际化仍然不生效?
  • ConverterFormatterHttpMessageConverter 到底有什么区别?

下面从内容协商开始,把整个流程串起来。

正文

一、内容协商是什么

内容协商,英文为 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 主要影响国际化语言选择
  • 内容类型协商和语言国际化是两套机制,只是经常在同一个请求中同时出现。

二、consumesproduces

Spring MVC 可以通过 @RequestMapping@GetMapping@PostMapping 等注解中的 consumesproduces 限制接口支持的媒体类型。

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

如果客户端要求:

1
Accept: application/xml

但接口只能生成 JSON,通常会得到:

1
406 Not Acceptable

3. 它们并不负责序列化

consumesproduces 只负责约束请求映射与媒体类型匹配。

真正执行下面这些工作的,是 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(...)

四、常见状态码与异常

理解内容协商后,可以更快地区分 400406415

场景 常见状态码
请求的 Content-Type 不被接口或转换器支持 415
客户端要求的响应格式无法生成 406
格式受支持,但请求体内容无法反序列化 400
@RequestBody 必填但请求体为空 400
Bean Validation 参数校验失败 400

例如,请求头是:

1
Content-Type: application/json

但请求体是非法 JSON:

1
2
3
{
"id": abc
}

此时 JSON 格式本身是被支持的,因此不是 415

问题在于 Jackson 无法把 abc 转换成目标字段类型,通常会抛出:

1
HttpMessageNotReadableException

最终返回:

1
400 Bad Request

可以这样记:

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 表示偏好权重,数值越大优先级越高。

也可以使用通配符:

1
Accept: application/*

或者:

1
Accept: */*

当客户端发送 */* 时,服务端会根据 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
byte[]

示例:

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
String

示例:

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
Resource

常用于文件下载:

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

主要处理:

1
ResourceRegion

它用于 HTTP Range 请求,例如:

1
Range: bytes=1000-1999

常见场景:

  • 视频拖动播放;
  • 音频分段加载;
  • 文件部分读取;
  • 断点续传。

服务端可能返回:

1
206 Partial Content

5. AllEncompassingFormHttpMessageConverter

主要处理:

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 的:

1
ObjectMapper

7. 为什么有些资料写“6+1”

在 Spring Framework 5.x 等历史版本中,默认列表中还可能看到:

1
SourceHttpMessageConverter

它主要处理 XML Source 类型。

因此,不同版本的资料中可能出现:

1
2
3
5 + 1
6 + 1
更多转换器

这些说法不一定互相矛盾,关键要结合:

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;
}
}

因此,转换器顺序很重要。

假设自定义转换器支持:

1
Object.class

并声明媒体类型:

1
*/*

那么它可能会抢先处理大量本应交给 Jackson、String 或 Resource 转换器的响应。

自定义转换器应尽量缩小匹配范围:

1
2
3
4
5
限制 Java 类型;
限制 MediaType;
明确只读、只写或双向;
避免 Object.class;
避免 MediaType.ALL。

十一、自定义 HttpMessageConverter

下面实现一种简单的自定义协议:

1
Content-Type: application/x-user

数据格式:

1
1001|SuperMario

对应 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,表示优先尝试。

因为该转换器只支持:

1
application/x-user

且只处理:

1
UserPayload

所以即使优先级较高,也不应该抢占普通 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

十二、configureMessageConvertersextendMessageConverters

这是自定义消息转换器时最容易踩的坑之一。

1. extendMessageConverters

1
2
3
4
5
6
7
8
@Override
public void extendMessageConverters(
List<HttpMessageConverter<?>> converters) {

converters.add(
new UserTextHttpMessageConverter()
);
}

它的语义是:

1
2
保留框架已经配置好的转换器;
在现有列表基础上增加、删除或调整。

适合绝大多数业务项目。

2. configureMessageConverters

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 项目中,还应谨慎使用:

1
@EnableWebMvc

因为它会切换到更显式的 Spring MVC 配置模式,可能改变 Boot 自动配置行为。

如果只是增加拦截器、格式化器或消息转换器,通常实现:

1
WebMvcConfigurer

即可,不必急着加 @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
用户 1001 不存在

切换到:

1
Locale.US

则可以得到:

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

也建议保留:

1
messages.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 通常推荐通过标准请求头传递语言偏好:

1
Accept-Language: zh-CN

或者:

1
Accept-Language: en-US

可以注册 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,更推荐使用:

1
Accept-Language

如果使用 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
Accept-Language: zh-CN

返回:

1
用户 1001 不存在

客户端传入:

1
Accept-Language: en-US

返回:

1
User 1001 does not exist

二十、业务异常国际化

业务异常不建议直接保存中文文案:

1
2
3
throw new BusinessException(
"用户不存在"
);

更合理的方式是保存:

1
2
3
业务错误码;
国际化消息码;
消息参数。

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
{
"message": "用户不存在"
}

更稳妥的结构是:

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

请求体:

1
2
3
{
"name": ""
}

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. 返回 415 Unsupported Media Type

检查:

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");
}

响应:

1
2
3
{
"message": "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。

参考资料

  1. Spring Framework Reference:Content Types
    https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-config/content-negotiation.html

  2. Spring Framework Reference:HTTP Message Conversion
    https://docs.spring.io/spring-framework/reference/web/webmvc/message-converters.html

  3. Spring Framework Reference:Message Converters Configuration
    https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-config/message-converters.html

  4. Spring Boot Reference:Internationalization
    https://docs.spring.io/spring-boot/reference/features/internationalization.html

  5. Spring Framework API:MessageSource
    https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/MessageSource.html

  6. 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;
转换器顺序为什么重要;
国际化应该放在哪一层;
错误码与展示文案为什么必须分离。

当系统规模逐渐增大时,清晰的边界比“能运行”更重要。

富贵岂由人,时会高志须酬。

能成功于千载者,必以近察远。


Spring Web 内容协商、消息转换器与 Spring Boot 国际化
https://allendericdalexander.github.io/2026/07/14/java/spring/spring-web-converter-i18n/
作者
AtLuoFu
发布于
2026年7月14日
许可协议