Spring BootでファイルアップロードとダウンロードをREST APIで実装する方法 - MultipartFileの使い方


Spring BootのREST APIでファイルのアップロードやダウンロードを実装しようとしたとき、「MultipartFileってどう使うの?」「ファイルの保存先はどう指定するの?」と詰まった経験はありませんか。

この記事では、ファイルの受け取り・保存・ダウンロードまでの一連の実装をコード例とともに解説します。

MultipartFileとは

MultipartFile は、Spring MVCが multipart/form-data 形式のリクエストを受け取ったときに生成するインターフェースです。

よく使うメソッドはこちらです。

メソッド内容
getOriginalFilename()クライアントが送信したファイル名
getContentType()MIMEタイプ(例: image/jpeg
getSize()ファイルサイズ(バイト)
transferTo(Path)指定パスにファイルを書き出す
getInputStream()ファイル内容をストリームとして取得

transferTo() が最もシンプルな保存方法ですが、Files.copy() と組み合わせるとオプション指定が柔軟にできます。

依存関係と基本設定

spring-boot-starter-web に含まれているため、追加の依存関係は不要です。

application.properties でファイルサイズの上限を設定しましょう。

# 1ファイルあたりの上限(デフォルト: 1MB)
spring.servlet.multipart.max-file-size=10MB
# リクエスト全体の上限(デフォルト: 10MB)
spring.servlet.multipart.max-request-size=10MB

デフォルトは 1MB / 10MB とかなり小さいので、本番では要件に合わせて調整が必要です。

アップロードエンドポイントの実装

validateFileType() の実装は次のセクション、sanitizeFilename() の実装はその直後で説明します。

@RestController
@RequestMapping("/api")
public class FileController {

    @Value("${app.upload-dir:/tmp/uploads}")
    private String uploadDir;

    @PostMapping("/upload")
    public ResponseEntity<Map<String, String>> upload(
            @RequestParam("file") MultipartFile file) {

        if (file.isEmpty()) {
            return ResponseEntity.badRequest()
                    .body(Map.of("error", "ファイルが空です"));
        }

        validateFileType(file);

        try {
            String filename = sanitizeFilename(file.getOriginalFilename());
            Path dest = Paths.get(uploadDir).resolve(filename);
            Files.createDirectories(dest.getParent());
            Files.copy(file.getInputStream(), dest, StandardCopyOption.REPLACE_EXISTING);
            return ResponseEntity.ok(Map.of("filename", filename));
        } catch (IOException e) {
            return ResponseEntity.internalServerError()
                    .body(Map.of("error", "ファイルの保存に失敗しました"));
        }
    }
}

保存先ディレクトリは @Value でプロパティから取得し、ソースコードにハードコードしないようにしましょう。

MIMEタイプ・拡張子バリデーション

クライアントが送信する Content-Type は偽装できます。MIMEタイプだけでなく拡張子との二重チェックを許可リスト方式で行うのが安全です。

private static final Set<String> ALLOWED_TYPES = Set.of(
    "image/jpeg", "image/png", "application/pdf"
);
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
    ".jpg", ".jpeg", ".png", ".pdf"
);

private void validateFileType(MultipartFile file) {
    String contentType = file.getContentType();
    if (contentType == null || !ALLOWED_TYPES.contains(contentType)) {
        throw new InvalidFileTypeException("許可されていないファイル形式です");
    }
    String name = file.getOriginalFilename();
    // 拡張子なし・nullは明示的に拒否する
    if (name == null || !name.contains(".")) {
        throw new InvalidFileTypeException("拡張子が不正です");
    }
    String ext = name.substring(name.lastIndexOf('.')).toLowerCase();
    if (!ALLOWED_EXTENSIONS.contains(ext)) {
        throw new InvalidFileTypeException("許可されていない拡張子です");
    }
}

バリデーション実装の詳細は Spring Bootのバリデーション実装 も参考にしてください。

ファイル名のサニタイズ

private String sanitizeFilename(String original) {
    if (original == null || original.isBlank()) return "unnamed";
    // パス区切り文字を除去してファイル名だけを取得
    Path fn = Paths.get(original).getFileName();
    // "." や ".." のようにドットのみの名前を弾く
    if (fn == null || fn.toString().matches("^\\.+$")) return "unnamed";
    String sanitized = fn.toString().replaceAll("[^a-zA-Z0-9._-]", "_");
    // 正規化後にベースディレクトリ内に収まるかを検証(パストラバーサル対策)
    Path baseDir = Paths.get(uploadDir).toAbsolutePath().normalize();
    Path resolved = baseDir.resolve(sanitized).toAbsolutePath().normalize();
    if (!resolved.startsWith(baseDir)) return "unnamed";
    return sanitized;
}

