Teil 8: Razor-Seiten mit EF Core in ASP.NET Core – Parallelität

Tom Dykstra und Jon P. Smith

Die Web-App Contoso University veranschaulicht, wie Razor-Seiten-Web-Apps mit EF Core und Visual Studio erstellt werden können. Informationen zu den Tutorials finden Sie im ersten Tutorial.

Wenn Probleme auftreten, die Sie nicht beheben können, laden Sie die vollständige App herunter, und vergleichen Sie diesen Code mit dem Code, den Sie anhand des Tutorials erstellt haben.

Dieses Tutorial zeigt, wie Sie Konflikte behandeln, wenn mehrere Benutzer gleichzeitig dieselbe Entität aktualisieren.

Nebenläufigkeitskonflikte

Ein Nebenläufigkeitskonflikt tritt auf, wenn:

  • Ein Benutzer zur Bearbeitungsseite für eine Entität navigiert.
  • Ein anderer Benutzer aktualisiert dieselbe Entität, bevor die Änderung des ersten Benutzers in die Datenbank geschrieben wird.

Wenn Parallelitätserkennung nicht aktiviert ist, überschreibt die letzte Aktualisierung der Datenbank die Änderungen des anderen Benutzers. Wenn dieses Risiko akzeptabel ist, können die Kosten für die Programmierung für Parallelitätserkennung den Vorteil überwiegen.

Pessimistische Parallelität

Eine Möglichkeit, Parallelitätskonflikte zu vermeiden, ist die Verwendung von Datenbanksperren. Man bezeichnet dies als pessimistische Parallelität. Bevor die App eine Datenbankzeile liest, die aktualisiert werden soll, wird eine Sperre angefordert. Nachdem eine Zeile für den Aktualisierungszugriff gesperrt wurde, dürfen keine anderen Benutzer die Zeile sperren, bis die erste Sperre freigegeben wird.

Das Verwalten von Sperren hat Nachteile. Die Programmierung kann komplex sein und Leistungsprobleme verursachen, wenn sich die Anzahl der Benutzer erhöht. Entity Framework Core bietet keine integrierte Unterstützung für pessimistische Parallelität.

Optimistische Nebenläufigkeit

Optimistische Nebenläufigkeit lässt Nebenläufigkeitskonflikte zu und reagiert entsprechend, wenn diese auftreten. Beispielsweise besucht Benutzer1 die Bearbeitungsseite des Fachbereichs und ändert das Budget für den englischen Fachbereich von 350.000 $ in 0 $.

Changing budget to 0

Bevor Benutzer1 auf Speichern klickt, besucht Benutzer2 dieselbe Seite und ändert das Feld „Startdatum“ von 9.1.2007 in 9.1.2013.

Changing start date to 2013

Benutzer1 klickt zuerst auf Speichern und sieht, dass die Änderung wirksam wird, da der Browser die Indexseite mit 0 (null) als Budgetbetrag anzeigt.

Benutzer2 klickt auf einer Bearbeitungsseite auf Speichern, die weiterhin ein Budget von 350.000 $ anzeigt. Was anschließend geschieht, ist abhängig davon, wie Sie Parallelitätskonflikte handhaben:

  • Verfolgen Sie nach, welche Eigenschaft ein Benutzer geändert hat, und aktualisieren Sie nur die entsprechenden Spalten in der Datenbank.

    In diesem Szenario sollten keine Daten verloren gehen. Von den beiden Benutzern wurden unterschiedliche Eigenschaften aktualisiert. Das nächste Mal, wenn eine Person den englischen Fachbereich durchsucht, sieht diese die Änderungen von Benutzer1 und Benutzer2. Diese Methode der Aktualisierung kann die Anzahl von Konflikten reduzieren, die zu Datenverlust führen können. Dieser Ansatz bietet einige Nachteile:

    • Kann Datenverlust nicht verhindern, wenn konkurrierende Änderungen an der gleichen Eigenschaft gemacht werden.
    • Ist in einer Web-App in der Regel nicht praktisch. Erfordert, dass der maßgebliche Zustand beibehalten wird, um alle abgerufenen Werte und neuen Werte nachzuverfolgen. Das Verwalten von großen Datenmengen kann den Zustand der App-Leistung beeinträchtigen.
    • Kann die Anwendungskomplexität im Vergleich zur Parallelitätsermittlung für eine Entität erhöhen.
  • Lassen Sie zu, dass die Änderungen von Benutzer2 die Änderungen von Benutzer1 überschreiben.

    Das nächste Mal, wenn jemand den englischen Fachbereich durchsucht, wird das Datum 9.1.2013 und der wiederhergestellte Wert von 350.000 $ angezeigt. Dieses Ansatz wird Client gewinnt- oder Last in Wins-Szenario (Letzter gewinnt) genannt. Alle Werte des Clients besitzen Vorrang vor dem Datenspeicher. Der Gerüstcode führt keine Parallelitätsverarbeitung durch. Client Wins (Speicher gewinnt) erfolgt automatisch.

  • Verhindern Sie, dass die Änderungen von Benutzer2 in der Datenbank aktualisiert werden. In der Regel würde die App:

    • Eine Fehlermeldung anzeigen
    • Den aktuellen Datenstatus anzeigen
    • Dem Benutzer ermöglichen, die Änderungen erneut anzuwenden

    Dieses Szenario wird Store Wins (Speicher gewinnt) genannt. Die Werte des Datenspeichers besitzen Vorrang gegenüber den Werten, die vom Client gesendet werden. Das Szenario „Store Wins“ (Speicher gewinnt) wird in diesem Tutorial verwendet. Diese Methode stellt sicher, dass keine Änderungen überschrieben werden, ohne dass ein Benutzer darüber benachrichtigt wird.

Konflikterkennung in EF Core

Eigenschaften, die als Parallelitätstoken konfiguriert sind, werden zur Implementierung von optimistischer Parallelitätssteuerung verwendet. Wenn ein Aktualisierungs- oder Löschvorgang durch SaveChanges oder SaveChangesAsync ausgelöst wird, wird der Wert des Parallelitätstokens in der Datenbank mit dem ursprünglichen, von EF Core gelesenen Wert verglichen:

  • Falls die Werte übereinstimmen, kann der Vorgang abgeschlossen werden.
  • Ist dies jedoch nicht der Fall, geht EF Core davon aus, dass ein anderer Benutzer einen in Konflikt stehenden Vorgang ausgeführt hat, bricht die aktuelle Transaktion ab, und löst eine DbUpdateConcurrencyException aus.

Wenn ein anderer Benutzer oder Prozess einen mit dem aktuellen Vorgang in Konflikt stehenden Vorgang ausgeführt hat, wird dies als Parallelitätskonflikt bezeichnet.

Bei relationalen Datenbanken überprüft EF Core den Wert des Parallelitätstokens in der WHERE-Klausel der UPDATE- und DELETE-Anweisungen, um einen Parallelitätskonflikt zu erkennen.

Das Datenmodell muss so konfiguriert werden, dass es eine Konflikterkennung ermöglicht, indem eine Nachverfolgungsspalte eingefügt wird, anhand derer festgestellt werden kann, ob eine Zeile geändert wurde. EF bietet zwei Ansätze für Parallelitätstoken:

Der SQL Server-Ansatz und die SQLite-Implementierung unterscheiden sich in den Details geringfügig. Eine Differenzdatei wird später im Tutorial gezeigt, in der die Unterschiede aufgelistet werden. Die Visual Studio-Registerkarte zeigt den SQL Server-Ansatz. Auf der Visual Studio Code-Registerkarte wird der Ansatz für Nicht-SQL Server-Datenbanken (z. B. SQLite) gezeigt.

  • Fügen Sie in das Modell eine Nachverfolgungsspalte ein, die dazu dient, festzustellen, wenn eine Zeile geändert wurde.
  • Wenden Sie TimestampAttribute auf die Parallelitätseigenschaft an.

