理解HTTP协议中的multipart/form-data

简介: 之前在写一个通用HTTP组件的时候遇到过媒体(Media)类型multipart/form-data的封装问题,这篇文章主要简单介绍一下HTTP协议中媒体类型multipart/form-data的定义、应用和简单实现。

前提



之前在写一个通用HTTP组件的时候遇到过媒体(Media)类型multipart/form-data的封装问题,这篇文章主要简单介绍一下HTTP协议中媒体类型multipart/form-data的定义、应用和简单实现。


multipart/form-data的定义



媒体类型multipart/form-data遵循multipart MIME数据流定义(该定义可以参考Section 5.1 - RFC2046),大概含义就是:媒体类型multipart/form-data的数据体由多个部分组成,这些部分由一个固定边界值(Boundary)分隔。


multipart/form-data请求体布局


multipart/form-data请求体的布局如下:


# 请求头 - 这个是必须的,需要指定Content-Type为multipart/form-data,指定唯一边界值
Content-Type: multipart/form-data; boundary=${Boundary}
# 请求体
--${Boundary}
Content-Disposition: form-data; name="name of file"
Content-Type: application/octet-stream
bytes of file
--${Boundary}
Content-Disposition: form-data; name="name of pdf"; filename="pdf-file.pdf"
Content-Type: application/octet-stream
bytes of pdf file
--${Boundary}
Content-Disposition: form-data; name="key"
Content-Type: text/plain;charset=UTF-8
text encoded in UTF-8
--${Boundary}--
复制代码


媒体类型multipart/form-data相对于其他媒体类型如application/x-www-form-urlencoded等来说,最明显的不同点是:


  • 请求头的Content-Type属性除了指定为multipart/form-data,还需要定义boundary参数
  • 请求体中的请求行数据是由多部分组成,boundary参数的值模式--${Boundary}用于分隔每个独立的分部
  • 每个部分必须存在请求头Content-Disposition: form-data; name="${PART_NAME}";,这里的${PART_NAME}需要进行URL编码,另外filename字段可以使用,用于表示文件的名称,但是其约束性比name属性低(因为并不确认本地文件是否可用或者是否有异议)
  • 每个部分可以单独定义Content-Type和该部分的数据体
  • 请求体以boundary参数的值模式--${Boundary}--作为结束标志


{% note warning flat %} RFC7578中提到两个multipart/form-data过期的使用方式,其一是Content-Transfer-Encoding请求头的使用,这里也不展开其使用方式,其二是请求体中单个表单属性传输多个二进制文件的方式建议换用multipart/mixed(一个"name"对应多个二进制文件的场景) {% endnote %}


特殊地:

  • 如果某个部分的内容为文本,其的Content-Typetext/plain,可指定对应的字符集,如Content-Type: text/plain;charset=UTF-8
  • 可以通过_charset_属性指定默认的字符集,用法如下:


Content-Disposition: form-data; name="_charset_"
UTF-8
--ABCDE--
Content-Disposition: form-data; name="field"
...text encoded in UTF-8...
ABCDE--
复制代码


Boundary参数取值规约


Boundary参数取值规约如下:

  • Boundary的值必须以英文中间双横杠--开头,这个--称为前导连字符
  • Boundary的值除了前导连字符以外的部分不能超过70个字符
  • Boundary的值不能包含HTTP协议或者URL禁用的特殊意义的字符,例如英文冒号:
  • 每个--${Boundary}之前默认强制必须为CRLF,如果某一个部分的文本类型请求体以CRLF结尾,那么在请求体的二级制格式上,必须显式存在两个CRLF,如果某一个部分的请求体不以CRLF结尾,可以只存在一个CRLF,这两种情况分别称为分隔符的显式类型和隐式类型,说的比较抽象,见下面的例子:


# 请求头
Content-type: multipart/data; boundary="--abcdefg"
--abcdefg
Content-Disposition: form-data; name="x"
Content-type: text/plain; charset=ascii
It does NOT end with a linebreak # <=== 这里没有CRLF,隐式类型
--abcdefg
Content-Disposition: form-data; name="y"
Content-type: text/plain; charset=ascii
It DOES end with a linebreak # <=== 这里有CRLF,显式类型
--abcdefg
## 直观看隐式类型的CRLF
It does NOT end with a linebreak CRLF --abcdefg
## 直观看显式类型的CRLF
It DOES end with a linebreak CRLF CRLF --abcdefg
复制代码


实现multipart/form-data媒体类型的POST请求



这里只针对低JDK版本的HttpURLConnection和高JDK版本内置的HttpClient编写multipart/form-data媒体类型的POST请求的HTTP客户端,其他如自定义Socket实现可以依照类似的思路完成。先引入org.springframework.boot:spring-boot-starter-web:2.6.0做一个简单的控制器方法:


@RestController
public class TestController {
    @PostMapping(path = "/test")
    public ResponseEntity<?> test(MultipartHttpServletRequest request) {
        return ResponseEntity.ok("ok");
    }
}
复制代码


Postman的模拟请求如下:

微信截图_20220513212622.png


后台控制器得到的请求参数如下:

微信截图_20220513212630.png


后面编写的客户端可以直接调用此接口进行调试。


封装请求体转换为字节容器的模块


这里的边界值全用显式实现,边界值直接用固定前缀加上UUID生成即可。简单实现过程中做了一些简化:

  • 只考虑提交文本表单数据和二进制(文件)表达数据
  • 基于上一点,每个部分都明确指定Content-Type这个请求头
  • 文本编码固定为UTF-8


编写一个MultipartWriter


