使用HttpURLConnection上传文件

时间:2022-06-06 15:39:39


前言


最近在做Android项目的时候遇到了文件上的传的需求,虽然以前做的是Web开发,但其实对HTTP协议的理解并不深入,因为HTTP连接及报文的生成发送等细节被浏览器封装了;而我本身学习的主动性不强,因此没遇到问题的时候总是没有动力去学习。

这几天花时间了解了一下HTTP上传文件的知识,这里作个笔记。


Volley框架


在这之前,项目中网络交互使用的是Google在2013年发布的网络框架Volley,Volley非常适合数据量不大,但通信频繁的网络操作,而对于大数据量的网络操作,比如说文件上传下载等,Volley的表现就会非常糟糕。

关于Volley的知识可以参考郭霖大神的博客:

http://blog.csdn.net/guolin_blog

郭霖写了四篇关于Volley的博客,从基本知识介绍到源码分析都包括了,我从他的博客中学到了很多东西,在此表示感谢!


HttpClient OR HTTPURLConnection?


既然Volley不适合文件上传,那只好另寻他路;众所周知,在Android中主要有两种方式来进行HTTP通信:HttpClient和HttpURLConnection,那么到底哪一种更好呢?这里同样介绍郭霖的一篇分析该问题的文章:

http://blog.csdn.net/guolin_blog/article/details/12452307

上面的链接是他翻译的一位Google工程师的文章,文章中详细解释了在HttpClient和HttpURLConnection中如何选择;总结起来就是:

使用HttpURLConnection上传文件


通过对比我决定使用HttpURLConnection来实现我的需求,但我对HTTP协议的了解不够深入,因此我先花时间作了一点学习,文章的最后我会给出一些参考资料的链接。


HTTP知识


由于我需要使用HttpURLConnection来发送HTTP消息,因此首先需要了解HTTP报文结构;于是我准备看一下浏览器是如何将表单和文件组织成HTTP报文的,首先准备一个HTML文件,代码如下:

<!DOCTYPE html>
<html>
<head lang="en">
<meta charset="UTF-8">
<title>测试HTTP协议</title>
<script type="text/javascript">
function onSubmitClick() {
var form = document.getElementById("fabokeForm");
form.submit();
}
</script>
</head>
<body>
<iframe id="hiddenIframe" name="hiddenIframe" width=0 height=0 frameborder=0 src="about:blank"></iframe>

<form id="fabokeForm" method="post" enctype="multipart/form-data" name="form_upload" target="hiddenIframe" action="http://www.b.res/t/upload.do">
<input name="tField_1" value=""><br />
<input name="tField_2" value=""><br />
<input type="file" name="tFile_1"><br />
<input type="file" name="tFile_2"><br />
<input id="submitBtn" type="submit" value="提交" onclick="onSubmitClick">
</form>
</body>

这段代码很简单,只含有一个Form表单,表单里面有两个文件域和两个普通表单文本域,点击“提交”按钮时会将表单提交;这里表单中的action是随便写的,因为我只关心浏览器生成的HTML消息结构,因此不用去写服务器端代码来接收文件;另外,我将form的target属性指向一个iframe防止页面跳转,这样可以在浏览器的“开发人员工具”中看到整个请求过程。

接下来将html文件用浏览器打开,给两个文本域填上值并选中两个文件:第一个是文本文件,内容仅仅是一个简单的字符串“Hello+你好”;第二个是一个非常小的图片文件(文件太大会使消息过长不好分析)。

页面截图如下:

使用HttpURLConnection上传文件


接下来点击“提交”按钮将表单提交,在Chrome的“开发者工具》network”中可以查看到HTTP请求的详细信息(先打开“开发者工具”再提交),如下所示:


使用HttpURLConnection上传文件


