大量のGobデータ

Gobs of data by Rob Pike

はじめに

データ構造をネットワーク越しに送信したり、ファイルに保存したりするには、そのデータ構造をエンコードし、 あとで再びデコードする必要があります。もちろん、エンコード方式はすでにたくさんあります。JSON、XML、 Googleのprotocol buffersなどです。そして今、Goのgobパッケージが 提供する、また新しいエンコード方式が加わりました。

なぜ新しいエンコード方式を定義するのでしょうか。それは多くの手間がかかりますし、しかも既存のものと 重複してしまいます。既存の形式のどれかをそのまま使えばよいのではないでしょうか。実のところ、私たちは すでにそうしています。Goには、先に挙げたすべてのエンコード方式に対応するパッケージ が用意されています(protocol bufferパッケージは別リポジトリにありますが、もっともダウンロードされている パッケージの一つです)。他の言語で書かれたツールやシステムとやりとりする場合も含め、多くの用途では これらの形式こそが正しい選択です。

しかし、Goで書かれた2つのサーバー間の通信のような、Go専用の環境であれば、もっと使いやすく、おそらくは より効率的な仕組みを作れる余地があります。

gobは、外部で定義された言語非依存のエンコード方式にはできない形で、Goという言語そのものと連携して 動作します。とはいえ、既存の仕組みから学ぶべき教訓もあります。

目標

gobパッケージはいくつかの目標を念頭に置いて設計されました。

まず、そしてもっとも明白な点として、非常に使いやすくなければなりませんでした。Goにはリフレクションが あるため、別途インターフェース定義言語や「プロトコルコンパイラ」を用意する必要がありません。データ構造 そのものさえあれば、パッケージがエンコードとデコードの方法を把握するのに十分です。一方で、このアプローチ のため、gobは他の言語とは同じようにはうまく連携できません。しかしそれでかまいません。gobは公然と Go中心の仕組みだからです。

効率も重要です。XMLやJSONに代表されるテキスト形式の表現は、効率的な通信ネットワークの中心に据えるには 遅すぎます。バイナリ形式のエンコードが必要です。

gobストリームは自己記述的でなければなりません。gobストリームはどれも、先頭から読めば、その中身について 事前に何も知らないエージェントでもストリーム全体を解釈できるだけの十分な情報を含んでいます。この性質の おかげで、ファイルに保存したgobストリームは、そこにどんなデータが入っているかをすっかり忘れてしまった あとでも、いつでもデコードできます。

Googleのprotocol buffersを使ってきた経験から学んだこともいくつかありました。

プロトコルバッファの欠点

protocol buffersはgobの設計に大きな影響を与えましたが、意図的に避けた3つの機能があります。 (protocol buffersが自己記述的ではないという性質については触れないでおきます。protocol bufferの エンコードに使われたデータ定義を知らなければ、それを解釈できないことがあるからです。)

第一に、protocol buffersはGoでいうstructというデータ型に対してしか機能しません。トップレベルで整数や 配列をエンコードすることはできず、フィールドを持つstructしかエンコードできません。少なくともGoに おいては、これは意味のない制約に思えます。整数の配列だけを送りたいのに、なぜわざわざstructに詰め込ま なければならないのでしょうか。

次に、protocol bufferの定義では、型Tの値をエンコードやデコードする際に、フィールドT.xT.yが 必ず存在しなければならない、と指定できます。こうした必須フィールドは一見よいアイデアに思えますが、 実装のコストが高くつきます。というのも、必須フィールドが欠けていることを報告できるようにするため、 コーデックはエンコードとデコードの最中に別のデータ構造を保持しておかなければならないからです。また これは保守上の問題でもあります。時間が経つにつれて、データ定義を変更して必須フィールドを削除したく なることがありますが、それによって既存のクライアントがクラッシュしてしまうかもしれません。そもそも エンコードにこの機能を持たせないほうがよいのです。(protocol buffersにはオプションフィールドもあります。 しかし必須フィールドがなければ、すべてのフィールドはオプションということになり、それで話は済みます。 オプションフィールドについては、もう少しあとで詳しく述べます。)

3つ目のprotocol buffersの欠点はデフォルト値です。protocol bufferで「デフォルト値付き」のフィールドの 値が省略されていた場合、デコードされた構造体はそのフィールドにデフォルト値が設定されたかのように 振る舞います。このアイデアは、フィールドへのアクセスをgetter/setterメソッドで制御している場合には うまく機能しますが、コンテナが単なる素朴なイディオムどおりのstructである場合には、きれいに扱うのが 難しくなります。必須フィールドの実装も一筋縄ではいきません。デフォルト値をどこで定義するのか、それらが どんな型を持つのか(テキストはUTF-8なのか、解釈されないバイト列なのか、浮動小数点数は何ビットなのか)と いった問題があり、一見単純そうに見えても、protocol buffersの設計と実装には数多くの複雑な事情があり ました。私たちはこれをgobには持ち込まないことに決め、代わりにGoの単純かつ効果的なデフォルトのルール に頼ることにしました。すなわち、何も設定しなければその型の「ゼロ値」になり、そしてゼロ値であれば 送信する必要はない、というルールです。

