Обработка параллелизма с entity Framework в ASP.NET приложении MVC (7 из 10)

Том Дайкстра (Tom Dykstra)

Пример веб-приложения Университета Contoso демонстрирует создание ASP.NET приложений MVC 4 с помощью Entity Framework 5 Code First и Visual Studio 2012. Сведения о серии руководств см. в первом руководстве серии.

Примечание

Если у вас возникла проблема, которую не удается устранить, скачайте завершенную главу и попробуйте воспроизвести проблему. Как правило, решение проблемы можно найти, сравнив код с готовым кодом. Сведения о некоторых распространенных ошибках и способах их устранения см. в статье Ошибки и обходные пути.

В предыдущих двух руководствах вы работали со связанными данными. В этом руководстве показано, как обрабатывать параллелизм. Вы создадите веб-страницы, которые работают с сущностью Department , а страницы, изменяющие и удаляющие Department сущности, будут обрабатывать ошибки параллелизма. На следующих иллюстрациях показаны страницы Индекс и Удаление, включая некоторые сообщения, которые отображаются при возникновении конфликта параллелизма.

Снимок экрана: страница

Снимок экрана: страница Университета с сообщением о том, что операция отменена, так как значение было изменено другим пользователем.

Конфликты параллелизма

Конфликт параллелизма возникает, когда один пользователь отображает данные сущности, чтобы изменить их, а другой пользователь обновляет данные той же сущности до того, как изменение первого пользователя будет записано в базу данных. Если не включить обнаружение таких конфликтов, то пользователь, обновляющий базу данных последним, перезаписывает изменения другого пользователя. Во многих приложениях такой риск допустим: при небольшом числе пользователей или обновлений, а также в случае, если перезапись некоторых изменений не является критической, стоимость реализации параллелизма может перевесить его преимущества. В этом случае вам не нужно настраивать приложение для обработки конфликтов параллелизма.

Пессимистичный параллелизм (блокировка)

Если приложению нужно предотвратить случайную потерю данных в сценариях параллелизма, одним из способов сделать это являются блокировки базы данных. Это называется пессимистичным параллелизмом. Например, перед чтением строки из базы данных вы запрашиваете блокировку для доступа для обновления или только для чтения. Если заблокировать строку для обновления, другие пользователи не могут заблокировать ее для обновления или только для чтения, так как получат копию данных, которые находятся в процессе изменения. Если заблокировать строку только для чтения, другие пользователи также могут заблокировать ее только для чтения, но не для обновления.

Управление блокировками имеет недостатки. Оно может оказаться сложным с точки зрения программирования. Для этого требуются значительные ресурсы для управления базами данных, и это может привести к проблемам с производительностью по мере увеличения числа пользователей приложения (т. е. недостаточного масштабирования). Поэтому не все системы управления базами данных поддерживают пессимистичный параллелизм. Платформа Entity Framework не предоставляет встроенной поддержки, и в этом руководстве не показано, как ее реализовать.

Оптимистическая блокировка

Альтернативой пессимистичному параллелизму является оптимистичный параллелизм. Оптимистическая блокировка допускает появление конфликтов параллелизма, а затем обрабатывает их соответствующим образом. Например, Джон запускает страницу Редактирование отделов и изменяет сумму бюджета для английского отдела с 350 000,00 долл. США на 0,00 долл. США.

Changing_English_dept_budget_to_100000

Прежде чем нажать кнопку Сохранить, Джейн запустит ту же страницу и изменит поле Дата начала с 01.09.2007 на 8.08.2013.

Changing_English_dept_start_date_to_1999

