Часть 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.
Прежде чем Мария нажимает кнопку Save (Сохранить), Дмитрий заходит на ту же страницу и изменяет значение в поле "Start Date" (Дата начала) с 9/1/2007 на 9/1/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 предоставляет два подхода для маркеров параллелизма:
Применение к свойству модели
[ConcurrencyCheck]
или IsConcurrencyToken. Такой подход не рекомендуется. Дополнительные сведения см. в разделе "Токены параллелизма".EF CoreПрименение к маркеру параллелизма в модели классов TimestampAttribute или IsRowVersion. В этом учебнике используется именно этот подход.
Подход 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 (Сохранить).
В браузере отображается страница Index с измененным значением и обновленным индикатором ConcurrencyToken
. Обратите внимание на обновленный индикатор ConcurrencyToken
, он отображается при второй обратной передаче на другой вкладке.
Измените другое поле на второй вкладке браузера.
Нажмите кнопку Сохранить. Отображаются сообщения об ошибках для всех полей, которые не соответствуют значениям базы данных:
В этом окне браузера не планировалось изменять поле "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.
Прежде чем Мария нажимает кнопку Save (Сохранить), Дмитрий заходит на ту же страницу и изменяет значение в поле "Start Date" (Дата начала) с 9/1/2007 на 9/1/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 (Сохранить).
В браузере отображается страница индекса с измененным значением и обновленным индикатором rowVersion. Обратите внимание на обновленный индикатор rowVersion, он отображается при второй обратной передаче на другой вкладке.
Измените другое поле на второй вкладке браузера.
Нажмите кнопку Сохранить. Отображаются сообщения об ошибках для всех полей, которые не соответствуют значениям базы данных:
В этом окне браузера не планировалось изменять поле "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.
Прежде чем Мария нажимает кнопку Save (Сохранить), Дмитрий заходит на ту же страницу и изменяет значение в поле "Start Date" (Дата начала) с 9/1/2007 на 9/1/2013.
Сначала Мария нажимает кнопку Save (Сохранить) и видит свое изменение, когда браузер отображает страницу индекса.
Дмитрий нажимает кнопку 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 (Сохранить).
В браузере отображается страница индекса с измененным значением и обновленным индикатором rowVersion. Обратите внимание на обновленный индикатор rowVersion, он отображается при второй обратной передаче на другой вкладке.
Измените другое поле на второй вкладке браузера.
Нажмите кнопку Сохранить. Отображаются сообщения об ошибках для всех полей, которые не соответствуют значениям базы данных:
В этом окне браузера не планировалось изменять поле "Name" (Имя). Скопируйте и вставьте текущее значение "Languages" (Языки) в поле "Name" (Имя). Вкладка "Выходить". Проверка на стороне клиента удаляет сообщение об ошибке.
Щелкните Сохранить еще раз. Сохраняется значение, введенное на второй вкладке браузера. Сохраненные значения отображаются на странице индекса.
Обновление страницы "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
.
Сведения о том, как наследовать модель данных, см. в разделе Наследование.
Дополнительные ресурсы
ASP.NET Core