シンボルでも文字列でもアクセス可能なHashを使おう!ActiveSupport {Hash編}
皆さんこんにちは!遅れてきた最年長ルーキー MUGENUP の osada です。
さて皆さんは初めてオープンクラスという概念を知った時、驚かれませんでしたか?私はとても驚きました!だって、基本クラスさえもオーバライド可能なんですよ?
[コラム]オープンクラスとは?
同名でクラス定義を行うと、クラスの再定義(上書き)ではなく、クラスへの追加拡張になる仕組み。
class A def a "a" end end a = A.new raise unless a.a == "a" raise if a.respond_to?(:b) # b というメソッドはない # 同名のクラス定義は拡張になる class A def b "b" end end raise unless a.b == "b" raise unless a.respond_to?(:b) # b というメソッドがある # a というメソッドも維持される raise unless a.respond_to?(:a) raise unless a.a == "a"
このオープンクラス、String
など基本クラスさえも拡張可能なのが凄いです。Javascript の prototype みたいですよね。
ActiveSupport コア拡張
オープンクラスという特徴的な仕組みのおかげで、Rails では基本クラスなのに便利なメソッドが沢山ある、という状態になっています。それがActiveSupportのコア拡張です。
ということで、今日のお題はActiveSupport
のHash
です。ターゲットはRails初心者の方。一緒に勉強できれば嬉しいです。
Hash 編
hash拡張のソースは、ここにありました。9ファイル、19メソッドあります。
ruby/2.0.0/gems/activesupport-3.2.16/lib/active_support/core_ext/hash
conversion.rb
まずは Hash 変換するconversion.rb
です。といっても対象はxml。そういえばそんなのもありましたね!
{"foo" => 1, "bar" => 2}.to_xml
できそうな気がしてましたよね?
Hash.from_xml(<<"EOS" <?xml version="1.0" encoding="UTF-8"?> <hash> <foo type="integer">1</foo> <bar type="integer">2</bar> </hash> EOS)
できそうな気がしてましたよね?
deep_dup.rb
ruby の dup
, clone
はシャローコピーだというのはよく知られています。
シャローコピー?という方はRubyist Magazine - 値渡しと参照渡しの違いを理解するがオススメ
deep_dup は深いコピーをしてくれます。当然再帰ですよね〜。
def deep_dup duplicate = self.dup duplicate.each_pair do |k,v| tv = duplicate[k] duplicate[k] = tv.is_a?(Hash) && v.is_a?(Hash) ? tv.deep_dup : v end duplicate end
ところで、duplicate[k]
とv
の値が異なることがあるのでしょうか?
deep_merge.rb
深いコピーがあるなら、深いマージもあるでしょう。そうでしょう。
こちらで興味深いのは、破壊メソッドを作っておいて、それを dup
と組み合わせれば通常のメソッドになるということです。センスありますね。
def deep_merge(other_hash) dup.deep_merge!(other_hash) end # Same as +deep_merge+, but modifies +self+. def deep_merge!(other_hash) other_hash.each_pair do |k,v| tv = self[k] self[k] = tv.is_a?(Hash) && v.is_a?(Hash) ? tv.deep_merge(v) : v end self end
diff.rb
Hash の差分って、どうされてますか? Array なら マイナス[1,2] - [2,3] # => [1]
が使えますが、Hash にはありません。ここでdiff
の出番です
しかしこのdiff
、 差分ではなく、差異なんです。
[6] pry(main)> { 1 => 2 }.diff({1 => 2, 2 => 2, 3 => 2}) => {2=>2, 3=>2}
2つのハッシュの相違点を取り出すメソッドなんですね。実装はとてもシンプルで、ハッシュ1とハッシュ2に同じkey
を見つけてvalue
も同じときは消す。ハッシュ2からハッシュ1にあるkeyを消す。そしてマージです。
def diff(h2) dup.delete_if { |k, v| h2[k] == v }.merge!(h2.dup.delete_if { |k, v| has_key?(k) }) end
なんと悲しいことに、Rails4 では Deprecated になっていました!
def diff(other) ActiveSupport::Deprecation.warn "Hash#diff is no longer used inside of Rails, and is being deprecated with no replacement. If you're using it to compare hashes for the purpose of testing, please use MiniTest's assert_equal instead." dup. delete_if { |k, v| other[k] == v }. merge!(other.dup.delete_if { |k, v| has_key?(k) }) end
Hash#deep_diff
, the recursive difference between two hashes by nikitug · Pull Request #8142 · rails/rails
deep_diff
を使えばいいじゃん、ということですが、まだ時期ではない、ということで、deprecated
を付けるだけになっているようです。しばらくは、diff
ということでしょうか。
with_indifferent_access.rb
params がシンボルでも文字列でもアクセスできて、不思議だなぁ?と思われたことありませんか? 普通は nil が返ってきます。
[8] pry(main)> h = {a: 1, "b" => 2} => {:a=>1, "b"=>2} [9] pry(main)> h[:a] => 1 [10] pry(main)> h["a"] => nil [11] pry(main)> h[:b] => nil [12] pry(main)> h["b"] => 2
そんな時に使うのか、with_indifferent_accessです!
[13] pry(main)> h = {a: 1, "b" => 2}.with_indifferent_access => {"a"=>1, "b"=>2} [14] pry(main)> h[:a] => 1 [15] pry(main)> h["a"] => 1 [16] pry(main)> h[:b] => 2 [17] pry(main)> h["b"] => 2
このメソッドは、HashWithIndifferentAccess
へのプロキシメソッドです。
def with_indifferent_access ActiveSupport::HashWithIndifferentAccess.new_from_hash_copying_default(self) end
それが何かと言いますと、
module ActiveSupport class HashWithIndifferentAccess < Hash
Hash のサブクラスでした。そして、new_from_hash_copying_default(self)
は
def self.new_from_hash_copying_default(hash) new(hash).tap do |new_hash| new_hash.default = hash.default end end
となっていますので、やっていることは、元 hash の default を自身に適用しているだけです。まさに継承している感じですね。
ではコンストラクタに秘密があるのでしょうか?
def initialize(constructor = {}) if constructor.is_a?(Hash) super() update(constructor) else super(constructor) end end
この場合、super
はHash
クラスの呼び出しですから、ミソはupdate
にあるようです。あれ?update
ってマージするメソッドだったような?
def update(other_hash) if other_hash.is_a? HashWithIndifferentAccess super(other_hash) else other_hash.each_pair { |key, value| regular_writer(convert_key(key), convert_value(value)) } self end end
ハッシュが既にHashWithIndifferentAccess
だった場合は、そのままインスタンス化するだけです。するとelse
が心臓部のようです。
other_hash.each_pair { |key, value| regular_writer(convert_key(key), convert_value(value)) }
文字列でもシンボルでも
key 側の処理はこんな感じ。
def convert_key(key) key.kind_of?(Symbol) ? key.to_s : key end
単に Symbol だったら文字列に変換しているんですね。ずるい!なんてシンプルな実装なんでしょうか。
このconvert_key
を各所で使うため、key は全てシンボルではなく文字列になります。
でしたよね?
[13] pry(main)> h = {a: 1, "b" => 2}.with_indifferent_access => {"a"=>1, "b"=>2}
convert_value
value側の処理です。
def convert_value(value) if value.is_a? Hash value.nested_under_indifferent_access elsif value.is_a?(Array) value.dup.replace(value.map { |e| convert_value(e) }) else value end end
1 . value が Hash だったときは、indifferent_access に変換して返します。 というか単にエリアスが貼ってあるだけでした。
alias nested_under_indifferent_access with_indifferent_access
普通に
value.with_indifferent_access
と書いても良い所をあえてvalue.nested_under_indifferent_access
と書く所、こだわっていますね。
2 . value がArray のとき、
elsif value.is_a?(Array) value.dup.replace(value.map { |e| convert_value(e) })
再帰して返すというお決まりのコードです。ところでなぜ
value.map { |e| convert_value(e) }
ではいけないのでしょうか?
これはおそらく、is_a?
は親クラスであっても true になるからだと思います。
value が Array のサブクラスだった場合、`value.map { |e| convert_value(e) }
では、サブクラスではなく、Arrayが返ってしまいますからね。
ということで、心臓部はこの2つのメソッドで、後は全て、conver_*
を経由するように書き換えているだけでした。例えば、key?
は convert_key
を経由するように書き換えているだけです。
def key?(key) super(convert_key(key)) end
シンボルでも文字列でもアクセスできる、というのは key
と value
に対して、根本のところで文字列に変換してしまう、という方法で実装されていました。
これさえ理解しおけば、シンボルでも文字列でも扱えるHashが使いこなせますね!
except.rb
Hash の中から、特定の key だけを消したいこと、結構ありますよね?これは有名なので皆さんご存知かと思います。
[12] pry(main)> {a: 1, b: 2}.except(:a, :c) => {:b=>2}
def except(*keys) dup.except!(*keys) end # Replaces the hash without the given keys. def except!(*keys) keys.each { |key| delete(key) } self end
keys.rb
Hash の key に対するメソッド群です。
- stringfy_keys
def stringify_keys dup.stringify_keys! end # Destructively convert all keys to strings. Same as # +stringify_keys+, but modifies +self+. def stringify_keys! keys.each do |key| self[key.to_s] = delete(key) end self end
delete で取り出しながら、key を変換して格納し直すという、 エレガントな入れ替えですね。
- 逆にシンボル化もできるようです。
def symbolize_keys dup.symbolize_keys! end # Destructively convert all keys to symbols, as long as they respond # to +to_sym+. Same as +symbolize_keys+, but modifies +self+. def symbolize_keys! keys.each do |key| self[(key.to_sym rescue key) || key] = delete(key) end self end
(key.to_sym rescue key)
とか、シンプルでかっこいいですね~。
そういえば、to_options とかご存知でした?
alias_method :to_options, :symbolize_keys alias_method :to_options!, :symbolize_keys!
- assert_valid_keys で key の確認を便利にできます。
def assert_valid_keys(*valid_keys) valid_keys.flatten! each_key do |k| raise(ArgumentError, "Unknown key: #{k}") unless valid_keys.include?(k) end end
reverse_merge.rb
渡した方の hash にマージするメソッドです。
self.reverse_merge(other_hash)
ではなく
other_hash.merge(self)
でいいんじゃないの?
ところが、面白い箇所が1点!
merge!( other_hash ){|key,left,right| left }
って何ですか?
def reverse_merge(other_hash) other_hash.merge(self) end # Destructive +reverse_merge+. def reverse_merge!(other_hash) # right wins if there is no left merge!( other_hash ){|key,left,right| left } end alias_method :reverse_update, :reverse_merge!
同じkeyがあったとき、左側(self)を優先にできるようです。
[24] pry(main)> {a: 1, b: 2}.reverse_merge!({a: 1, c: 3}) => {:a=>1, :b=>2, :c=>3} [25] pry(main)> {a: 1, b: 2}.reverse_merge({a: 1, c: 3}) => {:a=>1, :c=>3, :b=>2} [26] pry(main)> {a: 1, b: 2}.merge({a: 1, c: 3}) => {:a=>1, :b=>2, :c=>3} [27] pry(main)> {a: 1, b: 2}.merge!({a: 1, c: 3}) => {:a=>1, :b=>2, :c=>3}
破壊型のときは、自分に取り込むよ! ということで自分が優先なんでしょうか? merge に block が渡せるのは、初めて知りました。勉強になります
slice.rb
特定のkey だけを取り出したいこと、よくありますよね?
[15] pry(main)> a = {a: 1, b:2} => {:a=>1, :b=>2} [16] pry(main)> a.slice(:a, :c) => {:a=>1} [17] pry(main)> a => {:a=>1, :b=>2}
def slice(*keys) keys = keys.map! { |key| convert_key(key) } if respond_to?(:convert_key, true) hash = self.class.new keys.each { |k| hash[k] = self[k] if has_key?(k) } hash end
特定のkeysだけ取り出して、それ以外は返り値で返すとか、delete っぽくでオシャレ!Hashの分割で使えますね。
[18] pry(main)> a.slice!(:a, :c) => {:b=>2} [19] pry(main)> a => {:a=>1}
def slice!(*keys) keys = keys.map! { |key| convert_key(key) } if respond_to?(:convert_key, true) omit = slice(*self.keys - keys) hash = slice(*keys) replace(hash) omit end
逆に特定のkeys だけを返してくれるメソッドもありました。
[20] pry(main)> a = {a: 1, b:2} => {:a=>1, :b=>2} [21] pry(main)> a.extract!(:a, :c) => {:a=>1} [22] pry(main)> a => {:b=>2}
def extract!(*keys) result = {} keys.each {|key| result[key] = delete(key) } result end
まとめ
ということで、何か新しい発見はあったでしょうか?
9ファイル19メソッドのHash拡張でした。
with_indifferent_access, deep_dup, except, slice, slice!
辺りは、どんどん使っていきたいと思います。
便利なメソッドがあったら、是非教えて下さい!
宣伝
MUGENUP では、rails を使いたいエンジニアを募集中です。 無限流開発、ご一緒しませんか?
大きな裁量で自社サービス開発!Rubyエンジニアをウォンテッド! - 株式会社MUGENUPの求人 - Wantedly
最速で!最短で!真っ直ぐに!Facebookグループの書き込みを監視する方法!
皆さん、こんにちは!WAというと Web Application ではなく、RPGの方を思い出す MUGENUP の osada です。
いきなりの個人的な話で恐縮ですが、私が初めて書いたWeb Application はPHP
でした。当時 Web というと、「SSI で include」したり、「perl で cgi」とか、少し大掛かりだと「Java Applet」?、程度しか知らなかった私には、HTMLの中にプログラムが書けるというのは衝撃的なことでした。PHPの真髄は、すぐに動くことにあるのではないかと思っています。そしてさらに個人的な意見で恐縮ですが、ソースコードで最も価値があるのは動くコードだと思っています。少し学術的に言えば、合目的性ですね。そう言う意味で、PHPは私の好きな言語の一つです。PHP、良い言語ですよね!
はい、ということで今回は、コードの美しさとか全くなし。動けばOK、ということで、Facebook Group への書き込みをウォッチして、新着があったらメールで知らせる ツールを作ってみたいと思います。
え、理由ですか?単に必要だったからです(笑)。
Facebook Developer に登録して、アプリを作成
facebook developer へ登録
app へ
新しいアプリを作成 ボタン
新しいアプリを作成
-
承認
こんな感じに
Graph APIエクスプローラーを使用する
アクセストークンを取得
user groups にチェック
friends groups にチェックして、[Get Access Token]
アクセストークンをコピー
Facebook にアクセスする
koala
と メール送信用にmail
をインストール
# Gemfile source 'https://rubygems.org' gem "koala", "~> 1.8.0rc1" gem "mail" group :test, :development do gem "pry" gem 'pry-byebug' end
$ bundle
14.書く
watch_facebook.rb
# -*- coding: utf-8 -*- require "koala" require "mail" ACCESS_TOKEN = ARGV[0] GROUP_ID = ARGV[1] USER_NAME = ARGV[2] PASSWORD = ARGV[3] EMAILS = ARGV[4..-1] SMTP_SETTINGS = { address: 'smtp.gmail.com', port: 587, domain: 'smtp.gmail.com', user_name: USER_NAME, password: PASSWORD } FROM = "hoge-report@mugenup.com" SUBJECT = 'Hoge Report' begin load "config.rb" rescue LoadError end puts "access facebook ..." graph = Koala::Facebook::API.new(ACCESS_TOKEN) feeds = graph.get_connections(GROUP_ID, "feed") puts "reject already feeds ..." already_feeds_file = open("already_feeds.txt", "a+") already_read_ids = already_feeds_file.readlines.map(&:chomp) new_feeds = feeds.reject{|f| already_read_ids.include?(f["id"]) } exit if new_feeds.size == 0 puts "create messages ..." messages = new_feeds.map do |f| <<-EOS #{f["from"]["name"]} #{f["message"][0..100]}... #{'-' * 50} EOS end messages << "https://www.facebook.com/groups/#{GROUP_ID}/" puts "send mail new feeds ..." mail = Mail.new do from FROM to EMAILS.join(",") subject SUBJECT body messages.join("\n") end mail.charset = 'utf-8' mail.delivery_method :smtp, SMTP_SETTINGS mail.deliver! puts "store feed_ids (#{new_feeds.size}) ..." already_feeds_file.puts(new_feeds.map{|f| f["id"]}) already_feeds_file.close
15.使う
$ bundle exec ruby watch_facebook.rb ACCESS_TOKEN GROUP_ID USER_NAME PASSWORD EMAILS
← 適宜読み替えて入力してください。
または、config.rb
というのを作って、
こんな感じに書いてから、
$ bundle exec ruby watch_facebook.rb
16.ACCESS_TOKENの長期化
このままだと、ACCESS_TOKEN の有効期間は1時間です。これを長期化します。
omniauth を入れる(心の)余裕は無いんです!
ということで、下記を参考に、(7)で確認した、app-idとapp-secret、および、アクセストークンを入力して、送信します。
https://graph.facebook.com/oauth/access_token? grant_type=fb_exchange_token& client_id={app-id}&client_secret={app-secret}&fb_exchange_token={short-lived-token}
Unable to get a long term access token using facebook graph api - Stack Overflow
17.長期化されたアクセストークンを使う
こんな感じで、新しく長期化されたアクセストークンが取得できます。
expires
が 5184000
になっていることが確認できます。約2ヶ月、使い続けられます。
18.後は cron で登録するなりなんなり!
あ、cron
でbundle
付きを呼ぶのはちょっと面倒ですが、頑張ってください!
ということで、Facebookのグループを監視するアプリが作れました!
ちょっと解説
設定を引数から受け取る部分
ACCESS_TOKEN = ARGV[0] GROUP_ID = ARGV[1] USER_NAME = ARGV[2] PASSWORD = ARGV[3] EMAILS = ARGV[4..-1] SMTP_SETTINGS = { address: 'smtp.gmail.com', port: 587, domain: 'smtp.gmail.com', user_name: USER_NAME, password: PASSWORD } FROM = "hoge-report@mugenup.com" SUBJECT = 'Hoge Report'
初めに、一番速いのはなんだろう?と思って、ACCESS_TOKEN
を直書きしたのですが、これをコミットしてはいけないとゴーストが囁いたので、引数で渡すことにしました。
設定をファイルから受け取る部分
begin load "config.rb" rescue LoadError end
そうこうしているうちに、毎回引数で渡すのが面倒くさい、ということになり、ファイルに格納することにしました。このconfig.rb
は .gitignore
で対象外にしました。
Facebook のグループからメッセージを獲得する部分
puts "access facebook ..." graph = Koala::Facebook::API.new(ACCESS_TOKEN) feeds = graph.get_connections(GROUP_ID, "feed")
特に解説することはありません
既に確認済みのメッセージを削除する部分
puts "reject already feeds ..." already_feeds_file = open("already_feeds.txt", "a+") already_read_ids = already_feeds_file.readlines.map(&:chomp) new_feeds = feeds.reject{|f| already_read_ids.include?(f["id"]) } exit if new_feeds.size == 0
……特に解説することはありません
メール送信用のメッセージを作る部分
puts "create messages ..." messages = new_feeds.map do |f| <<-EOS #{f["from"]["name"]} #{f["message"][0..100]}... #{'-' * 50} EOS end messages << "https://www.facebook.com/groups/#{GROUP_ID}/"
......……特に解説することはありません(まさか解説する箇所は出てこないのでしょうか?)
メールを送信する部分
puts "send mail new feeds ..." mail = Mail.new do from FROM to EMAILS.join(",") subject SUBJECT body messages.join("\n") end mail.charset = 'utf-8' mail.delivery_method :smtp, SMTP_SETTINGS mail.deliver!
当初mail
コマンドを使用していたのですが、
connect to aspmx3.googlemail.com[74.125.137.26]:25: Connection refused
ということで、mac
のsmtp
からダイレクトに送れなかったため、mail
gemを導入しました。
これがなければ、もっとシンプルでもっと速く作れたのに、と思うとちょっと残念です。
さて、Mail ですが、送信相手が複数居る時は、カンマで区切りましょう。
charaset
を utf-8
にしないと、Non US-ASCII detected and no charset defined.
という警告がでます。
(説明することが合って良かったぁ〜)
送信したメッセージのIDを記録しておく部分
puts "store feed_ids (#{new_feeds.size}) ..." already_feeds_file.puts(new_feeds.map{|f| f["id"]}) already_feeds_file.close
特に説明することはありません!
まとめ
以上が、Facebookの特定のグループの新着をメールで送信するツールです。
全くシンプルに、ただ動くことだけを考えて書いたコードですが、いかがだったでしょうか?
ついつい、「DBを使おう」とか「ActiveSuppoだけでも使おう」とか、「関数に分けよう」とか、「クラスを作ろう」とか色々考えてしまうのですが、問題を解決することだけに注目するというのも、また楽しいプログラミングですよね!
そうそうPHPが好きだと言いましたが、単に作成者のRasmus Lerdorf
氏が好きなだけかもしれません。彼の発言は一読の価値があると思いますので、宜しければ一度、Rasmus Lerdorf - Google 検索してみてください!
宣伝
MUGENUP では、rails を使いたいエンジニアを募集中です。 無限流開発、ご一緒しませんか?
大きな裁量で自社サービス開発!Rubyエンジニアをウォンテッド! - 株式会社MUGENUPの求人 - Wantedly
あ、WAでビンタしあう方を思い出す方は友達になりましょう。
Startup Live! に登壇してきました
こんにちは、MUGENUP CTOの伊藤です。
12月1日の日曜日にリクルートキャリアアカデミーホールで開催された「Startup Live!」というイベントに、パネルディスカッションのパネラーとして登壇してきました。
Startup Live!ってなに?
開催概要についてはatndなどに書いてあるので引用します。
【現在参加者100名突破!更に増席50席分はこちら!】12/1(日)開催、話題の急成長スタートアップ8社の経営者が登壇!Start up Live! : ATND
■開催背景 2010年以降ソーシャルメディア、スマートフォンなどの各領域の拡大や日本のベンチャーキャピタルの投資規模拡大により急速に立ち上がるベンチャー企業が増加して参りました。以前に比べ、大企業から起業する人材層や、大企業から数名から数十名のベンチャーの経営幹部となるケースも増加しており、それらの背景から人材大手のリクルートキャリアとシードアーリーステージ企業の支援に特化したベンチャーキャピタルのSkyland Venturesが組んでスタートアップに特化し、成長ベンチャーの社長、CTOと直接会えるリクルーティングセミナーStartup Live!を開催します。当日は成長著しいベンチャー企業の社長、CTOが8社登壇します。
というわけで、「成長ベンチャーのCTO」としてMUGENUPから乗り込んできました。
スタートアップの勢い
クラウドワークス代表吉田さんの講演から始まり、パネルディスカッション<ビジネス>、パネルディスカッション<エンジニア>と各社ともイキイキとした内容となっており、スタートアップならではの面が良く出たイベントでした。
また、イベント翌日の12月2日(月)には登壇企業の内2社が調達のリリースを出しており、その点も改めてスタートアップの勢いを感じる出来事でした。
パネルディスカッションでは主に
- 開発体制はどうなっているか
- スタートアップのやりがい
- 今後エンジニアはどうすればいいのか
といった内容について話してきました。
開発体制はやはり気になるところで結構イベントの時などでも話題になりますよね、 せっかくなので弊社の開発体制についてここで少しご紹介します。
MUGENUPシステムチーム開発体制
MUGENUPではクラウドのクリエイターさんと社内の人間をつなぐ「MUGENUP WORK STATION」という システムで全ての制作物を管理しており、システムチームはその開発、運用を行っています。
チームで利用しているサービス、ツールは主に以下のものになります。
- PivotalTracker ストーリーの管理
- GitHub コードの管理、プルリクのレビューなど
- AWS サーバーサイドは全てAWSです
- ChatWork チーム内や社内の人との情報共有
- Skype チーム内の情報共有
- Qiita:Team チーム内のノウハウの共有など
- Toggl 見積もりの実測値計測用に
基本的に週に一回のタイミングでリリースを行うので、現在は週一で振り返りを行っています。
(イーゼルパッドと付箋を用いてぺたぺたやっています)
話は戻って
さて、そんなこんなでベンチャー色が出まくったイベントだったんですが、当日はLINE株式会社の森川社長もいらっしゃっていてブログに書かれています。
ベンチャー企業のリクルートイベントに行ってきました : LINE株式会社 森川社長ブログ
私自身今後はイベント等にどんどん出て行きたいと考えているので、MUGENUPのこと、MUGENUPシステムチームのことで話したいことがある人はぜひお声をかけてもらえればと思います。
宣伝
MUGENUP では、アジャイルな開発に興味津々なエンジニアを募集中です。 無限流開発、ご一緒しませんか?
大きな裁量で自社サービス開発!Rubyエンジニアをウォンテッド! - 株式会社MUGENUPの求人 - Wantedly
should を捨て expect を使うようになった、たった一つの理由
皆さんこんにちは。スーパーのビニール袋は濡れタオルを触ってから開ける方、MUGENUP の osada です。
今回はexpect
について調査したことを書きたいと思います。
ターゲットは、rspec
が上手く書けなくて、気が付くと3時間もテストを書いている、というようなrspec
初心者です(実話)。
中間管理職 expect さん
では、本題のexpect
です。まずは問1から。5秒で答えてください。
describe Person do it "should be a instance" do expect{Person.new}.to be_a(Person) end it "should be a instance" do expect(Person.new).to be_a(Person) end end
問1
- 両方テストが通る
- 上のテストが通らない
- 下のテストが通らない
- 両方通らない
できました?正解は
解答: 2. 上のテストが通らない
1) Person should be a instance Failure/Error: expect{Person.new}.to be_a Person expected #<Proc:0x007fbcf91a75a8@/Users/osada/projects/ruby_test/rspec_test/spec/person_spec.rb:9> to be a kind of Person # ./spec/person_spec.rb:9:in `block (2 levels) in <top (required)>'
では、問2
上のテストが通らない理由を説明してください
違いは、{}
と()
です。しかし前回のchange マッチャで使っていたのは{}
のブロックでした。今回はなぜダメなんでしょうか?
調べていきましょう。
ソースと共にあらんことを
ソースコードを読んで分かるように書くべきである、という考え方にあるように、Rubyist は常にソースと共にあるようです。 では、expect の深淵に足を踏み入れたいと思います。
私の環境では、ruby/2.0.0/gems/rspec-expectations-2.14.2/lib/rspec/expectations/syntax.rb
が入り口のようです。
expect と to
def expect(*target, &target_block) target << target_block if block_given? raise ArgumentError.new("You must pass an argument or a block to #expect but not both.") unless target.size == 1 ::RSpec::Expectations::ExpectationTarget.new(target.first) end
block
のときは、target
に追加してから、target.first
を委譲しています。ということは、引数とブロックを同時に渡せるのか?、とおもいきや、target.size==1
以外受け付けないので、ちょっと不思議な構造です。
::RSpec::Expectations::ExpectationTarget
は同じ階層のexpectation_target.rb
にあります。
# @example # expect(something) # => ExpectationTarget wrapping something class ExpectationTarget # @api private def initialize(target) @target = target end
とあるように、expect
はただのラッパーのようです。
そして target
はそのまま @target
に代入されています。ブロックだからといって、特別扱いはしていませんね。
では、続いて to
を見てみます。
def to(matcher=nil, message=nil, &block) prevent_operator_matchers(:to, matcher) RSpec::Expectations::PositiveExpectationHandler.handle_matcher(@target, matcher, message, &block) end
引数を見て驚きました。to
にはmessage
とblock
が渡せるのですね。そしてその先は、PositiveExpectationHandler
に委譲しているようです。
なんと expect
がしているのはこれだけです!シンプルですね。
この先はhandler
を読まなければなりません。
PositiveExpectationHandler
PositiveExpectationHandler があるのは、同じ階層の handler.rb
です。
ruby/2.0.0/gems/rspec-expectations-2.14.2/lib/rspec/expectations/handler.rb
class PositiveExpectationHandler < ExpectationHandler def self.handle_matcher(actual, matcher, message=nil, &block) check_message(message) ::RSpec::Matchers.last_should = :should ::RSpec::Matchers.last_matcher = matcher return ::RSpec::Matchers::BuiltIn::PositiveOperatorMatcher.new(actual) if matcher.nil?
いきなり困りました。 コロン2つで始まる式なんてありましたっけ? そのことについては後述することにして先に進めます。
match = matcher.matches?(actual, &block) return match if match
ここが判定の部分です。
前回 change
マッチャ で見ましたが、matcher
の matches?
メソッドを呼びだしています。
こうゆうの(↓)ですね。 to
からくる block
を渡せる matcher
と 渡せない matcher
があるようです。change
は渡せない方だったのですね。
def matches?(event_proc) raise_block_syntax_error if block_given? @actual_before = evaluate_value_proc event_proc.call @actual_after = evaluate_value_proc (!change_expected? || changed?) && matches_before? && matches_after? && matches_expected_delta? && matches_min? && matches_max? end
結局のところ、expect
で渡したtarget
はマッチャーの所まで、何の加工もなくそのまま委譲されるということが分かりますね。そしてbe_a
マッチャ(実体はbe_kind_of
マッチャ) は、こんな感じです。
class BeAKindOf < BaseMatcher def match(expected, actual) actual.kind_of? expected end end
やっと{}
で書くと失敗する理由がわかった気がします。
つまり、{Person.new}.kind_of? Person
は Proc.kind_of? Person
ですので、Proc
オブジェクトはPerson
のインスタンスではない、といことで失敗しているのでした。===
で評価しているマッチャであれば、成功するに違いありません。
ということで、expect に何を渡すかは、マッチャによるという身も蓋も無い話になってしまいましたが、ここを意識して書いていくことで、もっとスラスラと rspec が書けると良いなと思っています。
message は失敗したときの表示
せっかくなので続きを見ていきましょう。
match で true になったら、return するので、以降は match しなかった時の処理のようです。
message = message.call if message.respond_to?(:call) message ||= matcher.respond_to?(:failure_message_for_should) ? matcher.failure_message_for_should : matcher.failure_message
to
の第2引数、message
はここで使われるようです。
オリジナルのエラーメッセージを返すことができるんですね。試してみます。
it "should be a instance" do expect{Person.new}.to be_a(Person), "通らないヨーダ" end
1) Person should be a instance Failure/Error: expect{Person.new}.to be_a(Person), "通らないヨーダ" 通らないヨーダ # ./spec/person_spec.rb:10:in `block (2 levels) in <top (required)>'
おお、本当に変わりました。
指定しないときはいつものエラーメッセージですが、 それらは matcher が返していたようです。
message.call
はMethod
クラスのことのようですが、奥が深いのでスルーします(えっ)。
エラーメッセージを返すメソッドを作成して、
それを rspec の引数に渡すような使い方を想定しているのかもしれません。
diffable
ここで最後です。diffable?
とは何でしょうか?
if matcher.respond_to?(:diffable?) && matcher.diffable? ::RSpec::Expectations.fail_with message, matcher.expected, matcher.actual else ::RSpec::Expectations.fail_with message end end end
これを持っている matcher を探してみると、base_matcher.rb
で false
,
eq.rb, eql.rb, equal.rb
が true
、およびinclude.rb
はメソッド呼び出しがありました。
> rspec-expectations-2.14.2/lib/rspec/matchers/built_in/base_matcher.rb def diffable? false end > rspec-expectations-2.14.2/lib/rspec/matchers/built_in/eq.rb def diffable?; true; end > rspec-expectations-2.14.2/lib/rspec/matchers/built_in/include.rb def diffable? # Matchers do not diff well, since diff uses their inspect # output, which includes their instance variables and such. @expected.none? { |e| RSpec::Matchers.is_a_matcher?(e) } end
比較可能な場合において、期待値と実測値を表示するのに使うようですね。
まとめ
今回の調査でわかったのは下記の2点です。
expect
はmatcher
にそのまま委譲する。expect{}.to
で書けるのは、matcher 側がブロックを評価しているときのみto
にはmessage
と&block
が渡せる
ということで、matcher を知ることが、expect を書けるようになる条件なんですね。 基本の matcher はBuilt in matchers - RSpec Expectations - RSpec - Relishにありましたので、勉強していきたいと思います。
誤差をOKにするbe_with_in
、範囲を比較する cover
、他にも色々あるので、便利に使っていきたいです。
初めて知ったマッチャについてご紹介だけしておきます。
be_with_in
誤差をOKにするマッチャshould be_within(0.5).of(27.9)
exist
exist? を判定するマッチャ。expect(obj).to exist
match
正規表現のマッチャ=~
と同じexpect("a string").to match(/str/)
respond_to
クラスのメソッドが呼び出せるかどうか。しかも引数の呼び出し個数までマッチできる!expect([1, 2, 3]).to respond_to(:take).with(1).argument
satisfy
ブロックが渡せるマッチャ。何でもできちゃう。expect(10).to satisfy { |v| v % 5 == 0 }
throw
throw で渡ってきたシンボルを確認するマッチャexpect { throw :foo }.to throw_symbol(:foo)
yield
一言では説明できないのでパス! yield matchers - Built in matchers - RSpec Expectations - RSpec - Relishcover
範囲をマッチ。というか、range に cover なんてメソッドあったんですね。expect(1..10).to cover(5)
- start_with, end_with。先頭か末尾をマッチ。というか、ファイル名が
start_and_end_with.rb
なんですがexpect("this string").to end_with "string"
使えそうなもの、使えなさそうなもの。色々あって、楽しいですね。 これからも良いrspecを書いていきたいと思います。
あ、表題のshould
を捨てた理由、が書いてありませんでしたね。
should
がself
を対象にしたhandler
呼び出しでしかないから。
syntax_host.module_eval do def should(matcher=nil, message=nil, &block) ::RSpec::Expectations::PositiveExpectationHandler.handle_matcher(self, matcher, message, &block) end
やってること同じじゃん、ということで、これなら expect で書いたほうがクラスを開く必要がない分が良いな、と思った次第です。文字数増えるのはイヤなんですけどね〜。
では皆さん。良いspec ライフをお送り下さい。
宣伝
MUGENUP では、rails を使いたいエンジニアを募集中です。 無限流開発、ご一緒しませんか?
大きな裁量で自社サービス開発!Rubyエンジニアをウォンテッド! - 株式会社MUGENUPの求人 - Wantedly
(補講) ::って何よ?
さて、ちょっと後に回していた、::
についての調査結果です。
::RSpec::Matchers.last_should = :should
あるクラスまたはモジュールで定義された定数を外部から参照する ためには::演算子を用います。 またObjectクラスで 定義されている定数(トップレベルの定数と言う)を確実に参照する ためには左辺無しの::演算子が使えます。 親クラスとネストの外側のクラスで同名の定数が定義されているとネストの外 側の定数の方を先に参照します。 http://docs.ruby-lang.org/ja/2.0.0/doc/spec=2fvariables.html#const
# -*- coding: utf-8 -*- CONSTANT = "トップの定数だよ" class Person CONSTANT = "内部の定数だよ" def top_level_constant ::CONSTANT end def my_class_constant CONSTANT end end
describe Person do it do expect(Person.new.my_class_constant).to eq "内部の定数だよ" end it do expect(Person.new.top_level_constant).to eq "トップの定数だよ" end end
(補講2) ボクの考えた最強のマッチャ
最後に皆さんに課題です。3秒で選んでください。
it "should be true" do expect{Person.new}.to be_true end
- 通る
- 通らない
探検!Changeマッチャ
皆さんこんにちは! 株式会社 MUGENUP 開発部の osada です。 弊社 で使っているフレームワークは Ruby on Rails ということで、 テストで使用する change マッチャ について調べたことを書きたいと思います。 ターゲットは change でテストを書きたい rails 初心者の方です。 かくいう私もrails歴は半年もなく、初心者の皆さんと一緒に勉強出来ればと思います。
ですがその前に、MUGENUP 開発部って何をやっているの?ということで、 簡単にご紹介したいと思います。
MUGENUP とは?
株式会社 MUGENUP は、イラストを必要とする企業さんから発注を受けて、クリエイターさん達とアートディレクターが連携して、イラストを作成しお届けしています。 それらの各工程「受注、工程分割、担当割り当て、イラストの修正や相談」などの全てを MUGENUP WORK STATION というWEBシステムで管理しています。掲示板、画像投稿に変換、リアルタイム通知、スケジュール管理などなど、機能てんこ盛りなWEBシステムです。これのお陰で、日本中、世界中のクリエイターさん達が、いつ、どこにいても一緒に働けるという、クラウドならではの共同作業を実現しています。
開発部では、これら全てを、Ruby on Rails
で開発しています。Rails 3
, Ruby 2.0 (!)
で動いています。
Change Matcher のブロックは2度評価される
さて、そんな MUGENUP では今 change 熱 が流行っています。現在、開発を株式会社KRAYさんにご協力いたただいているのですが、大変勉強になることばかりで、その一つが 良いテストを書く ということです。
綺麗なテストを書くためには、should...==
を羅列するのではなく、change().from().to()
を使おう、ということになるのですが、どう書いたら良いかわからないことが多かったです。
例えば、ユーザーの email を update すると、新しいメールアドレスになる
ということを下記のように書きました。
expect{ put :update, {id: @user.id, user: {email: new_email}} }.to change{@user.reload.email}.from(@user.email).to(new_email)
これとは別に下記の書き方もできると思います。
expect{ put :update, {id: @user.id, user: {email: new_email}} @user.reload }.to change(@user, :email).from(@user.email).to(new_email)
どんな書き方が良いのでしょうか?そもそも、なぜこれが動くのでしょうか?
ドキュメントがないならソースを読めばいいじゃない ということで、ソースを当たることにしました。
ソースは私の環境ではvendor/bundle/ruby/2.0.0/gems/rspec-expectations-2.14.2/lib/rspec/matchers/built_in/change.rb
にありました。
インスタンス化 フェーズ
class Change def initialize(receiver=nil, message=nil, &block) @message = message @value_proc = block || lambda {receiver.__send__(message)} @expected_after = @expected_before = @minimum = @maximum = @expected_delta = nil @eval_before = @eval_after = false end
インスタンス化はここです。ブロックを渡した時はそのままが評価されます。change(@user, :email)
のように、receiver
を渡した時は、lambda
化されて、__send__
で渡されるようです。blockが先に来ていますので、change には blockを渡すのが王道なんでしょうか。
matcher フェーズ
def matches?(event_proc) raise_block_syntax_error if block_given? @actual_before = evaluate_value_proc event_proc.call @actual_after = evaluate_value_proc (!change_expected? || changed?) && matches_before? && matches_after? && matches_expected_delta? && matches_min? && matches_max? end def evaluate_value_proc case val = @value_proc.call when Enumerable, String val.dup else val end end
match 判定 はこれです。インスタンス化で渡されたvalue_proc
が、event_proc
の前後で発動し、評価されていることがわかります。
また、@value_proc
の返り値が Enumerable, String
だった場合には、dup
で返しているのも気が利いていますね。こうしないと破壊的操作がされたときに、追従してしまうことになり、テストが成立しないからだと考えられます。さらに言えばcase ... when
では val
がEnumerable, String
クラスのインスタンスかどうかを判定できることを利用した美しいコードです。
from と to と by と
match 判定の部分を見て行きましょう。
(!change_expected? || changed?) && matches_before? && matches_after? && matches_expected_delta? && matches_min? && matches_max?
changed
は実行前後の値の変化を、change_expected
は by
メソッドが呼ばれているかどうかで判定しています。
def changed? @actual_before != @actual_after end def change_expected? @expected_delta != 0 end def by(expected_delta) @expected_delta = expected_delta self end
matches_before?
は from
メソッドが呼ばれた時に @eval_before
フラグがセットされ、from
に渡した値と、@value_proc
の値が比較されます。
def matches_before? @eval_before ? expected_matches_actual?(@expected_before, @actual_before) : true end def from (before) @eval_before = true @expected_before = before self end
matches_after?
はその逆で、to
メソッドが呼ばれた時に同じ挙動となります。
matches_expected_delta?
は by
メソッドの判定で、実行前の実測値と差分の期待値が、実行後の実測値と同じかどうかを判定していました。
def matches_expected_delta? @expected_delta ? (@actual_before + @expected_delta == @actual_after) : true end
これがおよそ、match 判定の部分です。
from
もto
もby
も、フラグをセットし、実行前後の値の比較という挙動をしています。
およそ change マッチャの挙動を理解できたのではないでしょうか。
シンプルで素晴らしい構造だと思います。
そしてソースを読んだことで、知らなかったメソッドも知ることが出来ました。
それがby_at_least
とby_at_most
です。
by_at_least と by_at_most
by_at_least
とby_at_most
は下記となります。実行後と実行前の差分が、(少なくとも|多くとも) x である、ということのようです。いつ使うんでしょうか?ランダム要素があるときは、Hoge.stub(:rand){}
で確定させて返してしまうのですが、その用途ではないのでしょうか。機会があれば、使ってみたいと思います。
def by_at_least(minimum) @minimum = minimum self end def by_at_most(maximum) @maximum = maximum self end def matches_min? @minimum ? (@actual_after - @actual_before >= @minimum) : true end def matches_max? @maximum ? (@actual_after - @actual_before <= @maximum) : true end
まとめ
さて、長々と、Change class を見てきましたが、初めの疑問、change のブロックの中に reload を書くか、書かないか、はどちらが良いのでしょうか?
changeブロックが2回評価されることを考えると、1度目のreloadは無意味なのにDBアクセスを起こすので、changeの外にだすべきなのかな、と個人的に思います。ただ時間を計測したところそれほど差異が無かったため、もう少し突き詰めたいところです。
あと、今更で大変恐縮なのですが、changeマッチャについて知りたい人は、かの有名な Rubyist Magazine - スはスペックのス 【第 1 回】 RSpec の概要と、RSpec on Rails (モデル編)を読んでください。これだけ読めば、テストが書けるようになります!
では、最後に宣伝を。 MUGENUP では、rails を使いたいエンジニアを募集中です。 無限流開発、ご一緒しませんか?
大きな裁量で自社サービス開発!Rubyエンジニアをウォンテッド! - 株式会社MUGENUPの求人 - Wantedly