千万别把inputStream.available()返回当数据总长度用

1,463 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第7天,点击查看活动详情

我有个朋友从别人博客上拷了一个流的读取工具类,没弄清楚就直接用,出现了一个偶现bug,还好测试就发现了,不然喜提线上bug一个。别人博客里的代码不像开源项目,开源项目有人帮踩坑,别人博客里的代码可没有,当然要搞清楚再使用啦。

bug复现

需求是通过Feign下载一个文件,然后将下载接口得到的InputStream文件转成MultipartFile类型然后再调另外一个接口。从Feign返回的InputStream中读取文件流转换成MultipartFile类型过程中会涉及到将InputStream转成OutputStream的操作。直接使用了别人写的工具类,也没看实现细节,先把功能实现其他再说。
代码大概是这样的

Response response = xxxFeign.getFile(fileName);
MultipartFile mulFile =MultipartFileUtils.getMulFile(response.body().asInputStream());
Response response1 = xxxFeign.xxx(mulFile);

当调试的时候发现这个功能偶尔报错,有时候什么都不返回,有时候只返回一个损坏的文件,没有规律。 要么是下载的文件有问题,要么是转成MultipartFile有问题,要么接收MultipartFile参数的接口有问题。直接调用两个Feign接口都没问题,那就是转成MultipartFile有问题。深入看一下工具类,看到一个代码和之前自己使用的不一样的写法,那当然要研究一下是自己的方法好还是别人的方法好啦,如下。

byte[] bytes = new byte[inputStream.available()];
inputStream.read(bytes);
outputStream.write(bytes);

平时自己读取inputStream都是有循环的,这里没看见循环,这么神奇?inputStream.available()这个方法没用过查一下,一翻源码就发现了一段备注。
返回此输入流下一个方法调用可以不受阻塞地从此输入流读取(或跳过)的估计字节数。拿来当分配长度是不正确的。

d3b4ea0adfa54e9d9cae373075e2cfb3.png 果然不能乱用啊
读取本地文件的InputStream时,使用available()方法总是返回总长度的。但是当InputStream是通过网络获取的就有问题了

原因分析

inputStream.available()有时候不会返回InputStream的总长度。例如网络请求的InputStream会因为网络延迟等原因导致读取流时会出现读取阻塞,这时候available()返回的就不是总长度。

网络通信是进行一次系统调用,参数是一个需要通信数据指针,例如linux的int write(int sockfd, char *buf, int len);(参考Linux网络编程),那么一条数据可以直接全部发送,操作系统会将整个数据分包发送,并且接收方操作系统会接收完所有TCP包后会自动再重组完成才返回给应用层的。但是TCP的头部长度是固定的,也就是能分的包也是有数量限制的,所以 int write(int sockfd, char *buf, int len);传入数据长度是有限制的,一个长的HTTP数据报文一般不能一次性传完。需要在应用层再拆分,多次系统调用发送。

399660b57b6b4beb8525693c48025e70.png

网络延迟会导致InputStream阻塞是因为一个HTTP请求会进行多次系统调用通过TCP发送数据,HTTP数据报文会被分割成多分TCP数据依次到达目标应用层,当只到达一部分时调用流获取数据就会阻塞等待。inputStream.available()是返回此输入流下一个方法调用可以不受阻塞地从此输入流读取(或跳过)的估计字节数,所以就得不到总长度

扩展阅读之HTTP如何判断流全部到达

HTTP通过一个请求头Content-Length参数设置HTTP的长度,做TCP传输的界限的,确保不会粘包的。比如TCP发生粘包即通过Content-Length参数进行切割区别是哪个HTTP请求的数据。所以是通过Content-Length的值判断当前HTTP请求数据是否全部达到的,即流是否结束,是否需要继续阻塞等待数据
实验如下
通过springboot编写一个post请求接口,方法里面循环读取请求体的流,流结束即返回。用postman设置请求头参数Content-Length设置超大,请求体随便写发送请求。因为Content-Length设置比实际发送的数据多了,所以InputStream会一直阻塞,请求不会返回,因为客户端不会发送Content-Length长度的数据。点取消请求后报Unexpected EOF read on the socket错误。达到预期。 在这里插入图片描述

   @PostMapping("/post")
    public void post(HttpServletRequest httpServletRequest){
        
        InputStream inputStream = null;
        try {
            inputStream = httpServletRequest.getInputStream();
            for (int i = inputStream.read(); i != -1; i = inputStream.read()) {
                
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

扩展阅读之@RequestBody为什么不阻塞

根据上面实验,心想如果手动设置Content-Length长度,设置长度比实际发送数据的长度大岂不是会出现请求超时bug。去测试公司的接口,通过postman调接口把Content-Length设置超级长,预期会出现接口超时的情况,发现没超时。

猜想:可能是spring boot做反序列化的时候不等读完流的数据就返回了,spring boot是基于jackson做反序列化的,去翻一下源码。翻了半天搞不懂,通过实验的验证吧,以后看懂源码了再通过源码验证。
既然是反序列化的时候不读等读完流的数据,那么应该是根据一些符号或者什么字符做为读取结束的标记,即使流中数据没全部读取完成也结束读取,接口是基于@RequestBody的,对应的请求体结构是JSON字符串,那么的结束标记应当就是括号}

那我就将请求体里面的JSON字符串的括号}去掉,将Content-Length设置比实际长度大,果然请求超时了,Content-Length设置正常就报jackson反序列化错误。使用MultipartFile接收文件也类似,文件在请求体里面会有符号标志开始和结束,spring mvc会读取完文件流完成后转成MultipartFile类型传递,所以如果使用MultipartFile.getInputStream().available()也是会返回正确的结果,因为spring mvc已经帮我接收到本地临时文件了。

结论:@RequestBody不阻塞是因为jackson做反序列化的时候只读取第一个括号{}里面的数据,反序列化后返回,不读取完整的流,但是流没释放,如果Content-Length设置比实际长度大,因为上一个流没读完数据就返回了,所以同一个客户端下一次请求数据会当作是这个流的数据,导致第二次请求的流不会有数据或者数据不完整,所以会出现超时或者报400错误。