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

1.21 jigowatts

Great Scott!

ASP.NET MVCはじめました~データの更新と楽観的並行性制御

ASP.NET MVC

概要

一覧表示詳細表示新規登録ときて、データの更新です。それと合わせて、Entity Frameworkによる楽観的並行性制御(オプティミスティック同時実行制御)についても考えてみたいと思います。

環境

Visual Studio 2010
ASP.NET MVC2
SQLServer2008 R2

今回の要件は

  1. 更新対象データを選択
  2. ブラウザより値を入力
  3. データベースを入力値で更新

として、サンプルコードを書いてみました。

複数ユーザによる、同一データの更新

システムの設計や仕様によると思いますが、Webシステムなので2人のユーザが同じデータを違う値で更新してしまうこともあるかと思います。
このような場合、特に気にせず作っていると後勝ち(後で更新したユーザのデータで上書き)になってしまうでしょう。

  1. ユーザAがID=1のデータを選択し編集画面へ遷移
  2. ユーザBがID=1のデータを選択し編集画面へ遷移
  3. ユーザAがAddress項目を「港区」から「新宿区」へ変更
  4. ユーザBがAddress項目を「港区」から「目黒区」へ変更
  5. ユーザAが保存ボタンを押下し、Address項目が「新宿区」としてデータベースの値が更新される
  6. ユーザBが保存ボタンを押下し、Address項目が「目黒区」としてデータベースの値が更新される⇒他のユーザに更新されましたよ!って知らせてほしい

ユーザAさんの変更はなかったことになってしまいます。
同時更新は許すが、後勝ちを許さず、他のユーザの変更を察知*1できるような仕組みを作るとしたらどんな感じになるでしょうか。
Entity Frameworkには楽観的並行性制御をサポートする仕組みがあるのでこれを利用してみます。
変更の保存と同時実行制御の管理 (Entity Framework)

EDMデザイナにてUpdateDateのプロパティで同時実行モードをFixedに設定します。
f:id:sh_yoshida:20141111173819p:plain
このシステムではUpdateDate項目は必ず実行時間で更新しているため、変更があった場合は日時に差がでるため察知することができるはずです。*2

実装

コントローラーの用意
using System;
using System.Web.Mvc;
using Mvc2App.Models;
using Mvc2App.Services;
using Mvc2App.Services.Abstractions;
using Mvc2App.ViewModels.Peple;
using System.Data;
using Mvc2App.Common;

namespace Mvc2App.Controllers
{
    [Authorize]
    public class PepleController : Controller
    {
        IPepleService _service;

        public PepleController() : this(new PepleService())
        { 
            
        }

        public PepleController(IPepleService service)
        {
            _service = service;
        }
        
        ...

        public ActionResult Edit(int id)
        {
            try
            {
                var model = _service.GetById(id);
                return View(model);
            }
            catch (ArgumentException)
            {
                return RedirectToAction("NotFound", "Home");
            }
        }

        [HttpPost]
        public ActionResult Edit(PepleViewModel inputModel)
        {
            try
            {
                var person = new Person()
                {
                    ID = inputModel.ID,
                    Name = inputModel.Name,
                    Address = inputModel.Address,
                    PhoneNumber = inputModel.PhoneNumber,
                    UpdatedBy = inputModel.UpdatedBy,
                    UpdateDate = inputModel.UpdateDate
                };

                _service.Update(person);
                return RedirectToAction("Index");
            }
            catch (OptimisticConcurrencyException)
            {
                inputModel.SysMessage = Resource.GetValue("ERR_003");
                return View(inputModel);
            }
            catch (UpdateException)
            {
                inputModel.SysMessage = Resource.GetValue("ERR_002");
                return View(inputModel);
            }
            catch (ArgumentException)
            {
                inputModel.SysMessage = Resource.GetValue("ERR_003");
                return View(inputModel);
            }
        }
    }
}
サービスインタフェースとサービスクラスの用意
using System;
using System.Collections.Generic;
using System.Web.Mvc;
using Mvc2App.Models;
using Mvc2App.ViewModels.Peple;

namespace Mvc2App.Services.Abstractions
{
    public interface IPepleService
    {
        ...
        void Update(Person p);
    }
}
using System;
using System.Collections.Generic;
using System.Web.Mvc;
using Mvc2App.Models;
using Mvc2App.Repositories;
using Mvc2App.Repositories.Abstractions;
using Mvc2App.Services.Abstractions;
using Mvc2App.ViewModels.Peple;
using Mvc2App.Common;

