Saltar al contenido

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript... y lo que venga ;)

17 años online

el blog de José M. Aguilar

Inicio El autor Contactar

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web
ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript...

¡Microsoft MVP!
martes, 21 de abril de 2020
Blazor Es lógico pensar que muchas de las aplicaciones que construyamos con Blazor, sobre todo en el mundo empresarial, necesitarán acceder a bases de datos para obtener o almacenar información.

En caso de utilizar Blazor WebAssembly, ese acceso encajará conceptualmente con lo que venimos haciendo desde hace años al construir aplicaciones SPA: desde el lado cliente simplemente llamaremos a una API o endpoint que actuará como pasarela, por lo que todo el código de acceso a datos se encontrará en el servidor. Aunque existen algunas iniciativas para posibilitar que EF Core funcione directamente en el navegador con algunos proveedores, ciertamente no será el escenario habitual.

Sin embargo, si utilizamos Blazor Server, tendremos la sensación inicial de que es todo más sencillo. El código de eventos bindeados a la interfaz se ejecuta en servidor, por lo que a priori sólo tendríamos que replicar lo que solemos hacer con servicios o controladores: solicitar una instancia del contexto de datos al inyector de dependencias, y utilizarla para lograr nuestros propósitos.

Pero no, la cosa es algo más complicada que eso. De hecho, los que ya estéis creando aplicaciones Blazor Server con EF Core seguro que os habéis topado en algún momento con un error como el siguiente:
InvalidOperationException: A second operation started on this context before a previous operation 
completed. This is usually caused by different threads using the same instance of DbContext. For 
more information on how to avoid threading issues with DbContext, see 
https://go.microsoft.com/fwlink/?linkid=2097913.
Esta excepción se debe a que existen dos hilos de ejecución utilizando la misma instancia del contexto de datos al mismo tiempo, algo que Entity Framework no permite. Esto ocurrirá, por ejemplo, si tenéis en la misma página varios componentes que de forma concurrente intentan acceder a la base de datos al inicializarse.

¿Por qué ocurre esto?

Cómo reproducir el problema

Para enteder mejor lo que está pasando, veamos primero cómo reproducir el problema. Imaginemos que tenemos un contexto de datos FriendsContext que permite acceder a una colección de amigos almacenada en LocalDb, y que ha sido definido de la siguiente forma:
public class FriendsContext: DbContext
{
    public DbSet<Friend> Friends { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        if (!optionsBuilder.IsConfigured)
        {
            optionsBuilder.UseSqlServer(
                "data source=(Localdb)\\MSSQLLocaldb;Initial catalog=FriendsContext");
        }
        base.OnConfiguring(optionsBuilder);
    }

    protected override void OnModelCreating(ModelBuilder builder)
    {
        builder.Entity<Friend>().HasData(
            new Friend {Id = 1, Name = "John", Age = 29},
            new Friend {Id = 2, Name = "Lucy", Age =39},
            new Friend {Id = 3, Name = "Peter", Age = 19},
            new Friend {Id = 4, Name = "Mary", Age = 42},
            new Friend {Id = 5, Name = "Steve", Age = 32},
            new Friend {Id = 6, Name = "Bill", Age = 51}
        );
        base.OnModelCreating(builder);
    }
}

public class Friend
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int Age { get; set; }
}
Normalmente, este contexto de datos lo registraríamos en la clase Startup de nuestra aplicación como se muestra a continuación:
public void ConfigureServices(IServiceCollection services)
{
    ...
    services.AddDbContext<FriendsContext>(opt => {
        opt.UseSqlServer($" data source=(Localdb)\\MSSQLLocaldb;Initial catalog=FriendsContext");
    });

    // Ensure the database is created:
    // using var db = new FriendsContext();
    // db.Database.EnsureCreated();
}
De esta forma, FriendsContext será registrado con un lifetime scoped y estará disponible para todos aquellos componentes que lo requieran. Como veremos más adelante, aquí es justamente donde está el problema.

Ahora imaginemos que esta base de datos la utilizamos en un componente como el siguiente, llamado FriendList:
@* File: FriendList.razor *@
@using BlazorEfDemo.Data
@using Microsoft.EntityFrameworkCore
@inject FriendsContext Context

<h1>Friend List</h1>
@if (_friends == null)
{
    <p>Loading friends...</p>
}
else
{
    <ul>
        @foreach (var friend in _friends)
        {
            <li>@friend.Name is @friend.Age years old</li>
        }
    </ul>
}

@code {
    List<Friend> _friends = new List<Friend>();

    protected override async Task OnInitializedAsync()
    {
        _friends = await Context.Friends.ToListAsync();
    }
}
Y por último, creamos una página con un par de instancias del compontente FriendList como la siguiente:
@page "/"

<h1>Multiple friend list</h1>

<FriendList></FriendList>
<FriendList></FriendList>
Al ejecutar, veremos que el problema se produce justo en este momento. Dos instancias de un componente en cuya inicialización utilizamos el contexto de datos recibido por inyección de dependencias:

InvalidOperationException: A second operation started on this context before a previous operation completed. This is usually caused by different threads using the same instance of DbContext

