每篇一句

十个光头九个富,最后一个会砍树

前言

不知你在使用Spring Boot时是否对这样一个现象"诧异"过:同一个接口(同一个URL)在接口报错情况下,若你用rest访问,它返回给你的是一个json串;但若你用浏览器访问,它返回给你的是一段html。恰如下面例子(Spring Boot环境~):

@RestController
@RequestMapping
public class HelloController {
@GetMapping("/test/error")
public Object testError() {
System.out.println(1 / 0); // 强制抛出异常
return "hello world";
}
}

使用浏览器访问:http://localhost:8080/test/error



使用Postman访问:



同根不同命有木有。RESTful服务中很重要的一个特性是:同一资源可以有多种表述,这就是我们今天文章的主题:内容协商(ContentNegotiation)。

HTTP内容协商

虽然本文主要是想说Spring MVC中的内容协商机制,但是在此之前是很有必要先了解HTTP的内容协商是怎么回事(Spring MVC实现了它并且扩展了它更为强大~)。

定义

一个URL资源服务端可以以多种形式进行响应:即MIME(MediaType)媒体类型。但对于某一个客户端(浏览器、APP、Excel导出...)来说它只需要一种。so这样客户端和服务端就得有一种机制来保证这个事情,这种机制就是内容协商机制。

方式

