Bagian 8, Razor Halaman dengan EF Core inti ASP.NET - Konkurensi
Tom Dykstra, dan Jon P Smith
Aplikasi web Contoso University menunjukkan cara membuat Razor aplikasi web Pages menggunakan EF Core dan Visual Studio. Untuk informasi tentang seri tutorial, lihat tutorial pertama.
Jika Anda mengalami masalah yang tidak dapat Anda selesaikan, unduh aplikasi yang telah selesai dan bandingkan kode tersebut dengan apa yang Anda buat dengan mengikuti tutorial.
Tutorial ini menunjukkan cara menangani konflik saat beberapa pengguna memperbarui entitas secara bersamaan.
Konflik konkurensi
Konflik konkurensi terjadi ketika:
- Pengguna menavigasi ke halaman edit untuk entitas.
- Pengguna lain memperbarui entitas yang sama sebelum perubahan pengguna pertama ditulis ke database.
Jika deteksi konkurensi tidak diaktifkan, siapa pun yang memperbarui database terakhir kali menimpa perubahan pengguna lain. Jika risiko ini dapat diterima, biaya pemrograman untuk konkurensi mungkin melebihi manfaatnya.
Konkurensi pesimis
Salah satu cara untuk mencegah konflik konkurensi adalah dengan menggunakan kunci database. Ini disebut konkurensi pesimis. Sebelum aplikasi membaca baris database yang ingin diperbarui, aplikasi meminta kunci. Setelah baris dikunci untuk akses pembaruan, tidak ada pengguna lain yang diizinkan untuk mengunci baris hingga kunci pertama dirilis.
Mengelola kunci memiliki kekurangan. Ini bisa rumit untuk diprogram dan dapat menyebabkan masalah performa saat jumlah pengguna meningkat. Entity Framework Core tidak menyediakan dukungan bawaan untuk konkurensi pesimis.
Konkurensi optimis
Konkurensi optimis memungkinkan konflik konkurensi terjadi, lalu bereaksi dengan tepat ketika terjadi. Misalnya, Jane mengunjungi halaman edit Departemen dan mengubah anggaran untuk departemen Bahasa Inggris dari $ 350.000,00 menjadi $ 0,00.
Sebelum Klik Jane Simpan, John mengunjungi halaman yang sama dan mengubah bidang Tanggal Mulai dari 1/9/2007 menjadi 1/9/2013.
Jane mengklik Simpan terlebih dahulu dan melihat perubahannya berlaku, karena browser menampilkan halaman Indeks dengan nol sebagai jumlah Anggaran.
John mengklik Simpan pada halaman Edit yang masih menunjukkan anggaran $350.000,00. Apa yang terjadi selanjutnya ditentukan oleh cara Anda menangani konflik konkurensi:
Lacak properti mana yang telah dimodifikasi pengguna dan perbarui hanya kolom yang sesuai dalam database.
Dalam skenario, tidak ada data yang akan hilang. Properti yang berbeda diperbarui oleh dua pengguna. Lain kali seseorang menelusuri departemen Inggris, mereka akan melihat perubahan Jane dan John. Metode pembaruan ini dapat mengurangi jumlah konflik yang dapat mengakibatkan hilangnya data. Pendekatan ini memiliki beberapa kelemahan:
- Tidak dapat menghindari kehilangan data jika perubahan yang bersaing dilakukan pada properti yang sama.
- Umumnya tidak praktis di aplikasi web. Ini membutuhkan mempertahankan status yang signifikan untuk melacak semua nilai yang diambil dan nilai baru. Mempertahankan status dalam jumlah besar dapat memengaruhi performa aplikasi.
- Dapat meningkatkan kompleksitas aplikasi dibandingkan dengan deteksi konkurensi pada entitas.
Mari kita ubah menimpa perubahan Jane.
Lain kali seseorang menelusuri departemen Bahasa Inggris, mereka akan melihat 9/1/2013 dan nilai $350,000.00 yang diambil. Pendekatan ini disebut skenario Client Wins atau Last in Wins . Semua nilai dari klien lebih diutamakan daripada apa yang ada di penyimpanan data. Kode perancah tidak melakukan penanganan konkurensi, Client Wins terjadi secara otomatis.
Mencegah perubahan John diperbarui dalam database. Biasanya, aplikasi akan:
- Menampilkan pesan kesalahan.
- Perlihatkan status data saat ini.
- Izinkan pengguna untuk menerapkan kembali perubahan.
Ini disebut skenario Store Wins . Nilai penyimpanan data lebih diutamakan daripada nilai yang dikirimkan oleh klien. Skenario Store Wins digunakan dalam tutorial ini. Metode ini memastikan bahwa tidak ada perubahan yang ditimpa tanpa pengguna diberi tahu.
Deteksi konflik dalam EF Core
Properti yang dikonfigurasi sebagai token konkurensi digunakan untuk menerapkan kontrol konkurensi optimis. Ketika operasi pembaruan atau penghapusan dipicu oleh SaveChanges atau SaveChangesAsync, nilai token konkurensi dalam database dibandingkan dengan nilai asli yang dibaca oleh EF Core:
- Jika nilai cocok, operasi dapat diselesaikan.
- Jika nilai tidak cocok, EF Core mengasumsikan bahwa pengguna lain telah melakukan operasi yang bertentangan, membatalkan transaksi saat ini, dan melempar DbUpdateConcurrencyException.
Pengguna atau proses lain yang melakukan operasi yang bertentangan dengan operasi saat ini dikenal sebagai konflik konkurensi.
Pada database relasional EF Core memeriksa nilai token konkurensi dalam WHERE
klausul UPDATE
dan DELETE
pernyataan untuk mendeteksi konflik konkurensi.
Model data harus dikonfigurasi untuk mengaktifkan deteksi konflik dengan menyertakan kolom pelacakan yang dapat digunakan untuk menentukan kapan baris telah diubah. EF menyediakan dua pendekatan untuk token konkurensi:
Menerapkan
[ConcurrencyCheck]
atau IsConcurrencyToken ke properti pada model. Pendekatan ini tidak disarankan. Untuk informasi selengkapnya, lihat Token Konkurensi di EF Core.Menerapkan TimestampAttribute atau IsRowVersion ke token konkurensi dalam model. Ini adalah pendekatan yang digunakan dalam tutorial ini.
Pendekatan SQL Server dan detail implementasi SQLite sedikit berbeda. File perbedaan ditampilkan nanti dalam tutorial yang mencantumkan perbedaan. Tab Visual Studio memperlihatkan pendekatan SQL Server. Tab Visual Studio Code memperlihatkan pendekatan untuk database non-SQL Server, seperti SQLite.
- Dalam model, sertakan kolom pelacakan yang digunakan untuk menentukan kapan baris telah diubah.
- Terapkan ke TimestampAttribute properti konkurensi.
Models/Department.cs
Perbarui file dengan kode yang disorot berikut:
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; }
}
}
inilah TimestampAttribute yang mengidentifikasi kolom sebagai kolom pelacakan konkurensi. API fasih adalah cara alternatif untuk menentukan properti pelacakan:
modelBuilder.Entity<Department>()
.Property<byte[]>("ConcurrencyToken")
.IsRowVersion();
Atribut [Timestamp]
pada properti entitas menghasilkan kode berikut dalam ModelBuilder metode :
b.Property<byte[]>("ConcurrencyToken")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
Kode sebelumnya:
- Mengatur jenis
ConcurrencyToken
properti ke array byte.byte[]
adalah jenis yang diperlukan untuk SQL Server. - Panggilan IsConcurrencyToken.
IsConcurrencyToken
mengonfigurasi properti sebagai token konkurensi. Pada pembaruan, nilai token konkurensi dalam database dibandingkan dengan nilai asli untuk memastikannya tidak berubah sejak instans diambil dari database. Jika telah berubah, akan DbUpdateConcurrencyException dilemparkan dan perubahan tidak diterapkan. -
ValueGeneratedOnAddOrUpdatePanggilan , yang mengonfigurasi
ConcurrencyToken
properti agar memiliki nilai yang dihasilkan secara otomatis saat menambahkan atau memperbarui entitas. -
HasColumnType("rowversion")
mengatur jenis kolom dalam database SQL Server ke rowversion.
Kode berikut menunjukkan sebagian T-SQL yang dihasilkan oleh EF Core saat nama diperbarui Department
:
SET NOCOUNT ON;
UPDATE [Departments] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [ConcurrencyToken] = @p2;
SELECT [ConcurrencyToken]
FROM [Departments]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
Kode yang disorot sebelumnya menunjukkan WHERE
klausa yang berisi ConcurrencyToken
. Jika database ConcurrencyToken
tidak sama dengan ConcurrencyToken
parameter @p2
, tidak ada baris yang diperbarui.
Kode yang disorot berikut menunjukkan T-SQL yang memverifikasi persis satu baris diperbarui:
SET NOCOUNT ON;
UPDATE [Departments] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [ConcurrencyToken] = @p2;
SELECT [ConcurrencyToken]
FROM [Departments]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
@@ROWCOUNT mengembalikan jumlah baris yang terpengaruh oleh pernyataan terakhir. Jika tidak ada baris yang diperbarui, EF Core melempar .DbUpdateConcurrencyException
Menambahkan migrasi
Menambahkan properti mengubah ConcurrencyToken
model data, yang memerlukan migrasi.
Bangun proyek.
Jalankan perintah berikut di PMC:
Add-Migration RowVersion
Update-Database
Perintah sebelumnya:
-
Migrations/{time stamp}_RowVersion.cs
Membuat file migrasi. -
Migrations/SchoolContextModelSnapshot.cs
Memperbarui file. Pembaruan menambahkan kode berikut keBuildModel
metode :
b.Property<byte[]>("ConcurrencyToken")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
Halaman Departemen Perancah
Ikuti instruksi di halaman Scaffold Student dengan pengecualian berikut:
- Buat folder Halaman/Departemen .
- Gunakan
Department
untuk kelas model. - Gunakan kelas konteks yang ada alih-alih membuat yang baru.
Menambahkan kelas utilitas
Di folder proyek, buat Utility
kelas dengan kode berikut:
namespace ContosoUniversity
{
public static class Utility
{
public static string GetLastChars(byte[] token)
{
return token[7].ToString();
}
}
}
Kelas Utility
menyediakan metode yang GetLastChars
digunakan untuk menampilkan beberapa karakter terakhir dari token konkurensi. Kode berikut menunjukkan kode yang berfungsi dengan SQL Server iklan SQLite:
#if SQLiteVersion
using System;
namespace ContosoUniversity
{
public static class Utility
{
public static string GetLastChars(Guid token)
{
return token.ToString().Substring(
token.ToString().Length - 3);
}
}
}
#else
namespace ContosoUniversity
{
public static class Utility
{
public static string GetLastChars(byte[] token)
{
return token[7].ToString();
}
}
}
#endif
Direktif #if SQLiteVersion
praprosedur mengisolasi perbedaan dalam versi SQLite dan SQL Server dan membantu:
- Penulis mempertahankan satu basis kode untuk kedua versi.
- Pengembang SQLite menyebarkan aplikasi ke Azure dan menggunakan SQL Azure.
Bangun proyek.
Memperbarui halaman Indeks
Alat perancah membuat ConcurrencyToken
kolom untuk halaman Indeks, tetapi bidang tersebut tidak akan ditampilkan di aplikasi produksi. Dalam tutorial ini, bagian terakhir ditampilkan ConcurrencyToken
untuk membantu menunjukkan cara kerja penanganan konkurensi. Bagian terakhir tidak dijamin unik dengan sendirinya.
Perbarui halaman Pages\Departments\Index.cshtml :
- Ganti Indeks dengan Departemen.
- Ubah kode yang berisi
ConcurrencyToken
untuk menampilkan hanya beberapa karakter terakhir. - Ganti
FirstMidName
denganFullName
.
Kode berikut menunjukkan halaman yang diperbarui:
@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>
Memperbarui model halaman Edit
Perbarui Pages/Departments/Edit.cshtml.cs
dengan kode berikut:
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; }
// Replace ViewData["InstructorID"]
public SelectList InstructorNameSL { get; set; }
public async Task<IActionResult> OnGetAsync(int id)
{
Department = await _context.Departments
.Include(d => d.Administrator) // eager loading
.AsNoTracking() // tracking not required
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (Department == null)
{
return NotFound();
}
// Use strongly typed data rather than ViewData.
InstructorNameSL = new SelectList(_context.Instructors,
"ID", "FirstMidName");
return Page();
}
public async Task<IActionResult> OnPostAsync(int id)
{
if (!ModelState.IsValid)
{
return Page();
}
// Fetch current department from DB.
// ConcurrencyToken may have changed.
var departmentToUpdate = await _context.Departments
.Include(i => i.Administrator)
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (departmentToUpdate == null)
{
return HandleDeletedDepartment();
}
// Set ConcurrencyToken to value read in 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);
// Save the current ConcurrencyToken so next postback
// matches unless an new concurrency issue happens.
Department.ConcurrencyToken = (byte[])dbValues.ConcurrencyToken;
// Clear the model error for the next postback.
ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
}
}
InstructorNameSL = new SelectList(_context.Instructors,
"ID", "FullName", departmentToUpdate.InstructorID);
return Page();
}
private IActionResult HandleDeletedDepartment()
{
// ModelState contains the posted data because of the deletion error
// and overides the Department instance values when displaying Page().
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.");
}
}
}
Pembaruan konkurensi
OriginalValue diperbarui dengan ConcurrencyToken
nilai dari entitas ketika diambil dalam OnGetAsync
metode .
EF Core menghasilkan perintah dengan klausul SQL UPDATE
yang WHERE
berisi nilai asli ConcurrencyToken
. Jika tidak ada baris yang dipengaruhi oleh UPDATE
perintah, DbUpdateConcurrencyException
pengecualian akan dilemparkan. Tidak ada baris yang dipengaruhi oleh UPDATE
perintah ketika tidak ada baris yang memiliki nilai asli ConcurrencyToken
.
public async Task<IActionResult> OnPostAsync(int id)
{
if (!ModelState.IsValid)
{
return Page();
}
// Fetch current department from DB.
// ConcurrencyToken may have changed.
var departmentToUpdate = await _context.Departments
.Include(i => i.Administrator)
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (departmentToUpdate == null)
{
return HandleDeletedDepartment();
}
// Set ConcurrencyToken to value read in OnGetAsync
_context.Entry(departmentToUpdate).Property(
d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;
Dalam kode yang disorot sebelumnya:
- Nilai di
Department.ConcurrencyToken
adalah nilai ketika entitas diambil dalamGet
permintaan untukEdit
halaman. Nilai disediakan untukOnPost
metode oleh bidang tersembunyi di Razor halaman yang menampilkan entitas yang akan diedit. Nilai bidang tersembunyi disalin oleh pengikatDepartment.ConcurrencyToken
model. -
OriginalValue
adalah apa yang EF Core digunakan dalamWHERE
klausul. Sebelum baris kode yang disorot dijalankan:-
OriginalValue
memiliki nilai yang ada dalam database ketikaFirstOrDefaultAsync
dipanggil dalam metode ini. - Nilai ini mungkin berbeda dari apa yang ditampilkan di halaman Edit.
-
- Kode yang disorot memastikan bahwa EF Core menggunakan nilai asli
ConcurrencyToken
dari entitas yangDepartment
ditampilkan dalam klausa pernyataanUPDATE
SQLWHERE
.
Kode berikut menunjukkan Department
model.
Department
diinisialisasi dalam:
-
OnGetAsync
metode dengan kueri EF. -
OnPostAsync
metode dengan bidang tersembunyi di Razor halaman menggunakan pengikatan model:
public class EditModel : PageModel
{
private readonly ContosoUniversity.Data.SchoolContext _context;
public EditModel(ContosoUniversity.Data.SchoolContext context)
{
_context = context;
}
[BindProperty]
public Department Department { get; set; }
// Replace ViewData["InstructorID"]
public SelectList InstructorNameSL { get; set; }
public async Task<IActionResult> OnGetAsync(int id)
{
Department = await _context.Departments
.Include(d => d.Administrator) // eager loading
.AsNoTracking() // tracking not required
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (Department == null)
{
return NotFound();
}
// Use strongly typed data rather than ViewData.
InstructorNameSL = new SelectList(_context.Instructors,
"ID", "FirstMidName");
return Page();
}
public async Task<IActionResult> OnPostAsync(int id)
{
if (!ModelState.IsValid)
{
return Page();
}
// Fetch current department from DB.
// ConcurrencyToken may have changed.
var departmentToUpdate = await _context.Departments
.Include(i => i.Administrator)
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (departmentToUpdate == null)
{
return HandleDeletedDepartment();
}
// Set ConcurrencyToken to value read in OnGetAsync
_context.Entry(departmentToUpdate).Property(
d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;
Kode sebelumnya menunjukkan ConcurrencyToken
nilai entitas dari Department
permintaan diatur ke HTTP POST
nilai dari ConcurrencyToken
permintaan.HTTP GET
Ketika kesalahan konkurensi terjadi, kode yang disorot berikut mendapatkan nilai klien (nilai yang diposting ke metode ini) dan nilai database.
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);
// Save the current ConcurrencyToken so next postback
// matches unless an new concurrency issue happens.
Department.ConcurrencyToken = dbValues.ConcurrencyToken;
// Clear the model error for the next postback.
ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
}
Kode berikut menambahkan pesan kesalahan kustom untuk setiap kolom yang memiliki nilai database yang berbeda dari yang diposting ke OnPostAsync
:
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.");
}
Kode yang disorot ConcurrencyToken
berikut menetapkan nilai ke nilai baru yang diambil dari database. Saat berikutnya pengguna mengklik Simpan, hanya kesalahan konkurensi yang terjadi sejak tampilan terakhir halaman Edit yang akan tertangkap.
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);
// Save the current ConcurrencyToken so next postback
// matches unless an new concurrency issue happens.
Department.ConcurrencyToken = dbValues.ConcurrencyToken;
// Clear the model error for the next postback.
ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
}
Pernyataan ModelState.Remove
diperlukan karena ModelState
memiliki nilai sebelumnya ConcurrencyToken
.
Razor Di Halaman, ModelState
nilai untuk bidang lebih diutamakan daripada nilai properti model saat keduanya ada.
Perbedaan kode SQL Server vs SQLite
Berikut ini menunjukkan perbedaan antara versi SQL Server dan SQLite:
+ using System; // For GUID on SQLite
+ departmentToUpdate.ConcurrencyToken = Guid.NewGuid();
_context.Entry(departmentToUpdate)
.Property(d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;
- Department.ConcurrencyToken = (byte[])dbValues.ConcurrencyToken;
+ Department.ConcurrencyToken = dbValues.ConcurrencyToken;
Memperbarui halaman Edit Razor
Perbarui Pages/Departments/Edit.cshtml
dengan kode berikut:
@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");}
}
Kode sebelumnya:
- Memperbarui direktif
page
dari@page
ke@page "{id:int}"
. - Menambahkan versi baris tersembunyi.
ConcurrencyToken
harus ditambahkan sehingga postback mengikat nilai. - Menampilkan byte
ConcurrencyToken
terakhir untuk tujuan penelusuran kesalahan. -
ViewData
Mengganti dengan yang dititikInstructorNameSL
dengan kuat .
Menguji konflik konkurensi dengan halaman Edit
Buka dua instans browser Edit di departemen bahasa Inggris:
- Jalankan aplikasi dan pilih Departemen.
- Klik kanan hyperlink Edit untuk departemen bahasa Inggris dan pilih Buka di tab baru.
- Di tab pertama, klik edit hyperlink untuk departemen bahasa Inggris.
Dua tab browser menampilkan informasi yang sama.
Ubah nama di tab browser pertama dan klik Simpan.
Browser memperlihatkan halaman Indeks dengan nilai yang diubah dan indikator yang diperbarui ConcurrencyToken
. Perhatikan indikator yang diperbarui ConcurrencyToken
, indikator ditampilkan pada postback kedua di tab lain.
Ubah bidang lain di tab browser kedua.
Klik Simpan. Anda melihat pesan kesalahan untuk semua bidang yang tidak cocok dengan nilai database:
Jendela browser ini tidak berniat mengubah bidang Nama. Salin dan tempel nilai saat ini (Bahasa) ke bidang Nama. Tab keluar. Validasi sisi klien menghapus pesan kesalahan.
Klik Simpan lagi. Nilai yang Anda masukkan di tab browser kedua disimpan. Anda melihat nilai yang disimpan di halaman Indeks.
Memperbarui model halaman Hapus
Perbarui Pages/Departments/Delete.cshtml.cs
dengan kode berikut:
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))
{
// Department.ConcurrencyToken value is from when the entity
// was fetched. If it doesn't match the DB, a
// DbUpdateConcurrencyException exception is thrown.
_context.Departments.Remove(Department);
await _context.SaveChangesAsync();
}
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException)
{
return RedirectToPage("./Delete",
new { concurrencyError = true, id = id });
}
}
}
}
Halaman Hapus mendeteksi konflik konkurensi ketika entitas telah berubah setelah diambil.
Department.ConcurrencyToken
adalah versi baris saat entitas diambil. Saat EF Core membuat SQL DELETE
perintah, perintah tersebut menyertakan klausa WHERE dengan ConcurrencyToken
.
SQL DELETE
Jika perintah menghasilkan nol baris yang terpengaruh:
-
ConcurrencyToken
DalamSQL DELETE
perintah tidak cocokConcurrencyToken
dalam database. - Pengecualian
DbUpdateConcurrencyException
dilemparkan. -
OnGetAsync
dipanggil denganconcurrencyError
.
Memperbarui halaman Hapus Razor
Perbarui Pages/Departments/Delete.cshtml
dengan kode berikut:
@page "{id:int}"
@model ContosoUniversity.Pages.Departments.DeleteModel
@{
ViewData["Title"] = "Delete";
}
<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>
Kode sebelumnya membuat perubahan berikut:
- Memperbarui direktif
page
dari@page
ke@page "{id:int}"
. - Menambahkan pesan kesalahan.
- Mengganti FirstMidName dengan FullName di bidang Administrator .
- Perubahan
ConcurrencyToken
untuk menampilkan byte terakhir. - Menambahkan versi baris tersembunyi.
ConcurrencyToken
harus ditambahkan sehingga postback mengikat nilai.
Menguji konflik konkurensi
Buat departemen pengujian.
Buka dua instans browser Hapus pada departemen pengujian:
- Jalankan aplikasi dan pilih Departemen.
- Klik kanan hyperlink Hapus untuk departemen pengujian dan pilih Buka di tab baru.
- Klik edit hyperlink untuk departemen pengujian.
Dua tab browser menampilkan informasi yang sama.
Ubah anggaran di tab browser pertama dan klik Simpan.
Browser memperlihatkan halaman Indeks dengan nilai yang diubah dan indikator yang diperbarui ConcurrencyToken
. Perhatikan indikator yang diperbarui ConcurrencyToken
, indikator ditampilkan pada postback kedua di tab lain.
Hapus departemen pengujian dari tab kedua. Kesalahan konkurensi ditampilkan dengan nilai saat ini dari database. Mengklik Hapus akan menghapus entitas, kecuali ConcurrencyToken
telah diperbarui.
Pola aplikasi web perusahaan
Untuk panduan tentang membuat aplikasi ASP.NET Core yang andal, aman, berkinerja, dapat diuji, dan dapat diskalakan, lihat pola aplikasi web Enterprise. Aplikasi web sampel dengan kualitas produksi yang lengkap dan mengimplementasikan pola-pola tersebut tersedia.
Sumber Daya Tambahan:
- Token Konkurensi dalam EF Core
- Menangani konkurensi di EF Core
- Penelusuran kesalahan sumber ASP.NET Core 2.x
Langkah berikutnya
Ini adalah tutorial terakhir dalam seri ini. Topik tambahan dibahas dalam versi MVC dari seri tutorial ini.
Tutorial ini menunjukkan cara menangani konflik ketika beberapa pengguna memperbarui entitas secara bersamaan (pada saat yang sama).
Konflik konkurensi
Konflik konkurensi terjadi ketika:
- Pengguna menavigasi ke halaman edit untuk entitas.
- Pengguna lain memperbarui entitas yang sama sebelum perubahan pengguna pertama ditulis ke database.
Jika deteksi konkurensi tidak diaktifkan, siapa pun yang memperbarui database terakhir kali menimpa perubahan pengguna lain. Jika risiko ini dapat diterima, biaya pemrograman untuk konkurensi mungkin melebihi manfaatnya.
Konkurensi pesimis (penguncian)
Salah satu cara untuk mencegah konflik konkurensi adalah dengan menggunakan kunci database. Ini disebut konkurensi pesimis. Sebelum aplikasi membaca baris database yang ingin diperbarui, aplikasi meminta kunci. Setelah baris dikunci untuk akses pembaruan, tidak ada pengguna lain yang diizinkan untuk mengunci baris hingga kunci pertama dirilis.
Mengelola kunci memiliki kekurangan. Ini bisa rumit untuk diprogram dan dapat menyebabkan masalah performa saat jumlah pengguna meningkat. Entity Framework Core tidak menyediakan dukungan bawaan untuk itu, dan tutorial ini tidak menunjukkan cara mengimplementasikannya.
Konkurensi optimis
Konkurensi optimis memungkinkan konflik konkurensi terjadi, lalu bereaksi dengan tepat ketika terjadi. Misalnya, Jane mengunjungi halaman edit Departemen dan mengubah anggaran untuk departemen Bahasa Inggris dari $ 350.000,00 menjadi $ 0,00.
Sebelum Klik Jane Simpan, John mengunjungi halaman yang sama dan mengubah bidang Tanggal Mulai dari 1/9/2007 menjadi 1/9/2013.
Jane mengklik Simpan terlebih dahulu dan melihat perubahannya berlaku, karena browser menampilkan halaman Indeks dengan nol sebagai jumlah Anggaran.
John mengklik Simpan pada halaman Edit yang masih menunjukkan anggaran $350.000,00. Apa yang terjadi selanjutnya ditentukan oleh cara Anda menangani konflik konkurensi:
Anda dapat melacak properti mana yang telah dimodifikasi pengguna dan memperbarui hanya kolom yang sesuai dalam database.
Dalam skenario, tidak ada data yang akan hilang. Properti yang berbeda diperbarui oleh dua pengguna. Lain kali seseorang menelusuri departemen Inggris, mereka akan melihat perubahan Jane dan John. Metode pembaruan ini dapat mengurangi jumlah konflik yang dapat mengakibatkan hilangnya data. Pendekatan ini memiliki beberapa kelemahan:
- Tidak dapat menghindari kehilangan data jika perubahan yang bersaing dilakukan pada properti yang sama.
- Umumnya tidak praktis di aplikasi web. Ini membutuhkan mempertahankan status yang signifikan untuk melacak semua nilai yang diambil dan nilai baru. Mempertahankan status dalam jumlah besar dapat memengaruhi performa aplikasi.
- Dapat meningkatkan kompleksitas aplikasi dibandingkan dengan deteksi konkurensi pada entitas.
Kau bisa membiarkan perubahan John menimpa perubahan Jane.
Lain kali seseorang menelusuri departemen Bahasa Inggris, mereka akan melihat 9/1/2013 dan nilai $350,000.00 yang diambil. Pendekatan ini disebut skenario Client Wins atau Last in Wins . (Semua nilai dari klien lebih diutamakan daripada apa yang ada di penyimpanan data.) Jika Anda tidak melakukan pengkodan apa pun untuk penanganan konkurensi, Client Wins terjadi secara otomatis.
Anda dapat mencegah perubahan John diperbarui dalam database. Biasanya, aplikasi akan:
- Menampilkan pesan kesalahan.
- Perlihatkan status data saat ini.
- Izinkan pengguna untuk menerapkan kembali perubahan.
Ini disebut skenario Store Wins . (Nilai penyimpanan data lebih diutamakan daripada nilai yang dikirimkan oleh klien.) Anda menerapkan skenario Store Wins dalam tutorial ini. Metode ini memastikan bahwa tidak ada perubahan yang ditimpa tanpa pengguna diberi tahu.
Deteksi konflik dalam EF Core
EF Core
DbConcurrencyException
melempar pengecualian ketika mendeteksi konflik. Model data harus dikonfigurasi untuk mengaktifkan deteksi konflik. Opsi untuk mengaktifkan deteksi konflik meliputi yang berikut ini:
Konfigurasikan EF Core untuk menyertakan nilai asli kolom yang dikonfigurasi sebagai token konkurensi dalam klausa Di mana perintah Perbarui dan Hapus.
Ketika
SaveChanges
dipanggil, klausa Where mencari nilai asli properti apa pun yang dianotasi dengan ConcurrencyCheckAttribute atribut . Pernyataan pembaruan tidak akan menemukan baris untuk diperbarui jika salah satu properti token konkurensi berubah sejak baris pertama kali dibaca. EF Core menafsirkan bahwa sebagai konflik konkurensi. Untuk tabel database yang memiliki banyak kolom, pendekatan ini dapat menghasilkan klausa Where yang sangat besar, dan dapat memerlukan status dalam jumlah besar. Oleh karena itu pendekatan ini umumnya tidak disarankan, dan bukan metode yang digunakan dalam tutorial ini.Dalam tabel database, sertakan kolom pelacakan yang dapat digunakan untuk menentukan kapan baris telah diubah.
Dalam database SQL Server, jenis data kolom pelacakan adalah
rowversion
. Nilainyarowversion
adalah angka berurutan yang bertahap setiap kali baris diperbarui. Dalam perintah Perbarui atau Hapus, klausa Where menyertakan nilai asli kolom pelacakan (nomor versi baris asli). Jika baris yang diperbarui telah diubah oleh pengguna lain, nilai dalamrowversion
kolom berbeda dari nilai aslinya. Dalam hal ini, pernyataan Perbarui atau Hapus tidak dapat menemukan baris untuk diperbarui karena klausa Where. EF Core melempar pengecualian konkurensi ketika tidak ada baris yang dipengaruhi oleh perintah Perbarui atau Hapus.
Menambahkan properti pelacakan
Di Models/Department.cs
, tambahkan properti pelacakan bernama RowVersion:
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; }
}
}
Atribut TimestampAttribute inilah yang mengidentifikasi kolom sebagai kolom pelacakan konkurensi. API fasih adalah cara alternatif untuk menentukan properti pelacakan:
modelBuilder.Entity<Department>()
.Property<byte[]>("RowVersion")
.IsRowVersion();
Untuk database SQL Server, [Timestamp]
atribut pada properti entitas yang didefinisikan sebagai array byte:
- Menyebabkan kolom disertakan dalam klausa DELETE dan UPDATE WHERE.
- Mengatur jenis kolom dalam database ke rowversion.
Database menghasilkan nomor versi baris berurutan yang bertahap setiap kali baris diperbarui. Dalam perintah Update
atau Delete
, Where
klausul menyertakan nilai versi baris yang diambil. Jika baris yang diperbarui telah berubah sejak diambil:
- Nilai versi baris saat ini tidak cocok dengan nilai yang diambil.
- Perintah
Update
atauDelete
tidak menemukan baris karenaWhere
klausul mencari nilai versi baris yang diambil. - A
DbUpdateConcurrencyException
dilemparkan.
Kode berikut menunjukkan bagian dari T-SQL yang dihasilkan oleh EF Core ketika nama Departemen diperbarui:
SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
Kode yang disorot sebelumnya menunjukkan WHERE
klausa yang berisi RowVersion
. Jika database RowVersion
tidak sama dengan RowVersion
parameter (@p2
), tidak ada baris yang diperbarui.
Kode yang disorot berikut menunjukkan T-SQL yang memverifikasi persis satu baris diperbarui:
SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
@@ROWCOUNT mengembalikan jumlah baris yang terpengaruh oleh pernyataan terakhir. Jika tidak ada baris yang diperbarui, EF Core melempar .DbUpdateConcurrencyException
Memperbarui database
Menambahkan properti mengubah RowVersion
model data, yang memerlukan migrasi.
Bangun proyek.
Jalankan perintah berikut di PMC:
Add-Migration RowVersion
Perintah ini:
Migrations/{time stamp}_RowVersion.cs
Membuat file migrasi.Migrations/SchoolContextModelSnapshot.cs
Memperbarui file. Pembaruan menambahkan kode yang disorot berikut keBuildModel
metode :modelBuilder.Entity("ContosoUniversity.Models.Department", b => { b.Property<int>("DepartmentID") .ValueGeneratedOnAdd() .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); b.Property<decimal>("Budget") .HasColumnType("money"); b.Property<int?>("InstructorID"); b.Property<string>("Name") .HasMaxLength(50); b.Property<byte[]>("RowVersion") .IsConcurrencyToken() .ValueGeneratedOnAddOrUpdate(); b.Property<DateTime>("StartDate"); b.HasKey("DepartmentID"); b.HasIndex("InstructorID"); b.ToTable("Department"); });
Jalankan perintah berikut di PMC:
Update-Database
Halaman Departemen Perancah
Ikuti instruksi di halaman Scaffold Student dengan pengecualian berikut:
Buat folder Halaman/Departemen .
Gunakan
Department
untuk kelas model.- Gunakan kelas konteks yang ada alih-alih membuat yang baru.
Bangun proyek.
Memperbarui halaman Indeks
Alat perancah membuat RowVersion
kolom untuk halaman Indeks, tetapi bidang tersebut tidak akan ditampilkan di aplikasi produksi. Dalam tutorial ini, byte RowVersion
terakhir ditampilkan untuk membantu menunjukkan cara kerja penanganan konkurensi. Byte terakhir tidak dijamin unik dengan sendirinya.
Perbarui halaman Pages\Departments\Index.cshtml :
- Ganti Indeks dengan Departemen.
- Ubah kode yang berisi
RowVersion
untuk memperlihatkan hanya byte terakhir dari array byte. - Ganti FirstMidName dengan FullName.
Kode berikut menunjukkan halaman yang diperbarui:
@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>
RowVersion
</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>
@item.RowVersion[7]
</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>
Memperbarui model halaman Edit
Perbarui Pages/Departments/Edit.cshtml.cs
dengan kode berikut:
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; }
// Replace ViewData["InstructorID"]
public SelectList InstructorNameSL { get; set; }
public async Task<IActionResult> OnGetAsync(int id)
{
Department = await _context.Departments
.Include(d => d.Administrator) // eager loading
.AsNoTracking() // tracking not required
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (Department == null)
{
return NotFound();
}
// Use strongly typed data rather than ViewData.
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("RowVersion").OriginalValue = Department.RowVersion;
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);
// Save the current RowVersion so next postback
// matches unless an new concurrency issue happens.
Department.RowVersion = (byte[])dbValues.RowVersion;
// Clear the model error for the next postback.
ModelState.Remove("Department.RowVersion");
}
}
InstructorNameSL = new SelectList(_context.Instructors,
"ID", "FullName", departmentToUpdate.InstructorID);
return Page();
}
private IActionResult HandleDeletedDepartment()
{
var deletedDepartment = new Department();
// ModelState contains the posted data because of the deletion error
// and will overide the Department instance values when displaying Page().
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.");
}
}
}
diperbarui OriginalValue dengan rowVersion
nilai dari entitas ketika diambil dalam OnGetAsync
metode .
EF Core menghasilkan perintah SQL UPDATE dengan klausa WHERE yang berisi nilai asli RowVersion
. Jika tidak ada baris yang dipengaruhi oleh perintah UPDATE (tidak ada baris yang memiliki nilai asli RowVersion
), DbUpdateConcurrencyException
pengecualian akan dilemparkan.
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("RowVersion").OriginalValue = Department.RowVersion;
Dalam kode yang disorot sebelumnya:
- Nilai di
Department.RowVersion
adalah apa yang ada di entitas ketika awalnya diambil di halaman Dapatkan permintaan untuk Edit. Nilai disediakan untukOnPost
metode oleh bidang tersembunyi di Razor halaman yang menampilkan entitas yang akan diedit. Nilai bidang tersembunyi disalin oleh pengikatDepartment.RowVersion
model. -
OriginalValue
adalah apa yang EF Core akan digunakan dalam klausa Where. Sebelum baris kode yang disorot dijalankan,OriginalValue
memiliki nilai yang ada dalam database ketikaFirstOrDefaultAsync
dipanggil dalam metode ini, yang mungkin berbeda dari apa yang ditampilkan di halaman Edit. - Kode yang disorot memastikan bahwa EF Core menggunakan nilai asli
RowVersion
dari entitas yangDepartment
ditampilkan dalam klausa Where pernyataan SQL UPDATE.
Ketika kesalahan konkurensi terjadi, kode yang disorot berikut mendapatkan nilai klien (nilai yang diposting ke metode ini) dan nilai database.
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);
// Save the current RowVersion so next postback
// matches unless an new concurrency issue happens.
Department.RowVersion = (byte[])dbValues.RowVersion;
// Clear the model error for the next postback.
ModelState.Remove("Department.RowVersion");
}
Kode berikut menambahkan pesan kesalahan kustom untuk setiap kolom yang memiliki nilai database yang berbeda dari yang diposting ke OnPostAsync
:
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.");
}
Kode yang disorot RowVersion
berikut menetapkan nilai ke nilai baru yang diambil dari database. Saat berikutnya pengguna mengklik Simpan, hanya kesalahan konkurensi yang terjadi sejak tampilan terakhir halaman Edit yang akan tertangkap.
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);
// Save the current RowVersion so next postback
// matches unless an new concurrency issue happens.
Department.RowVersion = (byte[])dbValues.RowVersion;
// Clear the model error for the next postback.
ModelState.Remove("Department.RowVersion");
}
Pernyataan ModelState.Remove
diperlukan karena ModelState
memiliki nilai lama RowVersion
.
Razor Di Halaman, ModelState
nilai untuk bidang lebih diutamakan daripada nilai properti model saat keduanya ada.
Memperbarui halaman Edit
Perbarui Pages/Departments/Edit.cshtml
dengan kode berikut:
@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.RowVersion" />
<div class="form-group">
<label>RowVersion</label>
@Model.Department.RowVersion[7]
</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");}
}
Kode sebelumnya:
- Memperbarui direktif
page
dari@page
ke@page "{id:int}"
. - Menambahkan versi baris tersembunyi.
RowVersion
harus ditambahkan sehingga postback mengikat nilai. - Menampilkan byte
RowVersion
terakhir untuk tujuan penelusuran kesalahan. -
ViewData
Mengganti dengan yang dititikInstructorNameSL
dengan kuat .
Menguji konflik konkurensi dengan halaman Edit
Buka dua instans browser Edit di departemen bahasa Inggris:
- Jalankan aplikasi dan pilih Departemen.
- Klik kanan hyperlink Edit untuk departemen bahasa Inggris dan pilih Buka di tab baru.
- Di tab pertama, klik edit hyperlink untuk departemen bahasa Inggris.
Dua tab browser menampilkan informasi yang sama.
Ubah nama di tab browser pertama dan klik Simpan.
Browser memperlihatkan halaman Indeks dengan nilai yang diubah dan indikator rowVersion yang diperbarui. Perhatikan indikator rowVersion yang diperbarui, indikator ditampilkan pada postback kedua di tab lain.
Ubah bidang lain di tab browser kedua.
Klik Simpan. Anda melihat pesan kesalahan untuk semua bidang yang tidak cocok dengan nilai database:
Jendela browser ini tidak berniat mengubah bidang Nama. Salin dan tempel nilai saat ini (Bahasa) ke bidang Nama. Tab keluar. Validasi sisi klien menghapus pesan kesalahan.
Klik Simpan lagi. Nilai yang Anda masukkan di tab browser kedua disimpan. Anda melihat nilai yang disimpan di halaman Indeks.
Memperbarui model halaman Hapus
Perbarui Pages/Departments/Delete.cshtml.cs
dengan kode berikut:
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))
{
// Department.rowVersion value is from when the entity
// was fetched. If it doesn't match the DB, a
// DbUpdateConcurrencyException exception is thrown.
_context.Departments.Remove(Department);
await _context.SaveChangesAsync();
}
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException)
{
return RedirectToPage("./Delete",
new { concurrencyError = true, id = id });
}
}
}
}
Halaman Hapus mendeteksi konflik konkurensi ketika entitas telah berubah setelah diambil.
Department.RowVersion
adalah versi baris saat entitas diambil. Saat EF Core membuat perintah SQL DELETE, perintah tersebut menyertakan klausa WHERE dengan RowVersion
. Jika perintah SQL DELETE menghasilkan nol baris yang terpengaruh:
- Dalam
RowVersion
perintah SQL DELETE tidak cocokRowVersion
dalam database. - Pengecualian DbUpdateConcurrencyException dilemparkan.
-
OnGetAsync
dipanggil denganconcurrencyError
.
Memperbarui halaman Hapus
Perbarui Pages/Departments/Delete.cshtml
dengan kode berikut:
@page "{id:int}"
@model ContosoUniversity.Pages.Departments.DeleteModel
@{
ViewData["Title"] = "Delete";
}
<h2>Delete</h2>
<p class="text-danger">@Model.ConcurrencyErrorMessage</p>
<h3>Are you sure you want to delete this?</h3>
<div>
<h4>Department</h4>
<hr />
<dl class="dl-horizontal">
<dt>
@Html.DisplayNameFor(model => model.Department.Name)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.Name)
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.Budget)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.Budget)
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.StartDate)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.StartDate)
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.RowVersion)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.RowVersion[7])
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.Administrator)
</dt>
<dd>
@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.RowVersion" />
<div class="form-actions no-color">
<input type="submit" value="Delete" class="btn btn-danger" /> |
<a asp-page="./Index">Back to List</a>
</div>
</form>
</div>
Kode sebelumnya membuat perubahan berikut:
- Memperbarui direktif
page
dari@page
ke@page "{id:int}"
. - Menambahkan pesan kesalahan.
- Mengganti FirstMidName dengan FullName di bidang Administrator .
- Perubahan
RowVersion
untuk menampilkan byte terakhir. - Menambahkan versi baris tersembunyi.
RowVersion
harus ditambahkan sehingga postback mengikat nilai.
Menguji konflik konkurensi
Buat departemen pengujian.
Buka dua instans browser Hapus pada departemen pengujian:
- Jalankan aplikasi dan pilih Departemen.
- Klik kanan hyperlink Hapus untuk departemen pengujian dan pilih Buka di tab baru.
- Klik edit hyperlink untuk departemen pengujian.
Dua tab browser menampilkan informasi yang sama.
Ubah anggaran di tab browser pertama dan klik Simpan.
Browser memperlihatkan halaman Indeks dengan nilai yang diubah dan indikator rowVersion yang diperbarui. Perhatikan indikator rowVersion yang diperbarui, indikator ditampilkan pada postback kedua di tab lain.
Hapus departemen pengujian dari tab kedua. Kesalahan konkurensi ditampilkan dengan nilai saat ini dari database. Mengklik Hapus akan menghapus entitas, kecuali RowVersion
telah diperbarui.
Untuk panduan tentang membuat aplikasi ASP.NET Core yang andal, aman, berkinerja, dapat diuji, dan dapat diskalakan, lihat pola aplikasi web Enterprise. Aplikasi web sampel dengan kualitas produksi yang lengkap tersedia dan mengimplementasikan pola-pola tersebut.
Sumber Daya Tambahan:
- Token Konkurensi dalam EF Core
- Menangani konkurensi di EF Core
- Penelusuran kesalahan sumber ASP.NET Core 2.x
Langkah berikutnya
Ini adalah tutorial terakhir dalam seri ini. Topik tambahan dibahas dalam versi MVC dari seri tutorial ini.
Tutorial ini menunjukkan cara menangani konflik ketika beberapa pengguna memperbarui entitas secara bersamaan (pada saat yang sama). Jika Mengalami masalah, Anda tidak dapat menyelesaikan, mengunduh, atau melihat aplikasi yang telah selesai.Unduh instruksi.
Konflik konkurensi
Konflik konkurensi terjadi ketika:
- Pengguna menavigasi ke halaman edit untuk entitas.
- Pengguna lain memperbarui entitas yang sama sebelum perubahan pengguna pertama ditulis ke DB.
Jika deteksi konkurensi tidak diaktifkan, saat pembaruan bersamaan terjadi:
- Pembaruan terakhir menang. Artinya, nilai pembaruan terakhir disimpan ke DB.
- Pembaruan pertama saat ini hilang.
Konkurensi optimis
Konkurensi optimis memungkinkan konflik konkurensi terjadi, lalu bereaksi dengan tepat ketika terjadi. Misalnya, Jane mengunjungi halaman edit Departemen dan mengubah anggaran untuk departemen Bahasa Inggris dari $ 350.000,00 menjadi $ 0,00.
Sebelum Klik Jane Simpan, John mengunjungi halaman yang sama dan mengubah bidang Tanggal Mulai dari 1/9/2007 menjadi 1/9/2013.
Jane mengklik Simpan terlebih dahulu dan melihat perubahannya saat browser menampilkan halaman Indeks.
John mengklik Simpan pada halaman Edit yang masih menunjukkan anggaran $350.000,00. Apa yang terjadi selanjutnya ditentukan oleh cara Anda menangani konflik konkurensi.
Konkurensi optimis mencakup opsi berikut:
Anda dapat melacak properti mana yang telah dimodifikasi pengguna dan memperbarui hanya kolom yang sesuai di DB.
Dalam skenario, tidak ada data yang akan hilang. Properti yang berbeda diperbarui oleh dua pengguna. Lain kali seseorang menelusuri departemen Inggris, mereka akan melihat perubahan Jane dan John. Metode pembaruan ini dapat mengurangi jumlah konflik yang dapat mengakibatkan hilangnya data. Pendekatan ini:
- Tidak dapat menghindari kehilangan data jika perubahan yang bersaing dilakukan pada properti yang sama.
- Umumnya tidak praktis di aplikasi web. Ini membutuhkan mempertahankan status yang signifikan untuk melacak semua nilai yang diambil dan nilai baru. Mempertahankan status dalam jumlah besar dapat memengaruhi performa aplikasi.
- Dapat meningkatkan kompleksitas aplikasi dibandingkan dengan deteksi konkurensi pada entitas.
Kau bisa membiarkan perubahan John menimpa perubahan Jane.
Lain kali seseorang menelusuri departemen Bahasa Inggris, mereka akan melihat 9/1/2013 dan nilai $350,000.00 yang diambil. Pendekatan ini disebut skenario Client Wins atau Last in Wins . (Semua nilai dari klien lebih diutamakan daripada apa yang ada di penyimpanan data.) Jika Anda tidak melakukan pengkodan apa pun untuk penanganan konkurensi, Client Wins terjadi secara otomatis.
Anda dapat mencegah perubahan John diperbarui di DB. Biasanya, aplikasi akan:
- Menampilkan pesan kesalahan.
- Perlihatkan status data saat ini.
- Izinkan pengguna untuk menerapkan kembali perubahan.
Ini disebut skenario Store Wins . (Nilai penyimpanan data lebih diutamakan daripada nilai yang dikirimkan oleh klien.) Anda menerapkan skenario Store Wins dalam tutorial ini. Metode ini memastikan bahwa tidak ada perubahan yang ditimpa tanpa pengguna diberi tahu.
Menangani konkurensi
Saat properti dikonfigurasi sebagai token konkurensi:
- EF Core memverifikasi bahwa properti belum dimodifikasi setelah diambil. Pemeriksaan terjadi ketika SaveChanges atau SaveChangesAsync dipanggil.
- Jika properti telah diubah setelah diambil, akan DbUpdateConcurrencyException dilemparkan.
Model DB dan data harus dikonfigurasi untuk mendukung pelemparan DbUpdateConcurrencyException
.
Mendeteksi konflik konkurensi pada properti
Konflik konkurensi dapat dideteksi di tingkat properti dengan atribut ConcurrencyCheck . Atribut dapat diterapkan ke beberapa properti pada model. Untuk informasi selengkapnya, lihat Anotasi Data-ConcurrencyCheck.
Atribut [ConcurrencyCheck]
tidak digunakan dalam tutorial ini.
Mendeteksi konflik konkurensi pada baris
Untuk mendeteksi konflik konkurensi, kolom pelacakan rowversion ditambahkan ke model.
rowversion
:
- Apakah SQL Server spesifik. Database lain mungkin tidak menyediakan fitur serupa.
- Digunakan untuk menentukan bahwa entitas belum diubah sejak diambil dari DB.
DB menghasilkan angka berurutan rowversion
yang bertahap setiap kali baris diperbarui. Dalam perintah Update
atau Delete
, Where
klausul menyertakan nilai yang diambil dari rowversion
. Jika baris yang diperbarui telah berubah:
-
rowversion
tidak cocok dengan nilai yang diambil. - Perintah
Update
atauDelete
tidak menemukan baris karenaWhere
klausul menyertakan yang diambilrowversion
. - A
DbUpdateConcurrencyException
dilemparkan.
Dalam EF Core, ketika tidak ada baris yang diperbarui oleh perintah Update
atau Delete
, pengecualian konkurensi dilemparkan.
Menambahkan properti pelacakan ke entitas Departemen
Di Models/Department.cs
, tambahkan properti pelacakan bernama RowVersion:
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; }
}
}
Atribut Tanda Waktu menentukan bahwa kolom ini disertakan dalam Where
klausul Update
dan Delete
perintah. Atribut dipanggil Timestamp
karena versi SQL Server sebelumnya menggunakan jenis data SQL timestamp
sebelum jenis SQL rowversion
menggantinya.
API fasih juga dapat menentukan properti pelacakan:
modelBuilder.Entity<Department>()
.Property<byte[]>("RowVersion")
.IsRowVersion();
Kode berikut menunjukkan bagian dari T-SQL yang dihasilkan oleh EF Core ketika nama Departemen diperbarui:
SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
Kode yang disorot sebelumnya menunjukkan WHERE
klausa yang berisi RowVersion
. Jika DB RowVersion
tidak sama dengan RowVersion
parameter (@p2
), tidak ada baris yang diperbarui.
Kode yang disorot berikut menunjukkan T-SQL yang memverifikasi persis satu baris diperbarui:
SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
@@ROWCOUNT mengembalikan jumlah baris yang terpengaruh oleh pernyataan terakhir. Dalam tidak ada baris yang diperbarui, EF Core melempar .DbUpdateConcurrencyException
Anda dapat melihat T-SQL EF Core yang dihasilkan di jendela output Visual Studio.
Memperbarui DB
Menambahkan properti mengubah RowVersion
model DB, yang memerlukan migrasi.
Bangun proyek. Masukkan yang berikut ini di jendela perintah:
dotnet ef migrations add RowVersion
dotnet ef database update
Perintah sebelumnya:
Migrations/{time stamp}_RowVersion.cs
Menambahkan file migrasi.Migrations/SchoolContextModelSnapshot.cs
Memperbarui file. Pembaruan menambahkan kode yang disorot berikut keBuildModel
metode :Menjalankan migrasi untuk memperbarui DB.
Perancah model Departemen
Ikuti instruksi di Perancah model siswa dan gunakan Department
untuk kelas model.
Perintah sebelumnya mengacak Department
model. Buka proyek di Visual Studio.
Bangun proyek.
Memperbarui halaman Indeks Departemen
Mesin perancah membuat RowVersion
kolom untuk halaman Indeks, tetapi bidang tersebut tidak boleh ditampilkan. Dalam tutorial ini, byte RowVersion
terakhir ditampilkan untuk membantu memahami konkurensi. Byte terakhir tidak dijamin unik. Aplikasi nyata tidak akan ditampilkan RowVersion
atau byte terakhir dari RowVersion
.
Perbarui halaman Indeks:
- Ganti Indeks dengan Departemen.
- Ganti markup yang berisi
RowVersion
dengan byte terakhir .RowVersion
- Ganti FirstMidName dengan FullName.
Markup berikut menunjukkan halaman yang diperbarui:
@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>
RowVersion
</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>
@item.RowVersion[7]
</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>
Memperbarui model halaman Edit
Perbarui Pages/Departments/Edit.cshtml.cs
dengan kode berikut:
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; }
// Replace ViewData["InstructorID"]
public SelectList InstructorNameSL { get; set; }
public async Task<IActionResult> OnGetAsync(int id)
{
Department = await _context.Departments
.Include(d => d.Administrator) // eager loading
.AsNoTracking() // tracking not required
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (Department == null)
{
return NotFound();
}
// Use strongly typed data rather than ViewData.
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);
// null means Department was deleted by another user.
if (departmentToUpdate == null)
{
return HandleDeletedDepartment();
}
// Update the RowVersion to the value when this entity was
// fetched. If the entity has been updated after it was
// fetched, RowVersion won't match the DB RowVersion and
// a DbUpdateConcurrencyException is thrown.
// A second postback will make them match, unless a new
// concurrency issue happens.
_context.Entry(departmentToUpdate)
.Property("RowVersion").OriginalValue = Department.RowVersion;
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);
// Save the current RowVersion so next postback
// matches unless an new concurrency issue happens.
Department.RowVersion = (byte[])dbValues.RowVersion;
// Must clear the model error for the next postback.
ModelState.Remove("Department.RowVersion");
}
}
InstructorNameSL = new SelectList(_context.Instructors,
"ID", "FullName", departmentToUpdate.InstructorID);
return Page();
}
private IActionResult HandleDeletedDepartment()
{
// ModelState contains the posted data because of the deletion error and will overide the Department instance values when displaying Page().
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.");
}
}
}
Untuk mendeteksi masalah konkurensi, diperbarui OriginalValue dengan rowVersion
nilai dari entitas yang diambil.
EF Core menghasilkan perintah SQL UPDATE dengan klausa WHERE yang berisi nilai asli RowVersion
. Jika tidak ada baris yang dipengaruhi oleh perintah UPDATE (tidak ada baris yang memiliki nilai asli RowVersion
), DbUpdateConcurrencyException
pengecualian akan dilemparkan.
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);
// null means Department was deleted by another user.
if (departmentToUpdate == null)
{
return HandleDeletedDepartment();
}
// Update the RowVersion to the value when this entity was
// fetched. If the entity has been updated after it was
// fetched, RowVersion won't match the DB RowVersion and
// a DbUpdateConcurrencyException is thrown.
// A second postback will make them match, unless a new
// concurrency issue happens.
_context.Entry(departmentToUpdate)
.Property("RowVersion").OriginalValue = Department.RowVersion;
Dalam kode sebelumnya, Department.RowVersion
adalah nilai ketika entitas diambil.
OriginalValue
adalah nilai dalam DB ketika FirstOrDefaultAsync
dipanggil dalam metode ini.
Kode berikut mendapatkan nilai klien (nilai yang diposting ke metode ini) dan nilai DB:
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);
// Save the current RowVersion so next postback
// matches unless an new concurrency issue happens.
Department.RowVersion = (byte[])dbValues.RowVersion;
// Must clear the model error for the next postback.
ModelState.Remove("Department.RowVersion");
}
Kode berikut menambahkan pesan kesalahan kustom untuk setiap kolom yang memiliki nilai DB berbeda dari yang diposting ke OnPostAsync
:
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.");
}
Kode yang disorot RowVersion
berikut menetapkan nilai ke nilai baru yang diambil dari DB. Saat berikutnya pengguna mengklik Simpan, hanya kesalahan konkurensi yang terjadi sejak tampilan terakhir halaman Edit yang akan tertangkap.
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);
// Save the current RowVersion so next postback
// matches unless an new concurrency issue happens.
Department.RowVersion = (byte[])dbValues.RowVersion;
// Must clear the model error for the next postback.
ModelState.Remove("Department.RowVersion");
}
Pernyataan ModelState.Remove
diperlukan karena ModelState
memiliki nilai lama RowVersion
.
Razor Di Halaman, ModelState
nilai untuk bidang lebih diutamakan daripada nilai properti model saat keduanya ada.
Memperbarui halaman Edit
Perbarui Pages/Departments/Edit.cshtml
dengan markup berikut:
@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.RowVersion" />
<div class="form-group">
<label>RowVersion</label>
@Model.Department.RowVersion[7]
</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-default" />
</div>
</form>
</div>
</div>
<div>
<a asp-page="./Index">Back to List</a>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}
Markup sebelumnya:
- Memperbarui direktif
page
dari@page
ke@page "{id:int}"
. - Menambahkan versi baris tersembunyi.
RowVersion
harus ditambahkan sehingga post back mengikat nilai. - Menampilkan byte
RowVersion
terakhir untuk tujuan penelusuran kesalahan. -
ViewData
Mengganti dengan yang dititikInstructorNameSL
dengan kuat .
Menguji konflik konkurensi dengan halaman Edit
Buka dua instans browser Edit di departemen bahasa Inggris:
- Jalankan aplikasi dan pilih Departemen.
- Klik kanan hyperlink Edit untuk departemen bahasa Inggris dan pilih Buka di tab baru.
- Di tab pertama, klik edit hyperlink untuk departemen bahasa Inggris.
Dua tab browser menampilkan informasi yang sama.
Ubah nama di tab browser pertama dan klik Simpan.
Browser memperlihatkan halaman Indeks dengan nilai yang diubah dan indikator rowVersion yang diperbarui. Perhatikan indikator rowVersion yang diperbarui, indikator ditampilkan pada postback kedua di tab lain.
Ubah bidang lain di tab browser kedua.
Klik Simpan. Anda melihat pesan kesalahan untuk semua bidang yang tidak cocok dengan nilai DB:
Jendela browser ini tidak berniat mengubah bidang Nama. Salin dan tempel nilai saat ini (Bahasa) ke bidang Nama. Tab keluar. Validasi sisi klien menghapus pesan kesalahan.
Klik Simpan lagi. Nilai yang Anda masukkan di tab browser kedua disimpan. Anda melihat nilai yang disimpan di halaman Indeks.
Memperbarui halaman Hapus
Perbarui model halaman Hapus dengan kode berikut:
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))
{
// Department.rowVersion value is from when the entity
// was fetched. If it doesn't match the DB, a
// DbUpdateConcurrencyException exception is thrown.
_context.Departments.Remove(Department);
await _context.SaveChangesAsync();
}
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException)
{
return RedirectToPage("./Delete",
new { concurrencyError = true, id = id });
}
}
}
}
Halaman Hapus mendeteksi konflik konkurensi ketika entitas telah berubah setelah diambil.
Department.RowVersion
adalah versi baris saat entitas diambil. Saat EF Core membuat perintah SQL DELETE, perintah tersebut menyertakan klausa WHERE dengan RowVersion
. Jika perintah SQL DELETE menghasilkan nol baris yang terpengaruh:
-
RowVersion
dalam perintah SQL DELETE tidak cocokRowVersion
di DB. - Pengecualian DbUpdateConcurrencyException dilemparkan.
-
OnGetAsync
dipanggil denganconcurrencyError
.
Memperbarui halaman Hapus
Perbarui Pages/Departments/Delete.cshtml
dengan kode berikut:
@page "{id:int}"
@model ContosoUniversity.Pages.Departments.DeleteModel
@{
ViewData["Title"] = "Delete";
}
<h2>Delete</h2>
<p class="text-danger">@Model.ConcurrencyErrorMessage</p>
<h3>Are you sure you want to delete this?</h3>
<div>
<h4>Department</h4>
<hr />
<dl class="dl-horizontal">
<dt>
@Html.DisplayNameFor(model => model.Department.Name)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.Name)
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.Budget)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.Budget)
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.StartDate)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.StartDate)
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.RowVersion)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.RowVersion[7])
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.Administrator)
</dt>
<dd>
@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.RowVersion" />
<div class="form-actions no-color">
<input type="submit" value="Delete" class="btn btn-default" /> |
<a asp-page="./Index">Back to List</a>
</div>
</form>
</div>
Kode sebelumnya membuat perubahan berikut:
- Memperbarui direktif
page
dari@page
ke@page "{id:int}"
. - Menambahkan pesan kesalahan.
- Mengganti FirstMidName dengan FullName di bidang Administrator .
- Perubahan
RowVersion
untuk menampilkan byte terakhir. - Menambahkan versi baris tersembunyi.
RowVersion
harus ditambahkan sehingga post back mengikat nilai.
Menguji konflik konkurensi dengan halaman Hapus
Buat departemen pengujian.
Buka dua instans browser Hapus pada departemen pengujian:
- Jalankan aplikasi dan pilih Departemen.
- Klik kanan hyperlink Hapus untuk departemen pengujian dan pilih Buka di tab baru.
- Klik edit hyperlink untuk departemen pengujian.
Dua tab browser menampilkan informasi yang sama.
Ubah anggaran di tab browser pertama dan klik Simpan.
Browser memperlihatkan halaman Indeks dengan nilai yang diubah dan indikator rowVersion yang diperbarui. Perhatikan indikator rowVersion yang diperbarui, indikator ditampilkan pada postback kedua di tab lain.
Hapus departemen pengujian dari tab kedua. Kesalahan konkurensi ditampilkan dengan nilai saat ini dari DB. Mengklik Hapus akan menghapus entitas, kecuali RowVersion
telah diperbarui.
Lihat Pewarisan tentang cara mewarisi model data.
Pola aplikasi web perusahaan
Untuk panduan tentang membuat aplikasi ASP.NET Core yang andal, aman, berkinerja, dapat diuji, dan dapat diskalakan, lihat pola aplikasi web Enterprise. Aplikasi web contoh dengan kualitas produksi yang lengkap tersedia dan mengimplementasikan pola tersebut.
Sumber Daya Tambahan:
ASP.NET Core