Parte 8, Razor Pages com EF Core no ASP.NET Core – Simultaneidade
O aplicativo Web Contoso University demonstra como criar aplicativos Web das Razor Pages usando o EF Core e o Visual Studio. Para obter informações sobre a série de tutoriais, consulte o primeiro tutorial.
Se você encontrar problemas que não possa resolver, baixe o aplicativo concluído e compare esse código com o que você criou seguindo o tutorial.
Este tutorial mostra como lidar com conflitos quando os mesmos usuários atualizam uma entidade simultaneamente.
Conflitos de simultaneidade
Um conflito de simultaneidade ocorre quando:
- Um usuário navega para a página de edição de uma entidade.
- Outro usuário atualiza a mesma entidade antes que a primeira alteração do usuário seja gravada no banco de dados.
Se a detecção de simultaneidade não estiver habilitada, quem atualizar o banco de dados por último substituirá as alterações do outro usuário. Se esse risco for aceitável, o custo de programação para simultaneidade poderá superar o benefício.
Simultaneidade pessimista
Uma maneira de evitar conflitos de simultaneidade é usar bloqueios de banco de dados. Isso é chamado de simultaneidade pessimista. Antes que o aplicativo leia uma linha de banco de dados que ele pretende atualizar, ele solicita um bloqueio. Depois que uma linha é bloqueada para acesso de atualização, nenhum outro usuário tem permissão para bloquear a linha até que o primeiro bloqueio seja liberado.
O gerenciamento de bloqueios traz desvantagens. Pode ser complexo de programar e causar problemas de desempenho conforme o número de usuários aumenta. O Entity Framework Core não fornece suporte interno para simultaneidade pessimista.
Simultaneidade otimista
A simultaneidade otimista permite que conflitos de simultaneidade ocorram e, em seguida, responde adequadamente quando ocorrem. Por exemplo, Alice visita a página Editar Departamento e altera o orçamento para o departamento de inglês de US$ 350.000,00 para US$ 0,00.
Antes que Alice clique em Salvar, Julio visita a mesma página e altera o campo Data de Início de 1/9/2007 para 1/9/2013.
Jane clica em Salvar primeiro e vê que sua alteração entrará em vigor, já que o navegador exibe a página de Índice com zero como o valor do Orçamento.
Julio clica em Salvar em uma página Editar que ainda mostra um orçamento de US$ 350.000,00. O que acontece em seguida é determinado pela forma como você lida com conflitos de simultaneidade:
Controle qual propriedade um usuário modificou e atualize apenas as colunas correspondentes no banco de dados.
No cenário, não haverá perda de dados. Propriedades diferentes foram atualizadas pelos dois usuários. Na próxima vez que alguém navegar no departamento de inglês, verá as alterações de Alice e Julio. Esse método de atualização pode reduzir o número de conflitos que podem resultar em perda de dados. Essa abordagem tem algumas desvantagens:
- Não poderá evitar a perda de dados se forem feitas alterações concorrentes na mesma propriedade.
- Geralmente, não é prática em um aplicativo Web. Ela exige um estado de manutenção significativo para controlar todos os valores buscados e novos valores. Manter grandes quantidades de estado pode afetar o desempenho do aplicativo.
- Pode aumentar a complexidade do aplicativo comparado à detecção de simultaneidade em uma entidade.
Deixe a alteração de Julio substituir a alteração de Alice.
Na próxima vez que alguém navegar pelo departamento de inglês, verá 1/9/2013 e o valor de US$ 350.000,00 buscado. Essa abordagem é chamada de um cenário O cliente vence ou O último vence. Todos os valores do cliente têm precedência sobre o conteúdo do armazenamento de dados. O código scaffolded não faz a manipulação de simultaneidade, o cenário O cliente vence ocorrerá automaticamente.
Impeça as alterações de Julio de serem atualizadas no banco de dados. Normalmente, o aplicativo:
- Exibe uma mensagem de erro.
- Mostra o estado atual dos dados.
- Permite ao usuário aplicar as alterações novamente.
Isso é chamado de um cenário O armazenamento vence. Os valores do armazenamento de dados têm precedência sobre os valores enviados pelo cliente. O cenário O armazenamento vence é usado neste tutorial. Esse método garante que nenhuma alteração é substituída sem que um usuário seja alertado.
Detecção de conflitos em EF Core
As propriedades configuradas como tokens de simultaneidade são usadas para implementar o controle de simultaneidade otimista. Quando uma operação de atualização ou exclusão é disparada por SaveChanges ou SaveChangesAsync, o valor do token de simultaneidade no banco de dados é comparado com o valor original lido por EF Core:
- Se os valores coincidirem, a operação pode ser concluída.
- Se os valores não coincidirem, o EF Core presume que outro usuário executou uma operação conflitante, anula a transação atual e gera um DbUpdateConcurrencyException.
Outro usuário ou processo executando uma operação conflitante com a operação atual é conhecido como conflito de simultaneidade.
Em bancos de dados relacionais, EF Core verifica o valor do token de simultaneidade na cláusula WHERE
de instruções UPDATE
e DELETE
para detectar um conflito de simultaneidade.
O modelo de dados deve ser configurado para habilitar a detecção de conflitos, incluindo uma coluna de acompanhamento que pode ser usada para determinar quando uma linha foi alterada. O EF fornece duas abordagens para tokens de simultaneidade:
Aplicando
[ConcurrencyCheck]
ou IsConcurrencyToken a uma propriedade no modelo. Essa abordagem não é recomendada. Para obter mais informações, consulte Tokens de Simultaneidade em EF Core.Aplicando TimestampAttribute ou IsRowVersion a um token de simultaneidade no modelo. Essa é a abordagem usada neste tutorial.
A abordagem do SQL Server e os detalhes de implementação do SQLite são ligeiramente diferentes. Um arquivo de diferença é mostrado posteriormente no tutorial listando as diferenças. A guia Visual Studio mostra a abordagem do SQL Server. A guia Visual Studio Code mostra a abordagem para bancos de dados não SQL Server, como SQLite.
- No modelo, inclua uma coluna de acompanhamento que é usada para determinar quando uma linha é alterada.
- Aplique o TimestampAttribute à propriedade de simultaneidade.
Atualize o arquivo Models/Department.cs
com o seguinte código:
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace ContosoUniversity.Models
{
public class Department
{
public int DepartmentID { get; set; }
[StringLength(50, MinimumLength = 3)]
public string Name { get; set; }
[DataType(DataType.Currency)]
[Column(TypeName = "money")]
public decimal Budget { get; set; }
[DataType(DataType.Date)]
[DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}",
ApplyFormatInEditMode = true)]
[Display(Name = "Start Date")]
public DateTime StartDate { get; set; }
public int? InstructorID { get; set; }
[Timestamp]
public byte[] ConcurrencyToken { get; set; }
public Instructor Administrator { get; set; }
public ICollection<Course> Courses { get; set; }
}
}
O TimestampAttribute é o que identifica a coluna como uma coluna de acompanhamento de simultaneidade. A API fluente é uma maneira alternativa de especificar a propriedade de acompanhamento:
modelBuilder.Entity<Department>()
.Property<byte[]>("ConcurrencyToken")
.IsRowVersion();
O atributo [Timestamp]
em uma propriedade de entidade gera o seguinte código no método ModelBuilder:
b.Property<byte[]>("ConcurrencyToken")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
O código anterior:
- Define o tipo de propriedade
ConcurrencyToken
como matriz de bytes.byte[]
é o tipo necessário para SQL Server. - Chama IsConcurrencyToken.
IsConcurrencyToken
configura a propriedade como um token de simultaneidade. Em atualizações, o valor do token de simultaneidade no banco de dados é comparado ao valor original para garantir que ele não tenha sido alterado desde que a instância foi recuperada do banco de dados. Se ele tiver sido alterado, um DbUpdateConcurrencyException será gerado e as alterações não serão aplicadas. - Chama ValueGeneratedOnAddOrUpdate, que configura a propriedade
ConcurrencyToken
para ter um valor gerado automaticamente ao adicionar ou atualizar uma entidade. HasColumnType("rowversion")
define o tipo de coluna no banco de dados do SQL Server como rowversion.
O seguinte código mostra uma parte do T-SQL gerado pelo EF Core quando o nome do Department
é atualizado:
SET NOCOUNT ON;
UPDATE [Departments] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [ConcurrencyToken] = @p2;
SELECT [ConcurrencyToken]
FROM [Departments]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
O código anterior realçado mostra a cláusula WHERE
que contém ConcurrencyToken
. Se o banco de dados ConcurrencyToken
não for igual ao parâmetro ConcurrencyToken
@p2
, nenhuma linha será atualizada.
O seguinte código realçado mostra o T-SQL que verifica exatamente se uma linha foi atualizada:
SET NOCOUNT ON;
UPDATE [Departments] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [ConcurrencyToken] = @p2;
SELECT [ConcurrencyToken]
FROM [Departments]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
@@ROWCOUNT retorna o número de linhas afetadas pela última instrução. Se nenhuma linha for atualizada, o EF Core gerará uma DbUpdateConcurrencyException
.
Adicionar uma migração
Adicionar a propriedade ConcurrencyToken
muda o modelo de dados, o que exige uma migração.
Compile o projeto.
Executar os seguintes comandos no PMC:
Add-Migration RowVersion
Update-Database
Os comandos anteriores:
- Cria o arquivo de migração
Migrations/{time stamp}_RowVersion.cs
. - Atualiza o arquivo
Migrations/SchoolContextModelSnapshot.cs
. A atualização adiciona o seguinte código ao métodoBuildModel
:
b.Property<byte[]>("ConcurrencyToken")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
Aplicar scaffold a páginas de Departamento
Siga as instruções em páginas do aluno do Scaffold com as seguintes exceções:
- Crie uma pasta Pages/Departments.
- Use
Department
para a classe de modelo. - Use a classe de contexto existente, em vez de criar uma nova.
Adicionar uma classe de utilitário
Na pasta do projeto, crie a classe Utility
com o seguinte código:
namespace ContosoUniversity
{
public static class Utility
{
public static string GetLastChars(byte[] token)
{
return token[7].ToString();
}
}
}
A classe Utility
fornece o método GetLastChars
usado para exibir os últimos caracteres do token de simultaneidade. O código a seguir mostra o código que funciona com o SQLite e o SQL Server:
#if SQLiteVersion
using System;
namespace ContosoUniversity
{
public static class Utility
{
public static string GetLastChars(Guid token)
{
return token.ToString().Substring(
token.ToString().Length - 3);
}
}
}
#else
namespace ContosoUniversity
{
public static class Utility
{
public static string GetLastChars(byte[] token)
{
return token[7].ToString();
}
}
}
#endif
A #if SQLiteVersion
(diretiva de pré-processador) isola as diferenças nas versões do SQLite e SQL Server e ajuda:
- O autor mantém uma base de código para ambas as versões.
- Os desenvolvedores do SQLite implantam o aplicativo no Azure e usam o SQL Azure.
Compile o projeto.
Atualize a página Índice
A ferramenta de scaffolding criou uma coluna ConcurrencyToken
para a página de índice, mas esse campo não seria exibido em um aplicativo de produção. Neste tutorial, a última parte do ConcurrencyToken
é exibida para ajudar a mostrar como funciona a manipulação de simultaneidade. A última parte não tem garantia de ser exclusiva em si.
Atualize a página Pages\Departments\Index.cshtml:
- Substitua Índice por Departamentos.
- Altere o código que contém
ConcurrencyToken
para mostrar apenas os últimos caracteres. - Substitua
FirstMidName
porFullName
.
O código a seguir mostra a página atualizada:
@page
@model ContosoUniversity.Pages.Departments.IndexModel
@{
ViewData["Title"] = "Departments";
}
<h2>Departments</h2>
<p>
<a asp-page="Create">Create New</a>
</p>
<table class="table">
<thead>
<tr>
<th>
@Html.DisplayNameFor(model => model.Department[0].Name)
</th>
<th>
@Html.DisplayNameFor(model => model.Department[0].Budget)
</th>
<th>
@Html.DisplayNameFor(model => model.Department[0].StartDate)
</th>
<th>
@Html.DisplayNameFor(model => model.Department[0].Administrator)
</th>
<th>
Token
</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Department)
{
<tr>
<td>
@Html.DisplayFor(modelItem => item.Name)
</td>
<td>
@Html.DisplayFor(modelItem => item.Budget)
</td>
<td>
@Html.DisplayFor(modelItem => item.StartDate)
</td>
<td>
@Html.DisplayFor(modelItem => item.Administrator.FullName)
</td>
<td>
@Utility.GetLastChars(item.ConcurrencyToken)
</td>
<td>
<a asp-page="./Edit" asp-route-id="@item.DepartmentID">Edit</a> |
<a asp-page="./Details" asp-route-id="@item.DepartmentID">Details</a> |
<a asp-page="./Delete" asp-route-id="@item.DepartmentID">Delete</a>
</td>
</tr>
}
</tbody>
</table>
Atualizar o modelo da página Editar
Atualize Pages/Departments/Edit.cshtml.cs
com o seguinte código:
using ContosoUniversity.Data;
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading.Tasks;
namespace ContosoUniversity.Pages.Departments
{
public class EditModel : PageModel
{
private readonly ContosoUniversity.Data.SchoolContext _context;
public EditModel(ContosoUniversity.Data.SchoolContext context)
{
_context = context;
}
[BindProperty]
public Department Department { get; set; }
// Replace ViewData["InstructorID"]
public SelectList InstructorNameSL { get; set; }
public async Task<IActionResult> OnGetAsync(int id)
{
Department = await _context.Departments
.Include(d => d.Administrator) // eager loading
.AsNoTracking() // tracking not required
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (Department == null)
{
return NotFound();
}
// Use strongly typed data rather than ViewData.
InstructorNameSL = new SelectList(_context.Instructors,
"ID", "FirstMidName");
return Page();
}
public async Task<IActionResult> OnPostAsync(int id)
{
if (!ModelState.IsValid)
{
return Page();
}
// Fetch current department from DB.
// ConcurrencyToken may have changed.
var departmentToUpdate = await _context.Departments
.Include(i => i.Administrator)
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (departmentToUpdate == null)
{
return HandleDeletedDepartment();
}
// Set ConcurrencyToken to value read in OnGetAsync
_context.Entry(departmentToUpdate).Property(
d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;
if (await TryUpdateModelAsync<Department>(
departmentToUpdate,
"Department",
s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
try
{
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
var exceptionEntry = ex.Entries.Single();
var clientValues = (Department)exceptionEntry.Entity;
var databaseEntry = exceptionEntry.GetDatabaseValues();
if (databaseEntry == null)
{
ModelState.AddModelError(string.Empty, "Unable to save. " +
"The department was deleted by another user.");
return Page();
}
var dbValues = (Department)databaseEntry.ToObject();
await SetDbErrorMessage(dbValues, clientValues, _context);
// Save the current ConcurrencyToken so next postback
// matches unless an new concurrency issue happens.
Department.ConcurrencyToken = (byte[])dbValues.ConcurrencyToken;
// Clear the model error for the next postback.
ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
}
}
InstructorNameSL = new SelectList(_context.Instructors,
"ID", "FullName", departmentToUpdate.InstructorID);
return Page();
}
private IActionResult HandleDeletedDepartment()
{
// ModelState contains the posted data because of the deletion error
// and overides the Department instance values when displaying Page().
ModelState.AddModelError(string.Empty,
"Unable to save. The department was deleted by another user.");
InstructorNameSL = new SelectList(_context.Instructors, "ID", "FullName", Department.InstructorID);
return Page();
}
private async Task SetDbErrorMessage(Department dbValues,
Department clientValues, SchoolContext context)
{
if (dbValues.Name != clientValues.Name)
{
ModelState.AddModelError("Department.Name",
$"Current value: {dbValues.Name}");
}
if (dbValues.Budget != clientValues.Budget)
{
ModelState.AddModelError("Department.Budget",
$"Current value: {dbValues.Budget:c}");
}
if (dbValues.StartDate != clientValues.StartDate)
{
ModelState.AddModelError("Department.StartDate",
$"Current value: {dbValues.StartDate:d}");
}
if (dbValues.InstructorID != clientValues.InstructorID)
{
Instructor dbInstructor = await _context.Instructors
.FindAsync(dbValues.InstructorID);
ModelState.AddModelError("Department.InstructorID",
$"Current value: {dbInstructor?.FullName}");
}
ModelState.AddModelError(string.Empty,
"The record you attempted to edit "
+ "was modified by another user after you. The "
+ "edit operation was canceled and the current values in the database "
+ "have been displayed. If you still want to edit this record, click "
+ "the Save button again.");
}
}
}
As atualizações de simultaneidade
OriginalValue é atualizado com o valor ConcurrencyToken
da entidade quando ele foi buscado no método OnGetAsync
. O EF Core gera um comando SQL UPDATE
com uma cláusula WHERE
que contém o valor ConcurrencyToken
original. Se nenhuma linha for afetada pelo comando UPDATE
, uma exceção DbUpdateConcurrencyException
será gerada. Nenhuma linha é afetada pelo comando UPDATE
quando nenhuma linha tem o valor original de ConcurrencyToken
.
public async Task<IActionResult> OnPostAsync(int id)
{
if (!ModelState.IsValid)
{
return Page();
}
// Fetch current department from DB.
// ConcurrencyToken may have changed.
var departmentToUpdate = await _context.Departments
.Include(i => i.Administrator)
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (departmentToUpdate == null)
{
return HandleDeletedDepartment();
}
// Set ConcurrencyToken to value read in OnGetAsync
_context.Entry(departmentToUpdate).Property(
d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;
No código realçado anterior:
- O valor em
Department.ConcurrencyToken
é o valor quando a entidade foi buscada na solicitaçãoGet
para a páginaEdit
. O valor é fornecido ao métodoOnPost
por um campo oculto na página do Razor que exibe a entidade a ser editada. O valor do campo oculto é copiado paraDepartment.ConcurrencyToken
pelo associador de modelos. OriginalValue
é o que EF Core usa na cláusulaWHERE
. Antes que a linha de código realçada seja executada:OriginalValue
é o valor que estava no banco de dados quandoFirstOrDefaultAsync
foi chamado nesse método.- Esse valor pode ser diferente do que foi exibido na página Editar.
- O código realçado garante que o EF Core use o valor
ConcurrencyToken
original da entidadeDepartment
exibida na cláusulaWHERE
da declaração SQLUPDATE
.
O código a seguir mostra o modelo Department
. Department
é inicializado no:
- método
OnGetAsync
pela consulta do EF. - método
OnPostAsync
pelo campo oculto na página Razor usando model binding:
public class EditModel : PageModel
{
private readonly ContosoUniversity.Data.SchoolContext _context;
public EditModel(ContosoUniversity.Data.SchoolContext context)
{
_context = context;
}
[BindProperty]
public Department Department { get; set; }
// Replace ViewData["InstructorID"]
public SelectList InstructorNameSL { get; set; }
public async Task<IActionResult> OnGetAsync(int id)
{
Department = await _context.Departments
.Include(d => d.Administrator) // eager loading
.AsNoTracking() // tracking not required
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (Department == null)
{
return NotFound();
}
// Use strongly typed data rather than ViewData.
InstructorNameSL = new SelectList(_context.Instructors,
"ID", "FirstMidName");
return Page();
}
public async Task<IActionResult> OnPostAsync(int id)
{
if (!ModelState.IsValid)
{
return Page();
}
// Fetch current department from DB.
// ConcurrencyToken may have changed.
var departmentToUpdate = await _context.Departments
.Include(i => i.Administrator)
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (departmentToUpdate == null)
{
return HandleDeletedDepartment();
}
// Set ConcurrencyToken to value read in OnGetAsync
_context.Entry(departmentToUpdate).Property(
d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;
O código anterior mostra que o valor ConcurrencyToken
da entidade Department
da solicitação HTTP POST
é definido como o valor ConcurrencyToken
da solicitação HTTP GET
.
Quando ocorre um erro de simultaneidade, o código realçado a seguir obtém os valores do cliente (os valores postados para esse método) e os valores do banco de dados.
if (await TryUpdateModelAsync<Department>(
departmentToUpdate,
"Department",
s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
try
{
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
var exceptionEntry = ex.Entries.Single();
var clientValues = (Department)exceptionEntry.Entity;
var databaseEntry = exceptionEntry.GetDatabaseValues();
if (databaseEntry == null)
{
ModelState.AddModelError(string.Empty, "Unable to save. " +
"The department was deleted by another user.");
return Page();
}
var dbValues = (Department)databaseEntry.ToObject();
await SetDbErrorMessage(dbValues, clientValues, _context);
// Save the current ConcurrencyToken so next postback
// matches unless an new concurrency issue happens.
Department.ConcurrencyToken = dbValues.ConcurrencyToken;
// Clear the model error for the next postback.
ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
}
O seguinte código adiciona uma mensagem de erro personalizada a cada coluna que tem valores de banco de dados diferentes daqueles que foram postados em OnPostAsync
:
private async Task SetDbErrorMessage(Department dbValues,
Department clientValues, SchoolContext context)
{
if (dbValues.Name != clientValues.Name)
{
ModelState.AddModelError("Department.Name",
$"Current value: {dbValues.Name}");
}
if (dbValues.Budget != clientValues.Budget)
{
ModelState.AddModelError("Department.Budget",
$"Current value: {dbValues.Budget:c}");
}
if (dbValues.StartDate != clientValues.StartDate)
{
ModelState.AddModelError("Department.StartDate",
$"Current value: {dbValues.StartDate:d}");
}
if (dbValues.InstructorID != clientValues.InstructorID)
{
Instructor dbInstructor = await _context.Instructors
.FindAsync(dbValues.InstructorID);
ModelState.AddModelError("Department.InstructorID",
$"Current value: {dbInstructor?.FullName}");
}
ModelState.AddModelError(string.Empty,
"The record you attempted to edit "
+ "was modified by another user after you. The "
+ "edit operation was canceled and the current values in the database "
+ "have been displayed. If you still want to edit this record, click "
+ "the Save button again.");
}
O código realçado a seguir define o valor ConcurrencyToken
com o novo valor recuperado do banco de dados. Na próxima vez que o usuário clicar em Salvar, somente os erros de simultaneidade que ocorrerem desde a última exibição da página Editar serão capturados.
if (await TryUpdateModelAsync<Department>(
departmentToUpdate,
"Department",
s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
try
{
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
var exceptionEntry = ex.Entries.Single();
var clientValues = (Department)exceptionEntry.Entity;
var databaseEntry = exceptionEntry.GetDatabaseValues();
if (databaseEntry == null)
{
ModelState.AddModelError(string.Empty, "Unable to save. " +
"The department was deleted by another user.");
return Page();
}
var dbValues = (Department)databaseEntry.ToObject();
await SetDbErrorMessage(dbValues, clientValues, _context);
// Save the current ConcurrencyToken so next postback
// matches unless an new concurrency issue happens.
Department.ConcurrencyToken = dbValues.ConcurrencyToken;
// Clear the model error for the next postback.
ModelState.Remove($"{nameof(Department)}.{nameof(Department.ConcurrencyToken)}");
}
A instrução ModelState.Remove
é obrigatória porque ModelState
tem o valor ConcurrencyToken
anterior. No Razor Page, o valor ModelState
de um campo tem precedência sobre os valores de propriedade do modelo, quando ambos estão presentes.
Diferenças de código do SQL Server versus SQLite
Veja a seguir as diferenças entre as versões do SQL Server e SQLite:
+ using System; // For GUID on SQLite
+ departmentToUpdate.ConcurrencyToken = Guid.NewGuid();
_context.Entry(departmentToUpdate)
.Property(d => d.ConcurrencyToken).OriginalValue = Department.ConcurrencyToken;
- Department.ConcurrencyToken = (byte[])dbValues.ConcurrencyToken;
+ Department.ConcurrencyToken = dbValues.ConcurrencyToken;
Atualizar a página Editar Razor
Atualize Pages/Departments/Edit.cshtml
com o seguinte código:
@page "{id:int}"
@model ContosoUniversity.Pages.Departments.EditModel
@{
ViewData["Title"] = "Edit";
}
<h2>Edit</h2>
<h4>Department</h4>
<hr />
<div class="row">
<div class="col-md-4">
<form method="post">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<input type="hidden" asp-for="Department.DepartmentID" />
<input type="hidden" asp-for="Department.ConcurrencyToken" />
<div class="form-group">
<label>Version</label>
@Utility.GetLastChars(Model.Department.ConcurrencyToken)
</div>
<div class="form-group">
<label asp-for="Department.Name" class="control-label"></label>
<input asp-for="Department.Name" class="form-control" />
<span asp-validation-for="Department.Name" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Department.Budget" class="control-label"></label>
<input asp-for="Department.Budget" class="form-control" />
<span asp-validation-for="Department.Budget" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Department.StartDate" class="control-label"></label>
<input asp-for="Department.StartDate" class="form-control" />
<span asp-validation-for="Department.StartDate" class="text-danger">
</span>
</div>
<div class="form-group">
<label class="control-label">Instructor</label>
<select asp-for="Department.InstructorID" class="form-control"
asp-items="@Model.InstructorNameSL"></select>
<span asp-validation-for="Department.InstructorID" class="text-danger">
</span>
</div>
<div class="form-group">
<input type="submit" value="Save" class="btn btn-primary" />
</div>
</form>
</div>
</div>
<div>
<a asp-page="./Index">Back to List</a>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}
O código anterior:
- Atualiza a diretiva
page
de@page
para@page "{id:int}"
. - Adiciona uma versão de linha oculta.
ConcurrencyToken
deve ser adicionado para que o postback associe o valor. - Exibe o último byte de
ConcurrencyToken
para fins de depuração. - Substitui
ViewData
peloInstructorNameSL
fortemente tipado.
Testar conflitos de simultaneidade com a página Editar
Abra duas instâncias de navegadores de Editar no departamento de inglês:
- Execute o aplicativo e selecione Departamentos.
- Clique com o botão direito do mouse no hiperlink Editar do departamento de inglês e selecione Abrir em uma nova guia.
- Na primeira guia, clique no hiperlink Editar do departamento de inglês.
As duas guias do navegador exibem as mesmas informações.
Altere o nome na primeira guia do navegador e clique em Salvar.
O navegador mostra a página de Índice com o valor alterado e o indicadorConcurrencyToken
atualizado. Observe o indicadorConcurrencyToken
atualizado: ele é exibido no segundo postback na outra guia.
Altere outro campo na segunda guia do navegador.
Clique em Salvar. Você verá mensagens de erro em todos os campos que não correspondem aos valores do banco de dados:
Essa janela do navegador não pretendia alterar o campo Name. Copie e cole o valor atual (Languages) para o campo Name. Saída da guia. A validação do lado do cliente remove a mensagem de erro.
Clique em Salvar novamente. O valor inserido na segunda guia do navegador foi salvo. Você verá os valores salvos na página Índice.
Atualizar o modelo da página Excluir
Atualize Pages/Departments/Delete.cshtml.cs
com o seguinte código:
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;
namespace ContosoUniversity.Pages.Departments
{
public class DeleteModel : PageModel
{
private readonly ContosoUniversity.Data.SchoolContext _context;
public DeleteModel(ContosoUniversity.Data.SchoolContext context)
{
_context = context;
}
[BindProperty]
public Department Department { get; set; }
public string ConcurrencyErrorMessage { get; set; }
public async Task<IActionResult> OnGetAsync(int id, bool? concurrencyError)
{
Department = await _context.Departments
.Include(d => d.Administrator)
.AsNoTracking()
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (Department == null)
{
return NotFound();
}
if (concurrencyError.GetValueOrDefault())
{
ConcurrencyErrorMessage = "The record you attempted to delete "
+ "was modified by another user after you selected delete. "
+ "The delete operation was canceled and the current values in the "
+ "database have been displayed. If you still want to delete this "
+ "record, click the Delete button again.";
}
return Page();
}
public async Task<IActionResult> OnPostAsync(int id)
{
try
{
if (await _context.Departments.AnyAsync(
m => m.DepartmentID == id))
{
// Department.ConcurrencyToken value is from when the entity
// was fetched. If it doesn't match the DB, a
// DbUpdateConcurrencyException exception is thrown.
_context.Departments.Remove(Department);
await _context.SaveChangesAsync();
}
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException)
{
return RedirectToPage("./Delete",
new { concurrencyError = true, id = id });
}
}
}
}
A página Excluir detectou conflitos de simultaneidade quando a entidade foi alterada depois de ser buscada. Department.ConcurrencyToken
é a versão de linha quando a entidade foi buscada. Quando o EF Core cria o comando SQL DELETE
, ele inclui uma cláusula WHERE com ConcurrencyToken
. Se o comando SQL DELETE
não resultar em nenhuma linha afetada:
- A
ConcurrencyToken
no comandoSQL DELETE
não corresponderá aConcurrencyToken
no banco de dados. - Uma exceção
DbUpdateConcurrencyException
é gerada. OnGetAsync
é chamado com oconcurrencyError
.
Atualizar a página Excluir Razor
Atualize Pages/Departments/Delete.cshtml
com o seguinte código:
@page "{id:int}"
@model ContosoUniversity.Pages.Departments.DeleteModel
@{
ViewData["Title"] = "Delete";
}
<h1>Delete</h1>
<p class="text-danger">@Model.ConcurrencyErrorMessage</p>
<h3>Are you sure you want to delete this?</h3>
<div>
<h4>Department</h4>
<hr />
<dl class="row">
<dt class="col-sm-2">
@Html.DisplayNameFor(model => model.Department.Name)
</dt>
<dd class="col-sm-10">
@Html.DisplayFor(model => model.Department.Name)
</dd>
<dt class="col-sm-2">
@Html.DisplayNameFor(model => model.Department.Budget)
</dt>
<dd class="col-sm-10">
@Html.DisplayFor(model => model.Department.Budget)
</dd>
<dt class="col-sm-2">
@Html.DisplayNameFor(model => model.Department.StartDate)
</dt>
<dd class="col-sm-10">
@Html.DisplayFor(model => model.Department.StartDate)
</dd>
<dt class="col-sm-2">
@Html.DisplayNameFor(model => model.Department.ConcurrencyToken)
</dt>
<dd class="col-sm-10">
@Utility.GetLastChars(Model.Department.ConcurrencyToken)
</dd>
<dt class="col-sm-2">
@Html.DisplayNameFor(model => model.Department.Administrator)
</dt>
<dd class="col-sm-10">
@Html.DisplayFor(model => model.Department.Administrator.FullName)
</dd>
</dl>
<form method="post">
<input type="hidden" asp-for="Department.DepartmentID" />
<input type="hidden" asp-for="Department.ConcurrencyToken" />
<input type="submit" value="Delete" class="btn btn-danger" /> |
<a asp-page="./Index">Back to List</a>
</form>
</div>
O código anterior faz as seguintes alterações:
- Atualiza a diretiva
page
de@page
para@page "{id:int}"
. - Adiciona uma mensagem de erro.
- Substitua FirstMidName por FullName no campo Administrador.
- Altere
ConcurrencyToken
para exibir o último byte. - Adiciona uma versão de linha oculta.
ConcurrencyToken
deve ser adicionado para que o postback associe o valor.
Testar os conflitos de simultaneidade
Crie um departamento de teste.
Abra duas instâncias dos navegadores de Excluir no departamento de teste:
- Execute o aplicativo e selecione Departamentos.
- Clique com o botão direito do mouse no hiperlink Excluir do departamento de teste e selecione Abrir em uma nova guia.
- Clique no hiperlink Editar do departamento de teste.
As duas guias do navegador exibem as mesmas informações.
Altere o orçamento na primeira guia do navegador e clique em Salvar.
O navegador mostra a página de Índice com o valor alterado e o indicadorConcurrencyToken
atualizado. Observe o indicadorConcurrencyToken
atualizado: ele é exibido no segundo postback na outra guia.
Exclua o departamento de teste na segunda guia. Um erro de simultaneidade é exibido com os valores atuais do banco de dados. Clicar em Excluir exclui a entidade, a menos que ConcurrencyToken
tenha sido atualizada.
Recursos adicionais
- Tokens de simultaneidade em EF Core
- Manipular a simultaneidade em EF Core
- Depuração de origem do ASP.NET Core 2.x
Próximas etapas
Este é o último tutorial da série. Tópicos adicionais são abordados na versão MVC desta série de tutoriais.
Este tutorial mostra como lidar com conflitos quando os mesmos usuários atualizam uma entidade simultaneamente.
Conflitos de simultaneidade
Um conflito de simultaneidade ocorre quando:
- Um usuário navega para a página de edição de uma entidade.
- Outro usuário atualiza a mesma entidade antes que a primeira alteração do usuário seja gravada no banco de dados.
Se a detecção de simultaneidade não estiver habilitada, quem atualizar o banco de dados por último substituirá as alterações do outro usuário. Se esse risco for aceitável, o custo de programação para simultaneidade poderá superar o benefício.
Simultaneidade pessimista (bloqueio)
Uma maneira de evitar conflitos de simultaneidade é usar bloqueios de banco de dados. Isso é chamado de simultaneidade pessimista. Antes que o aplicativo leia uma linha de banco de dados que ele pretende atualizar, ele solicita um bloqueio. Depois que uma linha é bloqueada para acesso de atualização, nenhum outro usuário tem permissão para bloquear a linha até que o primeiro bloqueio seja liberado.
O gerenciamento de bloqueios traz desvantagens. Pode ser complexo de programar e causar problemas de desempenho conforme o número de usuários aumenta. O Entity Framework Core não fornece nenhum suporte interno para ele, e este tutorial não mostra como implementá-lo.
Simultaneidade otimista
A simultaneidade otimista permite que conflitos de simultaneidade ocorram e, em seguida, responde adequadamente quando ocorrem. Por exemplo, Alice visita a página Editar Departamento e altera o orçamento para o departamento de inglês de US$ 350.000,00 para US$ 0,00.
Antes que Alice clique em Salvar, Julio visita a mesma página e altera o campo Data de Início de 1/9/2007 para 1/9/2013.
Jane clica em Salvar primeiro e vê que sua alteração entrará em vigor, já que o navegador exibe a página de Índice com zero como o valor do Orçamento.
Julio clica em Salvar em uma página Editar que ainda mostra um orçamento de US$ 350.000,00. O que acontece em seguida é determinado pela forma como você lida com conflitos de simultaneidade:
Controle qual propriedade um usuário modificou e atualize apenas as colunas correspondentes no banco de dados.
No cenário, não haverá perda de dados. Propriedades diferentes foram atualizadas pelos dois usuários. Na próxima vez que alguém navegar no departamento de inglês, verá as alterações de Alice e Julio. Esse método de atualização pode reduzir o número de conflitos que podem resultar em perda de dados. Essa abordagem tem algumas desvantagens:
- Não poderá evitar a perda de dados se forem feitas alterações concorrentes na mesma propriedade.
- Geralmente, não é prática em um aplicativo Web. Ela exige um estado de manutenção significativo para controlar todos os valores buscados e novos valores. Manter grandes quantidades de estado pode afetar o desempenho do aplicativo.
- Pode aumentar a complexidade do aplicativo comparado à detecção de simultaneidade em uma entidade.
Você não pode deixar a alteração de Julio substituir a alteração de Alice.
Na próxima vez que alguém navegar pelo departamento de inglês, verá 1/9/2013 e o valor de US$ 350.000,00 buscado. Essa abordagem é chamada de um cenário O cliente vence ou O último vence. (Todos os valores do cliente têm precedência sobre o conteúdo do armazenamento de dados.) Se você não fizer nenhuma codificação para a manipulação de simultaneidade, o cenário O cliente vence ocorrerá automaticamente.
Você pode impedir que as alterações de Julio sejam atualizadas no banco de dados. Normalmente, o aplicativo:
- Exibe uma mensagem de erro.
- Mostra o estado atual dos dados.
- Permite ao usuário aplicar as alterações novamente.
Isso é chamado de um cenário O armazenamento vence. (Os valores do armazenamento de dados têm precedência sobre os valores enviados pelo cliente.) Você implementa o cenário O armazenamento vence neste tutorial. Esse método garante que nenhuma alteração é substituída sem que um usuário seja alertado.
Detecção de conflitos em EF Core
O EF Core gera exceções DbConcurrencyException
quando detecta conflitos. O modelo de dados deve ser configurado para habilitar a detecção de conflitos. As opções para habilitar a detecção de conflitos incluem as seguintes:
Configurar o EF Core para incluir os valores originais das colunas configuradas como tokens de simultaneidade na cláusula Where dos comandos Update e Delete.
Quando
SaveChanges
é chamado, a cláusula WHERE procura os valores originais de quaisquer propriedades anotadas com o atributo ConcurrencyCheckAttribute. As declarações de atualização não encontrarão uma linha a ser atualizada se qualquer uma das propriedades do token de simultaneidade for alteradas desde a primeira leitura da linha. O EF Core interpreta isso como um conflito de simultaneidade. Para tabelas de banco de dados que têm muitas colunas, essa abordagem pode resultar em cláusulas Where muito grandes e pode exigir grandes quantidades de estado. Portanto, essa abordagem geralmente não é recomendada e não é o método usado neste tutorial.Na tabela de banco de dados, inclua uma coluna de acompanhamento que pode ser usada para determinar quando uma linha é alterada.
Em um banco de dados do SQL Server, o tipo de dados da coluna de acompanhamento é
rowversion
. O valorrowversion
é um número sequencial que é incrementado sempre que a linha é atualizada. Em um comando Update ou Delete, a cláusula Where inclui o valor original da coluna de acompanhamento (o número de versão da linha original). Se a linha que está sendo atualizada tiver sido alterada por outro usuário, o valor na colunarowversion
será diferente do valor original. Nesse caso, a instrução Update ou Delete não pode localizar a linha a ser atualizada devido à cláusula Where. O EF Core gera uma exceção de simultaneidade quando nenhuma linha é afetada por um comando Update ou Delete.
Adicionar uma propriedade de acompanhamento
Em Models/Department.cs
, adicione uma propriedade de controle chamada RowVersion:
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace ContosoUniversity.Models
{
public class Department
{
public int DepartmentID { get; set; }
[StringLength(50, MinimumLength = 3)]
public string Name { get; set; }
[DataType(DataType.Currency)]
[Column(TypeName = "money")]
public decimal Budget { get; set; }
[DataType(DataType.Date)]
[DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
[Display(Name = "Start Date")]
public DateTime StartDate { get; set; }
public int? InstructorID { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; }
public Instructor Administrator { get; set; }
public ICollection<Course> Courses { get; set; }
}
}
O atributo TimestampAttribute é o que identifica a coluna como uma coluna de acompanhamento de simultaneidade. A API fluente é uma maneira alternativa de especificar a propriedade de acompanhamento:
modelBuilder.Entity<Department>()
.Property<byte[]>("RowVersion")
.IsRowVersion();
Para um banco de dados do SQL Server, o atributo [Timestamp]
em uma propriedade de entidade definida como matriz de bytes:
- Faz com que a coluna seja incluída nas cláusulas DELETE e UPDATE WHERE.
- Define o tipo de coluna no banco de dados como rowversion.
O banco de dados gera um número de versão de linha sequencial que é incrementado sempre que a linha é atualizada. Em um comando Update
ou Delete
, a cláusula Where
inclui o valor da versão de linha buscado. Se a linha que está sendo atualizada foi alterada desde que foi buscada:
- O valor da versão da linha atual não corresponde ao valor obtido.
- O comando
Update
ouDelete
não localiza uma linha porque a cláusulaWhere
procura o valor da versão da linha buscado. - Uma
DbUpdateConcurrencyException
é gerada.
O seguinte código mostra uma parte do T-SQL gerado pelo EF Core quando o nome do Departamento é atualizado:
SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
O código anterior realçado mostra a cláusula WHERE
que contém RowVersion
. Se o banco de dados RowVersion
não for igual ao parâmetro RowVersion
(@p2
), nenhuma linha será atualizada.
O seguinte código realçado mostra o T-SQL que verifica exatamente se uma linha foi atualizada:
SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
@@ROWCOUNT retorna o número de linhas afetadas pela última instrução. Se nenhuma linha for atualizada, o EF Core gerará uma DbUpdateConcurrencyException
.
Atualizar o banco de dados
Adicionar a propriedade RowVersion
muda o modelo de dados, o que exige uma migração.
Compile o projeto.
Execute o seguinte comando no PMC:
Add-Migration RowVersion
Esse comando:
Cria o arquivo de migração
Migrations/{time stamp}_RowVersion.cs
.Atualiza o arquivo
Migrations/SchoolContextModelSnapshot.cs
. A atualização adiciona o seguinte código realçado ao métodoBuildModel
:modelBuilder.Entity("ContosoUniversity.Models.Department", b => { b.Property<int>("DepartmentID") .ValueGeneratedOnAdd() .HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn); b.Property<decimal>("Budget") .HasColumnType("money"); b.Property<int?>("InstructorID"); b.Property<string>("Name") .HasMaxLength(50); b.Property<byte[]>("RowVersion") .IsConcurrencyToken() .ValueGeneratedOnAddOrUpdate(); b.Property<DateTime>("StartDate"); b.HasKey("DepartmentID"); b.HasIndex("InstructorID"); b.ToTable("Department"); });
Execute o seguinte comando no PMC:
Update-Database
Aplicar scaffold a páginas de Departamento
Siga as instruções em páginas do aluno do Scaffold com as seguintes exceções:
Crie uma pasta Pages/Departments.
Use
Department
para a classe de modelo.- Use a classe de contexto existente, em vez de criar uma nova.
Compile o projeto.
Atualize a página Índice
A ferramenta de scaffolding criou uma coluna RowVersion
para a página de índice, mas esse campo não seria exibido em um aplicativo de produção. Neste tutorial, o último byte do RowVersion
é exibido para ajudar a mostrar como funciona a manipulação de simultaneidade. O último byte não tem garantia de ser exclusivo em si.
Atualize a página Pages\Departments\Index.cshtml:
- Substitua Índice por Departamentos.
- Altere o código que contém
RowVersion
para mostrar apenas o último byte da matriz de bytes. - Substitua FirstMidName por FullName.
O código a seguir mostra a página atualizada:
@page
@model ContosoUniversity.Pages.Departments.IndexModel
@{
ViewData["Title"] = "Departments";
}
<h2>Departments</h2>
<p>
<a asp-page="Create">Create New</a>
</p>
<table class="table">
<thead>
<tr>
<th>
@Html.DisplayNameFor(model => model.Department[0].Name)
</th>
<th>
@Html.DisplayNameFor(model => model.Department[0].Budget)
</th>
<th>
@Html.DisplayNameFor(model => model.Department[0].StartDate)
</th>
<th>
@Html.DisplayNameFor(model => model.Department[0].Administrator)
</th>
<th>
RowVersion
</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Department)
{
<tr>
<td>
@Html.DisplayFor(modelItem => item.Name)
</td>
<td>
@Html.DisplayFor(modelItem => item.Budget)
</td>
<td>
@Html.DisplayFor(modelItem => item.StartDate)
</td>
<td>
@Html.DisplayFor(modelItem => item.Administrator.FullName)
</td>
<td>
@item.RowVersion[7]
</td>
<td>
<a asp-page="./Edit" asp-route-id="@item.DepartmentID">Edit</a> |
<a asp-page="./Details" asp-route-id="@item.DepartmentID">Details</a> |
<a asp-page="./Delete" asp-route-id="@item.DepartmentID">Delete</a>
</td>
</tr>
}
</tbody>
</table>
Atualizar o modelo da página Editar
Atualize Pages/Departments/Edit.cshtml.cs
com o seguinte código:
using ContosoUniversity.Data;
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading.Tasks;
namespace ContosoUniversity.Pages.Departments
{
public class EditModel : PageModel
{
private readonly ContosoUniversity.Data.SchoolContext _context;
public EditModel(ContosoUniversity.Data.SchoolContext context)
{
_context = context;
}
[BindProperty]
public Department Department { get; set; }
// Replace ViewData["InstructorID"]
public SelectList InstructorNameSL { get; set; }
public async Task<IActionResult> OnGetAsync(int id)
{
Department = await _context.Departments
.Include(d => d.Administrator) // eager loading
.AsNoTracking() // tracking not required
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (Department == null)
{
return NotFound();
}
// Use strongly typed data rather than ViewData.
InstructorNameSL = new SelectList(_context.Instructors,
"ID", "FirstMidName");
return Page();
}
public async Task<IActionResult> OnPostAsync(int id)
{
if (!ModelState.IsValid)
{
return Page();
}
var departmentToUpdate = await _context.Departments
.Include(i => i.Administrator)
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (departmentToUpdate == null)
{
return HandleDeletedDepartment();
}
_context.Entry(departmentToUpdate)
.Property("RowVersion").OriginalValue = Department.RowVersion;
if (await TryUpdateModelAsync<Department>(
departmentToUpdate,
"Department",
s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
try
{
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
var exceptionEntry = ex.Entries.Single();
var clientValues = (Department)exceptionEntry.Entity;
var databaseEntry = exceptionEntry.GetDatabaseValues();
if (databaseEntry == null)
{
ModelState.AddModelError(string.Empty, "Unable to save. " +
"The department was deleted by another user.");
return Page();
}
var dbValues = (Department)databaseEntry.ToObject();
await setDbErrorMessage(dbValues, clientValues, _context);
// Save the current RowVersion so next postback
// matches unless an new concurrency issue happens.
Department.RowVersion = (byte[])dbValues.RowVersion;
// Clear the model error for the next postback.
ModelState.Remove("Department.RowVersion");
}
}
InstructorNameSL = new SelectList(_context.Instructors,
"ID", "FullName", departmentToUpdate.InstructorID);
return Page();
}
private IActionResult HandleDeletedDepartment()
{
var deletedDepartment = new Department();
// ModelState contains the posted data because of the deletion error
// and will overide the Department instance values when displaying Page().
ModelState.AddModelError(string.Empty,
"Unable to save. The department was deleted by another user.");
InstructorNameSL = new SelectList(_context.Instructors, "ID", "FullName", Department.InstructorID);
return Page();
}
private async Task setDbErrorMessage(Department dbValues,
Department clientValues, SchoolContext context)
{
if (dbValues.Name != clientValues.Name)
{
ModelState.AddModelError("Department.Name",
$"Current value: {dbValues.Name}");
}
if (dbValues.Budget != clientValues.Budget)
{
ModelState.AddModelError("Department.Budget",
$"Current value: {dbValues.Budget:c}");
}
if (dbValues.StartDate != clientValues.StartDate)
{
ModelState.AddModelError("Department.StartDate",
$"Current value: {dbValues.StartDate:d}");
}
if (dbValues.InstructorID != clientValues.InstructorID)
{
Instructor dbInstructor = await _context.Instructors
.FindAsync(dbValues.InstructorID);
ModelState.AddModelError("Department.InstructorID",
$"Current value: {dbInstructor?.FullName}");
}
ModelState.AddModelError(string.Empty,
"The record you attempted to edit "
+ "was modified by another user after you. The "
+ "edit operation was canceled and the current values in the database "
+ "have been displayed. If you still want to edit this record, click "
+ "the Save button again.");
}
}
}
O OriginalValue é atualizado com o valor rowVersion
da entidade quando ele foi buscado no método OnGetAsync
. O EF Core gera um comando SQL UPDATE com uma cláusula WHERE que contém o valor RowVersion
original. Se nenhuma linha for afetada pelo comando UPDATE (nenhuma linha tem o valor RowVersion
original), uma exceção DbUpdateConcurrencyException
será gerada.
public async Task<IActionResult> OnPostAsync(int id)
{
if (!ModelState.IsValid)
{
return Page();
}
var departmentToUpdate = await _context.Departments
.Include(i => i.Administrator)
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (departmentToUpdate == null)
{
return HandleDeletedDepartment();
}
_context.Entry(departmentToUpdate)
.Property("RowVersion").OriginalValue = Department.RowVersion;
No código realçado anterior:
- O valor em
Department.RowVersion
é o que estava na entidade quando ela foi buscada originalmente na solicitação Get para a página Editar. O valor é fornecido ao métodoOnPost
por um campo oculto na página do Razor que exibe a entidade a ser editada. O valor do campo oculto é copiado paraDepartment.RowVersion
pelo associador de modelos. OriginalValue
é o que o EF Core usará na cláusula Where. Antes que a linha de código realçada seja executada, oOriginalValue
tem o valor que estava no banco de dados quandoFirstOrDefaultAsync
foi chamado nesse método, que pode ser diferente do que foi exibido na página de edição.- O código realçado garante que o EF Core use o valor
RowVersion
original da entidadeDepartment
exibida na cláusula Where da declaração SQL UPDATE.
Quando ocorre um erro de simultaneidade, o código realçado a seguir obtém os valores do cliente (os valores postados para esse método) e os valores do banco de dados.
if (await TryUpdateModelAsync<Department>(
departmentToUpdate,
"Department",
s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
try
{
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
var exceptionEntry = ex.Entries.Single();
var clientValues = (Department)exceptionEntry.Entity;
var databaseEntry = exceptionEntry.GetDatabaseValues();
if (databaseEntry == null)
{
ModelState.AddModelError(string.Empty, "Unable to save. " +
"The department was deleted by another user.");
return Page();
}
var dbValues = (Department)databaseEntry.ToObject();
await setDbErrorMessage(dbValues, clientValues, _context);
// Save the current RowVersion so next postback
// matches unless an new concurrency issue happens.
Department.RowVersion = (byte[])dbValues.RowVersion;
// Clear the model error for the next postback.
ModelState.Remove("Department.RowVersion");
}
O seguinte código adiciona uma mensagem de erro personalizada a cada coluna que tem valores de banco de dados diferentes daqueles que foram postados em OnPostAsync
:
private async Task setDbErrorMessage(Department dbValues,
Department clientValues, SchoolContext context)
{
if (dbValues.Name != clientValues.Name)
{
ModelState.AddModelError("Department.Name",
$"Current value: {dbValues.Name}");
}
if (dbValues.Budget != clientValues.Budget)
{
ModelState.AddModelError("Department.Budget",
$"Current value: {dbValues.Budget:c}");
}
if (dbValues.StartDate != clientValues.StartDate)
{
ModelState.AddModelError("Department.StartDate",
$"Current value: {dbValues.StartDate:d}");
}
if (dbValues.InstructorID != clientValues.InstructorID)
{
Instructor dbInstructor = await _context.Instructors
.FindAsync(dbValues.InstructorID);
ModelState.AddModelError("Department.InstructorID",
$"Current value: {dbInstructor?.FullName}");
}
ModelState.AddModelError(string.Empty,
"The record you attempted to edit "
+ "was modified by another user after you. The "
+ "edit operation was canceled and the current values in the database "
+ "have been displayed. If you still want to edit this record, click "
+ "the Save button again.");
}
O código realçado a seguir define o valor RowVersion
com o novo valor recuperado do banco de dados. Na próxima vez que o usuário clicar em Salvar, somente os erros de simultaneidade que ocorrerem desde a última exibição da página Editar serão capturados.
if (await TryUpdateModelAsync<Department>(
departmentToUpdate,
"Department",
s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
try
{
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
var exceptionEntry = ex.Entries.Single();
var clientValues = (Department)exceptionEntry.Entity;
var databaseEntry = exceptionEntry.GetDatabaseValues();
if (databaseEntry == null)
{
ModelState.AddModelError(string.Empty, "Unable to save. " +
"The department was deleted by another user.");
return Page();
}
var dbValues = (Department)databaseEntry.ToObject();
await setDbErrorMessage(dbValues, clientValues, _context);
// Save the current RowVersion so next postback
// matches unless an new concurrency issue happens.
Department.RowVersion = (byte[])dbValues.RowVersion;
// Clear the model error for the next postback.
ModelState.Remove("Department.RowVersion");
}
A instrução ModelState.Remove
é obrigatória porque ModelState
tem o valor RowVersion
antigo. No Razor Page, o valor ModelState
de um campo tem precedência sobre os valores de propriedade do modelo, quando ambos estão presentes.
Atualizar a página Editar
Atualize Pages/Departments/Edit.cshtml
com o seguinte código:
@page "{id:int}"
@model ContosoUniversity.Pages.Departments.EditModel
@{
ViewData["Title"] = "Edit";
}
<h2>Edit</h2>
<h4>Department</h4>
<hr />
<div class="row">
<div class="col-md-4">
<form method="post">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<input type="hidden" asp-for="Department.DepartmentID" />
<input type="hidden" asp-for="Department.RowVersion" />
<div class="form-group">
<label>RowVersion</label>
@Model.Department.RowVersion[7]
</div>
<div class="form-group">
<label asp-for="Department.Name" class="control-label"></label>
<input asp-for="Department.Name" class="form-control" />
<span asp-validation-for="Department.Name" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Department.Budget" class="control-label"></label>
<input asp-for="Department.Budget" class="form-control" />
<span asp-validation-for="Department.Budget" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Department.StartDate" class="control-label"></label>
<input asp-for="Department.StartDate" class="form-control" />
<span asp-validation-for="Department.StartDate" class="text-danger">
</span>
</div>
<div class="form-group">
<label class="control-label">Instructor</label>
<select asp-for="Department.InstructorID" class="form-control"
asp-items="@Model.InstructorNameSL"></select>
<span asp-validation-for="Department.InstructorID" class="text-danger">
</span>
</div>
<div class="form-group">
<input type="submit" value="Save" class="btn btn-primary" />
</div>
</form>
</div>
</div>
<div>
<a asp-page="./Index">Back to List</a>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}
O código anterior:
- Atualiza a diretiva
page
de@page
para@page "{id:int}"
. - Adiciona uma versão de linha oculta.
RowVersion
deve ser adicionado para que o postback associe o valor. - Exibe o último byte de
RowVersion
para fins de depuração. - Substitui
ViewData
peloInstructorNameSL
fortemente tipado.
Testar conflitos de simultaneidade com a página Editar
Abra duas instâncias de navegadores de Editar no departamento de inglês:
- Execute o aplicativo e selecione Departamentos.
- Clique com o botão direito do mouse no hiperlink Editar do departamento de inglês e selecione Abrir em uma nova guia.
- Na primeira guia, clique no hiperlink Editar do departamento de inglês.
As duas guias do navegador exibem as mesmas informações.
Altere o nome na primeira guia do navegador e clique em Salvar.
O navegador mostra a página de Índice com o valor alterado e o indicador de rowVersion atualizado. Observe o indicador de rowVersion atualizado: ele é exibido no segundo postback na outra guia.
Altere outro campo na segunda guia do navegador.
Clique em Salvar. Você verá mensagens de erro em todos os campos que não correspondem aos valores do banco de dados:
Essa janela do navegador não pretendia alterar o campo Name. Copie e cole o valor atual (Languages) para o campo Name. Saída da guia. A validação do lado do cliente remove a mensagem de erro.
Clique em Salvar novamente. O valor inserido na segunda guia do navegador foi salvo. Você verá os valores salvos na página Índice.
Atualizar o modelo da página Excluir
Atualize Pages/Departments/Delete.cshtml.cs
com o seguinte código:
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;
namespace ContosoUniversity.Pages.Departments
{
public class DeleteModel : PageModel
{
private readonly ContosoUniversity.Data.SchoolContext _context;
public DeleteModel(ContosoUniversity.Data.SchoolContext context)
{
_context = context;
}
[BindProperty]
public Department Department { get; set; }
public string ConcurrencyErrorMessage { get; set; }
public async Task<IActionResult> OnGetAsync(int id, bool? concurrencyError)
{
Department = await _context.Departments
.Include(d => d.Administrator)
.AsNoTracking()
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (Department == null)
{
return NotFound();
}
if (concurrencyError.GetValueOrDefault())
{
ConcurrencyErrorMessage = "The record you attempted to delete "
+ "was modified by another user after you selected delete. "
+ "The delete operation was canceled and the current values in the "
+ "database have been displayed. If you still want to delete this "
+ "record, click the Delete button again.";
}
return Page();
}
public async Task<IActionResult> OnPostAsync(int id)
{
try
{
if (await _context.Departments.AnyAsync(
m => m.DepartmentID == id))
{
// Department.rowVersion value is from when the entity
// was fetched. If it doesn't match the DB, a
// DbUpdateConcurrencyException exception is thrown.
_context.Departments.Remove(Department);
await _context.SaveChangesAsync();
}
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException)
{
return RedirectToPage("./Delete",
new { concurrencyError = true, id = id });
}
}
}
}
A página Excluir detectou conflitos de simultaneidade quando a entidade foi alterada depois de ser buscada. Department.RowVersion
é a versão de linha quando a entidade foi buscada. Quando o EF Core cria o comando SQL DELETE, ele inclui uma cláusula WHERE com RowVersion
. Se o comando SQL DELETE não resultar em nenhuma linha afetada:
- A
RowVersion
no comando SQL DELETE não corresponderá aRowVersion
no banco de dados. - Uma exceção DbUpdateConcurrencyException é gerada.
OnGetAsync
é chamado com oconcurrencyError
.
Atualizar a página Excluir
Atualize Pages/Departments/Delete.cshtml
com o seguinte código:
@page "{id:int}"
@model ContosoUniversity.Pages.Departments.DeleteModel
@{
ViewData["Title"] = "Delete";
}
<h2>Delete</h2>
<p class="text-danger">@Model.ConcurrencyErrorMessage</p>
<h3>Are you sure you want to delete this?</h3>
<div>
<h4>Department</h4>
<hr />
<dl class="dl-horizontal">
<dt>
@Html.DisplayNameFor(model => model.Department.Name)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.Name)
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.Budget)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.Budget)
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.StartDate)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.StartDate)
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.RowVersion)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.RowVersion[7])
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.Administrator)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.Administrator.FullName)
</dd>
</dl>
<form method="post">
<input type="hidden" asp-for="Department.DepartmentID" />
<input type="hidden" asp-for="Department.RowVersion" />
<div class="form-actions no-color">
<input type="submit" value="Delete" class="btn btn-danger" /> |
<a asp-page="./Index">Back to List</a>
</div>
</form>
</div>
O código anterior faz as seguintes alterações:
- Atualiza a diretiva
page
de@page
para@page "{id:int}"
. - Adiciona uma mensagem de erro.
- Substitua FirstMidName por FullName no campo Administrador.
- Altere
RowVersion
para exibir o último byte. - Adiciona uma versão de linha oculta.
RowVersion
deve ser adicionado para que o postback associe o valor.
Testar os conflitos de simultaneidade
Crie um departamento de teste.
Abra duas instâncias dos navegadores de Excluir no departamento de teste:
- Execute o aplicativo e selecione Departamentos.
- Clique com o botão direito do mouse no hiperlink Excluir do departamento de teste e selecione Abrir em uma nova guia.
- Clique no hiperlink Editar do departamento de teste.
As duas guias do navegador exibem as mesmas informações.
Altere o orçamento na primeira guia do navegador e clique em Salvar.
O navegador mostra a página de Índice com o valor alterado e o indicador de rowVersion atualizado. Observe o indicador de rowVersion atualizado: ele é exibido no segundo postback na outra guia.
Exclua o departamento de teste na segunda guia. Um erro de simultaneidade é exibido com os valores atuais do banco de dados. Clicar em Excluir exclui a entidade, a menos que RowVersion
tenha sido atualizada.
Recursos adicionais
- Tokens de simultaneidade em EF Core
- Manipular a simultaneidade em EF Core
- Depuração de origem do ASP.NET Core 2.x
Próximas etapas
Este é o último tutorial da série. Tópicos adicionais são abordados na versão MVC desta série de tutoriais.
Este tutorial mostra como lidar com conflitos quando os mesmos usuários atualizam uma entidade simultaneamente. Caso tenha problemas que não consiga resolver, baixe ou exiba o aplicativo concluído. Instruções de download.
Conflitos de simultaneidade
Um conflito de simultaneidade ocorre quando:
- Um usuário navega para a página de edição de uma entidade.
- Outro usuário atualiza a mesma entidade antes que a primeira alteração do usuário seja gravada no BD.
Se a detecção de simultaneidade não estiver habilitada, quando ocorrerem atualizações simultâneas:
- A última atualização vencerá. Ou seja, os últimos valores de atualização serão salvos no BD.
- A primeira das atualizações atuais será perdida.
Simultaneidade otimista
A simultaneidade otimista permite que conflitos de simultaneidade ocorram e, em seguida, responde adequadamente quando ocorrem. Por exemplo, Alice visita a página Editar Departamento e altera o orçamento para o departamento de inglês de US$ 350.000,00 para US$ 0,00.
Antes que Alice clique em Salvar, Julio visita a mesma página e altera o campo Data de Início de 1/9/2007 para 1/9/2013.
Alice clica em Salvar primeiro e vê a alteração quando o navegador exibe a página Índice.
Julio clica em Salvar em uma página Editar que ainda mostra um orçamento de US$ 350.000,00. O que acontece em seguida é determinado pela forma como você lida com conflitos de simultaneidade.
A simultaneidade otimista inclui as seguintes opções:
Controle qual propriedade um usuário modificou e atualize apenas as colunas correspondentes no BD.
No cenário, não haverá perda de dados. Propriedades diferentes foram atualizadas pelos dois usuários. Na próxima vez que alguém navegar no departamento de inglês, verá as alterações de Alice e Julio. Esse método de atualização pode reduzir o número de conflitos que podem resultar em perda de dados. Essa abordagem:
- Não poderá evitar a perda de dados se forem feitas alterações concorrentes na mesma propriedade.
- Geralmente, não é prática em um aplicativo Web. Ela exige um estado de manutenção significativo para controlar todos os valores buscados e novos valores. Manter grandes quantidades de estado pode afetar o desempenho do aplicativo.
- Pode aumentar a complexidade do aplicativo comparado à detecção de simultaneidade em uma entidade.
Você não pode deixar a alteração de Julio substituir a alteração de Alice.
Na próxima vez que alguém navegar pelo departamento de inglês, verá 1/9/2013 e o valor de US$ 350.000,00 buscado. Essa abordagem é chamada de um cenário O cliente vence ou O último vence. (Todos os valores do cliente têm precedência sobre o conteúdo do armazenamento de dados.) Se você não fizer nenhuma codificação para a manipulação de simultaneidade, o cenário O cliente vence ocorrerá automaticamente.
Você pode impedir que as alterações de Julio sejam atualizadas no BD. Normalmente, o aplicativo:
- Exibe uma mensagem de erro.
- Mostra o estado atual dos dados.
- Permite ao usuário aplicar as alterações novamente.
Isso é chamado de um cenário O armazenamento vence. (Os valores do armazenamento de dados têm precedência sobre os valores enviados pelo cliente.) Você implementa o cenário O armazenamento vence neste tutorial. Esse método garante que nenhuma alteração é substituída sem que um usuário seja alertado.
Tratamento de simultaneidade
Quando uma propriedade é configurada como um token de simultaneidade:
- O EF Core verifica se a propriedade não foi modificada depois que foi buscada. A verificação ocorre quando SaveChanges ou SaveChangesAsync é chamado.
- Se a propriedade tiver sido alterada depois que ela foi buscada, uma DbUpdateConcurrencyException será gerada.
O BD e o modelo de dados precisam ser configurados para dar suporte à geração de DbUpdateConcurrencyException
.
Detectando conflitos de simultaneidade em uma propriedade
Os conflitos de simultaneidade podem ser detectados no nível do propriedade com o atributo ConcurrencyCheck. O atributo pode ser aplicado a várias propriedades no modelo. Para obter mais informações, consulte Data Annotations-ConcurrencyCheck.
O atributo [ConcurrencyCheck]
não é usado neste tutorial.
Detectando conflitos de simultaneidade em uma linha
Para detectar conflitos de simultaneidade, uma coluna de controle de rowversion é adicionada ao modelo. rowversion
:
- É específico ao SQL Server. Outros bancos de dados podem não fornecer um recurso semelhante.
- É usado para determinar se uma entidade não foi alterada desde que foi buscada no BD.
O BD gera um número rowversion
sequencial que é incrementado sempre que a linha é atualizada. Em um comando Update
ou Delete
, a cláusula Where
inclui o valor buscado de rowversion
. Se a linha que está sendo atualizada foi alterada:
rowversion
não corresponde ao valor buscado.- Os comandos
Update
ouDelete
não encontram uma linha porque a cláusulaWhere
inclui arowversion
buscada. - Uma
DbUpdateConcurrencyException
é gerada.
No EF Core, quando nenhuma linha é atualizada por um comando Update
ou Delete
, uma exceção de simultaneidade é gerada.
Adicionar uma propriedade de controle à entidade Department
Em Models/Department.cs
, adicione uma propriedade de controle chamada RowVersion:
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace ContosoUniversity.Models
{
public class Department
{
public int DepartmentID { get; set; }
[StringLength(50, MinimumLength = 3)]
public string Name { get; set; }
[DataType(DataType.Currency)]
[Column(TypeName = "money")]
public decimal Budget { get; set; }
[DataType(DataType.Date)]
[DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
[Display(Name = "Start Date")]
public DateTime StartDate { get; set; }
public int? InstructorID { get; set; }
[Timestamp]
public byte[] RowVersion { get; set; }
public Instructor Administrator { get; set; }
public ICollection<Course> Courses { get; set; }
}
}
O atributo Timestamp especifica que essa coluna é incluída na cláusula Where
dos comandos Update
e Delete
. O atributo é chamado Timestamp
porque as versões anteriores do SQL Server usavam um tipo de dados timestamp
do SQL antes de o tipo rowversion
SQL substituí-lo.
A API fluente também pode especificar a propriedade de controle:
modelBuilder.Entity<Department>()
.Property<byte[]>("RowVersion")
.IsRowVersion();
O seguinte código mostra uma parte do T-SQL gerado pelo EF Core quando o nome do Departamento é atualizado:
SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
O código anterior realçado mostra a cláusula WHERE
que contém RowVersion
. Se o BD RowVersion
não for igual ao parâmetro RowVersion
(@p2
), nenhuma linha será atualizada.
O seguinte código realçado mostra o T-SQL que verifica exatamente se uma linha foi atualizada:
SET NOCOUNT ON;
UPDATE [Department] SET [Name] = @p0
WHERE [DepartmentID] = @p1 AND [RowVersion] = @p2;
SELECT [RowVersion]
FROM [Department]
WHERE @@ROWCOUNT = 1 AND [DepartmentID] = @p1;
@@ROWCOUNT retorna o número de linhas afetadas pela última instrução. Se nenhuma linha for atualizada, o EF Core gerará uma DbUpdateConcurrencyException
.
Veja o T-SQL gerado pelo EF Core na janela de Saída do Visual Studio.
Atualizar o BD
A adição da propriedade RowVersion
altera o modelo de BD, o que exige uma migração.
Compile o projeto. Insira o seguinte em uma janela Comando:
dotnet ef migrations add RowVersion
dotnet ef database update
Os comandos anteriores:
Adiciona o arquivo de migração
Migrations/{time stamp}_RowVersion.cs
.Atualiza o arquivo
Migrations/SchoolContextModelSnapshot.cs
. A atualização adiciona o seguinte código realçado ao métodoBuildModel
:Executam migrações para atualizar o BD.
Gerar o modelo Departamentos por scaffolding
Siga as instruções em Gere um modelo de aluno por scaffold e use Department
para a classe de modelo.
O comando anterior gera o modelo Department
por scaffolding. Abra o projeto no Visual Studio.
Compile o projeto.
Atualizar a página Índice de Departamentos
O mecanismo de scaffolding criou uma coluna RowVersion
para a página Índice, mas esse campo não deve ser exibido. Neste tutorial, o último byte da RowVersion
é exibido para ajudar a entender a simultaneidade. O último byte não tem garantia de ser exclusivo. Um aplicativo real não exibe RowVersion
ou o último byte de RowVersion
.
Atualize a página Índice:
- Substitua Índice por Departamentos.
- Substitua a marcação que contém
RowVersion
pelo último byte deRowVersion
. - Substitua FirstMidName por FullName.
A seguinte marcação mostra a página atualizada:
@page
@model ContosoUniversity.Pages.Departments.IndexModel
@{
ViewData["Title"] = "Departments";
}
<h2>Departments</h2>
<p>
<a asp-page="Create">Create New</a>
</p>
<table class="table">
<thead>
<tr>
<th>
@Html.DisplayNameFor(model => model.Department[0].Name)
</th>
<th>
@Html.DisplayNameFor(model => model.Department[0].Budget)
</th>
<th>
@Html.DisplayNameFor(model => model.Department[0].StartDate)
</th>
<th>
@Html.DisplayNameFor(model => model.Department[0].Administrator)
</th>
<th>
RowVersion
</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Department) {
<tr>
<td>
@Html.DisplayFor(modelItem => item.Name)
</td>
<td>
@Html.DisplayFor(modelItem => item.Budget)
</td>
<td>
@Html.DisplayFor(modelItem => item.StartDate)
</td>
<td>
@Html.DisplayFor(modelItem => item.Administrator.FullName)
</td>
<td>
@item.RowVersion[7]
</td>
<td>
<a asp-page="./Edit" asp-route-id="@item.DepartmentID">Edit</a> |
<a asp-page="./Details" asp-route-id="@item.DepartmentID">Details</a> |
<a asp-page="./Delete" asp-route-id="@item.DepartmentID">Delete</a>
</td>
</tr>
}
</tbody>
</table>
Atualizar o modelo da página Editar
Atualize Pages/Departments/Edit.cshtml.cs
com o seguinte código:
using ContosoUniversity.Data;
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Threading.Tasks;
namespace ContosoUniversity.Pages.Departments
{
public class EditModel : PageModel
{
private readonly ContosoUniversity.Data.SchoolContext _context;
public EditModel(ContosoUniversity.Data.SchoolContext context)
{
_context = context;
}
[BindProperty]
public Department Department { get; set; }
// Replace ViewData["InstructorID"]
public SelectList InstructorNameSL { get; set; }
public async Task<IActionResult> OnGetAsync(int id)
{
Department = await _context.Departments
.Include(d => d.Administrator) // eager loading
.AsNoTracking() // tracking not required
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (Department == null)
{
return NotFound();
}
// Use strongly typed data rather than ViewData.
InstructorNameSL = new SelectList(_context.Instructors,
"ID", "FirstMidName");
return Page();
}
public async Task<IActionResult> OnPostAsync(int id)
{
if (!ModelState.IsValid)
{
return Page();
}
var departmentToUpdate = await _context.Departments
.Include(i => i.Administrator)
.FirstOrDefaultAsync(m => m.DepartmentID == id);
// null means Department was deleted by another user.
if (departmentToUpdate == null)
{
return HandleDeletedDepartment();
}
// Update the RowVersion to the value when this entity was
// fetched. If the entity has been updated after it was
// fetched, RowVersion won't match the DB RowVersion and
// a DbUpdateConcurrencyException is thrown.
// A second postback will make them match, unless a new
// concurrency issue happens.
_context.Entry(departmentToUpdate)
.Property("RowVersion").OriginalValue = Department.RowVersion;
if (await TryUpdateModelAsync<Department>(
departmentToUpdate,
"Department",
s => s.Name, s => s.StartDate, s => s.Budget, s => s.InstructorID))
{
try
{
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
var exceptionEntry = ex.Entries.Single();
var clientValues = (Department)exceptionEntry.Entity;
var databaseEntry = exceptionEntry.GetDatabaseValues();
if (databaseEntry == null)
{
ModelState.AddModelError(string.Empty, "Unable to save. " +
"The department was deleted by another user.");
return Page();
}
var dbValues = (Department)databaseEntry.ToObject();
await SetDbErrorMessage(dbValues, clientValues, _context);
// Save the current RowVersion so next postback
// matches unless an new concurrency issue happens.
Department.RowVersion = (byte[])dbValues.RowVersion;
// Must clear the model error for the next postback.
ModelState.Remove("Department.RowVersion");
}
}
InstructorNameSL = new SelectList(_context.Instructors,
"ID", "FullName", departmentToUpdate.InstructorID);
return Page();
}
private IActionResult HandleDeletedDepartment()
{
// ModelState contains the posted data because of the deletion error and will overide the Department instance values when displaying Page().
ModelState.AddModelError(string.Empty,
"Unable to save. The department was deleted by another user.");
InstructorNameSL = new SelectList(_context.Instructors, "ID", "FullName", Department.InstructorID);
return Page();
}
private async Task SetDbErrorMessage(Department dbValues,
Department clientValues, SchoolContext context)
{
if (dbValues.Name != clientValues.Name)
{
ModelState.AddModelError("Department.Name",
$"Current value: {dbValues.Name}");
}
if (dbValues.Budget != clientValues.Budget)
{
ModelState.AddModelError("Department.Budget",
$"Current value: {dbValues.Budget:c}");
}
if (dbValues.StartDate != clientValues.StartDate)
{
ModelState.AddModelError("Department.StartDate",
$"Current value: {dbValues.StartDate:d}");
}
if (dbValues.InstructorID != clientValues.InstructorID)
{
Instructor dbInstructor = await _context.Instructors
.FindAsync(dbValues.InstructorID);
ModelState.AddModelError("Department.InstructorID",
$"Current value: {dbInstructor?.FullName}");
}
ModelState.AddModelError(string.Empty,
"The record you attempted to edit "
+ "was modified by another user after you. The "
+ "edit operation was canceled and the current values in the database "
+ "have been displayed. If you still want to edit this record, click "
+ "the Save button again.");
}
}
}
Para detectar um problema de simultaneidade, o OriginalValue é atualizado com o valor rowVersion
da entidade que foi buscada. O EF Core gera um comando SQL UPDATE com uma cláusula WHERE que contém o valor RowVersion
original. Se nenhuma linha for afetada pelo comando UPDATE (nenhuma linha tem o valor RowVersion
original), uma exceção DbUpdateConcurrencyException
será gerada.
public async Task<IActionResult> OnPostAsync(int id)
{
if (!ModelState.IsValid)
{
return Page();
}
var departmentToUpdate = await _context.Departments
.Include(i => i.Administrator)
.FirstOrDefaultAsync(m => m.DepartmentID == id);
// null means Department was deleted by another user.
if (departmentToUpdate == null)
{
return HandleDeletedDepartment();
}
// Update the RowVersion to the value when this entity was
// fetched. If the entity has been updated after it was
// fetched, RowVersion won't match the DB RowVersion and
// a DbUpdateConcurrencyException is thrown.
// A second postback will make them match, unless a new
// concurrency issue happens.
_context.Entry(departmentToUpdate)
.Property("RowVersion").OriginalValue = Department.RowVersion;
No código anterior, Department.RowVersion
é o valor quando a entidade foi buscada. OriginalValue
é o valor no BD quando FirstOrDefaultAsync
foi chamado nesse método.
O seguinte código obtém os valores de cliente (os valores postados nesse método) e os valores do BD:
try
{
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
var exceptionEntry = ex.Entries.Single();
var clientValues = (Department)exceptionEntry.Entity;
var databaseEntry = exceptionEntry.GetDatabaseValues();
if (databaseEntry == null)
{
ModelState.AddModelError(string.Empty, "Unable to save. " +
"The department was deleted by another user.");
return Page();
}
var dbValues = (Department)databaseEntry.ToObject();
await SetDbErrorMessage(dbValues, clientValues, _context);
// Save the current RowVersion so next postback
// matches unless an new concurrency issue happens.
Department.RowVersion = (byte[])dbValues.RowVersion;
// Must clear the model error for the next postback.
ModelState.Remove("Department.RowVersion");
}
O seguinte código adiciona uma mensagem de erro personalizada a cada coluna que tem valores de BD diferentes daqueles que foram postados em OnPostAsync
:
private async Task SetDbErrorMessage(Department dbValues,
Department clientValues, SchoolContext context)
{
if (dbValues.Name != clientValues.Name)
{
ModelState.AddModelError("Department.Name",
$"Current value: {dbValues.Name}");
}
if (dbValues.Budget != clientValues.Budget)
{
ModelState.AddModelError("Department.Budget",
$"Current value: {dbValues.Budget:c}");
}
if (dbValues.StartDate != clientValues.StartDate)
{
ModelState.AddModelError("Department.StartDate",
$"Current value: {dbValues.StartDate:d}");
}
if (dbValues.InstructorID != clientValues.InstructorID)
{
Instructor dbInstructor = await _context.Instructors
.FindAsync(dbValues.InstructorID);
ModelState.AddModelError("Department.InstructorID",
$"Current value: {dbInstructor?.FullName}");
}
ModelState.AddModelError(string.Empty,
"The record you attempted to edit "
+ "was modified by another user after you. The "
+ "edit operation was canceled and the current values in the database "
+ "have been displayed. If you still want to edit this record, click "
+ "the Save button again.");
}
O código realçado a seguir define o valor RowVersion
com o novo valor recuperado do BD. Na próxima vez que o usuário clicar em Salvar, somente os erros de simultaneidade que ocorrerem desde a última exibição da página Editar serão capturados.
try
{
await _context.SaveChangesAsync();
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException ex)
{
var exceptionEntry = ex.Entries.Single();
var clientValues = (Department)exceptionEntry.Entity;
var databaseEntry = exceptionEntry.GetDatabaseValues();
if (databaseEntry == null)
{
ModelState.AddModelError(string.Empty, "Unable to save. " +
"The department was deleted by another user.");
return Page();
}
var dbValues = (Department)databaseEntry.ToObject();
await SetDbErrorMessage(dbValues, clientValues, _context);
// Save the current RowVersion so next postback
// matches unless an new concurrency issue happens.
Department.RowVersion = (byte[])dbValues.RowVersion;
// Must clear the model error for the next postback.
ModelState.Remove("Department.RowVersion");
}
A instrução ModelState.Remove
é obrigatória porque ModelState
tem o valor RowVersion
antigo. No Razor Page, o valor ModelState
de um campo tem precedência sobre os valores de propriedade do modelo, quando ambos estão presentes.
Atualizar a página Editar
Atualize o Pages/Departments/Edit.cshtml
com a seguinte marcação:
@page "{id:int}"
@model ContosoUniversity.Pages.Departments.EditModel
@{
ViewData["Title"] = "Edit";
}
<h2>Edit</h2>
<h4>Department</h4>
<hr />
<div class="row">
<div class="col-md-4">
<form method="post">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<input type="hidden" asp-for="Department.DepartmentID" />
<input type="hidden" asp-for="Department.RowVersion" />
<div class="form-group">
<label>RowVersion</label>
@Model.Department.RowVersion[7]
</div>
<div class="form-group">
<label asp-for="Department.Name" class="control-label"></label>
<input asp-for="Department.Name" class="form-control" />
<span asp-validation-for="Department.Name" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Department.Budget" class="control-label"></label>
<input asp-for="Department.Budget" class="form-control" />
<span asp-validation-for="Department.Budget" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Department.StartDate" class="control-label"></label>
<input asp-for="Department.StartDate" class="form-control" />
<span asp-validation-for="Department.StartDate" class="text-danger">
</span>
</div>
<div class="form-group">
<label class="control-label">Instructor</label>
<select asp-for="Department.InstructorID" class="form-control"
asp-items="@Model.InstructorNameSL"></select>
<span asp-validation-for="Department.InstructorID" class="text-danger">
</span>
</div>
<div class="form-group">
<input type="submit" value="Save" class="btn btn-default" />
</div>
</form>
</div>
</div>
<div>
<a asp-page="./Index">Back to List</a>
</div>
@section Scripts {
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
}
A marcação anterior:
- Atualiza a diretiva
page
de@page
para@page "{id:int}"
. - Adiciona uma versão de linha oculta.
RowVersion
deve ser adicionado para que o postback associe o valor. - Exibe o último byte de
RowVersion
para fins de depuração. - Substitui
ViewData
peloInstructorNameSL
fortemente tipado.
Testar conflitos de simultaneidade com a página Editar
Abra duas instâncias de navegadores de Editar no departamento de inglês:
- Execute o aplicativo e selecione Departamentos.
- Clique com o botão direito do mouse no hiperlink Editar do departamento de inglês e selecione Abrir em uma nova guia.
- Na primeira guia, clique no hiperlink Editar do departamento de inglês.
As duas guias do navegador exibem as mesmas informações.
Altere o nome na primeira guia do navegador e clique em Salvar.
O navegador mostra a página de Índice com o valor alterado e o indicador de rowVersion atualizado. Observe o indicador de rowVersion atualizado: ele é exibido no segundo postback na outra guia.
Altere outro campo na segunda guia do navegador.
Clique em Salvar. Você verá mensagens de erro em todos os campos que não correspondem aos valores do BD:
Essa janela do navegador não pretendia alterar o campo Name. Copie e cole o valor atual (Languages) para o campo Name. Saída da guia. A validação do lado do cliente remove a mensagem de erro.
Clique em Salvar novamente. O valor inserido na segunda guia do navegador foi salvo. Você verá os valores salvos na página Índice.
Atualizar a página Excluir
Atualize o modelo da página Excluir com o seguinte código:
using ContosoUniversity.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using System.Threading.Tasks;
namespace ContosoUniversity.Pages.Departments
{
public class DeleteModel : PageModel
{
private readonly ContosoUniversity.Data.SchoolContext _context;
public DeleteModel(ContosoUniversity.Data.SchoolContext context)
{
_context = context;
}
[BindProperty]
public Department Department { get; set; }
public string ConcurrencyErrorMessage { get; set; }
public async Task<IActionResult> OnGetAsync(int id, bool? concurrencyError)
{
Department = await _context.Departments
.Include(d => d.Administrator)
.AsNoTracking()
.FirstOrDefaultAsync(m => m.DepartmentID == id);
if (Department == null)
{
return NotFound();
}
if (concurrencyError.GetValueOrDefault())
{
ConcurrencyErrorMessage = "The record you attempted to delete "
+ "was modified by another user after you selected delete. "
+ "The delete operation was canceled and the current values in the "
+ "database have been displayed. If you still want to delete this "
+ "record, click the Delete button again.";
}
return Page();
}
public async Task<IActionResult> OnPostAsync(int id)
{
try
{
if (await _context.Departments.AnyAsync(
m => m.DepartmentID == id))
{
// Department.rowVersion value is from when the entity
// was fetched. If it doesn't match the DB, a
// DbUpdateConcurrencyException exception is thrown.
_context.Departments.Remove(Department);
await _context.SaveChangesAsync();
}
return RedirectToPage("./Index");
}
catch (DbUpdateConcurrencyException)
{
return RedirectToPage("./Delete",
new { concurrencyError = true, id = id });
}
}
}
}
A página Excluir detectou conflitos de simultaneidade quando a entidade foi alterada depois de ser buscada. Department.RowVersion
é a versão de linha quando a entidade foi buscada. Quando o EF Core cria o comando SQL DELETE, ele inclui uma cláusula WHERE com RowVersion
. Se o comando SQL DELETE não resultar em nenhuma linha afetada:
- A
RowVersion
no comando SQL DELETE não corresponderá aRowVersion
no BD. - Uma exceção DbUpdateConcurrencyException é gerada.
OnGetAsync
é chamado com oconcurrencyError
.
Atualizar a página Excluir
Atualize Pages/Departments/Delete.cshtml
com o seguinte código:
@page "{id:int}"
@model ContosoUniversity.Pages.Departments.DeleteModel
@{
ViewData["Title"] = "Delete";
}
<h2>Delete</h2>
<p class="text-danger">@Model.ConcurrencyErrorMessage</p>
<h3>Are you sure you want to delete this?</h3>
<div>
<h4>Department</h4>
<hr />
<dl class="dl-horizontal">
<dt>
@Html.DisplayNameFor(model => model.Department.Name)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.Name)
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.Budget)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.Budget)
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.StartDate)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.StartDate)
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.RowVersion)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.RowVersion[7])
</dd>
<dt>
@Html.DisplayNameFor(model => model.Department.Administrator)
</dt>
<dd>
@Html.DisplayFor(model => model.Department.Administrator.FullName)
</dd>
</dl>
<form method="post">
<input type="hidden" asp-for="Department.DepartmentID" />
<input type="hidden" asp-for="Department.RowVersion" />
<div class="form-actions no-color">
<input type="submit" value="Delete" class="btn btn-default" /> |
<a asp-page="./Index">Back to List</a>
</div>
</form>
</div>
O código anterior faz as seguintes alterações:
- Atualiza a diretiva
page
de@page
para@page "{id:int}"
. - Adiciona uma mensagem de erro.
- Substitua FirstMidName por FullName no campo Administrador.
- Altere
RowVersion
para exibir o último byte. - Adiciona uma versão de linha oculta.
RowVersion
deve ser adicionado para que o postback associe o valor.
Testar conflitos de simultaneidade com a página Excluir
Crie um departamento de teste.
Abra duas instâncias dos navegadores de Excluir no departamento de teste:
- Execute o aplicativo e selecione Departamentos.
- Clique com o botão direito do mouse no hiperlink Excluir do departamento de teste e selecione Abrir em uma nova guia.
- Clique no hiperlink Editar do departamento de teste.
As duas guias do navegador exibem as mesmas informações.
Altere o orçamento na primeira guia do navegador e clique em Salvar.
O navegador mostra a página de Índice com o valor alterado e o indicador de rowVersion atualizado. Observe o indicador de rowVersion atualizado: ele é exibido no segundo postback na outra guia.
Exclua o departamento de teste na segunda guia. Um erro de simultaneidade é exibido com os valores atuais do banco de dados. Clicar em Excluir exclui a entidade, a menos que RowVersion
tenha sido atualizada.
Consulte Herança para saber como herdar um modelo de dados.