Consideraciones sobre Genéricos, Serialización y Reflejo en AOT de C#

El desarrollo de aplicaciones nativas Ahead-Of-Time (AOT) en C# a menudo presenta desafíos relacionados con genéricos, serialización y reflexión. Este artículo explora las soluciones y estrategias para abordar estos problemas.

Desafíos y Soluciones

Genéricos

Los genéricos en C#, especialmente cuando se crean tipos cerrados a partir de tipos abiertos en tiempo de ejecución, pueden causar problemas en la compilación AOT. Si el compilador AOT no identifica y genera las instanciaciones necesarias, pueden ocurrir errores en tiempo de ejecución. Un ejemplo común es cuando un tipo genérico, como List<T>, se instancia dinámicamente con un tipo que no está en el grafo de dependencias del compilador.

Para mitigar esto sin modificar invasivamente el código fuente (por ejemplo, instantiondo directamente new List<Location>()), se puede utilizar el mecanismo de Directivas de Tiempo de Ejecución (Runtime Directives - RD). Las RD, definidas en archivos XML, permiten especificar explícitamente qué partes del código deben ser incluidas en el gráfico de compilación AOT. Esto incluye la inciialización manual de tipos genéricos o la designación de métodos genéricos como nodos raíz.

El compilador AOT utiliza la configuración compilationRoots para procesar las directivas definidas en los archivos RD. A continuación, se muestra un ejemplo de un archivo rd.xml para asegurar que List<Location> sea incluido:

<?xml version="1.0" encoding="utf-8" ?>
<Directives xmlns="http://schemas.microsoft.com/netfx/2013/01/metadata">
 <Application>
  <Assembly Name="Example_21_1">
   <Type Name="System.Collections.Generic.List`1[[Example_21_1.Location,Example_21_1]]" Dynamic="Required All" />
  </Assembly>
 </Application>
</Directives>

Este archivo XML debe ser referenciado en el archivo del proyecto (.csproj):

<Project Sdk="Microsoft.NET.Sdk">
 <PropertyGroup>
  <OutputType>Exe</OutputType>
  <TargetFramework>net8.0</TargetFramework>
  <PublishAot>true</PublishAot>
 </PropertyGroup>
 <ItemGroup>
  <RdXmlFile Include="rd.xml" />
 </ItemGroup>
</Project>

La directiva Dynamic="Required All" asegura que todos los miembros (métodos, propiedades, etc.) del tipo genérico especificado sean incluidos en el gráfico de compilación.

Serialización

La serialización, especialmante la de objetos complejos, a menudo depende en gran medida de la reflexión. Dado que la reflexión requiere metadatos extensos, muchas bibliotecas de serialización de terceros pueden tener problemas con AOT. Sin embargo, el serializador JSON integrado de .NET aborda esto mediante el uso de Generadores de Origen (Source Generators). Estos generadores migran los metadatos necesarios desde el ensamblado a código generado estáticamente (hardcoded), permitiendo la serialización JSON compatible con AOT.

El siguiente ejemplo demuestra cómo utilizar el generador de origen para la serialización JSON:

using System.Text.Json;
using System.Text.Json.Serialization;

namespace Example_21_1
{
    internal class Program
    {
        static void Main(string[] args)
        {
            var person = new Person
            {
                Name = "john",
                Age = 30,
                BirthDate = new DateTime(1993, 5, 15),
                Gender = "Mail"
            };

            var jsonString = JsonSerializer.Serialize(person, SourceGenerationContext.Default.Person);
            Console.WriteLine(jsonString);
        }
    }

    [JsonSerializable(typeof(Person))]
    internal partial class SourceGenerationContext : JsonSerializerContext { }

    public class Person
    {
        public int Age { get; set; }
        public string Name { get; set; }
        public DateTime BirthDate { get; set; }
        public string Gender { get; set; }
    }
}

Durante la depuración en Visual Studio, se generará un archivo SourceGenerationContext.Person.g.cs que contiene los metadatos de la clase Person, a menudo almacenados en arreglos de propiedades.

Reflección

La reflexión es otro aspecto donde AOT puede presentar dificultades. Mientras que la reflexión simple y predecible suele ser manejada por el compilador AOT, las operaciones de reflexión que dependen de un contexto más amplio pueden fallar. Esto se debe a que el compilador AOT prioriza la velocidad de compilación y puede no analizar operaciones de reflexión complejas que requieren un análisis computacional intensivo.

Consideremos el siguiente código que usa reflexión para obtener propiedades:

internal class Program
{
    static void Main(string[] args)
    {
        ProcessType(typeof(Person));
    }

    static void ProcessType(Type type)
    {
        var properties = type.GetProperties();
        foreach (var prop in properties)
        {
            Console.WriteLine(prop);
        }
    }
}

public class Person
{
    public int Age { get; set; }
    public string Name { get; set; }
    public DateTime BirthDate { get; set; }
    public string Gender { get; set; }
}

En un entorno AOT, este código podría no extraer las propiedades de Person porque el compilador no puede inferir la necesidad de los metadatos de las propiedades a través del parámetro Type type. Para observar este comportamiento, se puede compilar con PublishTrimmed=true y analizar el ensamblado resultante con una herramienta como ILSpy, donde la clase Person podría aparecer vacía de metadatos de propiedades.

Para instruir explícitamente al compilador AOT sobre qué miembros acceder, se puede usar el atributo DynamicallyAccessedMembers. Por ejemplo, para asegurar que todas las propiedades públicas sean accesibles:

static void ProcessType([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] Type type)
{
    var properties = type.GetProperties();
    foreach (var prop in properties)
    {
        Console.WriteLine(prop);
    }
}

Para una personalización más avanzada y menos invasiva, se pueden utilizar descriptores de raíz del trimmer en archivos XML externos, similares a las RD. Estos descriptores permiten especificar con precisión qué miembros de un tipo deben conservarse, como excluir el campo Gender en el ejemplo de Person.

Un archivo link.xml para excluir un campo específico podría verse así:

<?xml version="1.0" encoding="utf-8" ?>
<linker>
 <assembly fullname="Example_21_1">
  <type fullname="Example_21_1.Person">
   <property signature="System.Int32 Age" />
   <property signature="System.String Name" />
   <property signature="System.DateTime BirthDate" />
  </type>
 </assembly>
</linker>

Este archivo XML se integra en el proceso de publicación AOT a través de la configuración del archivo del proyecto:

<Project Sdk="Microsoft.NET.Sdk">
 <PropertyGroup>
  <OutputType>Exe</OutputType>
  <TargetFramework>net8.0</TargetFramework>
  <PublishAot>true</PublishAot>
 </PropertyGroup>
 <ItemGroup>
  <TrimmerRootDescriptor Include="link.xml" />
 </ItemGroup>
</Project>

El uso de estas técnicas asegura que los metadatos necesarios para la reflexión sean incluidos en la compilación AOT, permitiendo que el código se ejecute correctamente.

Conclusión

La publicación de aplicaciones como AOT nativas puede presentar complejidades, especialmente con genéricos, serialización y reflexión. Las Directivas de Tiempo de Ejecución (RD) y los descriptores de raíz del trimmer ofrecen enfoques no invasivos para guiar al compilador AOT y asegurar que todos los componentes necesarios sean incluidos en la compilación final.

Etiquetas: AOT C# Genéricos Serialización Reflejo

Publicado el 10-17 22:43