当前位置: 首页 > 科技观察

Springboot中InputStream的神秘消失之谜

时间:2023-03-12 17:14:02 科技观察

前言最近小明接手了前同事的代码,意外的遇到了一个有道理的坑。为了避免两次掉进同一个坑,小明决定把这个坑记下来,并在坑前立了一个大大的牌子,防止其他小伙伴掉进去。HTTPClient模拟调用为了说明这个问题,我们先从最简单的http调用说起。设置bodyserver服务器的代码如下:@Controller@RequestMapping("/")publicclassReqController{@PostMapping(value="/body")@ResponseBodypublicStringbody(HttpServletRequesthttpServletRequest){try{Stringbody=StreamUtil.toString(httpServletRequest.getInputStream());System.out.println("requestbody:"+body);//从参数中获取returnbody;}catch(IOExceptione){e.printStackTrace();returne.getMessage();}}}javaclient要怎样才能发出请求让server读取传递过来的body呢?客户端一定不能麻烦你这个问题,有很多方法可以实现。我们以apachehttpclient为例://postrequestwithsetparameterspublicstaticStringpost(Stringurl,Stringbody){try{//通过HttpPost发送post请求HttpPosthttpPost=newHttpPost(url);StringEntitystringEntity=newStringEntity(body);//通过setEntity传递ourentityobjectoverhttpPost.setEntity(stringEntity);returnexecute(httpPost);}catch(UnsupportedEncodingExceptione){thrownewRuntimeException(e);}}//执行请求并返回响应数据privatestaticStringexecute(HttpRequestBasehttp){try{CloseableHttpClientclient=HttpClients.createDefault();//通过客户端调用execute方法entity,"UTF-8");//关闭Response.close();returnstr;}catch(IOExceptione){thrownewRuntimeException(e);}}可以发现httpclient包非常方便。我们将setEntity设置为输入参数对应的StringEntity。测试为了验证正确性,小明在本地实现了一个验证方法。@TestpublicvoidbodyTest(){Stringurl="http://localhost:8080/body";Stringbody=buildBody();Stringresult=HttpClientUtils.post(url,body);Assert.assertEquals("body",result);}privateStringbuildBody(){return"body";}很是放松,小明泄露了龙王的笑容。设置参数server小明看到有server的代码实现如下:@PostMapping(value="/param")@ResponseBodypublicStringparam(HttpServletRequesthttpServletRequest){//从参数中获取Stringparam=httpServletRequest.getParameter("id");System.out.println("param:"+param);returnparam;}privateMapbuildParamMap(){Mapma??p=newHashMap<>();map.put("id","123456");returnmap;}所有的参数都是通过getParameter方法获取的,应该怎么实现呢?客户端不难,小明想。因为之前很多代码都是这样实现的,所以ctrl+CV固定了如下代码://post请求带集合参数publicstaticStringpost(Stringurl,MapparamMap){ListnameValuePairs=newArrayList<>();for(Map.Entryentry:paramMap.entrySet()){NameValuePairpair=newBasicNameValuePair(entry.getKey(),entry.getValue());nameValuePairs.add(pair);}returnpost(url,nameValuePairs);}//带集合参数的post请求privatestaticStringpost(Stringurl,Listlist){try{//通过HttpPost发送post请求HttpPosthttpPost=newHttpPost(url);//我们发现Entity是一个接口,所以我只能找到实现类,发现实现类需要一个集合,集合的泛型类型是NameValuePairtypeUrlEncodedFormEntityformEntity=newUrlEncodedFormEntity(list);//通过setEntity传递我们的实体对象httpPost.setEntity(formEntity);returnexecute(httpPost);}catch(异常异常){thrownewRuntimeException(exception);}}这个是最常用的paramMap,构建简单;和具体的实现方法分开,也方便后期扩展。servlet标准UrlEncodedFormEntity是一个看似普通的,表明这是一个post表单请求。还涉及到servlet3.1的一个标准,必须满足以下标准才可以使用post表单的参数集合。1、请求是http或者https2。请求的方法是POST3。内容类型是:application/x-www-form-urlencoded4。servlet在请求对象上调用了相关的getParameter方法。当不满足上述条件时,参数集合中不会设置POST表单的数据,但仍然可以通过request对象的inputstream获取。当满足上述条件时,POST表单的数据将不再在请求对象的输入流中可用。这是一个非常重要的协议,让很多小伙伴都摸不着头脑。测试所以,小明也写了对应的测试用例:@TestpublicvoidparamTest(){Stringurl="http://localhost:8080/param";Mapma??p=buildParamMap();Stringresult=HttpClientUtils.post(url,map);Assert.assertEquals("123456",result);}小明想了想,不由皱起了眉头,发现事情并不简单。设置参数和body服务器端一个请求的输入参数比较大,所以放在body中,其他参数还是放在paramter中。@PostMapping(value="/paramAndBody")@ResponseBodypublicStringparamAndBody(HttpServletRequesthttpServletRequest){try{//从参数中获取Stringparam=httpServletRequest.getParameter("id");System.out.println("param:"+param);Stringbody=StreamUtil.toString(httpServletRequest.getInputStream());System.out.println("requestbody:"+body);//从参数中获取returnparam+"-"+body;}catch(IOExceptione){e.printStackTrace();return.getMessage();}}其中,StreamUtil#toString是一个对流进行简单处理的工具类。/***转换为字符串*@paraminputStreamstream*@returnresult*@since1.0.0*/publicstaticStringtoString(finalInputStreaminputStream){if(inputStream==null){returnnull;}try{intlength=inputStream.available();finalReaderreader=newInputStreamReader(inputStream,StandardCharsets.UTF_8);finalCharArrayBufferbuffer=newCharArrayBuffer(length);finalchar[]tmp=newchar[1024];intl;while((l=reader.read(tmp))!=-1){buffer.append(tmp,0,l);}returnbuffer.toString();}catch(Exceptionexception){thrownewRuntimeException(exception);}}Client那么问题来了,如何在HttpClient中同时设置parameter和body呢?机智的小伙伴我们可以先自己试试。小明尝试了多种方法,发现了一个残酷的现实——httpPost只能设置一个Entity,也尝试过查看各种子类,但都不是LUAN。就在小明想要放弃的时候,小明突然想到paramter可以通过拼接url来实现。也就是我们将parameter和url组合成一个新的URL,body的设置方式和之前一样。实现代码如下://带集合参数的post请求publicstaticStringpost(Stringurl,MapparamMap,Stringbody){try{ListnameValuePairs=newArrayList<>();for(Map.Entryentry:paramMap.entrySet()){NameValuePairpair=newBasicNameValuePair(entry.getKey(),entry.getValue());nameValuePairs.add(pair);}//构造url//构造请求路径并添加parameterURIuri=newURIBuilder(url).addParameters(nameValuePairs).build();//构造HttpClientCloseableHttpClienthttpClient=HttpClients.createDefault();//通过HttpPost发送post请求HttpPosthttpPost=newHttpPost(uri);httpPost.setEntity(newStringEntity(body))//获取响应//调用execute方法CloseableHttpResponseResponse=httpClient.execute(httpPost);//获取响应数据HttpEntityentity=Response.getEntity();//将数据转换成字符串Stringstr=EntityUtils.toString(entity,"UTF-8");//关闭Response.close();returnstr;}catch(URISyntaxException|IOException|ParseExceptione){thrownewRuntimeException(e);}}这里通过newURIBuilder(url).addParameters(nameValuePairs).build()结构新建一个URL,当然可以使用&key=value自己拼接测试代码@TestpublicvoidparamAndBodyTest(){Stringurl="http://localhost:8080/paramAndBody";Mapma??p=buildParamMap();Stringbody=buildBody();Stringresult=HttpClientUtils.post(url,map,body);Assert.assertEquals("123456-body",result);}测试通过,完美。新的征程当然,一般的文章到这里应该就结束了。不过以上不是本文的重点,我们的故事才刚刚开始。原木需要大雁飞过,天空一定会留下他的踪迹。程序应该更像这样。为了方便跟踪问题,我们一般会在调用的入参上留下日志痕迹。为了方便代码扩展和维护,小明当然使用了拦截器的方式。日志截取器importorg.slf4j.Logger;importorg.slf4j.LoggerFactory;importorg.springframework.stereotype.Component;importorg.springframework.util.StreamUtils;importorg.springframework.web.servlet.HandlerInterceptor;importorg.springframework.web.servlet.ModelAndView;importjavax.servlet.http.HttpServletRequest;importjavax.servlet.http.HttpServletResponse;importjava.nio.charset.StandardCharsets;importjava.util.Enumeration;/***日志镐*@author老马啸西风*@since1.0.0*/@ComponentpublicclassLogHandlerInterceptorimplementsHandlerInterceptor{privateLoggerlogger=LoggerFactory.getLogger(LogHandlerInterceptor.class);@OverridepublicbooleanpreHandle(HttpServletRequesthttpServletRequest,HttpServletResponsehttpServletResponse,Objecto)throwsException{//获取参数信息Enumerationenumeration=httpServletRequest.getParameterNames();while(enumeration.hasMoreElements()){StringparaName=enumeration.nextElement();logger.info("参数名:{},值:{}",paraName,httpServletRequest.getParameter(paraName));}//获取body信息Stringbody=StreamUtils.copyToString(httpServletRequest.getInputStream(),StandardCharsets.UTF_8);logger.info("body:{}",body);returntrue;}@OverridepublicvoidpostHandle(HttpServletRequesthttpServletRequest,HttpServletResponsehttpServletResponse,Objecto,ModelAndViewmodelAndView)throwsException{}@OverridepublicvoidafterCompletion(HttpServletRequesthttpServletRequest,HttpServletResponsehttpServletResponse,Objecto,Exceptione)throwsException{}}非常的简单易懂,输出入参中的parameter参数和body信息然后指定一下生效的范围:@ConfigurationpublicclassSpringMvcConfigextendsWebMvcConfigurerAdapter{@AutowiredprivateLogHandlerInterceptorlogHandlerInterceptor;@OverridepublicvoidaddInterceptors(InterceptorRegistryregistry){registry.addInterceptor(logHandlerInterceptor).addPathPatterns("/**");所有对super.addInterceptors的请求都会生效。刚才的日志拦截器是不是有问题,如果是,应该怎么解决吨?小明写完还以为万事大吉,结果跑测试用例的时候,整个人都裂了。所有Controller方法中的httpServletRequest.getInputStream()内容变为空。谁偷了我的输入流?想了想,小明发现了问题所在。肯定是我刚才加的日志拦截器有问题,因为stream作为流只能读取一次,而在日志中读取一次后,后面就无法读取了。但是日志一定要输出,那怎么办呢?犹豫不决就向谷歌要技术,八卦要围巾。于是小明就去查了下,解决办法比较直接,重写。重写HttpServletRequestWrapper先重写HttpServletRequestWrapper,保存每次读取的流信息,方便重复读取。/***@authorbinbin.hou*@since1.0.0*/publicclassMyHttpServletRequestWrapperextendsHttpServletRequestWrapper{privatebyte[]requestBody=null;//用于将流保存下来publicMyHttpServletRequestWrapper(HttpServletRequestrequest)throwsIOException{super(request);requestBody=StreamUtils.copyToByteArray(request.getInputStream());}@OverridepublicServletInputStreamgetInputStream(){finalByteArrayInputStreambais=newByteArrayInputStream(requestBody);returnnewServletInputStream(){@Overridepublicintread(){returnbais.read();//读取requestBody中的数据}@OverridepublicbooleanisFinished(){returnfalse;}@OverridepublicbooleanisReady(){returnfalse;}@OverridepublicvoidsetReadListener(ReadListenerreadListener){}};}@OverridepublicBufferedReadergetReader()throwsIOException{returnnewBufferedReader(newInputStreamReader(getInputStream()));}}实现Filter我们上面重写的MyHttpServletRequestWrapper什么时候生效呢?WecanimplementaFilterbyourselvestoreplacetheoriginalrequest:importorg.springframework.stereotype.Component;importjavax.servlet.*;importjavax.servlet.http.HttpServletRequest;importjava.io.IOException;/***@authorbinbin.hou*@since1.0.0*/@ComponentpublicclassHttpServletRequestReplacedFilterimplementsFilter{@Overridepublicvoiddestroy(){}@OverridepublicvoiddoFilter(ServletRequestrequest,ServletResponseresponse,FilterChainchain)throws{@Overridepublicvoiddestroy(){}@OverridepublicvoiddoFilter(ServletRequestrequest,ServletResponseresponse,FilterChainchain)throws{IOQuestRequestServletException,ServletException异常=null;//更换}}@Overridepublicvoidinit(FilterConfigarg0)throwsServletException{}}然后可以发现一切正常,龙王的笑容从小明的嘴角漏了一个拦截器+参数和bodyrequest。因此,解决整个问题是浪费时间。但是,不加思索地浪费时间,才是真正的浪费。核心的两个点是:(1)servlet标准的理解。(2)stream阅读的理解,以及spring的一些知识。