Compartilhar via


Personalização da serialização no Orleans

Um aspecto importante do Orleans é o suporte à personalização da serialização, que é o processo de conversão de um objeto ou estrutura de dados em um formato que pode ser armazenado ou transmitido e reconstruído posteriormente. Isso permite que os desenvolvedores controlem como os dados são codificados e decodificados quando são enviados entre diferentes partes do sistema. A personalização da serialização pode ser útil para otimizar o desempenho, a interoperabilidade e a segurança.

Provedores de serialização

Orleans fornece duas implementações de serializador:

Para configurar qualquer um desses pacotes, consulte Configuração da serialização no Orleans.

Implementação do serializador personalizado

Para criar uma implementação de serializador personalizado, algumas etapas comuns estão envolvidas. Você precisa implementar várias interfaces e registrar o serializador com o runtime do Orleans. As seções a seguir descrevem as etapas mais detalhadamente.

Comece implementando as seguintes interfaces de serialização Orleans:

  • IGeneralizedCodec: um codec que dá suporte a vários tipos.
  • IGeneralizedCopier: fornece funcionalidade para copiar objetos de vários tipos.
  • ITypeFilter: funcionalidade para permitir que os tipos sejam carregados e participem da serialização e desserialização.

Considere o seguinte exemplo de uma implementação de serializador personalizado:

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

No exemplo de implementação anterior:

  • Cada interface é implementada explicitamente para evitar conflitos com a resolução de nomes do método.
  • Cada método lança um NotImplementedException para indicar que o método não está implementado. Você precisará implementar cada método para fornecer a funcionalidade desejada.

A próxima etapa é registrar o serializador com o runtime do Orleans. Normalmente, isso é feito estendendo ISerializerBuilder e expondo um método de extensão personalizado AddCustomSerializer. O seguinte exemplo demonstra o padrão 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;
    }
}

Considerações adicionais seriam expor uma sobrecarga que aceita opções de serialização personalizadas específicas para a implementação personalizada. Essas opções podem ser configuradas junto com o registro no construtor. Essas opções podem ter a dependência injetada na implementação do serializador personalizado.

O Orleans dá suporte à integração com serializadores de terceiros usando um modelo de provedor. Isso requer uma implementação do tipo IExternalSerializer descrito na seção de serialização personalizada deste artigo. As integrações para alguns serializadores comuns são mantidas junto ao Orleans, como por exemplo:

A implementação personalizada de IExternalSerializer é descrita na seção a seguir.

Serializadores externos personalizados

Além da geração de serialização automática, o código do aplicativo pode fornecer serialização personalizada para os tipos escolhidos. O Orleans recomenda usar a geração de serialização automática para a maioria dos tipos de aplicativo e gravar serializadores personalizados apenas em casos raros quando você julgar ser possível obter um melhor desempenho por serializadores de codificação manual. Esta nota descreve como fazer isso e identifica alguns casos específicos quando pode ser útil.

Há três maneiras pelas quais aplicativos podem personalizar a serialização:

  1. Adicione métodos de serialização ao seu tipo e marque-os com os atributos apropriados (CopierMethodAttribute, SerializerMethodAttribute, DeserializerMethodAttribute). Esse método é preferível para os tipos que o aplicativo possui, ou seja, os tipos aos quais você pode adicionar novos métodos.
  2. Implemente IExternalSerializer e registre-o durante o tempo de configuração. Esse método é útil para integrar uma biblioteca de serialização externa.
  3. Escreva uma classe estática separada anotada com um [Serializer(typeof(YourType))] com os três métodos de serialização e os mesmos atributos acima. Esse método é útil para tipos que o aplicativo não possui, como por exemplo, tipos definidos em outras bibliotecas sobre os quais o aplicativo não tem controle.

Cada um desses métodos de serialização é detalhado nas seções a seguir.

Introdução à serialização personalizada

A serialização de Orleans ocorre em três estágios:

  • Os objetos são imediatamente copiados profundamente para garantir o isolamento.
  • Antes de serem colocados no fio, os objetos são serializados para um fluxo de bytes de mensagem.
  • Quando entregues à ativação de destino, os objetos são recriados (desserializados) do fluxo de bytes recebido.

Os tipos de dados que podem ser enviados em mensagens, ou seja, os tipos que podem ser passados como argumentos de método ou valores retornados, devem ter rotinas associadas que executam essas três etapas. Nos referimos a essas rotinas coletivamente como serializadores para um tipo de dados.

A copiadora de um tipo permanece sozinha, enquanto o serializador e o desserializador formam um par que trabalha em conjunto. Você pode fornecer apenas uma copiadora personalizada, ou apenas um serializador personalizado e um desserializador personalizado, ou é possível fornecer implementações personalizadas dos três.