根据HTTP规范,如果Form表单需要进行文件上传,enctype=“multipart/form-data”是必须设置的;注意上图中HTTP请求的Header区域有个Content-Type属性,其值为“multipart/form-data; boundary=----WebKitFormBoundaryJlHgWOswYf7CHgjV”,分号前面即表单enctype的属性值,表示本次请求有文件需要上传;而分号后面是一个boundary属性,其值为“----WebKitFormBoundaryJlHgWOswYf7CHgjV”,这个字符串是Form表单项、文件域分隔符;根据HTTP协议规范,服务器收到数据包后,会先从数据包的头部固定位置寻找到分隔字符串(HTTP头部Content-Type属性的boundary值),再据此拆分整个数据包并从中读取HTML的所有表单数据;其中JlHgWOswYf7CHgjV是随机生成的字符串,----WebKitFormBoundary是Webkit内核浏览器的表单分隔符前缀。

还有一点需要注意,就是HTTP报文头信息中的boundary在Body区域使用时还要在前面加两上短线,仔细看一下上面的截图就可以看出来:“multipart/form-data; boundary=----WebKitFormBoundaryJlHgWOswYf7CHgjV”中的boundary值只有四个短线;而Body区域的五个分隔符“------WebKitFormBoundaryJlHgWOswYf7CHgjV”都是六个短线。这一点在构造HTTP报文时需要注意。

根据以上描述,再去看图片中的HTTP请求的Body区域就很容易理解了,两文本域两个文件域被五个boundary分隔开;由于Chrome浏览器不显示上传文件的内容并对报文进行了分析组织,因此并不是原生的HTTP报文,为了查看原生报文内容,可以使用Fiddler等抓包工具,下面是用Fiddler抓包工具得到的原始HTTP请求报文:

POST http://www.b.res/t/upload.do HTTP/1.1
Host: www.b.res
Proxy-Connection: keep-alive
Content-Length: 1329
Cache-Control: max-age=0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Origin: null
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryJlHgWOswYf7CHgjV
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.8,en;q=0.6,ja;q=0.4,zh-TW;q=0.2,gl;q=0.2,nb;q=0.2

------WebKitFormBoundaryJlHgWOswYf7CHgjV
Content-Disposition: form-data; name="tField_1"

Hello
------WebKitFormBoundaryJlHgWOswYf7CHgjV
Content-Disposition: form-data; name="tField_2"

你好
------WebKitFormBoundaryJlHgWOswYf7CHgjV
Content-Disposition: form-data; name="tFile_1"; filename="我的文档.txt"
Content-Type: text/plain

Hello+你好
------WebKitFormBoundaryJlHgWOswYf7CHgjV
Content-Disposition: form-data; name="tFile_2"; filename="我的图片.png"
Content-Type: image/png

PNG


IHDR 8 ( $2 sRGB gAMA
a pHYs o d IDATXG KA Cb)4j JA Jz hs R R h1B
s

