您的位置:首页 > 理论基础 > 计算机网络

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

2016-11-17 19:13 1081 查看
做项目的过程中碰到一个需求:

在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字符与数值(二进制值)的对应关系如下:





也就是说,常规字符'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分段转码;而服务器端对待这样的大文件上传,同样需要进行类似的操作 分段读写、分段解码。思路是一样的,原理是相通的)。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  java base64 编码 http
相关文章推荐