Parte 8, Razor Pages com EF Core no ASP.NET Core – Simultaneidade

Tom Dykstra e Jon P Smith

O aplicativo Web Contoso University demonstra como criar aplicativos Web das Razor Pages usando o EF Core e o Visual Studio. Para obter informações sobre a série de tutoriais, consulte o primeiro tutorial.

Se você encontrar problemas que não possa resolver, baixe o aplicativo concluído e compare esse código com o que você criou seguindo o tutorial.

Este tutorial mostra como lidar com conflitos quando os mesmos usuários atualizam uma entidade simultaneamente.

Conflitos de simultaneidade

Um conflito de simultaneidade ocorre quando:

  • Um usuário navega para a página de edição de uma entidade.
  • Outro usuário atualiza a mesma entidade antes que a primeira alteração do usuário seja gravada no banco de dados.

Se a detecção de simultaneidade não estiver habilitada, quem atualizar o banco de dados por último substituirá as alterações do outro usuário. Se esse risco for aceitável, o custo de programação para simultaneidade poderá superar o benefício.

Simultaneidade pessimista

Uma maneira de evitar conflitos de simultaneidade é usar bloqueios de banco de dados. Isso é chamado de simultaneidade pessimista. Antes que o aplicativo leia uma linha de banco de dados que ele pretende atualizar, ele solicita um bloqueio. Depois que uma linha é bloqueada para acesso de atualização, nenhum outro usuário tem permissão para bloquear a linha até que o primeiro bloqueio seja liberado.

O gerenciamento de bloqueios traz desvantagens. Pode ser complexo de programar e causar problemas de desempenho conforme o número de usuários aumenta. O Entity Framework Core não fornece suporte interno para simultaneidade pessimista.

Simultaneidade otimista

A simultaneidade otimista permite que conflitos de simultaneidade ocorram e, em seguida, responde adequadamente quando ocorrem. Por exemplo, Alice visita a página Editar Departamento e altera o orçamento para o departamento de inglês de US$ 350.000,00 para US$ 0,00.

Changing budget to 0

Antes que Alice clique em Salvar, Julio visita a mesma página e altera o campo Data de Início de 1/9/2007 para 1/9/2013.

Changing start date to 2013

Jane clica em Salvar primeiro e vê que sua alteração entrará em vigor, já que o navegador exibe a página de Índice com zero como o valor do Orçamento.

Julio clica em Salvar em uma página Editar que ainda mostra um orçamento de US$ 350.000,00. O que acontece em seguida é determinado pela forma como você lida com conflitos de simultaneidade:

  • Controle qual propriedade um usuário modificou e atualize apenas as colunas correspondentes no banco de dados.

    No cenário, não haverá perda de dados. Propriedades diferentes foram atualizadas pelos dois usuários. Na próxima vez que alguém navegar no departamento de inglês, verá as alterações de Alice e Julio. Esse método de atualização pode reduzir o número de conflitos que podem resultar em perda de dados. Essa abordagem tem algumas desvantagens:

    • Não poderá evitar a perda de dados se forem feitas alterações concorrentes na mesma propriedade.
    • Geralmente, não é prática em um aplicativo Web. Ela exige um estado de manutenção significativo para controlar todos os valores buscados e novos valores. Manter grandes quantidades de estado pode afetar o desempenho do aplicativo.
    • Pode aumentar a complexidade do aplicativo comparado à detecção de simultaneidade em uma entidade.
  • Deixe a alteração de Julio substituir a alteração de Alice.

    Na próxima vez que alguém navegar pelo departamento de inglês, verá 1/9/2013 e o valor de US$ 350.000,00 buscado. Essa abordagem é chamada de um cenário O cliente vence ou O último vence. Todos os valores do cliente têm precedência sobre o conteúdo do armazenamento de dados. O código scaffolded não faz a manipulação de simultaneidade, o cenário O cliente vence ocorrerá automaticamente.

  • Impeça as alterações de Julio de serem atualizadas no banco de dados. Normalmente, o aplicativo:

    • Exibe uma mensagem de erro.
    • Mostra o estado atual dos dados.
    • Permite ao usuário aplicar as alterações novamente.

    Isso é chamado de um cenário O armazenamento vence. Os valores do armazenamento de dados têm precedência sobre os valores enviados pelo cliente. O cenário O armazenamento vence é usado neste tutorial. Esse método garante que nenhuma alteração é substituída sem que um usuário seja alertado.

Detecção de conflitos em EF Core

As propriedades configuradas como tokens de simultaneidade são usadas para implementar o controle de simultaneidade otimista. Quando uma operação de atualização ou exclusão é disparada por SaveChanges ou SaveChangesAsync, o valor do token de simultaneidade no banco de dados é comparado com o valor original lido por EF Core:

  • Se os valores coincidirem, a operação pode ser concluída.
  • Se os valores não coincidirem, o EF Core presume que outro usuário executou uma operação conflitante, anula a transação atual e gera um DbUpdateConcurrencyException.

Outro usuário ou processo executando uma operação conflitante com a operação atual é conhecido como conflito de simultaneidade.

Em bancos de dados relacionais, EF Core verifica o valor do token de simultaneidade na cláusula WHERE de instruções UPDATE e DELETE para detectar um conflito de simultaneidade.

O modelo de dados deve ser configurado para habilitar a detecção de conflitos, incluindo uma coluna de acompanhamento que pode ser usada para determinar quando uma linha foi alterada. O EF fornece duas abordagens para tokens de simultaneidade:

