Parte 8, Razor Pagine con EF Core in ASP.NET Core - Concorrenza

Tom Dykstra e Jon P Smith

L'app Web Contoso University illustra come creare Razor app Web Pages usando EF Core e Visual Studio. Per informazioni sulla serie di esercitazioni, vedere la prima esercitazione.

Se si verificano problemi che non è possibile risolvere, scaricare l'app completata e confrontare tale codice con quello creato seguendo questa esercitazione.

Questa esercitazione illustra come gestire i conflitti quando più utenti aggiornano un'entità contemporaneamente.

Conflitti di simultaneità

Un conflitto di simultaneità si verifica quando:

  • Un utente passa alla pagina di modifica di un'entità.
  • Un altro utente aggiorna la stessa entità prima che la modifica del primo utente venga scritta nel database.

Se il rilevamento della concorrenza non è abilitato, chi aggiorna per ultimo il database sovrascrive le modifiche dell'altro utente. Se questo rischio è accettabile, il costo della programmazione concorrente potrebbe superare il beneficio.

Concorrenza pessimistica

Un modo per impedire i conflitti di concorrenza consiste nell'usare lock del database. Questo approccio è denominato concorrenza pessimistica. Prima che l'app legga una riga del database che intende aggiornare, richiede un lock. Una volta bloccata una riga per l'accesso per gli aggiornamenti, nessun altro utente potrà bloccare la riga fino a quando non viene rilasciato il primo blocco.

La gestione dei blocchi presenta svantaggi. La programmazione può essere complessa e può causare problemi di prestazioni con l'aumentare del numero di utenti. Entity Framework Core non offre supporto predefinito per la concorrenza pessimistica.

Concorrenza ottimistica

La concorrenza ottimistica consente che si verifichino conflitti di concorrenza e quindi reagisce in modo appropriato quando si verificano. Ad esempio Jane visita la pagina Department Edit (Modifica - Reparto) e cambia il budget per il reparto English (Inglese) da $ 350.000,00 a $ 0,00.

Modifica del budget a 0

Prima che Jane faccia clic su Salva John visita la stessa pagina e cambia il valore del campo Start Date (Data inizio) da 9/1/2007 a 9/1/2013.

Modifica della data di inizio al 2013

Jane fa prima di tutto clic su Save e visualizza l'effetto della modifica, dato che il browser visualizza la pagina Index con zero come importo del budget.

John fa clic su Salva in una pagina Edit (Modifica) che visualizza ancora un budget pari a $ 350.000,00. Le operazioni successive dipendono da come si decide di gestire i conflitti di concorrenza:

  • Tenere traccia di quale proprietà un utente ha modificato e aggiornare solo le colonne corrispondenti nel database.

    In questo scenario, non andrebbe perso alcun dato. I due utenti hanno aggiornato proprietà diverse. Quando un utente torna a visualizzare il reparto English (Inglese), visualizza sia le modifiche di Jane sia quelle di John. Questo metodo di aggiornamento può ridurre il numero di conflitti che potrebbero comportare la perdita di dati. Questo approccio presenta alcuni svantaggi:

    • Non è possibile evitare la perdita di dati se vengono apportate modifiche simultanee alla stessa proprietà.
    • Risulta in genere poco pratico in un'app Web. Richiede il mantenimento di uno stato considerevole per tenere traccia di tutti i valori recuperati e dei nuovi valori. La gestione di grandi quantità di stato può ridurre le prestazioni dell'applicazione.
    • Può rendere più complesse le app rispetto al rilevamento della concorrenza in un'entità.
  • Consenti alla modifica di John di sovrascrivere quella di Jane.

    Quando un utente torna a visualizzare il reparto English (Inglese), visualizza 9/1/2013 e il valore $ 350.000,00 recuperato. Questo scenario è detto Client Wins o Last in Wins (Priorità ultimo accesso). Tutti i valori del client hanno la precedenza su quelli contenuti nell'archivio dati. Il codice con scaffolding non gestisce la concorrenza, il client wins viene eseguito automaticamente.

  • Impedire che le modifiche apportate da John vengano aggiornate nel database. In genere, l'app:

    • Visualizza un messaggio di errore.
    • Visualizza lo stato corrente dei dati.
    • Consente all'utente di riapplicare le modifiche.

    Questo è chiamato scenario Store Wins. I valori dell'archivio dati hanno la precedenza sui valori inviati dal client. Lo scenario Store Wins viene usato in questa esercitazione. Questo metodo garantisce che nessuna modifica venga sovrascritta senza che un utente riceva un avviso.

Rilevamento dei conflitti in EF Core

Le proprietà configurate come token di concorrenza vengono usate per implementare il controllo della concorrenza ottimistica. Quando un'operazione di aggiornamento o eliminazione viene attivata da SaveChanges o SaveChangesAsync, il valore del token di concorrenza nel database viene confrontato con il valore originale letto da EF Core:

  • Se i valori corrispondono, l'operazione può essere completata.
  • Se i valori non corrispondono, EF Core si presuppone che un altro utente abbia eseguito un'operazione in conflitto, interrompe la transazione corrente e genera un'eccezione DbUpdateConcurrencyException.

Un altro utente o processo che esegue un'operazione in conflitto con l'operazione corrente è noto come conflitto di concorrenza.

Nei database EF Core relazionali controlla il valore del token di concorrenza nella WHERE clausola delle UPDATE istruzioni e DELETE per rilevare un conflitto di concorrenza.

Il modello di dati deve essere configurato per abilitare il rilevamento dei conflitti includendo una colonna di rilevamento che può essere utilizzata per determinare quando una riga è stata modificata. EF offre due approcci per i token di concorrenza:

L'approccio di SQL Server e i dettagli dell'implementazione di SQLite sono leggermente diversi. Più avanti nel tutorial viene visualizzato un file con le differenze. La scheda Visual Studio mostra l'approccio di SQL Server. La scheda Visual Studio Code mostra l'approccio per database diversi da SQL Server, come SQLite.

  • Nel modello includere una colonna di rilevamento utilizzata per determinare quando una riga è stata modificata.
  • Applicare l'oggetto TimestampAttribute alla proprietà di concorrenza.

Aggiornare il Models/Department.cs file con il codice evidenziato seguente:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public class Department
    {
        public int DepartmentID { get; set; }

        [StringLength(50, MinimumLength = 3)]
        public string Name { get; set; }

        [DataType(DataType.Currency)]
        [Column(TypeName = "money")]
        public decimal Budget { get; set; }

        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}",
                       ApplyFormatInEditMode = true)]
        [Display(Name = "Start Date")]
        public DateTime StartDate { get; set; }

        public int? InstructorID { get; set; }

        [Timestamp]
        public byte[] ConcurrencyToken { get; set; }

        public Instructor Administrator { get; set; }
        public ICollection<Course> Courses { get; set; }
    }
}

TimestampAttribute è ciò che identifica la colonna come colonna di rilevamento della concorrenza. L'API fluente è un modo alternativo per specificare la proprietà di tracciamento:

modelBuilder.Entity<Department>()
  .Property<byte[]>("ConcurrencyToken")
  .IsRowVersion();

L'attributo [Timestamp] in una proprietà di entità genera il codice seguente nel ModelBuilder metodo :

 b.Property<byte[]>("ConcurrencyToken")
     .IsConcurrencyToken()
     .ValueGeneratedOnAddOrUpdate()
     .HasColumnType("rowversion");

Il codice precedente:

  • Imposta il tipo di ConcurrencyToken proprietà sulla matrice di byte. byte[] è il tipo obbligatorio per SQL Server.
  • Chiama IsConcurrencyToken. IsConcurrencyToken configura la proprietà come token di concorrenza. Durante gli aggiornamenti, il valore del token di concorrenza nel database viene confrontato con il valore originale per verificare che non sia cambiato da quando l'istanza è stata recuperata dal database. Se è stata modificata, viene generata un'eccezione DbUpdateConcurrencyException e le modifiche non vengono applicate.
  • Chiama ValueGeneratedOnAddOrUpdate, che configura la ConcurrencyToken proprietà in modo che venga generato automaticamente un valore durante l'aggiunta o l'aggiornamento di un'entità.
  • HasColumnType("rowversion") imposta il tipo di colonna nel database di SQL Server su rowversion.

Il codice seguente mostra una porzione del codice T-SQL generato da EF Core quando viene aggiornato il nome Department:

SET NOCOUNT ON;
UPDATE [Departments] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [ConcurrencyToken] = @p2;
SELECT [ConcurrencyToken]
FROM [Departments]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

Il codice evidenziato precedente mostra la clausola WHERE contenente ConcurrencyToken. Se il database ConcurrencyToken non corrisponde al parametro ConcurrencyToken@p2, non viene aggiornata alcuna riga.

Il codice evidenziato seguente mostra la notazione T-SQL che verifica che è stata aggiornata esattamente una riga:

SET NOCOUNT ON;
UPDATE [Departments] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [ConcurrencyToken] = @p2;
SELECT [ConcurrencyToken]
FROM [Departments]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

@@ROWCOUNT restituisce il numero di righe interessate dall'ultima istruzione. Se non vengono aggiornate righe, EF Core genera un'eccezione DbUpdateConcurrencyException.

Aggiungere una migrazione

L'aggiunta della proprietà ConcurrencyToken cambia il modello di dati e ciò richiede una migrazione.

Compila il progetto.

Eseguire i comandi seguenti nella console di Gestione pacchetti:

Add-Migration RowVersion
Update-Database

I comandi precedenti:

  • Crea il file di Migrations/{time stamp}_RowVersion.cs migrazione.
  • Aggiorna il Migrations/SchoolContextModelSnapshot.cs file. L'aggiornamento aggiunge il codice seguente al BuildModel metodo :
 b.Property<byte[]>("ConcurrencyToken")
     .IsConcurrencyToken()
     .ValueGeneratedOnAddOrUpdate()
     .HasColumnType("rowversion");

Crea pagine del reparto

Seguire le istruzioni in Scaffolding delle pagine Student con le eccezioni seguenti:

  • Creare una cartella Pages/Departments.
  • Usare Department per la classe del modello.
  • Usare la classe di contesto esistente anziché crearne una nuova.

Aggiungere una classe di utilità

Nella cartella del progetto, crea la classe Utility con il codice seguente:

namespace ContosoUniversity
{
    public static class Utility
    {
        public static string GetLastChars(byte[] token)
        {
            return token[7].ToString();
        }
    }
}

La classe Utility fornisce il metodo GetLastChars utilizzato per visualizzare gli ultimi caratteri del token di concorrenza. Il codice seguente illustra il codice che funziona con SQL Server ad SQLite:

#if SQLiteVersion
using System;

namespace ContosoUniversity
{
    public static class Utility
    {
        public static string GetLastChars(Guid token)
        {
            return token.ToString().Substring(
                                    token.ToString().Length - 3);
        }
    }
}
#else
namespace ContosoUniversity
{
    public static class Utility
    {
        public static string GetLastChars(byte[] token)
        {
            return token[7].ToString();
        }
    }
}
#endif

