Compartilhar via


Tutorial: Manipular simultaneidade com EF em um aplicativo MVC 5 do ASP.NET

Em tutoriais anteriores, você aprendeu como atualizar dados. Este tutorial mostra como usar a simultaneidade otimista para lidar com conflitos quando vários usuários atualizam a mesma entidade ao mesmo tempo. Você altera as páginas da Web que funcionam com a Department entidade para que elas lidem com erros de simultaneidade. As ilustrações a seguir mostram as páginas Editar e Excluir, incluindo algumas mensagens exibidas se ocorre um conflito de simultaneidade.

A captura de tela mostra a página Editar com valores para Nome do Departamento, Orçamento, Data de Início e Administrador com os valores atuais destacados.

A captura de tela mostra a página Excluir de um registro com uma mensagem sobre a operação de exclusão e um botão Excluir.

Neste tutorial, você:

  • Aprender sobre conflitos de simultaneidade
  • Adicionar simultaneidade otimista
  • Modificar controlador de departamento
  • Testar o tratamento de simultaneidade
  • Atualizar a página Excluir

Pré-requisitos

Conflitos de simultaneidade

Um conflito de simultaneidade ocorre quando um usuário exibe dados de uma entidade para editá-los e, em seguida, outro usuário atualiza os mesmos dados da entidade antes que a primeira alteração do usuário seja gravada no banco de dados. Se você não habilitar a detecção desses conflitos, a última pessoa que atualizar o banco de dados substituirá as outras alterações do usuário. Em muitos aplicativos, esse risco é aceitável: se houver poucos usuários ou poucas atualizações ou se não for realmente crítico se algumas alterações forem substituídas, o custo de programação para simultaneidade poderá superar o benefício. Nesse caso, você não precisa configurar o aplicativo para lidar com conflitos de simultaneidade.

Simultaneidade pessimista (bloqueio)

Se o aplicativo precisar evitar a perda acidental de dados em cenários de simultaneidade, uma maneira de fazer isso será usar bloqueios de banco de dados. Isso é chamado de simultaneidade pessimista. Por exemplo, antes de ler uma linha de um banco de dados, você solicita um bloqueio para o acesso somente leitura ou de atualização. Se você bloquear uma linha para o acesso de atualização, nenhum outro usuário terá permissão para bloquear a linha para o acesso somente leitura ou de atualização, porque ele obterá uma cópia dos dados que estão sendo alterados. Se você bloquear uma linha para o acesso somente leitura, outros também poderão bloqueá-la para o acesso somente leitura, mas não para atualização.

O gerenciamento de bloqueios traz desvantagens. Ele pode ser complexo de ser programado. Exige recursos de gerenciamento de banco de dados significativos e pode causar problemas de desempenho, conforme o número de usuários de um aplicativo aumenta. Por esses motivos, nem todos os sistemas de gerenciamento de banco de dados dão suporte à simultaneidade pessimista. O Entity Framework não fornece suporte interno para ele e este tutorial não mostra como implementá-lo.

Simultaneidade otimista

A alternativa à simultaneidade pessimista é a simultaneidade otimista. Simultaneidade otimista significa permitir que conflitos de simultaneidade ocorram e responder adequadamente se eles ocorrerem. Por exemplo, John executa a página Editar Departamentos, altera o valor do orçamento para o departamento de inglês de US$ 350.000,00 para US$ 0,00.

Antes de John clicar em Salvar, Jane executa a mesma página e altera o campo Data de Início de 01/09/2007 para 08/08/2013.

