Blazor de ASP.NET Core con Entity Framework Core (EF Core)

Nota

Esta no es la versión más reciente de este artículo. Para la versión actual, consulte la versión .NET 8 de este artículo.

Importante

Esta información hace referencia a un producto en versión preliminar, el cual puede sufrir importantes modificaciones antes de que se publique la versión comercial. Microsoft no proporciona ninguna garantía, expresa o implícita, con respecto a la información proporcionada aquí.

Para la versión actual, consulte la versión .NET 8 de este artículo.

Este artículo explica cómo usar Entity Framework Core (EF Core) en aplicaciones Blazor del lado del servidor.

El Blazor del lado servidor es un marco para aplicaciones con estado. La aplicación mantiene una conexión continua con el servidor, y el estado del usuario se mantiene en la memoria del servidor en un circuito. Un ejemplo de estado del usuario son los datos contenidos en las instancias de servicio de la inserción de dependencias (DI) que se encuentran en el ámbito del circuito. El modelo de aplicación único que proporciona Blazor requiere un enfoque especial para usar Entity Framework Core.

Nota:

Este artículo aborda EF Core en aplicaciones Blazor del lado del servidor. Las aplicaciones Blazor WebAssembly se ejecutan en un espacio aislado de WebAssembly que evita la mayoría de conexiones de base de datos directas. La ejecución de EF Core en Blazor WebAssembly supera el ámbito de este artículo.

Esta guía se aplica a los componentes que adoptan la representación interactiva del lado servidor (SSR interactivo) en una aplicación web Blazor.

Esta guía se aplica al proyecto Server de una solución hospedada Blazor WebAssembly o a una aplicación Blazor Server.

Aplicación de ejemplo

La aplicación de ejemplo fue compilada como referencia para aplicaciones Blazor del lado del servidor que usan EF Core. La aplicación de ejemplo incluye una cuadrícula con operaciones de ordenación y filtrado, eliminación, adición y actualización. En el ejemplo se muestra el uso de EF Core para controlar la simultaneidad optimista.

Ver o descargar código de ejemplo (cómo descargar): seleccione la carpeta que coincida con la versión de .NET que está adoptando. Dentro de la carpeta de versión, acceda al ejemplo denominado BlazorWebAppEFCore.

Ver o descargar código de ejemplo (cómo descargar): seleccione la carpeta que coincida con la versión de .NET que está adoptando. Dentro de la carpeta de versión, acceda al ejemplo denominado BlazorServerEFCoreSample.

En el ejemplo se usa una base de datos SQLite local para que se pueda utilizar en cualquier plataforma. En el ejemplo también se configura el registro de base de datos para mostrar las consultas SQL que se generan. Esto se configura en 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"
    }
  }
}

Los componentes de cuadrícula, adición y vista usan el patrón de "contexto por operación", en el que se crea un contexto para cada operación. El componente de edición usa el patrón de "contexto por componente", en el que se crea un contexto para cada componente.

Nota

En algunos de los ejemplos de código de este tema se necesitan espacios de nombres y servicios que no se muestran. Si desea inspeccionar el código totalmente operativo, como las directivas obligatorias @using e @inject para ejemplos de Razor, vea la aplicación de muestra.

Acceso a la base de datos

EF Core se basa en un objeto DbContext como medio para configurar el acceso a la base de datos y actuar como una unidad de trabajo. EF Core proporciona la extensión AddDbContext para las aplicaciones de ASP.NET Core, que registra el contexto como un servicio con ámbito de forma predeterminada. En las aplicaciones Blazor del lado del servidor, los registros con ámbito de servicio pueden ser problemáticos, ya que la instancia se comparte entre los componentes del circuito del usuario. DbContext no es seguro para subprocesos y no está diseñado para su uso simultáneo. Las duraciones existentes no son adecuadas por estos motivos:

  • Singleton comparte el estado entre todos los usuarios de la aplicación y lleva a un uso simultáneo inadecuado.
  • Con ámbito (el valor predeterminado) supone un problema similar entre los componentes para el mismo usuario.
  • Transitorio genera una nueva instancia por solicitud, pero como los componentes pueden ser de larga duración, esto da lugar a un contexto que dura más de lo previsto.

