1. 引言

本教程将探讨处理Spring中multipart HTTP请求时常见的"No Multipart Boundary Was Found"错误。我们将学习如何正确配置这类请求,避免踩坑。

2. 理解Multipart请求

首先明确我们要处理的请求类型。multipart请求是一种在单个HTTP消息体中传输多种不同类型数据的请求。其有效载荷被分割成多个部分,每个部分可能代表不同的文件或数据块。

这类请求常用于文件上传、邮件传输、媒体流传输或HTML表单提交,通过Content-Type头标识请求数据类型。下面我们详细说明需要设置的参数值。

2.1. 顶层类型

顶层类型指定我们发送内容的主要类别。当在单个HTTP请求中提交多种数据类型时,必须将值设置为multipart

⚠️ 注意:仅发送单个文件时,应使用Content-Type的离散类型(如image/jpeg),而非multipart

2.2. 子类型

除顶层类型外,Content-Type值还包含必填的子类型。子类型提供数据格式的额外信息。

不同RFC文档定义了多种multipart子类型,常见包括:

  • multipart/mixed
  • multipart/alternative
  • multipart/related
  • multipart/form-data

由于我们在单个请求中封装多种数据类型,需要额外参数分隔multipart消息的不同部分:边界参数(boundary)。

2.3. 边界参数

边界参数是multipart Content-Type的必填值,用于指定封装边界。

根据RFC 1341定义,封装边界是由两个连字符(--)后跟Content-Type头中的boundary值组成的分隔行,用于分隔HTTP消息中的各个部分。

看实际例子。以下浏览器请求包含两个body部分,典型的Content-Type头如下:

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

每个body部分由封装边界分隔,且每个部分包含:

  1. 头部区域
  2. 空行
  3. 内容本身
------WebKitFormBoundaryG8vpVejPYc8E16By
Content-Disposition: form-data; name="file"; filename="import.csv"
Content-Type: text/csv

content-of-the-csv-file
------WebKitFormBoundaryG8vpVejPYc8E16By
Content-Disposition: form-data; name="fileDescription"

Records
------WebKitFormBoundaryG8vpVejPYc8E16By--

最后,最后一个数据部分后是结束边界,末尾附加两个额外连字符。

3. 实战示例

现在我们创建一个简单示例复现"no multipart boundary was found"问题。

如前所述,所有multipart请求必须使用边界参数,因此可选择任意multipart子类型。为简单起见,使用multipart/form-data

首先创建一个接受两种数据类型(文件及其文本描述)的表单:

<form th:action="@{/files}" method="POST" enctype="multipart/form-data">
   <label for="file">File to upload:</label>
   <input type="file" id="file" name="file" required>
   <label for="fileDescription">File description:</label>
   <input type="text" id="fileDescription" name="fileDescription" placeholder="Description" required>
   <button type="submit">Upload</button>
</form>

enctype属性指定浏览器提交表单时的编码方式。

接着暴露一个REST接口:

@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public String upload(@RequestParam("file") MultipartFile file, String fileDescription) {
    return "files/success";
}

该方法处理HTTP POST请求,接受两个与表单输入匹配的参数。通过定义consumes属性指定预期内容类型。

最后选择测试工具。

3.1. 复现问题

curl和浏览器在提交表单数据时会自动生成multipart边界。因此复现问题的最简单方式是使用Postman

若仅将Content-Type设置为multipart/form-data,会收到以下响应:

{
    "timestamp": "2024-05-01T10:10:10.100+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "trace": "org.springframework.web.multipart.MultipartException: Failed to parse multipart servlet request... Caused by: org.apache.tomcat.util.http.fileupload.FileUploadException: the request was rejected because no multipart boundary was found... 43 more\n",
    "message": "Failed to parse multipart servlet request",
    "path": "/files"
}

使用OkHttp创建单元测试复现相同结果:

private static final String BOUNDARY = "OurCustomBoundaryValue";

private static final String BODY =
    "--" + BOUNDARY + "\r\n" +
        "Content-Disposition: form-data; name=\"file\"; filename=\"import.csv\"\r\n" +
        "Content-Type: text/csv\r\n" +
        "\r\n" +
        "content-of-the-csv-file\r\n" +
        "--" + BOUNDARY + "\r\n" +
        "Content-Disposition: form-data; name=\"fileDescription\"\r\n" +
        "\r\n" +
        "Records\r\n" +
        "--" + BOUNDARY + "--";

@Test
void givenFormData_whenPostWithoutBoundary_thenReturn500() throws IOException {
    RequestBody requestBody = RequestBody.create(BODY.getBytes(), parse(MediaType.MULTIPART_FORM_DATA_VALUE));

    try (Response response = executeCall(requestBody)) {
        assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), response.code());
    }
}

private Response executeCall(RequestBody requestBody) throws IOException {
    Request request = new Request.Builder().url(HOST + port + FILES)
        .post(requestBody)
        .build();

    return new OkHttpClient().newCall(request)
        .execute();
}

尽管我们使用封装边界分隔了body部分,但在调用解析MediaType的方法时故意省略了边界值。由于请求头缺少必填值,调用将失败。

4. 解决问题

错误信息明确指出问题在于Content-Type头中未设置边界参数。

解决方案有两种:

方案一:让Postman自动生成边界值

  • 不手动设置Content-Type,Postman会自动添加:
    Content-Type: multipart/form-data; boundary=<calculated when request is sent>
    

方案二:自定义边界值

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

同样,添加单元测试覆盖成功场景:

@Test
void givenFormData_whenPostWithBoundary_thenReturn200() throws IOException {
    RequestBody requestBody = RequestBody.create(BODY.getBytes(), parse(MediaType.MULTIPART_FORM_DATA_VALUE + "; boundary=" + BOUNDARY));

    try (Response response = executeCall(requestBody)) {
        assertEquals(HttpStatus.OK.value(), response.code());
    }
}

两种方案都很直观,但需注意以下最佳实践。

4.1. 避免错误的最佳实践

边界参数值需满足以下规则

  1. 由字母数字(A-Z, a-z, 0-9)和特殊字符组成的任意字符串
  2. 长度不超过70字符
  3. 特殊字符包括RFC 822定义的"特殊字符"及=?/
  4. 使用特殊字符时必须用引号包裹边界值

⚠️ 关键要求:边界值必须唯一,且不能出现在请求数据的任何部分。

遵循这些最佳实践可确保服务器正确解析边界字符串。

5. 总结

本教程展示了如何避免使用multipart请求时的常见错误。所有multipart Content-Type都需要边界参数。

浏览器、Postman和curl工具提供自动生成multipart边界的功能。但当需要自定义值时,必须遵循既定规则以确保跨系统的正确处理和兼容性。

本文使用的代码示例可在GitHub获取。


原始标题:Avoid “No Multipart Boundary Was Found” in Spring | Baeldung