Partie 8, Pages Razor avec EF Core dans ASP.NET Core - Concurrence

Tom Dykstra et Jon P Smith

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 $.

Changing budget to 0

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.

Changing start date to 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 :

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éthode BuildModel :
 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 #if SQLiteVersiondirective de préprocesseur 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 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>
                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ête Get pour la page Edit. La valeur est fournie à la méthode OnPost par un champ masqué de la page Razor qui affiche l’entité à modifier. La valeur du champ masqué est copiée dans Department.ConcurrencyToken par le classeur de modèles.
  • OriginalValue est ce que EF Core utilise dans la clause WHERE. 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 quand FirstOrDefaultAsync 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 clause UPDATE de l’instruction SQL WHERE.

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 le InstructorNameSL 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.

Department Edit page 1 after change

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.

Department Edit page 2 after change

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 :

Department Edit page error message

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 commande SQL DELETE ne correspond pas à ConcurrencyToken dans la base de données.
  • Une exception DbUpdateConcurrencyException est levée.
  • OnGetAsync est appelée avec concurrencyError.

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

É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 $.

Changing budget to 0

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.

Changing start date to 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 de rowversion 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 colonne rowversion 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 ou Delete ne trouvent pas de ligne, car la clause Where 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éthode BuildModel :

    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éthode OnPost par un champ masqué de la page Razor qui affiche l’entité à modifier. La valeur du champ masqué est copiée dans Department.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 le InstructorNameSL 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.

Department Edit page 1 after change

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.

Department Edit page 2 after change

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 :

Department Edit page error message

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 avec concurrencyError.

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

É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.Instructions de téléchargement.

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 $.

Changing budget to 0

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.

Changing start date to 2013

Jane clique la première sur Save et voit sa modification quand le navigateur revient à la page Index.

Budget changed to zero

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 ou Delete ne trouvent pas de ligne, car la clause Where comprend la valeur rowversion 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éthode BuildModel :

  • 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 de RowVersion.
  • 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 le InstructorNameSL 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.

Department Edit page 1 after change

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.

Department Edit page 2 after change

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 :

Department Edit page error message 1

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.

Department Edit page error message 2

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 avec concurrencyError.

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.

Ressources supplémentaires