Info
本記事はAkatsuki Games Advent Calendar 2025における14日目の記事です。 昨日の13日目は奈茶さんの記事『【Unity】良い感じに被写界深度を調整できるカメラを作る』でした。

はじめに 見出しへのリンク

サーバがHTTP経由でAPIを公開しクライアントがそれを呼び出すモデルにおいて、送受信するデータの形式としてJSONが広く利用されています。 JSON は基本的にどんな言語でもサポートされており(特にブラウザとの相性が良い)、可読性が高く、取り回しもしやすいため潰しが効きます。そのため、とりあえず JSON を採用しておけば困るケースはほとんどありません。

……と書きましたが、「ほとんど」と書いたように、困るとまではいかなくとも最適でないケースがいくつかあります。例えば、以下のケースです。

巨大な数値の取り扱いが処理系依存 見出しへのリンク

JSON において、数値の取り扱いは処理系依存です。 例として、int64 の最大値である 9223372036854775807 を含む JSON について、手元の Node.js と Ruby でそれぞれ出力した結果が以下になります。

$ node -e "console.log(JSON.parse('{\"id\": 9223372036854775807}'))"  
{ id: 9223372036854776000 }
ruby -e "require 'json'; puts JSON.parse('{\"id\": 9223372036854775807}')"
{"id" => 9223372036854775807}

Node.js 側は値が変わってしまっています。これは数値の取り扱いが実装依存のためですね。

データ量が大きくなりがち 見出しへのリンク

JSON はキーを含むスキーマ構造を文字で表すため、巨大なデータ転送には不向きです(無駄なデータ量が増えます)。 圧縮することで多少改善しますが、サーバ・クライアント側両方の対応が必要で、バイナリ形式と比べると大きくなる傾向があります。

また、バイナリを直接取り扱えません。一般に Base64 で文字列にエンコードする必要があり、データ量・処理量ともに無駄が増えます。

デコードの負荷が高め 見出しへのリンク

実装にもよりますが、テキストのパースが必要になるため、デコードが比較的高コストです。


開発の過程でこうした問題に遭遇した場合、圧縮やエンコードを駆使してJSONを使い続けるか1、バイナリ形式に切り替えるかに分かれます。 後者においては、スキーマ定義不要の MessagePack やスキーマ定義必須の Protocol Buffers (protobuf) が有名です。 また、バイナリ形式を扱う選択をした時点で効率性やデコード速度を改善したい場合がほとんどでしょうから、より高速・効率性重視で protobuf を採用するケースが多いと思います。

Info
protobuf と聞くと grpc とセットで使うのを想起される方がいらっしゃると思いますが、 protobuf 自体はデータ形式でしかないため、APIに依らず利用可能です。 そのため、私も同様の選択肢を迫られた結果、一部の業務においては grpc ではない HTTP API で protobuf のみを採用しています。

ただ、 protobuf に限らずバイナリ形式のデータを採用するにあたり、エンコーダ・デコーダのライブラリと利用しているフレームワークとの相性は重要です。 ライブラリを使うために複雑なビルド設定や依存管理が要求されるのであれば、開発の工数やスピードに悪影響を与えてしまいかねません。

その上で、今回私が protobuf を持ち込みたかったのはRuby on Rails で開発されている API サーバに対してで、事前調査において「protobuf とあまり相性が良くない」という情報が散見されていました2。 実際、私が protobuf を導入する際にも若干の調査と試行が必要だったため、導入後即利用できるというわけではなかったのは事実です(とはいえ、対応は比較的シンプルでした。内容は後述します)。

そこで、まずは Rails に protobuf がなぜ組み込みにくいのかを見ていきましょう。

Rails と protobuf の食い合わせの悪さについて 見出しへのリンク

Info

本記事では、以下の環境で動作確認をしています。

  • MacBook Pro (M3, 2023)
  • macOS Sequoia 15.7.2
  • Ruby 3.4.7
  • Rails 8.1.1
  • protoc 33.2

protobuf の基本 見出しへのリンク

スキーマを定義 見出しへのリンク

そもそも Protocol Buffers, 通称 protobuf とはなんでしょうか。 ざっくり言うと、 protobuf は「スキーマ定義を要するバイナリ形式フォーマット」になります。

スキーマ定義は .proto の拡張子を持つテキスト形式ファイルで表現されます。 いくつかのバージョンがあるのですが3、最も有名なのは proto3 と呼ばれるバージョンでしょう。例は以下のようになります。

syntax = "proto3";

package pb.models;

message User {
  string name = 1;
  optional uint32 type = 2;
  map<uint32, uint64> config_hash = 3;
}