La #if SQLiteVersiondirettiva del preprocessore isola le differenze nelle versioni di SQLite e SQL Server e consente di:

  • L'autore mantiene una codebase per entrambe le versioni.
  • Gli sviluppatori SQLite distribuiscono l'app in Azure e usano SQL Azure.

Compila il progetto.

Aggiornare la pagina Index

Lo strumento di scaffolding crea una colonna ConcurrencyToken per la pagina Index, ma questo campo non verrebbe visualizzato in un'app in produzione. In questa esercitazione viene visualizzata l'ultima parte del ConcurrencyToken per illustrare il funzionamento della gestione della concorrenza. L'ultima parte non è garantito che sia univoca da sola.

Aggiornare la pagina Pages\Departments\Index.cshtml:

  • Sostituire Indice con Reparti.
  • Modificare il codice contenente ConcurrencyToken per visualizzare solo gli ultimi pochi caratteri.
  • Sostituisci FirstMidName con FullName.

Il codice seguente mostra la pagina aggiornata:

@page
@model ContosoUniversity.Pages.Departments.IndexModel

@{
    ViewData["Title"] = "Departments";
}

<h2>Departments</h2>

<p>
    <a asp-page="Create">Create New</a>
</p>
<table class="table">
    <thead>
        <tr>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].Name)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].Budget)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].StartDate)
            </th>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].Administrator)
            </th>
            <th>
                Token
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model.Department)
        {
            <tr>
                <td>
                    @Html.DisplayFor(modelItem => item.Name)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Budget)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.StartDate)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Administrator.FullName)
                </td>
                <td>
                    @Utility.GetLastChars(item.ConcurrencyToken)
                </td>
                <td>
                    <a asp-page="./Edit" asp-route-id="@item.DepartmentID">Edit</a> |
                    <a asp-page="./Details" asp-route-id="@item.DepartmentID">Details</a> |
                    <a asp-page="./Delete" asp-route-id="@item.DepartmentID">Delete</a>
                </td>
            </tr>
        }
    </tbody>
</table>

Aggiornare il modello di pagina Edit

Aggiornare Pages/Departments/Edit.cshtml.cs con il codice seguente:

using ContosoUniversity.Data;
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class EditModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public EditModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        // Replace ViewData["InstructorID"] 
        public SelectList InstructorNameSL { get; set; }

        public async Task<IActionResult> OnGetAsync(int id)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)  // eager loading
                .AsNoTracking()                 // tracking not required
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                return NotFound();
            }

            // Use strongly typed data rather than ViewData.
            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FirstMidName");

            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            if (!ModelState.IsValid)
            {
                return Page();
            }

            // Fetch current department from DB.
            // ConcurrencyToken may have changed.
            var departmentToUpdate = await _context.Departments
                .Include(i => i.Administrator)
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (departmentToUpdate == null)
            {
                return HandleDeletedDepartment();
            }

            // Set ConcurrencyToken to value read in OnGetAsync
            _context.Entry(departmentToUpdate).Property(
                 d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;

            if (await TryUpdateModelAsync<Department>(
                departmentToUpdate,
                "Department",
                s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
            {
                try
                {
                    await _context.SaveChangesAsync();
                    return RedirectToPage("./Index");
                }
                catch (DbUpdateConcurrencyException ex)
                {
                    var exceptionEntry = ex.Entries.Single();
                    var clientValues = (Department)exceptionEntry.Entity;
                    var databaseEntry = exceptionEntry.GetDatabaseValues();
                    if (databaseEntry == null)
                    {
                        ModelState.AddModelError(string.Empty, "Unable to save. " +
                            "The department was deleted by another user.");
                        return Page();
                    }

                    var dbValues = (Department)databaseEntry.ToObject();
                    await SetDbErrorMessage(dbValues, clientValues, _context);

                    // Save the current ConcurrencyToken so next postback
                    // matches unless an new concurrency issue happens.
                    Department.ConcurrencyToken = (byte[])dbValues.ConcurrencyToken;
                    // Clear the model error for the next postback.
                    ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
                }
            }

            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FullName", departmentToUpdate.InstructorID);

            return Page();
        }

        private IActionResult HandleDeletedDepartment()
        {
            // ModelState contains the posted data because of the deletion error
            // and overides the Department instance values when displaying Page().
            ModelState.AddModelError(string.Empty,
                "Unable to save. The department was deleted by another user.");
            InstructorNameSL = new SelectList(_context.Instructors, "ID", "FullName", Department.InstructorID);
            return Page();
        }

        private async Task SetDbErrorMessage(Department dbValues,
                Department clientValues, SchoolContext context)
        {

            if (dbValues.Name != clientValues.Name)
            {
                ModelState.AddModelError("Department.Name",
                    $"Current value: {dbValues.Name}");
            }
            if (dbValues.Budget != clientValues.Budget)
            {
                ModelState.AddModelError("Department.Budget",
                    $"Current value: {dbValues.Budget:c}");
            }
            if (dbValues.StartDate != clientValues.StartDate)
            {
                ModelState.AddModelError("Department.StartDate",
                    $"Current value: {dbValues.StartDate:d}");
            }
            if (dbValues.InstructorID != clientValues.InstructorID)
            {
                Instructor dbInstructor = await _context.Instructors
                   .FindAsync(dbValues.InstructorID);
                ModelState.AddModelError("Department.InstructorID",
                    $"Current value: {dbInstructor?.FullName}");
            }

            ModelState.AddModelError(string.Empty,
                "The record you attempted to edit "
              + "was modified by another user after you. The "
              + "edit operation was canceled and the current values in the database "
              + "have been displayed. If you still want to edit this record, click "
              + "the Save button again.");
        }
    }
}

Aggiornamenti della concorrenza

OriginalValue viene aggiornato con il ConcurrencyToken valore dell'entità quando è stato recuperato nel OnGetAsync metodo . EF Core genera un comando SQL UPDATE con una clausola WHERE contenente il valore ConcurrencyToken originale. Se il comando UPDATE non influisce su alcuna riga, viene generata un'eccezione DbUpdateConcurrencyException. Nessuna riga è interessata dal comando UPDATE quando nessuna riga ha il valore originale ConcurrencyToken.

