文件上传听起来很无聊,直到你花了一下午时间在生产环境中调试 MultipartException: Failed to parse multipart servlet request

在 Spring Boot 中,文件上传涉及 MultipartFileMultipartAutoConfigurationspring.servlet.multipart.* 属性,以及默认运行在 servlet 容器上的假设。它能工作,但包含大量隐藏机制。

Solon 采用了不同的路径。核心类是 UploadedFile,它位于 web 层,无需额外配置,你可以明确控制何时进行 multipart 解析以及何时清理临时文件。

基础:UploadedFile

当控制器方法包含 UploadedFile 参数时,Solon 会自动触发 multipart 解析 —— 无需注解:

@Controller
public class FileController {

    @Post
    @Mapping("/upload")
    public String upload(UploadedFile file) {
        try {
            file.transferTo(new File("/storage/uploads/" + file.name));
            return "uploaded: " + file.name + " (" + file.contentSize + " bytes)";
        } finally {
            file.delete(); // 始终清理
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

你最常用的关键属性:

属性 描述
file.name 完整文件名(含扩展名)
file.extension 仅扩展名(如 jpg
file.contentType MIME 类型
file.contentSize 文件大小(字节)
file.content 原始 InputStream
file.contentAsBytes byte[]
file.isEmpty() 上传是否为空

值得注意的一点:框架不会自动清理临时文件。如果你跳过 delete(),这些临时文件会留在磁盘上。上面的 try/finally 模式不是可选的。

同名字段的多文件

当客户端在同一字段名下发送多个文件时,使用 UploadedFile[](自 v2.3.8 起支持):

@Post
@Mapping("/upload/batch")
public void uploadBatch(UploadedFile[] files) {
    for (UploadedFile file : files) {
        try {
            file.transferTo(new File("/storage/" + file.name));
        } finally {
            file.delete();
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

字段名不匹配时

如果表单字段名与参数名不同,使用 @Param

@Post
@Mapping("/avatar")
public void setAvatar(@Param("user_avatar") UploadedFile file) {
    // 处理表单字段 "user_avatar"
}

Enter fullscreen mode Exit fullscreen mode

混合表单:文件 + 常规字段

你可以在同一请求中同时接收文件和文本字段。参数注入会自动处理两者:

@Post
@Mapping("/upload/with-meta")
public void uploadWithMeta(UploadedFile file, String description, int category) {
    try {
        // file: 上传的文档
        // description, category: 纯文本表单字段
        saveFile(file, description, category);
    } finally {
        file.delete();
    }
}

Enter fullscreen mode Exit fullscreen mode

没有 UploadedFile 参数时

有时 multipart 表单只有文本字段 —— 没有文件附件。此时 Solon 不会自动触发 multipart 解析。在映射上使用 multipart = true

@Post
@Mapping(path = "/submit", multipart = true)
public void submit(String username, int age) {
    // 仅含文本字段的 multipart 表单
}

Enter fullscreen mode Exit fullscreen mode

你也可以在需要更多控制时通过 Context 手动访问文件:

@Post
@Mapping(path = "/upload/manual", multipart = true)
public void uploadManual(String username, Context ctx) {
    UploadedFile file = ctx.file("attachment");
    UploadedFile[] extras = ctx.files("extras");
    // 处理...
}

Enter fullscreen mode Exit fullscreen mode

安全:控制 autoMultipart

默认情况下,autoMultiparttrue —— 任何传入的 multipart 请求都会被自动解析。在面向公众的服务上,这意味着客户端可以向任何端点发送大文件并触发解析开销。

通过路由过滤器收紧控制:

Solon.start(App.class, args, app -> {
    app.router().filter(-1, (ctx, chain) -> {
        // 仅对上传路径解析 multipart
        ctx.autoMultipart(ctx.path().startsWith("/upload"));
        chain.doFilter(ctx);
    });
});

Enter fullscreen mode Exit fullscreen mode

对于集中式临时文件清理(v2.7.3+),过滤器同样有效:

@Component
public class MultipartCleanupFilter implements Filter {
    @Override
    public void doFilter(Context ctx, FilterChain chain) throws Throwable {
        try {
            chain.doFilter(ctx);
        } finally {
            if (ctx.isMultipartFormData()) {
                ctx.filesDelete(); // 清理所有上传的临时文件
            }
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

注意: 如果使用此模式,不要让上传处理器异步执行。过滤器在请求完成时运行 —— 异步处理器可能仍在运行时触发清理。

文件大小配置

# app.yml
server:
  request:
    maxBodySize: 2mb          # 最大请求体(默认:2mb)
    maxFileSize: 20mb         # 单个文件最大大小
    maxHeaderSize: 8kb        # 最大头部大小
    fileSizeThreshold: 512kb  # 低于此值:内存;高于此值:临时文件(v3.6.0+)

Enter fullscreen mode Exit fullscreen mode

fileSizeThreshold 设置(在 v3.6.0 中引入)会自动将小文件路由到内存,大文件路由到磁盘。在 v3.6.0 之前,你可以使用 useTempfile: true 强制临时文件模式 —— 该标志现已弃用。

文件下载

返回文件同样简洁。返回 DownloadedFile 用于字节数组或流,或直接返回 java.io.File

@Get
@Mapping("/download/report")
public DownloadedFile downloadReport() {
    byte[] pdf = reportService.generatePdf();
    return new DownloadedFile("application/pdf", pdf, "report.pdf");
}

@Get
@Mapping("/download/avatar/{userId}")
public File downloadAvatar(@Path String userId) {
    return new File("/storage/avatars/" + userId + ".jpg");
}

Enter fullscreen mode Exit fullscreen mode

如需更多控制(缓存、内联显示、ETag):

@Get
@Mapping("/preview/logo")
public DownloadedFile previewLogo() {
    DownloadedFile file = new DownloadedFile(new File("/assets/logo.png"));
    file.asAttachment(false);       // 内联显示,不触发下载
    file.cacheControl(3600);        // 缓存 1 小时(304)
    file.eTag("logo-v3");
    return file;
}

Enter fullscreen mode Exit fullscreen mode

DownloadedFile 还支持 HTTP Range,因此无需额外配置即可用于视频流和大文件可恢复下载。

无需额外依赖

以上所有内容均在 solon-web 中 —— 无需单独的 multipart 启动器,无需查找自动配置类:

<dependency>
    <groupId>org.noear</groupId>
    <artifactId>solon-web</artifactId>
</dependency>

Enter fullscreen mode Exit fullscreen mode

选择任意服务器适配器(JDK HTTP、Jetty、Undertow、Grizzly、smarthttp)—— 它们都通过 fileSizeThreshold 支持临时文件模式。

心智模型

与 Spring 的对比主要在于显式性:

  • Spring BootMultipartFile 由 servlet 层注入;MultipartAutoConfiguration 注册一切;spring.servlet.multipart.* 配置限制;CommonsMultipartResolver 可选。
  • SolonUploadedFile 参数 → 触发解析;临时文件保留直到你调用 delete()autoMultipart 提供路径级控制;配置位于 server.request.*

对于大多数项目,差异很小。关键在于当你希望清楚地理解何时发生解析、什么存在于内存与磁盘,以及谁负责清理时。Solon 使这些问题显式化,而不是隐藏在自动配置中。


参考: