ちりもつもればミルキーウェイ

好奇心に可処分時間が奪われる

json/protobuf(wire)/gobをバイナリ効率とかの観点から比べてみる

はじめに

どうも @convto です。

以前に protobufのwire encodingについて遊んた ことや gobについていろいろ仕様を調べて遊んだ こととかがあったので、いっちょいい感じの比較ができるんではということでやっていきたいと思います。

ついでにgobについては 当時のモチベを調べたりもしたことある のでそのへんもおまけ程度に言及しつつ、こういうときにgobつかえそうだよ〜みたいなのをまとめてみます。

比較するのは

です。おもな観点はバイナリ効率ですが、それぞれのエンコーディングのメッセージの互換性やら、周辺エコシステムの広がりも含めて評価できればと思います。

ちなみにバイナリの比較についてはせっかくなので稚作の↓をつかってます。そこそこ便利なので用途に合う方はぜひ愛でてあげてください

github.com

これは Go Advent Calendar 2022 の15日目の記事です。わいわい

json

jsonはfield_nameとvalueをどちらもエンコードされたメッセージに載せます。簡易的なコードとその出力は以下です。

package main

import (
    "encoding/json"
    "fmt"

    "github.com/convto/bit"
)

func main() {
    type item struct {
        Name  string
        Price uint64
    }

    i := item{
        Name:  "apple",
        Price: 100,
    }

    b, _ := json.Marshal(i)
    fmt.Println(bit.Dump(b))
}

(ちなみに説明してませんでしたが、今回の比較で利用する自作のライブラリである https://github.com/convto/bit は、与えられたバイナリを 01 の表現にして返します。バイナリとにらめっこするために作りました)

このコードで得られる出力は以下です。

00000000: 01111011 00100010 01001110 01100001 01101101 01100101  {"Name
00000006: 00100010 00111010 00100010 01100001 01110000 01110000  ":"app
0000000c: 01101100 01100101 00100010 00101100 00100010 01010000  le","P
00000012: 01110010 01101001 01100011 01100101 00100010 00111010  rice":
00000018: 00110001 00110000 00110000 01111101                    100}

この出力を見ると以下のことがわかります

という感じです。

なお、jsonの特筆すべき点としてはJavaScriptとの親和性が高いことと、そこから派生した歴史的経緯から幅広い言語で実装がありencodingとしてのポータビリティが高いことがあげられます。

特に特別な外部ライブラリなどを必要とせず素のJavaScriptから評価可能なのは、直接的なencodingの性質ではないかもしれませんが大きいアドバンテージのように思われます。

また、jsonエンコードされたメッセージを手に入れれば、外部の設定ファイルなどを必要とせずに元のメッセージの構造がわかります。このようなメッセージ単体で構造についての情報も完結しているようなencodingをさして 自己言及的(self-describing) であるみたいな表現もメッセージエンコーディングの文脈ではよく使われていることを目にします。

まとめると、

  • keyを変更すると互換性が担保できない
  • keyをそのまま文字として含んでいるのでバイナリ効率のいいencodingではない
  • webブラウザで利用できたり、様々な言語で実装があったりするためメッセージのポータビリティが高い
  • self-describingである

のような性質がjsonにはあることがわかります。

バイナリエンコーディングと比較するとたしかに効率がよくない部分もありますが、それ以上にメッセージのポータビリティや自己言及的な性質があることから明確なメリットも存在することがわかりますね!

jsonはシステムの周辺の環境を含めて考えたときに、メッセージのポータビリティに重きを置くようなシステムでは十分に活躍してくれるはずです。

逆にいえば、バイナリ効率が重視される、かつメッセージ自体のポータビリティがそこまで重要でない分野のメッセージエンコーディングとしては他の手段を検討したほうが良さそうです。

protobuf(wire)

ここではProtocolBuffersにまつわる広いトピックではなく、おもにそのencoding仕様であるwire formatに絞って話をします。以下にざっくりした仕様の紹介があります。

developers.google.com

まずはjsonと同じく実際のバイナリを確認しましょう。

適当な以下のメッセージ定義を利用してコード生成したとします。(実際のコード生成にはいくつかオプションが必要ですが、ここでは省略します)

syntax = "proto3";
package item;

message item {
  string name = 1;
  uint64 price = 2;
}

生成したコードを convto/playground/protoitem というパッケージに出力したとすると、以下のように書けます。

package main

import (
    "fmt"

    "github.com/convto/bit"
    "github.com/convto/playground/protoitem"
    "google.golang.org/protobuf/proto"
)

func main() {
    protoItem := protoitem.Item{
        Name:  "apple",
        Price: 100,
    }
    protob, _ := proto.Marshal(&protoItem)
    fmt.Println(bit.Dump(protob))
}

出力は以下です。

00000000: 00001010 00000101 01100001 01110000 01110000 01101100  ..appl
00000006: 01100101 00010000 01100100                             e.d

9byteや!すくなぁい!

なんでこんなに少ないねん!が気になった人むけwire format仕様

なにがなんやらな気がするのでここでwire formatの仕様について軽く言及しておきます。

wire formatではtagとvalueに値が分けられています。tagの読み方は昔の僕がいい画像を作っていて、以下の感じです。

tagにはfield_numberとtypeがあります。field_numberは可変長なので先頭1bitで終端判断をします。typeはvalueのデータ型を表現していて、wireは以下のデータ型をサポートしています。

Type Meaning Used For
0 Varint int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 64-bit fixed64, sfixed64, double
2 Length-delimited string, bytes, embedded messages, packed repeated fields
3 Start group groups (deprecated)
4 End group groups (deprecated)
5 32-bit fixed32, sfixed32, float

これに照らして考えると、先頭1byteの 00001010 はtagであり、 field_number = 1, type = 2(Length_delimited) であることがわかります。

length_delimitedな値はつぎにバイト長が連携されます。こちらも可変長なので先頭1bitが終端判定で使われ、下位7bitに値があります。今回の場合は 00000101 なので5byteの値が続くことがわかります。

後続5byteを読んでみるとASCIIの a p p l e という値が続くことがわかります。これをprotoファイルの定義を元にどの型で解釈すればいいか判断します。今回の場合はstringとして取得します。

次は 00010000 tagです。これは field_number = 2, type = 0(Varint) であることがわかります。varintなフィールドのvalueは可変長です。先頭1bitが終端判断で使われて、値の表現に必要なbyteがずらーと並んでいる形になってます。

今回のvalue01100100 です。これは先頭1bitが0なので終端であり、値は7bitで 64 + 32 + 4 = 100 であることがわかります!

wire formatについてこれ以上の詳細が気になったら 簡易的な実装 もしてるので読んでみてね

さて、jsonとくらべてめちゃめちゃバイナリ効率がよさそうなことがわかりました。wire formatには以下の性質があります。

  • バイナリエンコーディングであり効率がよい
  • keyにあたる概念をfield_numberとtype(型情報)を統合したtagという概念で管理している
  • ↑から、メッセージ上にproto IDLで管理しているスキーマにおけるフィールド名は 登場しない
  • field_numberが変更されなければスキーマ上のフィールド名を変更しても メッセージに影響はない
  • スキーマ上の型を変更しても、限定的ではあるが互換が担保される場合もある(同じtype間の移動 & オーバーフローしない範囲において)
  • self-describingではない
    • とくにtagから読み取ったtypeがlength-delimited typeの場合はproto定義が無いとvalueの構造がわからない
  • jsonのほうが大きいコストを払わずに解釈できる環境は多い

などの性質があります。

jsonと比較すると、バイナリ効率がよかったり、フィールド名の変更が可能だったり、嬉しい面があります。

いっぽうで、無理なく解釈できる環境はjsonのほうが多かったり、self-describingではないのでスキーマを共有できる関係性でないと解釈不可能だったりなど、明確にjsonのほうが秀でている部分もあります。

また、wire format自体の性質ではありませんが、ProtocolBuffersはprotocコンパイラなどを合わせた周辺のエコシステムによる柔軟なコード生成も魅力的です。

得意/不得意がありますが、適したユースケースには強くハマるし管理もしやすいので、近年(protocなどとセットで)選択されることが増えたように思います。

gob

前置きが長かった〜〜〜〜やっとgobの話です!

なんでjsonとprotobuf(wire)の話をしたかというと、ぼくは個人的にgobはバイナリ仕様的にはこれらの中間のような立ち位置のように感じるからです。

stream単位でself-describingでありつつ、バイナリエンコーディングでもありバイナリ効率もそこそこです。ただ、gob encodingはstream単位でやり取りすること前提にデザインされているので、それにあった使い方をしないとjsonより効率がわるくなる場合もあります。

どういうことかざっくりかいつまむと、streamにまずは型情報の詳細を流してそのあとにmessage本体をなげるような挙動になってます。message本体はself-describingじゃないけど、型情報を頭で流せば構造わかるからいいでしょという考えっすね。wire formatで例えるとバイナリにスキーマ情報も含まれるイメージです。

なので1メッセージだけのやりとりを考えると、メッセージ本体だけでなくその型情報が頭につくのでjsonよりサイズが大きくなったりします。

前置きはこの辺にしてサクッとバイナリ見ていきましょう

package main

import (
    "os"

    "encoding/gob"
    "github.com/convto/bit"
)


func main() {
    type item struct {
        Name  string
        Price uint64
    }
 
    i := item{
        Name:  "apple",
        Price: 100,
    }

    dump := bit.Dumper(os.Stdout)
    defer dump.Close()
    enc := gob.NewEncoder(dump)
    enc.Encode(i)
}

(ここで宣伝ですが、今回バイナリ見るために使ってる自作ライブラリ https://github.com/convto/bit がいい感じにio streamに対応してるのは地味に推しポイントです。encoding/hexのデザインを参考にしました)

さて出力は以下です。

00000000: 00100101 11111111 10000001 00000011 00000001 00000001  %.....
00000006: 00000100 01101001 01110100 01100101 01101101 00000001  .item.
0000000c: 11111111 10000010 00000000 00000001 00000010 00000001  ......
00000012: 00000100 01001110 01100001 01101101 01100101 00000001  .Name.
00000018: 00001100 00000000 00000001 00000101 01010000 01110010  ....Pr
0000001e: 01101001 01100011 01100101 00000001 00000110 00000000  ice...
00000024: 00000000 00000000 00001100 11111111 10000010 00000001  ......
0000002a: 00000101 01100001 01110000 01110000 01101100 01100101  .apple
00000030: 00000001 01100100 00000000                             .d.

全部で 51byte あります!大きいですね!

gobはメッセージ長:メッセージ本体みたいな構造になってます。最初の1byteが 00100101 なので、後続37bytesまでは1つ目のメッセージである型情報です。メッセージ長を示すバイトを含めると38bytesが型情報ですね。gobではネストした構造情報が送られていて、それぞれの構造ごとに終端byte( 00000000 )が含まれます。先のメッセージでいうと、以下の部分が型情報です。

00000000: 00100101 11111111 10000001 00000011 00000001 00000001  %.....
00000006: 00000100 01101001 01110100 01100101 01101101 00000001  .item.
0000000c: 11111111 10000010 00000000 00000001 00000010 00000001  ......
00000012: 00000100 01001110 01100001 01101101 01100101 00000001  .Name.
00000018: 00001100 00000000 00000001 00000101 01010000 01110010  ....Pr
0000001e: 01101001 01100011 01100101 00000001 00000110 00000000  ice...
00000024: 00000000 00000000

さて型情報がわかったところで、このあとに送られているはずのメッセージ本体について確認していきます。

メッセージ本体は以下のbytesになります

00000024:                   00001100 11111111 10000010 00000001  ......
0000002a: 00000101 01100001 01110000 01110000 01101100 01100101  .apple
00000030: 00000001 01100100 00000000                             .d.

39byteめは 00001100 でbodyの長さは12ですね。次のメッセージは後続12bytesです。詳細は割愛しますが、さっき受け取った型情報には識別子が含まれていて、このメッセージにもその識別子をもたせることで型情報とマッチングさせて型を解釈させています。

メッセージ長を示すバイトを含め、型を含まないメッセージ本体のサイズは13bytesです。

streamの先頭で型情報も流してるので、1メッセージだけのやり取りを考えると少し無駄が多いです。ですが同一streamにすでに同様の型情報が流れていれば2回目以降は型情報は省略されるので、そのような使い方をするとメッセージ本体のサイズが13byteで十分小さい利点を生かせると思います。(wire formatは9bytes, jsonは28bytes)

gobの詳細な仕様については立ち入って紹介はしませんが、興味あるかたはpackage documentに詳しいのでぜひご一読ください!(valueとかの扱いはちょっとwire formatとにてるとこもあります zigzag encodingっぽいとことか)

pkg.go.dev

以前頑張って↑を読んだこともあるのでこちらも興味あればごらんください

convto.hatenablog.com

性質をまとめると

  • 特別な外部定義やアノテーションなしにGoの値をそのまま使える
  • stream単位でself-describingである
  • メッセージ本体のバイナリ効率はそこそこよい
  • 1 stream 1 message の使い方だとjsonより効率わるいときもある
  • 実は型情報にフィールド名などが含まれているので、protobuf(wire)ほど柔軟に変更はできない
  • self-describingなので型情報しらなくてもGo同士ならうまくつかえる(はず)
  • 実質的に利用できるのはGoだけなので、中長期にかけて永続化されるデータにはつかわんほうがよさげ
    • 仕様あるしself-describingで構造の情報がstreamで完結してるので、根性出せばGo以外でも実装できんことはない

という感じですね。

stream単位でのself-describingな性質を持ちつつ、バイナリ効率もそこそこよいというjsonとprotobuf(wire)の中間のような性質をもっています。また、Goからの利用に最適化されていて、他のどの選択肢よりも必要な情報が少なくて利用ハードルが低いように思います。

一方でメッセージは実質Goからしか利用できないので、メッセージのポータビリティはかなり低いです。永続化されるデータに使うべきでは無いでしょう。

また 1 stream 1 message の使い方だとあまり効率は良くないので、stream単位で送るメッセージ量が多いユースケースなどの適切な利用パターンを探って使ってあげたいですね!

まとめ: gobはどういうときにつかうといいのかな

gobはユニークな性質をもったメッセージエンコーディングであり、以下の制約を満たすときメリットが大きいです。

  • Go同士でしか利用する想定がない
  • 永続化する情報ではなく、streamでのやりとりのようなメッセージ伝搬手段として、あるいは短時間で揮発するコンテンツに利用する
  • 1 stream あたり大量のメッセージをやりとりする

得られる最大のメリットとしてはなんといってもGoから特別な定義なしに利用でき、かつself-describingであることでしょう。特にGoからの利用に最適化されておりスムーズに使えるという性質は他のencodingでは代替不可です。

このメリットを重視するユースケースかつ、上記の制約を満たす場合は利用が検討できると思います!

おまけ

余談ですが、gobの設計にはProtocolBuffersでの失敗した部分の学びを活かされてるで〜というのが言及されてます。

https://go.dev/blog/gob

There were also some things to learn from our experiences with Google protocol buffers.

で、そのしくじりがなんだったかというと大きく分けて以下の3つが言及されてます。

  1. 構造体以外のメッセージを定義する表現力がprotobufにはなく、単一のフィールドだけでいいのに構造体として定義する必要がある(Goではstructとして受け取る必要がある)
    • たとえばむき出しのintとかだけ定義したいケースとかあるけど、そういうのはできないよねーという話
  2. requiredは一見うれしいが、困ることが割とあった
    • 実装がだるい
    • requiredフィールドを途中から削除すると、旧クライアントが必須だとおもってるフィールドが新クライアントから送られなくなって、旧クライアントがぶっ壊れる
    • requiredフィールドを途中から追加すると、新クライアントが必須だとおもってるフィールドを旧クライアントは送ってないので、新クライアントがぶっ壊れる
    • つまり前方/後方の互換を取るのが難しかった
  3. default設定だるい
    • 値がなかったらあたかもそれを受け取ったように振る舞わねばならない
    • requiredと合わせて実装がだるくなる要因
    • 素直にGoのゼロ値の挙動にあわせてよくねーか

で、実はこのうち2, 3のふたつは protobuf側の仕様で解消されてます

proto3からは

  • required消えた
  • default設定個別にできなくなった
    • Goのゼロ値に相当するような概念がproto仕様に組み込まれて、それがデフォルト値になって設定不可になった

という変更が加わったので、このへんはじつは現代においてはprotobuf(wire)と比較した差別化ポイントになりえません。

proto側で課題が解消されるのはいいことですが、gob選ぶ理由も一つ消えたので嬉しいような悲しいようなという感じですね。