Джон сначала нажимает кнопку Сохранить и видит изменения, когда браузер вернется на страницу Индекс, а затем Джейн нажимает кнопку Сохранить. Дальнейший ход событий определяется порядком обработки конфликтов параллелизма. Некоторые параметры перечислены ниже:

  • Вы можете отслеживать, для какого свойства пользователь изменил и обновил только соответствующие столбцы в базе данных. В этом примере сценария данные не будут потеряны, так как эти два пользователя обновляли разные свойства. В следующий раз, когда кто-то просматривает английский отдел, он увидит изменения Джона и Джейн — дата начала 08.08.2013 и бюджет в 0 долларов.

    Этот метод обновления помогает снизить число конфликтов, которые могут привести к потере данных, но не позволяет избежать такой потери, когда конкурирующие изменения вносятся в одно свойство сущности. То, работает ли Entity Framework в таком режиме, зависит от того, как вы реализуете код обновления. В веб-приложении это часто нецелесообразно, так как может потребоваться обрабатывать большой объем состояний, чтобы отслеживать все исходные значения свойств для сущности, а также новые значения. Поддержание большого количества состояний может повлиять на производительность приложения, так как оно либо требует ресурсов сервера, либо должно быть включено в саму веб-страницу (например, в скрытых полях).

  • Вы можете позволить, чтобы изменения Джейн перезаписывали изменения Джона. В следующий раз, когда кто-то просматривает английский отдел, он увидит 8/8/2013 и восстановленное значение $350,000.00. Такой подход называется победой клиента или сохранением последнего внесенного изменения. (Клиентские значения имеют приоритет над тем, что находится в хранилище данных.) Как указано во введении к этому разделу, если вы не делаете код для обработки параллелизма, это произойдет автоматически.

  • Вы можете предотвратить обновление изменений Джейн в базе данных. Как правило, вы отображаете сообщение об ошибке, показываете текущее состояние данных и разрешаете ей повторно применить изменения, если она по-прежнему хочет их внести. Это называется победой хранилища. (Значения в хранилище имеют приоритет над данными, передаваемыми клиентом.) В этом руководстве вы реализуете сценарий победы хранилища. Данный метод гарантирует, что никакие изменения не перезаписываются без оповещения пользователя о случившемся.

Обнаружение конфликтов параллелизма

Вы можете разрешать конфликты, обрабатывая исключения OptimisticConcurrencyException , создаваемые Entity Framework. Чтобы определить, когда именно нужно выдавать исключения, платформа Entity Framework должна быть в состоянии обнаруживать конфликты. Поэтому нужно соответствующим образом настроить базу данных и модель данных. Ниже приведены некоторые варианты для реализации обнаружения конфликтов:

  • Включите в таблицу базы данных столбец отслеживания, который позволяет определять, когда была изменена строка. Затем можно настроить Entity Framework для включения этого столбца в Where предложение SQL Update или Delete команд.

    Типом данных столбца отслеживания обычно является rowversion. Значение rowversion — это последовательное число, которое увеличивается при каждом обновлении строки. В команде UpdateWhere или Delete предложение включает исходное значение столбца отслеживания (исходная версия строки). Если обновляемая строка была изменена другим пользователем, значение в rowversion столбце отличается от исходного значения, поэтому Update оператору или Delete не удается найти строку для обновления из-за Where предложения . Когда Entity Framework обнаруживает, что ни один из строк не был обновлен командой Update или Delete (то есть, если число затронутых строк равно нулю), она интерпретирует это как конфликт параллелизма.

  • Настройте Entity Framework для включения исходных значений каждого столбца в таблице в Where предложение Update команд и Delete .

    Как и в первом варианте, если что-либо в строке изменилось с момента первого чтения строки, Where предложение не вернет строку для обновления, которую Entity Framework интерпретирует как конфликт параллелизма. Для таблиц базы данных с большим количеством столбцов такой подход может привести к созданию очень больших Where предложений и может потребовать поддержания большого количества состояний. Как отмечалось ранее, поддержание большого количества состояний может повлиять на производительность приложения, так как оно либо требует серверных ресурсов, либо должно быть включено в саму веб-страницу. Поэтому этот подход обычно не рекомендуется, и он не является методом, используемым в этом руководстве.

    Если вы хотите реализовать этот подход к параллелизму, необходимо пометить все свойства, не относящиеся к первичному ключу, в сущности, для которой требуется отслеживать параллелизм, добавив к ним атрибут ConcurrencyCheck . Это изменение позволяет Entity Framework включать все столбцы в предложение SQL WHERE инструкций UPDATE .

