Nguon: Microsoft Learn · .NET 8.0

Hướng dẫn: Xử lý Concurrency (đồng thời) - ASP.NET MVC với EF Core

Nguồn: Tutorial: Handle concurrency - ASP.NET MVC with EF Core

Trong các hướng dẫn trước, bạn đã học cách cập nhật dữ liệu. Hướng dẫn này trình bày cách xử lý các xung đột khi nhiều người dùng cùng cập nhật một thực thể (entity) vào cùng một lúc.

Bạn sẽ tạo các trang web làm việc với thực thể Department (Khoa/Phòng ban) và xử lý các lỗi concurrency. Các hình ảnh dưới đây minh họa trang Edit và Delete, bao gồm một số thông báo được hiển thị khi xảy ra xung đột concurrency.

Trong hướng dẫn này, bạn sẽ:

Điều kiện tiên quyết

Xung đột Concurrency

Xung đột concurrency xảy ra khi một người dùng hiển thị dữ liệu của một thực thể để chỉnh sửa, sau đó một người dùng khác cập nhật dữ liệu của cùng thực thể đó trước khi thay đổi của người dùng đầu tiên được ghi vào cơ sở dữ liệu. Nếu bạn không bật tính năng phát hiện các xung đột này, người nào cập nhật cơ sở dữ liệu sau cùng sẽ ghi đè lên thay đổi của người kia.

Pessimistic Concurrency (Khóa bi quan)

Nếu ứng dụng của bạn cần ngăn mất dữ liệu do tai nạn trong các tình huống concurrency, một cách để làm điều đó là sử dụng database locks (khóa cơ sở dữ liệu). Đây gọi là pessimistic concurrency. Entity Framework Core không cung cấp hỗ trợ tích hợp cho nó, và hướng dẫn này không trình bày cách triển khai.

Optimistic Concurrency (Lạc quan đồng thời)

Giải pháp thay thế cho pessimistic concurrency là optimistic concurrency. Optimistic concurrency có nghĩa là cho phép các xung đột concurrency xảy ra, sau đó phản ứng thích hợp nếu chúng xảy ra.

Ví dụ: Jane truy cập trang Edit của Department và thay đổi ngân sách (Budget) cho bộ phận Tiếng Anh từ $350,000.00 thành $0.00.

Trước khi Jane nhấn Save, John truy cập cùng trang đó và thay đổi trường Start Date từ 9/1/2007 thành 9/1/2013.

Jane nhấn Save trước và thấy thay đổi của mình khi trình duyệt quay lại trang Index.

Sau đó John nhấn Save trên trang Edit vẫn hiển thị ngân sách $350,000.00. Điều gì xảy ra tiếp theo được xác định bởi cách bạn xử lý các xung đột concurrency.

Một số tùy chọn bao gồm:

Phát hiện xung đột Concurrency

Bạn có thể giải quyết các xung đột bằng cách xử lý các ngoại lệ (exception) DbConcurrencyException mà Entity Framework ném ra. Để biết khi nào cần ném các ngoại lệ này, Entity Framework phải có khả năng phát hiện xung đột. Do đó, bạn phải cấu hình cơ sở dữ liệu và mô hình dữ liệu phù hợp.

Một số tùy chọn để bật tính năng phát hiện xung đột:

Thêm thuộc tính Tracking

Trong Models/Department.cs, thêm thuộc tính tracking có tên RowVersion:

csharp
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; }
    }
}

Attribute (thuộc tính) Timestamp chỉ định rằng cột này sẽ được bao gồm trong mệnh đề Where của các lệnh Update hoặc Delete được gửi đến cơ sở dữ liệu.

Nếu bạn muốn sử dụng fluent API, bạn có thể dùng phương thức IsRowVersion() trong Data/SchoolContext.cs:

csharp
modelBuilder.Entity<Department>()
    .Property(p => p.RowVersion).IsRowVersion();

Vì bạn đã thay đổi mô hình cơ sở dữ liệu, bạn cần thực hiện migration (di chuyển) khác:

dotnetcli
dotnet ef migrations add RowVersion
dotnetcli
dotnet ef database update

Tạo Departments Controller và Views

Scaffold (tạo bộ khung) một Departments controller và views như bạn đã làm trước đây cho Students, Courses và Instructors.

Trong file DepartmentsController.cs, thay đổi tất cả bốn lần xuất hiện của "FirstMidName" thành "FullName":

csharp
ViewData["InstructorID"] = new SelectList(_context.Instructors, "ID", "FullName", department.InstructorID);

Cập nhật Index View

Thay thế code trong Views/Departments/Index.cshtml bằng code sau (xóa cột RowVersion và hiển thị tên đầy đủ của quản trị viên):

cshtml
@model IEnumerable<ContosoUniversity.Models.Department>

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

<h2>Departments</h2>

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

Cập nhật các phương thức Edit

