Orleans 中的序列化

Orleans 广泛使用两种类型的序列化:

  • Grain 调用序列化 - 用于序列化传入和传出 grain 的对象。
  • Grain 存储序列化 - 用于序列化传入和传出存储系统的对象。

本文的大部分内容在阐释通过 Orleans 中的序列化框架进行 grain 调用序列化。 Grain 存储序列化程序部分讨论 Grain 存储序列化。

使用 Orleans 序列化

Orleans 包括一个可称为“Orleans.序列化”的可扩展高级序列化框架。 Orleans 中的序列化框架旨在满足以下目标:

  • 高性能 - 序列化程序专为性能而设计和优化。 更多信息请参阅本演示文稿
  • 高保真 - 序列化程序忠实地表示大多数 .NET 的类型系统,包括对泛型、多形性、继承层次结构、对象标识和循环图的支持。 不支持指针,因为它们不能跨进程移植。
  • 灵活性 - 可以通过创建代理或委托外部序列化库(如 System.Text.JsonNewtonsoft.JsonGoogle.Protobuf)将序列化程序自定义为支持第三方库。
  • 版本容错 - 序列化程序允许应用程序类型随时间推移而演变,支持:
    • 添加和删除成员
    • 子类
    • 数值加宽和缩小(例如:int 加宽或缩小到 longfloat 加宽或缩小到 double
    • 重命名类型

类型的高保真表示对于序列化程序来说并不常见,因此有些点需要进一步阐述:

  1. 动态类型和任意多形性:Orleans 对 grain 调用中可传递的类型没有任何限制,并保持实际数据类型的动态性。 这意味着,举例而言,如果 grain 接口中的方法声明为接受 IDictionary,但在运行时发送方传递了 SortedDictionary<TKey,TValue>,则接收方确实会获得 SortedDictionary(尽管“静态协定”/grain 接口未指定此行为)。

  2. 保留对象标识:如果在 grain 调用的参数中为同一对象传递多个类型,或者在参数中间接指向多个类型,Orleans 只会序列化该对象一次。 在接收方一端,Orleans 将正确还原所有引用,因此在反序列化之后,指向同一对象的两个指针仍然指向同一个对象。 在如下所述的场景中保留对象标识非常重要。 假设 grain A 向 grain B 发送一个包含 100 个条目的字典,该字典有 10 个键指向 A 端的同一个对象 obj。 如果不保留对象标识,B 会接收到 100 个条目,其中 10 个键指向 10 个不同的 obj 的克隆。 如果保留了对象标识,B 端的字典就会和 A 端的一模一样,10 个键指向单一的 obj 对象。 请注意,由于每个进程随机 .NET 中的默认字符串哈希代码实现,因此字典和哈希集(举例说)中的值的顺序可能不会保留。

为了支持版本容错,序列化程序要求开发人员明确序列化的类型和成员。 我们尽量降低这一点的麻烦程度。 必须使用 Orleans.GenerateSerializerAttribute 标记所有可序列化的类型,以指示 Orleans 为类型生成序列化程序代码。 完成此操作后,就可以使用内含的代码修补程序将所需的 Orleans.IdAttribute 添加为类型上的可序列化成员,如下所示:

An animated image of the available code fix being suggested and applied on the GenerateSerializerAttribute when the containing type doesn't contain IdAttribute's on its members.

下面是 Orleans 的可序列化类型的示例,演示如何应用属性。

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

Orleans 支持继承,并将单独序列化层次结构中的每个层,从而让它们具有不同的成员 ID。

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

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

在前面的代码中,请注意,PublicationBook 都有 [Id(0)] 成员,即使 Book 派生自 Publication。 这是 Orleans 推荐的做法,因为成员标识符的范围包含至继承级别,而不是作为一个整体类型。 可以在 PublicationBook 中单独添加和删除成员,但在未考虑特殊注意事项的情况下部署应用程序后,就无法将新的基类插入到层次结构中。

Orleans 还支持使用 internalprivatereadonly 成员序列化类型,例如在此示例类型中:

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

默认情况下, Orleans 将通过对类型全名进行编码来序列化类型。 你可以添加一个 Orleans.AliasAttribute 来替代该设置。 如此类型序列化使用的名称就能适应基础类的重命名或在程序集之间移动。 类型别名是全局范围的,应用程序不能有两个具有相同值的别名。 对于泛型类型,别名值必须包含前面带有反引号的泛型参数数,例如, MyGenericType<T, U> 的别名可以是 [Alias("mytype`2")]

序列化 record 类型

默认情况下,记录的主构造函数中定义的成员具有隐式 ID。 换句话说, Orleans 支持序列化 record 类型。 这意味着无法更改已部署类型的参数顺序,因为这会破坏与早期版本的应用程序 (在滚动升级时) 以及存储和流中该类型的序列化实例的兼容性。 在记录类型的正文中定义的成员不会与主要构造函数参数共享标识。

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

如果不希望将主要构造函数参数自动包含为可序列化字段,可以使用 [GenerateSerializer(IncludePrimaryConstructorParameters = false)]

用于序列化外部类型的代理项

有时,可能需要在没有完全控制的 grain 之间传递类型。 在这些情况下,手动在应用程序代码中转换某些自定义类型可能不切实际。 Orleans 以代理项类型的形式为这些情况提供解决方案。 代理项被序列化以取代其目标类型,并且具有目标类型转换的功能。 假设以下示例中的外部类型以及相应的代理项和转换器:

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

在上述代码中:

  • MyForeignLibraryValueType 是控件外部的类型,在使用库中定义。
  • MyForeignLibraryValueTypeSurrogate 是映射到 MyForeignLibraryValueType 的代理项类型。
  • RegisterConverterAttribute 指定 MyForeignLibraryValueTypeSurrogateConverter 为转换器,以在这两种类型之间进行映射。 类是 IConverter<TValue,TSurrogate> 接口的实现。

Orleans 支持类型层次结构中的类型序列化(派生自其他类型的类型)。 如果某个外部类型出现在类型层次结构中(例如作为自有类型之一的基础类),则必须另外实现 Orleans.IPopulator<TValue,TSurrogate> 接口。 请考虑以下示例:

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

版本控制规则

如果开发人员在修改类型时遵循一组规则,则支持版本容错。 如果开发人员熟悉 Google Protocol Buffers (Protobuf) 等系统,则也会熟悉这些规则。

复合类型(classstruct

  • 支持继承,但不支持修改对象的继承层次结构。 类的基类无法添加、更改为另一个类或删除。
  • 除了某些数值类型(如下文的《数值》部分所述),字段类型无法更改。
  • 可以在继承层次结构中的任意点添加或删除字段。
  • 无法更改字段 ID。
  • 类型层次结构中的每个级别必须有唯一的字段 ID,但基类和子类可以重复使用同一字段 ID。 例如,Base 类可以声明字段 ID 为 0Sub : Base 可以用同一 ID 0 声明其他字段。

数值

  • 不能更改数值字段的符号
    • intuint 之间的转换是无效的。
  • 可以更改数值字段的宽度
    • 例如:支持从 intlongulongushort 的转换。
    • 如果字段的运行时值会导致溢出,则缩小宽度的转换将引发。
      • 仅当运行时的值小于 ushort.MaxValue 时,才支持从 ulongushort 的转换。
      • 仅当运行时值介于 float.MinValuefloat.MaxValue 之间时,才支持从 doublefloat 的转换。
      • 对于范围比 doublefloat 都窄的 decimal 也是一样的。

复印机

Orleans 默认提升安全性。 包括某些类并发 bug 的安全性。 具体而言,默认情况下 Orleans 会立即复制传给 grain 调用的对象。 Orleans.序列化简化了该复制。当类型应用 Orleans.CodeGeneration.GenerateSerializerAttribute 时,Orleans 还会为该类型生成复制器。 Orleans 会避免复制使用 ImmutableAttribute 做标记的类型或单个成员。 更多详细信息请参阅《Orleans 的不可变类型的序列化》。

序列化最佳做法

  • 请使用[Alias("my-type")] 属性给类型提供别名。 具有别名的类型可以在不破坏兼容性的前提下被重命名。

  • 请勿record 更改为常规 class,反之亦然。 记录和类的表示方式不同,因为记录除常规成员外还有主要构造函数成员,因此两者不可互换。

  • 请勿将新类型添加到可序列化类型的现有类型层次结构中。 不得向现有类型添加新基类。 可以安全地将新子类添加到现有类型。

  • 请使用GenerateSerializerAttribute 和对应的 IdAttribute 声明替换 SerializableAttribute

  • 请使用零作为各类所有成员 ID 的开头。 子类及其基类中的 ID 可以安全地重叠。 以下示例中两个属性的 ID 都等于 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; }
    }
    
  • 根据需要扩大数字成员类型。 你可以将 sbyte 扩大到 shortintlong

    • 可以缩小数值成员类型,但如果缩小的类型无法正确表示观察到的值,则会导致运行时异常。 例如, int.MaxValue 不能由 short 字段表示,因此如果遇到此类值,将 int 字段缩小到 short 可能会导致运行时异常。
  • 请勿更改数值类型成员的符号。 例如,不得将成员类型从 uint 更改为 int 或从 int 更改为 uint

Grain 存储序列化程序

Orleans 包括一个由提供程序支持的持久性模型,可以通过 State 属性或通过将一个或多个 IPersistentState<TState> 值注入到 grain 中来访问。 在 Orleans 7.0 之前的版本中,每个提供程序都有不同的序列化配置机制。 Orleans 7.0 有一个通用的 grain 状态序列化程序接口 IGrainStorageSerializer,它为每个提供程序提供一个固定的自定义状态序列化方法。 受支持的存储提供程序实现的模式在提供程序的选项类上设置 IStorageProviderSerializerOptions.GrainStorageSerializer 属性,例如:

Grain 存储序列化当前默认设置 Newtonsoft.Json 以序列化状态。 可以通过在配置时修改该属性来替换。 以下示例使用 OptionsBuilder<TOptions> 对此进行了演示:

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

更多详细信息请参阅《OptionBuilder API》。

Orleans 有一个可扩展的高级序列化框架。 Orleans 序列化 grain 请求和响应消息中传递的数据类型,以及 grain 持久性状态对象。 作为此框架的一部分,Orleans 会自动为这些数据类型生成序列化代码。 除了为可 .NET 序列化的类型生成更有效的序列化/反序列化之外,Orleans 还尝试为不可 .NET 序列化的 grain 接口使用的类型生成序列化程序。 该框架还包括一组用于常用类型的、高效的内置序列化程序:列表、字典、字符串、基元、数组等。

