Skip to content

Spring MVC 内容协商 (Content Negotiation) 深度解析 🎯

什么是内容协商?为什么需要它?

想象一下这样的场景:你开发了一个 API,既要给前端 JavaScript 返回 JSON 数据,又要给移动端返回 XML 格式,还要给第三方系统提供不同的数据格式。如果为每种格式都写一个独立的接口,代码会变得非常冗余和难以维护。

NOTE

内容协商(Content Negotiation) 就是为了解决这个问题而生的!它让同一个 API 接口能够根据客户端的需求,智能地返回不同格式的数据。

Spring MVC 内容协商的工作原理

默认行为:基于 Accept 头部

Spring MVC 默认只检查 HTTP 请求的 Accept 头部来确定客户端期望的媒体类型。

kotlin
@RestController
@RequestMapping("/api/users")
class UserController {

    @GetMapping
    fun getUsers(): List<User> {
        // 返回用户列表,Spring会根据Accept头部自动选择格式
        return listOf(
            User(1, "张三", "[email protected]"),
            User(2, "李四", "[email protected]")
        )
    }
}

data class User(
    val id: Long,
    val name: String,
    val email: String
)
http
# 请求JSON格式
GET /api/users HTTP/1.1
Accept: application/json

# 请求XML格式  
GET /api/users HTTP/1.1
Accept: application/xml

