Orleans で使用されるシリアル化は、大きく分けて以下の 2 種類があります。
- グレイン呼び出しのシリアル化: グレインとの間で渡されるオブジェクトをシリアル化するために使用されます。
- グレイン ストレージのシリアル化: ストレージ システムとの間でオブジェクトをシリアル化するために使用されます。
この記事のほとんどは、 Orleansに含まれるシリアル化フレームワークを使用したグレイン呼び出しのシリアル化に焦点を当てています。 グレイン ストレージ シリアライザーのセクションでは、グレイン ストレージのシリアル化について説明します。
Orleans シリアル化を使用する
Orleansには、Orleansと呼ばれる高度で拡張可能なシリアル化フレームワークが含まれています。シリアル化。 Orleans に含まれるシリアル化フレームワークは、次の目標を満たすように設計されています。
- 高パフォーマンス: シリアライザーは、パフォーマンスのために設計および最適化されています。 詳細については、このプレゼンテーションをご覧ください。
- 高忠実度: シリアライザーは、.NET の型システムのほとんどを正確に表現し、ジェネリック、ポリモーフィズム、継承階層、オブジェクト ID、循環グラフのサポートを含みます。 ポインターはプロセス間で移植できないため、サポートされていません。
- 柔軟性: サロゲートを作成するか、System.Text.Json、Newtonsoft.Json、Google.Protobuf などの外部シリアル化ライブラリに委任することで、サードパーティライブラリをサポートするようにシリアライザーをカスタマイズできます。
-
バージョン許容度: シリアライザーを使用すると、アプリケーションの種類を時間の経過と同時に進化させることができます。次の機能がサポートされます。
- メンバーの追加と削除
- サブクラス化
- 数値の拡大と縮小 (
int
との間のlong
、float
間のdouble
など) - 型の名前を変更
型の忠実度の高い表現はシリアライザーでは非常に珍しいので、いくつかの点でさらに説明する必要があります。
動的型と任意のポリモーフィズム: Orleans は、グレイン呼び出しで渡される型に制限を適用せず、実際のデータ型の動的な性質を維持します。 つまり、たとえば、グレイン インターフェイス内のメソッドが IDictionaryを受け入れるように宣言されているが、実行時に送信者が SortedDictionary<TKey,TValue>を渡すと、受信側は実際に
SortedDictionary
を取得します ("静的コントラクト"/グレイン インターフェイスでこの動作が指定されていない場合でも)。オブジェクト ID の維持: グレイン呼び出しの引数で同じオブジェクトが複数回渡されるか、引数から複数回間接的に指されている場合、 Orleans は 1 回だけシリアル化します。 受信側では、 Orleans は、逆シリアル化後も同じオブジェクトを指す 2 つのポインターが同じオブジェクトを指すように、すべての参照を正しく復元します。 オブジェクト ID の保持は、次のようなシナリオで重要です。グレイン A がグレイン B に 100 個のエントリを持つディクショナリを送信し、ディクショナリ内の 10 個のキーが A 側の同じオブジェクト (
obj
) を指しているとします。 オブジェクト ID を保持しない場合、B は 100 個のエントリのディクショナリを受け取り、そのうち 10 個のキーはobj
の 10 個の異なる複製をポイントします。 オブジェクト ID が保持されている場合、B 側のディクショナリは A 側とまったく同じように見え、これらの 10 個のキーは単一のオブジェクトobj
を指します。 .NET の既定の文字列ハッシュ コードの実装はプロセスごとにランダム化されるため、ディクショナリとハッシュ セット内の値の順序 (たとえば) は保持されない可能性があることに注意してください。
バージョンの許容範囲をサポートするために、シリアライザーでは、シリアル化される型とメンバーを明示的に指定する必要があります。 私たちはこれを可能な限り無痛にしようとしました。 すべてのシリアル化可能な型を Orleans.GenerateSerializerAttribute でマークし、型のシリアライザー コードを生成するように Orleans に指示します。 これを完了したら、次に示すように、含まれているコード修正を使用して、型のシリアル化可能なメンバーに必要な Orleans.IdAttribute を追加できます。
属性を適用する方法を示す、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; }
}
前のコードでは、 Publication
と Book
の両方に [Id(0)]
を持つメンバーがあることに注意してください。ただし、 Book
は Publication
から派生しています。 メンバー識別子のスコープは、型全体ではなく継承レベルに設定されるため、 Orleans の推奨される方法です。
Publication
とBook
のメンバーは個別に追加および削除できますが、特別な考慮なしにアプリケーションをデプロイした後は、新しい基底クラスを階層に挿入することはできません。
Orleans は、次の例の型のように、internal
、private
、readonly
のメンバーの型のシリアル化もサポートします。
[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 を追加することで、これを上書きすることができます。 これを行うことで、基となるクラスの名前を変更したりアセンブリ間で位置を変更した場合でも、回復性のある名前を使用して型がシリアル化されます。 型のエイリアスはグローバルに範囲指定され、アプリケーションで 2 つのエイリアスに同じ値を持たせることはできません。 ジェネリック型の場合、エイリアス値にはバックティックに続けてジェネリック パラメーターの数を含める必要があります。たとえば、MyGenericType<T, U>
は[Alias("mytype`2")]
というエイリアスになる可能性があります。
record
型のシリアル化
レコードのプライマリ コンストラクターで定義されているメンバーには、既定で暗黙的な ID があります。 言い換えると、Orleans では record
型のシリアル化がサポートされます。 つまり、既にデプロイされている型のパラメーターの順序を変更することはできません。これは、アプリケーションの以前のバージョン (ローリング アップグレード シナリオの場合) や、ストレージとストリーム内のその型のシリアル化されたインスタンスとの互換性を解除するためです。 レコード型の本体で定義されたメンバーは、プライマリ コンストラクター パラメーターと ID を共有しません。
[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
が 2 つの型間でマップするコンバーターとして機能することを指定します。 このクラスは、 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
)
- 継承はサポートされていますが、オブジェクトの継承階層の変更はサポートされていません。 クラスの基底クラスを追加、変更、または削除することはできません。
- 以下の「数値 」セクションで 説明する数値型を除き、フィールド型を変更することはできません。
- 継承階層内の任意の時点でフィールドを追加または削除できます。
- フィールド ID は変更できません。
- フィールド ID は、型階層の各レベルで一意である必要がありますが、基底クラスとサブクラスの間で再利用できます。 たとえば、
Base
クラスは ID0
を持つフィールドを宣言でき、Sub : Base
クラスは同じ ID0
を持つ別のフィールドを宣言できます。
数値
- 数値フィールドの 符号を 変更することはできません。
-
int
とuint
の間の変換は無効です。
-
- 数値フィールドの 幅 を変更できます。
- たとえば、
int
からlong
への変換や、ulong
からushort
への変換がサポートされています。 - 幅を狭める変換は、フィールドの実行時の値がオーバーフローを引き起こすと例外をスローします。
-
ulong
からushort
への変換は、ランタイム値がushort.MaxValue
未満の場合にのみサポートされます。 -
double
からfloat
への変換は、ランタイム値がfloat.MinValue
とfloat.MaxValue
の間にある場合にのみサポートされます。 -
decimal
とdouble
の両方よりも範囲が狭いfloat
の場合も同様です。
- たとえば、
コピー機
Orleans は、一部のクラスのコンカレンシー バグからの安全性を含め、既定で安全性を促進します。 特に、 Orleans は、既定でグレイン呼び出しで渡されたオブジェクトを直ちにコピーします。 Orleans.シリアル化により、このコピーが容易になります。 Orleans.CodeGeneration.GenerateSerializerAttributeを型に適用すると、Orleansはその型のコピー子も生成されます。 Orleans では、型または ImmutableAttribute でマークされた個々のメンバーのコピーが回避されます。 詳細については、「Orleans での不変型のシリアル化」を参照してください。
推奨されるシリアル化の手順
✅ 属性を使用して型にエイリアスを指定
[Alias("my-type")]
。 エイリアスを持つ型は、互換性を損なうことなく名前を変更できます。❌ を通常の
record
に変更したり、その逆をしたりclass
。 レコードには通常のメンバーに加えてプライマリ コンストラクター メンバーがあるため、レコードとクラスは同じように表されません。したがって、この 2 つは交換可能ではありません。❌シリアル化可能な型の既存の型階層に新しい型を追加しないでください。 既存の型に新しい基底クラスを追加することはできません。 新しいサブクラスを既存の型に安全に追加できます。
✅ の使用を SerializableAttribute にGenerateSerializerAttribute、IdAttribute 宣言に対応させます。
✅型ごとにすべてのメンバー ID を 0 から開始します。 サブクラスとその基底クラスの ID は、安全に重複する可能性があります。 次の例の両方のプロパティには、
0
と等しい ID があります。[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
からshort
、int
、long
と拡大させることができます。- 数値メンバー型を絞り込むことができますが、縮小された型で観測値を正しく表すことができない場合は、ランタイム例外が発生します。 たとえば、
int.MaxValue
はshort
フィールドで表すことができないため、int
フィールドをshort
に絞り込むと、このような値が検出された場合にランタイム例外が発生する可能性があります。
- 数値メンバー型を絞り込むことができますが、縮小された型で観測値を正しく表すことができない場合は、ランタイム例外が発生します。 たとえば、
❌数値型メンバーの署名を変更しないでください。 たとえば、メンバーの型を
uint
からint
に変更したり、int
をuint
に変更したりしないでください。
グレイン ストレージ シリアライザー
Orleans には、State プロパティ経由でアクセスするか、グレインに 1 つ以上の IPersistentState<TState> 値を挿入することによってアクセスする、グレイン用のプロバイダーに基づく永続化モデルが含まれます。 Orleans 7.0 以前は、各プロバイダーにシリアル化を構成するためのさまざまなメカニズムがありました。 Orleans 7.0 では、各プロバイダーの状態シリアル化を一貫してカスタマイズできる汎用のグレイン状態シリアライザーインターフェイスIGrainStorageSerializerが導入されています。 サポートされているストレージ プロバイダーは、プロバイダーの options クラスに IStorageProviderSerializerOptions.GrainStorageSerializer プロパティを設定するパターンを実装します。次に例を示します。
- DynamoDBStorageOptions.GrainStorageSerializer
- AzureBlobStorageOptions.GrainStorageSerializer
- AzureTableStorageOptions.GrainStorageSerializer
- GrainStorageSerializer
グレイン ストレージのシリアル化の現在の既定は、状態をシリアル化する Newtonsoft.Json
です。 構成時にそのプロパティを変更することで、これを置き換えることができます。 次の例では、 OptionsBuilder<TOptions> を使用してこれを示します。
siloBuilder.AddAzureBlobGrainStorage(
"MyGrainStorage",
(OptionsBuilder<AzureBlobStorageOptions> optionsBuilder) =>
{
optionsBuilder.Configure<IMySerializer>(
(options, serializer) => options.GrainStorageSerializer = serializer);
});
詳細については、「OptionsBuilder API」を参照してください。
Orleans には、高度で拡張可能なシリアル化フレームワークがあります。 Orleans は、グレイン要求メッセージと応答メッセージで渡されるデータ型と、グレイン永続状態オブジェクトをシリアル化します。 このフレームワークの一部として、 Orleans はこれらのデータ型のシリアル化コードを自動的に生成します。 型がすでに.NETシリアル化可能なものに対して効率的なシリアル化/逆シリアル化を生成することに加え、Orleans は、.NETシリアル化可能でないグレイン インターフェースで使用される型のシリアライザーの生成も試みます。 フレームワークには、よく使用される型 (リスト、ディクショナリ、文字列、プリミティブ、配列など) の効率的な組み込みシリアライザーのセットもあります。
Orleansシリアライザーの 2 つの重要な機能は、他の多くのサード パーティのシリアル化フレームワークとは別に設定されます。動的型/任意のポリモーフィズムとオブジェクト ID です。
動的型と任意のポリモーフィズム: Orleans は、グレイン呼び出しで渡される型に制限を適用せず、実際のデータ型の動的な性質を維持します。 つまり、たとえば、グレイン インターフェイス内のメソッドが IDictionaryを受け入れるように宣言されているが、実行時に送信者が SortedDictionary<TKey,TValue>を渡すと、受信側は実際に
SortedDictionary
を取得します ("静的コントラクト"/グレイン インターフェイスでこの動作が指定されていない場合でも)。オブジェクト ID の維持: グレイン呼び出しの引数で同じオブジェクトが複数回渡されるか、引数から複数回間接的に指されている場合、 Orleans は 1 回だけシリアル化します。 受信側では、 Orleans は、逆シリアル化後も同じオブジェクトを指す 2 つのポインターが同じオブジェクトを指すように、すべての参照を正しく復元します。 オブジェクト ID の保持は、次のようなシナリオで重要です。グレイン A がグレイン B に 100 個のエントリを持つディクショナリを送信し、ディクショナリ内の 10 個のキーが A 側の同じオブジェクト (
obj
) を指しているとします。 オブジェクト ID を保持しない場合、B は 100 個のエントリのディクショナリを受け取り、そのうち 10 個のキーはobj
の 10 個の異なる複製をポイントします。 オブジェクト ID が保持されている場合、B 側のディクショナリは A 側とまったく同じように見え、これらの 10 個のキーは単一のオブジェクトobj
を指します。
標準の .NET バイナリ シリアライザーには上記の 2 つの動作が用意されているため、 Orleans でもこの標準的で使い慣れた動作をサポートすることが重要でした。
生成されたシリアライザー
Orleans では、次の規則を使用して、生成するシリアライザーを決定します。
- コア Orleans ライブラリを参照しているすべてのアセンブリ内のすべての型をスキャンします。
- これらのアセンブリから、グレイン インターフェイス メソッドシグネチャまたは状態クラス シグネチャで直接参照される型、または SerializableAttributeでマークされた任意の型のシリアライザーを生成します。
- さらに、グレイン インターフェイスや実装プロジェクトでは、KnownTypeAttribute または KnownAssemblyAttribute のアセンブリ レベルの属性を追加することで、シリアル化生成用に任意の型を指定することができます。 これらは、アセンブリ内の特定の型またはすべての対象となる型のシリアライザーを生成するようにコード ジェネレーターに指示します。 アセンブリ レベルの属性の詳細については、「アセンブリ レベルで属性を適用する」を参照してください。
フォールバック シリアル化
Orleans では、実行時の任意の型の転送がサポートされます。 そのため、組み込みのコード ジェネレーターは、事前に送信される型のセット全体を決定できません。 また、特定の型は、アクセスできない ( private
など) か、アクセスできないフィールド ( readonly
など) があるため、シリアライザーを生成できません。 したがって、予期しない型、または事前にシリアライザーを生成できなかった型の Just-In-Time シリアル化が必要です。 これらの型を担当するシリアライザーは、フォールバック シリアライザーと呼ばれます。
Orleans には、次の 2 つのフォールバック シリアライザーが付属しています。
- .NET の Orleans.Serialization.BinaryFormatterSerializer を使用する BinaryFormatter と
- 実行時に Orleans.Serialization.ILBasedSerializer 手順を生成する では、Orleans シリアル化フレームワークを利用して各フィールドをシリアル化するシリアライザーを作成します。 つまり、アクセスできない型
MyPrivateType
にカスタム シリアライザーを持つフィールドMyType
が含まれている場合、そのカスタム シリアライザーがシリアル化に使用されます。
FallbackSerializationProvider (クライアント) と ClientConfiguration (サイロ) の両方で、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 の以下の 2 つの主な目的を果たします。
- 実行時にグレインとクライアントの間でデータを送信するためのワイヤ形式として。
- 後で取得するために有効期間の長いデータを保持するためのストレージ形式として。
Orleans で生成されるシリアライザーは、柔軟性、パフォーマンス、および汎用性により、最初の目的に適しています。 明示的にバージョントレラントでないため、2 番目の目的には適していません。 永続的なデータ用に、 プロトコル バッファーなどのバージョン トレラント シリアライザーを構成することをお勧めします。 プロトコル バッファーは、Orleans.Serialization.ProtobufSerializer
NuGet の Orleans を経由してサポートされます。 選択したシリアライザーのベスト プラクティスに従って、バージョンの許容範囲を確保します。 前述のように、 SerializationProviders
構成プロパティを使用してサード パーティのシリアライザーを構成します。
.NET