Часть 8. Razor Страницы с EF Core ASP.NET Core — параллелизм

Том Дайкстра (Tom Dykstra), Йон П. Смит (Jon P Smith)

Веб-приложение Contoso University демонстрирует создание Razor веб-приложений Pages с помощью EF Core Visual Studio. Сведения о серии руководств см. в первом руководстве серии.

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

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

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

Конфликт параллелизма возникает в следующих условиях:

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

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

Пессимистичный параллелизм

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

Управление блокировками имеет недостатки. Его может быть сложно реализовать, и оно может вызывать проблемы с производительностью по мере увеличения числа пользователей. Entity Framework Core не предоставляет встроенную поддержку пессимистичного параллелизма.

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

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

Changing budget to 0

Прежде чем Мария нажимает кнопку Save (Сохранить), Дмитрий заходит на ту же страницу и изменяет значение в поле "Start Date" (Дата начала) с 9/1/2007 на 9/1/2013.

Changing start date to 2013

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

Дмитрий нажимает кнопку Save (Сохранить) на странице редактирования, где все еще отображается бюджет 350 000,00 USD. Дальнейший ход событий определяется порядком обработки конфликтов параллелизма.

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

    В этом сценарии никакие данные не теряются. Разные свойства были обновлены двумя пользователями. Когда какой-либо пользователь просмотрит кафедру английского языка в следующий раз, он увидит изменения, внесенные как Марией, так и Дмитрием. Этот способ обновления позволяет снизить количество конфликтов, способных привести к потере данных. Такой подход имеет ряд недостатков.

    • Он не позволяет избежать потери данных, если конкурирующие изменения вносятся для одного свойства.
    • Он не слишком хорошо подходит для веб-приложений, так как требует поддерживать значительный объем состояний, чтобы отслеживать все извлеченные и новые значения. Обслуживание большого объема состояний может негативно повлиять на производительность приложения.
    • Он может повысить сложность приложений по сравнению с обнаружением параллелизма для сущности.
  • Позвольте изменению Дмитрия перезаписать изменение Марии.

    Когда какой-либо пользователь просмотрит кафедру английского языка в следующий раз, он увидит дату 9/1/2013 и значение 350 000,00 USD. Такой подход называется победой клиента или сохранением последнего внесенного изменения. Все значения из клиента имеют приоритет над данными в хранилище. Сформированный код не выполняет обработку параллелизма, и автоматически применяется победа клиента.

  • Запретите обновление изменения Дмитрия в базе данных. Как правило приложение будет:

    • отображать сообщение об ошибке;
    • отображать текущее состояние данных;
    • разрешать пользователю повторно применять изменения.

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

Обнаружение конфликтов в EF Core

Свойства, настроенные как маркеры параллелизма, используются для реализации управления оптимистической блокировкой. Если операция обновления или удаления активируется SaveChanges или SaveChangesAsyncзначение маркера параллелизма в базе данных сравнивается с исходным значением, считываемым по EF Core:

  • Если значения совпадают, операция может завершиться.
  • Если значения не совпадают, предполагается EF Core , что другой пользователь выполнил конфликтующую операцию, прерывает текущую транзакцию и создает исключение DbUpdateConcurrencyException.

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

В реляционных базах данных EF Core проверка значение маркера параллелизма в WHERE предложении UPDATE и DELETE инструкциях для обнаружения конфликта параллелизма.

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

Подход SQL Server и сведения о реализации SQLite немного отличаются. Файл со сведениями о различиях будет представлен далее в учебнике. На вкладке Visual Studio отображается подход на основе SQL Server. А на вкладке Visual Studio Code — подход для баз данных, отличных от SQL Server, таких как SQLite.

  • Включите в модель столбец отслеживания, который позволяет определять, когда была изменена строка.
  • Примените класс TimestampAttribute к свойству concurrency.

Models/Department.cs Обновите файл со следующим выделенным кодом:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    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)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}",
                       ApplyFormatInEditMode = true)]
        [Display(Name = "Start Date")]
        public DateTime StartDate { get; set; }

        public int? InstructorID { get; set; }

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

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

Именно класс TimestampAttribute определяет столбец как столбец отслеживания параллелизма. Другой способ задания свойства отслеживания — текучий API.

modelBuilder.Entity<Department>()
  .Property<byte[]>("ConcurrencyToken")
  .IsRowVersion();

Атрибут [Timestamp] в свойстве entity создает в методе ModelBuilder следующий код:

 b.Property<byte[]>("ConcurrencyToken")
     .IsConcurrencyToken()
     .ValueGeneratedOnAddOrUpdate()
     .HasColumnType("rowversion");

Предыдущий код:

  • Задает типом свойства ConcurrencyToken массив байтов. byte[] является обязательным типом для SQL Server.
  • Вызывает IsConcurrencyToken. МетодIsConcurrencyToken настраивает свойство как маркер параллелизма. При обновлении значение токена параллелизма в базе данных сравнивается с исходным значением для проверки того, что оно не изменилось с момента извлечения экземпляра из базы данных. Если это значение изменилось, DbUpdateConcurrencyException создает исключение и изменения не применяются.
  • Вызывает метод ValueGeneratedOnAddOrUpdate, который настраивает свойство ConcurrencyToken так, чтобы значение автоматически создавалось при добавлении или обновлении сущности.
  • HasColumnType("rowversion") задает тип rowversion для столбца в базе данных SQL Server.

В следующем коде показана часть T-SQL, EF Core созданная при Department обновлении имени:

SET NOCOUNT ON;
UPDATE [Departments] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [ConcurrencyToken] = @p2;
SELECT [ConcurrencyToken]
FROM [Departments]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

Предыдущий выделенный код показывает предложение WHERE, содержащее ConcurrencyToken. Если база данных ConcurrencyToken не соответствует параметру ConcurrencyToken@p2, строки не обновляются.

Следующий выделенный код показывает код T-SQL, который подтверждает, что была обновлена всего одна строка.

SET NOCOUNT ON;
UPDATE [Departments] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [ConcurrencyToken] = @p2;
SELECT [ConcurrencyToken]
FROM [Departments]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

@@ROWCOUNT возвращает число строк, затронутых при выполнении последнего оператора. Если строки не обновляются, EF Core вызывает исключение DbUpdateConcurrencyException.

Добавление миграции

Добавление свойства ConcurrencyToken изменяет модель данных, которая требует миграции.

Выполните сборку проекта.

Выполните следующие команды в PMC:

Add-Migration RowVersion
Update-Database

Предыдущие команды:

  • Migrations/{time stamp}_RowVersion.cs Создает файл миграции.
  • Migrations/SchoolContextModelSnapshot.cs Обновления файл. Это изменение добавляет следующий код в метод BuildModel:
 b.Property<byte[]>("ConcurrencyToken")
     .IsConcurrencyToken()
     .ValueGeneratedOnAddOrUpdate()
     .HasColumnType("rowversion");

Формирование шаблона страниц кафедр

Следуйте инструкциям в разделе Формирование шаблона для страниц Student, за исключением следующего:

  • Создайте папку Pages/Departments.
  • Используйте класс модели Department.
  • Используйте существующий класс контекста вместо создания нового.

Добавление класса utility

В папке проекта создайте класс Utility, содержащий следующий код:

namespace ContosoUniversity
{
    public static class Utility
    {
        public static string GetLastChars(byte[] token)
        {
            return token[7].ToString();
        }
    }
}

Класс Utility предоставляет метод GetLastChars, используемый для вывода последних нескольких символов маркера параллелизма. Следующий код является кодом, который можно использовать как для SQLite, так и для SQL Server:

#if SQLiteVersion
using System;

namespace ContosoUniversity
{
    public static class Utility
    {
        public static string GetLastChars(Guid token)
        {
            return token.ToString().Substring(
                                    token.ToString().Length - 3);
        }
    }
}
#else
namespace ContosoUniversity
{
    public static class Utility
    {
        public static string GetLastChars(byte[] token)
        {
            return token[7].ToString();
        }
    }
}
#endif

Директива препроцессора#if SQLiteVersion изолирует различия в версиях SQLite и SQL Server и позволяет:

  • Авторам использовать для работы с обеими версиями один код.
  • Разработчикам SQLite развертывать приложение в Azure и использовать Azure SQL.

Выполните сборку проекта.

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

Средство формирования шаблонов создало столбец ConcurrencyToken для страницы индекса, однако это поле не будет отображаться в рабочем приложении. В этом учебнике отображается последняя часть ConcurrencyToken для демонстрации работы параллелизма. Эта последняя часть необязательно должна быть уникальной.

Обновите страницу Pages\Departments\Index.cshtml:

  • Замените Index на Departments.
  • Измените код, содержащий ConcurrencyToken, чтобы отображались только последние несколько символов.
  • Замените FirstMidName на FullName.

В следующем примере кода показана обновленная страница:

@page
@model ContosoUniversity.Pages.Departments.IndexModel

@{
    ViewData["Title"] = "Departments";
}

<h2>Departments</h2>

<p>
    <a asp-page="Create">Create New</a>
</p>
<table class="table">
    <thead>
        <tr>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].Name)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].Budget)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].StartDate)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].Administrator)
            </th>
            <th>
                Token
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model.Department)
        {
            <tr>
                <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>
                <td>
                    @Utility.GetLastChars(item.ConcurrencyToken)
                </td>
                <td>
                    <a asp-page="./Edit" asp-route-id="@item.DepartmentID">Edit</a> |
                    <a asp-page="./Details" asp-route-id="@item.DepartmentID">Details</a> |
                    <a asp-page="./Delete" asp-route-id="@item.DepartmentID">Delete</a>
                </td>
            </tr>
        }
    </tbody>
</table>

Обновление модели страницы "Edit" (Редактирование)

Обновите Pages/Departments/Edit.cshtml.cs, включив в него следующий код.

using ContosoUniversity.Data;
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class EditModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public EditModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        // Replace ViewData["InstructorID"] 
        public SelectList InstructorNameSL { get; set; }

        public async Task<IActionResult> OnGetAsync(int id)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)  // eager loading
                .AsNoTracking()                 // tracking not required
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                return NotFound();
            }

            // Use strongly typed data rather than ViewData.
            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FirstMidName");

            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            if (!ModelState.IsValid)
            {
                return Page();
            }

            // Fetch current department from DB.
            // ConcurrencyToken may have changed.
            var departmentToUpdate = await _context.Departments
                .Include(i => i.Administrator)
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (departmentToUpdate == null)
            {
                return HandleDeletedDepartment();
            }

            // Set ConcurrencyToken to value read in OnGetAsync
            _context.Entry(departmentToUpdate).Property(
                 d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;

            if (await TryUpdateModelAsync<Department>(
                departmentToUpdate,
                "Department",
                s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
            {
                try
                {
                    await _context.SaveChangesAsync();
                    return RedirectToPage("./Index");
                }
                catch (DbUpdateConcurrencyException ex)
                {
                    var exceptionEntry = ex.Entries.Single();
                    var clientValues = (Department)exceptionEntry.Entity;
                    var databaseEntry = exceptionEntry.GetDatabaseValues();
                    if (databaseEntry == null)
                    {
                        ModelState.AddModelError(string.Empty, "Unable to save. " +
                            "The department was deleted by another user.");
                        return Page();
                    }

                    var dbValues = (Department)databaseEntry.ToObject();
                    await SetDbErrorMessage(dbValues, clientValues, _context);

                    // Save the current ConcurrencyToken so next postback
                    // matches unless an new concurrency issue happens.
                    Department.ConcurrencyToken = (byte[])dbValues.ConcurrencyToken;
                    // Clear the model error for the next postback.
                    ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
                }
            }

            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FullName", departmentToUpdate.InstructorID);

            return Page();
        }

        private IActionResult HandleDeletedDepartment()
        {
            // ModelState contains the posted data because of the deletion error
            // and overides the Department instance values when displaying Page().
            ModelState.AddModelError(string.Empty,
                "Unable to save. The department was deleted by another user.");
            InstructorNameSL = new SelectList(_context.Instructors, "ID", "FullName", Department.InstructorID);
            return Page();
        }

        private async Task SetDbErrorMessage(Department dbValues,
                Department clientValues, SchoolContext context)
        {

            if (dbValues.Name != clientValues.Name)
            {
                ModelState.AddModelError("Department.Name",
                    $"Current value: {dbValues.Name}");
            }
            if (dbValues.Budget != clientValues.Budget)
            {
                ModelState.AddModelError("Department.Budget",
                    $"Current value: {dbValues.Budget:c}");
            }
            if (dbValues.StartDate != clientValues.StartDate)
            {
                ModelState.AddModelError("Department.StartDate",
                    $"Current value: {dbValues.StartDate:d}");
            }
            if (dbValues.InstructorID != clientValues.InstructorID)
            {
                Instructor dbInstructor = await _context.Instructors
                   .FindAsync(dbValues.InstructorID);
                ModelState.AddModelError("Department.InstructorID",
                    $"Current value: {dbInstructor?.FullName}");
            }

            ModelState.AddModelError(string.Empty,
                "The record you attempted to edit "
              + "was modified by another user after you. 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.");
        }
    }
}

Обновления параллелизма

OriginalValue обновляется с помощью значения ConcurrencyToken из сущности при получении в методе OnGetAsync. EF CoreSQL UPDATE создает команду с предложениемWHERE, содержащим исходное ConcurrencyToken значение. Если команда UPDATE не влияет ни на одну из строк, будет выведено исключение DbUpdateConcurrencyException. Команда UPDATE может не оказывать влияние на строки, если нет строк, имеющих исходное значение ConcurrencyToken.