Aktualisieren Sie die Datei Models/Department.cs mit dem folgenden hervorgehobenen Code:

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 identifiziert die Spalte als Parallelitätsnachverfolgungsspalte. Die Fluent-API ist eine alternative Methode, um die Nachverfolgungseigenschaft anzugeben:

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

Das [Timestamp]-Attribut für eine Entitätseigenschaft generiert den folgenden Code in der ModelBuilder-Methode:

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

Der vorangehende Code:

  • Legt den Eigenschaftentyp ConcurrencyToken auf Bytearray fest. byte[] ist der erforderliche Typ für SQL Server.
  • Aufruf von IsConcurrencyToken. IsConcurrencyToken konfiguriert die Eigenschaft als Parallelitätstoken. Bei Aktualisierungen wird der Wert des Parallelitätstokens in der Datenbank mit dem ursprünglichen Wert verglichen, um sicherzustellen, dass er sich nicht geändert hat, nachdem die Instanz aus der Datenbank abgerufen wurde. Wenn er sich geändert hat, wird eine DbUpdateConcurrencyException ausgelöst, und die Änderungen werden nicht übernommen.
  • Ruft ValueGeneratedOnAddOrUpdate auf, wodurch die ConcurrencyToken-Eigenschaft so konfiguriert wird, dass beim Hinzufügen oder Aktualisieren einer Entität automatisch ein Wert generiert wird.
  • HasColumnType("rowversion") legt den Spaltentyp in der SQL Server-Datenbank auf rowversion fest.

Der folgende Code zeigt einen Teil des T-SQL, das von EF Core generiert wird, wenn der Department-Name aktualisiert wird:

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

Das vorherige markierte Codebeispiel zeigt die WHERE-Klausel mit ConcurrencyToken an. Wenn die Datenbank ConcurrencyToken nicht dem ConcurrencyToken-Parameter @p2 entspricht, werden keine Zeilen aktualisiert.

Der folgende hervorgehobene Code stellt das T-SQL dar, das genau überprüft, ob eine Zeile aktualisiert wurde:

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 gibt die Anzahl der von der letzten Anweisung betroffenen Zeilen zurück. Wenn keine Zeilen aktualisiert werden, löst EF Core eine DbUpdateConcurrencyException aus.

Hinzufügen einer Migration

Das Hinzufügen der ConcurrencyToken-Eigenschaft ändert das Datenbankmodell, das eine Migration erfordert.

Erstellen Sie das Projekt.

Führen Sie die folgenden Befehle in der PMC aus:

Add-Migration RowVersion
Update-Database

Die obenstehenden Befehle haben folgende Konsequenzen:

  • Erstellt die Migrationsdatei Migrations/{time stamp}_RowVersion.cs.
  • Es wird ein Update für die Datei Migrations/SchoolContextModelSnapshot.cs ausgeführt. Die Aktualisierung fügt der BuildModel-Methode den folgenden Code hinzu:
 b.Property<byte[]>("ConcurrencyToken")
     .IsConcurrencyToken()
     .ValueGeneratedOnAddOrUpdate()
     .HasColumnType("rowversion");

Gerüstbau der Department-Seiten

Befolgen Sie die Anweisungen unter Gerüstbau der Student-Seiten mit den folgenden Ausnahmen:

  • Erstellen Sie einen Ordner Pages/Departments.
  • Verwenden Sie Department als Modellklasse.
  • Verwenden Sie die vorhandene Kontextklasse, anstatt eine neue Klasse zu erstellen.

Hinzufügen einer Hilfsprogrammklasse

Erstellen Sie mit dem folgenden Code im Projektordner die Klasse Utility:

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

Die Utility-Klasse stellt die GetLastChars-Methode bereit, mit der die letzten Zeichen des Parallelitätstokens angezeigt werden. Der folgende Code funktioniert mit SQLite und SQL Server:

#if SQLiteVersion
using System;

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

Die Präprozessoranweisung für #if SQLiteVersion isoliert die Unterschiede in den SQLite- und SQL Server-Versionen und unterstützt Folgendes:

  • Der Autor verwaltet eine Codebasis für beide Versionen.
  • SQLite-Entwickler stellen die App in Azure bereit und verwenden SQL Azure.

Erstellen Sie das Projekt.

Aktualisieren der Index-Seite

Das Gerüstbautool hat eine ConcurrencyToken-Spalte für die Index-Seite erstellt, dieses Feld würde jedoch in einer Produktions-App nicht angezeigt werden. In diesem Tutorial wird der letzte Teil von ConcurrencyToken angezeigt, damit Sie ein besseres Verständnis für die Funktionsweise der Parallelitätsverarbeitung bekommen. Der letzte Teil ist nicht zwingend eindeutig.

Aktualisieren Sie die Seite Pages\Departments\Index.cshtml:

  • Ersetzen Sie „Index“ durch „Abteilungen“.
  • Ändern Sie den Code, der ConcurrencyToken enthält, um nur die letzten Zeichen anzuzeigen.
  • Ersetzen Sie FirstMidName durch FullName.

Der folgende Code zeigt die aktualisierte Seite an:

@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>

Aktualisieren des Seitenbearbeitungsmodells

Aktualisieren Sie Pages/Departments/Edit.cshtml.cs mit folgendem Code:

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.");
        }
    }
}

Parallelitätsaktualisierungen

OriginalValue wird mit dem ConcurrencyToken-Wert aus der Entität aktualisiert, als diese in der OnGetAsync-Methode abgerufen wurde. EF Core generiert einen SQL UPDATE-Befehl mit einer WHERE-Klausel, die den ursprünglichen ConcurrencyToken-Wert enthält. Wenn keine Zeilen von dem UPDATE-Befehl betroffen sind, wird eine Ausnahme DbUpdateConcurrencyException ausgelöst. Der Befehl UPDATE wirkt sich nicht auf Zeilen aus, wenn keine Zeilen den ursprünglichen Wert ConcurrencyToken aufweisen.

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;

Im hervorgehobenen Code oben:

  • Der Wert in Department.ConcurrencyToken ist der Wert, als die Entität in der Get-Anforderung für die Edit-Seite abgerufen wurde. Der Wert wird für die OnPost-Methode durch ein ausgeblendetes Feld auf der Razor-Seite bereitgestellt, das die zu bearbeitende Entität anzeigt. Der Wert des ausgeblendeten Felds wird vom Modellbinder in Department.ConcurrencyToken kopiert.
  • OriginalValue gibt an, was EF Core in der WHERE-Klausel verwendet. Bevor die hervorgehobene Codezeile ausgeführt wird:
    • OriginalValue weist den Wert auf, der in der Datenbank vorhanden war, als FirstOrDefaultAsync in dieser Methode aufgerufen wurde.
    • Dieser Wert kann sich von dem unterscheiden, der auf der Seite „Bearbeiten“ angezeigt wurde.
  • Der hervorgehobene Code stellt sicher, dass EF Core den ursprünglichen ConcurrencyToken-Wert aus der angezeigten Department-Entität in der WHERE-Klausel der SQL UPDATE-Anweisung verwendet.

Der folgende Code veranschaulicht das Department-Modell. Department wird in initialisiert:

  • in der OnGetAsync-Methode durch die EF-Abfrage.
  • in der OnPostAsync-Methode nach dem ausgeblendeten Feld auf der Razor-Seite mithilfe der Modellbindung:
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;

