SpringMVCを使ってさまざまな入力チェックをする

アソビューで精算業務アプリケーションを担当しているアズマです。バックエンドをメインに保守開発をしています。

この記事を書いたきっかけは、AIにControllerの実装を任せたときに、入力チェックの実装方法がまちまちになってしまい、一部誤った実装になることがあったためです。 その理由として考えられたのは、入力チェックを実現するにはいくつかの定義方法があり、またフレームワークの機能を使うことなく実装もできてしまうことでした。

これらの曖昧な実装を防ぐため、フレームワークの機能を用いるのはもちろんのこと、使い方のルールを定義しておくことで、アプリケーション内で実装方法を揃えられ、レビューの負荷を下げることが狙えます。 本記事では入力チェックの実現方法を一通り紹介したあとに、AIに生成させる作法や享受できたメリットを紹介します。

入力チェック

WebアプリケーションはPCやスマートフォンなど、様々なクライアントからアクセスされます。 これらWebクライアント側でも入力補助のために入力チェックを行えますが、それはあくまで補助的なもので、クライアント側の入力チェックは簡単に回避されてサーバー側へ送信できます。

つまりシステムが想定していない値や、不正な値が送信される可能性があるので、これらによる誤入力や誤動作を防ぐため入力チェックを常にする必要があります。 SpringMVCでは入力チェックを行う機能が搭載されていますので、これを利用した入力チェックの基本的な実装方法と、いくつかの応用的な実装方法を紹介します。

入力した項目に対して値の検証をし、その結果を扱う

Webクライアントから受け取った値をControllerで制御します。 リクエストしたURLとパラメータの値は、Controllerのメソッドに引数を定義して受け取ります。 受け取ったパラメータに対しては、必ず入力した項目に対してWebアプリケーション側が受け取れる想定の値であるか、または入力を拒否する値なのかを検証をし、その結果に応じて後続の処理を行うのか、エラーとして終了するのかを制御します。

SpringMVCでの入力チェック機能を使うには、入力したパラメータに対して jakarta.validation.constraintsパッケージのアノテーションないしはorg.springframework.validation.annotation.Validatedアノテーションを用いて入力パラメータの検証を定義します。

以下の例は、リクエストパラメータのnameに対して、nullであってはならないという入力チェックを定義しています。

@PostMapping("/example")
public String example(@RequestParam @NotNull String name) {
    
}

入力チェックエラーを受け取った後のハンドリング

入力チェックの結果は、入力したパラメータの引数のすぐ次に org.springframework.validation.BindingResult を定義すると、そこに入力チェックの結果が格納されます。BindingResultは入力チェックの結果を扱うためのクラスで、入力チェックエラーの有無やエラーの内容を取得できます。

@PostMapping("/example")
public String example(@RequestParam @NotNull String name, BindingResult bindingResult) {
    if (bindingResult.hasFieldErrors()) {
        // 入力チェックエラーがある場合の処理
        //  .....
    }
}

入力した項目を1つのクラスにまとめて、入力チェックを行う

Webアプリケーションで受け取るパラメータは単一ではなく、複数存在する方がほとんどです。 この複数の入力パラメータの組み合わせを1つのクラスにまとめるクラス、いわゆるフォームクラスやフォームバッキングオブジェクトと呼ばれるクラスを用意して入力パラメータを格納します。本記事ではフォームクラスと呼びます。

フォームクラスに対して入力チェックを設定するには、パラメータに対して宣言するのと同様にフォームクラスへ@Validatedを付与し、そして項目ごとのチェック内容をこのフォームクラス内の変数へ定義します。

public class ExampleForm {
    @NotNull
    private String name;

    @Min(0)
    private Integer age;
}
@PostMapping("/example")
public String example(@Validated ExampleForm form, BindingResult bindingResult) {
    if (bindingResult.hasFieldErrors()) {
        // 入力チェックエラーがある場合の処理
        //  .....
    }
}

これにより入力パラメータの多寡に関わらず実装を揃えられ、Controllerの実装もシンプルになります。

高度な入力チェックを実装する

ここからは、より高度な入力チェックの実装方法を紹介します。

複数のフォームクラスと複数の入力チェックに対応する