public async Task<IActionResult> OnPostAsync(int id)
{
    if (!ModelState.IsValid)
    {
        return Page();
    }

    // Fetch current department from DB.
    // ConcurrencyToken may have changed.
    var departmentToUpdate = await _context.Departments
        .Include(i => i.Administrator)
        .FirstOrDefaultAsync(m => m.DepartmentID == id);

    if (departmentToUpdate == null)
    {
        return HandleDeletedDepartment();
    }

    // Set ConcurrencyToken to value read in OnGetAsync
    _context.Entry(departmentToUpdate).Property(
         d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;

Nel codice evidenziato precedente:

  • Il valore in Department.ConcurrencyToken è il valore quando l'entità è stata recuperata nella Get richiesta per la Edit pagina. Il valore viene fornito al metodo OnPost da un campo nascosto nella pagina Razor che visualizza l'entità da modificare. Il valore del campo nascosto viene copiato in Department.ConcurrencyToken dal model binder.
  • OriginalValue è ciò che EF Core usa nella clausola WHERE. Prima dell'esecuzione della riga di codice evidenziata:
    • OriginalValue ha il valore presente nel database quando FirstOrDefaultAsync è stato chiamato in questo metodo.
    • Questo valore potrebbe essere diverso da quello visualizzato nella pagina Modifica.
  • Il codice evidenziato garantisce che EF Core usi il valore originale ConcurrencyToken dall'entità Department visualizzata nella clausola WHERE dell'istruzione SQL UPDATE.

Il codice seguente mostra il modello Department. Department viene inizializzato in:

  • OnGetAsync metodo tramite la query EF.
  • OnPostAsyncmetodo in base al campo nascosto nella pagina usando l'associazione Razordi modelli:
public class EditModel : PageModel
{
    private readonly ContosoUniversity.Data.SchoolContext _context;

    public EditModel(ContosoUniversity.Data.SchoolContext context)
    {
        _context = context;
    }

    [BindProperty]
    public Department Department { get; set; }
    // Replace ViewData["InstructorID"] 
    public SelectList InstructorNameSL { get; set; }

    public async Task<IActionResult> OnGetAsync(int id)
    {
        Department = await _context.Departments
            .Include(d => d.Administrator)  // eager loading
            .AsNoTracking()                 // tracking not required
            .FirstOrDefaultAsync(m => m.DepartmentID == id);

        if (Department == null)
        {
            return NotFound();
        }

        // Use strongly typed data rather than ViewData.
        InstructorNameSL = new SelectList(_context.Instructors,
            "ID", "FirstMidName");

        return Page();
    }

    public async Task<IActionResult> OnPostAsync(int id)
    {
        if (!ModelState.IsValid)
        {
            return Page();
        }

        // Fetch current department from DB.
        // ConcurrencyToken may have changed.
        var departmentToUpdate = await _context.Departments
            .Include(i => i.Administrator)
            .FirstOrDefaultAsync(m => m.DepartmentID == id);

        if (departmentToUpdate == null)
        {
            return HandleDeletedDepartment();
        }

        // Set ConcurrencyToken to value read in OnGetAsync
        _context.Entry(departmentToUpdate).Property(
             d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;

Il codice precedente mostra che il valore ConcurrencyToken dell'entità Department della richiesta HTTP POST viene impostato sul valore ConcurrencyToken della richiesta HTTP GET.

Quando si verifica un errore di concorrenza, il codice evidenziato seguente ottiene i valori del client (i valori passati a questo metodo) e i valori del database.

if (await TryUpdateModelAsync<Department>(
    departmentToUpdate,
    "Department",
    s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
    try
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }
    catch (DbUpdateConcurrencyException ex)
    {
        var exceptionEntry = ex.Entries.Single();
        var clientValues = (Department)exceptionEntry.Entity;
        var databaseEntry = exceptionEntry.GetDatabaseValues();
        if (databaseEntry == null)
        {
            ModelState.AddModelError(string.Empty, "Unable to save. " +
                "The department was deleted by another user.");
            return Page();
        }

        var dbValues = (Department)databaseEntry.ToObject();
        await SetDbErrorMessage(dbValues, clientValues, _context);

        // Save the current ConcurrencyToken so next postback
        // matches unless an new concurrency issue happens.
        Department.ConcurrencyToken = dbValues.ConcurrencyToken;
        // Clear the model error for the next postback.
        ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
    }

Il codice seguente aggiunge un messaggio di errore personalizzato per ogni colonna che ha valori del database diversi da quelli inseriti in OnPostAsync:

private async Task SetDbErrorMessage(Department dbValues,
        Department clientValues, SchoolContext context)
{

    if (dbValues.Name != clientValues.Name)
    {
        ModelState.AddModelError("Department.Name",
            $"Current value: {dbValues.Name}");
    }
    if (dbValues.Budget != clientValues.Budget)
    {
        ModelState.AddModelError("Department.Budget",
            $"Current value: {dbValues.Budget:c}");
    }
    if (dbValues.StartDate != clientValues.StartDate)
    {
        ModelState.AddModelError("Department.StartDate",
            $"Current value: {dbValues.StartDate:d}");
    }
    if (dbValues.InstructorID != clientValues.InstructorID)
    {
        Instructor dbInstructor = await _context.Instructors
           .FindAsync(dbValues.InstructorID);
        ModelState.AddModelError("Department.InstructorID",
            $"Current value: {dbInstructor?.FullName}");
    }

    ModelState.AddModelError(string.Empty,
        "The record you attempted to edit "
      + "was modified by another user after you. The "
      + "edit operation was canceled and the current values in the database "
      + "have been displayed. If you still want to edit this record, click "
      + "the Save button again.");
}

Il codice evidenziato seguente imposta il valore ConcurrencyToken sul nuovo valore recuperato dal database. Quando l'utente fa di nuovo clic su Salva vengono rilevati solo gli errori di concorrenza che si verificano dopo l'ultima visualizzazione della pagina Edit (Modifica).

if (await TryUpdateModelAsync<Department>(
    departmentToUpdate,
    "Department",
    s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
    try
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }
    catch (DbUpdateConcurrencyException ex)
    {
        var exceptionEntry = ex.Entries.Single();
        var clientValues = (Department)exceptionEntry.Entity;
        var databaseEntry = exceptionEntry.GetDatabaseValues();
        if (databaseEntry == null)
        {
            ModelState.AddModelError(string.Empty, "Unable to save. " +
                "The department was deleted by another user.");
            return Page();
        }

        var dbValues = (Department)databaseEntry.ToObject();
        await SetDbErrorMessage(dbValues, clientValues, _context);

        // Save the current ConcurrencyToken so next postback
        // matches unless an new concurrency issue happens.
        Department.ConcurrencyToken = dbValues.ConcurrencyToken;
        // Clear the model error for the next postback.
        ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
    }

L'istruzione ModelState.Remove è obbligatoria perché ModelState ha il valore precedente ConcurrencyToken . Nella pagina Razor, il valore ModelState di un campo ha la precedenza sui valori delle proprietà del modello quando sono presenti entrambi.

Differenze di codice tra SQL Server e SQLite

Di seguito sono illustrate le differenze tra le versioni di SQL Server e SQLite:

+ using System;    // For GUID on SQLite

+ departmentToUpdate.ConcurrencyToken = Guid.NewGuid();

 _context.Entry(departmentToUpdate)
    .Property(d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;

- Department.ConcurrencyToken = (byte[])dbValues.ConcurrencyToken;
+ Department.ConcurrencyToken = dbValues.ConcurrencyToken;

Aggiornare la pagina Modifica Razor

Aggiornare Pages/Departments/Edit.cshtml con il codice seguente:

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.EditModel
@{
    ViewData["Title"] = "Edit";
}
<h2>Edit</h2>
<h4>Department</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <input type="hidden" asp-for="Department.DepartmentID" />
            <input type="hidden" asp-for="Department.ConcurrencyToken" />
            <div class="form-group">
                <label>Version</label>
                @Utility.GetLastChars(Model.Department.ConcurrencyToken)
            </div>
            <div class="form-group">
                <label asp-for="Department.Name" class="control-label"></label>
                <input asp-for="Department.Name" class="form-control" />
                <span asp-validation-for="Department.Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.Budget" class="control-label"></label>
                <input asp-for="Department.Budget" class="form-control" />
                <span asp-validation-for="Department.Budget" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.StartDate" class="control-label"></label>
                <input asp-for="Department.StartDate" class="form-control" />
                <span asp-validation-for="Department.StartDate" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <label class="control-label">Instructor</label>
                <select asp-for="Department.InstructorID" class="form-control"
                        asp-items="@Model.InstructorNameSL"></select>
                <span asp-validation-for="Department.InstructorID" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <input type="submit" value="Save" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>
<div>
    <a asp-page="./Index">Back to List</a>
</div>
@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

Il codice precedente:

  • Aggiorna la direttiva page da @page a @page "{id:int}".
  • Aggiunge una versione di riga nascosta. ConcurrencyToken deve essere aggiunto in modo che il postback associa il valore.
  • Visualizza l'ultimo byte di ConcurrencyToken a scopo di debug.
  • Sostituisce ViewData con l'elemento InstructorNameSL fortemente tipizzato.

Test dei conflitti di concorrenza con la pagina Modifica

Aprire due istanze del browser con la pagina Edit (Modifica) nel reparto English (Inglese):

  • Esegui l'app e seleziona Departments.
  • Fare clic con il pulsante destro del mouse sul collegamento ipertestuale Edit per il reparto di English e selezionare Apri in una nuova scheda.
  • Nella prima scheda fare clic sul collegamento ipertestuale Edit per il reparto English (Inglese).

Le due schede del browser visualizzano le stesse informazioni.

Modificare il nome nella prima scheda del browser e fare clic su Salva.

Pagina Modifica reparto 1 dopo la modifica

Il browser mostra la pagina Index con il valore modificato e l'indicatore ConcurrencyToken aggiornato. Nota l'indicatore aggiornato ConcurrencyToken: viene visualizzato al secondo postback nell'altra scheda.

Modificare un altro campo nella seconda scheda del browser.

Pagina Modifica reparto 2 dopo la modifica

Fare clic su Salva. Vengono visualizzati messaggi di errore per tutti i campi che non corrispondono ai valori del database:

Messaggio di errore della pagina Modifica reparto

Questa finestra del browser non intendeva modificare il campo Name (Nome). Copiare e incollare il valore corrente Languages (Lingue) nel campo Name (Nome). Premi Tab per uscire dal campo. La convalida lato client rimuove il messaggio di errore.

Fai clic di nuovo su Salva. Il valore immesso nella seconda scheda del browser viene salvato. I valori salvati vengono visualizzati nella pagina Index.

Aggiornare il modello di pagina Elimina

Aggiornare Pages/Departments/Delete.cshtml.cs con il codice seguente:

using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class DeleteModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public DeleteModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        public string ConcurrencyErrorMessage { get; set; }

        public async Task<IActionResult> OnGetAsync(int id, bool? concurrencyError)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)
                .AsNoTracking()
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                 return NotFound();
            }

            if (concurrencyError.GetValueOrDefault())
            {
                ConcurrencyErrorMessage = "The record you attempted to delete "
                  + "was modified by another user after you selected delete. "
                  + "The delete operation was canceled and the current values in the "
                  + "database have been displayed. If you still want to delete this "
                  + "record, click the Delete button again.";
            }
            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            try
            {
                if (await _context.Departments.AnyAsync(
                    m => m.DepartmentID == id))
                {
                    // Department.ConcurrencyToken value is from when the entity
                    // was fetched. If it doesn't match the DB, a
                    // DbUpdateConcurrencyException exception is thrown.
                    _context.Departments.Remove(Department);
                    await _context.SaveChangesAsync();
                }
                return RedirectToPage("./Index");
            }
            catch (DbUpdateConcurrencyException)
            {
                return RedirectToPage("./Delete",
                    new { concurrencyError = true, id = id });
            }
        }
    }
}

La pagina Elimina rileva i conflitti di concorrenza quando l'entità è cambiata dopo essere stata recuperata. Department.ConcurrencyToken è la versione di riga quando l'entità è stata recuperata. Quando EF Core crea il SQL DELETE comando, include una clausola WHERE con ConcurrencyToken. Se il SQL DELETE comando restituisce zero righe interessate:

  • L'oggetto ConcurrencyTokenSQL DELETE nel comando non corrisponde ConcurrencyToken al database.
  • Viene generata un'eccezione DbUpdateConcurrencyException .
  • OnGetAsync viene chiamato con concurrencyError.

Aggiornare la pagina Elimina Razor

Aggiornare Pages/Departments/Delete.cshtml con il codice seguente:

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.DeleteModel

@{
    ViewData["Title"] = "Delete";
}

<h1>Delete</h1>

<p class="text-danger">@Model.ConcurrencyErrorMessage</p>

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>Department</h4>
    <hr />
    <dl class="row">
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Department.Name)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Department.Name)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Department.Budget)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Department.Budget)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Department.StartDate)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Department.StartDate)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Department.ConcurrencyToken)
        </dt>
        <dd class="col-sm-10">
            @Utility.GetLastChars(Model.Department.ConcurrencyToken)
        </dd>
        <dt class="col-sm-2">
            @Html.DisplayNameFor(model => model.Department.Administrator)
        </dt>
        <dd class="col-sm-10">
            @Html.DisplayFor(model => model.Department.Administrator.FullName)
        </dd>
    </dl>

    <form method="post">
        <input type="hidden" asp-for="Department.DepartmentID" />
        <input type="hidden" asp-for="Department.ConcurrencyToken" />
        <input type="submit" value="Delete" class="btn btn-danger" /> |
        <a asp-page="./Index">Back to List</a>
    </form>
</div>

Il codice precedente apporta le modifiche seguenti:

  • Aggiorna la direttiva page da @page a @page "{id:int}".
  • Aggiunge un messaggio di errore.
  • Sostituisce FirstMidName con FullName nel campo Administrator.
  • Modifica ConcurrencyToken per visualizzare l'ultimo byte.
  • Aggiunge una versione di riga nascosta. ConcurrencyToken deve essere aggiunto in modo che il postback associa il valore.

Verifica dei conflitti di concorrenza

Crea un dipartimento di test.

Aprire due istanze del browser con la pagina Delete (Elimina):

  • Eseguire l'app e selezionare Reparti.
  • Fare clic con il pulsante destro del mouse sul collegamento ipertestuale Delete (Elimina) per il reparto di test e selezionare Apri in una nuova scheda.
  • Fare clic sul collegamento ipertestuale Edit per il reparto di test.

Le due schede del browser visualizzano le stesse informazioni.

Modificare il budget nella prima scheda del browser e fare clic su Salva.

Il browser mostra la pagina Indice con il valore modificato e l'indicatore aggiornato ConcurrencyToken. Si noti che l'indicatore aggiornato ConcurrencyTokenviene visualizzato nel secondo postback nell'altra scheda.

Eliminare il reparto di test dalla seconda scheda. Viene visualizzato un errore di concorrenza con i valori correnti del database. Facendo clic su Elimina viene eliminata l'entità, a meno che ConcurrencyToken non sia stato aggiornato.

Modelli di app Web aziendali

Per indicazioni sulla creazione di un'app Web affidabile, sicura, efficiente, testabile e scalabile ASP.NET Core, vedere modelli di app Web Enterprise. È disponibile un'app Web di esempio completa di qualità di produzione che implementa i modelli.

Questa esercitazione descrive la gestione dei conflitti quando più utenti aggiornano la stessa entità contemporaneamente.

Conflitti di simultaneità

Un conflitto di concorrenza si verifica quando:

  • Un utente passa alla pagina di modifica di un'entità.
  • Un altro utente aggiorna la stessa entità prima che la modifica del primo utente venga scritta nel database.

Se il rilevamento della concorrenza non è abilitato, chiunque aggiorni il database per ultimo sovrascrive le modifiche apportate dall'altro utente. Se questo rischio è accettabile, il costo della programmazione concorrente potrebbe essere superiore ai vantaggi.

Concorrenza pessimistica (blocco)