A abordagem do SQL Server e os detalhes de implementação do SQLite são ligeiramente diferentes. Um arquivo de diferença é mostrado posteriormente no tutorial listando as diferenças. A guia Visual Studio mostra a abordagem do SQL Server. A guia Visual Studio Code mostra a abordagem para bancos de dados não SQL Server, como SQLite.

  • No modelo, inclua uma coluna de acompanhamento que é usada para determinar quando uma linha é alterada.
  • Aplique o TimestampAttribute à propriedade de simultaneidade.

Atualize o arquivo Models/Department.cs com o seguinte código:

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

O TimestampAttribute é o que identifica a coluna como uma coluna de acompanhamento de simultaneidade. A API fluente é uma maneira alternativa de especificar a propriedade de acompanhamento:

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

O atributo [Timestamp] em uma propriedade de entidade gera o seguinte código no método ModelBuilder:

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

O código anterior:

  • Define o tipo de propriedade ConcurrencyToken como matriz de bytes. byte[] é o tipo necessário para SQL Server.
  • Chama IsConcurrencyToken. IsConcurrencyToken configura a propriedade como um token de simultaneidade. Em atualizações, o valor do token de simultaneidade no banco de dados é comparado ao valor original para garantir que ele não tenha sido alterado desde que a instância foi recuperada do banco de dados. Se ele tiver sido alterado, um DbUpdateConcurrencyException será gerado e as alterações não serão aplicadas.
  • Chama ValueGeneratedOnAddOrUpdate, que configura a propriedade ConcurrencyToken para ter um valor gerado automaticamente ao adicionar ou atualizar uma entidade.
  • HasColumnType("rowversion") define o tipo de coluna no banco de dados do SQL Server como rowversion.

O seguinte código mostra uma parte do T-SQL gerado pelo EF Core quando o nome do Department é atualizado:

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

O código anterior realçado mostra a cláusula WHERE que contém ConcurrencyToken. Se o banco de dados ConcurrencyToken não for igual ao parâmetro ConcurrencyToken@p2, nenhuma linha será atualizada.

O seguinte código realçado mostra o T-SQL que verifica exatamente se uma linha foi atualizada:

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 retorna o número de linhas afetadas pela última instrução. Se nenhuma linha for atualizada, o EF Core gerará uma DbUpdateConcurrencyException.

Adicionar uma migração

Adicionar a propriedade ConcurrencyToken muda o modelo de dados, o que exige uma migração.

Compile o projeto.

Executar os seguintes comandos no PMC:

Add-Migration RowVersion
Update-Database

Os comandos anteriores:

  • Cria o arquivo de migração Migrations/{time stamp}_RowVersion.cs.
  • Atualiza o arquivo Migrations/SchoolContextModelSnapshot.cs. A atualização adiciona o seguinte código ao método BuildModel:
 b.Property<byte[]>("ConcurrencyToken")
     .IsConcurrencyToken()
     .ValueGeneratedOnAddOrUpdate()
     .HasColumnType("rowversion");

Aplicar scaffold a páginas de Departamento

Siga as instruções em páginas do aluno do Scaffold com as seguintes exceções:

  • Crie uma pasta Pages/Departments.
  • Use Department para a classe de modelo.
  • Use a classe de contexto existente, em vez de criar uma nova.

Adicionar uma classe de utilitário

Na pasta do projeto, crie a classe Utility com o seguinte código:

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

A classe Utility fornece o método GetLastChars usado para exibir os últimos caracteres do token de simultaneidade. O código a seguir mostra o código que funciona com o SQLite e o 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

A #if SQLiteVersiondiretiva de pré-processador isola as diferenças nas versões do SQLite e SQL Server e ajuda:

  • O autor mantém uma base de código para ambas as versões.
  • Os desenvolvedores do SQLite implantam o aplicativo no Azure e usam o SQL Azure.

Compile o projeto.

Atualize a página Índice

A ferramenta de scaffolding criou uma coluna ConcurrencyToken para a página de índice, mas esse campo não seria exibido em um aplicativo de produção. Neste tutorial, a última parte do ConcurrencyToken é exibida para ajudar a mostrar como funciona a manipulação de simultaneidade. A última parte não tem garantia de ser exclusiva em si.

Atualize a página Pages\Departments\Index.cshtml:

  • Substitua Índice por Departamentos.
  • Altere o código que contém ConcurrencyToken para mostrar apenas os últimos caracteres.
  • Substitua FirstMidName por FullName.

O código a seguir mostra a página atualizada:

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

Atualizar o modelo da página Editar

Atualize Pages/Departments/Edit.cshtml.cs com o seguinte código:

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

As atualizações de simultaneidade

OriginalValue é atualizado com o valor ConcurrencyToken da entidade quando ele foi buscado no método OnGetAsync. O EF Core gera um comando SQL UPDATE com uma cláusula WHERE que contém o valor ConcurrencyToken original. Se nenhuma linha for afetada pelo comando UPDATE, uma exceção DbUpdateConcurrencyException será gerada. Nenhuma linha é afetada pelo comando UPDATE quando nenhuma linha tem o valor original de ConcurrencyToken.

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

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

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

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

No código realçado anterior:

  • O valor em Department.ConcurrencyToken é o valor quando a entidade foi buscada na solicitação Get para a página Edit. O valor é fornecido ao método OnPost por um campo oculto na página do Razor que exibe a entidade a ser editada. O valor do campo oculto é copiado para Department.ConcurrencyToken pelo associador de modelos.
  • OriginalValue é o que EF Core usa na cláusula WHERE. Antes que a linha de código realçada seja executada:
    • OriginalValue é o valor que estava no banco de dados quando FirstOrDefaultAsync foi chamado nesse método.
    • Esse valor pode ser diferente do que foi exibido na página Editar.
  • O código realçado garante que o EF Core use o valor ConcurrencyToken original da entidade Department exibida na cláusula WHERE da declaração SQL UPDATE.