作成するWebアプリケーションの画面によっては、複数の画面や画面のコンポーネントごとにフォームクラスをわけることで入力内容と画面と揃えて、1つのControllerで受け取る実装にもできます。 そのときもControllerではフォームクラスごとに入力チェックの内容をわけることができ、先述の入力チェックエラーを扱うBindingResultクラスも複数にわけて定義します。

@PostMapping("/example")
public String example(@Validated ExampleForm form, BindingResult formBindingResult, @Validated AnotherForm anotherForm, BindingResult anotherFormBindingResult) {
    if (formBindingResult.hasFieldErrors()) {
        // formの入力チェックエラーがある場合の処理
        //  .....
    }
    if (anotherFormBindingResult.hasFieldErrors()) {
        // anotherFormの入力チェックエラーがある場合の処理
        //  .....
    }
}

アプリケーション全体で共通化した入力チェックを定義する

アプリケーション全体で共通して利用する入力値に対して、各Controllerや各Formにて個別に入力チェックを定義するのではなく、あらかじめ共通化した入力チェックを定義しておく方法です。これにより、複数のControllerで同じ入力チェックを毎回定義する必要がなくなります。 以下に、入力パラメータに対して新たな入力チェックを追加する方法と、応用例の1つとしてデータベースへ問い合わせる入力チェックの定義方法を紹介します。

データベースを利用した入力チェックを行う

入力パラメータを使ってデータベースに問い合わせ、その値が存在するデータなのかを確認したい場合があります。静的なフォーマットチェックや不正な値のチェックが完了したあとなら、データベースを利用した入力チェックを1つのValidationとして分離して定義する方法も有効です。 Controllerの実装例を次に示します。

@Controller
@AllArgsConstructor
@Slf4j
@RequestMapping("/items")
public class ItemController {

    @ModelAttribute("itemSearchForm")
    public ItemSearchForm itemSearchForm() {
        return new ItemSearchForm();
    }

    @GetMapping("/detail")
    public ModelAndView findBy(ModelAndView mav, @Validated(ValidationOrder.class) ItemSearchForm itemSearchForm, BindingResult result, RedirectAttributes redirectAttributes) {
        if (result.hasErrors()) {
            // 入力チェックエラーがある場合の処理
        }
    }
}

フォームクラスにて、@StockExistsConstraintという独自の入力チェックを定義しています。これでこの項目に対して、StockExistsConstraintで定義した入力チェックが行われます。 groups属性を利用することで入力チェックの順番を定義できます。

@Getter @Setter
public class ItemSearchForm {
    @NotNull(message = "商品IDは必須です", groups = StaticValidators.class)
    @NotBlank(message = "商品IDは空文字であってはいけません", groups = StaticValidators.class)
    @StockExistsConstraint(groups = DynamicValidator.class)
    private String itemId;
}

Controllerに付与している ValidationOrderクラスや、フォームクラスで定義しているgroups属性で、入力チェックの順番を定義しています。 これらについては後述します。

次に、新たに作成した独自の入力チェックを定義します。その手順は以下のとおりです。

  • 入力チェックのアノテーションを作成する
  • 入力チェックの実装クラスを作成する

入力チェックのアノテーションを作成する

@Constraintにて、入力チェックの実装クラスを指定します。これで @StockExistsConstraintを定義した項目に対して、StockExistsValidatorクラスの実装で入力チェックが行われるようになります。

import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.*;

import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

@Retention(RUNTIME)
@Target({FIELD, METHOD})
@Constraint(validatedBy = StockExistsValidator.class)
public @interface StockExistsConstraint {
    String message() default "在庫がありません";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

入力チェックの実装クラスを作成する

先ほど宣言したアノテーションにて指定したクラスを作成します。jakarta.validation.ConstraintValidatorインターフェースを実装し、拡張するメソッドにて入力チェックのロジックを実装します。

以下の例は、商品情報を管理するリポジトリから、入力した商品IDを検索してそれが存在するかを返す実装です。 街灯する商品が無い場合はOptional.EMPTYを返す実装になっていますので、存在しない場合はこの入力チェックの結果はfalseになります。 このメソッドからfalseが返ると入力チェックエラーとなり、エラーメッセージはアノテーションで定義したものが利用されます。

なお、入力チェックの実装そのものはSpringのコンポーネントに登録する必要はありませんが、Springから参照できるようにコンポーネントとして登録しておくと、同じようにSpringに登録している @Service@Repositoryで宣言して管理対象にしているクラスへのアクセスができます。これでValidatorクラスからもデータベースへのアクセスが簡単にできます。

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

import org.springframework.stereotype.Component;

import com.github.apz.sample.repository.ItemRepository;

import lombok.AllArgsConstructor;

@Component @AllArgsConstructor
public class StockExistsValidator implements ConstraintValidator<StockExistsConstraint, String> {
    
