普通の Spring Boot 2.0 Web Applicatrion 〜 登録・更新処理と Bean Validataion 〜

Spring Boot 2 で、なるべく標準的なやり方で、トラディショナルな Spring MVC による Web Application を作成するチュートリアルを数回に分けて。

の3回目です。

目次

今回は 「登録・更新処理と Bean Validataion」の回となります。


前回は以下のような一覧表示までをやりました。

f:id:Naotsugu:20180516232409p:plain


サービスへ登録処理を追加

MemberService に登録用のメソッド register() と更新用のメソッド update() を追加します。

MemberService は以下のようになります。

@Service
@Transactional
public class MemberService {

    private MemberRepository repository;

    public MemberService(MemberRepository repository) {
        this.repository = repository;
    }
    
    @Transactional(readOnly = true)
    public Page<Member> finaAll(Pageable pageable) {
        return repository.findAll(pageable);
    }
    
    @Transactional(readOnly = true)
    public Member get(long memberId) {
        return repository.findById(memberId).get();
    }

    public Member register(Member member) {
        return repository.save(member);
    }

    public Member update(Member member) {
        return repository.save(member);
    }
}

1件取得用の get() も合わせて定義しました。


登録も更新も repository.save() を呼んでいます。 これは Spring Data の save() は以下のようになっているためです。

   @Transactional
    public <S extends T> S save(S entity) {
        if (entityInformation.isNew(entity)) {
            em.persist(entity);
            return entity;
        } else {
            return em.merge(entity);
        }
    }

idnull の場合は em.persist()id があれば em.merge() となります。

リポジトリのメソッドは共有ですが、サービスのメソッドとしては、ビジネス意図を表すため register()update() に分けて定義します。


サービスにはクラスレベルのアノテーションとして @Transactional を付けています。これによりこのサービスのメソッドが自動的にトランザクション配下に置かれます。つまりトランザクションの開始やコミット/ロールバックが自動で行われるようになります。

ロールバックの注意点として、非検査例外(RuntimeException及びそのサブクラス)が発生した場合はロールバックされますが、検査例外(Exception及びそのサブクラスでRuntimeExceptionのサブクラスじゃないもの)が発生した場合はロールバックされずコミットされる動きとなるため注意が必要です。


読み取り用のメソッドには @Transactional(readOnly = true) を付けることで読み取り専用とマークしています。 これによりこのメソッドではメソッド終了時にトランザクションのコミットもロールバックも行われなくなります。

トランザクションアノテーションはメソッドレベルだけで定義することもできますが、付け忘れなど発生するため、クラスレベルで定義して必要箇所を上書き設定するのが良いやり方です。


登録用コントローラメソッドと Bean Validation

登録用コントローラメソッドを MemberController に追加します。

    @GetMapping("/members/new")
    public String initCreationForm(Model model) {
        model.addAttribute("memberForm", MemberForm.of());
        return "members/memberEdit";
    }

    @PostMapping("/members/new")
    public String processCreationForm(@Valid MemberForm form, BindingResult result) {
        if (result.hasErrors()) {
            return "members/memberEdit";
        } else {
            service.register(form.toModel());
            return "redirect:/members";
        }
    }


initCreationForm() は登録画面の初期表示をい GETリクエストに応答します。

model.addAttribute() でモデルに登録用にインスタンス化した空のフォーム(後述)を追加しています。

model.addAttribute() の第一引数で属性名を指定していますが、指定しない場合は自動的に(先頭を小文字にした)クラス名として追加されます。


processCreationForm() は画面から登録ボタンを押した際のメソッドで @PostMapping を指定しているため POST メソッドに応答します。

第一引数にはフォーム、第二引数には BindingResult を指定しています。

MemberForm には @Valid アノテーションをつけています。

これにより、自動的に Bean Validation が行われるようになります。 Bean Validation の結果は、BindingResult に格納されるため、コントローラメソッド内でエラー内容に応じた処理が可能となります。


