MUGENUP勉強会を開催しました!

こんにちは、MUGENUPの伊藤です。

今回は先日2/27日に開催した勉強会をまとめてみました!

当日は外部の方を6人お招きし、全部で12人での開催となりました。

f:id:mgnup:20140227213058j:plain

開催までの流れ

MUGENUPの開発部では隔週木曜日に勉強会を開催しています。 元々は少人数でそれぞれの人が好きなことを発表するスタイルで、発表中にツッコミとかしながら和気あいあいとやっていました。

また、年末に外部の人を呼んでやったところ好評で、その後定期的に参加してくれるようになった人もいます。

そんなこんなで「本格的に外部の人を呼んでやってみたい!」と思い、元々MUGENUPと御縁のあったエンジニアの方や、知り合いづてなどで何人かに声をかけてみました。

すると皆さん快諾して頂き、晴れてこの勉強会開催にこぎつけました。

会場

今回はMUGENUPの入っているビルにある「シアタールーム」というところで開催しています。 名前の通り映画鑑賞や、カラオケなんかもできちゃう部屋なんですがソファでくつろげたり照明をいじれたりで結構雰囲気を出せるような部屋になっています。

f:id:mgnup:20140213194143j:plain

当日の内容

当日は19:30 ~ 21:30の2時間で予定していました。 普段は一人あたり5~10分の発表で、発表中にツッコミを入れたりするので一人あたりだいたい15分くらいでのんびりやっていました。

しかし、段々と参加者が増えていき、「あれ、これそもそも発表時間オーバーするかも」などと思っていたのですが、 最終的には発表者が10名程だったことからなんとかギリギリ全員が発表できました。

さて、気になる当日の発表内容ですが、

  • vagrant, chef, knife soloを使ってmunin-nodeを複数サーバーに展開し監視をするまで

  • 酔っぱらいレベルを測定できるアプリNomBay

  • Semantic Versioningについて

  • psd のファイル構造、または photoshop plugin を作る的な何か

  • 楽しくなるPerl開発環境

  • RubyのSymbolについて

  • 電子マネーに関する何か

  • binding.pryに隠れている黒魔術について

といった内容の発表がありました。

各人が好きなことを発表し合った結果、内容がかなりばらつき 普段なら接することはない技術をお互いが知ることができてかなり刺激的な勉強会になりました。

ちなみに私もperlは使ったことがなかったので、その後インストールして色々いじってみました。

社内では「また外部の人を呼んで勉強会をやろう!」という風に盛り上がったので、 また近いうちに開催予定です。

ご興味の有る方は伊藤までご連絡ください!

宣伝

MUGENUP では、知りたがりなエンジニアを募集中です。

無限流開発、ご一緒しませんか?

エントリーはこちら http://mugenup.com/recruit/information#web_application

f:id:mgnup:20131118023234p:plain

Rails 4.1 の spring で paralell_tests を使う方法

みなさん、こんにちは! 2週間ぶりのご無沙汰、MUGENUP の osada です。

ruby 2.1.0Rails 4.1 で開発した、みなゲー編集部が正式リリースとなりました! よろしくお願いします。

さて、そんなRails 4.1の新機能として、プレローダーspringが標準装備となりました。 本日はspringparalell_testsを併用する方法についてのお話です。

要旨は下記となります。

  • PARALLEL_TESTS_EXECUTABLEを設定して、paralell_testsspringを使わせない
  • bin/rspecを書き換えて、始めのプロセスだけspringを使う
  • FailuresLogger を使って、失敗したテストを再実行する
  • RuntimeLoggerを使って、テストのグループを平均化する

では、よろしくお願いします。

PARALLEL_TESTS_EXECUTABLEを設定して、paralell_testsspringを使わせない

Rails 4.1のテストで、rspecでは成功するのに、paralell_testsでは多くが失敗する、という現象に遭いました。 springが原因と考えられます。

通常、rakerspecコマンドを使うとき、コード(code)を読み込んで、メモリ上にRailsappインスタンスを生成し、使用します。処理が終わると、appは解放されます。

f:id:mgnup:20140223195200p:plain

この処理はかなり重い処理なので、毎回作るのではなく、一度作ったappを使いまわそう、というのがspringの動きです。springで作られたappは処理が終わっても維持され、再度使用されます。