なお、日本語を含むファイル名は正規表現でアンダースコアに置換されます。異なる日本語名のファイルが同一名に衝突して REPLACE_EXISTING で無警告上書きされる可能性があるため、本番では UUID や連番でリネームする方式を強く推奨します

例外処理の設定

public class InvalidFileTypeException extends RuntimeException {
    public InvalidFileTypeException(String message) { super(message); }
}
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MaxUploadSizeExceededException.class)
    public ResponseEntity<Map<String, String>> handleMaxUploadSize(
            MaxUploadSizeExceededException e) {
        return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
                .body(Map.of("error", "ファイルサイズが上限を超えています"));
    }

    @ExceptionHandler(InvalidFileTypeException.class)
    public ResponseEntity<Map<String, String>> handleInvalidFileType(
            InvalidFileTypeException e) {
        return ResponseEntity.badRequest()
                .body(Map.of("error", e.getMessage()));
    }
}

MaxUploadSizeExceededException はコントローラーに到達する前にスローされるため、@ControllerAdvice での捕捉が必要です。例外処理の全体設計については Spring BootのREST API例外処理 で詳しく解説しています。

ダウンロードエンドポイントの実装

@GetMapping("/download/{filename}")
public ResponseEntity<?> download(@PathVariable String filename) {
    try {
        String safeFilename = sanitizeFilename(filename);
        Path baseDir = Paths.get(uploadDir).toAbsolutePath().normalize();
        Path filePath = baseDir.resolve(safeFilename).toAbsolutePath().normalize();

        if (!filePath.startsWith(baseDir)) {
            return ResponseEntity.badRequest()
                    .body(Map.of("error", "無効なファイル名です"));
        }
        if (!Files.exists(filePath)) {
            return ResponseEntity.notFound().build();
        }

        Resource resource = new FileSystemResource(filePath);
        String contentType = Files.probeContentType(filePath);
        if (contentType == null) contentType = "application/octet-stream";

        return ResponseEntity.ok()
                .contentType(MediaType.parseMediaType(contentType))
                .header(HttpHeaders.CONTENT_DISPOSITION,
                        ContentDisposition.attachment()
                                .filename(safeFilename, StandardCharsets.UTF_8)
                                .build().toString())
                .body(resource);
    } catch (IOException e) {
        return ResponseEntity.internalServerError()
                .body(Map.of("error", "ファイルの読み取りに失敗しました"));
    }
}

throws IOException を宣言せず try-catch で捕捉してエラーレスポンスを返す形にしています。アップロードエンドポイントと同じ構造なので、エラー処理の流れが一貫しています。

curlで動作確認

# アップロード(-F オプションが自動で Content-Type: multipart/form-data を設定します)
curl -F "[email protected]" http://localhost:8080/api/upload

# ダウンロード
curl -O http://localhost:8080/api/download/sample.pdf

axios など他のクライアントから呼ぶ場合は FormData オブジェクトを使うことでヘッダーが正しく設定されます。Swagger UIで試す場合の設定は Spring BootのOpenAPI・Swagger UI連携 を参照してください。

本番運用の注意点

複数インスタンス構成では、ローカルディスクへの保存はそのまま使えません。インスタンス間でファイルを共有できないためです。S3などのオブジェクトストレージへの移行を検討しましょう。

ダウンロードエンドポイントへのアクセス制御が必要な場合はSpring Securityと組み合わせます。CORSの設定が必要なケースは Spring BootのCORS設定 も参照してください。

まとめ

  • MultipartFile で受け取ったファイルは Files.copy() で保存できる
  • ファイル名は正規化チェック(startsWith 検証)まで含めてパストラバーサル対策を徹底する
  • サイズ上限は application.properties で設定し、超過時は @ControllerAdvice でハンドリング
  • ダウンロードは ResponseEntity<?>Content-Disposition: attachment で実装し、try-catch で例外を統一処理する

本記事のコードはローカル開発ですぐ動かせる構成になっています。本番への適用前にファイル名戦略とストレージ設計を改めて確認してください。