В оставшейся части этого руководства вы добавите свойство отслеживания rowversion в Department сущность, создадите контроллер и представления и проверите, что все работает правильно.

Добавление свойства оптимистичного параллелизма в сущность Department

В Models\Department.cs добавьте свойство отслеживания с именем RowVersion:

public class Department
{
    public int DepartmentID { get; set; }

    [StringLength(50, MinimumLength = 3)]
    public string Name { get; set; }

    [DataType(DataType.Currency)]
    [Column(TypeName = "money")]
    public decimal Budget { get; set; }

    [DataType(DataType.Date)]
    public DateTime StartDate { get; set; }

    [Display(Name = "Administrator")]
    public int? InstructorID { get; set; }

    [Timestamp]
    public byte[] RowVersion { get; set; }

    public virtual Instructor Administrator { get; set; }
    public virtual ICollection<Course> Courses { get; set; }
}

Атрибут Timestamp указывает, что этот столбец будет включен в Where предложение Update команды и Delete , отправляемые в базу данных. Атрибут называется Timestamp, так как в предыдущих версиях SQL Server использовался тип данных timestamp SQL, прежде чем sql rowversion заменил его. Тип .NET для rowversion является массивом байтов. Если вы предпочитаете использовать fluent API, можно использовать метод IsConcurrencyToken , чтобы указать свойство отслеживания, как показано в следующем примере:

modelBuilder.Entity<Department>()
    .Property(p => p.RowVersion).IsConcurrencyToken();

См. статью о проблеме с заменой IsConcurrencyToken на GitHub.

Добавив свойство, вы изменили модель базы данных, поэтому нужно выполнить еще одну миграцию. Введите в консоли диспетчера пакетов (PMC) следующие команды:

Add-Migration RowVersion
Update-Database

Создание контроллера отдела

Department Создайте контроллер и просматривайте его так же, как и другие контроллеры, используя следующие параметры:

Add_Controller_dialog_box_for_Department_controller

В файле Controllers\DepartmentController.cs добавьте using оператор :

using System.Data.Entity.Infrastructure;

Измените "LastName" на "FullName" везде в этом файле (четыре вхождения), чтобы раскрывающиеся списки администратора отдела содержали полное имя преподавателя, а не только фамилию.

ViewBag.InstructorID = new SelectList(db.Instructors, "InstructorID", "FullName");

Замените существующий HttpPostEdit код для метода следующим кодом:

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Edit(
   [Bind(Include = "DepartmentID, Name, Budget, StartDate, RowVersion, InstructorID")]
    Department department)
{
   try
   {
      if (ModelState.IsValid)
      {
         db.Entry(department).State = EntityState.Modified;
         db.SaveChanges();
         return RedirectToAction("Index");
      }
   }
   catch (DbUpdateConcurrencyException ex)
   {
      var entry = ex.Entries.Single();
      var clientValues = (Department)entry.Entity;
      var databaseValues = (Department)entry.GetDatabaseValues().ToObject();

      if (databaseValues.Name != clientValues.Name)
         ModelState.AddModelError("Name", "Current value: "
             + databaseValues.Name);
      if (databaseValues.Budget != clientValues.Budget)
         ModelState.AddModelError("Budget", "Current value: "
             + String.Format("{0:c}", databaseValues.Budget));
      if (databaseValues.StartDate != clientValues.StartDate)
         ModelState.AddModelError("StartDate", "Current value: "
             + String.Format("{0:d}", databaseValues.StartDate));
      if (databaseValues.InstructorID != clientValues.InstructorID)
         ModelState.AddModelError("InstructorID", "Current value: "
             + db.Instructors.Find(databaseValues.InstructorID).FullName);
      ModelState.AddModelError(string.Empty, "The record you attempted to edit "
          + "was modified by another user after you got the original value. The "
          + "edit operation was canceled and the current values in the database "
          + "have been displayed. If you still want to edit this record, click "
          + "the Save button again. Otherwise click the Back to List hyperlink.");
      department.RowVersion = databaseValues.RowVersion;
   }
   catch (DataException /* dex */)
   {
      //Log the error (uncomment dex variable name after DataException and add a line here to write a log.
      ModelState.AddModelError(string.Empty, "Unable to save changes. Try again, and if the problem persists contact your system administrator.");
   }

   ViewBag.InstructorID = new SelectList(db.Instructors, "InstructorID", "FullName", department.InstructorID);
   return View(department);
}

