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

Nota

Questa non è la versione più recente di questo articolo. Per la versione corrente, vedere la versione .NET 8 di questo articolo.

Importante

Queste informazioni si riferiscono a un prodotto non definitive che può essere modificato in modo sostanziale prima che venga rilasciato commercialmente. Microsoft non riconosce alcuna garanzia, espressa o implicita, in merito alle informazioni qui fornite.

Per la versione corrente, vedere la versione .NET 8 di questo articolo.

Questo articolo illustra come usare Entity Framework Core (EF Core) nelle app lato Blazor server.

Il lato Blazor server è un framework di app con stato. L'app mantiene una connessione continua al server e lo stato dell'utente viene mantenuto nella memoria del server in un circuito. Un esempio di stato utente è costituito dai dati contenuti nelle istanze del servizio di inserimento delle dipendenze che hanno come ambito il circuito. Il modello di applicazione univoco che Blazor fornisce richiede un approccio speciale per l'uso di Entity Framework Core.

Nota

Questo articolo riguarda le EF Core app sul lato Blazor server. Blazor WebAssembly le app vengono eseguite in una sandbox WebAssembly che impedisce la maggior parte delle connessioni di database dirette. L'esecuzione EF Core in Blazor WebAssembly non rientra nell'ambito di questo articolo.

Queste linee guida si applicano ai componenti che adottano il rendering lato server interattivo (SSR interattivo) in un'app Blazor Web.

Queste indicazioni si applicano al Server progetto di una soluzione ospitata Blazor WebAssembly o di un'app Blazor Server .

Esempio di app

L'app di esempio è stata compilata come riferimento per le app lato Blazor server che usano EF Core. L'app di esempio include una griglia con operazioni di ordinamento e filtro, eliminazione, aggiunta e aggiornamento. L'esempio illustra l'uso di EF Core per gestire la concorrenza ottimistica.

Visualizzare o scaricare il codice di esempio (procedura per il download): selezionare la cartella corrispondente alla versione di .NET che si sta adottando. All'interno della cartella della versione accedere all'esempio denominato BlazorWebAppEFCore.

Visualizzare o scaricare il codice di esempio (procedura per il download): selezionare la cartella corrispondente alla versione di .NET che si sta adottando. All'interno della cartella della versione accedere all'esempio denominato BlazorServerEFCoreSample.

L'esempio usa un database SQLite locale in modo che possa essere usato in qualsiasi piattaforma. L'esempio configura anche la registrazione del database per visualizzare le query SQL generate. Questa operazione è configurata in 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"
    }
  }
}

La griglia, l'aggiunta e la visualizzazione dei componenti usano il modello "context-per-operation", in cui viene creato un contesto per ogni operazione. Il componente di modifica usa il modello "context-per-component", in cui viene creato un contesto per ogni componente.

Nota

Alcuni degli esempi di codice in questo argomento richiedono spazi dei nomi e servizi non visualizzati. Per esaminare il codice completamente funzionante, incluse le direttive e obbligatorie @using per Razor esempi, vedere l'app @inject di esempio.

Accesso al database

EF Coresi basa su come DbContext mezzo per configurare l'accesso al database e fungere da unità di lavoro. EF Core fornisce l'estensione AddDbContext per ASP.NET app Core che registra il contesto come servizio con ambito per impostazione predefinita. Nelle app lato Blazor server, le registrazioni del servizio con ambito possono essere problematiche perché l'istanza viene condivisa tra i componenti all'interno del circuito dell'utente. DbContext non è thread-safe e non è progettato per l'uso simultaneo. Le durate esistenti non sono appropriate per questi motivi:

  • Singleton condivide lo stato in tutti gli utenti dell'app e porta a un uso simultaneo inappropriato.
  • L'ambito (impostazione predefinita) pone un problema simile tra i componenti per lo stesso utente.
  • I risultati temporanei in una nuova istanza per ogni richiesta, ma poiché i componenti possono essere di lunga durata, ciò comporta un contesto di durata superiore a quello previsto.