コントローラメソッドの引数でよく使うものをまとめておきます。

オプション 説明
@ModelAttribute メソッドに付けた場合はコントローラメソッドを実行する前に呼ばれ、メソッドの戻り値のオブジェクトが自動的に Model に追加される。引数に付けた場合対象オブジェクト(Modelになければインスタンス化する)にリクエスト内容をバインディングする(モデル属性名とクラス名が同じ場合は省略できる)。
@Valid Bean Validation (JSR-303/JSR-349) を行う。
BindingResult リクエストからのデータバインディング結果が記録される。コントローラ内でデータバインディング時のエラー内容に応じた処理ができる。必ず対象とするモデル属性の後ろに定義する。
RedirectAttributes リダイレクト先にパラメータを渡す場合に利用する。


@ModelAttribute@Valid アノテーションは以下の組み合わせで指定することができます。

モデルに追加する属性の属性名により書き方が変わるため注意してください。

// model.addAttribute("memberForm", new MemberForm()) とした場合
public String process(@Valid MemberForm f, BindingResult r) {}

// @ModelAttribute を明示しても同じ
public String process(@ModelAttribute @Valid MemberForm f, BindingResult r) {}


// model.addAttribute("form", new MemberForm()) とした場合は @ModelAttribute で指定
public String process(@ModelAttribute("form") @Valid MemberForm f, BindingResult r) {}


フォームの作成

Member オブジェクトをそのまま View で使うこともできますが、その場合 View 側で setter を介したアクセスが必要になります(たいていの場合)。

今回は Member には setter を定義しないこととしているため、入力用のフォームを別途作成します。

package stdweb.web;

import stdweb.domain.Member;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
import java.io.Serializable;

public class MemberForm implements Serializable {

    @NotNull
    @Size(min = 1, max = 25)
    private String name = "";

    @NotNull
    @Pattern(regexp = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*$", message = "not email")
    private String email = "";

    public static MemberForm of() {
        return new MemberForm();
    }

    public static MemberForm of(Member member) {
        if (member == null) {
            return MemberForm.of();
        }
        MemberForm form = MemberForm.of();
        form.name = member.getName();
        form.email = member.getEmail();
        return form;
    }

    public Member toModel() {
        return new Member(name, email);
    }

    public Member toModel(long id) {
        return new Member(id, name, email);
    }


    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
}


フォームでは Bean Validataion を利用するため javax.validation.constraints の各種制約を定義しています。

また Member との値交換用のメソッドも合わせて定義しました。


登録用にフォームを用意せず Member へ直接値を設定するようにした場合、画面別で異なるバリデーションが必要だったり、メッセージ内容を画面毎で切り替える必要があったりすると対応が難しくなるケースがあります。

特に @Embedded で定義した値オブジェクトに制約を定義して Bean Validataion を行った場合のメッセージ出力で困ることがあります。例えば住所を Address という値オブジェクトにした場合、配送先住所なのか請求先住所なのか、エラーメッセージを出し分けたいなどといった場合に上手く対応できないことがあります。

一方フォームを使う場合は値交換といった無駄な処理が必要となり、この辺りはトレードオフになるためアプリケーションの特性に応じてどちらを採用するかを決める必要があります。


登録画面用の View 定義

src/main/resources/templates/members/memberEdit.html を以下のように作成します。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Member</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/css/bootstrap.min.css"
          integrity="sha384-WskhaSGFgHYWDcbwN70/dfYBj47jz9qbsMId/iRN3ewGhXQFZCSftd1LZCfmhktB"
          crossorigin="anonymous">
</head>

<body>
<div class="container">
<h2>Member</h2>

<form action="#" th:action="@{''}" th:object="${memberForm}" class="form-horizontal" method="post">

    <div class="form-group">
        <label th:for="name">Name</label>
        <input type="text" class="form-control" th:field="*{name}"
               th:class="${'form-control' + (#fields.hasErrors('name') ? ' is-invalid' : '')}"/>
        <div class="invalid-feedback" th:errors="*{name}">Name Error</div>
    </div>

