Phần 8: Razor Pages với EF Core trong ASP.NET Core - Xung đột đồng thời
Đây là hướng dẫn toàn diện về xử lý concurrency conflict (xung đột đồng thời) trong ứng dụng ASP.NET Core Razor Pages sử dụng Entity Framework Core. Đây là phần 8 trong chuỗi hướng dẫn Razor Pages và Entity Framework.
Tổng quan
Concurrency conflict (xung đột đồng thời) xảy ra khi:
- Một user điều hướng đến trang chỉnh sửa của một entity
- User khác cập nhật entity đó trước khi thay đổi của user đầu tiên được ghi vào database
Các phương pháp xử lý Concurrency
Pessimistic Concurrency (Đồng thời bi quan - Locking)
- Sử dụng database lock (khóa) để ngăn xung đột
- Trước khi đọc một hàng có ý định cập nhật, ứng dụng yêu cầu khóa
- Lập trình phức tạp và có thể gây ra vấn đề hiệu năng
- Entity Framework Core không cung cấp hỗ trợ tích hợp sẵn
Optimistic Concurrency (Đồng thời lạc quan)
- Cho phép xung đột xảy ra, sau đó xử lý phù hợp
- Ba chiến lược:
- Property-Level Tracking (Theo dõi cấp thuộc tính): Theo dõi thuộc tính nào đã thay đổi và chỉ cập nhật cột tương ứng
- Tránh mất dữ liệu khi các thuộc tính khác nhau được thay đổi
- Không thể ngăn mất dữ liệu nếu cùng một thuộc tính bị sửa đổi
- Không thực tế cho ứng dụng web (cần duy trì trạng thái đáng kể)
- Client Wins (Client thắng): Để thay đổi của client mới nhất ghi đè các thay đổi trước
- Tự động nếu không triển khai xử lý concurrency
- Có thể dẫn đến mất dữ liệu
- Store Wins (Database thắng): Ngăn thay đổi nếu dữ liệu đã được sửa bởi user khác
- Hiển thị thông báo lỗi với giá trị database hiện tại
- Cho phép user áp dụng lại thay đổi
- Được sử dụng trong hướng dẫn này
Phát hiện xung đột trong EF Core
EF Core ném ra DbUpdateConcurrencyException khi phát hiện xung đột bằng cách sử dụng concurrency token (token đồng thời) - các thuộc tính được cấu hình để phát hiện khi một hàng đã thay đổi.
Phương pháp triển khai
Dành cho SQL Server:
[Timestamp]
public byte[] ConcurrencyToken { get; set; }Dành cho SQLite:
public Guid ConcurrencyToken { get; set; } = Guid.NewGuid();Sau đó trong SchoolContext.cs:
modelBuilder.Entity<Department>()
.Property(d => d.ConcurrencyToken)
.IsConcurrencyToken();Các bước thiết lập
1. Thêm thuộc tính Tracking vào Model
Visual Studio (SQL Server):
[Timestamp]
public byte[] ConcurrencyToken { get; set; }Visual Studio Code (SQLite):
public Guid ConcurrencyToken { get; set; } = Guid.NewGuid();2. Tạo Migration (Tệp di chuyển)
# Visual Studio Add-Migration RowVersion Update-Database # Visual Studio Code dotnet ef migrations add RowVersion dotnet ef database update
3. Scaffold (Tạo code tự động) cho các trang Department
# Windows dotnet aspnet-codegenerator razorpage -m Department -dc SchoolContext -udl -outDir Pages\Departments --referenceScriptLibraries # Linux/macOS dotnet aspnet-codegenerator razorpage -m Department -dc SchoolContext -udl -outDir Pages/Departments --referenceScriptLibraries
4. Tạo lớp Utility
Visual Studio (SQL Server):
namespace ContosoUniversity
{
public static class Utility
{
public static string GetLastChars(byte[] token)
{
return token[7].ToString();
}
}
}Visual Studio Code (SQLite):
using System;
namespace ContosoUniversity
{
public static class Utility
{
public static string GetLastChars(Guid token)
{
return token.ToString().Substring(token.ToString().Length - 3);
}
}
}Triển khai trang Edit
Page Model của trang Edit
Code xử lý concurrency chính:
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();
// Đặt ConcurrencyToken về giá trị đã đọc trong 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);
Department.ConcurrencyToken = dbValues.ConcurrencyToken;
ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
}
}
InstructorNameSL = new SelectList(_context.Instructors,
"ID", "FullName", departmentToUpdate.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.");
}Razor Page Edit
@page "{id:int}"
@model ContosoUniversity.Pages.Departments.EditModel
<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>Triển khai trang Delete
Page Model của trang Delete
public class DeleteModel : PageModel
{
private readonly SchoolContext _context;
public DeleteModel(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 });
}
}
}Razor Page Delete
@page "{id:int}"
@model ContosoUniversity.Pages.Departments.DeleteModel
<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>Kiểm tra Concurrency
- Mở hai tab trình duyệt với trang Edit cho cùng một department
- Ở tab 1, thay đổi một trường và nhấn Save
- Ở tab 2, thay đổi một trường khác và nhấn Save
- Lỗi concurrency được hiển thị với các giá trị database hiện tại
- User có thể sao chép các giá trị hiện tại và thử lưu lại
Cách hoạt động
- OriginalValue (Giá trị gốc): Được đặt về giá trị concurrency token từ request GET
- Mệnh đề WHERE trong SQL: Bao gồm giá trị concurrency token gốc
- Không có hàng nào bị ảnh hưởng: Nếu token không khớp, không có hàng nào được cập nhật
- DbUpdateConcurrencyException: Được ném ra khi không có hàng nào bị ảnh hưởng
- Xử lý lỗi: Hiển thị các giá trị database hiện tại và cho phép user áp dụng lại thay đổi
Điểm quan trọng
- Concurrency token được tự động cập nhật bởi database trên SQL Server
- Đối với SQLite, cập nhật thủ công:
departmentToUpdate.ConcurrencyToken = Guid.NewGuid(); OriginalValuephải được đặt trướcSaveChangesAsync()ModelState.Remove()xóa lỗi concurrency token cũ trước khi hiển thị lại- Trường input ẩn (hidden) lưu trữ concurrency token qua các lần postback (gửi lại form)
Triển khai này đảm bảo các cập nhật đồng thời được phát hiện và user được thông báo trước khi thay đổi của họ bị mất.