# 请求任意格式
GET /api/users HTTP/1.1
Accept: */*

TIP

同一个 Controller 方法,不同的 Accept 头部,就能得到不同格式的响应!这就是内容协商的魅力所在。

自定义内容协商配置

基础配置:添加媒体类型映射

当你需要支持更多的内容格式或者自定义映射时,可以通过实现 WebMvcConfigurer 来配置:

kotlin
@Configuration
class WebConfiguration : WebMvcConfigurer {

    override fun configureContentNegotiation(configurer: ContentNegotiationConfigurer) {
        // 配置媒体类型映射
        configurer.mediaType("json", MediaType.APPLICATION_JSON) 
        configurer.mediaType("xml", MediaType.APPLICATION_XML)   
        configurer.mediaType("yaml", MediaType.parseMediaType("application/yaml")) 
        
        // 设置默认内容类型
        configurer.defaultContentType(MediaType.APPLICATION_JSON) 
    }
}

高级配置:多种协商策略

kotlin
@Configuration
class AdvancedWebConfiguration : WebMvcConfigurer {

    override fun configureContentNegotiation(configurer: ContentNegotiationConfigurer) {
        configurer
            // 1. 基于URL参数的协商 (例如: /api/users?format=json)
            .favorParameter(true) 
            .parameterName("format") 
            
            // 2. 基于URL路径扩展名的协商 (例如: /api/users.json)
            .favorPathExtension(false) // [!code warning] // 出于安全考虑,建议禁用
            
            // 3. 忽略Accept头部
            .ignoreAcceptHeader(false)
            
            // 4. 设置默认内容类型
            .defaultContentType(MediaType.APPLICATION_JSON)
            
            // 5. 媒体类型映射
            .mediaType("json", MediaType.APPLICATION_JSON)
            .mediaType("xml", MediaType.APPLICATION_XML)
            .mediaType("pdf", MediaType.APPLICATION_PDF)
    }
}

WARNING

安全提醒:基于路径扩展名的内容协商(如 /api/users.json)存在安全风险,Spring 官方建议使用查询参数方式替代。

实际业务场景应用

场景1:多格式数据导出API

kotlin
@RestController
@RequestMapping("/api/reports")
class ReportController {

    @GetMapping("/sales")
    fun getSalesReport(
        @RequestParam startDate: LocalDate,
        @RequestParam endDate: LocalDate
    ): SalesReport {
        // 生成销售报告数据
        return salesService.generateReport(startDate, endDate) 
    }
    
    // 同一个方法,根据Accept头部返回不同格式:
    // Accept: application/json -> JSON格式
    // Accept: application/xml -> XML格式  
    // Accept: application/pdf -> PDF格式(需要自定义MessageConverter)
}

data class SalesReport(
    val period: String,
    val totalSales: BigDecimal,
    val orders: List<OrderSummary>
)

场景2:API版本化与格式协商结合

kotlin
@RestController
@RequestMapping("/api/v1/products")
class ProductController {

    @GetMapping
    fun getProducts(
        @RequestParam(defaultValue = "0") page: Int,
        @RequestParam(defaultValue = "10") size: Int
    ): Page<Product> {
        return productService.findAll(PageRequest.of(page, size))
    }
    
    @GetMapping("/{id}")
    fun getProduct(@PathVariable id: Long): Product {
        return productService.findById(id) 
            ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "产品不存在")
    }
}
完整的配置示例
kotlin
@Configuration
@EnableWebMvc
class WebMvcConfiguration : WebMvcConfigurer {

    override fun configureContentNegotiation(configurer: ContentNegotiationConfigurer) {
        configurer
            // 启用基于参数的协商
            .favorParameter(true)
            .parameterName("format")
            
            // 禁用基于路径扩展名的协商(安全考虑)
            .favorPathExtension(false)
            
            // 不忽略Accept头部
            .ignoreAcceptHeader(false)
            
            // 设置默认内容类型
            .defaultContentType(MediaType.APPLICATION_JSON)
            
            // 配置媒体类型映射
            .mediaType("json", MediaType.APPLICATION_JSON)
            .mediaType("xml", MediaType.APPLICATION_XML)
            .mediaType("html", MediaType.TEXT_HTML)
            .mediaType("csv", MediaType.parseMediaType("text/csv"))
    }

    // 配置消息转换器以支持更多格式
    override fun configureMessageConverters(converters: MutableList<HttpMessageConverter<*>>) {
        // 添加自定义的CSV转换器
        converters.add(CsvHttpMessageConverter())
    }
}

内容协商的优先级策略

Spring MVC 按照以下优先级顺序进行内容协商:

IMPORTANT

协商优先级

  1. URL 参数(如果启用)
  2. HTTP Accept 头部
  3. 默认内容类型

常见问题与解决方案

问题1:406 Not Acceptable 错误

kotlin
// 错误示例:没有合适的消息转换器
@GetMapping("/data")
fun getData(): CustomObject {
    return CustomObject() // [!code error] // 如果没有对应的MessageConverter会报406错误
}

// 解决方案:确保有对应的消息转换器或使用标准格式
@GetMapping("/data")
fun getData(): ResponseEntity<Map<String, Any>> {
    val data = mapOf(
        "message" to "success",
        "data" to customService.getData()
    )
    return ResponseEntity.ok(data) 
}

问题2:格式协商不生效

WARNING

确保在配置中正确设置了媒体类型映射,并且客户端发送了正确的 Accept 头部或参数。

kotlin
// 调试内容协商
@RestController
class DebugController {
    
    @GetMapping("/debug")
    fun debug(request: HttpServletRequest): Map<String, Any> {
        return mapOf(
            "acceptHeader" to request.getHeader("Accept"),
            "formatParam" to request.getParameter("format"),
            "contentType" to request.contentType
        )
    }
}

最佳实践建议 ✅

TIP

推荐做法

  1. 优先使用 Accept 头部:这是 HTTP 标准的做法
  2. 谨慎使用路径扩展名:存在安全风险,建议禁用
  3. 提供查询参数作为备选:方便测试和某些特殊场景
  4. 设置合理的默认格式:通常是 JSON
  5. 充分测试各种格式:确保所有支持的格式都能正常工作

通过内容协商,你的 API 变得更加灵活和用户友好。同一个接口可以服务于不同的客户端需求,大大提高了代码的复用性和维护效率!🚀