Grailsの追加、更新、削除処理について深く考える
※この記事はGrails 2.4.2をベースに記述しています
一見簡単にみえるが、意外と奥が深い。 次のようなドメインクラスがあったとする。
class Person { String username static constraints = { username unique: true } }
この時、デフォルトのスキャフォルドは次のようなコントローラを生成する。
@Transactional(readOnly = true) class PersonController { ... @Transactional def save(Person personInstance) { if (personInstance == null) { notFound() return } if (personInstance.hasErrors()) { respond personInstance.errors, view:'create' return } personInstance.save flush:true request.withFormat { form multipartForm { flash.message = message(code: 'default.created.message', args: [message(code: 'person.label', default: 'Person'), personInstance.id]) redirect personInstance } '*' { respond personInstance, [status: CREATED] } } } @Transactional def update(Person personInstance) { if (personInstance == null) { notFound() return } if (personInstance.hasErrors()) { respond personInstance.errors, view:'edit' return } personInstance.save flush:true request.withFormat { form multipartForm { flash.message = message(code: 'default.updated.message', args: [message(code: 'Person.label', default: 'Person'), personInstance.id]) redirect personInstance } '*'{ respond personInstance, [status: OK] } } } @Transactional def delete(Person personInstance) { if (personInstance == null) { notFound() return } personInstance.delete flush:true request.withFormat { form multipartForm { flash.message = message(code: 'default.deleted.message', args: [message(code: 'Person.label', default: 'Person'), personInstance.id]) redirect action:"index", method:"GET" } '*'{ render status: NO_CONTENT } } } ... }
save
/update
/delete
メソッドがそれぞれ追加/更新/削除の処理になる。このコードをベースに説明する。
ドメインクラスをアクションの引数とした場合の動作
save
/update
/delete
メソッドは引数にPerson
クラスの値を取るように定義されいる。
def save(Person personInstance) def update(Person personInstance) def delete(Person personInstance)
これはGrails 2.3から追加された機能で、アクションメソッドの引数にドメインクラスを指定すると
- パラメータ中に
id
があった場合、自動的にデータがロードされる (ない場合は、新規にインスタンスが生成される) - リクエストパラメータがある場合は、自動的にデータがバインドされる
- 自動的にバリデートが行われる
といったことが行われる。
は非常に便利だが、柔軟性にもかける。このロジックは
id
パラメータがあった場合は、ドメインクラスのget
メソッドを使ってデータを取得するという単純なものである。この取得ロジックをカスタマイズできると非常に便利だが、現時点ではない。そのため、id
パラメータ以外の独自のデータ取得ロジックを実装したい場合は、アクションメソッドの引数にドメインクラスを指定することを諦める必要がある。については自動的にバインドされるためbindDataなどを使った時のように、バインドするプロパティを指定することはできない。そのため、ドメインクラスのconstraintsで
bindable
を使ったmass assignment脆弱性の対応をしっかりしておく必要がある。も一見便利なようだが、冗長なバリデーションを引き起こす。バリデーションのコストが低い場合はあまり気にしなくても良いかもしれないが、データベースにアクセスするようなバリデーションが含まれている場合は注意する必要がある。
まず、delete
メソッドではバリデーションは不要だろう (これはバグと言ってもいいかもしれないが)。バインドするパラメータが存在しないからである。
save
/update
についてはバリデーションが必要のため、問題ないように思える。ただ、このあとにsave
メソッドでデータを保存していることが問題になる。
@Transactional def save(Person personInstance) { … personInstance.save flush:true ... } @Transactional def update(Person personInstance) { … personInstance.save flush:true ... }
save
は自動的にバリデーションを実行する。そのため、save
を実行したときにバリデーションエラーになることを考慮しなければならない。はじめにバリデーションを通過済みだからといって、途中で値を変更したり、データベースへのアクセスがある制約の場合は他のトランザクションが完了することにより、save
を実行した時に必ずバリデーションを通過するとは限らない。
またsave
は複数回バリデーションを実行することにも注意する必要がある。これはドメインクラスで用意されているイベントフックの仕組みで値が変更された場合を考慮したものである。
そのため、上記の例で言うとsave
/update
メソッドはそれぞれ合計で3回のバリデーションが実行される。この例のPersonクラスはusername
にユニーク制約をつけているので、バリデーションでデータベースへSELECT文が発行され、計3回のSELECT文が発行されることになる。
この問題を回避するかはどうかは、どこまで最適化が必要かによるが、
- アクションの引数にドメインクラスを指定するのをやめて、自前で取得する
- イベントフックで値が更新されない前提であれば、自前で
validate
を呼んで、save
の時はバリデーションを無効にする
という案がある。
@Transactional def save() { def person = new Person() bindData person, request if (!person.validate()) { respond person.errors, view: 'create' return } person.save validate: false, flush: true ... } @Transactional def update(Long id) { def person = Person.get id if (!person) { notFound() return } bindData person, request if (!person.validate()) { respond person.errors, view: 'edit' return } person.save validate: false, flush: true ... } @Transactional def delete(Long id) { def person = Person.get id if (!person) { notFound() return } person.delete flush: true ... }
だが、person.save validate: false
などは、通常はやり過ぎた最適化なので、ユニーク制約がないなどバリデーションのコストが低い場合は単に
if (!person.validate()) { respond person.errors, view: 'create' return } person.save validate: false, flush: true
は
if (!person.save(flush: true)) { respond person.errors, view: 'create' return }
でよいだろう。または、バインド後にバリデーションエラーになる可能性がない場合は
@Transactional def save(Person personInstance) { ... personInstance.save validate: false, flush:true ... }
とする手もあるだろう。
楽観的排他制御
デフォルトのスキャフォルドが生成するコードには楽観的排他制御の考慮がない。
Grailsではドメインクラスに楽観的排他制御を行うためのversion
プロパティが追加される。このプロパティを使って楽観的排他制御をするには2つの考慮が必要がである。
- 変更画面に
hidden
などでバージョンの値を仕込んでおき、ポストされたときにデータベースのバージョンの値と比較する - Hibernateが楽観的排他エラーを検出したときのハンドリングをする
Hibernateによる楽観的排他制御ではユーザが設定したバージョンの値は完全に無視される。Grailsでversion
プロパティをバインド可能にして、あとはHibernateに任せておけば簡単に楽観的排他制御ができそうだが、恐らくセキュリティ上の理由から残念ながらそうはなっていない。
そのため、前者の考慮はユーザ自身でやらなければならない。Grails 2.2までのデフォルトのスキャフォルドでは考慮されていたのだが、なぜか2.3でREST対応のテンプレートになった時になくなってしまった。
対処は次のようにデータベースのversion
の値と、ユーザから送られてきたversion
の値を比較すればよい。
@Transactional def update(Long id, Long version) { def person = Person.get id if (!person) { notFound() return } if (person.version > version) { person.errors.rejectValue('version', 'default.optimistic.locking.failure', [message(code: 'person.label', default: 'Person')] as Object[], 'Another user has updated this Person while you were editing') respond person.errors, view: 'edit' return } bindData person, request if (!person.validate()) { respond person.errors, view: 'edit' return } person.save validate: false, flush: true ... }
必ずbindData
を実行する前といったように、データベースから取得したperson
の値を変更する前にチェックを入れる必要がある。これは、データをバインドしたあとに比較したら同じバージョンの値を比較してしまうからではない。Grailsのデータバインディングではデフォルトでversion
プロパティがバインディングの対象外になっている。
問題はこのアクションメソッドに@Transactionalが設定されていることだ。このアノテーションはGrails 2.3から追加された新しいトランザクションのアノテーションである。以前はサービスクラス限定だったが、新しいトランザクションアノテーションはコントローラなど、どのような場所で使えるようになっている。
@Transactionalが指定されたメソッドの中で、データベースにすでに保存されている (Merged状態の) ドメインクラスのインスタンスの変更を行うと、明示的にsave
を呼びださなくても、メソッドが終了する時点でインスタンスへの変更がフラッシュされ、トランザクションがコミットされる。トランザクションをロールバックするには、非検査例外をメソッドからスローするなどしなければならない。
上記のアクションメソッドは、バージョンチェックを行ったあとに、単にメソッドをreturnで終了しているだけである。そのため、このアクションメソッドは、実際にはトランザクションがコミットされて終了している。そのため、この前になにか値を変更すると、その変更は自動的にコミットされてしまうのだ。
次はHibernateの楽観的排他エラーの対処である。Hibernateは楽観的排他エラーを検出すると、org.hibernate.StaleObjectStateExceptionをスローする。ただ、Springを経由してHibernateを使っている場合は、Springが例外をラップして再スローするため、実際にはユーザにはorg.springframework.dao.OptimisticLockingFailureExceptionのサブクラスの例外がスローされる (参考: http://d.hatena.ne.jp/yomama/20070802)。
この例外はsave
の呼び出し時にキャッチしてハンドリングすればよい。
@Transactional def update(Long id, Long version) { def person = Person.get id if (!person) { notFound() return } if (person.version > version) { person.errors.rejectValue('version', 'default.optimistic.locking.failure', [message(code: 'person.label', default: 'Person')] as Object[], 'Another user has updated this Person while you were editing') respond person.errors, view: 'edit' return } bindData person, request if (!person.validate()) { respond person.errors, view: 'edit' return } try { person.save validate: false, flush: true } catch (OptimisticLockingFailureException e) { person.errors.rejectValue('version', 'default.optimistic.locking.failure', [message(code: 'person.label', default: 'Person')] as Object[], 'Another user has updated this Person while you were editing') respond person.errors, view: 'edit' return } ... }
このOptimisticLockingFailureExceptionは更新/削除時にデータが存在しなかった場合にもスローされる。
def person = Person.get id if (!person) { notFound() return }
でチェック済みのように思えるが、この処理のあとに別のトランザクションでデータが削除されている場合があるからだ。
楽観的排他エラーの場合も、データが存在しない場合もOptimisticLockingFailureException
がスローされるため、状況に応じて処理を分岐させることは難しいが、そうそう同時にトランザクションが実行されることがないなら、そのあたりは割りきりで良いだろう。もし、割り切れない場合は、データ取得時に悲観的ロックを取得して、同時に処理が走らないようにするなどの考慮が必要になる。
また、データが存在しない場合の考慮はdelete
メソッドでもする必要ある。
@Transactional def delete(Long id) { def person = Person.get id if (!person) { notFound() return } try { person.delete flush: true } catch (OptimisticLockingFailureException e) { notFound() return } ... }
一意制約違反の考慮
Grailsではusername unique: true
といったようなにユニーク制約をつけると、バリデーション実行時にデータベースにSELECT文を発行して制約違反がないかを確認する。そのため通常は一意制約違反の場合はバリデーションエラーになる。
しかし、このチェックは同時に複数のトランザクションが同時に走った場合、ともにSELECT文でのチェックをパスしてしまう可能性ある。このような場合は、そのあとのINSERT文や、UPDATE文を発行した時点でデータベース上で一意制約違反となる。
Hibernateはこのエラーを検出した場合、org.hibernate.exception.ConstraintViolationExceptionをスローするが、先ほどの楽観的排他制御と同様にSpringがorg.springframework.dao.DataIntegrityViolationExceptionのサブクラスでラップするため、アプリケーションではこの例外をキャッチする。
一意制約違反は、追加/更新時に考慮する必要があるため、save
/update
メソッドで対応を行う。
@Transactional def save() { def person = new Person() bindData person, request if (!person.validate()) { respond person.errors, view: 'create' return } try { person.save validate: false, flush: true } catch (DataIntegrityViolationException e) { person.errors.rejectValue('username', 'default.not.unique.message', ['username', message(code: 'person.label', default: 'Person'), person.username] as Object[], 'Username must be unique') respond person.errors, view: 'create' return } ... } @Transactional def update(Long id, Long version) { ... try { person.save validate: false, flush: true } catch (OptimisticLockingFailureException e) { person.errors.rejectValue('version', 'default.optimistic.locking.failure', [message(code: 'person.label', default: 'Person')] as Object[], 'Another user has updated this Person while you were editing') respond person.errors, view: 'edit' return } catch (DataIntegrityViolationException e) { person.errors.rejectValue('username', 'default.not.unique.message', ['username', message(code: 'person.label', default: 'Person'), person.username] as Object[], 'Username must be unique') respond person.errors, view: 'edit' return } ... }
これで動くと言いたいところだが、save
メソッドで実際にDataIntegrityViolationExceptionが発生する状況を発生されると以下のエラーが発生してしまう (何故かupdate
メソッドやOptimisticLockingFailureExceptionの場合は発生しないのだが...) 。
2014-07-27 14:55:01,417 [http-bio-8080-exec-5] ERROR StackTrace - Full Stack Trace: org.hibernate.AssertionFailure: null id in Person entry (don't flush the Session after an exception occurs) ...
これは@Transactional
をつけているため、ロールバック処理をしてあげなければならないのだろう。Springの@Transaction
アノテーションではTransactionAspectSupportを経由して、TransactionStatusのインスタンスを取得し、setRollbackOnly()を呼び出すことで、明示的にトランザクションをロールバックとして扱うことができる。しかし、新しいGrailsの@Transactional
アノテーションではこの手段が使えない。そのため、トランザクションをロールバックするには、先ほど説明したようにアクションメソッドを非検査例外で終了しなければならない (裏技としてAST変換で追加されるtransactionStatus
という変数を使ってtransactionStatus.setRollbackOnly()
とすることもできるが、公開されたAPIではないのでお勧めしない)。
非検査例外をスローすれば良いのだが、これはコントローラではアクションごとに画面遷移を制御しなければならないため、非検査例外をスローしてしまうと扱いが難しくなる。遷移先の情報を詰め込んで例外をスローし、ロールバック後にGrails 2.3から追加されたコントローラでの例外ハンドリング機能を使って例外をキャッチして画面遷移を制御する案もあるが、あまりスマートな解決方法ではない。
そのため、コントローラ内でロールバックの遷移が必要な場合は、ドメインクラスのwithTransaction
を使う、または処理をサービスクラスに移してトランザクションの境界をサービスクラスにすると良いだろう。
ただ、今回の例でいうと、そもそもトランザクションは必要なのだろうか。今回はPersonに対して変更が1度しかないこともあり、トランザクションはなくても良い。ただ、Personのsave
が一度しか無いからといって、常にトランザクションが必要ないわけではない。ドメインクラスに関連がある場合は、他のドメインクラスへ変更がカスケードされることがあるため、このような場合はトランザクションを使うべきだろう。
ということで今回は@Transactional
を消してしまう。
class PersonController { ... def save(Person personInstance) { ... } def update(Person personInstance) { ... } def delete(Person personInstance) { ... } ... }
またDataIntegrityViolationExceptionをキャッチした時、一意制約違反があったプロパティを特定できないことにも注意が必要である。同一ドメイン上に複数の一意制約が存在することはそうそうないと思うが、一律DataIntegrityViolationExceptionがスローされるため、どのプロパティで一意制約が発生したのか特定するのが難しい。このような場合は割り切ったエラー処理が必要になるだろう。テーブルロックをかけて、この例外が発生しないようにもできるが、これは良い選択ではないだろう。
一意制約が発生する可能性のプロパティが1つしかない場合は、それを前提にエラー処理を行うことができる。
そもそも、当たり前だが制約違反が発生しないのであればDataIntegrityViolationExceptionをキャッチする必要はない。
まとめ
追加、更新、削除処理は一見簡単にみえるが、まじめに実装すると色々考慮すべきことがある。 最終的には以下のような形になった。
def save() { def person = new Person() bindData person, request if (!person.validate()) { respond person.errors, view: 'create' return } try { person.save validate: false, flush: true } catch (DataIntegrityViolationException e) { person.errors.rejectValue('username', 'default.not.unique.message', ['username', message(code: 'person.label', default: 'Person'), person.username] as Object[], 'Username must be unique') respond person.errors, view: 'create' return } ... } def update(Long id, Long version) { def person = Person.get id if (!person) { notFound() return } if (person.version > version) { person.errors.rejectValue('version', 'default.optimistic.locking.failure', [message(code: 'person.label', default: 'Person')] as Object[], 'Another user has updated this Person while you were editing') respond person.errors, view: 'edit' return } bindData person, request if (!person.validate()) { respond person.errors, view: 'edit' return } try { person.save validate: false, flush: true } catch (OptimisticLockingFailureException e) { person.errors.rejectValue('version', 'default.optimistic.locking.failure', [message(code: 'person.label', default: 'Person')] as Object[], 'Another user has updated this Person while you were editing') respond person.errors, view: 'edit' return } catch (DataIntegrityViolationException e) { person.errors.rejectValue('username', 'default.not.unique.message', ['username', message(code: 'person.label', default: 'Person'), person.username] as Object[], 'Username must be unique') respond person.errors, view: 'edit' return } ... } def delete(Long id) { def person = Person.get id if (!person) { notFound() return } try { person.delete flush: true } catch (OptimisticLockingFailureException e) { notFound() return } ... }
重複したコードが増えるため、Domain Lockingプラグインや、独自のカスタムメソッドを用意するとよい。
最後にドメインクラスや、処理の内容に応じてこのコードが最適なわけではないことに注意してほしい。 そんなところで。