Edited at

GoとRustでProtocolBuffersとFlatBuffersのEnumの扱いについて

最初の動機はProtocolBuffersのEnum項目を未指定にした場合、Goでは0値として解釈されるが他の言語ではどうなるのか?という疑問からスタートしました。ついでにFlatBuffersも調べてみました。

結論から言うとProtocolBuffers、FlatBuffersいずれもGo、Rustにおいて未指定の場合は0値として解釈されます。

なおサンプルで使用したコードはこのリポジトリにあります。


ProtocolBuffers

試すprotobufは下記です。

syntax = "proto3";

message Message {
enum MessageType {
Handshake = 0;
SendTransaction = 1;
SendBlock = 2;
Bye = 3;
}
enum Status {
OK = 0;
NG = 1;
}
MessageType type = 1;
Status status = 2;
bytes payload = 3;
}


Go

Goのコード生成は下記のコマンドで行います。protocのインストールはこの辺りの記事を参考にしてください。

$ protoc --go_out=./p2p ./p2p.proto


何も指定しない場合

    m := &p2p.Message{}

buf, _ := proto.Marshal(m)
fmt.Printf("Serialize: %v\n", buf)
msg := &p2p.Message{}
_ = proto.Unmarshal(buf, msg)
fmt.Printf("Deserialize: %v\n", msg)
fmt.Printf("Type: %v\n", msg.Type)

下記の出力になります。そもそも、シリアライズデータが空になります。

Serialize: []

Deserialize:
Type: Handshake


0値を指定した場合

    m = &p2p.Message{Type: p2p.Message_Handshake}

buf, _ = proto.Marshal(m)
fmt.Printf("Serialize: %v\n", buf)
msg = &p2p.Message{}
_ = proto.Unmarshal(buf, msg)
fmt.Printf("Deserialize: %v\n", msg)
fmt.Printf("Type: %v\n", msg.Type)

結果は変わらず。どちらもシリアライズデータは空なので区別はできません。

Serialize: []

Deserialize:
Type: Handshake


0値と他のフィールドで0値以外を指定した場合

    m = &p2p.Message{Type: p2p.Message_Handshake, Status: p2p.Message_NG}

buf, _ = proto.Marshal(m)
fmt.Printf("Serialize: %v\n", buf)
msg = &p2p.Message{}
_ = proto.Unmarshal(buf, msg)
fmt.Printf("Deserialize: %v\n", msg)
fmt.Printf("Type: %v\n", msg.Type)

0値の箇所はやっぱり削られます。

Serialize: [16 1]

Deserialize: status:NG
Type: Handshake


0値以外を指定した場合

    m = &p2p.Message{Type: p2p.Message_Bye}

buf, _ = proto.Marshal(m)
fmt.Printf("Serialize: %v\n", buf)
msg = &p2p.Message{}
_ = proto.Unmarshal(buf, msg)
fmt.Printf("Deserialize: %v\n", msg)
fmt.Printf("Type: %v\n", msg.Type)

不安になったので別の値を指定してみました。もちろん、シリアライズされデコードできます。

Serialize: [8 3]

Deserialize: type:Bye
Type: Bye


Rust

Rustの場合はbuid時にコード生成するライブラリがあるので、build.rsに下記を記述します。

extern crate protoc_rust;

use protoc_rust::Customize;

fn main() {
protoc_rust::run(protoc_rust::Args {
out_dir: "src",
input: &["p2p.proto"],
includes: &["."],
customize: Customize {
carllerche_bytes_for_bytes: Some(true),
carllerche_bytes_for_string: Some(true),
..Default::default()
},
})
.expect("protoc");
}


何も指定しない場合

    let m = p2p::Message::new();

let buf = Bytes::from(m.write_to_bytes().unwrap());
println!("Serialize: {:?}", buf);
let msg = parse_from_bytes::<p2p::Message>(&buf).unwrap();
println!("Deserialize: {:?}", msg);
println!("Type: {:?}", msg.get_field_type());

Goと一緒です。Optionがある言語とはいえ、シリアライズ結果が空ではどうにもならないですね。

