Personnalisation de la sérialisation dans Orleans

Un aspect important de Orleans est qu’il prend en charge la personnalisation de la sérialisation, qui est le processus de conversion d’un objet ou d’une structure de données dans un format qui peut être stocké ou transmis, et reconstruit ultérieurement. Cela permet aux développeurs de contrôler la façon dont les données sont encodées et décodées lorsqu’elles sont envoyées entre différentes parties du système. La personnalisation de la sérialisation peut être utile pour optimiser les performances, l’interopérabilité et la sécurité.

Fournisseurs de sérialisation

Orleans fournit deux implémentations de sérialiseur :

Pour configurer l’un de ces packages, consultez Configuration de sérialisation dans Orleans.

Implémentation du sérialiseur personnalisé

Pour créer une implémentation de sérialiseur personnalisé, quelques étapes courantes sont nécessaires. Vous devez implémenter plusieurs interfaces, puis inscrire votre sérialiseur auprès du runtime Orleans. Les sections suivantes décrivent les étapes plus en détail.

Commencez par implémenter les interfaces de sérialisation suivantes Orleans :

  • IGeneralizedCodec : un codec qui prend en charge plusieurs types.
  • IGeneralizedCopier : fournit des fonctionnalités permettant de copier des objets de plusieurs types.
  • ITypeFilter : fonctionnalité permettant de charger des types et de participer à la sérialisation et à la désérialisation.

Prenons l’exemple suivant d’implémentation d’un sérialiseur personnalisé :

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();
}

Dans l’exemple d’implémentation précédent :

  • Chaque interface est explicitement implémentée pour éviter les conflits avec la résolution de noms de méthode.
  • Chaque méthode lève un NotImplementedException pour indiquer que la méthode n’est pas implémentée. Vous devez implémenter chaque méthode pour fournir les fonctionnalités souhaitées.

L’étape suivante consiste à inscrire votre sérialiseur auprès du runtime Orleans. Cela est généralement possible en étendant ISerializerBuilder et en exposant une méthode d’extension AddCustomSerializer personnalisée. L’exemple suivant illustre le modèle classique :

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;
    }
}

D’autres possibilités seraient d’exposer une surcharge qui accepte des options de sérialisation personnalisées spécifiques à votre implémentation personnalisée. Ces options peuvent être configurées avec l’inscription dans le générateur. Ces options peuvent être des dépendances injectées dans votre implémentation de sérialiseur personnalisé.

Orleans prend en charge l’intégration avec des sérialiseurs tiers à l’aide d’un modèle de fournisseur. Cela nécessite une implémentation du type IExternalSerializer décrit dans la section sur la sérialisation personnalisée de cet article. Les intégrations pour certains sérialiseurs courants sont conservées avec Orleans, par exemple :

L’implémentation personnalisée de IExternalSerializer est décrite dans la section suivante.

Sérialiseurs externes personnalisés

En plus de la génération automatique de sérialisation, le code d’application peut fournir une sérialisation personnalisée pour les types qu’il choisit. Orleans recommande d’utiliser la génération de sérialisation automatique pour la majorité de vos types d’application et d’écrire uniquement des sérialiseurs personnalisés dans les rares cas où vous pensez qu’il est possible d’améliorer les performances en codant manuellement les sérialiseurs. Cette note décrit comment procéder et identifie certains cas spécifiques où cela peut être utile.

Les applications peuvent personnaliser la sérialisation de trois façons différentes :

  1. Ajoutez des méthodes de sérialisation à votre type et marquez-les avec les attributs appropriés (CopierMethodAttribute, SerializerMethodAttribute, DeserializerMethodAttribute). Cette méthode est préférable pour les types qui appartiennent à votre application, c’est-à-dire les types auxquels vous pouvez ajouter de nouvelles méthodes.
  2. Implémentez IExternalSerializer et inscrivez-le pendant la configuration. Cette méthode est utile pour intégrer une bibliothèque de sérialisation externe.
  3. Écrivez une classe statique distincte annotée avec [Serializer(typeof(YourType))] contenant les 3 méthodes de sérialisation et les mêmes attributs que ci-dessus. Cette méthode est utile pour les types que l’application ne possède pas, par exemple, les types définis dans d’autres bibliothèques sur lesquelles votre application n’a aucun contrôle.

