先日、JavaのSpring bootで、ファイルアップロードを受け付けるAPIを作成しました。
multipart形式で受ければいいんだな。とはすぐに調べがつきました。
しかし、bootでの実装は詰まるタイミングが多かったのでメモとして残しておきます。
ちなみに、呼び出し側からは「Content-Type: multipart/form-data」でリクエストされます。
Springのファイルアップロードについては、基本的にはここを見ておけば問題ないです。
4.9. ファイルアップロード — TERASOLUNA Server Framework for Java (5.x) Development Guideline 5.7.0.RELEASE documentation (terasolunaorg.github.io)
しかし、boot向けではないですし、長いため後から見ると結構見落としている部分が多かったです。
まぁ、これは公式ドキュメントあるあるですよね。
サンプル
コントローラー
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.stream.Collectors;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
public class DemoController {
@ResponseStatus(HttpStatus.OK)
@PostMapping(value = "/demo/multipart", consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> upload(@RequestParam("uploadFile") MultipartFile file) {
// サイズチェック
// ファイル未設定 or サイズ0
if (file.isEmpty()) {
throw new RuntimeException();
}
// 上限チェック(5MB)
if (file.getSize() > 5242880) {
throw new RuntimeException();
}
return readFile(file);
}
// ファイル読み込み
// 読み込み結果の各行をカンマ区切りの1行へ
private String readFile(MultipartFile uploadFile) {
try (var is = uploadFile.getInputStream();
var isr = new InputStreamReader(is, "Windows-31J");
var br = new BufferedReader(isr)) {
return br.lines()
.collect(Collectors.joining(","));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
最初はサイズチェックの前に、fileのnullチェックをしていました。
しかし、実際にファイルを設定しないでリクエストしてみると、nullチェックはスルーしてそのままサイズチェックに来てしまいました。
ファイル未設定とサイズ0は同じ動きみたいです。
例外ハンドラ
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MultipartException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ResponseStatus(HttpStatus.PAYLOAD_TOO_LARGE)
@ExceptionHandler(MultipartException.class)
public String handleMultipart(MultipartException e) {
// レスポンス作成
return "ERROR999";
}
}
ファイルの保存用のディレクトリにアクセスできなかったり、アップロードされたファイルが受付最大値を超えていたりする場合、MultipartExceptionが発生します。
「@RestControllerAdvice」を付与した例外ハンドラクラスで処理するようにした方がいいと思います。
今回MultipartExceptionが発生するのは、サイズ超過と決め打ちしてHTTPステータスは413。
加えてエラーコードの設定などが主な処理内容です。
application.properties
spring.servlet.multipart.file-size-threshold=10MB
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
spring.servlet.multipart.location=/tmp
今回は、アプリ上の最大サイズは5MB(controllerでチェック)。
システム上の上限は10MBとしました(application.properties)。
spring.servlet.multipart.file-size-threshold=10MB
アップロードされたファイルをサーバーのディスクに書き込むか否かの閾値を設定します。
今回は一切ディスクに書き込んで欲しくないため、アップロードファイル上限と同じ10MBに設定しました。
ちなみに、「うちは書き込んでもらっても全然OK!」という場合でも、明示的に「0」を設定するのが推奨らしいです。
10MB以上のファイルがリクエストされた場合、MultipartExceptionがスローされます。
5MBが上限だと言っているのに、10MBアップロードするのは嫌がらせか?と泣きたくなりますが、ファイルのアップロードで気軽に500エラーが返却されるのは困ります。
そのため、例外ハンドラでMultipartExceptionを処理しています。
例外ハンドラでエラーコードを詰めるなら、「max-file-size=5MB」でもいいんじゃないか?と一瞬思いましたが、他のAPIで6MBを許容するなんてことになったら泣くのでやめました。
spring.servlet.multipart.location=/tmp
一時ファイルを書き出すディレクトリの指定です。
実は、アップロードされたファイルが「file-size-threshold」で設定した値未満であっても、場所は確保しにいきます。デフォルトのパスはありますし、ディレクトリがなければ作ってくれるので、あまり指定しないかもしれません。
ただし、以下の場合はハマる可能性があります。
一時ディレクトリの作成に失敗して、そのままMultipartExceptionがスローされます。
- AWS ECS (fargate)のコンテナ運用をしている。
- readonlyRootFilesystemを有効にしている。
その場合は、有効なディレクトリをプロパティに指定する必要があります。
単体テスト
MockMvcを用いたJunitテストを行っていました。
他のAPIはjsonでしたので、Multipartだとテストが結構困るかな?と思ったのですが、MockMultipartFileというのがありました。素晴らしすぎます。
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import java.io.FileInputStream;
import java.io.IOException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.RequestBuilder;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
@SpringBootTest
public class DemoTest {
@Autowired
private WebApplicationContext webApplicationContext;
private MockMvc mockMvc;
@BeforeEach
public void before() {
mockMvc = MockMvcBuilders
.webAppContextSetup(webApplicationContext)
.build();
}
@Test
public void controllerTest() throws Exception {
// MockMultipartFile作成
var multipartFile = createMockMultipartFile("uploadFile", "uploadTest.txt");
// テスト実施・検証
mockMvc.perform(multipartRequest(multipartFile))
.andExpect(status().isOk())
.andExpect(jsonPath("$").value("テ,ス,ト"));
}
// MockMultipartFile作成
private MockMultipartFile createMockMultipartFile(String key, String testFileName) {
try {
return new MockMultipartFile(
key,// ファイルの名前
new FileInputStream("src/test/resources/" + testFileName));// ファイルの内容
} catch (IOException e) {
throw new RuntimeException(e);
}
}
// multipartRequest
private RequestBuilder multipartRequest(MockMultipartFile multipartFile) {
return multipart("/demo/multipart") // controllerのURL
.file(multipartFile) // 作成したMockMultipartFile
.contentType(MediaType.MULTIPART_FORM_DATA) // リクエスト時のcontentType:multipart/form-data
.accept(MediaType.APPLICATION_JSON); // 返却はjson
}
}
コメント