ASP.NET Core Blazor com o Entity Framework Core (EF Core)

Observação

Esta não é a versão mais recente deste artigo. Para informações sobre a versão vigente, confira a Versão do .NET 8 deste artigo.

Importante

Essas informações relacionam-se ao produto de pré-lançamento, que poderá ser substancialmente modificado antes do lançamento comercial. A Microsoft não oferece nenhuma garantia, explícita ou implícita, quanto às informações fornecidas aqui.

Para informações sobre a versão vigente, confira a Versão do .NET 8 deste artigo.

Este artigo explica como usar o Entity Framework Core (EF Core) em aplicativos Blazor do servidor.

O lado do servidor Blazor é uma estrutura do aplicativo com estado. O aplicativo mantém uma conexão contínua com o servidor e o estado do usuário é mantido na memória do servidor em um circuito. Um exemplo do estado do usuário são os dados retidos nas instâncias de serviço de DI (injeção de dependência) que têm como escopo o circuito. O modelo de aplicativo exclusivo que Blazor fornece/requer uma abordagem especial para o uso do Entity Framework Core.

Observação

Este artigo aborda EF Core em aplicativos Blazor do servidor. Blazor WebAssembly os aplicativos são executados em uma área restrita WebAssembly que impede a maioria das conexões de banco de dados diretas. A execução de EF Core em Blazor WebAssembly não faz parte do escopo deste artigo.

Essas diretrizes se aplicam a componentes que adotam a renderização interativa no lado do servidor (SSR interativa) em um aplicativo da Web Blazor.

Essas diretrizes se aplicam ao projeto Server de uma solução Blazor WebAssembly hospedada ou de um aplicativo Blazor Server.

Aplicativo de exemplo

O aplicativo de exemplo foi criado como referência para aplicativos Blazor do servidor que usam EF Core. O aplicativo de exemplo inclui uma grade com operações de classificação e filtragem, exclusão, adição e atualização. O exemplo demonstra o uso de EF Core para lidar com a simultaneidade otimista.

Exibir ou baixar o código de amostra (como baixar): selecione a pasta que corresponde à versão do .NET que você está adotando. Na pasta de versão, acesse a amostra chamada BlazorWebAppEFCore.

Exibir ou baixar o código de amostra (como baixar): selecione a pasta que corresponde à versão do .NET que você está adotando. Na pasta de versão, acesse a amostra chamada BlazorServerEFCoreSample.

O exemplo usa um banco de dados SQLite local para que possa ser usado em qualquer plataforma. O exemplo também configura o registro em log do banco de dados para mostrar as consultas SQL geradas. Isso é configurado em appsettings.Development.json:

{
  "DetailedErrors": true,
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.Hosting.Lifetime": "Information",
      "Microsoft.EntityFrameworkCore.Database.Command": "Information"
    }
  }
}
{
  "DetailedErrors": true,
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.Hosting.Lifetime": "Information",
      "Microsoft.EntityFrameworkCore.Database.Command": "Information"
    }
  }
}
{
  "DetailedErrors": true,
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.Hosting.Lifetime": "Information",
      "Microsoft.EntityFrameworkCore.Database.Command": "Information"
    }
  }
}
{
  "DetailedErrors": true,
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information",
      "Microsoft.EntityFrameworkCore.Database.Command": "Information"
    }
  }
}
{
  "DetailedErrors": true,
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information",
      "Microsoft.EntityFrameworkCore.Database.Command": "Information"
    }
  }
}

Os componentes de grade, adição e exibição usam o padrão "contexto por operação", no qual um contexto é criado para cada operação. O componente de edição usa o padrão "contexto por componente", em que um contexto é criado para cada componente.

Observação

Alguns dos exemplos de código neste tópico exigem namespaces e serviços que não são mostrados. Para inspecionar o código totalmente funcional, incluindo as diretivas @using e @inject necessárias para Razor a obtenção de exemplos, consulte o aplicativo de exemplo.

Acesso ao banco de dados