Chacune de ces méthodes de sérialisation est détaillée dans les sections suivantes.

Présentation de la sérialisation personnalisée

La sérialisation Orleans se déroule en trois étapes :

  • Les objets sont immédiatement copiés en profondeur pour garantir l’isolation.
  • Avant d’être mis sur le réseau, les objets sont sérialisés dans un flux d’octets de message.
  • Lorsqu’ils sont remis à l’activation cible, les objets sont recréés (désérialisés) à partir du flux d’octets reçu.

Les types de données susceptibles d’être envoyés dans des messages (autrement dit, les types qui peuvent être transmis en tant qu’arguments de méthode ou valeurs de retour) doivent avoir des routines associées qui effectuent ces trois étapes. Nous faisons référence à ces routines collectivement en tant que sérialiseurs pour un type de données.

Le copieur d’un type est autonome, tandis que le sérialiseur et le désérialiseur constituent une paire d’éléments qui fonctionnent ensemble. Vous pouvez fournir uniquement un copieur personnalisé, ou simplement un sérialiseur personnalisé et un désérialiseur personnalisé, ou vous pouvez fournir des implémentations personnalisées des trois.

Les sérialiseurs sont inscrits pour chaque type de données pris en charge au démarrage du silo et chaque fois qu’un assembly est chargé. L’inscription est nécessaire pour les routines des sérialiseurs personnalisés pour qu’un type soit utilisé. La sélection des sérialiseurs est basée sur le type dynamique de l’objet à copier ou à sérialiser. Pour cette raison, il n’est pas nécessaire de créer des sérialiseurs pour les classes ou interfaces abstraites, car ils ne seront jamais utilisés.

Quand écrire un sérialiseur personnalisé

Une routine de sérialiseur créée manuellement fonctionne rarement mieux que les versions générées. Si vous êtes tenté d’en écrire un, vous devez d’abord prendre en compte les options suivantes :

  • S’il existe des champs ou des propriétés dans vos types de données qui n’ont pas besoin d’être sérialisés ou copiés, vous pouvez les marquer avec NonSerializedAttribute. Par conséquent, le code généré ignore ces champs lors de la copie et de la sérialisation. Utilisez ImmutableAttribute et Immutable<T>, si possible, pour éviter de copier des données immuables. Pour plus d’informations, consultez Optimiser la copie. Si vous évitez d’utiliser les types de collection génériques standard, ne le faites pas. Le runtime Orleans contient des sérialiseurs personnalisés pour les collections génériques qui utilisent la sémantique des collections pour optimiser la copie, la sérialisation et la désérialisation. Ces collections ont également des représentations « abrégées » spéciales dans le flux d’octets sérialisé, ce qui offre encore plus d’avantages en matière de performances. Par exemple, un Dictionary<string, string> sera plus rapide qu’un List<Tuple<string, string>>.

  • Le cas le plus courant où un sérialiseur personnalisé peut fournir un gain de performances notable correspond au cas où des informations sémantiques significatives codées dans le type de données ne sont pas disponibles par simple copie de valeurs de champ. Par exemple, les tableaux partiellement remplis peuvent souvent être sérialisés plus efficacement en traitant le tableau comme une collection de paires index/valeur, même si l’application conserve les données sous la forme d’un tableau entièrement réalisé pour la vitesse d’opération.

  • Avant d’écrire un sérialiseur personnalisé, il est essentiel de s’assurer que le sérialiseur généré nuit à vos performances. Le profilage vous aidera un peu ici, mais l’exécution de tests de contrainte de bout en bout de votre application avec des charges de sérialisation variables peut s’avérer encore plus précieuse pour évaluer l’impact au niveau du système plutôt que le micro-impact de la sérialisation. Par exemple, la création d’une version de test qui ne transmet aucun paramètre aux méthodes de grain ni n’en tire aucun résultat, en utilisant simplement des valeurs enregistrées à l’une des extrémités, effectue un zoom sur l’impact de la sérialisation et de la copie sur les performances du système.