Un modo per impedire i conflitti di concorrenza consiste nell'utilizzare blocchi del database. Questo approccio è denominato concorrenza pessimistica. Prima che l'app legga una riga del database che intende aggiornare, richiede un blocco. Una volta bloccata una riga per l'accesso per gli aggiornamenti, nessun altro utente potrà bloccare la riga fino a quando non viene rilasciato il primo blocco.

La gestione dei blocchi presenta svantaggi. Può risultare complesso da programmare e causare problemi di prestazioni con l'aumentare del numero di utenti. Entity Framework Core non offre supporto predefinito per questa modalità e la presente esercitazione non indica come implementarla.

Concorrenza ottimistica

La concorrenza ottimistica consente che si verifichino conflitti di concorrenza, quindi attiva le misure necessarie. Ad esempio, Jane visita la pagina di modifica del dipartimento e modifica il budget del dipartimento di inglese da $350.000,00 a $0,00.

Modifica del budget a 0

Prima che Jane faccia clic su Salva John visita la stessa pagina e cambia il valore del campo Start Date (Data inizio) da 9/1/2007 a 9/1/2013.

Modifica della data di inizio al 2013

Jane fa prima di tutto clic su Save e visualizza l'effetto della modifica, dato che il browser visualizza la pagina Index con zero come importo del budget.

John fa clic su Salva in una pagina Modifica che visualizza ancora un budget pari a $ 350.000,00. Le operazioni successive dipendono da come si decide di gestire i conflitti di concorrenza:

  • È possibile tenere traccia di quale proprietà un utente ha modificato e aggiornare solo le colonne corrispondenti nel database.

    Con questo scenario non si registra la perdita di dati. I due utenti hanno aggiornato proprietà diverse. Quando un utente torna a visualizzare il reparto English (Inglese), visualizza sia le modifiche di Jane sia quelle di John. Questo metodo di aggiornamento può ridurre il numero di conflitti che potrebbero causare la perdita di dati. Questo approccio presenta alcuni svantaggi:

    • Non è possibile evitare la perdita di dati se vengono apportate modifiche concorrenti alla stessa proprietà.
    • Risulta in genere poco pratico in un'app Web. Richiede di mantenere uno stato significativo per tenere traccia di tutti i valori recuperati e dei nuovi valori. La gestione di grandi quantità di stato può ridurre le prestazioni dell'applicazione.
    • Può rendere più complesse le app rispetto al rilevamento della concorrenza in un'entità.
  • Puoi fare in modo che la modifica di John sovrascriva quella di Jane.

    La prossima volta che un utente visualizza il reparto English (Inglese), visualizza 01/09/2013 e il valore recuperato di 350.000,00 $. Questo approccio è chiamato Client Wins o Last in Wins (Priorità ultimo accesso). (Tutti i valori del client prevalgono su quelli presenti nell'archivio dati.) Se non si scrive alcun codice per la gestione della concorrenza, Client Wins si applica automaticamente.

  • È possibile impedire che la modifica di John venga aggiornata nel database. In genere, l'app:

    • Visualizza un messaggio di errore.
    • Visualizza lo stato corrente dei dati.
    • Consente all'utente di riapplicare le modifiche.

    Questo è chiamato scenario Store Wins. I valori dell'archivio dati hanno la precedenza sui valori inviati dal client. In questa esercitazione si implementa lo scenario Delle vittorie nello Store. Questo metodo garantisce che nessuna modifica venga sovrascritta senza avvisare l'utente.

Rilevamento dei conflitti in EF Core

EF Core genera DbConcurrencyException eccezioni quando rileva conflitti. Il modello di dati deve essere configurato per abilitare il rilevamento dei conflitti. Le opzioni per abilitare il rilevamento dei conflitti includono le seguenti:

  • Configurare EF Core per includere i valori originali delle colonne configurati come token di concorrenza nella clausola Where dei comandi Update e Delete.

    Quando SaveChanges viene chiamato , la clausola Where cerca i valori originali di qualsiasi proprietà annotata con l'attributo ConcurrencyCheckAttribute . L'istruzione Update non troverà una riga da aggiornare se una delle proprietà del token di concorrenza è cambiata dopo la prima lettura della riga. EF Core lo interpreta come un conflitto di concorrenza. Per le tabelle di database con molte colonne, questo approccio può risultare in clausole Where molto grandi e richiedere grandi quantità di stato. Pertanto questo approccio è in genere sconsigliato e non è il metodo usato in questa esercitazione.

  • Nella tabella del database, includere una colonna di tracciamento che può essere usata per determinare quando è stata modificata una riga.

    In un database di SQL Server il tipo di dati della colonna di tracciamento è rowversion. Il valore rowversion è un numero sequenziale che viene incrementato ogni volta che la riga viene aggiornata. In un comando Update o Delete, la clausola Where include il valore originale della colonna di rilevamento (il numero di versione originale della riga). Se la riga da aggiornare è stata modificata da un altro utente, il valore nella colonna rowversion è diverso da quello originale. In tal caso, l'istruzione Update o Delete non riesce a trovare la riga da aggiornare a causa della clausola Where. EF Core genera un'eccezione di concorrenza quando nessuna riga viene interessata da un comando di Update o di Delete.

Aggiungere una proprietà di tracciamento

In Models/Department.csaggiungere una proprietà di rilevamento denominata RowVersion:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public class Department
    {
        public int DepartmentID { get; set; }

        [StringLength(50, MinimumLength = 3)]
        public string Name { get; set; }

        [DataType(DataType.Currency)]
        [Column(TypeName = "money")]
        public decimal Budget { get; set; }

        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        [Display(Name = "Start Date")]
        public DateTime StartDate { get; set; }

        public int? InstructorID { get; set; }

        [Timestamp]
        public byte[] RowVersion { get; set; }

        public Instructor Administrator { get; set; }
        public ICollection<Course> Courses { get; set; }
    }
}

L'attributo TimestampAttribute identifica la colonna come colonna di rilevamento della concorrenza. L'API fluente è un modo alternativo per specificare la proprietà di tracciamento:

modelBuilder.Entity<Department>()
  .Property<byte[]>("RowVersion")
  .IsRowVersion();

Per un database di SQL Server l'attributo [Timestamp] per una proprietà dell'entità definita come matrice di byte:

  • Causa l'inclusione della colonna nelle clausole Where per Delete e Update.
  • Imposta il tipo di colonna nel database su rowversion.

Il database genera un numero di versione di riga sequenziale che viene incrementato ogni volta che la riga viene aggiornata. In un comando Update o Delete la clausola Where include il valore della versione della riga recuperato. Se la riga da aggiornare è stata modificata da quando è stata recuperata:

  • Il valore della versione della riga corrente non corrisponde al valore recuperato.
  • I comandi Update o Delete non trovano una riga perché la clausola Where cerca il valore della versione della riga recuperata.
  • Viene generata un'eccezione DbUpdateConcurrencyException.

Il codice seguente mostra una parte del T-SQL generato da EF Core quando viene aggiornato il nome del reparto:

SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

Il codice evidenziato precedente mostra la clausola WHERE contenente RowVersion. Se il valore RowVersion del database non è uguale al parametro RowVersion (@p2) non viene aggiornata alcuna riga.

Il codice evidenziato seguente visualizza la notazione T-SQL che verifica che è stata aggiornata esattamente una riga:

SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

@@ROWCOUNT restituisce il numero di righe interessate dall'ultima istruzione. Se non vengono aggiornate righe, EF Core genera un'eccezione DbUpdateConcurrencyException.

Aggiornare il database

L'aggiunta della proprietà RowVersion cambia il modello di dati e ciò richiede una migrazione.

Compilare il progetto.

  • Eseguire il comando seguente nella console di Gestione pacchetti:

    Add-Migration RowVersion
    

Questo comando:

  • Crea il file di Migrations/{time stamp}_RowVersion.cs migrazione.

  • Aggiorna il Migrations/SchoolContextModelSnapshot.cs file. L'aggiornamento aggiunge al metodo BuildModel il codice evidenziato seguente:

    modelBuilder.Entity("ContosoUniversity.Models.Department", b =>
        {
            b.Property<int>("DepartmentID")
                .ValueGeneratedOnAdd()
                .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
    
            b.Property<decimal>("Budget")
                .HasColumnType("money");
    
            b.Property<int?>("InstructorID");
    
            b.Property<string>("Name")
                .HasMaxLength(50);
    
            b.Property<byte[]>("RowVersion")
                .IsConcurrencyToken()
                .ValueGeneratedOnAddOrUpdate();
    
            b.Property<DateTime>("StartDate");
    
            b.HasKey("DepartmentID");
    
            b.HasIndex("InstructorID");
    
            b.ToTable("Department");
        });
    
  • Eseguire il comando seguente nella PMC:

    Update-Database
    

Crea pagine del reparto

  • Seguire le istruzioni in Scaffolding delle pagine Student con le eccezioni seguenti:

  • Creare una cartella Pages/Departments.

  • Usare Department per la classe del modello.

    • Usare la classe di contesto esistente anziché crearne una nuova.

Compila il progetto.

Aggiornare la pagina Index

Lo strumento di scaffolding crea una colonna RowVersion per la pagina Index, ma questo campo non verrebbe visualizzato in un'app in produzione. In questa esercitazione, l'ultimo byte di RowVersion viene visualizzato per illustrare in modo più chiaro come funziona la gestione della concorrenza. L'univocità dell'ultimo byte non è garantita.

Aggiornare la pagina Pages\Departments\Index.cshtml:

  • Sostituire Indice con Reparti.
  • Modificare il codice che contiene RowVersion per visualizzare solo l'ultimo byte della matrice di byte.
  • Sostituire FirstMidName con FullName.

Il codice seguente mostra la pagina aggiornata:

@page
@model ContosoUniversity.Pages.Departments.IndexModel

@{
    ViewData["Title"] = "Departments";
}

<h2>Departments</h2>

<p>
    <a asp-page="Create">Create New</a>
</p>
<table class="table">
    <thead>
        <tr>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].Name)
                </th>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].Budget)
                </th>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].StartDate)
                </th>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].Administrator)
            </th>
            <th>
                RowVersion
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in Model.Department)
        {
            <tr>
                <td>
                    @Html.DisplayFor(modelItem => item.Name)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Budget)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.StartDate)
                </td>
                <td>
                    @Html.DisplayFor(modelItem => item.Administrator.FullName)
                </td>
                <td>
                    @item.RowVersion[7]
                </td>
                <td>
                    <a asp-page="./Edit" asp-route-id="@item.DepartmentID">Edit</a> |
                    <a asp-page="./Details" asp-route-id="@item.DepartmentID">Details</a> |
                    <a asp-page="./Delete" asp-route-id="@item.DepartmentID">Delete</a>
                </td>
            </tr>
        }
    </tbody>
</table>

Aggiornare il modello di pagina Edit

Aggiornare Pages/Departments/Edit.cshtml.cs con il codice seguente:

using ContosoUniversity.Data;
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class EditModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public EditModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        // Replace ViewData["InstructorID"] 
        public SelectList InstructorNameSL { get; set; }

        public async Task<IActionResult> OnGetAsync(int id)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)  // eager loading
                .AsNoTracking()                 // tracking not required
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                return NotFound();
            }

            // Use strongly typed data rather than ViewData.
            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FirstMidName");

            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            if (!ModelState.IsValid)
            {
                return Page();
            }

            var departmentToUpdate = await _context.Departments
                .Include(i => i.Administrator)
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (departmentToUpdate == null)
            {
                return HandleDeletedDepartment();
            }

            _context.Entry(departmentToUpdate)
                .Property("RowVersion").OriginalValue = Department.RowVersion;

            if (await TryUpdateModelAsync<Department>(
                departmentToUpdate,
                "Department",
                s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
            {
                try
                {
                    await _context.SaveChangesAsync();
                    return RedirectToPage("./Index");
                }
                catch (DbUpdateConcurrencyException ex)
                {
                    var exceptionEntry = ex.Entries.Single();
                    var clientValues = (Department)exceptionEntry.Entity;
                    var databaseEntry = exceptionEntry.GetDatabaseValues();
                    if (databaseEntry == null)
                    {
                        ModelState.AddModelError(string.Empty, "Unable to save. " +
                            "The department was deleted by another user.");
                        return Page();
                    }

                    var dbValues = (Department)databaseEntry.ToObject();
                    await setDbErrorMessage(dbValues, clientValues, _context);

                    // Save the current RowVersion so next postback
                    // matches unless an new concurrency issue happens.
                    Department.RowVersion = (byte[])dbValues.RowVersion;
                    // Clear the model error for the next postback.
                    ModelState.Remove("Department.RowVersion");
                }
            }

            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FullName", departmentToUpdate.InstructorID);

            return Page();
        }

        private IActionResult HandleDeletedDepartment()
        {
            var deletedDepartment = new Department();
            // ModelState contains the posted data because of the deletion error
            // and will overide the Department instance values when displaying Page().
            ModelState.AddModelError(string.Empty,
                "Unable to save. The department was deleted by another user.");
            InstructorNameSL = new SelectList(_context.Instructors, "ID", "FullName", Department.InstructorID);
            return Page();
        }

        private async Task setDbErrorMessage(Department dbValues,
                Department clientValues, SchoolContext context)
        {

            if (dbValues.Name != clientValues.Name)
            {
                ModelState.AddModelError("Department.Name",
                    $"Current value: {dbValues.Name}");
            }
            if (dbValues.Budget != clientValues.Budget)
            {
                ModelState.AddModelError("Department.Budget",
                    $"Current value: {dbValues.Budget:c}");
            }
            if (dbValues.StartDate != clientValues.StartDate)
            {
                ModelState.AddModelError("Department.StartDate",
                    $"Current value: {dbValues.StartDate:d}");
            }
            if (dbValues.InstructorID != clientValues.InstructorID)
            {
                Instructor dbInstructor = await _context.Instructors
                   .FindAsync(dbValues.InstructorID);
                ModelState.AddModelError("Department.InstructorID",
                    $"Current value: {dbInstructor?.FullName}");
            }

            ModelState.AddModelError(string.Empty,
                "The record you attempted to edit "
              + "was modified by another user after you. The "
              + "edit operation was canceled and the current values in the database "
              + "have been displayed. If you still want to edit this record, click "
              + "the Save button again.");
        }
    }
}

L'oggetto OriginalValue viene aggiornato con il valore rowVersion dell'entità recuperata nel metodo OnGetAsync. EF Core genera un comando SQL UPDATE con una clausola WHERE contenente il valore originale RowVersion . Se il comando UPDATE non ha effetto su nessuna riga (nessuna riga ha il valore originale RowVersion), viene generata un'eccezione DbUpdateConcurrencyException.

public async Task<IActionResult> OnPostAsync(int id)
{
    if (!ModelState.IsValid)
    {
        return Page();
    }

    var departmentToUpdate = await _context.Departments
        .Include(i => i.Administrator)
        .FirstOrDefaultAsync(m => m.DepartmentID == id);

    if (departmentToUpdate == null)
    {
        return HandleDeletedDepartment();
    }

    _context.Entry(departmentToUpdate)
        .Property("RowVersion").OriginalValue = Department.RowVersion;

Nel codice evidenziato precedente:

  • Il valore in Department.RowVersion corrisponde a quello presente nell'entità quando è stato recuperato in origine nella richiesta Get per la pagina Edit. Il valore viene fornito al metodo OnPost da un campo nascosto nella pagina Razor che visualizza l'entità da modificare. Il valore del campo nascosto viene copiato in Department.RowVersion dallo strumento di associazione di modelli.
  • OriginalValue è ciò che EF Core verrà usato nella clausola Where. Prima dell'esecuzione della riga di codice evidenziata, OriginalValue ha il valore che era presente nel database al momento della chiamata di FirstOrDefaultAsync in questo metodo, che potrebbe essere diverso da quello visualizzato nella pagina Edit.
  • Il codice evidenziato assicura che EF Core usi il valore originale RowVersion dell'entità visualizzata Department nella clausola Where dell'istruzione SQL UPDATE.

Quando si verifica un errore di concorrenza, il codice evidenziato seguente ottiene i valori del client (i valori passati a questo metodo) e i valori del database.

if (await TryUpdateModelAsync<Department>(
    departmentToUpdate,
    "Department",
    s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
    try
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }
    catch (DbUpdateConcurrencyException ex)
    {
        var exceptionEntry = ex.Entries.Single();
        var clientValues = (Department)exceptionEntry.Entity;
        var databaseEntry = exceptionEntry.GetDatabaseValues();
        if (databaseEntry == null)
        {
            ModelState.AddModelError(string.Empty, "Unable to save. " +
                "The department was deleted by another user.");
            return Page();
        }

        var dbValues = (Department)databaseEntry.ToObject();
        await setDbErrorMessage(dbValues, clientValues, _context);

        // Save the current RowVersion so next postback
        // matches unless an new concurrency issue happens.
        Department.RowVersion = (byte[])dbValues.RowVersion;
        // Clear the model error for the next postback.
        ModelState.Remove("Department.RowVersion");
    }

Il codice seguente aggiunge un messaggio di errore personalizzato per ogni colonna che ha valori del database diversi da quelli inseriti in OnPostAsync:

private async Task setDbErrorMessage(Department dbValues,
        Department clientValues, SchoolContext context)
{

    if (dbValues.Name != clientValues.Name)
    {
        ModelState.AddModelError("Department.Name",
            $"Current value: {dbValues.Name}");
    }
    if (dbValues.Budget != clientValues.Budget)
    {
        ModelState.AddModelError("Department.Budget",
            $"Current value: {dbValues.Budget:c}");
    }
    if (dbValues.StartDate != clientValues.StartDate)
    {
        ModelState.AddModelError("Department.StartDate",
            $"Current value: {dbValues.StartDate:d}");
    }
    if (dbValues.InstructorID != clientValues.InstructorID)
    {
        Instructor dbInstructor = await _context.Instructors
           .FindAsync(dbValues.InstructorID);
        ModelState.AddModelError("Department.InstructorID",
            $"Current value: {dbInstructor?.FullName}");
    }

    ModelState.AddModelError(string.Empty,
        "The record you attempted to edit "
      + "was modified by another user after you. The "
      + "edit operation was canceled and the current values in the database "
      + "have been displayed. If you still want to edit this record, click "
      + "the Save button again.");
}

Il codice evidenziato seguente imposta il valore RowVersion sul nuovo valore recuperato dal database. Quando l'utente fa di nuovo clic su Salva vengono rilevati solo gli errori di concorrenza che si verificano dopo l'ultima visualizzazione della pagina Edit (Modifica).

if (await TryUpdateModelAsync<Department>(
    departmentToUpdate,
    "Department",
    s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
    try
    {
        await _context.SaveChangesAsync();
        return RedirectToPage("./Index");
    }
    catch (DbUpdateConcurrencyException ex)
    {
        var exceptionEntry = ex.Entries.Single();
        var clientValues = (Department)exceptionEntry.Entity;
        var databaseEntry = exceptionEntry.GetDatabaseValues();
        if (databaseEntry == null)
        {
            ModelState.AddModelError(string.Empty, "Unable to save. " +
                "The department was deleted by another user.");
            return Page();
        }

        var dbValues = (Department)databaseEntry.ToObject();
        await setDbErrorMessage(dbValues, clientValues, _context);

        // Save the current RowVersion so next postback
        // matches unless an new concurrency issue happens.
        Department.RowVersion = (byte[])dbValues.RowVersion;
        // Clear the model error for the next postback.
        ModelState.Remove("Department.RowVersion");
    }

L'istruzione ModelState.Remove è necessaria perché ModelState presenta il valore obsoleto RowVersion. Nella pagina Razor, il valore ModelState di un campo ha la precedenza sui valori delle proprietà del modello quando sono presenti entrambi.

Aggiornare la pagina Edit

Aggiornare Pages/Departments/Edit.cshtml con il codice seguente:

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.EditModel
@{
    ViewData["Title"] = "Edit";
}
<h2>Edit</h2>
<h4>Department</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <input type="hidden" asp-for="Department.DepartmentID" />
            <input type="hidden" asp-for="Department.RowVersion" />
            <div class="form-group">
                <label>RowVersion</label>
                @Model.Department.RowVersion[7]
            </div>
            <div class="form-group">
                <label asp-for="Department.Name" class="control-label"></label>
                <input asp-for="Department.Name" class="form-control" />
                <span asp-validation-for="Department.Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.Budget" class="control-label"></label>
                <input asp-for="Department.Budget" class="form-control" />
                <span asp-validation-for="Department.Budget" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.StartDate" class="control-label"></label>
                <input asp-for="Department.StartDate" class="form-control" />
                <span asp-validation-for="Department.StartDate" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <label class="control-label">Instructor</label>
                <select asp-for="Department.InstructorID" class="form-control"
                        asp-items="@Model.InstructorNameSL"></select>
                <span asp-validation-for="Department.InstructorID" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <input type="submit" value="Save" class="btn btn-primary" />
            </div>
        </form>
    </div>
</div>
<div>
    <a asp-page="./Index">Back to List</a>
</div>
@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

Il codice precedente:

  • Aggiorna la direttiva page da @page a @page "{id:int}".
  • Aggiunge una versione di riga nascosta. RowVersion deve essere aggiunto in modo che il postback associa il valore.
  • Visualizza l'ultimo byte di RowVersion a scopo di debug.
  • Sostituisce ViewData con l'elemento InstructorNameSL con tipizzazione forte.

Test dei conflitti di concorrenza con la pagina Modifica

Aprire due istanze di browser con la pagina Edit (Modifica) e il reparto English (Inglese):

  • Eseguire l'app e selezionare Departments (Reparti).
  • Fare clic con il pulsante destro del mouse sul collegamento ipertestuale Edit per il reparto English e selezionare Apri in una nuova scheda.
  • Nella prima scheda fare clic sul collegamento ipertestuale Edit (Modifica) per il reparto English (Inglese).

Le due schede del browser visualizzano le stesse informazioni.

Modificare il nome nella prima scheda del browser e fare clic su Salva.

Pagina Department Edit (Modifica - Reparto) 1 dopo la modifica

Il browser visualizza la pagina Index con il valore modificato e l'indicatore rowVersion aggiornato. Si noti l'indicatore rowVersion aggiornato, che è visualizzato sul secondo postback nell'altra scheda.

Modificare un altro campo nella seconda scheda del browser.

Pagina 2 di modifica del reparto dopo la modifica

Fare clic su Salva. Vengono visualizzati messaggi di errore per tutti i campi che non corrispondono ai valori del database:

Messaggio di errore della pagina Modifica reparto

Questa finestra del browser non intendeva modificare il campo Name (Nome). Copiare e incollare il valore corrente (Languages) nel campo Name. Premi Tab per uscire dal campo. La convalida lato client rimuove il messaggio di errore.

Fai di nuovo clic su Salva. Il valore immesso nella seconda scheda del browser viene salvato. I valori salvati vengono visualizzati nella pagina Index.

Aggiornare il modello di pagina Elimina

Aggiornare Pages/Departments/Delete.cshtml.cs con il codice seguente:

using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class DeleteModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public DeleteModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        public string ConcurrencyErrorMessage { get; set; }

        public async Task<IActionResult> OnGetAsync(int id, bool? concurrencyError)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)
                .AsNoTracking()
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                 return NotFound();
            }

            if (concurrencyError.GetValueOrDefault())
            {
                ConcurrencyErrorMessage = "The record you attempted to delete "
                  + "was modified by another user after you selected delete. "
                  + "The delete operation was canceled and the current values in the "
                  + "database have been displayed. If you still want to delete this "
                  + "record, click the Delete button again.";
            }
            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            try
            {
                if (await _context.Departments.AnyAsync(
                    m => m.DepartmentID == id))
                {
                    // Department.rowVersion value is from when the entity
                    // was fetched. If it doesn't match the DB, a
                    // DbUpdateConcurrencyException exception is thrown.
                    _context.Departments.Remove(Department);
                    await _context.SaveChangesAsync();
                }
                return RedirectToPage("./Index");
            }
            catch (DbUpdateConcurrencyException)
            {
                return RedirectToPage("./Delete",
                    new { concurrencyError = true, id = id });
            }
        }
    }
}

