Condividi tramite


Personalizzazione della serializzazione in Orleans

Un aspetto importante di Orleans è il supporto per la personalizzazione della serializzazione, ovvero il processo di conversione di un oggetto o di una struttura di dati in un formato che può essere archiviato o trasmesso e ricostruito in un secondo momento. In questo modo, gli sviluppatori possono controllare la modalità di codifica e decodifica dei dati quando vengono inviati tra diverse parti del sistema. La personalizzazione della serializzazione può essere utile per ottimizzare le prestazioni, l'interoperabilità e la sicurezza.

Provider di serializzazione

Orleans fornisce due implementazioni del serializzatore:

Per configurare uno di questi pacchetti, consultare Configurazione della serializzazione in Orleans.

Implementazione del serializzatore personalizzato

Per creare un'implementazione del serializzatore personalizzato, sono necessari alcuni passaggi ordinari. È necessario implementare diverse interfacce e quindi registrare il serializzatore con il runtime Orleans. Le sezioni seguenti descrivono i passaggi in maggior dettaglio.

Per iniziare, implementare le seguenti interfacce di serializzazione Orleans:

  • IGeneralizedCodec: un codec che supporta più tipi.
  • IGeneralizedCopier: fornisce funzionalità per la copia di oggetti di più tipi.
  • ITypeFilter: funzionalità per consentire il caricamento dei tipi e per partecipare alla serializzazione e alla deserializzazione.

Si consideri il seguente esempio di implementazione di un serializzatore personalizzato:

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

Nel precedente esempio di implementazione:

  • Ogni interfaccia viene implementata esplicitamente per evitare conflitti con la risoluzione dei nomi del metodo.
  • Ogni metodo genera un NotImplementedException per indicare che il metodo non è implementato. È necessario implementare ogni metodo per fornire la funzionalità desiderata.

Il passaggio successivo consiste nel registrare il serializzatore con il runtime Orleans. Questa operazione viene in genere eseguita estendendo ISerializerBuilder ed esponendo un metodo di estensione personalizzato AddCustomSerializer. Il seguente esempio illustra il criterio tipico:

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

Prendere in considerazione anche l’esposizione di un overload che accetta opzioni di serializzazione personalizzate specifiche all'implementazione personalizzata. Queste opzioni possono essere configurate insieme alla registrazione nel generatore. Queste opzioni possono essere inserite nell'implementazione del serializzatore personalizzato.

Orleans supporta l'integrazione con serializzatori di terzi usando un modello di provider. Ciò richiede un'implementazione del tipo IExternalSerializer descritto nella sezione di questo articolo dedicata alla serializzazione personalizzata. Le integrazioni per alcuni serializzatori comuni vengono mantenute insieme a Orleans, ad esempio:

L'implementazione personalizzata di IExternalSerializer è descritta nella sezione seguente.

Serializzatori esterni personalizzati

Oltre alla generazione automatica della serializzazione, il codice dell'app può fornire serializzazione personalizzata per i tipi scelti. Orleans consiglia di usare la generazione di serializzazione automatica per la maggior parte dei tipi di app e di limitare la scrittura di serializzatori personalizzati a casi eccezionali, quando si ritenga possibile ottenere prestazioni migliori codificando manualmente i serializzatori. Questa nota illustra come eseguire questa operazione e identifica alcuni casi specifici in cui essa potrebbe tornare utile.

Le app hanno tre modi per personalizzare la serializzazione:

  1. Aggiungere metodi di serializzazione al tipo e contrassegnarli con gli attributi appropriati (CopierMethodAttribute, SerializerMethodAttribute, DeserializerMethodAttribute). Questo metodo è preferibile per i tipi di proprietà dell'app, ovvero i tipi a cui è possibile aggiungere nuovi metodi.
  2. Implementare IExternalSerializer e registrarlo durante la configurazione. Questo metodo è utile per l'integrazione di una libreria di serializzazione esterna.
  3. Scrivere una classe statica diversa, annotata con un [Serializer(typeof(YourType))] contenente i 3 metodi di serializzazione e con gli stessi attributi descritti in precedenza. Questo metodo è utile per i tipi che non sono di proprietà dell’app, ad esempio, i tipi definiti in altre librerie sulle quali l'app non ha alcun controllo.

Ognuno di questi metodi di serializzazione è descritto in dettaglio nelle sezioni seguenti.

Introduzione alla serializzazione personalizzata

La serializzazione Orleans avviene in tre fasi:

  • Gli oggetti vengono completamente copiati immediatamente per garantire l'isolamento.
  • Prima di essere messi in rete, gli oggetti vengono serializzati in un flusso di byte di messaggio.
  • Quando sono recapitati all'attivazione di destinazione, gli oggetti vengono ricreati (deserializzati) dal flusso di byte ricevuto.

I tipi di dati che possono essere inviati in messaggi, ovvero i tipi che possono essere passati come argomenti di metodo o valori restituiti, devono avere routine associate che eseguano questi tre passaggi. Si fa riferimento a tutte queste routine come serializzatori per un tipo di dati.

Il copiatore per un tipo è autonomo, mentre il serializzatore e il deserializzatore sono una coppia che collabora. È possibile fornire solo un copiatore personalizzato o solo un serializzatore personalizzato e un deserializzatore personalizzato; in alternativa, si possono fornire implementazioni personalizzate di tutte e tre.

I serializzatori vengono registrati per ogni tipo di dati supportato all'avvio del silo e ogni volta che viene caricato un assembly. La registrazione è necessaria per poter utilizzare le routine del serializzatore personalizzato di un tipo. La selezione del serializzatore si basa sul tipo dinamico dell'oggetto da copiare o serializzare. Per questo motivo, non è necessario creare serializzatori per classi o interfacce astratte, poiché non se ne farà mai uso.

Quando scrivere un serializzatore personalizzato

È raro che una routine serializzatore creata manualmente sia più efficiente di versioni generate. Se si è tentati di scriverne una, è consigliabile prendere in considerazione le seguenti opzioni:

  • Se sono presenti campi o proprietà all'interno dei tipi di dati che non necessitano di essere serializzati o copiati, contrassegnarli con NonSerializedAttribute. In questo modo, il codice generato ignora questi campi durante la copia e la serializzazione. Dove possibile, usare ImmutableAttribute e Immutable<T> per evitare di copiare dati non modificabili. Per ulteriori informazioni, consultare Ottimizzare la copiatura. Se si preferisce evitare i tipi di raccolta generici standard, non farlo. Il runtime Orleans contiene serializzatori personalizzati per le raccolte generiche che usano la semantica delle raccolte per ottimizzare la copiatura, la serializzazione e la deserializzazione. Queste raccolte hanno anche speciali rappresentazioni "abbreviate" nel flusso di byte serializzato, con un conseguente ulteriore miglioramento delle prestazioni. Ad esempio, un Dictionary<string, string> sarà più veloce di un List<Tuple<string, string>>.

  • Il caso più comune in cui un serializzatore personalizzato può offrire un notevole miglioramento delle prestazioni è quando importanti informazioni semantiche sono codificate nel tipo di dati e non sono disponibili tramite la semplice copiatura dei valori dei campi. Ad esempio, le matrici a bassa densità di popolazione possono spesso essere serializzate in modo più efficiente considerando la matrice come una raccolta di coppie indice/valore, anche se l'applicazione mantiene i dati come matrice completamente realizzata per velocizzare l'operazione.

  • Prima di scrivere un serializzatore personalizzato, è importante accertarsi che il serializzatore generato peggiori le prestazioni. La profilatura sarà di aiuto in questa operazione, ma è ancora più importante eseguire test di stress end-to-end dell'app con carichi di serializzazione variabili per misurare l'impatto a livello di sistema, anziché il micro-impatto della serializzazione. Ad esempio, la compilazione di una versione di test che non passa parametri a e risultati da metodi granulari, semplicemente usando valori in scatola a entrambe le estremità, evidenzierà l'impatto della serializzazione e della copia sulle prestazioni del sistema.

Aggiungere metodi di serializzazione a un tipo

Tutte le routine del serializzatore devono essere implementate come membri statici della classe o dello struct su cui operano. I nomi qui mostrati non sono obbligatori; la registrazione si basa sulla presenza dei rispettivi attributi, non sui nomi dei metodi. Notare che i metodi del serializzatore non devono necessariamente essere pubblici.

A meno che non si implementino tutte e tre le routine di serializzazione, è necessario contrassegnare il tipo con SerializableAttribute, in modo che i metodi mancanti vengano generati automaticamente.

Copiatrice

I metodi della copiatrice vengono contrassegnati con il Orleans.CodeGeneration.CopierMethodAttribute:

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

Le copiatrici sono in genere le routine serializzatori più semplici da scrivere. Prendono un oggetto che sia certamente dello stesso tipo in cui è definito la copiatrice, e devono restituire una copia semanticamente equivalente dell'oggetto.

Se, come parte del processo di copiatura dell'oggetto, è necessario copiare un sottooggetto, il modo migliore per farlo consiste nell'usare la routine SerializationManager.DeepCopyInner:

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

Importante

È importante usare SerializationManager.DeepCopyInner, invece di SerializationManager.DeepCopy, per mantenere il contesto di identità dell'oggetto per l'operazione di copia completa.

Mantenere l'identità dell'oggetto

Un’Importante responsabilità di una routine di copia è quella di mantenere l'identità dell'oggetto. Il runtime Orleans fornisce una classe helper a questo scopo. Prima di copiare un sottooggetto “manualmente” (non chiamando DeepCopyInner), verificare se è già stato fatto riferimento ad esso come segue:

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

L'ultima riga è la chiamata a RecordObject, necessaria per far sì che potenziali futuri riferimenti allo stesso oggetto foo vengano identificati correttamente da CheckObjectWhileCopying.

Nota

Questa operazione deve essere eseguita solo per le istanze di classe, per le istanze non struct o per le primitive .NET, come string, Uri e enum.

Se si usa DeepCopyInner per copiare sottoogetti, l'identità dell'oggetto viene gestita automaticamente.

