Worker Service: Cómo crear un servicio .Net Core 3 multiplataforma

La imagen muestra el logo de .Net Core 3.0

Hace unos meses, hablamos sobre como crear servicios multiplataforma con .Net Core 2, donde veíamos que había que hacer algunas «perrerías» para poder conseguirlo… Ha llegado .Net Core 3, y me ha parecido interesante hablar sobre la nueva manera que nos trae de conseguir esto.

En primer lugar, tenemos una nueva plantilla disponible para esto: Worker Service. Usando esta plantilla, vamos a poder tener un ejemplo para construir nuestro servicio. Aunque perfectamente podemos hacerlo desde una aplicación de consola añadiendo nosotros el código, vamos a «estrenar» la plantilla (si nos dan trabajo hecho, tampoco vamos a decir que no… xD )

Creando la solución

Lo primero por supuesto, es crear nuestra solución, y en ella, vamos a elegir la plantilla «Servicio de trabajo» (Worker Service en inglés):

La imagen muestra la plantilla seleccionada

Esto nos va a crear una solución con dos archivos, Program.cs y Worker.cs. Si abrimos Program.cs, vamos a poder comprobar que nuestro proyecto no es más que una aplicación de consola que crea un «Host» (al igual que hacíamos en .Net Core 2.X).

namespace PostWorker
{
    public class Program
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureServices((hostContext, services) =>
                {
                    services.AddHostedService<Worker>();
                });
    }
}

El segundo fichero que tenemos, Worker.cs, tiene una pinta parecida a esta:

namespace PostWorker
{
    public class Worker : BackgroundService
    {
        private readonly ILogger<Worker> _logger;

        public Worker(ILogger<Worker> logger)
        {
            _logger = logger;
        }

        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                _logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
                await Task.Delay(1000, stoppingToken);
            }
        }
    }
}

La principal novedad al crear nuestras tareas dentro del servicio, es que ahora heredamos de la clase abstracta «BackgroundService» (y no de IHostedService como hacíamos en .Net Core 2.X). Esto simplemente es para aportarnos una capa de abstracción sobre su funcionamiento, y realmente es esta clase la que implementa IHostedService y nos ofrece 3 métodos para trabajar con ella:

protected abstract Task ExecuteAsync(CancellationToken stoppingToken)
public virtual Task StartAsync(CancellationToken cancellationToken)
public virtual Task StopAsync(CancellationToken cancellationToken)        

Para crear nuestro servicio básico, tal como lo hace el ejemplo, basta con implementar el primero de ellos (que además al ser abstracto estamos obligados a implementar). Pero vamos a profundizar un poco más…

El primero de ellos, va a ser lo que queremos que se ejecute dentro de nuestro servicio, con la implementación por defecto, cuando arranque el servicio, se va a llamar directamente a ese método en una tarea. De igual manera, cuando el servicio se pare, se cancelará el token. Es importante tener en cuenta el CancellationToken como sistema de control del ciclo de vida, para evitar tener tareas bloqueadas al parar nuestro servicio.

Si necesitamos una funcionalidad un poco más avanzada, como ejecutar algo de código previo al arranque o en la parada, lo que vamos a tener que hacer es sobrescribir los métodos StartAsync y StopAsync según necesitemos, ya que no es obligatorio hacerlo con los dos. Un caso en el que nos podría hacer falta, es cuando necesitamos diferenciar las labores de arranque del servicio con las propias de ejecución. Por ejemplo, si quieres loguear cuando arranca o para tu servicio .Net Core 3:

namespace PostWorker
{
    public class Worker : BackgroundService
    {
        private readonly ILogger<Worker> _logger;

        public Worker(ILogger<Worker> logger)
        {
            _logger = logger;
        }

        public override async Task StartAsync(CancellationToken cancellationToken)
        {
            _logger.LogInformation("Worker starts");
            await base.StartAsync(cancellationToken);
        }

        public override async Task StopAsync(CancellationToken cancellationToken)
        {
            _logger.LogInformation("Worker stops");
            await base.StopAsync(cancellationToken);
        }

        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                _logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
                await Task.Delay(1000, stoppingToken);
            }
        }
    }
}

Es importante señalar, que podemos registrar tantos «Workers» como necesitemos, basta con que lo añadamos en el inyector de dependencias. Por ejemplo, si tuviésemos una clase Worker1 y otra Worker2, nuestro Program.cs se vería algo así:

namespace PostWorker
{
    public class Program
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureServices((hostContext, services) =>
                {
                    services.AddHostedService<Worker1>();
                    services.AddHostedService<Worker2>();                    
                });
    }
}

Con esto, ya tenemos un código perfectamente funcional como servicio, de hecho, si lo instalamos en Linux como servicio (daemon), podremos comprobar que funciona perfectamente. En cambio, si lo instalamos en Windows, para nuestra sorpresa (o no), simplemente esto no va a funcionar.

Instalando nuestro servicio .Net Core 3 en Windows

Si alguna vez has creado un servicio en Windows, sabes que tiene sus cositas… En .Net Core 2.X, teníamos que hacer ciertas perrerías para conseguirlo, pero por suerte, ahora tenemos un paquete NuGet que nos permite hacerlo sin dificultades (básicamente, hace las perrerías por nosotros). Este paquete es «Microsoft.Extensions.Hosting.WindowsServices«, basta con que lo añadamos a nuestro proyecto y que añadamos su uso en Program.cs con «UseWindowsService» sobre el HostBuilder:

namespace PostWorker
{
    public class Program
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .UseWindowsService() //Registramos el paquete
                .ConfigureServices((hostContext, services) =>
                {
                    services.AddHostedService<Worker>();                  
                });
    }
}

Este paquete lo que va a hacer, es registrar los servicios necesarios en el inyector de dependencias si corremos sobre Windows, y no hacer nada en el resto de los casos.

Con esto, ya tenemos nuestro servicio en .Net Core 3 listo para instalarlo en cualquier máquina, independientemente del sistema operativo que utilicemos. Si quieres probarlo tú mismo, he dejado el código en GitHub para que puedas salsearlo sin problemas.

Como medir el rendimiento de nuestro código

      4 comentarios en Como medir el rendimiento de nuestro código
La imagen muestra un medidor de velocidad para el entrada "Como medir el rendimiento de nuestro código"

A raíz de las entradas sobre la serie de entradas sobre la reflexión que publicamos antes del parón de verano, he estado escribiéndome con un lector sobre algunas posibles optimizaciones en el código, todas ellas midiendo el rendimiento del código para ver cuál era mejor. Llevaba tiempo queriendo escribir sobre este tema, ¿y que mejor momento qué ahora? 🙂

¿Cómo se mide el rendimiento del código?

Para conseguir datos que nos sirvan para esto, se utilizan pruebas de rendimiento o «benchmarks», los cuales nos van a servir para obtener las métricas que vamos a comparar (tiempo, cpu consumida, ram utilizada…) para decidir si nuestro código tiene el rendimiento esperado. Normalmente, esto se consigue midiendo los datos a comprobar y ejecutando el código un número suficiente de veces para asegurar que los posibles «ruidos externos» no afectan a las métricas.

Imagina que simplemente medimos el tiempo de ejecución de método ejecutándolo solo una vez, cualquier cosa que el sistema operativo ejecute podría falsear los resultados…

En el ecosistema .Net, existe una librería que se distribuye a través de NuGet que nos facilita enormemente esta labor, y es de la que os voy a hablar ahora, esta librería es BenchmarkDotNet, Vamos a ponernos en faena:

Como usar BenchmarkDotNet

Vamos a imaginar que dentro de nuestro código tenemos algo como esto:

public class OperacionesMatematicas
{
    public double Suma(double a, double b)
    {
        return a + b;
    }

    public double Multiplicacion(double a, double b)
    {
        return a * b;
    }

    public double Potencia(double @base, double exponente)
    {
        return Math.Pow(@base, exponente);
    }

    public double Potencia2(double @base, double exponente)
    {
        if (exponente == 0)
            return 1;

        var resultado = @base;
        for (int i = 1; i < exponente; i++)
        {
            resultado = resultado * @base;
        }
        return resultado;
    }
}

Simplemente es una clase que va a hacer ciertas operaciones matemáticas, teniendo además dos maneras de calcular una potencia, utilizando Math.Pow y multiplicando la base por si misma tantas veces como indica el exponente (lo que es una potencia vamos…).

Lo primero que vamos a necesitar para medir el rendimiento de nuestro código, es crear un proyecto de consola que será el que tenga el código para las pruebas, en este caso, lo voy a llamar «BenchmarkRunnerProject». A este proyecto le vamos a añadir el paquete «BenchmarkDotNet«, y vamos a crear una clase donde vamos a añadir la lógica de la prueba:

public class OperacionesMatematicasBenchmark
{
    [Benchmark]
    public void Suma()
    {
        var operaciones = new OperacionesMatematicas();
        operaciones.Suma(10, 20);
    }

    [Benchmark]
    public void Multiplicacion()
    {
        var operaciones = new OperacionesMatematicas();
        operaciones.Multiplicacion(10, 20);
    }

    [Benchmark]
    public void Potencia()
    {
        var operaciones = new OperacionesMatematicas();
        operaciones.Potencia(2, 2);
    }

    [Benchmark]
    public void Potencia2()
    {
        var operaciones = new OperacionesMatematicas();
        operaciones.Potencia2(2, 2);
    }
}

Por último, solo nos queda añadir al método Main la ejecución de las pruebas:

class Program
{
    static void Main(string[] args)
    {
        var summary = BenchmarkRunner.Run<OperacionesMatematicasBenchmark>();
        Console.Read();
    }
}

Una vez hecho esto, ya solo necesitamos ejecutar el proyecto para que nuestra prueba funcione. Pero ojo, tiene que estar en «Release» para poder medir el rendimiento del código con las optimizaciones del compilador. En caso contrario, nos mostrará un mensaje indicándonos que lo hagamos o desactivemos que tenga que ser Release:

La imagen muestra la salida de la consola cuando ejecutamos un benchamrk sin estar en release

Una vez que se ejecute, nos mostrará los resultados en la propia consola:

La imagen muestra un ejemplo de la salida básica

Con esto, ya tenemos la funcionalidad básica, donde solo medimos el tiempo de ejecución con los parámetros que hemos puesto «hardcoded». Pero tenemos la opción de indicar diferentes parámetros con el atributo «Params»:

public class OperacionesMatematicasBenchmark
{
    [Params(2, 3)]
    public int A { get; set; }

    [Params(2, 200)]
    public int B { get; set; }

    [Benchmark]
    public void Suma()
    {
        var operaciones = new OperacionesMatematicas();
        operaciones.Suma(A, B);
    }

    [Benchmark]
    public void Multiplicacion()
    {
        var operaciones = new OperacionesMatematicas();
        operaciones.Multiplicacion(A, B);
    }

    [Benchmark]
    public void Potencia()
    {
        var operaciones = new OperacionesMatematicas();
        operaciones.Potencia(A, B);
    }

    [Benchmark]
    public void Potencia2()
    {
        var operaciones = new OperacionesMatematicas();
        operaciones.Potencia2(A, B);
    }
}

Con este cambio, vamos a conseguir que se ejecute una prueba con cada una de las configuraciones:

La imagen muestra los resultados de la ejecución son parametros

E incluso ordenarlas si añadimos el atributo «RankColumn» a la clase:

La imagen muestra el resultado con el atributo rankcolum

Existen muchísimas opciones y parametrizaciones que pueden servirte para configurar cada uno de los aspectos del benchmark, e incluso comparando la ejecución en diferentes entornos (.Net Framework, .Net Core, Mono) y diferentes versiones de los entornos. Te recomiendo que le eches un ojo a la documentación para ver todas las opciones disponibles, ya que, es imposible hablar de todas ellas en una única entrada. (Por ejemplo, para medir la RAM con Diagnosers).

Como siempre, dejo el enlace al repositorio en GitHub con el código de la entrada.