O código a seguir mostra o modelo Department. Department é inicializado no:

  • método OnGetAsync pela consulta do EF.
  • método OnPostAsync pelo campo oculto na página Razor usando model binding:
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;

O código anterior mostra que o valor ConcurrencyToken da entidade Department da solicitação HTTP POST é definido como o valor ConcurrencyToken da solicitação HTTP GET.

Quando ocorre um erro de simultaneidade, o código realçado a seguir obtém os valores do cliente (os valores postados para esse método) e os valores do banco de dados.

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

O seguinte código adiciona uma mensagem de erro personalizada a cada coluna que tem valores de banco de dados diferentes daqueles que foram postados em OnPostAsync:

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

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

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

O código realçado a seguir define o valor ConcurrencyToken com o novo valor recuperado do banco de dados. Na próxima vez que o usuário clicar em Salvar, somente os erros de simultaneidade que ocorrerem desde a última exibição da página Editar serão capturados.

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

A instrução ModelState.Remove é obrigatória porque ModelState tem o valor ConcurrencyToken anterior. No Razor Page, o valor ModelState de um campo tem precedência sobre os valores de propriedade do modelo, quando ambos estão presentes.

Diferenças de código do SQL Server versus SQLite

Veja a seguir as diferenças entre as versões do SQL Server e SQLite:

+ using System;    // For GUID on SQLite

+ departmentToUpdate.ConcurrencyToken = Guid.NewGuid();

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

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

Atualizar a página Editar Razor

Atualize Pages/Departments/Edit.cshtml com o seguinte código:

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

O código anterior:

  • Atualiza a diretiva page de @page para @page "{id:int}".
  • Adiciona uma versão de linha oculta. ConcurrencyToken deve ser adicionado para que o postback associe o valor.
  • Exibe o último byte de ConcurrencyToken para fins de depuração.
  • Substitui ViewData pelo InstructorNameSL fortemente tipado.

Testar conflitos de simultaneidade com a página Editar

Abra duas instâncias de navegadores de Editar no departamento de inglês:

  • Execute o aplicativo e selecione Departamentos.
  • Clique com o botão direito do mouse no hiperlink Editar do departamento de inglês e selecione Abrir em uma nova guia.
  • Na primeira guia, clique no hiperlink Editar do departamento de inglês.

As duas guias do navegador exibem as mesmas informações.

Altere o nome na primeira guia do navegador e clique em Salvar.

Department Edit page 1 after change

O navegador mostra a página de Índice com o valor alterado e o indicadorConcurrencyToken atualizado. Observe o indicadorConcurrencyToken atualizado: ele é exibido no segundo postback na outra guia.

Altere outro campo na segunda guia do navegador.

Department Edit page 2 after change

Clique em Save (Salvar). Você verá mensagens de erro em todos os campos que não correspondem aos valores do banco de dados:

Department Edit page error message

Essa janela do navegador não pretendia alterar o campo Name. Copie e cole o valor atual (Languages) para o campo Name. Saída da guia. A validação do lado do cliente remove a mensagem de erro.

Clique em Salvar novamente. O valor inserido na segunda guia do navegador foi salvo. Você verá os valores salvos na página Índice.

Atualizar o modelo da página Excluir

Atualize Pages/Departments/Delete.cshtml.cs com o seguinte código:

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

A página Excluir detectou conflitos de simultaneidade quando a entidade foi alterada depois de ser buscada. Department.ConcurrencyToken é a versão de linha quando a entidade foi buscada. Quando o EF Core cria o comando SQL DELETE, ele inclui uma cláusula WHERE com ConcurrencyToken. Se o comando SQL DELETE não resultar em nenhuma linha afetada:

  • A ConcurrencyToken no comando SQL DELETE não corresponderá a ConcurrencyToken no banco de dados.
  • Uma exceção DbUpdateConcurrencyException é gerada.
  • OnGetAsync é chamado com o concurrencyError.

Atualizar a página Excluir Razor

Atualize Pages/Departments/Delete.cshtml com o seguinte código:

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

O código anterior faz as seguintes alterações:

  • Atualiza a diretiva page de @page para @page "{id:int}".
  • Adiciona uma mensagem de erro.
  • Substitua FirstMidName por FullName no campo Administrador.
  • Altere ConcurrencyToken para exibir o último byte.
  • Adiciona uma versão de linha oculta. ConcurrencyToken deve ser adicionado para que o postback associe o valor.

Testar os conflitos de simultaneidade

Crie um departamento de teste.

Abra duas instâncias dos navegadores de Excluir no departamento de teste:

  • Execute o aplicativo e selecione Departamentos.
  • Clique com o botão direito do mouse no hiperlink Excluir do departamento de teste e selecione Abrir em uma nova guia.
  • Clique no hiperlink Editar do departamento de teste.

As duas guias do navegador exibem as mesmas informações.

Altere o orçamento na primeira guia do navegador e clique em Salvar.

O navegador mostra a página de Índice com o valor alterado e o indicadorConcurrencyToken atualizado. Observe o indicadorConcurrencyToken atualizado: ele é exibido no segundo postback na outra guia.

Exclua o departamento de teste na segunda guia. Um erro de simultaneidade é exibido com os valores atuais do banco de dados. Clicar em Excluir exclui a entidade, a menos que ConcurrencyToken tenha sido atualizada.

Recursos adicionais