La pagina Delete (Elimina) rileva i conflitti di concorrenza quando l'entità è stata modificata dopo il recupero. Department.RowVersion è la versione della riga quando l'entità è stata recuperata. Quando EF Core crea il comando SQL DELETE, include una clausola WHERE con RowVersion. Se il comando SQL DELETE non ha effetto su nessuna riga:

  • RowVersion nel comando SQL DELETE non corrisponde a RowVersion nel database.
  • Viene generata un'eccezione DbUpdateConcurrencyException.
  • OnGetAsync viene chiamata con il/la concurrencyError.

Aggiornare la pagina Delete (Elimina)

Aggiornare Pages/Departments/Delete.cshtml con il codice seguente:

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.DeleteModel

@{
    ViewData["Title"] = "Delete";
}

<h2>Delete</h2>

<p class="text-danger">@Model.ConcurrencyErrorMessage</p>

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>Department</h4>
    <hr />
    <dl class="dl-horizontal">
        <dt>
            @Html.DisplayNameFor(model => model.Department.Name)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.Name)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.Budget)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.Budget)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.StartDate)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.StartDate)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.RowVersion)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.RowVersion[7])
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.Administrator)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.Administrator.FullName)
        </dd>
    </dl>
    
    <form method="post">
        <input type="hidden" asp-for="Department.DepartmentID" />
        <input type="hidden" asp-for="Department.RowVersion" />
        <div class="form-actions no-color">
            <input type="submit" value="Delete" class="btn btn-danger" /> |
            <a asp-page="./Index">Back to List</a>
        </div>
</form>
</div>

Il codice precedente apporta le modifiche seguenti:

  • Aggiorna la direttiva page da @page a @page "{id:int}".
  • Aggiunge un messaggio di errore.
  • Sostituisce FirstMidName con FullName nel campo Administrator.
  • Modifica RowVersion per visualizzare l'ultimo byte.
  • Aggiunge una versione di riga nascosta. RowVersion deve essere aggiunto in modo che il postback associa il valore.

Test dei conflitti di concorrenza

Crea un dipartimento di test.

Apri due istanze del browser nel reparto di test:

  • Esegui l'app e seleziona Departments.
  • Fare clic con il pulsante destro del mouse sul collegamento ipertestuale Delete (Elimina) per il reparto di test e selezionare Apri in una nuova scheda.
  • Fare clic sul collegamento ipertestuale Edit (Modifica) per il reparto di test.

Le due schede del browser visualizzano le stesse informazioni.

Modificare il budget nella prima scheda del browser e fare clic su Salva.

Il browser visualizza la pagina Index con il valore modificato e l'indicatore rowVersion aggiornato. Si noti l'indicatore rowVersion aggiornato, che è visualizzato sul secondo postback nell'altra scheda.

Eliminare il reparto di test dalla seconda scheda. È visualizzato un errore di concorrenza con i valori correnti del database. Facendo clic su Elimina viene eliminata l'entità, a meno che non vi siano stati aggiornamenti a RowVersion.

Per indicazioni sulla creazione di un'app Web affidabile, sicura, efficiente, testabile e scalabile ASP.NET Core, vedere modelli di app Web Enterprise. È disponibile un'app Web di esempio completa di qualità di produzione che implementa i modelli.

Questa esercitazione descrive la gestione dei conflitti quando più utenti aggiornano la stessa entità contemporaneamente. Se si verificano problemi che non riesci a risolvere, scaricare o visualizzare l'app completata.Istruzioni per il download.

Conflitti di simultaneità

Un conflitto di concorrenza si verifica quando:

  • Un utente passa alla pagina di modifica di un'entità.
  • Un altro utente aggiorna la stessa entità prima che la modifica del primo utente venga scritta nel database.

Se non è abilitato il rilevamento della concorrenza, quando si verificano aggiornamenti concorrenti:

  • L'ultimo aggiornamento è quello valido. In altri termini, nel database vengono salvati i valori dell'ultimo aggiornamento.
  • Il primo degli aggiornamenti correnti va perso.

Concorrenza ottimistica

La concorrenza ottimistica consente che si verifichino conflitti di concorrenza e poi reagisce in modo appropriato quando si verificano. Ad esempio Jane visita la pagina Department Edit (Modifica - Reparto) e cambia il budget per il reparto English (Inglese) da $ 350.000,00 a $ 0,00.

Modifica del budget a 0

Prima che Jane faccia clic su Salva John visita la stessa pagina e cambia il valore del campo Start Date (Data inizio) da 9/1/2007 a 9/1/2013.

Modifica della data di inizio in 2013

Jane fa clic su Salva per prima e vede la sua modifica quando il browser visualizza la pagina Indice.

Budget impostato su zero

John fa clic su Salva in una pagina Edit (Modifica) che visualizza ancora un budget pari a $ 350.000,00. Le operazioni successive dipendono da come si decide di gestire i conflitti di concorrenza.

La concorrenza ottimistica include le opzioni seguenti:

  • È possibile tenere traccia della proprietà che un utente ha modificato e aggiornare solo le colonne corrispondenti nel database.

    Con questo scenario non si registra la perdita di dati. I due utenti hanno aggiornato proprietà diverse. Quando un utente torna a visualizzare il reparto English (Inglese), visualizza sia le modifiche di Jane sia quelle di John. Questo metodo di aggiornamento riduce il numero di conflitti che possono comportare la perdita di dati. Questo approccio:

    • Non evita la perdita di dati se vengono apportate modifiche concorrenti alla stessa proprietà.
    • Risulta in genere poco pratico in un'app Web. Richiede il mantenimento di uno stato considerevole per tenere traccia di tutti i valori recuperati e dei nuovi valori. La gestione di grandi quantità di codice statico può ridurre le prestazioni dell'applicazione.
    • Può rendere più complesse le app rispetto al rilevamento della concorrenza in un'entità.
  • È possibile consentire che la modifica di John sovrascriva la modifica di Jane.

    Quando un utente torna a visualizzare il reparto English (Inglese), visualizza 9/1/2013 e il valore $ 350.000,00 recuperato. Questo scenario è detto Priorità client o Last in Wins (Priorità ultimo accesso). Tutti i valori del client hanno la precedenza su ciò che si trova nell'archivio dati. Se non si esegue alcuna codifica per la gestione della concorrenza, il client vince automaticamente.

  • È possibile impedire che la modifica di John venga aggiornata nel database. In genere, l'app:

    • Visualizza un messaggio di errore.
    • Visualizza lo stato corrente dei dati.
    • Consente all'utente di riapplicare le modifiche.

    Questo scenario è detto Store Wins (Priorità archivio). (I valori dell'archivio dati hanno la precedenza su quelli inviati dal client.) In questa esercitazione implementerai lo scenario in cui prevale l'archivio dati. Questo metodo garantisce che nessuna modifica venga sovrascritta senza che l'utente venga avvisato.

Gestione della concorrenza

Quando una proprietà è configurata come token di concorrenza:

  • EF Core verifica che la proprietà non sia stata modificata dopo essere stata recuperata. Il controllo si verifica quando SaveChanges viene chiamato o SaveChangesAsync .
  • Se la proprietà è stata modificata dopo essere stata recuperata, viene generata l'eccezione DbUpdateConcurrencyException.

Il database e il modello di dati devono essere configurati per supportare la generazione di DbUpdateConcurrencyException.

Rilevamento dei conflitti di concorrenza per una proprietà

È possibile rilevare i conflitti di concorrenza a livello di proprietà con l'attributo ConcurrencyCheck. L'attributo può essere applicato a più proprietà del modello. Per altre informazioni, vedere Data Annotations-ConcurrencyCheck (Annotazioni dei dati - ConcurrencyCheck).

L'attributo [ConcurrencyCheck] non viene usato in questa esercitazione.

Rilevare i conflitti di concorrenza in una riga

Per rilevare i conflitti di concorrenza si aggiunge al modello una colonna di rilevamento rowversion. rowversion :

  • È specifica di SQL Server. È possibile che altri database non dispongano di una funzionalità simile.
  • Viene usata per determinare che un'entità non è stata modificata dopo il suo recupero dal database.

Il database genera un numero rowversion sequenziale che viene incrementato ogni volta che la riga viene aggiornata. In un comando Update o Delete la clausola Where include il valore recuperato di rowversion. Se la riga che viene aggiornata è stata modificata:

  • rowversion non corrisponde al valore recuperato.
  • I comandi Update o Delete non trovano una riga perché la clausola Where include il valore rowversion recuperato.
  • Viene generata un'eccezione DbUpdateConcurrencyException.

In EF Core, quando nessuna riga è stata aggiornata da un Update comando o Delete , viene generata un'eccezione di concorrenza.

Aggiungere una proprietà di rilevamento all'entità Department

In Models/Department.csaggiungere una proprietà di rilevamento denominata RowVersion:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ContosoUniversity.Models
{
    public class Department
    {
        public int DepartmentID { get; set; }

        [StringLength(50, MinimumLength = 3)]
        public string Name { get; set; }

        [DataType(DataType.Currency)]
        [Column(TypeName = "money")]
        public decimal Budget { get; set; }

        [DataType(DataType.Date)]
        [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
        [Display(Name = "Start Date")]
        public DateTime StartDate { get; set; }

        public int? InstructorID { get; set; }

        [Timestamp]
        public byte[] RowVersion { get; set; }

        public Instructor Administrator { get; set; }
        public ICollection<Course> Courses { get; set; }
    }
}

L'attributo Timestamp specifica che questa colonna è inclusa nella clausola Where dei comandi Update e Delete. L'attributo viene chiamato Timestamp perché le versioni precedenti di SQL Server usavano un tipo di dati SQL timestamp prima che questo fosse sostituito dal tipo SQL rowversion.

L'API Fluent può anche specificare la proprietà di rilevamento:

modelBuilder.Entity<Department>()
  .Property<byte[]>("RowVersion")
  .IsRowVersion();

Il codice seguente mostra una parte del T-SQL generato da EF Core quando viene aggiornato il nome del reparto:

SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

Il codice evidenziato precedente mostra la clausola WHERE contenente RowVersion. Se nel database RowVersion non è uguale al parametro RowVersion (@p2) non viene aggiornata nessuna riga.

Il codice evidenziato seguente mostra la notazione T-SQL che verifica che è stata aggiornata esattamente una riga:

SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;

@@ROWCOUNT restituisce il numero di righe interessate dall'ultima istruzione. In nessuna riga viene aggiornata, EF Core genera un'eccezione DbUpdateConcurrencyException.

È possibile visualizzare il T-SQL generato da EF Core nella finestra di output di Visual Studio.

Aggiornare il database

L'aggiunta della proprietà RowVersion cambia il modello di database e ciò richiede una migrazione.

Compila il progetto. Digitare quanto segue in una finestra di comando:

dotnet ef migrations add RowVersion
dotnet ef database update

I comandi precedenti:

  • Aggiunge il file di Migrations/{time stamp}_RowVersion.cs migrazione.

  • Aggiorna il Migrations/SchoolContextModelSnapshot.cs file. L'aggiornamento aggiunge al metodo BuildModel il codice evidenziato di seguito:

  • Eseguono migrations per aggiornare il database.

Genera il modello Reparti

Seguire le istruzioni in Eseguire lo scaffolding del modello student e usare Department per la classe del modello.

Il comando precedente genera lo scaffolding del modello Department. Aprire il progetto in Visual Studio.

Compila il progetto.

Aggiornare la pagina Departments Index (Indice reparti)

Il motore di scaffolding crea una colonna RowVersion per la pagina Index, ma questo campo non deve essere visualizzato. In questa esercitazione, l'ultimo byte di RowVersion viene visualizzato per facilitare la comprensione della concorrenza. L'univocità dell'ultimo byte non è garantita. Un'app reale non visualizza RowVersion o l'ultimo byte di RowVersion.

Aggiornare la pagina Index:

  • Sostituire Indice con Reparti.
  • Sostituire il markup che contiene RowVersion con l'ultimo byte di RowVersion.
  • Sostituire FirstMidName con FullName.

Il markup seguente mostra la pagina aggiornata:

@page
@model ContosoUniversity.Pages.Departments.IndexModel

@{
    ViewData["Title"] = "Departments";
}

<h2>Departments</h2>

<p>
    <a asp-page="Create">Create New</a>
</p>
<table class="table">
    <thead>
        <tr>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].Name)
                </th>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].Budget)
                </th>
                <th>
                    @Html.DisplayNameFor(model => model.Department[0].StartDate)
                </th>
            <th>
                @Html.DisplayNameFor(model => model.Department[0].Administrator)
            </th>
            <th>
                RowVersion
            </th>
            <th></th>
        </tr>
    </thead>
    <tbody>
