プログラミング
バイナリパッキングは楽しい
Packing Binary Is Fun (hereticpleb.vercel.app)
要約
この記事では、JSONペイロードを最大80%削減できる独自のバイナリフォーマット「jBin」の作成プロセスを解説しています。バイナリパッキングの基本的な考え方から、スキーマ言語の設計、コンパイラの構築、そして最終的なCLIツールの実装まで、技術的な詳細を掘り下げています。
全文翻訳
なぜ誰もこんなことをするのだろうか?バイナリパッキングとは何か?スキーマ構文の構築 実装の時間 データエンコーディングとデコーディング コンパイラの構築 問題点 字句解析器(Lexer)の構築 AST(抽象構文木)とは? ASTの構築 パーサーの構築 意味解析器(Semantic Analyzer)の構築 動的パッカー コード生成(Codegen) ビジターパターン CLIの構築 結論
なぜ誰もこんなことをするのだろうか?Twitterで、JSONでデータを保存するのは「本物の™開発者」のやり方ではないと主張する人がいるのを見かけました。当然、私も「本物の™開発者」になる必要がありました。調べてみると、答えはバイナリでした。当然、私は「自分のバイナリフォーマットを作るのは、どれほど難しいだろうか?」という素晴らしいアイデアを思いつきました。きっと少しの作業で済むはずです。( ˶ˆᗜˆ˵ ) 残念ながら、それは少しの作業では済みませんでした。結局、JSONペイロードを80%も削減できる、完全なバイナリスキーマ言語を構築することになりました。
jBin
バイナリパッキングとは何か?
例えば、「hello world」というデータがあるとします。これはASCIIでは104 101 108 108 111 32 119 111 114 108 100、つまり h e l l o [スペース] w o r l d と変換されます。hはASCIIで104になります。これらのASCII値は8ビットに収まるため、各文字は1バイトを消費します。
h e l 01101000 01100101 01101100 l o [SPACE] 01101100 01101111 00100000 w o r 01110111 01101111 01110010 l d 01101100 01100100
なので、C言語で簡単に書けます。
FILE *f = fopen("file.bin", "wb");
unsigned char data[] = "hello world";
fwrite(data, 1, sizeof(data) - 1, f);
fclose(f);
シンプルですね。では、104を書き込んでみましょう。明らかに、104をASCII文字として書き込むことができます:「1」「0」「4」→ 49 48 52。しかし、これは1バイトで十分な数に対して3バイトもかかります。そのため、これらの2バイトを節約したい場合は、デコーダーに「これは文字列ではなく整数です」と伝える方法が必要です。ヘッダーを追加できます。追加するのはわずか1バイトです(まあ、タイプがいくつあるかによりますが…128種類以上のタイプがないことを願います…もしあれば、もっと大きな問題があります。素晴らしい!最初のバイトをタイプ、2番目のバイトをデータとして使用しましょう。
[タイプ][データ]
例えば、0が整数、1が文字列だとすると、104は 00000000 01101000 となり、文字列は… 00000001 01101000。おっと…これではhしか表現できません。異なる長さのデータを表現する方法が必要です。まあ、もう1バイト追加しましょう。それは文字列の長さを表すはずです。これで、バイナリは [タイプ][長さ][データ] となります。
素晴らしい!これで文字列と整数を一緒にパックできます!例えば、「userid」: 123 を表現したいとします。これで、すべてを一緒にパッケージ化できます。
[タイプ:文字列][長さ:6][単語:userid][タイプ:整数][長さ:-][データ:123]
素晴らしい!123を2バイトのヘッダーで1バイトの数値として表現できます。しかし、整数にはLENGTHフィールドを実際には使用していないことに注意してください。なぜそれが必要なのでしょうか?無駄なバイトですよね?さて…もしそれを削除したら、バイナリリーダーはヘッダーがどこで終わるのかを知る方法はどうなるでしょうか?ヘッダーの終端を伝える方法が必要です。「ヘッダーは終わり、実際のデータの読み取りを開始してください」と。うーん。バイトの終端を表すために何を使えばいいでしょうか?ヘッダー用の長さバイト、例えば?ええ。それは冗長です。長さフィールドを削除して、別の長さフィールドを追加することになります。しかし、ヘッダー自体にビットを使用できるかもしれません。1つのビットで「私はヘッダーの最後のバイトではありません。まだあります」と伝えることができます。あなたは思うかもしれません、なぜLSB(最下位ビット)を使わないのか?そうすれば、偶数しか表現できなくなります。それは…理想的ではありません。そのため、MSB(最上位ビット)を使用します。
これで、タグは次のようになります。
[継続ビット][7ビットのデータ]
継続ビットが1の場合、ヘッダーバイトがもう1つあります。0の場合は、ヘッダーは終了です。
これを使用すると、123は次のようになります。
[タイプ=0][データ=123]
文字列の場合は、
[タイプ=1][長さ=11][データ=104]...
つまり、タイプ1、長さ11ですが、待ってください…長さが127を超える場合はどうでしょうか?7ビットでは最大127までしか表現できません!同じことを整数にも使用します!最初のビットが1の場合、整数は続きます。
128は次のように書き込めます。
10000001 00000000
^ MSB / 継続ビット(ビッグエンディアン)
バイナリリーダーは次のように動作します。
最初のバイトを読み取ります。MSBは1なので、もう1バイトあります。残りの7ビットは1です。2番目のバイトを読み取ります。MSBは0なので、これが最後のバイトです。残りの7ビットは0です。2つの7ビット値を組み合わせて128を取得します。これは一種のvarint(可変長整数)です。使用しているエンコーディングはリトルエンディアンです。最も重要でない7ビットが最初にきます。
10000000 00000001
これは素晴らしいですね?同じバイナリで異なるタイプを表現でき、バイナリパーサーはそれらをすべて正しく読み取ることができます。
しかし、注意してください。データをフィールドごとに保存しています。
[タイプ][データ]
[タイプ][データ]
[タイプ][データ]
そして、ほとんどのデータはランダムな値が浮遊しているだけではありません。通常は構造化されています。Cの構造体を見てみましょう。
struct {
int i;
char *s;
int a[10];
}
これは32ビットシステムでは次のようになります。
[32ビット整数] [32ビットポインタ] [10 × 32ビット整数]
そして、毎回ヘッダーを追加する必要はありませんでした。なぜなら、構造体自体からデータのタイプを知っているからです。
うーん。これをバイナリデータにも応用できるのではないかと思います…
そして、はい、できます。それがスキーマです!
そのため、構造体の場合、スキーマは次のようになります。
i: int
s: char *
a: list(int)
スキーマにより、タイプを各値と一緒に保存することなく、タイプを知ることができます。
これで、バイナリフォーマットはタイプを気にする必要がなくなりました!データサイズだけを気にする必要があります!それがプロトバフが行っていることです。
では、さまざまなデータサイズを考えてみましょう。整数、浮動小数点数、ブール値、文字列があります。整数とブール値はvarintとして扱い、浮動小数点数は固定幅:f32またはf64です。文字列は異なります。バイトをvarintとしてエンコードすることはできません。なぜなら、バイト自体が保存する必要のある実際のデータだからです。そのため、文字列のバイト数を読み取る前に知る必要があります。
これで、エンコーディングタイプは次のようになります。
varint
f32
f64
delimited(区切り文字付き)
しかし待ってください…フィールドにどのようにアクセスしますか?「文字列をちょうだい」と言うだけではいけません。どれかを指定する必要があります。問題ありません。それぞれを数値で表現しましょう。インデックスを付けるか、ユーザーに独自の番号を割り当ててアドレス指定できるようにします。これがフィールド番号が必要な理由です。
そして、もう1つ注目してください。可能なタイプは4つだけです。4つの値は2ビットにぴったり収まります。
00 - varint
01 - f32
10 - f64
11 - delimited
これは素晴らしいですね。
では、それをエンコードしましょう…フィールド番号の中へ!
どうやって?バイナリのトリックです…いや、実際には違います。それらを一緒に詰め込むだけです。フィールド番号を左に2ビットシフトして、2ビットのタイプのためのスペースを作り、その空きビットにタイプをORします。
フィールド番号が10で、それがdelimitedタイプだとします。
00001010 (10)
それを2ビット左シフトします。
00101000
タイプとORします!
00101011
見てください!1バイトの数値が、タイプとフィールド番号の両方を教えてくれます!
しかし、さらに進むことはできませんか?すでにフィールド番号にタイプをパックしました。なぜそこで止まるのですか?長さをパックすることもできたらどうでしょうか?できます。
これで、ヘッダーは次のようになります。
[フィールド番号][長さ][タイプ]
すべて1つのvarintの中に!!!! ◝(ᵔᗜᵔ)◜
クール。ただし、1つのこと以外は、非常に手間のかかる作業です。文字列をパックするには、「これはdelimitedである」と伝え、長さを指定し、バイトを指定する必要があります。毎回。毎回。農民はそうします。下層民。私たちはそうしません。したがって、明らかに、解決策は完全なスキーマ言語を書くことです。スキーマファイルにデータを宣言し、プログラムにすべての面倒なフォーマットとエンコーディングのナンセンスを処理させます。
スキーマ構文の構築
さて…魂を吸い取らない構文を決定する必要があります(プロトバフを見ているよ)。
フィールド番号…それらは何ですか?インデックスですよね。食料品リストの項目にどのようにインデックスを付けますか?番号を書きます。アイテム。なぜ同じものを使わないのですか?だから、このようなものです。
1. 名前
スキーマはタイプを表現する必要があります。他の言語がどのように行うかを盗んで、次のようにしましょう。
1. 名前: タイプ
いいね?しかし、待ってください。メッセージ(構造体)の終わりを示すにはどうすればよいでしょうか?まあ、d