public async Task<IActionResult> OnPostAsync(int id)
{
    if (!ModelState.IsValid)
    {
        return Page();
    }

    // Fetch current department from DB.
    // ConcurrencyToken may have changed.
    var departmentToUpdate = await _context.Departments
        .Include(i => i.Administrator)
        .FirstOrDefaultAsync(m => m.DepartmentID == id);

    if (departmentToUpdate == null)
    {
        return HandleDeletedDepartment();
    }

    // Set ConcurrencyToken to value read in OnGetAsync
    _context.Entry(departmentToUpdate).Property(
         d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;

В предыдущем выделенном коде:

  • Значение Department.ConcurrencyToken представляет собой значение, предоставленное во время получения сущности в запросе Get для страницы Edit. Значение передается в метод OnPost посредством скрытого поля на странице Razor, на которой отображается редактируемая сущность. Значение скрытого поля копируется в Department.ConcurrencyToken связывателем модели.
  • OriginalValue — это то, что EF Core используется в предложении WHERE . Перед выполнением выделенной строки кода:
    • OriginalValue является значением в базе данных на момент вызова FirstOrDefaultAsync в этом методе.
    • Это значение может отличаться от значения, отображаемого на странице Edit.
  • Выделенный код гарантирует использование EF Core исходного ConcurrencyToken значения из отображаемой Department сущности в предложении инструкции WHERE SQLUPDATE.

В следующем примере кода показана модель Department. Department инициализируется в:

  • МетодеOnGetAsync через запрос EF.
  • Методе OnPostAsync в скрытом поле на странице Razor с помощью привязки модели:
public class EditModel : PageModel
{
    private readonly ContosoUniversity.Data.SchoolContext _context;

    public EditModel(ContosoUniversity.Data.SchoolContext context)
    {
        _context = context;
    }

    [BindProperty]
    public Department Department { get; set; }
    // Replace ViewData["InstructorID"] 
    public SelectList InstructorNameSL { get; set; }

    public async Task<IActionResult> OnGetAsync(int id)
    {
        Department = await _context.Departments
            .Include(d => d.Administrator)  // eager loading
            .AsNoTracking()                 // tracking not required
            .FirstOrDefaultAsync(m => m.DepartmentID == id);

        if (Department == null)
        {
            return NotFound();
        }

        // Use strongly typed data rather than ViewData.
        InstructorNameSL = new SelectList(_context.Instructors,
            "ID", "FirstMidName");

        return Page();
    }

    public async Task<IActionResult> OnPostAsync(int id)
    {
        if (!ModelState.IsValid)
        {
            return Page();
        }

        // Fetch current department from DB.
        // ConcurrencyToken may have changed.
        var departmentToUpdate = await _context.Departments
            .Include(i => i.Administrator)
            .FirstOrDefaultAsync(m => m.DepartmentID == id);

        if (departmentToUpdate == null)
        {
            return HandleDeletedDepartment();
        }

        // Set ConcurrencyToken to value read in OnGetAsync
        _context.Entry(departmentToUpdate).Property(
             d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;

Из приведенного выше кода видно, что значение ConcurrencyToken сущности Department из запроса HTTP POST равно значению ConcurrencyToken из запроса HTTP GET.

При возникновении ошибки параллелизма приведенный ниже выделенный код получает значения клиента (значения, переданные в этот метод) и значения из базы данных.

if (await TryUpdateModelAsync<Department>(
    departmentToUpdate,
    "Department",
    s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
    try
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }
    catch (DbUpdateConcurrencyException ex)
    {
        var exceptionEntry = ex.Entries.Single();
        var clientValues = (Department)exceptionEntry.Entity;
        var databaseEntry = exceptionEntry.GetDatabaseValues();
        if (databaseEntry == null)
        {
            ModelState.AddModelError(string.Empty, "Unable to save. " +
                "The department was deleted by another user.");
            return Page();
        }

        var dbValues = (Department)databaseEntry.ToObject();
        await SetDbErrorMessage(dbValues, clientValues, _context);

        // Save the current ConcurrencyToken so next postback
        // matches unless an new concurrency issue happens.
        Department.ConcurrencyToken = dbValues.ConcurrencyToken;
        // Clear the model error for the next postback.
        ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
    }

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

private async Task SetDbErrorMessage(Department dbValues,
        Department clientValues, SchoolContext context)
{

    if (dbValues.Name != clientValues.Name)
    {
        ModelState.AddModelError("Department.Name",
            $"Current value: {dbValues.Name}");
    }
    if (dbValues.Budget != clientValues.Budget)
    {
        ModelState.AddModelError("Department.Budget",
            $"Current value: {dbValues.Budget:c}");
    }
    if (dbValues.StartDate != clientValues.StartDate)
    {
        ModelState.AddModelError("Department.StartDate",
            $"Current value: {dbValues.StartDate:d}");
    }
    if (dbValues.InstructorID != clientValues.InstructorID)
    {
        Instructor dbInstructor = await _context.Instructors
           .FindAsync(dbValues.InstructorID);
        ModelState.AddModelError("Department.InstructorID",
            $"Current value: {dbInstructor?.FullName}");
    }

    ModelState.AddModelError(string.Empty,
        "The record you attempted to edit "
      + "was modified by another user after you. 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.");
}

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

if (await TryUpdateModelAsync<Department>(
    departmentToUpdate,
    "Department",
    s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
    try
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }
    catch (DbUpdateConcurrencyException ex)
    {
        var exceptionEntry = ex.Entries.Single();
        var clientValues = (Department)exceptionEntry.Entity;
        var databaseEntry = exceptionEntry.GetDatabaseValues();
        if (databaseEntry == null)
        {
            ModelState.AddModelError(string.Empty, "Unable to save. " +
                "The department was deleted by another user.");
            return Page();
        }

        var dbValues = (Department)databaseEntry.ToObject();
        await SetDbErrorMessage(dbValues, clientValues, _context);

        // Save the current ConcurrencyToken so next postback
        // matches unless an new concurrency issue happens.
        Department.ConcurrencyToken = dbValues.ConcurrencyToken;
        // Clear the model error for the next postback.
        ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
    }

Оператор ModelState.Remove является обязательным, поскольку ModelState имеет старое значение ConcurrencyToken. На странице Razor значение ModelState для поля имеет приоритет над значениями свойств модели, если они присутствуют вместе.

Различия между кодами для SQL Server и SQLite

Ниже вы можете увидеть различия между версиями SQL Server и SQLite:

+ using System;    // For GUID on SQLite

+ departmentToUpdate.ConcurrencyToken = Guid.NewGuid();

 _context.Entry(departmentToUpdate)
    .Property(d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;

- Department.ConcurrencyToken = (byte[])dbValues.ConcurrencyToken;
+ Department.ConcurrencyToken = dbValues.ConcurrencyToken;

Обновление страницы Edit Razor

Обновите Pages/Departments/Edit.cshtml, включив в него следующий код.

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.EditModel
@{
    ViewData["Title"] = "Edit";
}
<h2>Edit</h2>
<h4>Department</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <input type="hidden" asp-for="Department.DepartmentID" />
            <input type="hidden" asp-for="Department.ConcurrencyToken" />
            <div class="form-group">
                <label>Version</label>
                @Utility.GetLastChars(Model.Department.ConcurrencyToken)
            </div>
            <div class="form-group">
                <label asp-for="Department.Name" class="control-label"></label>
                <input asp-for="Department.Name" class="form-control" />
                <span asp-validation-for="Department.Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.Budget" class="control-label"></label>
                <input asp-for="Department.Budget" class="form-control" />
                <span asp-validation-for="Department.Budget" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.StartDate" class="control-label"></label>
                <input asp-for="Department.StartDate" class="form-control" />
                <span asp-validation-for="Department.StartDate" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <label class="control-label">Instructor</label>
                <select asp-for="Department.InstructorID" class="form-control"
                        asp-items="@Model.InstructorNameSL"></select>
                <span asp-validation-for="Department.InstructorID" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <input type="submit" value="Save" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>
<div>
    <a asp-page="./Index">Back to List</a>
</div>
@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

Предыдущий код:

  • Изменяет директиву page с @page на @page "{id:int}".
  • Добавляет версию скрытых строк. Нужно добавить ConcurrencyToken, чтобы обратная передача привязывала значение.
  • Отображает последний байт ConcurrencyToken в целях отладки.
  • Заменяет ViewData на строго типизированный InstructorNameSL.

Тестирование конфликтов параллелизма с использованием страницы "Edit" (Редактирование)

Откройте в браузере два экземпляра страницы "Edit" (Редактирование) для кафедры английского языка:

  • Запустите приложение и выберите "Departments" (Кафедры).
  • Щелкните правой кнопкой мыши гиперссылку Edit (Изменить) для кафедры английского языка и выберите пункт Открыть на новой вкладке.
  • На первой вкладке щелкните гиперссылку Edit (Изменить) для кафедры английского языка.

На обеих вкладках браузера отображаются одинаковые сведения.

Измените имя на первой вкладке браузера и нажмите кнопку Save (Сохранить).

Department Edit page 1 after change

В браузере отображается страница Index с измененным значением и обновленным индикатором ConcurrencyToken. Обратите внимание на обновленный индикатор ConcurrencyToken, он отображается при второй обратной передаче на другой вкладке.

Измените другое поле на второй вкладке браузера.

Department Edit page 2 after change

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

Department Edit page error message

В этом окне браузера не планировалось изменять поле "Name" (Имя). Скопируйте и вставьте текущее значение "Languages" (Языки) в поле "Name" (Имя). Вкладка "Выходить". Проверка на стороне клиента удаляет сообщение об ошибке.

Щелкните Сохранить еще раз. Сохраняется значение, введенное на второй вкладке браузера. Сохраненные значения отображаются на странице индекса.

Обновление модели страницы "Delete" (Удаление)

Обновите Pages/Departments/Delete.cshtml.cs, включив в него следующий код.

using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class DeleteModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public DeleteModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        public string ConcurrencyErrorMessage { get; set; }

        public async Task<IActionResult> OnGetAsync(int id, bool? concurrencyError)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)
                .AsNoTracking()
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                 return NotFound();
            }

            if (concurrencyError.GetValueOrDefault())
            {
                ConcurrencyErrorMessage = "The record you attempted to delete "
                  + "was modified by another user after you selected delete. "
                  + "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.";
            }
            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            try
            {
                if (await _context.Departments.AnyAsync(
                    m => m.DepartmentID == id))
                {
                    // Department.ConcurrencyToken value is from when the entity
                    // was fetched. If it doesn't match the DB, a
                    // DbUpdateConcurrencyException exception is thrown.
                    _context.Departments.Remove(Department);
                    await _context.SaveChangesAsync();
                }
                return RedirectToPage("./Index");
            }
            catch (DbUpdateConcurrencyException)
            {
                return RedirectToPage("./Delete",
                    new { concurrencyError = true, id = id });
            }
        }
    }
}

Страница "Delete" (Удаление) обнаруживает конфликты параллелизма при изменении сущности после ее получения. Department.ConcurrencyToken является версией строки при получении сущности. При EF Core создании SQL DELETE команды он включает предложение WHERE с ConcurrencyToken. Если команда SQL DELETE не затрагивает ни одной строки:

  • ConcurrencyToken в команде SQL DELETE не соответствует ConcurrencyToken в базе данных.
  • Возникает исключение DbUpdateConcurrencyException.
  • Вызывается OnGetAsync с concurrencyError.

Обновление страницы Razor "Delete" (Удаление)

Обновите Pages/Departments/Delete.cshtml, включив в него следующий код.

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.DeleteModel

@{
    ViewData["Title"] = "Delete";
}

<h1>Delete</h1>

<p class="text-danger">@Model.ConcurrencyErrorMessage</p>

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>Department</h4>
    <hr />
    <dl class="row">
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Department.Name)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Department.Name)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Department.Budget)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Department.Budget)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Department.StartDate)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Department.StartDate)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Department.ConcurrencyToken)
        </dt>
        <dd class="col-sm-10">
            @Utility.GetLastChars(Model.Department.ConcurrencyToken)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Department.Administrator)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Department.Administrator.FullName)
        </dd>
    </dl>

    <form method="post">
        <input type="hidden" asp-for="Department.DepartmentID" />
        <input type="hidden" asp-for="Department.ConcurrencyToken" />
        <input type="submit" value="Delete" class="btn btn-danger" /> |
        <a asp-page="./Index">Back to List</a>
    </form>
</div>

Приведенный выше код вносит следующие изменения:

  • Изменяет директиву page с @page на @page "{id:int}".
  • Добавляет сообщение об ошибке.
  • Заменяет FirstMidName на FullName в поле Administrator (Администратор).
  • Изменяет ConcurrencyToken для отображения последнего байта.
  • Добавляет версию скрытых строк. Нужно добавить ConcurrencyToken, чтобы обратная передача привязывала значение.

Тестирование конфликтов параллелизма

Создайте тестовую кафедру.

Откройте в браузере два экземпляра страницы "Delete" (Удаление) для тестовой кафедры:

  • Запустите приложение и выберите "Departments" (Кафедры).
  • Щелкните правой кнопкой мыши гиперссылку Delete (Удалить) для тестовой кафедры и выберите пункт Открыть на новой вкладке.
  • Щелкните гиперссылку Edit (Изменить) для тестовой кафедры.

На обеих вкладках браузера отображаются одинаковые сведения.

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

В браузере отображается страница Index с измененным значением и обновленным индикатором ConcurrencyToken. Обратите внимание на обновленный индикатор ConcurrencyToken, он отображается при второй обратной передаче на другой вкладке.

Удалите тестовый отдел на второй вкладке. Ошибка параллелизма отображается с текущими значениями из базы данных. При нажатии кнопки Delete (Удалить) сущность удаляется, если только не был обновлен ConcurrencyToken.

Дополнительные ресурсы

