原生java http方式上传大文件(含 文件流分段上传问题、base64分段转码问题解决思路)

时间:2023-01-10 20:58:46

做项目的过程中碰到一个需求:
在java客户端,使用http通信,把客户端的本地文件通过http发送上传到服务器;


请求格式是xml(不管是json还是xml都是字符串,所以这个无所谓),中间包含[文件流字符串];


之前的做法是,把文件流通过base64编码转换为base64Byte,然后和其它字符串信息放到一起,post的时候通过HttpURLConnection的write方法写入到服务器中去,这个上传的过程就完成了。


——————————
但是碰到一个问题,当文件体积较大时,从文件流转换成base64Byte后,体积会很大,可能会导致OOM;
(以二进制流的方式保存,体积最小;以byte数组的方式保存,体积会相对变大一些;以String形式保存,体积最大;)


出错原因是:
FileInputStream fis = new FileInputStream(file); //这一步打开了一个对准file准备进行读取的文件指针,但是还没有开始读写,file的相关数据没有从本地加载到内存中来;所以即使file的体积有10G那么大,这一步也是不会OOM的


//把文件流转换为字节数组
byte[] fileBytes;
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] byteBuf = new byte[1024];
int count;
while((count=fis.read(buf))!=-1)
{
baos.write(buf,0,count); //实际上,如果文件体积比较大的话,不用转码,在这一步就可能OOM了
}
fileBytes= baos.toByteArray();


byte[] base64Bytes = Base64.encodeBase64(fileBytes); //在这一步也可能OOM
(文件转换为byte[]时,是有可能OOM的;而转换为base64Bytes后,体积会增大1/3,所以有可能前一步没有OOM,却在这一步出现OOM;
为什么转码后体积会增大1/3,后面我会解释)


——————————
解决方法


既然file在本地没有加载到内存来的时候不会出现内存溢出的情况,我就想到了一个解决的方法:分段上传
(加大内存并不能从根本上解决内存溢出的问题,问题的根本原因不是内存不够大,而是代码有问题)


在本地的file通过HttpURLConnection的getOutputStream()进行write时,不是一次性全部写入,而是循环配合flush进行写入:
FileInputStream fis = new FileInputStream(file);
byte[] buf = new byte[1024];
int count;
while((count = fis.read(buf)) != -1)
{
os.write(Base64.encodeBase64(buf), 0, count);
os.flush();
}
(我从本地读1024字节,然后马上上传到服务器,清空本地缓存,然后再从本地读1024字节,这样循环读取,即使文件有20G,理论上也不有OOM问题出现,因为我从本地文件中读到的数据不会在内存中驻留)


——————————
解决问题的思路对了,但是出现了其他的细节问题
os.write(Base64.encodeBase64(buf), 0, count); //这一行代码报错了,出现了OOM
我搜集了一下资料,发现原因是:
HttpURLConnection的getOutputStream的实际对象是sun.net.www.http.PosterOutputStream,这个对象的flush方法代码是空的,write配合flush,并没有达到即时上传数据的效果。PosterOutputStream其实是自己在本地维护了一个缓冲区,你通过write写入的数据其实还是在这个本地的缓冲区里,只有当你getInputStream后,HttpURLConnection才会把这段缓冲区中的数据上传到服务器上。而flush达不到上传数据,清空本地缓存的效果。


——————————
(我是不能通过getInputStream来刷新缓冲流的,因为那就不是分段上传而是"分次"上传了)
那这就不是我的思路的问题了。再去搜索解决方法后,得知:
在创建HttpURLConnection对象的时候,要调用一个方法
hurlc.setChunkedStreamingMode(1024); //设置分块流模式 也就是分块上传 1024是getOutputStream维护的本地缓冲区的大小
调用该方法后,只要本地缓存区满了,HttpURLConnection就会自动把缓冲区里的数据发送到服务器,同时清空本地缓存(ps:HttpURLConnection的getOutputStream似乎是个抽象的工厂方法,在调用setChunkedStreamingMode方法后,我发现getOutputStream获取到的实例对象从sun.net.www.http.PosterOutputStream变成了sun.net.www.protocol.http.HttpURLConnection$StreamingOutputStream)


——————————
果然,调用setChunkedStreamingMode方法后,os.write(Base64.encodeBase64(buf), 0, count);没有再出现OOM异常了


但是,又出现了一个新的问题
我发现
FileInputStream fis = new FileInputStream(file);
byte[] buf = new byte[1024];
int count;
while((count = fis.read(buf)) != -1)
{
os.write(Base64.encodeBase64(buf), 0, count);
os.flush();
}
这段分段编码写入的代码,其编码所得结果,与非分段编码所得结果是不一样的
通过分段编码上传的图片内容出现了错误


我通过下面代码测试:
//分段编码
ByteArrayOutputStream os1 = new ByteArrayOutputStream();
InputStream file1 = new FileInputStream(path);
byte[] buf1 = new byte[1024];
int count1;
while((count1 = file1.read(buf1)) != -1)
{
os1.write(Base64.encodeBase64(buf1), 0, count1);
os1.flush();
}
file1.close();
System.out.println(os1.toString());


