Compartilhar via


Serialização em Orleans

Em geral, há dois tipos de serialização usados em Orleans:

  • Serialização de chamada de grãos – usada para serializar objetos passados de e para grãos.
  • Serialização de armazenamento de grãos – usada para serializar objetos de e para sistemas de armazenamento.

A maioria deste artigo é dedicada à serialização de chamada de grãos por meio da estrutura de serialização incluída em Orleans. A seção Serializadores de armazenamento de grãos aborda a serialização de armazenamento de grãos.

Usar a serialização Orleans

O Orleans inclui uma estrutura de serialização avançada e extensível que pode ser chamada de Orleans. Serialização. A estrutura de serialização incluída no Orleans foi projetada para atender às seguintes metas:

  • Alto desempenho – o serializador foi projetado e otimizado para desempenho. Mais detalhes estão disponíveis nesta apresentação.
  • Alta fidelidade – o serializador representa fielmente a maioria do sistema de tipos do .NET, incluindo suporte para genéricos, polimorfismo, hierarquias de herança, identidade de objeto e grafos cíclicos. Não há suporte para ponteiros, pois eles não são portáteis entre processos.
  • Flexibilidade – o serializador pode ser personalizado para dar suporte a bibliotecas de terceiros criando substitutos ou delegando a bibliotecas de serialização externas, como System.Text.Json, Newtonsoft.Json e Google.Protobuf.
  • Tolerância de versão – o serializador permite que os tipos de aplicativo evoluam ao longo do tempo, dando suporte para:
    • Adicionar e remover membros
    • Subclasses
    • Ampliação e estreitamento numéricos (por exemplo, int para/de long, float para/de double)
    • Renomear tipos

A representação de alta fidelidade de tipos é bastante incomum para serializadores, portanto, alguns pontos precisam de mais elaboração:

  1. Polimorfismo arbitrário e dinâmico de tipos: o Orleans não impõe restrições aos tipos que podem ser passados em chamadas de granularidade e mantém a natureza dinâmica do tipo de dados real. Isso significa, por exemplo, que se o método nas interfaces de granularidade for declarado para aceitar IDictionary, mas em runtime, o remetente passar SortedDictionary<TKey,TValue>, o receptor obterá SortedDictionary (embora a interface granular/"contrato estático" não tenha especificado esse comportamento).

  2. Manutenção da identidade do objeto: se o mesmo objeto passar vários tipos nos argumentos de uma chamada de granularidade ou for indiretamente apontado mais de uma vez nos argumentos, o Orleans vai serializar esse objeto apenas uma vez. No lado do receptor, o Orleans vai restaurar todas as referências corretamente de modo que dois ponteiros para o mesmo objeto ainda apontem para o mesmo objeto após a desserialização. É importante preservar a identidade do objeto em cenários como o seguinte. Imagine que o grão A está enviando um dicionário com 100 entradas para o grão B, e 10 das chaves no dicionário apontam para o mesmo objeto, obj, ao lado de A. Sem preservar a identidade do objeto, B receberia um dicionário de 100 entradas com essas 10 chaves apontando para 10 clones diferentes de obj. Com a identidade do objeto preservada, o dicionário do lado de B é exatamente igual ao do lado A, com essas 10 chaves apontando para um só objeto obj. Observe que, como as implementações de código hash de cadeia de caracteres padrão no .NET são aleatórias por processo, a ordenação dos valores em dicionários e conjuntos de hash (por exemplo) pode não ser preservada.

Para dar suporte à tolerância de versão, o serializador exige que os desenvolvedores sejam explícitos sobre quais tipos e membros são serializados. Tentamos tornar isso o mais indolor possível. Você precisa marcar todos os tipos serializáveis com Orleans.GenerateSerializerAttribute para instruir o Orleans a gerar código serializador para o seu tipo. Depois de fazer isso, você pode usar a correção de código incluída para adicionar o Orleans.IdAttribute necessário aos membros serializáveis em seus tipos, conforme demonstrado aqui:

Uma imagem animada da correção de código disponível que está sendo sugerida e aplicada no GenerateSerializerAttribute quando o tipo recipiente não contém IdAttribute em seus membros.

Aqui, temos um exemplo de tipo serializável no Orleans, demonstrando como aplicar os atributos.

[GenerateSerializer]
public class Employee
{
    [Id(0)]
    public string Name { get; set; }
}

Orleans dá suporte à herança e serializará camadas individuais na hierarquia separadamente, permitindo que elas tenham IDs de membro distintas.

[GenerateSerializer]
public class Publication
{
    [Id(0)]
    public string Title { get; set; }
}

[GenerateSerializer]
public class Book : Publication
{
    [Id(0)]
    public string ISBN { get; set; }
}

No código anterior, observe que Publication e Book têm membros com [Id(0)], embora Book derive de Publication. Essa é a prática recomendada no Orleans, porque os identificadores de membros têm como escopo o nível de herança, não o tipo como um todo. Os membros podem ser adicionados e removidos de Publication e Book independentemente, mas uma nova classe base não poderá ser inserida na hierarquia depois que o aplicativo tiver sido implantado sem consideração especial.

Orleans também dá suporte a serialização de tipos com membros internal, private e readonly, como neste tipo de exemplo:

[GenerateSerializer]
public struct MyCustomStruct
{
    public MyCustom(int intProperty, int intField)
    {
        IntProperty = intProperty;
        _intField = intField;
    }

    [Id(0)]
    public int IntProperty { get; }

    [Id(1)] private readonly int _intField;
    public int GetIntField() => _intField;

    public override string ToString() => $"{nameof(_intField)}: {_intField}, {nameof(IntProperty)}: {IntProperty}";
}

Por padrão, o Orleans vai serializar seu tipo codificando o nome completo. Você pode substituir isso, adicionando um Orleans.AliasAttribute. Isso fará com que seu tipo seja serializado usando um nome que seja resiliente à renomeação da classe subjacente ou à movimentação entre assemblies. Os aliases de tipo têm escopo global e você não pode ter dois aliases com o mesmo valor em um aplicativo. Para tipos genéricos, o valor do alias deve incluir o número de parâmetros genéricos precedidos por uma crase, por exemplo, MyGenericType<T, U> pode ter o alias [Alias("mytype`2")].

Serializando tipos record

Os membros definidos no construtor primário de um registro têm IDs implícitas por padrão. Em outras palavras, o Orleans dá suporte a tipos de serialização record. Isso significa que você não pode alterar a ordem de parâmetro para um tipo já implantado, pois isso interrompe a compatibilidade com as versões anteriores do aplicativo (no caso de uma atualização sem interrupção) e com instâncias serializadas desse tipo no armazenamento e nos fluxos. Os membros definidos no corpo de um tipo de registro não compartilham identidades com os parâmetros do construtor primário.

[GenerateSerializer]
public record MyRecord(string A, string B)
{
    // ID 0 won't clash with A in primary constructor as they don't share identities
    [Id(0)]
    public string C { get; init; }
}

Se você não quiser que os parâmetros do construtor primário sejam incluídos automaticamente como campos serializáveis, use [GenerateSerializer(IncludePrimaryConstructorParameters = false)].

Substitutos para serializar tipos estranhos

Às vezes, talvez seja necessário passar tipos entre granularidades sobre os quais você não tem controle total. Nesses casos, pode ser impraticável converter de e para algum tipo personalizado no código do aplicativo manualmente. O Orleans oferece uma solução para essas situações na forma de tipos alternativos. Os substitutos são serializados no lugar de seu tipo de destino e têm funcionalidade para converter de e para o tipo de destino. Considere o seguinte exemplo de um tipo estranho e um substituto e conversor correspondentes:

// This is the foreign type, which you do not have control over.
public struct MyForeignLibraryValueType
{
    public MyForeignLibraryValueType(int num, string str, DateTimeOffset dto)
    {
        Num = num;
        String = str;
        DateTimeOffset = dto;
    }

    public int Num { get; }
    public string String { get; }
    public DateTimeOffset DateTimeOffset { get; }
}

// This is the surrogate which will act as a stand-in for the foreign type.
// Surrogates should use plain fields instead of properties for better performance.
[GenerateSerializer]
public struct MyForeignLibraryValueTypeSurrogate
{
    [Id(0)]
    public int Num;

    [Id(1)]
    public string String;

    [Id(2)]
    public DateTimeOffset DateTimeOffset;
}

// This is a converter that converts between the surrogate and the foreign type.
[RegisterConverter]
public sealed class MyForeignLibraryValueTypeSurrogateConverter :
    IConverter<MyForeignLibraryValueType, MyForeignLibraryValueTypeSurrogate>
{
    public MyForeignLibraryValueType ConvertFromSurrogate(
        in MyForeignLibraryValueTypeSurrogate surrogate) =>
        new(surrogate.Num, surrogate.String, surrogate.DateTimeOffset);

    public MyForeignLibraryValueTypeSurrogate ConvertToSurrogate(
        in MyForeignLibraryValueType value) =>
        new()
        {
            Num = value.Num,
            String = value.String,
            DateTimeOffset = value.DateTimeOffset
        };
}

No código anterior:

  • O MyForeignLibraryValueType é um tipo fora do controle, definido em uma biblioteca de consumo.
  • O MyForeignLibraryValueTypeSurrogate é um tipo substituto que mapeia para MyForeignLibraryValueType.
  • O RegisterConverterAttribute especifica que o MyForeignLibraryValueTypeSurrogateConverter age como um conversor para mapear de e para os dois tipos. Esta classe é uma implementação da interface IConverter<TValue,TSurrogate>.

O Orleans dá suporte à serialização de tipos em hierarquias de tipo (tipos que derivam de outros tipos). Caso um tipo estranho apareça em uma hierarquia de tipos (por exemplo, como a classe base para um de seus próprios tipos), você também deve implementar a interface Orleans.IPopulator<TValue,TSurrogate>. Considere o seguinte exemplo:

// The foreign type is not sealed, allowing other types to inherit from it.
public class MyForeignLibraryType
{
    public MyForeignLibraryType() { }

    public MyForeignLibraryType(int num, string str, DateTimeOffset dto)
    {
        Num = num;
        String = str;
        DateTimeOffset = dto;
    }

    public int Num { get; set; }
    public string String { get; set; }
    public DateTimeOffset DateTimeOffset { get; set; }
}

// The surrogate is defined as it was in the previous example.
[GenerateSerializer]
public struct MyForeignLibraryTypeSurrogate
{
    [Id(0)]
    public int Num;

    [Id(1)]
    public string String;

    [Id(2)]
    public DateTimeOffset DateTimeOffset;
}

// Implement the IConverter and IPopulator interfaces on the converter.
[RegisterConverter]
public sealed class MyForeignLibraryTypeSurrogateConverter :
    IConverter<MyForeignLibraryType, MyForeignLibraryTypeSurrogate>,
    IPopulator<MyForeignLibraryType, MyForeignLibraryTypeSurrogate>
{
    public MyForeignLibraryType ConvertFromSurrogate(
        in MyForeignLibraryTypeSurrogate surrogate) =>
        new(surrogate.Num, surrogate.String, surrogate.DateTimeOffset);

    public MyForeignLibraryTypeSurrogate ConvertToSurrogate(
        in MyForeignLibraryType value) =>
        new()
    {
        Num = value.Num,
        String = value.String,
        DateTimeOffset = value.DateTimeOffset
    };

    public void Populate(
        in MyForeignLibraryTypeSurrogate surrogate, MyForeignLibraryType value)
    {
        value.Num = surrogate.Num;
        value.String = surrogate.String;
        value.DateTimeOffset = surrogate.DateTimeOffset;
    }
}

// Application types can inherit from the foreign type, assuming they're not sealed
// since Orleans knows how to serialize it.
[GenerateSerializer]
public sealed class DerivedFromMyForeignLibraryType : MyForeignLibraryType
{
    public DerivedFromMyForeignLibraryType() { }

    public DerivedFromMyForeignLibraryType(
        int intValue, int num, string str, DateTimeOffset dto) : base(num, str, dto)
    {
        IntValue = intValue;
    }

    [Id(0)]
    public int IntValue { get; set; }
}

Regras de controle de versão

Há suporte para tolerância de versão, desde que o desenvolvedor siga um conjunto de regras ao modificar tipos. Se o desenvolvedor estiver familiarizado com sistemas como os Buffers de Protocolo do Google (Protobuf), essas regras serão familiares.

