Orleans 中的序列化

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

  • 粒度调用序列化:用于序列化传入和传出粒度的对象。
  • 粒度存储序列化:用于在存储系统中序列化对象。

本文的大部分内容都侧重于通过包含在 Orleans中的序列化框架进行粒度调用序列化。 粒度存储序列化程序部分讨论粒度存储序列化。

使用 Orleans 序列化

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

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

序列化程序类型高保真表示形式相当不常见,因此一些点需要进一步解释:

  1. 动态类型和任意多态性: Orleans 不对在粒度调用中传递的类型强制实施限制,并维护实际数据类型的动态性质。 这意味着,例如,如果粒度接口中的方法声明为接受 IDictionary,但在运行时发送方传递一个 SortedDictionary<TKey,TValue>,接收方确实会获取一个 SortedDictionary (即使“静态协定”/粒度接口未指定此行为)。

  2. 维护对象标识:如果在 grain 调用的参数中多次传递同一对象,或者参数中有多次间接引用,Orleans 都只会序列化一次。 在接收方的处理器中,Orleans 会正确地还原所有引用,这样一来,反序列化后,指向同一对象的两个指针仍然指向同一对象。 在以下情况下,保留对象标识非常重要:假设粒度 A 在 A 端将包含 100 个条目的字典发送到粒度 B,字典中的 10 个键指向同一对象 obj。 如果不保留对象标识,B 会接收到 100 个条目,其中 10 个键指向 10 个不同的 obj 的克隆。 保留对象标识后,B 端的字典看起来与 A 端完全相同,这 10 个键指向单个对象 obj。 请注意,由于 .NET 中的默认字符串哈希代码实现按进程随机化,因此字典和哈希集(例如)中的值顺序可能不会保留。

为了支持版本容错,序列化程序要求明确序列化哪些类型和成员。 我们试图使这尽可能无痛。 标记所有可序列化类型 Orleans.GenerateSerializerAttribute ,以指示 Orleans 为类型生成序列化程序代码。 完成此操作后,可以使用包含的代码修复将所需的 Orleans.IdAttribute 添加到类型的可序列化成员,具体如下:

包含类型的成员中没有 IdAttribute 时,对 GenerateSerializerAttribute 提出和应用代码修复建议的动态图。

下面是 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)]

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

有时,可能需要在没有完全控制的粒度之间传递类型。 在这些情况下,在应用程序代码中手动转换到自定义类型或从自定义类型转换可能不切实际。 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 协议缓冲区(Protobuf)等系统,这些规则将很熟悉。

复合类型(classstruct

  • 支持继承,但不支持修改对象的继承层次结构。 不能添加、更改或删除类的基类。
  • 除了下面的 “数值 ”部分所述的某些数值类型外,不能更改字段类型。
  • 可以在继承层次结构中的任何点添加或删除字段。
  • 无法更改字段 ID。
  • 字段 ID 对于类型层次结构中的每个级别必须是唯一的,但可以在基类和子类之间重复使用。 例如,类 Base 可以声明具有 ID 0的字段,类 Sub : Base 可以声明具有相同 ID 0的不同字段。

数值

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

复印机

Orleans 默认情况下,促进安全性,包括某些类型并发错误的安全性。 具体而言,默认情况下,Orleans 会立即复制调用中传递的对象。 Orleans.序列化有助于进行这种复制。 在将 Orleans.CodeGeneration.GenerateSerializerAttribute 应用于某个类型时,Orleans 还会为该类型生成复制器。 Orleans 避免复制标记 ImmutableAttribute的类型或单个成员。 更多详细信息请参阅《Orleans 的不可变类型的序列化》。

序列化最佳做法

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

  • 请勿record 更改为常规 class,反之亦然。 记录与类在表示上并不完全相同,因为记录除了常规成员外,还具有主构造函数成员,因此两者不可互换。

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

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

  • 对于每种类型,要将所有成员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 包括一个由提供程序支持的 grain 持久性模型,可以通过 State 属性或通过将一个或多个 IPersistentState<TState> 值注入到 grain 中来访问。 在 Orleans 7.0 之前的版本中,每个提供程序都有不同的序列化配置机制。 在 Orleans 7.0 中,现在引入了一个通用的粒状态序列化接口,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 序列化在粒度请求和响应消息中传递的数据类型,以及粒度持久状态对象。 作为此框架的一部分, Orleans 自动生成这些数据类型的序列化代码。 除了为已经支持 .NET 序列化的类型生成更高效的序列化/反序列化之外,Orleans 还尝试为在粒度接口中使用但不支持 .NET 序列化的类型生成序列化程序。 该框架还包括一组用于常用类型的、高效的内置序列化程序:列表、字典、字符串、基元、数组等。

两个重要功能使Orleans的序列化器区别于许多其他第三方序列化框架:动态类型/任意多态性和对象标识。

  1. 动态类型和任意多态性: Orleans 不对在粒度调用中传递的类型强制实施限制,并维护实际数据类型的动态性质。 这意味着,例如,如果粒度接口中的方法声明为接受 IDictionary,但在运行时发送方传递一个 SortedDictionary<TKey,TValue>,接收方确实会获取一个 SortedDictionary (即使“静态协定”/粒度接口未指定此行为)。

  2. 维护对象标识:如果在粒度调用的参数中多次传递同一对象,或者从参数中对同一对象进行多次间接引用,Orleans 也仅对此对象进行一次序列化。 在接收方,Orleans 正确还原所有引用,以便在反序列化后,指向同一对象的两个指针仍然指向同一对象。 在以下情况下,保留对象标识非常重要:假设粒度 A 在 A 端将包含 100 个条目的字典发送到粒度 B,字典中的 10 个键指向同一对象 obj。 如果不保留对象标识,B 会接收到 100 个条目,其中 10 个键指向 10 个不同的 obj 的克隆。 保留对象标识后,B 端的字典看起来与 A 端完全相同,这 10 个键指向单个对象 obj

标准 .NET 二进制序列化程序提供了上述两种行为,因此我们也支持在 Orleans 中实现这一标准和熟悉的行为。

生成的序列化程序

Orleans 使用以下规则确定要生成的序列化程序:

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

回退序列化

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

使用ClientConfiguration属性在FallbackSerializationProvider(客户端)和GlobalConfiguration(服务节点)上配置回退序列化程序。

// 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 的二进制序列化可能很危险。 有关详细信息,请参阅 BinaryFormatter 安全指南BinaryFormatter 迁移指南

异常序列化

使用回退序列化程序来序列化异常。 使用默认配置时, 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 生成的序列化程序因其灵活性、性能和多功能性而适用于第一个目的。 它们不太适合第二个目的,因为它们并不具备明确的版本兼容性。 建议为持久性数据配置版本容错序列化程序,例如 协议缓冲区。 协议缓冲区通过 Orleans NuGet 包中的 获得支持。 遵循所选序列化程序的最佳做法,确保版本容错。 您可以按照上述说明使用 SerializationProviders 配置属性来配置第三方序列化程序。