zeitwerkとProtocol Buffersの共存

- (5 min read)

Contents

やりたいこと

  • railsでzeitwerkを有効にしつつProtocolBuffersを利用したい

前提

Protocol Buffersについての説明はしません

普通に使ってみる

# Gemfile
gem 'google-protobuf'
// proto/teacher.proto
syntax = "proto3";

message Teacher {
  string id = 1;
  string name = 2;
}

.protoからrubyのソースコードを生成する protoc --proto_path=protos --ruby_out=app/pb/ protos/*.proto を実行すると、 app/pb/teacher_pb.rbに以下のようなファイルが生成されます。

# Generated by the protocol buffer compiler.  DO NOT EDIT!
# source: teacher.proto

require 'google/protobuf'

Google::Protobuf::DescriptorPool.generated_pool.build do
  add_file("teacher.proto", :syntax => :proto3) do
    add_message "Teacher" do
      optional :id, :string, 1
      optional :name, :string, 2
    end
  end
end

Teacher = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("Teacher").msgclass

これをそのまま使おうとすると失敗します。

% bin/rails c
Loading development environment (Rails 7.0.2.2)
irb(main):001:0> Teacher
(irb):1:in `<main>': uninitialized constant Teacher (NameError)
Did you mean?  TeacherPb
irb(main):002:0> TeacherPb
/home/.rbenv/versions/3.1.0/lib/ruby/gems/3.1.0/gems/zeitwerk-2.5.4/lib/zeitwerk/loader/callbacks.rb:25:in `on_file_autoloaded': expected file /home/development/rails-playground/app/pb/teacher_pb.rb to define constant TeacherPb, but didn't (Zeitwerk::NameError)

      raise Zeitwerk::NameError.new("expected file #{file} to define constant #{cpath}, but didn't", cref.last)
      ^^^^^
irb(main):003:0> Pb::Teacher
(irb):3:in `<main>': uninitialized constant Pb (NameError)

zeitwerk環境でも読み込めるようにする

理由や対応方法などはRails6でProtocol Buffersを使用するがわかりやすいです。記事に習ってinflectorを追加してみましょう。

-- 記事から抜粋 --

class WithProtobufInflector < Zeitwerk::Inflector

  def camelize(basename, abspath)
    if basename =~ /\A.*_pb$/
      basename.gsub("_pb", '').camelize
    else
      super
    end
  end
end

Rails.autoloaders.each do |autoloader|
  autoloader.inflector = WithProtobufInflector.new
end

-- 記事から抜粋(終わり) --

うまくいきました。

% bin/rails c
Loading development environment (Rails 7.0.2.2)
irb(main):001:0> Teacher.new(name: 'foo')
=> <Teacher: id: "", name: "foo">

これをcontrollerに組み込んでも普通に使えます。

class WelcomeController < ApplicationController
  def index
    @teacher = Teacher.new(name: 'foo')
  end
end

しかし、controllerを変更するなどしてrails再読み込みが発生するとUnable to build file to DescriptorPool: duplicate file name (teacher.proto)というエラーが発生する

再読み込みを可能にする

これはファイルの変更時にzeitwerkのreloadingが走っていることが原因です。これをを防ぐためにはprotobuf部分を外部gem化すると良いです。 zeitwerkのドキュメントにあるように、一般的にGemはreloadingをする必要がなく、zeitwerkのデフォルトの設定でもreloadingは無効になっています。

Gems that implement regular libraries, so to speak, or services running in testing or production environments, won't normally have a use case for reloading.

gemの作成

bundle gem k_ota_schema_registry

他の言語からも使われることを想定して、ruby独自の部分はサブディレクトリに移しておく

cd k_ota_schema_registry

mkdir -p protos/k_ota_schema_registry ruby (rubyのgemのdirectory構造に合わせて出力できるようにprotoを置くディレクトリを作っておく)

mv bin lib Gemfile k_ota_schema_registry.gemspec Rakefile ruby

protocコマンドは対象となるprotoファイルを一つずつ指定する必要があるので、rakeタスクで一括生成できるようにしておく

# ruby/Rakefile

require "bundler/gem_tasks"
task default: 'protobuf:generate'

namespace :protobuf do
  desc 'generate ruby file'
  task :generate do
    protofiles = Dir.glob('protos/**/*.proto', base: '..')
    system(['protoc --proto_path=protos --ruby_out=ruby/lib', *protofiles].join(' '), chdir: '..')
  end
end

また、zeitwerk環境でも読み込めるようにしたように、zeitwerk対応を入れる。READMEの構造に、先程のzeitwerk対応のコードを入れれば良い。

# ruby/lib/k_ota_schema_registry.rb

require "zeitwerk"
require_relative "k_ota_schema_registry/inflector"

loader = Zeitwerk::Loader.for_gem
loader.inflector = KOtaSchemaRegistry::Inflector.new(__FILE__)
loader.setup

module KOtaSchemaRegistry
  class Error < StandardError; end
  # Your code goes here...
end

# ruby/lib/k_ota_schema_registry/inflector.rb
module KOtaSchemaRegistry
  class Inflector < Zeitwerk::GemInflector
    def camelize(basename, abspath)
      if basename =~ /\A.*_pb$/
        basename.gsub("_pb", '').camelize
      else
        super
      end
    end
  end
end

利用する側は素直にgemfileとして使える

# Gemfile
gem 'k_ota_schema_registry', git: 'https://github.com/k-o-ta/k_ota_schema_registry.git', branch: 'main'
% bin/rails c
Loading development environment (Rails 7.0.2.2)
irb(main):002:0> KOtaSchemaRegistry::Teacher.new(id: '1', name: 'foo')
=> <KOtaSchemaRegistry::Teacher: id: "1", name: "foo">
irb(main):003:0> KOtaSchemaRegistry::Foo::Student.new(id: '1', name: 'bar')
=> <KOtaSchemaRegistry::Foo::Student: id: "1", name: "bar">
irb(main):004:0> reload!
Reloading...
=> true
# reloadしても問題ない
irb(main):005:0> KOtaSchemaRegistry::Foo::Student.new(id: '1', name: 'bar')
=> <KOtaSchemaRegistry::Foo::Student: id: "1", name: "bar">