Trong cả phương thức HttpGet Edit và phương thức Details, thêm AsNoTracking. Trong phương thức HttpGet Edit, thêm eager loading (tải tức thì) cho Administrator:

csharp
var department = await _context.Departments
    .Include(d => d.Administrator)
    .AsNoTracking()
    .FirstOrDefaultAsync(m => m.DepartmentID == id);

Thay thế code hiện có cho phương thức HttpPost Edit bằng code sau:

csharp
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int? id, byte[] rowVersion)
{
    if (id == null)
    {
        return NotFound();
    }

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

    if (departmentToUpdate == null)
    {
        Department deletedDepartment = new Department();
        await TryUpdateModelAsync(deletedDepartment);
        ModelState.AddModelError(string.Empty,
            "Unable to save changes. The department was deleted by another user.");
        ViewData["InstructorID"] = new SelectList(_context.Instructors, "ID", "FullName", deletedDepartment.InstructorID);
        return View(deletedDepartment);
    }

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

    if (await TryUpdateModelAsync<Department>(
        departmentToUpdate,
        "",
        s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
    {
        try
        {
            await _context.SaveChangesAsync();
            return RedirectToAction(nameof(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 changes. The department was deleted by another user.");
            }
            else
            {
                var databaseValues = (Department)databaseEntry.ToObject();

                if (databaseValues.Name != clientValues.Name)
                {
                    ModelState.AddModelError("Name", $"Current value: {databaseValues.Name}");
                }
                if (databaseValues.Budget != clientValues.Budget)
                {
                    ModelState.AddModelError("Budget", $"Current value: {databaseValues.Budget:c}");
                }
                if (databaseValues.StartDate != clientValues.StartDate)
                {
                    ModelState.AddModelError("StartDate", $"Current value: {databaseValues.StartDate:d}");
                }
                if (databaseValues.InstructorID != clientValues.InstructorID)
                {
                    Instructor databaseInstructor = await _context.Instructors.FirstOrDefaultAsync(i => i.ID == databaseValues.InstructorID);
                    ModelState.AddModelError("InstructorID", $"Current value: {databaseInstructor?.FullName}");
                }

                ModelState.AddModelError(string.Empty, "The record you attempted to edit "
                        + "was modified by another user after you got the original value. 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. Otherwise click the Back to List hyperlink.");
                departmentToUpdate.RowVersion = (byte[])databaseValues.RowVersion;
                ModelState.Remove("RowVersion");
            }
        }
    }
    ViewData["InstructorID"] = new SelectList(_context.Instructors, "ID", "FullName", departmentToUpdate.InstructorID);
    return View(departmentToUpdate);
}

Code bắt đầu bằng cách cố gắng đọc bộ phận cần cập nhật. Nếu phương thức FirstOrDefaultAsync trả về null, bộ phận đã bị xóa bởi người dùng khác. Trong trường hợp đó, code sử dụng các giá trị form được post để tạo một thực thể Department để trang Edit có thể được hiển thị lại với thông báo lỗi.

View lưu trữ giá trị RowVersion gốc trong một trường ẩn, và phương thức này nhận giá trị đó trong tham số rowVersion. Trước khi gọi SaveChanges, bạn phải đặt giá trị thuộc tính RowVersion gốc đó vào collection OriginalValues của thực thể:

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

Cuối cùng, code đặt giá trị RowVersion của departmentToUpdate thành giá trị mới lấy từ cơ sở dữ liệu:

csharp
departmentToUpdate.RowVersion = (byte[])databaseValues.RowVersion;
ModelState.Remove("RowVersion");

Cập nhật Edit View

Trong Views/Departments/Edit.cshtml, thực hiện các thay đổi sau:

cshtml
@model ContosoUniversity.Models.Department

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

<h2>Edit</h2>

<h4>Department</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form asp-action="Edit">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <input type="hidden" asp-for="DepartmentID" />
            <input type="hidden" asp-for="RowVersion" />
            <div class="form-group">
                <label asp-for="Name" class="control-label"></label>
                <input asp-for="Name" class="form-control" />
                <span asp-validation-for="Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Budget" class="control-label"></label>
                <input asp-for="Budget" class="form-control" />
                <span asp-validation-for="Budget" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="StartDate" class="control-label"></label>
                <input asp-for="StartDate" class="form-control" />
                <span asp-validation-for="StartDate" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="InstructorID" class="control-label"></label>
                <select asp-for="InstructorID" class="form-control" asp-items="ViewBag.InstructorID">
                    <option value="">-- Select Administrator --</option>
                </select>
                <span asp-validation-for="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-action="Index">Back to List</a>
</div>

@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

Kiểm tra xung đột Concurrency

Chạy ứng dụng và đến trang Departments Index. Nhấp chuột phải vào liên kết Edit cho bộ phận Tiếng Anh và chọn Open in new tab, sau đó nhấp vào liên kết Edit cho bộ phận Tiếng Anh. Hai tab trình duyệt giờ hiển thị cùng thông tin.

Thay đổi một trường trong tab trình duyệt đầu tiên và nhấp Save.

Trình duyệt hiển thị trang Index với giá trị đã thay đổi.

Thay đổi một trường trong tab trình duyệt thứ hai.

Nhấp Save. Bạn sẽ thấy thông báo lỗi.

Nhấp Save lần nữa. Giá trị bạn nhập trong tab trình duyệt thứ hai được lưu. Bạn sẽ thấy các giá trị đã lưu khi trang Index xuất hiện.

Cập nhật trang Delete

Đối với trang Delete, Entity Framework phát hiện các xung đột concurrency do người khác chỉnh sửa bộ phận theo cách tương tự.

Cập nhật các phương thức Delete trong Departments Controller

Trong DepartmentsController.cs, thay thế phương thức HttpGet Delete bằng code sau:

csharp
public async Task<IActionResult> Delete(int? id, bool? concurrencyError)
{
    if (id == null)
    {
        return NotFound();
    }

    var department = await _context.Departments
        .Include(d => d.Administrator)
        .AsNoTracking()
        .FirstOrDefaultAsync(m => m.DepartmentID == id);
    if (department == null)
    {
        if (concurrencyError.GetValueOrDefault())
        {
            return RedirectToAction(nameof(Index));
        }
        return NotFound();
    }

    if (concurrencyError.GetValueOrDefault())
    {
        ViewData["ConcurrencyErrorMessage"] = "The record you attempted to delete "
            + "was modified by another user after you got the original values. "
            + "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. Otherwise "
            + "click the Back to List hyperlink.";
    }

    return View(department);
}

Thay thế code trong phương thức HttpPost Delete (có tên DeleteConfirmed) bằng code sau:

csharp
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(Department department)
{
    try
    {
        if (await _context.Departments.AnyAsync(m => m.DepartmentID == department.DepartmentID))
        {
            _context.Departments.Remove(department);
            await _context.SaveChangesAsync();
        }
        return RedirectToAction(nameof(Index));
    }
    catch (DbUpdateConcurrencyException /* ex */)
    {
        //Log the error (uncomment ex variable name and write a log.)
        return RedirectToAction(nameof(Delete), new { concurrencyError = true, id = department.DepartmentID });
    }
}

Cập nhật Delete View

Trong Views/Departments/Delete.cshtml, thay thế code scaffold bằng code sau (thêm trường thông báo lỗi và các trường ẩn cho DepartmentID và RowVersion):

cshtml
@model ContosoUniversity.Models.Department

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

<h2>Delete</h2>

<p class="text-danger">@ViewData["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.Name)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Name)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Budget)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Budget)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.StartDate)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.StartDate)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Administrator)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Administrator.FullName)
        </dd>
    </dl>
    
    <form asp-action="Delete">
        <input type="hidden" asp-for="DepartmentID" />
        <input type="hidden" asp-for="RowVersion" />
        <div class="form-actions no-color">
            <input type="submit" value="Delete" class="btn btn-default" /> |
            <a asp-action="Index">Back to List</a>
        </div>
    </form>
</div>

Cập nhật Details và Create Views

Thay thế code trong Views/Departments/Details.cshtml để xóa cột RowVersion và hiển thị tên đầy đủ của Administrator:

cshtml
@model ContosoUniversity.Models.Department

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

<h2>Details</h2>

<div>
    <h4>Department</h4>
    <hr />
    <dl class="row">
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Name)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Name)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Budget)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Budget)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.StartDate)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.StartDate)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Administrator)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Administrator.FullName)
        </dd>
    </dl>