Las recomendaciones siguientes están diseñadas para proporcionar un enfoque coherente al uso de EF Core en aplicaciones Blazor del lado del servidor.

  • De forma predeterminada, considere la posibilidad de usar un contexto por operación. El contexto está diseñado para la creación de instancias rápidas de baja sobrecarga:

    using var context = new MyContext();
    
    return await context.MyEntities.ToListAsync();
    
  • Use una marca para evitar varias operaciones simultáneas:

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

    Coloque las operaciones después de la línea Loading = true; en el bloque try.

    La lógica de carga no requiere bloquear registros de base de datos porque la seguridad para subprocesos no es una preocupación. La lógica de carga se usa para deshabilitar los controles de interfaz de usuario para que los usuarios no seleccionen accidentalmente botones ni actualicen campos mientras se capturan los datos.

  • Si hay alguna posibilidad de que varios subprocesos tengan acceso al mismo bloque de código, inserte un generador y realice una nueva instancia por operación. De lo contrario, la inserción y el uso del contexto suelen ser suficientes.

  • En el caso de las operaciones de larga duración que aprovechan el seguimiento de cambios o el control de simultaneidad de EF Core, el ámbito del contexto debe ser la duración del componente.

Nuevas instancias de DbContext

La manera más rápida de crear una instancia de DbContext consiste en usar new. Sin embargo, hay escenarios que requieren que se resuelvan dependencias adicionales:

El enfoque recomendado para crear un nuevo DbContext con dependencias es usar una fábrica. En EF Core 5.0 o posterior se proporciona un generador integrado para crear 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);
    }
}

En el generador anterior:

En el ejemplo siguiente se configura SQLite y se habilita el registro de datos. El código usa un método de extensión (AddDbContextFactory) para configurar el generador de bases de datos para la inserción de dependencias y proporcionar opciones predeterminadas:

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

La fábrica se inserta en los componentes y se usa para crear instancias de DbContext.

En la página principal de la aplicación de ejemplo, IDbContextFactory<ContactContext> se inserta en el componente:

@inject IDbContextFactory<ContactContext> DbFactory

Se crea DbContext mediante la fábrica (DbFactory) para eliminar un contacto en el método DeleteContactAsync:

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

Nota:

Filters es un elemento IContactFilters insertado y Wrapper es una referencia de componente al componente GridWrapper. Consulte el componente Home (Components/Pages/Home.razor) en la aplicación de ejemplo.

Nota:

Filters es un elemento IContactFilters insertado y Wrapper es una referencia de componente al componente GridWrapper. Consulte el componente Index (Pages/Index.razor) en la aplicación de ejemplo.

Ámbito de la duración del componente

Es posible que quiera crear un objeto DbContext que exista mientras dure un componente. Esto le permite usarlo como una unidad de trabajo y aprovechar características integradas como el seguimiento de cambios y la resolución de simultaneidad.

Puede usar el generador para crear un contexto y realizar su seguimiento mientras dure el componente. En primer lugar, implemente IDisposable e inserte la fábrica como se muestra en el componente EditContact (Components/Pages/EditContact.razor):

Puede usar el generador para crear un contexto y realizar su seguimiento mientras dure el componente. En primer lugar, implemente IDisposable e inserte la fábrica como se muestra en el componente EditContact (Pages/EditContact.razor):

@implements IDisposable
@inject IDbContextFactory<ContactContext> DbFactory

La aplicación de ejemplo garantiza que el contexto se desecha cuando se desecha el componente:

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 último, se invalida OnInitializedAsync para crear un contexto. En la aplicación de ejemplo, OnInitializedAsync carga el contacto en el mismo 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();
}

En el ejemplo anterior:

  • Cuando Busy se establece en true, pueden comenzar las operaciones asincrónicas. Cuando Busy se vuelve a establecer en false, las operaciones asincrónicas deben finalizar.
  • Coloque la lógica de control de errores adicional en un bloque catch.

Habilitar el registro de datos confidenciales

EnableSensitiveDataLogging incluye datos de la aplicación en los mensajes de excepción y en la plataforma de registro. Los datos registrados pueden incluir los valores asignados a las propiedades de las instancias de entidad y los valores de parámetro para los comandos enviados a la base de datos. El registro de datos con EnableSensitiveDataLogging es un riesgo de seguridad, ya que puede exponer contraseñas y otra Información de identificación personal (PII) cuando registra instrucciones SQL ejecutadas en la base de datos.

Se recomienda habilitar EnableSensitiveDataLogging solo para desarrollo y pruebas:

#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 adicionales