viernes, 3 de noviembre de 2017

Crear e inicializar un contexto en EF Core

En EF 6.x, crear e inicializar un contexto tiene magia. Magia en el sentido de que Entity Framework puede decidir automáticamente dónde y con que nombre crear la base de datos. Según el constructor elegido de DbContext, si existe o no una cadena de conexión o un inicializador en un fichero .config, incluso saber que prevalece lo escrito en un fichero .config sobre la configuración por código… en fin, magia, para lo bueno y para lo malo.

En EF Core (actualmente 2.0) se ha prescindido de esa magia, ahora tenemos que ser explícitos sobre la configuración del contexto. Esto parece una buena idea, no creo que haya mucho debate, pero por el contrario arrancar un proyecto requiere saber más sobre como inicializar y trabajar con EF.

Asumiendo que estamos trabajando con una aplicación de consola, la forma recomendada de agregar EF Core a un proyecto es instalar un proveedor.

dotnet add package Microsoft.EntityFrameworkCore.SqlServer

Después, habrá que agregar el CLI de EF Core y/o los comandos de PowerShell para el PMC (Power Management Console).

Para agregar el CLI de EF Core hay que editar el .csproj a mano.

<ItemGroup>
  <DotNetCliToolReference Include="Microsoft.EntityFrameworkCore.Tools.DotNet" Version="2.0.0" />
</ItemGroup>

Para agregar los comandos al PMC, podemos hacerlo agregando un paquete con normalidad.

dotnet add package Microsoft.EntityFrameworkCore.Tools

Nuestro .csproj quedaría así:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp2.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="2.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="2.0.0" />
  </ItemGroup>
  
  <ItemGroup>
    <DotNetCliToolReference Include="Microsoft.EntityFrameworkCore.Tools.DotNet" Version="2.0.0" />
  </ItemGroup>

</Project>
Si estamos en una aplicación de consola también será necesario agregar una referencia a Microsoft.EntityFrameworkCore.Design

Ahora ya estamos preparados para trabajar con EF Core, así que creamos un contexto cualquiera:

class ShopContext : DbContext
{
    public DbSet<Order> Orders { get; set; }
}

internal class Order
{
    public int Id { get; set; }
    public DateTime OrderDate { get; set; }
}

Y al crear nuestra primera migración, la primera en la frente:

dotnet ef migrations add Initial

No database provider has been configured for this DbContext. A provider can be configured by overriding the DbContext.OnConfiguring method or by using AddDbContext on the application service provider. If AddDbContext is used, then also ensure that your DbContext type accepts a DbContextOptions object in its constructor and passes it to the base constructor for DbContext.

El error nos está diciendo que el contexto no está “configurado” con ningún proveedor de base de datos, que no sabe a que bd atacar… que o bien usemos DbContext.OnConfiguring o bien AddDbContext sobre el “proveedor de servicios de la aplicación (el contenedor de dependencias)” y que si usamos este último método no olvidemos crear un constructor que acepte DbContextOptions y que llame a la base.

La primera solución pasa por sobrescribir OnConfiguring

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    if (!optionsBuilder.IsConfigured)
    {
        optionsBuilder.UseSqlServer(@"Server=(LocalDB)\MSSQLLocalDB;Database=Example;Trusted_Connection=True;");
    }
    base.OnConfiguring(optionsBuilder);
}

“IsConfigured” lo que viene a decir es “si no me han configurado ya, entonces me configuro”, luego veremos que será posible configurar el contexto desde fuera, porque si no lo hiciéramos y quisiéramos cambiar de proveedor o de cadena de conexión tendríamos que meter más código aquí, pero, en cualquier caso, esté o no configurado el contexto está bien saber que tenemos una última oportunidad para hacer lo que queramos con la configuración. Además, hay que tener en cuenta que OnConfiguring se llama siempre para cada instancia creada del contexto.

Ahora ya funciona crear una migración con las herramientas cliente y además podríamos usar el contexto en nuestro código:

            
using (var context = new ShopContext())
{
}

Sin embargo, parece mejor que el contexto sea configurado desde fuera, será más versátil y no habremos hardcodeado nada, probemos la segunda opción.

using System;
using Microsoft.EntityFrameworkCore;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            var options = new DbContextOptionsBuilder()
                .UseSqlServer(@"Server=(LocalDB)\MSSQLLocalDB;Database=Example;Trusted_Connection=True;")
                .Options;

            using (var context = new ShopContext(options))
            {
                context.Database.Migrate();
            }
        }
    }


    class ShopContext : DbContext
    {
        public ShopContext(DbContextOptions options) : base(options)
        {
        }
        public DbSet<Order> Orders { get; set; }
    }

    internal class Order
    {
        public int Id { get; set; }
        public DateTime OrderDate { get; set; }
    }
}

Ahora somos capaces de inyectar la configuración al contexto (luego IsConfigured vale true en OnConfiguring).

Tanto DbContextOptions como DbContextOptionsBuilder tiene su versión genérica, que cuando trabajemos más adelante con DI nos servirá si tenemos más de un contexto en nuestra aplicación.

            
var options = new DbContextOptionsBuilder<ShopContext>()
    .UseSqlServer(@"Server=(LocalDB)\MSSQLLocalDB;Database=Example;Trusted_Connection=True;")
    .Options;