namespace Mvc2App.Services
{  
    public class PepleService : IPepleService
    {
        private IPepleRepository _repository;
        public PepleService() : this(new PepleRepository())
        { 
        }

        public PepleService(IPepleRepository repository)
        {
            _repository = repository;
        }

        ...

        public void Update(Person p) 
        {
            _repository.Update(p);
        }
    }
}
リポジトリインタフェースとリポジトリクラスの用意
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Mvc2App.Models;

namespace Mvc2App.Repositories.Abstractions
{
    public interface IPepleRepository
    {
        ...
        void Update(Person p);
    }
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using Mvc2App.Repositories.Abstractions;
using Mvc2App.Models;

namespace Mvc2App.Repositories
{
    public class PepleRepository : IPepleRepository
    {
        DevEntities dbContext = new DevEntities();

        ...

        public Person GetById(int id)
        {
            var query = from x in dbContext.Peple
                        where x.ID == id
                        select x;

            var person = query.FirstOrDefault<Person>();
            return person;
        }

        public void Update(Person p)
        {
            var peple = GetById(p.ID);

            if (peple != null)
            {
                peple.Name = p.Name;
                peple.Address = p.Address;
                peple.PhoneNumber = p.PhoneNumber;
                peple.UpdatedBy = p.UpdatedBy;
                peple.UpdateDate = DateTime.Now;
                dbContext.SaveChanges();
            }
            else 
            {
                throw new ArgumentException();
            }
        }
    }
}
ビューの用意
<%@ Page Title="" Language="C#" MasterPageFile="~/Views/Shared/Site.Master"
 Inherits="System.Web.Mvc.ViewPage<Mvc2App.ViewModels.Peple.PepleViewModel>" %>

<asp:Content ID="Content1" ContentPlaceHolderID="TitleContent" runat="server">
	Edit
</asp:Content>

<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">

    <h2>Edit</h2>
    <div id="sysMessage"><%: Html.DisplayFor(model => model.SysMessage)%></div>
    <% using (Html.BeginForm()) {%>
        <%: Html.ValidationSummary(true) %>
        
        <fieldset>
            <legend>Fields</legend>
            
            <div class="editor-label">
                <%: Html.LabelFor(model => model.ID) %>
            </div>
            <div class="editor-field">
                <%: Html.TextBoxFor(model => model.ID, new Dictionary<string, Object>() { { "disabled", "disabled" } })%>
            </div>
            <div class="editor-label">
                <%: Html.LabelFor(model => model.Name) %>
            </div>
            <div class="editor-field">
                <%: Html.TextBoxFor(model => model.Name, new Dictionary<string, Object>() { { "readonly", "readonly" } })%>
            </div>
            <div class="editor-label">
                <%: Html.LabelFor(model => model.Address) %>
            </div>
            <div class="editor-field">
                <%: Html.TextBoxFor(model => model.Address) %>
                <%: Html.ValidationMessageFor(model => model.Address) %>
            </div>            
            <div class="editor-label">
                <%: Html.LabelFor(model => model.PhoneNumber) %>
            </div>
            <div class="editor-field">
                <%: Html.TextBoxFor(model => model.PhoneNumber) %>
                <%: Html.ValidationMessageFor(model => model.PhoneNumber) %>
            </div>

            <%: Html.HiddenFor(model => model.UpdatedBy)%>
            <%: Html.HiddenFor(model => model.UpdateDate) %>
                                    
            <p>
                <input type="submit" value="Save" />
            </p>
        </fieldset>
    <% } %>
    <div>
        <%: Html.ActionLink("Back to List", "Index") %>
    </div>
</asp:Content>

実行結果

1. ユーザAがID=1のデータを選択し編集画面へ遷移

2. ユーザBがID=1のデータを選択し編集画面へ遷移

3. ユーザAがAddress項目を「港区」から「新宿区」へ変更


f:id:sh_yoshida:20141112104637p:plain
4. ユーザBがAddress項目を「港区」から「目黒区」へ変更

f:id:sh_yoshida:20141112104703p:plain
5. ユーザAが保存ボタンを押下し、Address項目が「新宿区」としてデータベースの値が更新される

f:id:sh_yoshida:20141112104932p:plain
6. ユーザBが保存ボタンを押下し、Address項目が「目黒区」としてデータベースの値が更新される⇒他のユーザに更新されましたよ!って知らせてほしい

f:id:sh_yoshida:20141112104953p:plain

あれ??思ってたのと違う。想定ではユーザBが保存ボタンを押下した際にEntity FrameworkによりOptimisticConcurrencyExceptionが投げられ知らせてくれると思ったのですが普通に上書き更新されてしまいました。。。
実装方法が間違っているのでしょうか。

Entity Frameworkの楽観的並行性制御の確認

ブレークポイントを貼りデータ更新前で止めておき、SQL Server Management Studioよりクエリを実行します。


f:id:sh_yoshida:20141112105122p:plain

update dbo.Peple
set UpdateDate = getdate()
where ID = 1

f:id:sh_yoshida:20141112105146p:plain

いろいろ試してみたところ、データを取得した後に更新日時が変更となり、SaveChangesメソッドが実行されるとOptimisticConcurrencyExceptionが発生しました。
ユーザAの変更がSaveChangesメソッドで確定後、ユーザBがデータを取得しているのでエラーが発生しないのか・・・

データの取得方法を変更

Entity Frameworkによる楽観的並行性制御のサポート機能はそのままに、更新対象データを取得する際の条件に更新日時を含めるよう修正しました。

namespace Mvc2App.Repositories
{
    public class PepleRepository : IPepleRepository
    {
        DevEntities dbContext = new DevEntities();