Serialize: b""

Deserialize:
Type: Handshake


0値を指定した場合

    let mut m = p2p::Message::new();

m.set_field_type(p2p::Message_MessageType::Handshake);
let buf = Bytes::from(m.write_to_bytes().unwrap());
println!("Serialize: {:?}", buf);
let msg = parse_from_bytes::<p2p::Message>(&buf).unwrap();
println!("Deserialize: {:?}", msg);
println!("Type: {:?}", msg.get_field_type());

結果は一緒なので、もはやライブの使い方ですが、set_field_typetypeフィールドに値を設定します。OOPみたいでRustっぽくない気もします。

Serialize: b""

Deserialize:
Type: Handshake


0値と他のフィールドで0値以外を指定した場合

    let mut m = p2p::Message::new();

m.set_field_type(p2p::Message_MessageType::Handshake);
m.set_status(p2p::Message_Status::NG);
let buf = Bytes::from(m.write_to_bytes().unwrap());
println!("Serialize: {:?}", buf);
let msg = parse_from_bytes::<p2p::Message>(&buf).unwrap();
println!("Deserialize: {:?}", msg);
println!("Type: {:?}", msg.get_field_type());

Goと変わらず。

Serialize: b"\x10\x01"

Deserialize: status: NG
Type: Handshake


0値以外を指定した場合

    let mut m = p2p::Message::new();

m.set_field_type(p2p::Message_MessageType::Bye);
let buf = Bytes::from(m.write_to_bytes().unwrap());
println!("Serialize: {:?}", buf);
let msg = parse_from_bytes::<p2p::Message>(&buf).unwrap();
println!("Deserialize: {:?}", msg);
println!("Type: {:?}", msg.get_field_type());

やっぱり、Goと変わらず。

Serialize: b"\x08\x03"

Deserialize: type: Bye
Type: Bye


FlatBuffers

flatc --proto ../proto/p2p.protoでprotobufファイルから生成できるのですが、生成されるfbsファイルのnamespaceの使い方が微妙です。enumとtableでnamespaceが分断されて、package名が無いGoプログラムになりました。。。なので今回は手で記述しました。(protobufの記述を直せばちゃんと出力される?)

namespace P2P;

enum MessageType : int { Handshake = 0, SendTransaction, SendBlock, Bye }
enum Status : int { OK = 0, NG }

table Message {
type:MessageType;
status:Status;
payload:[byte];
}

xroot_type Message;


Go

Goのコード生成は下記のコマンドです。faltcのインストールはこの辺りの記事を参考にしてください。

$ flatc -g p2p.fbs

Goの場合は下記のように複数ファイルが生成されます。

├── P2P

│   ├── Message.go
│   ├── MessageType.go
│   └── Status.go


何も指定しない場合

    builder := flatbuffers.NewBuilder(0)

P2P.MessageStart(builder)
m := P2P.MessageEnd(builder)
builder.Finish(m)
b := builder.FinishedBytes()
fmt.Printf("Serialize: %v\n", b)
msg := P2P.GetRootAsMessage(b, 0)
fmt.Printf("Deserialize: %v\n", msg)
fmt.Printf("Type: %v\n", P2P.EnumNamesMessageType[msg.Type()])

typeは未指定でも0値が設定されます。

ProtocolBuffersとは違って、高速にパースするために値のoffset情報やvtable情報が入っているので空では無いです。ただし、結局は未指定の場合、フィールドは切り詰められます。

Serialize: [8 0 0 0 4 0 4 0 4 0 0 0]

Deserialize: &{{[8 0 0 0 4 0 4 0 4 0 0 0] 8}}
Type: Handshake


0値を指定した場合

    builder = flatbuffers.NewBuilder(0)

P2P.MessageStart(builder)
P2P.MessageAddType(builder, P2P.MessageTypeHandshake)
m = P2P.MessageEnd(builder)
builder.Finish(m)
b = builder.FinishedBytes()
fmt.Printf("Serialize: %v\n", b)
msg = P2P.GetRootAsMessage(b, 0)
fmt.Printf("Deserialize: %v\n", msg)
fmt.Printf("Type: %v\n", P2P.EnumNamesMessageType[msg.Type()])