詳細は公式のドキュメント4を読んでいただくとして、大まかにだけ説明すると syntax で記法バージョン、 package で名前空間、 message でデータのスキーマをそれぞれ指定するといった具合です。

これで「バイナリはこう表現される」という定義ができました。 ただ、定義データが上記のテキストのままだと、いちいちプログラムで扱える形にパースしないといけません。これは無駄なので、 protobuf では protoc と呼ばれるコンパイラ・コードジェネレータを用いて、データをエンコード・デコードするためのコード(バインディング)を各言語ごとに事前生成しておく必要があります。

言語ごとのバインディングを生成 見出しへのリンク

例として、前の項で作成した proto から Ruby 用コード、バインディングを生成してみましょう。 生成するには、 protobuf のコンパイラが必要です。 GitHub からダウンロードするか、パッケージマネージャを利用してインストールしておきます5

user.proto と同じディレクトリにおいて、以下のコマンドで生成します。

protoc --ruby_out="./" -I . user.proto

すると、以下のようなバインディングファイルが生成されるはずです。

# frozen_string_literal: true
# Generated by the protocol buffer compiler.  DO NOT EDIT!
# source: user.proto

require 'google/protobuf'


descriptor_data = "\n\nuser.proto\x12\tpb.models\"\x99\x01\n\x04User\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x11\n\x04type\x18\x02 \x01(\rH\x00\x88\x01\x01\x12\x34\n\x0b\x63onfig_hash\x18\x03 \x03(\x0b\x32\x1f.pb.models.User.ConfigHashEntry\x1a\x31\n\x0f\x43onfigHashEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12\r\n\x05value\x18\x02 \x01(\x04:\x02\x38\x01\x42\x07\n\x05_typeb\x06proto3"

pool = ::Google::Protobuf::DescriptorPool.generated_pool
pool.add_serialized_file(descriptor_data)

module Pb
  module Models
    User = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("pb.models.User").msgclass
  end
end

descriptor_data にコンパイルされた proto が挿入され、 Ruby で扱いやすいようクラスにしてくれているのがわかります。コンパイル済みのスキーマをプーリングすることで、スキーマ情報へのアクセス高速化・一元管理を実現しているようです。 なお、 ::Google::Protobuf::DescripterPoolgoogle-protobuf Gem から提供されているクラスのため、事前に当該 Gem のインストールも必要です(これをランタイムと呼びます)。 この Gem は C で書かれたネイティブ拡張のため、極めて高速なわけですね6

Rubyから扱えているか試すには以下のようにします。

gem install google-protobuf -v 4.XX.Y # protoc に対応したバージョンを選びます。 protoc 33.2 なら 4.33.2 です。
ruby -e "require './user_pb'; pp Pb::Models::User.new(name: \"Sample User\", type: 1, config_hash: {1 => 2, 100 => 200})"

うまくいけば、以下のように値が表示されているはずです。

$ ruby -e "require './user_pb'; puts Pb::Models::User.new(name: \"Sample User\", type: 1, config_hash: {1 => 2, 100 => 200}).to_proto"
<Pb::Models::User: name: "Sample User", type: 1, config_hash: {1=>2, 100=>200}>

これを protobuf のバイナリとして書き出すには #to_proto を利用します。

$ ruby -e "require './user_pb'; pp Pb::Models::User.new(name: \"Sample User\", type: 1, config_hash: {1 => 2, 100 => 200}).to_proto"                      
"\n" + "\vSample User\x10\x01\x1A\x05\bd\x10\xC8\x01\x1A\x04\b\x01\x10\x02"

API サーバで利用する場合、これをクライアントに渡してあげれば良いわけですね。 念の為、きちんとデコードできるかを .decode を使って確認してみましょう。

$ ruby -e "require './user_pb'; pp Pb::Models::User.decode(Pb::Models::User.new(name: \"Sample User\", type: 1, config_hash: {1 => 2, 100 => 200}).to_proto)" 
<Pb::Models::User: name: "Sample User", type: 1, config_hash: {1=>2, 100=>200}>

きちんと戻すこともできていますね。 ところで、このシンプルなサンプルにおける protobuf のデータ量と JSON のデータ量の差を比較してみましょう。 #to_proto ではなく #to_json を使うことで protobuf のクラスから JSON を出力できるので、それぞれの #size をみてあげれば良さそうです。

$ ruby -e "require './user_pb'; pp Pb::Models::User.new(name: \"Sample User\", type: 1, config_hash: {1 => 2, 100 => 200}).to_proto.size" # protobuf                   
28
ruby -e "require './user_pb'; pp Pb::Models::User.new(name: \"Sample User\", type: 1, config_hash: {1 => 2, 100 => 200}).to_json.size" # JSON 
66

これらの結果から、サイズが半分まで小さくなっていることが確認できました。バイナリ形式の強みですね。

Railsでの難しさ(1) : リロード 見出しへのリンク

ここまでで、あくまで Ruby における protobuf の基本的な使い方を説明しました。では、 Rails で動かす上で何が課題になるのでしょうか?

まず最初の課題として、生成されたファイル名規則が Rails の期待するものと一致していない、というのがあります。すでに試したように、protoc で生成されたファイル名の末尾には _pb というサフィックスがつくため、これがオートロード時の名前解決において邪魔です。 とはいえ、これ自体は Inflector を用いたり、 package や proto 側の Message 名を調整することで簡単に回避可能です。例えば、以下のような proto ファイルを作れば app/models/pb 配下7にそのまま出力・利用することができます(ここでは後者のアプローチを取り、メッセージ名を UserPb とします)。

syntax = "proto3";

package pb;

message UserPb {
  string name = 1;
  optional uint32 type = 2;
  map<uint32, uint64> config_hash = 3;
}

proto/ 配下に proto を配置する場合、生成は以下のコマンドで良いでしょう。

protoc --ruby_out="./app/models/pb" -I proto user.proto # Rails プロジェクトのルートディレクトリで実行すると仮定

こうすれば、簡単に読み込めます。

bin/rails c
$ bin/rails c
Loading development environment (Rails 8.1.1)
sample(dev):001> Pb::UserPb.new
=> <Pb::UserPb: name: "", config_hash: {}>

良さそうに見えますよね? しかし、これには問題があります。

前の項で、生成された Ruby のバインディングコードでは、共用の ::Google::Protobuf::DescriptorPool にコンパイルされた proto が追加されていると説明しました。 これが、リロードと相性が極めて悪いのです。同名の proto を再登録、つまりリロードによって再実行された場合、 Google::Protobuf::TypeError のエラーと共に失敗します。

$ bin/rails c
Loading development environment (Rails 8.1.1)
sample(dev):001> Pb::UserPb.new
=> <Pb::UserPb: name: "", config_hash: {}>
sample(dev):002> reload!
Reloading...
=> nil
sample(dev):003> Pb::UserPb.new
app/models/pb/user_pb.rb:11:in 'Google::Protobuf::DescriptorPool#add_serialized_file': Unable to build file to DescriptorPool: duplicate file name role.proto (Google::Protobuf::TypeError)

pool.add_serialized_file(descriptor_data)

ではリロードのタイミングで generated_pool をクリアすれば良いのでは? と思わなくもないのですが、すでに述べた通り protobuf は C で書かれたネイティブ拡張機能のため、 Ruby 側からできることはほとんどありません。 実際に C のコードを読んでみると、クリア・削除を行うAPIは一切提供されていないことがわかります。つまり原理上、 Ruby の protobuf のバインディングはリロードに対応していない わけです。

しかし、これだけならまだ対応できます。 Zeitwerk には config.autoload_once_paths という便利な設定があり、ここに含めたディレクトリはリロードの対象外にできるのです。 以下のように application.rb へ記載した上で app/gen/pb 配下にバインディングを出力すれば、リロードのエラーは出なくなるはずです。

(... snip ...)

    config.autoload_once_paths << "#{root}/app/gen"

(... snip ...)

生成は以下で行います。

protoc --ruby_out="./app/gen/pb" -I proto user.proto # Rails プロジェクトのルートディレクトリで実行すると仮定

これなら、リロードしても問題ありません。解決でしょうか?

 $ bin/rails c                                         
Loading development environment (Rails 8.1.1)
sample(dev):001> Pb::UserPb.new
=> <Pb::UserPb: name: "", config_hash: {}>
sample(dev):002> reload!
Reloading...
=> nil
sample(dev):003> Pb::UserPb.new
=> <Pb::UserPb: name: "", config_hash: {}>

残念ながら、もうちょっとだけ続きます。

Railsでの難しさ(2) : require による依存解決 見出しへのリンク

少し複雑な proto を書き出してみましょう。 User に依存関係、具体的には Role を持たせてみましょうか。

syntax = "proto3";

package pb;

message RolePb {
  uint32 id = 1;
  uint32 value = 2;
}
@@ -2,8 +2,11 @@
 
 package pb;
 
+import "role.proto";
+
 message UserPb {
   string name = 1;
   optional uint32 type = 2;
   map<uint32, uint64> config_hash = 3;
-}
+  repeated RolePb roles = 4;
+}

