いまの職場に入社した頃はPython大好きRuby大嫌いだったのだけど、3年半もRubyばっかり触っていたら流石に少し克服しつつあるので、初めてGemを作ってみることにした。
ネタはKotlinで便利なdata class
data class Point(val x: Double = 0.0, val y: Double = 0.0) { val norm get() = Math.sqrt(x * x + y * y) }
つくってみると、「Rubyのこの仕組みって、こうやって実装されてるのか!」ってところがいくつか有ったので、書いておこうかなと思う。
そもそもなんでデータクラスを作ろうと思ったのか?
まずは前提の共有から。
いま検索画面を仕事でつくっていて、「検索条件」のように6つくらいの値(キーワード、ソート順、カテゴリ、etc)を保持する箱がほしいと常日頃おもっていた。
↑ではかなり控えめに書いたんだけど、現状だと6つくらいの値がすべてコントローラのインスタンス変数で保持されていて、無秩序にそれらをパーシャルビューやヘルパーメソッドから読んでいるような実装になっている。イメージこんなかんじ↓
<% if @keyword.present? %> <h1><%= @keyword %>の検索結果</h1> <% else %> <% if professional_search? %> <h1>プロに仕事を依頼する</h1> <% elsif ... %> ... <% end %> <% end %>
def professional_search? @keyword.blank? && @only_professional_flag.present? end
これを、
SearchQueryViewModel = ViewModel.create(:keyword, :sort_order, category_ids, ...) @search_query_view_model = SearchQueryViewModel.new(keyword: params[:q], sort_order=params[:order], ...)
のように、コントローラの変数は1つにして整理したり、
search_result = User.search(...) UserPartialViewModel = ViewModel.create(:display_name, :avarage_feedback_score, :num_contracts) @users = User.where(id: search_result.user_ids).includes(:feedback_summary, :profile).map{|user| UserPartialViewModel.new( display_name: user.profile.display_name, avarage_feedback_score: user.feedback_summary.avarage_score, num_contracts: user.feedback_summary.num_contracts) }
<% @users.each do |user| %> <%= render 'shared_partials/user', locals: { user: user } %> <% end %>
のように、パーシャルビューに必要な変数たちをひとまとめにしたり、あとは、ヘルパーに生やしてる変数をビューモデル側で定義したり、
まぁそれなりにひとかたまりに値をまとめる箱がほしいと思うケースがあった。
とくにライブラリとか使わなくてもシンプルに
class HogeViewModel def initialize(keyword:, hoge_flag:) @keyword = keyword @hoge_flag = hoge_flag end attr_reader :keyword, :hoge_flag end
のようなボイラープレートコードを書きまくればいい話なんだけど、できればこれを先に書いたような HogeViewModel = ViewModel.create(:keyword, :hoge_flag)
みたいにサクッと済ませられる仕組みが欲しかった。
「Ruby標準のStructでいいんじゃないか?」とも思ったのだけど、こいつは多機能すぎた。そもそもImmutableではないし、#members
のように属性一覧をイテレートできるような仕組みまであるのはビューモデルでは全く必要ない。
ということで、自分が欲しいものをそのまま作ってみることにした。
bundle gem
Nodeでいうところの npm init みたいな感じの物があるだろうと思ったら、やっぱりあった。
bundle gem kt_data_class --test=rspec
RSpecの下準備をやってくれるのは結構ありがたみある。
CIの設定
bundle gem を叩いただけで、 .travis.yml
が作られるし、rakeのデフォルト動作で単体試験実行をしてくれるようになっている。なので、そのままTravis CIをONにするだけで、masterプッシュ時に自動でテストがまわるCIっぽいことは十分にできる。
ただ「CIで実行したときだけspecが落ちる」という事象が起きたときに、Travis CIだとどうにもこうにもデバッグができず、かなり格闘をすることになった。 やっぱり使い慣れてるCircle CIでSSHデバッグしたいなーーと思い、速攻で乗り換えた。
結局は require 'spec _helper'
を書き忘れていただけ、というショボショボの原因だったんだけど、それでもなぜかローカルではspecが通ってしまうので結構ハマった。
大抵の場合にはTravis CIで十分なんだろうなーとは思うけど、とはいえRubyのバージョンによってはTravis CIはRubyのダウンロードとか毎度やって無駄に時間がかかるし、Circle CIのコンフィグはコピペすれば動く系なので、Circle CIを最初から使ってしまうのが楽かなぁと個人的には思った。
eql?, equal?, ==
, ===
は何を返すべきか
これはPython信者からするとかなり意味不明とキレたくなるところだ。Rubyにはequalっぽいメソッドが多く、名前もややこしい。 same_object?
same_value?
とかそういうメソッド名にしなかったのはなんで??ってのがものすごく謎い。
という愚痴はここまでにして、
を参考に実装した。あとは、HashとかStructがそれぞれどういう eql? equal? ==
になっているかも参考にした。
このあたりは、毎度コンソールで動作確認するのがしんどいので、
describe 'equality' do let(:klass1) { KtDataClass.create(:x) } let(:klass2) { KtDataClass.create(:x) } describe '同一のクラスの2つの同値インスタンスの比較' do let(:instance1) { klass1.new(x: 1) } let(:instance2) { klass1.new(x: 1) } it { expect(instance1.equal?(instance2)).to eq(false) } it { expect(instance1 == instance2).to eq(true) } it { expect(instance1 <=> instance2).to eq(0) } it { expect(instance1.eql?(instance2)).to eq(true) } it { expect(instance1 === instance2).to eq(true) } end describe '同一のクラスの2つの異なる値のインスタンスの比較' do let(:instance1) { klass1.new(x: 1) } let(:instance2) { klass1.new(x: 2) } it { expect(instance1.equal?(instance2)).to eq(false) } it { expect(instance1 == instance2).to eq(false) } it { expect(instance1 <=> instance2).not_to eq(0) } it { expect(instance1.eql?(instance2)).to eq(false) } it { expect(instance1 === instance2).to eq(false) } end describe '定義が同じで、異なるクラスの2つのインスタンスの比較' do let(:instance1) { klass1.new(x: 1) } let(:instance2) { klass2.new(x: 1) } it { expect(instance1.equal?(instance2)).to eq(false) } it { expect(instance1 == instance2).to eq(true) } it { expect(instance1 <=> instance2).to eq(0) } it { expect(instance1.eql?(instance2)).to eq(false) } it { expect(instance1 === instance2).to eq(true) } end
のようにspecをササッと書いて、こいつが通るまで実装を繰り返すっていうTDDっぽい感じで進めると捗った。
分割代入はどうやったら実現できるのか
これは必要のない機能なんだけど、どうやって実現されてるのか知りたかったので実装した。
Structだと
Point = Struct.new(:x, :y) p1 = Point.new(3, 4) x1, y1 = p1 x1 # => 3 y1 # => 4
のような挙動になるやつは、何を実装すればできるようになるのか?
to_a あたりを生やせばいいのかな?と思って調べ始めたところ
Rubyの多重代入におけるto_aとto_aryの挙動 - maeharinの日記
この記事に行き着いた。 to_ary
っていうメソッドを生やせばいいんだとか。(これまたわかりにくい名前だw)
初期化ブロックはどうやって実装するのか?
Structだと
Point = Struct.new(:x, :y) do def norm Math.sqrt(x * x + y * y) end end
のように拡張することができる。これはどうしても欲しい。
で、何を調べていたか忘れたけど、このあたりで「あれ、既に似たようなライブラリあるじゃん??」ってのを発見して、
この実装を結構参考にさせてもらった。クラスの拡張は class_eval
を使ってるんだなー、と。
少し悩んだ末に型安全を捨てた
実はこのGemを作り始めたときは
Point = KtDataClass.create(x: Fixnum, y: Fixnum)
のように、型を明示的に指定して、
p1 = Point.new(x: 3, y: 4) # => #<Point:0x000000000233c5f0 @x=3, @y=4> p2 = Point.new(x: 3, y: "4") # ArgumentError: type mismatch: y must be a Fixnum, String given
のように、型に合わないものは入れられないようにするつもりだった。
特に理由はないが、なんとなく「入れられるものを制限しておいたほうが、無用な条件分岐とか is_a?
とかしなくて済むかなー」くらいの軽いノリで。
ところが、実際に使うとなると、これだと結構不便だということがわかった。
一番「あれれ」と思ったのが、true/falseを入れるときの定義。 TrueClass or FalseClass
みたいな謎の定義をしないといけない。それはあまり狙ったところではない。むしろ邪魔くさい。
さらに、
このあたりの議論を見るに、Rubyはダックタイピングなので、型で制限するのは筋がよくないということだ。
それはそのとおりだと思った。
Gemの公開
何を間違ったか、最初の方でRakefileを消してしまっていたので rake build
ができなかった。
とはいえ、specをrake経由で実行したいとは思わないので、
require "bundler/gem_tasks"
の1行だけを書いたRakefileを定義して、無事にrake buildでパッケージが作れた。
あとは、 rake release
でリリースしようとしたら、Gitに何かがpushされるっぽく邪魔くさかったので、
rake build gem push pkg/kt_data_class-0.1.0.gem
のように、明示的にプッシュ対象を指定して、シンプルにリリースした。
このあたりは手作業でやるのがだるいので、そのうちCIのgit tagトリガーのbuildに任せたいと思う。
まとめ
Gemを作ると、結構勉強になることが多かった。