1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Protobufがデータ形式の領域を制覇すべき理由

Posted at

Group60.png

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つのフィールド(querypage_numberresult_per_page)が含まれており、各フィールドには対応する型、フィールド名、フィールド番号があります。フィールドの型はstringint32enum、または複合型にすることができます。

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型を使用することができます。

例えば、今SearchRequestcorpusフィールドを追加し、その値がUNIVERSALWEBIMAGESLOCALNEWSPRODUCTSVIDEOのいずれかになるようにします。これは、メッセージ定義に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.protob.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値が識別子としてあり、lengthvalueデータの長さを表し(固定長のvalueの場合はlengthはありません)、valueはデータ自体の内容です。

tag値については、field_numberwire_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を例にとって、そのエンコードの過程は以下の通りです。

要するに、負数の場合、その補碼に演算を行います。数値nsint32型の場合、(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エンコード規則に従います):

  1. 最も外側のメッセージには、対応するtaglength(ある場合)、そしてvalueがあります。
  2. valueがネストされたメッセージの場合、このネストされたメッセージには独自のtaglength(ある場合)、そしてvalueがあります。
  3. 同様に、ネストされたメッセージの中にさらにネストされたメッセージがある場合は、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の値は独立したtagvalueのストレージを持ちます;一方、dogフィールドはpackedを使用しており、tagは1回だけ格納され、その後すべてのdogの値の合計長さlengthが続き、その後すべてのdogの値が順番に並べられます。このように、データ量が大きい場合、packedを使用したrepeatedフィールドは、データが占める空間を大幅に削減し、伝送中の帯域幅の消費を減らすことができます。

結論

Protobufはその効率性(サイズの面で)と専門性(専門的な型)により、将来のデータ伝送分野においてより高い普及度を持つことになると思われます。

Leapcell: The Next-Gen Serverless Platform for Web Hosting, Async Tasks, and Redis

最後に、サービスをデプロイするのに最適なプラットフォームである**Leapcell** を紹介します。

barndpic.png

1. 多言語サポート

  • JavaScript、Python、Go、またはRustを使って開発できます。

2. 無料で無制限のプロジェクトをデプロイ

  • 使用量に応じてのみ課金 — リクエストがなければ、課金はありません。

3. 抜群のコスト効率

  • 実行時課金で、アイドル時の課金はありません。
  • 例:平均応答時間60msで、$25で694万件のリクエストをサポートできます。

4. 合理化された開発者体験

  • 直感的なUIで簡単にセットアップできます。
  • 完全自動化されたCI/CDパイプラインとGitOpsの統合。
  • 実行時のメトリクスとログ記録により、実行可能なインサイトを得られます。

5. 簡単なスケーラビリティと高いパフォーマンス

  • 自動スケーリングにより、高い同時実行を簡単に処理できます。
  • 運用オーバーヘッドはゼロ — 構築に集中できます。

Frame3-withpadding2x.png

ドキュメントでさらに詳しく調べる!

LeapcellのTwitter:https://x.com/LeapcellHQ

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?