В представлении исходное RowVersion значение будет храниться в скрытом поле. Когда связыватель модели создает department экземпляр, этот объект будет иметь исходное RowVersion значение свойства и новые значения для других свойств, введенные пользователем на странице Правка. Затем, когда Entity Framework создает команду SQL UPDATE , эта команда будет включать WHERE предложение, которое ищет строку с исходным RowVersion значением.

Если команда не влияет на UPDATE строки (строки не имеют исходного RowVersion значения), Entity Framework создает DbUpdateConcurrencyException исключение, а код в блоке catch получает затронутую Department сущность из объекта исключения. Эта сущность содержит как значения, считанные из базы данных, так и новые значения, введенные пользователем:

var entry = ex.Entries.Single();
var clientValues = (Department)entry.Entity;
var databaseValues = (Department)entry.GetDatabaseValues().ToObject();

Затем код добавляет пользовательское сообщение об ошибке для каждого столбца, значения базы данных которого отличаются от значений, введенных пользователем на странице Редактирование:

if (databaseValues.Name != currentValues.Name)
    ModelState.AddModelError("Name", "Current value: " + databaseValues.Name);
    // ...

В более длинном сообщении об ошибке объясняется, что произошло и что с этим делать:

ModelState.AddModelError(string.Empty, "The record you attempted to edit "
    + "was modified by another user after you got the original value. The"
    + "edit operation was canceled and the current values in the database "
    + "have been displayed. If you still want to edit this record, click "
    + "the Save button again. Otherwise click the Back to List hyperlink.");

Наконец, код задает RowVersion для объекта Department новое значение, полученное из базы данных. Это новое значение RowVersion будет сохранено в скрытом поле при повторном отображении страницы "Edit" (Редактирование). Когда пользователь в следующий раз нажимает кнопку Save (Сохранить), перехватываются только те ошибки параллелизма, которые возникли с момента повторного отображения страницы "Edit" (Редактирование).

В views\Department\Edit.cshtml добавьте скрытое поле для сохранения RowVersion значения свойства сразу после скрытого DepartmentID поля свойства:

@model ContosoUniversity.Models.Department

@{
    ViewBag.Title = "Edit";
}

<h2>Edit</h2>

@using (Html.BeginForm()) {
    @Html.AntiForgeryToken()
    @Html.ValidationSummary(true)

    <fieldset>
        <legend>Department</legend>

        @Html.HiddenFor(model => model.DepartmentID)
        @Html.HiddenFor(model => model.RowVersion)

        <div class="editor-label">
            @Html.LabelFor(model => model.Name)
        </div>

В файле Views\Department\Index.cshtml замените существующий код следующим кодом, чтобы переместить ссылки на строки влево и изменить заголовок страницы и заголовки столбцов, чтобы они отображались FullName вместо столбца LastNameАдминистратор :

@model IEnumerable<ContosoUniversity.Models.Department>

@{
    ViewBag.Title = "Departments";
}

<h2>Departments</h2>

<p>
    @Html.ActionLink("Create New", "Create")
</p>
<table>
    <tr>
        <th></th>
        <th>Name</th>
        <th>Budget</th>
        <th>Start Date</th>
        <th>Administrator</th>
    </tr>

@foreach (var item in Model) {
    <tr>
        <td>
            @Html.ActionLink("Edit", "Edit", new { id=item.DepartmentID }) |
            @Html.ActionLink("Details", "Details", new { id=item.DepartmentID }) |
            @Html.ActionLink("Delete", "Delete", new { id=item.DepartmentID })
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.Name)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.Budget)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.StartDate)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.Administrator.FullName)
        </td>
    </tr>
}

</table>

Тестирование обработки оптимистичного параллелизма

Запустите сайт и щелкните Отделы:

Снимок экрана: страница

