Nie ma nic odkrywczego w stwierdzeniu – mamy jesień. Listopad nie zachęca do spacerów. A cóż brzmi kusząco w zimne, pochmurne dni? Oczywiście kakałko i programowanie!

W momencie kiedy pracujemy w środowiskach rozproszonych czasami potrzebujemy wykonywać pewne zadania jako oddzielny proces. Czasami jest to generowanie raportu, czasami jakieś inne zadanie długo lub krótko trwałe. W takich sytuacjach możemy do tego użyć prostej aplikacji konsolowej, zamkniętej w kontenerze i uruchamianej co jakiś czas za pomocą Kubernetes-a. Nie oceniając jakości metody(są sytuacje, w których nie polecam jej stosowania, ale to temat na inny artykuł), chciałbym wam przedstawić jak podłączyć do prostej aplikacji konsolowej zarządzanie zmiennymi za pomocą pliku appsettings.json i jak umożliwić migrację EF Core z poziomu CLI.

Dodanie konfiguracji

W pierwszej kolejności musimy dodać sczytywanie konfiguracji z pliku appsettings, a także, jeżeli tego potrzebujemy, ze zmiennych środowiskowych. Musimy dodać kilka paczek nuget-a do naszego projeku:

  • Microsoft.Extensions.Configuration
  • Microsoft.Extensions.Configuration.EnvironmentVariables
  • Microsoft.Extensions.Configuration.FileExtensions
  • Microsoft.Extensions.Configuration.Json

Uwielbiam modularność z jaką jest pisany .NET Core, ale czasami mam obawy że liczba potrzebnych paczek będzie podobna do aplikacji pisanych w Node 😂

Przechodząc dalej będziemy musieli załadować konfigurację w dwóch miejscach. Osobiście lubię ją wydzielić do prywatnej metody w klasie Program

private static IConfiguration BuildConfiguration()
    => new ConfigurationBuilder().SetBasePath(Directory.GetCurrentDirectory())
        .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
        .AddEnvironmentVariables()
        .Build();

Nie ma tutaj nic szczególnego dlatego opisze to krótko i zwięźle. Zaczynamy od utworzenia naszego builder-a konfiguracji. Następnie informujemy go, gdzie znajduje się nasza konfiguracja. Następne linijki służą do załadowania pliku appsettings.jsoni zmiennych środowiskowych. Ostatnia linijka to zbudowanie obiektu konfiguracji.

Poniżej możecie zerknąć na plik appsettings, który zawiera tylko connectionString do bazy.

{
  "ConnectionStrings": {
    "SqlServerConnection": "Server=localhost,1433;Database=Test;User Id=SA;Password=yourStrong(!)Password"
  }
}

Dodanie kontenera DI

Możliwe, że w tym przykładzie będzie to overkill, jednak przyzwyczaiłem się do korzystania z kontenerów DI. Potrzebując konsolowej aplikacji w mniejszym lub większym stopniu kontener DI się przyda, więc wiedza nie pójdzie w las.

Wystarczy załadować jedną paczkę:

  • Microsoft.Extensions.DependencyInjection

Całą konfiguracje kontenera możemy zamieścić w głównej metodzie naszej aplikacji konsolowej (Main):

var configuration = BuildConfiguration();
var connectionString = configuration.GetConnectionString("SqlServerConnection");
var services = new ServiceCollection();
services.AddDbContext(
    o => o.UseSqlServer(connectionString,
        x => x.MigrationsAssembly(typeof(PersonContext).Assembly.FullName)));

var dbContext = services.BuildServiceProvider().GetRequiredService();

if (dbContext.Database.GetPendingMigrations().Any())
    dbContext.Database.Migrate();

Od razu zawarłem automatyczne pobieranie dbcontext-u z kontenera DI i uruchamianie oczekujących migracji.

Sama konfiguracja encji jak i dbContext-u nie ma tutaj dużego znaczenia. Przykładowa konfiguracja wygląda następująco:

public class Person
{
    public int Id { get; set; }
    public string Name { get; set; }
}

public class PersonContext : DbContext
{
    public DbSet Persons { get; set; }

    public PersonContext(DbContextOptions options) : base(options)
    { }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity()
            .HasKey(x => x.Id);