Próximas etapas

Este é o último tutorial da série. Tópicos adicionais são abordados na versão MVC desta série de tutoriais.

Este tutorial mostra como lidar com conflitos quando os mesmos usuários atualizam uma entidade simultaneamente.

Conflitos de simultaneidade

Um conflito de simultaneidade ocorre quando:

  • Um usuário navega para a página de edição de uma entidade.
  • Outro usuário atualiza a mesma entidade antes que a primeira alteração do usuário seja gravada no banco de dados.

Se a detecção de simultaneidade não estiver habilitada, quem atualizar o banco de dados por último substituirá as alterações do outro usuário. Se esse risco for aceitável, o custo de programação para simultaneidade poderá superar o benefício.

Simultaneidade pessimista (bloqueio)

Uma maneira de evitar conflitos de simultaneidade é usar bloqueios de banco de dados. Isso é chamado de simultaneidade pessimista. Antes que o aplicativo leia uma linha de banco de dados que ele pretende atualizar, ele solicita um bloqueio. Depois que uma linha é bloqueada para acesso de atualização, nenhum outro usuário tem permissão para bloquear a linha até que o primeiro bloqueio seja liberado.

O gerenciamento de bloqueios traz desvantagens. Pode ser complexo de programar e causar problemas de desempenho conforme o número de usuários aumenta. O Entity Framework Core não fornece nenhum suporte interno para ele, e este tutorial não mostra como implementá-lo.

Simultaneidade otimista

A simultaneidade otimista permite que conflitos de simultaneidade ocorram e, em seguida, responde adequadamente quando ocorrem. Por exemplo, Alice visita a página Editar Departamento e altera o orçamento para o departamento de inglês de US$ 350.000,00 para US$ 0,00.

Changing budget to 0

Antes que Alice clique em Salvar, Julio visita a mesma página e altera o campo Data de Início de 1/9/2007 para 1/9/2013.

Changing start date to 2013

Jane clica em Salvar primeiro e vê que sua alteração entrará em vigor, já que o navegador exibe a página de Índice com zero como o valor do Orçamento.

Julio clica em Salvar em uma página Editar que ainda mostra um orçamento de US$ 350.000,00. O que acontece em seguida é determinado pela forma como você lida com conflitos de simultaneidade:

  • Controle qual propriedade um usuário modificou e atualize apenas as colunas correspondentes no banco de dados.

    No cenário, não haverá perda de dados. Propriedades diferentes foram atualizadas pelos dois usuários. Na próxima vez que alguém navegar no departamento de inglês, verá as alterações de Alice e Julio. Esse método de atualização pode reduzir o número de conflitos que podem resultar em perda de dados. Essa abordagem tem algumas desvantagens:

    • Não poderá evitar a perda de dados se forem feitas alterações concorrentes na mesma propriedade.
    • Geralmente, não é prática em um aplicativo Web. Ela exige um estado de manutenção significativo para controlar todos os valores buscados e novos valores. Manter grandes quantidades de estado pode afetar o desempenho do aplicativo.
    • Pode aumentar a complexidade do aplicativo comparado à detecção de simultaneidade em uma entidade.
  • Você não pode deixar a alteração de Julio substituir a alteração de Alice.

    Na próxima vez que alguém navegar pelo departamento de inglês, verá 1/9/2013 e o valor de US$ 350.000,00 buscado. Essa abordagem é chamada de um cenário O cliente vence ou O último vence. (Todos os valores do cliente têm precedência sobre o conteúdo do armazenamento de dados.) Se você não fizer nenhuma codificação para a manipulação de simultaneidade, o cenário O cliente vence ocorrerá automaticamente.

  • Você pode impedir que as alterações de Julio sejam atualizadas no banco de dados. Normalmente, o aplicativo:

    • Exibe uma mensagem de erro.
    • Mostra o estado atual dos dados.
    • Permite ao usuário aplicar as alterações novamente.

    Isso é chamado de um cenário O armazenamento vence. (Os valores do armazenamento de dados têm precedência sobre os valores enviados pelo cliente.) Você implementa o cenário O armazenamento vence neste tutorial. Esse método garante que nenhuma alteração é substituída sem que um usuário seja alertado.

Detecção de conflitos em EF Core

O EF Core gera exceções DbConcurrencyException quando detecta conflitos. O modelo de dados deve ser configurado para habilitar a detecção de conflitos. As opções para habilitar a detecção de conflitos incluem as seguintes:

  • Configurar o EF Core para incluir os valores originais das colunas configuradas como tokens de simultaneidade na cláusula Where dos comandos Update e Delete.

    Quando SaveChanges é chamado, a cláusula WHERE procura os valores originais de quaisquer propriedades anotadas com o atributo ConcurrencyCheckAttribute. As declarações de atualização não encontrarão uma linha a ser atualizada se qualquer uma das propriedades do token de simultaneidade for alteradas desde a primeira leitura da linha. O EF Core interpreta isso como um conflito de simultaneidade. Para tabelas de banco de dados que têm muitas colunas, essa abordagem pode resultar em cláusulas Where muito grandes e pode exigir grandes quantidades de estado. Portanto, essa abordagem geralmente não é recomendada e não é o método usado neste tutorial.

  • Na tabela de banco de dados, inclua uma coluna de acompanhamento que pode ser usada para determinar quando uma linha é alterada.

    Em um banco de dados do SQL Server, o tipo de dados da coluna de acompanhamento é rowversion. O valor rowversion é um número sequencial que é incrementado sempre que a linha é atualizada. Em um comando Update ou Delete, a cláusula Where inclui o valor original da coluna de acompanhamento (o número de versão da linha original). Se a linha que está sendo atualizada tiver sido alterada por outro usuário, o valor na coluna rowversion será diferente do valor original. Nesse caso, a instrução Update ou Delete não pode localizar a linha a ser atualizada devido à cláusula Where. O EF Core gera uma exceção de simultaneidade quando nenhuma linha é afetada por um comando Update ou Delete.

