Gestione della concorrenza con Entity Framework in un'applicazione MVC ASP.NET (7 di 10)
di Tom Dykstra
L'applicazione Web di esempio Contoso University illustra come creare ASP.NET applicazioni MVC 4 usando Entity Framework 5 Code First e Visual Studio 2012. Per informazioni sulla serie di esercitazioni, vedere la prima esercitazione della serie.
Nota
Se si verifica un problema che non è possibile risolvere, scaricare il capitolo completato e provare a riprodurre il problema. In genere è possibile trovare la soluzione al problema confrontando il codice con il codice completato. Per alcuni errori comuni e come risolverli, vedere Errori e soluzioni alternative.
Nelle due esercitazioni precedenti sono stati usati dati correlati. Questa esercitazione illustra come gestire la concorrenza. Si creeranno pagine Web che funzionano con l'entità Department
e le pagine che modificano ed eliminano Department
le entità gestiranno gli errori di concorrenza. Le illustrazioni seguenti mostrano le pagine Indice ed Elimina, inclusi alcuni messaggi visualizzati se si verifica un conflitto di concorrenza.
Conflitti di concorrenza
Un conflitto di concorrenza si verifica quando un utente visualizza i dati di un'entità per modificarli mentre un altro utente aggiorna i dati della stessa entità prima che la modifica del primo utente venga scritta nel database. Se non si abilita il rilevamento di questi conflitti, l'ultimo utente che aggiorna il database sovrascrive le modifiche apportate dall'altro utente. In molte applicazioni questo rischio è accettabile: se il numero di utenti è ridotto o se l'eventuale sovrascrittura di alcune modifiche non è un aspetto critico, i costi della programmazione per la concorrenza possono superare i vantaggi. In tal caso non è necessario configurare l'applicazione per la gestione dei conflitti di concorrenza.
Concorrenza pessimistica (blocco)
Se è importante che l'applicazione eviti la perdita accidentale di dati in scenari di concorrenza, un metodo per garantire che ciò accada è l'uso dei blocchi di database. Si tratta di una concorrenza pessimistica. Ad esempio, prima di leggere una riga da un database si richiede un blocco per l'accesso di sola lettura o per l'accesso in modalità aggiornamento. Se si blocca una riga per l'accesso di aggiornamento, nessun altro utente può bloccare la riga per l'accesso di sola lettura o di aggiornamento, perché riceverebbe una copia di dati dei quali è in corso la modifica. Se si blocca una riga per l'accesso in sola lettura, anche altri utenti possono bloccarla per l'accesso in sola lettura, ma non per l'aggiornamento.
La gestione dei blocchi presenta svantaggi. La sua programmazione può risultare complicata. Richiede risorse di gestione del database significative e può causare problemi di prestazioni man mano che aumenta il numero di utenti di un'applicazione, ovvero non è scalabile correttamente. Per questi motivi non tutti i sistemi di gestione di database supportano la concorrenza pessimistica. Entity Framework non offre alcun supporto predefinito e questa esercitazione non illustra come implementarla.
Concorrenza ottimistica
L'alternativa alla concorrenza pessimistica è la concorrenza ottimistica. Nella concorrenza ottimistica si consente che i conflitti di concorrenza si verifichino, quindi si reagisce con le modalità appropriate. Ad esempio, John esegue la pagina Di modifica reparti, modifica l'importo budget per il reparto inglese da $ 350.000.00 a $ 0,00.
Prima che John faccia clic su Salva, Jane esegue la stessa pagina e modifica il campo Data inizio da 9/1/2007 a 8/8/2013.
Giorgio fa clic su Salva per primo e visualizza la modifica quando il browser torna alla pagina Indice, quindi Jane fa clic su Salva. Le operazioni successive dipendono da come si decide di gestire i conflitti di concorrenza. Di seguito sono elencate alcune opzioni:
È possibile tenere traccia della proprietà che un utente ha modificato e aggiornare solo le colonne corrispondenti nel database. Nello scenario dell'esempio non si perde nessun dato, perché i due utenti hanno aggiornato proprietà diverse. La prossima volta che qualcuno esplora il reparto inglese, vedranno le modifiche di John e Jane , una data di inizio dell'8/8/2013 e un budget pari a Zero dollari.
Questo metodo di aggiornamento può ridurre il numero di conflitti che causano la perdita di dati, ma non può evitare la perdita di dati se vengono apportate modifiche in competizione tra loro alla stessa proprietà di un'entità. Questo funzionamento di Entity Framework dipende dalla modalità di implementazione del codice di aggiornamento. In molti casi in un'app Web questo approccio risulta poco pratico, perché richiede la gestione di grandi quantità di codice statico per tenere traccia di tutti i valori di proprietà originali per un'entità, nonché dei nuovi valori. La gestione di grandi quantità di stato può influire sulle prestazioni dell'applicazione perché richiede risorse server o deve essere inclusa nella pagina Web stessa (ad esempio, in campi nascosti).
Puoi lasciare che Jane cambi sovrascrivendo il cambiamento di John. La prossima volta che qualcuno esplora il reparto inglese, vedrà 8/8/2013 e il valore di $350.000.00 ripristinato. Questo scenario è detto Priorità client o Last in Wins (Priorità ultimo accesso). I valori del client hanno la precedenza su ciò che si trova nell'archivio dati. Come indicato nell'introduzione a questa sezione, se non si esegue alcuna codifica per la gestione della concorrenza, questa operazione verrà eseguita automaticamente.
È possibile impedire che la modifica di Jane venga aggiornata nel database. In genere, viene visualizzato un messaggio di errore, viene visualizzato lo stato corrente dei dati e si consente di riapplicare le modifiche se si vuole ancora apportare tali modifiche. Questo scenario è detto Store Wins (Priorità archivio). I valori dell'archivio dati hanno la precedenza sui valori inviati dal client. In questa esercitazione verrà implementato lo scenario Delle vittorie nello Store. Questo metodo garantisce che nessuna modifica venga sovrascritta senza che un utente riceva un avviso.
Rilevamento di conflitti di concorrenza
È possibile risolvere i conflitti gestendo le eccezioni OptimisticConcurrencyException generate da Entity Framework. Per determinare quando generare queste eccezioni, Entity Framework deve essere in grado di rilevare i conflitti. Pertanto è necessario configurare il database e il modello di dati in modo appropriato. Di seguito sono elencate alcune opzioni per abilitare il rilevamento dei conflitti:
Nella tabella del database, includere una colonna di rilevamento che può essere usata per determinare quando è stata modificata una riga. È quindi possibile configurare Entity Framework per includere tale colonna nella
Where
clausola di SQLUpdate
oDelete
comandi.Il tipo di dati della colonna di rilevamento è in genere rowversion. Il valore rowversion è un numero sequenziale incrementato ogni volta che la riga viene aggiornata. In un
Update
comando oDelete
laWhere
clausola include il valore originale della colonna di rilevamento (la versione originale della riga). Se la riga da aggiornare è stata modificata da un altro utente, il valore nellarowversion
colonna è diverso dal valore originale, quindi l'istruzioneUpdate
oDelete
non riesce a trovare la riga da aggiornare a causa dellaWhere
clausola . Quando Entity Framework rileva che nessuna riga è stata aggiornata dalUpdate
comando oDelete
( ovvero quando il numero di righe interessate è zero), interpreta tale valore come conflitto di concorrenza.Configurare Entity Framework per includere i valori originali di ogni colonna nella tabella nella
Where
clausola deiUpdate
comandi eDelete
.Come nella prima opzione, se una riga è cambiata dopo la prima lettura della riga, la
Where
clausola non restituirà una riga da aggiornare, che Entity Framework interpreta come conflitto di concorrenza. Per le tabelle di database con molte colonne, questo approccio può comportare clausole molto grandiWhere
e può richiedere la gestione di grandi quantità di stato. Come indicato in precedenza, la gestione di grandi quantità di stato può influire sulle prestazioni dell'applicazione perché richiede risorse server o deve essere inclusa nella pagina Web stessa. Pertanto, questo approccio in genere non è consigliato e non è il metodo usato in questa esercitazione.Se si vuole implementare questo approccio alla concorrenza, è necessario contrassegnare tutte le proprietà non chiave primaria nell'entità per cui si vuole tenere traccia della concorrenza aggiungendo l'attributo ConcurrencyCheck . Questa modifica consente a Entity Framework di includere tutte le colonne nella clausola SQL
WHERE
delleUPDATE
istruzioni.
Nella parte restante di questa esercitazione si aggiungerà una proprietà di rilevamento rowversion all'entità Department
, si creerà un controller e una vista e si testerà per verificare che tutto funzioni correttamente.
Aggiungere una proprietà di concorrenza ottimistica all'entità Department
In Models\Department.cs aggiungere una proprietà di rilevamento denominata RowVersion
:
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)]
public DateTime StartDate { get; set; }
[Display(Name = "Administrator")]
public int? InstructorID { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; }
public virtual Instructor Administrator { get; set; }
public virtual ICollection<Course> Courses { get; set; }
}
L'attributo Timestamp specifica che questa colonna verrà inclusa nella Where
clausola dei Update
comandi e Delete
inviati al database. L'attributo è denominato Timestamp perché le versioni precedenti di SQL Server usavano un tipo di dati timestamp SQL prima che la rowversion SQL lo sostituissi. Il tipo .Net per rowversion
è una matrice di byte. Se si preferisce usare l'API Fluent, è possibile usare il metodo IsConcurrencyToken per specificare la proprietà di rilevamento, come illustrato nell'esempio seguente:
modelBuilder.Entity<Department>()
.Property(p => p.RowVersion).IsConcurrencyToken();
Vedere il problema di GitHub Replace IsConcurrencyToken by IsRowVersion.
In seguito all'aggiunta di una proprietà il modello di database è stato modificato, pertanto è necessario eseguire una nuova migrazione. Nella console di Gestione pacchetti immettere i comandi seguenti:
Add-Migration RowVersion
Update-Database
Creare un controller di reparto
Creare un Department
controller e visualizzare lo stesso modo in cui sono stati usata le altre controller, usando le impostazioni seguenti:
In Controllers\DepartmentController.cs aggiungere un'istruzione using
:
using System.Data.Entity.Infrastructure;
Modificare "LastName" in "FullName" ovunque in questo file (quattro occorrenze) in modo che gli elenchi a discesa dell'amministratore del reparto contengano il nome completo dell'insegnante anziché solo il cognome.
ViewBag.InstructorID = new SelectList(db.Instructors, "InstructorID", "FullName");
Sostituire il codice esistente per il HttpPost
Edit
metodo con il codice seguente:
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Edit(
[Bind(Include = "DepartmentID, Name, Budget, StartDate, RowVersion, InstructorID")]
Department department)
{
try
{
if (ModelState.IsValid)
{
db.Entry(department).State = EntityState.Modified;
db.SaveChanges();
return RedirectToAction("Index");
}
}
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
var clientValues = (Department)entry.Entity;
var databaseValues = (Department)entry.GetDatabaseValues().ToObject();
if (databaseValues.Name != clientValues.Name)
ModelState.AddModelError("Name", "Current value: "
+ databaseValues.Name);
if (databaseValues.Budget != clientValues.Budget)
ModelState.AddModelError("Budget", "Current value: "
+ String.Format("{0:c}", databaseValues.Budget));
if (databaseValues.StartDate != clientValues.StartDate)
ModelState.AddModelError("StartDate", "Current value: "
+ String.Format("{0:d}", databaseValues.StartDate));
if (databaseValues.InstructorID != clientValues.InstructorID)
ModelState.AddModelError("InstructorID", "Current value: "
+ db.Instructors.Find(databaseValues.InstructorID).FullName);
ModelState.AddModelError(string.Empty, "The record you attempted to edit "
+ "was modified by another user after you got the original value. The "
+ "edit operation was canceled and the current values in the database "
+ "have been displayed. If you still want to edit this record, click "
+ "the Save button again. Otherwise click the Back to List hyperlink.");
department.RowVersion = databaseValues.RowVersion;
}
catch (DataException /* dex */)
{
//Log the error (uncomment dex variable name after DataException and add a line here to write a log.
ModelState.AddModelError(string.Empty, "Unable to save changes. Try again, and if the problem persists contact your system administrator.");
}
ViewBag.InstructorID = new SelectList(db.Instructors, "InstructorID", "FullName", department.InstructorID);
return View(department);
}
La visualizzazione archivierà il valore originale RowVersion
in un campo nascosto. Quando il gestore di associazione di modelli crea l'istanza department
, tale oggetto avrà il valore della proprietà originale RowVersion
e i nuovi valori per le altre proprietà, come immesso dall'utente nella pagina Modifica. Quindi, quando Entity Framework crea un comando SQL UPDATE
, tale comando includerà una WHERE
clausola che cerca una riga con il valore originale RowVersion
.
Se nessuna riga è interessata dal UPDATE
comando (nessuna riga ha il valore originale RowVersion
), Entity Framework genera un'eccezione DbUpdateConcurrencyException
e il codice nel catch
blocco ottiene l'entità interessata Department
dall'oggetto eccezione. Questa entità ha entrambi i valori letti dal database e i nuovi valori immessi dall'utente:
var entry = ex.Entries.Single();
var clientValues = (Department)entry.Entity;
var databaseValues = (Department)entry.GetDatabaseValues().ToObject();
Il codice aggiunge quindi un messaggio di errore personalizzato per ogni colonna con valori di database diversi da quelli immessi dall'utente nella pagina Modifica:
if (databaseValues.Name != currentValues.Name)
ModelState.AddModelError("Name", "Current value: " + databaseValues.Name);
// ...
Un messaggio di errore più lungo spiega cosa è successo e cosa fare su di esso:
ModelState.AddModelError(string.Empty, "The record you attempted to edit "
+ "was modified by another user after you got the original value. The"
+ "edit operation was canceled and the current values in the database "
+ "have been displayed. If you still want to edit this record, click "
+ "the Save button again. Otherwise click the Back to List hyperlink.");
Infine, il codice imposta il RowVersion
valore dell'oggetto Department
sul nuovo valore recuperato dal database. Questo nuovo valore RowVersion
viene archiviato nel campo nascosto quando viene visualizzata nuovamente la pagina Edit (Modifica). Quando l'utente torna a fare clic su Salva vengono rilevati solo gli errori di concorrenza che si verificano dopo la nuova visualizzazione della pagina Edit (Modifica).
In Views\Department\Edit.cshtml aggiungere un campo nascosto per salvare il valore della RowVersion
proprietà, subito dopo il campo nascosto per la DepartmentID
proprietà:
@model ContosoUniversity.Models.Department
@{
ViewBag.Title = "Edit";
}
<h2>Edit</h2>
@using (Html.BeginForm()) {
@Html.AntiForgeryToken()
@Html.ValidationSummary(true)
<fieldset>
<legend>Department</legend>
@Html.HiddenFor(model => model.DepartmentID)
@Html.HiddenFor(model => model.RowVersion)
<div class="editor-label">
@Html.LabelFor(model => model.Name)
</div>
In Views\Department\Index.cshtml sostituire il codice esistente con il codice seguente per spostare i collegamenti di riga a sinistra e modificare il titolo della pagina e le intestazioni di LastName
colonna da visualizzare FullName
anziché nella colonna Administrator:
@model IEnumerable<ContosoUniversity.Models.Department>
@{
ViewBag.Title = "Departments";
}
<h2>Departments</h2>
<p>
@Html.ActionLink("Create New", "Create")
</p>
<table>
<tr>
<th></th>
<th>Name</th>
<th>Budget</th>
<th>Start Date</th>
<th>Administrator</th>
</tr>
@foreach (var item in Model) {
<tr>
<td>
@Html.ActionLink("Edit", "Edit", new { id=item.DepartmentID }) |
@Html.ActionLink("Details", "Details", new { id=item.DepartmentID }) |
@Html.ActionLink("Delete", "Delete", new { id=item.DepartmentID })
</td>
<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>
</tr>
}
</table>
Test della gestione della concorrenza ottimistica
Eseguire il sito e fare clic su Reparti:
Fare clic con il pulsante destro del mouse sul collegamento ipertestuale Modifica per Kim Abercrombie e scegliere Apri nella nuova scheda, quindi fare clic sul collegamento ipertestuale Modifica per Kim Abercrombie. Le due finestre visualizzano le stesse informazioni.
Modificare un campo nella prima finestra del browser e fare clic su Salva.
Il browser visualizza la pagina Index con il valore modificato.
Modificare il campo nella seconda finestra del browser e fare clic su Salva.
Fare clic su Salva nella seconda finestra del browser. Viene visualizzato un messaggio di errore:
Fare di nuovo clic su Salva. Il valore immesso nel secondo browser viene salvato insieme al valore originale dei dati modificati nel primo browser. I valori salvati vengono visualizzati nella pagina Index.
Aggiornamento della pagina Elimina
Per la pagina Delete (Elimina), Entity Framework rileva conflitti di concorrenza causati da un altro utente che ha modificato il reparto con modalità simili. Quando il HttpGet
Delete
metodo visualizza la visualizzazione di conferma, la visualizzazione include il valore originale RowVersion
in un campo nascosto. Tale valore è quindi disponibile per il HttpPost
Delete
metodo chiamato quando l'utente conferma l'eliminazione. Quando Entity Framework crea il comando SQL DELETE
, include una WHERE
clausola con il valore originale RowVersion
. Se il comando restituisce zero righe interessate (ovvero la riga è stata modificata dopo la visualizzazione della pagina di conferma elimina), viene generata un'eccezione di concorrenza e il HttpGet Delete
metodo viene chiamato con un flag di errore impostato su true
per riprodurre la pagina di conferma con un messaggio di errore. È anche possibile che nessuna riga sia stata interessata perché la riga è stata eliminata da un altro utente, quindi in tal caso viene visualizzato un messaggio di errore diverso.
In DepartmentController.cs sostituire il HttpGet
Delete
metodo con il codice seguente:
public ActionResult Delete(int id, bool? concurrencyError)
{
Department department = db.Departments.Find(id);
if (concurrencyError.GetValueOrDefault())
{
if (department == null)
{
ViewBag.ConcurrencyErrorMessage = "The record you attempted to delete "
+ "was deleted by another user after you got the original values. "
+ "Click the Back to List hyperlink.";
}
else
{
ViewBag.ConcurrencyErrorMessage = "The record you attempted to delete "
+ "was modified by another user after you got the original values. "
+ "The delete operation was canceled and the current values in the "
+ "database have been displayed. If you still want to delete this "
+ "record, click the Delete button again. Otherwise "
+ "click the Back to List hyperlink.";
}
}
return View(department);
}
Il metodo accetta un parametro facoltativo che indica se la pagina viene nuovamente visualizzata dopo un errore di concorrenza. Se questo flag è true
, viene inviato un messaggio di errore alla visualizzazione usando una ViewBag
proprietà .
Sostituire il codice nel HttpPost
Delete
metodo (denominato DeleteConfirmed
) con il codice seguente:
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Delete(Department department)
{
try
{
db.Entry(department).State = EntityState.Deleted;
db.SaveChanges();
return RedirectToAction("Index");
}
catch (DbUpdateConcurrencyException)
{
return RedirectToAction("Delete", new { concurrencyError=true } );
}
catch (DataException /* dex */)
{
//Log the error (uncomment dex variable name after DataException and add a line here to write a log.
ModelState.AddModelError(string.Empty, "Unable to delete. Try again, and if the problem persists contact your system administrator.");
return View(department);
}
}
Nel codice sottoposto a scaffolding appena sostituito, questo metodo accettava solo un ID record:
public ActionResult DeleteConfirmed(int id)
Questo parametro è stato modificato in un'istanza Department
di entità creata dal gestore di associazione di modelli. In questo modo è possibile accedere al valore della RowVersion
proprietà oltre alla chiave del record.
public ActionResult Delete(Department department)
Anche il nome del metodo di azione è stato modificato da DeleteConfirmed
a Delete
. Codice con scaffolding denominato il HttpPost
Delete
metodo per assegnare al HttpPost
metodo DeleteConfirmed
una firma univoca. CLR richiede metodi di overload per avere parametri di metodo diversi. Ora che le firme sono univoche, è possibile attenersi alla convenzione MVC e usare lo stesso nome per i HttpPost
metodi ed HttpGet
delete.
Se viene rilevato un errore di concorrenza, il codice visualizza nuovamente la pagina di conferma Delete (Elimina) e visualizza un flag indicante che è necessario visualizzare un messaggio di errore di concorrenza.
In Views\Department\Delete.cshtml sostituire il codice con scaffolding con il codice seguente che apporta alcune modifiche di formattazione e aggiunge un campo del messaggio di errore. Le modifiche sono evidenziate.
@model ContosoUniversity.Models.Department
@{
ViewBag.Title = "Delete";
}
<h2>Delete</h2>
<p class="error">@ViewBag.ConcurrencyErrorMessage</p>
<h3>Are you sure you want to delete this?</h3>
<fieldset>
<legend>Department</legend>
<div class="display-label">
@Html.DisplayNameFor(model => model.Name)
</div>
<div class="display-field">
@Html.DisplayFor(model => model.Name)
</div>
<div class="display-label">
@Html.DisplayNameFor(model => model.Budget)
</div>
<div class="display-field">
@Html.DisplayFor(model => model.Budget)
</div>
<div class="display-label">
@Html.DisplayNameFor(model => model.StartDate)
</div>
<div class="display-field">
@Html.DisplayFor(model => model.StartDate)
</div>
<div class="display-label">
@Html.DisplayNameFor(model => model.Administrator.FullName)
</div>
<div class="display-field">
@Html.DisplayFor(model => model.Administrator.FullName)
</div>
</fieldset>
@using (Html.BeginForm()) {
@Html.AntiForgeryToken()
@Html.HiddenFor(model => model.DepartmentID)
@Html.HiddenFor(model => model.RowVersion)
<p>
<input type="submit" value="Delete" /> |
@Html.ActionLink("Back to List", "Index")
</p>
}
Questo codice aggiunge un messaggio di errore tra le h2
intestazioni e h3
:
<p class="error">@ViewBag.ConcurrencyErrorMessage</p>
LastName
Sostituisce con FullName
nel Administrator
campo :
<div class="display-label">
@Html.LabelFor(model => model.InstructorID)
</div>
<div class="display-field">
@Html.DisplayFor(model => model.Administrator.FullName)
</div>
Infine, aggiunge campi nascosti per le DepartmentID
proprietà e RowVersion
dopo l'istruzione Html.BeginForm
:
@Html.HiddenFor(model => model.DepartmentID)
@Html.HiddenFor(model => model.RowVersion)
Eseguire la pagina Indice reparti. Fare clic con il pulsante destro del mouse sul collegamento ipertestuale Elimina per il reparto inglese e selezionare Apri nella nuova finestra, quindi nella prima finestra fare clic sul collegamento ipertestuale Modifica per il reparto inglese.
Nella prima finestra modificare uno dei valori e fare clic su Salva:
La pagina Indice conferma la modifica.
Nella seconda finestra fare clic su Elimina.
Viene visualizzato il messaggio di errore di concorrenza e i valori di Department (Reparto) vengono aggiornati con i dati attualmente presenti nel database.
Se si fa di nuovo clic su Delete (Elimina) viene visualizzata la pagina Index che indica che il reparto è stato eliminato.
Riepilogo
Questo argomento completa l'introduzione alla gestione dei conflitti di concorrenza. Per informazioni su altri modi per gestire vari scenari di concorrenza, vedere Modelli di concorrenza ottimistica e Utilizzo dei valori delle proprietà nel blog del team di Entity Framework. L'esercitazione successiva illustra come implementare l'ereditarietà di tabelle per gerarchia per le Instructor
entità e Student
.
I collegamenti ad altre risorse di Entity Framework sono disponibili nella mappa del contenuto di accesso ai dati ASP.NET.