ruby は メソッドに return が不要なのではなく、元々そうなっているだけ

皆さん、こんにちは。MUGENUP の osada です。 いきなりですが、問題です。

def if_expression(flag)
  if flag
    "NG"
  else
    "OK"
  end
end

Ruby最後に評価された値が返る と言われていますね。 では、上記のメソッド

if_expression false

の返り値は、何ですか?






はい、正解です。OKが返ってきますね。

ちょっと長いので、リファクタリングしましょう。

def if_modifier(flag)
  "OK"
  "NG" if flag
end

さてもう一度。

if_modifier(false)

の返り値は、何ですか? Ruby最後に評価された値が返る と言われていましたよね?






はい。正解です。nil が返ってきますね。

え、"OK"じゃないのかって?

いいえ、後置if は if とは全く違います

右辺の条件が成立する時に、左辺の式を評価してその結果を返します。 条件が成立しなければ nil を返します。 (http://docs.ruby-lang.org/ja/2.1.0/doc/spec=2fcontrol.html)

という流れでreturn を書けばこんな混乱しないのにという話がでました。

いやいや、return とか不要じゃない?という立場でこの記事を書いています。

ruby最後に評価される値 が返る、と言われますが、それはどのような意味なのでしょうか?

そんな本日は ruby の挙動についてのお話です。 ターゲットは、アセンブラに興味がある人です(アセンブラを使いこなす人は、対象外でお願いします (笑))

if と、if 修飾子 (後置 if) の違い

まず if と if 修飾子を使ったコードを書きます。

# return_if.rb

def if_expression(flag)
  if flag
    "NG"
  else
    "OK"
  end
end

def if_modifier(flag)
  "OK"
  "NG" if flag
end

そしておもむろに、ディスアセンブラを書きます(!)。

# disasm.rb
iseq = RubyVM::InstructionSequence.compile_file ARGV.first, false
print iseq.disasm

さきほどのファイルを食わせます

[master]~/projects/ruby_test/practice/if_expression: ruby disasem.rb return_if.rb
== disasm: <RubyVM::InstructionSequence:<main>@return_if.rb>============
0000 putspecialobject 1                                               (  20)
0002 putspecialobject 2
0004 putobject        :if_expression
0006 putiseq          if_expression
0008 send             <callinfo!mid:core#define_method, argc:3, ARGS_SKIP>
0010 pop
0011 putspecialobject 1                                               (  28)
0013 putspecialobject 2
0015 putobject        :if_modifier
0017 putiseq          if_modifier
0019 send             <callinfo!mid:core#define_method, argc:3, ARGS_SKIP>
0021 leave
== disasm: <RubyVM::InstructionSequence:if_expression@return_if.rb>=====
local table (size: 2, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, keyword: 0@3] s1)
[ 2] flag<Arg>
0000 getlocal         flag, 0                                         (  21)
0003 branchunless     11
0005 jump             7
0007 putstring        "NG"                                            (  22)
0009 jump             13                                              (  21)
0011 putstring        "OK"                                            (  24)
0013 leave
== disasm: <RubyVM::InstructionSequence:if_modifier@return_if.rb>=======
local table (size: 2, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, keyword: 0@3] s1)
[ 2] flag<Arg>
0000 getlocal         flag, 0                                         (  30)
0003 branchunless     11
0005 jump             7
0007 putstring        "NG"
0009 jump             12
0011 putnil
0012 leave

初めの ブロックはメソッド定義なので飛ばしまずが、 なんとなく気分はわかっていただけると思います。

2番目の if_expressonブロックと、 3番目の if_modifier ブロックを見ていくことにしましょう。

if_expression

2番めのブロックは、if_expression のコードです。

== disasm: <RubyVM::InstructionSequence:if_expression@return_if.rb>=====
local table (size: 2, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, keyword: 0@3] s1)
[ 2] flag<Arg>
0000 getlocal         flag, 0                                         (  21)
0003 branchunless     11
0005 jump             7
0007 putstring        "NG"                                            (  22)
0009 jump             13                                              (  21)
0011 putstring        "OK"                                            (  24)
0013 leave
  • 0番でflag 変数から値をとりだします。
  • 3番で、それが nil, false でなければ、11番に飛びます。

if なのに、branchunless というのは面白いですね。 これは、否定を確認した方が速いからでしょう。

flagfalse のとき、

  • 11番は、"OK"を スタックに積みます。
  • 13番で leave します。

flagtrue のとき、

  • 5番で、jump 7します。
  • 7番で "NG" を スタックに積みます。
  • 9番で jump 13 します。
  • 13番で leave します。

if_modifier

3番めのブロックは、if_modifier のコードです。

== disasm: <RubyVM::InstructionSequence:if_modifier@return_if.rb>=======
local table (size: 2, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, keyword: 0@3] s1)
[ 2] flag<Arg>
0000 getlocal         flag, 0                                         (  30)
0003 branchunless     11
0005 jump             7
0007 putstring        "NG"
0009 jump             12
0011 putnil
0012 leave
  • 0番でflag 変数から値をとりだします。
  • 3番で、それが nil, false でなければ、11番に飛びます。
  • 11番で putnilnil をスタックに積みます
  • 12番で lreave します。

はい、成立しないときは、nil が返りますね。仕様ですから仕方ないですよね。

あれ? "OK" は、どこに行ったのでしょう? putstring すらないとか、かわいそうですね。

return が不要な理由

ruby に return が不要な理由は、もうお分かりですね。

というかそもそも、return ではなくleaveですね。 ruby としては、メソッドの元から去る(leave)だけで、 値を返し(return)ているつもりはなさそうです。

return は単に、メソッドから去るタイミングを指定しているだけなので、 最後に書く必要はない、ということなのでしょう。

あれ?そんなのどの言語も一緒じゃん! と思われません?

ではここで、LL界の委員長 python にご出座いただきましょう。

python は return が必須です!

pythonは return が必須です。

def if_expression(flag):
    if flag:
        return "NG"
    else:
        return "OK"

import dis

dis.dis(if_expression)
[master]~/projects/python_test: python if_expression.py
  2           0 LOAD_FAST                0 (flag)
              3 POP_JUMP_IF_FALSE       10

  3           6 LOAD_CONST               1 ('NG')
              9 RETURN_VALUE

  5     >>   10 LOAD_CONST               2 ('OK')
             13 RETURN_VALUE
             14 LOAD_CONST               0 (None)
             17 RETURN_VALUE

左端はソース行なので、無視してください。

  • 0番で flag から ロードします
  • 3番で、それが FALSE なら、 10番にジャンプします

これも、falseをチェックするのはお決まりですね。

flagtrue のとき、

  • 6番で "NG" をスタックに積みます
  • 9番で、RETURN_VALUEします( return だよ!やったね!)

flagtrue のとき、

  • 10番で、"OK" をスタックに積みます。
  • 13番で、RETURN_VALUEします

あれ?2行残っていますね。

  • 14番で、Noneを スタックに積みます。
  • 17番で、RETURN_VALUEします

これは、python の関数が、return がないときはNoneを返す という仕様になっているからです。

pythonインデントがブロックを表すので、 この関数自体の返り値が書かれていません。 よって、最後に None を返すコードが暗黙的に追加されています。

さて、動作はrubyとほとんど同じですね。

では、return を書かないとどうなるか見てみましょう。

def if_expression(flag):
    if flag:
        "OK"
    else:
        "NG"

import dis
dis.dis(if_expression)
[master]~/projects/python_test: python if_expression.py
 17           0 LOAD_FAST                0 (flag)
              3 POP_JUMP_IF_FALSE        9

 18           6 JUMP_FORWARD             0 (to 9)

 20     >>    9 LOAD_CONST               0 (None)
             12 RETURN_VALUE

何もない……。

スタックに積んだ物が返る、というのはどの言語でも一緒ですが、 return をどのように取り扱うかで、コードが異なる、ということですね。

まとめ

  • if 修飾子 は 成立しないとき nil を返します
  • メソッドは最後にスタックに積まれた値を返します
  • RubyVM::InstructionSequenceVM のコードが読めます
  • ifbranchunless を判定します
  • rubyreturn は、メソッドの返りのタイミングの指定です。
  • pythonreturn は、関数の返り値の指定です。

