Upload và download file: MultipartFile, streaming, pre-signed URL

4 câu hỏi~25 phútTừ khóa: multipart/form-data, MultipartFile, max-file-size, max-request-size, file-size-threshold, transferTo, InputStream, Resource, StreamingResponseBody, Content-Disposition, magic bytes, Apache Tika, pre-signed URL

Tiếp đi: upload ảnh sản phẩm và tải PDF hóa đơn trong shop-app.

Được. File khác JSON ở một điểm lớn: nó có thể rất to. Mọi quyết định ở bài này xoay quanh câu hỏi "file có bị load hết vào RAM không?".

1. Bài toán

2. Vấn đề

// cách hay thấy, nhưng sai
@GetMapping("/export")
public byte[] export() {
    return exportService.buildCsv();   // 500MB byte[] trong heap
}

3. Giải pháp

4. Code từng bước

Bước 1: Dependency

<dependency>
  <groupId>org.apache.tika</groupId>
  <artifactId>tika-core</artifactId>
  <version>3.0.0</version>
</dependency>

Bước 2: Config

spring:
  servlet:
    multipart:
      max-file-size: 5MB          # mỗi file
      max-request-size: 20MB      # cả request (nhiều file)
      file-size-threshold: 256KB  # nhỏ hơn: giữ RAM; lớn hơn: ghi file tạm
server:
  tomcat:
    max-swallow-size: 2MB         # Tomcat không đọc hết body quá lớn bị từ chối

Vượt giới hạn → MaxUploadSizeExceededException, map sang 413 trong advice:

@ExceptionHandler(MaxUploadSizeExceededException.class)
ProblemDetail tooLarge() {
    return ProblemDetail.forStatusAndDetail(HttpStatus.PAYLOAD_TOO_LARGE, "File tối đa 5MB");
}

Bước 3: Code Java

Upload ảnh:

@RestController
@RequestMapping("/api/products/{productId}/images")
@RequiredArgsConstructor
public class ProductImageController {

    private static final Set<String> ALLOWED = Set.of("image/jpeg", "image/png", "image/webp");
    private final Tika tika = new Tika();
    private final ImageStorage storage;

    @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<ImageResponse> upload(@PathVariable Long productId,
                                                @RequestParam("file") MultipartFile file) throws IOException {
        if (file.isEmpty()) throw new InvalidFileException("File rỗng");

        String realType;
        try (InputStream in = file.getInputStream()) {
            realType = tika.detect(in);                // đọc magic bytes, không tin header
        }
        if (!ALLOWED.contains(realType)) throw new InvalidFileException("Chỉ nhận JPEG/PNG/WebP");

        String key = "products/%d/%s.%s".formatted(productId, UUID.randomUUID(), ext(realType));
        try (InputStream in = file.getInputStream()) {
            storage.put(key, in, file.getSize(), realType);   // stream thẳng lên storage
        }
        URI location = URI.create("/api/products/%d/images/%s".formatted(productId, key));
        return ResponseEntity.created(location).body(new ImageResponse(key));
    }
}

Download PDF có sẵn trên disk: trả Resource, Spring tự stream theo buffer.

@GetMapping("/api/orders/{id}/invoice.pdf")
public ResponseEntity<Resource> invoice(@PathVariable Long id) {
    Path path = invoiceService.pathOf(id);                     // kiểm tra quyền + tồn tại bên trong
    Resource pdf = new FileSystemResource(path);
    ContentDisposition cd = ContentDisposition.attachment()
            .filename("hoa-don-%d.pdf".formatted(id), StandardCharsets.UTF_8)
            .build();
    return ResponseEntity.ok()
            .contentType(MediaType.APPLICATION_PDF)
            .header(HttpHeaders.CONTENT_DISPOSITION, cd.toString())
            .contentLength(pdf.contentLength())
            .body(pdf);
}

Export CSV sinh dần: StreamingResponseBody ghi từng dòng, không dựng file trong RAM.

@GetMapping("/api/admin/orders/export.csv")
public ResponseEntity<StreamingResponseBody> export(@RequestParam YearMonth month) {
    StreamingResponseBody body = out -> {
        var writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8));
        writer.write("id,customer,total\n");
        exportService.forEachOrder(month, o -> {            // đọc DB theo trang / Stream + fetchSize
            writer.write("%d,%s,%s\n".formatted(o.id(), o.customer(), o.total()));
        });
        writer.flush();
    };
    return ResponseEntity.ok()
            .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
            .header(HttpHeaders.CONTENT_DISPOSITION,
                    ContentDisposition.attachment().filename("orders-" + month + ".csv").build().toString())
            .body(body);
}

StreamingResponseBody chạy trên thread của async executor (bài 4.2.12), nên cấu hình spring.mvc.async.request-timeout đủ dài và dùng executor riêng.

Bước 4: Test bằng curl

curl -i -F "file=@cat.jpg" localhost:8080/api/products/10/images
# 201, Location: /api/products/10/images/products/10/9b1e....jpg

curl -i -F "file=@shell.php;type=image/jpeg" localhost:8080/api/products/10/images
# 400 "Chỉ nhận JPEG/PNG/WebP"     ← header nói jpeg, Tika nói không

curl -i -F "file=@big-10mb.jpg" localhost:8080/api/products/10/images
# 413

curl -OJ localhost:8080/api/orders/42/invoice.pdf     # lưu thành hoa-don-42.pdf
curl -o orders.csv "localhost:8080/api/admin/orders/export.csv?month=2026-09"

