每篇一句

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

前言

不知你在使用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 {@Overridepublic 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:@Overrideprotected HandlerMethod handleNoMatch(...) {if (helper.hasConsumesMismatch()) {...throw new HttpMediaTypeNotSupportedException(contentType, new ArrayList<>(mediaTypes));}// 抛出异常:HttpMediaTypeNotAcceptableExceptionif (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@ResponseBodypublic 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)

相关阅读

ContentNegotiation内容协商机制(一)—Spring MVC内置支持的4种内容协商方式【享学Spring MVC】
ContentNegotiation内容协商机制(二)—Spring MVC内容协商实现原理及自定义配置【享学Spring MVC】
ContentNegotiation内容协商机制(三)—在视图View上的应用:ContentNegotiatingViewResolver深度解析【享学Spring MVC】


关注A哥

Author A哥(YourBatman)
个人站点 www.yourbatman.cn
E-mail yourbatman@qq.com
微 信 fsx641385712
活跃平台
公众号 BAT的乌托邦(ID:BAT-utopia)
知识星球 BAT的乌托邦
每日文章推荐 每日文章推荐

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

  1. 修改Spring boot内置的tomcat端口

    介绍两种种简单的修改spring boot内置端口的方法: 第一种:在入口直接设置端口,代码如下所示: public static void main(String[] args){SpringApp ...

  2. Spring Boot 内置Tomcat——集成PHP解决方案

    Demo:https://gitee.com/shentuzhigang/mini-project/tree/master/springboot-embed-tomcat-php-demo 问题分析 ...

  3. Spring Boot 内置Tomcat——getServletContext().getRealPath()为临时目录问题解决方案

    问题描述 getServletContext().getRealPath()为临时目录 问题分析 默认情况下Spring Boot中request.getServletContext().getRea ...

  4. Spring Boot——内置Tomcat配置阿里云免费SSL证书(PFX格式证书)[启用HTTPS协议]

    基本概念 SSL证书:SSL证书是数字证书的一种,类似于驾驶证.护照和营业执照的电子副本.因为配置在服务器上,也称为SSL服务器证书. SSL 证书就是遵守 SSL协议,由受信任的数字证书颁发机构CA ...

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

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

  6. Spring Boot内置Tomcat设置超时时间

    最近有个小工程扫描出一个安全漏洞, SlowHttp慢速攻击的,需要修改 Tomcat 的配置,也正好关于 Tomcat 的参数调优,正好记录一下. 漏洞信息 查了一下这个漏洞,漏洞有两个解决方法, ...

  7. CORS跨域资源共享(二):详解Spring MVC对CORS支持的相关类和API【享学Spring MVC】

    每篇一句 重构一时爽,一直重构一直爽.但出了问题火葬场 前言 上篇文章通过我模拟的跨域请求实例和结果分析,相信小伙伴们都已经80%的掌握了CORS到底是怎么一回事以及如何使用它.由于Java语言中的w ...

  8. 解决多字段联合逻辑校验问题【享学Spring MVC】

    解决多字段联合逻辑校验问题[享学Spring MVC] 参考文章: (1)解决多字段联合逻辑校验问题[享学Spring MVC] (2)https://www.cnblogs.com/yourbatm ...

  9. 从原理层面掌握@RequestAttribute、@SessionAttribute的使用【享学Spring MVC】

    每篇一句 改我们就改得:取其精华,去其糟粕.否则木有意义 前言 如果说知道@SessionAttributes这个注解的人已经很少了,那么不需要统计我就可以确定的说:知道@RequestAttribu ...

  10. 21.android 7.0,8.0,9.0 Settings设置内置选项在一级菜单fragment方式

    我的私人博客:www.mrloveqin.top 可以查看更多内容 21. Settings内置选项在一级菜单fragment方式 ① 在AndroidManifest.xml 添加如下代码 < ...

最新文章

  1. mysql 索引 死锁,由不同的索引更新解决MySQL死锁套路
  2. vscode 搭建go开发环境的13个插件的安装
  3. apt-get无法下载,一些网址Not Found 404
  4. java.awt.headless 模式(Linux, java.awt.headless and the DISPLAY environment variable)
  5. 向上造型和向下造型_国标舞So easy?!亲,你怕是凹错造型咯~
  6. python深浅拷贝的底层理解_理解python中的深拷贝与浅拷贝
  7. 认识JSON补丁:JSON-P 1.1概述系列
  8. 查询varchar实际大小_微服务和VUE(11): mybatis 动态查询
  9. pycham窗口显示多个编辑页面
  10. 有序数组原地删除重复出现的元素问题(js)
  11. ubuntu手机识别
  12. Atitit. Atiposter 发帖机版本历史 编年史
  13. iOS多线程(一):GCD的基本使用
  14. Linux_版简易C语言IDE制作
  15. Lenovo ServerGuide 10.4
  16. 六级(2020/12-2) Section B
  17. docker安装mysql8 并且忽略大小写问题解决
  18. 非法指令 (核心已转储) 彻底解决方案
  19. 用户在电商网站中购买成功了,那么它在微服务中经历了什么(转)
  20. 推荐几个小白学习的网站

热门文章

  1. android微信支付指纹支付密码错误,安卓微信支付怎么设置指纹锁
  2. 《区块链技术与应用》公开课系列笔记——目录导航页
  3. php在线生成logo,logo在线生成 php在线生成ico文件的代码
  4. .NET调用新浪微博开放平台接口的代码示例
  5. CS231n Assiganment#1解析(一)——KNN
  6. CTFshow—Misc入门1—23以及41(基础操作+信息附加)
  7. 一位大学教授的感叹:一流大学的真实样子!
  8. 浅析三点SEO理论到底带给了我们什么
  9. JS日历控件 (兼容IE firefox) 可选择时间
  10. 软硬件全开源,航芯方案分享 | 热敏打印机方案