Partilhar via


Parte 8, Razor Páginas com EF Core no ASP.NET Core - Concorrência

Tom Dykstrae Jon P Smith

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

Se tiveres problemas que não consegues resolver, faz o download do aplicativo concluído e compara esse código exactamente com o que criaste seguindo o tutorial.

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

Conflitos de concorrência

Um conflito de simultaneidade ocorre quando:

  • Um usuário navega até 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 deteção de simultaneidade não estiver habilitada, quem atualizar o banco de dados por último substituirá as alterações do outro usuário. Se este risco for aceitável, o custo da programação para simultaneidade pode superar o benefício.

Controle de concorrência pessimista

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

A gestão de fechaduras tem desvantagens. Pode ser complexo de programar e pode causar problemas de desempenho à medida que o número de usuários aumenta. O Entity Framework Core não fornece suporte nativo para simultaneidade pessimista.

Concorrência otimista

A simultaneidade otimista permite que conflitos de simultaneidade aconteçam e, em seguida, reage adequadamente quando acontecem. Por exemplo, Jane visita a página de edição do Departamento e altera o orçamento do departamento de Inglês de $350,000.00 para $0,00.

Alterar o orçamento para 0

Antes de Jane clicar Salvar, João visita a mesma página e altera o campo Data de Início de 01/09/2007 a 01/09/2013.

Alteração da data de início para 2013

Jane clica Guardar primeiro e vê a sua alteração ser aplicada, já que o navegador apresenta a página Índice com zero como o valor do orçamento.

João clica Guardar numa página editar que ainda mostra um orçamento de 350.000,00 dólares. O que acontece a seguir é determinado pela forma como você lida com conflitos de simultaneidade:

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

    No cenário, nenhum dado seria perdido. Diferentes propriedades foram atualizadas pelos dois usuários. Da próxima vez que alguém navegar pelo departamento de inglês, verá as mudanças de Jane e John. Esse método de atualização pode reduzir o número de conflitos que podem resultar em perda de dados. Esta abordagem tem algumas desvantagens:

    • Não é possível evitar a perda de dados se forem feitas alterações concorrentes na mesma propriedade.
    • Geralmente não é prático em um aplicativo Web. Requer a manutenção de um estado significativo para acompanhar todos os valores buscados e novos valores. A manutenção de grandes quantidades de estado pode afetar o desempenho do aplicativo.
    • Pode aumentar a complexidade do aplicativo em comparação com a deteção de simultaneidade em uma entidade.
  • Deixe que a mudança de João substitua a mudança de Jane.

    Da próxima vez que alguém navegar pelo departamento de inglês, verá 01/09/2013 e o valor obtido de $350.000,00. Essa abordagem é chamada de cenário Client Wins ou Last in Wins. Todos os valores do cliente têm precedência sobre o que está no armazenamento de dados. O código gerado não faz gestão de simultaneidade, "Client Wins" ocorre automaticamente.

  • Impedir que a alteração do João seja registada na base de dados. Normalmente, o aplicativo:

    • Exiba uma mensagem de erro.
    • Mostrar o estado atual dos dados.
    • Permitir que o usuário reaplique as alterações.

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

Deteçã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 é acionada 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 corresponderem, a operação pode ser concluída.
  • Se os valores não corresponderem, EF Core assume que outro usuário executou uma operação conflitante, anula a transação atual e lança um DbUpdateConcurrencyException.

Outro usuário ou processo que executa uma operação que entra em conflito 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 detetar um conflito de simultaneidade.

O modelo de dados deve ser configurado para habilitar a deteção de conflitos, incluindo uma coluna de controle 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 da implementação do SQLite são ligeiramente diferentes. Um arquivo de diferenças é mostrado posteriormente no tutorial listando as diferenças. A guia Visual Studio mostra a abordagem do SQL Server. A guia Código do Visual Studio mostra a abordagem para bancos de dados que não sejam do SQL Server, como SQLite.

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

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

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 coluna de rastreamento de concorrência. A API fluent é uma maneira alternativa de especificar a propriedade de rastreamento:

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 o SQL Server.
  • Chama IsConcurrencyToken. IsConcurrencyToken configura a propriedade como um token de simultaneidade. Nas 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. Caso tenha ocorrido uma alteração, é gerada uma DbUpdateConcurrencyException e as alterações não sã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 código a seguir mostra uma parte do T-SQL gerado por 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 destacado anterior mostra a cláusula WHERE que contém ConcurrencyToken. Se a ConcurrencyToken do banco de dados não for igual ao parâmetro ConcurrencyToken@p2, nenhuma linha será atualizada.

