Help us understand the problem. What is going on with this article?

Rails6でProtocol Buffersを使用する

こんにちは。

SmartHR アドベントカレンダーの4日目を担当しますエンジニアの@kurobaraです。

SmartHRでは、プラットフォーム化を促進するプラスアプリの開発をしています。
プラスアプリについては、弊社社長によるSmartHR Next 2018やCTO 芹澤の中長期戦略などに詳しく書かれています。

ところで、前日が社長だと次に渡されるバトンを担当する側としても緊張しますね><

前置き

皆さん、型は大好きですか?
僕も好きです。

特にサーバサイドになんらかの言語でAPIをフロントエンドにTypeScriptで・・・
そして、サーバサイドとフロントエンドのI/Fとして、JSONを使用する

割とよくあるパターンかつ比較的デファクトだと思います
比較的、最近だとサーバサイドにGraphQLを使用しているという話もチラホラ聞くことが多くなってきましたが、今回の話とはちょっと異なるので割愛します

しかしながら、JSONを使っているとフロントエンドにTypeScriptを使用しているにも関わらず、以下の内容で恩恵を受けづらいこともあるのでは?と個人的に感じています

  • (現場によりますが)型情報だけでなく、JSONのKeyそのものが存在しないことも多くある
    • 同じキーで、 型が違う とか、なんなら逆にある特定のエッジケース だけでキーが増えるとかも...
  • 注意しないと、バリューに合わせてNullableにしなければならないケースも出てくる
    • TypeScriptの型システムの恩恵を受けられない
  • 異なる型として認識されてしまうこともある
    • 例えば、数値をダブルクォートで括ってAPIが返してしまい、JSON.parseで文字列型として認識されてしまうことなど
  • 双方の都合によるパターンではっきりしないJSONになっていることもある
    • フロントエンドのライブラリとか...
    • サーバ側だと、勢いでさっと作ったJSONとか...
  • etc...

挙げればキリが無さそうですが、これに対してのアプローチとして色々あるかと思います。

  • テストで担保&テストからドキュメント生成
  • OpenAPIを使用する
    • 定義ファイルを書いたり...
    • 実装から定義ファイル作ったり...
  • JSON Schemaを頑張って書く
  • etc...(なんか色々やってたような気がしますが忘れました)

どれもこれもですが、(忘れたくなるぐらいなので)結構面倒なのでは(?)と感じています

  • テストで担保は現実的に難しい
    • ケースを網羅するのは、ちょっと大変でしょう
    • ましてや、そこからドキュメント生成だとテストが書けてれば・・・
  • OpenAPI / JSON Schema
    • 巨大になるとYAMLやJSONは管理不可能でツールを使わざるを得ない
    • どこかしらで自動生成したものから定義ファイルを起こし直すなど、なんらかの形での対応が必要そう
    • (開発において)フロー整備するなり、うまく仕組み化する必要もある
  • etc...

というわけで、自分はものぐさな訳ですが下記をやってくれるといいなと思っています

  • 型定義はほしい
  • I/Fとしてのスキーマは普遍でいてほしい
    • JSONでもいいけど、キーの欠落は極力勘弁してほしいし、APIの定義は決してズレないでほしい
  • 定義ファイルから、エンティティとなるコード生成もしてもらいたい
    • 定義ファイルの作成や変更を行う場合の変更コストは、仕方ないけど飲む

そんな皆様にProtocol Buffersの使用をオススメしたいかと思います

個人的にオススメしたいところは概ねこのあたりです

  • JSON SchemaやOpenAPIの領域を必要最低限カバーできている
  • スキーマ言語の仕様として、覚えることがそんなに多くない
    • 簡素かつ可読可能
    • 特定の言語に依存していない
    • データの表現が必要十分で適用範囲が広い
    • 必要異常にスキーマ言語として多機能では無い
  • 複数言語間でやり取りできるようにするためのシリアライズ/デシリアライズ用のクラスや構造体を自動生成できる
    • 一貫性を保つことが十分可能
  • 複数言語間でやり取りできるJSONを生成できる
  • スキーマに合わせて実装を揃えていくことができる
    • APIの定義として、決してブレることが起きない

こんなところでしょうか。。。

例えば、SmartHR APIの従業員取得 のうち一部のデータを表現してみるとこんな感じでしょうか。
普通に見ても、可読性高く、型定義、データ構造も初見で見たとしても意味が分かりそうですよね!

syntax = "proto3";

package smarthr.crews;

import "smarthr/department.proto";