Orleans 序列化程序有两个重要特性,使之有别于其他许多第三方序列化框架:动态类型/任意多形性和对象标识。

  1. 动态类型和任意多形性:Orleans 对 grain 调用中可传递的类型没有任何限制,并保持实际数据类型的动态性。 这意味着,举例而言,如果 grain 接口中的方法声明为接受 IDictionary,但在运行时发送方传递了 SortedDictionary<TKey,TValue>,则接收方确实会获得 SortedDictionary(尽管“静态协定”/grain 接口未指定此行为)。

  2. 保留对象标识:如果在 grain 调用的参数中为同一对象传递多个类型,或者在参数中间接指向多个类型,Orleans 只会序列化该对象一次。 在接收方一端,Orleans 将正确还原所有引用,因此在反序列化之后,指向同一对象的两个指针仍然指向同一个对象。 在如下所述的场景中保留对象标识非常重要。 假设 grain A 向 grain B 发送一个包含 100 个条目的字典,该字典有 10 个键指向 A 端的同一个对象 obj。 如果不保留对象标识,B 将收到包含 100 个条目的字典,其中 10 个键指向 10 个不同的 obj 克隆。如果保留对象标识,B 端的字典将与 A 端的字典完全相同,其中 10 个键指向单个对象 obj。

上述两种行为由标准的 .NET 二进制序列化程序提供,因此我们也必须在 Orleans 中支持这种常见的标准行为。

生成的序列化程序

Orleans 使用以下规则来决定要生成的序列化程序。 规则包括:

  1. 扫描所有引用核心 Orleans 库的程序集中的所有类型。
  2. 在这些程序集中:为直接在 grain 接口方法签名或状态类签名中引用的类型或者标记了 SerializableAttribute 的任何类型生成序列化程序。
  3. 此外,grain 接口或实现项目可以指向用于生成序列化的任意类型,为此,可以添加一个 KnownTypeAttributeKnownAssemblyAttribute 程序集级属性,来告知代码生成器为程序集中的特定类型或所有符合条件的类型生成序列化程序。 有关程序集级属性的详细信息,请参阅在程序集级别应用属性

回退序列化

Orleans 支持在运行时传输任意类型,因此内置代码生成器无法确定所有会提前传输的类型。 此外,无法为某些类型生成序列化程序,因为它们不可访问(例如 private)或具有不可访问的字段(例如 readonly)。 因此,需要对意外或无法提前生成序列化程序的类型进行实时序列化。 负责这些类型的序列化程序称为回退序列化程序。 Orleans 有两个回退序列化程序:

可以使用客户端上的 ClientConfiguration 和 silo 上的 GlobalConfiguration 中的 FallbackSerializationProvider 属性来配置回退序列化程序。

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

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

或者,可以在 XML 配置中指定回退序列化提供程序:

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

BinaryFormatterSerializer 是默认的回退序列化程序。

异常序列化

使用回退序列化程序来序列化异常。 使用默认配置时,BinaryFormatter 是回退序列化程序,因此必须遵循 ISerializable 模式,以确保正确序列化异常类型中的所有属性。

下面是正确实现了序列化的异常类型的示例:

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

序列化最佳做法

Orleans 中的序列化有两个主要作用:

  1. 作为在运行时在 grain 和客户端之间传输数据的报文格式。
  2. 作为一种存储格式,用于保存长期生存的数据供以后检索。

Orleans 生成的序列化程序因其灵活性、性能和多功能性而适用于第一个目的。 它们不适用于第二个目的,因为它们没有明确的版本容错能力。 建议用户为持久性数据配置一个版本容错的序列化程序,例如协议缓冲区。 可以通过 Microsoft.Orleans.OrleansGoogleUtils NuGet 包中的 Orleans.Serialization.ProtobufSerializer 支持协议缓冲区。 应使用所选特定序列化程序的最佳做法来确保版本容错。 可以使用上述 SerializationProviders 配置属性来配置第三方序列化程序。