[Spring Boot]Controller周りのあれこれ
はじめに
Controller周りでつまづいた点を雑にまとめてみました。
非常に細かい話になりますが、以下の3本立てでお送りします。
- 正しくHTTPステータスを返したい
-
@ModelAttribute
でクエリパラメータを任意の変数名にマッピングしたい - バリデーションメソッドの制約
なお、Javaは17, Spring Bootのバージョンは2.5.6で検証しました。
正しくHTTPステータスを返したい
- クエリパラメータによってメソッドを振り分けたい
というときがあり、その中で正しくHTTPステータスを返すのに四苦八苦したことがあります。
例えば以下のような場合です。
- リクエストが
/v1/music/songs?artist_name
→searchByArtist()
にルーティング - リクエストが
/v1/music/songs?category_name
→searchByCategory()
にルーティング - どちらにもルーティングできない場合は
400 Bad Request
を返す
はじめに、以下のように書いてみました。
package com.foo.music.api;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
public class BadSampleApi {
@RequestMapping(path = "/v1/music/songs", params = "artist_name", method = RequestMethod.GET)
public List<String> searchByArtist(@RequestParam("artist_name") String artistName) {
// 省略
}
@RequestMapping(path = "/v1/music/songs", params = "category_name", method = RequestMethod.GET)
public List<String> searchByCategory(@RequestParam("category_name") String categoryName) {
// 省略
}
}
たいていの場合はこれで問題ないのですが、以下のようなリクエストの場合に問題が生じます。
/v1/music/songs?artist_name=The Beatles&category_name=Rock
artist_name
とcategory_name
の両方が指定されており、どちらのメソッドにもルーティングできないため、400 Bad Request
を期待します。
しかし、500 Internal Server Error
が返ってきました。
エラーログには以下のようなメッセージが…
java.lang.IllegalStateException: Ambiguous handler methods mapped for '/v1/music/songs':
どうやらSpringがどちらのメソッドにルーティングしていいか判断がつかないため、エラーとなるようです。
少し悩んだ末、@RequestMapping
の仕様を見てみることにしました。
すると、params
には!
を用いて除外したいクエリパラメータを記述することができるようです。
そこで、以下のように変えてみました。
package com.foo.music.api;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
public class GoodSampleApi {
@RequestMapping(path = "/v1/music/songs", params = {"artist_name", "!category_name"}, method = RequestMethod.GET)
public List<String> searchByArtist(@RequestParam("artist_name") String artistName) {
// 省略
}
@RequestMapping(path = "/v1/music/songs", params = {"category_name", "!artist_name"}, method = RequestMethod.GET)
public List<String> searchByCategory(@RequestParam("category_name") String categoryName) {
// 省略
}
}
すると、以下のようなリクエストでも400 Bad Request
が返るようになりました!
/v1/music/songs?artist_name=The Beatles&category_name=Rock
推測ですが、除外の条件を入れたことによって
Springが「どちらのメソッドにもルーティングできないよ!」と判断できるようになった
のではないかと思います。
@RequestMapping
とは
[Tips] リクエストURLをどのメソッドで処理するか定義するアノテーションです。
以下の要素を持っており、柔軟に定義することができます。
一覧
要素 | 説明 | 型 |
---|---|---|
name | マッピング事態に名前を割り当てる | String |
path | 受け付けるURIを指定 {variable}のようなプレースホルダで可変にすることも可能。 また、Antスタイルのパスパターンもサポートされている。 |
String[] |
value | pathのエイリアス | String[] |
params | 受け付けたいクエリパラメータを指定! を用いて受け付けたくないクエリパラメータを指定可能 |
String[] |
method | 受け付けたいHTTPリクエストメソッドを指定 | RequestMethod[] |
headers | 受け付けたいリクエストヘッダーを指定! を用いて受け付けたくないリクエストヘッダーを指定可能 |
String[] |
consumes | 受け付けたいリクエストヘッダーのContent-Typeを指定! を用いて受け付けたくないContent-Typeを指定可能 |
String[] |
produces | 受け付けたいリクエストヘッダーのAcceptを指定! を用いて受け付けたくないAcceptを指定可能 |
String[] |
@ModelAttribute
でクエリパラメータを任意の変数名にマッピングしたい
リクエストパラメータを任意の型で受け取りたい時には@ModelAttribute
を使います。
※アノテーション省略可能
しかしながら、リクエストパラメータをバインドするのに少々工夫が必要な場合があります。
例えば、
/v1/music/songs?artist_name=The Beatles&category_name=Rock
のようなリクエストのとき、以下のコードではリクエストパラメータがうまくバインドされません。
package com.foo.music.api;
import com.foo.music.api.form.SearchConditionForm;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
public class SampleApi {
@RequestMapping(path = "/v1/music/songs", method = RequestMethod.GET)
public List<String> search(SearchConditionForm searchConditionForm) {
// 省略
}
}
package com.foo.music.api.form;
import lombok.Value;
@Value
public class SearchConditionForm {
String artistName; // リクエストパラメータが"artist_name"だとマッピングされない
String categoryName; // リクエストパラメータが"category_name"だとマッピングされない
}
このコードではリクエストパラメータとSearchConditionForm
のフィールド名が完全一致しないとうまくバインドされません。
かといって、フィールド名をスネークケースにしたくはない・・・
(蛇足ですが、Java書いててスネークケースを使うことってあるんでしょうか…?)
そこで調べてみると、@ConstructorProperties
というアノテーションを使ってうまくバインドできそうでした。
package com.foo.music.api.form;
import lombok.Value;
import java.beans.ConstructorProperties;
@Value
public class SearchConditionForm {
String artistName;
String categoryName;
// @ConstructorPropertiesの配列の順番とコンストラクタの引数の順番が対応している
@ConstructorProperties({"artist_name", "category_name"})
public SearchConditionForm(String artistName, String categoryName) {
this.artistName = artistName;
this.categoryName = categoryName;
}
}
さらにlombokの@RequiredArgsConstructor
を使ってよりスマートに書くことができます。
package com.foo.music.api.form;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import java.beans.ConstructorProperties;
@RequiredArgsConstructor(onConstructor = @__({@ConstructorProperties({"artist_name", "category_name"})}))
@Value
public class SearchConditionForm {
String artistName;
String categoryName;
}
バリデーションメソッドの制約
リクエストパラメータやクエリパラメータを任意の型で受け取り、バリデーションするときは、以下が必要です。
- Controllerメソッドのバリデーションしたい引数の型に
@Valid
を付ける - 受け取る型のフィールドかメソッドにバリデーションのアノテーションを付ける
さらにメソッドにアノテーションをつけてバリデーションをしたい場合、以下の制約もあります。
- メソッド名を
getXXX()
の形にする
以下の例では、getLimitAsInteger()では
バリデーションが実行されるのに対し、convertLimitToInteger()
ではバリデーションが実行されません。
package com.foo.music.api;
import com.foo.music.api.form.SearchConditionForm;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.List;
@RestController
public class SampleApi {
@RequestMapping(path = "/v1/music/songs", method = RequestMethod.GET)
public List<String> search(@Valid SearchConditionForm searchConditionForm) {
// 省略
}
}
package com.foo.music.api.form;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import javax.validation.constraints.Max;
import java.beans.ConstructorProperties;
@RequiredArgsConstructor(onConstructor = @__({@ConstructorProperties({"artist_name", "category_name", "limit"})}))
@Value
public class SearchConditionForm {
String artistName;
String categoryName;
String limit;
// バリデーションが実行される
@Max(100)
public Integer getLimitAsInteger() {
return Integer.parseInt(this.limit);
}
// バリデーションが実行されない
@Max(100)
public Integer convertLimitToInteger() {
return Integer.parseInt(this.limit);
}
}
getXXX()
でないといけない理由はわかりません。
どなたか知っている方いたら教えてください(笑)
一度デバッグして調査を試みたのですが、挫折しました…
おわりに
非常に細かい話ですが、知らないとドツボにはまる(実際はまりました)ので忘れないようにまとめました。
しかし、書いてて「これ需要あるのか…?」と思いました(笑)
ニッチな内容になってしまったのを少し反省し、次回はもうちょっと役に立ちそうなことを書きたいと思います。
おしまい
Discussion