👋

[Spring Boot]Controller周りのあれこれ

2021/12/22に公開約6,800字

はじめに

Controller周りでつまづいた点を雑にまとめてみました。
非常に細かい話になりますが、以下の3本立てでお送りします。

  • 正しくHTTPステータスを返したい
  • @ModelAttributeでクエリパラメータを任意の変数名にマッピングしたい
  • バリデーションメソッドの制約

なお、Javaは17, Spring Bootのバージョンは2.5.6で検証しました。

正しくHTTPステータスを返したい

  • クエリパラメータによってメソッドを振り分けたい

というときがあり、その中で正しくHTTPステータスを返すのに四苦八苦したことがあります。

例えば以下のような場合です。

  • リクエストが/v1/music/songs?artist_namesearchByArtist()にルーティング
  • リクエストが/v1/music/songs?category_namesearchByCategory()にルーティング
  • どちらにもルーティングできない場合は400 Bad Requestを返す

はじめに、以下のように書いてみました。

BadSampleApi.java
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_namecategory_nameの両方が指定されており、どちらのメソッドにもルーティングできないため、400 Bad Requestを期待します。
しかし、500 Internal Server Errorが返ってきました。
エラーログには以下のようなメッセージが…

java.lang.IllegalStateException: Ambiguous handler methods mapped for '/v1/music/songs':

どうやらSpringがどちらのメソッドにルーティングしていいか判断がつかないため、エラーとなるようです。

少し悩んだ末、@RequestMappingの仕様を見てみることにしました。
すると、paramsには!を用いて除外したいクエリパラメータを記述することができるようです。
そこで、以下のように変えてみました。

GoodSampleApi.java
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が「どちらのメソッドにもルーティングできないよ!」と判断できるようになった
のではないかと思います。

[Tips] @RequestMappingとは

リクエスト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
のようなリクエストのとき、以下のコードではリクエストパラメータがうまくバインドされません。

SampleApi.java
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) {
        // 省略
    }
}
SearchConditionForm.java
package com.foo.music.api.form;

import lombok.Value;

@Value
public class SearchConditionForm {
    String artistName; // リクエストパラメータが"artist_name"だとマッピングされない
    String categoryName; // リクエストパラメータが"category_name"だとマッピングされない
}

このコードではリクエストパラメータとSearchConditionFormのフィールド名が完全一致しないとうまくバインドされません。
かといって、フィールド名をスネークケースにしたくはない・・・
(蛇足ですが、Java書いててスネークケースを使うことってあるんでしょうか…?)

そこで調べてみると、@ConstructorPropertiesというアノテーションを使ってうまくバインドできそうでした。

SearchConditionForm.java
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を使ってよりスマートに書くことができます。

SearchConditionForm.java
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()ではバリデーションが実行されません。

SampleApi.java
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) {
        // 省略
    }
}
SearchConditionForm.java
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

ログインするとコメントできます