public class MultipartWriter {
    private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
    private static final byte[] FIELD_SEP = ": ".getBytes(StandardCharsets.ISO_8859_1);
    private static final byte[] CR_LF = "\r\n".getBytes(StandardCharsets.ISO_8859_1);
    private static final String TWO_HYPHENS_TEXT = "--";
    private static final byte[] TWO_HYPHENS = TWO_HYPHENS_TEXT.getBytes(StandardCharsets.ISO_8859_1);
    private static final String CONTENT_DISPOSITION_KEY = "Content-Disposition";
    private static final String CONTENT_TYPE_KEY = "Content-Type";
    private static final String DEFAULT_CONTENT_TYPE = "multipart/form-data; boundary=";
    private static final String DEFAULT_BINARY_CONTENT_TYPE = "application/octet-stream";
    private static final String DEFAULT_TEXT_CONTENT_TYPE = "text/plain;charset=UTF-8";
    private static final String DEFAULT_CONTENT_DISPOSITION_VALUE = "form-data; name=\"%s\"";
    private static final String FILE_CONTENT_DISPOSITION_VALUE = "form-data; name=\"%s\"; filename=\"%s\"";
    private final Map<String, String> headers = new HashMap<>(8);
    private final List<AbstractMultipartPart> parts = new ArrayList<>();
    private final String boundary;
    private MultipartWriter(String boundary) {
        this.boundary = Objects.isNull(boundary) ? TWO_HYPHENS_TEXT +
                UUID.randomUUID().toString().replace("-", "") : boundary;
        this.headers.put(CONTENT_TYPE_KEY, DEFAULT_CONTENT_TYPE + this.boundary);
    }
    public static MultipartWriter newMultipartWriter(String boundary) {
        return new MultipartWriter(boundary);
    }
    public static MultipartWriter newMultipartWriter() {
        return new MultipartWriter(null);
    }
    public MultipartWriter addHeader(String key, String value) {
        if (!CONTENT_TYPE_KEY.equalsIgnoreCase(key)) {
            headers.put(key, value);
        }
        return this;
    }
    public MultipartWriter addTextPart(String name, String text) {
        parts.add(new TextPart(String.format(DEFAULT_CONTENT_DISPOSITION_VALUE, name), DEFAULT_TEXT_CONTENT_TYPE, this.boundary, text));
        return this;
    }
    public MultipartWriter addBinaryPart(String name, byte[] bytes) {
        parts.add(new BinaryPart(String.format(DEFAULT_CONTENT_DISPOSITION_VALUE, name), DEFAULT_BINARY_CONTENT_TYPE, this.boundary, bytes));
        return this;
    }
    public MultipartWriter addFilePart(String name, File file) {
        parts.add(new FilePart(String.format(FILE_CONTENT_DISPOSITION_VALUE, name, file.getName()), DEFAULT_BINARY_CONTENT_TYPE, this.boundary, file));
        return this;
    }
    private static void writeHeader(String key, String value, OutputStream out) throws IOException {
        writeBytes(key, out);
        writeBytes(FIELD_SEP, out);
        writeBytes(value, out);
        writeBytes(CR_LF, out);
    }
    private static void writeBytes(String text, OutputStream out) throws IOException {
        out.write(text.getBytes(DEFAULT_CHARSET));
    }
    private static void writeBytes(byte[] bytes, OutputStream out) throws IOException {
        out.write(bytes);
    }
    interface MultipartPart {
        void writeBody(OutputStream os) throws IOException;
    }
    @RequiredArgsConstructor
    public static abstract class AbstractMultipartPart implements MultipartPart {
        protected final String contentDispositionValue;
        protected final String contentTypeValue;
        protected final String boundary;
        protected String getContentDispositionValue() {
            return contentDispositionValue;
        }
        protected String getContentTypeValue() {
            return contentTypeValue;
        }
        protected String getBoundary() {
            return boundary;
        }
        public final void write(OutputStream out) throws IOException {
            writeBytes(TWO_HYPHENS, out);
            writeBytes(getBoundary(), out);
            writeBytes(CR_LF, out);
            writeHeader(CONTENT_DISPOSITION_KEY, getContentDispositionValue(), out);
            writeHeader(CONTENT_TYPE_KEY, getContentTypeValue(), out);
            writeBytes(CR_LF, out);
            writeBody(out);
            writeBytes(CR_LF, out);
        }
    }
    public static class TextPart extends AbstractMultipartPart {
        private final String text;
        public TextPart(String contentDispositionValue,
                        String contentTypeValue,
                        String boundary,
                        String text) {
            super(contentDispositionValue, contentTypeValue, boundary);
            this.text = text;
        }
        @Override
        public void writeBody(OutputStream os) throws IOException {
            os.write(text.getBytes(DEFAULT_CHARSET));
        }
        @Override
        protected String getContentDispositionValue() {
            return contentDispositionValue;
        }
        @Override
        protected String getContentTypeValue() {
            return contentTypeValue;
        }
    }
    public static class BinaryPart extends AbstractMultipartPart {
        private final byte[] content;
        public BinaryPart(String contentDispositionValue,
                          String contentTypeValue,
                          String boundary,
                          byte[] content) {
            super(contentDispositionValue, contentTypeValue, boundary);
            this.content = content;
        }
        @Override
        public void writeBody(OutputStream out) throws IOException {
            out.write(content);
        }
    }
    public static class FilePart extends AbstractMultipartPart {
        private final File file;
        public FilePart(String contentDispositionValue,
                        String contentTypeValue,
                        String boundary,
                        File file) {
            super(contentDispositionValue, contentTypeValue, boundary);
            this.file = file;
        }
        @Override
        public void writeBody(OutputStream out) throws IOException {
            try (InputStream in = new FileInputStream(file)) {
                final byte[] buffer = new byte[4096];
                int l;
                while ((l = in.read(buffer)) != -1) {
                    out.write(buffer, 0, l);
                }
                out.flush();
            }
        }
    }
    public void forEachHeader(BiConsumer<String, String> consumer) {
        headers.forEach(consumer);
    }
    public void write(OutputStream out) throws IOException {
        if (!parts.isEmpty()) {
            for (AbstractMultipartPart part : parts) {
                part.write(out);
            }
        }
        writeBytes(TWO_HYPHENS, out);
        writeBytes(this.boundary, out);
        writeBytes(TWO_HYPHENS, out);
        writeBytes(CR_LF, out);
    }
}
复制代码


这个类已经封装好三种不同类型的部分请求体实现,forEachHeader()方法用于遍历请求头,而最终的write()方法用于把请求体写入到OutputStream中。