Der Code oben zeigt, dass der ConcurrencyToken-Wert der Department-Entität aus der HTTP POST-Anforderung auf den ConcurrencyToken-Wert aus der HTTP GET-Anforderung festgelegt wird.

Wenn ein Parallelitätsfehler auftritt, ruft der folgende hervorgehobene Code die Clientwerte (die Werte, die für diese Methode bereitgestellt werden) und die Datenbankwerte ab.

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)}");
    }

Der folgende Code fügt eine benutzerdefinierte Fehlermeldung für jede Spalte ein, deren Datenbankwerte sich von jenen unterscheiden, die für OnPostAsync bereitgestellt wurden:

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.");
}

Der folgende hervorgehobene Code legt den ConcurrencyToken-Wert auf den neuen Wert fest, der aus der Datenbank abgerufen wurde. Das nächste Mal, wenn der Benutzer auf Speichern klickt, werden nur Parallelitätsfehler abgefangen, die seit der letzten Anzeige der Bearbeitungsseite aufgetreten sind.

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)}");
    }

Die ModelState.Remove-Anweisung ist erforderlich, weil ModelState den vorherigen ConcurrencyToken-Wert aufweist. In der Razor-Seite hat der Wert ModelState Vorrang vor den Modelleigenschaftswerten, wenn beide vorhanden sind.

Codeunterschiede zwischen SQL Server und SQLite

Im Folgenden werden die Unterschiede zwischen den SQL Server- und SQLite-Versionen gezeigt:

+ 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;

Aktualisieren der Seite „Edit“ von Razor

Aktualisieren Sie Pages/Departments/Edit.cshtml mit folgendem Code:

@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");}
}

Der vorangehende Code:

  • Aktualisiert die page-Anweisung von @page auf @page "{id:int}".
  • Fügt eine ausgeblendete Zeilenversion hinzu. ConcurrencyToken muss hinzugefügt werden, damit ein Postback-Ereignis den Wert bindet.
  • Zeigt das letzte Byte von ConcurrencyToken zu Debugzwecken an.
  • Ersetzt ViewData durch den stark typisierten InstructorNameSL.

Testen von Nebenläufigkeitskonflikten mit der Seite „Bearbeiten“

Öffnen Sie für den englischen Fachbereich zwei Browserinstanzen der Seite „Bearbeiten“:

  • Führen Sie die Anwendung aus, und wählen Sie „Abteilungen“ aus.
  • Klicken Sie mit der rechten Maustaste auf den Link Bearbeiten für den englischen Fachbereich, und klicken Sie auf In neuer Registerkarte öffnen.
  • Klicken Sie in der ersten Registerkarte auf den Bearbeiten-Link für den englischen Fachbereich.

Beide Registerkarten zeigen die gleichen Informationen an.

Ändern Sie den Namen in der ersten Registerkarte, und klicken Sie auf Speichern.

Department Edit page 1 after change

Der Browser zeigt die Index-Seite mit dem geänderten Wert und dem aktualisierten ConcurrencyToken-Indikator an. Beachten Sie, dass der aktualisierte ConcurrencyToken-Indikator beim zweiten Postback-Ereignis auf der anderen Registerkarte angezeigt wird.

Ändern Sie ein anderes Feld in der zweiten Registerkarte.

Department Edit page 2 after change

Klicken Sie auf Speichern. Es werden Fehlermeldungen für alle Felder angezeigt, die nicht mit den Datenbankwerten übereinstimmen:

Department Edit page error message

In diesem Browserfenster sollte nicht das Namensfeld geändert werden. Kopieren Sie den aktuellen Wert (Sprachen), und fügen Sie ihn in das Namensfeld ein. Wechseln Sie durch Drücken der TAB-Taste zum nächsten Feld. Im Rahmen der clientseitigen Überprüfung wird die Fehlermeldung entfernt.

Klicken Sie erneut auf Speichern. Der Wert, den Sie auf der zweiten Registerkarte eingegeben haben, wird gespeichert. Die gespeicherten Werte werden auf der Indexseite angezeigt.

Aktualisieren des Modells für die Seite „Löschen“

Aktualisieren Sie Pages/Departments/Delete.cshtml.cs mit folgendem Code:

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 });
            }
        }
    }
}

Die Seite „Löschen“ erkennt Nebenläufigkeitskonflikte, wenn die Entität geändert wurde, nachdem sie abgerufen wurde. Bei Department.ConcurrencyToken handelt es sich um die Zeilenversion, nachdem die Entität abgerufen wurde. Wenn EF Core den SQL DELETE-Befehl erstellt, umfasst dieser eine WHERE-Klausel mit ConcurrencyToken. Wenn die Ergebnisse des SQL DELETE-Befehls null betroffene Zeilen ergeben:

  • Stimmt ConcurrencyToken im SQL DELETE-Befehl nicht mit ConcurrencyToken in der Datenbank überein.
  • Wird eine DbUpdateConcurrencyException-Ausnahme ausgelöst.
  • Wird OnGetAsync mit concurrencyError aufgerufen.

Aktualisieren der Razor-Seite „Delete“ (Löschen)

Aktualisieren Sie Pages/Departments/Delete.cshtml mit folgendem Code:

@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>

Durch den vorangehenden Code werden folgende Änderungen vorgenommen:

  • Aktualisiert die page-Anweisung von @page auf @page "{id:int}".
  • Eine Fehlermeldung wird hinzugefügt.
  • „FirstMidName“ wird durch „FullName“ im Feld Administrator ersetzt.
  • ConcurrencyToken wird geändert, um das letzte Byte anzuzeigen.
  • Fügt eine ausgeblendete Zeilenversion hinzu. ConcurrencyToken muss hinzugefügt werden, damit ein Postback-Ereignis den Wert bindet.

Testen Sie auf Parallelitätskonflikte

Erstellen Sie einen Fachbereich zum Testen.

Öffnen Sie im Testfachbereich zwei Browserinstanzen zum „Löschen“:

  • Führen Sie die Anwendung aus, und wählen Sie „Abteilungen“ aus.
  • Klicken Sie mit der rechten Maustaste auf den Link Löschen für den Testfachbereich, und klicken Sie aufIn neuer Registerkarte öffnen.
  • Klicken Sie auf den Link Bearbeiten für den Testfachbereich.

Beide Registerkarten zeigen die gleichen Informationen an.

Ändern Sie das Budget in der ersten Registerkarte, und klicken Sie auf Speichern.

Der Browser zeigt die Index-Seite mit dem geänderten Wert und dem aktualisierten ConcurrencyToken-Indikator an. Beachten Sie, dass der aktualisierte ConcurrencyToken-Indikator beim zweiten Postback-Ereignis auf der anderen Registerkarte angezeigt wird.

Löschen Sie den Testfachbereich aus der zweiten Registerkarte. Ein Parallelitätsfehler wird mit den aktuellen Werten aus der Datenbank angezeigt. Durch Klicken auf Löschen wird die Entität gelöscht, es sei denn, ConcurrencyToken wurde aktualisiert.

Zusätzliche Ressourcen

Nächste Schritte

Dies ist das letzte Tutorial der Serie. Weitere Themen werden in der MVC-Version dieser Tutorialreihe behandelt.

Dieses Tutorial zeigt, wie Sie Konflikte behandeln, wenn mehrere Benutzer gleichzeitig dieselbe Entität aktualisieren.

Nebenläufigkeitskonflikte

