|
著者| ヤン・チェンシー 背景Thriftは、Facebookがオープンソース化した高性能で軽量なRPCサービスフレームワークです。シリアル化とサービス通信機能を備え、クロスプラットフォームおよびクロス言語互換性をサポートするフルスタックRPCソリューションです。全体的なアーキテクチャを下図に示します。 Thrift ソフトウェア スタックは明確に定義されており、各レイヤーで疎結合かつプラグ可能なコンポーネントを備えているため、図に示すように、ビジネス シナリオに応じて柔軟に組み合わせることができます。 Thrift は幅広いトピックであるため、この記事ではそのすべてを網羅するのではなく、シリアル化プロトコルについてのみ説明します。 プロトコルの原則バイナリプロトコルメッセージ形式このセクションでは、例を用いてバイナリメッセージ形式を直感的に説明します。IDL定義は次のとおりです。 // インターフェース サービス SupService { SearchDepartmentByKeywordResponse SearchDepartmentByKeyword( 1: SearchDepartmentByKeywordRequestリクエスト) }
// 聞く 構造体SearchDepartmentByKeywordRequest { 1: オプションの文字列キーワード 2: オプションのi32制限 3: オプションのi32オフセット }
// リクエストペイロードが次のとおりであると仮定します。 { キーワード: 「ヒバリ」 制限: 50, オフセット: なし、 } エンコード図特定のコンテンツのエンコードエンコードされたバイト ストリームをキャプチャしました (見やすくするために 10 進数に変換されています)。
/* インターフェース名の長さ */ 0 0 0 25 /* インターフェース名 */ 83 101 97 114 99 104 68 101 112 97 114 116 109 101 110 116 66 121 75 101 121 119 111 114 100 /* メッセージタイプ */ 1 /* メッセージシーケンス番号 */ 0 0 0 1 /* キーワードフィールドタイプ */ 11 /* キーワードフィールドID */ 0 1 /* キーワード長 */ 0 0 0 4 /* キーワード値 */ 108 97 114 107 /* フィールドタイプを制限する */ 8 /* 制限フィールドID */ 0 2 /* 制限値 */ 0 0 0 50 /* フィールドターミネータ */ 0 意味のエンコードメッセージヘッダー - msg_type: メッセージ タイプ。4 つのタイプがあります。
- 呼び出し: クライアントメッセージ。リモートメソッドを呼び出し、相手からの応答を待ちます。
- OneWay: クライアント側メッセージ。応答を期待せずにリモートメソッドを呼び出します。
- 返信: サーバー側メッセージ。正常な応答です。
- 例外: サーバー側メッセージ。例外応答。
- `msg_seq_id`: メッセージシーケンス番号。クライアントは、順序が乱れたレスポンスを処理し、リクエストとレスポンスを一致させるために、メッセージシーケンス番号を使用します。サーバーはこのシーケンス番号を確認する必要はなく、論理的な依存関係を持つこともありません。レスポンスでそのまま返すだけで済みます。
メッセージ本文 メッセージ本文には 2 つのエンコード モードがあります。 - 固定長型 -> TVモード、つまり、フィールドタイプ + シーケンス番号 + フィールド値
- 可変長タイプ -> TLVパターン、つまり、フィールドタイプ + シーケンス番号 + フィールド長 + フィールド値
- field_type: フィールド タイプ (String、I64、Struct、Stop など)。フィールド タイプには 2 つの目的があります。
- Stop タイプは、ネストされた解析を停止するために使用されます。
- スキップにはノンストップ型が使用されます(スキップ操作は現在のフィールドをスキップします。これは「よくある質問 - 互換性」で説明されています)。
- fied_id: デコード中にフィールドを識別するために使用されるフィールドシーケンス番号。
- len: フィールドの長さ。文字列などの可変長型に使用されます。
- 値: フィールド値
データ形式 1. 固定長データ型 データ型 | タイプ識別子(8ビット) | 型サイズ(単位:バイト) | ブール | 2 | 1 | バイト | 3 | 1 | ダブル | 4 | 8 | i16 | 6 | 2 | i32 | 8 | 4 | i64 | 10 | 8 |
2. 可変長データ型 データ型 | タイプ識別子(8ビット) | 文字サイズ(長さ + 値) | 弦 | 11 | 4 + N | 構造体 | 12 | ネストされたデータ + 1バイトのストップ文字 (0) | 地図 | 13 | 1 + 1 + 4 + N*(X+Y) [キーの型 + 値の型 + 長さ + 値] | セット | 14 | 1 + 4 + N [val の型 + 長さ + 値] | リスト | 15 | 1 + 4 + N [val の型 + 長さ + 値] |
その他の契約コンパクトプロトコルCompactプロトコルは、ほとんどのフィールドのエンコードにおいてBinaryプロトコルとの一貫性を維持したバイナリ圧縮プロトコルです。違いは、整数型(可変長型の長さを含む)が、まずジグザグエンコード、次に可変長整数圧縮エンコードというプロセスを使用して実装され、スペースの節約を最大化する点にあります。 質問は、varint と zigzag とは何ですか? varintエンコーディング解決された問題: 固定長整数型の絶対値が小さい場合、大きなスペースが無駄になります。
統計によれば、RPC 通信中に送信される整数値のほとんどは非常に小さく、固定長のストレージを使用すると無駄になります。 たとえば、i32 型の数字 7 をエンコードする場合、最初の 3 バイトは無駄になると言えます。 00000000 00000000 00000000 00000111 解決策: 整数型を固定長ストレージから可変長ストレージに変換します (可能な場合は 1 バイトのみを使用し、2 バイトの使用は避けます)。
原理は複雑ではありません。整数は 7 ビットのセグメントに分割され、各バイトの最上位ビットは、次のバイトがデータに属するかどうかを示すフラグとして使用されます。1 は、次のバイトがまだ現在のデータに属していることを意味し、0 は、これが現在のデータの最後のバイトであることを意味します。 i32 型と値 955 を例にとると、元の 4 バイトが 2 バイトに圧縮されていることがわかります。 バイナリエンコード: 00000000 00000000 00000011 10111011 セグメンテーション: 0000 0000000 0000000 0000111 0111011 コンパクトエンコーディング: 00000111 10111011 もちろん、varintエンコーディングにも欠点があります。大きな数値を保存する場合、バイナリよりも多くのスペースを消費する可能性があります。4バイトで保存すべき数値を5バイトで保存する必要がある場合や、8バイトで保存すべき数値を10バイトで保存する必要がある場合などです。 ジグザグ符号化問題は解決しました。絶対値が小さい負の数は、varint型でエンコードすると大きなメモリオーバーヘッドが発生します。例えば、i32型の負の数🌰、つまり-11がこれに該当します。
元のコード: 10000000 00000000 00000000 00001011 逆コード: 1111111 11111111 11111111 11110100 2の補数: 11111111 11111111 11111111 11110101 可変長整数エンコーディング: 00001111 11111111 11111111 11111111 11110101 明らかに、絶対値が小さい負の数の場合、varint エンコードでは先頭の 1 が多くなりすぎて圧縮が困難になり、バイナリ エンコードよりも大きなスペース オーバーヘッドが発生します。 解決策: 負の数を正の数に変換し、先頭の 1 を先頭の 0 に変換することで、可変長整数の圧縮が容易になります。
アルゴリズムの式と手順とデモンストレーション: // アルゴリズム式 32ビット: (n << 1) ^ (n >> 31) 64ビット: (n << 1) ^ (n >> 63)
/* アルゴリズムの手順: * 1. 正と負を区別しません。符号ビットは末尾に移動され、値ビットは先頭に移動されます。 * 2. 負の数の場合: 符号ビットは変更されず、値ビットが反転されます。 /
// 負の数の例 (-11) 2の補数: 11111111 11111111 11111111 11110101 符号ビットを末尾に、値ビットを先頭に移動します: 1111111 111111111 11111111 11101011 符号ビットは変更されず、値ビットは反転されます(21):00000000 00000000 00000000 00010101
正の数 (11) 2の補数: 00000000 000000000 00000000 00010101 符号ビットは末尾に移動され、値ビットは先頭にシフトされます(22):00000000 00000000 00000000 00101010 【不思議な事実】なぜジグザグという名前がついたのか? このアルゴリズムは、負の数を正の奇数として、正の数を偶数としてエンコードします。最終的な結果は、正の数と負の数を交互に並べたものです。 エンコード前 エンコード後 0 0 -1 1 1 2 -23 24 JSONプロトコルThrift は、バイナリシリアル化プロトコルだけでなく、JSON などのテキストベースのプロトコルもサポートします。 データ形式 /* ブール値、i8、i16、i32、i64、倍精度、文字列 */ "シリアルナンバー": { 「タイプ」:「値」 } // 例 "1": { "str": "キーワード }
/* 構造体 */ "シリアルナンバー": { 「rec」:{ 「メンバーID」: { 「メンバータイプ」:「メンバー値」 }, ... } } // 例 "1": { 「rec」:{ "1": { 「i32」:50 } } }
/* マップ */ "シリアルナンバー": { 「マップ」: [ 「キータイプ」、 「値の型」、 要素数 「キー1」 「値1」、 ... 「キーn」、 「値n」 ] } // 例 "6": { 「マップ」: [ 「i64」、 "str", 1、 666, 「マップ値」 ] }
/* リスト */ "シリアルナンバー": { "set/lst": [ 「値の型」、 要素数 「ele1」、 「ele2」、 「エレン」 ] } // 例 "2": { 「1st」: [ "str", 2、 「ヒバリ」、「キーワード」 } ケース分析フィールドタイプの変更によりRPCタイムアウトが発生しました現象:サービスAがサービスBにアクセスします。ビジネスロジックは短時間で処理されますが、リクエスト全体が15秒後にタイムアウトします。これは常に発生します。 直接的な原因: IDL タイプが変更され、サーバー (サービス B) のみがアップグレードされ、クライアント (サービス A) はアップグレードされませんでした。 根本的な原因は、`string` が可変長エンコーディングであるのに対し、`i64` は固定長エンコーディングであることです。クライアントがアップグレードされていないため、`signTime` はデシリアライズ時に `string` として解析されます。しかし、可変長エンコーディングでは TLV モードが使用されるため、解析時に `signTime` の下位 4 バイトが `string` の `length` に変換されます。 `signTime` はタイムスタンプであり、たとえば 1624206147902 のような大きな整数で、次のようにバイナリに変換されます。 00000000 00000000 00000001 01111010 00101010 00111011 00000001 00111110 下位4バイトを10進数に変換すると、378となる。 つまり、SignTime 値としてさらに 378 バイトを読み取る必要があり、これがペイロード全体のサイズを超え、最終的にソケット読み取りがタイムアウトすることになります。 [注] 型を変更しても、必ずしもタイムアウトが発生するわけではありません。値が小さい場合、解析される長さも小さくなり、文字列全体を読み取ることができます。ただし、誤った解析は、以下のような予期しない状況を引き起こす可能性があります。 - 文字化けしたテキスト
- ヌル値
- エラーメッセージ: 不明なデータ型 xxx (例外をスキップ)
よくある質問互換性フィールドを追加追加されたフィールドは `skip` オプションを使用してスキップされ、互換性が確保されます。 フィールドを削除コンパイルされた解析コードは、field_id に基づく switch-case 構造であり、直接的な構文互換性があります。 フィールド名を変更するバイナリ プロトコルは名前をエンコードしないため、互換性が損なわれることはありません。 例外Thrift には 2 種類の例外があります。1 つはフレームワークに組み込まれた例外で、もう 1 つは IDL 定義の例外です。 フレームワークに組み込まれている例外には、「メソッド名が正しくありません」、「メッセージシーケンス番号が正しくありません」、「プロトコルエラー」などがあります。これらの例外はフレームワークによって捕捉され、例外メッセージとしてカプセル化されます。デシリアライズ時に、以下のようにエラーに変換され、上位層にスローされます。 別のタイプの例外は、IDL でキーワード「exception」を使用してユーザーによって定義され、その使用方法は「struct」の使用方法とほとんど変わりません。 オプションと必須の実装原則optional はフィールドに入力できることを示し、require はフィールドが必須であることを示します。 フィールドがオプションとしてマークされた後: - プリミティブ型はポインタ型にコンパイルされます
- シリアル化コードは null チェックを実行します。フィールドが空の場合はエンコードされません。
フィールドが必須としてマークされた後: - 基本型は非ポインタ型にコンパイルされます (複合型の場合、optional と require の間に違いはありません)。
- シリアル化ではnullチェックは行われず、フィールドは常にエンコードされます。明示的に値が割り当てられていない場合は、デフォルト値(デフォルトのnull値またはIDLで明示的に指定されたデフォルト値)がエンコードされます。
|