//非分段编码
ByteArrayOutputStream os2 = new ByteArrayOutputStream();
InputStream file2 = new FileInputStream(path);
byte[] buf2 = new byte[1024];
int count2;
while((count2 = file2.read(buf2)) != -1)
{
os2.write(buf2, 0, count2);
os2.flush();
}
file2.close();
System.out.println(new String(Base64.encodeBase64(os2.toByteArray())));


两者的结果:
/9j/4AAQSkZJR...wDtUAVs7eF...
/9j/4AAQSkZJR...wDt89ymnxJ...
前面一段还是相同的,转到后面,就开始南辕北辙了


——————————
原因我去网上找了一下但是没有找到直接答案,但是看到一篇解释base64编码原理的文章
原文链接:http://www.cnblogs.com/luguo3000/p/3940197.html


假设有文件A(txt文件,包含文本内容"ABCDEFG"),转换为InputStream->byte[]后
它们的ASIIC码分别对应65、66、67、68、69、70、71
二进制表现形式为:
1000001 1000010 1000011 1000100 1000101 1000110 1000111
对高位补零后:
01000001 01000010 01000011 01000100 01000101 01000110 01000111
在内存中的实际表现:
01000001010000100100001101000100010001010100011001000111


而base64编码,使用的字符包括(A-Z、a-z、0-9、+、/、=)这些常规可读字符,使用base64编码的原因,用途,在于把一些乱码字符、不可读字符转换为常规可读字符;
(因为java底层的通信协议、或者说其它的通信协议,很多地方用到远程通信这一块的,对一些乱码字符不支持传输,所以需要把乱码字符转换成常规可读字符才能进行传输;该转码的用途很广泛,可以通过base64编码去存储任何原本因编码集不支持而无法正常存储的字符)


比如对于'矙'这个字符,部分传输协议的编码集就不认识它,所以无法直接传输,必须base64转码
'矙'的UTF-8编码值为30681,二进制表现形式为111011111011001->(0)111011111011001
需要两个字节来存储01110111 11011001


base64编码只有(A-Z、a-z、0-9、+、/、=)这些字符来表示。需要强调的是,在base64编码规范中,字符'A'不等于65、'B'也不是66...。base64字符与数值(二进制值)的对应关系如下:

原生java http方式上传大文件(含 文件流分段上传问题、base64分段转码问题解决思路)
也就是说,常规字符'A'=65=01000001;而base64字符'A'=0=00000000;


base64字符代表的二进制值是无法直接表示'矙'这个字符的,因为base64字符的值范围在0~63之间(二进制值在(00)000000~(00)111111之间)。
那如何通过(00)000000~(00)111111之间的数值来表示01110111 11011001呢?


这就是base64的编码算法了
一个base64字符的二进制值在(00)000000~(00)111111之间,也就是说它可以表示000000~111111之间的二进制数,一个base64字符的有效位为后6位。如何通过以6bit为单位的base64字符表示以8bit为单位的常规字节?
6和8的最小公倍数为24,即 每4个base64字符可以表示3个常规字节;
 
回到刚才的文件A,编码过程:
(初始文件A)->"ABCDEFG"
(转UTF-8码 int)->65 66 67 68 69 70 71
("ABCDEFG"的二进制表示;7字节)->1000001 1000010 1000011 1000100 1000101 1000110 1000111
(高位补零)->01000001 01000010 01000011 01000100 01000101 01000110 01000111
(连写)->01000001010000100100001101000100010001010100011001000111
(按6bit为单位对所有bit进行分割;得到10字节)->010000 010100 001001 000011 010001 000100 010101 000110 010001 11
(按6bit*4=8bit*3的对应关系再分割;得到3组6*4字节)->(010000 010100 001001 000011) (010001 000100 010101 000110) (010001 11)
(高位补2个零;末尾的低位也补零)->(00010000 00010100 00001001 00000011) (00010001 00000100 00010101 00000110) (00010001 00110000)
(二进制值换算成十进制)->(16 20 9 3) (17 4 21 6) (17 48)
(按base64编码的值-字符对应表,得出上面的十进制值对应的base64字符)->(Q U J D) (R E V G) (R w)
(每组base64字符都要求是4个,空白的位置补'='字符)->(Q U J D) (R E V G) (R w = =)
(文件A的最终转码结果)->QUJDREVGRw==
这里以文本文件作为演示,因为文本文件机器可读人也可读;实际情况中,很多时候转码的目标文件并不是文本文件,那就不能以可读字符串形式表示了,会直接以二进制格式表示


体积增大的原因,是因为3字节=24bit=分割成4个6bit-,对4个6bit高位补零后,就得到4个字节
也就是说3个常规字节经base64编码后会生成4个base64字节,这就是文件经base64转码后体积会增加1/3的原因