Ajouter des méthodes de sérialisation à un type

Toutes les routines de sérialiseur doivent être implémentées en tant que membres statiques de la classe ou du struct sur lesquels elles opèrent. Les noms indiqués ici ne sont pas requis ; l’inscription est basée sur la présence des attributs respectifs, et non sur les noms des méthodes. Notez que les méthodes de sérialiseur n’ont pas besoin d’être publiques.

Sauf si vous implémentez les trois routines de sérialisation, vous devez marquer votre type avec SerializableAttribute afin que les méthodes manquantes soient générées pour vous.

Copieur

Les méthodes de copieur sont marquées avec Orleans.CodeGeneration.CopierMethodAttribute :

[CopierMethod]
static private object Copy(object input, ICopyContext context)
{
    // ...
}

Les copieurs sont généralement les routines de sérialiseur les plus simples à écrire. Ils prennent un objet, garanti comme étant du type dans lequel le copieur est défini, et doivent retourner une copie sémantiquement équivalente de l’objet.

Si, dans le cadre de la copie de l’objet, un sous-objet doit être copié, la meilleure façon de le faire consiste à utiliser la routine SerializationManager.DeepCopyInner :

var fooCopy = SerializationManager.DeepCopyInner(foo, context);

Important

Il est important d’utiliser SerializationManager.DeepCopyInner, à la place de SerializationManager.DeepCopy, afin de maintenir le contexte d’identité des objets pour l’opération de copie complète.

Maintenir l’identité des objets

Une responsabilité importante d’une routine de copie consiste à maintenir l’identité des objets. Le runtime Orleans fournit une classe d’assistance à cet effet. Avant de copier un sous-objet « manuellement » (pas en appelant DeepCopyInner), vérifiez si cela a déjà été référencé comme suit :

var fooCopy = context.CheckObjectWhileCopying(foo);
if (fooCopy is null)
{
    // Actually make a copy of foo
    context.RecordObject(foo, fooCopy);
}

La dernière ligne, l’appel à RecordObject, qui est nécessaire pour que les références futures possibles au même objet que les références foo soient trouvées correctement par CheckObjectWhileCopying.

Notes

Cette opération doit être effectuée uniquement pour les instances de classe, et non pas pour les instances struct ni pour les primitives .NET telles que string, Uri et enum.

Si vous utilisez DeepCopyInner pour copier des sous-objets, l’identité des objets est gérée pour vous.

serializer

Les méthodes de sérialisation sont marquées avec Orleans.CodeGeneration.SerializerMethodAttribute :

[SerializerMethod]
static private void Serialize(
    object input,
    ISerializationContext context,
    Type expected)
{
    // ...
}

Comme avec les copieurs, l’objet « input » transmis à un sérialiseur est garanti comme étant une instance du type de définition. Le type « expected » (attendu) peut être ignoré. Il est basé sur les informations de type au moment de la compilation concernant l’élément de données et il est utilisé à un niveau supérieur pour former le préfixe de type dans le flux d’octets.

Pour sérialiser des sous-objets, utilisez la routine SerializationManager.SerializeInner :

SerializationManager.SerializeInner(foo, context, typeof(FooType));

S’il n’existe aucun type attendu particulier pour foo, vous pouvez transmettre null pour le type attendu.

La classe BinaryTokenStreamWriter fournit une grande variété de méthodes pour écrire des données dans le flux d’octets. Une instance de cette classe peut être obtenue via la propriété context.StreamWriter. Reportez-vous à cette classe pour consulter la documentation.

Désérialiseur

Les méthodes de désérialisation sont marquées avec Orleans.CodeGeneration.DeserializerMethodAttribute :

[DeserializerMethod]
static private object Deserialize(
    Type expected,
    IDeserializationContext context)
{
    //...
}

Le type « expected » (attendu) peut être ignoré. Il est basé sur les informations de type au moment de la compilation concernant l’élément de données et il est utilisé à un niveau supérieur pour former le préfixe de type dans le flux d’octets. Le type réel de l’objet à créer sera toujours le type de classe dans lequel le désérialiseur est défini.