こうして、gobは一種の一般化され単純化されたprotocol bufferのような形に落ち着きました。では、gobは どのように動作するのでしょうか。

エンコードされたgobデータは、int8uint16といった型を意識したものではありません。その代わりに、 Goの定数にいくらか似た形で、整数値は符号付きか符号なしかを問わず、抽象的でサイズを持たない数として 扱われます。int8をエンコードすると、その値はサイズを持たない可変長整数として送信されます。int64を エンコードした場合も、同じくサイズを持たない可変長整数として送信されます。符号付きと符号なしは区別 して扱われますが、サイズを持たないという点はどちらの値にも共通しています。両方の値が7であれば、 通信路上に送られるビット列は同一になります。受信側がその値をデコードするときには、任意の整数型の 変数に格納します。つまり、送信側はint8由来の7を送るかもしれませんが、受信側はそれをint64に格納 するかもしれません。値は整数であり、収まりさえすれば何でもうまくいきます。収まらなければエラーに なります。変数のサイズから切り離されているおかげで、このエンコードにはある種の柔軟性が生まれます。 ソフトウェアが進化するにつれて整数変数の型を拡張しても、古いデータを引き続きデコードできるのです。

この柔軟性はポインタにも当てはまります。送信の前に、すべてのポインタは平坦化されます。int8*int8**int8****int8などの型の値は、いずれも整数値として送信され、それは任意のサイズの int、あるいは*int******intなど、どんな形にも格納できます。ここでもまた、柔軟性が確保されて いるわけです。

また、structをデコードするとき、エンコーダーによって送信されたフィールドだけが格納先に保存される ということからも、柔軟性が生まれます。次の値を考えてみましょう。

type T struct{ X, Y, Z int } // エクスポートされたフィールドのみがエンコードとデコードの対象になります。
var t = T{X: 7, Y: 0, Z: 8}

tのエンコードでは、7と8だけが送信されます。Yの値はゼロなので、そもそも送信すらされません。 ゼロ値を送る必要はないからです。

受信側はこの値を代わりに次のような構造体にデコードすることもできます。

type U struct{ X, Y *int8 } // 注: int8へのポインタです
var u U

こうして得られるuの値は、Xだけが(値7が入ったint8変数のアドレスに)設定された状態になります。 Zフィールドは無視されます。どこに入れればよいのか分からないからです。structをデコードするときは、 名前と互換性のある型でフィールドが対応付けられ、両方に存在するフィールドだけが処理の対象になります。 この単純なアプローチによって「オプションフィールド」の問題がうまく解消されます。型Tにフィールドが 追加されて進化していっても、古いバージョンの受信側は自分が認識している部分だけを使って引き続き動作 できます。こうしてgobは、追加の仕組みや記法を一切必要とすることなく、オプションフィールドが持つ 重要な効果、すなわち拡張性を実現しています。

整数から、バイト列、文字列、配列、スライス、マップ、さらには浮動小数点数まで、他のすべての型を 組み立てられます。浮動小数点数の値はIEEE 754の浮動小数点ビットパターンとして表現され、整数として 保存されますが、その型さえ分かっていれば問題なく扱えますし、gobでは常に型が分かっています。ちなみに、 その整数はバイトを逆順にして送信されます。小さな整数のような、よくある浮動小数点数の値は下位側に 0が並ぶことが多く、それらの送信を避けられるからです。

Goだからこそ実現できるgobの優れた特徴の一つとして、自分の型にGobEncoderGobDecoderインターフェースを満たさせることで、 独自のエンコード方式を定義できるという点があります。これはJSON パッケージのMarshalerUnmarshaler、そしてfmt パッケージのStringerインターフェースと似たやり方です。この仕組み を使えば、データを送信する際に特別な機能を表現したり、制約を強制したり、秘密の情報を隠したりできます。 詳細はドキュメントを参照してください。

通信路上の型

ある型を初めて送信するとき、gobパッケージはそのデータストリームにその型の記述を含めます。実際に 行われているのは、その型を記述し、一意な番号を与える内部構造体を、標準的なgobエンコード形式そのもの を使ってエンコードするということです(基本型と型記述構造体自身のレイアウトは、ブートストラップの ためにソフトウェア側であらかじめ定義されています)。型が記述された後は、その型番号によって参照できる ようになります。