Ein Nebenläufigkeitskonflikt tritt auf, wenn:

  • Ein Benutzer zur Bearbeitungsseite für eine Entität navigiert.
  • Ein anderer Benutzer aktualisiert dieselbe Entität, bevor die Änderung des ersten Benutzers in die Datenbank geschrieben wird.

Wenn Parallelitätserkennung nicht aktiviert ist, überschreibt die letzte Aktualisierung der Datenbank die Änderungen des anderen Benutzers. Wenn dieses Risiko akzeptabel ist, können die Kosten für die Programmierung für Parallelitätserkennung den Vorteil überwiegen.

Pessimistische Parallelität (Sperren)

Eine Möglichkeit, Parallelitätskonflikte zu vermeiden, ist die Verwendung von Datenbanksperren. Man bezeichnet dies als pessimistische Parallelität. Bevor die App eine Datenbankzeile liest, die aktualisiert werden soll, wird eine Sperre angefordert. Nachdem eine Zeile für den Aktualisierungszugriff gesperrt wurde, dürfen keine anderen Benutzer die Zeile sperren, bis die erste Sperre freigegeben wird.

Das Verwalten von Sperren hat Nachteile. Die Programmierung kann komplex sein und Leistungsprobleme verursachen, wenn sich die Anzahl der Benutzer erhöht. Entity Framework Core enthält keine integrierte Unterstützung, und in diesem Tutorial wird nicht gezeigt, wie die Implementierung erfolgt.

Optimistische Nebenläufigkeit

Optimistische Nebenläufigkeit lässt Nebenläufigkeitskonflikte zu und reagiert entsprechend, wenn diese auftreten. Beispielsweise besucht Benutzer1 die Bearbeitungsseite des Fachbereichs und ändert das Budget für den englischen Fachbereich von 350.000 $ in 0 $.

Changing budget to 0

Bevor Benutzer1 auf Speichern klickt, besucht Benutzer2 dieselbe Seite und ändert das Feld „Startdatum“ von 9.1.2007 in 9.1.2013.

Changing start date to 2013

Benutzer1 klickt zuerst auf Speichern und sieht, dass die Änderung wirksam wird, da der Browser die Indexseite mit 0 (null) als Budgetbetrag anzeigt.

Benutzer2 klickt auf einer Bearbeitungsseite auf Speichern, die weiterhin ein Budget von 350.000 $ anzeigt. Was anschließend geschieht, ist abhängig davon, wie Sie Parallelitätskonflikte handhaben:

  • Sie können nachverfolgen, welche Eigenschaft ein Benutzer geändert hat und nur die entsprechenden Spalten in der Datenbank aktualisieren.

    In diesem Szenario sollten keine Daten verloren gehen. Von den beiden Benutzern wurden unterschiedliche Eigenschaften aktualisiert. Das nächste Mal, wenn eine Person den englischen Fachbereich durchsucht, sieht diese die Änderungen von Benutzer1 und Benutzer2. Diese Methode der Aktualisierung kann die Anzahl von Konflikten reduzieren, die zu Datenverlust führen können. Dieser Ansatz bietet einige Nachteile:

    • Kann Datenverlust nicht verhindern, wenn konkurrierende Änderungen an der gleichen Eigenschaft gemacht werden.
    • Ist in einer Web-App in der Regel nicht praktisch. Erfordert, dass der maßgebliche Zustand beibehalten wird, um alle abgerufenen Werte und neuen Werte nachzuverfolgen. Das Verwalten von großen Datenmengen kann den Zustand der App-Leistung beeinträchtigen.
    • Kann die Anwendungskomplexität im Vergleich zur Parallelitätsermittlung für eine Entität erhöhen.
  • Sie können zulassen, dass die Änderungen von Benutzer2 die Änderungen von Benutzer1 überschreiben.

    Das nächste Mal, wenn jemand den englischen Fachbereich durchsucht, wird das Datum 9.1.2013 und der wiederhergestellte Wert von 350.000 $ angezeigt. Dieses Ansatz wird Client gewinnt- oder Last in Wins-Szenario (Letzter gewinnt) genannt. (Alle Werte des Clients haben Vorrang vor dem Datenspeicher.) Wenn Sie keine Codierung für die Parallelitätsbehandlung durchführen, wird automatisch das „Client gewinnt“-Szenario ausgeführt.

  • Sie können verhindern, dass die Änderungen von Benutzer2 in die Datenbank aufgenommen werden. In der Regel würde die App:

    • Eine Fehlermeldung anzeigen
    • Den aktuellen Datenstatus anzeigen
    • Dem Benutzer ermöglichen, die Änderungen erneut anzuwenden

    Dieses Szenario wird Store Wins (Speicher gewinnt) genannt. (Die Werte des Datenspeichers haben Vorrang gegenüber den Werten, die vom Client gesendet werden). In diesem Tutorial implementieren Sie das Szenario „Store Wins“ (Speicher gewinnt). Diese Methode stellt sicher, dass keine Änderungen überschrieben werden, ohne dass ein Benutzer darüber benachrichtigt wird.

Konflikterkennung in EF Core

EF Core löst DbConcurrencyException-Ausnahmen aus, wenn Konflikte erkannt werden. Das Datenmodell muss konfiguriert werden, um die Konflikterkennung zu aktivieren. Optionen für das Aktivieren der Konflikterkennung schließen Folgendes ein:

  • Konfigurieren Sie EF Core so, dass die ursprünglichen Werte von Spalten, die als Parallelitätstoken in der Where-Klausel der Update- und Delete-Befehle enthalten sind, einbezogen werden.

    Wenn SaveChanges aufgerufen wird, sucht die Where-Klausel nach den ursprünglichen Werten von Eigenschaften, die als Anmerkung mit dem ConcurrencyCheckAttribute-Attribut versehen wurden. Die Update-Anweisung findet keine zu aktualisierende Zeile, wenn sich eine der Parallelitätstokeneigenschaften geändert hat, seit die Zeile zum ersten Mal gelesen wurde. EF Core interpretiert dies als einen Parallelitätskonflikt. Bei Datenbanktabellen mit vielen Spalten kann dieser Ansatz zu sehr großen Where-Klauseln führen, was wiederum dazu führen kann, dass eine große Anzahl von Zuständen erforderlich ist. Deshalb wird dieser Ansatz in der Regel nicht empfohlen, und ist nicht die Methode, die in diesem Tutorial verwendet wird.

  • Fügen Sie eine Änderungsverfolgungsspalte in die Datenbanktabelle ein, die verwendet werden kann, um zu bestimmen, wenn eine Änderung an einer Zeile vorgenommen wurde.

    In einer SQL Server-Datenbank ist rowversion der Datentyp der Änderungsverfolgungsspalte. Der Wert rowversion ist eine sequenzielle Zahl, die jedes Mal erhöht wird, wenn die Zeile aktualisiert wird. In einem Update- oder Delete-Befehl enthält die Where-Klausel den ursprünglichen Wert der Änderungsverfolgungsspalte (die ursprüngliche Zeilenversionsnummer). Wenn die zu aktualisierende Zeile von einem anderen Benutzer geändert wurde, unterscheidet sich der Wert in der Spalte rowversion vom ursprünglichen Wert. In diesem Fall kann die Update- oder Delete-Anweisung die zu aktualisierende Zeile aufgrund der Where-Klausel nicht finden. EF Core löst eine Parallelitätsausnahme aus, wenn keine Zeilen von einem Update- oder Delete-Befehl betroffen sind.

Fügen Sie eine Nachverfolgungseigenschaft hinzu

Fügen Sie der Datei Models/Department.cs eine Nachverfolgungseigenschaft namens „RowVersion“ hinzu:

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; }
    }
}