f:id:mgnup:20140223195207p:plain

このspringbin_stubとして提供され、bin/というディレクトリに rails, rspec, rakeのコマンドが用意されます。例えば、

be bin/rake db:create

のように、通常のコマンドの代わりに、bin/下のコマンドを呼び出すことで、springが使用されます。

一方、paralell_testsというのは、rakeなどの処理を複数のプロセスに分けて同時に処理する方法です。

f:id:mgnup:20140223195212p:plain

例えば、parallel:specを実行すると、下記の4つのコマンドが実行されます。

bebundle execalias です

$ be rake parallel:spec

TEST_ENV_NUMBER=;export TEST_ENV_NUMBER;PARALLEL_TEST_GROUPS=4;export PARALLEL_TEST_GROUPS;rspec
TEST_ENV_NUMBER=3;export TEST_ENV_NUMBER;PARALLEL_TEST_GROUPS=4;export PARALLEL_TEST_GROUPS;rspec
TEST_ENV_NUMBER=4;export TEST_ENV_NUMBER;PARALLEL_TEST_GROUPS=4;export PARALLEL_TEST_GROUPS;rspec
TEST_ENV_NUMBER=2;export TEST_ENV_NUMBER;PARALLEL_TEST_GROUPS=4;export PARALLEL_TEST_GROUPS;rspec

しかし、これにspringを使用すると、4つのプロセスがあるのに、実際に稼働するappは1つになってしまいます。

f:id:mgnup:20140223195216p:plain

よって、テストが失敗してしまうのです。

原因はparalell_testsbin/rspecを使用するという点にあります。これをどうにかしましょう。

$ be rake parallel:spec

TEST_ENV_NUMBER=;export TEST_ENV_NUMBER;PARALLEL_TEST_GROUPS=4;export PARALLEL_TEST_GROUPS;bin/rspec
……

結論からいうと、PARALLEL_TESTS_EXECUTABLEを設定することで、bin/rspecの使用を回避できます。

PARALLEL_TESTS_EXECUTABLE="bundle exec rspec" be rake parallel:spec

TEST_ENV_NUMBER=;export TEST_ENV_NUMBER;PARALLEL_TEST_GROUPS=4;export PARALLEL_TEST_GROUPS;bundle exec rspec
……

この理由は、parallel_testsは、実行するファイルを下記のように設定しているからです。

def executable
  ENV['PARALLEL_TESTS_EXECUTABLE'] || determine_executable
end

またrspecdetermine_executableは下記であり、[bin/rspec, script/spec, bundle exec rspec, spec, rspec] のどれかを起動する事がわかります。

def determine_executable
  cmd = case
  when File.exists?("bin/rspec")
    "bin/rspec"
  when File.file?("script/spec")
    "script/spec"
  when ParallelTests.bundler_enabled?
    cmd = (run("bundle show rspec-core") =~ %r{Could not find gem.*} ? "spec" : "rspec")
    "bundle exec #{cmd}"
  else
    %w[spec rspec].detect{|cmd| system "#{cmd} --version > /dev/null 2>&1" }
  end

  cmd or raise("Can't find executables rspec or spec")
end

bin/rspecを削除しても良いのですが、それではspringが使えなくなるので、 PARALLEL_TESTS_EXECUTABLEを設定する方が適切でしょう。

以上、本項の結論は、

parallel_testsに、springを使わせないためには、PARALLEL_TESTS_EXECUTABLEを設定する となります。

bin/rspecを書き換えて、始めのプロセスだけspringを使う

しかしせっかくのspringです。 4つの内1つだけでspringを使い、 残り3つを別のプロセスで動かせば良いのではないでしょうか?

f:id:mgnup:20140223195219p:plain

相手はbin_stubですから、修正はとても簡単です。

bin/rspecのこれを、

if !Process.respond_to?(:fork) || Gem::Specification.find_all_by_name("spring").empty?
  exec "bundle", "exec", "rspec", *ARGV
else
  ARGV.unshift "rspec"
  load Gem.bin_path("spring", "spring")
end

こうしましょう!

if !Process.respond_to?(:fork) || Gem::Specification.find_all_by_name("spring").empty?
  exec "bundle", "exec", "rspec", *ARGV
