Spring Boot DB連携

Spring BootのWEB画面上で排他制御を実装してみた(ソースコード編)

今回も引き続き、排他制御の1つである「悲観的排他制御」を利用した実装について述べる。ここでは、具体的なサンプルプログラムのソースコードを共有する。

前提条件

下記記事を参照のこと。

Spring BootのWEB画面上で排他制御を実装してみた(完成イメージと前提条件)今回は、データ更新処理内に排他制御を加えてみたので、そのサンプルプログラムを共有する。 排他制御は、1つのデータに複数のアクセスが...

作成したサンプルプログラムの内容

作成したサンプルプログラムの構成は以下の通り。
サンプルプログラムの構成
なお、上図の赤枠は、前提条件に記載したソースコードと比較し、変更になったソースコードを示す。赤枠のソースコードについては今後記載する。

まず、ユーザーデータ(USER_DATA)テーブルにアクセスするMapperクラスは以下の通り。今回は、悲観ロックをかけてデータ取得するfindByIdForUpdateメソッドを追加していて、ここでは5秒待つようになっている。

package com.example.demo;

import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Update;
import java.util.Collection;

@Mapper
public interface UserDataMapper {

    /**
     * ユーザーデータテーブル(user_data)を全件取得する
     * @return ユーザーデータテーブル(user_data)を全データ
     */
    @Select("SELECT u.id, u.name, u.birth_year as birthY, u.birth_month as birthM"
            + ", u.birth_day as birthD, u.sex as sex, m.sex_value as sex_value "
            + " FROM USER_DATA u, M_SEX m WHERE u.sex = m.sex_cd ORDER BY u.id ")
    Collection<UserData> findAll();

    /**
     * 指定したIDをもつユーザーデータテーブル(user_data)のデータを取得する
     * @param id ID
     * @return ユーザーデータテーブル(user_data)の指定したIDのデータ
     */
    @Select("SELECT id, name, birth_year as birthY, birth_month as birthM"
            + ", birth_day as birthD, sex FROM USER_DATA WHERE id = #{id}" )
    UserData findById(Long id);

    /**
     * 指定したIDをもつユーザーデータテーブル(user_data)のデータを
     * ロックをかけて取得する
     * @param id ID
     * @return ユーザーデータテーブル(user_data)の指定したIDのデータ
     */
    @Select("SELECT id, name, birth_year as birthY, birth_month as birthM"
            + ", birth_day as birthD, sex FROM USER_DATA WHERE id = #{id}"
            + " FOR UPDATE WAIT 5 ")
    UserData findByIdForUpdate(Long id);

    /**
     * 指定したIDをもつユーザーデータテーブル(user_data)のデータを削除する
     * @param id ID
     */
    @Delete("DELETE FROM USER_DATA WHERE id = #{id}")
    void deleteById(Long id);

    /**
     * 指定したユーザーデータテーブル(user_data)のデータを追加する
     * @param userData ユーザーデータテーブル(user_data)の追加データ
     */
    @Insert("INSERT INTO USER_DATA (id, name, birth_year, birth_month, birth_day, sex)"
            + " VALUES (#{id}, #{name}, #{birthY}, #{birthM}, #{birthD}, #{sex})")
    void create(UserData userData);

    /**
     * 指定したユーザーデータテーブル(user_data)のデータを更新する
     * @param userData ユーザーデータテーブル(user_data)の更新データ
     */
    @Update("UPDATE USER_DATA SET name = #{name}, birth_year = #{birthY}"
            + ", birth_month = #{birthM}, birth_day = #{birthD}"
            + ", sex = #{sex} WHERE id = #{id}")
    void update(UserData userData);

    /**
     * ユーザーデータテーブル(user_data)の最大値IDを取得する
     * @return ユーザーデータテーブル(user_data)の最大値ID
     */
    @Select("SELECT NVL(max(id), 0) FROM USER_DATA")
    long findMaxId();
}

なお、select ~ for update文についての詳細は、以下のサイトを参照のこと。
https://www.shift-the-oracle.com/sql/select-for-update.html



また、主要な処理を含むServiceImplクラスは以下の通り。createOrUpdateメソッド内で、更新時はMapperクラスのfindByIdForUpdateメソッドを呼び出し、その後10秒待機し、更新処理を行うようにしている。