Das TimestampAttribute-Attribut identifiziert die Spalte als Parallelitätsnachverfolgungsspalte. Die Fluent-API ist eine alternative Methode, um die Nachverfolgungseigenschaft anzugeben:

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

Für eine SQL Server-Datenbank wird das [Timestamp]-Attribut für eine Entitätseigenschaft als Bytearray definiert:

  • Bewirkt, dass die Spalte in DELETE- und UPDATE WHERE-Klauseln eingeschlossen wird.
  • Legt den Spaltentyp in der Datenbank auf rowversion fest.

Die Datenbank generiert eine sequenzielle Zeilenversionsnummer, die jedes Mal erhöht wird, wenn die Zeile aktualisiert wird. In einem Update- oder Delete-Befehl enthält die Where-Klausel den abgerufenen Zeilenversionswert. Wenn sich die aktualisierte Zeile seit ihrem Abruf geändert hat, geschieht Folgendes:

  • Der Wert der aktuellen Zeilenversion stimmt nicht mit dem abgerufenen Wert überein.
  • Der Update- oder Delete-Befehl findet keine Zeile, da die Where-Klausel nach dem abgerufenen Zeilenversionswert sucht.
  • Es wird eine DbUpdateConcurrencyException ausgelöst.

Der folgende Code zeigt einen Teil des T-SQL, das von EF Core generiert wird, wenn der Name von Department aktualisiert wird:

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

Das vorherige markierte Codebeispiel zeigt die WHERE-Klausel mit RowVersion an. Wenn die Datenbank RowVersion nicht dem RowVersion-Parameter (@p2) entspricht, werden keine Zeilen aktualisiert.

Der folgende hervorgehobene Code stellt das T-SQL dar, das genau überprüft, ob eine Zeile aktualisiert wurde:

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 gibt die Anzahl der von der letzten Anweisung betroffenen Zeilen zurück. Wenn keine Zeilen aktualisiert werden, löst EF Core eine DbUpdateConcurrencyException aus.

Aktualisieren der Datenbank

Das Hinzufügen der RowVersion-Eigenschaft ändert das Datenbankmodell, das eine Migration erfordert.

Erstellen Sie das Projekt.

  • Führen Sie den folgenden Befehl in der PMC aus:

    Add-Migration RowVersion
    

Dieser Befehl:

  • Erstellt die Migrationsdatei Migrations/{time stamp}_RowVersion.cs.

  • Es wird ein Update für die Datei Migrations/SchoolContextModelSnapshot.cs ausgeführt. Über dieses Update wird der BuildModel-Methode der folgende hervorgehobene Code hinzugefügt:

    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");
        });
    
  • Führen Sie den folgenden Befehl in der PMC aus:

    Update-Database
    

Gerüstbau der Department-Seiten

  • Befolgen Sie die Anweisungen unter Gerüstbau der Student-Seiten mit den folgenden Ausnahmen:

  • Erstellen Sie einen Ordner Pages/Departments.

  • Verwenden Sie Department als Modellklasse.

    • Verwenden Sie die vorhandene Kontextklasse, anstatt eine neue Klasse zu erstellen.

Erstellen Sie das Projekt.

Aktualisieren der Index-Seite

Das Gerüstbautool hat eine RowVersion-Spalte für die Index-Seite erstellt, dieses Feld würde jedoch in einer Produktions-App nicht angezeigt werden. In diesem Tutorial wird das letzte Byte der RowVersion angezeigt, damit Sie ein besseres Verständnis für die Funktionsweise der Parallelitätsverarbeitung bekommen. Das letzte Byte ist nicht zwingend eindeutig.

Aktualisieren Sie die Seite Pages\Departments\Index.cshtml:

  • Ersetzen Sie „Index“ durch „Abteilungen“.
  • Ändern Sie den Code, der RowVersion enthält, um nur das letzte Byte des Bytearrays anzuzeigen.
  • Ersetzen Sie „FirstMidName“durch „FullName“.

Der folgende Code zeigt die aktualisierte Seite an:

@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>

Aktualisieren des Seitenbearbeitungsmodells

Aktualisieren Sie Pages/Departments/Edit.cshtml.cs mit folgendem Code:

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.");
        }
    }
}

OriginalValue wird mit dem rowVersion-Wert aus der Entität aktualisiert, als diese in der OnGetAsync-Methode abgerufen wurde. EF Core generiert einen SQL UPDATE-Befehl mit einer WHERE-Klausel mit dem ursprünglichen RowVersion-Wert. Wenn keine Zeilen durch den UPDATE-Befehl betroffen sind (keine Zeile enthält den ursprünglichen RowVersion-Wert), wird eine DbUpdateConcurrencyException-Ausnahme ausgelöst.

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;

Im hervorgehobenen Code oben:

  • Ist der Wert in Department.RowVersion der Inhalt der Entität, als diese ursprünglich in der Get-Anforderung für die Edit-Seite abgerufen wurde. Der Wert wird für die OnPost-Methode durch ein ausgeblendetes Feld auf der Razor-Seite bereitgestellt, das die zu bearbeitende Entität anzeigt. Der Wert des ausgeblendeten Felds wird vom Modellbinder in Department.RowVersion kopiert.
  • In der Where-Klausel wird OriginalValue von EF Core verwendet. Bevor die hervorgehobene Codezeile ausgeführt wird, enthält OriginalValue den Wert, der in der Datenbank enthalten war, als FirstOrDefaultAsync in dieser Methode aufgerufen wurde. Dieser kann sich möglicherweise von dem auf der Edit-Seite angezeigten Wert unterscheiden.
  • Der hervorgehobene Code stellt sicher, dass EF Core den ursprünglichen RowVersion-Wert aus der angezeigten Department-Entität in der Where-Klausel der SQL UPDATE-Anweisung verwendet.

Wenn ein Parallelitätsfehler auftritt, ruft der folgende hervorgehobene Code die Clientwerte (die Werte, die für diese Methode bereitgestellt werden) und die Datenbankwerte ab.

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");
    }

Der folgende Code fügt eine benutzerdefinierte Fehlermeldung für jede Spalte ein, deren Datenbankwerte sich von jenen unterscheiden, die für OnPostAsync bereitgestellt wurden:

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.");
}

Der folgende hervorgehobene Code legt den RowVersion-Wert auf den neuen Wert fest, der aus der Datenbank abgerufen wurde. Das nächste Mal, wenn der Benutzer auf Speichern klickt, werden nur Parallelitätsfehler abgefangen, die seit der letzten Anzeige der Bearbeitungsseite aufgetreten sind.

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");
    }

Die Anweisung ModelState.Remove ist erforderlich, da ModelState über den alten RowVersion-Wert verfügt. In der Razor-Seite hat der Wert ModelState Vorrang vor den Modelleigenschaftswerten, wenn beide vorhanden sind.

Aktualisieren der Seite „Bearbeiten“

Aktualisieren Sie Pages/Departments/Edit.cshtml mit folgendem Code:

@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");}
}

Der vorangehende Code:

  • Aktualisiert die page-Anweisung von @page auf @page "{id:int}".
  • Fügt eine ausgeblendete Zeilenversion hinzu. RowVersion muss hinzugefügt werden, damit ein Postback-Ereignis den Wert bindet.
  • Zeigt das letzte Byte von RowVersion zu Debugzwecken an.
  • Ersetzt ViewData durch den stark typisierten InstructorNameSL.

Testen von Nebenläufigkeitskonflikten mit der Seite „Bearbeiten“