Tipos compostos (class & struct)

  • Há suporte para herança, mas não há suporte para a modificação da hierarquia de herança de um objeto. A classe base de uma classe não pode ser adicionada, alterada para outra classe ou removida.
  • Com exceção de alguns tipos numéricos, descritos na seção Numéricos abaixo, os tipos de campo não podem ser alterados.
  • Campos podem ser adicionados ou removidos em qualquer ponto de uma hierarquia de herança.
  • As IDs de campo não podem ser alteradas.
  • As IDs de campo devem ser exclusivas para cada nível em uma hierarquia de tipos, mas podem ser reutilizadas entre classes base e subclasses. Por exemplo, a classe Base pode declarar um campo com a ID 0, e um campo diferente pode ser declarado por Sub : Base com a mesma ID, 0.

Numerics

  • A assinatura de um campo numérico não pode ser alterada.
    • As conversões entre int e uint são inválidas.
  • A largura de um campo numérico pode ser alterada.
    • Por exemplo, há suporte para conversões de int para long ou ulong para ushort.
    • Conversões que restringem a largura serão geradas se o valor de runtime de um campo causar um estouro.
      • A conversão de ulong para ushort só terá suporte se o valor em runtime for menor que ushort.MaxValue.
      • As conversões de double para float só terá suporte se o valor do runtime estiver entre float.MinValue e float.MaxValue.
      • Da mesma forma para decimal, que tem um intervalo mais estreito que double e float.

Copiadoras

Orleans promove a segurança por padrão. Isso inclui a segurança de algumas classes de bugs de simultaneidade. Em particular, Orleans copiará imediatamente objetos passados em chamadas de grãos por padrão. Essa cópia é facilitada por Orleans.Serialization, e quando Orleans.CodeGeneration.GenerateSerializerAttribute é aplicado a um tipo, Orleans também gerará copiadores para esse tipo. Orleans evitará copiar tipos ou membros individuais marcados usando o ImmutableAttribute. Para obter mais detalhes, consulte Serialização de tipos imutáveis em Orleans.

Melhores práticas serialização

  • Forneça seus aliases de tipos usando o atributo [Alias("my-type")]. Tipos com aliases podem ser renomeados sem interromper a compatibilidade.

  • Não altere um record para um class regular ou vice-versa. Registros e classes não são representados de forma idêntica, pois os registros têm membros do construtor primário além de membros regulares e, portanto, os dois não são intercambiáveis.

  • Não adicione novos tipos a uma hierarquia de tipo existente para um tipo serializável. Você não deve adicionar uma nova classe base a um tipo existente. Você não deve adicionar uma nova subclasse a um tipo existente.

  • Substitua os usos de SerializableAttribute por GenerateSerializerAttribute e declarações correspondentes IdAttribute.

  • Inicie todas as IDs de membro em zero para cada tipo. As IDs em uma subclasse e sua classe base podem se sobrepor com segurança. Ambas as propriedades no exemplo a seguir têm IDs iguais a 0.

    [GenerateSerializer]
    public sealed class MyBaseClass
    {
        [Id(0)]
        public int MyBaseInt { get; set; }
    }
    
    [GenerateSerializer]
    public sealed class MySubClass : MyBaseClass
    {
        [Id(0)]
        public int MyBaseInt { get; set; }
    }
    
  • Amplie os tipos de membro numéricos conforme necessário. Você pode ampliar sbyte para short para int para long.

    • Você pode restringir tipos de membro numéricos, mas isso resultará em uma exceção de runtime, se os valores observados não puderem ser representados corretamente pelo tipo restrito. Por exemplo, int.MaxValue não pode ser representado por um campo short, portanto, restringir um campo int a short poderá resultar em uma exceção de runtime se esse valor for encontrado.
  • Não altere a assinatura de um membro de tipo numérico. Você não deve alterar o tipo de um membro de uint para int ou um int para uint, por exemplo.

Serializadores de armazenamento de granularidade

O Orleans inclui um modelo de persistência com suporte do provedor para granularidades, acessado por meio da propriedade State ou injetando um ou mais valores IPersistentState<TState> na sua granularidade. Antes do Orleans 7.0, cada provedor tinha um mecanismo diferente para configurar a serialização. No Orleans 7.0, agora há uma interface de serializador de estado de granularidade de uso geral, IGrainStorageSerializer, que oferece uma forma consistente de personalizar a serialização de estado para cada provedor. Os provedores de armazenamento com suporte implementam um padrão que envolve a definição da propriedade IStorageProviderSerializerOptions.GrainStorageSerializer na classe de opções do provedor, por exemplo:

No momento, a serialização de armazenamento de granularidade usa como padrão Newtonsoft.Json para serializar o estado. Você pode substituir isso modificando essa propriedade no momento da configuração. O exemplo a seguir demonstra isso, usando OptionsBuilder<TOptions>:

siloBuilder.AddAzureBlobGrainStorage(
    "MyGrainStorage",
    (OptionsBuilder<AzureBlobStorageOptions> optionsBuilder) =>
    {
        optionsBuilder.Configure<IMySerializer>(
            (options, serializer) => options.GrainStorageSerializer = serializer);
    });

Para obter mais informações, consulte OptionsBuilder API.

O Orleans tem uma estrutura de serialização avançada e extensível. O Orleans serializa tipos de dados passados em mensagens de solicitação e resposta de granularidade, bem como objetos de estado persistente de granularidade. Como parte dessa estrutura, o Orleans gera automaticamente o código de serialização para esses tipos de dados. Além de gerar uma serialização/desserialização mais eficiente para tipos que já são serializáveis no .NET, o Orleans também tenta gerar serializadores para tipos usados em interfaces de granularidade que não são serializáveis em .NET. A estrutura também inclui um conjunto de serializadores internos eficientes para tipos usados com frequência: listas, dicionários, cadeias de caracteres, primitivos, matrizes etc.

Dois recursos importantes do serializador do Orleans o diferenciam de muitas outras estruturas de serialização de terceiros: polimorfismo arbitrário/dinâmico de tipos e identidade de objeto.

  1. Polimorfismo arbitrário e dinâmico de tipos: o Orleans não impõe restrições aos tipos que podem ser passados em chamadas de granularidade e mantém a natureza dinâmica do tipo de dados real. Isso significa, por exemplo, que se o método nas interfaces de granularidade for declarado para aceitar IDictionary, mas em runtime, o remetente passar SortedDictionary<TKey,TValue>, o receptor obterá SortedDictionary (embora a interface granular/"contrato estático" não tenha especificado esse comportamento).

  2. Manutenção da identidade do objeto: se o mesmo objeto passar vários tipos nos argumentos de uma chamada de granularidade ou for indiretamente apontado mais de uma vez nos argumentos, o Orleans vai serializar esse objeto apenas uma vez. No lado do receptor, o Orleans vai restaurar todas as referências corretamente de modo que dois ponteiros para o mesmo objeto ainda apontem para o mesmo objeto após a desserialização. É importante preservar a identidade do objeto em cenários como o seguinte. Imagine que o grão A está enviando um dicionário com 100 entradas para o grão B, e 10 das chaves no dicionário apontam para o mesmo objeto, obj, ao lado de A. Sem preservar a identidade do objeto, B receberia um dicionário de 100 entradas com essas 10 chaves apontando para 10 clones diferentes do obj. Com a identidade do objeto preservada, o dicionário do lado de B se pareceria exatamente com o lado de A com essas 10 chaves apontando para um único objeto obj.

Os dois comportamentos acima são fornecidos pelo serializador binário padrão do .NET e, portanto, era importante para nós oferecer suporte a esse comportamento padrão e familiar também no Orleans.

Serializadores gerados

O Orleans usa as regras a seguir para decidir quais serializadores gerar. As regras são:

  1. Examine todos os tipos em todos os assemblies que fazem referência à biblioteca principal do Orleans.
  2. Dentre esses assemblies: gere serializadores para tipos que são referenciados diretamente em assinaturas de método de interfaces de granularidade ou assinatura de classe de estado, ou então para qualquer tipo marcado com SerializableAttribute.
  3. Além disso, um projeto de interface ou implementação de granularidade pode apontar para tipos arbitrários para geração de serialização adicionando atributos de nível de assembly KnownTypeAttribute ou KnownAssemblyAttribute para dizer ao gerador de código para gerar serializadores para tipos específicos ou todos os tipos qualificados em um assembly. Para obter mais informações sobre atributos no nível do assembly, consulte Aplicar atributos no nível do assembly.

Serialização de fallback

O Orleans dá suporte à transmissão de tipos arbitrários em runtime e, portanto, o gerador de código interno não pode determinar todo o conjunto de tipos que serão transmitidos antecipadamente. Além disso, determinados tipos não podem ter serializadores gerados para eles porque são inacessíveis (por exemplo, private) ou ter campos inacessíveis (por exemplo, readonly). Portanto, há uma necessidade de serialização just-in-time de tipos que eram inesperados ou não podiam ter serializadores gerados antecipadamente. O serializador responsável por esses tipos é chamado de serializador de fallback. O Orleans é fornecido com dois serializadores de fallback:

O serializador de fallback pode ser configurado usando a propriedade FallbackSerializationProvider em ClientConfiguration no cliente e GlobalConfiguration nos silos.

// Client configuration
var clientConfiguration = new ClientConfiguration();
clientConfiguration.FallbackSerializationProvider =
    typeof(FantasticSerializer).GetTypeInfo();

// Global configuration
var globalConfiguration = new GlobalConfiguration();
globalConfiguration.FallbackSerializationProvider =
    typeof(FantasticSerializer).GetTypeInfo();

Como alternativa, o provedor de serialização de fallback pode ser especificado na configuração XML:

<Messaging>
    <FallbackSerializationProvider
        Type="GreatCompany.FantasticFallbackSerializer, GreatCompany.SerializerAssembly"/>
</Messaging>

O BinaryFormatterSerializer é o serializador de fallback padrão.

Aviso

A serialização binária com BinaryFormatter pode ser perigosa. Para obter mais informações, consulte o guia de segurança BinaryFormatter e o guia de migração BinaryFormatter.

Serialização de exceção

As exceções são serializadas usando o serializador de fallback. Usando a configuração padrão, BinaryFormatter é o serializador de fallback e, portanto, o padrão ISerializable deve ser seguido para garantir a serialização correta de todas as propriedades em um tipo de exceção.

Aqui está um exemplo de um tipo de exceção com serialização implementada corretamente:

[Serializable]
public class MyCustomException : Exception
{
    public string MyProperty { get; }

    public MyCustomException(string myProperty, string message)
        : base(message)
    {
        MyProperty = myProperty;
    }

    public MyCustomException(string transactionId, string message, Exception innerException)
        : base(message, innerException)
    {
        MyProperty = transactionId;
    }

    // Note: This is the constructor called by BinaryFormatter during deserialization
    public MyCustomException(SerializationInfo info, StreamingContext context)
        : base(info, context)
    {
        MyProperty = info.GetString(nameof(MyProperty));
    }

    // Note: This method is called by BinaryFormatter during serialization
    public override void GetObjectData(SerializationInfo info, StreamingContext context)
    {
        base.GetObjectData(info, context);
        info.AddValue(nameof(MyProperty), MyProperty);
    }
}

Melhores práticas serialização

A serialização atende a duas finalidades primárias no Orleans:

  1. Como um formato de conexão para transmitir dados entre granularidades e clientes em runtime.
  2. Como um formato de armazenamento para manter dados de longa duração para recuperação posterior.

Os serializadores gerados pelo Orleans são adequados para a primeira finalidade devido à flexibilidade, desempenho e versatilidade. Eles não são tão adequados para a segunda finalidade, pois não são explicitamente tolerantes a versões. É recomendável que os usuários configurem um serializador tolerante a versão, como Buffers de Protocolo para dados persistentes. Os buffers de protocolo são suportados por meio de Orleans.Serialization.ProtobufSerializer a partir do pacote NuGet Microsoft.Orleans.OrleansGoogleUtils. As melhores práticas para o serializador específico da escolha devem ser adotadas para garantir a tolerância a versões. Serializadores de terceiros podem ser configurados usando a propriedade de configuração SerializationProviders, conforme descrito acima.