いまの職場に入社した頃は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)を保持する箱がほしいと常日頃おもっていた。
qiita.com
↑ではかなり控えめに書いたんだけど、現状だと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
github.com
RSpecの下準備をやってくれるのは結構ありがたみある。
CIの設定
bundle gem を叩いただけで、 .travis.yml
が作られるし、rakeのデフォルト動作で単体試験実行をしてくれるようになっている。なので、そのままTravis CIをONにするだけで、masterプッシュ時に自動でテストがまわるCIっぽいことは十分にできる。
ただ「CIで実行したときだけspecが落ちる」という事象が起きたときに、Travis CIだとどうにもこうにもデバッグができず、かなり格闘をすることになった。
やっぱり使い慣れてるCircle CIでSSHデバッグしたいなーーと思い、速攻で乗り換えた。
github.com
結局は require 'spec _helper'
を書き忘れていただけ、というショボショボの原因だったんだけど、それでもなぜかローカルではspecが通ってしまうので結構ハマった。
大抵の場合にはTravis CIで十分なんだろうなーとは思うけど、とはいえRubyのバージョンによってはTravis CIはRubyのダウンロードとか毎度やって無駄に時間がかかるし、Circle CIのコンフィグはコピペすれば動く系なので、Circle CIを最初から使ってしまうのが楽かなぁと個人的には思った。
eql?, equal?, ==
, ===
は何を返すべきか
これはPython信者からするとかなり意味不明とキレたくなるところだ。Rubyにはequalっぽいメソッドが多く、名前もややこしい。 same_object?
same_value?
とかそういうメソッド名にしなかったのはなんで??ってのがものすごく謎い。
という愚痴はここまでにして、
mickey24.hatenablog.com
を参考に実装した。あとは、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っぽい感じで進めると捗った。
分割代入はどうやったら実現できるのか
これは必要のない機能なんだけど、どうやって実現されてるのか知りたかったので実装した。
github.com
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
のように拡張することができる。これはどうしても欲しい。
で、何を調べていたか忘れたけど、このあたりで「あれ、既に似たようなライブラリあるじゃん??」ってのを発見して、
github.com
この実装を結構参考にさせてもらった。クラスの拡張は class_eval
を使ってるんだなー、と。
github.com
少し悩んだ末に型安全を捨てた
実はこの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
みたいな謎の定義をしないといけない。それはあまり狙ったところではない。むしろ邪魔くさい。
さらに、
qiita.com
このあたりの議論を見るに、Rubyはダックタイピングなので、型で制限するのは筋がよくないということだ。
それはそのとおりだと思った。
github.com
Gemの公開
何を間違ったか、最初の方でRakefileを消してしまっていたので rake build
ができなかった。
とはいえ、specをrake経由で実行したいとは思わないので、
require "bundler/gem_tasks"
の1行だけを書いたRakefileを定義して、無事にrake buildでパッケージが作れた。
github.com
あとは、 rake release
でリリースしようとしたら、Gitに何かがpushされるっぽく邪魔くさかったので、
rake build
gem push pkg/kt_data_class-0.1.0.gem
のように、明示的にプッシュ対象を指定して、シンプルにリリースした。
このあたりは手作業でやるのがだるいので、そのうちCIのgit tagトリガーのbuildに任せたいと思う。
まとめ
Gemを作ると、結構勉強になることが多かった。