Saltar al contenido

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript... y lo que venga ;)

17 años online

el blog de José M. Aguilar

Inicio El autor Contactar

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web
ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript...

¡Microsoft MVP!
martes, 16 de marzo de 2021
.NET Core

Como decíamos hace unos días, los generadores de código C# nos brindan la posibilidad de crear al vuelo código C# e incluirlo en nuestros proyectos en tiempo de compilación.

Por no alargar demasiado el post, vimos un sencillísimo ejemplo de implementación, pero ahora vamos a crear algo más complejo que podría ayudarnos a solucionar un problema que tendría difícil solución de no contar con esta característica del compilador.

1. Definición de objetivos

El reto al que vamos a enfrentarnos ya lo expusimos en el post anterior como un caso de uso simple de los generadores de código, así que vamos a reproducir la descripción del escenario.

Imaginemos que en nuestra aplicación tenemos clases que representan operadores matemáticos como SumOperator, MultiplyOperator, DivideOperator, SubtractOperator. Imaginad también que nos interesa tener un tipo enum Operators donde aparezca un miembro por cada operador disponible, algo como:

public enum Operators
{
    Sum,
    Multiply,
    Divide,
    Subtract
}

El problema que tiene enfocar esto de forma manual es que resultaría sencillo implementar una nueva clase operador y olvidar crear su correspondiente entrada en la enumeración Operators. Aquí es donde vienen al rescate los generadores de código :)

Lo que implementaremos hoy es un generador de código C# que creará la enumeración por nosotros en tiempo de compilación, manteniéndola sincronizada en todo momento con las clases que tengamos definidas en el proyecto. Para ello, crearemos un generador llamado OperatorsEnumGenerator que:

  • En la fase de análisis de código recopilará las clases del proyecto a compilar cuyo nombre finalice por Operator.
  • En la fase de generación de código creará el enum con los miembros registrados anteriormente.

¡Vamos allá!

2. Creación del proyecto del generador

El proyecto del generador de código C# es una simple biblioteca de clases .NET Standard 2.0 en la que instalamos los siguientes paquetes NuGet:

  • Microsoft.CodeAnalysis.CSharp
  • Microsoft.CodeAnalysis.Analyzers

Una vez preparado el entorno, añadamos el andamiaje de nuestro generador introduciendo la clase OperatorsEnumGenerator que, como sabemos, debe implementar la interfaz ISourceGenerator y estar decorada con el atributo [Generator:

[Generator]
public class OperatorsEnumGenerator : ISourceGenerator
{
    public void Initialize(GeneratorInitializationContext context)
    {
        // TODO
    }

    public void Execute(GeneratorExecutionContext context)
    {
        // TODO
    }
}

En el método Initialize() es donde configuraremos los analizadores de código del proyecto que está siendo compilado, que se ejecutarán durante la primera fase. En este caso, crearemos un analizador que simplemente recopilará los nombres de las clases del proyecto cuyo nombre acabe en Operator.

Luego, en Execute(), generaremos el código de la enumeración Operators basándonos en la colección de clases que recopilamos anteriormente.

2.1. Obteniendo los nombres de las clases operador

Para obtener el nombre de las clases que el compilador va recorriendo durante la compilación, debemos crear una implementación de ISyntaxReceiver. El método OnVisitSyntaxNode() es invocado cada vez que el compilador encuentra un nodo sintáctico (clases, propiedades, métodos, etc.), por lo que podemos aprovechar ese momento para almacenar los nombres de las clases que nos interesen.

Como podéis ver, la implementación es trivial; cuando recibimos un nodo de tipo ClassDeclarationSyntax y su nombre acaba en "Operator", recortamos este sufijo y lo añadimos a una colección en memoria:

public class OperatorTypesCollector : ISyntaxReceiver
{
    public List<string> OperatorTypes { get; } = new ();
    public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
    {
        if (syntaxNode is ClassDeclarationSyntax c 
            && c.Identifier.Text.EndsWith("Operator"))
        {
            var name = c.Identifier.Text;
            OperatorTypes.Add(
                name.Substring(0, name.Length - "Operator".Length)
            );
        }
    }
}

Una vez creado, debemos registrar nuestro recolector en el método Initialize() del generador, de la siguiente manera. Como se puede intuir, lo que hacemos es registrar en el contexto del generador, mediante el método RegisterForSyntaxNotifications(), el delegado que se encargará de crear la clase que será notificada al visitar los nodos sintácticos:

[Generator]
public class OperatorsEnumGenerator : ISourceGenerator
{
    public void Initialize(GeneratorInitializationContext context)
    {
        context.RegisterForSyntaxNotifications(
            () => new OperatorTypesCollector()
        );
    }

    public void Execute(GeneratorExecutionContext context)
    {
        // TODO
    }
}    

2.2. Generando el código

Durante la primera pasada del compilador, nuestra clase OperatorTypesCollector se encargará de almacenar los nombres de los operadores que se han ido encontrando. Ya estamos en disposición de implementar el método Execute(), que es donde generaremos el código partiendo de esta información.

De nuevo, la cosa es bastante sencilla. Echad un vistazo al código y lo comentamos justo después:

public void Execute(GeneratorExecutionContext context)
{
    var collector = (OperatorTypesCollector)context.SyntaxReceiver;
    var names = string.Join(", ", collector.OperatorTypes);

    var code = @$"
namespace Shared
{{
    public enum Operators 
    {{
        {names}
    }}        
}}";

    context.AddSource("Operators.cs", SourceText.From(code, Encoding.UTF8));
}

Como podéis ver, lo único que hacemos es obtener una referencia hacia la instancia de nuestro ISyntaxReceiver para obtener de la lista de nombres que ha recopilado. El resto sólo consiste en construir el código fuente C# que vamos a emitir y añadirlo al proyecto con un nombre de archivo (que debe ser único).

De esta forma, si incluimos nuestro generador en un proyecto que contenga las clases SumOperator y MultiplyOperator, se añadirá al mismo el siguiente archivo de código fuente:

namespace Shared
{
    public enum Operators 
    {
        Sum, Multiply
    }        
}

3. Utilizando el generador desde otro proyecto

Como hemos comentado anteriormente, los generadores pueden ser añadidos fácilmente a los proyectos en los que queremos utilizarlos. La forma más sencilla de hacerlo es, sin duda, mediante una referencia directa entre proyectos.

En la captura de pantalla lateral podemos observar una solución con un proyecto llamado Generators, donde hemos implementado nuestro generador, y otro proyecto llamado Calculator desde donde vamos a consumirlo. Lo único a tener en cuenta es que este proyecto debe ser .NET 5 (aunque también puede ser .NET Core 3.1, siempre que se establezca la versión del lenguaje a preview).

Desde Calculator hemos añadido una referencia al proyecto de los generadores, indicando que se trata de un analizador de código en el archivo de proyecto .csproj:

<ItemGroup>
<ProjectReference 
    Include="..\Generators\Generators.csproj"
    OutputItemType="Analyzer" />
</ItemGroup>

Ahora, para probar el generador basta con introducir el siguiente código en el archivo Program.cs del proyecto principal:

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Operators:");
        foreach (var op in Enum.GetNames(typeof(Shared.Operators)))
        {
            Console.WriteLine(op);
        }        
    }
}

public class SumOperator { }
public class MultiplyOperator { }
// TODO: Implementar otros operadores

Como habéis podido comprobar, la aplicación define un par de clases que siguen la convención de nombrado para operadores (SumOperator y MultiplyOperator), y muestra por consola los miembros del enumerado Shared.Operators que es insertado en el proyecto de forma automática por el generador de código.

Y si ejecutamos la aplicación, el resultado será el esperado:

Operators:
Sum
Multiply

¡Y esto es todo, ya tenemos nuestro generador en marcha! Recapitulando un poco, el proceso ha consistido en:

  • Crear un proyecto de generadores de código, con:
    • Target .NET Standard 2.0.
    • Referencias a Microsoft.CodeAnalysis.CSharp y Microsoft.CodeAnalysis.Analyzers.
  • En dicho proyecto, añadir la clase del generador, que implementa ISourceGenerator y está decorada con [Generator]. En ella:
    • Registrar un ISyntaxReceiver para recopilar información del proyecto en su método Initialize().
    • Usar en el método Execute() la información recopilada para componer el contenido de los archivos de código fuente a generar, y añadirlos finalmente al proyecto.

Espero que este ejemplo os haya sido de utilidad para comprender cómo funcionan los generadores. Y si es así, seguro que ya estaréis pensando en otros escenarios para sacar partido a este potente mecanismo, que desde luego va a dar mucho juego en nuestros proyectos.

Publicado en Variable not found.

6 Comentarios:

Javier Campos dijo...

Aprovecho y dejo un comentario por aquí, que no ha dado tiempo a comentar hoy... he intentado probar esto para reemplazar un T4 que usamos, y he tenido montones de problemas.

El primero de todos, que hay muchas librerías que tienen sus propias versiones de los nuget (especialmente las que emiten IL, tipo autofac, automapper, etc.) de los generadores (al final la cadena de dependencias hace que no tengas la versión exacta del compilador que requiere el generador).

Esto se soluciona metiendo una referencia al nuget Microsoft.Net.Compilers -en donde referencias al proyecto del generador- (ojo: no en el propio proyecto del generador) de modo que tu código compila con la versión que tú quieres. Es solucionable, pero poco menos que un engorro (especialmente si cambias de versión con respecto a donde está el generador).

Pero la que me ha hecho no poder usarlo (o no querer, podría haber tirado para atrás ese uso), es que no parece que se pueda (o no sé cómo, mejor dicho) seleccionar la versión de C# o las opciones del compilador que genera el código que le pasas... están esas opciones (que te vienen en el contexto), pero son todas readonly, y no he encontrado la forma de modificarlo.

En mi caso, el código generado tenía las nullable references activas, y eran unos generics cuyas constraints eran cosas como where T1: not null, algo que no compila (ni warnings ni nada, no compila) si no tienes las nullable reference activadas.

He probado a activarlas en el proyecto del generador, a poner especificamente el ISourceGenerator en un bloque nullable, a activarlas globalmente en ambos proyectos, y no había manera (o, de nuevo, no sé cómo).

Pero si tienes alguna idea sobre todo esto y me he liado más de la cuenta, estaría encantado de alguna guía (si me puedo quitar los T4, mejor que mejor :-) )

Javier Campos dijo...

Por supuesto quería decir where T1: notnull, sin espacio... lo otro no compila ni con nullable references ni sin ellas :-D

José María Aguilar dijo...

Hola Javier!

Aún no me he visto en un escenario como el que comentas, por lo que no puedo darte pistas concretas al respecto.

Pero de todas formas, creo que no he entendido bien el problema. Desde tu generador creas el código como un string y ese código es compilado con el proyecto principal, usando la versión de C# definida en éste, por lo que no deberías encontrar problemas si tu código generado está alineado con ella. Me estoy perdiendo algo, ¿verdad?

Respecto a guías, todavía no existe mucha información más allá del par de posts oficiales y un repo con ejemplos.

Saludos!

Javier Campos dijo...

Pues eso es lo que yo suponía, pero si es que lo hace, el NullableReference del proyecto no lo tiene en cuenta (o eso parece), porque me da errores de compilación si pongo un constraint de ese tipo. O igual algo he cagado yo... seguiré haciendo pruebas!

Gracias y saludos!

Javier Campos dijo...

Ya he visto el problema, era que estaba usando la versión de Visual Studio 16.8.0 y parece que el compilador que trae (antes de la 16.8.2, parece ser) no tenía soporte para esto fuera del preview.

Actualizando a 16.9.2 se ha quitado el problema, pero parece que, o bien tienes siempre la misma versión del toolset del compilador metida en el nuget (Microsoft.Analyzers.CSharp, en este caso la 3.9.0.0), o dará algunos problemas (este no es -exactamente- el mismo problema, pero en la solución que le dan comentan esto: https://developercommunity.visualstudio.com/t/cs8032-an-instance-of-analyzer-microsoftcodeanalys/1252455 ).

Está muy bien esto, pero parece que te ata a que la versión de "Microsoft.CodeAnalysis.CSharp" que metas sea la misma que lleva el toolset de la versión de Visual Studio que tienes (por eso al meter el Microsoft.Net.Compilers especifico de la versión del CodeAnalysis que tenía el proyecto del generador, compilaba).

Es un problema menor para proyectos pequeños y que se hagan entre poca gente, pero en proyectos más grandes, puede haber problemas con ello (no deja de ser otro problema más de versiones del toolchain, que al final los tienes por algún lado o por otro en proyectos grandes... pero algo a tener en cuenta, desde luego).

Por cierto, lo de los nullables se fue el problema también al actualizar Visual Studio (me huelo que la llamada al ISourceGenerator la estaba haciendo desde un MSBuild que sería más antiguo, y por eso no cuadraba con el proyecto)

Pues solucionado! Ahora a discutir si merece la pena tener en cuenta el tema este de las versiones, o seguir con los T4 (que tienen otra suerte distinta de problemas, por supuesto :-) )

Saludos!

José María Aguilar dijo...

Genial, pues muchas gracias por comentarlo, igual puede ayudar a alguien más :)

Suerte en tu batalla!