Щелкните правой кнопкой мыши гиперссылку Изменить для Ким Аберкромби и выберите Открыть на новой вкладке, а затем щелкните гиперссылку Изменить для Ким Аберкромби. В двух окнах отображаются одни и те же сведения.

Department_Edit_page_before_changes

Измените поле в первом окне браузера и нажмите кнопку Сохранить.

Department_Edit_page_1_after_change

В браузере отображается страница индекса с измененным значением.

Departments_Index_page_after_first_budget_edit

Измените любое поле во втором окне браузера и нажмите кнопку Сохранить.

Department_Edit_page_2_after_change

Нажмите кнопку Сохранить во втором окне браузера. Отображается сообщение об ошибке:

Снимок экрана: страница

Снова нажмите кнопку Save (Сохранить). Значение, введенное во втором браузере, сохраняется вместе с исходным значением данных, которые вы изменяете в первом браузере. Сохраненные значения отображаются при открытии страницы индекса.

Department_Index_page_with_change_from_second_browser

Обновление страницы удаления

Для страницы "Delete" (Удаление) платформа Entity Framework обнаруживает конфликты параллелизма, вызванные схожим изменением кафедры. HttpGetDelete Когда метод отображает представление подтверждения, представление включает исходное RowVersion значение в скрытое поле. Затем это значение становится доступным HttpPostDelete для метода, вызываемого при подтверждении пользователем удаления. Когда Entity Framework создает команду SQL DELETE , она включает WHERE предложение с исходным RowVersion значением. Если команда приводит к нулю затронутых строк (это означает, что строка была изменена после отображения страницы подтверждения Удаления), создается исключение параллелизма, а HttpGet Delete метод вызывается с флагом ошибки, равным true , чтобы повторно отобразить страницу подтверждения с сообщением об ошибке. Кроме того, возможно, что были затронуты нулевые строки, так как строка была удалена другим пользователем, поэтому в этом случае отображается другое сообщение об ошибке.

В файле DepartmentController.cs замените HttpGetDelete метод следующим кодом:

public ActionResult Delete(int id, bool? concurrencyError)
{
    Department department = db.Departments.Find(id);

    if (concurrencyError.GetValueOrDefault())
    {
        if (department == null)
        {
            ViewBag.ConcurrencyErrorMessage = "The record you attempted to delete "
                + "was deleted by another user after you got the original values. "
                + "Click the Back to List hyperlink.";
        }
        else
        {
            ViewBag.ConcurrencyErrorMessage = "The record you attempted to delete "
                + "was modified by another user after you got the original values. "
                + "The delete operation was canceled and the current values in the "
                + "database have been displayed. If you still want to delete this "
                + "record, click the Delete button again. Otherwise "
                + "click the Back to List hyperlink.";
        }
    }

    return View(department);
}

Этот метод принимает необязательный параметр, который указывает, отображается ли страница повторно после ошибки параллелизма. Если этот флаг имеет значение true, в представление отправляется сообщение об ошибке с помощью ViewBag свойства .

Замените код в методе HttpPostDelete (с именем DeleteConfirmed) следующим кодом:

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Delete(Department department)
{
    try
    {
        db.Entry(department).State = EntityState.Deleted;
        db.SaveChanges();
        return RedirectToAction("Index");
    }
    catch (DbUpdateConcurrencyException)
    {
        return RedirectToAction("Delete", new { concurrencyError=true } );
    }
    catch (DataException /* dex */)
    {
        //Log the error (uncomment dex variable name after DataException and add a line here to write a log.
        ModelState.AddModelError(string.Empty, "Unable to delete. Try again, and if the problem persists contact your system administrator.");
        return View(department);
    }
}

В шаблонном коде, который вы только что заменили, этот метод принимал только идентификатор записи:

public ActionResult DeleteConfirmed(int id)

Вы изменили этот параметр на экземпляр сущности Department, созданный связывателем модели. Это дает доступ к значению RowVersion свойства в дополнение к ключу записи.

public ActionResult Delete(Department department)

