multipart/form-data与httpclient文件上传

写在前面:本文讨论的内容都是基于java相关技术栈。

文件上传无论是在传统的基于html的web系统开发,还是目前主流的移动app开发,都是一个比较常见的功能需求。例如:web oa系统,可能会涉及到各种文档、合同、档案文件的上传。移动app的开发可能会涉及到用户头像、图片动态、语音动态、视频动态等多媒体文件的上传。但是传统的html web文件上传和移动app开发中的文件上传的技术实现还是有很大的差异的。

本文重点讨论的是httpclient方式进行文件上传,但并不是只贴实现代码,而是希望通过循序渐进的方式能够让大家理解为什么httpclient文件上传要那么写。

所以本文的表达脉络是先通过html web的文件上传来了解http协议,了解了文件上传相关的http协议后,再去理解httpclient文件上传的代码实现,以及前端的httpclient文件上传代码与后端的springmvc服务代码如何配合。

html web文件上传

在进行java web系统开发的时候,我们要实现文件上传,最简单的方式应该就是通过html的form表单提交,如果要通过form表单进行文件上传的话,有两个要点:
1、 form表单中包含input file元素

<input type="file" name="filename"/>

2、将form表单的enctype属性设置为multipart/form-data。

<form action="http://xxxxx/xxxx" method="post" enctype="multipart/form-data">

下面来看下一段完整的html文件上传代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>文件上传demo</title>
</head>
<body>
<form action="http://192.168.111.11/api/file/upload" method="post"  enctype="multipart/form-data">
    <div>
        <textarea name="moment" placeholder="说点什么..."></textarea>
    </div>
    <div>
        <input type="file" name="file0"/>
    </div>
    <div>
        <input type="file" name="file1"/>
    </div>
    <div>
        <input type="file" name="file2"/>
    </div>
    <div>
        <input type="submit"/>
    </div>
</form>
</body>
</html>

运行后的效果如下:
在这里插入图片描述

表单内容并不多,模拟一个类似发朋友圈的小功能。可以输入文字,可以上传图片(图片没后做强校验,也可以上传文本文件,主要为了观察报文,最多上传三个文件)。点击提交之后,就会请求一个url,这url可以随便写一个,我们主要是为了观察请求报文。

录入了文本、选择了一个文本文件、一个图片文件。
点击提交按钮后,用charles抓取了该提交的http请求报文如下:
在这里插入图片描述

http协议报文解析

如上图所示,我们主要来分析报文的主要框架和相关属性做一些说明,其余的很多细节属性本文不做展开。

请求报文

请求报文由3部分组成

  • 请求行
    • "post"是请求方法。GET和POST是最常见的HTTP方法,除此以外还包括DELETE、HEAD、OPTIONS、PUT、TRACE。不过,当前的大多数浏览器只支持GET和POST
    • post后面紧跟的是请求路径。
    • "HTTP/1.1"是http协议的版本。
  • 请求头:请求头里会包含很多属性,例如:
    • host指明了请求的主机地址和端口号。
    • Content-length指明了发送的报文长度。
    • Content-type指定了请求报文体的MIME类型。(这是我们重点关注的部分)
  • 请求体:这里面就是发送的具体内容了。不同的Content-type对应的请求提的内容格式也是不同的。
    • 如果Content-type为text/plain,那么请求体的内容就应该是纯文本的字符串。
    • 如果Content-type为application/json,那么请求体的内容就应该是json格式的字符串。
    • 如果Content-type为multipart/form-data,那么请求体的内容就是另外一中相对较复杂的结构化的格式。(下面介绍)

请求头和请求体这两部分内容之间是通过一个空行来分隔的

响应报文