package com.example.demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.BindingResult;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

@Service
public class DemoServiceImpl implements DemoService{

    /**
     * ユーザーデータテーブル(user_data)へアクセスするマッパー
     */
    @Autowired
    private UserDataMapper mapper;

    /**
     * {@inheritDoc}
     */
    @Override
    public List<DemoForm> demoFormList() {
        List<DemoForm> demoFormList = new ArrayList<>();
        //ユーザーデータテーブル(user_data)から全データを取得する
        Collection<UserData> userDataList = mapper.findAll();
        for (UserData userData : userDataList) {
            demoFormList.add(getDemoForm(userData));
        }
        return demoFormList;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public DemoForm findById(String id) {
        Long longId = stringToLong(id);
        UserData userData = mapper.findById(longId);
        return getDemoForm(userData);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Transactional
    public void deleteById(String id){
        Long longId = stringToLong(id);
        mapper.deleteById(longId);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Transactional
    public void createOrUpdate(DemoForm demoForm){
        //更新・追加処理を行うエンティティを生成
        UserData userData = getUserData(demoForm);
        //IDが付与されていなければ追加し、
        //付与されていれば更新処理を行う
        if(demoForm.getId() == null){
            //追加処理
            userData.setId(mapper.findMaxId() + 1);
            mapper.create(userData);
        }else{
            Long longId = stringToLong(demoForm.getId());
            try{
                //更新時はロックをかけてユーザーデータを取得
                mapper.findByIdForUpdate(longId);
            }catch (Exception ex){
                throw new RuntimeException(ex);
            }
            //10秒ほど処理待ちする
            try{
                Thread.sleep(10000);
            }catch (InterruptedException ex){
                throw new RuntimeException(ex);
            }
            //更新処理
            mapper.update(userData);
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public String checkForm(DemoForm demoForm, BindingResult result, String normalPath){
        //formオブジェクトのチェック処理を行う
        if(result.hasErrors()){
            //エラーがある場合は、入力画面のままとする
            return "input";
        }
        //生年月日の日付チェック処理を行う
        //エラーがある場合は、エラーメッセージ・エラーフィールドの設定を行い、
        //入力画面のままとする
        int checkDate = DateCheckUtil.checkDate(demoForm.getBirthYear()
                , demoForm.getBirthMonth(), demoForm.getBirthDay());
        switch(checkDate){
            case 1:
                //生年月日_年が空文字の場合のエラー処理
                result.rejectValue("birthYear", "validation.date-empty"
                        , new String[]{"生年月日_年"}, "");
                return "input";
            case 2:
                //生年月日_月が空文字の場合のエラー処理
                result.rejectValue("birthMonth", "validation.date-empty"
                        , new String[]{"生年月日_月"}, "");
                return "input";
            case 3:
                //生年月日_日が空文字の場合のエラー処理
                result.rejectValue("birthDay", "validation.date-empty"
                        , new String[]{"生年月日_日"}, "");
                return "input";
            case 4:
                //生年月日の日付が不正な場合のエラー処理
                result.rejectValue("birthYear", "validation.date-invalidate");
                //生年月日_月・生年月日_日は、エラーフィールドの設定を行い、
                //メッセージを空文字に設定している
                result.rejectValue("birthMonth", "validation.empty-msg");
                result.rejectValue("birthDay", "validation.empty-msg");
                return "input";
            case 5:
                //生年月日の日付が未来日の場合のエラー処理
                result.rejectValue("birthYear", "validation.date-future");
                //生年月日_月・生年月日_日は、エラーフィールドの設定を行い、
                //メッセージを空文字に設定している
                result.rejectValue("birthMonth", "validation.empty-msg");
                result.rejectValue("birthDay", "validation.empty-msg");
                return "input";
            default:
                //性別が不正に書き換えられていないかチェックする
                if(!demoForm.getSexItems().keySet().contains(demoForm.getSex())){
                    result.rejectValue("sex", "validation.sex-invalidate");
                    return "input";
                }
                //エラーチェックに問題が無いので、正常時の画面遷移先に遷移
                return normalPath;
        }
    }

    /**
     * DemoFormオブジェクトに引数のユーザーデータの各値を設定する
     * @param userData ユーザーデータ
     * @return DemoFormオブジェクト
     */
    private DemoForm getDemoForm(UserData userData){
        if(userData == null){
            return null;
        }
        DemoForm demoForm = new DemoForm();
        demoForm.setId(String.valueOf(userData.getId()));
        demoForm.setName(userData.getName());
        demoForm.setBirthYear(String.valueOf(userData.getBirthY()));
        demoForm.setBirthMonth(String.valueOf(userData.getBirthM()));
        demoForm.setBirthDay(String.valueOf(userData.getBirthD()));
        demoForm.setSex(userData.getSex());
        demoForm.setSex_value(userData.getSex_value());
        return demoForm;
    }

    /**
     * UserDataオブジェクトに引数のフォームの各値を設定する
     * @param demoForm DemoFormオブジェクト
     * @return ユーザーデータ
     */
    private UserData getUserData(DemoForm demoForm){
        UserData userData = new UserData();
        if(!DateCheckUtil.isEmpty(demoForm.getId())){
            userData.setId(Long.valueOf(demoForm.getId()));
        }
        userData.setName(demoForm.getName());
        userData.setBirthY(Integer.valueOf(demoForm.getBirthYear()));
        userData.setBirthM(Integer.valueOf(demoForm.getBirthMonth()));
        userData.setBirthD(Integer.valueOf(demoForm.getBirthDay()));
        userData.setSex(demoForm.getSex());
        userData.setSex_value(demoForm.getSex_value());
        return userData;
    }

    /**
     * 引数の文字列をLong型に変換する
     * @param id ID
     * @return Long型のID
     */
    private Long stringToLong(String id){
        try{
            return Long.parseLong(id);
        }catch(NumberFormatException ex){
            return null;
        }
    }

}



また、コントローラクラスは以下の通り。データ更新を行うsendメソッド内で、WAITのタイムアウトエラー等が発生した場合は、システムエラー画面に遷移するようにしている。

package com.example.demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.SessionAttributes;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.support.SessionStatus;
import org.springframework.validation.annotation.Validated;
import java.util.ArrayList;
import java.util.List;

@Controller
@SessionAttributes(types = {DemoForm.class})
public class DemoController {

    /**
     * Demoサービスクラスへのアクセス
     */
    @Autowired
    private DemoService demoService;

    /**
     * ユーザーデータテーブル(user_data)のデータを取得して返却する
     * @return ユーザーデータリスト
     */
    @ModelAttribute("demoFormList")
    public List<DemoForm> userDataList(){
        List<DemoForm> demoFormList = new ArrayList<>();
        return demoFormList;
    }

    /**
     * Formオブジェクトを初期化して返却する
     * @return Formオブジェクト
     */
    @ModelAttribute("demoForm")
    public DemoForm createDemoForm(){
        DemoForm demoForm = new DemoForm();
        return demoForm;
    }

    /**
     * 初期表示(一覧)画面に遷移する
     * @param model Modelオブジェクト
     * @return 一覧画面へのパス
     */
    @RequestMapping("/")
    public String index(Model model){
        //ユーザーデータリストを取得
        List<DemoForm> demoFormList = demoService.demoFormList();
        //ユーザーデータリストを更新
        model.addAttribute("demoFormList", demoFormList);
        return "list";
    }

    /**
     * 更新処理を行う画面に遷移する
     * @param id 更新対象のID
     * @param model Modelオブジェクト
     * @return 入力・更新画面へのパス
     */
    @GetMapping("/update")
    public String update(@RequestParam("id") String id, Model model){
        //更新対象のユーザーデータを取得
        DemoForm demoForm = demoService.findById(id);
        //ユーザーデータを更新
        model.addAttribute("demoForm", demoForm);
        return "input";
    }

    /**
     * 削除確認画面に遷移する
     * @param id 更新対象のID
     * @param model Modelオブジェクト
     * @return 削除確認画面へのパス
     */
    @GetMapping("/delete_confirm")
    public String delete_confirm(@RequestParam("id") String id, Model model){
        //削除対象のユーザーデータを取得
        DemoForm demoForm = demoService.findById(id);
        //ユーザーデータを更新
        model.addAttribute("demoForm", demoForm);
        return "confirm_delete";
    }

    /**
     * 削除処理を行う
     * @param demoForm Formオブジェクト
     * @return 一覧画面の表示処理
     */
    @PostMapping(value = "/delete", params = "next")
    public String delete(DemoForm demoForm){
        //指定したユーザーデータを削除
        demoService.deleteById(demoForm.getId());
        //一覧画面に遷移
        return "redirect:/to_index";
    }

    /**
     * 削除完了後に一覧画面に戻る
     * @param model Modelオブジェクト
     * @return 一覧画面
     */
    @GetMapping("/to_index")
    public String toIndex(Model model){
        return index(model);
    }

    /**
     * 削除確認画面から一覧画面に戻る
     * @param model Modelオブジェクト
     * @return 一覧画面
     */
    @PostMapping(value = "/delete", params = "back")
    public String confirmDeleteBack(Model model){
        return index(model);
    }

    /**
     * 追加処理を行う画面に遷移する
     * @param model Modelオブジェクト
     * @return 入力・更新画面へのパス
     */
    @PostMapping("/add")
    public String add(Model model){
        model.addAttribute("demoForm", new DemoForm());
        return "input";
    }

    /**
     * エラーチェックを行い、エラーが無ければ確認画面に遷移し、
     * エラーがあれば入力画面のままとする
     * @param demoForm Formオブジェクト
     * @param result バインド結果
     * @return 確認画面または入力画面へのパス
     */
    @PostMapping(value = "/confirm", params = "next")
    public String confirm(@Validated DemoForm demoForm, BindingResult result){
        //生年月日の日付チェック処理を行い、画面遷移する
        return demoService.checkForm(demoForm, result, "confirm");
    }

    /**
     * 一覧画面に戻る
     * @param model Modelオブジェクト
     * @return 一覧画面の表示処理
     */
    @PostMapping(value = "/confirm", params = "back")
    public String confirmBack(Model model){
        return index(model);
    }

    /**
     * データ更新処理
     * @param demoForm Formオブジェクト
     * @param result バインド結果
     * @return 完了画面
     */
    @RequestMapping(value = "/send", params = "next")
    public String send(@Validated DemoForm demoForm, Model model, BindingResult result){
        //チェック処理を行い、エラーがなければ、更新・追加処理を行う
        String normalPath = "redirect:/complete";
        String checkPath = demoService.checkForm(demoForm, result, "redirect:/complete");
        if(normalPath.equals(checkPath)){
            try{
                //ユーザーデータがあれば更新し、無ければ追加する
                demoService.createOrUpdate(demoForm);
            }catch(RuntimeException ex){
                //エラー時は、システムエラー画面に遷移
                if(ex.toString().contains("ORA-30006")){
                    model.addAttribute("errClass", "WAITタイムアウトエラー");
                }else{
                    model.addAttribute("errClass", ex.toString());
                }
                return "system_error";
            }
        }
        return checkPath;
    }

    /**
     * 完了画面に遷移する
     * @param sessionStatus セッションステータス
     * @return 完了画面
     */
    @GetMapping("/complete")
    public String complete(SessionStatus sessionStatus){
        //セッションオブジェクトを破棄
        sessionStatus.setComplete();
        return "complete";
    }

    /**
     * 入力画面に戻る
     * @return 入力画面
     */
    @RequestMapping(value = "/send", params = "back")
    public String sendBack(){
        return "input";
    }

}

また、今回追加したシステムエラー画面のHTMLソースは以下の通り。

<!DOCTYPE html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
    <meta charset="UTF-8">
    <title>エラーページ</title>
</head>
<body>
    <p><font color="red">システムエラーが発生しました。</font></p>
    <p th:text="'発生したエラー:  ' + ${errClass}">
        ここに発生したエラーのクラス名が設定されます
    </p>
    <br/><br/>
    <form method="post" th:action="@{/}">
        <input type="submit" value="一覧に戻る" />
    </form>
</body>
</html>

その他のソースコード内容は、以下のサイトを参照のこと。
https://github.com/purin-it/java/tree/master/spring-boot-exclusive-2/demo

要点まとめ

  • select ~ for update文を実行することで、悲観ロックの取得が行える。
  • select ~ for update文には、「wait 秒数」や「nowait」オプションを付与することができる。