martes, 6 de marzo de 2018

Servicios comunes en aplicación de consola .NET Core

Si usamos ASP.NET Core, es fácil que estemos acostumbrados a que los servicios comunes de la plataforma estén allí o al menos, que sea muy fácil incluirlos y consumirlos. Sin embargo, con una aplicación de consola monda y lironda, tenemos un triste Main y puede ser que incluir DI, logging o configuración se haga un poco cuesta arriba (al menos a mí se me hizo).

En cualquier caso, se tiene que poder hacer, porque ya sabemos que una aplicación ASP.NET Core es al fin y al cabo una aplicación de consola (aunque ASP.NET Core es también un framework y hace uso de IoC para facilitar nuestro trabajo). Sin embargo, con File > New > Project… > Console App estamos solos contra el mundo.

Por servicios comunes se entienden DI (bueno, estrictamente hablando un contenedor de IoC), logging y configuración, servicios a los que no debemos renunciar en una aplicación de consola, o al menos no renunciar si no queremos.

Después de escribir el post, me chivaron por twitter que en .NET Core 2.1 habrá importantes cambios en cuanto a crear un host en una aplicación de consola. Puedes leer más información en Having Fun with the .NET Core Generic Host y desde el propio github del equipo de producto.

Yo por mi parte he actualizado el repositorio de github con un ejemplo idéntico al aquí expuesto pero funcionando con el nuevo host genérico, así se pueden comparar ambos. En cualquier caso, el post seguía aquí...

Lo primero que vamos a ver es la configuración.

var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");

var builder = new ConfigurationBuilder()
    .SetBasePath(Path.GetDirectoryName(Assembly.GetEntryAssembly().Location))
    .AddJsonFile("appsettings.json", optional: true)
    .AddJsonFile($"appsettings.{environment}.json", optional: true)
    .AddEnvironmentVariables()
    .AddCommandLine(args);

Inicialmente, para establecer la ruta base de la configuración usaba Directory.GetCurrentDirectory(), pero de nuevo en twitter me dijeron que mejor no asumir que el cwd (current working directory) es el mismo directorio que donde esté el ejecutable.

Para que no explote nada, tenemos que agregar en la raíz del proyecto un fichero con el nombre appsettings.json, por ejemplo:

{
  "Logging": {
    "IncludeScopes": false,
    "Debug": {
      "LogLevel": {
        "Default": "Warning"
      }
    },
    "Console": {
      "LogLevel": {
        "Default": "Debug"
      }
    }
  },
  "Foo": {
    "Bar": "Baz"
  }
}

Es importante poner “Copy if newer” en “Copy to Output Directory” para que se copie el fichero en el directorio \bin.

Además, toca agregar paquetes porque en una aplicación de consola empezamos con un .csproj más limpio que una patena.

dotnet add package Microsoft.Extensions.Configuration.FileExtensions
dotnet add package Microsoft.Extensions.Configuration.Json
dotnet add package Microsoft.Extensions.Configuration.EnvironmentVariables
dotnet add package Microsoft.Extensions.Configuration.CommandLine

Ahora vamos con el logging.

var loggerFactory = new LoggerFactory()
    .AddConsole(configuration.GetSection("Logging:Console"))
    .AddDebug();

Y de nuevo toca agregar los paquetes oportunos.

dotnet add package Microsoft.Extensions.Logging.Console
dotnet add package Microsoft.Extensions.Logging.Debug

La verdad es que en este punto ya podríamos usar tanto la configuración como el logging.

var logger = loggerFactory.CreateLogger<Program>();
logger.LogWarning("Hello World!");

Sin embargo, nos falta hacer DI para poder sacar todo el jugo a nuestra aplicación de consola.

Forzando un poco el ejemplo y para ver después cómo es posible usar patrón de opciones creamos la clase Foo que recogerá la configuración que agregamos antes en el fichero appsettings.json

class Foo
{
    public string Bar { get; set; }
}

Ahora ya sí, creamos el contenedor.

IServiceCollection services = new ServiceCollection();
            
services
    .AddSingleton(loggerFactory)
    .AddLogging();
            
services
    .AddSingleton(configuration)
    .AddOptions()
    .Configure<Foo>(configuration.GetSection("Foo"));

var serviceProvider = services.BuildServiceProvider();

De nuevo, estos son los paquetes que hay que instalar.

dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Microsoft.Extensions.Options.ConfigurationExtensions

En este punto, toda la infraestructura está lista, pero ¿Cómo arrancamos nuestra aplicación de consola? Pues creando una clase App e invocando su método Run (tanto App como Run parecen nombres comúnmente usados pero eres libre de elegir cualquier otro). Lógicamente, nos toca registrar la clase App en el contenedor y resolverla antes de poder usarla.

Todo el código junto y disponible en un repositorio de github

using System;
using System.IO;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");

            var builder = new ConfigurationBuilder()
                .SetBasePath(Directory.GetCurrentDirectory())
                .AddJsonFile("appsettings.json", optional: false)
                .AddJsonFile($"appsettings.{environment}.json", optional: true)
                .AddEnvironmentVariables()
                .AddCommandLine(args);

            var configuration = builder.Build();

            var loggerFactory = new LoggerFactory()
                .AddConsole(configuration.GetSection("Logging:Console"))
                .AddDebug();

            IServiceCollection services = new ServiceCollection();

            services
                .AddSingleton(loggerFactory)
                .AddLogging();

            services
                .AddSingleton(configuration)
                .AddOptions()
                .Configure<Foo>(configuration.GetSection("Foo"));

            services.AddTransient<App>();

            var serviceProvider = services.BuildServiceProvider();

            var app = (App)serviceProvider.GetService(typeof(App));
            app.Run();
        }
    }

    class Foo
    {
        public string Bar { get; set; }
    }

    class App
    {
        private readonly ILogger<App> _logger;
        private readonly Foo _foo;

        public App(ILogger<App> logger, IOptions<Foo> foo)
        {
            _logger = logger;
            _foo = foo.Value;
        }
        public void Run()
        {
            _logger.LogDebug(_foo.Bar);
        }
    }
}

Un saludo!