Следующие шаги

Это последний учебник из серии. Дополнительные темы рассматриваются в версии этой серии для MVC.

Это руководство описывает, как обрабатывать конфликты, когда несколько пользователей параллельно (одновременно) изменяют одну сущность.

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

Конфликт параллелизма возникает в следующих условиях:

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

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

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

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

Управление блокировками имеет недостатки. Его может быть сложно реализовать, и оно может вызывать проблемы с производительностью по мере увеличения числа пользователей. В Entity Framework Core нет встроенной поддержки этой функции, и данное руководство не рассказывает, как ее реализовать.

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

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

Changing budget to 0

Прежде чем Мария нажимает кнопку Save (Сохранить), Дмитрий заходит на ту же страницу и изменяет значение в поле "Start Date" (Дата начала) с 9/1/2007 на 9/1/2013.

Changing start date to 2013

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

Дмитрий нажимает кнопку Save (Сохранить) на странице редактирования, где все еще отображается бюджет 350 000,00 USD. Дальнейший ход событий определяется порядком обработки конфликтов параллелизма.

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

    В этом сценарии никакие данные не теряются. Разные свойства были обновлены двумя пользователями. Когда какой-либо пользователь просмотрит кафедру английского языка в следующий раз, он увидит изменения, внесенные как Марией, так и Дмитрием. Этот способ обновления позволяет снизить количество конфликтов, способных привести к потере данных. Такой подход имеет ряд недостатков.

    • Он не позволяет избежать потери данных, если конкурирующие изменения вносятся для одного свойства.
    • Он не слишком хорошо подходит для веб-приложений, так как требует поддерживать значительный объем состояний, чтобы отслеживать все извлеченные и новые значения. Обслуживание большого объема состояний может негативно повлиять на производительность приложения.
    • Он может повысить сложность приложений по сравнению с обнаружением параллелизма для сущности.
  • Вы можете позволить изменению Дмитрия перезаписать изменение Марии.

    Когда какой-либо пользователь просмотрит кафедру английского языка в следующий раз, он увидит дату 9/1/2013 и значение 350 000,00 USD. Такой подход называется победой клиента или сохранением последнего внесенного изменения. (Все значения от клиента имеют приоритет над тем, что находится в хранилище данных.) Если вы не выполняете код для обработки параллелизма, клиент Wins происходит автоматически.

  • Вы можете запретить обновление изменения Дмитрия в базе данных. Как правило приложение будет:

    • отображать сообщение об ошибке;
    • отображать текущее состояние данных;
    • разрешать пользователю повторно применять изменения.

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

Обнаружение конфликтов в EF Core

EF Core создает DbConcurrencyException исключения при обнаружении конфликтов. Модель данных необходимо настроить для обнаружения конфликтов. Ниже приведены варианты реализации обнаружения конфликтов.

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

    При вызове SaveChanges предложение Where ищет исходные значения всех свойств, помеченных атрибутом ConcurrencyCheckAttribute. Инструкция UPDATE не найдет обновляемую строку, если какие-либо свойства токенов параллелизма изменились с момента первого чтения строки. EF Core интерпретирует это как конфликт параллелизма. Для таблиц базы данных со множеством столбцов этот подход может привести к очень большим предложениям Where и потребовать большого объема состояний. Поэтому в общем случае данный подход не рекомендуется, кроме того, он не применяется и в этом руководстве.

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

    В базе данных SQL Server типом данных для столбца отслеживания является rowversion. Значение rowversion является последовательным номером, увеличивающимся при каждом обновлении строки. В команде Update или Delete предложение Where содержит исходное значение столбца отслеживания (номер исходной версии строки). Если обновляемая строка была изменена другим пользователем, значение в столбце rowversion отличается от исходного. В этом случае инструкции Update или Delete не удается найти строку для обновления из-за предложения Where. EF Core создает исключение параллелизма, если строки не затрагиваются командой Update или Delete.

Добавление свойства отслеживания

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

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    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)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        [Display(Name = "Start Date")]
        public DateTime StartDate { get; set; }

        public int? InstructorID { get; set; }

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

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

Именно атрибут TimestampAttribute определяет столбец как столбец отслеживания параллелизма. Другой способ задания свойства отслеживания — текучий API.

modelBuilder.Entity<Department>()
  .Property<byte[]>("RowVersion")
  .IsRowVersion();

Для базы данных SQL Server атрибут [Timestamp] свойства сущности, определенного как массив байтов, делает следующее:

  • приводит к тому, что столбец включается в предложения WHERE инструкций DELETE и UPDATE;
  • задает тип rowversion для столбца в базе данных.

База данных формирует последовательный номер версии строки, увеличивающийся при каждом обновлении строки. В команде Update или Delete предложение Where включает в себя извлеченное значение версии строки. Если обновляемая строка изменились с момента получения:

  • текущее значение версии строки не соответствует полученному значению;
  • команда Update или Delete не находит строку, так как предложение Where ищет полученное значение версии строки;
  • Возникает исключение DbUpdateConcurrencyException.

В следующем коде показана часть T-SQL, EF Core созданная при обновлении имени отдела:

SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

Предыдущий выделенный код показывает предложение WHERE, содержащее RowVersion. Если база данных RowVersion не соответствует параметру RowVersion (@p2), строки не обновляются.

Следующий выделенный код показывает код T-SQL, который подтверждает, что была обновлена всего одна строка.

SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

@@ROWCOUNT возвращает число строк, затронутых при выполнении последнего оператора. Если строки не обновляются, EF Core вызывает исключение DbUpdateConcurrencyException.

Обновление базы данных

Добавление свойства RowVersion изменяет модель данных, которая требует миграции.

Выполните сборку проекта.

  • Выполните следующую команду в PMC:

    Add-Migration RowVersion
    

Эта команда:

  • Migrations/{time stamp}_RowVersion.cs Создает файл миграции.

  • Migrations/SchoolContextModelSnapshot.cs Обновления файл. Это изменение добавляет следующий выделенный код в метод BuildModel:

    modelBuilder.Entity("ContosoUniversity.Models.Department", b =>
        {
            b.Property<int>("DepartmentID")
                .ValueGeneratedOnAdd()
                .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
    
            b.Property<decimal>("Budget")
                .HasColumnType("money");
    
            b.Property<int?>("InstructorID");
    
            b.Property<string>("Name")
                .HasMaxLength(50);
    
            b.Property<byte[]>("RowVersion")
                .IsConcurrencyToken()
                .ValueGeneratedOnAddOrUpdate();
    
            b.Property<DateTime>("StartDate");
    
            b.HasKey("DepartmentID");
    
            b.HasIndex("InstructorID");
    
            b.ToTable("Department");
        });
    
  • Выполните следующую команду в PMC:

    Update-Database
    

Формирование шаблона страниц кафедр

  • Следуйте инструкциям в разделе Формирование шаблона для страниц Student, за исключением следующего:

  • Создайте папку Pages/Departments.

  • Используйте класс модели Department.

    • Используйте существующий класс контекста вместо создания нового.

Выполните сборку проекта.

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

Средство формирования шаблонов создало столбец RowVersion для страницы индекса, однако это поле не будет отображаться в рабочем приложении. В этом учебнике отображается последний байт RowVersion для демонстрации работы параллелизма. Этот последний байт необязательно является уникальным.

Обновите страницу Pages\Departments\Index.cshtml:

  • Замените Index на Departments.
  • Измените код, содержащий RowVersion, чтобы отображался только последний байт массива байтов.
  • Замените FirstMidName на FullName.

В следующем примере кода показана обновленная страница:

@page
@model ContosoUniversity.Pages.Departments.IndexModel

@{
    ViewData["Title"] = "Departments";
}

<h2>Departments</h2>

<p>
    <a asp-page="Create">Create New</a>
</p>
<table class="table">
    <thead>
        <tr>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].Name)
                </th>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].Budget)
                </th>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].StartDate)
                </th>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].Administrator)
            </th>
            <th>
                RowVersion
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model.Department)
        {
            <tr>
                <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>
                <td>
                    @item.RowVersion[7]
                </td>
                <td>
                    <a asp-page="./Edit" asp-route-id="@item.DepartmentID">Edit</a> |
                    <a asp-page="./Details" asp-route-id="@item.DepartmentID">Details</a> |
                    <a asp-page="./Delete" asp-route-id="@item.DepartmentID">Delete</a>
                </td>
            </tr>
        }
    </tbody>
</table>

Обновление модели страницы "Edit" (Редактирование)

Обновите Pages/Departments/Edit.cshtml.cs, включив в него следующий код.