    private ItemRepository itemRepository;
    
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        try {
            Integer.valueOf(value);
        } catch (NumberFormatException e) {
            return false; // 数値でない場合は無効
        }
        return itemRepository.findById(Integer.valueOf(value)).isPresent();
    }

}

入力チェックの順番を定義する

Controllerでは以下のように ItemSearchFormに対して、@Validated(ValidationOrder.class)と宣言していました。これによりValidationOrderクラスで定義した順番で入力チェックが行われるようになります。

   @GetMapping("/detail")
    public ModelAndView findBy(ModelAndView mav, @Validated(ValidationOrder.class) ItemSearchForm itemSearchForm, BindingResult result, RedirectAttributes redirectAttributes) {
    
    }

ValidationOrderの定義は以下です。GroupSequenceアノテーションを利用して、StaticValidatorsクラスで定義した入力チェックを先に行い、そのあとにDynamicValidatorクラスで定義した入力チェックを行うように定義しています。 また、StaticValidatorsクラスで定義した入力チェックのどれかがエラーになった場合は、後続のDynamicValidatorクラスで定義した入力チェックは行われず、StaticValidatorsクラスの入力チェックのエラーが優先されるようになります。

import jakarta.validation.GroupSequence;

@GroupSequence({StaticValidators.class, DynamicValidator.class})
public interface ValidationOrder {

}

StaticValidatorsDynamicValidatorは、以下の空インタフェースです。

public interface StaticValidators {

}
public interface DynamicValidator {

}

そしてフォームクラスの入力チェックにて、以下のgroups属性を指定していました。

@Getter @Setter
public class ItemSearchForm {
    @NotNull(message = "商品IDは必須です", groups = StaticValidators.class)
    @NotBlank(message = "商品IDは空文字であってはいけません", groups = StaticValidators.class)
    @StockExistsConstraint(groups = DynamicValidator.class)
    private String itemId;
}

このItemSearchFormの入力チェックが行われたときには、まずStaticValidatorsクラスで定義した入力チェックが行われます。groupsを指定しない場合は、入力チェックエラーはすべて実施されますので、独自実装をしたStockExistsConstraintの入力チェックも実施されてしまいます。 この場合、せっかく手前の@NotNull@NotBlankの入力チェックでエラーが出ているのに、後続の@StockExistsConstraintの入力チェックも行われてしまい、不要な処理やエラー制御が必要になってしまいますので、groupsで入力チェックの順番を定義しています。

AIを利用して入力チェックを実装する

ここからは、アソビューでのAIを活用したバリデーションの実装事例を紹介します。あるタスクにおいて、AIにControllerの実装を任せたところ、実装方針がまちまちになり、誤った実装になることがありました。今までに紹介したような、独自ルールの実装パターンもあるため、AIにとって難易度が高かったのだと思います。

具体的には、他の実装済みControllerの入力チェックから類似する実装を拾ってくる、エラーハンドリング後の遷移方法を間違えるなどです。一見すると初歩的なミスにも見えますが、実装方法が複数存在して曖昧になりがちであるため発生した問題でした。 そのため私はAIに実装させるための参考実装となるControllerを指定し、そしてコーディング規約とルールを定め、入力チェックの方法もルール化と参考実装を示すことで、期待通りに実装できました。

定めたルールの概略は以下です

  • 入力チェックの順番と優先順位
    • ショートカットさせる入力チェックの定義方法
  • エラーハンドリングの実装

参考実装は以下です。入力パラメータを扱うフォームクラス、入力チェックの定義、入力チェックエラーの結果とハンドリングを実装しています。

@Controller
@AllArgsConstructor
@Slf4j
@RequestMapping("/items")
public class ItemController {

    @ModelAttribute("itemSearchForm")
    public ItemSearchForm itemSearchForm() {
        return new ItemSearchForm();
    }

    private ItemService itemService;

    @GetMapping("")
    public ModelAndView index(ModelAndView mav) {
        mav.setViewName("items/index");
        mav.addObject("stocks", itemService.findStocks());
        return mav;
    }