message Crew {
  enum Gender {
    MALE = 0;
    FEMALE = 1;
  }
  string id = 1;
  string emp_code = 2;
  string last_name = 3;
  string first_name = 4;
  string last_name_yomi = 5;
  string first_name_yomi = 6;
  string business_last_name = 7;
  string business_first_name = 8;
  string business_last_name_yomi = 9;
  string business_first_name_yomi = 10;
  string birth_at = 11;
  Gender gender = 12;
  string tel_number = 13;
  string email = 14;
  Address address = 15;
  repeated Department departments = 16;
  ...
}

message Address {
  string id = 1;
  string pref = 2;
}

# このスキーマ定義は解説用に書いていますので、公式的な内容ではございません><

本題

さて、そんなProtocol Buffersですが、Railsに組み込みたい場合のことを今回は話そうと思います

組み込み方法

基本は、以下の流れで組み込んで行きます

  1. google-protobuf, grpc-tools のgemをGemfileに組み込む
    • 後者に関しては、Timestampなどの拡張機能もあるのであれば良さそうという感じでしょうか
  2. *.protoのような定義ファイルを記述
    • ここで型定義などスキーマを書きます
    • キーが欠落するとか、型が曖昧とかそういう問題はここで解消可能です
  3. スキーマ定義からRubyで処理可能なファイルを生成する
    • protocコマンドを使って、スキーマファイルから*_pb.rbファイルを生成します
      • シリアライズ/デシリアライズとなるクラスが生成されます
    • Ruby以外の異なる言語とやり取りしたい場合、同じスキーマを使って他言語用のファイルも合わせて生成します
  4. Railsから生成したクラスを読めるようにする
    • *_pb.rbを読み込む

Rails5系までは、何も考えずにこの流れで問題は起きないです

ファイル生成

一つずつ*.protoファイルから*.rbに変換する処理は面倒です。
従って、下記のようなものを実行できるコマンド、Railsの場合だとRakeタスクを用意しておくとよいかと思います

RUBY_OUT_DIR = "app/pb"
Dir["#{Rails.root}/proto/**/*.proto"].each do |file_path|
  proto_file = file_path.gsub(Rails.root.to_s, '.')
  system("grpc_tools_ruby_protoc --ruby_out=#{Rails.root}/#{RUBY_OUT_DIR} --proto_path=./proto #{proto_file}") 
end

MIMEの指定

一応Draftとしては、application/protobufのようです
が、観測している限りであれば、application/vnd.google.protobufapplication/x-protobufなどがあるようです

下記のような形で規定しておけば、Railsで使用する際でも便利に扱えるかと思います

# config/initializers/mime_types.rb
Mime::Type.register "application/protobuf", :protobuf

Rails6の場合

話が長くなりましたが、あえてRails6と記載したのは嵌る箇所が存在するためです

Rails6から定数やクラスの自動読み込みの仕組みが変更(zeitwerkの導入)となりました
これにより、スキーマから生成されるファイルは影響を受ける形となっています

zeitwerkを有効にしている場合、下記の制約が発生しています

  • Rails内で使うファイル名は、定義されている定数名と一致しなければならない
  • ファイル名はディレクトリ名と合わせて名前空間として扱わなければならない

この制約により、生成したファイル(_pb.rb)をそのまま扱うと、eager_load*でエラーとなります
また、development/testでは、config.eager_load = false と設定されています
従って、テストがグリーンだったり、開発環境で動作確認とし実行した場合だと気づかないことが多いです

# 余談ですが、個人的に開発協力をしているアプリケーションでは前述の問題に見事に嵌りrevertを行いました><

一番簡単な解決方法

一時凌ぎで根本解決では無いものの、Rails6のデフォルトのオートローダーを従来のオートローダーを使うようにすれば良いです。
これはオートローダーがzeitwerkでは無く従来の物になるため、問題無く動きます

# config/application.rb
config.load_defaults "6.0"
config.autoloader = :classic

いつまでも使えるものでは無いため、できれば根本解決を目指したほうが良さそうです

# 先程の余談は、revert後にこちらで一旦対処していました...

根本解決する方法

この記事を執筆している以上ですが、根本的な解決は可能ですので、protoファイル、Railsの双方で以下で実施する手順を行います

protoファイルで対応すること

  • protoファイルに定義したpackageのパスとディレクトリをきちんと合わせる
    • 生成された*_pb.rbのファイルパスが、zeitwerkではそのまま名前空間となるため
  • protoファイルでimportしたファイルパスディレクトリ構造に合わせて調整する
    • 生成された*_pb.rbのrequireが影響を受けるため
  • messageの名前をファイル名と一致させる
    • messageが、生成された*_pb.rb内で設定されるクラス名となるため
  • protoファイルに複数のmessageを記載せず、1つのファイルに1つmessageとする
    • zeitwerkによって、ファイル名は、定義されている定数名と一致させておく必要があるため

つまり前述のprotoファイルではどうなるかというと、下記のような形になります