EF Core depende de um DbContext como meio de configuração do acesso ao banco de dados e atuar como uma unidade de trabalho. EF Corefornece a AddDbContext extensão para aplicativos ASP.NET Core que registram o contexto como um serviço com escopo por padrão. Em aplicativos Blazor do servidor, os registros de serviço com escopo podem ser problemáticos porque a instância é compartilhada entre componentes dentro do circuito do usuário. DbContext não é thread-safe e não foi projetado para uso simultâneo. Os tempos de vida existentes são inadequados pelos motivos a seguir:

  • Singleton compartilha o estado com todos os usuários do aplicativo e acarreta uso simultâneo inadequado.
  • Com escopo (o padrão) representa um problema semelhante entre componentes para o mesmo usuário.
  • Resultados transitórios em uma nova instância por solicitação, mas, a possibilidade de longa duração dos componentes resulta em um contexto de vida mais longa que o esperado.

As recomendações a seguir foram projetadas para fornecer uma abordagem consistente ao usar EF Core em aplicativos Blazor do servidor.

  • Por padrão, considere usar um único contexto por operação. O contexto foi projetado para instanciação rápida e de baixa sobrecarga:

    using var context = new MyContext();
    
    return await context.MyEntities.ToListAsync();
    
  • Use um sinalizador para impedir várias operações simultâneas:

    if (Loading)
    {
        return;
    }
    
    try
    {
        Loading = true;
    
        ...
    }
    finally
    {
        Loading = false;
    }
    

    Coloque as operações após a linha Loading = true;no bloco try.

    O carregamento da lógica não requer o bloqueio de registros de banco de dados, porque o acesso thread-safe não é uma preocupação. A lógica de carregamento é usada para desabilitar controles de interface do usuário para que os usuários não selecionem botões ou atualizem campos inadvertidamente durante a busca por dados.

  • Se houver alguma chance de que vários threads possam acessar o mesmo bloco de código, injete um alocador e crie uma nova instância por operação. Caso contrário, injetar e usar o contexto costuma ser suficiente.

  • Para operações de vida mais longa que aproveitam o acompanhamento de alterações EF Core's ou o controle de simultaneidade, crie o escopo do contexto para o tempo de vida do componente.

Novas instâncias DbContext

A maneira mais rápida de criar uma nova instância DbContext é usar new para criar uma nova instância. No entanto, há cenários que exigem a resolução de dependências adicionais:

A abordagem recomendada para criar um novo DbContext com dependências é o uso de um alocador. EF Core 5.0 ou posterior fornece um alocador interno para a criação de novos contextos.

using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace BlazorServerDbContextExample.Data
{
    public class DbContextFactory<TContext> 
        : IDbContextFactory<TContext> where TContext : DbContext
    {
        private readonly IServiceProvider provider;

        public DbContextFactory(IServiceProvider provider)
        {
            this.provider = provider ?? throw new ArgumentNullException(
                $"{nameof(provider)}: You must configure an instance of " +
                "IServiceProvider");
        }

        public TContext CreateDbContext() => 
            ActivatorUtilities.CreateInstance<TContext>(provider);
    }
}

No alocador anterior:

O exemplo a seguir configura o SQLite e habilita o registro de dados. O código usa um método de extensão (AddDbContextFactory) para configurar o alocador de banco de dados para DI (injeção de dependência) e fornecer opções padrão:

builder.Services.AddDbContextFactory<ContactContext>(opt =>
    opt.UseSqlite($"Data Source={nameof(ContactContext.ContactsDb)}.db"));
builder.Services.AddDbContextFactory<ContactContext>(opt =>
    opt.UseSqlite($"Data Source={nameof(ContactContext.ContactsDb)}.db"));
builder.Services.AddDbContextFactory<ContactContext>(opt =>
    opt.UseSqlite($"Data Source={nameof(ContactContext.ContactsDb)}.db"));
services.AddDbContextFactory<ContactContext>(opt =>
    opt.UseSqlite($"Data Source={nameof(ContactContext.ContactsDb)}.db"));
services.AddDbContextFactory<ContactContext>(opt =>
    opt.UseSqlite($"Data Source={nameof(ContactContext.ContactsDb)}.db"));

O alocador é injetado em componentes e usado para criar novas instâncias DbContext.

Na página inicial do aplicativo de exemplo, IDbContextFactory<ContactContext> é injetado no componente:

@inject IDbContextFactory<ContactContext> DbFactory

Um DbContext é criado usando o alocador (DbFactory) para excluir um contato no DeleteContactAsync método:

private async Task DeleteContactAsync()
{
    using var context = DbFactory.CreateDbContext();
    Filters.Loading = true;

    if (Wrapper is not null && context.Contacts is not null)
    {
        var contact = await context.Contacts
            .FirstAsync(c => c.Id == Wrapper.DeleteRequestId);

        if (contact is not null)
        {
            context.Contacts?.Remove(contact);
            await context.SaveChangesAsync();
        }
    }

    Filters.Loading = false;

    await ReloadAsync();
}
private async Task DeleteContactAsync()
{
    using var context = DbFactory.CreateDbContext();
    Filters.Loading = true;

    if (Wrapper is not null && context.Contacts is not null)
    {
        var contact = await context.Contacts
            .FirstAsync(c => c.Id == Wrapper.DeleteRequestId);

        if (contact is not null)
        {
            context.Contacts?.Remove(contact);
            await context.SaveChangesAsync();
        }
    }

    Filters.Loading = false;

    await ReloadAsync();
}
private async Task DeleteContactAsync()
{
    using var context = DbFactory.CreateDbContext();
    Filters.Loading = true;

    if (Wrapper is not null && context.Contacts is not null)
    {
        var contact = await context.Contacts
            .FirstAsync(c => c.Id == Wrapper.DeleteRequestId);

        if (contact is not null)
        {
            context.Contacts?.Remove(contact);
            await context.SaveChangesAsync();
        }
    }

    Filters.Loading = false;

    await ReloadAsync();
}
private async Task DeleteContactAsync()
{
    using var context = DbFactory.CreateDbContext();

    Filters.Loading = true;

    var contact = await context.Contacts.FirstAsync(
        c => c.Id == Wrapper.DeleteRequestId);

    if (contact != null)
    {
        context.Contacts.Remove(contact);
        await context.SaveChangesAsync();
    }

    Filters.Loading = false;

    await ReloadAsync();
}
private async Task DeleteContactAsync()
{
    using var context = DbFactory.CreateDbContext();

    Filters.Loading = true;

    var contact = await context.Contacts.FirstAsync(
        c => c.Id == Wrapper.DeleteRequestId);

    if (contact != null)
    {
        context.Contacts.Remove(contact);
        await context.SaveChangesAsync();
    }

    Filters.Loading = false;

    await ReloadAsync();
}

Observação

Filters é um IContactFilters injetado, e Wrapper é uma referência de componente para o componente GridWrapper. Consulte o Home componente (Components/Pages/Home.razor) no aplicativo de exemplo.

Observação

Filters é um IContactFilters injetado, e Wrapper é uma referência de componente para o componente GridWrapper. Consulte o Index componente (Pages/Index.razor) no aplicativo de exemplo.

Escopo para o tempo de vida do componente

Talvez seja desejado criar um DbContext que exista durante o tempo de vida de um componente. Isso permite que seja usado como uma unidade de trabalho e aproveitar os recursos internos, como o acompanhamento de alterações e a resolução de simultaneidade.

O alocador pode ser usado para criar um contexto e rastreá-lo durante o tempo de vida do componente. Primeiramente, implemente IDisposable e injete o alocador conforme mostrado no componente EditContact (Components/Pages/EditContact.razor):

O alocador pode ser usado para criar um contexto e rastreá-lo durante o tempo de vida do componente. Primeiramente, implemente IDisposable e injete o alocador conforme mostrado no componente EditContact (Pages/EditContact.razor):