上に書いたように同じbyteが出力されるので、0値を指定してもフィールドは切り詰められます。

Serialize: [8 0 0 0 4 0 4 0 4 0 0 0]

Deserialize: &{{[8 0 0 0 4 0 4 0 4 0 0 0] 8}}
Type: Handshake


0値と他のフィールドで0値以外を指定した場合

    builder = flatbuffers.NewBuilder(0)

P2P.MessageStart(builder)
P2P.MessageAddType(builder, P2P.MessageTypeHandshake)
P2P.MessageAddStatus(builder, P2P.StatusNG)
m = P2P.MessageEnd(builder)
builder.Finish(m)
b = builder.FinishedBytes()
fmt.Printf("Serialize: %v\n", b)
msg = P2P.GetRootAsMessage(b, 0)
fmt.Printf("Deserialize: %v\n", msg)
fmt.Printf("Type: %v\n", P2P.EnumNamesMessageType[msg.Type()])

byte列が長くなってますが、0値は切り詰められます。

Serialize: [12 0 0 0 8 0 8 0 0 0 4 0 8 0 0 0 1 0 0 0]

Deserialize: &{{[12 0 0 0 8 0 8 0 0 0 4 0 8 0 0 0 1 0 0 0] 12}}
Type: Handshake


0値以外を指定した場合

    builder = flatbuffers.NewBuilder(0)

P2P.MessageStart(builder)
P2P.MessageAddType(builder, P2P.MessageTypeBye)
m = P2P.MessageEnd(builder)
builder.Finish(m)
b = builder.FinishedBytes()
fmt.Printf("Serialize: %v\n", b)
msg = P2P.GetRootAsMessage(b, 0)
fmt.Printf("Deserialize: %v\n", msg)
fmt.Printf("Type: %v\n", P2P.EnumNamesMessageType[msg.Type()])

byte列は上のものと同じ長さです。ちゃんと、0値ではない値が取得できます。

Serialize: [12 0 0 0 0 0 6 0 8 0 4 0 6 0 0 0 3 0 0 0]

Deserialize: &{{[12 0 0 0 0 0 6 0 8 0 4 0 6 0 0 0 3 0 0 0] 12}}
Type: Bye


Rust

Rustの場合はprotbuf同様、buid時にコード生成するライブラリがあるのでbuild.rsに下記を記述します。

extern crate flatc_rust;

use std::path::Path;

fn main() {
flatc_rust::run(flatc_rust::Args {
inputs: &[Path::new("./p2p.fbs")],
out_dir: Path::new("./src/"),
..Default::default()
}).expect("flatc");
}


何も指定しない場合

    let mut builder = flatbuffers::FlatBufferBuilder::new_with_capacity(0);

let m = Message::create(&mut builder, &Default::default());
builder.finish(m, None);
let buf = builder.finished_data();
println!("Serialize: {:?}", buf);
let msg = get_root_as_message(buf);
println!("Deserialize: {:?}", msg);
println!("Type: {:?}", msg.type_());

Rustの場合はSerializeにデフォルト値を指定しないといけません。。。

とはいえ出力されるbyte列はGoと一緒なのでフィールドは切り詰められます。

Serialize: [8, 0, 0, 0, 4, 0, 4, 0, 4, 0, 0, 0]

Deserialize: Message { _tab: Table { buf: [8, 0, 0, 0, 4, 0, 4, 0, 4, 0, 0, 0], loc: 8 } }
Type: Handshake


0値を指定した場合

    let mut builder = flatbuffers::FlatBufferBuilder::new_with_capacity(0);

let m = Message::create(
&mut builder,
&MessageArgs {
type_: MessageType::Handshake,
..Default::default()
},
);
builder.finish(m, None);
let buf = builder.finished_data();
println!("Serialize: {:?}", buf);
let msg = get_root_as_message(buf);
println!("Deserialize: {:?}", msg);
println!("Type: {:?}", msg.type_());

