multipartform-data与httpclient文件上传

    科技2026-04-09  8

    写在前面:本文讨论的内容都是基于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和POSTpost后面紧跟的是请求路径。"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协议的情况下,虽然可以通过网络找到解决方案的代码,但是也只是做了一回粘贴侠而已。可能需求稍有变动、或者业务上有一点个性化的需求,就不知道如何去改造手里的代码了。所以在解决了功能需求后,别忘了回过头来再深入的研究下自己粘贴的每一个解决方案。

    Processed: 0.013, SQL: 9