Leapcell: The Next-Gen Serverless Platform for Web Hosting, Async Tasks, and Redis
Protobufの深い理解
Protobufとは何か
Protobuf(Google Protocol Buffers)は、公式ドキュメントで定義されている通り:Protocol buffersは、言語やプラットフォームに依存しない、拡張可能な構造化データのシリアライズ方法であり、データ通信プロトコルやデータストレージなどのシナリオで幅広く適用できます。Googleが提供するツールライブラリで、効率的なプロトコルデータ交換形式を持ち、柔軟で効率的かつ自動化された構造化データのシリアライズメカニズムという特徴を備えています。
XMLと比較すると、Protobufでエンコードされたデータのサイズが小さく、エンコードとデコードの速度が速いです。Jsonと比較すると、Protobufは変換効率でより優れており、その時間効率と空間効率の両方がJSONの3~5倍に達しています。
公式の説明通り:「Protocol buffersは、Googleの言語やプラットフォームに依存しない、拡張可能な構造化データのシリアライズメカニズムです。XMLを思い浮かべてくださいが、もっと小さく、速く、シンプルです。一度データの構造を定義すると、特別に生成されたソースコードを使って、様々なデータストリームから構造化データを簡単に書き込んだり読み取ったりでき、様々な言語を使って操作できます。」
データ形式の比較
person
オブジェクトがあると仮定して、それをそれぞれJSON、XML、Protobufで表して、それらの違いを見てみましょう。
XML形式
<person>
<name>John</name>
<age>24</age>
</person>
JSON形式
{
"name":"John",
"age":24
}
Protobuf形式
Protobufはデータを直接バイナリ形式で表し、XMLやJSON形式ほど直感的ではありません。例えば:
[10 6 69 108 108 122 111 116 16 24]
Protobufの利点
優れた性能/高い効率
- 時間オーバーヘッド:XMLの形式化(シリアライズ)のオーバーヘッドは許容できますが、XMLの解析(デシリアライズ)のオーバーヘッドは比較的大きいです。Protobufはこの点を最適化しており、シリアライズとデシリアライズの時間オーバーヘッドを大幅に削減できます。
- 空間オーバーヘッド:Protobufはまた、空間の占有量も大幅に削減します。
コード生成メカニズム
例えば、以下のような構造に似た内容を書きます:
message testA
{
required int32 m_testA = 1;
}
Protobufは自動的に対応する.h
ファイルと.cpp
ファイルを生成し、構造testA
に対する操作をクラスにカプセル化します。
後方互換性と前方互換性のサポート
クライアントとサーバーが同時にプロトコルを使用している場合、クライアントがプロトコルにバイトを追加しても、クライアントの正常な使用に影響を与えません。
複数のプログラミング言語のサポート
Googleが公式に公開したソースコードには、以下のような複数のプログラミング言語へのサポートが含まれています:
- C++
- C#
- Dart
- Go
- Java
- Kotlin
- Python
Protobufの欠点
バイナリ形式による読みやすさの低下
性能を向上させるために、Protobufはバイナリ形式でエンコードを行いますが、これによりデータの読みやすさが低下し、開発やテスト段階での効率に影響を与えます。ただし、通常の状況では、Protobufは非常に信頼性が高く、重大な問題が発生することは一般的にありません。
自己記述性の欠如
一般的に、XMLは自己記述性がありますが、Protobuf形式はそうではありません。バイナリ形式のプロトコル内容であり、事前に書かれた構造と照合しないとその機能を知ることが難しいです。
汎用性の低下
Protobufは複数の言語でのシリアライズとデシリアライズをサポートしていますが、プラットフォームや言語を跨いだ汎用的な伝送標準ではありません。多プラットフォームのメッセージ送信のシナリオでは、他のプロジェクトとの互換性が良くなく、対応する適応と変換作業がしばしば必要です。jsonやXMLと比較すると、その汎用性はやや不十分です。
使用ガイド
メッセージタイプの定義
Protoのメッセージタイプファイルは一般的に.proto
で終わります。.proto
ファイル内では、1つ以上のメッセージタイプを定義することができます。
以下は検索クエリのメッセージタイプを定義する例です。ファイルの先頭のsyntax
は、バージョン情報を記述するために使用されます。現在、protoにはproto2とproto3の2つのバージョンがあります。
syntax="proto3";
明示的に構文形式をproto3に設定します。syntax
が設定されていない場合、デフォルトはproto2となります。query
は検索する内容を表し、page_number
は検索のページ番号を表し、result_per_page
は1ページあたりのアイテム数を表します。syntax = "proto3"
は、コメントと空白行を除いて、.proto
ファイルの最初の行に配置する必要があります。
以下のメッセージには3つのフィールド(query
、page_number
、result_per_page
)が含まれており、各フィールドには対応する型、フィールド名、フィールド番号があります。フィールドの型はstring
、int32
、enum
、または複合型にすることができます。
syntax = "proto3";
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
}
フィールド番号
メッセージタイプ内の各フィールドには、一意の番号を定義する必要があり、この番号はバイナリデータ内でフィールドを識別するために使用されます。[1,15]の範囲内の番号は、1バイトでエンコードして表すことができます;[16,2047]の範囲内の場合は、2バイトでエンコードして表す必要があります。したがって、頻繁に発生するフィールドに15以下の番号を割り当てることで、空間を節約できます。番号の最小値は1で、最大値は2^29 - 1 = 536870911です。[19000, 19999]の範囲内の番号は使用できません。なぜなら、これらの番号はprotoコンパイラの内部で使用されているためです。同様に、他の予約済みの番号も使用できません。
フィールドの規則
各フィールドはsingular
またはrepeated
で修飾することができます。proto3構文では、修飾型が指定されていない場合、デフォルト値はsingular
です。
-
singular
:修飾されたフィールドが最大で1回しか出現しないことを意味します。つまり、0回または1回出現します。 -
repeated
:修飾されたフィールドが任意の回数出現できることを意味します。0回を含みます。proto3構文では、repeated
で修飾されたフィールドは、デフォルトでpacked
エンコードを使用します。
コメント
.proto
ファイルにコメントを追加することができます。コメントの構文はC/C++スタイルと同じで、//
または/* ... */
を使用します。
/* SearchRequest represents a search query, with pagination options to
* indicate which results to include in the response. */
message SearchRequest {
string query = 1;
int32 page_number = 2; // Which page number do we want?
int32 result_per_page = 3; // Number of results to return per page.
}
予約フィールド
message
内のフィールドを削除またはコメントアウトする場合、将来の他の開発者がmessage
の定義を更新する際に、以前のフィールド番号を再利用する可能性があります。彼らが誤って古いバージョンの.proto
ファイルを読み込んだ場合、データの破損などの深刻な問題が発生する可能性があります。このような問題を回避するために、予約フィールド番号とフィールド名を指定することができます。将来誰かがこれらのフィールド番号を使用した場合、protoをコンパイルする際にエラーが発生し、protoに問題があることがわかります。
注意:同じフィールドに対して、フィールド名とフィールド番号を混在して使用しないでください。
message Foo {
reserved 2, 15, 9 to 11;
reserved "foo", "bar";
}
フィールド型と言語型のマッピング
定義された.proto
ファイルは、ジェネレータを通じてGo言語のコードを生成することができます。例えば、a.proto
ファイルから生成されるGoファイルはa.pb.go
ファイルです。
proto内の基本型とGo言語型のマッピングは、以下の表に示す通りです(ここではGoとC/C++の型マッピングのみを記載しており、他の言語についてはhttps://developers.google.com/protocol-buffers/docs/proto3を参照してください):
.proto Type | Go Type | C++ Type |
---|---|---|
double | float64 | double |
float | float32 | float |
int32 | int32 | int32 |
int64 | int64 | int64 |
uint32 | uint32 | uint32 |
uint64 | uint64 | uint64 |
sint32 | int32 | int32 |
sint64 | int64 | int64 |
fixed32 | uint32 | uint32 |
fixed64 | uint64 | uint64 |
sfixed32 | int32 | int32 |
sfixed64 | int64 | int64 |
bool | bool | bool |
string | string | string |
bytes | []byte | string |
デフォルト値
.proto Type | default value |
---|---|
string | "" |
bytes | []byte |
bool | false |
numeric types | 0 |
enums | first defined enum value |
Enum型
メッセージを定義する際に、フィールドの値が予想される値のいずれかのみになるようにする場合、enum型を使用することができます。
例えば、今SearchRequest
にcorpus
フィールドを追加し、その値がUNIVERSAL
、WEB
、IMAGES
、LOCAL
、NEWS
、PRODUCTS
、VIDEO
のいずれかになるようにします。これは、メッセージ定義にenumを追加し、各可能なenum値に定数を追加することで実現できます。
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
enum Corpus {
UNIVERSAL = 0;
WEB = 1;
IMAGES = 2;
LOCAL = 3;
NEWS = 4;
PRODUCTS = 5;
VIDEO = 6;
}
Corpus corpus = 4;
}
Corpus
enumの最初の定数は0にマッピングする必要があり、すべてのenum定義には0にマッピングされる定数を含める必要があり、この値はenum定義の最初の行の内容になります。これは、0がenumのデフ
他のProtoをインポートする
1つの.proto
ファイル内で他の.proto
ファイルをインポートすることができ、それによりインポートされたファイル内で定義されたメッセージタイプを使用することができます。
import "myproject/other_protos.proto";
デフォルトでは、直接インポートされた.proto
ファイル内で定義されたメッセージタイプのみを使用することができます。しかし、時には.proto
ファイルを新しい場所に移動する必要がある場合があります。この場合、古い場所に仮想的な.proto
ファイルを配置し、import public
構文を使用してすべてのインポートを新しい場所に転送することができます。つまり、.proto
ファイルを直接移動してすぐにすべての呼び出し箇所を更新するのではなく、import public
を使用することです。import public
ステートメントを含むprotoファイルをインポートする場所では、インポートされた依存関係の公開依存関係を引き継ぐことができます。
例えば、現在のフォルダにa.proto
とb.proto
ファイルがあり、a.proto
ファイル内でb.proto
をインポートしているとします。つまり、a.proto
ファイルには以下の内容があります:
import "b.proto";
今、b.proto
内のメッセージをcommon/com.proto
ファイルに入れて、他の場所で使用するようにしたいとします。この場合、b.proto
を修正し、その中でcom.proto
をインポートすることができます。注意すべきは、単独のimport
ではb.proto
内で定義されたメッセージのみを使用でき、b.proto
内でインポートされたprotoファイル内のメッセージタイプを使用することができないため、import public
を使用する必要があることです。
// b.protoファイル、内部のメッセージ定義をcommon/com.protoファイルに移動し、
// 以下のインポートステートメントを追加する
import public "common/com.proto"
protoc
を使用してコンパイルする際には、-I
または--proto_path
オプションを使用してprotoc
にインポートされたファイルをどこで探すかを通知する必要があります。検索パスが指定されていない場合、protoc
は現在のディレクトリ(protoc
が呼び出されたパス)で検索します。
proto2バージョンのメッセージタイプはproto3ファイルにインポートして使用することができますし、proto3バージョンのメッセージタイプもproto2ファイルにインポートすることができます。ただし、proto2内のenum型はproto3構文に直接適用することはできません。
ネストされたメッセージ
メッセージタイプは、別のメッセージタイプの内部で定義することができます。つまり、ネストされた定義が可能です。例えば、SearchResponse
の内部でResult
型を定義しており、複数レベルのネストをサポートしています。
message SearchResponse {
message Result {
string url = 1;
string title = 2;
repeated string snippets = 3;
}
repeated Result results = 1;
}
外部のメッセージタイプが別のメッセージ内のメッセージを使用する場合、例えばSomeOtherMessage
型がResult
を使用する場合、SearchResponse.Result
を使用することができます。
message SomeOtherMessage {
SearchResponse.Result result = 1;
}
未知のフィールド
未知のフィールドとは、protoコンパイラが認識できないフィールドのことです。例えば、古いバイナリファイルが新しいバイナリファイルから送信された新しいフィールドを含むデータを解析する場合、これらの新しいフィールドは古いバイナリファイル内で未知のフィールドになります。proto3の初期バージョンでは、メッセージが解析される際に未知のフィールドは破棄されていましたが、バージョン3.5では未知のフィールドの保持が再導入されました。未知のフィールドは解析中に保持され、シリアライズされた出力に含まれます。
エンコードの原理
TLVエンコード形式
Protobufの高い効率の鍵は、そのTLV(tag-length-value)エンコード形式にあります。各フィールドには一意のtag
値が識別子としてあり、length
はvalue
データの長さを表し(固定長のvalue
の場合はlength
はありません)、value
はデータ自体の内容です。
tag
値については、field_number
とwire_type
の2つの部分で構成されます。field_number
は先ほどmessage
内の各フィールドに与えられた番号で、wire_type
は型(固定長または可変長)を表します。wire_type
は現在0から5までの6つの値があり、これらの6つの値は3ビットで表すことができます。
wire_type
の値は以下の表に示す通りで、3と4は非推奨となっており、残りの4つの型に注目すればよいです。Varintでエンコードされたデータの場合、バイト長length
を格納する必要はありません。このとき、TLVエンコード形式はTVエンコードに退化します。64ビットと32ビットのデータの場合も、type
値が長さが8バイトか4バイトかを既に示しているため、length
は不要です。
wire_type | Encoding Method | Encoding Length | Storage Method | Data Type |
---|---|---|---|---|
0 | Varint | 可変長 | T - V | int32 int64 uint32 uint64 bool enum |
0 | Zigzag + Varint | 可変長 | T - V | sint32 sint64 |
1 | 64ビット | 固定8バイト | T - V | fixed64 sfixed64 double |
2 | length-delimi | 可変長 | T - L - V | string bytes packed repeated fields embedded |
3 | start group | 非推奨 | 非推奨 | |
4 | end group | 非推奨 | 非推奨 | |
5 | 32ビット | 固定4バイト | T - V | fixed32 sfixed32 float |
Varintエンコードの原理
Varintは可変長のintで、可変長のエンコード方法です。これにより、小さな数値はより少ないバイト数で表すことができ、数値を表すために使用するバイト数を減らすことでデータ圧縮を実現します。int32型の数値の場合、通常は4バイトで表す必要がありますが、Varintエンコードを使用すると、128未満のint32型の数値は1バイトで表すことができます。大きな数値の場合は、5バイトで表す必要がある場合もありますが、ほとんどのメッセージでは非常に大きな数値は通常出現しませんので、Varintエンコードを使用することで、数値をより少ないバイト数で表すことができます。
Varintは可変長のエンコードであり、各バイトの最上位ビットを通じて各フィールドを区別します。バイトの最上位ビットが1の場合、その後続のバイトも数値の一部であることを意味します;0の場合、これが最後のバイトであり、残りの7ビットはすべて数値を表すために使用されます。各バイトは1ビットの空間を無駄にすることになります(つまり1/8 = 12.5%の無駄)が、多くの数値が固定で4バイトで表す必要がない場合、大量の空間を節約することができます。
例えば、int32型の数値65のVarintエンコードの過程は以下の通りで、元々4バイトを占めていた65は、エンコード後は1バイトしか占めません。
int32型の数値128の場合、エンコード後は2バイトを占めます。
Varintのデコードはエンコードの逆の過程であり、比較的シンプルですので、ここでは例を挙げません。
Zigzagエンコード
数値を符号なし整数に変換し、その後Varintエンコードを使用してエンコード後のバイト数を減らします。
Zigzagは符号なし整数を使用して符号付き整数を表し、絶対値の小さな数値をより少ないバイト数で表すことができます。Zigzagエンコードを理解する前に、まずいくつかの概念を理解しましょう:
- 原碼:最上位ビットが符号ビットで、残りのビットは絶対値を表します。
- 反碼:符号ビットを除いて、原碼の残りのビットを1つずつ反転させたものです。
- 補碼:正数の場合、補碼はその数値自体です;負数の場合、符号ビットを除いて、原碼の残りのビットを1つずつ反転させてから1を加えたものです。
int32型の数値-2を例にとって、そのエンコードの過程は以下の通りです。
要するに、負数の場合、その補碼に演算を行います。数値n
がsint32
型の場合、(n<<1) ^ (n>>31)
の演算を行います;sint64
型の場合、(n<<1) ^ (n>>63)
の演算を行います。この演算により、負数が正数に変換され、この過程がZigzagエンコードです。最後に、Varintエンコードを使用します。
VarintとZigzagエンコードは内容の長さを自己解析できるため、長さ項目を省略することができ、TLVストレージはTVストレージに簡略化され、length
項目が不要になります。
tagとvalueの値の計算方法
tag
tag
はフィールドの識別情報とデータ型情報を格納しており、つまりtag = wire_type
(フィールドのデータ型)+ field_number
(識別番号)です。フィールド番号はtag
を通じて取得することができ、定義されたメッセージフィールドに対応します。計算式はtag = field_number<<3 | wire_type
で、その後それに対してVarintエンコードを行います。
value
value
は、VarintとZigzagエンコードを行った後のメッセージフィールドの値です。
stringのエンコード(続き)
フィールドの型がstring
型の場合、フィールドの値はUTF-8でエンコードされます。例えば、以下のようなメッセージ定義があります:
message stringEncodeTest {
string test = 1;
}
Go言語では、このメッセージをエンコードするサンプルコードは以下の通りです:
func stringEncodeTest(){
vs:=&api.StringEncodeTest{
Test:"English",
}
data,err:=proto.Marshal(vs)
if err!=nil{
fmt.Println(err)
return
}
fmt.Printf("%v\n",data)
}
エンコード後のバイナリ内容は以下の通りです:
[10 14 67 104 105 110 97 228 184 173 144 155 189 228 120 186]
ネスト型のエンコード
ネストされたメッセージとは、value
が別のフィールドメッセージであることを意味します。外部のメッセージはTLVストレージを使用して格納され、そのvalue
もTLVストレージ構造です。全体のエンコード構造の模式図は以下の通りです(木構造として想像することができ、外部のメッセージが根ノードで、その内部のネストされたメッセージが子ノードとなり、各ノードはTLVエンコード規則に従います):
- 最も外側のメッセージには、対応する
tag
、length
(ある場合)、そしてvalue
があります。 -
value
がネストされたメッセージの場合、このネストされたメッセージには独自のtag
、length
(ある場合)、そしてvalue
があります。 - 同様に、ネストされたメッセージの中にさらにネストされたメッセージがある場合は、TLV規則に従ってエンコードを続けます。
packed
付きのrepeated
フィールド
repeated
で修飾されたフィールドは、packed
が付いていてもなくても構いません。同じrepeated
フィールドの複数のフィールド値について、それらのtag
値はすべて同じです。つまり、データ型とフィールドの順序番号が同じです。複数のTV
ストレージを使用すると、tag
の冗長性が生じます。
packed = true
が設定されている場合、repeated
フィールドのストレージ方法が最適化されます。つまり、同じtag
は1回だけ格納され、その後repeated
フィールドの下のすべての値の合計長さlength
が追加され、TLVV...
ストレージ構造が形成されます。この方法により、シリアライズされたデータの長さを効果的に圧縮し、伝送オーバーヘッドを削減することができます。例えば:
message repeatedEncodeTest{
// 方法1、packedなし
repeated int32 cat = 1;
// 方法2、packedあり
repeated int32 dog = 2 [packed=true];
}
上記の例で、cat
フィールドはpacked
を使用しておらず、各cat
の値は独立したtag
とvalue
のストレージを持ちます;一方、dog
フィールドはpacked
を使用しており、tag
は1回だけ格納され、その後すべてのdog
の値の合計長さlength
が続き、その後すべてのdog
の値が順番に並べられます。このように、データ量が大きい場合、packed
を使用したrepeated
フィールドは、データが占める空間を大幅に削減し、伝送中の帯域幅の消費を減らすことができます。
結論
Protobufはその効率性(サイズの面で)と専門性(専門的な型)により、将来のデータ伝送分野においてより高い普及度を持つことになると思われます。
Leapcell: The Next-Gen Serverless Platform for Web Hosting, Async Tasks, and Redis
最後に、サービスをデプロイするのに最適なプラットフォームである**Leapcell** を紹介します。
1. 多言語サポート
- JavaScript、Python、Go、またはRustを使って開発できます。
2. 無料で無制限のプロジェクトをデプロイ
- 使用量に応じてのみ課金 — リクエストがなければ、課金はありません。
3. 抜群のコスト効率
- 実行時課金で、アイドル時の課金はありません。
- 例:平均応答時間60msで、$25で694万件のリクエストをサポートできます。
4. 合理化された開発者体験
- 直感的なUIで簡単にセットアップできます。
- 完全自動化されたCI/CDパイプラインとGitOpsの統合。
- 実行時のメトリクスとログ記録により、実行可能なインサイトを得られます。
5. 簡単なスケーラビリティと高いパフォーマンス
- 自動スケーリングにより、高い同時実行を簡単に処理できます。
- 運用オーバーヘッドはゼロ — 構築に集中できます。
LeapcellのTwitter:https://x.com/LeapcellHQ