使用HttpURLConnection上传文件

18 篇文章 0 订阅


前言


最近在做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来实现我的需求,但我对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="提交" οnclick="onSubmitClick">
	</form>
</body>

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

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

页面截图如下:


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



根据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响应数据,整个流程结束。


参考资料


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



评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值