Serializadores são registrados para cada tipo de dados com suporte na inicialização de silos e sempre que um assembly é carregado. O registro é necessário para rotinas do serializador personalizado para um tipo a ser usado. A seleção do serializador baseia-se no tipo dinâmico do objeto a ser copiado ou serializado. Por esse motivo, não é necessário criar serializadores para classes abstratas ou interfaces, pois eles nunca serão usados.

Quando gravar um serializador personalizado

Uma rotina de serializador personalizado raramente terá um desempenho melhor do que as versões geradas. Se você estiver tentado escrever um, primeiro deverá considerar as seguintes opções:

  • Se houver campos ou propriedades em seus tipos de dados que não precisam ser serializados ou copiados, você poderá marcá-los com o NonSerializedAttribute. Isso fará com que o código gerado ignore esses campos ao copiar e serializar. Use ImmutableAttribute e Immutable<T> sempre que possível para evitar copiar dados imutáveis. Para obter mais informações, consulte Otimizar a cópia. Se você estiver evitando usar os tipos de coleção genérica padrão, não faça isso. O runtime do Orleans contém serializadores personalizados para as coleções genéricas que usam a semântica das coleções para otimizar a cópia, a serialização e a desserialização. Essas coleções também têm representações especiais "abreviadas" no fluxo de bytes serializado, resultando em ainda mais vantagens de desempenho. Por exemplo, um Dictionary<string, string> será mais rápido que um List<Tuple<string, string>>.

  • O caso mais comum em que um serializador personalizado pode fornecer um ganho de desempenho perceptível é quando há informações semânticas significativas codificadas no tipo de dados que não ficam disponíveis simplesmente copiando valores de campo. Por exemplo, matrizes que são pouco preenchidas geralmente podem ser serializadas com mais eficiência tratando a matriz como uma coleção de pares de índice/valor, mesmo que o aplicativo mantenha os dados como uma matriz totalmente realizada para agilizar a operação.

  • Uma coisa importante a fazer antes de gravar um serializador personalizado é verificar se o serializador gerado está prejudicando seu desempenho. A criação de perfil ajudará um pouco aqui, mas ainda é mais valioso executar testes de estresse de ponta a ponta do aplicativo com cargas de serialização variadas para medir o impacto no nível do sistema, em vez do micro-impacto da serialização. Por exemplo, a compilação de uma versão de teste que não passa parâmetros ou resultados de métodos de granularidade, usando apenas valores enlatados em ambas as extremidades, ampliará o impacto da serialização e da cópia no desempenho do sistema.

Adicionar métodos de serialização a um tipo

Todas as rotinas de serializador devem ser implementadas como membros estáticos da classe ou struct em que operam. Os nomes mostrados aqui não são necessários, pois o registro baseia-se na presença dos respectivos atributos e não em nomes de métodos. Observe que os métodos do serializador não precisam ser públicos.

A menos que você implemente todas as três rotinas de serialização, você deve marcar seu tipo com o SerializableAttribute e, assim, os métodos ausentes serão gerados para você.

Copiadora

Os métodos da copiadora são sinalizados com o Orleans.CodeGeneration.CopierMethodAttribute:

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

As copiadoras geralmente são as rotinas de serializador mais simples de gravar. Eles pegam um objeto, com a garantia de serem do mesmo tipo que o definido na copiadora e devem retornar uma cópia semanticamente equivalente do objeto.

Se, como parte da cópia do objeto, um subobjeto precisar ser copiado, a melhor maneira de fazer isso é usar a rotina SerializationManager.DeepCopyInner:

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

Importante

É importante usar SerializationManager.DeepCopyInner, em vez de SerializationManager.DeepCopy, para manter o contexto de identidade do objeto para a operação de cópia completa.

Manter a identidade do objeto

Uma responsabilidade importante de uma rotina de cópia é manter a identidade do objeto. O runtime do Orleans fornece uma classe auxiliar para essa finalidade. Antes de copiar um subobjeto "manualmente" (não chamando DeepCopyInner), verifique se ele já foi referenciado da seguinte maneira:

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

A última linha é a chamada para RecordObject, que é necessária para que possíveis referências futuras ao mesmo objeto que as referências foo sejam encontradas corretamente por CheckObjectWhileCopying.

Observação

Isso só deve ser feito para instâncias de classe, não struct instâncias ou primitivos do .NET, como string, Uri e enum.

Se você usar DeepCopyInner para copiar subobjetos, a identidade do objeto será manipulada para você.