请求报文由3部分组成

  • 状态行
    • "HTTP/1.1"是http协议的版本。
    • "200"是http响应状态码。
    • "OK"是状态码的描述。
  • 响应头:有些header属性是响应头和请求头都可以使用的:
    • Content-type指定了响应报文体的MIME类型。
  • 响应体:这里面就是响应的具体内容了。不同的Content-type对应的响应体的内容格式也应该是不同的(不绝对)。
    • 如果Content-type为text/html,那么响应体的内容就应该是html页面。
    • 如果Content-type为application/json,那么响应体的内容就应该是json格式的字符串。
    • 如果Content-type为octet-stream,那么响应体的内容就应该是二进制流。(常用于文件下载)

响应头和响应体这两部分内容之间也是通过一个空行来分隔的

为什么请求头和请求体、响应头和响应体之间用空行来分隔?
主要是因为http协议的解析规则是以\r\n来区分各个报文部分的。
以请求报文为例:请求行的内容只有一行,以\r\n结尾。服务端解析时只要读取到第一个\r\n,就可以认为请求行已经读取完成了,再往下读取的行就应该是header的属性了,header会有很多属性,每一个属性都是一行,也同样是以\r\n结尾。那读取到什么时候才能读取到请求体呢,也就是连续读取到2个\r\n的时候,其中后一个\r\n也就是那个用来分隔的空行,这时就意味着请求头已经读取完了,往下再读已经是请求体了。
响应报文同理,因为响应报文的状态行也只有一行。响应头也会有多行。

文件上传的请求报文特殊性

上一部分的内容主要是为了简单的介绍一下http协议,在文件上传功能上,我们重点关注的是请求报文中的请求头里的Content-type属性和请求体里的内容。下面我们把这连部分内容摘出来。

form表单文件上传时的Content-type

Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryMvt2bmLJuxgWXCUl

文件上传时的请求体内容

------WebKitFormBoundaryMvt2bmLJuxgWXCUl
Content-Disposition: form-data; name="moment"

心情不错的
------WebKitFormBoundaryMvt2bmLJuxgWXCUl
Content-Disposition: form-data; name="file0"; filename="t1.txt"
Content-Type: text/plain

this is a txt file
------WebKitFormBoundaryMvt2bmLJuxgWXCUl
Content-Disposition: form-data; name="file1"; filename="zxy.jpg"
Content-Type: image/jpeg

这里是一大堆zxy.jpg图片对应的二进制字节
------WebKitFormBoundaryMvt2bmLJuxgWXCUl
Content-Disposition: form-data; name="file2"; filename=""
Content-Type: application/octet-stream


------WebKitFormBoundaryMvt2bmLJuxgWXCUl--

关于multipart/form-data请求报文的特殊性

因为请求报文中请求头里的Content-Type为multipart/form-data,multipart直接翻译的话叫做"多部分",不用太纠结这个叫法,形象点理解的话,可以这样认为。这种请求的http请求,他的请求体不是单一一个内容,而是也是有固定结构的多各部分组成的。每个部分也可能是不同的表现类型,比如说,第一部分是纯文本,第二部分是图片文件,第三部分是json串。这有点像是大报文里嵌套了多个小报文一样,但是小报文里面没有请求头,也没有那么多的header属性。但是还应该提供一个boundary属性来作为多个小报文的分隔符。画个图理解一下。
在这里插入图片描述

boundary

对照上图我们来理解下,boundary翻译为中文叫做边界、界限、分界限的意思。基于浏览器进行提交时,boundary如果不指定浏览器会自动生成一个字符串,multipart中前面加两个中横线也就是以"–{boundary}“来分隔每个独立的部分。前后分别加两个中横线也就是以”–{boundary}–"来标识整个报文的结束。

Content-Disposition

Content-Disposition是http协议中header中规定的一个属性。Disposition中文意思为:布置、安排。Content-Disposition也就可以理解为,他定义了报文的内容要如何展现或者处理。他在http协议中有两种用法:
1、用在响应报文的header中事,用来指示响应的内容该以何种形式展示,是以内联(inline)的形式(即网页或者页面的一部分),还是以附件(attachment)的形式下载并保存到本地。例如:

Content-Disposition: inline
Content-Disposition: attachment
Content-Disposition: attachment; filename=“filename.jpg”

2、用在multipart的报文体中时(既可以用于请求报文、也可以用于响应报文),他的作用就是用来定义每个小报文的属性值。所谓属性,可以粗略认为只有两个:name、filename。
用在multipart/form-data中时,他前面的写法固定为:
Content-Disposition: form-data;

后面紧跟属性定义。例如:

Content-Disposition: form-data; name=“file1”; filename=“zxy.jpg”

小结

到此,我们针对文件上传的报文的格式、以及一些必要属性做了解释。这一部分内容如果能够理解,那么再看httpclient文件上传的代码就都能对号入座了。

httpclient文件上传

直接贴代码

public class HttpClientUtil {

	/**
	 * 文件上传
	 *
	 * @param fileServer 接收请求的远程服务地址 (例如:http://192.168.111.11/api/file/upload)
	 * @param filesMap   要上传的多个文件的k-v信息,k为要给文件定义的name属性值,v就是File类型的本地文件集合。可以同一个name k对应多个文件
	 * @param param      附加的参数信息(例如:文件要关联的某个主体id,备注等。不同业务自己斟酌)
	 * @param header     自定义的header属性
	 * @return 返回服务端的响应报文
	 */
	public static String postFile(String fileServer, Map<String, List<File>> filesMap, Map<String, String> param, Map<String, String> header) {
		CloseableHttpResponse response = null;
		CloseableHttpClient httpClient = HttpClients.createDefault();
		HttpPost postRequest = new HttpPost(fileServer);
		// 如果自定义了header属性,则将自定义的header属性填充到请求报文的header中
		if (header != null) {
			for (Map.Entry<String, String> entry : header.entrySet()) {
				postRequest.addHeader(entry.getKey(), entry.getValue());
			}
		}

		// 创建一个Multipart构造器
		MultipartEntityBuilder builder = MultipartEntityBuilder.create();
		// 设置为浏览器兼容模式(采用模拟浏览器提交的方式)
		builder.setMode(HttpMultipartMode.BROWSER_COMPATIBLE);
		/*
		 设置字符编码为utf-8,这个设置只影响的报文体中小报文的header内容的编码,不能影响小报文体的编码,这个地方如果不设置,默认采用ASCII方式编码。
		 当然如果这个地方不设置,也可以在调用端手动设置header的Content-type属性为"multipart/form-data;charset=utf-8",指定编码。
		*/
		builder.setCharset(Charset.forName("utf-8"));

		// 遍历文件map
		for (Map.Entry<String, List<File>> entry : filesMap.entrySet()) {
			// 将要上传的每个文件都作为multipart的一个part
			for (File file : entry.getValue()) {
				// 构造一个fileBody ,就相当于构造了一个文件小报文
				FileBody fileBody = new FileBody(file);
				// 把上面的小报文添加到multipart中
				builder.addPart(entry.getKey(), fileBody);
			}
		}

		// 如果附加参数不为空,则将每个参数都作为multipart的一个part
		if (param != null) {
			for (Map.Entry<String, String> entry : param.entrySet()) {
				/*
				 构造一个stringBody,就相当于构造了一个纯文本小报文.
				 并且显示设置了content-type为text/plain;charset=utf-8。让参数值采用utf-8格式编码.
				*/
				StringBody stringBody = new StringBody(entry.getValue(), ContentType.TEXT_PLAIN.withCharset("utf-8"));
				// 把上面的小报文添加到multipart中
				builder.addPart(entry.getKey(), stringBody);

				// 也可以采用下面这行写法
				// builder.addTextBody(entry.getKey(), entry.getValue(), ContentType.TEXT_PLAIN.withCharset("utf-8"));

				// 不建议下面这行写法,大概率会有乱码问题。这种方式将会以ISO_8859_1格式对value进行编码
				// builder.addTextBody(entry.getKey(), entry.getValue());

			}
		}

		// 将构造出http请求报文体实体对象 设置给HttpPost实例,从而构造了一个完整的报文
		postRequest.setEntity(builder.build());
		return execute(httpClient, postRequest, response);
	}