http的内容协商方式大致有两种:

  1. 服务端将可用列表(自己能提供的MIME类型们)发给客户端,客户端选择后再告诉服务端。这样服务端再按照客户端告诉的MIME返给它。(缺点:多一次网络交互,而且使用对使用者要求高,所以此方式一般不用
  2. 常用)客户端发请求时就指明需要的MIME们(比如Http头部的:Accept),服务端根据客户端指定的要求返回合适的形式,并且在响应头中做出说明(如:Content-Type

    1. 若客户端要求的MIME类型服务端提供不了,那就406错误吧~
常用请求头、响应头

请求头

Accept:告诉服务端需要的MIME(一般是多个,比如text/plainapplication/json等。/表示可以是任何MIME资源)

Accept-Language:告诉服务端需要的语言(在中国默认是中文嘛,但浏览器一般都可以选择N多种语言,但是是否支持要看服务器是否可以协商)

Accept-Charset:告诉服务端需要的字符集

Accept-Encoding:告诉服务端需要的压缩方式(gzip,deflate,br)

响应头

Content-Type:告诉客户端响应的媒体类型(如application/jsontext/html等)

Content-Language:告诉客户端响应的语言

Content-Charset:告诉客户端响应的字符集

Content-Encoding:告诉客户端响应的压缩方式(gzip)

报头AcceptContent-Type的区别

有很多文章粗暴的解释:Accept属于请求头,Content-Type属于响应头,其实这是不准确的。

在前后端分离开发成为主流的今天,你应该不乏见到前端的request请求上大都有Content-Type:application/json;charset=utf-8这个请求头,因此可见Content-Type并不仅仅是响应头。

HTTP协议规范的格式如下四部分

  1. <request-line>(请求消息行)
  2. <headers>(请求消息头)
  3. <blank line>(请求空白行)
  4. <request-body>(请求消息体)

Content-Type请求消息体的数据格式,因为请求和响应中都可以有消息体,所以它即可用在请求头,亦可用在响应头。

关于更多Http中的Content-Type的内容,我推荐参见此文章:Http请求中的Content-Type


Spring MVC内容协商

Spring MVC实现了HTTP内容协商的同时,又进行了扩展。它支持4种协商方式:

  1. HTTPAccept
  2. 扩展名
  3. 请求参数
  4. 固定类型(producers)

说明:以下示例基于Spring进行演示,而非Spring Boot

方式一:HTTP头Accept

@RestController
@RequestMapping
public class HelloController {
@ResponseBody
@GetMapping("/test/{id}")
public Person test(@PathVariable(required = false) String id) {
System.out.println("id的值为:" + id);
Person person = new Person();
person.setName("fsx");
person.setAge(18);
return person;
}
}

如果默认就这样,不管浏览器访问还是Postman访问,得到的都是json串

但若你仅仅只需在pom加入如下两个包:

<!-- 此处需要导入databind包即可, jackson-annotations、jackson-core都不需要显示自己的导入了-->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.9.8</version>
</dependency>
<!-- jackson默认只会支持的json。若要xml的支持,需要额外导入如下包 -->
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
<version>2.9.8</version>
</dependency>

再用浏览器/Postman访问,得到结果就是xml了,形如这样:

有的文章说:浏览器是xml,postman是json。本人亲试:都是xml。

但若我们postman手动指定这个头:Accept:application/json,返回就和浏览器有差异了(若不手动指定,Accept默认值是*/*):



并且我们可以看到response的头信息对比如下:

手动指定了Accept:application/json



木有指定Accept(默认*/*):

原因简析

Chrome浏览器请求默认发出的Accept是:Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3

由于我例子使用的是@ResponseBody,因此它不会返回一个view:交给消息转换器处理,因此这就和MediaType以及权重有关了。

消息最终都会交给AbstractMessageConverterMethodProcessor.writeWithMessageConverters()方法:

// @since 3.1
AbstractMessageConverterMethodProcessor:
protected <T> void writeWithMessageConverters( ... ) {
Object body;
Class<?> valueType;
Type targetType;
...
HttpServletRequest request = inputMessage.getServletRequest();
// 这里交给contentNegotiationManager.resolveMediaTypes() 找出客户端可以接受的MediaType们~~~
// 此处是已经排序好的(根据Q值等等)
List<MediaType> acceptableTypes = getAcceptableMediaTypes(request);
// 这是服务端它所能提供出的MediaType们
List<MediaType> producibleTypes = getProducibleMediaTypes(request, valueType, targetType); // 协商。 经过一定的排序、匹配 最终匹配出一个合适的MediaType
...
// 把待使用的们再次排序,
MediaType.sortBySpecificityAndQuality(mediaTypesToUse); // 最终找出一个最合适的、最终使用的:selectedMediaType
for (MediaType mediaType : mediaTypesToUse) {
if (mediaType.isConcrete()) {
selectedMediaType = mediaType;
break;
} else if (mediaType.isPresentIn(ALL_APPLICATION_MEDIA_TYPES)) {
selectedMediaType = MediaType.APPLICATION_OCTET_STREAM;
break;
}
}
}

acceptableTypes是客户端通过Accept告知的。

producibleTypes代表着服务端所能提供的类型们。参考这个getProducibleMediaTypes()方法:

AbstractMessageConverterMethodProcessor:

	protected List<MediaType> getProducibleMediaTypes( ... ) {
// 它设值的地方唯一在于:@RequestMapping.producers属性
// 大多数情况下:我们一般都不会给此属性赋值吧~~~
Set<MediaType> mediaTypes = (Set<MediaType>) request.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
if (!CollectionUtils.isEmpty(mediaTypes)) {
return new ArrayList<>(mediaTypes);
}
// 大多数情况下:都会走进这个逻辑 --> 从消息转换器中匹配一个合适的出来
else if (!this.allSupportedMediaTypes.isEmpty()) {
List<MediaType> result = new ArrayList<>();
// 从所有的消息转换器中 匹配出一个/多个List<MediaType> result出来
// 这就代表着:我服务端所能支持的所有的List<MediaType>们了
for (HttpMessageConverter<?> converter : this.messageConverters) {
if (converter instanceof GenericHttpMessageConverter && targetType != null) {
if (((GenericHttpMessageConverter<?>) converter).canWrite(targetType, valueClass, null)) {
result.addAll(converter.getSupportedMediaTypes());
}
}
else if (converter.canWrite(valueClass, null)) {
result.addAll(converter.getSupportedMediaTypes());
}
}
return result;
} else {
return Collections.singletonList(MediaType.ALL);
}
}

可以看到服务端最终能够提供哪些MediaType,来源于消息转换器HttpMessageConverter对类型的支持。

本例的现象:起初返回的是json串,仅仅只需要导入jackson-dataformat-xml后就返回xml了。原因是因为加入MappingJackson2XmlHttpMessageConverter都有这个判断:

	private static final boolean jackson2XmlPresent = ClassUtils.isPresent("com.fasterxml.jackson.dataformat.xml.XmlMapper", classLoader);

		if (jackson2XmlPresent) {
addPartConverter(new MappingJackson2XmlHttpMessageConverter());
}

所以默认情况下Spring MVC并不支持application/xml这种媒体格式,所以若不导包协商出来的结果是:application/json

默认情况下优先级是xml高于json。当然一般都木有xml包,所以才轮到json的。

另外还需要注意一点:有的小伙伴说通过在请求头里指定Content-Type:application/json来达到效果。现在你应该知道,这样做显然是没用的(至于为何没用,希望读者做到了心知肚明),只能使用Accept这个头来指定~~~

第一种协商方式是Spring MVC完全基于HTTP Accept首部的方式了。该种方式Spring MVC默认支持且默认已开启。

优缺点:

  • 优点:理想的标准方式
  • 缺点:由于浏览器的差异,导致发送的Accept Header头可能会不一样,从而得到的结果不具备浏览器兼容性

方式二:(变量)扩展名

基于上面例子:若我访问/test/1.xml返回的是xml,若访问/test/1.json返回的是json;完美~

这种方式使用起来非常的便捷,并且还不依赖于浏览器。但我总结了如下几点使时的注意事项:

  1. 扩展名必须是变量的扩展名。比如上例若访问test.json / test.xml就404~
  2. @PathVariable的参数类型只能使用通用类型(String/Object),因为接收过来的value值就是1.json/1.xml,所以若用Integer接收将报错类型转换错误~

    1. 小技巧:我个人建议是这部分不接收(这部分不使用@PathVariable接收),拿出来只为内容协商使用
  3. 扩展名优先级比Accept要高(并且和使用神马浏览器无关)

优缺点:

  • 优点:灵活,不受浏览器约束
  • 缺点:丧失了同一URL的多种展现方式。在实际环境中使用还是较多的,因为这种方式更符合程序员的习惯

方式三:请求参数

这种协商方式Spring MVC支持,但默认是关闭的,需要显示的打开:

@Configuration
@EnableWebMvc
public class WebMvcConfig extends WebMvcConfigurerAdapter {
@Override
public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
// 支持请求参数协商
configurer.favorParameter(true);
}
}

请求URL:/test/1?format=xml返回xml;/test/1?format=json返回json。同样的我总结如下几点注意事项:

  1. 前两种方式默认是开启的,但此种方式需要手动显示开启
  2. 此方式优先级低于扩展名(因此你测试时若想它生效,请去掉url的后缀)

优缺点:

  • 优点:不受浏览器约束
  • 缺点:需要额外的传递format参数,URL变得冗余繁琐,缺少了REST的简洁风范。还有个缺点便是:还需手动显示开启。
方式四:固定类型(produces)

它就是利用@RequestMapping注解属性produces(可能你平时也在用,但并不知道原因):

@ResponseBody
@GetMapping(value = {"/test/{id}", "/test"}, produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
public Person test() { ... }

访问:/test/1返回的就是json;即使你已经导入了jackson的xml包,返回的依旧还是json。

它也有它很很很重要的一个注意事项:produces指定的MediaType类型不能和后缀、请求参数、Accept冲突。例如本利这里指定了json格式,如果你这么访问/test/1.xml,或者format=xml,或者Accept不是application/json或者*/* 将无法完成内容协商:http状态码为406,报错如下:



produces使用固然也比较简单,针对上面报错406的原因,我简单解释如下。

原因:

1、先解析请求的媒体类型:1.xml解析出来的MediaTypeapplication/xml

2、拿着这个MediaType(当然还有URL、请求Method等所有)去匹配HandlerMethod的时候会发现producers匹配不上

3、匹配不上就交给RequestMappingInfoHandlerMapping.handleNoMatch()处理:

RequestMappingInfoHandlerMapping:

	@Override
protected HandlerMethod handleNoMatch(...) {
if (helper.hasConsumesMismatch()) {
...
throw new HttpMediaTypeNotSupportedException(contentType, new ArrayList<>(mediaTypes));
}
// 抛出异常:HttpMediaTypeNotAcceptableException
if (helper.hasProducesMismatch()) {
Set<MediaType> mediaTypes = helper.getProducibleMediaTypes();
throw new HttpMediaTypeNotAcceptableException(new ArrayList<>(mediaTypes));
}
}

4、抛出异常后最终交给DispatcherServlet.processHandlerException()去处理这个异常,转换到Http状态码

会调用所有的handlerExceptionResolvers来处理这个异常,本处会被DefaultHandlerExceptionResolver最终处理。最终处理代码如下(406状态码):

	protected ModelAndView handleHttpMediaTypeNotAcceptable(HttpMediaTypeNotAcceptableException ex,
HttpServletRequest request, HttpServletResponse response, @Nullable Object handler) throws IOException { response.sendError(HttpServletResponse.SC_NOT_ACCEPTABLE);
return new ModelAndView();
}

Spring MVC默认注册的异常处理器是如下3个:

原理

有了关于Accept的原理描述,理解它就非常简单了。因为指定了produces属性,所以getProducibleMediaTypes()方法在拿服务端支持的媒体类型时:

protected List<MediaType> getProducibleMediaTypes( ... ){
Set<MediaType> mediaTypes = (Set<MediaType>) request.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
if (!CollectionUtils.isEmpty(mediaTypes)) {
return new ArrayList<>(mediaTypes);
}
...
}

因为设置了producers,所以代码第一句就能拿到值了(后面的协商机制完全同上)。

备注:若produces属性你要指定的非常多,建议可以使用!xxx语法,它是支持这种语法(排除语法)的~

优缺点:

  • 优点:使用简单,天然支持
  • 缺点:让HandlerMethod处理器缺失灵活性
Spring Boot默认异常消息处理

再回到开头的Spring Boot为何对异常消息,浏览器和postman的展示不一样。这就是Spring Boot默认的对异常处理方式:它使用的就是基于 固定类型(produces)实现的内容协商。

Spirng Boot出现异常信息时候,会默认访问/error,它的处理类是:BasicErrorController

@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {
...
// 处理类浏览器
@RequestMapping(produces = "text/html")
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
...
return (modelAndView != null ? modelAndView : new ModelAndView("error", model));
} // 处理restful/json方式
@RequestMapping
@ResponseBody
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
Map<String, Object> body = getErrorAttributes(request, isIncludeStackTrace(request, MediaType.ALL));
HttpStatus status = getStatus(request);
return new ResponseEntity<Map<String, Object>>(body, status);
}
...
}

有了上面的解释,对这块代码的理解应该就没有盲点了~

总结

内容协商在RESTful流行的今天还是非常重要的一块内容,它对于提升用户体验,提升效率和降低维护成本都有不可忽视的作用,注意它三的优先级为:后缀 > 请求参数 > HTTP首部Accept

一般情况下,我们为了通用都会使用基于Http的内容协商(Accept),但在实际应用中其实很少用它,因为不同的浏览器可能导致不同的行为(比如ChromeFirefox就很不一样),所以为了保证“稳定性”一般都选择使用方案二或方案三(比如Spring的官方doc)

相关阅读

【小家Spring】Spring MVC容器的web九大组件之---HandlerMapping源码详解(二)---RequestMappingHandlerMapping系列

ContentNegotiation内容协商机制(一)---Spring MVC内置支持的4种内容协商方式【享学Spring MVC】

ContentNegotiation内容协商机制(二)---Spring MVC内容协商实现原理及自定义配置【享学Spring MVC】

ContentNegotiation内容协商机制(三)---在视图View上的应用:ContentNegotiatingViewResolver深度解析【享学Spring MVC】

知识交流

The last:如果觉得本文对你有帮助,不妨点个赞呗。当然分享到你的朋友圈让更多小伙伴看到也是被作者本人许可的~

若对技术内容感兴趣可以加入wx群交流:Java高工、架构师3群

若群二维码失效,请加wx号:fsx641385712(或者扫描下方wx二维码)。并且备注:"java入群" 字样,会手动邀请入群


若对Spring、SpringBoot、MyBatis等源码分析感兴趣,可加我wx:fsx641385712,手动邀请你入群一起飞

Spring MVC内置支持的4种内容协商方式【享学Spring MVC】的更多相关文章

  1. Spring MVC内容协商实现原理及自定义配置【享学Spring MVC】

    每篇一句 在绝对力量面前,一切技巧都是浮云 前言 上文 介绍了Http内容协商的一些概念,以及Spring MVC内置的4种协商方式使用介绍.本文主要针对Spring MVC内容协商方式:从步骤.原理 ...

  2. 内容协商在视图View上的应用【享学Spring MVC】

    每篇一句 人生很有意思:首先就得活得长.活得长才能够见自己,再长就可以见众生 前言 在经过 前两篇 文章了解了Spring MVC的内容协商机制之后,相信你已经能够熟练的运用Spring MVC提供的 ...

  3. HandlerMethodArgumentResolver(三):基于消息转换器的参数处理器【享学Spring MVC】

    每篇一句 一个事实是:对于大多数技术,了解只需要一天,简单搞起来只需要一周.入门可能只需要一个月 前言 通过 前面两篇文章 的介绍,相信你对HandlerMethodArgumentResolver了 ...

  4. 从原理层面掌握@InitBinder的使用【享学Spring MVC】

    每篇一句 大魔王张怡宁:女儿,这堆金牌你拿去玩吧,但我的银牌不能给你玩.你要想玩银牌就去找你王浩叔叔吧,他那银牌多 前言 为了讲述好Spring MVC最为复杂的数据绑定这块,我前面可谓是做足了功课, ...

  5. 迭代器模式的一种应用场景以及C#对于迭代器的内置支持

    迭代器模式 先放上gof中对于迭代器模式的介绍镇楼 意图 提供一种方法顺序访问一个聚合对象中各个元素, 而又不需暴露该对象的内部表示. 别名 游标(Cursor). 动机 一个聚合对象, 如列表(li ...

  6. MVC路由 路由的三种扩展 替换MVC内置的Handler

    Global.asax 是 程序入口文件 路由配置   为什么localhost:8088/Home/Index/1 能返问到我们写的 会去掉前缀跟端口号  变成Home/Index/1 用这个跟路由 ...

  7. [.Net Core] 简单使用 Mvc 内置的 Ioc

    简单使用 Mvc 内置的 Ioc 本文基于 .NET Core 2.0. 鉴于网上的文章理论较多,鄙人不才,想整理一份 Hello World(Demo)版的文章. 目录 场景一:简单类的使用 场景二 ...

  8. Spring中内置的一些工具类

    学习Java的人,或者开发很多项目,都需要使用到Spring 这个框架,这个框架对于java程序员来说.学好spring 就不怕找不到工作.我们时常会写一些工具类,但是有些时候 我们不清楚,我们些的工 ...

  9. 简单使用 Mvc 内置的 Ioc

    简单使用 Mvc 内置的 Ioc 本文基于 .NET Core 2.0. 鉴于网上的文章理论较多,鄙人不才,想整理一份 Hello World(Demo)版的文章. 目录 场景一:简单类的使用 场景二 ...

随机推荐

  1. Storm之API简介

    Storm之API简介 Component组件 1)基本接口 (1)IComponent接口 (2)ISpout接口 (3)IRichSpout接口 (4)IStateSpout接口 (5)IRich ...

  2. Kotlin学习快速入门(1)——基本数据类型以及String常用方法使用

    本文适合有Java基础的人 Kotlin语法特点 相比java,省略括号,可以自动判断类型,省略new关键字,空指针捕获 主函数 kotlin文件(kt文件)中,只有要下列的方法,就可以运行,无需像之 ...

  3. mysql 不同版本下 group by 组内排序的差异

    最近发现网上找的 group by 组内排序语句在不同的mysql版本中结果不一样.   建表语句:   SET FOREIGN_KEY_CHECKS=0;   -- ---------------- ...

  4. CMS简单内容管理系统

    架构 NewsDaoSQLServerImpl public class NewsDaoSQLServerImpl extends BaseDao implements NewsDao { publi ...

  5. TIJ学习--RTTI(Runtime-Time Type Identification)

    TIJ学习--RTTI(Runtime-Time Type Identification) RTTI 运行时类型检查机制 获取一个类的Class引用的三种方法 class TestClass{} Te ...

  6. 如何编译生成Linux-C静态链接库

    目标生成的静态库文件为:libnpcp.a 举例:我们有四个文件分别为:npcp.c npcp.h other.h main.c main.h在npcp.c里面#include "other ...

  7. 佳木斯集训Day8

    本来能AK的啊啊啊啊啊,唯一一天可以AK,却被Champion误导了(好吧实际上是我理解有问题) T1我写了俩小时,就是一道数列题,推公式的,可以二分解,我觉得二分麻烦,就直接想O(1)了 #incl ...

  8. 记一次IDEA 打包环境JDK版本和生产环境JDK版本不一致引发的血案

    问题描述: 本地开发环境idea中能正常运行项目,而idea打war包到Linux服务器的Tomcat下却不能正常运行,报如下错误: 09-Aug-2019 08:56:06.878 SEVERE [ ...

  9. 如何编写一个WebPack的插件原理及实践

    _ 阅读目录 一:webpack插件的基本原理 二:理解 Compiler对象 和 Compilation 对象 三:插件中常用的API 四:编写插件实战 回到顶部 一:webpack插件的基本原理 ...

  10. Go标准库--net/http学习

    Go中对网络的支持提供了标准库,net包提供了可移植的网络I/O接口,包括TCP/IP.UDP.域名解析和Unix域socket. http包提供了HTTP客户端和服务端的实现. 一般我们用http肯 ...