5. Cách hoạt động

Upload multipart
Client ──body──► Tomcat ──► MultipartResolver (StandardServletMultipartResolver)
                              ├─ part < threshold → RAM
                              └─ part ≥ threshold → file tạm (java.io.tmpdir)
                           ──► MultipartFile (getInputStream() đọc từ đó)
                           ──► app stream lên storage
                           ──► request kết thúc → file tạm bị xóa

Download
Resource:              ResourceHttpMessageConverter đọc từng buffer ──► socket
StreamingResponseBody: thread async gọi lambda, ghi vào OutputStream ──► socket
                       heap chỉ giữ buffer, không giữ cả file
Pre-signed URL (file lớn)
Client ──1. xin URL upload──► shop-app (kiểm tra quyền, sinh URL hết hạn 5 phút)
Client ──2. PUT file────────────────────────────────► S3 / MinIO
Client ──3. báo xong (key)──► shop-app (kiểm tra object, lưu DB)

6. So sánh

CáchRAMDùng khi
byte[] / getBytes()Cả fileFile rất nhỏ (avatar vài KB)
ResourceBufferFile đã có (disk, classpath, storage stream)
StreamingResponseBodyBufferNội dung sinh dần (CSV export, zip)
Pre-signed URL0 (không qua app)File lớn, nhiều, cần CDN

7. Khi nào nên dùng / không nên dùng

Mình đặt max-file-size 5MB rồi, sao vẫn cần kiểm tra ở nginx hay gateway nữa?

Bạn hiểu đúng là Spring đã chặn, chỉ cần thêm một điểm: Spring chặn khá muộn. Khi exception ném ra, Tomcat đã nhận một phần body, đã chiếm một thread và băng thông. Kẻ xấu gửi nhiều request 1GB vẫn làm app bận.

nginx: client_max_body_size 6m;   → từ chối ngay ở biên, app không thấy request
gateway: giới hạn body + rate limit
Spring: max-file-size              → lớp bảo vệ cuối + thông báo lỗi đẹp

Nhiều lớp, mỗi lớp rẻ hơn lớp sau.

Tên file tiếng Việt như "Hóa đơn tháng 9.pdf" tải về bị lỗi font tên, sửa sao?

Đúng rồi, header HTTP vốn chỉ an toàn với ASCII. Cần dạng RFC 6266 có filename*:

Content-Disposition: attachment; filename="=?UTF-8?Q?...?="; filename*=UTF-8''H%C3%B3a%20%C4%91%C6%A1n%20th%C3%A1ng%209.pdf

Không cần tự encode: ContentDisposition.attachment().filename(name, StandardCharsets.UTF_8) sinh đúng cả hai. Muốn mở trong tab thay vì tải về thì dùng inline() thay cho attachment().

Phỏng vấn hỏi về upload/download file thì trả lời sao?

Q1. Làm sao download file lớn mà không bị OutOfMemory?

🇻🇳 Tiếng Việt:

Không trả byte[]. Nếu file có sẵn em trả Resource, Spring đọc và ghi theo buffer. Nếu nội dung sinh dần như export CSV em dùng StreamingResponseBody, ghi từng dòng ra OutputStream và đọc DB theo trang. Với file rất lớn em cho client tải thẳng từ object storage bằng pre-signed URL.

🇬🇧 English:

I never return a byte array. For an existing file I return a Resource so Spring copies it in buffers. For generated content like a CSV export I use StreamingResponseBody, writing row by row to the output stream while reading the database in pages. For very large files the client downloads directly from object storage with a pre-signed URL.

Q2. Bảo mật khi nhận file upload cần chú ý gì?

🇻🇳 Tiếng Việt:

Giới hạn kích thước ở cả gateway và Spring. Kiểm tra loại file bằng magic bytes, không tin Content-Type hay đuôi file. Không dùng tên file của client làm đường dẫn để tránh path traversal, tự sinh key. Lưu ngoài web root hoặc trên object storage, và kiểm tra quyền trước khi cho tải.

🇬🇧 English:

Limit the size at both the gateway and Spring. Check the real file type with magic bytes instead of trusting the Content-Type header or the extension. Never use the client file name as a path, to avoid path traversal; generate my own key. Store files outside the web root or in object storage, and check authorization before serving them.

Q3. Khi nào dùng pre-signed URL?

🇻🇳 Tiếng Việt:

Khi file lớn hoặc lượng upload nhiều. App chỉ kiểm tra quyền và sinh URL có hạn, client upload thẳng lên storage nên app không tốn thread, RAM và băng thông. Sau đó client báo key cho app để lưu metadata.

🇬🇧 English:

When files are large or uploads are frequent. The app only checks permissions and issues a short-lived URL, and the client uploads straight to storage, so the app spends no threads, memory or bandwidth. Afterwards the client sends the object key back so the app can store the metadata.

Cách nhớ ngắn gọn


Tự kiểm tra

max-file-size và max-request-size khác nhau thế nào?

Một file so với tổng cả request multipart (nhiều file + field).

file-size-threshold dùng để làm gì?

Part nhỏ hơn ngưỡng giữ trong RAM, lớn hơn ghi ra file tạm.

Vì sao không tin file.getContentType()?

Do client gửi, giả được; phải đọc magic bytes.

Export CSV 500MB nên trả kiểu gì?

StreamingResponseBody, ghi dần và đọc DB theo trang.

Upload vượt giới hạn nên trả status nào?

413 Payload Too Large.