Serializer

I metodi di serializzazione vengono contrassegnati con Orleans.CodeGeneration.SerializerMethodAttribute:

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

Come per le copiatrici, è garantito che l'oggetto "input" passato a un serializzatore sia un'istanza del tipo di definizione. Il tipo "previsto" può essere ignorato; si basa sulle informazioni sul tipo in fase di compilazione relative all'elemento di dati e viene usato a un livello superiore per formare il prefisso del tipo nel flusso di byte.

Per serializzare oggetti secondari, utilizzare la routine SerializationManager.SerializeInner:

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

Se non esiste uno specifico tipo previsto per foo, è possibile usare null come tipo previsto.

La classe BinaryTokenStreamWriter fornisce un'ampia gamma di metodi per la scrittura di dati nel flusso di byte. È possibile ottenere un'istanza della classe tramite la proprietà context.StreamWriter. Consultare la classe per la documentazione.

Deserializzatore

I metodi di deserializzazione vengono contrassegnati con Orleans.CodeGeneration.DeserializerMethodAttribute:

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

Il tipo "previsto" può essere ignorato; si basa sulle informazioni sul tipo in fase di compilazione relative all'elemento di dati e viene usato a un livello superiore per formare il prefisso del tipo nel flusso di byte. Il tipo effettivo dell'oggetto da creare sarà sempre il tipo di classe in cui è definito il deserializzatore.

Per deserializzare gli oggetti secondari, utilizzare la routine SerializationManager.DeserializeInner:

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

Oppure, in alternativa:

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

Se non esiste un particolare tipo previsto per foo, usare la variante non generica DeserializeInner e usare null come tipo previsto.

La classe BinaryTokenStreamReader fornisce un'ampia gamma di metodi per la lettura dei dati dal flusso di byte. È possibile ottenere un'istanza della classe tramite la proprietà context.StreamReader. Consultare la classe per la documentazione.

Scrivere un provider di serializzatori

In questo metodo, si implementa Orleans.Serialization.IExternalSerializer e lo si aggiunge alla proprietà SerializationProviderOptions.SerializationProviders, sia ClientConfiguration nel client che GlobalConfiguration nei silo. Per informazioni sulla configurazione, consultare Provider di serializzazione.

Le implementazioni di IExternalSerializer seguono il modello descritto in precedenza per la serializzazione con l'aggiunta di un metodo Initialize e un metodo IsSupportedType che usa Orleans per determinare se il serializzatore supporta un determinato tipo. Questa è la definizione dell'interfaccia:

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

Scrivere un serializzatore per un singolo tipo

In questo metodo si scrive una nuova classe annotata con un attributo [SerializerAttribute(typeof(TargetType))] dove TargetType è il tipo che viene serializzato, e si implementano le 3 routine di serializzazione. Le regole per la scrittura di tali routine sono identiche a quelle per l'implementazione di IExternalSerializer. Orleans usa il [SerializerAttribute(typeof(TargetType))] per determinare se questa classe sia un serializzatore per TargetType e se questo attributo possa essere specificato più volte nella stessa classe se in grado di serializzare più tipi. Di seguito è riportato un esempio per una classe di questo tipo:

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

Serializzare tipi generici

Il parametro TargetType di [Serializer(typeof(TargetType))] può essere un tipo open-generic, ad esempio MyGenericType<T>. In tal caso, la classe del serializzatore deve avere gli stessi parametri generici del tipo di destinazione. Orleans creerà una versione concreta del serializzatore in fase di esecuzione per ogni tipo concreto MyGenericType<T> serializzato (ad esempio, uno per ciascun MyGenericType<int> e MyGenericType<string>).

Suggerimenti per la scrittura di serializzatori e deserializzatori

Spesso, il metodo più semplice per scrivere una coppia serializzatore/deserializzatore consiste nel serializzare costruendo una matrice di byte e scrivendo la lunghezza della matrice nel flusso seguita dalla matrice stessa, e quindi deserializzare ripristinando il processo. Se la matrice è a lunghezza fissa, è possibile ometterla dal flusso. Questo funziona molto bene quando si dispone di un tipo di dati che è possibile rappresentare in modo compatto e che non dispone di oggetti secondari che potrebbero essere duplicati (quindi non è necessario preoccuparsi dell'identità dell'oggetto).

Un altro approccio, ovvero quello adottato dal runtime Orleans per raccolte come dizionari, adatto per le classi con struttura interna significativa e complessa, consiste nell’usare metodi di istanza per accedere al contenuto semantico dell'oggetto, serializzare il contenuto e quindi effettuare la deserializzazione impostando il contenuto semantico anziché lo stato interno complesso. In questo approccio, gli oggetti interni vengono scritti usando SerializeInner e letti usando DeserializeInner. In questo caso, è comune scrivere anche un copiatore personalizzato.

Se si scrive un serializzatore personalizzato e questo ha un aspetto simile a una sequenza di chiamate a SerializeInner per ogni campo della classe, non è necessario un serializzatore personalizzato per tale classe.

Vedi anche