using ContosoUniversity.Data;
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class EditModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public EditModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        // Replace ViewData["InstructorID"] 
        public SelectList InstructorNameSL { get; set; }

        public async Task<IActionResult> OnGetAsync(int id)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)  // eager loading
                .AsNoTracking()                 // tracking not required
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                return NotFound();
            }

            // Use strongly typed data rather than ViewData.
            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FirstMidName");

            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            if (!ModelState.IsValid)
            {
                return Page();
            }

            var departmentToUpdate = await _context.Departments
                .Include(i => i.Administrator)
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (departmentToUpdate == null)
            {
                return HandleDeletedDepartment();
            }

            _context.Entry(departmentToUpdate)
                .Property("RowVersion").OriginalValue = Department.RowVersion;

            if (await TryUpdateModelAsync<Department>(
                departmentToUpdate,
                "Department",
                s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
            {
                try
                {
                    await _context.SaveChangesAsync();
                    return RedirectToPage("./Index");
                }
                catch (DbUpdateConcurrencyException ex)
                {
                    var exceptionEntry = ex.Entries.Single();
                    var clientValues = (Department)exceptionEntry.Entity;
                    var databaseEntry = exceptionEntry.GetDatabaseValues();
                    if (databaseEntry == null)
                    {
                        ModelState.AddModelError(string.Empty, "Unable to save. " +
                            "The department was deleted by another user.");
                        return Page();
                    }

                    var dbValues = (Department)databaseEntry.ToObject();
                    await setDbErrorMessage(dbValues, clientValues, _context);

                    // Save the current RowVersion so next postback
                    // matches unless an new concurrency issue happens.
                    Department.RowVersion = (byte[])dbValues.RowVersion;
                    // Clear the model error for the next postback.
                    ModelState.Remove("Department.RowVersion");
                }
            }

            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FullName", departmentToUpdate.InstructorID);

            return Page();
        }

        private IActionResult HandleDeletedDepartment()
        {
            var deletedDepartment = new Department();
            // ModelState contains the posted data because of the deletion error
            // and will overide the Department instance values when displaying Page().
            ModelState.AddModelError(string.Empty,
                "Unable to save. The department was deleted by another user.");
            InstructorNameSL = new SelectList(_context.Instructors, "ID", "FullName", Department.InstructorID);
            return Page();
        }

        private async Task setDbErrorMessage(Department dbValues,
                Department clientValues, SchoolContext context)
        {

            if (dbValues.Name != clientValues.Name)
            {
                ModelState.AddModelError("Department.Name",
                    $"Current value: {dbValues.Name}");
            }
            if (dbValues.Budget != clientValues.Budget)
            {
                ModelState.AddModelError("Department.Budget",
                    $"Current value: {dbValues.Budget:c}");
            }
            if (dbValues.StartDate != clientValues.StartDate)
            {
                ModelState.AddModelError("Department.StartDate",
                    $"Current value: {dbValues.StartDate:d}");
            }
            if (dbValues.InstructorID != clientValues.InstructorID)
            {
                Instructor dbInstructor = await _context.Instructors
                   .FindAsync(dbValues.InstructorID);
                ModelState.AddModelError("Department.InstructorID",
                    $"Current value: {dbInstructor?.FullName}");
            }

            ModelState.AddModelError(string.Empty,
                "The record you attempted to edit "
              + "was modified by another user after you. 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.");
        }
    }
}

OriginalValue обновляется с помощью значения rowVersion из сущности при получении в методе OnGetAsync. EF Core создает команду SQL UPDATE с предложением WHERE, содержащим исходное RowVersion значение. Если команда UPDATE не затрагивает никакие строки (нет строк, имеющих исходное значение RowVersion), возникает исключение DbUpdateConcurrencyException.

public async Task<IActionResult> OnPostAsync(int id)
{
    if (!ModelState.IsValid)
    {
        return Page();
    }

    var departmentToUpdate = await _context.Departments
        .Include(i => i.Administrator)
        .FirstOrDefaultAsync(m => m.DepartmentID == id);

    if (departmentToUpdate == null)
    {
        return HandleDeletedDepartment();
    }

    _context.Entry(departmentToUpdate)
        .Property("RowVersion").OriginalValue = Department.RowVersion;

В предыдущем выделенном коде:

  • Значение в Department.RowVersion — это значение, которое имела сущность, когда она была изначально получена в запросе Get для страницы редактирования. Значение передается в метод OnPost посредством скрытого поля на странице Razor, на которой отображается редактируемая сущность. Значение скрытого поля копируется в Department.RowVersion связывателем модели.
  • OriginalValue — то, что EF Core будет использоваться в предложении Where. Перед выполнением выделенной строки кода OriginalValue имеет значение, которое было в базе данных при вызове FirstOrDefaultAsync в этом методе. Оно может отличаться от того, которое отображалось на странице редактирования.
  • Выделенный код гарантирует, что EF Core использует исходное RowVersion значение из отображаемой Department сущности в предложении Where инструкции SQL UPDATE.

При возникновении ошибки параллелизма приведенный ниже выделенный код получает значения клиента (значения, переданные в этот метод) и значения из базы данных.

if (await TryUpdateModelAsync<Department>(
    departmentToUpdate,
    "Department",
    s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
    try
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }
    catch (DbUpdateConcurrencyException ex)
    {
        var exceptionEntry = ex.Entries.Single();
        var clientValues = (Department)exceptionEntry.Entity;
        var databaseEntry = exceptionEntry.GetDatabaseValues();
        if (databaseEntry == null)
        {
            ModelState.AddModelError(string.Empty, "Unable to save. " +
                "The department was deleted by another user.");
            return Page();
        }

        var dbValues = (Department)databaseEntry.ToObject();
        await setDbErrorMessage(dbValues, clientValues, _context);

        // Save the current RowVersion so next postback
        // matches unless an new concurrency issue happens.
        Department.RowVersion = (byte[])dbValues.RowVersion;
        // Clear the model error for the next postback.
        ModelState.Remove("Department.RowVersion");
    }

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

private async Task setDbErrorMessage(Department dbValues,
        Department clientValues, SchoolContext context)
{

    if (dbValues.Name != clientValues.Name)
    {
        ModelState.AddModelError("Department.Name",
            $"Current value: {dbValues.Name}");
    }
    if (dbValues.Budget != clientValues.Budget)
    {
        ModelState.AddModelError("Department.Budget",
            $"Current value: {dbValues.Budget:c}");
    }
    if (dbValues.StartDate != clientValues.StartDate)
    {
        ModelState.AddModelError("Department.StartDate",
            $"Current value: {dbValues.StartDate:d}");
    }
    if (dbValues.InstructorID != clientValues.InstructorID)
    {
        Instructor dbInstructor = await _context.Instructors
           .FindAsync(dbValues.InstructorID);
        ModelState.AddModelError("Department.InstructorID",
            $"Current value: {dbInstructor?.FullName}");
    }

    ModelState.AddModelError(string.Empty,
        "The record you attempted to edit "
      + "was modified by another user after you. 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.");
}

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

if (await TryUpdateModelAsync<Department>(
    departmentToUpdate,
    "Department",
    s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
    try
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }
    catch (DbUpdateConcurrencyException ex)
    {
        var exceptionEntry = ex.Entries.Single();
        var clientValues = (Department)exceptionEntry.Entity;
        var databaseEntry = exceptionEntry.GetDatabaseValues();
        if (databaseEntry == null)
        {
            ModelState.AddModelError(string.Empty, "Unable to save. " +
                "The department was deleted by another user.");
            return Page();
        }

        var dbValues = (Department)databaseEntry.ToObject();
        await setDbErrorMessage(dbValues, clientValues, _context);

        // Save the current RowVersion so next postback
        // matches unless an new concurrency issue happens.
        Department.RowVersion = (byte[])dbValues.RowVersion;
        // Clear the model error for the next postback.
        ModelState.Remove("Department.RowVersion");
    }

Оператор ModelState.Remove является обязательным, так как ModelState имеет старое значение RowVersion. На странице Razor значение ModelState для поля имеет приоритет над значениями свойств модели, если они присутствуют вместе.

Обновление страницы Edit

Обновите Pages/Departments/Edit.cshtml, включив в него следующий код.

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.EditModel
@{
    ViewData["Title"] = "Edit";
}
<h2>Edit</h2>
<h4>Department</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <input type="hidden" asp-for="Department.DepartmentID" />
            <input type="hidden" asp-for="Department.RowVersion" />
            <div class="form-group">
                <label>RowVersion</label>
                @Model.Department.RowVersion[7]
            </div>
            <div class="form-group">
                <label asp-for="Department.Name" class="control-label"></label>
                <input asp-for="Department.Name" class="form-control" />
                <span asp-validation-for="Department.Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.Budget" class="control-label"></label>
                <input asp-for="Department.Budget" class="form-control" />
                <span asp-validation-for="Department.Budget" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.StartDate" class="control-label"></label>
                <input asp-for="Department.StartDate" class="form-control" />
                <span asp-validation-for="Department.StartDate" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <label class="control-label">Instructor</label>
                <select asp-for="Department.InstructorID" class="form-control"
                        asp-items="@Model.InstructorNameSL"></select>
                <span asp-validation-for="Department.InstructorID" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <input type="submit" value="Save" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>
<div>
    <a asp-page="./Index">Back to List</a>
</div>
@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

Предыдущий код:

  • Изменяет директиву page с @page на @page "{id:int}".
  • Добавляет версию скрытых строк. Нужно добавить RowVersion, чтобы обратная передача привязывала значение.
  • Отображает последний байт RowVersion в целях отладки.
  • Заменяет ViewData на строго типизированный InstructorNameSL.

Тестирование конфликтов параллелизма с использованием страницы "Edit" (Редактирование)

Откройте в браузере два экземпляра страницы "Edit" (Редактирование) для кафедры английского языка:

  • Запустите приложение и выберите "Departments" (Кафедры).
  • Щелкните правой кнопкой мыши гиперссылку Edit (Изменить) для кафедры английского языка и выберите пункт Открыть на новой вкладке.
  • На первой вкладке щелкните гиперссылку Edit (Изменить) для кафедры английского языка.

На обеих вкладках браузера отображаются одинаковые сведения.

Измените имя на первой вкладке браузера и нажмите кнопку Save (Сохранить).

Department Edit page 1 after change

В браузере отображается страница индекса с измененным значением и обновленным индикатором rowVersion. Обратите внимание на обновленный индикатор rowVersion, он отображается при второй обратной передаче на другой вкладке.

Измените другое поле на второй вкладке браузера.

Department Edit page 2 after change

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

Department Edit page error message

В этом окне браузера не планировалось изменять поле "Name" (Имя). Скопируйте и вставьте текущее значение "Languages" (Языки) в поле "Name" (Имя). Вкладка "Выходить". Проверка на стороне клиента удаляет сообщение об ошибке.

Щелкните Сохранить еще раз. Сохраняется значение, введенное на второй вкладке браузера. Сохраненные значения отображаются на странице индекса.

Обновление модели страницы "Delete" (Удаление)

Обновите Pages/Departments/Delete.cshtml.cs, включив в него следующий код.

using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class DeleteModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public DeleteModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        public string ConcurrencyErrorMessage { get; set; }

        public async Task<IActionResult> OnGetAsync(int id, bool? concurrencyError)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)
                .AsNoTracking()
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                 return NotFound();
            }

            if (concurrencyError.GetValueOrDefault())
            {
                ConcurrencyErrorMessage = "The record you attempted to delete "
                  + "was modified by another user after you selected delete. "
                  + "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.";
            }
            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            try
            {
                if (await _context.Departments.AnyAsync(
                    m => m.DepartmentID == id))
                {
                    // Department.rowVersion value is from when the entity
                    // was fetched. If it doesn't match the DB, a
                    // DbUpdateConcurrencyException exception is thrown.
                    _context.Departments.Remove(Department);
                    await _context.SaveChangesAsync();
                }
                return RedirectToPage("./Index");
            }
            catch (DbUpdateConcurrencyException)
            {
                return RedirectToPage("./Delete",
                    new { concurrencyError = true, id = id });
            }
        }
    }
}

