導入と properties の統合及び UTF-8 化

まず build.gradle に以下を定義する:

dependencies {
    compile('org.springframework.boot:spring-boot-starter-validation')
}

これですぐに使えるわけだが、その前にバリデータの設定をする。 後述するがバリデーションメッセージに関してはデフォルトで ValidationMessages.properties に定義されているものを使用するのだが、これも後述するがバリデーションメッセージのプレースホルダに適用されるフィールド名は messages.properties のものが使用されるので 2 箇所に書くことになってしまう。 できれば messages.properties に両方書くようにしたい。 更にデフォルトで properties ファイルは UTF-8 エンコーディングになっていない。 これも UTF-8 に変更したい。

これを実現するには以下のような WebMvcConfigurer を実装したコンフィギュレーションファイルを定義する:

@Configuration
class Configuration : WebMvcConfigurer {

    /**
     * バリデータを返す.
     *
     * @return バリデータ
     */
    override fun getValidator(): Validator? {
        val source = ReloadableResourceBundleMessageSource().also {
            it.setBasename("classpath:messages")  // ValidationMessages.properties でなく messages.properties を使用する
            it.setDefaultEncoding("UTF-8")  // エンコーディングとして UTF-8 を使用する
        }
        return LocalValidatorFactoryBean().also { it.setValidationMessageSource(source) }
    }
}

ちなみに Spring 4 以前は WebMvcConfigurerAdapter というインターフェースを使用していたようだが Spring 5 (Spring Boot 2.0) では非推奨となり WebMvcConfigurer を使用するようになった。

messages.properties でなく messages.yaml などというファイルを置くとよろしくやってくれるのかどうか試したのだが駄目だった。 残念。

バリデーション例

例えば以下のように messages.properties に定義してあるものとする:

# フィールド名
date=日付
name=名前
tag=タグ
markdown=Markdown

# バリデーションメッセージ (デフォルトの差し替え)
javax.validation.constraints.NotBlank.message={0} を入力してください。

# カスタムバリデーションメッセージ
validation.max-length={0} は {1} 文字以下で入力してください。
validation.not-selected={0} を選択してください。

javax.validation.constraints.NotBlank.message に関してはバリデーションで使用する @NotBlank アノテーションでのエラー時に表示されるメッセージの差し替えである。 この場合 @NotBlank アノテーションのパッケージ名を含めたクラス名が javax.validation.constraints.NotBlank の為それに .message を加えたものを定義しておくとデフォルトメッセージの差し替えができる。 @NotEmpty@Size なども同様となる。

尚、こういう全体的に適用できるバリデーションメッセージだけでなく項目ごとに個別に指定したいバリデーションエラーメッセージがある。 例えば @Size によるバリデーションエラーは「0 文字以上 64 文字以下」のような表示になってしまうが、多くの場合は「0 文字」は不要で最大桁数のみ通知すればいいはずだ。 こういう場合に任意のプロパティ名でカスタムバリデーションメッセージを定義しておく。

フィールド名に関してはフォームの POST 時に使用する Form インスタンスのフィールド名と同じにしておく。

Form

class PostForm {

    // 必須
    @NotBlank
    var date: String = ""

    // 必須かつ 64 文字以内
    @NotBlank @Size(min = 0, max = 64, message = "{validation.max-length}")
    var name: String = ""

    // 選択必須
    @NotEmpty(message = "{validation.not-selected}")
    var tag: Array<Int> = arrayOf()

    // 必須
    @NotBlank
    var markdown: String = ""
}

上記の例のようにバリデーションエラー時に表示されるカスタムメッセージを messages.properties を使用して指定したい場合は {validation.max-length} のような記法で書く。 {} を付けずに書くと任意の文字列を指定できるが、折角 messages.properties が使えるのにハードコーディングすることもないだろう。

Controller

@Controller
class MyController {

    /**
     * 記事追加画面を表示する.
     *
     * @param model モデル
     * @param form PostForm
     * @return template 名
     */
    @GetMapping("/posts/add")
    fun addPost(model: Model, @ModelAttribute("form") form: PostForm): String = "posts/add"

    /**
     * 記事を追加する.
     *
     * @param model モデル
     * @param form PostForm
     * @param result BindingResult
     * @return template 名
     */
    @PostMapping("/posts/add")
    fun savePost(model: Model, @Validated @ModelAttribute("form") form: PostForm, result: BindingResult): String {
        if (result.hasErrors()) {
            return "/posts/add"
        }
        
        // TODO 登録処理
        return "redirect:/posts"
    }
}
  • 登録画面初期表示時の GET と POST を分ける (Form に対するバリデーション指定の為)
  • BindingResultForm の直後の引数として定義する (※位置が違うと正しく機能しないので注意) とメソッド内で result.hasErrors() でバリデーションエラーの有無を取得できる (BindingResult がないとメソッドの中まで処理が進まずに弾かれてしまう)
  • 登録画面初期表示時の GET のメソッドの方にも Form を含めたほうが良い (フォーム項目の初期データの表示時に Form に直接値をセットすれば良い)
  • FormModel は HTML 内の <form> によって POST される項目か否かで使い分けると良さそうに見える

Thymeleaf

<form th:action="@{/posts/add}" method="POST" th:object="${form}">
    <div>
        <input type="text" id="date" name="date" th:value="*{date}" th:classappend="${#fields.hasErrors('*{date}') ? 'is-invalid' : ''}">
        <div class="invalid-feedback" th:if="${#fields.hasErrors('*{date}')}" th:errors="*{date}"></div>
    </div>
    <div>
        <input type="text" id="name" name="name" th:value="*{name}" th:classappend="${#fields.hasErrors('*{name}') ? 'is-invalid' : ''}">
        <div class="invalid-feedback" th:if="${#fields.hasErrors('*{name}')}" th:errors="*{name}"></div>
    </div>
    <div>
        <select class="custom-select" id="tag" name="tag" multiple="multiple" th:classappend="${#fields.hasErrors('*{tag}') ? 'is-invalid' : ''}">
            <option th:each="t : ${tags}" th:value="${t.id}" th:text="${t.name}" th:selected="${#arrays.contains(form.tag, t.id)}"></option>
        </select>
        <div class="invalid-feedback" th:if="${#fields.hasErrors('*{tag}')}" th:errors="*{tag}"></div>
    </div>
    <div>
        <textarea id="markdown" name="markdown" class="form-control" rows="16" th:classappend="${#fields.hasErrors('*{markdown}') ? 'is-invalid' : ''}">[[*{markdown}]]</textarea>
        <div class="invalid-feedback" th:if="${#fields.hasErrors('*{markdown}')}" th:errors="*{markdown}"></div>
    </div>
    <div>
        <input type="submit" value="保存"/>
    </div>
</form>

<form> の内容を全部書いたので若干見づらいが重要なのは ${#fields.hasErrors('(Form フィールド名)')} でそのフィールドにエラーがあるかどうかが取得でき th:errors="(Form フィールド名)" で対応するエラーメッセージが要素内のテキストノードに格納されるということだ。 コードにも例示したがエラーの有無で入力フォームの見た目を変えたい場合は th:classappend を使用して class 属性を追加して CSS で見た目を変更すれば良い。

ここまで定義した内容で名前以外すべて空、名前は最大文字数をオーバーした状態で submit すると以下のようにエラーメッセージが表示される:

  • 日付 を入力してください。
  • 名前 は 64 文字以下で入力してください。
  • タグ を選択してください。
  • Markdown を入力してください。