John clica em Salvar primeiro e vê sua alteração quando o navegador retorna à página Índice e, em seguida, Jane clica em Salvar. O que acontece em seguida é determinado pela forma como você lida com conflitos de simultaneidade. Algumas das opções incluem o seguinte:

  • Controle qual propriedade um usuário modificou e atualize apenas as colunas correspondentes no banco de dados. No cenário de exemplo, nenhum dado é perdido, porque 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 John e Jane - uma data de início de 8/8/2013 e um orçamento de zero dólares.

    Esse método de atualização pode reduzir a quantidade de conflitos que podem resultar em perda de dados, mas ele não poderá evitar a perda de dados se forem feitas alterações concorrentes à mesma propriedade de uma entidade. Se o Entity Framework funciona dessa maneira depende de como o código de atualização é implementado. Geralmente, isso não é prático em um aplicativo Web, porque pode exigir que você mantenha grandes quantidades de estado para manter o controle de todos os valores de propriedade originais de uma entidade, bem como novos valores. A manutenção de grandes quantidades de estado pode afetar o desempenho do aplicativo, pois exige recursos do servidor ou deve ser incluída na própria página da Web (por exemplo, em campos ocultos) ou em um cookie.

  • Você pode deixar a alteração de Jane substituir a alteração de John. Na próxima vez que alguém navegar no departamento de inglês, verá 8/8/2013 e o valor restaurado de $ 350.000,00. Isso é chamado 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.) Conforme observado na introdução a esta seção, se você não fizer nenhuma codificação para a manipulação de simultaneidade, isso ocorrerá automaticamente.

  • Você pode impedir que a alteração de Jane seja atualizada no banco de dados. Normalmente, você exibiria uma mensagem de erro, mostraria a ela o estado atual dos dados e permitiria que ela reaplicasse suas alterações se ainda quisesse fazê-las. 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ê implementará 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 sobre o que está acontecendo.

Detectando conflitos de simultaneidade

Você pode resolver conflitos manipulando exceções OptimisticConcurrencyException que o Entity Framework gera. Para saber quando gerar essas exceções, o Entity Framework precisa poder detectar conflitos. Portanto, é necessário configurar o banco de dados e o modelo de dados de forma adequada. Algumas opções para habilitar a detecção de conflitos incluem as seguintes:

  • Na tabela de banco de dados, inclua uma coluna de acompanhamento que pode ser usada para determinar quando uma linha é alterada. Em seguida, você pode configurar o Entity Framework para incluir essa coluna na Where cláusula de SQL Update ou Delete comandos.

    O tipo de dados da coluna de controle normalmente é rowversion. O valor rowversion é um número sequencial que é incrementado sempre que a linha é atualizada. Em um Update comando ou Delete , a Where cláusula inclui o valor original da coluna de controle (a versão original da linha). Se a linha que está sendo atualizada tiver sido alterada por outro usuário, o valor na rowversion coluna será diferente do valor original, portanto, a Update instrução or Delete não poderá encontrar a linha a ser atualizada por causa da Where cláusula. Quando o Entity Framework descobre que nenhuma linha foi atualizada pelo Update comando or Delete (ou seja, quando o número de linhas afetadas é zero), ele interpreta isso como um conflito de simultaneidade.

  • Configure o Entity Framework para incluir os valores originais de cada coluna na tabela na Where cláusula de Update e Delete comandos.

    Como na primeira opção, se algo na linha tiver sido alterado desde que a linha foi lida pela primeira vez, a cláusula não retornará uma linha para atualizar, o Where que o Entity Framework interpreta como um conflito de simultaneidade. Para tabelas de banco de dados que têm muitas colunas, essa abordagem pode resultar em cláusulas muito grandes Where e pode exigir que você mantenha grandes quantidades de estado. Conforme observado anteriormente, manter grandes quantidades de estado pode afetar o desempenho do aplicativo. Portanto, essa abordagem geralmente não é recomendada e não é o método usado neste tutorial.

    Se você quiser implementar essa abordagem de simultaneidade, precisará marcar todas as propriedades que não são de chave primária na entidade para a qual deseja rastrear a simultaneidade adicionando o atributo ConcurrencyCheck a elas. Essa alteração permite que o Entity Framework inclua todas as colunas na cláusula SQL WHERE de UPDATE instruções.

No restante deste tutorial, você adicionará uma propriedade de acompanhamento rowversion à Department entidade, criará um controlador e exibições e testará para verificar se tudo funciona corretamente.