Страница "Delete" (Удаление) обнаруживает конфликты параллелизма при изменении сущности после ее получения. Department.RowVersion является версией строки при получении сущности. При EF Core создании команды SQL DELETE она включает предложение WHERE с RowVersion. Если команда SQL DELETE не затрагивает ни одной строки:

  • RowVersion в команде SQL DELETE не соответствует RowVersion в базе данных.
  • Возникает исключение DbUpdateConcurrencyException.
  • Вызывается OnGetAsync с concurrencyError.

Обновление страницы "Delete" (Удаление)

Обновите Pages/Departments/Delete.cshtml, включив в него следующий код.

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.DeleteModel

@{
    ViewData["Title"] = "Delete";
}

<h2>Delete</h2>

<p class="text-danger">@Model.ConcurrencyErrorMessage</p>

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>Department</h4>
    <hr />
    <dl class="dl-horizontal">
        <dt>
            @Html.DisplayNameFor(model => model.Department.Name)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.Name)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.Budget)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.Budget)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.StartDate)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.StartDate)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.RowVersion)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.RowVersion[7])
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.Administrator)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.Administrator.FullName)
        </dd>
    </dl>
    
    <form method="post">
        <input type="hidden" asp-for="Department.DepartmentID" />
        <input type="hidden" asp-for="Department.RowVersion" />
        <div class="form-actions no-color">
            <input type="submit" value="Delete" class="btn btn-danger" /> |
            <a asp-page="./Index">Back to List</a>
        </div>
</form>
</div>

Приведенный выше код вносит следующие изменения:

  • Изменяет директиву page с @page на @page "{id:int}".
  • Добавляет сообщение об ошибке.
  • Заменяет FirstMidName на FullName в поле Administrator (Администратор).
  • Изменяет RowVersion для отображения последнего байта.
  • Добавляет версию скрытых строк. Нужно добавить RowVersion, чтобы обратная передача привязывала значение.

Тестирование конфликтов параллелизма

Создайте тестовую кафедру.

Откройте в браузере два экземпляра страницы "Delete" (Удаление) для тестовой кафедры:

  • Запустите приложение и выберите "Departments" (Кафедры).
  • Щелкните правой кнопкой мыши гиперссылку Delete (Удалить) для тестовой кафедры и выберите пункт Открыть на новой вкладке.
  • Щелкните гиперссылку Edit (Изменить) для тестовой кафедры.

На обеих вкладках браузера отображаются одинаковые сведения.

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

В браузере отображается страница индекса с измененным значением и обновленным индикатором rowVersion. Обратите внимание на обновленный индикатор rowVersion, он отображается при второй обратной передаче на другой вкладке.

Удалите тестовый отдел на второй вкладке. Ошибка параллелизма отображается с текущими значениями из базы данных. При нажатии кнопки Delete (Удалить) сущность удаляется, если только не был обновлен RowVersion.

Дополнительные ресурсы

Следующие шаги

Это последний учебник из серии. Дополнительные темы рассматриваются в версии этой серии для MVC.

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

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

Конфликт параллелизма возникает в следующих условиях:

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

Если обнаружение параллелизма отключено, возникают параллельные изменения:

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

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

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

Changing budget to 0

Прежде чем Мария нажимает кнопку Save (Сохранить), Дмитрий заходит на ту же страницу и изменяет значение в поле "Start Date" (Дата начала) с 9/1/2007 на 9/1/2013.

Changing start date to 2013

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

Budget changed to zero

Дмитрий нажимает кнопку Save (Сохранить) на странице редактирования, где все еще отображается бюджет 350 000,00 USD. Дальнейший ход событий определяется порядком обработки конфликтов параллелизма.

Оптимистическая блокировка включает в себя следующие параметры:

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

    В этом сценарии никакие данные не теряются. Разные свойства были обновлены двумя пользователями. Когда какой-либо пользователь просмотрит кафедру английского языка в следующий раз, он увидит изменения, внесенные как Марией, так и Дмитрием. Этот способ обновления позволяет снизить количество конфликтов, способных привести к потере данных. Особенности этого подхода:

    • Он не позволяет избежать потери данных, если конкурирующие изменения вносятся для одного свойства.
    • Он не слишком хорошо подходит для веб-приложений, так как требует поддерживать значительный объем состояний, чтобы отслеживать все извлеченные и новые значения. Обслуживание большого объема состояний может негативно повлиять на производительность приложения.
    • Он может повысить сложность приложений по сравнению с обнаружением параллелизма для сущности.
  • Вы можете позволить изменению Дмитрия перезаписать изменение Марии.

    Когда какой-либо пользователь просмотрит кафедру английского языка в следующий раз, он увидит дату 9/1/2013 и значение 350 000,00 USD. Такой подход называется победой клиента или сохранением последнего внесенного изменения. (Все значения от клиента имеют приоритет над тем, что находится в хранилище данных.) Если вы не выполняете код для обработки параллелизма, клиент Wins происходит автоматически.

  • Вы можете запретить обновление изменения Дмитрия в базе данных. Как правило приложение будет:

    • отображать сообщение об ошибке;
    • отображать текущее состояние данных;
    • разрешать пользователю повторно применять изменения.

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

Обработка параллелизма

Если свойство настроено как токен параллелизма:

  • EF Core проверяет, что свойство не было изменено после получения. Проверка возникает при SaveChanges вызове или SaveChangesAsync вызове.
  • Если свойство было изменено после получения, DbUpdateConcurrencyException создается исключение.

Нужно настроить базу данных и модель данных для поддержки исключения DbUpdateConcurrencyException.

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

Конфликты параллелизма можно обнаружить на уровне свойств с помощью атрибута ConcurrencyCheck. Этот атрибут можно применить к нескольким свойствам в модели. Дополнительные сведения см. в описании ConcurrencyCheck в подразделе "Заметки к данным".

Атрибут[ConcurrencyCheck] в этом руководстве не используется.

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

Чтобы обнаружить конфликты параллелизма, в модель добавлен столбец отслеживания rowversion. rowversion :

  • Относится к SQL Server. Другие базы данных могут не предоставлять подобную функцию.
  • Используется для определения того, что сущность не была изменена после ее получения из базы данных.

База данных формирует последовательный номер rowversion, увеличивающийся при каждом обновлении строки. В команде Update или Delete предложение Where включает извлеченное значение из rowversion. Если обновляемая строка изменились:

  • rowversion не соответствует полученному значению.
  • Команда Update или Delete не находит строку, так как предложение Where включает полученное значение rowversion.
  • Возникает исключение DbUpdateConcurrencyException.

В EF Coreслучае, если строки не были обновлены Update или Delete командой, создается исключение параллелизма.

Добавление свойства отслеживания в сущность Department

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

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    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)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        [Display(Name = "Start Date")]
        public DateTime StartDate { get; set; }

        public int? InstructorID { get; set; }

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

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