M Q ? M6 I R [ 1 T $ } vv: Dנ uUA++y Ť r  0 C:A ɧV,~ : F5Ih a %H(Rv i % h y )c ⽱Za 6 W >aԌ" 0 #0dL {Lg LJ "/: "hu
& dms
I V' . ƤڨN ЈѼJ ɹ d m 4h k se9 S J E:Y (9P ϑ 7 $9Pz Đ v bZoے e ^i<E# ָ $@ Qz7 4f- e{' O 2 ^ B M =h
R-l4& 8b^ @w -H W H:- 0P p @ aIdy p @ o L I- H { ɋ ;EJLR+ؿڀZ= O륱 `^ < ġ # 겔DP 8.:C.z
}g $R J
J z ? , ) ٛ 5@9J m !L @ LN_ ot E 묜 v ,M D 3H.@~gh ` $ q ˫pQ D Opz 7ܺ O U ꒀ K .S ' IEND B`
------WebKitFormBoundaryJlHgWOswYf7CHgjV--

从上面的代码中可以看到完整的HTTP报文,而我们要上传文件就需要使用HttpURLConnection构建这样的报文。


使用HTTPURLConnection上传文件


经过上面的分析,已经了解了使用HttpURLConnection上传文件的原理;下面是一个工具类,就是利用上面的分析完成的上传功能:

package com.bj.app.util;

import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLEncoder;
import java.util.Map;
import java.util.Random;

/**
* HTTP工具类, 此类如果参与UI的更新, 需要异步处理.
* Created by zhyh on 2015/1/12.
*/
public class HttpUtility {

private static final String CHARSET_ENCODING = "UTF-8";
private static final String LINE_FEED = "\r\n";

private static String multipartBoundary;
private static char[] MULTIPART_CHARS =
("-_1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ").toCharArray();

/**
* 发送POST请求
*
* @param purl HTTP请求URL
* @param paramMap 需要携带的参数Map
*/
public static String post(String purl, Map<String, String> paramMap) throws Exception {
return post(purl, null, paramMap, null);
}

/**
* 发送POST请求
*
* @param purl HTTP请求URL
* @param headerMap 需要携带的HTTP请求头信息
* @param paramMap 需要携带的参数Map
*/
public static String post(String purl, Map<String, String> headerMap, Map<String,
String> paramMap) throws Exception {
return post(purl, headerMap, paramMap, null);
}

/**
* 发送POST请求
*
* @param purl HTTP请求URL
* @param headerMap 需要携带的HTTP请求头信息
* @param paramMap 需要携带的参数Map
* @param fileMap 需要上传的文件
*/
public static String post(String purl, Map<String, String> headerMap, Map<String,
String> paramMap, Map<String, File> fileMap) throws Exception {
multipartBoundary = _generateMultipartBoundary();
return _doPost(purl, headerMap, paramMap, fileMap);
}

private static String _doPost(String purl, Map<String, String> headerMap, Map<String,
String> paramMap, Map<String, File> fileMap) throws Exception {
HttpURLConnection connection = null;
DataOutputStream dataOutStream = null;
try {
connection = _openPostConnection(purl);
dataOutStream = new DataOutputStream(connection.getOutputStream());

// 向HTTP请求添加头信息
_doAddHeaders(dataOutStream, headerMap);

// 添加Post请求参数
_doAddFormFields(dataOutStream, paramMap);

// 向HTTP请求添加上传文件部分
_doAddFilePart(dataOutStream, fileMap);

dataOutStream.writeBytes(LINE_FEED);
dataOutStream.writeBytes("--" + multipartBoundary);
dataOutStream.writeBytes(LINE_FEED);
dataOutStream.close();

return _doFetchResponse(connection);
} finally {
if (connection != null) connection.disconnect();
try {
if (dataOutStream != null) dataOutStream.close();
} catch (Exception ignored) {
}
}
}

/**
* 生成HTTP协议中的边界字符串
*
* @return 边界字符串
*/
private static String _generateMultipartBoundary() {
Random rand = new Random();
char[] chars = new char[rand.nextInt(9) + 12]; // 随机长度(12 - 20个字符)
for (int i = 0; i < chars.length; i++) {
chars[i] = MULTIPART_CHARS[rand.nextInt(MULTIPART_CHARS.length)];
}
return "===AndroidFormBoundary" + new String(chars);
}

private static HttpURLConnection _openPostConnection(String purl) throws IOException {
URL url = new URL(purl);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setDoInput(true);
connection.setUseCaches(false);
connection.setDoOutput(true);
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=" +
multipartBoundary);
connection.setRequestProperty("User-Agent", "Android Client Agent");

return connection;
}

private static void _doAddHeaders(DataOutputStream oStream, Map<String,
String> headerMap) throws IOException {
if (headerMap == null || headerMap.isEmpty()) return;

for (Map.Entry<String, String> entry : headerMap.entrySet()) {
oStream.writeBytes(entry.getKey() + ":" + entry.getValue());
oStream.writeBytes(LINE_FEED);
}
}

/**
* 向HTTP报文中添加Form表单域参数
*
* @param oStream HTTP输出流
* @param paramMap 参数Map
* @throws IOException
*/
private static void _doAddFormFields(DataOutputStream oStream, Map<String,
String> paramMap) throws IOException {
if (paramMap == null || paramMap.isEmpty()) return;

for (Map.Entry<String, String> entry : paramMap.entrySet()) {
oStream.writeBytes("--" + multipartBoundary);
oStream.writeBytes(LINE_FEED);

oStream.writeBytes("Content-Disposition: form-data; name=\"" + entry.getKey() + "\"");
oStream.writeBytes(LINE_FEED);

oStream.writeBytes(LINE_FEED);
oStream.writeBytes(URLEncoder.encode(entry.getValue(), CHARSET_ENCODING));
oStream.writeBytes(LINE_FEED);
}
}

/**
* 向HTTP请求添加上传文件部分
*
* @param oStream 由HTTPURLConnection获取的输出流
* @param fileMap 文件Map, key为文件域名, value为要上传的文件
*/
private static void _doAddFilePart(DataOutputStream oStream, Map<String,
File> fileMap) throws IOException {
if (fileMap == null || fileMap.isEmpty()) return;

for (Map.Entry<String, File> fileEntry : fileMap.entrySet()) {
String fileName = fileEntry.getValue().getName();

oStream.writeBytes("--" + multipartBoundary);
oStream.writeBytes(LINE_FEED);

oStream.writeBytes("Content-Disposition: form-data; name=\"" + fileEntry.getKey() +
"\"; filename=\"" + fileName + "\"");
oStream.writeBytes(LINE_FEED);

oStream.writeBytes("Content-Type: " + URLConnection.guessContentTypeFromName(fileName));
oStream.writeBytes(LINE_FEED);
oStream.writeBytes(LINE_FEED);

InputStream iStream = null;
try {
iStream = new FileInputStream(fileEntry.getValue());
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = iStream.read(buffer)) != -1) {
oStream.write(buffer, 0, bytesRead);
}

iStream.close();
oStream.writeBytes(LINE_FEED);
oStream.flush();
} catch (IOException ignored) {
} finally {
try {
if (iStream != null) iStream.close();
} catch (Exception ignored) {
}
}
}
}