Dado que el contexto de datos está registrado como scoped, ambos componentes reciben desde el inyector la misma instancia de FriendsContext, y al ser inicializadas de forma concurrente se produce la excepción.
Es importante recordar que el ámbito scoped en Blazor no va asociado a peticiones o acciones, sino a conexiones, lo que quiere decir que, en la práctica, una instancia con este lifefime vivirá mientras el usuario permanezca conectado.
Otro escenario en el que podría producirse es si por ejemplo tenemos un botón bindeado a un método desde el cual utilizamos el contexto de datos. Si el usuario pulsa repetidas veces el botón y no hemos controlado la reentrada en el método, podría ocurrir que un evento se produzca cuando aún no ha terminado la ejecución del anterior, generando el mismo problema.

Muy bien, ¿y cómo lo solucionamos?

Ya habiendo entendido cuál es el problema, existen varios enfoques para solucionarlo.

1. Hacer que el componente herede de OwningComponentBase o OwningComponentBase<T>

Como sabemos, los componentes heredan normalmente de ComponentBase, pero esto puede modificarse utilizando la directiva @inherits en el archivo .razor.

Si nuestro componente hereda de OwningComponentBase<T>, tendremos a nuestra disposición una propiedad llamada Service de tipo T cuya vida estará ligada a la de éste, es decir, el objeto será liberado automáticamente cuando el componente sea eliminado.

Por tanto, el componente podría rescribirse de la siguiente forma:
@inherits OwningComponentBase<FriendsContext>
...
@code {
    List<Friend> _friends = new List<Friend>();

    protected override async Task OnInitializedAsync()
    {
        // "Service" is inherited from OwningComponentBase<FriendsContext>
        _friends = await Service.Friends.ToListAsync();
    }
}
Con esto podremos solucionar fácilmente el caso de los múltiples componentes, pues cada uno de ellos tendrá su propia instancia de FriendsContext con la que acceder a los datos. Sin embargo, la concurrencia se crea en el interior de la misma instancia del componente (por ejemplo, porque se ejecutan varios eventos simultáneamente o porque programáticamente queramos lanzar hilos que accedan a datos), esta solución no será suficiente.

También hay que tener en cuenta que podremos llegar a mantener un contexto en memoria por cada componente activo, aunque si no hay mucha carga de usuarios no debería ser un problema.

2. Utilizar un ámbito transient para el contexto de datos

Otra opción es registrar el contexto de datos en la colección de servicios utilizando un scope de tipo transient en la clase Startup, haciendo así que cada dependencia sea resuelta con una nueva instancia:
services.AddDbContext<FriendsContext>(ServiceLifetime.Transient);
En los componentes podríamos solicitar dicha instancia cuando fuera necesaria, pero también tendríamos que encargarnos nosotros de su liberación. El siguiente código muestra cómo hacerlo mediante un bloque using justo en el punto donde vamos a utilizar el contexto de datos:
@inject IServiceProvider ServiceProvider
...
@code {
    List<Friend> _friends = new List<Friend>();

    protected override async Task OnInitializedAsync()
    {
        using (var ctx = ServiceProvider.GetRequiredService<FriendsContext>())
        {
            _friends = await ctx.Friends.ToListAsync();
        }
    }
}
Observad que hemos inyectado IServiceProvider para tener acceso al contenedor de dependencias.

3. Instanciar manualmente el contexto cuando vayamos a utilizarlo

El problema que tiene el enfoque anterior es que puede ocasiones en las que no nos interese que el contexto de datos esté registrado como transient. Por ejemplo, si el mismo proyecto Blazor incluye Razor Pages o controladores MVC que hagan uso del mismo contexto de datos, seguro que nos interesará que esté registrado como scoped.
En estos casos, simplemente tendremos que instanciar y liberar manualmente el contexto de datos, por ejemplo así:
@code {
    List<Friend> _friends = new List<Friend>();

    protected override async Task OnInitializedAsync()
    {
        using (var ctx = new FriendsContext())
        {
            _friends = await ctx.Friends.ToListAsync();
        }
    }
}
Si a la hora de instanciar necesitamos enviar al contexto las opciones de configuración indicadas desde la clase Startup, podemos solicitar al inyector una instancia de DbContextOptions<FriendsContext> y enviársela al constructor manualmente en el momento de la instanciación:
@inject DbContextOptions<FriendsContext> DbOptions
...
@code {
    List<Friend> _friends = new List<Friend>();

    protected override async Task OnInitializedAsync()
    {
        using (var ctx = new FriendsContext(DbOptions))
        {
            _friends = await ctx.Friends.ToListAsync();
        }
    }
}
¡Espero que os sea de utilidad!

 Publicado en Variable not found.

2 Comentarios:

MontyCLT dijo...

Hola J. María. Muchas gracias por escribir este post. Experimenté problemas similares con la instancia de la sesión de RavenDB.

(https://groups.google.com/forum/#!topic/ravendb/DWdgXQjKdJk)

Desconocía la existencia de OwinComponent, me podría haber salvado de un apuro en su día, aunque al final opté por portar la aplicación a WebAssembly y hacer un API con ASP.NET Core que haga de puente.

Un saludo.

José María Aguilar dijo...

Hola, Iván!

Pues sí, parece que el problema era similar al que trata este post y probablemente se podría haber solucionado usando una de estas técnicas. De todas formas, el salto a Wasm (aunque aún en preview) parece también una solución acertada :)

Muchas gracias comentar :)