Partie 8, Pages Razor avec EF Core dans ASP.NET Core - Concurrence
L’application web Contoso University montre comment créer des applications web Pages Razor avec EF Core et Visual Studio. Pour obtenir des informations sur la série de didacticiels, consultez le premier didacticiel.
Si vous rencontrez des problèmes que vous ne pouvez pas résoudre, téléchargez l’application finale et comparez ce code à celui que vous avez créé en suivant le tutoriel.
Ce tutoriel montre comment gérer les conflits quand plusieurs utilisateurs mettent à jour une entité en même temps.
Conflits d’accès concurrentiel
Un conflit d’accès concurrentiel se produit quand :
- Un utilisateur accède à la page de modification d’une entité.
- Un autre utilisateur met à jour la même entité avant que la modification du premier utilisateur soit écrite dans la base de données.
Si la détection de l’accès concurrentiel n’est pas activée, quiconque qui met à jour la base de données en dernier remplace les modifications de l’autre utilisateur. Si ce risque est acceptable, le coût de programmation de l’accès concurrentiel peut l’emporter sur l’avantage.
Accès concurrentiel pessimiste
Une façon d’éviter les conflits d’accès concurrentiel consiste à utiliser des verrous de base de données. Ceci est appelé « accès concurrentiel pessimiste ». Avant de lire une ligne de base de données qu’elle entend mettre à jour, une application demande un verrou. Dès lors qu’une ligne est verrouillée pour l’accès aux mises à jour, aucun autre utilisateur n’est autorisé à verrouiller la ligne tant que le premier verrou n’est pas libéré.
La gestion des verrous présente des inconvénients. Elle peut être difficile à programmer et peut occasionner des problèmes de performances à mesure que le nombre d’utilisateurs augmente. Entity Framework Core ne fournit aucune prise en charge intégrée de la concurrence pessimiste.
Accès concurrentiel optimiste
L’accès concurrentiel optimiste autorise la survenance des conflits d’accès concurrentiel, et réagit correctement quand ils surviennent. Par exemple, Jane consulte la page de modification de département et change le montant de « Budget » pour le département « English » en le faisant passer de 350 000,00 $ à 0,00 $.
Avant que Jane clique sur Save, John consulte la même page et change le champ Start Date de 01/09/2007 en 01/09/2013.
Jane clique d’abord sur Save et voit sa modification prendre effet, puisque le navigateur affiche la page d’index avec un montant de budget égal à zéro.
John clique sur Save dans une page Edit qui affiche toujours un budget de 350 000,00 $. Ce qui se passe ensuite dépend de la façon dont vous gérez les conflits d’accès concurrentiel :
Effectuez le suivi des propriétés modifiées par un utilisateur et mettez à jour seulement les colonnes correspondantes dans la base de données.
Dans le scénario, aucune donnée ne serait perdue. Des propriétés différentes ont été mises à jour par les deux utilisateurs. La prochaine fois que quelqu’un consultera le département « English », il verra à la fois les modifications de Jane et de John. Cette méthode de mise à jour peut réduire le nombre de conflits susceptibles d’entraîner une perte de données. Cette approche présente quelques inconvénients :
- Elle ne peut pas éviter une perte de données si des modifications concurrentes sont apportées à la même propriété.
- Elle n’est généralement pas pratique dans une application web. Elle nécessite la tenue à jour d’un état significatif afin d’effectuer le suivi de toutes les valeurs récupérées et des nouvelles valeurs. La maintenance de grandes quantités d’état peut affecter les performances de l’application.
- Elle peut augmenter la complexité de l’application par rapport à la détection de l’accès concurrentiel sur une entité.
Laissez les modifications de John remplacer les modifications de Jane.
La prochaine fois que quelqu’un consultera le département « English », il verra la date 01/09/2013 et la valeur 350 000,00 $ récupérée. Cette approche est un scénario Priorité au client ou Priorité au dernier. Toutes les valeurs du client sont prioritaires par rapport au contenu du magasin de données. Le code généré n’effectue aucune gestion de concurrence. Client Wins se produit automatiquement.
Empêchez les modifications de John de faire l’objet d’une mise à jour dans la base de données. En règle générale, l’application :
- affiche un message d’erreur ;
- indique l’état actuel des données ;
- autorise l’utilisateur à réappliquer les modifications.
Il s’agit alors d’un scénario Priorité au magasin. Les valeurs du magasin de données sont prioritaires par rapport à celles soumises par le client. Le scénario Store Wins est utilisé dans ce tutoriel. Cette méthode garantit qu’aucune modification n’est remplacée sans qu’un utilisateur soit averti.
Détection de conflit dans EF Core
Les propriétés configurées en tant que jetons d’accès concurrentiel sont utilisées pour implémenter un contrôle d’accès concurrentiel optimiste. Lorsqu’une opération de mise à jour ou de suppression est déclenchée par SaveChanges ou SaveChangesAsync, la valeur du jeton d’accès concurrentiel dans la base de données est comparée à la valeur d’origine lue par EF Core :
- Si les valeurs correspondent, l’opération peut s’effectuer.
- Si les valeurs ne correspondent pas, EF Core suppose qu’un autre utilisateur a effectué une opération conflictuelle, abandonne la transaction en cours et lève un DbUpdateConcurrencyException.
Un autre utilisateur ou processus effectuant une opération en conflit avec l’opération en cours est appelé conflit d’accès concurrentiel.
Sur les bases de données relationnelles, EF Core vérifie la valeur du jeton d’accès concurrentiel dans la clause WHERE
des instructions UPDATE
et DELETE
pour détecter un conflit d’accès concurrentiel.
Le modèle de données doit être configuré pour activer la détection des conflits en incluant une colonne de suivi qui peut être utilisée pour déterminer quand une ligne a été modifiée. EF fournit deux approches pour les jetons d’accès concurrentiel :
Application de
[ConcurrencyCheck]
ou IsConcurrencyToken à une propriété sur le modèle. Cette approche n’est pas recommandée. Pour plus d'informations, consultez Jetons d’accès concurrentiel dans EF Core.Application de TimestampAttribute ou IsRowVersion à un jeton d’accès concurrentiel dans le modèle. Il s’agit de l’approche utilisée dans ce tutoriel.
L’approche SQL Server et les détails de l’implémentation SQLite sont légèrement différents. Un fichier de différences répertoriant les différences s’affiche plus loin dans le tutoriel. L’onglet Visual Studio affiche l’approche SQL Server. L’onglet Visual Studio Code montre l’approche pour les bases de données non SQL Server, telles que SQLite.
- Dans le modèle, incluez une colonne de suivi utilisée pour déterminer quand une ligne a été modifiée.
- Appliquez le TimestampAttribute à la propriété d’accès concurrentiel.
Mettez à jour le fichier Models/Department.cs
avec le code en surbrillance suivant :
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; }
}
}
Le TimestampAttribute est ce qui identifie la colonne en tant que colonne de suivi d’accès concurrentiel. L’API Fluent est un autre moyen de spécifier la propriété de suivi :
modelBuilder.Entity<Department>()
.Property<byte[]>("ConcurrencyToken")
.IsRowVersion();
L’attribut [Timestamp]
sur une propriété d’entité génère le code suivant dans la méthode ModelBuilder :
b.Property<byte[]>("ConcurrencyToken")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
Le code précédent :
- Définit le type de propriété
ConcurrencyToken
sur le groupe d’octets.byte[]
est le type requis pour SQL Server. - Appelle IsConcurrencyToken.
IsConcurrencyToken
configure la propriété en tant que jeton d’accès concurrentiel. Lors des mises à jour, la valeur du jeton d’accès concurrentiel dans la base de données est comparée à la valeur d’origine pour s’assurer qu’elle n’a pas changé depuis que l’instance a été récupérée à partir de la base de données. Si elle a changé, un DbUpdateConcurrencyException est levé et les modifications ne sont pas appliquées. - Appelle ValueGeneratedOnAddOrUpdate, qui configure la propriété
ConcurrencyToken
pour qu’une valeur soit générée automatiquement lors de l’ajout ou de la mise à jour d’une entité. HasColumnType("rowversion")
définit le type de colonne dans la base de données SQL Server sur rowversion.
Le code suivant montre une partie du T-SQL généré par EF Core quand le nom Department
est mis à jour :
SET NOCOUNT ON;
UPDATE [Departments] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [ConcurrencyToken] = @p2;
SELECT [ConcurrencyToken]
FROM [Departments]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
Le code en surbrillance ci-dessus montre la clause WHERE
contenant ConcurrencyToken
. Si la base de données ConcurrencyToken
n’est pas égale au paramètre ConcurrencyToken
@p2
, aucune ligne n’est mise à jour.
Le code en surbrillance suivant montre le T-SQL qui vérifie qu’une seule ligne a été mise à jour :
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 retourne le nombre de lignes affectées par la dernière instruction. Si aucune ligne n’est mise à jour, EF Core lève un DbUpdateConcurrencyException
.
Ajouter une migration
L’ajout de la propriété ConcurrencyToken
change le modèle de données, ce qui nécessite une migration.
Créez le projet.
Exécutez les commandes suivantes dans PMC :
Add-Migration RowVersion
Update-Database
Les commandes précédentes :
- Crée le fichier de migration
Migrations/{time stamp}_RowVersion.cs
. - Met à jour le fichier
Migrations/SchoolContextModelSnapshot.cs
. La mise à jour ajoute le code suivant à la méthodeBuildModel
:
b.Property<byte[]>("ConcurrencyToken")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
Générer automatiquement des modèles de pages Department
Suivez les instructions dans Générer automatiquement des modèles de pages Student avec les exceptions suivantes :
- Créez un dossier Pages/Departments.
- Utilisez
Department
pour la classe de modèle. - Utilisez la classe de contexte existante au lieu d’en créer une nouvelle.
Ajouter une classe utilitaire
Dans le dossier du projet, créez la classe Utility
avec le code suivant :
namespace ContosoUniversity
{
public static class Utility
{
public static string GetLastChars(byte[] token)
{
return token[7].ToString();
}
}
}
La classe Utility
fournit la méthode GetLastChars
utilisée pour afficher les derniers caractères du jeton d’accès concurrentiel. Le code suivant montre le code qui fonctionne à la fois avec SQLite et SQL Server :
#if SQLiteVersion
using System;
namespace ContosoUniversity
{
public static class Utility
{
public static string GetLastChars(Guid token)
{
return token.ToString().Substring(
token.ToString().Length - 3);
}
}
}
#else
namespace ContosoUniversity
{
public static class Utility
{
public static string GetLastChars(byte[] token)
{
return token[7].ToString();
}
}
}
#endif
La directive de préprocesseur #if SQLiteVersion
isole les différences entre les versions SQLite et SQL Server, et aide :
- L’auteur conserve une base de code pour les deux versions.
- Les développeurs SQLite déploient l’application sur Azure et utilisent SQL Azure.
Créez le projet.
Mettre à jour la page Index
L’outil de génération de modèles automatique créé une colonne ConcurrencyToken
pour la page Index, mais ce champ ne s’affiche pas dans une application de production. Dans ce tutoriel, la dernière partie du ConcurrencyToken
est affichée pour montrer comment la gestion de l’accès concurrentiel fonctionne. Il n’est pas garanti que la dernière partie soit unique par elle-même.
Mettre à jour la page Pages\Departments\Index.cshtml :
- Remplacez Index par Departments.
- Modifiez le code contenant
ConcurrencyToken
pour afficher uniquement les derniers caractères. - Remplacez
FirstMidName
parFullName
.
Le code suivant affiche la page mise à jour :
@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>
Mettre à jour le modèle de page de modification
Mettez à jour Pages/Departments/Edit.cshtml.cs
à l’aide du code suivant :
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.");
}
}
}
Les mises à jour de l’accès concurrentiel
OriginalValue est mise à jour avec la valeur ConcurrencyToken
de l’entité au moment où elle a été récupérée dans la méthode OnGetAsync
. EF Core génère une commande SQL UPDATE
avec une clause WHERE
contenant la valeur ConcurrencyToken
d’origine. Si aucune ligne n’est affectée par la commande UPDATE
, une exception DbUpdateConcurrencyException
est levée. Aucune ligne n’est affectée par la commande UPDATE
quand aucune ligne n’a la valeur ConcurrencyToken
d’origine.
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;
Dans le code en surbrillance précédent :
- La valeur dans
Department.ConcurrencyToken
est la valeur lorsque l’entité a été extraite dans la requêteGet
pour la pageEdit
. La valeur est fournie à la méthodeOnPost
par un champ masqué de la page Razor qui affiche l’entité à modifier. La valeur du champ masqué est copiée dansDepartment.ConcurrencyToken
par le classeur de modèles. OriginalValue
est ce que EF Core utilise dans la clauseWHERE
. Avant l’exécution de la ligne de code mise en surbrillance :OriginalValue
a la valeur qui se trouvait dans la base de données quandFirstOrDefaultAsync
a été appelée dans cette méthode.- Cette valeur peut être différente de celle affichée dans la page Modifier.
- Le code en surbrillance garantit qu’EF Core utilise la valeur
ConcurrencyToken
d’origine de l’entitéDepartment
affichée dans la clauseUPDATE
de l’instruction SQLWHERE
.
Le code suivant montre le modèle Department
. Department
est initialisé dans la :
- méthode
OnGetAsync
par la requête EF. - méthode
OnPostAsync
par le champ masqué dans la page Razor à l’aide de la liaison de données :
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;
Le code précédent montre que la valeur ConcurrencyToken
de l’entité Department
de la requête HTTP POST
est définie sur la valeur ConcurrencyToken
de la requête HTTP GET
.
Quand une erreur d’accès concurrentiel se produit, le code en surbrillance suivant obtient les valeurs du client (valeurs publiées dans cette méthode) et les valeurs de la base de données.
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)}");
}
Le code suivant ajoute un message d’erreur personnalisé pour chaque colonne dont les valeurs dans la base de données sont différentes de celles envoyées à 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.");
}
Le code en surbrillance suivant affecte à ConcurrencyToken
la nouvelle valeur récupérée à partir de la base de données. La prochaine fois que l’utilisateur cliquera sur Save, seules les erreurs d’accès concurrentiel qui se sont produites depuis le dernier affichage de la page Edit seront interceptées.
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’instruction ModelState.Remove
est nécessaire, car ModelState
contient l’ancienne valeur ConcurrencyToken
. Dans la Page Razor, la valeur ModelState
d’un champ est prioritaire par rapport aux valeurs de propriétés du modèle quand les deux sont présentes.
Différences de code entre SQL Server et SQLite
Voici les différences entre les versions SQL Server et 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;
Mettre à jour la page Edit Razor
Mettez à jour Pages/Departments/Edit.cshtml
à l’aide du code suivant :
@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");}
}
Le code précédent :
- Il met à jour la directive
page
en remplaçant@page
par@page "{id:int}"
. - Ajoute une version de ligne masquée.
ConcurrencyToken
doit être ajouté afin que la publication (postback) lie la valeur. - Affiche le dernier octet de
ConcurrencyToken
à des fins de débogage. - Remplace
ViewData
par leInstructorNameSL
fortement typé.
Tester les conflits d’accès concurrentiel avec la page Edit
Ouvrez deux instances de navigateur de la page Edit sur le département English :
- Exécutez l’application et sélectionnez Departments.
- Cliquez avec le bouton droit sur le lien hypertexte Edit correspondant au département English, puis sélectionnez Open in new tab.
- Sous le premier onglet, cliquez sur le lien hypertexte Edit correspondant au département English.
Les deux onglets de navigateur affichent les mêmes informations.
Changez le nom sous le premier onglet de navigateur, puis cliquez sur Save.
Le navigateur affiche la page Index avec la valeur modifiée et un indicateur ConcurrencyToken
mis à jour. Notez l’indicateur ConcurrencyToken
mis à jour ; il est affiché sur la deuxième publication (postback) dans l’autre onglet.
Changez un champ différent sous le deuxième onglet du navigateur.
Cliquez sur Enregistrer. Des messages d’erreur s’affichent pour tous les champs qui ne correspondent pas aux valeurs de la base de données :
Cette fenêtre de navigateur n’avait pas l’intention de changer le champ Name. Copiez et collez la valeur actuelle (Languages) dans le champ Name. Appuyez sur Tab. La validation côté client supprime le message d’erreur.
Cliquez à nouveau sur Enregistrer. La valeur que vous avez entrée sous le deuxième onglet du navigateur est enregistrée. Les valeurs enregistrées sont visibles dans la page Index.
Mettre à jour le modèle de page Delete
Mettez à jour Pages/Departments/Delete.cshtml.cs
à l’aide du code suivant :
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 page Delete détecte les conflits d’accès concurrentiel quand l’entité a changé après avoir été récupérée. Department.ConcurrencyToken
est la version de ligne quand l’entité a été récupérée. Quand EF Core crée la commande SQL DELETE
, il inclut une clause WHERE avec ConcurrencyToken
. Si après l’exécution de la commande SQL DELETE
aucune ligne n’est affectée :
- La valeur de
ConcurrencyToken
dans la commandeSQL DELETE
ne correspond pas àConcurrencyToken
dans la base de données. - Une exception
DbUpdateConcurrencyException
est levée. OnGetAsync
est appelée avecconcurrencyError
.
Mettre à jour la page Razor Delete
Mettez à jour Pages/Departments/Delete.cshtml
à l’aide du code suivant :
@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>
Le code précédent apporte les modifications suivantes :
- Il met à jour la directive
page
en remplaçant@page
par@page "{id:int}"
. - Il ajoute un message d’erreur.
- Il remplace FirstMidName par FullName dans le champ Administrator.
- Il change
ConcurrencyToken
pour afficher le dernier octet. - Ajoute une version de ligne masquée.
ConcurrencyToken
doit être ajouté afin que la publication (postback) lie la valeur.
Tester les conflits d'accès concurrentiel
Créez un département test.
Ouvrez deux instances de navigateur de la page Delete sur le département test :
- Exécutez l’application et sélectionnez Departments.
- Cliquez avec le bouton droit sur le lien hypertexte Delete correspondant au département test, puis sélectionnez Open in new tab.
- Cliquez sur le lien hypertexte Edit correspondant au département test.
Les deux onglets de navigateur affichent les mêmes informations.
Changez le budget sous le premier onglet de navigateur, puis cliquez sur Save.
Le navigateur affiche la page Index avec la valeur modifiée et un indicateur ConcurrencyToken
mis à jour. Notez l’indicateur ConcurrencyToken
mis à jour ; il est affiché sur la deuxième publication (postback) dans l’autre onglet.
Supprimez le service de test du deuxième onglet. Une erreur d’accès concurrentiel s’affiche avec les valeurs actuelles de la base de données. Un clic sur Supprimer supprime l’entité, sauf si ConcurrencyToken
a été mis à jour.
Ressources supplémentaires
- Jetons d’accès concurrentiel dans EF Core
- Gérer l’accès concurrentiel dans EF Core
- Débogage d’une source ASP.NET Core 2.x
Étapes suivantes
Ce tutoriel est le dernier de la série. Des rubriques supplémentaires sont abordées dans la version MVC de cette série de tutoriels.
Ce didacticiel montre comment gérer les conflits quand plusieurs utilisateurs mettent à jour une entité en même temps.
Conflits d’accès concurrentiel
Un conflit d’accès concurrentiel se produit quand :
- Un utilisateur accède à la page de modification d’une entité.
- Un autre utilisateur met à jour la même entité avant que la modification du premier utilisateur soit écrite dans la base de données.
Si la détection de l’accès concurrentiel n’est pas activée, quiconque qui met à jour la base de données en dernier remplace les modifications de l’autre utilisateur. Si ce risque est acceptable, le coût de programmation de l’accès concurrentiel peut l’emporter sur l’avantage.
Accès concurrentiel pessimiste (verrouillage)
Une façon d’éviter les conflits d’accès concurrentiel consiste à utiliser des verrous de base de données. Ceci est appelé « accès concurrentiel pessimiste ». Avant de lire une ligne de base de données qu’elle entend mettre à jour, une application demande un verrou. Dès lors qu’une ligne est verrouillée pour l’accès aux mises à jour, aucun autre utilisateur n’est autorisé à verrouiller la ligne tant que le premier verrou n’est pas libéré.
La gestion des verrous présente des inconvénients. Elle peut être difficile à programmer et peut occasionner des problèmes de performances à mesure que le nombre d’utilisateurs augmente. Entity Framework Core n’assure pas de prise en charge intégrée pour celle-ci et ce tutoriel ne vous montre pas comment l’implémenter.
Accès concurrentiel optimiste
L’accès concurrentiel optimiste autorise la survenance des conflits d’accès concurrentiel, et réagit correctement quand ils surviennent. Par exemple, Jane consulte la page de modification de département et change le montant de « Budget » pour le département « English » en le faisant passer de 350 000,00 $ à 0,00 $.
Avant que Jane clique sur Save, John consulte la même page et change le champ Start Date de 01/09/2007 en 01/09/2013.
Jane clique d’abord sur Save et voit sa modification prendre effet, puisque le navigateur affiche la page d’index avec un montant de budget égal à zéro.
John clique sur Save dans une page Edit qui affiche toujours un budget de 350 000,00 $. Ce qui se passe ensuite dépend de la façon dont vous gérez les conflits d’accès concurrentiel :
Vous pouvez effectuer le suivi des propriétés modifiées par un utilisateur et mettre à jour seulement les colonnes correspondantes dans la base de données.
Dans le scénario, aucune donnée ne serait perdue. Des propriétés différentes ont été mises à jour par les deux utilisateurs. La prochaine fois que quelqu’un consultera le département « English », il verra à la fois les modifications de Jane et de John. Cette méthode de mise à jour peut réduire le nombre de conflits susceptibles d’entraîner une perte de données. Cette approche présente quelques inconvénients :
- Elle ne peut pas éviter une perte de données si des modifications concurrentes sont apportées à la même propriété.
- Elle n’est généralement pas pratique dans une application web. Elle nécessite la tenue à jour d’un état significatif afin d’effectuer le suivi de toutes les valeurs récupérées et des nouvelles valeurs. La maintenance de grandes quantités d’état peut affecter les performances de l’application.
- Elle peut augmenter la complexité de l’application par rapport à la détection de l’accès concurrentiel sur une entité.
Vous pouvez laisser les modifications de John remplacer les modifications de Jane.
La prochaine fois que quelqu’un consultera le département « English », il verra la date 01/09/2013 et la valeur 350 000,00 $ récupérée. Cette approche est un scénario Priorité au client ou Priorité au dernier. (Toutes les valeurs du client sont prioritaires sur ce qui se trouve dans la banque de données.) Si vous n’effectuez aucun codage pour la gestion de la concurrence, Client Wins se produit automatiquement.
Vous pouvez empêcher les modifications de John de faire l’objet d’une mise à jour dans la base de données. En règle générale, l’application :
- affiche un message d’erreur ;
- indique l’état actuel des données ;
- autorise l’utilisateur à réappliquer les modifications.
Il s’agit alors d’un scénario Priorité au magasin. (Les valeurs de la banque de données sont prioritaires par rapport à celles soumises par le client.) Dans ce tutoriel, vous implémentez le scénario Store Wins. Cette méthode garantit qu’aucune modification n’est remplacée sans qu’un utilisateur soit averti.
Détection de conflit dans EF Core
EF Core lève des exceptions DbConcurrencyException
quand il détecte des conflits. Le modèle de données doit être configuré pour activer la détection de conflits. Voici les options qui permettent d’activer la détection de conflits :
Configurez EF Core de façon à inclure les valeurs d’origine des colonnes configurées en tant que jetons d’accès concurrentiel dans la clause Where des commandes Mettre à jour et Supprimer.
Quand
SaveChanges
est appelé, la clause Where recherche les valeurs d’origine des propriétés annotées avec l’attribut ConcurrencyCheckAttribute. L’instruction update ne trouve pas de ligne à mettre à jour si aucune propriété de jeton d’accès concurrentiel n’a changé depuis la première lecture de la ligne. EF Core interprète cela comme un conflit d’accès concurrentiel. Pour les tables de base de données qui comptent de nombreuses colonnes, cette approche peut aboutir à des clauses Where de très grande taille et peut nécessiter de grandes quantités d’états. Par conséquent, cette approche n’est généralement pas recommandée et n’est pas la méthode utilisée dans ce didacticiel.Dans la table de base de données, incluez une colonne de suivi qui peut être utilisée pour déterminer quand une ligne a été modifiée.
Dans une base de données SQL Server, le type de données de la colonne de suivi est
rowversion
. La valeur derowversion
est un nombre séquentiel qui est incrémenté chaque fois que la ligne est mise à jour. Dans une commande Update ou Delete, la clause Where inclut la valeur d’origine de la colonne de suivi (le numéro de version de la ligne d’origine). Si la ligne mise à jour a été modifiée par un autre utilisateur, la valeur de la colonnerowversion
est différente de la valeur d’origine. Dans ce cas, l’instruction Update ou Delete ne peut pas trouver la ligne à mettre à jour en raison de la clause Where. EF Core lève une exception d’accès concurrentiel quand aucune ligne n’est affectée par une commande Mettre à jour ou Supprimer.
Ajouter une propriété de suivi
Dans Models/Department.cs
, ajoutez une propriété de suivi nommée 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’attribut TimestampAttribute est ce qui identifie la colonne en tant que colonne de suivi d’accès concurrentiel. L’API Fluent est un autre moyen de spécifier la propriété de suivi :
modelBuilder.Entity<Department>()
.Property<byte[]>("RowVersion")
.IsRowVersion();
Pour une base de données SQL Server, l’attribut [Timestamp]
d’une propriété d’entité définie en tant que tableau d’octets :
- Entraîne l’inclusion de la colonne dans les clauses Where des commandes DELETE et UPDATE.
- Définit le type de colonne dans la base de données sur rowversion.
La base de données génère un numéro de version de ligne séquentiel qui est incrémenté chaque fois que la ligne est mise à jour. Dans une commande Update
ou Delete
, la clause Where
comprend la valeur de version de ligne récupérée. Si la ligne mise à jour a changé depuis sa récupération :
- La valeur de version de ligne actuelle ne correspond pas à la valeur récupérée.
- Les commandes
Update
ouDelete
ne trouvent pas de ligne, car la clauseWhere
recherche la valeur de version de ligne récupérée. - Une
DbUpdateConcurrencyException
est levée.
Le code suivant montre une partie du T-SQL généré par EF Core quand le nom du service est mis à jour :
SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
Le code en surbrillance ci-dessus montre la clause WHERE
contenant RowVersion
. Si la base de données RowVersion
n’est pas égale au paramètre RowVersion
(@p2
), aucune ligne n’est mise à jour.
Le code en surbrillance suivant montre le T-SQL qui vérifie qu’une seule ligne a été mise à jour :
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 retourne le nombre de lignes affectées par la dernière instruction. Si aucune ligne n’est mise à jour, EF Core lève un DbUpdateConcurrencyException
.
Mettre à jour la base de données
L’ajout de la propriété RowVersion
change le modèle de données, ce qui nécessite une migration.
Créez le projet.
Exécutez la commande suivante dans PMC :
Add-Migration RowVersion
Cette commande :
Crée le fichier de migration
Migrations/{time stamp}_RowVersion.cs
.Met à jour le fichier
Migrations/SchoolContextModelSnapshot.cs
. La mise à jour ajoute le code en surbrillance suivant à la méthodeBuildModel
: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"); });
Exécutez la commande suivante dans PMC :
Update-Database
Générer automatiquement des modèles de pages Department
Suivez les instructions dans Générer automatiquement des modèles de pages Student avec les exceptions suivantes :
Créez un dossier Pages/Departments.
Utilisez
Department
pour la classe de modèle.- Utilisez la classe de contexte existante au lieu d’en créer une nouvelle.
Créez le projet.
Mettre à jour la page Index
L’outil de génération de modèles automatique créé une colonne RowVersion
pour la page Index, mais ce champ ne s’affiche pas dans une application de production. Dans ce tutoriel, le dernier octet de RowVersion
est affiché pour montrer comment la gestion de l’accès concurrentiel fonctionne. Il n’est pas garanti que le dernier octet soit unique par lui-même.
Mettre à jour la page Pages\Departments\Index.cshtml :
- Remplacez Index par Departments.
- Modifiez le code contenant
RowVersion
pour afficher uniquement le dernier octet du tableau d’octets. - Remplacez FirstMidName par FullName.
Le code suivant affiche la page mise à jour :
@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>
Mettre à jour le modèle de page de modification
Mettez à jour Pages/Departments/Edit.cshtml.cs
à l’aide du code suivant :
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.");
}
}
}
Le OriginalValue est mis à jour avec la valeur rowVersion
de l’entité au moment où elle a été récupérée dans la méthode OnGetAsync
. EF Core génère une commande SQL UPDATE avec une clause WHERE contenant la valeur RowVersion
d’origine. Si aucune ligne n’est affectée par la commande UPDATE (aucune ligne ne contient la valeur RowVersion
d’origine), une exception DbUpdateConcurrencyException
est levée.
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;
Dans le code en surbrillance précédent :
- La valeur de
Department.RowVersion
est celle qui se trouvait dans l’entité au moment où elle a été initialement récupérée dans la requête Get pour la page Edit. La valeur est fournie à la méthodeOnPost
par un champ masqué de la page Razor qui affiche l’entité à modifier. La valeur du champ masqué est copiée dansDepartment.RowVersion
par le classeur de modèles. OriginalValue
est la valeur qu’utilisera EF Core dans la clause Where. Avant l’exécution de la ligne de code en surbrillance,OriginalValue
a la valeur qui se trouvait dans la base de données au moment oùFirstOrDefaultAsync
été appelé dans cette méthode, laquelle risque d’être différente de celle qui figurait dans la page Edit.- Le code en surbrillance garantit qu’EF Core utilise la valeur
RowVersion
d’origine de l’entitéDepartment
affichée dans la clause Where de l’instruction SQL UPDATE.
Quand une erreur d’accès concurrentiel se produit, le code en surbrillance suivant obtient les valeurs du client (valeurs publiées dans cette méthode) et les valeurs de la base de données.
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");
}
Le code suivant ajoute un message d’erreur personnalisé pour chaque colonne dont les valeurs dans la base de données sont différentes de celles envoyées à 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.");
}
Le code en surbrillance suivant affecte à RowVersion
la nouvelle valeur récupérée à partir de la base de données. La prochaine fois que l’utilisateur cliquera sur Save, seules les erreurs d’accès concurrentiel qui se sont produites depuis le dernier affichage de la page Edit seront interceptées.
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’instruction ModelState.Remove
est nécessaire car ModelState
contient l’ancienne valeur RowVersion
. Dans la Page Razor, la valeur ModelState
d’un champ est prioritaire par rapport aux valeurs de propriétés du modèle quand les deux sont présentes.
Mettre à jour la page Edit
Mettez à jour Pages/Departments/Edit.cshtml
à l’aide du code suivant :
@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");}
}
Le code précédent :
- Il met à jour la directive
page
en remplaçant@page
par@page "{id:int}"
. - Ajoute une version de ligne masquée.
RowVersion
doit être ajouté afin que la publication (postback) lie la valeur. - Affiche le dernier octet de
RowVersion
à des fins de débogage. - Remplace
ViewData
par leInstructorNameSL
fortement typé.
Tester les conflits d’accès concurrentiel avec la page Edit
Ouvrez deux instances de navigateur de la page Edit sur le département English :
- Exécutez l’application et sélectionnez Departments.
- Cliquez avec le bouton droit sur le lien hypertexte Edit correspondant au département English, puis sélectionnez Open in new tab.
- Sous le premier onglet, cliquez sur le lien hypertexte Edit correspondant au département English.
Les deux onglets de navigateur affichent les mêmes informations.
Changez le nom sous le premier onglet de navigateur, puis cliquez sur Save.
Le navigateur affiche la page Index avec la valeur modifiée et un indicateur rowVersion mis à jour. Notez l’indicateur rowVersion mis à jour ; il est affiché sur la deuxième publication (postback) sous l’autre onglet.
Changez un champ différent sous le deuxième onglet du navigateur.
Cliquez sur Enregistrer. Des messages d’erreur s’affichent pour tous les champs qui ne correspondent pas aux valeurs de la base de données :
Cette fenêtre de navigateur n’avait pas l’intention de changer le champ Name. Copiez et collez la valeur actuelle (Languages) dans le champ Name. Appuyez sur Tab. La validation côté client supprime le message d’erreur.
Cliquez à nouveau sur Enregistrer. La valeur que vous avez entrée sous le deuxième onglet du navigateur est enregistrée. Les valeurs enregistrées sont visibles dans la page Index.
Mettre à jour le modèle de page Delete
Mettez à jour Pages/Departments/Delete.cshtml.cs
à l’aide du code suivant :
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 page Delete détecte les conflits d’accès concurrentiel quand l’entité a changé après avoir été récupérée. Department.RowVersion
est la version de ligne quand l’entité a été récupérée. Quand EF Core crée la commande SQL DELETE, il inclut une clause WHERE avec RowVersion
. Si après l’exécution de la commande SQL DELETE aucune ligne n’est affectée :
- La valeur de
RowVersion
dans la commande SQL DELETE ne correspond pas àRowVersion
dans la base de données. - Une exception DbUpdateConcurrencyException est levée.
OnGetAsync
est appelée avecconcurrencyError
.
Mettre à jour la page Delete
Mettez à jour Pages/Departments/Delete.cshtml
à l’aide du code suivant :
@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>
Le code précédent apporte les modifications suivantes :
- Il met à jour la directive
page
en remplaçant@page
par@page "{id:int}"
. - Il ajoute un message d’erreur.
- Il remplace FirstMidName par FullName dans le champ Administrator.
- Il change
RowVersion
pour afficher le dernier octet. - Ajoute une version de ligne masquée.
RowVersion
doit être ajouté afin que la publication (postback) lie la valeur.
Tester les conflits d'accès concurrentiel
Créez un département test.
Ouvrez deux instances de navigateur de la page Delete sur le département test :
- Exécutez l’application et sélectionnez Departments.
- Cliquez avec le bouton droit sur le lien hypertexte Delete correspondant au département test, puis sélectionnez Open in new tab.
- Cliquez sur le lien hypertexte Edit correspondant au département test.
Les deux onglets de navigateur affichent les mêmes informations.
Changez le budget sous le premier onglet de navigateur, puis cliquez sur Save.
Le navigateur affiche la page Index avec la valeur modifiée et un indicateur rowVersion mis à jour. Notez l’indicateur rowVersion mis à jour ; il est affiché sur la deuxième publication (postback) sous l’autre onglet.
Supprimez le service de test du deuxième onglet. Une erreur d’accès concurrentiel s’affiche avec les valeurs actuelles de la base de données. Un clic sur Supprimer supprime l’entité, sauf si RowVersion
a été mis à jour.
Ressources supplémentaires
- Jetons d’accès concurrentiel dans EF Core
- Gérer l’accès concurrentiel dans EF Core
- Débogage d’une source ASP.NET Core 2.x
Étapes suivantes
Ce tutoriel est le dernier de la série. Des rubriques supplémentaires sont abordées dans la version MVC de cette série de tutoriels.
Ce didacticiel montre comment gérer les conflits quand plusieurs utilisateurs mettent à jour une entité en même temps. Si vous rencontrez des problèmes que vous ne pouvez pas résoudre, téléchargez ou affichez l’application terminée. Télécharger les instructions.
Conflits d’accès concurrentiel
Un conflit d’accès concurrentiel se produit quand :
- Un utilisateur accède à la page de modification d’une entité.
- Un autre utilisateur met à jour la même entité avant que la modification du premier utilisateur soit écrite dans la base de données.
Si la détection d’accès concurrentiel n’est pas activée, quand des mises à jour simultanées se produisent :
- La dernière mise à jour est prioritaire. Autrement dit, les dernières valeurs mises à jour sont enregistrées dans la base de données.
- La première des mises à jour en cours est perdue.
Accès concurrentiel optimiste
L’accès concurrentiel optimiste autorise la survenance des conflits d’accès concurrentiel, et réagit correctement quand ils surviennent. Par exemple, Jane consulte la page de modification de département et change le montant de « Budget » pour le département « English » en le faisant passer de 350 000,00 $ à 0,00 $.
Avant que Jane clique sur Save, John consulte la même page et change le champ Start Date de 01/09/2007 en 01/09/2013.
Jane clique la première sur Save et voit sa modification quand le navigateur revient à la page Index.
John clique sur Save dans une page Edit qui affiche toujours un budget de 350 000,00 $. Ce qui se passe ensuite est déterminé par la façon dont vous gérez les conflits d’accès concurrentiel.
L’accès concurrentiel optimiste comprend les options suivantes :
Vous pouvez effectuer le suivi des propriétés modifiées par un utilisateur et mettre à jour seulement les colonnes correspondantes dans la base de données.
Dans le scénario, aucune donnée ne serait perdue. Des propriétés différentes ont été mises à jour par les deux utilisateurs. La prochaine fois que quelqu’un consultera le département « English », il verra à la fois les modifications de Jane et de John. Cette méthode de mise à jour peut réduire le nombre de conflits susceptibles d’entraîner une perte de données. Cette approche a les caractéristiques suivantes :
- Elle ne peut pas éviter une perte de données si des modifications concurrentes sont apportées à la même propriété.
- Elle n’est généralement pas pratique dans une application web. Elle nécessite la tenue à jour d’un état significatif afin d’effectuer le suivi de toutes les valeurs récupérées et des nouvelles valeurs. La maintenance de grandes quantités d’état peut affecter les performances de l’application.
- Elle peut augmenter la complexité de l’application par rapport à la détection de l’accès concurrentiel sur une entité.
Vous pouvez laisser les modifications de John remplacer les modifications de Jane.
La prochaine fois que quelqu’un consultera le département « English », il verra la date 01/09/2013 et la valeur 350 000,00 $ récupérée. Cette approche est un scénario Priorité au client ou Priorité au dernier. (Toutes les valeurs du client sont prioritaires sur ce qui se trouve dans la banque de données.) Si vous n’effectuez aucun codage pour la gestion de la concurrence, Client Wins se produit automatiquement.
Vous pouvez empêcher les modifications de John d’être mises à jour dans la base de données. En règle générale, l’application :
- affiche un message d’erreur ;
- indique l’état actuel des données ;
- autorise l’utilisateur à réappliquer les modifications.
Il s’agit alors d’un scénario Priorité au magasin. (Les valeurs de la banque de données sont prioritaires par rapport à celles soumises par le client.) Dans ce tutoriel, vous implémentez le scénario Store Wins. Cette méthode garantit qu’aucune modification n’est remplacée sans qu’un utilisateur soit averti.
Gestion de l’accès concurrentiel
Quand une propriété est configurée en tant que jeton d’accès concurrentiel :
- EF Core vérifie que cette propriété n’a pas été modifiée après avoir été récupérée. La vérification se produit lorsque SaveChanges ou SaveChangesAsync est appelé.
- Si la propriété a été modifiée après avoir été récupérée, une DbUpdateConcurrencyException est levée.
Le modèle de données et de la base de données doivent être configurés pour prendre en charge la levée de DbUpdateConcurrencyException
.
Détection des conflits d’accès concurrentiel sur une propriété
Les conflits d’accès concurrentiel peuvent être détectés au niveau de la propriété avec l’attribut ConcurrencyCheck. L’attribut peut être appliqué à plusieurs propriétés sur le modèle. Pour plus d’informations, consultez Data Annotations-ConcurrencyCheck (Annotations de données-ConcurrencyCheck).
Nous n’utilisons pas l’attribut [ConcurrencyCheck]
dans ce didacticiel.
Détection des conflits d’accès concurrentiel sur une ligne
Pour détecter les conflits d’accès concurrentiel, une colonne de suivi rowversion est ajoutée au modèle. rowversion
:
- est propre à SQL Server. D’autres bases de données peuvent ne pas fournir une fonctionnalité similaire.
- Sert à déterminer qu’une entité n’a pas été modifiée depuis qu’elle a été récupérée à partir de la base de données.
La base de données génère un numéro rowversion
séquentiel qui est incrémenté chaque fois que la ligne est mise à jour. Dans une commande Update
ou Delete
, la clause Where
comprend la valeur récupérée de rowversion
. Si la ligne mise à jour a changé :
rowversion
ne correspond pas à la valeur récupérée.- Les commandes
Update
ouDelete
ne trouvent pas de ligne, car la clauseWhere
comprend la valeurrowversion
récupérée. - Une
DbUpdateConcurrencyException
est levée.
Dans EF Core, quand aucune ligne n’a été mise à jour par une commande Update
ou Delete
, une exception d’accès concurrentiel est levée.
Ajouter une propriété de suivi à l’entité Department
Dans Models/Department.cs
, ajoutez une propriété de suivi nommée 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’attribut Timestamp spécifie que cette colonne est incluse dans la clause Where
des commandes Update
et Delete
. L’attribut se nomme Timestamp
, car les versions précédentes de SQL Server utilisaient un type de données SQL timestamp
avant son remplacement par le type SQL rowversion
.
L’API Fluent peut également spécifier la propriété de suivi :
modelBuilder.Entity<Department>()
.Property<byte[]>("RowVersion")
.IsRowVersion();
Le code suivant montre une partie du T-SQL généré par EF Core quand le nom du service est mis à jour :
SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
Le code en surbrillance ci-dessus montre la clause WHERE
contenant RowVersion
. Si la RowVersion
de la base de données n’est pas égale au paramètre RowVersion
(@p2
), aucune ligne n’est mise à jour.
Le code en surbrillance suivant montre le T-SQL qui vérifie qu’une seule ligne a été mise à jour :
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 retourne le nombre de lignes affectées par la dernière instruction. Si aucune ligne n’est mise à jour, EF Core lève une DbUpdateConcurrencyException
.
Vous pouvez voir le T-SQL généré par EF Core dans la fenêtre de sortie de Visual Studio.
Mettre à jour la base de données
L’ajout de la propriété RowVersion
change le modèle de base de données, ce qui nécessite une migration.
Créez le projet. Entrez ce qui suit dans une fenêtre de commande :
dotnet ef migrations add RowVersion
dotnet ef database update
Les commandes précédentes :
Crée le fichier de migration
Migrations/{time stamp}_RowVersion.cs
.Met à jour le fichier
Migrations/SchoolContextModelSnapshot.cs
. La mise à jour ajoute le code en surbrillance suivant à la méthodeBuildModel
:Exécutent des migrations pour mettre à jour la base de données.
Générer automatiquement le modèle Departments
Suivez les instructions fournies dans Générer automatiquement le modèle d’étudiant et utilisez Department
pour la classe de modèle.
La commande précédente génère automatiquement le modèle Department
. Ouvrez le projet dans Visual Studio.
Créez le projet.
Mettre à jour la page d’index des départements
Le moteur de génération de modèles automatique créé une colonne RowVersion
pour la page Index, mais ce champ ne doit pas être affiché. Dans ce didacticiel, le dernier octet de RowVersion
est affiché afin d’aider à mieux comprendre l’accès concurrentiel. Il n’est pas garanti que le dernier octet soit unique. Une application réelle n’afficherait pas RowVersion
ou le dernier octet de RowVersion
.
Mettez à jour la page Index :
- Remplacez Index par Departments.
- Remplacez le balisage contenant
RowVersion
par le dernier octet deRowVersion
. - Remplacez FirstMidName par FullName.
Le balisage suivant montre la page mise à jour :
@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>
Mettre à jour le modèle de page de modification
Mettez à jour Pages/Departments/Edit.cshtml.cs
à l’aide du code suivant :
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.");
}
}
}
Pour détecter un problème d’accès concurrentiel, la OriginalValueest mise à jour avec la valeur rowVersion
de l’entité récupérée. EF Core génère une commande SQL UPDATE avec une clause WHERE contenant la valeur RowVersion
d’origine. Si aucune ligne n’est affectée par la commande UPDATE (aucune ligne ne contient la valeur RowVersion
d’origine), une exception DbUpdateConcurrencyException
est levée.
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;
Dans le code précédent, Department.RowVersion
est la valeur quand l’entité a été récupérée. OriginalValue
est la valeur présente dans la base de données quand FirstOrDefaultAsync
a été appelée dans cette méthode.
Le code suivant obtient les valeurs du client (celles envoyées à cette méthode) et les valeurs de la base de données :
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");
}
Le code suivant ajoute un message d’erreur personnalisé pour chaque colonne dont les valeurs dans la base de données sont différentes de celles envoyées à 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.");
}
Le code en surbrillance suivant affecte à RowVersion
la nouvelle valeur récupérée à partir de la base de données. La prochaine fois que l’utilisateur cliquera sur Save, seules les erreurs d’accès concurrentiel qui se sont produites depuis le dernier affichage de la page Edit seront interceptées.
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’instruction ModelState.Remove
est nécessaire car ModelState
contient l’ancienne valeur RowVersion
. Dans la Page Razor, la valeur ModelState
d’un champ est prioritaire par rapport aux valeurs de propriétés du modèle quand les deux sont présentes.
Mettre à jour la page Edit
Mettez à jour Pages/Departments/Edit.cshtml
avec la balise suivante :
@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");}
}
Le balisage précédent :
- Il met à jour la directive
page
en remplaçant@page
par@page "{id:int}"
. - Ajoute une version de ligne masquée.
RowVersion
doit être ajouté afin que la publication lie la valeur. - Affiche le dernier octet de
RowVersion
à des fins de débogage. - Remplace
ViewData
par leInstructorNameSL
fortement typé.
Tester les conflits d’accès concurrentiel avec la page Edit
Ouvrez deux instances de navigateur de la page Edit sur le département English :
- Exécutez l’application et sélectionnez Departments.
- Cliquez avec le bouton droit sur le lien hypertexte Edit correspondant au département English, puis sélectionnez Open in new tab.
- Sous le premier onglet, cliquez sur le lien hypertexte Edit correspondant au département English.
Les deux onglets de navigateur affichent les mêmes informations.
Changez le nom sous le premier onglet de navigateur, puis cliquez sur Save.
Le navigateur affiche la page Index avec la valeur modifiée et un indicateur rowVersion mis à jour. Notez l’indicateur rowVersion mis à jour ; il est affiché sur la deuxième publication (postback) sous l’autre onglet.
Changez un champ différent sous le deuxième onglet du navigateur.
Cliquez sur Enregistrer. Des messages d’erreur s’affichent pour tous les champs qui ne correspondent pas aux valeurs de la base de données :
Cette fenêtre de navigateur n’avait pas l’intention de changer le champ Name. Copiez et collez la valeur actuelle (Languages) dans le champ Name. Appuyez sur Tab. La validation côté client supprime le message d’erreur.
Cliquez à nouveau sur Enregistrer. La valeur que vous avez entrée sous le deuxième onglet du navigateur est enregistrée. Les valeurs enregistrées sont visibles dans la page Index.
Mettre à jour la page Delete
Mettez à jour le modèle de page de suppression avec le code suivant :
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 page Delete détecte les conflits d’accès concurrentiel quand l’entité a changé après avoir été récupérée. Department.RowVersion
est la version de ligne quand l’entité a été récupérée. Quand EF Core crée la commande SQL DELETE, il inclut une clause WHERE avec RowVersion
. Si après l’exécution de la commande SQL DELETE aucune ligne n’est affectée :
- La valeur de
RowVersion
dans la commande SQL DELETE ne correspond pas àRowVersion
dans la base de données. - Une exception DbUpdateConcurrencyException est levée.
OnGetAsync
est appelée avecconcurrencyError
.
Mettre à jour la page Delete
Mettez à jour Pages/Departments/Delete.cshtml
à l’aide du code suivant :
@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>
Le code précédent apporte les modifications suivantes :
- Il met à jour la directive
page
en remplaçant@page
par@page "{id:int}"
. - Il ajoute un message d’erreur.
- Il remplace FirstMidName par FullName dans le champ Administrator.
- Il change
RowVersion
pour afficher le dernier octet. - Ajoute une version de ligne masquée.
RowVersion
doit être ajouté afin que la publication lie la valeur.
Tester les conflits d’accès concurrentiel avec la page Delete
Créez un département test.
Ouvrez deux instances de navigateur de la page Delete sur le département test :
- Exécutez l’application et sélectionnez Departments.
- Cliquez avec le bouton droit sur le lien hypertexte Delete correspondant au département test, puis sélectionnez Open in new tab.
- Cliquez sur le lien hypertexte Edit correspondant au département test.
Les deux onglets de navigateur affichent les mêmes informations.
Changez le budget sous le premier onglet de navigateur, puis cliquez sur Save.
Le navigateur affiche la page Index avec la valeur modifiée et un indicateur rowVersion mis à jour. Notez l’indicateur rowVersion mis à jour ; il est affiché sur la deuxième publication (postback) sous l’autre onglet.
Supprimez le service de test du deuxième onglet. Une erreur d’accès concurrentiel s’affiche avec les valeurs actuelles de la base de données. Un clic sur Supprimer supprime l’entité, sauf si RowVersion
a été mis à jour.
Pour découvrir comment hériter d’un modèle de données, consultez Héritage.