之前在写一个通用HTTP
组件的时候遇到过媒体(Media
)类型multipart/form-data
的封装问题,这篇文章主要简单介绍一下HTTP
协议中媒体类型multipart/form-data
的定义、应用和简单实现。
媒体类型multipart/form-data
遵循multipart MIME
数据流定义(该定义可以参考Section 5.1 - RFC2046),大概含义就是:媒体类型multipart/form-data
的数据体由多个部分组成,这些部分由一个固定边界值(Boundary
)分隔。
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-Type
为text/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
的值除了前导连字符以外的部分不能超过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
这里只针对低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
的模拟请求如下:
后台控制器得到的请求参数如下:
后面编写的客户端可以直接调用此接口进行调试。
这里的边界值全用显式实现,边界值直接用固定前缀加上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
中。
实现代码如下(只做最简实现,没有考虑容错和异常处理):
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);
控制台输出如下;
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的请求也多加了很多个,懒得改)