初めに
業務でgrpcを使用する機会があったので、プロトコルバッファをこちらのサイトを見ながら学習しました。
その内容をアウトプットのため投稿します。
また、使用する言語はGoでproto3を使用します。
概要
プロトコルバッファは、構造化されている型付けされたデータを言語やプラットフォームに依存せずシリアライズすることができます。
定義言語(インタフェース定義言語)・シリアライズ形式・各言語向けランタイムライブラリ・プロトコンパイラ生成コードの4要素からなる[2]。
出典:Wikipedia
簡単な流れ
プロトコルバッファを使用する際の簡単な流れは以下です。
-
.proto
ファイルにデータの定義を行う。 - 指定した開発言語のオブジェクトを生成する。
- バイナリファイルに変換されてデータを送受信する。
特徴
プロトコルバッファの特徴は以下です。
- バイナリファイルでデータの送受信がされるためjsonに比べて軽量である。
- 型安全である。
- さまざまな言語に変換可能である。
- データを追加・削除する際に既存のデータを壊すことがない。
- 数メガバイトを超えるデータには向かない。
- gRPCに使用できる。
インタフェース定義言語のメリット
プロトコルバッファは.proto
ファイルにデータが事前に定義されているので、フロント・モバイルアプリとバックエンド間や、マイクロサービス化されたシステムで、最初に相互の認識を合わせて開発することができます。(仕様書の様な役割を果たせる)
これはスキーマ駆動開発と呼ばれています。
定義方法
プロトコルバッファの.proto
ファイルのデータ定義方法を記載します。
syntax
まず最初にsyntaxを定義します。
冒頭にも述べましたが、今回はproto3
を使用します。
この記述がないとproto2
を使用するとみなされるので、proto3
を使用したい場合は記載が必要です。
syntax = "proto3";
message
messageはGoにコンパイルする際に構造体になるデータで、複数定義することができます。
基本形
message メッセージの名前 {
型 フィールド名 = フィールド番号
型 フィールド名 = フィールド番号
}
実際に定義すると以下のような記載になります。
message Request {
string s = 1;
int32 i = 2;
}
message Response {
...
}
フィールド番号
フィールド番号は一意の値である必要があります。
1 ~ 15の数値は、フィールド番号とフィールドの型を含めてエンコードに1バイトかかり、16~2047のフィールド番号は2バイトかかります。
そのため、頻繁に使用するフィールドには1 ~ 15の数値を割り振った方が良いでしょう。
また、1 ~ 536,870,911までの数値を使用できますが、19000 ~ 19999は予約番号であり使用することはできません。
コメント
コメントは//
か/**/
で記載可能です。
// commet
/* comment */
型
Scalar型
int32
、string
、bool
などの型があります。
こちらに詳細が記載してあるのでご確認ください。
列挙型
列挙型は、定義したいずれかのフィールドが含まれている必要があります。
詳細な定義のルールは以下です。
- フィールド名は大文字で記載する。
- 0 を数値のデフォルト値として使用できるようにするため、最初のフィールドのフィールド番号は0にする。
基本形
enum 名前 {
フィールド名 = タグ番号;
フィールド名 = タグ番号;
}
先ほど使用していたmessageに追加してみます。
message Request {
string s = 1;
int32 i = 2;
// Scalar型と同様に扱うことができる
em em = 3;
}
// 追加
enum em {
UNKNOW = 0;
ONE = 1;
TWO = 2;
}
Request
メッセージに列挙型であるem型のemというフィールドを追加しました。
最初のフィールドは使用しないことが多いのでUNKNOW
というフィールドにしています。
repeated
repeatedフィールドでは配列を扱うことができます。
repeated 型 フィールド名 = フィールド番号;
先ほど使用していたmessageに追加してみます。
message Request {
string s = 1;
int32 i = 2;
em em = 3;
// 1つ以上のstring型の文字列が使用できる
repeated string arr = 4;
}
enum em {
UNKNOW = 0;
ONE = 1;
TWO = 2;
}
map
mapではkey-valueの連想配列のような型を定義できます。
keyはstring
、numeric
、bool
のいずれかを使用できます。
repeated
フィールドは使用できません。
基本形
map<keyの型, Valueの型> フィールド名 = フィールド番号;
先ほど使用していたmessageに追加してみます。
message Request {
string s = 1;
int32 i = 2;
em em = 3;
repeated string arr = 4;
// 追加
map<string, MapValue> associative = 5;
}
enum em {
UNKNOW = 0;
ONE = 1;
TWO = 2;
}
// 追加
message MapValue {}
keyがstring型でvalueがMapValue
messageのmapを作成することができました。
Oneof
Oneofは定義した複数の型のいずれかがを値として持ちます。
oneof フィールド名 {
型 フィールド名 = フィールド番号;
型 フィールド名 = フィールド番号;
}
先ほど使用していたmessageに追加してみます。
message Request {
string s = 1;
int32 i = 2;
em em = 3;
repeated string arr = 4;
map<string, MapValue> associative = 5;
// 追加
oneof O {
string OS = 6;
OneofField OF = 7;
}
}
enum em {
UNKNOW = 0;
ONE = 1;
TWO = 2;
}
message MapValue {}
// 追加
message OneofField {}
Oフォールドはstring型、OneofField
型のいずれかを受け入れます。
ゼロ値
定義しているフィールドに値が格納されていない場合に、型にはゼロ値が格納されます。
各型のゼロ値は以下の表を参照ください。
型 | ゼロ値 |
---|---|
strings | 空の文字列 |
bytes | 空のバイト |
bools | false |
numeric | 0 |
enums | 最初に定義された列挙値で、0 にする必要がある |
repeated | 空のリスト |
参照: https://developers.google.com/protocol-buffers/docs/proto3#default
messageのネスト
messageはネストすることができます。
ネストされたmessageは、.
を用いてアクセスすることができます。
ネストされた子のmessageのスコープは親のmessage内であるため、親のmessage外でも別のmessageとして定義することができます。
message A {
message B {}
}
// 上記のBというmessageとは別のmessageである
message B {}
// Bへアクセス
A.B
先ほど使用していたmessageに追加してみます。
message Request {
string s = 1;
int32 i = 2;
em em = 3;
repeated string arr = 4;
map<string, MapValue> associative = 5;
oneof O {
string OS = 6;
OneofField OF = 7;
}
// 追加
Parent.Cheidlen nest = 8;
}
enum em {
UNKNOW = 0;
ONE = 1;
TWO = 2;
}
message MapValue {}
message OneofField {}
// 追加
message Parent {
message Cheidlen {}
}
import
プロトコルバッファでは、別のファイルのprotoファイルをimportして使用することができます。
import "パス.proto";
先ほどのファイルに別のファイルを追加してみます。
ディレクトリ構成は以下の様になります。
proto
├── another.proto
└── file.proto
file.proto
が今までの使用していたファイルでanother.proto
が新しく追加したファイルです。
カレントディレクトリはproto
ディレクトリです。
では、another.proto
にデータの定義をしていきます。
syntax = "proto3";
message Other {
int32 i = 1;
}
Other
というmessageを定義しました。
上記をfile.proto
で読み込んで使用します。
syntax = "proto3";
import "another.proto";
message Request {
string s = 1;
int32 i = 2;
em em = 3;
repeated string arr = 4;
map<string, MapValue> associative = 5;
oneof O {
string OS = 6;
OneofField OF = 7;
}
Parent.Cheidlen nest = 8;
// 追加
Other another = 9;
}
enum em {
UNKNOW = 0;
ONE = 1;
TWO = 2;
}
message MapValue {}
message OneofField {}
message Parent {
message Cheidlen {}
}
message Cheidlen {}
another
というanother.proto
のフィールドをfile.proto
で読み込みました。
プロトコンパイルする際
実際にプロトコンパイルする際は、-I / --proto_path
オプションを使用してディレクトリを指定する必要があります。
今回は、カレントディレクトリであるproto
ディレクトリを指定することで、正常にコンパイルすることができます。
package
packageを使用することで、各ファイルに名前競合を防ぐことができます。
同じ名称のmessageがある場合などに有用です。
package パッケージ名;
実際にfile.proto
とanother.proto
にパッケージ名を追加してみます。
file.proto
syntax = "proto3";
// 追加
package file;
import "another.proto";
message Request {
string s = 1;
int32 i = 2;
em em = 3;
repeated string arr = 4;
map<string, MapValue> associative = 5;
oneof O {
string OS = 6;
OneofField OF = 7;
}
Parent.Cheidlen nest = 8;
// 更新
another.Other another = 9;
}
enum em {
UNKNOW = 0;
ONE = 1;
TWO = 2;
}
message MapValue {}
message OneofField {}
message Parent {
message Cheidlen {}
}
message Cheidlen {}
another.proto
syntax = "proto3";
// 追加
package another;
message Other {
int32 i = 1;
}
今回は名前の競合が起きていないので付与する意味はありませんが、実装方法を残すため定義してみました。
option
optionを使用して、コンパイルされるGoファイルのpackageを指定します。
option go_package = "./pb";
という記述をfile.proto
、another.proto
の両方に記載します。
file.proto
syntax = "proto3";
// 追加
option go_package = "./pb";
package file;
import "another.proto";
message Request {
string s = 1;
int32 i = 2;
em em = 3;
repeated string arr = 4;
map<string, MapValue> associative = 5;
oneof O {
string OS = 6;
OneofField OF = 7;
}
Parent.Cheidlen nest = 8;
another.Other another = 9;
}
enum em {
UNKNOW = 0;
ONE = 1;
TWO = 2;
}
message MapValue {}
message OneofField {}
message Parent {
message Cheidlen {}
}
message Cheidlen {}
another.proto
syntax = "proto3";
// 追加
package another;
option go_package = "./pb";
message Other {
int32 i = 1;
}
プロトコンパイル
最後に、下記コマンドを使用して今まで記載してきたprotoファイルをGoへコンパイルします。
protoc -I. --go_out=. *.proto
-
-I.
はprotoファイルのimport
文のパスを特定しています。- 今回はカレントディレクトリである
proto
ディレクトリ内で作業しているので.
を指定しています。
- 今回はカレントディレクトリである
-
--go_out=.
は、Goへ変換するコマンドです。.
は出力されるファイルの場所を指定しています。 -
*.proto
今回は、カレントディレクトリ配下の2つをコンパイルするので、全ての.protoの拡張子のファイルをコンパイルする様に設定しています。
実際に変換した後
├── another.proto
├── file.proto
└── pb
├── another.pb.go
└── file.pb.go
実際に変換すると上記のようなディレクトリ構造になり、another.pb.go
、file.pb.go
ファイルが生成されています。
ファイルの中に定義したmessageが構造体として定義されていることが分かります。
type Request struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
S string `protobuf:"bytes,1,opt,name=s,proto3" json:"s,omitempty"`
I int32 `protobuf:"varint,2,opt,name=i,proto3" json:"i,omitempty"`
Em Em `protobuf:"varint,3,opt,name=em,proto3,enum=file.Em" json:"em,omitempty"`
Arr []string `protobuf:"bytes,4,rep,name=arr,proto3" json:"arr,omitempty"`
Associative map[string]*MapValue `protobuf:"bytes,5,rep,name=associative,proto3" json:"associative,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
// Types that are assignable to O:
//
// *Request_OS
// *Request_OF
O isRequest_O `protobuf_oneof:"O"`
Nest *Parent_Cheidlen `protobuf:"bytes,8,opt,name=nest,proto3" json:"nest,omitempty"`
Another *Other `protobuf:"bytes,9,opt,name=another,proto3" json:"another,omitempty"`
}
type Other struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
I int32 `protobuf:"varint,1,opt,name=i,proto3" json:"i,omitempty"`
}
以上。