Adicionar uma propriedade de acompanhamento

Em Models/Department.cs, adicione uma propriedade de controle chamada RowVersion:

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

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

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

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

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

        public int? InstructorID { get; set; }

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

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

O atributo TimestampAttribute é o que identifica a coluna como uma coluna de acompanhamento de simultaneidade. A API fluente é uma maneira alternativa de especificar a propriedade de acompanhamento:

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

Para um banco de dados do SQL Server, o atributo [Timestamp] em uma propriedade de entidade definida como matriz de bytes:

  • Faz com que a coluna seja incluída nas cláusulas DELETE e UPDATE WHERE.
  • Define o tipo de coluna no banco de dados como rowversion.

O banco de dados gera um número de versão de linha sequencial que é incrementado sempre que a linha é atualizada. Em um comando Update ou Delete, a cláusula Where inclui o valor da versão de linha buscado. Se a linha que está sendo atualizada foi alterada desde que foi buscada:

  • O valor da versão da linha atual não corresponde ao valor obtido.
  • O comando Update ou Delete não localiza uma linha porque a cláusula Where procura o valor da versão da linha buscado.
  • Uma DbUpdateConcurrencyException é gerada.

O seguinte código mostra uma parte do T-SQL gerado pelo EF Core quando o nome do Departamento é atualizado:

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

O código anterior realçado mostra a cláusula WHERE que contém RowVersion. Se o banco de dados RowVersion não for igual ao parâmetro RowVersion (@p2), nenhuma linha será atualizada.

O seguinte código realçado mostra o T-SQL que verifica exatamente se uma linha foi atualizada:

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 retorna o número de linhas afetadas pela última instrução. Se nenhuma linha for atualizada, o EF Core gerará uma DbUpdateConcurrencyException.

Atualizar o banco de dados

Adicionar a propriedade RowVersion muda o modelo de dados, o que exige uma migração.

Compile o projeto.

  • Execute o seguinte comando no PMC:

    Add-Migration RowVersion
    

Esse comando:

  • Cria o arquivo de migração Migrations/{time stamp}_RowVersion.cs.

  • Atualiza o arquivo Migrations/SchoolContextModelSnapshot.cs. A atualização adiciona o seguinte código realçado ao método BuildModel:

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

    Update-Database
    

Aplicar scaffold a páginas de Departamento

  • Siga as instruções em páginas do aluno do Scaffold com as seguintes exceções:

  • Crie uma pasta Pages/Departments.

  • Use Department para a classe de modelo.

    • Use a classe de contexto existente, em vez de criar uma nova.

Compile o projeto.

Atualize a página Índice

A ferramenta de scaffolding criou uma coluna RowVersion para a página de índice, mas esse campo não seria exibido em um aplicativo de produção. Neste tutorial, o último byte do RowVersion é exibido para ajudar a mostrar como funciona a manipulação de simultaneidade. O último byte não tem garantia de ser exclusivo em si.

Atualize a página Pages\Departments\Index.cshtml:

  • Substitua Índice por Departamentos.
  • Altere o código que contém RowVersion para mostrar apenas o último byte da matriz de bytes.
  • Substitua FirstMidName por FullName.

O código a seguir mostra a página atualizada:

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

Atualizar o modelo da página Editar

Atualize Pages/Departments/Edit.cshtml.cs com o seguinte código:

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

O OriginalValue é atualizado com o valor rowVersion da entidade quando ele foi buscado no método OnGetAsync. O EF Core gera um comando SQL UPDATE com uma cláusula WHERE que contém o valor RowVersion original. Se nenhuma linha for afetada pelo comando UPDATE (nenhuma linha tem o valor RowVersion original), uma exceção DbUpdateConcurrencyException será gerada.

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;

No código realçado anterior:

  • O valor em Department.RowVersion é o que estava na entidade quando ela foi buscada originalmente na solicitação Get para a página Editar. O valor é fornecido ao método OnPost por um campo oculto na página do Razor que exibe a entidade a ser editada. O valor do campo oculto é copiado para Department.RowVersion pelo associador de modelos.
  • OriginalValue é o que o EF Core usará na cláusula Where. Antes que a linha de código realçada seja executada, o OriginalValue tem o valor que estava no banco de dados quando FirstOrDefaultAsync foi chamado nesse método, que pode ser diferente do que foi exibido na página de edição.
  • O código realçado garante que o EF Core use o valor RowVersion original da entidade Department exibida na cláusula Where da declaração SQL UPDATE.

Quando ocorre um erro de simultaneidade, o código realçado a seguir obtém os valores do cliente (os valores postados para esse método) e os valores do banco de dados.

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

O seguinte código adiciona uma mensagem de erro personalizada a cada coluna que tem valores de banco de dados diferentes daqueles que foram postados em OnPostAsync:

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

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

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

O código realçado a seguir define o valor RowVersion com o novo valor recuperado do banco de dados. Na próxima vez que o usuário clicar em Salvar, somente os erros de simultaneidade que ocorrerem desde a última exibição da página Editar serão capturados.

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

A instrução ModelState.Remove é obrigatória porque ModelState tem o valor RowVersion antigo. No Razor Page, o valor ModelState de um campo tem precedência sobre os valores de propriedade do modelo, quando ambos estão presentes.