HttpURLConnection实现


实现代码如下(只做最简实现,没有考虑容错和异常处理):


public class HttpURLConnectionApp {
    private static final String URL = "http://localhost:9099/test";
    public static void main(String[] args) throws Exception {
        MultipartWriter writer = MultipartWriter.newMultipartWriter();
        writer.addTextPart("name", "throwable")
                .addTextPart("domain", "vlts.cn")
                .addFilePart("ico", new File("I:\\doge_favicon.ico"));
        DataOutputStream requestPrinter = new DataOutputStream(System.out);
        writer.write(requestPrinter);
        HttpURLConnection connection = (HttpURLConnection) new java.net.URL(URL).openConnection();
        connection.setRequestMethod("POST");
        connection.addRequestProperty("Connection", "Keep-Alive");
        // 设置请求头
        writer.forEachHeader(connection::addRequestProperty);
        connection.setDoInput(true);
        connection.setDoOutput(true);
        connection.setConnectTimeout(10000);
        connection.setReadTimeout(10000);
        DataOutputStream out = new DataOutputStream(connection.getOutputStream());
        // 设置请求体
        writer.write(out);
        StringBuilder builder = new StringBuilder();
        BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8));
        String line;
        while (Objects.nonNull(line = reader.readLine())) {
            builder.append(line);
        }
        int responseCode = connection.getResponseCode();
        reader.close();
        out.close();
        connection.disconnect();
        System.out.printf("响应码:%d,响应内容:%s\n", responseCode, builder);
    }
}
复制代码


执行响应结果:


响应码:200,响应内容:ok
复制代码


可以尝试加入两行代码打印请求体:


MultipartWriter writer = MultipartWriter.newMultipartWriter();
writer.addTextPart("name", "throwable")
        .addTextPart("domain", "vlts.cn")
        .addFilePart("ico", new File("I:\\doge_favicon.ico"));
DataOutputStream requestPrinter = new DataOutputStream(System.out);
writer.write(requestPrinter);
复制代码


控制台输出如下;

微信截图_20220513212643.png


JDK内置HttpClient实现


JDK11+内置了HTTP客户端实现,具体入口是java.net.http.HttpClient,实现编码如下:


public class HttpClientApp {
    private static final String URL = "http://localhost:9099/test";
    public static void main(String[] args) throws Exception {
        HttpClient httpClient = HttpClient.newBuilder()
                .connectTimeout(Duration.of(10, ChronoUnit.SECONDS))
                .build();
        MultipartWriter writer = MultipartWriter.newMultipartWriter();
        writer.addTextPart("name", "throwable")
                .addTextPart("domain", "vlts.cn")
                .addFilePart("ico", new File("I:\\doge_favicon.ico"));
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        writer.write(out);
        HttpRequest.Builder requestBuilder = HttpRequest.newBuilder();
        writer.forEachHeader(requestBuilder::header);
        HttpRequest request = requestBuilder.uri(URI.create(URL))
                .method("POST", HttpRequest.BodyPublishers.ofByteArray(out.toByteArray()))
                .build();
        HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
        System.out.printf("响应码:%d,响应内容:%s\n", response.statusCode(), response.body());
    }
}
复制代码


内置的HTTP组件几乎都是使用Reactive编程模型,使用的API都是相对底层,灵活性比较高但是易用性不高。


小结



媒体类型multipart/form-data常用于POST方法下的HTTP请求,至于作为HTTP响应的场景相对少见。


参考资料:


(本文完 c-1-d e-a-20211226 写完后发现了Boundary前导多加了中横杠,不过看了Postman的请求也多加了很多个,懒得改)


