Cómo hacer el serializador .NET más rápido con .NET 7 / C# 11, caso de MemoryPack
Lancé un nuevo serializador llamado MemoryPack , un nuevo serializador específico de C# que funciona mucho más rápido que otros serializadores.

En comparación con MessagePack para C# , un serializador binario rápido, el rendimiento es varias veces más rápido para los objetos estándar e incluso entre 50 y 100 veces más rápido cuando los datos son óptimos. El mejor soporte es .NET 7, pero ahora es compatible con .NET Standard 2.1 (.NET 5, 6), Unity e incluso TypeScript. También admite polimorfismo (Unión), referencias circulares tolerantes a versiones completas y las últimas API de E/S modernas (IBufferWriter, ReadOnlySeqeunce, Pipelines).
El rendimiento del serializador se basa tanto en la "especificación del formato de datos" como en la "implementación en cada idioma". Por ejemplo, mientras que los formatos binarios generalmente tienen una ventaja sobre los formatos de texto (como JSON), es posible tener un serializador JSON que sea más rápido que un serializador binario (como se demostró con Utf8Json ). Entonces, ¿cuál es el serializador más rápido? Cuando llega a la especificación y la implementación, nace el verdadero serializador más rápido.
He desarrollado y sigo desarrollando y manteniendo MessagePack para C# durante muchos años, y MessagePack para C# es un serializador muy exitoso en el mundo .NET, con más de 4000 GitHub Stars. También ha sido adoptado por productos estándar de Microsoft como Visual Studio 2022, SignalR MessagePack Hub Protocol y el protocolo Blazor Server (blazorpack).
En los últimos 5 años, también he procesado cerca de 1000 problemas. He estado trabajando en la compatibilidad con AOT con generadores de código usando Roslyn desde hace 5 años y lo he demostrado, especialmente en Unity, un entorno AOT (IL2CPP) y muchos juegos móviles de Unity que lo usan.
Además de MessagePack para C#, he creado serializadores como ZeroFormatter (formato propio) y Utf8Json (JSON), que han recibido muchas GitHub Stars, por lo que tengo un conocimiento profundo de las características de rendimiento de los diferentes formatos. Además, he estado involucrado en la creación del marco RPC MagicOnion , la base de datos en memoria MasterMemory , el cliente PubSub AlterNats y las implementaciones de cliente (Unity)/servidor de varios títulos de juegos.
El objetivo de MemoryPack es ser el último serializador rápido, práctico y versátil. Y creo que lo logré.
Generador de fuente incremental
MemoryPack adopta por completo el Generador de fuente incremental mejorado en .NET 6. En términos de uso, no es tan diferente de MessagePack para C#, excepto por cambiar el tipo de destino a parcial.
using MemoryPack;
// Source Generator makes serialize/deserialize code
[MemoryPackable]
public partial class Person
{
public int Age { get; set; }
public string Name { get; set; }
}
// usage
var v = new Person { Age = 40, Name = "John" };
var bin = MemoryPackSerializer.Serialize(v);
var val = MemoryPackSerializer.Deserialize<Person>(bin);

