このアプリでの更新の流れ
①ホテル一覧が表示される。
②更新したいホテルの「UPDATEボタン」をクリック
③更新用のモーダルダイアログの表示
④更新ボタンでAPIへDB更新のリクエスト
⑤成功or失敗をアラートで表示
やりたいこと
ある人物Aさんが①-④の操作を行う間に、
Bさんが同じホテルの更新を既に更新していた場合、
Aさんのホテル更新は失敗し、⑤でエラーを表示する。
今回の制御の方法
versionカラムの追加
MySQLの該当テーブルにversionカラムを追加します。
※他カラム省略
+-------------------+---------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+-------------------+---------------+------+-----+---------+----------------+
| version | int(10) | NO | | 0 | |
+-------------------+---------------+------+-----+---------+----------------+
さらに、ホテルリスト表示にもちいるResponseにversion情報を追加しておきます。
Responseの一部
"body": {
"items": [
{
"id": 369,
"name": "The Plaza Hotel",
"address": "Fifth Avenue at Central Park South 10019",
"countryCode": "US",
"cityCode": "NYC",
"grade": 5,
"facility": "託児サービス |エレベーター |監視つきチャイルドケア / アクティビティ サービス |美容室 |ATM / 銀行 |コンシェルジュ サービス |ギフトショップまたはニューススタンド |ショップ (敷地内) |客室総数 - 282|フロアの数 - 20|朝食あり (有料) |ランドリー設備 |図書室 |セーフティボックス (フロントデスク) |スパサービス (敷地内) |複数の言語を話すスタッフ |24 時間対応フロントデスク |ビジネスセンター |エクスプレス チェックアウト |ドライクリーニング / ランドリーサービス |リムジンまたはタウンカーサービスあり |フルサービス スパ |スパ トリートメントルーム |スチームサウナ |サウナ |ウェディング サービス |エクスプレス チェックイン |ツアー / チケット案内 |WiFi (有料) |会議室 1 室 |バレーパーキング (有料) |手荷物保管サービス |バー / ラウンジの数 - 3|レストランの数 - 5|フィットネス設備 |新聞 (ロビー、無料) |託児サービス (有料)",
"imagePath": "http://media.expedia.com/hotels/1000000/30000/28100/28044/28044_210_b.jpg",
"version": 0
},
このversionを更新時の比較に用いるためです。
Entity に @Version
フィールドの追加
@Column(name = "version")
@Version
private int version;
javax.persistence.Version
の @Version
で管理します。
これで更新時に、
Hibernate: update table set colmun=? where version=?
といった感じにversionで条件指定されるようになります。
更新用のServiceクラス
もっとよい方法があるのではと思いつつ、調べてもこれといった情報を見つけれず現状こうなりました。
@Service
@Transactional
public class HotelUpdateService {
@Autowired
private HotelRepository hotelRepository;
@PersistenceContext
private EntityManager entityManager;
/**
*
* @return
*/
public HotelUpdateResponseBody updateHotel(HotelInfo hotelInfo) {
Hotel target = hotelRepository.findOne(hotelInfo.getId());
//ここで「このアプリでの更新の流れ」①で表示したときのversionと、現在のversionを比較
if(target.getVersion() != hotelInfo.getVersion()){
throw new ObjectOptimisticLockingFailureException(Hotel.class, hotelInfo.getId());
}
//省略してますが、更新内容をsetする処理
target.setUpdateDatetime(new Timestamp(System.currentTimeMillis()));
//楽観的ロック
entityManager.lock(target, LockModeType.OPTIMISTIC);
//更新処理
Hotel updatedHotel = hotelRepository.save(target);
}
}
updateHotel(HotelInfo hotelInfo)
メソッドの引数であるHotelInfo
には、
ホテルリスト表示にレスポンスで渡したversion情報が含まれ、そのversionを処理時に現在のversionと比較してます。
上記ソースコード内//楽観的ロック
では、トランザクションレベルでの制御のようです。
正常に更新が完了された場合は、versionがインクリメントされます。
この制御だけでは、今回の要件を満たすことはできなさそうでした。
画面側
ここまで出準備は整っているので、後は更新リクエスト時
hiddenタグのvalueにホテルリスト表示時のidを持たせればOKです。
確認
ブラウザの画面を2つ用意して、同じホテルの更新します。
下記は、更新用モーダルダイアログ表示をしている画像です。
この2つのブラウザのうち、左側を先に更新してそのあと右を更新してみます。
左側は更新に成功し、右側は更新に失敗することが確認できたので
「やりたいこと」ができていそうです。
念の為、ログを確認してみます。
ApiHttpException: {'status': 400, 'error': {'invalidParameters': None, 'message': 'Already updated error', 'code': 'HTLERR100', 'type': 'VALIDATION', 'detailMessage': 'Object of class [com.denatravel.api.hotel.repository.entity.Hotel] with identifier [396]: optimistic locking failed'}}
Serviceクラスで投げたExceptionが出力されていました。
※ここで表示されるログの情報はExceptionHandlerクラスで設定してます。
※'detailMessage'
は、下記で作成されるmessageです。
throw new ObjectOptimisticLockingFailureException(Hotel.class, hotelInfo.getId());
問題なく動いているようには見えるものの、あまりよい実装方法ではないと思い、色々と調べてみると問題があったので訂正内容を記載します。
訂正(今回の制御の方法)
Serviceクラスに、EntityManager
を利用して楽観的ロックを行っておりましたが、
JpaRepository
を用いる場合の方法が異なっておりました。
Serviceクラス(訂正前)
@Service
@Transactional
public class HotelUpdateService {
@Autowired
private HotelRepository hotelRepository;
@PersistenceContext
private EntityManager entityManager;
/**
*
* @return
*/
public HotelUpdateResponseBody updateHotel(HotelInfo hotelInfo) {
Hotel target = hotelRepository.findOne(hotelInfo.getId());
//ここで「このアプリでの更新の流れ」①で表示したときのversionと、現在のversionを比較
if(target.getVersion() != hotelInfo.getVersion()){
throw new ObjectOptimisticLockingFailureException(Hotel.class, hotelInfo.getId());
}
//省略してますが、更新内容をsetする処理
target.setUpdateDatetime(new Timestamp(System.currentTimeMillis()));
//楽観的ロック
entityManager.lock(target, LockModeType.OPTIMISTIC);
//更新処理
Hotel updatedHotel = hotelRepository.save(target);
}
}
JpaRepositoryを用いる場合は、Entityクラスに@Version
フィールドを設置し
Repositoryクラスで、下記のようにアノテーションを用いて楽観的ロックを実装します。
Entityクラス
@Column(name = "version")
@Version
private int version;
Repositoryクラス
@Repository
public interface HotelRepository extends JpaRepository<Hotel, Long> {
@Lock(LockModeType.OPTIMISTIC)
Hotel findOne(Long id);
}
よってServiceクラスではEntityManagerは不要です。
Serviceクラス(訂正後)
@Service
@Transactional
public class HotelUpdateService {
@Autowired
private HotelRepository hotelRepository;
/**
*
* @return
*/
public HotelUpdateResponseBody updateHotel(HotelInfo hotelInfo) {
Hotel target = hotelRepository.findOne(hotelInfo.getId());
//ここで「このアプリでの更新の流れ」①で表示したときのversionと、現在のversionを比較
if(target.getVersion() != hotelInfo.getVersion()){
throw new ObjectOptimisticLockingFailureException(Hotel.class, hotelInfo.getId());
}
//省略してますが、更新内容をsetする処理
target.setUpdateDatetime(new Timestamp(System.currentTimeMillis()));
//更新処理
Hotel updatedHotel = hotelRepository.save(target);
}
}
Unitテストをした際に @MonckBean
を定義していたServiceクラスで
NoSuchBeanDefinitionException: No bean named 'entityManagerFactory' is defined
のようなエラーが発生して、EntityManagerの利用に問題が発覚し、今回の訂正にいたりました。
どうやら、@PersistenceContext
を複数定義していたことに問題がありそうでした。
これでUpdateのリクエストを投げると、下記のようにversionで条件指定して、インクリメントするようなクエリが発行されます。
Hibernate: update hotel set ..., version=? where id=? and version=?
それぞれの仕組みについての理解があまく、だいぶはまってしまいました。