相关文章
|
14天前
|
监控 安全 搜索推荐
设置 HTTPS 协议以确保数据传输的安全性
设置 HTTPS 协议以确保数据传输的安全性
|
2天前
|
JavaScript 安全 Java
谈谈UDP、HTTP、SSL、TLS协议在java中的实际应用
下面我将详细介绍UDP、HTTP、SSL、TLS协议及其工作原理,并提供Java代码示例(由于Deno是一个基于Node.js的运行时,Java代码无法直接在Deno中运行,但可以通过理解Java示例来类比Deno中的实现)。
13 1
|
1月前
HAProxy的高级配置选项-配置haproxy支持https协议及服务器动态上下线
文章介绍了如何配置HAProxy以支持HTTPS协议和实现服务器的动态上下线。
90 8
HAProxy的高级配置选项-配置haproxy支持https协议及服务器动态上下线
|
12天前
|
安全 网络协议 网络安全
在实现HTTPS时,有哪些常见的安全协议
在实现HTTPS时,有哪些常见的安全协议
|
12天前
|
前端开发 JavaScript 安全
深入解析 http 协议
HTTP(超文本传输协议)不仅用于传输文本,还支持图片、音频和视频等多种类型的数据。当前广泛使用的版本为 HTTP/1.1。HTTPS 可视为 HTTP 的安全增强版,主要区别在于添加了加密层。HTTP 请求和响应均遵循固定格式,包括请求行/状态行、请求/响应头、空行及消息主体。URL(统一资源定位符)用于标识网络上的资源,其格式包含协议、域名、路径等信息。此外,HTTP 报头提供了附加信息,帮助客户端和服务端更好地处理请求与响应。状态码则用于指示请求结果,如 200 表示成功,404 表示未找到,500 表示服务器内部错误等。
16 0
深入解析 http 协议
|
20天前
|
数据采集 JSON API
🎓Python网络请求新手指南:requests库带你轻松玩转HTTP协议
本文介绍Python网络编程中不可或缺的HTTP协议基础,并以requests库为例,详细讲解如何执行GET与POST请求、处理响应及自定义请求头等操作。通过简洁易懂的代码示例,帮助初学者快速掌握网络爬虫与API开发所需的关键技能。无论是安装配置还是会话管理,requests库均提供了强大而直观的接口,助力读者轻松应对各类网络编程任务。
73 3
|
21天前
|
机器学习/深度学习 JSON API
HTTP协议实战演练场:Python requests库助你成为网络数据抓取大师
在数据驱动的时代,网络数据抓取对于数据分析、机器学习等至关重要。HTTP协议作为互联网通信的基石,其重要性不言而喻。Python的`requests`库凭借简洁的API和强大的功能,成为网络数据抓取的利器。本文将通过实战演练展示如何使用`requests`库进行数据抓取,包括发送GET/POST请求、处理JSON响应及添加自定义请求头等。首先,请确保已安装`requests`库,可通过`pip install requests`进行安装。接下来,我们将逐一介绍如何利用`requests`库探索网络世界,助你成为数据抓取大师。在实践过程中,务必遵守相关法律法规和网站使用条款,做到技术与道德并重。
32 2
|
22天前
|
数据采集 存储 JSON
从零到一构建网络爬虫帝国:HTTP协议+Python requests库深度解析
在网络数据的海洋中,网络爬虫遵循HTTP协议,穿梭于互联网各处,收集宝贵信息。本文将从零开始,使用Python的requests库,深入解析HTTP协议,助你构建自己的网络爬虫帝国。首先介绍HTTP协议基础,包括请求与响应结构;然后详细介绍requests库的安装与使用,演示如何发送GET和POST请求并处理响应;最后概述爬虫构建流程及挑战,帮助你逐步掌握核心技术,畅游数据海洋。
51 3
|
28天前
|
数据采集 网络协议 API
HTTP协议大揭秘!Python requests库实战,让网络请求变得简单高效
【9月更文挑战第13天】在数字化时代,互联网成为信息传输的核心平台,HTTP协议作为基石,定义了客户端与服务器间的数据传输规则。直接处理HTTP请求复杂繁琐,但Python的`requests`库提供了一个简洁强大的接口,简化了这一过程。HTTP协议采用请求与响应模式,无状态且结构化设计,使其能灵活处理各种数据交换。
51 8
|
27天前
|
缓存 网络协议 UED
HTTP协议介绍
HTTP协议的
30 5

热门文章

最新文章