ということで、ruby にとって、returnメソッドの返り値の指定ではなく、 どのタイミングでメソッドからleaveするかの指定、という意味なので、 実質returnが不要になる、ということのようです。

さて、rubyreturn 必要ですか

メソッドの値を返す場合は、必ずreturnを使用する。

Rubyコーディング規約

それと return なしのほうがちょっとだけ効率もいい。 それはなぜかというと RubyHackingGuide でも見てくれればわかるのだが、 return は結局 longjmp だからだ。

LoveRubyNet Wiki: RubyCodingStyle

Avoid return where not required.

styleguide/RUBY-STYLE at master · chneukirchen/styleguide

……あれ〜??

(おまけ) 人生、宇宙、すべての答え

ruby の ソースを追いかけていて、人生の答えを知りました。 さすが ruby ですね。

/**
  @c joke
  @e The Answer to Life, the Universe, and Everything
  @j 人生、宇宙、すべての答え。
 */
DEFINE_INSN
answer
()
()
(VALUE ret)
{
    ret = INT2FIX(42);
}

http://svn.ruby-lang.org/cgi-bin/viewvc.cgi/trunk/insns.def?view=markup

参考

RailsプロジェクトのRSpec3.0.0へのアップデート

皆さん、こんにちわ。MUGENUPの narikazu です。6月2日にRSpec 3.0.0がリリースされました(Myron Marston » RSpec 2.99.0 and 3.0.0 have been released!)。RSpec 3.0.0での変更点はMyron Marston » Notable Changes in RSpec 3あるいは、その日本語訳のRSpec 3の重要な変更 - 有頂天Rubyをご参照下さい。

ここでは、弊社Railsプロジェクト(Rails 3.2.18)でのRSpec3.0.0へのアップデート手順を記述いたします。この記事が皆さんのアップデート時の参考になれば幸いです。

アップデート手順

アップデート手順は以下の記事を参考に致しました。

正直、ほとんど上記記事と同じ手順を踏んでいます(汗)。しかし、異なる点もあるので、以下手順の大まかな流れをご説明します。

  1. 現在のテストが全てsuccessすることを確認
  2. rspec-railsのバージョンを2.99にアップデート
  3. 2.99の状態でテストを実行
  4. テスト結果のdeprecationを全て修正(transpecと必要なGemのインストールおよび手作業)
  5. rpsec-railsのバージョンを3.0.1にアップデート
  6. 3.0.1の状態でテストを実行
  7. テスト結果のdeprecationを全て修正(transpecと手作業)

以下では、各手順について詳細に記述していきます。

現在のテストが全てsuccessすることを確認

弊社では、CircleCIへブランチをpushすることをトリガーにテストを実行しています。以前はローカル環境にて並列でテストを高速でブン回すparallel_testsを使用しておりました。CircleCIはテスト結果がGithub上で表示されるので、とても便利ですね〜。少し話は逸れましたが、私の場合はRSpecアップデート用のブランチを作成し、pushしている間にrspec-railsのバージョンを上げる作業をしていました。

rspec-railsのバージョンを2.99にアップデート

Gemfileでrspec-railsのバージョンを2.99.0と指定し、bundle update rspecを実行します(bundle update rspec-railsではないので注意)。

# Gemfile
group :test, :development do
  gem 'rspec-rails', '~> 2.99.0'
  # ...
end

2.99の状態でテストを実行

RSpec 2.99でどのようなdeprecation出るか確認するために、全体テストを行います。私の場合はRSpec 2.99アップデートのcommitを積んでGithubにブランチをpushし、CircleCIがテストをブン回してくれました。

テスト結果のdeprecatedを全て修正(transpecと必要なGemのインストールおよび手作業)

2.99で出たdeprecationを放置して、3.0.0に上げるとそのテストは3.0.0に準拠した記述方法ではないのでテストは落ちてしまいます。よって、出てきたdeprecationは全てこのタイミングで修正しましょう。ちなみに、私は258個のdeprecationがありました。

2.99でのdeprecationの修正は以下の流れで行いました。

  1. RSpec3.0で外部化されたGemのインストール
  2. GemのTranspecを使って全自動でRSpec3.0系の記述方法に移行
  3. Transpecで対応できなかった箇所を手作業で修正

以下で各手順について詳細にご説明します。

RSpec3.0で外部化されたGemのインストール

`mock_model` is deprecated. Use the `rspec-activemodel-mocks` gem instead.

上記のdeprecationが出力された方はいらっしゃらないでしょうか?RSpec2.0系で使用できたmock_modelはRSpec3.0では使用できなくなり、rspec-activemodel-mocksとして外部Gemになりました。よって、このgemをGemfileに記述し、bundle installでインストールしましょう。

# Gemfile
group :test do
  gem 'rspec-activemodel-mocks'
  # ...
end

また下記のdeprecationが出力された方もいらっしゃるのではないでしょうか?

`expect(collection).to have(1).attachment` is deprecated. Use the rspec-collection_matchers gem or replace your expectation with something like `expect(collection.size).to eq(1)`

RSpec 2系でcollectionの数を検証するためにexpect(object).to have(2).itemsという構文がありました。RSpec 3ではこの構文はなくなり、expect(object.items.size).to eq 2と書く必要があります。個人的には、RSpec 2系の書き方は自然言語の英語のように書けて気に入っているので、従来の書き方を続けたいと思いました。そのような人のために、rspec-collection_matchersというGemが存在します。これもGemfileに記述し、bundle installを使ってインストールしましょう。

# Gemfile
group :test do
  gem "rspec-collection_matchers"
  # ...
end

弊社のRailsプロジェクトでは使用していませんでしたが、its記法もRSpec 3では使用できなくなり外部Gem化(rspec/rspec-its)しているようです。必要な方はインストールするとよいでしょう!

GemのTranspecを使って全自動でRSpec3.0系の記述方法に移行

RSpec2系を3.0の記述方法にコマンド一発で自動で移行してくれる神Gem transpecがあります。こちらをインストールし、コマンドでサクッと3.0の記述方法に書き変えましょう。インストールはgem i transpecで行います。使い方も簡単で私の場合はtranspec --no-explicit-spec-type --keep have_itemsというコマンドを打って一気に書き換えました。

注意していただきたいのは--no-explicit-spec-type--keep have_itemsオプションをつけていることです。

--no-explicit-spec-typeオプション

2系ではspecファイルが配置されているディレクトリから自動的にspecのタイプ(model, controller, feature等)を判別してくれました。しかし、RSpec 3ではこの機能がオフになっています。よって、Rspec 3ではspecタイプを明示する必要があります。引き続き配置しているディレクトリに応じてspecタイプを自動判別させるためには、spec_helper.rbinfer_spec_type_from_file_location!オプションを付けます。Transpecは自動的にspecタイプのメタデータを追加します。しかし、2系と同じ挙動が気に入っているので、--no-explicit-spec-typeオプションを指定して実行することで、specタイプのメタデータ追加が無効になり、代わりにinfer_spec_type_from_file_location!を自動的に追加します。

--keep have_itemsオプション

expect(object).to have(2).items構文を引き続き使用するべくrspec-collection_matchersのGemを入れた方は、Transpecでhave().item記法を変更させないように--keep have_itemsオプションを付けましょう。

Transpecで対応できなかった箇所を手作業で修正

いくつかTranspecで書換えを行った後もdeprecationが残っていたので、手で書き換えました。ここでは全てのdeprecationを修正しておきましょう。

rpsec-railsのバージョンを3.0.1にアップデート

2.99.0にアップデートした時と同様にGemfileでrspec-railsのバージョンを3.0.1と指定し、bundle update rspecを実行します(bundle update rspec-railsではないので注意)。

# Gemfile
group :test, :development do
  gem 'rspec-rails', '~> 3.0.1'
  # ...
end

3.0.1にアップデート後、再度全テストを通します。

テスト結果のdeprecationを全て修正(transpecと手作業)

3.0.1アップデート後にテストを実行すると以下のエラーで落ちました。