Атрибут Timestamp указывает, что этот столбец входит в предложение Where для команд Update и Delete. Этот атрибут называется Timestamp, так как предыдущие версии SQL Server использовали тип данных timestamp SQL, пока его не сменил тип rowversion.

Текучий API также может задавать свойство отслеживания:

modelBuilder.Entity<Department>()
  .Property<byte[]>("RowVersion")
  .IsRowVersion();

В следующем коде показана часть T-SQL, EF Core созданная при обновлении имени отдела:

SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

Предыдущий выделенный код показывает предложение WHERE, содержащее RowVersion. Если база данных RowVersion не соответствует параметру RowVersion (@p2), никакие строки не обновляются.

Следующий выделенный код показывает код T-SQL, который подтверждает, что была обновлена всего одна строка.

SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

@@ROWCOUNT возвращает число строк, затронутых при выполнении последнего оператора. В ней не обновляются строки, EF Core вызывается DbUpdateConcurrencyExceptionисключение .

Вы увидите, что T-SQL EF Core создается в окне вывода Visual Studio.

Обновление базы данных

Добавление свойства RowVersion изменяет модель базы данных, которая требует миграции.

Выполните сборку проекта. Введите в командном окне следующее:

dotnet ef migrations add RowVersion
dotnet ef database update

Предыдущие команды:

  • Migrations/{time stamp}_RowVersion.cs Добавляет файл миграции.

  • Migrations/SchoolContextModelSnapshot.cs Обновления файл. Это изменение добавляет следующий выделенный код в метод BuildModel:

  • Запускает миграции для обновления базы данных.

Формирование шаблона для модели кафедр

Следуйте инструкциям в разделе Формирование шаблона для модели Student и используйте Department для класса модели.

Предыдущая команда формирует шаблон для модели Department. Откройте проект в Visual Studio.

Выполните сборку проекта.

Изменение страницы индекса кафедр

Подсистема формирования шаблонов создала столбец RowVersion для страницы индекса, однако это поле не должно отображаться. В этом руководстве отображается последний байт RowVersion для лучшего понимания параллелизма. Этот последний байт необязательно является уникальным. Реальное приложение не будет отображать RowVersion или последний байт RowVersion.

Обновите страницу индекса:

  • Замените Index на Departments.
  • Замените разметку, содержащую RowVersion, на последний байт RowVersion.
  • Замените FirstMidName на FullName.

Следующая разметка показывает обновленную страницу:

@page
@model ContosoUniversity.Pages.Departments.IndexModel

@{
    ViewData["Title"] = "Departments";
}

<h2>Departments</h2>

<p>
    <a asp-page="Create">Create New</a>
</p>
<table class="table">
    <thead>
        <tr>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].Name)
                </th>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].Budget)
                </th>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].StartDate)
                </th>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].Administrator)
            </th>
            <th>
                RowVersion
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
@foreach (var item in Model.Department) {
        <tr>
            <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>
            <td>
                @item.RowVersion[7]
            </td>
            <td>
                <a asp-page="./Edit" asp-route-id="@item.DepartmentID">Edit</a> |
                <a asp-page="./Details" asp-route-id="@item.DepartmentID">Details</a> |
                <a asp-page="./Delete" asp-route-id="@item.DepartmentID">Delete</a>
            </td>
        </tr>
}
    </tbody>
</table>

Обновление модели страницы "Edit" (Редактирование)

Обновите Pages/Departments/Edit.cshtml.cs, включив в него следующий код.

using ContosoUniversity.Data;
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class EditModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public EditModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        // Replace ViewData["InstructorID"] 
        public SelectList InstructorNameSL { get; set; }

        public async Task<IActionResult> OnGetAsync(int id)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)  // eager loading
                .AsNoTracking()                 // tracking not required
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                return NotFound();
            }

            // Use strongly typed data rather than ViewData.
            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FirstMidName");

            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            if (!ModelState.IsValid)
            {
                return Page();
            }

            var departmentToUpdate = await _context.Departments
                .Include(i => i.Administrator)
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            // null means Department was deleted by another user.
            if (departmentToUpdate == null)
            {
                return HandleDeletedDepartment();
            }

            // Update the RowVersion to the value when this entity was
            // fetched. If the entity has been updated after it was
            // fetched, RowVersion won't match the DB RowVersion and
            // a DbUpdateConcurrencyException is thrown.
            // A second postback will make them match, unless a new 
            // concurrency issue happens.
            _context.Entry(departmentToUpdate)
                .Property("RowVersion").OriginalValue = Department.RowVersion;

            if (await TryUpdateModelAsync<Department>(
                departmentToUpdate,
                "Department",
                s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
            {
                try
                {
                    await _context.SaveChangesAsync();
                    return RedirectToPage("./Index");
                }
                catch (DbUpdateConcurrencyException ex)
                {
                    var exceptionEntry = ex.Entries.Single();
                    var clientValues = (Department)exceptionEntry.Entity;
                    var databaseEntry = exceptionEntry.GetDatabaseValues();
                    if (databaseEntry == null)
                    {
                        ModelState.AddModelError(string.Empty, "Unable to save. " +
                            "The department was deleted by another user.");
                        return Page();
                    }

                    var dbValues = (Department)databaseEntry.ToObject();
                    await SetDbErrorMessage(dbValues, clientValues, _context);

                    // Save the current RowVersion so next postback
                    // matches unless an new concurrency issue happens.
                    Department.RowVersion = (byte[])dbValues.RowVersion;
                    // Must clear the model error for the next postback.
                    ModelState.Remove("Department.RowVersion");
                }
            }

            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FullName", departmentToUpdate.InstructorID);

            return Page();
        }

        private IActionResult HandleDeletedDepartment()
        {
            // ModelState contains the posted data because of the deletion error and will overide the Department instance values when displaying Page().
            ModelState.AddModelError(string.Empty,
                "Unable to save. The department was deleted by another user.");
            InstructorNameSL = new SelectList(_context.Instructors, "ID", "FullName", Department.InstructorID);
            return Page();
        }

        private async Task SetDbErrorMessage(Department dbValues,
                Department clientValues, SchoolContext context)
        {

            if (dbValues.Name != clientValues.Name)
            {
                ModelState.AddModelError("Department.Name",
                    $"Current value: {dbValues.Name}");
            }
            if (dbValues.Budget != clientValues.Budget)
            {
                ModelState.AddModelError("Department.Budget",
                    $"Current value: {dbValues.Budget:c}");
            }
            if (dbValues.StartDate != clientValues.StartDate)
            {
                ModelState.AddModelError("Department.StartDate",
                    $"Current value: {dbValues.StartDate:d}");
            }
            if (dbValues.InstructorID != clientValues.InstructorID)
            {
                Instructor dbInstructor = await _context.Instructors
                   .FindAsync(dbValues.InstructorID);
                ModelState.AddModelError("Department.InstructorID",
                    $"Current value: {dbInstructor?.FullName}");
            }

            ModelState.AddModelError(string.Empty,
                "The record you attempted to edit "
              + "was modified by another user after you. 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.");
        }
    }
}

Для обнаружения проблемы параллелизма OriginalValue обновляется с помощью значения rowVersion из сущности, откуда он был получен. EF Core создает команду SQL UPDATE с предложением WHERE, содержащим исходное RowVersion значение. Если команда UPDATE не затрагивает никакие строки (нет строк, имеющих исходное значение RowVersion), возникает исключение DbUpdateConcurrencyException.