    @GetMapping("/detail")
    public ModelAndView findBy(ModelAndView mav, @Validated(ValidationOrder.class) ItemSearchForm itemSearchForm, BindingResult result, RedirectAttributes redirectAttributes) {
        if (result.hasErrors()) {
            log.info("validation error: {}", result.getAllErrors().get(0).getDefaultMessage());
            redirectAttributes.addFlashAttribute("error", result.getAllErrors().get(0).getDefaultMessage());
            mav.setViewName("redirect:/items");
            return mav;
        }
        mav.addObject("stocks", itemService.findStocks());
        var stock = itemService.findById(Integer.valueOf(itemSearchForm.getItemId()));
        mav.addObject(stock.orElseThrow(() -> new IllegalStateException("在庫が見つかりませんでした")));
        mav.setViewName("items/detail");
        return mav;
    }
}

この参考実装を元に、商品を登録する機能を生成させたのが以下です。/register のPOSTリクエスト、ならびに登録用のフォームクラスの実装が生成されました。

@Controller
@AllArgsConstructor
@Slf4j
@RequestMapping("/items")
public class ItemController {

    @ModelAttribute("itemSearchForm")
    public ItemSearchForm itemSearchForm() {
        return new ItemSearchForm();
    }

    @ModelAttribute("itemRegisterForm")
    public ItemRegisterForm itemRegisterForm() {
        return new ItemRegisterForm();
    }

    private ItemService itemService;

    @GetMapping("")
    public ModelAndView index(ModelAndView mav) {
        mav.setViewName("items/index");
        mav.addObject("stocks", itemService.findStocks());
        return mav;
    }

    @GetMapping("/detail")
    public ModelAndView findBy(ModelAndView mav, @Validated(ValidationOrder.class) ItemSearchForm itemSearchForm, BindingResult result, RedirectAttributes redirectAttributes) {
        if (result.hasErrors()) {
            log.info("validation error: {}", result.getAllErrors().get(0).getDefaultMessage());
            redirectAttributes.addFlashAttribute("error", result.getAllErrors().get(0).getDefaultMessage());
            mav.setViewName("redirect:/items");
            return mav;
        }
        mav.addObject("stocks", itemService.findStocks());
        var stock = itemService.findById(Integer.valueOf(itemSearchForm.getItemId()));
        mav.addObject(stock.orElseThrow(() -> new IllegalStateException("在庫が見つかりませんでした")));
        mav.setViewName("items/detail");
        return mav;
    }

    @PostMapping("/register")
    public ModelAndView register(ModelAndView mav, @Validated ItemRegisterForm itemRegisterForm, BindingResult result, RedirectAttributes redirectAttributes) {
        if (result.hasErrors()) {
            log.info("validation error: {}", result.getAllErrors().get(0).getDefaultMessage());
            redirectAttributes.addFlashAttribute("error", result.getAllErrors().get(0).getDefaultMessage());
            mav.setViewName("redirect:/items");
            return mav;
        }
        var stock = itemService.register(itemRegisterForm.getName(), itemRegisterForm.getPrice());
        redirectAttributes.addFlashAttribute("success", "商品を登録しました。ID: " + stock.getItemId());
        mav.setViewName("redirect:/items");
        return mav;
    }
}

期待通りの結果が得られました。

まとめ

SpringMVCで入力チェックを行う方法と、そこからAIに実装させるためのルールや参考実装を示すことの重要性について紹介しました。

具体的なルールを定め、さらに実装例を示すことで、期待通りの実装が手間なくでき、横展開が可能になることも実際に体験できました。

  • AIに生成させるときには明示的なルールを事前に定めるのが重要
  • 参考となる実装例を示すと期待通りの実装が生成され、コードレビューも容易になる

このあたりのことは、AIに限らず人間が実装するときにも同様ですけれども、AIで生成するときには、どこまでが汎用的な実装で良いのか、どこまでが独自のルールで実装してほしいのかを明示しておくのが良いかと思います。

参考実装

https://github.com/A-pZ/spring-sample-validations/tree/main

最後に

アソビューでは、「生きるに、遊びを。」を実現するための良いプロダクトを世の中に届けられるよう共に挑戦していく様々なエンジニアを募集しています。 カジュアル面談のご希望も随時お受けしておりますので、お気軽にエントリーください!
お待ちしております。 https://www.asoview.co.jp/career/engineer