`raise_too_low_error': You are using capybara 2.1.0. RSpec requires version >= 2.2.0. (RSpec::Support::LibraryVersionTooLowError)

Gemのcapybaraのバージョンが低いと言われたので、bundle update capybaraでアップデートしました。

また以下のようなdeprecationが表示されました。

The Fuubar formatter uses the deprecated formatter interface not supported directly by RSpec 3.  To continue to use this formatter you must install the `rspec-legacy_formatters` gem, which provides support for legacy formatters or upgrade the formatter to a compatible version.

これはテストの進捗状況を可視化するgem fuubar(Ruby - Rspecのテスト進捗状況が可視化できるFuubarがいい感じ! - Qiita)のバージョンが低いため表示されます。Rspec 3に対応した2.0.0.rc1を指定しbundle update fuubarでアップデートしました。

# Gemfile
group :test, :development do
  gem 'fuubar', '~> 2.0.0.rc1'
end

そして、再度transpecを用いてdeprecationを消していきます。 なぜかというと2系のstub_chain記法がallow(obj).to receive_message_chain()という記法になるのですがRspec 3.0からでないと使えないため、3.0にアップデート後もtranspecを使用します。前回と同様transpec --no-explicit-spec-type --keep have_itemsコマンドを実行致しました。

これでほとんどのdeprecationが消えました。

また今回のRailsプロジェクトでは本来は通るfeatureテストが時々落ちることがあるので、一度落ちたテストをもう一度実行するモンキーパッチ(sorah / auto_retry.rb)を使わせていただいていました。しかし、Rspec3.0アップデートのapi変更でエラーが出るようになっていたので、少し書き換えました。以下のコードにより、featureスペックかit "hoge", auto_retry: true do ... endとしているテストは落ちるともう一度だけテストを実行してくれます。

# spec/support/auto_retry.rb

module RSpec
  module Core
    class Example
      def run_with_retry(example_group_instance, reporter)
        @retrying_count = 0
        succeeded = run_without_retry(example_group_instance, reporter)

        unless succeeded
          return finish_without_retry(reporter) unless retry_needed?

          $stderr.puts "[RETRY] #{self.location}: #{self.full_description}"
          @retrying_count += 1

          # Initialize itself (originally this has done in ExampleGroup#run_example)
          @exception = nil
          # before_all_ivarsをbefore_context_ivarsに書き換え
          example_group_instance = example_group_instance.class.new.tap do |group|
            group.class.set_ivars(group, group.class.before_context_ivars)
          end

          succeeded = run_without_retry(example_group_instance, reporter)
        end

        succeeded
      end

      alias_method :run_without_retry, :run
      alias_method :run, :run_with_retry

      private

      def finish_with_retry(reporter)
        if (@retrying_count || 0) < 1 && @exception
          false
        else
          finish_without_retry(reporter)
        end

      end

      alias finish_without_retry finish
      alias finish finish_with_retry

      def retry_needed?
        # 元々は@optionsで[:auto_retry]としていたが、metadata[:auto_retry]に書き換え
        metadata[:auto_retry] == true || file_path =~ /spec\/features/
      end
    end
  end
end

その他にはguardのバージョンが低くて動かないようになっていたので、最新のものにアップデートしRspec3でも動くようにしました。

まとめ

以上、弊社のRailsアプリでのRspec3.0へのアップデート手順を記述してみました。作業自体は2〜3時間で終わりました。transpecという神Gemがあるので思ったよりも大変ではなかったですので、皆さんもアップデートしてはいかがでしょうか?

またここで網羅できなかったことおよび、基本的な手順は以下の記事から得ることができます。

【MySQL】`SELECT id FROM news ORDER BY published_at DESC` と `SELECT * FROM news ORDER BY published_at DESC` では結果が異なることについて

皆さん、こんにちは。 暖かくなってきたので Tシャツとサンダルで通勤しちゃったりする方、 MUGENUP の osada です。 服装に気を使わなくてよい(わけではないのですが)というのは、 エンジニアの利点の一つですよね。

さて、先日、こんな現象が持ち込まれました。

「下の2つのコードで、結果が異なるんですが……?」

News.order("published_at DESC").map(&:id)
News.order("published_at DESC").select(:id).map(&:id)

今日はMySQL の order で * は特別な動きをする(ようだ)というお話です。

対象は、上記の2つの結果が異なる理由が分からない人です。

Rails ではなく、MySQL レベルの挙動の違いだった

まず疑ったのが、Rails で挙動を書き換えているんじゃないか? ということです。 select をハックして、SQLレベルでは、order が書き換わっているのでは?

しかし、結果はNOでした。

[2] pry(main)> News.order("published_at DESC").map(&:id)
  News Load (4.4ms)  SELECT `news`.* FROM `news` ORDER BY published_at DESC
=> [10, 11, 12, 13, 14, 15, 16, 9, 8, 7, 6, 5, 4, 3, 2, 1]
[3] pry(main)> News.order("published_at DESC").select(:id).map(&:id)
  News Load (0.5ms)  SELECT id FROM `news` ORDER BY published_at DESC
=> [15, 14, 13, 12, 11, 10, 16, 9, 8, 7, 6, 5, 4, 3, 2, 1]

試しに、発行されたSQLを、db console から叩きましたが、結果は同じでした。

10 から 15 までの順番が、全く逆順になっているのです。

そこに注目してみると、 id が 10 から 15 のレコードの、published_at は 値が同じ でした (これはバグだったので、プルリクで止めました)。

そういえば、ORDER BY で対象カラムの値が同じとき、MySQL はどのような挙動を示すのでしょうか?

MySQLオプティマイザトレース

幸いにも、MySQLには、実行計画を見る仕組みが備わっていました。

[D14] MySQL 5.6時代のパフォーマンスチューニング *db tech showcase 2013 Tokyo

こちらの 17ページ の通りにやってみます。

mysql> set session optimizer_trace='enabled=on,one_line=off';
Query OK, 0 rows affected (0.17 sec)

mysql> set session optimizer_trace_max_mem_size=102400;
Query OK, 0 rows affected (0.01 sec)

mysql> SELECT id FROM `news` ORDER BY published_at DESC;
......

mysql> select * from information_schema.optimizer_trace\g;
......
| SELECT `news`.id FROM `news` ORDER BY published_at DESC | {
  "steps": [
    {
      "join_preparation": {
        "select#": 1,
        "steps": [
          {
            "expanded_query": "/* select#1 */ select `news`.`id` AS `id` from `news` order by `news`.`published_at` desc"
          }
        ]
      }
    },
    {
      "join_optimization": {
        "select#": 1,
        "steps": [
          {
            "table_dependencies": [
              {
                "table": "`news`",
                "row_may_be_null": false,
                "map_bit": 0,
                "depends_on_map_bits": [
                ]
              }
            ]
          },
          {
            "rows_estimation": [
              {
                "table": "`news`",
                "table_scan": {
                  "rows": 16,
                  "cost": 1
                }
              }
            ]
          },
          {
            "considered_execution_plans": [
              {
                "plan_prefix": [
                ],
                "table": "`news`",
                "best_access_path": {
                  "considered_access_paths": [
                    {
                      "access_type": "scan",
                      "rows": 16,
                      "cost": 4.2,
                      "chosen": true,
                      "use_tmp_table": true
                    }
                  ]
                },
                "cost_for_plan": 4.2,
                "rows_for_plan": 16,
                "sort_cost": 16,
                "new_cost_for_plan": 20.2,
                "chosen": true
              }
            ]
          },
          {
            "attaching_conditions_to_tables": {
              "original_condition": null,
              "attached_conditions_computation": [
              ],
              "attached_conditions_summary": [
                {
                  "table": "`news`",
                  "attached": null
                }
              ]
            }
          },
          {
            "clause_processing": {
              "clause": "ORDER BY",
              "original_clause": "`news`.`published_at` desc",
              "items": [
                {
                  "item": "`news`.`published_at`"
                }
              ],
              "resulting_clause_is_simple": true,
              "resulting_clause": "`news`.`published_at` desc"
            }
          },
          {
            "refine_plan": [
              {
                "table": "`news`",
                "access_type": "table_scan"
              }
            ]
          }
        ]
      }
    },
    {
      "join_execution": {
        "select#": 1,
        "steps": [
          {
            "filesort_information": [
              {
                "direction": "desc",
                "table": "`news`",
                "field": "published_at"
              }
            ],
            "filesort_priority_queue_optimization": {
              "usable": false,
              "cause": "not applicable (no LIMIT)"
            },
            "filesort_execution": [
            ],
            "filesort_summary": {
              "rows": 16,
              "examined_rows": 16,
              "number_of_tmp_files": 0,
              "sort_buffer_size": 14544,
              "sort_mode": "<sort_key, additional_fields>"
            }
          }
        ]
      }
    }
  ]
} |                                 0 |                       0 |
.....

凄い色々でてきました。 一つ一つ見ていくと、大変興味深そうですが、 今回は、違いだけに注目したいので、 さっそくもう一つのSQLの実行計画も確認します。

mysql> SELECT * FROM `news` ORDER BY published_at DESC;

mysql> select * from information_schema.optimizer_trace\g;

すると、ある一点だけが異なりました。

            "filesort_summary": {
              "rows": 16,
              "examined_rows": 16,
              "number_of_tmp_files": 0,
              "sort_buffer_size": 10908,
              "sort_mode": "<sort_key, rowid>"
            }

filesort_summarysort_mode です

SQL sort_mode
SELECT id FROMnewsORDER BY published_at DESC; "<sort_key, additional_fields>"
SELECT * FROMnewsORDER BY published_at DESC; "<sort_key, rowid>"

SELECT * のときは、sort_key の他に rowid が使用されるようです。 つまり、published_at が同じときは、rowid でソートされるのですね。

MySQL の rowid ?

この row_id については、ググると下記の情報が見つかりました。

8.2.1.15 ORDER BY Optimization

The sort_mode value provides information about the algorithm used and the contents of tuples in the sort buffer:

  • <sort_key, rowid>: Sort buffer tuples contain the sort key value and row ID of the original table row. Tuples are sorted by sort key value and the row ID is used to read the row from the table.
  • <sort_key, additional_fields>: Sort buffer tuples contain the sort key value and columns referenced by the query. Tuples are sorted by sort key value and column values are read directly from the tuple.

  • <sort_key, rowid>: ソートバッファタプル には、ソートキー と テーブルの行id が含まれる. タプルは ソートキーと、テーブルから読まれた行IDによって、ソートされる。

  • <sort_key, additional_fields>: ソートバッファタプルには、ソートキーと、クエリで参照されるカラムが含まれる。タプルは、タプルから直接読み取られたソートキーとカラムによってソートされる。

……なかなか難しいですね!

これ以上の資料が見つからなかったため、挙動の類推でしかないのですが、 rowid のときは、元のテーブルの順序をそのまま維持する という動作なのではないかと思います。

実際、自分で比較アルゴリズムを書くならば、 同値のときに、わざわざ swap するプログラムにはしないと思うのです。

# 昇順 (後ろほど、値が大きい)

for(i = 0, length = tuples.length; i < length - 1; i++){
  if(tuples[i + 1] < tuples[i]){
    tmp = tuples[i];
    tuples[i] = tuples[i + 1];
    tuples[i + 1] = tuples[i];
  } else if(tuples[i + 1] == tuples[i]){
    # わざわざここに swap を書いたりしない。
  }
}

一方、クエリ上に カラムが存在した場合、当然ながらそれはソートします。

ということで、* と、それ以外で、結果が異なる、ということになったようです。

インデックスを貼るとどうなるか?

さてfilesortをみたのなら、当然のようにインデックスを貼りたくなるはずです。

mysql> alter table news add index published_at_idx (published_at);
Query OK, 0 rows affected (0.06 sec)
Records: 0  Duplicates: 0  Warnings: 0


mysql> SELECT id FROM `news` ORDER BY published_at DESC;
......
mysql> select * from information_schema.optimizer_trace\g;
......
          {
            "refine_plan": [
              {
                "table": "`news`",
                "access_type": "index_scan"
              }
            ]
          },
          {
            "reconsidering_access_paths_for_index_ordering": {
              "clause": "ORDER BY",
              "index_order_summary": {
                "table": "`news`",
                "index_provides_order": true,
                "order_direction": "desc",
                "index": "published_at_idx",
                "plan_changed": true,
                "access_type": "index_scan"
              }
            }
          }
......
mysql> SELECT * FROM `news` ORDER BY published_at DESC;
......
mysql> select * from information_schema.optimizer_trace\g;
......
          {
            "refine_plan": [
              {
                "table": "`news`",
                "access_type": "table_scan"
              }
            ]
          },
          {
            "reconsidering_access_paths_for_index_ordering": {
              "clause": "ORDER BY",
              "index_order_summary": {
                "table": "`news`",
                "index_provides_order": false,
                "order_direction": "undefined",
                "index": "unknown",
                "plan_changed": false
              }
            }
          }
......
            "filesort_summary": {
              "rows": 16,
              "examined_rows": 16,
              "number_of_tmp_files": 0,
              "sort_buffer_size": 10908,
              "sort_mode": "<sort_key, rowid>"
            }

* ではインデックスが使われず、id ではインデックスが使われました。 一体どうゆうことなんでしょうか?

MySQL :: MySQL 5.1 リファレンスマニュアル :: 6.2.12 ORDER BY最適化

によれば、index が貼られた カラムの order ならば、index が使われるはずですが……?

次のクエリではインデックスを使用して ORDER BY部分を解決します。

SELECT * FROM t1
  ORDER BY key_part1,key_part2,... ;

レコードを全件取得しているので、テーブルスキャンが最適だ、ということなのでしょうか? 試しに LIMIT 10 をつけてみると、plan_changed が発動していました。 なるほど、絞込をするときには、インデックスが使われるのですね。

mysql> SELECT * FROM `news` ORDER BY published_at DESC LIMIT 10;
......
          {
            "reconsidering_access_paths_for_index_ordering": {
              "clause": "ORDER BY",
              "index_order_summary": {
                "table": "`news`",
                "index_provides_order": true,
                "order_direction": "desc",
                "index": "published_at_idx",
                "plan_changed": true,
                "access_type": "index_scan"
              }
            }           

単に ORDER BY でインデックスを貼っても効果がないこともあるようなので、 必ず確認して使っていくようにしたいものです。

このindex 周りは奥が深いので、別の機会に、とさせていただこうと思います。 「待てないよ!」という方は、漢(オトコ)のコンピュータ道 が絶賛、オススメです!

まとめ

  • MySQLORDER BY は、ソート指定カラムの値が同値のときに、クエリによって、結果が異なることがあります。
  • SELECT * FROM のときは、rowid が使われるので、同値のときは、テーブルへ INSERT された順がそのままです。
  • SELECT some_column FROM のときは、ORDER BY の指定カラム以外に、some_column がソート対象に含まるため、それらがソートに使われます。
  • ORM を使っている人は、意識しづらいので、気をつけましょう。
  • 同値が疑われるときは、必ず id でも order しましょう. News.order("published_at DESC").order("id DESC").map(&:id)

ということで、思わぬところで、order 順が違ってしまった、という記事でございました。

後書き

なお、使用しているMySQLは、5.6.17 です

Server version: 5.6.17 Homebrew

紙幅の関係上、order の内容を文字列 にしましたが、arel_tableを使ったほうがせくしーです。

News.order(News.arel_table[:published_at].desc)

ところで、「Oracle には row_id があり、MySQL にはないので、テーブルの最後のレコードを見つけるのが大変だ」 という話を、昔同僚としたことがあるのですが、MySQL はいつから row_id が使えるようになったのでしょうか? Oracle に吸収されてからですか?

gem の内部を書き換える2つの方法 + おまけ(後から明示的に dependent: :destroy を止める方法 Rails3 限定)

皆さん、こんにちは。MUGENUP の osada です。 いきなりですが、

gem の内部を書き換えたい!

と思ったことはありませんか?

globalize というgem が、内部で

has_many :translations, dependent: :destroy

を実行するのですが、このdpendent: :destroyを止めたい、という要望から発生した、 gemの内部の挙動をオーバライドする方法のご紹介です。

想定する読者は、ruby を使い始めたばかりで、オープンクラスメタプログラミングを使ったことがない人です。

概要

dpendent: :destroyを止める方法として、下記の方法を紹介します。

  • オープンクラスを使ったパッチを当てる
  • module_evalを使ってパッチを当てる
  • has_many を再定義する
  • (Rails 3.2) destroy 用のメソッドをオーバーライドする
  • (Rails 3.2) destroy 用の callback を停止する

gem のクローンは最終手段

gem の挙動の一部を書き換えたいからといって、 gem を app などにコピーして使うのは、悪手だと考えています。 理由は、 バージョンアップに追従できない からです。 例えば、重要なセキュリティ修正 があった場合、それを取り込むのは大変難しくなるでしょう。

それよりも、gem をクローンし、パッチを宛てたものを 新しい gem として読み込む方が適切です。 この場合、クローン元であるオリジナルの gem をマージしてくることで、 バージョンアップに追従することができます。

しかしこれも、マージなどのメンテナンスコストを考えると最終手段だと考えます。

(もし、大きな修正が必要である場合、gem を継承して拡張するほうが適切でしょう)

オープンクラスを使ったモンキーパッチを当てる

軽度な修正であれば、モンキーパッチを当てる方が簡単です。 rubyオープンクラスは、拡張として使うことができます。

オープンクラスについては、 シンボルでも文字列でもアクセス可能なHashを使おう!ActiveSupport {Hash編} - MUGENUP技術ブログ でも触れました。

同名でクラス定義を行うと、クラスの再定義(上書き)ではなく、クラスへの追加拡張になる仕組み。

です。 つまりそれは、gem の中で定義したものであっても、app 側で再定義できる、ということです。

今回書き換えたい globalizehas_many は、vendor/bundle/gems/globalize-3.1.0/lib/globalize/active_record/act_macro.rb の、def setup_translates!(options)に定義されています。

module Globalize
  module ActiveRecord
    module ActMacro
      ......

      def setup_translates!(options)
        ......

        has_many :translations, :class_name  => translation_class.name,
                                :foreign_key => options[:foreign_key],
                                :dependent   => :destroy,
                                :extend      => HasManyExtensions,
                                :autosave    => false
        ......
      end
    end

    ......
  end
end

まずこのファイルを、config/initializer/extensions/globzlize/active_record/act_macro.rb にコピーし、setup_translates!以外を削除、has_many から dependent: :destroy を 削除します。

module Globalize
  module ActiveRecord
    module ActMacro
      protected
      def setup_translates!(options)
        ......

        has_many :translations, :class_name  => translation_class.name,
                                :foreign_key => options[:foreign_key],
                                :extend      => HasManyExtensions,
                                :autosave    => false
        ......
      end
    end
  end
end

以上、終了です。簡単ですね!

別の場所でクラスを開きなおして、メソッドを再定義するだけで良い のですから、 わざわざ gem をコピーしたり、クローンしたりする必要はありません。

メソッドチェインを使用する。

上記のメソッドを再定義するタイプのパッチは、非常に簡単ですが、 alias_method_chain などのメソッドに紐づいた実装を切ってしまう、という欠点があります。 どこかで誰かが、さらにパッチを当てているかもしれません。 できれば、メソッドチェインタイプのパッチを宛てる方が良いでしょう

module Globalize
  module ActiveRecord
    module ActMacro
      def setup_translates_with_stop_dependent_destroy!(options)
        setup_translates_without_stop_dependent_destroy!(options)

        has_many :translations, :class_name  => translation_class.name,
                                :foreign_key => options[:foreign_key],
                                :extend      => HasManyExtensions,
                                :autosave    => false
      end

      alias_method_chain :setup_translates!, :stop_dependent_destroy
    end
  end
end

実際、このglobalizeglobalize-versioning という拡張gemを用意しており、 このタイプの拡張でなければ、同時に使うとエラーになってしまいます。

module_eval を使ってパッチを当てる

さて、alias_method_chain であれば、オープンクラスである必要はなさそうな気がします。 それがもう一つの方法、module_eval を使う方法です。

config/initializers/globalize_patch.rb に先ほどのパッチをコピーします。 先ほどとは異なり、名前空間が異なるので、HasManyExtensions は、 ::Globalize::ActiveRecord::HasManyExtensions のように宣言する必要があります。

Globalize::ActiveRecord::ActMacro.module_eval do
  def setup_translates_with_stop_dependent_destroy!(options)
    setup_translates_without_stop_dependent_destroy!(options)

    has_many :translations, :class_name  => translation_class.name,
      :foreign_key => options[:foreign_key],
      :extend      => ::Globalize::ActiveRecord::HasManyExtensions,
      :autosave    => false
  end

  alias_method_chain :setup_translates!, :stop_dependent_destroy
end

以上、終了です。簡単ですね!

といっても、これらはコードを読む側からすると、 どこで何が再定義されているかわからない知らないところで何かが勝手に変わっている 、という意味で魔術的手法です。

用法・用量を守ってお使いください

オープンクラスと module_eval(class_eval) の違い

さて、この2つはどう違うのでしょうか?

みんな大好き stackoverflow に、回答がありましたので、引用します。 ruby - class_eval and open classes - Stack Overflow

reopen class will not inform you (but class_eval will raise error) if the existing class does not exist or not loaded.

class_eval は拡張元のクラスがないとき エラーになる。オープンクラスは何も教えてくれない。

オープンクラスというのは、クラスを定義する際の特徴的な動き、というだけのことであり、 そもそも拡張を意図としている訳ではない、ということですね。

特徴的なオープンクラス

他の言語では、module_eval のような拡張の仕方がメインなのではないでしょうか。

例えば、python であれば、後からクラスにメソッドを追加するときは、定義したメソッドを追加する、というコードになります。

まず、オープンクラスではないので、class A を再定義すれば、メソッドは消えてしまいます。

class A(object):
    def hello(self):
        print "hello", self.__class__.__name__

A().hello()
# => hello A

class A(object):
    def bye(self):
        print "bye", self.__class__.__name__

A().hello()
# Traceback (most recent call last):
#   File "tmp.py", line 11, in <module>
#     A().hello()
# AttributeError: 'A' object has no attribute 'hello'

よって、メソッドを定義し、クラスに追加することで、拡張します。

class A(object):
    def hello(self):
        print "hello", self.__class__.__name__

def bye(self):
    print "bye", self.__class__.__name__

A.bye = bye

a = A()
a.bye()
a.hello()
# => bye A
# => hello A

メソッドへの割り込みは例えば以下のようになるかもしれません (他にも拡張の方法があると思います)。

class A(object):
    def hello(self):
        print "hello", self.__class__.__name__

def hello_with_bye(self):
    self.hello_without_bye()
    print "bye", self.__class__.__name__

A.hello_without_bye = A.hello
A.hello = hello_with_bye

A().hello()
# => hello A
# => bye A

coffeescript(javascript) の場合も同じです。

class A
  hello: ->
    console.log "hello"

new A().hello()
# hello

class A
  bye: ->
    console.log "bye"

new A().hello()
# TypeError: Object #<A> has no method 'hello'
#   at Object.<anonymous> (/Users/osada/tmp.coffee:11:9)
#   at Object.<anonymous> (/Users/osada/tmp.coffee:1:1)
#   at Module._compile (module.js:456:26)
class A
  hello: ->
    console.log "hello"

A.prototype.bye = -> console.log "bye"

a = new A()
a.hello()
a.bye()
# hello
# bye
class A
  hello: ->
    console.log "hello"

A.prototype.hello_without_bye = A.prototype.hello

A.prototype.hello = ->
  this.hello_without_bye()
  console.log "bye"

new A().hello()
# hello
# bye

……ちょっと例が悪かったでしょうか。 pythonjavascript は大変親和性が高いので、 同じようなコードになってしまいました。

いずれにせよ、

  1. クラス定義
  2. クラスに対して、メソッドを追加

という手順を踏むことになります。

オープンクラス のように

  1. クラス定義
  2. 同名のクラス定義を使用して、定義の続きを行う

という動作は、他の言語と比べると、かなり特徴的であると思います。

予想外の箇所でオーバライドしてしまうこともあるため、 理解して使うことがオススメです。

コラム [オープンクラスでテストが落ちた]

単体テストは通るのに、全体テストが通らない、という原因がオープンクラスだったことがあります。 モジュールのテストのために、適当なクラスを宣言して、include して使っていた所、

class Foo
 include SomeModule
end

別の箇所でも Foo を定義しており、全体テストのときのみ、再定義が発動して テストが落ちていました。 他の言語であれば、同名のクラスは再定義されるので、no method errorで落ちるところが、 オープンクラスでは、メソッド自体は定義されないので落ちないが、 他で定義されたコードにより想定外の干渉が起きるということです。 オープンクラスならではだと思います。

has_many を再定義する

さて、パッチは魔術だ、というお話を致しました。 実際、該当のソースを読んだだけでは、dependent: :destroy が無効化されていることには気づけないでしょう。

できるなら、明示的に オーバライドしていることを宣言したいところです。

この globalizetranslates メソッドを呼ぶときに、内部で has_many を定義しています。

translates :description, versioning: :paper_trail
# translates メソッドを呼ぶと、gem 内のメソッドで下記が実行される
has_many :translations, :class_name  => translation_class.name,
                                :foreign_key => options[:foreign_key],
                                :dependent   => :destroy,
                                :extend      => HasManyExtensions,
                                :autosave    => false

よって、translates の後に has_many を再定義してしまえば、上書きされます。

例えば、Project というクラスに使用した場合、 translation_class.nameProject::Translation になります。

class Project
  translates :description, versioning: :paper_trail

  has_many :translations, :class_name  => Project::Translation,
    :foreign_key => nil,
    :extend      => ::Globalize::ActiveRecord::HasManyExtensions,
    :autosave    => false

これを毎回書くのは大変なのですが、読み手には優しくなります。 もう少し楽に書きながら、明示する方法はないでしょうか?

destroy 用のメソッドをオーバーライドする (Rails3.2)

dependent: :destroy は、before_destroy で発動します。

このメソッドをオーバーライドしてしまえば、 事実上、destroy を止めることができます。

メソッドはhas_many で定義されており、名前は "has_many_dependent_for_#{name}"。 つまり今回で言うと、"has_many_dependent_for_translation" です。

def configure_dependency
  if options[:dependent]
    ............
    model.before_destroy dependency_method_name
  end
end

def dependency_method_name
  "has_many_dependent_for_#{name}"
end

よって、translates の後に、true を返すメソッドとして再定義すればOKです。

translates :description, versioning: :paper_trail
has_many_dependent_for_translations = -> { true }

このように再定義できることを知っておくと、コールバックに割り込みができて便利です。 しかし単にコールバックを止めることはできないのでしょうか? 実は、skip_callbackとして下記のコードを書いても、停止されなかったのです。

translates :description, versioning: :paper_trail
skip_callback :destroy, :before, :has_many_dependent_for_translations

ここには一つ、バッドノウハウを知っている必要があります。

destroy 用の callback を停止する(Rails3.2)

結論から書くと、dependen: :destroy のコールバック自体を止めるには下記のように書きます。

translates :description, versioning: :paper_trail
skip_callback :destroy, :before, "(has_many_dependent_for_translations)"

括弧をつけたメソッド名の文字列 です。

callback には、メソッド名のシンボル、文字列、または Proc を設定できます。 ここに登録されるときの名前が、文字列の場合には 括弧が付く ようです。

def _compile_filter(filter)
        method_name = "_callback_#{@kind}_#{next_id}"
        case filter
        when Array
          filter.map {|f| _compile_filter(f)}
        when Symbol
          filter
        when String
          "(#{filter})”

こちらは destroy コールバック自体を止めているので、一番適切な止め方だと考えています。 括弧が付くというのは、かなりのハマりどころでした。

しかし残念なことに、Rails4.0 でこの実装はなくなってしまいました。 汎用的にコールバックを停止することは難しいようです。

Rails 4 では、メソッド名に由来する削除ができない

Rails 4 では、コールバックの登録が lambda になっており、 メソッド名を使用した、オーバーライドや、コールバックの停止、が出来なくなっています。

def self.add_before_destroy_callbacks(model, reflection)
  .......
  model.before_destroy lambda { |o| o.association(name).handle_dependency }
end

before_destroy での登録が lambda なので、 skip_callback を使おうとしても対象が宣言できないためです。

よって、対応策として、下記の2つを考えました。

  • reset_callbacks(:destroy)
  • callbacks を直接操作する

reset_callbacks は全てのコールバックを消してしまうため、 dependent: :destroy 以外の destroy callback を使っている gem には、使用できません。 また、他の destroy よりも前に宣言しておく必要があります。

translates :description, versioning: :paper_trail
reset_callbacks(:destroy)

一方、callbacksを直接操作するのは、かなりの黒魔術です。

translates :description, versioning: :paper_trail
skip_callback :destroy, :before, _destroy_callbacks.instance_variable_get(:@chain).last.filter

こちらは、直近に登録された filter を取り出すことで、skip_callback を使っています。 こちらも、複数のcallbackが登録されていた場合、dependent: :destroylast とは 限らないため、慎重な使い方が要求されますし、使うべきではないでしょう。

よって、Rails 4 の場合、明示して打ち消すのは難しいようです

まとめ

gem の中を書き換えるには、下記の4つの方法が考えられます。

  • app に コピーして修正して使う
  • クローンして修正した gem を使う
  • オープンクラスでパッチを当てる
  • module_eval(class_eval) でパッチを当てる

dependent: :destroy を止めるには、Rails 3 系であれば、下記2つを使い、明示的に止めることができます。

  • destroy 用のメソッドをオーバーライドする
  • destroy 用の callback を停止する

しかし、Rails 4 では、パッチを当てることが有効でしょう。

ということで、gem の内部を書き換える方法の共有でした。

検索用

今回、幾つかググっても情報が出てこなかったので、検索用に英語用のワードも記載しておきます。 この記事が誰かの助けになることができれば幸いです。

Cancel (remove) the dependent destroy defined in gem

「Qiitaの中の人とQiita:Teamを使う人と共に理想のチームについて考えてみませんか?」というイベントに出てきました

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

2014年5月15日(木)に開催された「Qiitaの中の人とQiita:Teamを使う人と共に理想のチームについて考えてみませんか?」というイベントでQiita:Teamを使う人としてお話させていただきました。

f:id:mgnup:20140518194749j:plain

MUGENUPでは昨年の夏頃からQiita:Teamを使っています。 以前にはIncrementsさんに事例として取り上げていただきました。

Qiita:Teamを使った議事録の共有・保存で仕事の効率化をめざす MUGENUP CTO 伊藤勝悟氏 - Qiita Blog

イベント当日はIncrements代表取締役の海野さんと二人で「チーム」「組織」「情報共有」「コニュニケーション」と言った切り口から各社でどのような取り組みをしているかについてお話させていただきました。

ここでは弊社のコミュニケーションについて起こった問題と解決のためのトライを書いてみます。

エンジニアと他部署でのコミュニケーションの必要性

開発部ではクラウドソーシングのためのクリエイター、クライアント、MUGENUPを結ぶ管理システムをメインで開発しています。 このシステムは社内の殆どの人が日々の仕事をする上で多くの時間を割く重要なものです。

自分たちが作っているものを実際に使う人(=社内の人)とコミュニケーションを取ることは重要な事です。

人が増えてオフィスが増えた

現在MUGENUPはオフィスが3部屋あります。 これまで人数の増加に伴い段々と拡張してきました。

開発部も途中から新しい部屋にお引っ越しになり、当初は広い部屋を専有して開発していたのですが、あっという間にその部屋も埋まってしまいました。

コミュニケーションが減る

部屋がわかれると他の部屋にいる人たちとのコミュニケーションが大きく減ります。 一日中顔も合わせないということもざらにあります。

自分たちが作っているものを実際に使っている人とのコミュニケーションが減ってくると、 今まで出来ていた開発時の意思疎通が難しくなってきます。

どういう対応をしているか

そこで、現在開発部と別の部署でコミュニケーションを取る機会を増やしています。 一緒にランチしたり、時間をとってもらいどのように仕事をしているかの話を聞いたりなどです。

ただ、これは一時的な解決の部分もあり、より人数が増えた場合どうするのかといった問題も有ります。 そのような点も踏まえて開発の仕組みを考える必要があると再認識しました。

(全社的な取り組みについて)

イベントでお話した際に皆さんの反応が大きかったのが「パワーランチ」という取り組みです。 これは新しく入った社員の方が「他の部署の人たちとなかなか接する機会がない」ということで提案した企画でした。

内容は週に一回各部署から何人かずつ選び、その人達でランチに行く(経費は会社持ち)というものでした。

3月くらいから初めて、先日全社員が一通り行き終わったところだったのですが、私も普段から開発部の人たちとしかランチには行かないのでなかなか新鮮な体験でした。

「うちの会社でも似たようなことやってます」という方もいらっしゃったので、結構メジャーな企画なんですかね?

ドメイン名を使ってEC2を運用していたら、ELBのスケールアウトで苦労した話

2014年5月14日 13時00分 修正 タイトルが誤解を招くものだったので、「なぜ URL に www を付けるのか。または、サブドメインなしでは CNAME が使えない件」から変更致しました。併せて、画像に Public IP と Private IP の明記を行いました。

皆さん、こんにちは。MUGENUP の osada です。 今回は、スケールアウト時にELB(Amazon Elastic Load Balancer) を使うときの注意点についての記事です。 といっても、インフラ・エンジニアには自明のことと思いますので、読者の対象は インフラ・エンジニアではないけど、インフラもやるというベンチャーならではのエンジニア向けです。

要旨

  • ELBにはEIPなどのAレコードを関連付けることが出来ず、ドメインとサーバーを結びつけるには提供されるCNAMEを使う必要があります
  • サブドメイン無しのドメイン名 (zone apex) には、CNAMEレコードを貼ることはできません。
  • ELB を使ったスケールアウトを考えているなら、zone apex ではなく、サブドメイン で運用すると簡単です
  • zone apx を使って運用する場合、スケールアウトには、route 53 が必要そうです。

弊社 のインフラは AWS です。

まず弊社のインフラをお話すると、AWS(Amazon Web Service)を使用しており、CTO の伊藤がメイン・オペレーションを担当しています (彼は TCP/IP や WebSocket の RFP などを嬉々として語る通信系のツワモノです)。 しかし、それでは CTO が 単一障害点(Single Point of Failure, SPOF) となってしまうため、 社員全員が AWS へのアクセス権を持ち、一通りオペレーションができる状態となっています。

スケールアウトの頓挫

さて、先日、ご好評いただいている弊社のゲーム攻略サイトみなゲー(β版)が アクセス増のため、スケールアウトが必要となりました。

サーバ1台だったものを、ロードバランサーを使って、2台構成にする という、とても単純な作業です。 ……と、その時はまだ思っていたのです。

元々は、サーバ1台に対して、IP を使って DNS を貼っていました。

f:id:mgnup:20140514121236p:plain

そこで、ELBに従来のIPを設定し、ELBから、新しい2台のサーバに振り分けることを計画しました。

f:id:mgnup:20140514121239p:plain

ところが、AWSロードバランサーである、ELB (Elastic Load Balancing) には、 IP を振ることができなかったのです。

ELB が IP を持たない理由

SPOF(単一障害点) の話を上述しましたが、これはロードバランサーにおいても同じです。 もしロードバランサー1台が壊れてしまったら、どのサーバにもアクセスできなくなってしまいます。 ELBはそれを避けるため、複数台のロードバランサーを1つとして扱っているようです。 そのため、ELB 自体には、DNS が振られるのであって、IP を振ることはできません。

f:id:mgnup:20140514121241p:plain

IP を振る とは? [DNS の設定]

DNS とは、ドメイン名(mina-game.com など)を扱うサービスです。

ドメイン名の扱い方には複数ありますが、 本稿では、IPアドレスと結びつけるAレコードと、別名を付けるCNAMEレコードを取り上げます

レコード名 役割
A (Adress) レコード IPアドレスと結びつける mina-game.com => 123.456.78.90
CNAME レコード ドメイン名に別名を付ける mina-game.com => www.mina-game.com

今まで、mina-game.com にアクセスすると、123.456.78.90 のサーバにアクセスできていたのは、 Aレコード が付いていたからです。

ところが、ELB には IP を付けられない ため、Aレコードを貼ることができません。

よって、この場合、 CNAME を貼る ことが正解となります。

……そう、その時は、それで解決すると思っていたのです。

CNAME には、サブドメイン(ホストドメイン)が必須

ところが、ドメインmina-game.com に対して、CNAME を振ることは出来ない のです。

f:id:mgnup:20140514121244p:plain

少々長いですが、AWS から引用します。

ドメイン名を登録する際は、ドメイン名自体だけでなく、一連のサブドメイン名全体も予約することになります。例えば、example.com をカスタムドメイン名として登録する場合、foo.bar.example.com や foo.myLB.example.com などのサブドメイン名を作成することができます。この一連のドメインサブドメイン名は、ゾーンと呼ばれます。予約した example.com などのドメイン名は、ゾーンの階層の最上部にあるため、Zone Apex と呼ばれます。 DNS ルールでは Zone Apex(example.com など)での CNAME レコードの作成が許可されていません。例えば、example.com というドメイン名を所有している場合、CNAME レコードはサブドメイン名である foo.example.com には使用できますが、Zone Apex である example.com には使用できません。 http://docs.aws.amazon.com/ja_jp/ElasticLoadBalancing/latest/DeveloperGuide/using-domain-names-with-elb.html

mina-game.comゾーンの階層の最上部にある、Zone Apex であり、それには CNAME を使用する事ができないのです。

もし、www.mina-game.com などの サブドメイン を指定してあったなら、 cname の差し替えで期待どおりの処理が終わっていたことでしょう。 しかし、今回は zone apex で運用していたため、ELB を使ったスケールアウトをすることはできませんでした。

Route 53 を使用する

zone apex で運用を始めてしまった以上、ELB でのスケールアウトは無理なのでしょうか?

AWSのヘルプの続きに、こう書いてあります。

Zone Apex とロードバランサーDNS 名を関連付ける場合は、次のオプションを使用します。 Option 2: DNS サービス Amazon Route 53 はホストゾーンのドメインに関する情報を格納するため、Amazon Route 53 を使用してドメインを作成します。

あ、Route 53 使えば良いんですね。

まるでこのために作られたのではないかと思えるくらい、 完璧なお膳立てで、AWS 製の DNS Amazon Route 53 を案内されてしまいました。

というわけで、zone apex を使って AWS ELB でスケールアウトするには、route 53 を使おう、という結論に落ち着きました.

f:id:mgnup:20140514121247p:plain

元々は使用していなかったサービスが一つ追加で必要になってしまいましたが、無事目的を達成出来ました。

まとめ

  • ELBにはEIPなどのAレコードを関連付けることが出来ず、ドメインとサーバーを結びつけるには提供されるCNAMEを使う必要があります
  • サブドメイン無しのドメイン名 (zone apex) には、CNAMEレコードを貼ることはできません。
  • ELB を使ったスケールアウトを考えているなら、zone apex ではなく、サブドメイン で運用すると簡単です
  • zone apx を使って運用する場合、スケールアウトには、route 53 が必要そうです。

というわけで、インフラ初心者が送る スケールアウトで困った経験の共有でございました。

参考

もっと詳しい解説

【Rails3.2】【バッドノウハウ】includes 使用時に where で ドットを使うと想定外になるかも知れない件、または、なぜRails4 から references が必要になったのか

皆さん。お久しぶりです。MUGENUP の osada です。 某死にすぎるゲームの1周目をクリアしたので、戻ってまいりました! 目標を達成するために大事なことは観察力である、ということを思い知らされるゲームでした。

さてそんな今回は、Rails 3.2includes に関するバッドノウハウの共有です。 弊社 okuda が、トライ・アンド・エラーの末に見つけた、バグの解説になります。 Rails 4 系 をお使いの方には不要ですので、何かの話のネタになれば幸いです。

includes は LEFT OUTER JOIN と eager loading の 2 種類がある

Railsでは、includes は 2種類の使い方ができます。 1. eager loading 2. LEFT OUTER JOIN

通常の使い方だと、(1) の eager loading となり、 where 句の中で、includes した テーブルを参照すると、 (2) の LEFT OUTER JOIN となる、という認識でした。

class User < ActiveRecord::Base
  attr_accessible :email, :name
  has_many :user_items
end

describe User do
  let!(:user) { create :user, name: "user0", email: "user0@example.com" }

  it "eager loading" do
    subject = User.where("`name` LIKE ?", "%user0%").explain
    expect(subject).to include "SELECT `users`.* FROM `users`  WHERE (`name` LIKE '%user0%')"
    # => User Load (0.9ms)  SELECT `users`.* FROM `users` WHERE (`name` LIKE '%user0%')
    # => UserItem Load (1.3ms)  SELECT `user_items`.* FROM `user_items` WHERE `user_items`.`user_id` IN (252)
  end

  it "outer join by string" do
    subject = User.includes(:user_items).where("user_items.number IS NULL").explain
    expect(subject).to include "LEFT OUTER JOIN"
    # => SQL (2.0ms)  SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`email` AS t0_r2, `users`.`created_at` AS t0_r3, `users`.`updated_at` AS t0_r4, `user_items`.`id` AS t1_r0, `user_items`.`user_id` AS t1_r1, `user_items`.`item_id` AS t1_r2, `user_items`.`number` AS t1_r3, `user_items`.`created_at` AS t1_r4, `user_items`.`updated_at` AS t1_r5 FROM `users` LEFT OUTER JOIN `user_items` ON `user_items`.`user_id` = `users`.`id` WHERE `user_items`.`number` IS NULL
  end
end

一体何が問題なのか?

上でLEFT OUTER JOIN になる条件を

where 句の中で、includes した テーブルを参照する と書いたのですが、それは誤りでした。

端的に言うと、 where 句の中で、ドットが含まれていれば、LEFT OUTER JOIN とみなす 、ということだったのです。

何が問題になるのでしょうか?

皆さん。where 句に email を使ったことはありませんか?

it "like email" do
  subject = User.includes(:user_items).where("`email` LIKE ?", "%user0@example.com%").explain
  expect(subject).to include "LEFT OUTER JOIN"
  expect(subject).to include "SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`email` AS t0_r2, `users`.`created_at` AS t0_r3, `users`.`updated_at` AS t0_r4, `user_items`.`id` AS t1_r0, `user_items`.`user_id` AS t1_r1, `user_items`.`item_id` AS t1_r2, `user_items`.`number` AS t1_r3, `user_items`.`created_at` AS t1_r4, `user_items`.`updated_at` AS t1_r5 FROM `users` LEFT OUTER JOIN `user_items` ON `user_items`.`user_id` = `users`.`id` WHERE (`email` LIKE '%user0@example.com%')"
end

user_itemsjoin するつもりがないのに、join されています。 email にはほとんど必ずドットが 含まれます。 email 以外では eager loading なのに、email で where をした途端に、いきなりLEFT OUTER JOIN になってしまうのです。 無論それは、email に限りません。nameでドットが含まれていれば同じことです。

it "like name with dot" do
  subject = User.includes(:user_items).where("`name` LIKE ?", "%user0.name%").explain
  expect(subject).to include "LEFT OUTER JOIN"
end

さらには、stringで構築しているから、という理由でもありません。 arel_table で構築しても、結果は同じです。

it "like name with dot on arel" do
   subject = User.includes(:user_items).where(User.arel_table[:name].matches("%user0.name%")).explain
   expect(subject).to include "LEFT OUTER JOIN"
end

なぜこんなことになってしまうのでしょうか?

それは、eager loadingLEFT OUTER JOIN を区別するものが、下記のメソッドだからです。

references_eager_loaded_tables? で、どちらなのかを決めますが、 その決定権を握るのがtables_in_stringです。

def references_eager_loaded_tables?
  joined_tables = arel.join_sources.map do |join|
    if join.is_a?(Arel::Nodes::StringJoin)
      tables_in_string(join.left)
    else
      [join.left.table_name, join.left.table_alias]
    end
  end

  joined_tables += [table.name, table.table_alias]

  # always convert table names to downcase as in Oracle quoted table names are in uppercase
  joined_tables = joined_tables.flatten.compact.map { |t| t.downcase }.uniq

  (tables_in_string(to_sql) - joined_tables).any?
end

def tables_in_string(string)
  return [] if string.blank?
  # always convert table names to downcase as in Oracle quoted table names are in uppercase
  # ignore raw_sql_ that is used by Oracle adapter as alias for limit/offset subqueries
  string.scan(/([a-zA-Z_][.\w]+).?\./).flatten.map{ |s| s.downcase }.uniq - ['raw_sql_']
end

string.scan の中で、sql の中に、ドットが含まれているかどうか、のみで判断していることが見て取れると思います。

末尾の行を pry した結果が下記です。

[1] pry(#<ActiveRecord::Relation>)> to_sql
=> "SELECT `users`.* FROM `users`  WHERE (`users`.`name` LIKE '%user0.name%')"
[2] pry(#<ActiveRecord::Relation>)> tables_in_string(to_sql)
=> ["users", "user0"]
[3] pry(#<ActiveRecord::Relation>)> joined_tables
=> ["users"]
[4] pry(#<ActiveRecord::Relation>)> (tables_in_string(to_sql) - joined_tables).any?
=> true

確かに、ドットで判断していることが分かります。

まとめ

以上が、Rails3.2で、includesに起きる、バッドノウハウです。 知っていればどうということはありませんが、意図せず想定外の結果を産むこともありますので、十分お気をつけ下さい。

むしろ、この機会に、最近リリースされたRails4.1.0に乗り換えるのも良いのではないでしょうか。

(おまけ) Rails 4.1 では、LEFT OUTER JOIN の使用に、references が必要になった。

さて、そんなわけで、Rails 4から、LEFT OUTER JOINしたい時は明示的に、references` を書くことになりました。