/**
* 获取HTTP响应
*
* @param connection HTTP请求连接
* @return 响应字符串
* @throws IOException
*/
private static String _doFetchResponse(HttpURLConnection connection) throws IOException {
int status = connection.getResponseCode();
if (status != HttpURLConnection.HTTP_OK) {
throw new IOException("服务器返回状态非正常响应状态.");
}
return new String(CommonUtil.streamToByteArray(connection.getInputStream()));
}
}

代码虽然比较长,但逻辑并不复杂,所有的逻辑都在_doPost私有方法中体现。

首先构造出域分隔符,由_generateMultipartBoundary方法完成,该分隔符以===AndroidFormBoundary为前缀(自定义),后面跟一个随机长度(12 - 20个字符)的随机字符串,在输出分隔符时还会在前面加上两个短线--(见前面的分析);该字符串可以随意定义,但不能过于简单,确保整个分隔符不会在文件或表单项的内容中出现。

接着创建HttpURLConnection实例并设置属性,在_openPostConnection方法中完成;此方法便有设置Content-Type属性的代码,并使用了第一步生成的Form表单分隔符。

第三步添加HTTP请求头信息,每行一条,本步骤比较简单。

第四步是添加普通表单域,针对第一个表单域,先输出multipartBoundary(注意前面需要输出两个短线);换行后接着输出Content-Disposition属性,其值参考上面浏览器的HTTP请求截图;再次换行后输出表单域的值。

第五步是添加需要上传的文件,针对每一个文件域,先输出multipartBoundary(注意前面需要输出两个短线);换行后接着输出Content-Disposition属性,其值参考上面浏览器的HTTP请求截图;再次换行后输出Content-Type属性值,同样参考截图,此处用到了URLConnection的guessContentTypeFromName方法根据文件名来判断文件类型;连续两次换行后开始输出文件数据,仅仅是文件IO操作比较简单,最后还要输出换行符,此时一个文件域输出结束。

在经过上面输出后,再次换行,最后还要输出一个multipartBoundary(注意前面需要输出两个短线);最后换行后将输出流关闭。

最后获取并返回HTTP响应数据,整个流程结束。


参考资料


以下是我在学习过程的参考资料(部分),在此表示感谢!