どちらの排他制御を選ぶか
ASP.NET Webフォームアプリケーションは連載初回にも述べたように「ステートレス」であり、悲観的排他制御をしようにもロック状態をメモリ上に取っておくことができません。従って、単純な方法では悲観的排他制御を行うことができません。
そのため、ASP.NET Webフォームアプリケーションでは、基本的には楽観的排他制御を選ぶことになります。
もちろん、悲観的排他制御を行う方法がないわけではありません。例えば、データベースに処理中のデータの情報を登録することで、疑似的に悲観的排他制御を行うといった方法があります。
ただし、処理が煩雑になってしまいますし、あまり一般的ではないため、今回は説明を割愛します。詳しく知りたい場合は以下の書籍の12章「業務排他制御による対話型トランザクション処理の開発」を参考にしてください。
今回は楽観的排他制御に絞って、その実装方法を説明します。
楽観的排他制御の実装方法
それでは実際に排他制御をどのように実装すればよいのか見ていきましょう。
時刻印アルゴリズム
楽観的排他制御を行う際、「更新前」と「更新時」に対象データが変更されたかどうかをチェックするということはすでに述べました。
では、「変更された」ことをどのようにして検出すればよいでしょうか?
最も簡易な方法としては、対象データのすべての項目について、変更前と変更時の値を比較してチェックする方法があります。しかし、この方法は比較項目が多く、無駄が多いのでお勧めしません。
そこで、どうするかというと、データ変更時に必ず値を更新する「時刻印(タイムスタンプ)」項目を追加し、このタイムスタンプを変更前、変更時で比較するようにします。この方法のことを「時刻印アルゴリズム」と呼びます。
予約テーブルに時刻印アルゴリズムを適用するケースの例を以下に示します。
時刻印アルゴリズムを用いることの利点は、比較項目が1つだけとなり実装がシンプルになることです。しかし、何よりもSQL Server、ASP.NET、そしてEntity Frameworkに実装をサポートする機能が備わっていることが大きな利点です。
時刻印アルゴリズムの実装
では、実際に時刻印アルゴリズムを用いた楽観的排他制御の実装方法を見ていきましょう。
予約登録機能に組み込んでいきます。
[1]POCO Entityにタイムスタンプを追加する
Reservationエンティティクラスにタイムスタンプ項目を追加します。
public class Reservation { public int ReservationId { get; set; } public int MeetingRoomId { get; set; } public DateTime ReserveDateFrom { get; set; } public DateTime ReserveDateTo { get; set; } public string Purpose { get; set; } public string Remarks { get; set; } [Timestamp] //(1) public byte[] Version { get; set; } //(2) public virtual MeetingRoom MeetingRoom { get; set; } }
-
(1)タイムスタンプ列であることを示すため、System.ComponentModel.DataAnnotations.Timestamp属性を付ける。
Timestamp属性が付いた項目は、SQL Server上でrowversion型の列として定義されます。 - (2)rowversion型の列に対応する型であるbyte[]型として、タイムスタンプ項目「Version」を定義する。
タイムスタンプ項目があると、EFは自動でその項目を使った楽観的排他制御を行うようになります。
SQL Server 2005から登場した列の型で、レコードの更新時に自動的にカウントアップされる二進数のデータ型です。rowversion型を用いることで、開発者がコードによりタイムスタンプ列の更新を行う必要がなくなります。詳しくは以下のページを参照してください。
[2]Reservation.aspxに排他制御のための変更を加える
排他制御を行うために、主にFormViewに変更を加えます。
<asp:ObjectDataSource ID="ReservationsObjectDataSource" runat="server" DataObjectTypeName="MRRS.Entity.Reservation" TypeName="MRRS.BLL.ReservationLogic" SelectMethod="Find" UpdateMethod="Update" DeleteMethod="Delete" OnSelected="ReservationsObjectDataSource_Selected" ViewStateMode="Enabled"> <SelectParameters> <asp:QueryStringParameter Name="reservationId" QueryStringField="reservationId" /> </SelectParameters> </asp:ObjectDataSource> <!-- (1) --> <asp:FormView ID="ReservationsFormView" runat="server" DataSourceID="ReservationsObjectDataSource" DefaultMode="Edit" DataKeyNames="ReservationId,Version" OnItemDeleted="ReservationsFormView_ItemDeleted" OnItemUpdated="ReservationsFormView_ItemUpdated" OnItemUpdating="ReservationsFormView_ItemUpdating" ViewStateMode="Enabled"> <!-- (2) -->
-
(1)FormView.DataKeyNamesプロパティに[1]で追加したタイムスタンプ列を追加する。
追加しないと、対象データを取得した際のタイムスタンプ値が更新処理に引き渡されなくなります。 -
(2)FormViewのビューステートを有効にする。
有効にしないと、更新前に再びデータ取得処理(ReservationLogic.Findメソッド)が実行されます。そのため、必ず最新のタイムスタンプを取得してから更新処理に行くため、排他制御でエラーになりません。
[3]Reservation.aspx.csに排他エラー時の処理を記述する
排他エラーが発生するとSystem.Data.Entity.Infrastructure.DbUpdateConcurrencyExceptionが発生するので、本連載第3回『エラー処理をパターンにはめよう』を参考に適切に例外を処理し、排他エラーが発生したことをユーザーに通知します。
protected void ReservationsFormView_ItemUpdated(object sender, FormViewUpdatedEventArgs e) { var ex = e.Exception; if (ex != null) { if (ex.InnerException is ReservationLogic.OverlapReservationException) { this.ShowErrorMessage("予約期間が重なる予約がすでに登録されています。"); // 入力値を維持 e.KeepInEditMode = true; // 例外を処理済みとマーク e.ExceptionHandled = true; } if (ex.InnerException is DbUpdateConcurrencyException) //(1) { this.ShowErrorMessage("他のユーザーにより先に更新されました。もう一度一覧画面より選択してやり直してください。"); //(2) // 入力値を維持 e.KeepInEditMode = true; // 例外を処理済みとマーク e.ExceptionHandled = true; } // 例外発生時は画面遷移しない return; } // 予約参照画面に戻る Response.Redirect("~/Reservations.aspx"); }
- (1)FormView.ItemUpdatedイベントで発生した例外のInnerExceptionがDbUpdateConcurrencyExceptionであるか判定する。
- (2)排他エラーである旨のエラーメッセージを表示する。
同様にFormView.ItemDeletedイベントについても、排他エラー発生時の処理を実装します。
[4]MRRS.DAL.ReservationRepository.csで、削除処理を変更する
これまでの削除処理は最初に削除対象データを取得し、そのデータを使ってIDbSet<T>.Removeメソッドを呼び出していました。
public void Delete(Reservation reservation) { var target = context.Reservations.Find(reservation.ReservationId); context.Reservations.Remove(target); }
このままでは削除時にVersion列の比較を行おうにも、必ず最新のデータを使ってしまうため、常に「変更されていない」と判定されてしまいます。
そこで、次のように、変更処理と同じようにEntityStateだけを変更するよう、コードを修正します。
public void Delete(Reservation reservation) { context.Entry<Reservation>(reservation).State = EntityState.Deleted; //(1) }
- (1)DbEntityEntry<T>.Stateプロパティに、EntityState.Deletedを設定することにより、「削除」とマークする。