	/**
	 * 该方法是为了抽象与所有post请求的公共逻辑
	 * postFile 方法用于上传文件
	 * postJson 方法用于json请求 (为节省篇幅,本文略去该方法的实现)
	 * postParam 方法用于参数键值对请求 (为节省篇幅,本文略去该方法的实现)
	 */
	private static String execute(CloseableHttpClient client, HttpPost post, CloseableHttpResponse response) {
		try {
			response = client.execute(post);
			HttpEntity entity = response.getEntity();
			if (entity != null) {
				return EntityUtils.toString(entity);
			}
		} catch (IOException e) {
			throw new RuntimeException(e.getMessage(), e);
		} finally {
			try {
				if (response != null) {
					response.close();
				}
			} catch (IOException e) {
				throw new RuntimeException(e.getMessage(), e);
			}
		}
		return null;
	}


	public static void main(String[] args) {
		String fileServer = "http://192.168.111.11/api/file/upload";
		Map<String, String> headers = new HashMap<>();
		Map<String, String> param = new HashMap<>();
		param.put("title", "非常高兴的一天");
		param.put("moment", "来一起看看这些漂亮的风景,水平有限,但自我感觉还是拍的不错的");
		
		/*
		 重点注意这里,filesMap的构造要和后端的文件上传服务接口相配合
		 */
		Map<String, List<File>> filesMap = new HashMap<>();
		File file1 = new File("/home/xxx/1.jpg");
		File file2 = new File("/home/xxx/2.jpg");
		filesMap.put("imgFiles", Arrays.asList(file1, file2));
		File file3 = new File("/home/xxx/3.txt");
		filesMap.put("txtFiles", Arrays.asList(file3));
		/*
		 以上的这种构造,后端的controller层,应该像如下这样接收:
		 @RequestMapping(value = "upload", method = RequestMethod.POST, produces = "application/json;charset=utf-8")
    	 @ResponseBody
		 public String upload(HttpServletRequest request, HttpServletResponse response,
						 @RequestParam(value = "title") String title,
						 @RequestParam(value = "moment") String moment,
						 @RequestParam(value = "imgFiles", required = false) MultipartFile[] imgFiles,
						 @RequestParam(value = "txtFiles", required = false) MultipartFile[] txtFiles);
						 
						 
		 
		如果是以下这种聚合
		 				 				 
		File file1 = new File("/home/xxx/1.jpg");
		File file2 = new File("/home/xxx/2.jpg");
		File file3 = new File("/home/xxx/3.txt");
		filesMap.put("files", Arrays.asList(file1,file2,file3));
		
		按照这种方式组织的文件,后端的controller层就应该像下面这样接收
		@RequestMapping(value = "upload", method = RequestMethod.POST, produces = "application/json;charset=utf-8")
    	@ResponseBody
		public String upload(HttpServletRequest request, HttpServletResponse response,
						 @RequestParam(value = "title") String title,
						 @RequestParam(value = "moment") String moment,
						 @RequestParam(value = "files", required = false) MultipartFile[] files);
		 */

		HttpClientUtil.postFile(fileServer, filesMap, param, headers);
	}
	
}

总结

因为有了上面的对于multipart/form-data的http协议的分析,再来看httpclient的文件上传代码就很清晰明了了。在不了解底层http协议的情况下,虽然可以通过网络找到解决方案的代码,但是也只是做了一回粘贴侠而已。可能需求稍有变动、或者业务上有一点个性化的需求,就不知道如何去改造手里的代码了。所以在解决了功能需求后,别忘了回过头来再深入的研究下自己粘贴的每一个解决方案。


版权声明:本文为weixin_42340670原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
THE END
< <上一篇
下一篇>>