Phần 2: Razor Pages với EF Core trong ASP.NET Core - CRUD
Bởi Tom Dykstra, Jeremy Likness, và Jon P Smith
Ứng dụng web Contoso University minh họa cách tạo ứng dụng web Razor Pages (trang Razor) sử dụng EF Core (Entity Framework Core) và Visual Studio. Để biết thêm thông tin về series hướng dẫn này, xem hướng dẫn đầu tiên.
Nếu gặp vấn đề không giải quyết được, hãy tải ứng dụng hoàn chỉnh và so sánh code đó với những gì bạn đã tạo khi làm theo hướng dẫn.
Bài này trình bày hướng dẫn thứ hai trong series tám phần. Trong hướng dẫn này, bạn xem xét và tùy chỉnh code CRUD (create - tạo, read - đọc, update - cập nhật, delete - xóa) mà scaffolding (công cụ tạo code tự động) tạo ra cho các trang Razor.
Trong hướng dẫn này, bạn:
- Tùy chỉnh các trang: Details (Chi tiết), Create (Tạo), Edit (Sửa), và Delete (Xóa)
- Tìm hiểu cách bảo vệ chống overposting (gửi dữ liệu thừa)
- Xem xét các phương pháp khác nhau để đọc một entity (thực thể)
- Tìm hiểu về View model (mô hình hiển thị)
Điều kiện tiên quyết
- Xem lại hướng dẫn trước, Razor Pages với Entity Framework Core trong ASP.NET Core (Hướng dẫn 1/8).
Xem xét cách tiếp cận trong hướng dẫn (không dùng repository)
Một số lập trình viên sử dụng service layer (tầng dịch vụ) hoặc repository pattern (mẫu repository) để tạo lớp trừu tượng giữa UI (giao diện người dùng - Razor Pages) và data access layer (tầng truy cập dữ liệu). Thay vì cách đó, hướng dẫn này thêm code EF Core trực tiếp vào các lớp page model. Phương pháp này giúp giảm thiểu sự phức tạp và giữ cho hướng dẫn tập trung vào EF Core.
Cập nhật trang Details
Code scaffolded cho các trang Students không bao gồm dữ liệu enrollment (đăng ký). Trong phần này, enrollments được thêm vào trang Details.
Đọc enrollments
Để hiển thị dữ liệu enrollment của sinh viên trên trang, dữ liệu enrollment phải được đọc. Code scaffolded trong file Pages/Students/Details.cshtml.cs chỉ đọc dữ liệu Student mà không có dữ liệu Enrollment:
public async Task<IActionResult> OnGetAsync(int? id)
{
if (id == null)
{
return NotFound();
}
Student = await _context.Students.FirstOrDefaultAsync(m => m.ID == id);
if (Student == null)
{
return NotFound();
}
return Page();
}Thay thế phương thức OnGetAsync bằng code sau để đọc dữ liệu enrollment cho sinh viên được chọn:
public async Task<IActionResult> OnGetAsync(int? id)
{
if (id == null)
{
return NotFound();
}
Student = await _context.Students
.Include(s => s.Enrollments)
.ThenInclude(e => e.Course)
.AsNoTracking()
.FirstOrDefaultAsync(m => m.ID == id);
if (Student == null)
{
return NotFound();
}
return Page();
}Các phương thức Include và ThenInclude khiến context tải navigation property (thuộc tính điều hướng) Student.Enrollments, và trong mỗi enrollment, tải navigation property Enrollment.Course. Các phương thức này được xem xét chi tiết trong Đọc dữ liệu liên quan (Hướng dẫn 6/8).
Phương thức AsNoTracking cải thiện hiệu năng trong các tình huống mà các entity được trả về không được cập nhật trong context hiện tại. AsNoTracking được thảo luận ở phần sau trong hướng dẫn này.
Hiển thị enrollments
Để hiển thị danh sách enrollment, thay thế code trong file Pages/Students/Details.cshtml bằng code sau:
@page
@model ContosoUniversity.Pages.Students.DetailsModel
@{
ViewData["Title"] = "Details";
}
<h1>Details</h1>
<div>
<h4>Student</h4>
<hr />
<dl class="row">
<dt class="col-sm-2">
@Html.DisplayNameFor(model => model.Student.LastName)
</dt>
<dd class="col-sm-10">
@Html.DisplayFor(model => model.Student.LastName)
</dd>
<dt class="col-sm-2">
@Html.DisplayNameFor(model => model.Student.FirstMidName)
</dt>
<dd class="col-sm-10">
@Html.DisplayFor(model => model.Student.FirstMidName)
</dd>
<dt class="col-sm-2">
@Html.DisplayNameFor(model => model.Student.EnrollmentDate)
</dt>
<dd class="col-sm-10">
@Html.DisplayFor(model => model.Student.EnrollmentDate)
</dd>
<dt class="col-sm-2">
@Html.DisplayNameFor(model => model.Student.Enrollments)
</dt>
<dd class="col-sm-10">
<table class="table">
<tr>
<th>Course Title</th>
<th>Grade</th>
</tr>
@foreach (var item in Model.Student.Enrollments)
{
<tr>
<td>
@Html.DisplayFor(modelItem => item.Course.Title)
</td>
<td>
@Html.DisplayFor(modelItem => item.Grade)
</td>
</tr>
}
</table>
</dd>
</dl>
</div>
<div>
<a asp-page="./Edit" asp-route-id="@Model.Student.ID">Edit</a> |
<a asp-page="./Index">Back to List</a>
</div>Code này lặp qua các entity trong navigation property Enrollments. Với mỗi enrollment, code hiển thị tiêu đề khóa học và điểm. Tiêu đề khóa học được lấy từ entity Course được lưu trong navigation property Course của entity Enrollments.
Chạy ứng dụng, chọn tab Students, và chọn link Details cho một sinh viên. Danh sách các khóa học và điểm của sinh viên được chọn sẽ hiển thị.
Sử dụng các phương thức khác nhau để đọc một entity
Code được tạo sử dụng phương thức FirstOrDefaultAsync để đọc một entity. Phương thức này trả về null nếu không tìm thấy gì. Nếu không, nó trả về hàng đầu tiên thỏa mãn tiêu chí lọc của truy vấn. Phương thức FirstOrDefaultAsync thường là lựa chọn tốt hơn so với các phương án thay thế sau:
- Phương thức
SingleOrDefaultAsyncném một exception nếu có nhiều hơn một entity thỏa mãn điều kiện lọc. Để xác định xem truy vấn có thể trả về nhiều hơn một hàng không,SingleOrDefaultAsynccố gắng lấy nhiều hàng. Công việc thêm này không cần thiết nếu truy vấn chỉ có thể trả về một entity, chẳng hạn khi tìm kiếm trên một khóa duy nhất. - Phương thức
FindAsyncđịnh vị entity với primary key (khóa chính). Nếu context đang theo dõi entity với primary key đó, entity sẽ được trả về mà không cần request đến database. Phương thức này được tối ưu để tra cứu một entity đơn, nhưng bạn không thể gọi phương thứcIncludevớiFindAsync. Nếu cần dữ liệu liên quan,FirstOrDefaultAsynclà lựa chọn tốt hơn.
Route data vs. query string
URL cho trang Details là https://localhost:<port>/Students/Details?id=1. Giá trị primary key của entity nằm trong query string (chuỗi truy vấn). Một số lập trình viên thích truyền giá trị khóa trong route data (dữ liệu tuyến đường): https://localhost:<port>/Students/Details/1.
Cập nhật trang Create
Code OnPostAsync scaffolded cho trang Create dễ bị tấn công overposting. Thay thế phương thức OnPostAsync trong file Pages/Students/Create.cshtml.cs bằng code sau:
public async Task<IActionResult> OnPostAsync()
{
var emptyStudent = new Student();
if (await TryUpdateModelAsync<Student>(
emptyStudent,
"student", // Prefix for form value.
s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate))
{
_context.Students.Add(emptyStudent);
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
return Page();
}Sử dụng phương thức TryUpdateModelAsync
Code trong phần trên tạo một đối tượng Student rồi sử dụng các trường form đã đăng để cập nhật các thuộc tính của đối tượng Student. Phương thức TryUpdateModelAsync:
- Sử dụng các giá trị form đã đăng từ thuộc tính
PageContexttrong lớpPageModel. - Chỉ cập nhật các thuộc tính được liệt kê (
s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate). - Tìm kiếm các trường form có tiền tố "student". Ví dụ:
Student.FirstMidName. Không phân biệt chữ hoa/thường. - Sử dụng hệ thống model binding (ràng buộc mô hình) để chuyển đổi giá trị form từ chuỗi sang các kiểu trong model
Student. Ví dụ: giá trịEnrollmentDateđược chuyển đổi thành kiểuDateTime.
Chạy ứng dụng và tạo một entity sinh viên để kiểm tra trang Create.
Ngăn chặn overposting
Sử dụng phương thức TryUpdateModel để cập nhật các trường với giá trị đã đăng là best practice (thực hành tốt nhất) về bảo mật vì nó ngăn chặn overposting. Ví dụ, giả sử entity Student bao gồm thuộc tính Secret mà trang web này không nên cập nhật hoặc thêm:
public class Student
{
public int ID { get; set; }
public string LastName { get; set; }
public string FirstMidName { get; set; }
public DateTime EnrollmentDate { get; set; }
public string Secret { get; set; }
}Ngay cả khi ứng dụng không có trường Secret trên trang Create hoặc Update, hacker có thể đặt giá trị Secret bằng cách overposting. Hacker có thể sử dụng một công cụ như Fiddler, hoặc viết một số JavaScript, để đăng giá trị form Secret. Code gốc không giới hạn các trường mà model binder sử dụng khi tạo instance Student.
Bất kỳ giá trị nào hacker chỉ định cho trường form Secret sẽ được cập nhật trong database.
Làm việc với View model
View model (mô hình hiển thị) cung cấp một cách thay thế để ngăn chặn overposting.
Application model (mô hình ứng dụng) thường được gọi là domain model (mô hình miền). Domain model thường chứa tất cả các thuộc tính cần thiết cho entity tương ứng trong database. View model chỉ chứa các thuộc tính cần thiết cho trang UI, ví dụ trang Create.
Ngoài view model, một số ứng dụng sử dụng binding model (mô hình ràng buộc) hoặc input model (mô hình đầu vào) để truyền dữ liệu giữa lớp page model của Razor Pages và trình duyệt.
Xem xét view model StudentVM sau:
public class StudentVM
{
public int ID { get; set; }
public string LastName { get; set; }
public string FirstMidName { get; set; }
public DateTime EnrollmentDate { get; set; }
}Code sau sử dụng view model StudentVM để tạo sinh viên mới:
[BindProperty]
public StudentVM StudentVM { get; set; }
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
return Page();
}
var entry = _context.Add(new Student());
entry.CurrentValues.SetValues(StudentVM);
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}Phương thức SetValues đặt các giá trị của đối tượng này bằng cách đọc giá trị từ một đối tượng PropertyValues khác. Phương thức SetValues sử dụng khớp tên thuộc tính. Kiểu view model:
- Không cần liên quan đến kiểu model.
- Yêu cầu các thuộc tính phải khớp.
Sử dụng view model StudentVM yêu cầu trang Create sử dụng entity StudentVM thay vì entity Student.
Cập nhật trang Edit
Trong file Pages/Students/Edit.cshtml.cs, thay thế các phương thức OnGetAsync và OnPostAsync bằng code sau:
public async Task<IActionResult> OnGetAsync(int? id)
{
if (id == null)
{
return NotFound();
}
Student = await _context.Students.FindAsync(id);
if (Student == null)
{
return NotFound();
}
return Page();
}
public async Task<IActionResult> OnPostAsync(int id)
{
var studentToUpdate = await _context.Students.FindAsync(id);
if (studentToUpdate == null)
{
return NotFound();
}
if (await TryUpdateModelAsync<Student>(
studentToUpdate,
"student",
s => s.FirstMidName, s => s.LastName, s => s.EnrollmentDate))
{
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
return Page();
}Các thay đổi trong code tương tự trang Create với một vài ngoại lệ:
- Phương thức
FirstOrDefaultAsyncđược thay thế bằng phương thứcFindAsync. Khi không cần bao gồm dữ liệu liên quan, phương thứcFindAsynchiệu quả hơn. - Phương thức
OnPostAsynccó tham sốid. - Sinh viên hiện tại được lấy từ database thay vì tạo một sinh viên rỗng.
Chạy ứng dụng và kiểm tra bằng cách tạo và sửa một sinh viên.
Sử dụng Entity States (trạng thái entity)
Database context theo dõi xem các entity trong bộ nhớ có đồng bộ với các hàng tương ứng trong database không. Thông tin theo dõi này xác định những gì xảy ra khi phương thức SaveChangesAsync được gọi. Ví dụ, khi một entity mới được truyền vào phương thức AddAsync, trạng thái của entity đó được đặt thành Added. Khi phương thức SaveChangesAsync được gọi, database context phát ra lệnh SQL INSERT.
Một entity có thể ở một trong các trạng thái sau:
Added(Đã thêm): Entity chưa tồn tại trong database. Phương thứcSaveChangesphát ra lệnhINSERT.Unchanged(Không thay đổi): Không cần lưu thay đổi nào với entity này. Entity có trạng thái này khi được đọc từ database.Modified(Đã sửa đổi): Một số hoặc tất cả các giá trị thuộc tính của entity đã được sửa đổi. Phương thứcSaveChangesphát ra lệnhUPDATE.Deleted(Đã xóa): Entity được đánh dấu để xóa. Phương thứcSaveChangesphát ra lệnhDELETE.Detached(Tách rời): Database context không theo dõi entity.
Trong ứng dụng desktop, thay đổi trạng thái thường được thiết lập tự động. Trong ứng dụng web, đối tượng DbContext đọc entity và hiển thị dữ liệu sẽ bị dispose (giải phóng) sau khi trang được render. Khi phương thức OnPostAsync của trang được gọi, một request web mới được thực hiện với một instance mới của đối tượng DbContext. Đọc lại entity trong context mới mô phỏng xử lý desktop.
Cập nhật trang Delete
Trong phần này, thông báo lỗi tùy chỉnh được triển khai khi lệnh gọi SaveChanges thất bại.
Thay thế code trong file Pages/Students/Delete.cshtml.cs bằng code sau:
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System;
using System.Threading.Tasks;
namespace ContosoUniversity.Pages.Students
{
public class DeleteModel : PageModel
{
private readonly ContosoUniversity.Data.SchoolContext _context;
private readonly ILogger<DeleteModel> _logger;
public DeleteModel(ContosoUniversity.Data.SchoolContext context,
ILogger<DeleteModel> logger)
{
_context = context;
_logger = logger;
}
[BindProperty]
public Student Student { get; set; }
public string ErrorMessage { get; set; }
public async Task<IActionResult> OnGetAsync(int? id, bool? saveChangesError = false)
{
if (id == null)
{
return NotFound();
}
Student = await _context.Students
.AsNoTracking()
.FirstOrDefaultAsync(m => m.ID == id);
if (Student == null)
{
return NotFound();
}
if (saveChangesError.GetValueOrDefault())
{
ErrorMessage = String.Format("Delete {ID} failed. Try again", id);
}
return Page();
}
public async Task<IActionResult> OnPostAsync(int? id)
{
if (id == null)
{
return NotFound();
}
var student = await _context.Students.FindAsync(id);
if (student == null)
{
return NotFound();
}
try
{
_context.Students.Remove(student);
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
catch (DbUpdateException ex)
{
_logger.LogError(ex, ErrorMessage);
return RedirectToAction("./Delete",
new { id, saveChangesError = true });
}
}
}
}Code này thêm các chức năng sau:
- Logging (ghi log) cho .NET và ASP.NET Core.
- Tham số tùy chọn
saveChangesErrorvào chữ ký phương thứcOnGetAsync. Tham sốsaveChangesErrorcho biết liệu lệnh gọi phương thức có xảy ra sau khi xóa đối tượngStudentthất bại không.
Thao tác xóa có thể thất bại vì các vấn đề mạng tạm thời. Lỗi mạng tạm thời có nhiều khả năng xảy ra hơn khi database ở trên cloud. Tham số saveChangesError là false khi phương thức OnGetAsync của trang Delete được gọi từ UI. Khi phương thức OnGetAsync được gọi bởi phương thức OnPostAsync vì thao tác xóa thất bại, tham số saveChangesError là true.
Phương thức OnPostAsync lấy entity được chọn, sau đó gọi phương thức Remove để đặt trạng thái entity thành Deleted. Khi SaveChanges được gọi, lệnh SQL DELETE được tạo ra. Nếu lệnh gọi Remove thất bại:
- Exception của database được bắt.
- Phương thức
OnGetAsynccủa trang Delete được gọi với tham sốsaveChangesError=true.
Thêm thông báo lỗi vào trang Delete (Pages/Students/Delete.cshtml):
@page
@model ContosoUniversity.Pages.Students.DeleteModel
@{
ViewData["Title"] = "Delete";
}
<h1>Delete</h1>
<p class="text-danger">@Model.ErrorMessage</p>
<h3>Are you sure you want to delete this?</h3>
<div>
<h4>Student</h4>
<hr />
<dl class="row">
<dt class="col-sm-2">
@Html.DisplayNameFor(model => model.Student.LastName)
</dt>
<dd class="col-sm-10">
@Html.DisplayFor(model => model.Student.LastName)
</dd>
<dt class="col-sm-2">
@Html.DisplayNameFor(model => model.Student.FirstMidName)
</dt>
<dd class="col-sm-10">
@Html.DisplayFor(model => model.Student.FirstMidName)
</dd>
<dt class="col-sm-2">
@Html.DisplayNameFor(model => model.Student.EnrollmentDate)
</dt>
<dd class="col-sm-10">
@Html.DisplayFor(model => model.Student.EnrollmentDate)
</dd>
</dl>
<form method="post">
<input type="hidden" asp-for="Student.ID" />
<input type="submit" value="Delete" class="btn btn-danger" /> |
<a asp-page="./Index">Back to List</a>
</form>
</div>Chạy ứng dụng và xóa một sinh viên để kiểm tra trang Delete.