Orleans 中的序列化

Orleans 中所使用的序列化大致有兩種:

  • 精細度呼叫序列化 - 用來序列化傳入和傳出精細度的物件。
  • 精細度儲存序列化 - 用來序列化進出儲存體系統的物件。

本文中大部分都是透過 Orleans 中包含的序列化架構,專門用來呼叫序列化。 精細度儲存序列化程式一節討論精細度儲存體序列化。

使用 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 會正確還原所有參考,使相同物件的兩個指標在還原序列化之後仍然指向相同的物件。 在類似以下的案例中,物件識別的保留非常重要。 假設精細度 A 將具有 100 個項目的字典傳送精細度 B,該字典中有 10 個索引鍵指向位於 A 端的同一個物件 obj。 如果沒有保留物件識別,則 B 會收到 100 個項目的字典,其中 10 個索引鍵指向 10 個不同的 obj 複本。 使用物件識別保留時,B 端的字典看起來與指向單一物件 obj 的 10 個索引鍵完全相同。 請注意,由於 .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 支援繼承,而且會個別序列化階層中的個別層,使其具有不同的成員識別碼。

[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 型別

依預設,在記錄的主要建構函式中定義的成員具有隱含識別碼。 換句話說,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 通訊協定緩衝區 (Protobuf) 等系統,則這些規則會很熟悉。

複合型別 (class & struct)

  • 支援繼承,但不支援修改物件的繼承階層。 類別的基底類別無法新增、變更為另一個類別或移除。
  • 除了某些數值型別的例外狀況之外,如下面數值一節所述,無法變更欄位型別。
  • 您可以在繼承階層中的任何時間點新增或移除欄位。
  • 欄位識別碼無法變更。
  • 欄位識別碼對於型別階層中的每個層級都必須是唯一的,但可以在基底類別和子類別之間重複使用。 例如,Base 類別可以宣告具有識別碼 0 的欄位,而不同的欄位可以使用具有相同識別碼 0Sub : Base 來宣告。

數值

  • 無法變更數值欄位的正負號狀態
    • intuint 之間的轉換無效。
  • 數值欄位的寬度可以變更。
    • 例如:支援從 int 轉換為 long 或從 ulong 轉換為 ushort
    • 如果欄位的執行階段值會造成溢位,則會擲回縮小寬度的轉換。
      • 只有在執行階段的值小於 ushort.MaxValue 時,才支援從 ulong 轉換為 ushort
      • 只有在執行階段值介於 float.MinValuefloat.MaxValue 之間時,才支援從 double 轉換為 float
      • 同樣地,對於 decimal,其範圍比 doublefloat 都窄。

複製器

Orleans 預設可提升安全性。 這包括某些並行錯誤類別的安全性。 特別是,Orleans 預設會立即複製傳入精細度呼叫的物件。 這項複製是由 Orleans 輔助處理的。序列化以及將 Orleans.CodeGeneration.GenerateSerializerAttribute 套用至型別時,Orleans 也會產生該型別的複製器。 Orleans 將會避免複製使用 ImmutableAttribute 標記的型別或個別成員。 如需詳細資料,請參閱Orleans 中的不可變型別序列化

序列化最佳做法

  • 請務必利用 [Alias("my-type")] 屬性提供型別別名。 重新命名具有別名的型別不會破壞相容性。

  • 請勿record 變更為一般 class,反之亦然。 記錄和類別不會以完全相同的方式表示,因為記錄除了一般成員之外,還有主要建構函式成員,因此這兩者不可互換。

  • 請勿將新型別新增至現有型別階層以做為可序列化的型別。 您不得將新的基底類別新增至現有的型別。 您可以將新的子類別安全地新增至現有的型別。

  • 請務必SerializableAttribute 的用法取代為 GenerateSerializerAttribute 及對應的 IdAttribute 宣告。

  • ✅每個型別的所有成員識別碼務必從零開始。 子類別及其基底類別中的識別碼可以安全地重疊。 下列範例中的兩個屬性都有等於 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 中,現在有一般用途的 Grain 狀態序列化程式介面,即 IGrainStorageSerializer,提供一致的方式讓每個提供者自訂狀態序列化。 支援的儲存體提供者會實作一種模式,其中涉及在提供者的選項類別上設定 IStorageProviderSerializerOptions.GrainStorageSerializer 屬性,例如:

Grain 儲存體序列化目前的預設設定是使用 Newtonsoft.Json 來序列化狀態。 您可以在設定時修改該屬性來取代這個設定。 下列範例示範使用 OptionsBuilder<TOptions>

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

如需詳細資訊,請參閱 OptionsBuilder 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 會正確還原所有參考,使相同物件的兩個指標在還原序列化之後仍然指向相同的物件。 在類似以下的案例中,物件識別的保留非常重要。 假設精細度 A 將具有 100 個項目的字典傳送精細度 B,該字典中有 10 個索引鍵指向位於 A 端的同一個物件 obj。 如果沒有保留物件識別,則 B 會收到 100 個項目的字典,其中 10 個索引鍵指向 10 個不同的 obj 複本。使用物件識別保留時,B 端的字典看起來與指向單一物件 obj 的 10 個索引鍵完全相同。

上述兩種行為是由標準 .NET 二進位序列化程式所提供,因此在 Orleans 中支援此標準且熟悉的行為對我們而言非常重要。

產生的序列化程式

Orleans 使用下列規則決定要產生的序列化程式。 規則如下:

  1. 在參考核心 Orleans 程式庫的所有組件中掃描所有型別。
  2. 在這些組件中:針對在 Grain 介面方法簽章或狀態類別簽章中直接參考的型別,或是針對使用 SerializableAttribute 標記的任何型別,產生序列化程式。
  3. 此外,Grain 介面或實作專案可以指向任意型別以用於序列化的產生,透過新增 KnownTypeAttributeKnownAssemblyAttribute 組件層級屬性,告知程式碼產生器針對組件內特定型別或所有符合資格的型別,產生序列化程式。 如需組件層級屬性的詳細資訊,請參閱在組件層級套用屬性

後援序列化

Orleans 支援在執行階段傳輸任意型別,因此內建程式碼產生器無法判斷將會事先傳輸的整組型別。 此外,某些型別無法產生序列化程式,因其無法存取 (例如 private) 或具有無法存取的欄位 (例如 readonly)。 因此,非預期或無法事先產生序列化程式的型別有 just-in-time 序列化型別的需求。 負責這些型別的序列化程式稱為後援序列化程式。 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 組態屬性設定協力廠商的序列化程式,如上所述。