Öffnen Sie für den englischen Fachbereich zwei Browserinstanzen der Seite „Bearbeiten“:

  • Führen Sie die Anwendung aus, und wählen Sie „Abteilungen“ aus.
  • Klicken Sie mit der rechten Maustaste auf den Link Bearbeiten für den englischen Fachbereich, und klicken Sie auf In neuer Registerkarte öffnen.
  • Klicken Sie in der ersten Registerkarte auf den Bearbeiten-Link für den englischen Fachbereich.

Beide Registerkarten zeigen die gleichen Informationen an.

Ändern Sie den Namen in der ersten Registerkarte, und klicken Sie auf Speichern.

Department Edit page 1 after change

Der Browser zeigt die Indexseite mit dem geänderten Wert und dem aktualisierten RowVersion-Indikator an. Beachten Sie, dass der aktualisierte RowVersion-Indikator beim zweiten Postback-Ereignis auf der anderen Registerkarte angezeigt wird.

Ändern Sie ein anderes Feld in der zweiten Registerkarte.

Department Edit page 2 after change

Klicken Sie auf Speichern. Es werden Fehlermeldungen für alle Felder angezeigt, die nicht mit den Datenbankwerten übereinstimmen:

Department Edit page error message

In diesem Browserfenster sollte nicht das Namensfeld geändert werden. Kopieren Sie den aktuellen Wert (Sprachen), und fügen Sie ihn in das Namensfeld ein. Wechseln Sie durch Drücken der TAB-Taste zum nächsten Feld. Im Rahmen der clientseitigen Überprüfung wird die Fehlermeldung entfernt.

Klicken Sie erneut auf Speichern. Der Wert, den Sie auf der zweiten Registerkarte eingegeben haben, wird gespeichert. Die gespeicherten Werte werden auf der Indexseite angezeigt.

Aktualisieren des Modells für die Seite „Löschen“

Aktualisieren Sie Pages/Departments/Delete.cshtml.cs mit folgendem Code:

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 });
            }
        }
    }
}

Die Seite „Löschen“ erkennt Nebenläufigkeitskonflikte, wenn die Entität geändert wurde, nachdem sie abgerufen wurde. Bei Department.RowVersion handelt es sich um die Zeilenversion, nachdem die Entität abgerufen wurde. Wenn EF Core den SQL-Befehl DELETE erstellt, umfasst dieser eine WHERE-Klausel mit RowVersion. Wenn die Ergebnisse des SQL-DELETE-Befehls 0 (null) betroffene Zeilen ergeben:

  • Stimmt die RowVersion im SQL-DELETE-Befehl nicht mit RowVersion in der Datenbank überein.
  • Wird eine DbUpdateConcurrencyException-Ausnahme ausgelöst.
  • Wird OnGetAsync mit concurrencyError aufgerufen.

Aktualisieren der Seite „Delete“ (Löschen)

Aktualisieren Sie Pages/Departments/Delete.cshtml mit folgendem Code:

@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>

Durch den vorangehenden Code werden folgende Änderungen vorgenommen:

  • Aktualisiert die page-Anweisung von @page auf @page "{id:int}".
  • Eine Fehlermeldung wird hinzugefügt.
  • „FirstMidName“ wird durch „FullName“ im Feld Administrator ersetzt.
  • RowVersion wird geändert, um das letzte Byte anzuzeigen.
  • Fügt eine ausgeblendete Zeilenversion hinzu. RowVersion muss hinzugefügt werden, damit ein Postback-Ereignis den Wert bindet.

Testen Sie auf Parallelitätskonflikte

Erstellen Sie einen Fachbereich zum Testen.

Öffnen Sie im Testfachbereich zwei Browserinstanzen zum „Löschen“:

  • Führen Sie die Anwendung aus, und wählen Sie „Abteilungen“ aus.
  • Klicken Sie mit der rechten Maustaste auf den Link Löschen für den Testfachbereich, und klicken Sie aufIn neuer Registerkarte öffnen.
  • Klicken Sie auf den Link Bearbeiten für den Testfachbereich.

Beide Registerkarten zeigen die gleichen Informationen an.

Ändern Sie das Budget in der ersten Registerkarte, und klicken Sie auf Speichern.

Der Browser zeigt die Indexseite mit dem geänderten Wert und dem aktualisierten RowVersion-Indikator an. Beachten Sie, dass der aktualisierte RowVersion-Indikator beim zweiten Postback-Ereignis auf der anderen Registerkarte angezeigt wird.

Löschen Sie den Testfachbereich aus der zweiten Registerkarte. Ein Parallelitätsfehler wird mit den aktuellen Werten aus der Datenbank angezeigt. Durch Klicken auf Löschen wird die Entität gelöscht, es sei denn, RowVersion wurde aktualisiert.

Zusätzliche Ressourcen

Nächste Schritte

Dies ist das letzte Tutorial der Serie. Weitere Themen werden in der MVC-Version dieser Tutorialreihe behandelt.

Dieses Tutorial zeigt, wie Sie Konflikte behandeln, wenn mehrere Benutzer gleichzeitig dieselbe Entität aktualisieren. Wenn nicht zu lösende Probleme auftreten, laden Sie die fertige App herunter, oder zeigen Sie diese an.Herunterladen der Anweisungen

Nebenläufigkeitskonflikte

Ein Nebenläufigkeitskonflikt tritt auf, wenn:

  • Ein Benutzer zur Bearbeitungsseite für eine Entität navigiert.
  • Ein anderer Benutzer dieselbe Entität aktualisiert, bevor die Änderung des ersten Benutzers in die Datenbank geschrieben wird.

Wenn die Parallelitätserkennung nicht aktiviert ist, während gleichzeitige Updates ausgeführt werden, geschieht Folgendes:

  • Das letzte Update gilt. Das bedeutet, dass die neuesten zu aktualisierenden Werte in der Datenbank gespeichert werden.
  • Das erste der aktuellen Updates geht verloren.

Optimistische Nebenläufigkeit

Optimistische Nebenläufigkeit lässt Nebenläufigkeitskonflikte zu und reagiert entsprechend, wenn diese auftreten. Beispielsweise besucht Benutzer1 die Bearbeitungsseite des Fachbereichs und ändert das Budget für den englischen Fachbereich von 350.000 $ in 0 $.

Changing budget to 0

Bevor Benutzer1 auf Speichern klickt, besucht Benutzer2 dieselbe Seite und ändert das Feld „Startdatum“ von 9.1.2007 in 9.1.2013.

Changing start date to 2013

Benutzer1 klickt zuerst auf Speichern und sieht die Änderungen, wenn im Browser die Indexseite angezeigt wird.

Budget changed to zero

Benutzer2 klickt auf einer Bearbeitungsseite auf Speichern, die weiterhin ein Budget von 350.000 $ anzeigt. Was daraufhin geschieht ist abhängig davon, wie Sie Nebenläufigkeitskonflikte handhaben.

