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!

lunes, 26 de febrero de 2018

Find vs First vs Single

Hay varios métodos de LINQ que, a priori, parecen sirven al mismo propósito, pero que al fijarnos en detalle vemos son muy distintos, más si cabe si estamos usando LINQ to Entities, me estoy refiriendo a First, FirstOrDefault, Single, SingleOrDefault y Find (método que de hecho sólo está disponible en DbSet<>).

Find es el método que deberíamos casi siempre intentar usar porque es el único que podría evitar un round-trip a la base de datos. Find primero busca en el contexto y si no encuentra nada, es entonces cuando ejecuta una consulta a la base de datos. En caso de ir a base de datos y volverse con las manos vacías, devuelve null. Por contra, Find recibe un params object[] keyValues, con lo que si la clave es compuesta podría no ser muy intuitivo y propenso a errores.

Sólo se me ocurre no usar Find si usamos por ejemplo Include, ya que Include es un método de extensión de IQueryable<> y Find devuelve directamente una entidad.

Por otro lado, First, FirstOrDefault, Single y SingleOrDefault siempre ejecutarán una consulta a la base de datos, aunque la entidad que fueran a materializar estuviera ya disponible en el contexto. Que siempre vaya a la base de datos no significa que devuelva los valores que estén en la base de datos ya que, de estar disponible en el contexto, nos devolverá la instancia previamente materializada, esto es lo que se denomina en terminología de ORMs, IdentityMap.

Otra gran diferencia es que Single lanzará una excepción si encuentra 2 o más registros, así que no hay se debería usar First cuando en realidad se quería usar Single o, mejor aún, Find.

Con el siguiente código se puede ver todo lo expuesto.

using System;
using System.Linq;
using Microsoft.EntityFrameworkCore;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            using (var context = new ShopContext())
            {
                context.Database.EnsureDeleted();
                context.Database.EnsureCreated();
                context.Orders.Add(new Order() { Id = 1, CustomerPurchaseOrder = "foo" });
                context.Orders.Add(new Order() { Id = 2, CustomerPurchaseOrder = "foo" });
                context.SaveChanges();
            }

            using (var context = new ShopContext())
            {
                var order = context.Orders.Find(1);
                //exec sp_executesql N'SELECT TOP(1) [e].[Id], [e].[CustomerPurchaseOrder]
                //FROM [Orders] AS [e]
                //WHERE [e].[Id] = @__get_Item_0',N'@__get_Item_0 int',@__get_Item_0=1

                order.CustomerPurchaseOrder = "panicoenlaxbox"; // Identity Map

                order = context.Orders.Find(1); // no db query

                Console.WriteLine(order.CustomerPurchaseOrder); //panicoenlaxbox

                order = context.Orders.Find(3);                
                //exec sp_executesql N'SELECT TOP(1) [e].[Id], [e].[CustomerPurchaseOrder]
                //FROM [Orders] AS [e]
                //WHERE [e].[Id] = @__get_Item_0',N'@__get_Item_0 int',@__get_Item_0=3

                Console.WriteLine(order == null); // True

                order = context.Orders.First(o => o.Id == 1);
                //SELECT TOP(1) [o].[Id], [o].[CustomerPurchaseOrder]
                //FROM [Orders] AS [o]
                //WHERE [o].[Id] = 1

                Console.WriteLine(order.CustomerPurchaseOrder); //panicoenlaxbox

                try
                {
                    order = context.Orders.First(o => o.Id == 3);
                }
                catch (Exception e)
                {
                    Console.WriteLine(e.Message); // Sequence contains no elements
                }

                order = context.Orders.FirstOrDefault(o => o.Id == 3);
                //SELECT TOP(1) [o].[Id], [o].[CustomerPurchaseOrder]
                //FROM [Orders] AS [o]
                //WHERE [o].[Id] = 2

                Console.WriteLine(order == null); // True

                order = context.Orders.Single(o => o.Id == 1);
                //SELECT TOP(2) [o].[Id], [o].[CustomerPurchaseOrder]
                //FROM [Orders] AS [o]
                //WHERE [o].[Id] = 1

                Console.WriteLine(order.CustomerPurchaseOrder); //panicoenlaxbox

                try
                {
                    order = context.Orders.Single(o => o.Id == 3);
                }
                catch (Exception e)
                {
                    Console.WriteLine(e.Message); // Sequence contains no elements
                }

                try
                {
                    order = context.Orders.Single(o => o.CustomerPurchaseOrder == "foo");
                }
                catch (Exception e)
                {
                    Console.WriteLine(e.Message); // Sequence contains more than one element
                }

                order = context.Orders.SingleOrDefault(o => o.Id == 1);
                //SELECT TOP(2) [o].[Id], [o].[CustomerPurchaseOrder]
                //FROM [Orders] AS [o]
                //WHERE [o].[Id] = 1

                Console.WriteLine(order.CustomerPurchaseOrder); //panicoenlaxbox                
            }
        }
    }

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

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

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<Order>().Property(o => o.Id).ValueGeneratedNever();
            base.OnModelCreating(modelBuilder);
        }
    }

    internal class Order
    {
        public int Id { get; set; }
        public string CustomerPurchaseOrder { get; set; }
    }
}

Un saludo!

miércoles, 31 de enero de 2018

SQL dinámico con ExpandoObject y Dapper

Crear una consulta SQL con una condición de filtrado dinámico es algo que tarde o temprano sale a la palestra

En la solución que se muestra a continuación, se usa un objeto de tipo ExpandoObject que permite agregar propiedades en tiempo de ejecución. Lo explica muy bien @eiximenis en el post Var, object y dynamic

Si a ExpandoObject le sumamos Dapper, la solución es segura contra SQL injection y flexible en cuanto a su confección

En el ejemplo he procurado que sea vea que podemos tanto agregar propierdades de forma dinámica por el mero hecho de asignar un valor, así como castear el ExpandoObject a un dicccionario para agregar propiedades de forma dinámica

Primero el código SQL para crear una tabla y que el ejemplo funcione

CREATE TABLE [dbo].[Table_1](
	[Id] [int] NOT NULL,
	[TenantId] [int] NOT NULL,
	[C1] [int] NULL,
	[C2] [nvarchar](50) NULL,
	[C3] [datetime2](7) NULL,
 CONSTRAINT [PK_Table_1] PRIMARY KEY CLUSTERED 
(
	[Id] ASC
)

Ahora el código

using System;
using System.Collections.Generic;
using System.Data.SqlClient;
using System.Dynamic;
using System.Linq;
using Dapper;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            QueryExample(new Dictionary<string, object>()
            {
                { "C1", 1 },
                { "C2", "Foo" },
                { "C3", DateTime.Now }
            });
        }

        private static void QueryExample(IDictionary<string, object> criterias)
        {
            const string connectionString = @"Server=(LocalDB)\MSSQLLocalDB;Database=Example;Trusted_Connection=True;";

            var sql = "SELECT * FROM Table_1 WHERE TenantId = @TenantId";

            var columnsWhitelist = new[] { "C1", "C2", "C3" };

            foreach (var criteria in criterias)
            {
                if (!columnsWhitelist.Contains(criteria.Key))
                {
                    throw new ArgumentException($"{criteria.Key} is not allowed as column name", nameof(criteria));
                }
                sql += $" AND {criteria.Key} = @{criteria.Key}";
            }

            dynamic parameters = new ExpandoObject();

            parameters.TenantId = 1;

            var dictionary = (IDictionary<string, object>)parameters;
            foreach (var criteria in criterias)
            {
                dictionary.Add(criteria.Key, criteria.Value);
            }

            using (var connection = new SqlConnection(connectionString))
            {
                connection.Query(sql, (ExpandoObject)parameters);
            }

            Console.ReadKey();
        }
    }
}

El SQL que se ejecuta finalmente es el siguiente

exec sp_executesql N'SELECT * FROM Table_1 WHERE TenantId = @TenantId AND C1 = @C1 AND C2 = @C2 AND C3 = @C3',N'@TenantId int,@C1 int,@C2 nvarchar(4000),@C3 datetime',@TenantId=1,@C1=1,@C2=N'Foo',@C3='2018-01-31 18:58:56.823'

Un saludo!