Вы также изменили имя метода действия с DeleteConfirmed на Delete. Шаблонный код с именем HttpPostDelete метода DeleteConfirmed , чтобы дать методу уникальную сигнатуру HttpPost . (Среда CLR требует, чтобы перегруженные методы имели разные параметры метода.) Теперь, когда сигнатуры уникальны, можно придерживаться соглашения MVC и использовать то же имя для HttpPost методов и HttpGet delete.

При перехвате ошибки параллелизма код повторно отображает страницу подтверждения удаления и предоставляет флаг, указывающий, что нужно отобразить сообщение об ошибке параллелизма.

В файле Views\Department\Delete.cshtml замените шаблонный код следующим кодом, который вносит некоторые изменения в форматирование и добавляет поле сообщения об ошибке. Изменения выделены.

@model ContosoUniversity.Models.Department

@{
    ViewBag.Title = "Delete";
}

<h2>Delete</h2>

<p class="error">@ViewBag.ConcurrencyErrorMessage</p>

<h3>Are you sure you want to delete this?</h3>
<fieldset>
    <legend>Department</legend>

    <div class="display-label">
         @Html.DisplayNameFor(model => model.Name)
    </div>
    <div class="display-field">
        @Html.DisplayFor(model => model.Name)
    </div>

    <div class="display-label">
         @Html.DisplayNameFor(model => model.Budget)
    </div>
    <div class="display-field">
        @Html.DisplayFor(model => model.Budget)
    </div>

    <div class="display-label">
         @Html.DisplayNameFor(model => model.StartDate)
    </div>
    <div class="display-field">
        @Html.DisplayFor(model => model.StartDate)
    </div>

    <div class="display-label">
         @Html.DisplayNameFor(model => model.Administrator.FullName)
    </div>
    <div class="display-field">
        @Html.DisplayFor(model => model.Administrator.FullName)
    </div>
</fieldset>
@using (Html.BeginForm()) {
    @Html.AntiForgeryToken()
   @Html.HiddenFor(model => model.DepartmentID)
    @Html.HiddenFor(model => model.RowVersion)
    <p>
        <input type="submit" value="Delete" /> |
        @Html.ActionLink("Back to List", "Index")
    </p>
}

Этот код добавляет сообщение об ошибке h2 между заголовками и h3 :

<p class="error">@ViewBag.ConcurrencyErrorMessage</p>

Он заменяет LastNameFullName на в Administrator поле :

<div class="display-label">
    @Html.LabelFor(model => model.InstructorID)
</div>
<div class="display-field">
    @Html.DisplayFor(model => model.Administrator.FullName)
</div>

Наконец, он добавляет скрытые DepartmentID поля для свойств и RowVersion после Html.BeginForm оператора :

@Html.HiddenFor(model => model.DepartmentID)
@Html.HiddenFor(model => model.RowVersion)

Запустите страницу "Индекс отделов". Щелкните правой кнопкой мыши гиперссылку Удалить для отдела английского языка и выберите Открыть в новом окне, а затем в первом окне щелкните гиперссылку Изменить для отдела английского языка.

В первом окне измените одно из значений и нажмите кнопку Сохранить .

Department_Edit_page_after_change_before_delete

На странице Индекс подтверждается изменение.

Departments_Index_page_after_budget_edit_before_delete

Во втором окне нажмите кнопку Удалить.

Department_Delete_confirmation_page_before_concurrency_error

Вы видите сообщение об ошибке параллелизма, а значения кафедры обновляются с использованием актуальных сведений из базы данных.

Department_Delete_confirmation_page_with_concurrency_error

Если нажать кнопку Delete (Удалить) еще раз, вы будете перенаправлены на страницу индекса, которая показывает, что кафедра была удалена.

Итоги

На этом заканчивается введение в обработку конфликтов параллелизма. Сведения о других способах обработки различных сценариев параллелизма см. в статьях Оптимистические шаблоны параллелизма и Работа со значениями свойств в блоге команды Entity Framework. В следующем руководстве показано, как реализовать наследование таблиц на иерархию для сущностей Instructor и Student .

Ссылки на другие ресурсы Entity Framework можно найти в ASP.NET карте содержимого доступа к данным.