public ShopContext(DbContextOptions<ShopContext> options) : base(options)
{
}

Sin embargo, lo que deja de funcionar ahora es el CLI, porque, aunque encuentra el contexto, no tiene un constructor sin parámetros y por ende no se puede instanciar.

Unable to create an object of type 'ShopContext'. Add an implementation of 'IDesignTimeDbContextFactory ' to the project, or see https://go.microsoft.com/fwlink/?linkid=851728 for additional patterns supported at design time.

IDesignTimeDbContextFactory ayuda a las herramientas de cliente de EF a crear un contexto si el mismo no tiene un constructor público sin parámetros.

    
class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<ShopContext>
{
    public ShopContext CreateDbContext(string[] args)
    {
        var options = new DbContextOptionsBuilder<ShopContext>()
            .UseSqlServer(@"Server=(LocalDB)\MSSQLLocalDB;Database=Example;Trusted_Connection=True;")
            .Options;
        return new ShopContext(options);
    }
} 

Esta clase sólo será usada por las herramientas cliente, esto es importante, es tiempo de diseño, nada tiene que ver con tiempo de ejecución.

Ahora ya funcionan las migraciones.

Aunque con lo visto hasta aquí se podría funcionar, no estamos haciendo uso del contenedor de dependencias.

Para hacerlo en una aplicación de consola.

        
static void Main(string[] args)
{
    var options = new DbContextOptionsBuilder()
        .UseSqlServer(@"Server=(LocalDB)\MSSQLLocalDB;Database=Example;Trusted_Connection=True;")
        .Options;

    IServiceCollection services = new ServiceCollection();
    services
        .AddSingleton(options)
        .AddScoped<ShopContext>();

    ServiceProvider serviceProvider = services.BuildServiceProvider();

        using (var context = serviceProvider.GetService<ShopContext>())
    {
        context.Database.EnsureCreated();
    }
}

Y si queremos simplificar el registro de servicios, tenemos el método de extensión AddDbContext, que registra el contexto como Scoped y nos da una lamba que se llamará la primera vez que se resuelva el contexto y que devolverá un DbContextOptions, que se registrará como Singleton.

        
static void Main(string[] args)
{
    IServiceCollection services = new ServiceCollection();
    services.AddDbContext<ShopContext>(builder =>
    {
        builder.UseSqlServer(@"Server=(LocalDB)\MSSQLLocalDB;Database=Example;Trusted_Connection=True;");
    });

    ServiceProvider serviceProvider = services.BuildServiceProvider();

    using (var context = serviceProvider.GetService<ShopContext>())
    {
        context.Database.EnsureCreated();
    }
}

En realidad, AddDbContext registra muchos más servicios con su llamada a AddCoreServices

Si por el contrario nuestra aplicación es web la cosa cambia.

De serie (y asumiendo que estamos usando Visual Studio) viene con el paquete Microsoft.AspNetCore.All Microsoft.AspNetCore.App que ya incluye los paquetes Microsoft.EntityFrameworkCore.SqlServer, Microsoft.EntityFrameworkCore.Tools y Microsoft.EntityFrameworkCore.Design, es decir, para tener lo mismo que en el ejemplo anterior sólo tendríamos que agregar el CLI de EF (el de PMC ya viene de serie) y también el CLI de EF (en ASP.NET Core 2.1, dotnet ef es un comando de serie)

Además, ahora estas herramientas encontrarán el método BuildWebHost

    
    public class Program
    {
        public static void Main(string[] args)
        {
            BuildWebHost(args).Run();
        }

        public static IWebHost BuildWebHost(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .Build();
    }

Es importante saber que se busca el método BuildWebHost con ese nombre exacto, cualquier otro nombre no valdrá.

Si las herramientas cliente encuentran BuildWebHost lo ejecutarán y también ejecutarán Startup.Configure y Startup.ConfigureServices (porque aquí es donde habremos llamado a AddDbContext y registrado los servicios).

La consecuencia directa de esto es que el código de inicialización (migración, seed, etc) que tuviéramos en Startup.Configure ya no debería estar allí. Se recomienda moverlo al método Main.

public static void Main(string[] args)
{
    // Construir IWebHost
    var host = BuildWebHost(args);

    // Inicialización
    using (var scope = host.Services.CreateScope())
    {
        var services = scope.ServiceProvider;
        try
        {
            var context = services.GetRequiredService<ShopContext>();
            // Hacer algo útil...
        }
        catch (Exception ex)
        {
            ILogger<Program> logger = services.GetRequiredService<ILogger<Program>>();
            logger.LogError(ex, "An error occurred while seeding the database.");
        }
    }

    // Ejecutar IWebHost
    host.Run();
}

En este gist de Unai Zorrila hay un ejemplo más molón

Como dato curioso (o no tan curioso y sí peligroso), si pasara que tenemos tanto BuildWebHost como IDesignTimeDbContextFactory, se ejecutarían ambos, aunque prevalecería IDesignTimeDbContextFactory.

Y para terminar, además del método extensor AddDbContext, en EF Core 2 han metido el nuevo método de extensión AddDbContextPool que usa un pool de contextos para mejorar el rendimiento, aunque tiene ciertas limitaciones.

¡Un saludo!