else
  if ENV["TEST_ENV_NUMBER"].nil? || ENV["TEST_ENV_NUMBER"].empty?
    ARGV.unshift "rspec"
    load Gem.bin_path("spring", "spring")
  else
    exec "bundle", "exec", "rspec", *ARGV
  end
end

ENV["TEST_ENV_NUMBER"].nil? は通常のbin/rspec起動時です。 ENV["TEST_ENV_NUMBER"].empty?は、parallel_testsの一番始めのプロセスの時です。

つまり、

bin/rspecとして起動したとき、または、parallel_testsの一番始めのプロセスの時に、springを使用する。

というbin_stubになりました。

さて、肝心のスピードアップですが、featureテストなどが多いため、私のプロジェクトでは違いが現れませんでした。 参考までとはなりますが、環境の影響が少ないspec/modelに適用した結果を載せておきます。

[feature/parallel stash]~/projects/mugenup/workstation: time RAILS_ENV=test PARALLEL_TESTS_EXECUTABLE="bundle exec rspec" bundle exec rake parallel:spec[model]
……
444 examples, 0 failures

Took 216.680576 seconds

real    3m47.661s
user    3m20.585s
sys     0m25.249s
[feature/parallel stash]~/projects/mugenup/workstation: time RAILS_ENV=test bundle exec bin/rake parallel:spec[model]

……
444 examples, 0 failures

Took 212.844729 seconds

real    3m37.395s
user    0m1.378s
sys     0m0.285s

realを見ると10 secほど速くなっているように見えますが、誤差の範囲かもしれません。 もし「速くなったよ!」という方がいらっしゃいましたら、ご一報ください!

FailuresLogger を使って、失敗したテストを再実行する

featureテストは実際のブラウザの挙動を再現するため、 タイムアウトなどの理由により、 タイミングによって失敗することもあります。 parallel_testsを使うと、さらに失敗しやすくなります。

たまたま失敗したのか、本当に失敗しているのかを確認するため、 失敗したテストのみを再度実行するのですが、 その時に役立つのが、FailuresLoggerです。

.rspec_parallelparalell_testsのみのオプションを設定できます。

# .rspec_parallel

--format progress
--format ParallelTests::RSpec::FailuresLogger --out tmp/failing_specs.log

これには失敗したテストがログに残ります。標準出力と同じものです。

f:id:mgnup:20140223200202p:plain

ここから、再度テストを実行しましょう。

$ grep rspec tmp/failing_specs.log | awk '{print $2}' | xargs bundle exec rspec