        base.OnModelCreating(modelBuilder);
    }
}

Opis Problemu

Wszystko wygląda poprawnie, niemal jak w standardowej aplikacji MVC. Tak więc pojawia się pytanie: co się stanie, jeżeli spróbujemy uruchomić migrację teraz? W konsoli otrzymamy następujący błąd (aby otrzymać szczegółowe logi podczas migracji pamiętajcie o dodaniu flagi -v):

Finding DbContext classes...
Finding IDesignTimeDbContextFactory implementations...
Finding application service provider...
Finding Microsoft.Extensions.Hosting service provider...
No static method 'CreateHostBuilder(string[])' was found on class 'Program'.
No application service provider was found.
Finding DbContext classes in the project...
Found DbContext 'PersonContext'.
Microsoft.EntityFrameworkCore.Design.OperationException: Unable to create an object of type 'PersonContext'. For the different patterns supported at design time, see https://go.microsoft.com/fwlink/?linkid=851728

Jak widać EF Core wymaga od nas zdefiniowania kolejnego elementu, który pozwoli my na utworzenie obiektu `PersonContext`.

Implementacja IDesignTimeDbContextFactory

Musimy do projektu dołożyć jeszcze jedną klasą. Nazwijmy ją PersonContextFactory. Musi ona implementować interfejs IDesignTimeDbContextFactory. W przypadku małych aplikacji wole dodać ją jako zagnieżdżoną klasę prywatną klasy Program. Pozwala to na dostęp do wcześniej stworzonej metody BuildConfiguration.

public class Program 
{
     //...    
    // ReSharper disable once UnusedType.Local
    private class PersonContextFactory : IDesignTimeDbContextFactory
    {
        public PersonContext CreateDbContext(string[] args)
        {
            var configuration = BuildConfiguration();
            var connectionString = configuration.GetConnectionString("SqlServerConnection");
            var builder = new DbContextOptionsBuilder();
    
            builder.UseSqlServer(connectionString,
                o => o.MigrationsAssembly(typeof(PersonContext).Assembly.FullName));
            
            return new PersonContext(builder.Options);
        }
    }
}

Kod powyżej nie zawiera w sobie czarnej magii. Po prostu sczytujemy oraz budujemy konfigurację naszego dbContext-u, a na koniec tworzymy go. Tutaj jeszcze mała uwaga z mojej strony: oprócz standardowych paczek EF Core, takich jak:

  • Microsoft.EntityFrameworkCore
  • Microsoft.EntityFrameworkCore.SqlServer

Trzeba dodać jeszcze:

  • Microsoft.EntityFrameworkCore.Design

Przy małych aplikacjach niemal zawsze o niej zapominam 😂 , chwalmy errory i zdolność ich odczytywania.

Teraz szybki test czy wszystko działa tak jak należy:

Finding DbContext classes in the project...
Using DbContext factory 'PersonContextFactory'.
Using context 'PersonContext'.
Finding design-time services for provider 'Microsoft.EntityFrameworkCore.SqlServer'...
Using design-time services from provider 'Microsoft.EntityFrameworkCore.SqlServer'.
Finding design-time services referenced by assembly 'EFCoreConsoleMigration'.
No referenced design-time services were found.
Finding IDesignTimeServices implementations in assembly 'EFCoreConsoleMigration'...
No design-time services were found.
DetectChanges starting for 'PersonContext'.
DetectChanges completed for 'PersonContext'.
Writing migration to '/Users/dante/Workspace/EFCoreConsoleMigration/EFCoreConsoleMigration/Migrations/20201107125749_InitialMigration.cs'.
Writing model snapshot to '/Users/dante/Workspace/EFCoreConsoleMigration/EFCoreConsoleMigration/Migrations/PersonContextModelSnapshot.cs'.
'PersonContext' disposed.
Done. To undo this action, use 'ef migrations remove'

Jak widać po logach EF znalazł naszą klasę PersonContextFactory i był w stanie jej użyć do zbudowania kontekstu. Następnie, bez problemów, wygenerował migrację.

To by było na tyle, dzięki za przeczytanie i do następnego!

PS. Spodobał Ci się ten artykuł?

Referencje