Parte 8, Razor Pagine con EF Core in ASP.NET Core - Concorrenza
L'app Web Contoso University illustra come creare Razor app Web Pages usando EF Core e Visual Studio. Per informazioni sulla serie di esercitazioni, vedere la prima esercitazione.
Se si verificano problemi che non è possibile risolvere, scaricare l'app completata e confrontare tale codice con quello creato seguendo questa esercitazione.
Questa esercitazione illustra come gestire i conflitti quando più utenti aggiornano un'entità contemporaneamente.
Conflitti di concorrenza
Un conflitto di concorrenza si verifica quando:
- Un utente passa alla pagina di modifica di un'entità.
- Un altro utente aggiorna la stessa entità prima che la modifica del primo utente venga scritta nel database.
Se il rilevamento della concorrenza non è abilitato, chiunque aggiorni il database per ultimo sovrascrive le modifiche apportate dall'altro utente. Se questo rischio è accettabile, il costo della programmazione per la concorrenza potrebbe essere superiore ai vantaggi.
Concorrenza pessimistica
Un modo per impedire i conflitti di concorrenza consiste nell'usare blocchi di database. Questo approccio è denominato concorrenza pessimistica. Prima che l'app legga una riga del database che intende aggiornare, richiede un blocco. Una volta bloccata una riga per l'accesso per gli aggiornamenti, nessun altro utente potrà bloccare la riga fino a quando non viene rilasciato il primo blocco.
La gestione dei blocchi presenta svantaggi. La programmazione può essere complessa e può causare problemi di prestazioni con l'aumentare del numero di utenti. Entity Framework Core non offre supporto predefinito per la concorrenza pessimistica.
Concorrenza ottimistica
La concorrenza ottimistica consente che si verifichino conflitti di concorrenza, quindi attiva le misure necessarie. Ad esempio Jane visita la pagina Department Edit (Modifica - Reparto) e cambia il budget per il reparto English (Inglese) da $ 350.000,00 a $ 0,00.
Prima che Jane faccia clic su Salva John visita la stessa pagina e cambia il valore del campo Start Date (Data inizio) da 9/1/2007 a 9/1/2013.
Jane fa prima di tutto clic su Save e visualizza l'effetto della modifica, dato che il browser visualizza la pagina Index con zero come importo del budget.
John fa clic su Salva in una pagina Edit (Modifica) che visualizza ancora un budget pari a $ 350.000,00. Le operazioni successive dipendono da come si decide di gestire i conflitti di concorrenza:
Tenere traccia della proprietà modificata da un utente e aggiornare solo le colonne corrispondenti nel database.
Con questo scenario non si registra la perdita di dati. I due utenti hanno aggiornato proprietà diverse. Quando un utente torna a visualizzare il reparto English (Inglese), visualizza sia le modifiche di Jane sia quelle di John. Questo metodo di aggiornamento riduce il numero di conflitti che possono comportare la perdita di dati. Questo approccio presenta alcuni svantaggi:
- Non evita la perdita di dati se vengono apportate modifiche concorrenti alla stessa proprietà.
- Risulta in genere poco pratico in un'app Web. Richiede la manutenzione di un volume importante di codice statico per tenere traccia di tutti i valori recuperati e i nuovi valori. La gestione di grandi quantità di codice statico può ridurre le prestazioni dell'applicazione.
- Può rendere più complesse le app rispetto al rilevamento della concorrenza in un'entità.
Riscrivere il cambiamento di Jane.
Quando un utente torna a visualizzare il reparto English (Inglese), visualizza 9/1/2013 e il valore $ 350.000,00 recuperato. Questo scenario è detto Priorità client o Last in Wins (Priorità ultimo accesso). Tutti i valori del client hanno la precedenza su ciò che si trova nell'archivio dati. Il codice con scaffolding non gestisce la concorrenza, il client wins viene eseguito automaticamente.
Impedire che la modifica di John venga aggiornata nel database. In genere, l'app:
- Visualizza un messaggio di errore.
- Visualizza lo stato corrente dei dati.
- Consente all'utente di riapplicare le modifiche.
Questo scenario è detto Store Wins (Priorità archivio). I valori dell'archivio dati hanno la precedenza sui valori inviati dal client. Lo scenario Store Wins viene usato in questa esercitazione. Questo metodo garantisce che nessuna modifica venga sovrascritta senza che un utente riceva un avviso.
Rilevamento dei conflitti in EF Core
Le proprietà configurate come token di concorrenza vengono usate per implementare il controllo della concorrenza ottimistica. Quando un'operazione di aggiornamento o eliminazione viene attivata da SaveChanges o SaveChangesAsync, il valore del token di concorrenza nel database viene confrontato con il valore originale letto da EF Core:
- Se i valori corrispondono, l'operazione può essere completata.
- Se i valori non corrispondono, EF Core si presuppone che un altro utente abbia eseguito un'operazione in conflitto, interrompe la transazione corrente e genera un'eccezione DbUpdateConcurrencyException.
Un altro utente o processo che esegue un'operazione in conflitto con l'operazione corrente è noto come conflitto di concorrenza.
Nei database EF Core relazionali controlla il valore del token di concorrenza nella WHERE
clausola delle UPDATE
istruzioni e DELETE
per rilevare un conflitto di concorrenza.
Il modello di dati deve essere configurato per abilitare il rilevamento dei conflitti includendo una colonna di rilevamento che può essere utilizzata per determinare quando una riga è stata modificata. Ef offre due approcci per i token di concorrenza:
[ConcurrencyCheck]
Applicazione o IsConcurrencyToken a una proprietà nel modello. Questo approccio non è consigliato. Per altre informazioni, vedere Token di concorrenza in EF Core.TimestampAttribute Applicazione o IsRowVersion a un token di concorrenza nel modello. Questo è l'approccio usato in questa esercitazione.
L'approccio di SQL Server e i dettagli dell'implementazione di SQLite sono leggermente diversi. Un file di differenza viene mostrato più avanti nell'esercitazione che elenca le differenze. La scheda Visual Studio mostra l'approccio di SQL Server. La scheda Visual Studio Code mostra l'approccio per i database non SQL Server, ad esempio SQLite.
- Nel modello includere una colonna di rilevamento utilizzata per determinare quando una riga è stata modificata.
- Applicare l'oggetto TimestampAttribute alla proprietà di concorrenza.
Aggiornare il Models/Department.cs
file con il codice evidenziato seguente:
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 è ciò che identifica la colonna come colonna di rilevamento della concorrenza. L'API Fluent è un modo alternativo per specificare la proprietà di rilevamento:
modelBuilder.Entity<Department>()
.Property<byte[]>("ConcurrencyToken")
.IsRowVersion();
L'attributo [Timestamp]
in una proprietà di entità genera il codice seguente nel ModelBuilder metodo :
b.Property<byte[]>("ConcurrencyToken")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
Il codice precedente:
- Imposta il tipo di
ConcurrencyToken
proprietà sulla matrice di byte.byte[]
è il tipo obbligatorio per SQL Server. - Chiama IsConcurrencyToken.
IsConcurrencyToken
configura la proprietà come token di concorrenza. Negli aggiornamenti, il valore del token di concorrenza nel database viene confrontato con il valore originale per assicurarsi che non sia stato modificato dopo che l'istanza è stata recuperata dal database. Se è stata modificata, viene generata un'eccezione DbUpdateConcurrencyException e le modifiche non vengono applicate. - Chiama ValueGeneratedOnAddOrUpdate, che configura la
ConcurrencyToken
proprietà in modo che venga generato automaticamente un valore durante l'aggiunta o l'aggiornamento di un'entità. HasColumnType("rowversion")
imposta il tipo di colonna nel database di SQL Server su rowversion.
Il codice seguente mostra una parte del T-SQL generato da EF Core quando il Department
nome viene aggiornato:
SET NOCOUNT ON;
UPDATE [Departments] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [ConcurrencyToken] = @p2;
SELECT [ConcurrencyToken]
FROM [Departments]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
Il codice evidenziato precedente visualizza la clausola WHERE
contenente ConcurrencyToken
. Se il database ConcurrencyToken
non è uguale al ConcurrencyToken
parametro @p2
, non vengono aggiornate righe.
Il codice evidenziato seguente visualizza la notazione T-SQL che verifica che è stata aggiornata esattamente una riga:
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 restituisce il numero di righe interessate dall'ultima istruzione. Se non vengono aggiornate righe, EF Core genera un'eccezione DbUpdateConcurrencyException
.
Aggiungere una migrazione
L'aggiunta della proprietà ConcurrencyToken
cambia il modello di dati e ciò richiede una migrazione.
Compilare il progetto.
Eseguire i comandi seguenti nella console di Gestione pacchetti:
Add-Migration RowVersion
Update-Database
I comandi precedenti:
- Crea il file di
Migrations/{time stamp}_RowVersion.cs
migrazione. - Aggiorna il
Migrations/SchoolContextModelSnapshot.cs
file. L'aggiornamento aggiunge il codice seguente alBuildModel
metodo :
b.Property<byte[]>("ConcurrencyToken")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
Scaffolding delle pagine Department
Seguire le istruzioni in Scaffolding delle pagine Student con le eccezioni seguenti:
- Creare una cartella Pages/Departments.
- Usare
Department
per la classe del modello. - Usare la classe di contesto esistente anziché crearne una nuova.
Aggiungere una classe di utilità
Nella cartella del progetto creare la Utility
classe con il codice seguente:
namespace ContosoUniversity
{
public static class Utility
{
public static string GetLastChars(byte[] token)
{
return token[7].ToString();
}
}
}
La Utility
classe fornisce il GetLastChars
metodo usato per visualizzare gli ultimi caratteri del token di concorrenza. Il codice seguente illustra il codice che funziona con SQL Server ad 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
La #if SQLiteVersion
direttiva del preprocessore isola le differenze nelle versioni di SQLite e SQL Server e consente di:
- L'autore mantiene una codebase per entrambe le versioni.
- Gli sviluppatori SQLite distribuiscono l'app in Azure e usano SQL Azure.
Compilare il progetto.
Aggiornare la pagina Index
Lo strumento di scaffolding crea una colonna ConcurrencyToken
per la pagina Index, ma questo campo non verrebbe visualizzato in un'app in produzione. In questa esercitazione viene visualizzata l'ultima parte di per illustrare il funzionamento della ConcurrencyToken
gestione della concorrenza. L'ultima parte non è garantita che sia univoca da sola.
Aggiornare la pagina Pages\Departments\Index.cshtml:
- Sostituire Index con Departments.
- Modificare il codice contenente
ConcurrencyToken
per visualizzare solo gli ultimi caratteri. - Sostituisci
FirstMidName
conFullName
.
Il codice seguente mostra la pagina aggiornata:
@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>
Aggiornare il modello di pagina Edit
Aggiornare Pages/Departments/Edit.cshtml.cs
con il codice seguente:
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.");
}
}
}
Aggiornamenti della concorrenza
OriginalValue viene aggiornato con il ConcurrencyToken
valore dell'entità quando è stato recuperato nel OnGetAsync
metodo . EF Core genera un SQL UPDATE
comando con una WHERE
clausola contenente il valore originale ConcurrencyToken
. Se nessuna riga è interessata dal UPDATE
comando, viene generata un'eccezione DbUpdateConcurrencyException
. Nessuna riga interessata dal UPDATE
comando quando nessuna riga ha il valore originale 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;
Nel codice evidenziato precedente:
- Il valore in
Department.ConcurrencyToken
è il valore quando l'entità è stata recuperata nellaGet
richiesta per laEdit
pagina. Il valore viene fornito alOnPost
metodo da un campo nascosto nella Razor pagina che visualizza l'entità da modificare. Il valore del campo nascosto viene copiato inDepartment.ConcurrencyToken
dallo strumento di associazione di modelli. OriginalValue
è ciò che EF Core viene usato nellaWHERE
clausola . Prima dell'esecuzione della riga di codice evidenziata:OriginalValue
ha il valore presente nel database quandoFirstOrDefaultAsync
è stato chiamato in questo metodo.- Questo valore potrebbe essere diverso da quello visualizzato nella pagina Modifica.
- Il codice evidenziato assicura che EF Core usi il valore originale
ConcurrencyToken
dell'entità visualizzataDepartment
nella clausola dell'istruzioneWHERE
SQLUPDATE
.
Il codice seguente illustra il Department
modello. Department
viene inizializzato in:
OnGetAsync
metodo dalla query ef.OnPostAsync
metodo in base al campo nascosto nella pagina usando l'associazione Razordi modelli:
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;
Il codice precedente mostra il ConcurrencyToken
valore dell'entità Department
dalla HTTP POST
richiesta viene impostato sul ConcurrencyToken
valore della HTTP GET
richiesta.
Quando si verifica un errore di concorrenza, il codice evidenziato seguente ottiene i valori client (i valori inseriti in questo metodo) e i valori del 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)}");
}
Il codice seguente aggiunge un messaggio di errore personalizzato per ogni colonna che ha valori del database diversi da quelli inseriti in 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.");
}
Il codice evidenziato seguente imposta il valore ConcurrencyToken
sul nuovo valore recuperato dal database. Quando l'utente fa di nuovo clic su Salva vengono rilevati solo gli errori di concorrenza che si verificano dopo l'ultima visualizzazione della pagina Edit (Modifica).
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)}");
}
L'istruzione ModelState.Remove
è obbligatoria perché ModelState
ha il valore precedente ConcurrencyToken
. Razor Nella pagina il ModelState
valore di un campo ha la precedenza sui valori delle proprietà del modello quando sono presenti entrambi.
Differenze tra codice SQL Server e SQLite
Di seguito sono illustrate le differenze tra le versioni di SQL Server e 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;
Aggiornare la pagina Modifica Razor
Aggiornare Pages/Departments/Edit.cshtml
con il codice seguente:
@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");}
}
Il codice precedente:
- Aggiorna la direttiva
page
da@page
a@page "{id:int}"
. - Aggiunge una versione di riga nascosta.
ConcurrencyToken
deve essere aggiunto in modo che il postback associa il valore. - Visualizza l'ultimo byte di
ConcurrencyToken
a scopo di debug. - Sostituisce
ViewData
con l'elementoInstructorNameSL
fortemente tipizzato.
Eseguire il test dei conflitti di concorrenza con la pagina Edit (Modifica)
Aprire due istanze di browser con la pagina Edit (Modifica) e il reparto English (Inglese):
- Eseguire l'app e selezionare Departments (Reparti).
- Fare clic con il pulsante destro del mouse sul collegamento ipertestuale Edit (Modifica) per il reparto English (Inglese) e selezionare Apri in una nuova scheda.
- Nella prima scheda fare clic sul collegamento ipertestuale Edit (Modifica) per il reparto English (Inglese).
Le due schede del browser visualizzano le stesse informazioni.
Modificare il nome nella prima scheda del browser e fare clic su Salva.
Il browser mostra la pagina Indice con il valore modificato e l'indicatore aggiornato ConcurrencyToken
. Si noti che l'indicatore aggiornato ConcurrencyToken
viene visualizzato nel secondo postback nell'altra scheda.
Modificare un altro campo nella seconda scheda del browser.
Fare clic su Salva. Vengono visualizzati messaggi di errore per tutti i campi che non corrispondono ai valori del database:
Questa finestra del browser non prevedeva la modifica del campo Name (Nome). Copiare e incollare il valore corrente Languages (Lingue) nel campo Name (Nome). Esci. La convalida lato client rimuove il messaggio di errore.
Fare di nuovo clic su Salva. Il valore immesso nella seconda scheda del browser viene salvato. I valori salvati vengono visualizzati nella pagina Index.
Aggiornare il modello di pagina Elimina
Aggiornare Pages/Departments/Delete.cshtml.cs
con il codice seguente:
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 });
}
}
}
}
La pagina Delete (Elimina) rileva i conflitti di concorrenza quando l'entità è stata modificata dopo il recupero. Department.ConcurrencyToken
è la versione di riga quando l'entità è stata recuperata. Quando EF Core crea il SQL DELETE
comando, include una clausola WHERE con ConcurrencyToken
. Se il SQL DELETE
comando restituisce zero righe interessate:
- L'oggetto
ConcurrencyToken
SQL DELETE
nel comando non corrispondeConcurrencyToken
al database. - Viene generata un'eccezione
DbUpdateConcurrencyException
. OnGetAsync
viene chiamata conconcurrencyError
.
Aggiornare la pagina Elimina Razor
Aggiornare Pages/Departments/Delete.cshtml
con il codice seguente:
@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>
Il codice precedente apporta le modifiche seguenti:
- Aggiorna la direttiva
page
da@page
a@page "{id:int}"
. - Aggiunge un messaggio di errore.
- Sostituisce FirstMidName con FullName nel campo Administrator (Amministratore).
- Modifica
ConcurrencyToken
per visualizzare l'ultimo byte. - Aggiunge una versione di riga nascosta.
ConcurrencyToken
deve essere aggiunto in modo che il postback associa il valore.
Testare i conflitti di concorrenza
Creare un reparto di test.
Aprire due istanze del browser con la pagina Delete (Elimina):
- Eseguire l'app e selezionare Departments (Reparti).
- Fare clic con il pulsante destro del mouse sul collegamento ipertestuale Delete (Elimina) per il reparto di test e selezionare Apri in una nuova scheda.
- Fare clic sul collegamento ipertestuale Edit (Modifica) per il reparto di test.
Le due schede del browser visualizzano le stesse informazioni.
Modificare il budget nella prima scheda del browser e fare clic su Salva.
Il browser mostra la pagina Indice con il valore modificato e l'indicatore aggiornato ConcurrencyToken
. Si noti che l'indicatore aggiornato ConcurrencyToken
viene visualizzato nel secondo postback nell'altra scheda.
Eliminare il reparto di test dalla seconda scheda. Viene visualizzato un errore di concorrenza con i valori correnti del database. Facendo clic su Elimina viene eliminata l'entità, a meno che non ConcurrencyToken
sia stato aggiornato.
Risorse aggiuntive
- Token di concorrenza in EF Core
- Gestire la concorrenza in EF Core
- Debug dell'origine ASP.NET Core 2.x
Passaggi successivi
Questa è l'ultima esercitazione nella serie. Ulteriori argomenti sono trattati nella versione MVC di questa serie di esercitazioni.
Questa esercitazione descrive la gestione dei conflitti quando più utenti aggiornano la stessa entità contemporaneamente.
Conflitti di concorrenza
Un conflitto di concorrenza si verifica quando:
- Un utente passa alla pagina di modifica di un'entità.
- Un altro utente aggiorna la stessa entità prima che la modifica del primo utente venga scritta nel database.
Se il rilevamento della concorrenza non è abilitato, chiunque aggiorni il database per ultimo sovrascrive le modifiche apportate dall'altro utente. Se questo rischio è accettabile, il costo della programmazione per la concorrenza potrebbe essere superiore ai vantaggi.
Concorrenza pessimistica (blocco)
Un modo per impedire i conflitti di concorrenza consiste nell'usare blocchi di database. Questo approccio è denominato concorrenza pessimistica. Prima che l'app legga una riga del database che intende aggiornare, richiede un blocco. Una volta bloccata una riga per l'accesso per gli aggiornamenti, nessun altro utente potrà bloccare la riga fino a quando non viene rilasciato il primo blocco.
La gestione dei blocchi presenta svantaggi. La programmazione può essere complessa e può causare problemi di prestazioni con l'aumentare del numero di utenti. Entity Framework Core non offre supporto predefinito per questa modalità e la presente esercitazione non indica come implementarla.
Concorrenza ottimistica
La concorrenza ottimistica consente che si verifichino conflitti di concorrenza, quindi attiva le misure necessarie. Ad esempio Jane visita la pagina Department Edit (Modifica - Reparto) e cambia il budget per il reparto English (Inglese) da $ 350.000,00 a $ 0,00.
Prima che Jane faccia clic su Salva John visita la stessa pagina e cambia il valore del campo Start Date (Data inizio) da 9/1/2007 a 9/1/2013.
Jane fa prima di tutto clic su Save e visualizza l'effetto della modifica, dato che il browser visualizza la pagina Index con zero come importo del budget.
John fa clic su Salva in una pagina Edit (Modifica) che visualizza ancora un budget pari a $ 350.000,00. Le operazioni successive dipendono da come si decide di gestire i conflitti di concorrenza:
È possibile tenere traccia della proprietà che un utente ha modificato e aggiornare solo le colonne corrispondenti nel database.
Con questo scenario non si registra la perdita di dati. I due utenti hanno aggiornato proprietà diverse. Quando un utente torna a visualizzare il reparto English (Inglese), visualizza sia le modifiche di Jane sia quelle di John. Questo metodo di aggiornamento riduce il numero di conflitti che possono comportare la perdita di dati. Questo approccio presenta alcuni svantaggi:
- Non evita la perdita di dati se vengono apportate modifiche concorrenti alla stessa proprietà.
- Risulta in genere poco pratico in un'app Web. Richiede la manutenzione di un volume importante di codice statico per tenere traccia di tutti i valori recuperati e i nuovi valori. La gestione di grandi quantità di codice statico può ridurre le prestazioni dell'applicazione.
- Può rendere più complesse le app rispetto al rilevamento della concorrenza in un'entità.
È possibile consentire che la modifica di John sovrascriva la modifica di Jane.
Quando un utente torna a visualizzare il reparto English (Inglese), visualizza 9/1/2013 e il valore $ 350.000,00 recuperato. Questo scenario è detto Priorità client o Last in Wins (Priorità ultimo accesso). Tutti i valori del client hanno la precedenza su ciò che si trova nell'archivio dati. Se non si esegue alcuna codifica per la gestione della concorrenza, il client vince automaticamente.
È possibile impedire l'aggiornamento del database con la modifica di John. In genere, l'app:
- Visualizza un messaggio di errore.
- Visualizza lo stato corrente dei dati.
- Consente all'utente di riapplicare le modifiche.
Questo scenario è detto Store Wins (Priorità archivio). I valori dell'archivio dati hanno la precedenza sui valori inviati dal client. In questa esercitazione si implementa lo scenario Delle vittorie nello Store. Questo metodo garantisce che nessuna modifica venga sovrascritta senza che un utente riceva un avviso.
Rilevamento dei conflitti in EF Core
EF Core genera DbConcurrencyException
eccezioni quando rileva conflitti. Il modello di dati deve essere configurato per abilitare il rilevamento dei conflitti. Di seguito sono elencate alcune opzioni per abilitare il rilevamento dei conflitti:
Configurare EF Core per includere i valori originali delle colonne configurati come token di concorrenza nella clausola Where dei comandi Update e Delete.
Quando
SaveChanges
viene chiamato , la clausola Where cerca i valori originali di qualsiasi proprietà annotata con l'attributo ConcurrencyCheckAttribute . L'istruzione Update non troverà una riga da aggiornare se una delle proprietà del token di concorrenza è cambiata dopo la prima lettura della riga. EF Core interpreta come un conflitto di concorrenza. Per le tabelle di database con molte colonne, questo approccio può risultare in clausole Where molto grandi e richiedere grandi quantità di stato. Pertanto questo approccio è in genere sconsigliato e non è il metodo usato in questa esercitazione.Nella tabella del database, includere una colonna di rilevamento che può essere usata per determinare quando è stata modificata una riga.
In un database di SQL Server il tipo di dati della colonna di rilevamento è
rowversion
. Il valorerowversion
è un numero sequenziale che viene incrementato ogni volta che la riga viene aggiornata. In un comando Update o Delete, la clausola Where include il valore originale della colonna di rilevamento (il numero di versione originale della riga). Se la riga da aggiornare è stata modificata da un altro utente, il valore nella colonnarowversion
è diverso dal valore originale. In tal caso, l'istruzione Update o Delete non riesce a trovare la riga da aggiornare a causa della clausola Where. EF Core genera un'eccezione di concorrenza quando nessuna riga è interessata da un comando Update o Delete.
Aggiungere una proprietà di rilevamento modifiche
In Models/Department.cs
aggiungere una proprietà di rilevamento denominata 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; }
}
}
L'attributo TimestampAttribute identifica la colonna come colonna di rilevamento della concorrenza. L'API Fluent è un modo alternativo per specificare la proprietà di rilevamento:
modelBuilder.Entity<Department>()
.Property<byte[]>("RowVersion")
.IsRowVersion();
Per un database di SQL Server l'attributo [Timestamp]
per una proprietà dell'entità definita come matrice di byte:
- Causa l'inclusione della colonna nelle clausole Where per Delete e Update.
- Imposta il tipo di colonna nel database su rowversion.
Il database genera un numero di versione di riga sequenziale che viene incrementato ogni volta che la riga viene aggiornata. In un comando Update
o Delete
la clausola Where
include il valore della versione della riga recuperato. Se la riga da aggiornare è stata modificata dopo il recupero:
- Il valore della versione della riga corrente non corrisponde al valore recuperato.
- I comandi
Update
oDelete
non trovano una riga perché la clausolaWhere
cerca il valore della versione della riga recuperata. - Viene generata un'eccezione
DbUpdateConcurrencyException
.
Il codice seguente mostra una parte del T-SQL generato da EF Core quando viene aggiornato il nome del reparto:
SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
Il codice evidenziato precedente visualizza la clausola WHERE
contenente RowVersion
. Se il valore RowVersion
del database non è uguale al parametro RowVersion
(@p2
) non viene aggiornata alcuna riga.
Il codice evidenziato seguente visualizza la notazione T-SQL che verifica che è stata aggiornata esattamente una riga:
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 restituisce il numero di righe interessate dall'ultima istruzione. Se non vengono aggiornate righe, EF Core genera un'eccezione DbUpdateConcurrencyException
.
Aggiornare il database
L'aggiunta della proprietà RowVersion
cambia il modello di dati e ciò richiede una migrazione.
Compilare il progetto.
Eseguire il comando seguente nella console di Gestione pacchetti:
Add-Migration RowVersion
Questo comando:
Crea il file di
Migrations/{time stamp}_RowVersion.cs
migrazione.Aggiorna il
Migrations/SchoolContextModelSnapshot.cs
file. L'aggiornamento aggiunge al metodoBuildModel
il codice evidenziato seguente: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"); });
Eseguire il comando seguente nella console di Gestione pacchetti:
Update-Database
Scaffolding delle pagine Department
Seguire le istruzioni in Scaffolding delle pagine Student con le eccezioni seguenti:
Creare una cartella Pages/Departments.
Usare
Department
per la classe del modello.- Usare la classe di contesto esistente anziché crearne una nuova.
Compilare il progetto.
Aggiornare la pagina Index
Lo strumento di scaffolding crea una colonna RowVersion
per la pagina Index, ma questo campo non verrebbe visualizzato in un'app in produzione. In questa esercitazione, l'ultimo byte di RowVersion
viene visualizzato per illustrare in modo più chiaro come funziona la gestione della concorrenza. L'univocità dell'ultimo byte non è garantita.
Aggiornare la pagina Pages\Departments\Index.cshtml:
- Sostituire Index con Departments.
- Modificare il codice che contiene
RowVersion
per visualizzare solo l'ultimo byte della matrice di byte. - Sostituire FirstMidName con FullName.
Il codice seguente mostra la pagina aggiornata:
@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>
Aggiornare il modello di pagina Edit
Aggiornare Pages/Departments/Edit.cshtml.cs
con il codice seguente:
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.");
}
}
}
L'oggetto OriginalValue viene aggiornato con il rowVersion
valore dell'entità quando è stato recuperato nel OnGetAsync
metodo . EF Core genera un comando SQL UPDATE con una clausola WHERE contenente il valore originale RowVersion
. Se il comando UPDATE non ha effetto su nessuna riga (nessuna riga ha il valore originale RowVersion
), viene generata un'eccezione DbUpdateConcurrencyException
.
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;
Nel codice evidenziato precedente:
- Il valore in
Department.RowVersion
corrisponde a quello presente nell'entità quando è stato recuperato in origine nella richiesta Get per la pagina Edit. Il valore viene fornito alOnPost
metodo da un campo nascosto nella Razor pagina che visualizza l'entità da modificare. Il valore del campo nascosto viene copiato inDepartment.RowVersion
dallo strumento di associazione di modelli. OriginalValue
è ciò che EF Core verrà usato nella clausola Where. Prima dell'esecuzione della riga di codice evidenziata,OriginalValue
ha il valore che era presente nel database al momento della chiamata diFirstOrDefaultAsync
in questo metodo, che potrebbe essere diverso da quello visualizzato nella pagina Edit.- Il codice evidenziato assicura che EF Core usi il valore originale
RowVersion
dell'entità visualizzataDepartment
nella clausola Where dell'istruzione SQL UPDATE.
Quando si verifica un errore di concorrenza, il codice evidenziato seguente ottiene i valori client (i valori inseriti in questo metodo) e i valori del 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");
}
Il codice seguente aggiunge un messaggio di errore personalizzato per ogni colonna che ha valori del database diversi da quelli inseriti in 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.");
}
Il codice evidenziato seguente imposta il valore RowVersion
sul nuovo valore recuperato dal database. Quando l'utente fa di nuovo clic su Salva vengono rilevati solo gli errori di concorrenza che si verificano dopo l'ultima visualizzazione della pagina Edit (Modifica).
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");
}
L'istruzione ModelState.Remove
è necessaria perché ModelState
presenta il valore obsoleto RowVersion
. Razor Nella pagina il ModelState
valore di un campo ha la precedenza sui valori delle proprietà del modello quando sono presenti entrambi.
Aggiornare la pagina Edit
Aggiornare Pages/Departments/Edit.cshtml
con il codice seguente:
@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");}
}
Il codice precedente:
- Aggiorna la direttiva
page
da@page
a@page "{id:int}"
. - Aggiunge una versione di riga nascosta.
RowVersion
deve essere aggiunto in modo che il postback associa il valore. - Visualizza l'ultimo byte di
RowVersion
a scopo di debug. - Sostituisce
ViewData
con l'elementoInstructorNameSL
fortemente tipizzato.
Eseguire il test dei conflitti di concorrenza con la pagina Edit (Modifica)
Aprire due istanze di browser con la pagina Edit (Modifica) e il reparto English (Inglese):
- Eseguire l'app e selezionare Departments (Reparti).
- Fare clic con il pulsante destro del mouse sul collegamento ipertestuale Edit (Modifica) per il reparto English (Inglese) e selezionare Apri in una nuova scheda.
- Nella prima scheda fare clic sul collegamento ipertestuale Edit (Modifica) per il reparto English (Inglese).
Le due schede del browser visualizzano le stesse informazioni.
Modificare il nome nella prima scheda del browser e fare clic su Salva.
Il browser visualizza la pagina Index con il valore modificato e l'indicatore rowVersion aggiornato. Si noti l'indicatore rowVersion aggiornato, che è visualizzato sul secondo postback nell'altra scheda.
Modificare un altro campo nella seconda scheda del browser.
Fare clic su Salva. Vengono visualizzati messaggi di errore per tutti i campi che non corrispondono ai valori del database:
Questa finestra del browser non prevedeva la modifica del campo Name (Nome). Copiare e incollare il valore corrente Languages (Lingue) nel campo Name (Nome). Esci. La convalida lato client rimuove il messaggio di errore.
Fare di nuovo clic su Salva. Il valore immesso nella seconda scheda del browser viene salvato. I valori salvati vengono visualizzati nella pagina Index.
Aggiornare il modello di pagina Elimina
Aggiornare Pages/Departments/Delete.cshtml.cs
con il codice seguente:
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 });
}
}
}
}
La pagina Delete (Elimina) rileva i conflitti di concorrenza quando l'entità è stata modificata dopo il recupero. Department.RowVersion
è la versione di riga quando l'entità è stata recuperata. Quando EF Core crea il comando SQL DELETE, include una clausola WHERE con RowVersion
. Se il comando SQL DELETE non ha effetto su nessuna riga:
RowVersion
nel comando SQL DELETE non corrisponde aRowVersion
nel database.- Viene generata un'eccezione DbUpdateConcurrencyException.
OnGetAsync
viene chiamata conconcurrencyError
.
Aggiornare la pagina Delete (Elimina)
Aggiornare Pages/Departments/Delete.cshtml
con il codice seguente:
@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>
Il codice precedente apporta le modifiche seguenti:
- Aggiorna la direttiva
page
da@page
a@page "{id:int}"
. - Aggiunge un messaggio di errore.
- Sostituisce FirstMidName con FullName nel campo Administrator (Amministratore).
- Modifica
RowVersion
per visualizzare l'ultimo byte. - Aggiunge una versione di riga nascosta.
RowVersion
deve essere aggiunto in modo che il postback associa il valore.
Testare i conflitti di concorrenza
Creare un reparto di test.
Aprire due istanze del browser con la pagina Delete (Elimina):
- Eseguire l'app e selezionare Departments (Reparti).
- Fare clic con il pulsante destro del mouse sul collegamento ipertestuale Delete (Elimina) per il reparto di test e selezionare Apri in una nuova scheda.
- Fare clic sul collegamento ipertestuale Edit (Modifica) per il reparto di test.
Le due schede del browser visualizzano le stesse informazioni.
Modificare il budget nella prima scheda del browser e fare clic su Salva.
Il browser visualizza la pagina Index con il valore modificato e l'indicatore rowVersion aggiornato. Si noti l'indicatore rowVersion aggiornato, che è visualizzato sul secondo postback nell'altra scheda.
Eliminare il reparto di test dalla seconda scheda. Viene visualizzato un errore di concorrenza con i valori correnti del database. Facendo clic su Elimina viene eliminata l'entità, a meno che non RowVersion
sia stato aggiornato.
Risorse aggiuntive
- Token di concorrenza in EF Core
- Gestire la concorrenza in EF Core
- Debug dell'origine ASP.NET Core 2.x
Passaggi successivi
Questa è l'ultima esercitazione nella serie. Ulteriori argomenti sono trattati nella versione MVC di questa serie di esercitazioni.
Questa esercitazione descrive la gestione dei conflitti quando più utenti aggiornano la stessa entità contemporaneamente. Se si verificano problemi non è possibile risolvere, scaricare o visualizzare l'app completata. Istruzioni per il download.
Conflitti di concorrenza
Un conflitto di concorrenza si verifica quando:
- Un utente passa alla pagina di modifica di un'entità.
- Un altro utente aggiorna la stessa entità prima che la modifica del primo utente venga scritta nel database.
Se non è abilitato il rilevamento della concorrenza, quando si verificano aggiornamenti concorrenti:
- L'ultimo aggiornamento è quello valido. In altri termini, nel database vengono salvati i valori dell'ultimo aggiornamento.
- I dati del primo aggiornamento vengono ignorati.
Concorrenza ottimistica
La concorrenza ottimistica consente che si verifichino conflitti di concorrenza, quindi attiva le misure necessarie. Ad esempio Jane visita la pagina Department Edit (Modifica - Reparto) e cambia il budget per il reparto English (Inglese) da $ 350.000,00 a $ 0,00.
Prima che Jane faccia clic su Salva John visita la stessa pagina e cambia il valore del campo Start Date (Data inizio) da 9/1/2007 a 9/1/2013.
Jane fa clic su Salva per prima e vede la sua modifica quando il browser torna alla pagina di indice.
John fa clic su Salva in una pagina Edit (Modifica) che visualizza ancora un budget pari a $ 350.000,00. Le operazioni successive dipendono da come si decide di gestire i conflitti di concorrenza.
La concorrenza ottimistica include le opzioni seguenti:
È possibile tenere traccia della proprietà che un utente ha modificato e aggiornare solo le colonne corrispondenti nel database.
Con questo scenario non si registra la perdita di dati. I due utenti hanno aggiornato proprietà diverse. Quando un utente torna a visualizzare il reparto English (Inglese), visualizza sia le modifiche di Jane sia quelle di John. Questo metodo di aggiornamento riduce il numero di conflitti che possono comportare la perdita di dati. Questo approccio:
- Non evita la perdita di dati se vengono apportate modifiche concorrenti alla stessa proprietà.
- Risulta in genere poco pratico in un'app Web. Richiede la manutenzione di un volume importante di codice statico per tenere traccia di tutti i valori recuperati e i nuovi valori. La gestione di grandi quantità di codice statico può ridurre le prestazioni dell'applicazione.
- Può rendere più complesse le app rispetto al rilevamento della concorrenza in un'entità.
È possibile consentire che la modifica di John sovrascriva la modifica di Jane.
Quando un utente torna a visualizzare il reparto English (Inglese), visualizza 9/1/2013 e il valore $ 350.000,00 recuperato. Questo scenario è detto Priorità client o Last in Wins (Priorità ultimo accesso). Tutti i valori del client hanno la precedenza su ciò che si trova nell'archivio dati. Se non si esegue alcuna codifica per la gestione della concorrenza, il client vince automaticamente.
È possibile impedire che la modifica di John venga implementata nel database. In genere, l'app:
- Visualizza un messaggio di errore.
- Visualizza lo stato corrente dei dati.
- Consente all'utente di riapplicare le modifiche.
Questo scenario è detto Store Wins (Priorità archivio). I valori dell'archivio dati hanno la precedenza sui valori inviati dal client. In questa esercitazione si implementa lo scenario Delle vittorie nello Store. Questo metodo garantisce che nessuna modifica venga sovrascritta senza che un utente riceva un avviso.
Gestione della concorrenza
Quando una proprietà è configurata come token di concorrenza:
- EF Core verifica che la proprietà non sia stata modificata dopo il recupero. Il controllo si verifica quando SaveChanges viene chiamato o SaveChangesAsync .
- Se la proprietà è stata modificata dopo il recupero, viene generata un'eccezione DbUpdateConcurrencyException .
Il database e il modello di dati devono essere configurati per supportare la generazione di DbUpdateConcurrencyException
.
Rilevamento dei conflitti di concorrenza per una proprietà
È possibile rilevare i conflitti di concorrenza a livello delle proprietà con l'attributo ConcurrencyCheck. L'attributo può essere applicato a più proprietà del modello. Per altre informazioni, vedere Data Annotations-ConcurrencyCheck (Annotazioni dei dati - ConcurrencyCheck).
L'attributo [ConcurrencyCheck]
non viene usato in questa esercitazione.
Rilevamento dei conflitti di concorrenza per una riga
Per rilevare i conflitti di concorrenza si aggiunge al modello una colonna di rilevamento rowversion. rowversion
:
- È specifica per SQL Server. È possibile che altri database non dispongano di una funzionalità simile.
- Viene usata per determinare che un'entità non è stata modificata dopo il suo recupero dal database.
Il database genera un numero rowversion
sequenziale che viene incrementato ogni volta che la riga viene aggiornata. In un comando Update
o Delete
la clausola Where
include il valore recuperato di rowversion
. Se la riga che viene aggiornata è stata modificata:
rowversion
non corrisponde al valore recuperato.- I comandi
Update
oDelete
non trovano una riga perché la clausolaWhere
include il valorerowversion
recuperato. - Viene generata un'eccezione
DbUpdateConcurrencyException
.
In EF Core, quando nessuna riga è stata aggiornata da un Update
comando o Delete
, viene generata un'eccezione di concorrenza.
Aggiungere una proprietà di rilevamento all'entità Department
In Models/Department.cs
aggiungere una proprietà di rilevamento denominata 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; }
}
}
L'attributo Timestamp specifica che questa colonna è inclusa nella clausola Where
dei comandi Update
e Delete
. L'attributo viene chiamato Timestamp
perché le versioni precedenti di SQL Server usavano un tipo di dati SQL timestamp
prima che questo fosse sostituito dal tipo SQL rowversion
.
L'API Fluent può anche specificare la proprietà di rilevamento:
modelBuilder.Entity<Department>()
.Property<byte[]>("RowVersion")
.IsRowVersion();
Il codice seguente mostra una parte del T-SQL generato da EF Core quando viene aggiornato il nome del reparto:
SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
Il codice evidenziato precedente visualizza la clausola WHERE
contenente RowVersion
. Se nel database RowVersion
non è uguale al parametro RowVersion
(@p2
) non viene aggiornata nessuna riga.
Il codice evidenziato seguente visualizza la notazione T-SQL che verifica che è stata aggiornata esattamente una riga:
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 restituisce il numero di righe interessate dall'ultima istruzione. In nessuna riga viene aggiornata, EF Core genera un'eccezione DbUpdateConcurrencyException
.
È possibile visualizzare l'errore T-SQL EF Core generato nella finestra di output di Visual Studio.
Aggiornare il database
L'aggiunta della proprietà RowVersion
cambia il modello di database e ciò richiede una migrazione.
Compilare il progetto. Digitare quanto segue in una finestra di comando:
dotnet ef migrations add RowVersion
dotnet ef database update
I comandi precedenti:
Aggiunge il file di
Migrations/{time stamp}_RowVersion.cs
migrazione.Aggiorna il
Migrations/SchoolContextModelSnapshot.cs
file. L'aggiornamento aggiunge al metodoBuildModel
il codice evidenziato seguente:Eseguono migrations per aggiornare il database.
Scaffolding del modello Departments (Reparti)
Seguire le istruzioni in Eseguire lo scaffolding del modello Student (Studente) e usare Department
per la classe modello.
Il comando precedente esegue lo scaffolding del modello Department
. Aprire il progetto in Visual Studio.
Compilare il progetto.
Aggiornare la pagina Departments Index (Indice reparti)
Il motore di scaffolding crea una colonna RowVersion
per la pagina Index, ma questo campo non deve essere visualizzato. In questa esercitazione, l'ultimo byte di RowVersion
viene visualizzato per facilitare la comprensione della concorrenza. L'univocità dell'ultimo byte non è garantita. Un'app reale non visualizza RowVersion
o l'ultimo byte di RowVersion
.
Aggiornare la pagina Index:
- Sostituire Index con Departments.
- Sostituire il markup che contiene
RowVersion
con l'ultimo byte diRowVersion
. - Sostituire FirstMidName con FullName.
Il markup seguente visualizza la pagina aggiornata:
@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>
Aggiornare il modello di pagina Edit
Aggiornare Pages/Departments/Edit.cshtml.cs
con il codice seguente:
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.");
}
}
}
Per rilevare un problema di concorrenza, viene OriginalValue aggiornato con il rowVersion
valore dell'entità recuperata. EF Core genera un comando SQL UPDATE con una clausola WHERE contenente il valore originale RowVersion
. Se il comando UPDATE non ha effetto su nessuna riga (nessuna riga ha il valore originale RowVersion
), viene generata un'eccezione DbUpdateConcurrencyException
.
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;
Nel codice precedente, Department.RowVersion
è il valore al momento del recupero dell'entità. OriginalValue
è il valore presente nel database quando in questo metodo è stato chiamato FirstOrDefaultAsync
.
Il codice seguente ottiene i valori del client (i valori inseriti in questo metodo) e i valori del database:
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");
}
Il codice seguente aggiunge un messaggio di errore personalizzato per ogni colonna che ha valori del database diversi da quelli inseriti in 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.");
}
Il codice evidenziato seguente imposta il valore RowVersion
sul nuovo valore recuperato dal database. Quando l'utente fa di nuovo clic su Salva vengono rilevati solo gli errori di concorrenza che si verificano dopo l'ultima visualizzazione della pagina Edit (Modifica).
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");
}
L'istruzione ModelState.Remove
è necessaria perché ModelState
presenta il valore obsoleto RowVersion
. Razor Nella pagina il ModelState
valore di un campo ha la precedenza sui valori delle proprietà del modello quando sono presenti entrambi.
Aggiornare la pagina Edit
Eseguire l'aggiornamento Pages/Departments/Edit.cshtml
con il markup seguente:
@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");}
}
Il markup precedente:
- Aggiorna la direttiva
page
da@page
a@page "{id:int}"
. - Aggiunge una versione di riga nascosta. L'aggiunta di
RowVersion
è necessaria per far sì che il postback associ il valore. - Visualizza l'ultimo byte di
RowVersion
a scopo di debug. - Sostituisce
ViewData
con l'elementoInstructorNameSL
fortemente tipizzato.
Eseguire il test dei conflitti di concorrenza con la pagina Edit (Modifica)
Aprire due istanze di browser con la pagina Edit (Modifica) e il reparto English (Inglese):
- Eseguire l'app e selezionare Departments (Reparti).
- Fare clic con il pulsante destro del mouse sul collegamento ipertestuale Edit (Modifica) per il reparto English (Inglese) e selezionare Apri in una nuova scheda.
- Nella prima scheda fare clic sul collegamento ipertestuale Edit (Modifica) per il reparto English (Inglese).
Le due schede del browser visualizzano le stesse informazioni.
Modificare il nome nella prima scheda del browser e fare clic su Salva.
Il browser visualizza la pagina Index con il valore modificato e l'indicatore rowVersion aggiornato. Si noti l'indicatore rowVersion aggiornato, che è visualizzato sul secondo postback nell'altra scheda.
Modificare un altro campo nella seconda scheda del browser.
Fare clic su Salva. Vengono visualizzati messaggi di errore per tutti i campi che non corrispondono ai valori del database:
Questa finestra del browser non prevedeva la modifica del campo Name (Nome). Copiare e incollare il valore corrente Languages (Lingue) nel campo Name (Nome). Esci. La convalida lato client rimuove il messaggio di errore.
Fare di nuovo clic su Salva. Il valore immesso nella seconda scheda del browser viene salvato. I valori salvati vengono visualizzati nella pagina Index.
Aggiornare la pagina Delete (Elimina)
Aggiornare il modello di pagina Delete (Elimina) con il codice seguente:
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 });
}
}
}
}
La pagina Delete (Elimina) rileva i conflitti di concorrenza quando l'entità è stata modificata dopo il recupero. Department.RowVersion
è la versione di riga quando l'entità è stata recuperata. Quando EF Core crea il comando SQL DELETE, include una clausola WHERE con RowVersion
. Se il comando SQL DELETE non ha effetto su nessuna riga:
RowVersion
nel comando SQL DELETE non corrisponde aRowVersion
nel database.- Viene generata un'eccezione DbUpdateConcurrencyException.
OnGetAsync
viene chiamata conconcurrencyError
.
Aggiornare la pagina Delete (Elimina)
Aggiornare Pages/Departments/Delete.cshtml
con il codice seguente:
@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>
Il codice precedente apporta le modifiche seguenti:
- Aggiorna la direttiva
page
da@page
a@page "{id:int}"
. - Aggiunge un messaggio di errore.
- Sostituisce FirstMidName con FullName nel campo Administrator (Amministratore).
- Modifica
RowVersion
per visualizzare l'ultimo byte. - Aggiunge una versione di riga nascosta. L'aggiunta di
RowVersion
è necessaria per far sì che il postback associ il valore.
Eseguire il test dei conflitti di concorrenza con la pagina Delete (Elimina)
Creare un reparto di test.
Aprire due istanze del browser con la pagina Delete (Elimina):
- Eseguire l'app e selezionare Departments (Reparti).
- Fare clic con il pulsante destro del mouse sul collegamento ipertestuale Delete (Elimina) per il reparto di test e selezionare Apri in una nuova scheda.
- Fare clic sul collegamento ipertestuale Edit (Modifica) per il reparto di test.
Le due schede del browser visualizzano le stesse informazioni.
Modificare il budget nella prima scheda del browser e fare clic su Salva.
Il browser visualizza la pagina Index con il valore modificato e l'indicatore rowVersion aggiornato. Si noti l'indicatore rowVersion aggiornato, che è visualizzato sul secondo postback nell'altra scheda.
Eliminare il reparto di test dalla seconda scheda. Viene visualizzato un errore di concorrenza con i valori correnti del database. Facendo clic su Elimina viene eliminata l'entità, a meno che non RowVersion
sia stato aggiornato.
Per informazioni su come ereditare un modello di dati, vedere Ereditarietà.