# proto/smarthr/crews/crew.proto #<- packageとパスをあわせてファイルを用意
syntax = "proto3";

package smarthr.crews;

import "smarthr/department.proto";
import "smarthr/crews/address.proto"; #<- ファイルのimportを追加

message Crew {
  enum Gender {
    MALE = 0;
    FEMALE = 1;
  }
  string id = 1;
  string emp_code = 2;
  string last_name = 3;
  string first_name = 4;
  string last_name_yomi = 5;
  string first_name_yomi = 6;
  string business_last_name = 7;
  string business_first_name = 8;
  string business_last_name_yomi = 9;
  string business_first_name_yomi = 10;
  string birth_at = 11;
  Gender gender = 12;
  string tel_number = 13;
  string email = 14;
  Address address = 15;
  repeated Department departments = 16;
  ...
}

addressは1つの定義がファイルから分離して、別のファイルにします

# proto/smarthr/crews/address.proto
syntax = "proto3";

package smarthr.crews;

message Address {
  string id = 1;
  string pref = 2;
}

Rails側で対応すること

自前でカスタムしたInflectionを用意し、Railsに設定します
カスタムzeitwerkの実装方法については、こちらを参照します

前述のprotoファイルの手順で挙げた内容が行えていれば、下記のコードで生成したファイルをRails内で問題無くzeitwerkでオートロードすることが可能です
zeitwerkは、ファイル名をString#camelizeで活用するということから、下記の形でInflectionを実装します

  • ファイル名をチェック
    • パスは関係ないので見ない
  • _pb で終わるものがあれば、Protocol Buffersで生成したファイルと見なす
    • そうじゃない場合、親クラスの処理を実行する(ファイル名のString#camelize)
  • 一致する場合、_pbを除去し、除去したものをcamelizeしたものを返す

最後にRailsのautolodersに自前実装したzeitwerkのInflectorを適用しています

class WithProtocolBufferInflector < Zeitwerk::Inflector

  def camelize(basename, abspath)
    if basename =~ /\A.*_pb$/
      basename.gsub("_pb", '').camelize #<- `_pb` をここで除去した形でRails側でロードするように対応
    else
      super
    end
  end
end

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

# 余談ですが、この処理を行わないとここでエラーになります(普通に見てもまぁ読めそうって感じのコードですね)

おそらく、今回のケースに限らずですが、他のものでもzeitwerkが想定している規約に合わないファイル類もきっと同じところでエラーになるかと思います。
特にRails5->Rails6にアップデートしたらエラー祭りになるとかはこういうところであったりするのでは(?)と個人的に思います

JSON作れるの?(おまけ1)

そういえば、前述の内容の中で、複数言語間でやり取りできるJSONを生成できる と述べてましたがこちらも下記のようなコードで可能です
(前提として、Crewは、crew_pb.rbの内容と仮定します)

crew = Crew.new(id: "id", emp_code: "001", )
Crew.encode(crew) #<- Protocol Buffersでシリアライズしたデータ
crew.to_proto #<- Protocol Buffersでシリアライズしたデータ
crew.encode_json #<- Protocol Buffersの定義に合うJSONデータ

因みに、encode_jsonメソッドは下記の2つのオプションが存在します

  • preserve_proto_fieldnames: trueを設定するとオリジナルのフィールド名(つまりProtocol Buffersに規定した名)を使用する(デフォルトはcamelCase)
  • emit_defaults: trueを設定すると0 / false値(blankにあたるもの)を出力する(デフォルトは省略します)

TypeScriptでもProtocol Buffersを使いたい(おまけ2)

TypeScriptでもProtocol Buffersが使用できるか?というところですが、実はできます

ts-protoc-genを使えば可能です。

まずはインストール

$ npm install ts-protoc-gen

次に定義ファイルから、_pb.js*_pb.d.tsを生成します

$ export TYPE_SCRIPT_OUT_DIR=public/js
$ protoc --plugin='protoc-gen-ts=./node_modules/.bin/protoc-gen-ts' --js_out='import_style=commonjs,binary:#{TYPE_SCRIPT_OUT_DIR}' --ts_out='#{TYPE_SCRIPT_OUT_DIR}' --proto_path=./proto #{proto_file}

最後に型定義ファイルを使って、axiosなどでサーバと疎通させれば良さそうでしょう
雰囲気、こんな感じでバイナリデータで疎通する形になるかと思います
(ContentTypeは前述したDraftを使用)

const data = new Address()
data.setId("1")
data.setPref("東京都")

axios.post(url, data, {
  responseType: 'arraybuffer',
  headers: {
    'Content-Type': 'application/protobuf',
  },
})
smarthr
社会の非合理を、ハックする。
https://smarthr.co.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした