檔案上傳聽起來很無聊,直到你花了一個下午在生產環境中除錯 MultipartException: Failed to parse multipart servlet request

在 Spring Boot 中,檔案上傳涉及 MultipartFileMultipartAutoConfigurationspring.servlet.multipart.* 屬性,以及一個無聲的假設——你正在 servlet 容器上執行。它能運作,但帶有許多隱藏的機制。

Solon 採取不同的做法。核心類別是 UploadedFile,它位於網頁層,無需額外設定,而且你可以明確控制何時進行 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 解析。請在 mapping 上使用 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 starter,也無需尋找自動設定類別:

<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 使這些問題明確化,而不是隱藏在自動設定中。


參考資料: