
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
yMicrosoft.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étodoInitialize()
. - 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.
- Registrar un
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.