Goと変わらず。

Serialize: [8, 0, 0, 0, 4, 0, 4, 0, 4, 0, 0, 0]

Deserialize: Message { _tab: Table { buf: [8, 0, 0, 0, 4, 0, 4, 0, 4, 0, 0, 0], loc: 8 } }
Type: Handshake


0値と他のフィールドで0値以外を指定した場合

    let mut builder = flatbuffers::FlatBufferBuilder::new_with_capacity(0);

let m = Message::create(
&mut builder,
&MessageArgs {
type_: MessageType::Handshake,
status: Status::NG,
..Default::default()
},
);
builder.finish(m, None);
let buf = builder.finished_data();
println!("Serialize: {:?}", buf);
let msg = get_root_as_message(buf);
println!("Deserialize: {:?}", msg);
println!("Type: {:?}", msg.type_());

Goと変わらず。

Serialize: [12, 0, 0, 0, 8, 0, 8, 0, 0, 0, 4, 0, 8, 0, 0, 0, 1, 0, 0, 0]

Deserialize: Message { _tab: Table { buf: [12, 0, 0, 0, 8, 0, 8, 0, 0, 0, 4, 0, 8, 0, 0, 0, 1, 0, 0, 0], loc: 12 } }
Type: Handshake


0値以外を指定した場合

    let mut builder = flatbuffers::FlatBufferBuilder::new_with_capacity(0);

let m = Message::create(
&mut builder,
&MessageArgs {
type_: MessageType::Bye,
..Default::default()
},
);
builder.finish(m, None);
let buf = builder.finished_data();
println!("Serialize: {:?}", buf);
let msg = get_root_as_message(buf);
println!("Deserialize: {:?}", msg);
println!("Type: {:?}", msg.type_());

もちろん、Goと変わらず。

Serialize: [12, 0, 0, 0, 0, 0, 6, 0, 8, 0, 4, 0, 6, 0, 0, 0, 3, 0, 0, 0]

Deserialize: Message { _tab: Table { buf: [12, 0, 0, 0, 0, 0, 6, 0, 8, 0, 4, 0, 6, 0, 0, 0, 3, 0, 0, 0], loc: 12 } }
Type: Bye


結論

結局、ProtocolBuffersもFlatBuffersもシリアライズ時に未指定・0値のものは切り詰められるので、デシリアライズ時は0値で復元されるということです。


おまけ FlatBuffersのシリアライズデータを覗く

この記事が一番分かりやすかったのですが、理解するまで時間かかったのでここに書いておきます。

空の方がわかりにくいので先に値があるパターンを説明します。

    builder = flatbuffers.NewBuilder(0)

P2P.MessageStart(builder)
P2P.MessageAddType(builder, P2P.MessageTypeSendBlock)
P2P.MessageAddStatus(builder, P2P.StatusNG)
m = P2P.MessageEnd(builder)
builder.Finish(m)
b = builder.FinishedBytes()
fmt.Printf("Serialize: %v\n", b)
msg = P2P.GetRootAsMessage(b, 0)
fmt.Printf("Deserialize: %v\n", msg)
fmt.Printf("Type: %v\n", P2P.EnumNamesMessageType[msg.Type()])
fmt.Printf("Type: %v\n", P2P.EnumNamesMessageType[msg.Status()])

Serialize: [12 0 0 0 8 0 12 0 8 0 4 0 8 0 0 0 1 0 0 0 2 0 0 0]

Deserialize: &{{[12 0 0 0 8 0 12 0 8 0 4 0 8 0 0 0 1 0 0 0 2 0 0 0] 12}}
Type: SendBlock
Type: NG

stringが無いので元記事よりも単純な構造になっています。

// root table offset