Adicionar simultaneidade otimista

Em Modelos\Department.cs, adicione uma propriedade de rastreamento chamada RowVersion:

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

    [Display(Name = "Administrator")]
    public int? InstructorID { get; set; }

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

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

O atributo Timestamp especifica que essa coluna será incluída na cláusula e Update Delete nos Where comandos enviados ao banco de dados. O atributo é chamado de carimbo de data/hora porque as versões anteriores do SQL Server usavam um tipo de dados de carimbo de data/hora SQL antes que a versão de linha do SQL o substituísse. O tipo .Net para rowversion é uma matriz de bytes.

Se preferir usar a API fluente, você poderá usar o método IsConcurrencyToken para especificar a propriedade de rastreamento, conforme mostrado no exemplo a seguir:

modelBuilder.Entity<Department>()
    .Property(p => p.RowVersion).IsConcurrencyToken();

Adicionando uma propriedade, você alterou o modelo de banco de dados e, portanto, precisa fazer outra migração. No PMC (Console do Gerenciador de Pacotes), Insira os seguintes comandos:

Add-Migration RowVersion
Update-Database

Modificar controlador de departamento

Em Controladores\DepartmentController.cs, adicione uma using instrução:

using System.Data.Entity.Infrastructure;

No arquivo DepartmentController.cs, altere todas as quatro ocorrências de "Sobrenome" para "FullName" para que as listas suspensas do administrador do departamento contenham o nome completo do instrutor em vez de apenas o sobrenome.

ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName");

Substitua o código existente para o HttpPost Edit método pelo seguinte código:

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Edit(int? id, byte[] rowVersion)
{
    string[] fieldsToBind = new string[] { "Name", "Budget", "StartDate", "InstructorID", "RowVersion" };

    if (id == null)
    {
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
    }

    var departmentToUpdate = await db.Departments.FindAsync(id);
    if (departmentToUpdate == null)
    {
        Department deletedDepartment = new Department();
        TryUpdateModel(deletedDepartment, fieldsToBind);
        ModelState.AddModelError(string.Empty,
            "Unable to save changes. The department was deleted by another user.");
        ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName", deletedDepartment.InstructorID);
        return View(deletedDepartment);
    }

    if (TryUpdateModel(departmentToUpdate, fieldsToBind))
    {
        try
        {
            db.Entry(departmentToUpdate).OriginalValues["RowVersion"] = rowVersion;
            await db.SaveChangesAsync();

            return RedirectToAction("Index");
        }
        catch (DbUpdateConcurrencyException ex)
        {
            var entry = ex.Entries.Single();
            var clientValues = (Department)entry.Entity;
            var databaseEntry = entry.GetDatabaseValues();
            if (databaseEntry == null)
            {
                ModelState.AddModelError(string.Empty,
                    "Unable to save changes. The department was deleted by another user.");
            }
            else
            {
                var databaseValues = (Department)databaseEntry.ToObject();

                if (databaseValues.Name != clientValues.Name)
                    ModelState.AddModelError("Name", "Current value: "
                        + databaseValues.Name);
                if (databaseValues.Budget != clientValues.Budget)
                    ModelState.AddModelError("Budget", "Current value: "
                        + String.Format("{0:c}", databaseValues.Budget));
                if (databaseValues.StartDate != clientValues.StartDate)
                    ModelState.AddModelError("StartDate", "Current value: "
                        + String.Format("{0:d}", databaseValues.StartDate));
                if (databaseValues.InstructorID != clientValues.InstructorID)
                    ModelState.AddModelError("InstructorID", "Current value: "
                        + db.Instructors.Find(databaseValues.InstructorID).FullName);
                ModelState.AddModelError(string.Empty, "The record you attempted to edit "
                    + "was modified by another user after you got the original value. 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. Otherwise click the Back to List hyperlink.");
                departmentToUpdate.RowVersion = databaseValues.RowVersion;
            }
        }
        catch (RetryLimitExceededException /* dex */)
        {
            //Log the error (uncomment dex variable name and add a line here to write a log.)
            ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists, see your system administrator.");
        }
    }
    ViewBag.InstructorID = new SelectList(db.Instructors, "ID", "FullName", departmentToUpdate.InstructorID);
    return View(departmentToUpdate);
}