Atualizar a página Editar

Atualize Pages/Departments/Edit.cshtml com o seguinte código:

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

O código anterior:

  • Atualiza a diretiva page de @page para @page "{id:int}".
  • Adiciona uma versão de linha oculta. RowVersion deve ser adicionado para que o postback associe o valor.
  • Exibe o último byte de RowVersion para fins de depuração.
  • Substitui ViewData pelo InstructorNameSL fortemente tipado.

Testar conflitos de simultaneidade com a página Editar

Abra duas instâncias de navegadores de Editar no departamento de inglês:

  • Execute o aplicativo e selecione Departamentos.
  • Clique com o botão direito do mouse no hiperlink Editar do departamento de inglês e selecione Abrir em uma nova guia.
  • Na primeira guia, clique no hiperlink Editar do departamento de inglês.

As duas guias do navegador exibem as mesmas informações.

Altere o nome na primeira guia do navegador e clique em Salvar.

Department Edit page 1 after change

O navegador mostra a página de Índice com o valor alterado e o indicador de rowVersion atualizado. Observe o indicador de rowVersion atualizado: ele é exibido no segundo postback na outra guia.

Altere outro campo na segunda guia do navegador.

Department Edit page 2 after change

Clique em Save (Salvar). Você verá mensagens de erro em todos os campos que não correspondem aos valores do banco de dados:

Department Edit page error message

Essa janela do navegador não pretendia alterar o campo Name. Copie e cole o valor atual (Languages) para o campo Name. Saída da guia. A validação do lado do cliente remove a mensagem de erro.

Clique em Salvar novamente. O valor inserido na segunda guia do navegador foi salvo. Você verá os valores salvos na página Índice.

Atualizar o modelo da página Excluir

Atualize Pages/Departments/Delete.cshtml.cs com o seguinte código:

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

A página Excluir detectou conflitos de simultaneidade quando a entidade foi alterada depois de ser buscada. Department.RowVersion é a versão de linha quando a entidade foi buscada. Quando o EF Core cria o comando SQL DELETE, ele inclui uma cláusula WHERE com RowVersion. Se o comando SQL DELETE não resultar em nenhuma linha afetada:

  • A RowVersion no comando SQL DELETE não corresponderá a RowVersion no banco de dados.
  • Uma exceção DbUpdateConcurrencyException é gerada.
  • OnGetAsync é chamado com o concurrencyError.

Atualizar a página Excluir

Atualize Pages/Departments/Delete.cshtml com o seguinte código:

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

O código anterior faz as seguintes alterações:

  • Atualiza a diretiva page de @page para @page "{id:int}".
  • Adiciona uma mensagem de erro.
  • Substitua FirstMidName por FullName no campo Administrador.
  • Altere RowVersion para exibir o último byte.
  • Adiciona uma versão de linha oculta. RowVersion deve ser adicionado para que o postback associe o valor.

Testar os conflitos de simultaneidade

Crie um departamento de teste.

Abra duas instâncias dos navegadores de Excluir no departamento de teste:

  • Execute o aplicativo e selecione Departamentos.
  • Clique com o botão direito do mouse no hiperlink Excluir do departamento de teste e selecione Abrir em uma nova guia.
  • Clique no hiperlink Editar do departamento de teste.

As duas guias do navegador exibem as mesmas informações.

Altere o orçamento na primeira guia do navegador e clique em Salvar.

O navegador mostra a página de Índice com o valor alterado e o indicador de rowVersion atualizado. Observe o indicador de rowVersion atualizado: ele é exibido no segundo postback na outra guia.

Exclua o departamento de teste na segunda guia. Um erro de simultaneidade é exibido com os valores atuais do banco de dados. Clicar em Excluir exclui a entidade, a menos que RowVersion tenha sido atualizada.

Recursos adicionais

Próximas etapas

Este é o último tutorial da série. Tópicos adicionais são abordados na versão MVC desta série de tutoriais.

Este tutorial mostra como lidar com conflitos quando os mesmos usuários atualizam uma entidade simultaneamente. Caso tenha problemas que não consiga resolver, baixe ou exiba o aplicativo concluído.Baixe as instruções.

Conflitos de simultaneidade

Um conflito de simultaneidade ocorre quando:

  • Um usuário navega para a página de edição de uma entidade.
  • Outro usuário atualiza a mesma entidade antes que a primeira alteração do usuário seja gravada no BD.

Se a detecção de simultaneidade não estiver habilitada, quando ocorrerem atualizações simultâneas:

  • A última atualização vencerá. Ou seja, os últimos valores de atualização serão salvos no BD.
  • A primeira das atualizações atuais será perdida.

Simultaneidade otimista

A simultaneidade otimista permite que conflitos de simultaneidade ocorram e, em seguida, responde adequadamente quando ocorrem. Por exemplo, Alice visita a página Editar Departamento e altera o orçamento para o departamento de inglês de US$ 350.000,00 para US$ 0,00.

Changing budget to 0

Antes que Alice clique em Salvar, Julio visita a mesma página e altera o campo Data de Início de 1/9/2007 para 1/9/2013.

Changing start date to 2013

Alice clica em Salvar primeiro e vê a alteração quando o navegador exibe a página Índice.

Budget changed to zero

Julio clica em Salvar em uma página Editar que ainda mostra um orçamento de US$ 350.000,00. O que acontece em seguida é determinado pela forma como você lida com conflitos de simultaneidade.