1. 12 0 0 0 // root tableの開始offset 12バイト先なので、6行目を示している
//vtable info
2. 8 0 // vtable infoのsize
3. 12 0 // data tableのsize
4. 8 0 // typeフィールドのoffset 8行目を示している
5. 4 0 // statusフィールドのoffset 7行目を示している
// data table
6. 8 0 0 0 // vtable infoの開始が8offset前であることを示している つまり2行目
7. 1 0 0 0 // statusの値 (NG)
8. 2 0 0 0 // typeの値 (SendBlock)

1行目のroot tableのoffsetから6行目に飛んで、そこから2行目に戻って各フィールドのoffsetを取得するというのがフィールドの値の取得方法?生成されるプログラムはoffsetが各フィールドごとにハードコーディングされています。

func (rcv *Message) Type() MessageType {

o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.GetInt32(o + rcv._tab.Pos)
}
return 0
}

フィールドが全部0値の場合は下記になります。12byteですが、フィールドの情報は存在しません。

その場合、上のメソッドは 変数o が0となり、return 0 を返すという処理になります。

// root table offset

1. 8 0 0 0 root table offset (4行目の位置を示している)
//vtable info
2. 4 0 // vtable infoのsize
3. 4 0 // data tableのsize
// data table
4. 4 0 0 0 // vtable infoの開始が4offset前であることを示している つまり2行目


追記

じゃあ、typeしか指定しない場合とstatusしか指定しない場合はどうなんだろうというの書いたあとに気になったので調べてみました。


typeのみ

  builder = flatbuffers.NewBuilder(0)

P2P.MessageStart(builder)
P2P.MessageAddType(builder, P2P.MessageTypeBye)
m = P2P.MessageEnd(builder)
builder.Finish(m)
b = builder.FinishedBytes()
fmt.Printf("Serialize: %v\n", b)
msg = P2P.GetRootAsMessage(b, 0)
fmt.Printf("Deserialize: %v\n", msg)
fmt.Printf("Type: %v\n", P2P.EnumNamesMessageType[msg.Type()])

typeしか指定していない場合、最初のところでアライメントの調整が入っています。

// root table offset

12 0 0 0 0 0
//vtable info
6 0
8 0
4 0 // ハードコーディングのoffsetはここを示す
// data table
6 0 0 0
3 0 0 0 // 結局ここの値が返る


statusのみ

  builder = flatbuffers.NewBuilder(0)

P2P.MessageStart(builder)
P2P.MessageAddStatus(builder, P2P.StatusNG)
m = P2P.MessageEnd(builder)
builder.Finish(m)
b = builder.FinishedBytes()
fmt.Printf("Serialize: %v\n", b)
msg = P2P.GetRootAsMessage(b, 0)
fmt.Printf("Deserialize: %v\n", msg)
fmt.Printf("Type: %v\n", P2P.EnumNamesMessageType[msg.Type()])

statusだけ指定した場合vtableは伸びますが、typeの場所は0になっています。

// root table offset

12 0 0 0
//vtable info
8 0
8 0
0 0 // ハードコーディングのoffsetはここを示しているが0なのでdata talbeは見ない
4 0
// data table
8 0 0 0
1 0 0 0

プログラム的には値のvtableにあるdata tableのoffset情報を取得->data tableの値取得となっています。

func (rcv *Message) Type() MessageType {

o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.GetInt32(o + rcv._tab.Pos)
}
return 0
}

ついでにライブラリの方まで追うと、root table offset(t.Pos)からvtableの場所を求め

t.GetVOffsetT(vtable)でvtalbeのサイズを調べてオーバーしていなければ、

t.GetVOffsetT(vtable + UOffsetT(vtableOffset))でvtable内のハードコーディングされた値のoffsetからdata tableのoffsetを求めています。

func (t *Table) Offset(vtableOffset VOffsetT) VOffsetT {

vtable := UOffsetT(SOffsetT(t.Pos) - t.GetSOffsetT(t.Pos))
if vtableOffset < t.GetVOffsetT(vtable) {
return t.GetVOffsetT(vtable + UOffsetT(vtableOffset))
}
return 0
}
func (t *Table) GetSOffsetT(off UOffsetT) SOffsetT {
return GetSOffsetT(t.Bytes[off:])
}