読者です 読者をやめる 読者になる 読者になる

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から追加された機能で、アクションメソッドの引数にドメインクラスを指定すると

  1. パラメータ中にidがあった場合、自動的にデータがロードされる (ない場合は、新規にインスタンスが生成される)
  2. リクエストパラメータがある場合は、自動的にデータがバインドされる
  3. 自動的にバリデートが行われる

といったことが行われる。

  1. は非常に便利だが、柔軟性にもかける。このロジックはidパラメータがあった場合は、ドメインクラスのgetメソッドを使ってデータを取得するという単純なものである。この取得ロジックをカスタマイズできると非常に便利だが、現時点ではない。そのため、idパラメータ以外の独自のデータ取得ロジックを実装したい場合は、アクションメソッドの引数にドメインクラスを指定することを諦める必要がある。

  2. については自動的にバインドされるためbindDataなどを使った時のように、バインドするプロパティを指定することはできない。そのため、ドメインクラスのconstraintsでbindableを使ったmass assignment脆弱性の対応をしっかりしておく必要がある。

  3. も一見便利なようだが、冗長なバリデーションを引き起こす。バリデーションのコストが低い場合はあまり気にしなくても良いかもしれないが、データベースにアクセスするようなバリデーションが含まれている場合は注意する必要がある。

まず、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による楽観的排他制御ではユーザが設定したバージョンの値は完全に無視される。Grailsversionプロパティをバインド可能にして、あとは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プラグインや、独自のカスタムメソッドを用意するとよい。

最後にドメインクラスや、処理の内容に応じてこのコードが最適なわけではないことに注意してほしい。 そんなところで。