Phần 8, Razor Pages với EF Core trong ASP.NET Core - Xử lý xung đột đồng thời (Concurrency)
Tổng quan
Hướng dẫn này trình bày cách xử lý xung đột khi nhiều người dùng cùng cập nhật một entity. Nội dung sử dụng ứng dụng web Contoso University làm ví dụ.
Xung đột đồng thời (Concurrency Conflicts)
Xung đột đồng thời xảy ra khi:
- Người dùng điều hướng đến trang Edit của một entity
- Người dùng khác cập nhật entity đó trước khi thay đổi của người dùng đầu tiên được ghi vào cơ sở dữ liệu
Pessimistic Concurrency (Đồng thời bi quan - Khóa)
Sử dụng khóa cơ sở dữ liệu để ngăn chặn xung đột. Entity Framework Core không cung cấp hỗ trợ tích hợp cho pessimistic concurrency.
Optimistic Concurrency (Đồng thời lạc quan)
Cho phép xung đột xảy ra, sau đó phản ứng phù hợp. Ba cách tiếp cận:
- Theo dõi ở cấp property: Theo dõi property nào được sửa đổi và chỉ cập nhật những cột đó
- Client Wins: Cho phép thay đổi mới nhất ghi đè lên thay đổi trước đó
- Store Wins: Ngăn thay đổi xung đột với giá trị cơ sở dữ liệu (dùng trong hướng dẫn này)
Phát hiện xung đột trong EF Core
EF Core dùng concurrency token (token đồng thời) để phát hiện xung đột. Khi SaveChanges hoặc SaveChangesAsync được gọi, giá trị concurrency token trong cơ sở dữ liệu được so sánh với giá trị gốc mà EF Core đã đọc.
Phương thức cấu hình
EF Core cung cấp hai cách:
- Thuộc tính ConcurrencyCheck (không khuyến nghị)
- Thuộc tính Timestamp hoặc IsRowVersion (khuyến nghị)
Triển khai cho SQL Server
Thêm Concurrency Token vào Model
Cập nhật 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; }
}
}Thuộc tính [Timestamp]:
- Xác định cột là cột theo dõi đồng thời
- Đặt kiểu cột trong SQL Server thành
rowversion - Cấu hình property có giá trị tự động tạo khi thêm hoặc cập nhật
Fluent API tương đương
modelBuilder.Entity<Department>()
.Property<byte[]>("ConcurrencyToken")
.IsRowVersion();T-SQL được tạo ra
EF Core tạo câu lệnh UPDATE với concurrency token trong mệnh đề WHERE:
SET NOCOUNT ON; UPDATE [Departments] SET [Name] = @p0 WHERE [DepartmentID] = @p1 AND [ConcurrencyToken] = @p2; SELECT [ConcurrencyToken] FROM [Departments] WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
Nếu ConcurrencyToken trong cơ sở dữ liệu không khớp với tham số, không có hàng nào được cập nhật và DbUpdateConcurrencyException được ném ra.
Triển khai cho SQLite
Thêm Concurrency Token vào Model
Cập nhật 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; }
public Guid ConcurrencyToken { get; set; } = Guid.NewGuid();
public Instructor Administrator { get; set; }
public ICollection<Course> Courses { get; set; }
}
}Cập nhật DbContext
Cập nhật Data/SchoolContext.cs:
public class SchoolContext : DbContext
{
public SchoolContext(DbContextOptions<SchoolContext> options) : base(options)
{
}
public DbSet<Course> Courses { get; set; }
public DbSet<Enrollment> Enrollments { get; set; }
public DbSet<Student> Students { get; set; }
public DbSet<Department> Departments { get; set; }
public DbSet<Instructor> Instructors { get; set; }
public DbSet<OfficeAssignment> OfficeAssignments { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Course>().ToTable(nameof(Course))
.HasMany(c => c.Instructors)
.WithMany(i => i.Courses);
modelBuilder.Entity<Student>().ToTable(nameof(Student));
modelBuilder.Entity<Instructor>().ToTable(nameof(Instructor));
modelBuilder.Entity<Department>()
.Property(d => d.ConcurrencyToken)
.IsConcurrencyToken();
}
}Cập nhật Token trong Code
Trong phương thức cập nhật, cập nhật token:
departmentToUpdate.ConcurrencyToken = Guid.NewGuid();
Tạo Migration
Visual Studio
Add-Migration RowVersion Update-Database
Visual Studio Code
dotnet ef migrations add RowVersion dotnet ef database update
Lớp tiện ích (Utility Class)
Tạo lớp helper để hiển thị concurrency token:
Cho SQL Server
namespace ContosoUniversity
{
public static class Utility
{
public static string GetLastChars(byte[] token)
{
return token[7].ToString();
}
}
}Cho SQLite
using System;
namespace ContosoUniversity
{
public static class Utility
{
public static string GetLastChars(Guid token)
{
return token.ToString().Substring(
token.ToString().Length - 3);
}
}
}Cập nhật trang Index
Cập nhật Pages/Departments/Index.cshtml:
@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>Page Model của trang Edit
Phiên bản SQL Server
Cập nhật 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; }
public SelectList InstructorNameSL { get; set; }
public async Task<IActionResult> OnGetAsync(int id)
{
Department = await _context.Departments
.Include(d => d.Administrator)
.AsNoTracking()
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (Department == null)
{
return NotFound();
}
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(
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);
Department.ConcurrencyToken = (byte[])dbValues.ConcurrencyToken;
ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
}
}
InstructorNameSL = new SelectList(_context.Instructors,
"ID", "FullName", departmentToUpdate.InstructorID);
return Page();
}
private IActionResult HandleDeletedDepartment()
{
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.");
}
}
}Logic xử lý xung đột quan trọng
Code then chốt để xử lý xung đột đồng thời:
// Đặt giá trị gốc là giá trị đọc trong yêu cầu GET
_context.Entry(departmentToUpdate).Property(
d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;
// EF Core sẽ đưa token vào mệnh đề WHERE:
// WHERE [DepartmentID] = @p1 AND [ConcurrencyToken] = @p2
// Nếu giá trị không khớp, không có hàng nào được cập nhật và exception được ném raBắt và xử lý Exception
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);
// Cập nhật token về giá trị hiện tại
Department.ConcurrencyToken = (byte[])dbValues.ConcurrencyToken;
ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
}Razor Page Edit
Cập nhật 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 Model trang Delete
Cập nhật 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))
{
_context.Departments.Remove(Department);
await _context.SaveChangesAsync();
}
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException)
{
return RedirectToPage("./Delete",
new { concurrencyError = true, id = id });
}
}
}
}Kiểm tra xung đột đồng thời
Kiểm tra xung đột Edit
- Mở hai phiên trình duyệt trên trang Edit của cùng một department
- Sửa các trường khác nhau trên mỗi tab
- Nhấn Save trên tab đầu tiên (thành công)
- Nhấn Save trên tab thứ hai (hiển thị thông báo lỗi với giá trị cơ sở dữ liệu hiện tại)
- Người dùng có thể sửa xung đột và gửi lại
Kiểm tra xung đột Delete
- Mở hai phiên trang Delete
- Sửa department trên tab khác
- Nhấn Delete trên tab thứ hai
- Xem thông báo lỗi đồng thời với tùy chọn xóa lại nếu muốn
Lưu ý quan trọng
- Kịch bản Store Wins được triển khai (giá trị cơ sở dữ liệu được ưu tiên)
- Trường ẩn (hidden field) lưu giá trị
ConcurrencyTokengốc qua các lần postback ModelState.Remove()xóa lỗi token cũ để hiển thị token mớiAsNoTracking()được dùng trongOnGetAsyncvì entity không được theo dõi để cập nhật- Đối với SQLite, cần tạo GUID mới theo cách thủ công khi cập nhật vì rowversion của SQL Server là tự động
- Kiểm tra đồng thời xảy ra trong mệnh đề WHERE của câu lệnh UPDATE/DELETE