Micronaut 在设计上借鉴了大量 Spring 的经验,同时针对编译期处理做了极致优化,内容协商(Content Negotiation)就是其中一个用起来很舒服的特性。所谓内容协商,指的是客户端在 HTTP 请求头里通过 Accept 声明自己期望的响应格式,服务端根据这个头自动挑选对应的处理方法返回 JSON、XML 或其他媒体类型。整个过程完全由框架路由完成,控制器代码不需要写任何手动判断逻辑。本文将从基础用法讲起,一步步实现基于 Accept 头的多格式响应接口,并分析路由匹配的细节和常见的踩坑点。

一、内容协商的基本原理
HTTP 协议本身早就为多格式响应提供了标准方案。当客户端发起请求时,可以在 Accept 头中携带期望的媒体类型,例如 application/json、application/xml 或者 text/plain。服务端拿到这个头之后,会与自己能够产出的格式列表做交集,选出最合适的一种作为响应的 Content-Type。
Micronaut 的实现方式是把媒体类型直接纳入路由匹配条件。也就是说,@Controller 中两个方法可以映射完全相同的 URI 和 HTTP 方法,只要各自通过 @Produces 声明的媒体类型不同,框架在编译期就会生成两条独立的路由记录。运行时根据请求头挑选命中的那一条,匹配失败则返回 406 状态码。
这一点和手动在方法里读 Accept 头再分支处理相比优势明显:方法职责单一,每种格式的序列化逻辑互不干扰,测试的时候也可以针对单一格式编写独立的用例,不必在一次测试里覆盖所有分支。
二、实现 JSON 与 XML 双格式接口
先定义一个简单的数据类,然后写两个方法分别处理 JSON 和 XML 请求。Micronaut 内置了对 JSON 的支持,XML 序列化则需要额外引入 micronaut-jaxb 模块,并用 JAXB 注解标注实体类。
// build.gradle 依赖
// implementation "io.micronaut.xml:micronaut-jaxb"
import io.micronaut.http.annotation.*;
import io.micronaut.http.MediaType;
import jakarta.xml.bind.annotation.*;
@XmlRootElement(name = "user")
@XmlAccessorType(XmlAccessType.FIELD)
public class User {
@XmlElement(name = "name")
private String name;
@XmlElement(name = "email")
private String email;
public User() {}
public User(String name, String email) {
this.name = name;
this.email = email;
}
public String getName() { return name; }
public String getEmail() { return email; }
}
@Controller("/api/users")
public class UserController {
@Get("/{id}")
@Produces(MediaType.APPLICATION_JSON)
public User getUserJson(Long id) {
return new User("张三", "zhangsan@ipipp.com");
}
@Get("/{id}")
@Produces(MediaType.APPLICATION_XML)
public User getUserXml(Long id) {
return new User("张三", "zhangsan@ipipp.com");
}
}
两个方法都映射到 /api/users/{id},唯一区别是 @Produces 的值。当请求头为 Accept: application/json 时走第一个方法,Accept: application/xml 时走第二个方法。如果想支持纯文本,再加一个 @Produces(MediaType.TEXT_PLAIN) 的方法并返回拼好的字符串即可,灵活性非常高。
需要注意,如果客户端不发送 Accept 头,Micronaut 默认会按照 application/json 处理,这也是多数 REST 客户端的默认行为,所以 JSON 方法通常作为兜底存在。
三、质量因子与路由优先级
Accept 头并非只能写一个值,客户端可以一次声明多个可接受的格式,并用 q 值表达偏好程度,例如:
Accept: text/html;q=0.9, application/json;q=1.0, */*;q=0.5
Micronaut 在解析这类头时会读取 q 值并排序,优先匹配权重最高的媒体类型。上例中 application/json 权重为 1.0,服务端会优先尝试 JSON 方法;如果对应方法不存在,再看 text/html;都失败时 */* 会匹配任意声明过的 @Produces。
这里有个容易忽略的细节:当 Accept 头里出现通配符,比如 application/*,框架会在所有以 application/ 开头的方法中挑选一个,具体选哪一个取决于路由注册顺序。为了避免结果不确定,建议服务端每种格式都有明确的 @Produces 声明,客户端尽量使用精确的媒体类型而不是通配符。
四、406 问题排查与测试验证
实践里最常见的报错是 406 Not Acceptable,出现的原因基本只有两类:一是服务端根本没有声明客户端请求的媒体类型,二是 XML 依赖没有引入导致相关路由未注册。排查时可以先确认依赖,再用 curl 逐个验证:
curl -H "Accept: application/json" http://localhost:8080/api/users/1 curl -H "Accept: application/xml" http://localhost:8080/api/users/1
两条命令分别应返回 JSON 和 XML 内容,响应头中的 Content-Type 也会相应变化。写成自动化测试更稳妥,Micronaut 的嵌入式服务器让整件事非常简单:
@MicronautTest
public class UserControllerTest {
@Inject
@Client("/")
HttpClient client;
@Test
void testJsonResponse() {
HttpRequest<Object> request = HttpRequest.GET("/api/users/1")
.accept(MediaType.APPLICATION_JSON_TYPE);
HttpResponse<String> resp = client.toBlocking()
.exchange(request, String.class);
assertEquals(HttpStatus.OK, resp.getStatus());
assertTrue(resp.getBody().get().contains("\"name\""));
}
@Test
void testXmlResponse() {
HttpRequest<Object> request = HttpRequest.GET("/api/users/1")
.accept(MediaType.APPLICATION_XML_TYPE);
HttpResponse<String> resp = client.toBlocking()
.exchange(request, String.class);
assertTrue(resp.getBody().get().contains("<user>"));
}
}
另外提醒一点,如果某些旧客户端只支持 text/html,而你希望给它们返回一段可读的 HTML 页面,同样只需增加一个对应的 @Produces(MediaType.TEXT_HTML) 方法,不需要改动任何现有逻辑。这种按格式拆分方法的写法在接口需要同时服务前端页面、移动端和第三方系统时尤其实用,每种消费方的输出格式独立演进,互不影响,维护成本远低于在一个方法里做字符串拼接分支。