——————————
base64编码原理解释了,再看刚才的分段编码
ByteArrayOutputStream os1 = new ByteArrayOutputStream();
InputStream file1 = new FileInputStream(path);
byte[] buf1 = new byte[1024];
int count1;
while((count1 = file1.read(buf1)) != -1)
{
os1.write(Base64.encodeBase64(buf1), 0, count1); //可以发现一个问题:Base64.encodeBase64(buf1)编码后,体积会增加1/3,所以这里的Base64.encodeBase64(buf1)编码转换后的实际长度和count1并不相等,所以实际写入到os1中的base64字符数只有Base64.encodeBase64(buf1)编码产生的字符数的3/4
os1.flush();
}
file1.close();
System.out.println(os1.toString());


修改后:
ByteArrayOutputStream os1 = new ByteArrayOutputStream();
InputStream file1 = new FileInputStream(path);
byte[] byteBuf = new byte[1024];
byte[] base64ByteBuf;
while(file1.read(byteBuf) != -1)
{
base64ByteBuf = Base64.encodeBase64(byteBuf);
os1.write(base64ByteBuf, 0, base64ByteBuf.length);
os1.flush();
}
file1.close();
System.out.println(os1.toString());


——————————
修改后,发现分段编码的结果发生了变化,跟之前不一样了
但仍然不是正确的结果


原因在于,base64字符的基础单位是(6bit*4=4字节),而3个常规字节(8bit*3)才能刚好产生4个base64字节
根本原因在于,如果进行编码的常规字节数不是3的倍数,最后就会余下1或2个字节,而这1、2个字节编码的结果就会产生'='字符;
使用1024作为分段编码缓冲时,编码的结果是3+3+3+...+1
也就是每次都会余1字节


而没有使用分段编码时,当编码到第1024个字节时,"余下"的1字节会跟后面的字节形成连续,就不会产生'='字符
(对一段byte字符进行base64编码时,中间是绝不会产生'='字符的,因为只有在结尾才可能余下1或2个字节,所以对一段byte字符进行编码时,只有结尾才可能产生1或2个'='补全字符)


——————————
解决方法是,使用3的公倍数作为缓冲区大小


修改后:
ByteArrayOutputStream os1 = new ByteArrayOutputStream();
InputStream file1 = new FileInputStream(path);
byte[] byteBuf = new byte[3*1024];
byte[] base64ByteBuf;
while(file1.read(byteBuf) != -1)
{
base64ByteBuf = Base64.encodeBase64(byteBuf);
os1.write(base64ByteBuf, 0, base64ByteBuf.length);
os1.flush();
}
file1.close();
System.out.println(os1.toString());
测试结果再次发生了改变
中间不再有'='字符了,因为中间每次都是3字节3字节的编码,没有余下多余的字节
对比之后发现,中间段的结果已经正常了


——————————
但是,发现,结尾处两个转码的结果有些许不同


原因在于,假设文件A的长度为3001个字节;
在第二次循环读取时,只读到1个有效字节,而byteBuf的剩余2999个字节都是无效字节,而此时编码时,却把多余的2999个无效字节也编码了进去
(如果是非分段转码,就不会出现这种情况)


解决方法:
ByteArrayOutputStream os1 = new ByteArrayOutputStream();
InputStream file1 = new FileInputStream(path);
byte[] byteBuf = new byte[3*1000];
byte[] base64ByteBuf;
int count1; //每次从文件中读取到的有效字节数
while((count1=file1.read(byteBuf)) != -1)
{
if(count1!=byteBuf.length) //如果有效字节数不为3*1000,则说明文件已经读到尾了,不够填充满byteBuf了
{
byte[] copy = Arrays.copyOf(byteBuf, count1); //从byteBuf中截取包含有效字节数的字节段
base64ByteBuf = Base64.encodeBase64(copy); //对有效字节段进行编码
}
else
{
base64ByteBuf = Base64.encodeBase64(byteBuf);
}
os1.write(base64ByteBuf, 0, base64ByteBuf.length);
os1.flush();
}
file1.close();
System.out.println(os1.toString());


至此,base64分段编码才算大功告成。大文件上传核心代码才算大功告成。
其实代码改起来非常简单,但是不知道原因不知道原理的话,是无法无中生有的
对我本人来说原本只是想随便写一下,但没想到写的过程中发现自己有很多坑没有发现。写的过程中把自己不懂的地方没有发现的坑也完善了。不说碰到一个知识点就要追根究底,但实际开发中,每一个自己能亲身碰到的实际问题都是锻炼自己的绝佳机会,这种近距离触碰问题、解决问题的机会是难得的。虽然开发中还有很多其它问题也很重要,但你没有亲手碰到过,是无法共鸣的。所以自己在开发中碰到了问题,还是建议大概弄清原因。
弄清原理后,即使以后出现这个问题的其它"变种",也能找到原因并自己解决,但仅仅粘贴复制无法做到这一点(eg:该示例讲解了大文件上传、base64分段转码;而服务器端对待这样的大文件上传,同样需要进行类似的操作 分段读写、分段解码。思路是一样的,原理是相通的)。