O código realçado a seguir mostra o T-SQL que verifica se exatamente 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, EF Core gera um DbUpdateConcurrencyException.

Adicionar uma migração

Adicionar a propriedade ConcurrencyToken altera o modelo de dados, o que requer uma migração.

Construa o projeto.

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

Páginas do Departamento de Andaimes

Siga as instruções nas páginas Scaffold Student com as seguintes exceções:

  • Crie uma pasta Páginas/Departamentos.
  • 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 ad 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 diretiva de pré-processador #if SQLiteVersion isola as diferenças nas versões 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.

Construa o projeto.

Atualizar a página Índice

A ferramenta de andaime criou uma coluna ConcurrencyToken para a página Í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. Não é garantido que a última porção seja única por si só.

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 concorrência

OriginalValue é atualizado com o valor ConcurrencyToken da entidade quando esta foi obtida no método OnGetAsync. EF Core gera um comando SQL UPDATE com uma cláusula WHERE contendo o valor ConcurrencyToken original. Se nenhuma linha for afetada pelo comando UPDATE, uma exceção DbUpdateConcurrencyException será lançada. Nenhuma linha é afetada pelo comando UPDATE quando nenhuma linha tem o valor ConcurrencyToken original.

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 destacado anterior:

  • O valor em Department.ConcurrencyToken é o valor quando a entidade foi buscada na solicitação de Get para a página Edit. O valor é fornecido ao método OnPost por um campo oculto na página Razor que exibe a entidade a ser editada. O associador de modelos copia o valor do campo oculto para Department.ConcurrencyToken.
  • OriginalValue é o que EF Core usa na cláusula WHERE. Antes que a linha de código realçada seja executada:
    • OriginalValue tem 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 assegura que EF Core utilize o valor original ConcurrencyToken da entidade Department exibida na cláusula WHERE da instrução SQL UPDATE.

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

  • OnGetAsync método pela consulta EF.
  • Método OnPostAsync através do campo oculto na página Razor usando o modelo de vinculação :
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 lançados nesse 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 código a seguir adiciona uma mensagem de erro personalizada para cada coluna que tem valores de banco de dados diferentes do que foi postado para 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 para o novo valor recuperado do banco de dados. Da próxima vez que o utilizador clicar em Guardar, apenas os erros de simultaneidade que ocorrerem desde a última exibição da página de Edição serão detetados.

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 é necessária porque ModelState tem o valor anterior de ConcurrencyToken. Na página Razor, 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 vs SQLite

A seguir mostra as diferenças entre as versões 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 de Edição 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 vincule o valor.
  • Exibe o último byte de ConcurrencyToken para fins de depuração.
  • Substitui ViewData pelo InstructorNameSLdigitado fortemente.

Testar os conflitos de simultaneidade com a página de edição

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

  • Execute o aplicativo e selecione Departamentos.
  • Clique com o botão direito do rato na hiperligação Editar do departamento de Inglês e selecione Abrir num novo separador.
  • Na primeira guia, clique no hiperlink Edit para o departamento de inglês.

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

Altere o nome na primeira aba do navegador e clique em Gravar.

Departamento Editar página 1 após a alteração

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

Altere um campo diferente na segunda guia do navegador.

Departamento Editar página 2 após a alteração

Clique Salvar. Você vê mensagens de erro para todos os campos que não correspondem aos valores do banco de dados:

mensagem de erro da Página de Edição do Departamento

Esta janela do navegador não pretendia alterar o campo Nome. Copie e cole o valor atual (Idiomas) no campo Nome. Pressione Tab. A validação do lado do cliente remove a mensagem de erro.

Clique Salvar novamente. O valor inserido no segundo separador do navegador foi guardado. Você vê os valores salvos na página Índice.

Atualizar o modelo de página de exclusão

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 detecta conflitos de concorrência quando a entidade é alterada depois de ter sido obtida. Department.ConcurrencyToken é a versão da linha quando a entidade foi obtida. Quando EF Core cria o comando SQL DELETE, ele inclui uma cláusula WHERE com ConcurrencyToken. Se o comando SQL DELETE resultar em zero linhas afetadas:

  • O ConcurrencyToken no comando SQL DELETE não corresponde ConcurrencyToken no banco de dados.
  • Uma DbUpdateConcurrencyException exceção é lançada.
  • OnGetAsync é chamado com o concurrencyError.

Atualizar a página Eliminar 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.
  • Substitui FirstMidName por FullName no campo Administrador.
  • A alteração de ConcurrencyToken para mostrar o último byte.
  • Adiciona uma versão de linha oculta. ConcurrencyToken deve ser adicionado para que o postback vincule o valor.

Testar conflitos de concorrência

Crie um departamento de testes.

Abra duas instâncias de navegador de Delete no departamento de teste:

  • Execute o aplicativo e selecione Departamentos.
  • Clique com o botão direito do rato na hiperligação Eliminar para o departamento de teste e selecione Abrir no novo separador.
  • Clique na hiperligação Editar para o departamento de teste.

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

Altere o orçamento no primeiro separador do navegador e clique em Guardar.

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

Exclua o departamento de teste na segunda guia. Um erro de concorrência é mostrado juntamente com os valores atuais do banco de dados. Clicar em Apagar irá apagar a entidade, a menos que ConcurrencyToken tenha sido atualizada.

Padrões de aplicativos Web corporativos

Para obter orientação sobre como criar um aplicativo ASP.NET Core confiável, seguro, com desempenho, testável e escalável, consulte Padrões de aplicativos Web corporativos. Está disponível um aplicativo Web de exemplo completo com qualidade de produção que implementa os padrões.

Recursos adicionais

Próximos passos

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 vários usuários atualizam uma entidade simultaneamente (ao mesmo tempo).

Conflitos de concorrência

Um conflito de simultaneidade ocorre quando:

  • Um usuário navega até 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 deteção de simultaneidade não estiver habilitada, quem atualizar o banco de dados por último substituirá as alterações do outro usuário. Se este risco for aceitável, o custo da programação para simultaneidade pode superar o benefício.

Concorrência pessimista (controlo)

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

A gestão de fechaduras tem desvantagens. Pode ser complexo de programar e pode causar problemas de desempenho à medida que o número de usuários aumenta. O Entity Framework Core não fornece suporte interno para ele, e este tutorial não mostra como implementá-lo.

Concorrência otimista

A simultaneidade otimista permite que conflitos de simultaneidade aconteçam e, em seguida, reage adequadamente quando acontecem. Por exemplo, Jane visita a página de edição do Departamento e altera o orçamento do departamento de Inglês de $350,000.00 para $0,00.

Alterar o orçamento para 0

Antes de Jane clicar Salvar, João visita a mesma página e altera o campo Data de Início de 01/09/2007 a 01/09/2013.

Alteração da data de início para 2013

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

João clica Guardar numa página de Edição que ainda mostra um orçamento de 350.000,00 USD. O que acontece a seguir é determinado pela forma como você lida com conflitos de simultaneidade:

  • Você pode controlar qual propriedade um usuário modificou e atualizar apenas as colunas correspondentes no banco de dados.

    No cenário, nenhum dado seria perdido. Diferentes propriedades foram atualizadas pelos dois usuários. Da próxima vez que alguém navegar pelo departamento de inglês, verá as mudanças de Jane e John. Esse método de atualização pode reduzir o número de conflitos que podem resultar em perda de dados. Esta abordagem tem algumas desvantagens:

    • Não é possível evitar a perda de dados se forem feitas alterações concorrentes na mesma propriedade.
    • Geralmente não é prático em um aplicativo Web. É necessário manter um estado significativo para controlar todos os valores obtidos e novos valores. A manutenção de grandes quantidades de estado pode afetar o desempenho do aplicativo.
    • Pode aumentar a complexidade do aplicativo em comparação com a deteção de simultaneidade em uma entidade.
  • Você pode deixar a alteração do John substituir a alteração da Jane.

    Da próxima vez que alguém navegar pelo departamento de inglês, verá 01/09/2013 e o valor obtido de $350.000,00. Essa abordagem é denominada cenário Client Wins ou Last in Wins. (Todos os valores do cliente têm precedência sobre o que está no armazenamento de dados.) Se você não fizer nenhuma codificação para manipulação de simultaneidade, Client Wins acontece automaticamente.

  • Você pode impedir que a alteração de João seja atualizada no banco de dados. Normalmente, o aplicativo:

    • Exiba uma mensagem de erro.
    • Mostrar o estado atual dos dados.
    • Permitir que o usuário reaplique as alterações.

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

Deteção de conflitos em EF Core

EF Core lança DbConcurrencyException exceções quando detecta conflitos. O modelo de dados tem de ser configurado para permitir a deteção de conflitos. As opções para habilitar a deteção de conflitos incluem o seguinte:

  • Configure EF Core para incluir os valores originais das colunas configuradas como tokens de simultaneidade e 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. A declaração de atualização não encontrará uma linha para atualizar se alguma das propriedades do token de simultaneidade tiver sido alterada desde que a linha foi inicialmente lida. EF Core interpreta isso como um conflito de concorrência. Para tabelas de bases de dados que têm muitas colunas, esta abordagem pode resultar em cláusulas WHERE muito extensas e pode exigir uma grande quantidade de estado. Portanto, essa abordagem geralmente não é recomendada e não é o método usado neste tutorial.

  • Na tabela do banco de dados, inclua uma coluna de controle que possa ser usada para determinar quando uma linha foi 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 cada vez que a linha é atualizada. Em um comando Atualizar ou Excluir, a cláusula Where inclui o valor original da coluna de acompanhamento (o número da 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 consegue encontrar a linha a ser atualizada devido à cláusula Where. EF Core lança uma exceção de simultaneidade quando nenhuma linha é afetada por um comando Atualizar ou Excluir.

Adicionar uma propriedade de acompanhamento

No Models/Department.cs, adicione uma propriedade de acompanhamento 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 concorrência. A API fluent é uma maneira alternativa de especificar a propriedade de rastreamento:

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 cada vez que a linha é atualizada. Em um comando Update ou Delete, a cláusula Where inclui o valor da versão da linha buscada. Se a linha que está a ser atualizada tiver sido alterada desde que foi buscada:

  • O valor da versão da linha atual não corresponde ao valor buscado.
  • Os comandos Update ou Delete não encontram uma linha porque a cláusula Where procura o valor da versão da linha buscada.
  • Uma DbUpdateConcurrencyException é lançada.

O código a seguir mostra uma parte do T-SQL gerado por 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 destacado anterior mostra a cláusula WHERE que contém RowVersion. Se o RowVersion do banco de dados não for igual ao parâmetro RowVersion (@p2), nenhuma linha será atualizada.

O código realçado a seguir mostra o T-SQL que verifica se exatamente 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, EF Core lançará um DbUpdateConcurrencyException.

Atualizar a base de dados

Adicionar a propriedade RowVersion altera o modelo de dados, o que requer uma migração.

Construa o projeto.

  • Execute o seguinte comando no PMC:

    Add-Migration RowVersion
    

Este 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
    

Páginas do Departamento de Andaimes

  • Siga as instruções nas páginas Scaffold Student com as seguintes exceções:

  • Crie uma pasta Páginas/Departamentos.

  • Use Department para a classe de modelo.

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

Construa o projeto.

Atualizar a página Índice

A ferramenta de andaime criou uma coluna RowVersion para a página Í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. Não é garantido que o último byte seja único por si só.

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 de edição

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 foi buscado no método OnGetAsync. EF Core gera um comando SQL UPDATE com uma cláusula WHERE contendo o valor RowVersion original. Se nenhuma linha for afetada pelo comando UPDATE (nenhuma linha tiver o valor RowVersion original), uma exceção DbUpdateConcurrencyException será lançada.

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 destacado anterior:

  • O valor em Department.RowVersion é o que estava na entidade quando foi originalmente obtido no pedido Get para a página de edição. O valor é fornecido ao método OnPost por um campo oculto na página Razor que exibe a entidade a ser editada. O valor do campo oculto é copiado para Department.RowVersion pelo fichário do modelo.
  • OriginalValue é o que EF Core usará na cláusula Where. Antes que a linha de código realçada seja executada, 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 Editar.
  • O código realçado garante que EF Core use o valor RowVersion original da entidade Department exibida na cláusula Where da instrução SQL UPDATE.

Quando ocorre um erro de simultaneidade, o código realçado a seguir obtém os valores do cliente (os valores lançados nesse 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 código a seguir adiciona uma mensagem de erro personalizada para cada coluna que tem valores de banco de dados diferentes do que foi postado para 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 para o novo valor recuperado do banco de dados. Da próxima vez que o utilizador clicar em Guardar, apenas erros de simultaneidade que ocorrerem desde a última exibição da página Editar serão detetados.

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 é necessária porque ModelState tem o antigo valor de RowVersion. Na página Razor, 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 de edição

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 vincule o valor.
  • Exibe o último byte de RowVersion para fins de depuração.
  • Substitui ViewData pelo InstructorNameSLdigitado fortemente.

Testar conflitos de simultaneidade na página de edição

Abra duas instâncias do navegador para editar no Departamento de Inglês.

  • Execute o aplicativo e selecione Departamentos.
  • Clique com o botão direito do rato na hiperligação Editar para o departamento de inglês e selecione Abrir no novo separador.
  • Na primeira guia, clique no hiperlink Edit para o Departamento de Inglês.

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

Altere o nome no primeiro separador do navegador e clique em Guardar.

Departamento Editar página 1 após a alteração

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

Altere um campo diferente na segunda guia do navegador.

Departamento Editar página 2 após a alteração

Clique em Guardar. Você vê mensagens de erro para todos os campos que não correspondem aos valores do banco de dados:

mensagem de erro da página de edição do Departamento

Esta janela do navegador não pretendia alterar o campo Nome. Copie e cole o valor atual (Idiomas) no campo Nome. Aperte a tecla Tab. A validação no lado do cliente remove a mensagem de erro.

Clicar Salvar novamente. O valor inserido na segunda guia do navegador é salvo. Você vê os valores salvos na página Índice.

Atualizar o modelo da página Eliminar

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 Eliminar deteta conflitos de simultaneidade quando a entidade é alterada após ter sido recuperada. Department.RowVersion é a versão da linha quando a entidade foi buscada. Quando EF Core cria o comando SQL DELETE, ele inclui uma cláusula WHERE com RowVersion. Se o comando SQL DELETE resultar em zero linhas afetadas:

  • O RowVersion no comando SQL DELETE não corresponde a RowVersion no banco de dados.
  • Uma exceção do tipo DbUpdateConcurrencyException é lançada.
  • 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.
  • Substitui FirstMidName por FullName no campo Administrador.
  • Altera o RowVersion para exibir o último byte.
  • Adiciona uma versão de linha oculta. RowVersion deve ser adicionado para que o postback vincule o valor.

Testar conflitos de concorrência

Crie um departamento de teste.

Abra duas instâncias de navegador de Delete no departamento de teste:

  • Execute o aplicativo e selecione Departamentos.
  • Clique com o botão direito do rato na hiperligação Eliminar para o departamento de teste e selecione Abrir no novo separador.
  • Clique no hiperlink Edit para o 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 Índice com o valor alterado e o indicador rowVersion atualizado. Observe o indicador rowVersion atualizado, ele é exibido no segundo postback na outra guia.

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

Para obter orientação sobre como criar um aplicativo ASP.NET Core confiável, seguro, com desempenho, testável e escalável, consulte Padrões de aplicativos Web corporativos. Está disponível um aplicativo Web de exemplo completo com qualidade de produção que implementa os padrões.

Recursos adicionais

Próximos passos

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 vários usuários atualizam uma entidade simultaneamente (ao mesmo tempo). Se tiver problemas que não consegue resolver, pode transferir ou ver a aplicação concluída.Instruções de download.

Conflitos de concorrência

Um conflito de simultaneidade ocorre quando:

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

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

  • A última atualização vence. Ou seja, os últimos valores de atualização são salvos no banco de dados.
  • A primeira das atualizações atuais foi perdida.

Concorrência otimista

A simultaneidade otimista permite que conflitos de simultaneidade aconteçam e, em seguida, reage adequadamente quando acontecem. Por exemplo, Jane visita a página de edição do Departamento e altera o orçamento do departamento de Inglês de $350,000.00 para $0,00.

Alterar o orçamento para 0

Antes de Jane clicar Salvar, João visita a mesma página e altera o campo Data de Início de 01/09/2007 a 01/09/2013.

Alteração da data de início para 2013

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

Orçamento alterado para zero

João clica Guardar numa página de Edição que ainda mostra um orçamento de 350.000,00 USD. O que acontece a seguir é determinado pela forma como você lida com conflitos de concorrência.

A concorrência otimista inclui as seguintes opções:

  • Você pode controlar qual propriedade um usuário modificou e atualizar apenas as colunas correspondentes no banco de dados.

    No cenário, nenhum dado seria perdido. Diferentes propriedades foram atualizadas pelos dois usuários. Da próxima vez que alguém navegar pelo departamento de inglês, verá as mudanças de Jane e John. Esse método de atualização pode reduzir o número de conflitos que podem resultar em perda de dados. Esta abordagem:

    • Não é possível evitar a perda de dados se forem feitas alterações concorrentes na mesma propriedade.
    • Geralmente não é prático em um aplicativo Web. Requer a manutenção de um estado significativo para manter o controle de todos os valores buscados e novos valores. A manutenção de grandes quantidades de estado pode afetar o desempenho do aplicativo.
    • Pode aumentar a complexidade do aplicativo em comparação com a deteção de simultaneidade em uma entidade.
  • Você pode deixar a alteração do João substituir a alteração da Jane.

    Da próxima vez que alguém navegar pelo departamento de inglês, verá 9/1/2013 e o valor de $350.000,00 obtido. Essa abordagem é chamada de Client Wins ou Last in Wins cenário. (Todos os valores do cliente têm precedência sobre o que está no armazenamento de dados.) Se você não fizer nenhuma codificação para manipulação de simultaneidade, Client Wins acontece automaticamente.

  • Você pode impedir que a alteração de João seja atualizada no banco de dados. Normalmente, o aplicativo:

    • Exiba uma mensagem de erro.
    • Mostrar o estado atual dos dados.
    • Permitir que o usuário reaplique as alterações.

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

Manipulação de concorrência

Quando uma propriedade é configurada como um token de simultaneidade :

O banco de dados e o modelo de dados devem ser configurados para suportar o lançamento de DbUpdateConcurrencyException.

Detetando conflitos de simultaneidade numa propriedade

Conflitos de simultaneidade podem ser detetados ao nível da propriedade com o atributo ConcurrencyCheck. O atributo pode ser aplicado a várias propriedades no modelo. Para obter mais informações, consulte Anotações de Dados-Verificação de Concorrência.

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

Detetando conflitos de concorrência numa linha

Para detetar conflitos de simultaneidade, uma rowversion coluna de acompanhamento é adicionada ao modelo. rowversion :

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

O banco de dados gera um número de rowversion sequencial que é incrementado cada vez 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 tiver sido 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 rowversionbuscada.
  • Uma DbUpdateConcurrencyException é lançada.

No EF Core, quando nenhuma linha tiver sido atualizada por um comando Update ou Delete, uma exceção de simultaneidade será lançada.

Adicionar uma propriedade de acompanhamento à entidade Departamento

No Models/Department.cs, adicione uma propriedade de acompanhamento 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 está incluída na cláusula Where dos comandos Update e Delete. O atributo é chamado de Timestamp porque as versões anteriores do SQL Server usavam um tipo de dados SQL timestamp antes que o tipo de rowversion SQL o substituísse.

A API fluent também pode especificar a propriedade de rastreamento:

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

O código a seguir mostra uma parte do T-SQL gerado por 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 destacado anterior mostra a cláusula WHERE que contém RowVersion. Se o RowVersion de banco de dados não for igual ao parâmetro RowVersion (@p2), nenhuma linha será atualizada.

O código realçado a seguir mostra o T-SQL que verifica se exatamente 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, EF Core lança um DbUpdateConcurrencyException.

Você pode ver a T-SQL EF Core que é gerada na janela de saída do Visual Studio.

Atualizar o banco de dados

Adicionar a propriedade RowVersion altera o modelo de banco de dados, que requer uma migração.

Construa o projeto. Digite o seguinte em uma janela de comando:

dotnet ef migrations add RowVersion
dotnet ef database update

Os comandos anteriores:

  • Adiciona o ficheiro 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:

  • Executa migrações para atualizar o banco de dados.

Andaime o modelo de Departamentos

Siga as instruções em para estruturar o modelo do aluno e use Department para a classe modelo.

O comando anterior estrutura o modelo Department. Abra o projeto no Visual Studio.

Construa o projeto.

Atualizar a página Índice de Departamentos

O mecanismo de andaime criou uma coluna RowVersion para a página Índice, mas esse campo não deve ser exibido. Neste tutorial, o último byte do RowVersion é exibido para ajudar a entender a simultaneidade. Não é garantido que o último byte seja exclusivo. Um aplicativo real não exibiria 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 marcação 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 de edição

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 detetar um problema de concorrência, o OriginalValue é atualizado com o valor de rowVersion da entidade que foi buscada. EF Core gera um comando SQL UPDATE com uma cláusula WHERE contendo o valor RowVersion original. Se nenhuma linha for afetada pelo comando UPDATE (nenhuma linha tiver o valor RowVersion original), uma exceção DbUpdateConcurrencyException será lançada.

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 banco de dados quando FirstOrDefaultAsync foi chamado nesse método.

O código a seguir obtém os valores do cliente (os valores lançados nesse método) e os valores do banco de dados:

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 código a seguir adiciona uma mensagem de erro personalizada para cada coluna que tem valores de banco de dados diferentes do que foi postado para 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 como o novo valor recuperado do banco de dados. Da próxima vez que o utilizador clicar Salvar, apenas erros de concorrência que acontecerem desde a última vez que a página Editar foi exibida serão detetados.

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 é necessária porque ModelState tem o valor antigo de RowVersion. Na página Razor, 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 de edição

Atualize 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 post back vincule o valor.
  • Exibe o último byte de RowVersion para fins de depuração.
  • Substitui ViewData pelo InstructorNameSLdigitado fortemente.

Testar conflitos de simultaneidade com a página de edição

Abra duas janelas do navegador da ferramenta Edit no Departamento de Inglês.

  • Execute o aplicativo e selecione Departamentos.
  • Clique com o botão direito do rato na hiperligação Editar para o departamento de inglês e selecione Abrir no novo separador.
  • Na primeira guia, clique no hiperlink Edit para o departamento de inglês.

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

Altere o nome no primeiro separador do navegador e clique em Salvar.

Departamento Editar página 1 após a alteração

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

Altere um campo diferente na segunda guia do navegador.

Departamento Editar página 2 após a alteração

Clique Salvar. Você vê mensagens de erro para todos os campos que não correspondem aos valores de banco de dados:

Mensagem de erro ao editar a página do Departamento 1

Esta janela do navegador não pretendia alterar o campo Nome. Copie e cole o valor atual (Idiomas) no campo Nome. Tab para fora. A validação do lado do cliente remove a mensagem de erro.

Erro na página de edição do departamento 2

Clique Salvar novamente. O valor que introduziu na segunda guia do navegador é guardado. Você vê os valores salvos na página Índice.

Atualizar a página Excluir

Atualize o modelo da página "Eliminar" 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 deteta conflitos de simultaneidade quando a entidade é alterada depois de ter sido obtida. Department.RowVersion é a versão da linha de dados quando a entidade foi obtida. Quando EF Core cria o comando SQL DELETE, ele inclui uma cláusula WHERE com RowVersion. Se o comando SQL DELETE resultar em zero linhas afetadas:

  • O RowVersion no comando SQL DELETE não corresponde com o RowVersion no banco de dados.
  • Lança-se uma exceção do tipo DbUpdateConcurrencyException.
  • 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.
  • Substitui FirstMidName por FullName no campo Administrador.
  • Altera RowVersion para exibir o último byte.
  • Adiciona uma versão de linha oculta. RowVersion deve ser adicionado para que o post back vincule o valor.

Testar conflitos de simultaneidade com a página Eliminar

Crie um departamento de teste.

Abra duas instâncias de navegador de Delete no departamento de teste:

  • Execute o aplicativo e selecione Departamentos.
  • Clique com o botão direito do rato na hiperligação Eliminar do departamento de teste e selecione Abrir num novo separador.
  • Clique no hiperlink Edit para o departamento de teste.

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

Altere o orçamento no primeiro separador do navegador e clique em Guardar.

O navegador mostra a página Índice com o valor alterado e o indicador rowVersion atualizado. Observar o indicador rowVersion atualizado; ele é apresentado no segundo postback na outra aba.

Exclua o departamento de teste da segunda guia. Um erro de concorrência é exibido com os valores atuais do banco de dados. Clicar em Eliminar elimina a entidade, a menos que RowVersion tenha sido atualizada.

Consulte Herança sobre como herdar um modelo de dados.

Padrões de aplicativos Web corporativos

Para obter orientação sobre como criar um aplicativo ASP.NET Core confiável, seguro, com desempenho, testável e escalável, consulte Padrões de aplicativos Web corporativos. Está disponível um aplicativo Web de exemplo completo com qualidade de produção que implementa os padrões.

Recursos adicionais

anteriores