public async Task<IActionResult> OnPostAsync(int id)
{
    if (!ModelState.IsValid)
    {
        return Page();
    }

    var departmentToUpdate = await _context.Departments
        .Include(i => i.Administrator)
        .FirstOrDefaultAsync(m => m.DepartmentID == id);

    // null means Department was deleted by another user.
    if (departmentToUpdate == null)
    {
        return HandleDeletedDepartment();
    }

    // Update the RowVersion to the value when this entity was
    // fetched. If the entity has been updated after it was
    // fetched, RowVersion won't match the DB RowVersion and
    // a DbUpdateConcurrencyException is thrown.
    // A second postback will make them match, unless a new 
    // concurrency issue happens.
    _context.Entry(departmentToUpdate)
        .Property("RowVersion").OriginalValue = Department.RowVersion;

В приведенном выше коде Department.RowVersion является значением на момент извлечения сущности. OriginalValue является значением в базе данных на момент вызова FirstOrDefaultAsync в этом методе.

Следующий код возвращает значения клиента (значения, переданные в этот метод) и значения базы данных:

try
{
    await _context.SaveChangesAsync();
    return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
    var exceptionEntry = ex.Entries.Single();
    var clientValues = (Department)exceptionEntry.Entity;
    var databaseEntry = exceptionEntry.GetDatabaseValues();
    if (databaseEntry == null)
    {
        ModelState.AddModelError(string.Empty, "Unable to save. " +
            "The department was deleted by another user.");
        return Page();
    }

    var dbValues = (Department)databaseEntry.ToObject();
    await SetDbErrorMessage(dbValues, clientValues, _context);

    // Save the current RowVersion so next postback
    // matches unless an new concurrency issue happens.
    Department.RowVersion = (byte[])dbValues.RowVersion;
    // Must clear the model error for the next postback.
    ModelState.Remove("Department.RowVersion");
}

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

private async Task SetDbErrorMessage(Department dbValues,
        Department clientValues, SchoolContext context)
{

    if (dbValues.Name != clientValues.Name)
    {
        ModelState.AddModelError("Department.Name",
            $"Current value: {dbValues.Name}");
    }
    if (dbValues.Budget != clientValues.Budget)
    {
        ModelState.AddModelError("Department.Budget",
            $"Current value: {dbValues.Budget:c}");
    }
    if (dbValues.StartDate != clientValues.StartDate)
    {
        ModelState.AddModelError("Department.StartDate",
            $"Current value: {dbValues.StartDate:d}");
    }
    if (dbValues.InstructorID != clientValues.InstructorID)
    {
        Instructor dbInstructor = await _context.Instructors
           .FindAsync(dbValues.InstructorID);
        ModelState.AddModelError("Department.InstructorID",
            $"Current value: {dbInstructor?.FullName}");
    }

    ModelState.AddModelError(string.Empty,
        "The record you attempted to edit "
      + "was modified by another user after you. 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.");
}

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

try
{
    await _context.SaveChangesAsync();
    return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
    var exceptionEntry = ex.Entries.Single();
    var clientValues = (Department)exceptionEntry.Entity;
    var databaseEntry = exceptionEntry.GetDatabaseValues();
    if (databaseEntry == null)
    {
        ModelState.AddModelError(string.Empty, "Unable to save. " +
            "The department was deleted by another user.");
        return Page();
    }

    var dbValues = (Department)databaseEntry.ToObject();
    await SetDbErrorMessage(dbValues, clientValues, _context);

    // Save the current RowVersion so next postback
    // matches unless an new concurrency issue happens.
    Department.RowVersion = (byte[])dbValues.RowVersion;
    // Must clear the model error for the next postback.
    ModelState.Remove("Department.RowVersion");
}

Оператор ModelState.Remove является обязательным, так как ModelState имеет старое значение RowVersion. На странице Razor значение ModelState для поля имеет приоритет над значениями свойств модели, если они присутствуют вместе.

Обновление страницы Edit

Обновите Pages/Departments/Edit.cshtml, включив в него следующую разметку:

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.EditModel
@{
    ViewData["Title"] = "Edit";
}
<h2>Edit</h2>
<h4>Department</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <input type="hidden" asp-for="Department.DepartmentID" />
            <input type="hidden" asp-for="Department.RowVersion" />
            <div class="form-group">
                <label>RowVersion</label>
                @Model.Department.RowVersion[7]
            </div>
            <div class="form-group">
                <label asp-for="Department.Name" class="control-label"></label>
                <input asp-for="Department.Name" class="form-control" />
                <span asp-validation-for="Department.Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.Budget" class="control-label"></label>
                <input asp-for="Department.Budget" class="form-control" />
                <span asp-validation-for="Department.Budget" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.StartDate" class="control-label"></label>
                <input asp-for="Department.StartDate" class="form-control" />
                <span asp-validation-for="Department.StartDate" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <label class="control-label">Instructor</label>
                <select asp-for="Department.InstructorID" class="form-control"
                        asp-items="@Model.InstructorNameSL"></select>
                <span asp-validation-for="Department.InstructorID" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <input type="submit" value="Save" class="btn btn-default" />
            </div>
        </form>
    </div>
</div>
<div>
    <a asp-page="./Index">Back to List</a>
</div>
@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

Предыдущая разметка:

  • Изменяет директиву page с @page на @page "{id:int}".
  • Добавляет версию скрытых строк. Нужно добавить RowVersion, чтобы обратная передача привязывала значение.
  • Отображает последний байт RowVersion в целях отладки.
  • Заменяет ViewData на строго типизированный InstructorNameSL.

Тестирование конфликтов параллелизма с использованием страницы "Edit" (Редактирование)

Откройте в браузере два экземпляра страницы "Edit" (Редактирование) для кафедры английского языка:

  • Запустите приложение и выберите "Departments" (Кафедры).
  • Щелкните правой кнопкой мыши гиперссылку Edit (Изменить) для кафедры английского языка и выберите пункт Открыть на новой вкладке.
  • На первой вкладке щелкните гиперссылку Edit (Изменить) для кафедры английского языка.

На обеих вкладках браузера отображаются одинаковые сведения.

Измените имя на первой вкладке браузера и нажмите кнопку Save (Сохранить).

Department Edit page 1 after change

В браузере отображается страница индекса с измененным значением и обновленным индикатором rowVersion. Обратите внимание на обновленный индикатор rowVersion, он отображается при второй обратной передаче на другой вкладке.

Измените другое поле на второй вкладке браузера.

Department Edit page 2 after change

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

Department Edit page error message 1

В этом окне браузера не планировалось изменять поле "Name" (Имя). Скопируйте и вставьте текущее значение "Languages" (Языки) в поле "Name" (Имя). Вкладка "Выходить". Проверка на стороне клиента удаляет сообщение об ошибке.

Department Edit page error message 2

Щелкните Сохранить еще раз. Сохраняется значение, введенное на второй вкладке браузера. Сохраненные значения отображаются на странице индекса.

Обновление страницы "Delete" (Удаление)

Обновите страницу "Delete" (Удаление) с помощью следующего кода:

using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class DeleteModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public DeleteModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        public string ConcurrencyErrorMessage { get; set; }

        public async Task<IActionResult> OnGetAsync(int id, bool? concurrencyError)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)
                .AsNoTracking()
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                 return NotFound();
            }

            if (concurrencyError.GetValueOrDefault())
            {
                ConcurrencyErrorMessage = "The record you attempted to delete "
                  + "was modified by another user after you selected delete. "
                  + "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.";
            }
            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            try
            {
                if (await _context.Departments.AnyAsync(
                    m => m.DepartmentID == id))
                {
                    // Department.rowVersion value is from when the entity
                    // was fetched. If it doesn't match the DB, a
                    // DbUpdateConcurrencyException exception is thrown.
                    _context.Departments.Remove(Department);
                    await _context.SaveChangesAsync();
                }
                return RedirectToPage("./Index");
            }
            catch (DbUpdateConcurrencyException)
            {
                return RedirectToPage("./Delete",
                    new { concurrencyError = true, id = id });
            }
        }
    }
}

Страница "Delete" (Удаление) обнаруживает конфликты параллелизма при изменении сущности после ее получения. Department.RowVersion является версией строки при получении сущности. При EF Core создании команды SQL DELETE она включает предложение WHERE с RowVersion. Если команда SQL DELETE не затрагивает ни одной строки:

  • RowVersion в команде SQL DELETE не соответствует RowVersion в базе данных.
  • Возникает исключение DbUpdateConcurrencyException.
  • Вызывается OnGetAsync с concurrencyError.

Обновление страницы "Delete" (Удаление)

Обновите Pages/Departments/Delete.cshtml, включив в него следующий код.

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.DeleteModel

@{
    ViewData["Title"] = "Delete";
}

<h2>Delete</h2>

<p class="text-danger">@Model.ConcurrencyErrorMessage</p>

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>Department</h4>
    <hr />
    <dl class="dl-horizontal">
        <dt>
            @Html.DisplayNameFor(model => model.Department.Name)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.Name)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.Budget)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.Budget)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.StartDate)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.StartDate)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.RowVersion)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.RowVersion[7])
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.Administrator)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.Administrator.FullName)
        </dd>
    </dl>
    
    <form method="post">
        <input type="hidden" asp-for="Department.DepartmentID" />
        <input type="hidden" asp-for="Department.RowVersion" />
        <div class="form-actions no-color">
            <input type="submit" value="Delete" class="btn btn-default" /> |
            <a asp-page="./Index">Back to List</a>
        </div>
</form>
</div>

Приведенный выше код вносит следующие изменения:

  • Изменяет директиву page с @page на @page "{id:int}".
  • Добавляет сообщение об ошибке.
  • Заменяет FirstMidName на FullName в поле Administrator (Администратор).
  • Изменяет RowVersion для отображения последнего байта.
  • Добавляет версию скрытых строк. Нужно добавить RowVersion, чтобы обратная передача привязывала значение.

Тестирование конфликтов параллелизма с использованием страницы "Delete" (Удаление)

Создайте тестовую кафедру.

Откройте в браузере два экземпляра страницы "Delete" (Удаление) для тестовой кафедры:

  • Запустите приложение и выберите "Departments" (Кафедры).
  • Щелкните правой кнопкой мыши гиперссылку Delete (Удалить) для тестовой кафедры и выберите пункт Открыть на новой вкладке.
  • Щелкните гиперссылку Edit (Изменить) для тестовой кафедры.

На обеих вкладках браузера отображаются одинаковые сведения.

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

В браузере отображается страница индекса с измененным значением и обновленным индикатором rowVersion. Обратите внимание на обновленный индикатор rowVersion, он отображается при второй обратной передаче на другой вкладке.

Удалите тестовый отдел на второй вкладке. Ошибка параллелизма отображается с текущими значениями из базы данных. При нажатии кнопки Delete (Удалить) сущность удаляется, если только не был обновлен RowVersion.

Сведения о том, как наследовать модель данных, см. в разделе Наследование.

Дополнительные ресурсы