User.includes(:user_items).where("user_items.number IS NULL")
# => Mysql2::Error: Unknown column 'user_items.number' in 'where clause': SELECT `users`.* FROM `users`  WHERE (user_items.number IS NULL)

User.includes(:user_items).where("user_items.number IS NULL").references(:user_items)
# => SQL (0.4ms)  SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`email` AS t0_r2, `users`.`created_at` AS t0_r3, `users`.`updated_at` AS t0_r4, `user_items`.`id` AS t1_r0, `user_items`.`user_id` AS t1_r1, `user_items`.`item_id` AS t1_r2, `user_items`.`number` AS t1_r3, `user_items`.`created_at` AS t1_r4, `user_items`.`updated_at` AS t1_r5 FROM `users` LEFT OUTER JOIN `user_items` ON `user_items`.`user_id` = `users`.`id` WHERE (user_items.number IS NULL)

上述のメソッドは、下記のように書き換わりました

def references_eager_loaded_tables?
  joined_tables = arel.join_sources.map do |join|
    if join.is_a?(Arel::Nodes::StringJoin)
      tables_in_string(join.left)
    else
      [join.left.table_name, join.left.table_alias]
    end
  end

  joined_tables += [table.name, table.table_alias]

  # always convert table names to downcase as in Oracle quoted table names are in uppercase
  joined_tables = joined_tables.flatten.compact.map { |t| t.downcase }.uniq

  (references_values - joined_tables).any?
end

変更されているのは、末尾の (references_values - joined_tables).any? です。 3.2系のときは、(tables_in_string(to_sql) - joined_tables).any? でした。

これらの問題については、こちらで検討され、修正がされたようです。 includes eager loading is realized with LEFT OUTER JOIN strategy when querying with value contains 'dot' · Issue #7177 · rails/rails

Deprecatedになった、ということでしたが、今回試したところ、エラーになりました。 確認したところ、Rails 4.1 では、使用不可になったようです。 Remove implicit join references that were deprecated in 4.0.

(おまけ 2) Arel を使おう

事例を説明するために、where の中に文字列を書きましたが、sexy ではありません。 綺麗に書きましょう。

it "like name call outer join" do
    subject = User.includes(:user_items).where(user_items: { number: nil } ).explain
    expect(subject).to include "LEFT OUTER JOIN"
end

(おまけ 3)

使用したテストを置いておきます。

備考1

SQLの確認に、to_sql ではなくexplain を使っているのは、Rails 3系のto_sqlは、eager loading を考慮しないためです。 Rails 4系では修正されています。

ActiveRecord::Relation#to_sql does not match SQL executed when using includes · Issue #6132 · rails/rails