Die optimistische Nebenläufigkeit umfasst die folgenden Optionen:

  • Sie können Nachverfolgen, welche Eigenschaft ein Benutzer geändert hat, und nur die entsprechenden Spalten in der Datenbank aktualisieren.

    In diesem Szenario sollten keine Daten verloren gehen. Von den beiden Benutzern wurden unterschiedliche Eigenschaften aktualisiert. Das nächste Mal, wenn eine Person den englischen Fachbereich durchsucht, sieht diese die Änderungen von Benutzer1 und Benutzer2. Diese Methode der Aktualisierung kann die Anzahl von Konflikten reduzieren, die zu Datenverlust führen können. Dieser Ansatz:

    • Kann Datenverlust nicht verhindern, wenn konkurrierende Änderungen an der gleichen Eigenschaft gemacht werden.
    • Ist in einer Web-App in der Regel nicht praktisch. Erfordert, dass der maßgebliche Zustand beibehalten wird, um alle abgerufenen Werte und neuen Werte nachzuverfolgen. Das Verwalten von großen Datenmengen kann den Zustand der App-Leistung beeinträchtigen.
    • Kann die Anwendungskomplexität im Vergleich zur Parallelitätsermittlung für eine Entität erhöhen.
  • Sie können zulassen, dass die Änderungen von Benutzer2 die Änderungen von Benutzer1 überschreiben.

    Das nächste Mal, wenn jemand den englischen Fachbereich durchsucht, wird das Datum 9.1.2013 und der wiederhergestellte Wert von 350.000 $ angezeigt. Dieses Ansatz wird Client gewinnt- oder Last in Wins-Szenario (Letzter gewinnt) genannt. (Alle Werte des Clients haben Vorrang vor dem Datenspeicher.) Wenn Sie keine Codierung für die Parallelitätsbehandlung durchführen, wird automatisch das „Client gewinnt“-Szenario ausgeführt.

  • Sie können verhindern, dass die Änderungen von Benutzer2 in die Datenbank aufgenommen werden. In der Regel würde die App:

    • Eine Fehlermeldung anzeigen
    • Den aktuellen Datenstatus anzeigen
    • Dem Benutzer ermöglichen, die Änderungen erneut anzuwenden

    Dieses Szenario wird Store Wins (Speicher gewinnt) genannt. (Die Werte des Datenspeichers haben Vorrang gegenüber den Werten, die vom Client gesendet werden). In diesem Tutorial implementieren Sie das Szenario „Store Wins“ (Speicher gewinnt). Diese Methode stellt sicher, dass keine Änderungen überschrieben werden, ohne dass ein Benutzer darüber benachrichtigt wird.

Behandlung von Parallelität

Wenn eine Eigenschaft als ein Parallelitätstoken konfiguriert ist:

  • stellt EF Core sicher, dass die Eigenschaft nicht geändert wurde, nachdem sie abgerufen wurde. Die Überprüfung findet statt, wenn SaveChanges oder SaveChangesAsync aufgerufen wird.
  • Wenn die Eigenschaft geändert wurde, nachdem sie abgerufen wurde, wird eine DbUpdateConcurrencyException ausgelöst.

Das Datenbank- und Datenmodell müssen konfiguriert sein, um das Auslösen von DbUpdateConcurrencyException zu unterstützen.

Erkennen von Nebenläufigkeitskonflikten mit Eigenschaften

Nebenläufigkeitskonflikte können auf der Eigenschaftenebene über das ConcurrencyCheck-Attribut erkannt werden. Das Attribut kann auf mehrere Eigenschaften für das Modell angewendet werden. Weitere Informationen finden Sie unter Datenanmerkungen-ConcurrencyCheck.

Das Attribut [ConcurrencyCheck] wird in diesem Tutorial nicht verwendet.

Erkennen von Nebenläufigkeitskonflikten mit einer Zeile

Um Nebenläufigkeitskonflikte zu erkennen, wird dem Modell eine Rowversion-Nachverfolgungsspalte (Zeilenversion) hinzugefügt. rowversion :

  • Ist SQL Server-spezifisch. Andere Datenbanken enthalten möglicherweise keine ähnlichen Features.
  • Wird verwendet, um zu bestimmen, dass eine Entität, seit dem Abruf aus der Datenbank, nicht geändert wurde.

Die Datenbank generiert eine sequenzielle Anzahl von rowversion, die jedes Mal erhöht wird, wenn die Zeile aktualisiert wird. In einem Update- oder Delete-Befehl enthält die Where-Klausel den abgerufenen Wert von rowversion. Wenn sich die aktualisierte Zeile geändert hat, geschieht Folgendes:

  • rowversion entspricht nicht dem abgerufenen Wert.
  • Die Befehle Update oder Delete finden keine Zeile, da die Where-Klausel die abgerufene rowversion enthält.
  • Es wird eine DbUpdateConcurrencyException ausgelöst.

In EF Core wird eine Parallelitätsausnahme ausgelöst, wenn keine Zeilen durch einen Update- oder Delete-Befehl aktualisiert werden.

Hinzufügen einer Nachverfolgungseigenschaft zur Entität „Department“

Fügen Sie der Datei Models/Department.cs eine Nachverfolgungseigenschaft namens „RowVersion“ hinzu:

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; }
    }
}

Das Timestamp-Attribut gibt an, dass diese Spalte in der Where-Klausel der Befehle Update und Delete enthalten ist. Das Attribut wird Timestamp genannt, weil vorherige Versionen von SQL Server einen SQL-timestamp-Datentyp verwendet haben, bevor dieser durch SQL-rowversion ersetzt wurde.

Die Fluent-API kann auch die Nachverfolgungseigenschaft angeben:

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

Der folgende Code zeigt einen Teil des T-SQL, das von EF Core generiert wird, wenn der Name von Department aktualisiert wird:

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

Das vorherige markierte Codebeispiel zeigt die WHERE-Klausel mit RowVersion an. Wenn die Datenbank RowVersion nicht dem RowVersion-Parameter (@p2) entspricht, werden keine Zeilen aktualisiert.

Der folgende hervorgehobene Code stellt das T-SQL dar, das genau überprüft, ob eine Zeile aktualisiert wurde:

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 gibt die Anzahl der von der letzten Anweisung betroffenen Zeilen zurück. Wenn keine Zeilen aktualisiert werden, löst EF Core eine DbUpdateConcurrencyException aus.

Sie können das von EF Core generierte T-SQL im Ausgabefenster von Visual Studio sehen.

Aktualisieren der Datenbank

Das Hinzufügen der RowVersion-Eigenschaft ändert das Datenbankmodell, das eine Migration erfordert.

Erstellen Sie das Projekt. Geben Sie Folgendes in ein Befehlsfenster ein:

dotnet ef migrations add RowVersion
dotnet ef database update

Die obenstehenden Befehle haben folgende Konsequenzen:

  • Die Migrationsdatei Migrations/{time stamp}_RowVersion.cs wird hinzugefügt.

  • Es wird ein Update für die Datei Migrations/SchoolContextModelSnapshot.cs ausgeführt. Über dieses Update wird der BuildModel-Methode der folgende hervorgehobene Code hinzugefügt:

  • Migrationen werden durchgeführt, um die Datenbank zu aktualisieren.

Erstellen des Gerüsts für das Abteilungsmodell

Führen Sie die Schritte unter Erstellen des Gerüsts für das Studentenmodell aus, und verwenden Sie Department für die Modellklasse.

Der vorherige Befehl erstellt ein Gerüst für das Department-Modell. Öffnen Sie das Projekt in Visual Studio.

Erstellen Sie das Projekt.

Aktualisieren der Indexseite für Abteilungen

Die Gerüstbauengine hat eine RowVersion-Spalte auf der Indexseite erstellt, jedoch sollte dieses Feld nicht angezeigt werden. In diesem Tutorial wird das letzte Byte der RowVersion angezeigt, damit Sie ein besseres Verständnis über die Parallelität erlangen. Das letzte Byte ist nicht zwingend eindeutig. Eine echte App würde RowVersion oder das letzte Byte von RowVersion nicht anzeigen.

Aktualisieren der Indexseite:

  • Ersetzen Sie „Index“ durch „Abteilungen“.
  • Ersetzen Sie das Markup mit RowVersion durch das letzten Byte von RowVersion.
  • Ersetzen Sie „FirstMidName“durch „FullName“.

Das folgende Markup zeigt die aktualisierte Seite an:

@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>

Aktualisieren des Seitenbearbeitungsmodells

Aktualisieren Sie Pages/Departments/Edit.cshtml.cs mit folgendem Code:

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.");
        }
    }
}

Um ein Parallelitätsproblem zu erkennen, wird OriginalValue mit dem Wert rowVersion aus der Entität aktualisiert, aus der dieser abgerufen wurde. EF Core generiert einen SQL UPDATE-Befehl mit einer WHERE-Klausel mit dem ursprünglichen RowVersion-Wert. Wenn keine Zeilen durch den UPDATE-Befehl betroffen sind (keine Zeile enthält den ursprünglichen RowVersion-Wert), wird eine DbUpdateConcurrencyException-Ausnahme ausgelöst.

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;

Im obenstehenden Code wird der Wert Department.RowVersion zurückgegeben, sobald die Entität abgerufen wurde. OriginalValue ist der Wert in der Datenbank, wenn FirstOrDefaultAsync in dieser Methode aufgerufen wurde.

Der folgende Code ruft die Clientwerte (die für diese Methode bereitgestellten Werte) und die Datenbankwerte ab:

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");
}

Der folgende Code fügt eine benutzerdefinierte Fehlermeldung für jede Spalte ein, deren Datenbankwerte sich von jenen unterscheiden, die auf OnPostAsync bereitgestellt wurden:

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.");
}

Der folgende hervorgehobene Code legt den RowVersion-Wert auf den neuen Wert fest, der aus der Datenbank abgerufen wurde. Das nächste Mal, wenn der Benutzer auf Speichern klickt, werden nur Parallelitätsfehler abgefangen, die seit der letzten Anzeige der Bearbeitungsseite aufgetreten sind.

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");
}

Die Anweisung ModelState.Remove ist erforderlich, da ModelState über den alten RowVersion-Wert verfügt. In der Razor-Seite hat der Wert ModelState Vorrang vor den Modelleigenschaftswerten, wenn beide vorhanden sind.

Aktualisieren der Seite „Bearbeiten“

Aktualisieren Sie Pages/Departments/Edit.cshtml mit dem folgenden Markup:

@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");}
}

Das obenstehende Markup:

  • Aktualisiert die page-Anweisung von @page auf @page "{id:int}".
  • Fügt eine ausgeblendete Zeilenversion hinzu. RowVersion muss hinzugefügt werden, damit über ein Postback-Ereignis der Wert gebunden werden kann.
  • Zeigt das letzte Byte von RowVersion zu Debugzwecken an.
  • Ersetzt ViewData durch den stark typisierten InstructorNameSL.

Testen von Nebenläufigkeitskonflikten mit der Seite „Bearbeiten“

Öffnen Sie für den englischen Fachbereich zwei Browserinstanzen der Seite „Bearbeiten“:

  • Führen Sie die Anwendung aus, und wählen Sie „Abteilungen“ aus.
  • Klicken Sie mit der rechten Maustaste auf den Link Bearbeiten für den englischen Fachbereich, und klicken Sie auf In neuer Registerkarte öffnen.
  • Klicken Sie in der ersten Registerkarte auf den Bearbeiten-Link für den englischen Fachbereich.

Beide Registerkarten zeigen die gleichen Informationen an.

Ändern Sie den Namen in der ersten Registerkarte, und klicken Sie auf Speichern.

Department Edit page 1 after change

Der Browser zeigt die Indexseite mit dem geänderten Wert und dem aktualisierten RowVersion-Indikator an. Beachten Sie, dass der aktualisierte RowVersion-Indikator beim zweiten Postback-Ereignis auf der anderen Registerkarte angezeigt wird.

Ändern Sie ein anderes Feld in der zweiten Registerkarte.

Department Edit page 2 after change

Klicken Sie auf Speichern. Es werden Fehlermeldungen für alle Felder angezeigt, die nicht mit den Datenbankwerten übereinstimmen:

Department Edit page error message 1

In diesem Browserfenster sollte nicht das Namensfeld geändert werden. Kopieren Sie den aktuellen Wert (Sprachen), und fügen Sie ihn in das Namensfeld ein. Wechseln Sie durch Drücken der TAB-Taste zum nächsten Feld. Im Rahmen der clientseitigen Überprüfung wird die Fehlermeldung entfernt.

Department Edit page error message 2

Klicken Sie erneut auf Speichern. Der Wert, den Sie auf der zweiten Registerkarte eingegeben haben, wird gespeichert. Die gespeicherten Werte werden auf der Indexseite angezeigt.

Aktualisieren der Seite „Delete“ (Löschen)

Aktualisieren Sie das Seitenmodell „Löschen“ mit dem folgenden Code:

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 });
            }
        }
    }
}

Die Seite „Löschen“ erkennt Nebenläufigkeitskonflikte, wenn die Entität geändert wurde, nachdem sie abgerufen wurde. Bei Department.RowVersion handelt es sich um die Zeilenversion, nachdem die Entität abgerufen wurde. Wenn EF Core den SQL-Befehl DELETE erstellt, umfasst dieser eine WHERE-Klausel mit RowVersion. Wenn die Ergebnisse des SQL-DELETE-Befehls 0 (null) betroffene Zeilen ergeben:

  • Stimmt die RowVersion im SQL-DELETE-Befehl nicht mit RowVersion in der Datenbank überein.
  • Wird eine DbUpdateConcurrencyException-Ausnahme ausgelöst.
  • Wird OnGetAsync mit concurrencyError aufgerufen.

Aktualisieren der Seite „Delete“ (Löschen)

Aktualisieren Sie Pages/Departments/Delete.cshtml mit folgendem Code:

@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>

Durch den vorangehenden Code werden folgende Änderungen vorgenommen:

  • Aktualisiert die page-Anweisung von @page auf @page "{id:int}".
  • Eine Fehlermeldung wird hinzugefügt.
  • „FirstMidName“ wird durch „FullName“ im Feld Administrator ersetzt.
  • RowVersion wird geändert, um das letzte Byte anzuzeigen.
  • Fügt eine ausgeblendete Zeilenversion hinzu. RowVersion muss hinzugefügt werden, damit über ein Postback-Ereignis der Wert gebunden werden kann.

Überprüfen von Nebenläufigkeitskonflikten mit der Seite „Löschen“

Erstellen Sie einen Fachbereich zum Testen.

Öffnen Sie im Testfachbereich zwei Browserinstanzen zum „Löschen“:

  • Führen Sie die Anwendung aus, und wählen Sie „Abteilungen“ aus.
  • Klicken Sie mit der rechten Maustaste auf den Link Löschen für den Testfachbereich, und klicken Sie aufIn neuer Registerkarte öffnen.
  • Klicken Sie auf den Link Bearbeiten für den Testfachbereich.

Beide Registerkarten zeigen die gleichen Informationen an.

Ändern Sie das Budget in der ersten Registerkarte, und klicken Sie auf Speichern.

Der Browser zeigt die Indexseite mit dem geänderten Wert und dem aktualisierten RowVersion-Indikator an. Beachten Sie, dass der aktualisierte RowVersion-Indikator beim zweiten Postback-Ereignis auf der anderen Registerkarte angezeigt wird.

Löschen Sie den Testfachbereich aus der zweiten Registerkarte. Ein Parallelitätsfehler wird mit den aktuellen Werten aus der Datenbank angezeigt. Durch Klicken auf Löschen wird die Entität gelöscht, es sei denn, RowVersion wurde aktualisiert.

Informationen zum Vererben eines Datenmodells finden Sie unter Vererbung.

Zusätzliche Ressourcen