Nguon: Microsoft Learn · .NET 8.0

Phần 8: Razor Pages với EF Core trong ASP.NET Core - Xung đột đồng thời

Nguồn: Part 8: Razor Pages with EF Core - Concurrency

Đâ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:

Các phương pháp xử lý Concurrency

Pessimistic Concurrency (Đồng thời bi quan - Locking)

Optimistic Concurrency (Đồng thời lạc quan)

  1. 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
  2. Tránh mất dữ liệu khi các thuộc tính khác nhau được thay đổi
  3. Không thể ngăn mất dữ liệu nếu cùng một thuộc tính bị sửa đổi
  4. Không thực tế cho ứng dụng web (cần duy trì trạng thái đáng kể)
  5. Client Wins (Client thắng): Để thay đổi của client mới nhất ghi đè các thay đổi trước
  6. Tự động nếu không triển khai xử lý concurrency
  7. Có thể dẫn đến mất dữ liệu
  8. Store Wins (Database thắng): Ngăn thay đổi nếu dữ liệu đã được sửa bởi user khác
  9. Hiển thị thông báo lỗi với giá trị database hiện tại
  10. Cho phép user áp dụng lại thay đổi
  11. Đượ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:

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

Dành cho SQLite:

csharp
public Guid ConcurrencyToken { get; set; } = Guid.NewGuid();

Sau đó trong SchoolContext.cs:

csharp
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):

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

Visual Studio Code (SQLite):

csharp
public Guid ConcurrencyToken { get; set; } = Guid.NewGuid();

2. Tạo Migration (Tệp di chuyển)

powershell
# 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

dotnetcli
# 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):

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

Visual Studio Code (SQLite):

csharp
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:

csharp
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

cshtml
@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

csharp
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

cshtml
@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

  1. Mở hai tab trình duyệt với trang Edit cho cùng một department
  2. Ở tab 1, thay đổi một trường và nhấn Save
  3. Ở tab 2, thay đổi một trường khác và nhấn Save
  4. Lỗi concurrency được hiển thị với các giá trị database hiện tại
  5. 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

  1. OriginalValue (Giá trị gốc): Được đặt về giá trị concurrency token từ request GET
  2. Mệnh đề WHERE trong SQL: Bao gồm giá trị concurrency token gốc
  3. 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
  4. DbUpdateConcurrencyException: Được ném ra khi không có hàng nào bị ảnh hưởng
  5. 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

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.