Pour désérialiser des sous-objets, utilisez la routine SerializationManager.DeserializeInner :

var foo = SerializationManager.DeserializeInner(typeof(FooType), context);

Ou encore :

var foo = SerializationManager.DeserializeInner<FooType>(context);

S’il n’existe aucun type attendu particulier pour foo, utilisez la variante DeserializeInner non générique et transmettez null pour le type attendu.

La classe BinaryTokenStreamReader fournit une grande variété de méthodes pour lire des données à partir du flux d’octets. Une instance de cette classe peut être obtenue via la propriété context.StreamReader. Reportez-vous à cette classe pour consulter la documentation.

Écrire un fournisseur de sérialiseur

Dans cette méthode, vous implémentez Orleans.Serialization.IExternalSerializer et l’ajoutez à la propriété SerializationProviderOptions.SerializationProviders sur ClientConfiguration sur le client et GlobalConfiguration sur les silos. Pour plus d’informations sur la configuration, consultez Fournisseurs de sérialisation.

L’implémentation de IExternalSerializer suit le modèle précédemment décrit pour la sérialisation avec l’ajout d’une méthode Initialize et d’une méthode IsSupportedType que Orleans utilise pour déterminer si le sérialiseur prend en charge un type donné. Voici la définition de l’interface :

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);
}

Écrire un sérialiseur pour un type individuel

Dans cette méthode, vous écrivez une nouvelle classe annotée avec un attribut [SerializerAttribute(typeof(TargetType))], où TargetType est le type sérialisé, et vous implémentez les 3 routines de sérialisation. Les règles d’écriture de ces routines sont identiques à celles lors de l’implémentation de IExternalSerializer. Orleans utilise [SerializerAttribute(typeof(TargetType))] pour déterminer que cette classe est un sérialiseur pour TargetType et cet attribut peut être spécifié plusieurs fois sur la même classe s’il est en mesure de sérialiser plusieurs types. Un exemple est fourni ci-dessous pour cette classe :

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;
    }
}

Sérialiser des types génériques

Le paramètre TargetType de [Serializer(typeof(TargetType))] peut être un type générique ouvert, par exemple, MyGenericType<T>. Dans ce cas, la classe de sérialiseur doit avoir les mêmes paramètres génériques que le type cible. Orleans créera une version concrète du sérialiseur au moment de l’exécution pour chaque type MyGenericType<T> concret sérialisé, par exemple, une pour chaque MyGenericType<int> et MyGenericType<string>.

Conseils pour l’écriture de sérialiseurs et de désérialiseurs

Souvent, la façon la plus simple d’écrire une paire de sérialiseur/désérialiseur consiste à sérialiser en construisant un tableau d’octets et en écrivant la longueur du tableau dans le flux, suivie du tableau lui-même, puis à désérialiser en inversant ce processus. Si le tableau est de longueur fixe, vous pouvez l’omettre du flux. Cela fonctionne bien quand vous disposez d’un type de données que vous pouvez représenter de manière compacte et qui n’a pas de sous-objets susceptibles d’être dupliqués (vous n’avez donc pas à vous soucier de l’identité des objets).

Une autre approche, qui est l’approche que le runtime Orleans adopte pour les collections telles que les dictionnaires, fonctionne bien pour les classes dotées d’une structure interne significative et complexe : utiliser des méthodes d’instance pour accéder au contenu sémantique de l’objet, sérialiser ce contenu et désérialiser en définissant le contenu sémantique plutôt que l’état interne complexe. Dans cette approche, les objets internes sont écrits à l’aide de SerializeInner et lus à l’aide de DeserializeInner. Dans ce cas, il est également courant d’écrire un copieur personnalisé.

Si vous écrivez un sérialiseur personnalisé qui se présente au final comme une séquence d’appels à SerializeInner pour chaque champ de la classe, vous n’avez pas besoin d’un sérialiseur personnalisé pour cette classe.

Voir aussi