Del 8, Razor sidor med EF Core i ASP.NET Core – Samtidighet
Contoso University-webbappen visar hur du skapar webbappar för Razor Pages med hjälp av EF Core och Visual Studio. Information om självstudieserien finns i den första självstudien.
Om du stöter på problem som du inte kan lösa, ladda ner den färdiga appen och jämför den koden med den du skapade när du följde handledningen.
Den här självstudien visar hur du hanterar konflikter när flera användare uppdaterar en entitet samtidigt.
Samtidighetskonflikter
En samtidighetskonflikt uppstår när:
- En användare navigerar till redigeringssidan för en entitet.
- En annan användare uppdaterar samma entitet innan den första användarens ändring skrivs till databasen.
Om samtidighetsidentifiering inte är aktiverat skriver den som uppdaterar databasen senast över den andra användarens ändringar. Om den här risken är acceptabel kan kostnaden för programmering för samtidighet uppväga fördelen.
Pessimistisk konkurrentåtkomst
Ett sätt att förhindra samtidighetskonflikter är att använda databaslås. Detta kallas pessimistisk samtidighet. Innan appen läser en databasrad som den tänker uppdatera begär den ett lås. När en rad har låsts för uppdateringsåtkomst får inga andra användare låsa raden förrän det första låset har släppts.
Att hantera lås har nackdelar. Det kan vara komplext att programmera och kan orsaka prestandaproblem när antalet användare ökar. Entity Framework Core ger inget inbyggt stöd för pessimistisk samtidighet.
Optimistisk samtidighet
Optimistisk samtidighet gör att samtidighetskonflikter kan inträffa och reagerar sedan på rätt sätt när de gör det. Jane besöker till exempel sidan Avdelningsredigering och ändrar budgeten för den engelska avdelningen från 350 000,00 USD till 0,00 USD.
Innan Jane klickar på Sparabesöker John samma sida och ändrar fältet Startdatum från 2007-09-01 till 2013-09-01.
Jane klickar Spara först och ser att hennes ändring börjar gälla, eftersom webbläsaren visar sidan Index med noll som budgetbelopp.
John klickar på Spara på en redigeringssida som fortfarande visar en budget på 350 000,00 USD. Vad som händer härnäst bestäms av hur du hanterar samtidighetskonflikter:
Håll reda på vilken egenskap en användare har ändrat och uppdatera endast motsvarande kolumner i databasen.
I scenariot skulle inga data gå förlorade. De två användarna uppdaterade olika egenskaper. Nästa gång någon bläddrar på den engelska avdelningen ser de både Janes och Johns ändringar. Den här uppdateringsmetoden kan minska antalet konflikter som kan leda till dataförlust. Den här metoden har vissa nackdelar:
- Det går inte att undvika dataförlust om konkurrerande ändringar görs i samma attribut.
- Är vanligtvis inte praktiskt i en webbapp. Det kräver att betydande tillstånd bibehålls för att hålla reda på alla hämtade värden och nya värden. Att upprätthålla stora mängder tillstånd kan påverka appens prestanda.
- Kan öka appkomplexiteten jämfört med samtidighetsidentifiering på en entitet.
Låt Johns förändring skriva över Janes förändring.
Nästa gång någon bläddrar på den engelska avdelningen ser de datumet 2013-01-09 och det hämtade beloppet på 350 000,00 USD. Den här metoden kallas för ett klient vinner eller sista in vinner scenario. Alla värden från klienten har företräde framför vad som finns i datalagret. Den genererade koden hanterar ingen samtidighet, och "Client Wins" sker automatiskt.
Förhindra att Johns ändring uppdateras i databasen. Normalt skulle appen:
- Visa ett felmeddelande.
- Visa datans aktuella tillstånd.
- Tillåt att användaren återanvänder ändringarna.
Detta kallas för ett Store Wins scenario. Datalagringsvärdena har företräde framför de värden som skickas av klienten. "Store Wins-scenariot" används i den här handledningen. Den här metoden säkerställer att inga ändringar skrivs över utan att en användare aviseras.
Konfliktidentifiering i EF Core
Egenskaper som konfigurerats som samtidighetsmarkörer används för att implementera optimistisk samtidighetskontroll. När en uppdaterings- eller borttagningsåtgärd utlöses av SaveChanges eller SaveChangesAsyncjämförs värdet för samtidighetstoken i databasen med det ursprungliga värdet som lästes av EF Core:
- Om värdena matchar kan åtgärden slutföras.
- Om värdena inte matchar förutsätter EF Core att en annan användare har utfört en konfliktåtgärd, avbryter den aktuella transaktionen och genererar en DbUpdateConcurrencyException.
En annan användare eller process som utför en åtgärd som står i konflikt med den aktuella åtgärden kallas samtidighetskonflikt.
I relationsdatabaser kontrollerar EF Core värdet för konkurrenstokenet i WHERE
-klausulen i UPDATE
- och DELETE
-satser för att upptäcka en samtidighetskonflikt.
Datamodellen måste konfigureras för att aktivera konfliktidentifiering genom att inkludera en spårningskolumn som kan användas för att avgöra när en rad har ändrats. EF tillhandahåller två metoder för samtidighetstoken:
Tillämpa
[ConcurrencyCheck]
eller IsConcurrencyToken på en egenskap i modellen. Den här metoden rekommenderas inte. Mer information finns i Konkurrenstoken i EF Core.Tillämpa TimestampAttribute eller IsRowVersion på en samtidighetstoken i modellen. Det här är den metod som används i den här handledningen.
SQL Server-metoden och SQLite-implementeringsinformationen skiljer sig något åt. En skillnadsfil presenteras senare i handledningen där skillnaderna listas. Fliken Visual Studio visar SQL Server-metoden. Fliken Visual Studio Code visar metoden för icke-SQL Server-databaser, till exempel SQLite.
- I modellen inkluderar du en spårningskolumn som används för att avgöra när en rad har ändrats.
- Använd TimestampAttribute på samtidighetsegenskapen.
Uppdatera Models/Department.cs
-filen med följande markerade kod:
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; }
}
}
TimestampAttribute är vad som identifierar kolumnen som en kolumn för samtidighetsspårning. Api:et fluent är ett alternativt sätt att ange spårningsegenskapen:
modelBuilder.Entity<Department>()
.Property<byte[]>("ConcurrencyToken")
.IsRowVersion();
Attributet [Timestamp]
på en entitetsegenskap genererar följande kod i metoden ModelBuilder:
b.Property<byte[]>("ConcurrencyToken")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
Föregående kod:
- Anger egenskapstypen
ConcurrencyToken
till bytematris.byte[]
är den typ som krävs för SQL Server. - Anropar IsConcurrencyToken.
IsConcurrencyToken
konfigurerar egenskapen som en samtidighetstoken. Vid uppdateringar jämförs värdet för samtidighetstoken i databasen med det ursprungliga värdet för att säkerställa att det inte har ändrats sedan instansen hämtades från databasen. Om den har ändrats genereras en DbUpdateConcurrencyException och ändringar tillämpas inte. - Anropar ValueGeneratedOnAddOrUpdate, som konfigurerar egenskapen
ConcurrencyToken
så att ett värde genereras automatiskt när en entitet läggs till eller uppdateras. -
HasColumnType("rowversion")
anger kolumntypen i SQL Server-databasen till rowversion.
Följande kod visar en del av T-SQL som genereras av EF Core när Department
namn uppdateras:
SET NOCOUNT ON;
UPDATE [Departments] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [ConcurrencyToken] = @p2;
SELECT [ConcurrencyToken]
FROM [Departments]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
Den föregående markerade koden visar WHERE
-satsen som innehåller ConcurrencyToken
. Om databasen ConcurrencyToken
inte är lika med parametern ConcurrencyToken
@p2
uppdateras inga rader.
Följande markerade kod visar T-SQL som verifierar att exakt en rad har uppdaterats:
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 returnerar antalet rader som påverkades av det senaste uttalandet. Om inga rader uppdateras kastar EF Core ett DbUpdateConcurrencyException
.
Lägga till en migrering
Om du lägger till egenskapen ConcurrencyToken
ändras datamodellen, vilket kräver en migrering.
Skapa projektet.
Kör följande kommandon i PMC:
Add-Migration RowVersion
Update-Database
Föregående kommandon:
- Skapar
Migrations/{time stamp}_RowVersion.cs
migreringsfilen. - Uppdaterar
Migrations/SchoolContextModelSnapshot.cs
-filen. Uppdateringen lägger till följande kod i metodenBuildModel
:
b.Property<byte[]>("ConcurrencyToken")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
Sidor för byggställningsavdelningen
Följ anvisningarna på studentsidor med följande undantag:
- Skapa en pages/departments mapp.
- Använd
Department
för modellklassen. - Använd den befintliga kontextklassen i stället för att skapa en ny.
Lägga till en verktygsklass
I projektmappen skapar du klassen Utility
med följande kod:
namespace ContosoUniversity
{
public static class Utility
{
public static string GetLastChars(byte[] token)
{
return token[7].ToString();
}
}
}
Klassen Utility
innehåller metoden GetLastChars
som används för att visa de sista tecknen i samtidighetstoken. Följande kod visar koden som fungerar med både SQLite ad SQL Server:
#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
Det #if SQLiteVersion
förprocessordirektivet isolerar skillnaderna i SQLite- och SQL Server-versionerna och hjälper dig:
- Författaren har en kodbas för båda versionerna.
- SQLite-utvecklare distribuerar appen till Azure och använder SQL Azure.
Skapa projektet.
Uppdatera sidan Index
Verktyget scaffolding skapade en ConcurrencyToken
kolumn för indexsidan, men det fältet skulle inte visas i en produktionsapp. I den här handledningen visas den sista delen av ConcurrencyToken
för att hjälpa till att visa hur samtidighetshantering fungerar. Den sista delen är inte garanterad att vara unik av sig själv.
Uppdatera sidan Pages\Departments\Index.cshtml:
- Ersätt index med avdelningar.
- Ändra koden som innehåller
ConcurrencyToken
så att bara de sista tecknen visas. - Ersätt
FirstMidName
medFullName
.
Följande kod visar den uppdaterade sidan:
@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>
Uppdatera redigeringssidemodellen
Uppdatera Pages/Departments/Edit.cshtml.cs
med följande kod:
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.");
}
}
}
Samtidighetsuppdateringarna
OriginalValue uppdateras med värdet ConcurrencyToken
från entiteten när det hämtades i metoden OnGetAsync
.
EF Core genererar ett SQL UPDATE
-kommando med en WHERE
-sats som innehåller det ursprungliga ConcurrencyToken
-värdet. Om inga rader påverkas av kommandot UPDATE
genereras ett DbUpdateConcurrencyException
undantag. Inga rader påverkas av kommandot UPDATE
när inga rader har det ursprungliga ConcurrencyToken
värdet.
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;
I föregående markerade kod:
- Värdet i
Department.ConcurrencyToken
är värdet när entiteten hämtades iGet
begäran för sidanEdit
. Värdet anges till metodenOnPost
av ett dolt fält på sidan Razor som visar den entitet som ska redigeras. Värdet för det dolda fältet kopieras tillDepartment.ConcurrencyToken
av modellbindaren. -
OriginalValue
är vad EF Core använder iWHERE
-satsen. Innan den markerade kodraden körs:-
OriginalValue
har värdet som fanns i databasen närFirstOrDefaultAsync
anropades i den här metoden. - Det här värdet kan skilja sig från det som visades på sidan Redigera.
-
- Den markerade koden ser till att EF Core använder det ursprungliga
ConcurrencyToken
-värdet från den visadeDepartment
-entiteten i SQLUPDATE
-instruktionensWHERE
-sats.
Följande kod visar Department
modellen.
Department
är initierad i:
-
OnGetAsync
-metoden för EF-fråga. - Metod
OnPostAsync
med det dolda fältet på Razor-sidan med modellbindning:
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;
Föregående kod visar ConcurrencyToken
värdet för den Department
entiteten från HTTP POST
begäran har angetts till värdet ConcurrencyToken
från HTTP GET
begäran.
När ett samtidighetsfel inträffar hämtar följande markerade kod klientvärdena (värdena som publicerats till den här metoden) och databasvärdena.
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)}");
}
Följande kod lägger till ett anpassat felmeddelande för varje kolumn som har databasvärden som skiljer sig från vad som publicerades i 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.");
}
Följande markerade kod anger värdet ConcurrencyToken
till det nya värdet som hämtats från databasen. Nästa gång användaren klickar på Sparafångas endast samtidighetsfel som inträffar sedan den senaste visningen av sidan Redigera.
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)}");
}
Instruktionen ModelState.Remove
krävs eftersom ModelState
har det tidigare ConcurrencyToken
värdet. På Razor-sidan har värdet ModelState
för ett fält företräde framför modellegenskapsvärdena när båda finns.
Skillnader mellan SQL Server och SQLite-kod
Följande visar skillnaderna mellan SQL Server- och SQLite-versionerna:
+ 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;
Uppdatera Redigera-sidan Razor
Uppdatera Pages/Departments/Edit.cshtml
med följande kod:
@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");}
}
Föregående kod:
- Uppdaterar
page
-direktivet från@page
till@page "{id:int}"
. - Lägger till en dold radversion.
ConcurrencyToken
måste läggas till så att postback binder värdet. - Visar den sista byte av
ConcurrencyToken
för felsökning. - Ersätter
ViewData
med den tydligt typadeInstructorNameSL
.
Testa samtidighetskonflikter med Redigera sidan
Öppna två webbläsarinstanser av Redigera på den engelska avdelningen:
- Kör appen och välj Avdelningar.
- Högerklicka på hyperlänken Redigera för den engelska avdelningen och välj Öppna på ny flik.
- På den första fliken klickar du på hyperlänken Redigera för den engelska avdelningen.
De två webbläsarflikarna visar samma information.
Ändra namnet på den första webbläsarfliken och klicka på Spara.
Webbläsaren visar sidan Index med det ändrade värdet och uppdaterad ConcurrencyToken
indikator. Observera den uppdaterade indikatorn för ConcurrencyToken
. Den visas på den andra återställningen på den andra fliken.
Ändra ett annat fält på den andra webbläsarfliken.
Klicka på Spara. Du ser felmeddelanden för alla fält som inte matchar databasvärdena:
Det här webbläsarfönstret hade inte för avsikt att ändra fältet Namn. Kopiera och klistra in det aktuella värdet (språk) i fältet Namn. Ta bort. Verifiering på klientsidan tar bort felmeddelandet.
Klicka på Spara igen. Värdet som du angav på den andra webbläsarfliken sparas. Du ser de sparade värdena på sidan Index.
Uppdatera sidmodellen Ta bort
Uppdatera Pages/Departments/Delete.cshtml.cs
med följande kod:
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 });
}
}
}
}
Sidan Ta bort identifierar samtidighetskonflikter när entiteten har ändrats efter att den hämtades.
Department.ConcurrencyToken
är radversionen när entiteten hämtades. När EF Core skapar kommandot SQL DELETE
innehåller det en WHERE-sats med ConcurrencyToken
. Om kommandot SQL DELETE
resulterar i att noll rader påverkas:
- Kommandot
ConcurrencyToken
iSQL DELETE
matchar inteConcurrencyToken
i databasen. - Ett
DbUpdateConcurrencyException
-undantag kastas. -
OnGetAsync
anropas medconcurrencyError
.
Uppdatera sidan Ta bort Razor
Uppdatera Pages/Departments/Delete.cshtml
med följande kod:
@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>
Föregående kod gör följande ändringar:
- Uppdaterar
page
-direktivet från@page
till@page "{id:int}"
. - Lägger till ett felmeddelande.
- Ersätter FirstMidName med FullName i fältet Administrator.
- Ändrar
ConcurrencyToken
för att visa senaste byte. - Lägger till en dold radversion.
ConcurrencyToken
måste läggas till så att postback binder värdet.
Testa samtidighetskonflikter
Skapa en testavdelning.
Öppna två webbläsarfönster av Delete på testavdelningen.
- Kör appen och välj Avdelningar.
- Högerklicka på hyperlänken Ta bort för testavdelningen och välj Öppna på ny flik.
- Klicka på hyperlänken Redigera för testavdelningen.
De två webbläsarflikarna visar samma information.
Ändra budgeten på den första webbläsarfliken och klicka på Spara.
Webbläsaren visar sidan Index med det ändrade värdet och uppdaterad ConcurrencyToken
indikator. Observera den uppdaterade indikatorn för ConcurrencyToken
. Den visas på det andra postback-anropet i den andra fliken.
Ta bort testavdelningen från den andra fliken. Ett samtidighetsfel visas med de aktuella värdena från databasen. Om du klickar på Ta bort tas entiteten bort, såvida inte ConcurrencyToken
har uppdaterats.
Mönster för företagswebbappar
Vägledning om hur du skapar en tillförlitlig, säker, högpresterande, testbar och skalbar ASP.NET Core-app finns i Enterprise-webbappmönster. En komplett exempelwebbapp av produktionskvalitet som implementerar mönstren är tillgänglig.
Ytterligare resurser
Nästa steg
Det här är den sista handledningen i serien. Ytterligare avsnitt beskrivs i MVC-versionen av den här självstudieserien.
Den här självstudien visar hur du hanterar konflikter när flera användare uppdaterar en entitet samtidigt (samtidigt).
Samtidighetskonflikter
En samtidighetskonflikt uppstår när:
- En användare navigerar till redigeringssidan för en entitet.
- En annan användare uppdaterar samma entitet innan den första användarens ändring skrivs till databasen.
Om samtidighetsidentifiering inte är aktiverat skriver den som uppdaterar databasen senast över den andra användarens ändringar. Om den här risken är acceptabel kan kostnaden för programmering för samtidighet uppväga fördelen.
Pessimistisk konkurrens (låsning)
Ett sätt att förhindra samtidighetskonflikter är att använda databaslås. Detta kallas pessimistisk samtidighet. Innan appen läser en databasrad som den tänker uppdatera begär den ett lås. När en rad har låsts för uppdateringsåtkomst får inga andra användare låsa raden förrän det första låset har släppts.
Att hantera lås har nackdelar. Det kan vara komplext att programmera och kan orsaka prestandaproblem när antalet användare ökar. Entity Framework Core har inget inbyggt stöd för det, och den här självstudien visar inte hur du implementerar det.
Optimistisk samtidighet
Optimistisk samtidighet gör att samtidighetskonflikter kan inträffa och reagerar sedan på rätt sätt när de gör det. Jane besöker till exempel sidan Avdelningsredigering och ändrar budgeten för den engelska avdelningen från 350 000,00 USD till 0,00 USD.
Innan Jane klickar på Sparabesöker John samma sida och ändrar fältet Startdatum från 2007-09-01 till 2013-09-01.
Jane klickar Spara först och ser att hennes ändring börjar gälla, eftersom webbläsaren visar sidan Index med noll som budgetbelopp.
John klickar på Spara på en redigeringssida som fortfarande visar en budget på 350 000,00 USD. Vad som händer härnäst bestäms av hur du hanterar samtidighetskonflikter:
Du kan hålla reda på vilken egenskap en användare har ändrat och uppdatera endast motsvarande kolumner i databasen.
I scenariot skulle inga data gå förlorade. De två användarna uppdaterade olika egenskaper. Nästa gång någon bläddrar på den engelska avdelningen ser de både Janes och Johns ändringar. Den här uppdateringsmetoden kan minska antalet konflikter som kan leda till dataförlust. Den här metoden har vissa nackdelar:
- Det går inte att undvika dataförlust om konkurrerande ändringar görs i samma attribut.
- Är vanligtvis inte praktiskt i en webbapp. Det kräver att en betydande status bibehålls för att kunna hålla reda på alla hämtade värden och nya värden. Att upprätthålla stora mängder tillstånd kan påverka appens prestanda.
- Kan öka appkomplexiteten jämfört med samtidighetsidentifiering på en entitet.
Du kan låta Johns ändring skriva över Janes förändring.
Nästa gång någon besöker den engelska avdelningen kommer de att se datumet 2013-09-01 och det hämtade värdet på 350 000,00 USD. Den här metoden kallas för ett klient vinner eller sist in vinner scenario. (Alla värden från klienten har företräde framför vad som finns i datalagret.) Om du inte kodar för samtidighetshantering sker klientvinster automatiskt.
Du kan förhindra att Johns ändring uppdateras i databasen. Normalt skulle appen:
- Visa ett felmeddelande.
- Visa datans aktuella tillstånd.
- Tillåt att användaren återanvänder ändringarna.
Detta kallas ett Store Wins-scenario. (Datalagringsvärdena har företräde framför de värden som skickas av klienten.) Du implementerar scenariot Store Wins i den här självstudien. Den här metoden säkerställer att inga ändringar skrivs över utan att en användare aviseras.
Konfliktidentifiering i EF Core
EF Core genererar DbConcurrencyException
undantag när konflikter identifieras. Datamodellen måste konfigureras för att aktivera konfliktidentifiering. Alternativ för att aktivera konfliktidentifiering är följande:
Konfigurera EF Core så att de ursprungliga värdena för kolumner som har konfigurerats som samtidighetstoken inkluderas i WHERE-klausulen för kommandona Uppdatera och Ta bort.
När
SaveChanges
anropas letar Where-satsen efter de ursprungliga värdena för alla egenskaper som kommenterats med attributet ConcurrencyCheckAttribute. Uppdateringsinstrukeringen hittar ingen rad som ska uppdateras om någon av egenskaperna för samtidighetstoken har ändrats sedan raden lästes först. EF Core tolkar detta som en samtidighetskonflikt. För databastabeller som har många kolumner kan den här metoden resultera i mycket stora Where-satser och kan kräva stora mängder tillstånd. Därför rekommenderas inte den här metoden, och det är inte den metod som används i den här självstudien.I databastabellen inkluderar du en spårningskolumn som kan användas för att avgöra när en rad har ändrats.
I en SQL Server-databas är datatypen för spårningskolumnen
rowversion
. Värdetrowversion
är ett sekventiellt tal som ökas varje gång raden uppdateras. I kommandot Uppdatera eller Ta bort innehåller Where-satsen det ursprungliga värdet för spårningskolumnen (det ursprungliga radversionsnumret). Om raden som uppdateras har ändrats av en annan användare skiljer sig värdet i kolumnenrowversion
än det ursprungliga värdet. I så fall kan instruktionen Uppdatera eller Ta bort inte hitta raden som ska uppdateras på grund av Where-satsen. EF Core genererar ett samtidighetsfel när inga rader påverkas av kommandot Uppdatera eller Ta bort.
Lägga till en spårningsegenskap
I Models/Department.cs
lägger du till en spårningsegenskap med namnet 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; }
}
}
Attributet TimestampAttribute är det som identifierar kolumnen som en samtidighetsspårningskolumn. Api:et fluent är ett alternativt sätt att ange spårningsegenskapen:
modelBuilder.Entity<Department>()
.Property<byte[]>("RowVersion")
.IsRowVersion();
För en SQL Server-databas: [Timestamp]
-attributet på en entitetsegenskap definieras som byte-array.
- Gör att kolumnen inkluderas i satserna DELETE och UPDATE WHERE.
- Anger kolumntypen i databasen till rowversion.
Databasen genererar ett sekventiellt radversionsnummer som ökas varje gång raden uppdateras. I ett Update
- eller Delete
-kommando innehåller Where
-satsen det hämtade radversionsvärdet. Om raden som uppdateras har ändrats sedan den hämtades:
- Det aktuella radversionsvärdet matchar inte det hämtade värdet.
- Kommandona
Update
ellerDelete
hittar ingen rad eftersomWhere
-satsen söker efter värdet för den hämtade radversionen. - En
DbUpdateConcurrencyException
kastas.
Följande kod visar en del av T-SQL som genereras av EF Core när avdelningsnamnet uppdateras:
SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
Den föregående markerade koden visar WHERE
-satsen som innehåller RowVersion
. Om databasen RowVersion
inte är lika med parametern RowVersion
(@p2
) uppdateras inga rader.
Följande markerade kod visar T-SQL som verifierar att exakt en rad har uppdaterats:
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 returnerar antalet rader som påverkades av det senaste uttalandet. Om inga rader uppdateras genererar EF Core en DbUpdateConcurrencyException
.
Uppdatera databasen
Om du lägger till egenskapen RowVersion
ändras datamodellen, vilket kräver en migrering.
Skapa projektet.
Kör följande kommando i PMC:
Add-Migration RowVersion
Det här kommandot:
Skapar
Migrations/{time stamp}_RowVersion.cs
migreringsfilen.Uppdaterar
Migrations/SchoolContextModelSnapshot.cs
-filen. Uppdateringen lägger till följande markerade kod i metodenBuildModel
: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"); });
Kör följande kommando i PMC:
Update-Database
Byggnadsställningsavdelningens sidor
Följ anvisningarna i Scaffold Student-sidor med följande undantag:
Skapa en pages/departments mapp.
Använd
Department
för modellklassen.- Använd den befintliga kontextklassen i stället för att skapa en ny.
Skapa projektet.
Uppdatera sidan Index
Verktyget scaffolding skapade en RowVersion
kolumn för indexsidan, men det fältet skulle inte visas i en produktionsapp. I den här handledningen visas den sista byten av RowVersion
för att hjälpa till att visa hur samtidighetshantering fungerar. De sista bytena är inte garanterade att vara unika av sig själva.
Uppdatera Pages\Departments\Index.cshtml sida:
- Ersätt index med avdelningar.
- Ändra koden som innehåller
RowVersion
för att bara visa den sista byteen i bytematrisen. - Ersätt FirstMidName med FullName.
Följande kod visar den uppdaterade sidan:
@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>
Uppdatera Redigeringssidemodellen
Uppdatera Pages/Departments/Edit.cshtml.cs
med följande kod:
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.");
}
}
}
OriginalValue uppdateras med värdet rowVersion
från entiteten när den hämtades i metoden OnGetAsync
.
EF Core genererar ett SQL UPDATE-kommando med en WHERE-sats som innehåller det ursprungliga RowVersion
-värdet. Om inga rader påverkas av kommandot UPDATE (inga rader har det ursprungliga RowVersion
värdet) genereras ett DbUpdateConcurrencyException
undantag.
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;
I föregående markerade kod:
- Värdet i
Department.RowVersion
är det som fanns i entiteten när det ursprungligen hämtades i Get-begäran för Edit-sidans. Värdet anges till metodenOnPost
av ett dolt fält på sidan Razor som visar den entitet som ska redigeras. Värdet för det dolda fältet kopieras tillDepartment.RowVersion
av modellbindaren. -
OriginalValue
är vad EF Core kommer att använda i Where-satsen. Innan den markerade kodraden körs harOriginalValue
värdet som fanns i databasen närFirstOrDefaultAsync
anropades i den här metoden, vilket kan skilja sig från det som visades på sidan Redigera. - Den markerade koden ser till att EF Core använder det ursprungliga
RowVersion
-värdet från den visadeDepartment
-entiteten i SQL UPDATE-instruktionens Where-sats.
När ett samtidighetsfel inträffar hämtar följande markerade kod klientvärdena (värdena som publicerats till den här metoden) och databasvärdena.
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");
}
Följande kod lägger till ett anpassat felmeddelande för varje kolumn som har databasvärden som skiljer sig från vad som publicerades i 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.");
}
Följande markerade kod anger värdet RowVersion
till det nya värdet som hämtats från databasen. Nästa gång användaren klickar på Sparafångas endast samtidighetsfel som inträffar sedan den senaste visningen av sidan Redigera.
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");
}
Instruktionen ModelState.Remove
krävs eftersom ModelState
har det gamla RowVersion
värdet. På Razor-sidan har värdet ModelState
för ett fält företräde framför modellegenskapsvärdena när båda finns.
Uppdatera sidan Redigera
Uppdatera Pages/Departments/Edit.cshtml
med följande kod:
@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");}
}
Föregående kod:
- Uppdaterar
page
-direktivet från@page
till@page "{id:int}"
. - Lägger till en dold radversion.
RowVersion
måste läggas till så att postback binder värdet. - Visar den sista byte av
RowVersion
för felsökning. - Ersätter
ViewData
med den starkt typadeInstructorNameSL
.
Testa samtidighetskonflikter med redigeringssidan
Öppna två webbläsarinstanser av Edit i den engelska avdelningen:
- Kör appen och välj Avdelningar.
- Högerklicka på hyperlänken Redigera för den engelska avdelningen och välj Öppna på ny flik.
- På den första fliken klickar du på hyperlänken Redigera för den engelska avdelningen.
De två webbläsarflikarna visar samma information.
Ändra namnet på den första webbläsarfliken och klicka på Spara.
Webbläsaren visar sidan Index med det ändrade värdet och den uppdaterade rowVersion-indikatorn. Observera den uppdaterade rowVersion-indikatorn, den visas vid den andra postbacken på den andra fliken.
Ändra ett annat fält på den andra webbläsarfliken.
Klicka på Spara. Du ser felmeddelanden för alla fält som inte matchar databasvärdena:
Det här webbläsarfönstret hade inte för avsikt att ändra fältet Namn. Kopiera och klistra in det aktuella värdet (språk) i fältet Namn. Ta bort. Verifiering på klientsidan tar bort felmeddelandet.
Klicka på Spara igen. Värdet som du angav på den andra webbläsarfliken sparas. Du ser de sparade värdena på sidan Index.
Uppdatera sidmodellen Ta bort
Uppdatera Pages/Departments/Delete.cshtml.cs
med följande kod:
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 });
}
}
}
}
Sidan Ta bort identifierar samtidighetskonflikter när entiteten har ändrats efter att den hämtades.
Department.RowVersion
är radversionen när entiteten hämtades. När EF Core skapar SQL DELETE-kommandot innehåller det en WHERE-sats med RowVersion
. Om SQL DELETE-kommandot resulterar i att noll rader påverkas:
-
RowVersion
i SQL DELETE-kommandot matchar inteRowVersion
i databasen. - Ett DbUpdateConcurrencyException-undantag genereras.
-
OnGetAsync
anropas medconcurrencyError
.
Uppdatera sidan Ta bort
Uppdatera Pages/Departments/Delete.cshtml
med följande kod:
@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>
Föregående kod gör följande ändringar:
- Uppdaterar
page
-direktivet från@page
till@page "{id:int}"
. - Lägger till ett felmeddelande.
- Ersätter FirstMidName med FullName i fältet Administrator.
- Ändrar
RowVersion
för att visa senaste byte. - Lägger till en dold radversion.
RowVersion
måste läggas till så att postback binder värdet.
Testa samtidighetskonflikter
Skapa en testavdelning.
Öppna två webbläsarfönster av Delete på testavdelningen.
- Kör appen och välj Avdelningar.
- Högerklicka på hyperlänken Ta bort för testavdelningen och välj Öppna på ny flik.
- Klicka på hyperlänken Redigera för testavdelningen.
De två webbläsarflikarna visar samma information.
Ändra budgeten på den första webbläsarfliken och klicka på Spara.
Webbläsaren visar sidan Index med det ändrade värdet och den uppdaterade rowVersion-indikatorn. Observera den uppdaterade rowVersion-indikatorn. Den visas på den andra postbacken på den andra fliken.
Ta bort testavdelningen från den andra fliken. Ett samtidighetsfel visas med de aktuella värdena från databasen. Om du klickar på Ta bort tas entiteten bort, såvida inte RowVersion
har uppdaterats.
Vägledning om hur du skapar en tillförlitlig, säker, högpresterande, testbar och skalbar ASP.NET Core-app finns i Enterprise-webbappmönster. En komplett exempelwebbapp av produktionskvalitet som implementerar mönstren är tillgänglig.
Ytterligare resurser
Nästa steg
Det här är den sista guiden i serien. Ytterligare avsnitt beskrivs i MVC-versionen av den här självstudieserien.
Den här självstudien visar hur du hanterar konflikter när flera användare uppdaterar en entitet samtidigt (samtidigt). Om du stöter på problem som du inte kan lösa ladda ned eller visa den färdiga appen.Ladda ned instruktioner.
Samtidighetskonflikter
En samtidighetskonflikt uppstår när:
- En användare navigerar till redigeringssidan för en entitet.
- En annan användare uppdaterar samma entitet innan den första användarens ändring skrivs till databasen.
Om samtidighetsidentifiering inte är aktiverat när samtidiga uppdateringar inträffar:
- Den senaste uppdateringen vinner. De senaste uppdateringsvärdena sparas alltså i databasen.
- Den första av de aktuella uppdateringarna saknas.
Optimistisk samtidighet
Optimistisk samtidighet gör att samtidighetskonflikter kan inträffa och reagerar sedan på rätt sätt när de gör det. Jane besöker till exempel sidan Avdelningsredigering och ändrar budgeten för den engelska avdelningen från 350 000,00 USD till 0,00 USD.
Innan Jane klickar på Sparabesöker John samma sida och ändrar fältet Startdatum från 2007-09-01 till 2013-09-01.
Först klickar Jane på Spara och ser sin ändring när webbläsaren visar indexsidan.
John klickar på Spara på en redigeringssida som fortfarande visar en budget på 350 000,00 USD. Vad som händer härnäst bestäms av hur du hanterar samtidighetskonflikter.
Optimistisk samtidighet innehåller följande alternativ:
Du kan hålla reda på vilken egenskap en användare har ändrat och uppdatera endast motsvarande kolumner i databasen.
I scenariot skulle inga data gå förlorade. De två användarna uppdaterade olika egenskaper. Nästa gång någon bläddrar på den engelska avdelningen ser de både Janes och Johns ändringar. Den här uppdateringsmetoden kan minska antalet konflikter som kan leda till dataförlust. Den här metoden:
- Det går inte att undvika dataförlust om konkurrerande ändringar görs i samma fält.
- Är vanligtvis inte praktiskt i en webbapp. Det kräver att betydande tillstånd bibehålls för att hålla reda på alla hämtade värden och nya värden. Att upprätthålla stora mängder tillstånd kan påverka appens prestanda.
- Kan öka appkomplexiteten jämfört med samtidighetsidentifiering på en entitet.
Du kan låta Johns ändring skriva över Janes förändring.
Nästa gång någon bläddrar i den engelska institutionen, kommer de att se datumet 1/9/2013 och det hämtade värdet på 350 000,00. Den här metoden kallas för ett klient vinner eller sist in vinner scenario. (Alla värden från klienten har företräde framför vad som finns i datalagret.) Om du inte kodar för samtidighetshantering sker klientvinster automatiskt.
Du kan förhindra att Johns ändring uppdateras i databasen. Normalt skulle appen:
- Visa ett felmeddelande.
- Visa datans aktuella tillstånd.
- Tillåt att användaren återanvänder ändringarna.
Detta kallas för ett Store Wins scenario. (Datalagringsvärdena har företräde framför de värden som skickas av klienten.) Du implementerar scenariot Store Wins i den här självstudien. Den här metoden säkerställer att inga ändringar skrivs över utan att en användare aviseras.
Hantera samtidighet
När en egenskap har konfigurerats som en samtidighetstoken:
- EF Core verifierar att egenskapen inte har ändrats efter att den hämtades. Kontrollen sker när SaveChanges eller SaveChangesAsync anropas.
- Om egenskapen har ändrats efter att den hämtades utlöses en DbUpdateConcurrencyException.
Databas- och datamodellen måste konfigureras för att kunna generera DbUpdateConcurrencyException
.
Identifiera samtidighetskonflikter på ett attribut
Samtidighetskonflikter kan identifieras på egenskapsnivå med attributet ConcurrencyCheck. Attributet kan tillämpas på flera egenskaper i modellen. Mer information finns i Data Annotations - ConcurrencyCheck.
Attributet [ConcurrencyCheck]
används inte i den här handledningen.
Identifiera samtidighetskonflikter på en rad
För att identifiera samtidighetskonflikter läggs en radversion spårningskolumn till modellen.
rowversion
:
- Är SQL Server specifikt. Andra databaser kanske inte har en liknande funktion.
- Används för att fastställa att en entitet inte har ändrats sedan den hämtades från databasdatabasen.
Databasen genererar ett sekventiellt rowversion
tal som ökas varje gång raden uppdateras. I ett Update
- eller Delete
-kommando innehåller Where
-satsen det hämtade värdet för rowversion
. Om raden som uppdateras har ändrats:
-
rowversion
matchar inte det hämtade värdet. - Kommandona
Update
ellerDelete
hittar ingen rad eftersomWhere
-satsen innehåller den hämtaderowversion
. - Ett
DbUpdateConcurrencyException
kastas.
I EF Coregenereras ett samtidighetsfel när inga rader har uppdaterats av ett Update
- eller Delete
-kommando.
Lägga till en spårningsegenskap i entiteten Department
I Models/Department.cs
lägger du till en spårningsegenskap med namnet 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; }
}
}
Attributet Timestamp anger att den här kolumnen ingår i Where
-satsen i kommandona Update
och Delete
. Attributet kallas Timestamp
eftersom tidigare versioner av SQL Server använde en SQL-timestamp
datatyp innan SQL-rowversion
typ ersatte det.
Api:et fluent kan också ange spårningsegenskapen:
modelBuilder.Entity<Department>()
.Property<byte[]>("RowVersion")
.IsRowVersion();
Följande kod visar en del av T-SQL som genereras av EF Core när avdelningsnamnet uppdateras:
SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
Den föregående markerade koden visar WHERE
-satsen som innehåller RowVersion
. Om db-RowVersion
inte är lika med parametern RowVersion
(@p2
) uppdateras inga rader.
Följande markerade kod visar T-SQL som verifierar att exakt en rad har uppdaterats:
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 returnerar antalet rader som påverkades av det senaste uttrycket. Om inga rader uppdateras, genererar EF Core en DbUpdateConcurrencyException
.
Du kan se T-SQL-EF Core som genereras i utdatafönstret i Visual Studio.
Uppdatera databasen
Om du lägger till egenskapen RowVersion
ändras DB-modellen, vilket kräver en migrering.
Skapa projektet. Ange följande i ett kommandofönster:
dotnet ef migrations add RowVersion
dotnet ef database update
Föregående kommandon:
Lägger till migreringsfilen
Migrations/{time stamp}_RowVersion.cs
.Uppdaterar
Migrations/SchoolContextModelSnapshot.cs
-filen. Uppdateringen lägger till följande markerade kod i metodenBuildModel
:Kör migreringar för att uppdatera databasen.
Skapa grundstrukturen för avdelningsmodellen
Följ anvisningarna i Stödstrukturera studentmodellen och använd Department
för model class.
Föregående kommando bygger upp strukturen för Department
-modellen. Öppna projektet i Visual Studio.
Skapa projektet.
Uppdatera sidan Avdelningsindex
Genereringsmotorn skapade en RowVersion
-kolumn för Indexsidan, men det fältet ska inte visas. I den här handledningen visas den sista byten av RowVersion
för att hjälpa till att förstå samtidighet. Det sista bytet är inte garanterat unikt. En riktig app skulle inte visa RowVersion
eller sista bytet av RowVersion
.
Uppdatera sidan Index:
- Ersätt index med avdelningar.
- Ersätt markeringen som innehåller
RowVersion
med den sista byten avRowVersion
. - Ersätt FirstMidName med FullName.
Följande markering visar den uppdaterade sidan:
@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>
Uppdatera Redigeringssidemodellen
Uppdatera Pages/Departments/Edit.cshtml.cs
med följande kod:
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.");
}
}
}
För att identifiera ett samtidighetsproblem uppdateras OriginalValue med värdet rowVersion
från den entitet som hämtades.
EF Core genererar ett SQL UPDATE-kommando med en WHERE-sats som innehåller det ursprungliga RowVersion
-värdet. Om inga rader påverkas av kommandot UPDATE (inga rader har det ursprungliga RowVersion
värdet) genereras ett DbUpdateConcurrencyException
undantag.
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;
I föregående kod är Department.RowVersion
värdet när entiteten hämtades.
OriginalValue
är värdet i databasen när FirstOrDefaultAsync
anropades i den här metoden.
Följande kod hämtar klientvärdena (värdena som har publicerats till den här metoden) och DB-värdena:
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");
}
Följande kod lägger till ett anpassat felmeddelande för varje kolumn som har db-värden som skiljer sig från det som publicerades i 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.");
}
Följande markerade kod anger värdet RowVersion
till det nya värdet som hämtats från databasen. Nästa gång användaren klickar på Sparafångas endast samtidighetsfel som inträffar sedan den senaste visningen av sidan Redigera.
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");
}
Instruktionen ModelState.Remove
krävs eftersom ModelState
har det gamla RowVersion
värdet. På Razor-sidan har värdet ModelState
för ett fält företräde framför modellegenskapsvärdena när båda finns.
Uppdatera sidan Redigera
Uppdatera Pages/Departments/Edit.cshtml
med följande markering:
@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");}
}
Föregående markering:
- Uppdaterar
page
-direktivet från@page
till@page "{id:int}"
. - Lägger till en dold radversion.
RowVersion
måste läggas till så att post back binder värdet. - Visar den sista byte av
RowVersion
för felsökning. - Ersätter
ViewData
med den starkt typadeInstructorNameSL
.
Testa samtidighetskonflikter med Redigeringssidan
Öppna två webbläsarinstanser av Redigera på den engelska avdelningen:
- Kör appen och välj Avdelningar.
- Högerklicka på hyperlänken Redigera för den engelska avdelningen och välj Öppna på ny flik.
- På den första fliken klickar du på hyperlänken Redigera för den engelska avdelningen.
De två webbläsarflikarna visar samma information.
Ändra namnet på den första webbläsarfliken och klicka på Spara.
Webbläsaren visar sidan Index med det ändrade värdet och den uppdaterade rowVersion-indikatorn. Observera den uppdaterade rowVersion-indikatorn. Den visas vid den andra återladdningen i den andra fliken.
Ändra ett annat fält på den andra webbläsarfliken.
Klicka på Spara. Du ser felmeddelanden för alla fält som inte matchar DB-värdena:
Det här webbläsarfönstret hade inte för avsikt att ändra fältet Namn. Kopiera och klistra in det aktuella värdet (språk) i fältet Namn. Ta bort. Verifiering på klientsidan tar bort felmeddelandet.
Klicka på Spara igen. Värdet som du angav på den andra webbläsarfliken sparas. Du ser de sparade värdena på sidan Index.
Uppdatera sidan Ta bort
Uppdatera borttagningssidans modell med följande kod:
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 });
}
}
}
}
Sidan Ta bort identifierar samtidighetskonflikter när entiteten har ändrats efter att den hämtades.
Department.RowVersion
är radversionen när entiteten hämtades. När EF Core skapar SQL DELETE-kommandot innehåller det en WHERE-sats med RowVersion
. Om SQL DELETE-kommandot resulterar i att noll rader påverkas:
-
RowVersion
i SQL DELETE-kommandot matchar inteRowVersion
i databasen. - Ett DbUpdateConcurrencyException-undantag genereras.
-
OnGetAsync
anropas medconcurrencyError
.
Uppdatera sidan Ta bort
Uppdatera Pages/Departments/Delete.cshtml
med följande kod:
@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>
Föregående kod gör följande ändringar:
- Uppdaterar
page
-direktivet från@page
till@page "{id:int}"
. - Lägger till ett felmeddelande.
- Ersätter FirstMidName med FullName i fältet Administrator.
- Ändrar
RowVersion
för att visa senaste byte. - Lägger till en dold radversion.
RowVersion
måste läggas till så att post back binder värdet.
Testa samtidighetskonflikter med sidan Ta bort
Skapa en testavdelning.
Öppna två webbläsarinstanser av Delete på testavdelningen.
- Kör appen och välj Avdelningar.
- Högerklicka på hyperlänken Ta bort för testavdelningen och välj Öppna på ny flik.
- Klicka på hyperlänken Redigera för testavdelningen.
De två webbläsarflikarna visar samma information.
Ändra budgeten på den första webbläsarfliken och klicka på Spara.
Webbläsaren visar sidan Index med det ändrade värdet och den uppdaterade rowVersion-indikatorn. Observera den uppdaterade rowVersion-indikatorn. Den visas vid den andra återuppladdningen i den andra fliken.
Ta bort testavdelningen från den andra fliken. Ett samtidighetsfel visas med de aktuella värdena från databasen. Om du klickar på Ta bort tas entiteten bort, såvida inte RowVersion
har uppdaterats.
Se Arv om hur du ärver en datamodell.
Mönster för företagswebbappar
Vägledning om hur du skapar en tillförlitlig, säker, högpresterande, testbar och skalbar ASP.NET Core-app finns i Enterprise-webbappmönster. En komplett exempelwebbapp av produktionskvalitet som implementerar mönstren är tillgänglig.
Ytterligare resurser
ASP.NET Core