protobuf では、上記のように import 文を使うことで別のファイルの定義を参照して利用することができます。また、 repeated を使うことで配列の表現も可能です。 以下コマンドでバインディングを生成します。

$ protoc --ruby_out="./app/gen/pb" -I proto user.proto role.proto

role_pb.rb については自明なので割愛するとして、問題は user_pb.rb です。

@@ -4,9 +4,11 @@
 
 require 'google/protobuf'
 
+require 'role_pb'
 
-descriptor_data = "\n\nuser.proto\x12\x02pb\"\x96\x01\n\x06UserPb\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x11\n\x04type\x18\x02 \x01(\rH\x00\x88\x01\x01\x12/\n\x0b\x63onfig_hash\x18\x03 \x03(\x0b\x32\x1a.pb.UserPb.ConfigHashEntry\x1a\x31\n\x0f\x43onfigHashEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12\r\n\x05value\x18\x02 \x01(\x04:\x02\x38\x01\x42\x07\n\x05_typeb\x06proto3"
 
+descriptor_data = "\n\nuser.proto\x12\x02pb\x1a\nrole.proto\"\xb1\x01\n\x06UserPb\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x11\n\x04type\x18\x02 \x01(\rH\x00\x88\x01\x01\x12/\n\x0b\x63onfig_hash\x18\x03 \x03(\x0b\x32\x1a.pb.UserPb.ConfigHashEntry\x12\x19\n\x05roles\x18\x04 \x03(\x0b\x32\n.pb.RolePb\x1a\x31\n\x0f\x43onfigHashEntry\x12\x0b\n\x03key\x18\x01 \x01(\r\x12\r\n\x05value\x18\x02 \x01(\x04:\x02\x38\x01\x42\x07\n\x05_typeb\x06proto3"
+
 pool = ::Google::Protobuf::DescriptorPool.generated_pool
 pool.add_serialized_file(descriptor_data)

このように、 Ruby の require を利用することで import 相当の依存解決を行なっていることが分かります。 ただし require で指定されるパスは -I で指定されたディレクトリを基準として自動的に生成されるため、何もしないと $LOAD_PATH に見つからずエラーになります。

 $ bin/rails c
Loading development environment (Rails 8.1.1)
sample(dev):001> Pb::UserPb.new
app/gen/pb/user_pb.rb:7:in '<main>': cannot load such file -- role_pb (LoadError)
        from (sample):1:in '<main>

なので、この pb ディレクトリを $LOAD_PATH に足してあげれば動きます。これも application.rb に足してみましょう。

(... snip ...)

    config.autoload_once_paths << "#{root}/app/gen"
    $LOAD_PATH.unshift("#{root}/app/gen/pb") unless $LOAD_PATH.include?("#{root}/app/gen/pb")

(... snip ...)

これでリロード対象からも除外しつつ、 require にも対応させることができます。

 $ bin/rails c                                                    
Loading development environment (Rails 8.1.1)
sample(dev):001> Pb::UserPb.new
=> <Pb::UserPb: name: "", config_hash: {}, roles: []>
sample(dev):002> Pb::RolePb.new
=> <Pb::RolePb: id: 0, value: 0>
sample(dev):003> reload!
Reloading...
=> nil
sample(dev):004> Pb::UserPb.new
=> <Pb::UserPb: name: "", config_hash: {}, roles: []>
sample(dev):005> Pb::RolePb.new
=> <Pb::RolePb: id: 0, value: 0>

ただ、これが綺麗な対応か? にはやや疑問が残ります。

ここまでの対応まとめ 見出しへのリンク

ここまでの対応をまとめます。

  • 課題1 : Rails のローダーが期待する命名規則と異なる
    • → Inflector や message 側の命名調整で対応可
  • 課題2 : 依存関係がある proto について、Rubyコードが require を行なっている
    • $LOAD_PATH に対し明示的に追加する
  • 残課題
    • 使うための対応がやや大袈裟ではないか

なるべくシンプルに Rails へ適応させるには 見出しへのリンク

ここまで、なぜ Rails で protobuf がそのまま使いにくいか、またどうすれば良いかについて説明してきました。 ただ、 Inflector を使ったり $LOAD_PATH を操作したりするのは、やりたいことに対して若干大袈裟すぎる気もします。

そのため、私はここまで述べた方法とは別の、よりシンプルなアプローチを用いて導入することにしました。こちらについてご紹介します。

protoc 実行後、 pb ファイルの require を書き換える 見出しへのリンク

protoc を実際に利用する際、基本的にシェルスクリプトを書いて実行すると思いますが、私のアプローチでは protoc により生成された rb ファイルの require を一括で置換するようにしました。 具体的には require 'dir/file' となっていた部分を require Rails.root.join 'app/pb' 'dir/file' というように書き換えます。 こうすることで、 $LOAD_PATH をわざわざ操作する必要がなくなり、設定変更が不要になるわけです。

スクリプトは、例えば以下のようになります:

#!/bin/bash -e

cd "$(dirname "$0")/.."

out_dir=./app/pb

# 古いバインディングを一旦削除してから再生成
find $out_dir -type f -name *_pb.rb | xargs rm
find proto -type f -name "*.proto" | xargs protoc --ruby_out="$out_dir" -I proto

find "$out_dir" -type f -size 0 -delete
find "$out_dir" -type f -name "*_pb.rb" | while read file; do
  # macOS ではなく Linux の場合はそのまま sed でも動くはず
  gsed -i "/^require 'google\//! s#^require '#require Rails.root.join 'app/pb/' '#" "$file"
done

Zeitwerk の管理下から除外し、 initializer で一括 require する 見出しへのリンク

すでに述べたように、 Ruby の protobuf バインディングはどうせリロードに対応していないので、そもそも Zeitwerk に管理させる必要がありません。 そのため、以下のように application.rb でオートロード対象外としてしまい、 initializer で一括ロードしています。

(... snip ...)

    # Protocol Buffer の Ruby ファイルは proto の依存解決のために require を指定しているため、 Zeitwerk の管理下に置かない
    Rails.autoloaders.main.ignore(Rails.root.join("app/pb").to_s)

(... snip ...)
Rails.application.config.after_initialize do
  # app/pb 配下のファイルを全て読み込み Pb 配下の全てのクラスを定義する
  Rails.root.glob("app/pb/**/*.rb").each { |f| require f }
end

こうすることで、 Eager Loading や読み込み順序など、小難しいことを考えずに済むというメリットがあります。名前空間配下が全てロードされているので、モンキーパッチを当てたい時も楽です。 オートロード対象外なのでリロード時に問題が起きることもありませんし、 Inflector やら Message の名前やらを考える必要もありません。

Simple is best. そう思いませんか。

まとめ 見出しへのリンク

Rails において protobuf を利用する際に起きがちな問題について、発生する原因とその対策を述べました。 また、シンプルなアプローチ案として、オートローダの管理から外した上で initializer から手動でロードする案を提案しました。

長々と書いてしまいましたが、一度理解さえしてしまえば導入は難しくないと思います。 JSON と異なり、クライアント側とスキーマを合わせて利用する必要があるなど若干手間のかかるフォーマットですが、その分高速かつ効率的なので、パフォーマンスが必要な場面で活躍することでしょう。

「Rails でも普通に動作するんだよ」という点だけでも、みなさまに届いていれば幸いです。

Tip
本記事で作成した proto や、いくつかのサンプルコードを含めた Rails 8.1 のサンプルプロジェクトを以下で公開しています。 https://github.com/flfymoss/rails-protobuf-sample

ライセンス 見出しへのリンク

本記事の内容は、特記なき限りCreative Commons Attribution 4.0 International Public License8のもとで自由に利用することができます。ただし、別のライセンスが示されている部分についてはそちらに従ってください。 なお、Zennにて同一の内容9を掲載しています。

Info
明日は上野山遼音さんの記事になります。お楽しみに!

  1. JSONを採用し続けるのが必ずしも悪い選択肢とは限りません。特に対ブラウザで使用する場合はgzipなど圧縮がまず利用できますし、負荷や処理速度が許容できる場合かどうかが重要になってくるでしょう。 ↩︎

  2. 例えば この記事この記事 など。 ↩︎

  3. protobuf の記法は proto2, proto3 とバージョニングされてきましたが、最近は editions という形に変わっているようです。とはいえ、少なくとも proto3 以降に大きな変化は見られません。 ↩︎

  4. https://protobuf.dev/programming-guides/proto3/ ↩︎

  5. 例えば、私は mise を利用して管理しています。 ↩︎

  6. ただし、その代償として Ruby 側でよしなに拡張することが難しく、それが後述するリロードの難しさなどに繋がってきています。 ↩︎

  7. 配置は別にどこでも良いのですが、 Rails アプリケーション内からモデルのように呼び出すことが多いため、 lib/ などに置くよりは app/ 配下に置きたいかな、という気持ちでここに配置しています。 ↩︎

  8. https://creativecommons.org/licenses/by/4.0/legalcode.ja ↩︎

  9. https://zenn.dev/flfymoss/articles/2025-12-12-rails-81-with-protobuf ↩︎