</div>
<div>
    <a asp-action="Edit" asp-route-id="@Model.DepartmentID">Edit</a> |
    <a asp-action="Index">Back to List</a>
</div>

Thay thế code trong Views/Departments/Create.cshtml để thêm tùy chọn Select vào danh sách dropdown:

cshtml
@model ContosoUniversity.Models.Department

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

<h2>Create</h2>

<h4>Department</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form asp-action="Create">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <div class="form-group">
                <label asp-for="Name" class="control-label"></label>
                <input asp-for="Name" class="form-control" />
                <span asp-validation-for="Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Budget" class="control-label"></label>
                <input asp-for="Budget" class="form-control" />
                <span asp-validation-for="Budget" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="StartDate" class="control-label"></label>
                <input asp-for="StartDate" class="form-control" />
                <span asp-validation-for="StartDate" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="InstructorID" class="control-label"></label>
                <select asp-for="InstructorID" class="form-control" asp-items="ViewBag.InstructorID">
                    <option value="">-- Select Administrator --</option>
                </select>
            </div>
            <div class="form-group">
                <input type="submit" value="Create" class="btn btn-default" />
            </div>
        </form>
    </div>
</div>

<div>
    <a asp-action="Index">Back to List</a>
</div>

@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

Lấy code

Tải xuống hoặc xem ứng dụng hoàn chỉnh.