Spring 异常 “No Multipart Boundary Was Found”

1、简介

本文将带你了解在 Spring 中处理文件上传(Multipart)请求时出现异常:“No Multipart Boundary Was Found” 的原因,以及解决办法。

2、理解 Multipart 请求

简而言之,Multipart 请求是一种 HTTP 请求,它在一条消息的请求体中传输一种或多种不同的数据。请求体被分成多个部分,请求中的每个部分都可能代表不同的文件或数据。

通常使用它来传输或上传文件、交换电子邮件、流媒体或提交 HTML 表单,并使用 Content-Type 标头来指明在请求中发送的数据类型。

来看看 Multipart 请求需要设置哪些值。

2.1、主类型

主类型(顶级类型)指定了我们发送的内容的主要类别。如果我们在单个 HTTP 请求中提交多种数据类型,则需要将 Content-Type Header 值设置为 multipart

2.2、子类型

除了顶级类型外,Content-Type Header 值还包含一个强制的子类型。子类型值提供了有关数据格式的附加信息。

在不同的 RFC 中定义了多种 multipart 子类型。例如 multipart/mixedmultipart/alternativemultipart/relatedmultipart/form-data(常用)。

由于我们在一个请求中封装了多种不同的数据类型,因此需要一个额外的参数来分隔 multipart 消息的不同部分:即,boundary 参数。

2.3、Boundary 参数

Boundary 指令(参数)是 multipart Content-Type 的强制值,它指定了封装边界。

根据 RFC 1341 的定义,封装(Boundary)边界是由两个连字符(“–”)后跟 Content-Type Header 中的 boundary 值组成的分隔线。它用于分隔 HTTP 请求体中的各个部分(即,子请求体)。

来看一个实际的案例,Web 浏览器请求包含两个 Part。通常情况下,Content-Type Header 信息如下:

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

封装边界(Boundary)分隔了请求体的每个部分。此外,每个部分都有自己的 Header 部分、一个空行和内容本身。

------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--

最后,在最后一个数据部分之后,有一个结尾边界(Boundary),在结尾处附加了两个连字符。

3、实际案例

现在,创建一个简单的示例,来重现 “no multipart boundary was found” 问题。

如前所述,所有 multipart 请求都必须使用 boundary 参数,因此我们可以选择任何一种 multipart subtype。为简单起见,我们使用 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";
}

最后,需要选择测试工具进行测试。

3.1、重现问题

在提交表单数据时,cURL 和 Web 浏览器都会自动生成 multipart boundary。因此,重现该问题的最简单方法是使用 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();
}

尽管我们使用 boundary 分隔了请求体,但在调用用于解析 MediaType 的方法时,我们故意省略了 boundary 值。由于请求头缺少必须值,调用失败。

4、解决这一问题

如错误信息所示,问题与 Content-Type Header 中未设置 boundary 参数有关。

解决这个问题的方法之一是让 Postman 自动生成其值,而不是自己设置 Content-Type 值。这样的话,Postman 就会自动添加以下 Content-Type Header 信息:

Content-Type: multipart/form-data; boundary=<calculated when request is sent>

如果你想定义自定义 boundary 值,可以这样做:

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());
    }
}

这两种情况的解决方案都比较直观,但有几点需要注意。

  1. boundary 参数值是由字母数字(A-Z、a-z、0-9)和特殊字符组成的任意字符串,长度不超过 70 个字符。特殊字符包括 RFC 822 中定义为 “特殊” 的所有字符,以及另外三个字符 “=”、“?” 和 “/”。如果使用特殊字符,还必须用引号括起 boundary
  2. 此外,它必须是唯一的,并且不应出现在请求中发送的任何数据中。

5、总结

本文介绍了什么是 Multipart 请求,以及在 Spring 中处理文件上传(Multipart)请求时出现异常:“No Multipart Boundary Was Found” 的原因和解决办法。

Web 浏览器、Postman 和 curl 工具可自动生成 multipart boundary。不过,当我们要使用自定义值时,还是需要遵循已定义的规则,以确保在不同系统间正确处理和兼容。


Ref:https://www.baeldung.com/spring-avoid-no-multipart-boundary-was-found