常用的方式是get和post,get用于查询,post用于数据的更新,这两种方法的不同,以及springboot mvc解析的不同,postman模拟请求时候配置的不同,总结记录下
基于Http协议,根据Http的请求方法对应的数据传输能力把Http请求分为Url类请求和Body类请求,Url类请求包括但不限于GET、HEAD、OPTIONS、TRACE 等请求方法。Body类请求包括但不限于POST、PUT、PUSH、PATCH、DELETE 等请求方法。
因此对于GET请求来说,是不支持数据在http body内的请求方式的。POST方式不仅支持Url类请求也支持body类请求。
GET请求通常用于简单的查询,因为参数会暴露在url上,且受限于url长度限制,复杂条件查询可用POST请求。
GET请求只能用于Content-Type: application/x-www-form-urlencoded,这种请求方式key value作为参数拼接在url后面。
POST请求我们通常使用的Content-Type为application/x-www-form-urlencoded、application/json、
请求的参数只能在url上
比如http://localhost:8555/order/path/1024
,这个1024就是路径参数
对应的Controller代码接收参数如下,使用@PathVariable进行接收参数,
@RequestMapping(value = "/order/path/{id}", method = RequestMethod.GET)
public String order(@PathVariable("id") Integer id) {
return id+"";
}
使用curl命令如下
curl -X GET --header 'Accept: text/plain' 'http://localhost:8111/order/path/1024'
postman请求如下图,直接输入请求的url即可,其它不需要输入。
通过postman的console查看http报文如下:
GET /order/path/1024 HTTP/1.1
User-Agent: PostmanRuntime/7.26.8
Accept: */*
Cache-Control: no-cache
Postman-Token: 9a70a3bc-dab4-4159-8f93-a6d60493af17
Host: localhost:8111
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8
Content-Length: 4
Date: Fri, 11 Dec 2020 02:23:51 GMT
1024
发现并没有上送Content-Type,没有则默认使用application/x-www-form-urlencoded
这种请求方式参数通过key value格式追加到url后面
比如http://localhost:8111/order/form?name=zhangsan&age=20
对应Controller代码接收参数如下,使用@RequestParam参数进行参数绑定
@RequestMapping(value = "/order/form", method = RequestMethod.GET)
public String order(@RequestParam(value = "name") String name, @RequestParam(value = "age") Integer age) {
return name+age;
}
客户端使用curl请求方式
curl -X GET --header 'Accept: text/plain' 'http://localhost:8111/order/form?name=zhangsan&age=20'
postman请求方式,这种请求即把参数通过key value追加到了url末尾,
在query params输入参数,会自动把参数拼接追加到url的末尾,对应的postman console如下
GET /order/form?name=zhangsan&age=20 HTTP/1.1
User-Agent: PostmanRuntime/7.26.8
Accept: */*
Cache-Control: no-cache
Postman-Token: 8497cafc-fa8a-466d-a2e9-62020f458062
Host: localhost:8111
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8
Content-Length: 10
Date: Fri, 11 Dec 2020 02:46:15 GMT
zhangsan20
发现也并没有上送Content-Type,没有则默认使用application/x-www-form-urlencoded。
http的request header内还有个重要的Content-Length,这个字段表示http body的数据长度,因为用get请求,参数只能在url上,是没有http body的,因此Content-Length为0,因此对于get请求来说,http header是没有Content-Length。
代码和case2一样,使用postman请求,body是form-data
发现竟然请求成功了。postman console如下
GET /order/form HTTP/1.1
User-Agent: PostmanRuntime/7.26.8
Accept: */*
Cache-Control: no-cache
Postman-Token: e949875d-9d9c-432b-846d-5db171f36a60
Host: localhost:8111
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: multipart/form-data; boundary=--------------------------538007265269603903797417
Content-Length: 269
----------------------------538007265269603903797417
Content-Disposition: form-data; name="name"
wangwu
----------------------------538007265269603903797417
Content-Disposition: form-data; name="age"
22
----------------------------538007265269603903797417--
HTTP/1.1 200 OK
Content-Type: text/plain;charset=UTF-8
Content-Length: 8
Date: Mon, 14 Dec 2020 12:17:12 GMT
wangwu22
发现postman的form-data实际是multipart/form-data,这种请求方式通常是上传附件时候使用,看到这里有Content-Length,使用body的时候才会有该header熟悉。
get方式使用multipart/form-data方式实际很少,可以忽略,知道使用postman有这个方式即可。
使用x-www-form-urlencode方式,发现请求报错
后台报错如下:Resolved [org.springframework.web.bind.MissingServletRequestParameterException: Required String parameter 'name' is not present]
,参数无法解析,postman http解析如下
为什么content-type是application/x-www-form-urlencoded,也无法解析请求参数呢?因为请求参数是在body,而非url,get请求请求参数只能在url上(multipart/form-data是例外),@RequestParam对于get请求,只会去解析url上的解析参数。
使用json格式进行查询,请求成功,代码如下
@RequestMapping(value = "/order/json", method = RequestMethod.GET)
public String orderJson(@RequestBody TestParam param) {
return JSON.toJSONString(param);
}
@Data
public class TestParam {
private Integer age;
private String name;
}
@RequestBody对于get请求,会去解析http body。
postman console如下
postman console如下
翻车了,感觉和之前理解的GET请求参数只能放url是不一样的,GET请求也可以用json格式请求,请求数据放http body。
测试代码如下
@RequestMapping(value = "/order/path/{id}", method = RequestMethod.POST)
public String order(@ApiParam("消息id") @PathVariable("id") Integer id) {
return id+"";
}
使用curl命令
curl -X POST --header 'Content-Type: application/json' --header 'Accept: text/plain' 'http://localhost:8111/order/path/1024'
#和get请求命令不同,使用post请求使用,默认Content-Type: application/json
#使用content-type是x-www-form-urlencode也是可以
curl -X POST --header 'Content-Type: x-www-form-urlencode' --header 'Accept: text/plain' 'http://localhost:8111/order/path/1024'
postman请求和console结果如下,无content-type,说明使用的是x-www-form-urlencode
测试代码如下
@RequestMapping(value = "/order/form", method = RequestMethod.POST)
public String order(@RequestParam(value = "name") String name, @RequestParam(value = "age") Integer age) {
return name+age;
}
curl请求命令
curl -X POST --header 'Content-Type: application/json' --header 'Accept: text/plain' 'http://localhost:8111/order/form/data?name=wangwu&age=22'
#和get请求命令不同,使用post请求使用,默认Content-Type: application/json
前面curl命令是swagger打印的,如果使用x-www-form-urlencode,测试如下
curl -X POST --header 'Content-Type: x-www-form-urlencode' --header 'Accept: text/plain' 'http://localhost:8111/order/form/data?name=wangwu&age=22'
#这样也是可以的
postman请求和console结果如下,没有content-type,说明使用的是x-www-form-urlencode方式,如下图
顶顶顶顶顶顶顶顶顶顶
测试代码如下
@RequestMapping(value = "/order/form", method = RequestMethod.POST)
public String order(@RequestParam(value = "name") String name, @RequestParam(value = "age") Integer age) {
return name+age;
}
postman请求以及console如下
测试代码如下
@RequestMapping(value = "/order/form", method = RequestMethod.POST)
public String order(@RequestParam(value = "name") String name, @RequestParam(value = "age") Integer age) {
return name+age;
}
postman请求以及console如下
发现@RequestParam是可以解析POST content-type是x-www-form-urlencode的http body ,GET请求不行。
测试代码如下
@RequestMapping(value = "/order/json", method = RequestMethod.POST)
public String orderJson(@RequestBody TestParam param) {
return JSON.toJSONString(param);
}
@Data
public class TestParam {
private Integer age;
private String name;
}
postman请求以及console如图,请求content-type是application/json
Content-Type | Content-Length | 适用请求 | ||
路径在url上 | x-www-form-urlencode | 无 | GET、POST | @PathVariable |
请求参数拼接在url | x-www-form-urlencode | 无 | GET、POST | @RequestParam |
请求参数在http body | x-www-form-urlencode | 有 | GET | @RequestParam |
multipart/form-data | 有 | GET、POST | @RequestParam | |
application/json | 有 | GET、POST | @RequestBody |
http协议,实际底层使用的是tcp协议,在上面封装了http格式的报文(所谓协议就是一种数据格式,发送方和接收方约定以此 格式编码和解码报文),http报文格式如下
http请求解析过程大概如下:
客户端(浏览器)通过socket和服务端(tomcat)建立tcp连接,客户端发送http请求,数据格式就是图中,服务端接收到数据先解析请求行、再解析请求头、最后解析请求体
解析请求行:解析第一个空格前面数据作为请求方式,接着解析第二个空格前面作为url,接着解析第二个空格后面作为HTTP/1.1,接着是CRLF说明请求行解析完毕,接着下面解析请求头
解析请求头:解析冒号前面的作为key,冒号后面的作为value,接着是CRLF,如果连续两个CRLF,则说明该header是最后一个header,那么接着就要解析http body。
解析请求体:如果http header内存在Content-Length的时候,说明http body才存在,根据从网络输入流读取Content-Length长度的数据,然后按照http header内的字符集进行解码(解码具体就是java.lang.String.String(byte[], Charset))。
因此对于有http body的时候,http header必须有Content-Length,不然,怎么知道要从网络输入流读取多少字节作为http body呢
http报文解析逻辑在org.apache.coyote.http11.Http11Processor.service(SocketWrapperBase>)方法内,解析请求行和请求头代码如下:
首先http客户端和服务端连接连接,通过socket发送数据到服务端后,服务端接收到的数据就存储在缓冲区org.apache.coyote.http11.Http11InputBuffer对象上(严格来说是存放在org.apache.coyote.http11.Http11InputBuffer.byteBuffer属性上,这个属性是java.nio.ByteBuffer),此时的数据还是二进制流,并未被解析,要使用数据,必须要对数据解析,把二进制数据解析为我们可视数据,接着就是在parseRequestLine解析请求行,在parseHeaders解析请求头(http header)。
那么解析后的数据存储到org.apache.coyote.Request,该对象并非HttpRequest,而是tomcat定义的一个http数据载体,用于网络交互。
比如请求行GET /order/form?name=zhangsan&age=20 HTTP/1.1
,
org.apache.coyote.Request.methodMB 存放解析http请求行的method,比如例子中的GET
org.apache.coyote.Request.uriMB 存放解析http请求行uri,比如例子中的/order/form
org.apache.coyote.Request.queryMB 存放解析http请求行的请求参数,比如例子中的zhangsan&age=20
org.apache.coyote.Request.protoMB 存放解析http请求行的版本,比如例子中的HTTP/1.1
parseHeaders方法解析请求头(http header)时候,按照规则把解析到的header的key value保存在org.apache.coyote.http11.Http11InputBuffer.headers,而这个对象是和org.apache.coyote.Request.headers是一个对象,因此就把header解析到了org.apache.coyote.Request上。
此时解析的请求参数应用程序并不能通过request.getParameter(String)来使用,这个方法是从org.apache.coyote.Request.parameters对象上获取参数的(具体就是org.apache.tomcat.util.http.Parameters.paramHashValues这个map),那么是什么时候解析到org.apache.tomcat.util.http.Parameters的呢?
解析请求参数到Parameters.paramHashValues是在方法org.apache.catalina.connector.Request.parseParameters()内进行解析到该map的,通过key value规则把请求参数解析到paramHashValues这个map上(注意:是把org.apache.tomcat.util.http.Parameters.queryMB上的查询参数串解析到map上,而org.apache.tomcat.util.http.Parameters.queryMB和org.apache.coyote.Request.queryMB是同一个对象,在解析http请求行的时候就已经存在了)。那么该方法是什么时候被调用的呢?在org.apache.catalina.connector.Request.getParameterValues(String)方法内,如果未被解析过,则执行解析请求参数,代码如图:
org.apache.catalina.connector.Request.getParameterValues(String)是在spring mvc的@RequestParam获取绑定参数的值时候,调用的,执行堆栈如下:
图中的@1处就是根据@RequestParam指定的参数名称从HttpRequest获取参数值,具体就是从org.apache.coyote.Request获取参数值(最终是从org.apache.tomcat.util.http.Parameters.paramHashValues这个map上获取)。
图中@2是@RequestParam获取参数名称
图中@3是根据请求的uri找到对应的Controller mapping方法执行
因此一个简单的request.getParameter(String)执行获取请求参数值就明白了,具体图如下:
从上面分析可知,请求参数在url上,如何获取解析url上的请求参数,那么如果请求是json,数据在http body上送的方式呢?
json报文格式,对应mvc接收都是使用的@RequestBody,这个注解的具体解析类是RequestResponseBodyMethodProcessor。
我分析的思路是这样的,http客户端通过socket已经把二进制数据都传输完了,二进制数据都保存在org.apache.coyote.Request.inputBuffer对象上(实际就是jdk的字节缓冲区ByteBuffer),那么无论何时读取http body数据,都是要从org.apache.coyote.Request.inputBuffer对象上读取,那么就看哪里调用了org.apache.coyote.Request.inputBuffer对象,如下图
从经验和字面上分析,应该是doRead方法,把断点打在该方法,果然是这里,堆栈如下图:
接着通过MappingJackson2HttpMessageConverter(具体执行方法是MappingJackson2HttpMessageConverter.read(Type, Class>, HttpInputMessage) )把二进制流数据转换为json格式数据。说明json请求方式,http body报文并不会保存在org.apache.coyote.Request,org.apache.coyote.Request保存的只是二进制流。
如下图
对应的java Controller代码
@RequestMapping(value = "/order/form", method = RequestMethod.POST)
public String order(@RequestParam(value = "name") String name, @RequestParam(value = "age") Integer age) {
return name+age;
}
这种情况,请求数据在http body内,经过分析,请求数据也是解析到org.apache.coyote.Request.parameters对象(即org.apache.tomcat.util.http.Parameters.paramHashValues这个map上),和请求参数拼接在url上一样。那么什么时候调用去解析请求参数到paramHashValues的呢?只要是第一次调用org.apache.catalina.connector.Request.getParameterXXX方法的时候,判断请求参数未解析,就会去解析参数到paramHashValues。
对于Content-Type为x-www-form-urlencode,GET和POST,请求数据在http body还是拼接在url后面,最终解析后的请求参数都在org.apache.coyote.Request.parameters对象(即org.apache.tomcat.util.http.Parameters.paramHashValues这个map上),可以使用request.getParameter(String)来获取请求参数值。
对于Content-Type为application/json,无论GET还是POST,请求数据只能在http body,使用@RequestBody进行解析为java bean,解析后的请求数据并不保存到org.apache.coyote.Request对象。
最后,写这个笔记有什么用呢?是因为生产曾经遇到一个bug,我们需要把jenkins构建日志保存到fastdfs,从而供用户查看构建日志,具体流程如下:
有用户反馈有时候查看到的构建日志不完整,发现这些不完整的构建日志到fastdfs中也是不完整的,这种情况发生在前端项目构建日志,而且基本出现在前端的构建日志通常都比较大(有些200k)的时候,请求是POST x-www-form-urlencode,刚开始排查,以为是超过了http请求的最大限制,经过测试500k的数据发送,发现是没有问题。代码如下:
public void uploadConsoleOut(BuildWithDetails details, Long buildId) {
try {
String cOnsoleOutputText= details.getConsoleOutputText();//获取jenkins构建日志,jenkins console log
Map map = new HashMap<>(2);
map.put("file", consoleText);
map.put("pathValue", "buildLog-" + buildLogId);
String post= post=HttpUtil.post(url, map);//url是包管理系统接口,HttpUtil是Hutool工具类
//其它省略
}
} catch (IOException e) {
//只打印异常
log.error("upload buildlog occurs exception", e);
}
}
继而想起了x-www-form-urlencode时候,请求参数是保存在org.apache.coyote.Request.parameters对象,用出现问题的这个构建日志本地做测试,发现org.apache.coyote.Request.parameters对象的参数key不仅有file和pathValue,还有其它一大堆,此时才明白,可能是构建日志中有&符号导致http解析的时候还有其它key,我们以为格式是file=[构建日志内容]&pathValue=buildLog-5685,但是实际构建日志内容有&,实际是file=[构建日志内容一部分]&构建日志另一部分=构建日志其它部分&pathValue=buildLog-5685,这样就导致包管理系统获取file的时候只获取到了构建日志中第一个&符号之前的日志数据,剩余就没了,最终导致用户只查看到部分构建日志。解决办法,改用json,修复后代码如下,问题解决。
public void uploadConsoleOut(BuildWithDetails details, Long buildId) {
try {
String cOnsoleOutputText= details.getConsoleOutputText();//获取jenkins构建日志,jenkins console log
Map map = new HashMap<>(2);
map.put("file", consoleText);
map.put("pathValue", "buildLog-" + buildLogId);
String jsOnStr= JSONUtil.toJsonStr(map);
String post= post=HttpUtil.post(url, jsonStr);//修复后hutool对于json串会采用content-type为application/json
//其它省略
}
} catch (IOException e) {
//只打印异常
log.error("upload buildlog occurs exception", e);
}
}
第一次出现这个问题解决了,由于没记录且间隔了几个月,又遇到了发布系统保存执行日志到fastdfs也是同样问题,执行日志中有&导致执行日志打印不完整,当时以为shell问题,后来解决半天才想起来是这个问题,还是记录下为好。
通常我们的web项目都会有个全局的异常处理器,都是使用@Controller和@ExceptionHander。
ControllerAdvice拆开来就是Controller Advice,Advice在Spring的AOP中,是用来封装一个切面所有属性的,包括切入点和需要织入的切面逻辑。这里ControllerAdvice也可以这么理解,其抽象级别应该是用于对Controller进行切面环绕的,而具体的业务织入方式则是通过结合其他的注解来实现的。但是Controller的具体实现,并不是通过AOP功能实现的。
@ControllerAdvice使用AOP思想可以这么理解:此注解对目标Controller的通知是个环绕通知,织入的方式是注解方式,增强器是注解标注的方法。如此就很好理解@ControllerAdvice搭配@InitBinder/@ModelAttribute/@ExceptionHandler起到的效果喽~
@ControllerAdvice是在类上声明的注解,其用法主要有三点:
1.结合方法型注解@ExceptionHandler,用于捕获Controller中抛出的指定类型的异常,从而达到不同类型的异常区别处理的目的。 基本web项目都用到。
2.结合方法型注解@InitBinder,用来设置WebDataBinder,WebDataBinder自动绑定前台请求参数到model中。
3.结合方法型注解@ModelAttribute,让全局的@RequestMapping都能获得在此处设置的键值对,即在目标Controller方法之前执行。
@ControllerAdvice
public class GloableException {
private final static Logger logger = LoggerFactory.getLogger(GloableException.class);
@ExceptionHandler({Exception.class})
@ResponseBody
@ResponseStatus(value = HttpStatus.OK)
public CommonResult handleControllerException(Exception e, WebRequest request, HttpServletResponse response) {
logger.error(e.getMessage(), e);
if (e instanceof BussinessException) {
return CommonResult.failed("业务异常!");
}
return CommonResult.failed("系统异常:"+e.getMessage());
}
}
public class BussinessException extends RuntimeException{
public BussinessException(String msg) {
super(msg);
}
}
@RestController
public class IndexController {
@RequestMapping(value = "/order/form", method = RequestMethod.GET)
public String order(@RequestParam(value = "name") String name, @RequestParam(value = "age") Integer age) {
if (true) {
throw new BussinessException("测试参数异常!");
}
return name+age;
}
}
测试结果
{
"code": 500,
"message": "业务异常!",
"data": null
}
@ControllerAdvice和@ExceptionHandler是在哪里解析的呢?
以前一直从@ControllerAdvice字面意思理解为是通过AOP来实现的,发现实际却不是,因此看了下这块源码
@ControllerAdvice被注解,说明也是个bean。
debug代码,关键处在org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver#getExceptionHandlerMethod(HandlerMethod, Exception)
方法,执行堆栈如下
getExceptionHandlerMethod关键代码解析
/*
* 查找匹配的全局异常处理方法
*/
for (Map.Entry entry : this.exceptionHandlerAdviceCache.entrySet()) {
ControllerAdviceBean advice = entry.getKey();
if (advice.isApplicableToBeanType(handlerType)) {//如果handlerType被@ControllerAdvice注解,handlerType是controller的clazz
ExceptionHandlerMethodResolver resolver = entry.getValue();//异常处理解析器,表示的是@Controller内的@ExceptionHandler
Method method = resolver.resolveMethod(exception);//根据异常类型,从缓存ExceptionHandlerMethodResolver.mappedMethods获取到匹配的@ExceptionHandler注解的方法
if (method != null) {
return new ServletInvocableHandlerMethod(advice.resolveBean(), method);//包装为ServletInvocableHandlerMethod,参数分别是@Controller bean, @ExceptionHandler注解方法
}
}
}
那么this.exceptionHandlerAdviceCache的内容是怎么来的呢?
在org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver.initExceptionHandlerAdviceCache()
方法内
private void initExceptionHandlerAdviceCache() {
if (getApplicationContext() == null) {
return;
}
//查找IOC容器内的被@ControllerAdvice注解的bean
List adviceBeans = ControllerAdviceBean.findAnnotatedBeans(getApplicationContext());
AnnotationAwareOrderComparator.sort(adviceBeans);
for (ControllerAdviceBean adviceBean : adviceBeans) {
Class> beanType = adviceBean.getBeanType();
if (beanType == null) {
throw new IllegalStateException("Unresolvable type for ControllerAdviceBean: " + adviceBean);
}
//获取@ControllerAdvice注解的bean被@ExceptionHandler注解方法,最终把这些方法缓存到ExceptionHandlerMethodResolver.mappedMethods
ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(beanType);
if (resolver.hasExceptionMappings()) {
this.exceptionHandlerAdviceCache.put(adviceBean, resolver);//把被@ControllerAdvice注解的bean缓存
}
if (ResponseBodyAdvice.class.isAssignableFrom(beanType)) {
this.responseBodyAdvice.add(adviceBean);
}
}
}
获取到异常处理方法后,通过反射调用异常处理方法,具体逻辑是在org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver.doResolveHandlerMethodException(HttpServletRequest, HttpServletResponse, HandlerMethod, Exception)
具体执行堆栈记录如下
GloableException.handleControllerException(Exception, WebRequest, HttpServletResponse) line: 26
GeneratedMethodAccessor62.invoke(Object, Object[]) line: not available
DelegatingMethodAccessorImpl.invoke(Object, Object[]) line: 45005
Method.invoke(Object, Object...) line: 498
ServletInvocableHandlerMethod(InvocableHandlerMethod).doInvoke(Object...) line: 189
ServletInvocableHandlerMethod(InvocableHandlerMethod).invokeForRequest(NativeWebRequest, ModelAndViewContainer, Object...) line: 138
ServletInvocableHandlerMethod.invokeAndHandle(ServletWebRequest, ModelAndViewContainer, Object...) line: 102
ExceptionHandlerExceptionResolver.doResolveHandlerMethodException(HttpServletRequest, HttpServletResponse, HandlerMethod, Exception) line: 412
ExceptionHandlerExceptionResolver(AbstractHandlerMethodExceptionResolver).doResolveException(HttpServletRequest, HttpServletResponse, Object, Exception) line: 61
ExceptionHandlerExceptionResolver(AbstractHandlerExceptionResolver).resolveException(HttpServletRequest, HttpServletResponse, Object, Exception) line: 139
HandlerExceptionResolverComposite.resolveException(HttpServletRequest, HttpServletResponse, Object, Exception) line: 80
DispatcherServlet.processHandlerException(HttpServletRequest, HttpServletResponse, Object, Exception) line: 1297
DispatcherServlet.processDispatchResult(HttpServletRequest, HttpServletResponse, HandlerExecutionChain, ModelAndView, Exception) line: 1109
DispatcherServlet.doDispatch(HttpServletRequest, HttpServletResponse) line: 1055
通常我们的应用抛出了异常且没被@HandlerException捕获处理的话,页面会出现这个错误
这个具体是怎么来的呢?
springboot有个ErrorController接口,默认是异常处理的入口,默认会创建controller bean BasicErrorController,如果程序抛出的异常没被捕获,则会执行到该方法的/error内(/error的来源是org.springframework.boot.autoconfigure.web.ErrorProperties.path),
具体执行流程如下:
tomcat的执行入口是CoyoteAdapter.service(Request, Response),接着执行tomcat的pipeline,继而执行FilterChain,然后是Servlet,具体如图
其中tomcat pipeline是[StandardEngineValve, ErrorReportValve, StandardHostValve, NonLoginAuthenticator, StandardContextValve, StandardWrapperValve],执行方式和filterchain类似,也是串行执行。
分几种情况来说:
代码@2处,如果没有抛出异常,不进入异常处理,返回的http status code 200。
代码@2处抛出异常,如果在代码@5处,这个异常被吞了,则返回的http status code 200,通常web项目这样是捕捉到异常进行统一处理,比如下面代码,异常就被吞了。
@ControllerAdvice
public class GloableException {
private final static Logger logger = LoggerFactory.getLogger(GloableException.class);
@ExceptionHandler({Exception.class})
@ResponseBody
@ResponseStatus(value = HttpStatus.OK)
public CommonResult handleControllerException(Exception e, WebRequest request, HttpServletResponse response) throws Exception {
logger.error(e.getMessage(), e);
if (e instanceof BussinessException) {
return CommonResult.failed("业务异常!");
}
return CommonResult.failed("系统异常:"+e.getMessage());
}
}
代码@5处,如果抛出了异常呢?有人问,这里本来就是为了做异常统一处理,为什么要要抛出异常呢?比如健康检查失败,就要返回http status code非200错误,按照上面做法,异常被吞了。比如用发布系统发布,curl health-url非200就任务发布失败,还有发布到k8s容器内也可以用健康url作为个心跳探测,如果连续10次curl返回http status code非200,则认为应用宕机了,会自动重启应用。因此这样情况是有场景的。代码如下:
@ControllerAdvice
public class GloableException {
private final static Logger logger = LoggerFactory.getLogger(GloableException.class);
@ExceptionHandler({Exception.class})
@ResponseBody
@ResponseStatus(value = HttpStatus.OK)
public CommonResult handleControllerException(Exception e, WebRequest request, HttpServletResponse response) throws Exception {
logger.error(e.getMessage(), e);
if (e instanceof BussinessException) {
return CommonResult.failed("业务异常!");
} else if (e instanceof HealthException) {//健康检查失败抛出HealthException,返回500错误
throw e;
}
return CommonResult.failed("系统异常:"+e.getMessage());
}
}
这样就是在代码@5处抛出了异常,异常一层层的向上throw异常,最终是执行tomcat pipeline的StandardHostValve.invoke(Request, Response)抛出ServletException异常被捕获并吞了,执行org.apache.catalina.core.StandardWrapperValve.exception(Request, Response, Throwable)
,把http status code由200改为500。这段代码如下
private void exception(Request request, Response response,
Throwable exception) {
request.setAttribute(RequestDispatcher.ERROR_EXCEPTION, exception);//把异常保存到request,在StandardHostValve内就可以取出来了
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);//把http status code改为500
response.setError();//把org.apache.coyote.Response.errorState改为1,这样在StandardHostValve内就可以forward到/error接口
}
StandardHostValve执行完tomcat pipeline后,由于springboot默认加了个/error Controller接口供http status code非200的时候调用,因此会根据异常forward到/error接口,代码截图解释如下:
org.apache.catalina.core.StandardHostValve.custom(Request, Response, ErrorPage)代码截图如下
forward后就去执行Servlet的service功能,即DispatcherServlet.doDispatch(HttpServletRequest, HttpServletResponse),找到/error的对应Controller方法执行,最终进入执行org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController.error(HttpServletRequest)。
我们在项目中,可以重写ErrorController来覆盖springboot默认的BasicErrorController。
最近给一个项目写健康检查接口用于发布检查,在抛出异常后发现返回的http status code还是200,经过排查发现项目重写了ErrorController,把健康检查抛出的异常的请求响应http status code给了200。
@Controller
@RequestMapping(value = "/error")
public class CommonErrorController implements ErrorController {
protected Map getErrorAttributes(HttpServletRequest request) {
DispatcherServletWebRequest requestAttributes = new DispatcherServletWebRequest(request);
ErrorAttributes errorAttributes = new DefaultErrorAttributes();
return errorAttributes.getErrorAttributes(requestAttributes, false);
}
@RequestMapping
@ResponseBody
public Result error(HttpServletRequest request, HttpServletResponse response) {
// set CORS header
response.setStatus(200);//给请求都返回200,因此健康检查抛出异常也不会返回500
response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin"));
response.setHeader("Access-Control-Allow-Methods", "GET, POST");
response.setHeader("Access-Control-Max-Age", "3600");
response.setHeader("Access-Control-Allow-Headers", "content-type");
response.setHeader("Access-Control-Allow-Credentials","true"); //是否支持COOKIE跨应用
Map errorAttributes = getErrorAttributes(request);
int code = Integer.parseInt(String.valueOf(errorAttributes.get("status")));
String key = String.valueOf(errorAttributes.get("error"));
String message = String.valueOf(errorAttributes.get("message"));
return CommonResult.failed(message);
}
@Override
public String getErrorPath() {
return "/error";
}
}
具体跳转到/error的执行堆栈如下
BasicErrorController.error(HttpServletRequest) line: 98
ServletInvocableHandlerMethod(InvocableHandlerMethod).doInvoke(Object...) line: 189
ServletInvocableHandlerMethod(InvocableHandlerMethod).invokeForRequest(NativeWebRequest, ModelAndViewContainer, Object...) line: 138
ServletInvocableHandlerMethod.invokeAndHandle(ServletWebRequest, ModelAndViewContainer, Object...) line: 102
RequestMappingHandlerAdapter.invokeHandlerMethod(HttpServletRequest, HttpServletResponse, HandlerMethod) line: 895
RequestMappingHandlerAdapter.handleInternal(HttpServletRequest, HttpServletResponse, HandlerMethod) line: 800
RequestMappingHandlerAdapter(AbstractHandlerMethodAdapter).handle(HttpServletRequest, HttpServletResponse, Object) line: 87
DispatcherServlet.doDispatch(HttpServletRequest, HttpServletResponse) line: 1038
DispatcherServlet.doService(HttpServletRequest, HttpServletResponse) line: 942
DispatcherServlet(FrameworkServlet).processRequest(HttpServletRequest, HttpServletResponse) line: 1005
DispatcherServlet(FrameworkServlet).doGet(HttpServletRequest, HttpServletResponse) line: 897
DispatcherServlet(HttpServlet).service(HttpServletRequest, HttpServletResponse) line: 634
DispatcherServlet(FrameworkServlet).service(HttpServletRequest, HttpServletResponse) line: 882
DispatcherServlet(HttpServlet).service(ServletRequest, ServletResponse) line: 741
ApplicationFilterChain.internalDoFilter(ServletRequest, ServletResponse) line: 231
ApplicationFilterChain.doFilter(ServletRequest, ServletResponse) line: 166
ApplicationDispatcher.invoke(ServletRequest, ServletResponse, ApplicationDispatcher$State) line: 712
ApplicationDispatcher.processRequest(ServletRequest, ServletResponse, ApplicationDispatcher$State) line: 461
ApplicationDispatcher.doForward(ServletRequest, ServletResponse) line: 384
ApplicationDispatcher.forward(ServletRequest, ServletResponse) line: 312
StandardHostValve.custom(Request, Response, ErrorPage) line: 394
StandardHostValve.status(Request, Response) line: 253
StandardHostValve.throwable(Request, Response, Throwable) line: 348
StandardHostValve.invoke(Request, Response) line: 173
写着篇记录,记录了下通用的全局异常处理,以及如何控制http status code的显示,对于整个http请求的处理也再加深下印象,便于工作中排查问题。
redirct VS forward
实际开发中有这样需求,需要对所有的入参和出参进行解密和加密,这样功能和业务无关,肯定不能在每个Controller写,需要抽取为公共处理,最好是业务无感。有人说可以使用mvc拦截器实现,我测试分析了下,使用拦截器是无法实现的。
先来看下mvc的拦截器执行流程和http二进制流数据如何通过@RequestBody转换为java bean的过程
@1:每个http请求都要由DispatcherServlet这个Servlet接入进行处理,是个总入口,这个DispatcherServlet这个Servlet是注册到了tomcat的,由tomcat进行调用。
@2:根据请求的uri从DispatcherServlet.handlerMappings缓存中获取待执行的Controller的mapping方法。这些mapping方法在spring启动时候注册到handlerMappings。
@3:执行拦截器的前置处理方法,此时http body的数据(业务数据)还保存在tomcat的输入流内,还是二进制数据状态。由于此时不知道数据的结构,是无法进行把二进制流解析为java bean的。
@4:把http body二进制流数据按照@RequestBody指定的java bean格式进行解码
@5:反射调用,执行具体的业务Controller mapping方法
@6:通过@ResponseBody把java bean对象编码为二进制流,组装到http body
@7:执行拦截器的后置处理方法。此时业务数据也已经是二进制流数据了
@8:处理Controller mapping的执行结果,执行结果正常or异常的不同处理方式
@9:执行拦截器的收尾方法
通过上面的分析,发现使用拦截器是无法实现对json字符串进行加解密。那么可以考虑在编解码处对数据进行解密和加密。
springMVC内置了几种HttpMessageConverter用于消息转换,其中MappingJackson2HttpMessageConverter是用于二进制流数据和java bean之间的转换。那么我们只需要在MappingJackson2HttpMessageConverter上加上加解密功能即可。自己写的时候在ObjectMapper的读写上卡壳,后来发现了这位大佬的已经有具体实现https://www.throwx.cn/2019/11/29/spring-mvc-param-global-encryption-decryption-in-action/。
重写MappingJackson2HttpMessageConverter后,需要把内置的MappingJackson2HttpMessageConverter给移除,需要实现WebMvcConfigurer的extendMessageConverters方法,把默认的MappingJackson2HttpMessageConverter给移除。
@Configuration
public class SpringMvcConfig implements WebMvcConfigurer {
/**
* 移除内置的MappingJackson2HttpMessageConverter
*/
@Override
public void extendMessageConverters(List> converters) {
converters.forEach(converter -> {
if (converter instanceof MappingJackson2HttpMessageConverter) {
converters.remove(converter);
}
});
}
/**
* 添加自定义的MappingJackson2HttpMessageConverter
*/
@Override
public void configureMessageConverters(List> converters) {
converters.add(customMappingJackson2HttpMessageConverter());
}
@Bean
public CustomMappingJackson2HttpMessageConverter customMappingJackson2HttpMessageConverter() {
return new CustomMappingJackson2HttpMessageConverter();
}
}
mvc在使用消息转换器进行消息转换前后,会去执行被@ControllerAdvice注解的RequestBodyAdvice实现,具体代码如图
分别写个RquestBodyAdvice、ResponseBodyAdvice的实现类,并且用@ControllerAdvice注解,在RquestBodyAdvice的afterBodyRead写解密,在ResponseBodyAdvice的beforeBodyWrite写加密逻辑即可,比实现消息转换器简单了好多。