Personalización de la serialización en Orleans
Un aspecto importante de Orleans es su compatibilidad con la personalización de la serialización, que es el proceso de convertir un objeto o una estructura de datos en un formato que se puede almacenar o transmitir y, posteriormente, reconstruir. Esto permite a los desarrolladores controlar cómo se codifican y descodifican los datos cuando se envían entre las distintas partes del sistema. La personalización de la serialización puede resultar útil para optimizar el rendimiento, la interoperabilidad y la seguridad.
Proveedores de serialización
Orleans ofrece dos implementaciones de serializador:
Para configurar cualquiera de estos paquetes, consulte Configuración de serialización en Orleans.
Implementación personalizada de un serializador
La creación de la implementación personalizada de un serializador implica algunos pasos comunes. Tiene que implementar varias interfaces y, luego, registrar el serializador con el runtime de Orleans. En las secciones siguientes, se describen los pasos con más detalle.
Para empezar, implemente las interfaces de serialización de Orleans siguientes:
- IGeneralizedCodec: códec que admite varios tipos.
- IGeneralizedCopier: proporciona funcionalidad para copiar objetos de varios tipos.
- ITypeFilter: funcionalidad para permitir que se carguen los tipos y participen en la serialización y deserialización.
Piense en el ejemplo siguiente de la implementación personalizada de un serializador:
internal sealed class CustomOrleansSerializer :
IGeneralizedCodec, IGeneralizedCopier, ITypeFilter
{
void IFieldCodec.WriteField<TBufferWriter>(
ref Writer<TBufferWriter> writer,
uint fieldIdDelta,
Type expectedType,
object value) =>
throw new NotImplementedException();
object IFieldCodec.ReadValue<TInput>(
ref Reader<TInput> reader, Field field) =>
throw new NotImplementedException();
bool IGeneralizedCodec.IsSupportedType(Type type) =>
throw new NotImplementedException();
object IDeepCopier.DeepCopy(object input, CopyContext context) =>
throw new NotImplementedException();
bool IGeneralizedCopier.IsSupportedType(Type type) =>
throw new NotImplementedException();
}
En la implementación del ejemplo anterior:
- Cada interfaz se implementa explícitamente para evitar conflictos con la resolución de nombres de método.
- Cada método genera una excepción NotImplementedException para indicar que el método no está implementado. Deberá implementar cada método para proporcionar la funcionalidad deseada.
El paso siguiente es registrar el serializador con el runtime de Orleans. Por lo general, esto se logra mediante la extensión de ISerializerBuilder y la exposición de un método de extensión AddCustomSerializer
personalizado. En el ejemplo siguiente, se muestra el patrón típico:
using Microsoft.Extensions.DependencyInjection;
using Orleans.Serialization;
using Orleans.Serialization.Serializers;
using Orleans.Serialization.Cloning;
public static class SerializationHostingExtensions
{
public static ISerializerBuilder AddCustomSerializer(
this ISerializerBuilder builder)
{
var services = builder.Services;
services.AddSingleton<CustomOrleansSerializer>();
services.AddSingleton<IGeneralizedCodec, CustomOrleansSerializer>();
services.AddSingleton<IGeneralizedCopier, CustomOrleansSerializer>();
services.AddSingleton<ITypeFilter, CustomOrleansSerializer>();
return builder;
}
}
Otras posibilidades serían exponer una sobrecarga que acepte opciones de serialización personalizadas específicas para su implementación personalizada. Estas opciones se pueden configurar junto con el registro en el generador. Estas opciones se pueden insertar como dependencias en la implementación personalizada del serializador.
Orleans admite la integración con serializadores de terceros mediante un modelo de proveedor. Esto requiere una implementación del tipo IExternalSerializer, que se describe en la sección de este artículo dedicada a la serialización personalizada. Orleans mantiene integraciones para algunos serializadores comunes, por ejemplo:
- Búferes de protocolo: Orleans.Serialization.ProtobufSerializer del paquete NuGet Microsoft.Orleans.OrleansGoogleUtils.
- Bond: Orleans.Serialization.BondSerializer a partir del paquete NuGet Microsoft.Orleans.Serialization.Bond.
- Newtonsoft.Json: Orleans.Serialization.OrleansJsonSerializer a partir de la biblioteca principal de Orleans.
La implementación personalizada de IExternalSerializer
se describe en la sección siguiente.
Serializadores externos personalizados
Además de la generación de serialización automática, el código de la aplicación puede proporcionar serialización personalizada para los tipos que elija. Orleans recomienda usar la generación de serialización automática para la mayoría de los tipos de la aplicación y escribir serializadores personalizados solo en las raras ocasiones en las que crea que es posible mejorar el rendimiento mediante serializadores de codificación manual. Aquí se describe cómo hacerlo y se identifican algunos casos específicos en los que puede resultar útil.
Las aplicaciones pueden personalizar la serialización de tres maneras:
- Agregando métodos de serialización al tipo y marcándolos con los atributos adecuados (CopierMethodAttribute, SerializerMethodAttribute, DeserializerMethodAttribute). Este método es preferible para los tipos que posee la aplicación, es decir, los tipos a los que se pueden agregar métodos nuevos.
- Implementando
IExternalSerializer
y registrándolo en tiempo de configuración. Este método es útil para integrar una biblioteca de serialización externa. - Escribiendo una clase estática independiente anotada con
[Serializer(typeof(YourType))]
con los tres métodos de serialización y los mismos atributos que antes. Este método es útil para los tipos que la aplicación no posee, por ejemplo, los tipos definidos en otras bibliotecas sobre los que la aplicación no tiene control.
Cada uno de estos métodos de serialización se detalla en las secciones siguientes.
Introducción a la serialización personalizada
La serialización de Orleans se produce en tres fases:
- Los objetos se copian en profundidad de inmediato para garantizar el aislamiento.
- Antes de transmitirlos, los objetos se serializan en una secuencia de bytes de mensaje.
- Cuando se entregan a la activación de destino, los objetos se vuelven a crear (deserializan) a partir de la secuencia de bytes recibida.
Los tipos de datos que se pueden enviar en mensajes (es decir, los tipos que se pueden pasar como argumentos de método o valores devueltos) deben tener rutinas asociadas que realicen estos tres pasos. Estas rutinas se conocen en conjunto como serializadores de un tipo de datos.
El copiador de un tipo es independiente, mientras que el serializador y el deserializador son un par de elementos que funcionan juntos. Puede proporcionar solo un copiador personalizado, o simplemente un serializador personalizado y un deserializador personalizado, o bien puede proporcionar implementaciones personalizadas de los tres.
Los serializadores se registran para cada tipo de datos admitido en el inicio del silo y cada vez que se carga un ensamblado. Es necesario el registro en el caso de las rutinas de los serializadores personalizados para que se use un tipo. La selección del serializador se basa en el tipo dinámico del objeto que se va a copiar o serializar. Por este motivo, no es necesario crear serializadores para interfaces o clases abstractas, ya que nunca se usarán.
Cuándo escribir un serializador personalizado
Una rutina de serializador diseñada de manera manual rara vez funcionará mejor que las versiones generadas. Si le tienta la idea de escribir una, primero debe tener en cuenta las opciones siguientes:
Si hay campos o propiedades dentro de los tipos de datos que no tienen que serializarse o copiarse, márquelos con NonSerializedAttribute. Esto hará que el código generado omita estos campos al copiar y serializar. Use ImmutableAttribute y Immutable<T> siempre que sea posible para evitar copiar datos inmutables. Para más información, consulte Optimización de la copia. Si está intentando evitar usar los tipos de colección genéricos estándar, no lo haga. El runtime de Orleans contiene serializadores personalizados para las colecciones genéricas que usan la semántica de las colecciones para optimizar el proceso de copia, serialización y deserialización. Estas colecciones también tienen representaciones "abreviadas" especiales en la secuencia de bytes serializada, lo que da lugar a todavía más ventajas de rendimiento. Por ejemplo,
Dictionary<string, string>
será más rápido queList<Tuple<string, string>>
.El caso más común en el que un serializador personalizado puede proporcionar una mejora de rendimiento notable es cuando hay información semántica significativa codificada en el tipo de datos que no está disponible si simplemente se copian los valores de campo. Por ejemplo, las matrices poco llenas a menudo pueden serializarse de forma más eficaz si se trata la matriz como una colección de pares índice-valor, incluso si la aplicación conserva los datos como una matriz completa para acelerar el funcionamiento.
Antes de escribir un serializador personalizado es fundamental asegurarse de que el serializador generado afecta al rendimiento. La generación de perfiles ayudará en cierto modo, pero resultará aún más valiosa la realización de pruebas de esfuerzo de un extremo a otro de la aplicación con diferentes cargas de serialización para medir el impacto en el nivel del sistema, en lugar del microimpacto de la serialización. Por ejemplo, si se crea una versión de prueba que no pasa ningún parámetro a los métodos de grano ni resultados desde estos, sino que simplemente usa valores predefinidos en cada extremo, se podrá comprender mejor el impacto de la serialización y la copia en el rendimiento del sistema.
Adición de métodos de serialización a un tipo
Todas las rutinas de serializador deben implementarse como miembros estáticos de la clase o la estructura en las que operan. Los nombres que se muestran aquí no son necesarios; el registro se basa en la presencia de los atributos respectivos, no en los nombres de método. Tenga en cuenta que no hace falta que los métodos de serializador sean públicos.
A menos que implemente las tres rutinas de serialización, debe marcar el tipo con SerializableAttribute para que se generen automáticamente los métodos que faltan.
Copiador
Los métodos de copiador se marcan con Orleans.CodeGeneration.CopierMethodAttribute:
[CopierMethod]
static private object Copy(object input, ICopyContext context)
{
// ...
}
Los copiadores suelen ser las rutinas de serializador más sencillas que se pueden escribir. Toman un objeto, con la garantía de que es del mismo tipo que el tipo en el que se define el copiador, y deben devolver una copia semánticamente equivalente del objeto.
Si, como parte de la copia del objeto, es necesario copiar un subobjeto, la mejor manera de hacerlo es mediante la rutina SerializationManager.DeepCopyInner:
var fooCopy = SerializationManager.DeepCopyInner(foo, context);
Importante
Es importante usar SerializationManager.DeepCopyInner, en lugar de SerializationManager.DeepCopy, para mantener el contexto de la identidad del objeto para la operación de copia completa.
Mantenimiento de la identidad del objeto
Una responsabilidad importante de una rutina de copia consiste en mantener la identidad del objeto. El runtime de Orleans proporciona una clase auxiliar con este fin. Antes de copiar un subobjeto "a mano" (no mediante una llamada a DeepCopyInner
), compruebe si ya se ha hecho referencia a él de la manera siguiente:
var fooCopy = context.CheckObjectWhileCopying(foo);
if (fooCopy is null)
{
// Actually make a copy of foo
context.RecordObject(foo, fooCopy);
}
La última línea es la llamada a RecordObject, necesaria para que CheckObjectWhileCopying encuentre correctamente las posibles referencias futuras al mismo objeto como referencias foo
.
Nota
Esto se debe hacer únicamente para las instancias de clase, no para instancias de struct
ni primitivas de .NET como string
, Uri
y enum
.
Si usa DeepCopyInner
para copiar subobjetos, la identidad del objeto se controla automáticamente.
serializer
Los métodos de serialización se marcan con Orleans.CodeGeneration.SerializerMethodAttribute:
[SerializerMethod]
static private void Serialize(
object input,
ISerializationContext context,
Type expected)
{
// ...
}
Al igual que sucede con los copiadores, está garantizado que el objeto "input" que se pasa a un serializador es una instancia del tipo de definición. Se puede omitir el tipo "expected", ya que se basa en la información del tipo en tiempo de compilación sobre el elemento de datos y se usa en un nivel superior para formar el prefijo de tipo en la secuencia de bytes.
Para serializar subobjetos, use la rutina SerializationManager.SerializeInner:
SerializationManager.SerializeInner(foo, context, typeof(FooType));
Si no hay ningún tipo esperado determinado para "foo", puede pasar null para el tipo esperado.
La clase BinaryTokenStreamWriter proporciona una amplia variedad de métodos para escribir datos en la secuencia de bytes. Se puede obtener una instancia de la clase mediante la propiedad context.StreamWriter
. Consulte la clase para documentarse.
Deserializador
Los métodos de deserialización se marcan con Orleans.CodeGeneration.DeserializerMethodAttribute:
[DeserializerMethod]
static private object Deserialize(
Type expected,
IDeserializationContext context)
{
//...
}
Se puede omitir el tipo "expected", ya que se basa en la información del tipo en tiempo de compilación sobre el elemento de datos y se usa en un nivel superior para formar el prefijo de tipo en la secuencia de bytes. El tipo real del objeto que se va a crear siempre será el tipo de la clase en la que está definido el deserializador.
Para deserializar subobjetos, use la rutina SerializationManager.DeserializeInner:
var foo = SerializationManager.DeserializeInner(typeof(FooType), context);
O bien, alternativamente:
var foo = SerializationManager.DeserializeInner<FooType>(context);
Si no hay ningún tipo esperado determinado para "foo", use la variante DeserializeInner
no genérica y pase null
para el tipo esperado.
La clase BinaryTokenStreamReader proporciona una amplia variedad de métodos para leer datos de la secuencia de bytes. Se puede obtener una instancia de la clase mediante la propiedad context.StreamReader
. Consulte la clase para documentarse.
Escritura de un proveedor de serializador
En este método, implementará Orleans.Serialization.IExternalSerializer y lo agregará a la propiedad SerializationProviderOptions.SerializationProviders tanto en ClientConfiguration en el cliente como en GlobalConfiguration en los silos. Para información sobre la configuración, consulte Proveedores de serialización.
Las implementaciones de IExternalSerializer
siguen el patrón que descrito anteriormente para la serialización con la adición de un método Initialize
y un método IsSupportedType
que Orleans usa para determinar si el serializador admite un tipo determinado. Esta es la definición de la interfaz:
public interface IExternalSerializer
{
/// <summary>
/// Initializes the external serializer. Called once when the serialization manager creates
/// an instance of this type
/// </summary>
void Initialize(Logger logger);
/// <summary>
/// Informs the serialization manager whether this serializer supports the type for serialization.
/// </summary>
/// <param name="itemType">The type of the item to be serialized</param>
/// <returns>A value indicating whether the item can be serialized.</returns>
bool IsSupportedType(Type itemType);
/// <summary>
/// Tries to create a copy of source.
/// </summary>
/// <param name="source">The item to create a copy of</param>
/// <param name="context">The context in which the object is being copied.</param>
/// <returns>The copy</returns>
object DeepCopy(object source, ICopyContext context);
/// <summary>
/// Tries to serialize an item.
/// </summary>
/// <param name="item">The instance of the object being serialized</param>
/// <param name="context">The context in which the object is being serialized.</param>
/// <param name="expectedType">The type that the deserializer will expect</param>
void Serialize(object item, ISerializationContext context, Type expectedType);
/// <summary>
/// Tries to deserialize an item.
/// </summary>
/// <param name="context">The context in which the object is being deserialized.</param>
/// <param name="expectedType">The type that should be deserialized</param>
/// <returns>The deserialized object</returns>
object Deserialize(Type expectedType, IDeserializationContext context);
}
Escritura de un serializador para un tipo individual
En este método, se escribe una clase nueva anotada con un atributo [SerializerAttribute(typeof(TargetType))]
, donde TargetType
es el tipo que se está serializando, y se implementan las tres rutinas de serialización. Las reglas para escribir esas rutinas son idénticas a las que se utilizan al implementar el IExternalSerializer
. Orleans usa [SerializerAttribute(typeof(TargetType))]
para determinar que esta clase es un serializador para TargetType
y este atributo puede especificarse varias veces en la misma clase si es capaz de serializar varios tipos. A continuación se muestra un ejemplo de esta clase:
public class User
{
public User BestFriend { get; set; }
public string NickName { get; set; }
public int FavoriteNumber { get; set; }
public DateTimeOffset BirthDate { get; set; }
}
[Orleans.CodeGeneration.SerializerAttribute(typeof(User))]
internal class UserSerializer
{
[CopierMethod]
public static object DeepCopier(
object original, ICopyContext context)
{
var input = (User)original;
var result = new User();
// Record 'result' as a copy of 'input'. Doing this
// immediately after construction allows for data
// structures that have cyclic references or duplicate
// references. For example, imagine that 'input.BestFriend'
// is set to 'input'. In that case, failing to record
// the copy before trying to copy the 'BestFriend' field
// would result in infinite recursion.
context.RecordCopy(original, result);
// Deep-copy each of the fields.
result.BestFriend =
(User)context.SerializationManager.DeepCopy(input.BestFriend);
// strings in .NET are immutable, so they can be shallow-copied.
result.NickName = input.NickName;
// ints are primitive value types, so they can be shallow-copied.
result.FavoriteNumber = input.FavoriteNumber;
result.BirthDate =
(DateTimeOffset)context.SerializationManager.DeepCopy(input.BirthDate);
return result;
}
[SerializerMethod]
public static void Serializer(
object untypedInput, ISerializationContext context, Type expected)
{
var input = (User) untypedInput;
// Serialize each field.
SerializationManager.SerializeInner(input.BestFriend, context);
SerializationManager.SerializeInner(input.NickName, context);
SerializationManager.SerializeInner(input.FavoriteNumber, context);
SerializationManager.SerializeInner(input.BirthDate, context);
}
[DeserializerMethod]
public static object Deserializer(
Type expected, IDeserializationContext context)
{
var result = new User();
// Record 'result' immediately after constructing it.
// As with the deep copier, this
// allows for cyclic references and de-duplication.
context.RecordObject(result);
// Deserialize each field in the order that they were serialized.
result.BestFriend =
SerializationManager.DeserializeInner<User>(context);
result.NickName =
SerializationManager.DeserializeInner<string>(context);
result.FavoriteNumber =
SerializationManager.DeserializeInner<int>(context);
result.BirthDate =
SerializationManager.DeserializeInner<DateTimeOffset>(context);
return result;
}
}
Serialización de tipos genéricos
El parámetro TargetType
de [Serializer(typeof(TargetType))]
puede ser un tipo genérico abierto, por ejemplo, MyGenericType<T>
. En ese caso, la clase del serializador debe tener los mismos parámetros genéricos que el tipo de destino. Orleans creará una versión concreta del serializador en runtime para cada tipo MyGenericType<T>
concreto que se serializa, por ejemplo, uno para cada MyGenericType<int>
y MyGenericType<string>
.
Sugerencias para escribir serializadores y deserializadores
A menudo, la manera más sencilla de escribir un par de serializador/deserializador consiste en serializar mediante la construcción de una matriz de bytes y la escritura de la longitud de la matriz en la secuencia seguida de la propia matriz y, luego, deserializar invirtiendo el proceso. Si la matriz es de longitud fija, puede omitirla de la secuencia. Esto funciona bien cuando se tiene un tipo de datos que se puede representar de forma compacta y que no contiene subobjetos que podrían duplicarse (de este modo, no tiene que preocuparse por la identidad del objeto).
Otro enfoque, que es el que el runtime de Orleans adopta para colecciones como diccionarios, funciona bien para las clases con una estructura interna significativa y compleja. Consiste en usar métodos de instancia para acceder al contenido semántico del objeto, serializar ese contenido y deserializarlo mediante el establecimiento del contenido semántico, en lugar del estado interno complejo. En este enfoque, los objetos internos se escriben mediante SerializeInner y se leen mediante DeserializeInner. En este caso, es habitual escribir también un copiador personalizado.
Si escribe un serializador personalizado y este acaba pareciendo una secuencia de llamadas a SerializeInner para cada campo de la clase, no necesita un serializador personalizado para esa clase.