Parte 8, Razor Páginas com EF Core no ASP.NET Core - Concorrência
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.
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.
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:
Aplicar
[ConcurrencyCheck]
ou IsConcurrencyToken a uma propriedade no modelo. Esta abordagem não é recomendada. Para mais informações, consulte Tokens de Concorrência em EF Core.Aplicar TimestampAttribute ou IsRowVersion a um token de simultaneidade no modelo. Esta é a abordagem usada neste tutorial.
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.
- Visual Studio
- de código do Visual Studio
- 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.
- Visual Studio
- de código do Visual Studio
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étodoBuildModel
:
b.Property<byte[]>("ConcurrencyToken")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
Páginas do Departamento de Andaimes
- Visual Studio
- de código do Visual Studio
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:
- Visual Studio
- de código do Visual Studio
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
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:
- Visual Studio
- de código do Visual Studio
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.
- Visual Studio
- de código do Visual Studio
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 deGet
para a páginaEdit
. O valor é fornecido ao métodoOnPost
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 paraDepartment.ConcurrencyToken
. -
OriginalValue
é o que EF Core usa na cláusulaWHERE
. Antes que a linha de código realçada seja executada:-
OriginalValue
tem 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 assegura que EF Core utilize o valor original
ConcurrencyToken
da entidadeDepartment
exibida na cláusulaWHERE
da instrução SQLUPDATE
.
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 :
- Visual Studio
- de código do Visual Studio
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
peloInstructorNameSL
digitado 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.
O navegador mostra a página Índice com o valor alterado e o indicador ConcurrencyToken
atualizado. Observe o indicador de ConcurrencyToken
atualizado, ele é exibido no segundo postback na outra guia.
Altere um campo diferente na segunda guia do navegador.
Clique Salvar. Você vê mensagens de erro para todos os campos que não correspondem aos valores do banco de dados:
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 comandoSQL DELETE
não correspondeConcurrencyToken
no banco de dados. - Uma
DbUpdateConcurrencyException
exceção é lançada. -
OnGetAsync
é chamado com oconcurrencyError
.
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 ConcurrencyToken
atualizado. Observe o indicador de ConcurrencyToken
atualizado, 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
- Tokens de concorrência em EF Core
- Lidar com simultaneidade em EF Core
- Depuração ASP.NET de origem do Core 2.x
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.
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.
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 valorrowversion
é 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 colunarowversion
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();
- Visual Studio
- de código do Visual Studio
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
ouDelete
não encontram uma linha porque a cláusulaWhere
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.
- Visual Studio
- de código do Visual Studio
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é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"); });
- Visual Studio
- de código do Visual Studio
Execute o seguinte comando no PMC:
Update-Database
Páginas do Departamento de Andaimes
- Visual Studio
- de código do Visual Studio
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étodoOnPost
por um campo oculto na página Razor que exibe a entidade a ser editada. O valor do campo oculto é copiado paraDepartment.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 quandoFirstOrDefaultAsync
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 entidadeDepartment
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
peloInstructorNameSL
digitado 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.
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.
Clique em Guardar. Você vê mensagens de erro para todos os campos que não correspondem aos valores do banco de dados:
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 aRowVersion
no banco de dados. - Uma exceção do tipo DbUpdateConcurrencyException é lançada.
-
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.
- 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
- Tokens de concorrência em EF Core
- Lidar com simultaneidade em EF Core
- Depuração do ASP.NET Core 2.x a partir do código-fonte
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.
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.
Jane clica Salvar primeiro e vê sua alteração quando o navegador exibe a página Índice.
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 :
- EF Core verifica que a propriedade não foi modificada depois de ter sido obtenida. A verificação ocorre quando SaveChanges ou SaveChangesAsync é chamado.
- Se a propriedade tiver sido alterada depois de ter sido buscada, uma DbUpdateConcurrencyException é lançada.
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
ouDelete
não encontram uma linha porque a cláusulaWhere
inclui arowversion
buscada. - 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étodoBuildModel
:Executa migrações para atualizar o banco de dados.
Andaime o modelo de Departamentos
- Visual Studio
- de código do Visual Studio
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 deRowVersion
. - 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
peloInstructorNameSL
digitado 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.
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.
Clique Salvar. Você vê mensagens de erro para todos os campos que não correspondem aos valores de banco de dados:
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.
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 oRowVersion
no banco de dados. - Lança-se uma exceção do tipo DbUpdateConcurrencyException.
-
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.
- 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.