@foreach (var item in Model.Department) {
        <tr>
            <td>
                @Html.DisplayFor(modelItem => item.Name)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.Budget)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.StartDate)
            </td>
            <td>
                @Html.DisplayFor(modelItem => item.Administrator.FullName)
            </td>
            <td>
                @item.RowVersion[7]
            </td>
            <td>
                <a asp-page="./Edit" asp-route-id="@item.DepartmentID">Edit</a> |
                <a asp-page="./Details" asp-route-id="@item.DepartmentID">Details</a> |
                <a asp-page="./Delete" asp-route-id="@item.DepartmentID">Delete</a>
            </td>
        </tr>
}
    </tbody>
</table>

Aggiornare il modello della pagina Modifica

Aggiornare Pages/Departments/Edit.cshtml.cs con il codice seguente:

using ContosoUniversity.Data;
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class EditModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public EditModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        // Replace ViewData["InstructorID"] 
        public SelectList InstructorNameSL { get; set; }

        public async Task<IActionResult> OnGetAsync(int id)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)  // eager loading
                .AsNoTracking()                 // tracking not required
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                return NotFound();
            }

            // Use strongly typed data rather than ViewData.
            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FirstMidName");

            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            if (!ModelState.IsValid)
            {
                return Page();
            }

            var departmentToUpdate = await _context.Departments
                .Include(i => i.Administrator)
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            // null means Department was deleted by another user.
            if (departmentToUpdate == null)
            {
                return HandleDeletedDepartment();
            }

            // Update the RowVersion to the value when this entity was
            // fetched. If the entity has been updated after it was
            // fetched, RowVersion won't match the DB RowVersion and
            // a DbUpdateConcurrencyException is thrown.
            // A second postback will make them match, unless a new 
            // concurrency issue happens.
            _context.Entry(departmentToUpdate)
                .Property("RowVersion").OriginalValue = Department.RowVersion;

            if (await TryUpdateModelAsync<Department>(
                departmentToUpdate,
                "Department",
                s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
            {
                try
                {
                    await _context.SaveChangesAsync();
                    return RedirectToPage("./Index");
                }
                catch (DbUpdateConcurrencyException ex)
                {
                    var exceptionEntry = ex.Entries.Single();
                    var clientValues = (Department)exceptionEntry.Entity;
                    var databaseEntry = exceptionEntry.GetDatabaseValues();
                    if (databaseEntry == null)
                    {
                        ModelState.AddModelError(string.Empty, "Unable to save. " +
                            "The department was deleted by another user.");
                        return Page();
                    }

                    var dbValues = (Department)databaseEntry.ToObject();
                    await SetDbErrorMessage(dbValues, clientValues, _context);

                    // Save the current RowVersion so next postback
                    // matches unless an new concurrency issue happens.
                    Department.RowVersion = (byte[])dbValues.RowVersion;
                    // Must clear the model error for the next postback.
                    ModelState.Remove("Department.RowVersion");
                }
            }

            InstructorNameSL = new SelectList(_context.Instructors,
                "ID", "FullName", departmentToUpdate.InstructorID);

            return Page();
        }

        private IActionResult HandleDeletedDepartment()
        {
            // ModelState contains the posted data because of the deletion error and will overide the Department instance values when displaying Page().
            ModelState.AddModelError(string.Empty,
                "Unable to save. The department was deleted by another user.");
            InstructorNameSL = new SelectList(_context.Instructors, "ID", "FullName", Department.InstructorID);
            return Page();
        }

        private async Task SetDbErrorMessage(Department dbValues,
                Department clientValues, SchoolContext context)
        {

            if (dbValues.Name != clientValues.Name)
            {
                ModelState.AddModelError("Department.Name",
                    $"Current value: {dbValues.Name}");
            }
            if (dbValues.Budget != clientValues.Budget)
            {
                ModelState.AddModelError("Department.Budget",
                    $"Current value: {dbValues.Budget:c}");
            }
            if (dbValues.StartDate != clientValues.StartDate)
            {
                ModelState.AddModelError("Department.StartDate",
                    $"Current value: {dbValues.StartDate:d}");
            }
            if (dbValues.InstructorID != clientValues.InstructorID)
            {
                Instructor dbInstructor = await _context.Instructors
                   .FindAsync(dbValues.InstructorID);
                ModelState.AddModelError("Department.InstructorID",
                    $"Current value: {dbInstructor?.FullName}");
            }

            ModelState.AddModelError(string.Empty,
                "The record you attempted to edit "
              + "was modified by another user after you. The "
              + "edit operation was canceled and the current values in the database "
              + "have been displayed. If you still want to edit this record, click "
              + "the Save button again.");
        }
    }
}

Per rilevare un problema di concorrenza, viene OriginalValue aggiornato con il rowVersion valore dell'entità recuperata. EF Core genera un comando SQL UPDATE con una clausola WHERE contenente il valore originale RowVersion . Se il comando UPDATE non ha effetto su nessuna riga (nessuna riga ha il valore originale RowVersion), viene generata un'eccezione DbUpdateConcurrencyException.