Le raccomandazioni seguenti sono progettate per offrire un approccio coerente all'uso EF Core nelle app lato Blazor server.

  • Per impostazione predefinita, è consigliabile usare un contesto per ogni operazione. Il contesto è progettato per la creazione di istanze a sovraccarico rapido e basso:

    using var context = new MyContext();
    
    return await context.MyEntities.ToListAsync();
    
  • Usare un flag per impedire più operazioni simultanee:

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

    Posizionare le operazioni dopo la Loading = true; riga nel try blocco.

    La logica di caricamento non richiede il blocco dei record del database perché thread safety non è un problema. La logica di caricamento viene usata per disabilitare i controlli dell'interfaccia utente in modo che gli utenti non selezionino inavvertitamente pulsanti o aggiornino i campi durante il recupero dei dati.

  • Se è possibile che più thread possano accedere allo stesso blocco di codice, inserire una factory e creare una nuova istanza per ogni operazione. In caso contrario, l'inserimento e l'uso del contesto sono in genere sufficienti.

  • Per le operazioni di lunga durata che sfruttano EF Coreil rilevamento delle modifiche o il controllo della concorrenza, definire l'ambito del contesto per la durata del componente.

Nuove DbContext istanze

Il modo più rapido per creare una nuova DbContext istanza consiste nell'usare new per creare una nuova istanza. Esistono tuttavia scenari che richiedono la risoluzione di dipendenze aggiuntive:

L'approccio consigliato per creare un nuovo DbContext con le dipendenze consiste nell'usare una factory. EF Core 5.0 o versione successiva offre una factory predefinita per la creazione di nuovi contesti.

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

Nella factory precedente:

L'esempio seguente configura SQLite e abilita la registrazione dei dati. Il codice usa un metodo di estensione (AddDbContextFactory) per configurare la factory di database per l'inserimento delle dipendenze e fornire le opzioni predefinite:

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 factory viene inserita nei componenti e usata per creare nuove DbContext istanze.

Nella home page dell'app di esempio viene IDbContextFactory<ContactContext> inserito nel componente:

@inject IDbContextFactory<ContactContext> DbFactory

Un DbContext oggetto viene creato usando la factory (DbFactory) per eliminare un contatto nel DeleteContactAsync metodo :

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 è un oggetto inserito IContactFiltersed Wrapper è un riferimento al GridWrapper componente. Vedere il Home componente (Components/Pages/Home.razor) nell'app di esempio.

Nota

Filters è un oggetto inserito IContactFiltersed Wrapper è un riferimento al GridWrapper componente. Vedere il Index componente (Pages/Index.razor) nell'app di esempio.

Ambito della durata del componente

È possibile creare un oggetto DbContext esistente per la durata di un componente. In questo modo è possibile usarlo come unità di lavoro e sfruttare le funzionalità predefinite, ad esempio il rilevamento delle modifiche e la risoluzione della concorrenza.

È possibile usare la factory per creare un contesto e monitorarlo per la durata del componente. Prima di tutto, implementare IDisposable e inserire la factory come illustrato nel EditContact componente (Components/Pages/EditContact.razor):

È possibile usare la factory per creare un contesto e monitorarlo per la durata del componente. Prima di tutto, implementare IDisposable e inserire la factory come illustrato nel EditContact componente (Pages/EditContact.razor):

@implements IDisposable
@inject IDbContextFactory<ContactContext> DbFactory

L'app di esempio garantisce che il contesto venga eliminato quando il componente viene eliminato:

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

OnInitializedAsync Viene infine eseguito l'override per creare un nuovo contesto. Nell'app di esempio carica OnInitializedAsync il contatto nello stesso metodo:

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

Nell'esempio precedente:

  • Quando Busy è impostato su true, le operazioni asincrone possono iniziare. Quando Busy viene impostato di nuovo su false, le operazioni asincrone devono essere completate.
  • Inserire una logica aggiuntiva di gestione degli errori in un catch blocco.

Abilitare la registrazione dei dati sensibili

EnableSensitiveDataLogging include i dati dell'applicazione nei messaggi di eccezione e nella registrazione del framework. I dati registrati possono includere i valori assegnati alle proprietà delle istanze di entità e i valori dei parametri per i comandi inviati al database. La registrazione dei dati con EnableSensitiveDataLogging è un rischio per la sicurezza, in quanto può esporre password e altre informazioni personali (PII) quando registra le istruzioni SQL eseguite sul database.

È consigliabile abilitare EnableSensitiveDataLogging solo per lo sviluppo e il test:

#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

Risorse aggiuntive