        public void Update(Person p)
        {
            var peple = FindUpdateData(p.ID,p.UpdateDate);

            if (peple != null)
            {
                //ここから
                peple.Name = p.Name;
                peple.Address = p.Address;
                peple.PhoneNumber = p.PhoneNumber;
                peple.UpdatedBy = p.UpdatedBy;
                peple.UpdateDate = DateTime.Now;
                //ここまでの間に他から同一レコードを変更された場合、楽観的同時実行制御のエラーが発生する
                dbContext.SaveChanges();
            }
            else
            {
                throw new ArgumentException();
            }
        }

        public Person FindUpdateData(int id, DateTime dt)
        {
            var person = dbContext.Peple.Where(m => m.ID == id
                && m.UpdateDate.Year == dt.Year
                && m.UpdateDate.Month == dt.Month
                && m.UpdateDate.Day == dt.Day
                && m.UpdateDate.Hour == dt.Hour
                && m.UpdateDate.Minute == dt.Minute
                && m.UpdateDate.Second == dt.Second
                ).FirstOrDefault();

            return person;
        }
    }
}

LINQ to EntitiesでDateTime型の比較も一手間かかりました。

var person = dbContext.Peple.Where(m => m.ID == id
    && m.UpdateDate == dt).FirstOrDefault();

これだと条件に一致せず値が取得できないため、年から秒までそれぞれを比較条件としました。SQLServer側ではミリ秒単位で持っているけど、DateTime型のValueは秒までのため一致しないのかな??

厳密にはミリ秒も比較条件としたかったのですが、ビューで保持している更新日が秒単位までのため、入力データのミリ秒は0となってしまい比較できません。

<%: Html.HiddenFor(model => model.UpdateDate) %>
<input id="UpdateDate" name="UpdateDate" type="hidden" value="2014/11/12 11:08:17" />
というわけで

ミリ秒単位で先に更新されてしまった場合、上書きしてしまうという微妙な機能となってしまいましたが、データ更新と楽観的並行性制御でした(・ω・)


f:id:sh_yoshida:20141112120120p:plain

追記

参考

www.entityframeworktutorial.net
EF4の場合Attachして、EntityStateをModifiedにするにはこんな感じ。これでいけた。
やっぱりRowVersionカラムを用意してConcurrency ModeをFixedにしてあげる方がよさそう。

using (ProductEntities context = new ProductEntities())
{
    try
    {
        context.品目リスト.Attach(inputModel);
        context.ObjectStateManager.ChangeObjectState(inputModel, EntityState.Modified);
        context.SaveChanges();
    }
    catch (OptimisticConcurrencyException ex)
    {

        System.Diagnostics.Debug.WriteLine(ex.Message);
        return View(inputModel); 
    }
}

接続型シナリオと非接続型シナリオというそれぞれのシナリオに応じて書き分ける必要があるんですね。そもそも非接続型の書き方を知らなった!
更新画面なんかはContextが違うから非接続型シナリオの書き方でないと変更が追跡できず、当然Concurrency Modeを設定したところで意図する動きにならなかったんですね。






ASP.NET MVCはじめました~データベースより値を取得し一覧表示する - 1.21 jigowatts

ASP.NET MVCはじめました~データベースより値を取得し詳細を表示する - 1.21 jigowatts

ASP.NET MVCはじめました~データの新規登録 - 1.21 jigowatts

*1:察知?検知?

*2:厳密には行バージョンカラムを追加し、同時実行モードをFixedに設定した方が確実かもしれませんがこのままいきます。