/Users/osada/projects/mugenup/workstation/vendor/bundle/gems/rspec-core-2.14.7/lib/rspec/core/configuration.rb:896:in `load': cannot load such file -- /Users/osada/projects/mugenup/workstation/spec/views/projects/edit.html.erb_spec.rb:34 (LoadError)

あら、失敗してしまいました。なぜでしょうか? 実際にファイルを開いてみたところ、ascii color codeが含まれていました。

f:id:mgnup:20140223200208p:plain

たしかに、これでは、rspecが通りませんね。 といっても--colorを消してしまうのも、嬉しくありません。

そこで、正規表現を使って、抜き出すことにしました。

  • rspec から始まり、「ドット、スラッシュ、コロン、英数字とアンダースコア」で構成される文字列を抽出する
$ ruby -ne 'puts $1 if /(?<=rspec )([\.\/:\w]+)/' tmp/failing_specs.log | xargs bundle exec rspec

これで、失敗したテストのみ、再実行することができます。

rake taskにするべきか?とも思ったのですが、さらに実行を遅らせてしまいそうなので、 aliasの登録だけにしておきました。

こうゆうとき、どういう技を使えばいいか、わからないの

rubyワンライナーを書くのは、少し大変ですね。

RuntimeLoggerを使って、テストのグループを平均化する

さて、paralell_testsはデフォルトでは、ファイルサイズ順に均等化します。 ファイルサイズの大きい順に並べて、4つのグループに順番に追加していくわけです。

RuntimeLoggerを使うことで、テストにかかった時間で、均等化することができます。

--format progress
--format ParallelTests::RSpec::RuntimeLogger --out tmp/parallel_runtime_rspec.log

tmp/parallel_runtime_rspec.logには、テストファイル名と、その実行時間が記録されています。

[feature/parallel stash]~/projects/mugenup/workstation: tail tmp/parallel_runtime_rspec.log
……
./spec/views/sub_projects/show.html.erb_spec.rb:2.261982
./spec/views/projects/show.html.erb_spec.rb:2.806851

テストのグループ分けのとき、with_runtime_infoメソッドが呼ばれます。 runtime_logつまりtmp/parallel_runtime_rspecがあるとき、かつ、 今回のtestsファイル群が、このログの中に含まれていると考えられるときに、 このログを使ってグループ分けします。

puts "Using recorded test runtime"が出力されれば、成功です。

def with_runtime_info(tests)
  lines = File.read(runtime_log).split("\n") rescue []

  # use recorded test runtime if we got enough data
  if lines.size * 1.5 > tests.size
    puts "Using recorded test runtime"
    times = Hash.new(1)
    lines.each do |line|
      test, time = line.split(":")
      next unless test and time
      times[File.expand_path(test)] = time.to_f
    end
    tests.sort.map{|test| [test, times[File.expand_path(test)]] }
  else # use file sizes
    tests.sort.map{|test| [test, File.stat(test).size] }
  end
end

実際に、使用したときと、使用していないときを比較してみます。

[feature/parallel stash]~/projects/mugenup/workstation: RAILS_ENV=test bundle exec bin/rake parallel:spec[model]

Finished in 2 minutes 19.6 seconds
78 examples, 0 failures

Finished in 3 minutes 19.1 seconds
124 examples, 0 failures

Finished in 3 minutes 10.7 seconds
116 examples, 0 failures

Finished in 3 minutes 13.1 seconds
126 examples, 0 failures

使用していないときの処理時間は2:19.6 〜 3:19.1であり、60秒以上のズレがあります。

一方、runtime_logを使用したときは下記です。 Using recorded test runtimeが表示されていることが確認できます。

[feature/parallel stash]~/projects/mugenup/workstation: RAILS_ENV=test bundle exec bin/rake parallel:spec[model]
Using recorded test runtime

Finished in 3 minutes 7.8 seconds
102 examples, 0 failures

Finished in 2 minutes 58.3 seconds
108 examples, 0 failures

Finished in 3 minutes 10.2 seconds
123 examples, 0 failures

Finished in 3 minutes 11.8 seconds
111 examples, 0 failures

2:58.3 〜 3:11.8であり、13秒ほどのズレでした。

残念ながら、こちらも大きなスピードアップは感じられませんでしたが、 速くなったよ、という方はご一報いただけると嬉しいです。

まとめ

parallel_testsspringの使い方、いかがだったでしょうか?

  • PARALLEL_TESTS_EXECUTABLE="bundle exec rspec" を設定して、paralell_testsspringを使わせない
  • bin/rspecを書き換えて、始めのプロセスだけspringを使う
  • FailuresLogger を使って、失敗したテストを再実行する
  • RuntimeLoggerを使って、テストのグループを平均化する

弊社でもテストが遅いということが、問題になっており、日々改善に勤しんでおります。

こんなやり方あるよ!という情報をお持ちの方、いらっしゃいましたら、 ご教授いただけますと幸いです。

よろしくお願いします!

宣伝

MUGENUP では、rails を使いたいエンジニアを募集中です。 無限流開発、ご一緒しませんか?

大きな裁量で自社サービス開発!Rubyエンジニアをウォンテッド! - 株式会社MUGENUPの求人 - Wantedly

f:id:mgnup:20131118023234p:plain

【探検】Railsカラム更新のメソッド1

初めに

皆さん、初めまして。そして、明けましておめでとうございます。株式会社MUGENUP 開発部の奥田です。 今回、初めて技術ブログを書くことになりました。よろしくお願い致します。

何回かに分けてRailsのカラム更新メソッドについて書いていき、今回はupdate_attributeupdate_attributesについて記述します。2つのメソッドは私の環境だとactiverecord-3.2.16/lib/active_record/persistence.rbにありました。以下の表は簡単なまとめです。

メソッド名 validation callback 更新カラム数
update_attribute なし あり 1つだけ
update_attributes あり あり 複数

それではそれぞれのメソッドのソースコードを探検してみましょう〜!!

ActiveRecord::Persistence#update_attribute

  # Updates a single attribute and saves the record.
  # This is especially useful for boolean flags on existing records. Also note that
  #
  # * Validation is skipped.
  # * Callbacks are invoked.
  # * updated_at/updated_on column is updated if that column is available.
  # * Updates all the attributes that are dirty in this object.
  #
  def update_attribute(name, value)
    name = name.to_s
    raise ActiveRecordError, "#{name} is marked as readonly" if self.class.readonly_attributes.include?(name)
    send("#{name}=", value)
    save(:validate => false)
  end

save(:validate => false)となっているので、validationなしということがわかります。Callbackを起こすは英語でinvokeと表現するのですね〜。updated_atまたはupdated_onは更新するとわざわざ記述しているので、更新しないメソッドもあるのかもしれません。驚いたのはsend("#{name}=", value)の部分で、sendメソッドはカラム名=をメソッドと認識することです。sendメソッドを深堀りするのも面白そうだと思いました。

ActiveRecord::Persistence#update_attributes

  # Updates the attributes of the model from the passed-in hash and saves the
  # record, all wrapped in a transaction. If the object is invalid, the saving
  # will fail and false will be returned.
  #
  # When updating model attributes, mass-assignment security protection is respected.
  # If no +:as+ option is supplied then the +:default+ role will be used.
  # If you want to bypass the protection given by +attr_protected+ and
  # +attr_accessible+ then you can do so using the +:without_protection+ option.
  #
  def update_attributes(attributes, options = {})
    # The following transaction covers any possible database side-effects of the
    # attributes assignment. For example, setting the IDs of a child collection.
    with_transaction_returning_status do
      self.assign_attributes(attributes, options)
      save
    end
  end

update_attributesにはoptionsがあるんですね〜。:without_protectionoptionsに入れることでmass-assignmentのprotectionチェックなしにできることを初めて知りました。assign_attributesの中も見たい気持ちはありますが、今後のネタとして取っておきます(笑)。コメントアウトで強調したいところを+で囲っていますが、私の知らない記法でした。Markdown記法ではないようなので、要調査です。

番外編 ActiveRecord::Persistence#update_attributes!

#update_attributessaveを呼んでいるのでカラム更新の失敗時に戻り値としてfalseを返します。そして、#update_attribute!をつけた#update_attributes!の場合はカラム更新の失敗時に例外を返しますが、その中身はどうなっているのでしょう?saveが呼ばれて、!がついてるということは・・・?

  # Updates its receiver just like +update_attributes+ but calls <tt>save!</tt> instead
  # of +save+, so an exception is raised if the record is invalid.
  def update_attributes!(attributes, options = {})
    # The following transaction covers any possible database side-effects of the
    # attributes assignment. For example, setting the IDs of a child collection.
    with_transaction_returning_status do
      self.assign_attributes(attributes, options)
      save!
    end
  end

予想通りですが、save!が呼ばれていました〜。

最後に

簡単にではありましたが、Railsのカラム更新メソッドについて見てきました。この記事を読んで初めて知ったことがあれば幸いです。他にもカラム更新のメソッドはあるので、私の次回の記事でもカラム更新のメソッドを探検したいと思います。

宣伝

MUGENUP では、Rails を使いたいエンジニアを募集中です。 無限流開発、ご一緒しませんか?

大きな裁量で自社サービス開発!Rubyエンジニアをウォンテッド! - 株式会社MUGENUPの求人 - Wantedly

f:id:mgnup:20131118023234p:plain

【拡張】論理和できるenumを書いてみた【gem】

新年あけましておめでとうございます! MUGENUP の osada です。 2014 年は挑戦の年、ということで、MUGENUP でも新しい事業を初めています。

ゲームライター・編集者募集中|みなゲー

f:id:mgnup:20140113031124p:plain

みんなで作るゲーマー向けの攻略サイト「みなゲー」。 その攻略記事を書いてくださる方を募集しています。 スマホゲームなら俺に任せろ!という豪の方、攻略記事を書いてみませんか?

開発部でも、新しい挑戦としてRuby 2.1Rails 4.1 で開発を初めています!

新機能 enum

Rails 4.1 に enum という新機能が実装されました。

Ruby on Rails 4.1 Release Notes — Ruby on Rails Guides

class Conversation < ActiveRecord::Base
  enum status: [ :active, :archived ]
end
 
conversation.archived!
conversation.active? # => false
conversation.status  # => "archived"
 
Conversation.archived # => Relation for all archived Conversations

status など、特定のグループの中のどれか1つの値を持つ要素」を便利に書けるようになりました。

enum では プレイングマネージャー を表現できない

enum はグループの間でどれか一つの要素を表現します。各要素は排他関係です。:active:archived が同時に存在することはありません。

そう聞くと、グループの複数の値が共存して欲しい。 そう思うことはないでしょうか?

例えば、野球で、監督兼選手は、プレイングマネージャーと呼ばれるそうです。

class User < ActiveRecord::Base
  enum role: [ :manager, :player ]
end

しかしこのとき、enum ではプレイングマネージャーを表現できません。Userがなれるのは、manager(監督)か、player(選手)しかないからです。

論理和できる列挙型、bitwise_enum を作ってみた

bitwise_enum/lib/bitwise_enum.rb at master · osdakira/bitwise_enum

とうことで複数の要素を表現できる、bitwise_enum を作ってみました。

通常のenumは、要素を[0, 1, 2]と表現していくため、 manager0palyer には 1が入ります。

一方、bitwise_enum では、manager1playerには2が入ります。 プレイングマネージャーを表現したいときは3にすればOK、ということですね。

これはビット(2進数)に直すと、1桁目がmanager、2桁目がplayerを表現していることになります。 このようにすることで、複数の要素を同時に表現することが可能です。

f:id:mgnup:20140113171345p:plain

使い方

class User < ActiveRecord::Base
  bitwise_enum role: [ :manager, :player ]
end

基本的な使い方はenumと同様です。

user = User.new
user.manager!
user.manager? # => true
user.role  # => "['manager']"

user = User.new
user.player!
user.player? # => true
user.role    # => "['player']"

違いとしては、要素が共存できるということです。

user.manager!
user.manager? # => true
user.player!
user.player? # => true
user.role    # => "['manager', 'player']"

プレイングマネージャーが表現出来ました!

一方で、今までは要素は上書きしていましたが、 bitwise_enumでは、明示的に削除する必要があります。 そのためにnot_xx()というメソッドがあります。

user.manager!
user.manager? # => true
user.not_manager!
user.manager? # => false

また、全てをリセットするためのreset_xx()というメソッドがあります。

user.manager! # => ['manager']
user.reset_role # => nil
user.role = []

enumの実装も同じなのですが、manager!を呼んだ時点で、 update!が動いているため、即座にSQLが動いてしまいます。 このままでは error ハンドリングするときに困るので、代入するときにもビット演算を行うようになっています。

user.role = :manager
user.role # => ['manager']
user.role = :admin
user.role # => ['manager', 'admin']

scope も、通常の値ではなく、ビット演算の値でSQLを発行します。

User.manager # => SELECT `users`.* FROM `users`  WHERE (role & 1 = 1)

ただコレに関しでは実装部分が、arelを使わない未熟なものです。

klass.scope value, -> { klass.where("#{name} & #{bit} = #{bit}") }

どなたかarelの使い方について、情報を教えていただけないでしょうか!

以上が、プレイングマネージャーが実装できるbitwise_enumの説明です。

gem にしてみた

せっかくなので gem にしてみました。

osdakira/bitwise_enum

gem を作るのは簡単でしたが、テスト環境の構築に迷いました。rails に載せずに環境を作るのは難しいですね。

まとめ

  • 列挙した要素の値を、重複して持つことができる bitwise_enumを実装しました。
  • enum のコードにビット演算を追加して実現しています。

皆さんはenumをどう使われているでしょうか?是非使い方を教えて下さい!

宣伝

MUGENUP では、rails を使いたいエンジニアを募集中です。 無限流開発、ご一緒しませんか?

大きな裁量で自社サービス開発!Rubyエンジニアをウォンテッド! - 株式会社MUGENUPの求人 - Wantedly

f:id:mgnup:20131118023234p:plain

【悲報】ActiveSupport::Concern の ClassMethod はモジュールメソッドになる件

皆さんこんにちは!「太鼓式マッサージ?面白そう!」と思ったら「タイ古式マッサージ」でした。MUGENUP の osada です。名前って難しいですよね。

ところで、ActiveSupport::ConcernClassMethod で定義したクラス変数が、どこにあるか、ご存知ですか?

# app/models/concern/item_module.rb
module ItemModule
  extend ActiveSupport::Concern
  module ClassMethods
    def my_module_method
      @@concern_class_variable ||= "concern_class_variable"
    end
  end
end
# app/models/item.rb
class Item < ActiveRecord::Base
  include ItemModule
end
# spec/models/item_spec.rb
describe Item do
  it "ClassMethods で定義すると参照できる" do
    expect(Item.my_module_method).to eq "concern_class_variable"
  end
end

ここまではOKだと思います。 では、これを自身のクラス変数として参照できないことをご存知でしょうか?

  it "ClassMethods で取り込んだクラスメソッドを実行しても自身のクラス変数としては定義されない" do
    expect{
      Item.my_module_method
    }.not_to change{Item.class_variable_defined?(:@@concern_class_variable)}.from(false)
  end

参照はできるのに、定義されていない。それは一体どこにあるの?というのが今回のお話です。

クラス変数はどこに定義されているの?

ruby の場合、クラスメソッドはクラスに特異メソッドを追加する、という文脈で定義されます。

Ruby におけるクラスメソッドとはクラスの特異メソッドのことです。 したがって、何らかの方法でクラスオブジェクトにメソッドを定義すれば、そ れがクラスメソッドとなります。 http://docs.ruby-lang.org/ja/2.0.0/doc/spec=2fdef.html#class_method

よって、クラスメソッド内で定義されたクラス変数は、自身のクラス変数となります。

class Item < ActiveRecord::Base
  include ItemModule
  class << self
    def my_class_methods
      @@my_class_variable = "my_class_variable"
    end
  end
end
  it "クラスメソッドを実行するとクラス変数が定義される" do
    expect{
      Item.my_class_methods
    }.to change{Item.class_variable_defined?(:@@my_class_variable)}.from(false).to(true)
  end

これは全く期待通りの動きです。先ほどと違うのは、自身のクラスメソッドとして定義しているという点ですね。 では、class定義ならOKで、module だからダメなんでしょうか?

module の included として定義してみる

ActiveSupport::Concern では includedというメソッドで、クラス定義の中でコードを評価することができます。

module ItemModule
  extend ActiveSupport::Concern
  included do
    class << self
      def my_singleton_module_method
        @@singleton_include_module_method = "singleton_include_module_method"
      end
    end
  end
end

この結果はクラス変数はクラスに紐付きました。期待通りです。

  it "included で 拡張した特異クラスのクラスメソッドはクラス変数が定義される" do
    expect{
      Item.my_singleton_module_method
    }.to change{Item.class_variable_defined?(:@@singleton_include_module_method)}.from(false).to(true)
  end

どうやら、module だからではないようですね。違いは、includedClassMethodなので、期待通りに動かないのはClassMethodのせいのようです。

ClassMethodincludedの違いはどこにあるのか?

ClassMethod はどのように動くのでしょうか?

base.extend const_get("ClassMethods") if const_defined?("ClassMethods")

ClassMethodという定義があったら、baseに対してextendするという実装になっています。

一方includesは、

base.class_eval(&@_included_block) if instance_variable_defined?("@_included_block")
    def included(base = nil, &block)
      if base.nil?
        @_included_block = block
      else
        super
      end
    end

base クラスのclass_evalを呼び出しているため、自身のコンテキストで評価されます。

違いはClassMethodはモジュールのextendであり、includedはクラス定義内での評価というところです。

引数で指定したモジュールのインスタンスメソッドを self の特異 メソッドとして追加します。 instance method Object#extend

extend されたクラス変数はモジュール変数となる

extendはモジュールのメソッドを、特異クラスのメソッドとして追加するだけです。よって、extendで追加されたクラスメソッド内で定義されたクラス変数は、モジュール変数になってしまうのです!!!

  it "ClassMethods で定義したクラスメソッドは、モジュール変数" do
    expect{
      Item.my_module_method
    }.to change{ItemModule::ClassMethods.class_variable_defined?(:@@concern_class_variable)}.from(false).to(true)
  end

そしてさらに恐ろしいことに、モジュール変数はincludeした全てで共有されます。マニュアルにしっかり書かれています。

モジュールで定義されたクラス変数(モジュール変数)は、そのモジュールをイ ンクルードしたクラス間でも共有されます。 http://docs.ruby-lang.org/ja/2.0.0/doc/spec=2fvariables.html#class

module Foo
  @@foo = 1
end
class Bar
  include Foo
  p @@foo += 1          # => 2
end
class Baz
  include Foo
  p @@foo += 1          # => 3
end

ClassMethod はモジュールメソッドだ

名前って難しいですよね。ClassMethod だと思っていたら(そして実際にクラスメソッドなのですが)、その中で定義したクラス変数は、モジュール変数になってしまうのです。

これからはできるだけ ClassMethod は使わないようにしよう、と心に決めた日でした。

[注意] モジュール変数、クラス変数、クラスメソッドの役割について

モジュール変数を定義するべきではない、というのはよく言われていることだと思います。そもそもモジュールは数学的な意味で関数であるべきだというのが私の持論です。副作用がないと言い換えても良いです。

なので、モジュール内でクラス変数を使うべきではないというご指摘は最もだと思います。クラス変数を使わない方が良いというのも納得できます。

本記事は、クラス変数やクラスメソッドの使用を推奨したものではありません。あくまで、知っておくと罠が回避できるかも、という記事ですので、ご了承のほどよろしくお願い致します。

参考

» ActiveSupport::Concern でハッピーなモジュールライフを送る TECHSCORE BLOG

宣伝

MUGENUP では、rails を使いたいエンジニアを募集中です。 無限流開発、ご一緒しませんか?

大きな裁量で自社サービス開発!Rubyエンジニアをウォンテッド! - 株式会社MUGENUPの求人 - Wantedly

f:id:mgnup:20131118023234p:plain

シンボルでも文字列でもアクセス可能な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のコア拡張です。

ということで、今日のお題はActiveSupportHashです。ターゲットは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

rubydup, 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

この場合、superHashクラスの呼び出しですから、ミソは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

シンボルでも文字列でもアクセスできる、というのは keyvalue に対して、根本のところで文字列に変換してしまう、という方法で実装されていました。

これさえ理解しおけば、シンボルでも文字列でも扱える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

f:id:mgnup:20131118023234p:plain

最速で!最短で!真っ直ぐに!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 に登録して、アプリを作成

  1. facebook developer へ登録

    https://developers.facebook.com/

  2. app へ

    f:id:mgnup:20131201224213p:plain

  3. 新しいアプリを作成 ボタン

    f:id:mgnup:20131201224220p:plain

  4. 新しいアプリを作成

    f:id:mgnup:20131201224455p:plain

  5. captcha

    f:id:mgnup:20131201224223p:plain

  6. 承認

    f:id:mgnup:20131201224226p:plain

  7. こんな感じに

    f:id:mgnup:20131201224254p:plain

  8. Graph APIエクスプローラーを使用する

    f:id:mgnup:20131201224209p:plain

  9. アクセストークンを取得

    f:id:mgnup:20131201224230p:plain

  10. user groups にチェック

    f:id:mgnup:20131201224243p:plain

  11. friends groups にチェックして、[Get Access Token]

    f:id:mgnup:20131201224233p:plain

  12. アクセストークンをコピー

    f:id:mgnup:20131201224240p:plain

  13. 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というのを作って、

f:id:mgnup:20131201230418p:plain

こんな感じに書いてから、

$ 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.長期化されたアクセストークンを使う

f:id:mgnup:20131201224250p:plain

こんな感じで、新しく長期化されたアクセストークンが取得できます。 expires5184000 になっていることが確認できます。約2ヶ月、使い続けられます。

18.後は cron で登録するなりなんなり!

あ、cronbundle付きを呼ぶのはちょっと面倒ですが、頑張ってください!

ということで、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

ということで、macsmtpからダイレクトに送れなかったため、mailgemを導入しました。 これがなければ、もっとシンプルでもっと速く作れたのに、と思うとちょっと残念です。

さて、Mail ですが、送信相手が複数居る時は、カンマで区切りましょう。

charasetutf-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

f:id:mgnup:20131118023234p:plain

あ、WAでビンタしあう方を思い出す方は友達になりましょう。