そのため、最初の型Tを送信するとき、gobエンコーダーはTの記述を送信し、それに(たとえば)127という 型番号のタグを付けます。以降、最初の値も含めてすべての値の先頭にはその番号が付くので、Tの値の ストリームは次のようになります。

("define type id" 127, definition of type T)(127, T value)(127, T value), ...

こうした型番号のおかげで、再帰的な型を記述し、その型の値を送信することが可能になります。そのため、 gobは木構造のような型もエンコードできます。

type Node struct {
    Value       int
    Left, Right *Node
}

(gobはポインタを表現しないにもかかわらず、なぜこれがゼロ値によるデフォルトのルールでうまくいくのかを 考えてみるのは、読者への演習問題としておきましょう。)

この型情報があるおかげで、gobストリームはブートストラップ用の型の集合を除いて完全に自己記述的です。 そしてそのブートストラップの型集合こそが、明確に定義された出発点になっています。

マシンのコンパイル

ある型の値を初めてエンコードするとき、gobパッケージはそのデータ型専用の小さなインタプリタ型マシンを 組み立てます。このマシンを構築する際にはその型に対してリフレクションを使いますが、いったんマシンが 組み立てられてしまえば、以降はリフレクションに依存しません。このマシンはunsafeパッケージといくつか のトリックを使い、データを高速にエンコード済みのバイト列に変換します。リフレクションを使い続けて unsafeを避けることもできたはずですが、その場合は大幅に遅くなっていたでしょう(Go向けのprotocol buffer対応でも同様の高速化手法が採られていますが、その設計はgobの実装から影響を受けています)。同じ 型の後続の値については、すでにコンパイル済みのマシンを使うので、すぐにエンコードできます。

[更新: Go 1.4以降、gobパッケージはunsafeパッケージを使わなくなりました。その結果、性能はわずかに 低下しています。]

デコードも似ていますが、より難しくなります。値をデコードするとき、gobパッケージは、デコード対象と してエンコーダー側が定義した型の値を表すバイトスライスと、そこにデコードする先のGoの値の両方を 扱います。gobパッケージはこのペアに対応するマシンを組み立てます。これは、通信路上で送られてきた gobの型と、デコード先として与えられたGoの型を掛け合わせたものです。ただし、このデコード用のマシンが いったん組み立てられれば、こちらもまたリフレクションを使わないエンジンとなり、unsafeな手法を用いて 最大限の速度を引き出します。

利用方法

内部では色々なことが行われていますが、その結果として、データを送信するための効率的で使いやすい エンコードの仕組みが得られています。ここでは、エンコード時とデコード時で異なる型を使う完全な例を 示します。値の送受信がいかに簡単かに注目してください。必要なのは、値と変数を gobパッケージに渡すことだけで、あとの作業はすべてパッケージが 引き受けてくれます。

package main

import (
    "bytes"
    "encoding/gob"
    "fmt"
    "log"
)

type P struct {
    X, Y, Z int
    Name    string
}

type Q struct {
    X, Y *int32
    Name string
}

func main() {
    // エンコーダーとデコーダーを初期化する。通常、encとdecはネットワーク接続に
    // 結び付けられ、エンコーダーとデコーダーは別々のプロセスで動作する。
    var network bytes.Buffer        // ネットワーク接続の代わり
    enc := gob.NewEncoder(&network) // networkへ書き込む。
    dec := gob.NewDecoder(&network) // networkから読み込む。
    // 値をエンコードする(送信する)。
    err := enc.Encode(P{3, 4, 5, "Pythagoras"})
    if err != nil {
        log.Fatal("encode error:", err)
    }
    // 値をデコードする(受信する)。
    var q Q
    err = dec.Decode(&q)
    if err != nil {
        log.Fatal("decode error:", err)
    }
    fmt.Printf("%q: {%d,%d}\n", q.Name, *q.X, *q.Y)
}

このサンプルコードはGo Playgroundでコンパイルして実行できます。

rpcパッケージは、gobの上に構築されており、このエンコードとデコードの 自動化をネットワーク越しのメソッド呼び出しのための通信手段に仕立て上げています。これについてはまた 別の記事で取り上げることにします。

詳細

gobパッケージのドキュメント、特に doc.goというファイルには、ここで説明した詳細の多くが さらに詳しく書かれており、このエンコード方式がどのようにデータを表現するかを示す完全な実例も含まれて います。gobの実装内部に興味があるなら、そこから読み始めるのがよいでしょう。

By Rob Pike