Source Generator también sirve como analizador, por lo que puede detectar si se puede serializar de forma segura emitiendo un error de compilación en el momento de la edición.
Tenga en cuenta que la versión de Unity utiliza el generador de origen antiguo en lugar del generador de origen incremental debido a razones de idioma/versión del compilador.
Especificación binaria para C#
El lema de MemoryPack es "Codificación cero". Esta no es una historia especial; El principal serializador binario de Rust, bincode , por ejemplo, tiene una especificación similar. FlatBuffers también lee y escribe contenido similar a los datos de la memoria sin analizar la implementación.
Sin embargo, a diferencia de FlatBuffers y otros, MemoryPack es un serializador de propósito general que no requiere un tipo especial y serializa/deserializa contra POCO. También tiene versiones tolerantes a las adiciones de miembros de esquema y soporte de polimorfismo (Unión).
codificación variable vs fija
Int32 tiene 4 bytes, pero en JSON, por ejemplo, los números se codifican como cadenas con una codificación de longitud variable de 1 a 11 bytes (p. ej., 1 o -2147483648). Muchos formatos binarios también tienen especificaciones de codificación de longitud variable de 1 a 5 bytes para ahorrar tamaño. Por ejemplo, el tipo numérico de Protocol Buffers tiene una codificación de enteros de longitud variable que almacena el valor en 7 bits y el indicador de presencia o ausencia de seguimiento en 1 bit (varint). Esto significa que cuanto menor sea el número, menos bytes se requieren. Por el contrario, en el peor de los casos, el número aumentará a 5 bytes, que es mayor que los 4 bytes originales. Paquete de mensajes y CBORse procesan de manera similar utilizando codificación de longitud variable, con un mínimo de 1 byte para números pequeños y un máximo de 5 bytes para números grandes.
Esto significa que varint ejecuta un procesamiento adicional que en el caso de longitud fija. Comparemos los dos en código concreto. La longitud variable es la codificación varint + ZigZag (los números negativos y positivos se combinan) que se usa en protobuf.
// Fixed encoding
static void WriteFixedInt32(Span<byte> buffer, int value)
{
ref byte p = ref MemoryMarshal.GetReference(buffer);
Unsafe.WriteUnaligned(ref p, value);
}
// Varint encoding
static void WriteVarInt32(Span<byte> buffer, int value) => WriteVarInt64(buffer, (long)value);
static void WriteVarInt64(Span<byte> buffer, long value)
{
ref byte p = ref MemoryMarshal.GetReference(buffer);
ulong n = (ulong)((value << 1) ^ (value >> 63));
while ((n & ~0x7FUL) != 0)
{
Unsafe.WriteUnaligned(ref p, (byte)((n & 0x7f) | 0x80));
p = ref Unsafe.Add(ref p, 1);
n >>= 7;
}
Unsafe.WriteUnaligned(ref p, (byte)n);
}
Esto es aún más pronunciado cuando se aplica a matrices.
// https://sharplab.io/
Inspect.Heap(new int[]{ 1, 2, 3, 4, 5 });
// Fixed-length(MemoryPack)
void Serialize(int[] value)
{
// Size can be calculated and allocate in advance
var size = (sizeof(int) * value.Length) + 4;
EnsureCapacity(size);
// MemoryCopy once
MemoryMarshal.AsBytes(value.AsSpan()).CopyTo(buffer);
}
// Variable-length(MessagePack)合
void Serialize(int[] value)
{
foreach (var item in value)
{
// Unknown size, so check size each times
EnsureCapacity(); // if (buffer.Length < writeLength) Resize();
// Variable length encoding per element
WriteVarInt32(item);
}
}
Las matrices en C# no son solo tipos primitivos como int, esto también es cierto para estructuras con varias primitivas, por ejemplo, una matriz Vector3 con (float x, float y, float z) tendría el siguiente diseño de memoria.

Un flotante (4 bytes) es una longitud fija de 5 bytes en MessagePack. El 1 byte adicional está precedido por un identificador que indica de qué tipo es el valor (Int, Float, String…). Específicamente, [0xca, x, x, x, x, x]. El formato MemoryPack no tiene identificador, por lo que se escriben 4 bytes tal cual.
Considere Vector3[10000], que fue 50 veces mejor que el punto de referencia.
// these fields exists in type
// byte[] buffer
// int offset
void SerializeMemoryPack(Vector3[] value)
{
// only do copy once
var size = Unsafe.SizeOf<Vector3>() * value.Length;
if ((buffer.Length - offset) < size)
{
Array.Resize(ref buffer, buffer.Length * 2);
}
MemoryMarshal.AsBytes(value.AsSpan()).CopyTo(buffer.AsSpan(0, offset))
}
void SerializeMessagePack(Vector3[] value)
{
// Repeat for array length x number of fields
foreach (var item in value)
{
// X
{
// EnsureCapacity
// (Actually, create buffer-linked-list with bufferWriter.Advance, not Resize)
if ((buffer.Length - offset) < 5)
{
Array.Resize(ref buffer, buffer.Length * 2);
}
var p = MemoryMarshal.GetArrayDataReference(buffer);
Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset), (byte)0xca);
Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset + 1), item.X);
offset += 5;
}
// Y
{
if ((buffer.Length - offset) < 5)
{
Array.Resize(ref buffer, buffer.Length * 2);
}
var p = MemoryMarshal.GetArrayDataReference(buffer);
Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset), (byte)0xca);
Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset + 1), item.Y);
offset += 5;
}
// Z
{
if ((buffer.Length - offset) < 5)
{
Array.Resize(ref buffer, buffer.Length * 2);
}
var p = MemoryMarshal.GetArrayDataReference(buffer);
Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset), (byte)0xca);
Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset + 1), item.Z);
offset += 5;
}
}
}
Con MemoryPack, sólo una única copia de memoria. Esto cambiaría literalmente el tiempo de procesamiento en un orden de magnitud y es la razón de la aceleración de 50x~100x en el gráfico al comienzo de este artículo.
Por supuesto, el proceso de deserialización también es una copia única.
// Deserialize of MemoryPack, only copy
Vector3[] DeserializeMemoryPack(ReadOnlySpan<byte> buffer, int size)
{
var dest = new Vector3[size];
MemoryMarshal.Cast<byte, Vector3>(buffer).CopyTo(dest);
return dest;
}
// Require read float many times in loop
Vector3[] DeserializeMessagePack(ReadOnlySpan<byte> buffer, int size)
{
var dest = new Vector3[size];
for (int i = 0; i < size; i++)
{
var x = ReadSingle(buffer);
buffer = buffer.Slice(5);
var y = ReadSingle(buffer);
buffer = buffer.Slice(5);
var z = ReadSingle(buffer);
buffer = buffer.Slice(5);
dest[i] = new Vector3(x, y, z);
}
return dest;
}
Sin embargo, la mayoría de la gente probablemente no lo use, y nadie usaría una opción propietaria que haría que MessagePack fuera incompatible.
Entonces, con MemoryPack, quería una especificación que ofreciera el mejor rendimiento como C# de forma predeterminada.
Optimización de cadenas
MemoryPack tiene dos especificaciones para String: UTF8 o UTF16. dado que la cadena C# es UTF16, serializarla como UTF16 ahorra el costo de codificar/descodificar a UTF8.
void EncodeUtf16(string value)
{
var size = value.Length * 2;
EnsureCapacity(size);
// Span<char> -> Span<byte> -> Copy
MemoryMarshal.AsBytes(value.AsSpan()).CopyTo(buffer);
}
string DecodeUtf16(ReadOnlySpan<byte> buffer, int length)
{
ReadOnlySpan<char> src = MemoryMarshal.Cast<byte, char>(buffer).Slice(0, length);
return new string(src);
}
Sin embargo, incluso con UTF8, MemoryPack tiene algunas optimizaciones que otros serializadores no tienen.
// fast
void WriteUtf8MemoryPack(string value)
{
var source = value.AsSpan();
var maxByteCount = (source.Length + 1) * 3;
EnsureCapacity(maxByteCount);
Utf8.FromUtf16(source, dest, out var _, out var bytesWritten, replaceInvalidSequences: false);
}
// slow
void WriteUtf8StandardSerializer(string value)
{
var maxByteCount = Encoding.UTF8.GetByteCount(value);
EnsureCapacity(maxByteCount);
Encoding.UTF8.GetBytes(value, dest);
}
Por lo general, los serializadores pueden reservar un búfer generoso. Por lo tanto, MemoryPack asigna tres veces la longitud de la cadena, que es el peor de los casos para la codificación UTF8, para evitar el doble recorrido.
En el caso de la decodificación, se aplican optimizaciones especiales adicionales.
// fast
string ReadUtf8MemoryPack(int utf16Length, int utf8Length)
{
unsafe
{
fixed (byte* p = &buffer)
{
return string.Create(utf16Length, ((IntPtr)p, utf8Length), static (dest, state) =>
{
var src = MemoryMarshal.CreateSpan(ref Unsafe.AsRef<byte>((byte*)state.Item1), state.Item2);
Utf8.ToUtf16(src, dest, out var bytesRead, out var charsWritten, replaceInvalidSequences: false);
});
}
}
}
// slow
string ReadStandardSerialzier(int utf8Length)
{
return Encoding.UTF8.GetString(buffer.AsSpan(0, utf8Length));
}
var length = CalcUtf16Length(utf8data);
var str = String.Create(length);
Encoding.Utf8.DecodeToString(utf8data, str);
Sin embargo, MemoryPack registra tanto UTF16-Length como UTF8-Length en el encabezado. Por lo tanto, la combinación de String.Create<TState>(Int32, TState, SpanAction<Char,TState>) y Utf8.ToUtf16 proporciona la descodificación más eficiente para C# String.
Sobre el tamaño de la carga útil
La codificación de longitud fija de números enteros se puede inflar en tamaño en comparación con la codificación de longitud variable. Sin embargo, en la era moderna, el uso de codificación de longitud variable solo para reducir el tamaño pequeño de los números enteros es más una desventaja.
Dado que los datos no son solo números enteros, si realmente desea reducir el tamaño, debe considerar la compresión ( LZ4 , ZStandard , Brotli , etc.), y si comprime los datos, casi no tiene sentido la codificación de longitud variable. Si desea ser más especializado y más pequeño, la compresión orientada a columnas le dará mejores resultados (por ejemplo, Apache Parquet ).
Para una compresión eficiente integrada con la implementación de MemoryPack, actualmente tengo clases auxiliares para BrotliEncode/Decode como estándar.
También tengo varios atributos que aplican una compresión especial a ciertas columnas primitivas, como la compresión de columnas.
[MemoryPackable]
public partial class Sample
{
public int Id { get; set; }
[BitPackFormatter]
public bool[] Data { get; set; }
[BrotliFormatter]
public byte[] Payload { get; set; }
}
BrotliFormatter aplica directamente el algoritmo de compresión. En realidad, esto funciona mejor que comprimir todo el archivo.

Esto se debe a que no se necesita una copia intermedia y el proceso de compresión se puede aplicar directamente a los datos serializados.
El método para extraer el rendimiento y la relación de compresión mediante la aplicación de procesamiento personalizado según los datos, en lugar de una simple compresión general, se detalla en el artículo Reducción del costo de registro en dos órdenes de magnitud con CLP en el blog de ingeniería de Uber.
Uso de las nuevas características de .NET 7/C#11
MemoryPack tiene firmas de métodos ligeramente diferentes en la implementación de .NET Standard 2.1 y la implementación de .NET 7. .NET 7 es una implementación más agresiva y orientada al rendimiento que aprovecha las características más recientes del lenguaje.
Primero, la interfaz del serializador utiliza miembros abstractos estáticos de la siguiente manera
public interface IMemoryPackable<T>
{
// note: serialize parameter should be `ref readonly` but current lang spec can not.
// see proposal https://github.com/dotnet/csharplang/issues/6010
static abstract void Serialize<TBufferWriter>(ref MemoryPackWriter<TBufferWriter> writer, scoped ref T? value)
where TBufferWriter : IBufferWriter<byte>;
static abstract void Deserialize(ref MemoryPackReader reader, scoped ref T? value);
}
[MemortyPackable]
partial class Foo : IMemoryPackable
{
static void IMemoryPackable<Foo>.Serialize<TBufferWriter>(ref MemoryPackWriter<TBufferWriter> writer, scoped ref Foo? value)
{
}
static void IMemoryPackable<Foo>.Deserialize(ref MemoryPackReader reader, scoped ref Foo? value)
{
}
}
public void WritePackable<T>(scoped in T? value)
where T : IMemoryPackable<T>
{
// If T is IMemoryPackable, call static method directly
T.Serialize(ref this, ref Unsafe.AsRef(value));
}
//
public void WriteValue<T>(scoped in T? value)
{
// call Serialize from interface virtual method
IMemoryPackFormatter<T> formatter = MemoryPackFormatterProvider.GetFormatter<T>();
formatter.Serialize(ref this, ref Unsafe.AsRef(value));
}
public ref struct MemoryPackWriter<TBufferWriter>
where TBufferWriter : IBufferWriter<byte>
{
ref TBufferWriter bufferWriter;
ref byte bufferReference;
int bufferLength;
// internally MemoryPack uses some struct buffer-writers
struct BrotliCompressor : IBufferWriter<byte>
struct FixedArrayBufferWriter : IBufferWriter<byte>
Por ejemplo, las colecciones se pueden serializar o deserializar como IEnumerable<T> para una implementación común, pero MemoryPack proporciona una implementación independiente para todos los tipos. Para simplificar, List<T> se puede procesar como
public void Serialize(ref MemoryPackWriter writer, IEnumerable<T> value)
{
foreach(var item in source)
{
writer.WriteValue(item);
}
}
public void Serialize(ref MemoryPackWriter writer, List<T> value)
{
foreach(var item in source)
{
writer.WriteValue(item);
}
}
Sin embargo, MemoryPack lo ha optimizado aún más.
public sealed class ListFormatter<T> : MemoryPackFormatter<List<T?>>
{
public override void Serialize<TBufferWriter>(ref MemoryPackWriter<TBufferWriter> writer, scoped ref List<T?>? value)
{
if (value == null)
{
writer.WriteNullCollectionHeader();
return;
}
writer.WriteSpan(CollectionsMarshal.AsSpan(value));
}
}
// MemoryPackWriter.WriteSpan
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void WriteSpan<T>(scoped Span<T?> value)
{
if (!RuntimeHelpers.IsReferenceOrContainsReferences<T>())
{
DangerousWriteUnmanagedSpan(value);
return;
}
var formatter = GetFormatter<T>();
WriteCollectionHeader(value.Length);
for (int i = 0; i < value.Length; i++)
{
formatter.Serialize(ref this, ref value[i]);
}
}
// MemoryPackWriter.DangerousWriteUnmanagedSpan
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void DangerousWriteUnmanagedSpan<T>(scoped Span<T> value)
{
if (value.Length == 0)
{
WriteCollectionHeader(0);
return;
}
var srcLength = Unsafe.SizeOf<T>() * value.Length;
var allocSize = srcLength + 4;
ref var dest = ref GetSpanReference(allocSize);
ref var src = ref Unsafe.As<T, byte>(ref MemoryMarshal.GetReference(value));
Unsafe.WriteUnaligned(ref dest, value.Length);
Unsafe.CopyBlockUnaligned(ref Unsafe.Add(ref dest, 4), ref src, (uint)srcLength);
Advance(allocSize);
}
En el caso de Deserialize, también hay algunas optimizaciones interesantes. En primer lugar, la deserialización de MemoryPack acepta una referencia T? valor, y si el valor es nulo, sobrescribirá el objeto generado internamente (al igual que un serializador normal), si se pasa el valor. Esto permite la asignación cero de la creación de nuevos objetos durante la deserialización. Las colecciones también se reutilizan llamando a Clear() en el caso de List<T>.
Luego, al realizar una llamada Span especial, todo se maneja como Spans, lo que evita la sobrecarga adicional de List<T>.Add.
public sealed class ListFormatter<T> : MemoryPackFormatter<List<T?>>
{
public override void Deserialize(ref MemoryPackReader reader, scoped ref List<T?>? value)
{
if (!reader.TryReadCollectionHeader(out var length))
{
value = null;
return;
}
if (value == null)
{
value = new List<T?>(length);
}
else if (value.Count == length)
{
value.Clear();
}
var span = CollectionsMarshalEx.CreateSpan(value, length);
reader.ReadSpanWithoutReadLengthHeader(length, ref span);
}
}
internal static class CollectionsMarshalEx
{
/// <summary>
/// similar as AsSpan but modify size to create fixed-size span.
/// </summary>
public static Span<T?> CreateSpan<T>(List<T?> list, int length)
{
list.EnsureCapacity(length);
ref var view = ref Unsafe.As<List<T?>, ListView<T?>>(ref list);
view._size = length;
return view._items.AsSpan(0, length);
}
// NOTE: These structure depndent on .NET 7, if changed, require to keep same structure.
internal sealed class ListView<T>
{
public T[] _items;
public int _size;
public int _version;
}
}
// MemoryPackReader.ReadSpanWithoutReadLengthHeader
public void ReadSpanWithoutReadLengthHeader<T>(int length, scoped ref Span<T?> value)
{
if (length == 0)
{
value = Array.Empty<T>();
return;
}
if (!RuntimeHelpers.IsReferenceOrContainsReferences<T>())
{
if (value.Length != length)
{
value = AllocateUninitializedArray<T>(length);
}
var byteCount = length * Unsafe.SizeOf<T>();
ref var src = ref GetSpanReference(byteCount);
ref var dest = ref Unsafe.As<T, byte>(ref MemoryMarshal.GetReference(value)!);
Unsafe.CopyBlockUnaligned(ref dest, ref src, (uint)byteCount);
Advance(byteCount);
}
else
{
if (value.Length != length)
{
value = new T[length];
}
var formatter = GetFormatter<T>();
for (int i = 0; i < length; i++)
{
formatter.Deserialize(ref this, ref value[i]);
}
}
}
Pero CollectionsMarshal.AsSpan, obtendrá un intervalo de longitud 0, porque el tamaño interno no cambia. Si tuviéramos CollectionMarshals.AsMemory, podríamos obtener la matriz sin procesar desde allí con un combo MemoryMarshal.TryGetArray, pero desafortunadamente no hay forma de obtener la matriz original del Span. Entonces, fuerzo la estructura de tipo para que coincida con Unsafe.As y cambio List<T>._size, pude obtener la matriz interna expandida.
De esa manera, podríamos optimizar el tipo no administrado para simplemente copiarlo y evitar List<T>.Add (que verifica el tamaño de la matriz cada vez) y empaquetar los valores a través de Span<T>[index], que es mucho más alto que la deserialización de un serializador convencional. actuación.
Si bien la optimización de List<T> es representativa, hay demasiadas otras para presentar, todos los tipos se analizaron y se aplicó la mejor optimización posible a cada uno.
Serialize acepta IBufferWriter<byte> como su estructura nativa y Deserialize acepta ReadOnlySpan<byte> y ReadOnlySequence<byte>.
Esto se debe a que System.IO.Pipelines requiere estos tipos . En otras palabras, dado que es la base del servidor de ASP .NET Core (Kestrel), puede esperar una serialización de mayor rendimiento conectándose directamente a él.
IBufferWriter<byte> es particularmente importante porque puede escribir directamente en el búfer, logrando así una copia cero en el proceso de serialización. La compatibilidad con IBufferWriter<byte> es un requisito previo para los serializadores modernos, ya que ofrece un mayor rendimiento que usar byte[] o Stream. El serializador para el gráfico al principio (System.Text.Json, protobuf-net, Microsoft.Orleans.Serialization, MessagePack for C# y MemoryPack) lo admite.
Paquete de mensajes frente a paquete de memoria
MessagePack para C# es muy fácil de usar y tiene un rendimiento excelente. En particular, los siguientes puntos son mejores que MemoryPack
- Excelente compatibilidad entre idiomas
- Compatibilidad con JSON (especialmente para claves de cadena) y legibilidad humana
- Perfecta versión tolerante por defecto
- Serialización de objetos y tipos anónimos.
- deserialización dinámica
- compresión LZ4 incrustada
- Larga estabilidad probada
Sin embargo, es superior a MessagePack de las siguientes maneras
- Rendimiento, especialmente para arreglos de tipos no administrados
- Soporte AOT fácil de usar
- Método de construcción de polimorfismo extendido (unión)
- Soporte para referencias circulares
- Sobrescribir deserialización
- Generación de código TypeScript
- Formateador personalizado basado en atributos flexible
MemoryPack no es un serializador experimental que solo se enfoca en el rendimiento, sino que también pretende ser un serializador práctico. Con este fin, también me he basado en mi experiencia con MessagePack para C# para proporcionar una serie de funciones.
- Admite API de E/S modernas (`IBufferWriter<byte>`, `ReadOnlySpan<byte>`, `ReadOnlySequence<byte>`)
- Generación de código basada en el generador de código compatible con AOT nativo, sin Dynamic CodeGen (IL.Emit)
- API no genéricas sin reflexión
- Deserializar en instancia existente
- Serialización de polimorfismo (Unión)
- compatibilidad limitada con tolerancia a la versión (rápido/predeterminado) y compatibilidad completa con tolerancia a la versión
- Serialización de referencia circular
- Serialización de transmisión basada en PipeWriter/Reader
- Generación de código TypeScript y ASP.NET Core Formatter
- Unity (2021.3) Compatibilidad con IL2CPP a través del generador de fuentes .NET