Se o método FindAsync retornar nulo, isso indicará que o departamento foi excluído por outro usuário. O código mostrado usa os valores de formulário postados para criar uma entidade de departamento para que a página Editar possa ser exibida novamente com uma mensagem de erro. Como alternativa, você não precisará recriar a entidade de departamento se exibir apenas uma mensagem de erro sem exibir novamente os campos de departamento.

A exibição armazena o valor original RowVersion em um campo oculto e o método o recebe no rowVersion parâmetro. Antes de chamar SaveChanges, você precisa colocar isso no valor da propriedade RowVersion original na coleção OriginalValues da entidade. Em seguida, quando o Entity Framework criar um comando SQL UPDATE , esse comando incluirá uma WHERE cláusula que procura uma linha que tenha o valor original RowVersion .

Se nenhuma linha for afetada UPDATE pelo comando (nenhuma linha tem o valor original RowVersion ), o Entity Framework gerará uma DbUpdateConcurrencyException exceção e o catch código no bloco obterá a entidade afetada Department do objeto de exceção.

var entry = ex.Entries.Single();

Esse objeto tem os novos valores inseridos pelo usuário em sua Entity propriedade, e você pode obter os valores lidos do banco de dados chamando o GetDatabaseValues método.

var clientValues = (Department)entry.Entity;
var databaseEntry = entry.GetDatabaseValues();

O GetDatabaseValues método retorna null se alguém tiver excluído a linha do banco de dados; caso contrário, você terá que converter o objeto retornado na Department classe para acessar as Department propriedades. (Como você já verificou a exclusão, databaseEntry seria nulo apenas se o departamento fosse excluído após FindAsync as execuções e antes SaveChanges das execuções.)