A simultaneidade otimista inclui as seguintes opções:

  • Controle qual propriedade um usuário modificou e atualize apenas as colunas correspondentes no BD.

    No cenário, não haverá perda de dados. Propriedades diferentes foram atualizadas pelos dois usuários. Na próxima vez que alguém navegar no departamento de inglês, verá as alterações de Alice e Julio. Esse método de atualização pode reduzir o número de conflitos que podem resultar em perda de dados. Essa abordagem:

    • Não poderá evitar a perda de dados se forem feitas alterações concorrentes na mesma propriedade.
    • Geralmente, não é prática em um aplicativo Web. Ela exige um estado de manutenção significativo para controlar todos os valores buscados e novos valores. Manter grandes quantidades de estado pode afetar o desempenho do aplicativo.
    • Pode aumentar a complexidade do aplicativo comparado à detecção de simultaneidade em uma entidade.
  • Você não pode deixar a alteração de Julio substituir a alteração de Alice.

    Na próxima vez que alguém navegar pelo departamento de inglês, verá 1/9/2013 e o valor de US$ 350.000,00 buscado. Essa abordagem é chamada de um cenário O cliente vence ou O último vence. (Todos os valores do cliente têm precedência sobre o conteúdo do armazenamento de dados.) Se você não fizer nenhuma codificação para a manipulação de simultaneidade, o cenário O cliente vence ocorrerá automaticamente.

  • Você pode impedir que as alterações de Julio sejam atualizadas no BD. Normalmente, o aplicativo:

    • Exibe uma mensagem de erro.
    • Mostra o estado atual dos dados.
    • Permite ao usuário aplicar as alterações novamente.

    Isso é chamado de um cenário O armazenamento vence. (Os valores do armazenamento de dados têm precedência sobre os valores enviados pelo cliente.) Você implementa o cenário O armazenamento vence neste tutorial. Esse método garante que nenhuma alteração é substituída sem que um usuário seja alertado.

Tratamento de simultaneidade

Quando uma propriedade é configurada como um token de simultaneidade:

O BD e o modelo de dados precisam ser configurados para dar suporte à geração de DbUpdateConcurrencyException.

Detectando conflitos de simultaneidade em uma propriedade

Os conflitos de simultaneidade podem ser detectados no nível do propriedade com o atributo ConcurrencyCheck. O atributo pode ser aplicado a várias propriedades no modelo. Para obter mais informações, consulte Data Annotations-ConcurrencyCheck.

O atributo [ConcurrencyCheck] não é usado neste tutorial.

Detectando conflitos de simultaneidade em uma linha

Para detectar conflitos de simultaneidade, uma coluna de controle de rowversion é adicionada ao modelo. rowversion:

  • É específico ao SQL Server. Outros bancos de dados podem não fornecer um recurso semelhante.
  • É usado para determinar se uma entidade não foi alterada desde que foi buscada no BD.

O BD gera um número rowversion sequencial que é incrementado sempre que a linha é atualizada. Em um comando Update ou Delete, a cláusula Where inclui o valor buscado de rowversion. Se a linha que está sendo atualizada foi alterada:

  • rowversion não corresponde ao valor buscado.
  • Os comandos Update ou Delete não encontram uma linha porque a cláusula Where inclui a rowversion buscada.
  • Uma DbUpdateConcurrencyException é gerada.

No EF Core, quando nenhuma linha é atualizada por um comando Update ou Delete, uma exceção de simultaneidade é gerada.

Adicionar uma propriedade de controle à entidade Department

Em Models/Department.cs, adicione uma propriedade de controle chamada RowVersion:

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

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

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

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

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

        public int? InstructorID { get; set; }

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

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

O atributo Timestamp especifica que essa coluna é incluída na cláusula Where dos comandos Update e Delete. O atributo é chamado Timestamp porque as versões anteriores do SQL Server usavam um tipo de dados timestamp do SQL antes de o tipo rowversion SQL substituí-lo.

A API fluente também pode especificar a propriedade de controle:

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

O seguinte código mostra uma parte do T-SQL gerado pelo EF Core quando o nome do Departamento é atualizado:

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

O código anterior realçado mostra a cláusula WHERE que contém RowVersion. Se o BD RowVersion não for igual ao parâmetro RowVersion (@p2), nenhuma linha será atualizada.

O seguinte código realçado mostra o T-SQL que verifica exatamente se uma linha foi atualizada:

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 retorna o número de linhas afetadas pela última instrução. Se nenhuma linha for atualizada, o EF Core gerará uma DbUpdateConcurrencyException.

Veja o T-SQL gerado pelo EF Core na janela de Saída do Visual Studio.

Atualizar o BD

A adição da propriedade RowVersion altera o modelo de BD, o que exige uma migração.

Compile o projeto. Insira o seguinte em uma janela Comando:

dotnet ef migrations add RowVersion
dotnet ef database update

Os comandos anteriores:

  • Adiciona o arquivo de migração Migrations/{time stamp}_RowVersion.cs.

  • Atualiza o arquivo Migrations/SchoolContextModelSnapshot.cs. A atualização adiciona o seguinte código realçado ao método BuildModel:

  • Executam migrações para atualizar o BD.

Gerar o modelo Departamentos por scaffolding

Siga as instruções em Gere um modelo de aluno por scaffold e use Department para a classe de modelo.

O comando anterior gera o modelo Department por scaffolding. Abra o projeto no Visual Studio.

Compile o projeto.

Atualizar a página Índice de Departamentos

O mecanismo de scaffolding criou uma coluna RowVersion para a página Índice, mas esse campo não deve ser exibido. Neste tutorial, o último byte da RowVersion é exibido para ajudar a entender a simultaneidade. O último byte não tem garantia de ser exclusivo. Um aplicativo real não exibe RowVersion ou o último byte de RowVersion.

Atualize a página Índice:

  • Substitua Índice por Departamentos.
  • Substitua a marcação que contém RowVersion pelo último byte de RowVersion.
  • Substitua FirstMidName por FullName.

A seguinte marcação mostra a página atualizada:

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

Atualizar o modelo da página Editar

Atualize Pages/Departments/Edit.cshtml.cs com o seguinte código:

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

Para detectar um problema de simultaneidade, o OriginalValue é atualizado com o valor rowVersion da entidade que foi buscada. O EF Core gera um comando SQL UPDATE com uma cláusula WHERE que contém o valor RowVersion original. Se nenhuma linha for afetada pelo comando UPDATE (nenhuma linha tem o valor RowVersion original), uma exceção DbUpdateConcurrencyException será gerada.

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;

No código anterior, Department.RowVersion é o valor quando a entidade foi buscada. OriginalValue é o valor no BD quando FirstOrDefaultAsync foi chamado nesse método.

O seguinte código obtém os valores de cliente (os valores postados nesse método) e os valores do BD:

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

O seguinte código adiciona uma mensagem de erro personalizada a cada coluna que tem valores de BD diferentes daqueles que foram postados em OnPostAsync:

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

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

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

O código realçado a seguir define o valor RowVersion com o novo valor recuperado do BD. Na próxima vez que o usuário clicar em Salvar, somente os erros de simultaneidade que ocorrerem desde a última exibição da página Editar serão capturados.

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

A instrução ModelState.Remove é obrigatória porque ModelState tem o valor RowVersion antigo. No Razor Page, o valor ModelState de um campo tem precedência sobre os valores de propriedade do modelo, quando ambos estão presentes.

Atualizar a página Editar

Atualize o Pages/Departments/Edit.cshtml com a seguinte marcação:

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

A marcação anterior:

  • Atualiza a diretiva page de @page para @page "{id:int}".
  • Adiciona uma versão de linha oculta. RowVersion deve ser adicionado para que o postback associe o valor.
  • Exibe o último byte de RowVersion para fins de depuração.
  • Substitui ViewData pelo InstructorNameSL fortemente tipado.

Testar conflitos de simultaneidade com a página Editar

Abra duas instâncias de navegadores de Editar no departamento de inglês:

  • Execute o aplicativo e selecione Departamentos.
  • Clique com o botão direito do mouse no hiperlink Editar do departamento de inglês e selecione Abrir em uma nova guia.
  • Na primeira guia, clique no hiperlink Editar do departamento de inglês.

As duas guias do navegador exibem as mesmas informações.

Altere o nome na primeira guia do navegador e clique em Salvar.

Department Edit page 1 after change

O navegador mostra a página de Índice com o valor alterado e o indicador de rowVersion atualizado. Observe o indicador de rowVersion atualizado: ele é exibido no segundo postback na outra guia.

Altere outro campo na segunda guia do navegador.

Department Edit page 2 after change

Clique em Save (Salvar). Você verá mensagens de erro em todos os campos que não correspondem aos valores do BD:

Department Edit page error message 1

Essa janela do navegador não pretendia alterar o campo Name. Copie e cole o valor atual (Languages) para o campo Name. Saída da guia. A validação do lado do cliente remove a mensagem de erro.

Department Edit page error message 2

Clique em Salvar novamente. O valor inserido na segunda guia do navegador foi salvo. Você verá os valores salvos na página Índice.

Atualizar a página Excluir

Atualize o modelo da página Excluir com o seguinte código:

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

A página Excluir detectou conflitos de simultaneidade quando a entidade foi alterada depois de ser buscada. Department.RowVersion é a versão de linha quando a entidade foi buscada. Quando o EF Core cria o comando SQL DELETE, ele inclui uma cláusula WHERE com RowVersion. Se o comando SQL DELETE não resultar em nenhuma linha afetada:

  • A RowVersion no comando SQL DELETE não corresponderá a RowVersion no BD.
  • Uma exceção DbUpdateConcurrencyException é gerada.
  • OnGetAsync é chamado com o concurrencyError.

Atualizar a página Excluir

Atualize Pages/Departments/Delete.cshtml com o seguinte código:

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

O código anterior faz as seguintes alterações:

  • Atualiza a diretiva page de @page para @page "{id:int}".
  • Adiciona uma mensagem de erro.
  • Substitua FirstMidName por FullName no campo Administrador.
  • Altere RowVersion para exibir o último byte.
  • Adiciona uma versão de linha oculta. RowVersion deve ser adicionado para que o postback associe o valor.

Testar conflitos de simultaneidade com a página Excluir

Crie um departamento de teste.

Abra duas instâncias dos navegadores de Excluir no departamento de teste:

  • Execute o aplicativo e selecione Departamentos.
  • Clique com o botão direito do mouse no hiperlink Excluir do departamento de teste e selecione Abrir em uma nova guia.
  • Clique no hiperlink Editar do departamento de teste.

As duas guias do navegador exibem as mesmas informações.

Altere o orçamento na primeira guia do navegador e clique em Salvar.

O navegador mostra a página de Índice com o valor alterado e o indicador de rowVersion atualizado. Observe o indicador de rowVersion atualizado: ele é exibido no segundo postback na outra guia.

Exclua o departamento de teste na segunda guia. Um erro de simultaneidade é exibido com os valores atuais do banco de dados. Clicar em Excluir exclui a entidade, a menos que RowVersion tenha sido atualizada.

Consulte Herança para saber como herdar um modelo de dados.

Recursos adicionais