    <div class="form-group">
        <label th:for="email">Email address</label>
        <input type="text" class="form-control" th:field="*{email}"
               th:class="${'form-control' + (#fields.hasErrors('email') ? ' is-invalid' : '')}"/>
        <div class="invalid-feedback" th:errors="*{email}">Email Error</div>
    </div>

    <div class="form-group">
        <button class="btn btn-outline-primary" type="submit">Save</button>
    </div>
</form>

</div>
</body>
</html>

ここで注目したいのは以下です。

  • th:object="${memberForm}"
    • ​オブジェクトを設定すると、タグ配下で *{} でオブジェクトの属性値を(オブジェクト指定を省略して)得ることができる
  • th:field="*{name}"
    • Thymeleaf の Spring 拡張で、id、name、value の設定をよしなにやってくれる
  • #fields.hasErrors('name')
    • #fields というユーティリティオブジェクトでエラーの発生を判断できる
  • th:errors="*{name}"
    • 該当属性のエラー内容を取得できます


入力フィールドに is-invalid クラスを付与した場合、invalid-feedback クラスの内容が Visible となって表示されます。

このクラスは Bootstrap の定義によるものです。


実行すると以下のようになります

f:id:Naotsugu:20180523221345p:plain


バリデーションエラー発生じは以下のようになります。

f:id:Naotsugu:20180523221404p:plain


更新用コントローラメソッドの作成

memberEdit.html は登録画面と共用することにし、更新用のコントローラメソッドを以下のように追加します。

    @GetMapping("/members/{memberId}/edit")
    public String initUpdateMemberForm(@PathVariable("memberId") long memberId, Model model) {
        Member member = service.get(memberId);
        model.addAttribute(MemberForm.of(member));
        return "members/memberEdit";
    }

    @PostMapping("/members/{memberId}/edit")
    public String processUpdateMemberForm(@Valid MemberForm form, BindingResult result,
                                          @PathVariable("memberId") long memberId) {
        if (result.hasErrors()) {
            return "members/memberEdit";
        } else {
            service.update(form.toModel(memberId));
            return "redirect:/members";
        }
    }


今まで見てきたものと大体同じです。

コントローラ全体は以下のようになりました。

package stdweb.web;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import stdweb.app.MemberService;
import stdweb.domain.Member;

import javax.validation.Valid;


@Controller
public class MemberController {

    private static final Logger log = LoggerFactory.getLogger(MemberController.class);

    private final MemberService service;

    public MemberController(MemberService service) {
        this.service = service;
    }


    @GetMapping("/members")
    public String members(Model model) {
        Page<Member> results = service.finaAll(PageRequest.of(0, 10));
        model.addAttribute("members", results);
        return "members/membersList";
    }


    @GetMapping("/members/new")
    public String initCreationForm(Model model) {
        model.addAttribute("memberForm", MemberForm.of());
        return "members/memberEdit";
    }

    @PostMapping("/members/new")
    public String processCreationForm(@Valid MemberForm form, BindingResult result) {
        log.error(form.getName());
        log.error(result.toString());
        if (result.hasErrors()) {
            return "members/memberEdit";
        } else {
            service.register(form.toModel());
            return "redirect:/members";
        }
    }


    @GetMapping("/members/{memberId}/edit")
    public String initUpdateMemberForm(@PathVariable("memberId") long memberId, Model model) {
        Member member = service.get(memberId);
        model.addAttribute(MemberForm.of(member));
        return "members/memberEdit";
    }

    @PostMapping("/members/{memberId}/edit")
    public String processUpdateMemberForm(@Valid MemberForm form, BindingResult result,
                                          @PathVariable("memberId") long memberId) {
        if (result.hasErrors()) {
            return "members/memberEdit";
        } else {
            service.update(form.toModel(memberId));
            return "redirect:/members";
        }
    }
}


以上で登録用処理と更新用の処理が追加できました。

一覧画面からの遷移は次回に持越します。