public async Task<IActionResult> OnPostAsync(int id)
{
    if (!ModelState.IsValid)
    {
        return Page();
    }

    var departmentToUpdate = await _context.Departments
        .Include(i => i.Administrator)
        .FirstOrDefaultAsync(m => m.DepartmentID == id);

    // null means Department was deleted by another user.
    if (departmentToUpdate == null)
    {
        return HandleDeletedDepartment();
    }

    // Update the RowVersion to the value when this entity was
    // fetched. If the entity has been updated after it was
    // fetched, RowVersion won't match the DB RowVersion and
    // a DbUpdateConcurrencyException is thrown.
    // A second postback will make them match, unless a new 
    // concurrency issue happens.
    _context.Entry(departmentToUpdate)
        .Property("RowVersion").OriginalValue = Department.RowVersion;

Nel codice precedente, Department.RowVersion è il valore quando è stata recuperata l'entità. OriginalValue è il valore presente nel database quando in questo metodo è stato chiamato FirstOrDefaultAsync.

Il codice seguente ottiene i valori del client (i valori inseriti in questo metodo) e i valori del database:

try
{
    await _context.SaveChangesAsync();
    return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
    var exceptionEntry = ex.Entries.Single();
    var clientValues = (Department)exceptionEntry.Entity;
    var databaseEntry = exceptionEntry.GetDatabaseValues();
    if (databaseEntry == null)
    {
        ModelState.AddModelError(string.Empty, "Unable to save. " +
            "The department was deleted by another user.");
        return Page();
    }

    var dbValues = (Department)databaseEntry.ToObject();
    await SetDbErrorMessage(dbValues, clientValues, _context);

    // Save the current RowVersion so next postback
    // matches unless an new concurrency issue happens.
    Department.RowVersion = (byte[])dbValues.RowVersion;
    // Must clear the model error for the next postback.
    ModelState.Remove("Department.RowVersion");
}

Il codice seguente aggiunge un messaggio di errore personalizzato per ogni colonna che presenta valori DB diversi da quelli inviati a OnPostAsync:

private async Task SetDbErrorMessage(Department dbValues,
        Department clientValues, SchoolContext context)
{

    if (dbValues.Name != clientValues.Name)
    {
        ModelState.AddModelError("Department.Name",
            $"Current value: {dbValues.Name}");
    }
    if (dbValues.Budget != clientValues.Budget)
    {
        ModelState.AddModelError("Department.Budget",
            $"Current value: {dbValues.Budget:c}");
    }
    if (dbValues.StartDate != clientValues.StartDate)
    {
        ModelState.AddModelError("Department.StartDate",
            $"Current value: {dbValues.StartDate:d}");
    }
    if (dbValues.InstructorID != clientValues.InstructorID)
    {
        Instructor dbInstructor = await _context.Instructors
           .FindAsync(dbValues.InstructorID);
        ModelState.AddModelError("Department.InstructorID",
            $"Current value: {dbInstructor?.FullName}");
    }

    ModelState.AddModelError(string.Empty,
        "The record you attempted to edit "
      + "was modified by another user after you. The "
      + "edit operation was canceled and the current values in the database "
      + "have been displayed. If you still want to edit this record, click "
      + "the Save button again.");
}

Il seguente codice evidenziato imposta il valore RowVersion sul nuovo valore recuperato dal database. Quando l'utente fa di nuovo clic su Salva vengono rilevati solo gli errori di concorrenza che si verificano dopo l'ultima visualizzazione della pagina Edit (Modifica).

try
{
    await _context.SaveChangesAsync();
    return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
    var exceptionEntry = ex.Entries.Single();
    var clientValues = (Department)exceptionEntry.Entity;
    var databaseEntry = exceptionEntry.GetDatabaseValues();
    if (databaseEntry == null)
    {
        ModelState.AddModelError(string.Empty, "Unable to save. " +
            "The department was deleted by another user.");
        return Page();
    }

    var dbValues = (Department)databaseEntry.ToObject();
    await SetDbErrorMessage(dbValues, clientValues, _context);

    // Save the current RowVersion so next postback
    // matches unless an new concurrency issue happens.
    Department.RowVersion = (byte[])dbValues.RowVersion;
    // Must clear the model error for the next postback.
    ModelState.Remove("Department.RowVersion");
}

L'istruzione ModelState.Remove è necessaria perché ModelState presenta il valore obsoleto RowVersion. Nella pagina Razor, il valore ModelState di un campo ha la precedenza sui valori delle proprietà del modello quando sono presenti entrambi.

Aggiornare la pagina Modifica

Aggiorna Pages/Departments/Edit.cshtml con il markup seguente:

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.EditModel
@{
    ViewData["Title"] = "Edit";
}
<h2>Edit</h2>
<h4>Department</h4>
<hr />
<div class="row">
    <div class="col-md-4">
        <form method="post">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            <input type="hidden" asp-for="Department.DepartmentID" />
            <input type="hidden" asp-for="Department.RowVersion" />
            <div class="form-group">
                <label>RowVersion</label>
                @Model.Department.RowVersion[7]
            </div>
            <div class="form-group">
                <label asp-for="Department.Name" class="control-label"></label>
                <input asp-for="Department.Name" class="form-control" />
                <span asp-validation-for="Department.Name" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.Budget" class="control-label"></label>
                <input asp-for="Department.Budget" class="form-control" />
                <span asp-validation-for="Department.Budget" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Department.StartDate" class="control-label"></label>
                <input asp-for="Department.StartDate" class="form-control" />
                <span asp-validation-for="Department.StartDate" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <label class="control-label">Instructor</label>
                <select asp-for="Department.InstructorID" class="form-control"
                        asp-items="@Model.InstructorNameSL"></select>
                <span asp-validation-for="Department.InstructorID" class="text-danger">
                </span>
            </div>
            <div class="form-group">
                <input type="submit" value="Save" class="btn btn-default" />
            </div>
        </form>
    </div>
</div>
<div>
    <a asp-page="./Index">Back to List</a>
</div>
@section Scripts {
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}

Il markup precedente:

  • Aggiorna la direttiva page da @page a @page "{id:int}".
  • Aggiunge una versione di riga nascosta. L'aggiunta di RowVersion è necessaria per far sì che il postback associ il valore.
  • Visualizza l'ultimo byte di RowVersion a scopo di debug.
  • Sostituisce ViewData con l'elemento InstructorNameSL fortemente tipizzato.

Eseguire il test dei conflitti di concorrenza con la pagina Edit (Modifica)

Aprire due istanze di browser con la pagina Edit (Modifica) e il reparto English (Inglese):

  • Eseguire l'app e selezionare Departments (Reparti).
  • Fare clic con il pulsante destro del mouse sul collegamento ipertestuale Edit (Modifica) per il reparto English (Inglese) e selezionare Apri in una nuova scheda.
  • Nella prima scheda fare clic sul collegamento ipertestuale Edit per il reparto English (Inglese).

Le due schede del browser visualizzano le stesse informazioni.

Modificare il nome nella prima scheda del browser e fare clic su Salva.

Department Edit pagina 1 dopo la modifica

Il browser visualizza la pagina Index con il valore modificato e l'indicatore rowVersion aggiornato. Si noti l'indicatore rowVersion aggiornato, che è visualizzato sul secondo postback nell'altra scheda.

Modificare un altro campo nella seconda scheda del browser.

Pagina 2 di modifica del reparto dopo la modifica

Fare clic su Salva. Vengono visualizzati messaggi di errore per tutti i campi che non corrispondono ai valori del database:

Messaggio di errore della pagina Modifica reparto 1

Questa finestra del browser non prevedeva la modifica del campo Name (Nome). Copiare e incollare il valore corrente Languages (Lingue) nel campo Name (Nome). Premi Tab per uscire dal campo. La convalida lato client rimuove il messaggio di errore.

Messaggio di errore della pagina Modifica reparto 2

Fare di nuovo clic su Salva. Il valore immesso nella seconda scheda del browser viene salvato. I valori salvati vengono visualizzati nella pagina Index.

Aggiornare la pagina Delete (Elimina)

Aggiornare il modello di pagina Delete (Elimina) con il codice seguente:

using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;

namespace ContosoUniversity.Pages.Departments
{
    public class DeleteModel : PageModel
    {
        private readonly ContosoUniversity.Data.SchoolContext _context;

        public DeleteModel(ContosoUniversity.Data.SchoolContext context)
        {
            _context = context;
        }

        [BindProperty]
        public Department Department { get; set; }
        public string ConcurrencyErrorMessage { get; set; }

        public async Task<IActionResult> OnGetAsync(int id, bool? concurrencyError)
        {
            Department = await _context.Departments
                .Include(d => d.Administrator)
                .AsNoTracking()
                .FirstOrDefaultAsync(m => m.DepartmentID == id);

            if (Department == null)
            {
                 return NotFound();
            }

            if (concurrencyError.GetValueOrDefault())
            {
                ConcurrencyErrorMessage = "The record you attempted to delete "
                  + "was modified by another user after you selected delete. "
                  + "The delete operation was canceled and the current values in the "
                  + "database have been displayed. If you still want to delete this "
                  + "record, click the Delete button again.";
            }
            return Page();
        }

        public async Task<IActionResult> OnPostAsync(int id)
        {
            try
            {
                if (await _context.Departments.AnyAsync(
                    m => m.DepartmentID == id))
                {
                    // Department.rowVersion value is from when the entity
                    // was fetched. If it doesn't match the DB, a
                    // DbUpdateConcurrencyException exception is thrown.
                    _context.Departments.Remove(Department);
                    await _context.SaveChangesAsync();
                }
                return RedirectToPage("./Index");
            }
            catch (DbUpdateConcurrencyException)
            {
                return RedirectToPage("./Delete",
                    new { concurrencyError = true, id = id });
            }
        }
    }
}

La pagina Elimina rileva i conflitti di concorrenza quando l'entità è cambiata dopo essere stata recuperata. Department.RowVersion è la versione di riga quando l'entità è stata recuperata. Quando EF Core crea il comando SQL DELETE, include una clausola WHERE contenente RowVersion. Se il comando SQL DELETE non ha effetto su nessuna riga:

  • RowVersion nel comando SQL DELETE non corrisponde a RowVersion nel DB.
  • Viene generata un'eccezione DbUpdateConcurrencyException.
  • OnGetAsync viene chiamato con concurrencyError.

Aggiorna la pagina Elimina

Aggiornare Pages/Departments/Delete.cshtml con il codice seguente:

@page "{id:int}"
@model ContosoUniversity.Pages.Departments.DeleteModel

@{
    ViewData["Title"] = "Delete";
}

<h2>Delete</h2>

<p class="text-danger">@Model.ConcurrencyErrorMessage</p>

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>Department</h4>
    <hr />
    <dl class="dl-horizontal">
        <dt>
            @Html.DisplayNameFor(model => model.Department.Name)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.Name)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.Budget)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.Budget)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.StartDate)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.StartDate)
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.RowVersion)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.RowVersion[7])
        </dd>
        <dt>
            @Html.DisplayNameFor(model => model.Department.Administrator)
        </dt>
        <dd>
            @Html.DisplayFor(model => model.Department.Administrator.FullName)
        </dd>
    </dl>
    
    <form method="post">
        <input type="hidden" asp-for="Department.DepartmentID" />
        <input type="hidden" asp-for="Department.RowVersion" />
        <div class="form-actions no-color">
            <input type="submit" value="Delete" class="btn btn-default" /> |
            <a asp-page="./Index">Back to List</a>
        </div>
</form>
</div>

Il codice precedente apporta le modifiche seguenti:

  • Aggiorna la direttiva page da @page a @page "{id:int}".
  • Aggiunge un messaggio di errore.
  • Sostituisce FirstMidName con FullName nel campo Administrator.
  • Modifica RowVersion per visualizzare l'ultimo byte.
  • Aggiunge una versione di riga nascosta. L'aggiunta di RowVersion è necessaria per far sì che il postback associ il valore.

Testare i conflitti di concorrenza con la pagina Elimina

Crea un dipartimento di test.

Aprire due istanze del browser con la pagina Delete (Elimina):

  • Eseguire l'app e selezionare Reparti.
  • Fare clic con il pulsante destro del mouse sul collegamento ipertestuale Delete (Elimina) per il reparto di test e selezionare Apri in una nuova scheda.
  • Fare clic sul collegamento ipertestuale Edit per il reparto di test.

Le due schede del browser visualizzano le stesse informazioni.

Modificare il budget nella prima scheda del browser e fare clic su Salva.

Il browser visualizza la pagina Index con il valore modificato e l'indicatore rowVersion aggiornato. Si noti l'indicatore rowVersion aggiornato, che è visualizzato sul secondo postback nell'altra scheda.

Eliminare il reparto di test dalla seconda scheda. Viene visualizzato un errore di concorrenza con i valori correnti del database. Facendo clic su Elimina viene eliminata l'entità, a meno che RowVersion non sia stato aggiornato.

Per informazioni su come ereditare un modello di dati, vedere Ereditarietà.

Modelli di app Web aziendali

Per indicazioni sulla creazione di un'app Web affidabile, sicura, efficiente, testabile e scalabile ASP.NET Core, vedere modelli di app Web Enterprise. È disponibile un'app Web di esempio completa di qualità di produzione che implementa i modelli.

Passaggi successivi

Questa è l'ultima esercitazione nella serie. Ulteriori argomenti sono trattati nella versione MVC di questa serie di esercitazioni.