if (databaseEntry == null)
{
    ModelState.AddModelError(string.Empty,
        "Unable to save changes. The department was deleted by another user.");
}
else
{
    var databaseValues = (Department)databaseEntry.ToObject();

Em seguida, o código adiciona uma mensagem de erro personalizada para cada coluna que tem valores de banco de dados diferentes dos que o usuário inseriu na página Editar:

if (databaseValues.Name != currentValues.Name)
    ModelState.AddModelError("Name", "Current value: " + databaseValues.Name);
    // ...

Uma mensagem de erro mais longa explica o que aconteceu e o que fazer a respeito:

ModelState.AddModelError(string.Empty, "The record you attempted to edit "
    + "was modified by another user after you got the original value. 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. Otherwise click the Back to List hyperlink.");

Por fim, o código define o RowVersion valor do Department objeto como o novo valor recuperado do banco de dados. Esse novo valor RowVersion será armazenado no campo oculto quando a página Editar for exibida novamente, e na próxima vez que o usuário clicar em Salvar, somente os erros de simultaneidade que ocorrem desde a nova exibição da página Editar serão capturados.

Em Views\Department\Edit.cshtml, adicione um campo oculto para salvar o valor da RowVersion propriedade, imediatamente após o campo oculto da DepartmentID propriedade:

@model ContosoUniversity.Models.Department

@{
    ViewBag.Title = "Edit";
}

<h2>Edit</h2>

@using (Html.BeginForm())
{
    @Html.AntiForgeryToken()
    
    <div class="form-horizontal">
        <h4>Department</h4>
        <hr />
        @Html.ValidationSummary(true)
        @Html.HiddenFor(model => model.DepartmentID)
        @Html.HiddenFor(model => model.RowVersion)

Testar o tratamento de simultaneidade

Execute o site e clique em Departamentos.

Clique com o botão direito do mouse no hiperlink Editar do departamento de inglês e selecione Abrir em uma nova guia e, em seguida, clique no hiperlink Editar do departamento de inglês. As duas guias exibem as mesmas informações.

Altere um campo na primeira guia do navegador e clique em Salvar.

O navegador mostra a página Índice com o valor alterado.

Altere um campo na segunda guia do navegador e clique em Salvar. Você verá uma mensagem de erro:

A captura de tela mostra a página Editar com uma mensagem que explica que a operação foi cancelada porque o valor foi alterado por outro usuário.

Clique em Salvar novamente. O valor inserido na segunda guia do navegador é salvo junto com o valor original dos dados alterados no primeiro navegador. Você verá os valores salvos quando a página Índice for exibida.

Atualizar a página Excluir

Para a página Excluir, o Entity Framework detecta conflitos de simultaneidade causados pela edição por outra pessoa do departamento de maneira semelhante. Quando o HttpGet Delete método exibe a exibição de confirmação, a exibição inclui o valor original RowVersion em um campo oculto. Esse valor fica disponível para o HttpPost Delete método chamado quando o usuário confirma a exclusão. Quando o Entity Framework cria o comando SQL DELETE , ele inclui uma WHERE cláusula com o valor original RowVersion . Se o comando resultar em zero linhas afetadas (o que significa que a linha foi alterada depois que a página de confirmação Excluir foi exibida), uma exceção de simultaneidade será lançada e o HttpGet Delete método será chamado com um sinalizador de erro definido como true para exibir novamente a página de confirmação com uma mensagem de erro. Também é possível que zero linhas tenham sido afetadas porque a linha foi excluída por outro usuário, portanto, nesse caso, uma mensagem de erro diferente é exibida.

Em DepartmentController.cs, substitua o HttpGet Delete método pelo seguinte código:

public async Task<ActionResult> Delete(int? id, bool? concurrencyError)
{
    if (id == null)
    {
        return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
    }
    Department department = await db.Departments.FindAsync(id);
    if (department == null)
    {
        if (concurrencyError.GetValueOrDefault())
        {
            return RedirectToAction("Index");
        }
        return HttpNotFound();
    }

    if (concurrencyError.GetValueOrDefault())
    {
        ViewBag.ConcurrencyErrorMessage = "The record you attempted to delete "
            + "was modified by another user after you got the original values. "
            + "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. Otherwise "
            + "click the Back to List hyperlink.";
    }

    return View(department);
}

O método aceita um parâmetro opcional que indica se a página está sendo exibida novamente após um erro de simultaneidade. Se esse sinalizador for true, uma mensagem de erro será enviada para a exibição usando uma ViewBag propriedade.

Substitua o HttpPost Delete código no método (chamado DeleteConfirmed) pelo seguinte código:

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Delete(Department department)
{
    try
    {
        db.Entry(department).State = EntityState.Deleted;
        await db.SaveChangesAsync();
        return RedirectToAction("Index");
    }
    catch (DbUpdateConcurrencyException)
    {
        return RedirectToAction("Delete", new { concurrencyError = true, id=department.DepartmentID });
    }
    catch (DataException /* dex */)
    {
        //Log the error (uncomment dex variable name after DataException and add a line here to write a log.
        ModelState.AddModelError(string.Empty, "Unable to delete. Try again, and if the problem persists contact your system administrator.");
        return View(department);
    }
}

No código gerado por scaffolding que acabou de ser substituído, esse método aceitou apenas uma ID de registro:

public async Task<ActionResult> DeleteConfirmed(int id)

Você alterou esse parâmetro para uma instância da entidade Department criada pelo associador de modelos. Isso lhe dá acesso ao valor da propriedade, RowVersion além da chave de registro.

public async Task<ActionResult> Delete(Department department)

Você também alterou o nome do método de ação de DeleteConfirmed para Delete. O código scaffolded nomeou o HttpPost Delete método DeleteConfirmed para dar ao HttpPost método uma assinatura exclusiva. (O CLR requer que os métodos sobrecarregados tenham parâmetros de método diferentes.) Agora que as assinaturas são exclusivas, você pode manter a convenção MVC e usar o mesmo nome para os HttpPost métodos e HttpGet delete.

Se um erro de simultaneidade é capturado, o código exibe novamente a página Confirmação de exclusão e fornece um sinalizador que indica que ela deve exibir uma mensagem de erro de simultaneidade.

Em Views\Department\Delete.cshtml, substitua o código scaffolded pelo código a seguir que adiciona um campo de mensagem de erro e campos ocultos para as propriedades DepartmentID e RowVersion. As alterações são realçadas.

@model ContosoUniversity.Models.Department

@{
    ViewBag.Title = "Delete";
}

<h2>Delete</h2>

<p class="error">@ViewBag.ConcurrencyErrorMessage</p>

<h3>Are you sure you want to delete this?</h3>
<div>
    <h4>Department</h4>
    <hr />
    <dl class="dl-horizontal">
        <dt>
            Administrator
        </dt>

        <dd>
            @Html.DisplayFor(model => model.Administrator.FullName)
        </dd>

        <dt>
            @Html.DisplayNameFor(model => model.Name)
        </dt>

        <dd>
            @Html.DisplayFor(model => model.Name)
        </dd>

        <dt>
            @Html.DisplayNameFor(model => model.Budget)
        </dt>

        <dd>
            @Html.DisplayFor(model => model.Budget)
        </dd>

        <dt>
            @Html.DisplayNameFor(model => model.StartDate)
        </dt>

        <dd>
            @Html.DisplayFor(model => model.StartDate)
        </dd>

    </dl>

    @using (Html.BeginForm()) {
        @Html.AntiForgeryToken()
        @Html.HiddenFor(model => model.DepartmentID)
        @Html.HiddenFor(model => model.RowVersion)

        <div class="form-actions no-color">
            <input type="submit" value="Delete" class="btn btn-default" /> |
            @Html.ActionLink("Back to List", "Index")
        </div>
    }
</div>

Este código adiciona uma mensagem de erro entre os h2 títulos e h3 :

<p class="error">@ViewBag.ConcurrencyErrorMessage</p>

Ele substitui LastName por FullName Administrator no campo:

<dt>
  Administrator
</dt>
<dd>
  @Html.DisplayFor(model => model.Administrator.FullName)
</dd>

Por fim, ele adiciona campos ocultos para as DepartmentID propriedades e RowVersion após a Html.BeginForm instrução:

@Html.HiddenFor(model => model.DepartmentID)
@Html.HiddenFor(model => model.RowVersion)

Execute a página Índice de departamentos. Clique com o botão direito do mouse no hiperlink Excluir do departamento de inglês e selecione Abrir em uma nova guia e, na primeira guia, clique no hiperlink Editar do departamento de inglês.

Na primeira janela, altere um dos valores e clique em Salvar.

A página Índice confirma a alteração.

Na segunda guia, clique em Excluir.

Você verá a mensagem de erro de simultaneidade e os valores de Departamento serão atualizados com o que está atualmente no banco de dados.

Department_Delete_confirmation_page_with_concurrency_error

Se você clicar em Excluir novamente, será redirecionado para a página Índice, que mostra que o departamento foi excluído.

Obter o código

Download do projeto concluído

Recursos adicionais

Links para outros recursos do Entity Framework podem ser encontrados no ASP.NET Acesso a Dados – Recursos Recomendados.

Para obter informações sobre outras maneiras de lidar com vários cenários de simultaneidade, consulte Padrões de simultaneidade otimista e Trabalhando com valores de propriedade no MSDN. O próximo tutorial mostra como implementar a herança de tabela por hierarquia para as Instructor entidades e Student .

Próximas etapas

Neste tutorial, você:

  • Aprendeu sobre conflitos de simultaneidade
  • Adicionada simultaneidade otimista
  • Controlador de departamento modificado
  • Tratamento de simultaneidade testado
  • Atualizou a página Excluir

Avance para o próximo artigo para saber como implementar a herança no modelo de dados.