@implements IDisposable
@inject IDbContextFactory<ContactContext> DbFactory

O aplicativo de exemplo garante que o contexto seja descartado quando o componente for descartado:

public void Dispose()
{
    Context?.Dispose();
}
public void Dispose()
{
    Context?.Dispose();
}
public void Dispose()
{
    Context?.Dispose();
}
public void Dispose()
{
    Context?.Dispose();
}
public void Dispose()
{
    Context?.Dispose();
}

Por fim, OnInitializedAsync é substituído para criar um novo contexto. No aplicativo de exemplo, OnInitializedAsync carrega o contato no mesmo método:

protected override async Task OnInitializedAsync()
{
    Busy = true;

    try
    {
        Context = DbFactory.CreateDbContext();

        if (Context is not null && Context.Contacts is not null)
        {
            var contact = await Context.Contacts.SingleOrDefaultAsync(c => c.Id == ContactId);

            if (contact is not null)
            {
                Contact = contact;
            }
        }
    }
    finally
    {
        Busy = false;
    }

    await base.OnInitializedAsync();
}
protected override async Task OnInitializedAsync()
{
    Busy = true;

    try
    {
        Context = DbFactory.CreateDbContext();

        if (Context is not null && Context.Contacts is not null)
        {
            var contact = await Context.Contacts.SingleOrDefaultAsync(c => c.Id == ContactId);

            if (contact is not null)
            {
                Contact = contact;
            }
        }
    }
    finally
    {
        Busy = false;
    }

    await base.OnInitializedAsync();
}
protected override async Task OnInitializedAsync()
{
    Busy = true;

    try
    {
        Context = DbFactory.CreateDbContext();

        if (Context is not null && Context.Contacts is not null)
        {
            var contact = await Context.Contacts.SingleOrDefaultAsync(c => c.Id == ContactId);

            if (contact is not null)
            {
                Contact = contact;
            }
        }
    }
    finally
    {
        Busy = false;
    }

    await base.OnInitializedAsync();
}
protected override async Task OnInitializedAsync()
{
    Busy = true;

    try
    {
        Context = DbFactory.CreateDbContext();
        Contact = await Context.Contacts
            .SingleOrDefaultAsync(c => c.Id == ContactId);
    }
    finally
    {
        Busy = false;
    }

    await base.OnInitializedAsync();
}
protected override async Task OnInitializedAsync()
{
    Busy = true;

    try
    {
        Context = DbFactory.CreateDbContext();
        Contact = await Context.Contacts
            .SingleOrDefaultAsync(c => c.Id == ContactId);
    }
    finally
    {
        Busy = false;
    }

    await base.OnInitializedAsync();
}

No exemplo anterior:

  • Quando Busy é definido como true, as operações assíncronas podem começar. Quando Busy é redefinido como false, as operações assíncronas devem ser finalizadas.
  • Coloque a lógica de tratamento de erro adicional em um bloco catch.

Habilite o registro em log de dados confidenciais

EnableSensitiveDataLogging inclui dados do aplicativo em mensagens de exceção e registro em log de estrutura. Os dados registrados podem incluir os valores atribuídos às propriedades de instâncias de entidade e de valores de parâmetro para comandos enviados ao banco de dados. O registro de dados com EnableSensitiveDataLogging é um risco de segurança, pois pode expor senhas e outras informações de identificação pessoal (PII) ao registrar instruções SQL executadas no banco de dados.

Recomenda-se habilitar EnableSensitiveDataLogging apenas para desenvolvimento e teste:

#if DEBUG
    services.AddDbContextFactory<ContactContext>(opt =>
        opt.UseSqlite($"Data Source={nameof(ContactContext.ContactsDb)}.db")
        .EnableSensitiveDataLogging());
#else
    services.AddDbContextFactory<ContactContext>(opt =>
        opt.UseSqlite($"Data Source={nameof(ContactContext.ContactsDb)}.db"));
#endif

Recursos adicionais