serializador

Os métodos de serialização são sinalizados com o Orleans.CodeGeneration.SerializerMethodAttribute:

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

Assim como acontece com as copiadoras, o objeto "input" passado para um serializador tem a garantia de ser uma instância do tipo definidor. O tipo "esperado" pode ser ignorado, pois é baseado em informações de tipo de tempo de compilação sobre o item de dados e é usado em um nível mais alto para formar o prefixo de tipo no fluxo de bytes.

Para serializar subobjetos, use a rotina SerializationManager.SerializeInner:

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

Se não houver um tipo esperado específico para foo, você poderá passar nulo para o tipo esperado.

A classe BinaryTokenStreamWriter fornece uma ampla variedade de métodos para gravar dados no fluxo de bytes. Uma instância da classe pode ser obtida por meio da propriedade context.StreamWriter. Consulte a classe para ver a documentação.

Desserializador

Os métodos de desserialização são sinalizados com o Orleans.CodeGeneration.DeserializerMethodAttribute:

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

O tipo "esperado" pode ser ignorado, pois é baseado em informações de tipo de tempo de compilação sobre o item de dados e é usado em um nível mais alto para formar o prefixo de tipo no fluxo de bytes. O tipo real do objeto a ser criado sempre será o tipo de classe na qual o desserializador está definido.

Para desserializar subobjetos, use a rotina SerializationManager.DeserializeInner:

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

Ou, alternativamente:

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

Se não houver um tipo esperado específico para foo, use a variante não genérica DeserializeInner e passe null para o tipo esperado.

A classe BinaryTokenStreamReader fornece uma ampla variedade de métodos para ler dados do fluxo de bytes. Uma instância da classe pode ser obtida por meio da propriedade context.StreamReader. Consulte a classe para ver a documentação.

Gravar um provedor de serializador

Nesse método, você implementa Orleans.Serialization.IExternalSerializer e adiciona-o à propriedade SerializationProviderOptions.SerializationProviders em ClientConfiguration no cliente e GlobalConfiguration nos silos. Para obter informações sobre a configuração, consulte Provedores de serialização.

As implementações de IExternalSerializer seguem o padrão descrito anteriormente para serialização com a adição de um método Initialize e um método IsSupportedType que o Orleans usa para determinar se o serializador dá suporte a um determinado tipo. Esta é a definição de 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);
}

Gravar um serializador para um tipo individual

Nesse método, você grava uma nova classe anotada com um atributo [SerializerAttribute(typeof(TargetType))], onde TargetType é o tipo que está sendo serializado e implementa as três rotinas de serialização. As regras de como escrever essas rotinas são idênticas às da implementação do IExternalSerializer. O Orleans usa o [SerializerAttribute(typeof(TargetType))] para determinar se essa classe é um serializador TargetType e esse atributo pode ser especificado várias vezes na mesma classe, caso ela seja capaz de serializar vários tipos. Veja abaixo um exemplo dessa 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;
    }
}

Serializar tipos genéricos

O parâmetro TargetType de [Serializer(typeof(TargetType))] pode ser um tipo genérico aberto, por exemplo, MyGenericType<T>. Nesse caso, a classe do serializador deve ter os mesmos parâmetros genéricos que o tipo de destino. O Orleans criará uma versão concreta do serializador em runtime para cada tipo concreto MyGenericType<T> serializado, por exemplo, um para cada MyGenericType<int> e MyGenericType<string>.

Dicas para gravar serializadores e desserializadores

Geralmente, a maneira mais simples de escrever um par serializador/desserializador é serializar construindo uma matriz de bytes e gravando o tamanho da matriz no fluxo, seguido pela própria matriz e desserializar em seguida, revertendo o processo. Se a matriz for de comprimento fixo, você poderá omiti-la do fluxo. Isso funciona bem quando você tem um tipo de dados que pode representar de forma compacta e que não tenha subobjetos que podem ser duplicados (assim não é preciso se preocupar com a identidade do objeto).

Outra abordagem, a qual o runtime do Orleans usa para coleções como dicionários, funciona bem para classes com estrutura interna significativa e complexa: usar métodos de instância para acessar o conteúdo semântico do objeto, serializar esse conteúdo e desserializar definindo o conteúdo semântico em vez do estado interno complexo. Nessa abordagem, os objetos internos são gravados usando SerializeInner e lidos usando DeserializeInner. Nesse caso, também é comum gravar uma copiadora personalizada.

Se você gravar um serializador personalizado e ele acabar parecendo uma sequência de chamadas para SerializeInner para cada campo da classe, não será necessário um serializador personalizado para essa classe.

Confira também