Quantcast
Channel: Active Recordの記事一覧|TechRacho by BPS株式会社
Viewing all 68 articles
Browse latest View live

Rails: Active Record 5.2のメモリ肥大化を探る(翻訳)

$
0
0

概要

原著者の許諾を得て翻訳・公開いたします。

翻訳には含めませんでしたが、元記事のコメントも興味深い内容です。

Rails: Active Record 5.2のメモリ肥大化を探る(翻訳)

Active Recordの現在のパターンはリソースの大量消費につながります。以下はRails 5.2を用いた分析です。


Ruby 3×3計画は、Matz率いるRubyコミュニティの高貴な目標のひとつです。このアイデアは、Rubyインタプリタを3倍高速にできる現代的な最適化を広範囲に渡って用いることであり、野心的かつ崇高かつエキサイティングな目標です。このムーブメントは、just-in-timeコンパイラメモリ肥大化の軽減にかかわる革新的な作業といったRubyコアにおける多数の興味深い実験につながりました。もしRubyが高速化とメモリ削減を実現したら、パフォーマンスの恩恵を誰でも無償で受けられることになり、それこそが私たちが望んでいるものです。

しかし大きな問題は、Rubyがさらなる高速化を達成できるマジック以外のものを当てにできないことです。Rubyがコードの奥深くに潜む(低速な)バブルソートを魔法のように修正できるようになる予定はありません。Active Recordの内部には、世のRubyアプリのほとんどを大きく高速化するために修正されるべき多くの無駄が潜んでいます。Rubyの最大消費者は結局Railsであり、そのRailsはActive Record次第なのです。

悲しいことに、Active RecordのパフォーマンスはRails 2の時代からそれほど向上していません。実際、遅い場合やめちゃくちゃ遅い場合が少なからずあるのです。

無駄の多いActive Record

まずはごく小さなサンプルコードから始めたいと思います。

Topicに含まれる30カラムのテーブルがあるとしましょう。

以下を実行すると、Active Recordのアロケーションがどのぐらいになるかおわかりでしょうか。

a = []
Topic.limit(1000).each do |u|
   a << u.id
end
Total allocated: 3835288 bytes (26259 objects)

上を、同じぐらい非効率な生SQL版と比較してみましょう。

sql = -"select * from topics limit 1000"
ActiveRecord::Base.connection.raw_connection.async_exec(sql).column_values(0)
Total allocated: 8200 bytes (4 objects)

驚くべき無駄の量です。これは次の2つからなります。

  • 著しいメモリ使用量
  • パフォーマンスの低下

訳注: 上のa << u.idという書き方には問題があります。詳しくは週刊Railsウォッチをご覧ください。

しかしこれがActive Recordの残念な点

ここで、私が「遅い」Active Recordコードを書いて、大きく最適化された生SQLコードと比較するのはインチキだという反応がたちどころに起こるでしょう。

次のように書くべきと言われるでしょう。

a = []
Topic.select(:id).limit(1000).each do |u|
  a << u.id
end

この場合の結果は以下のようになるでしょう。

Total allocated: 1109357 bytes (11097 objects)

次のように書けばさらによい結果になるとも言われるでしょう。

Topic.limit(1000).pluck(:id)

この場合の結果は以下のようになるでしょう。

Total allocated: 221493 bytes (5098 objects)

ここまでを取り急ぎまとめてみます。

  • 「生SQL版」で割り当てられるのはわずか4オブジェクトであり、1000個のIntegerを直接返すことができます。これらはRubyのヒープに個別に割り当てられることもありませんし、GC対象スロットになることも多くありません。
  • 「ネイティブ版」Active Recordでは26259個のオブジェクトが割り当てられます。
  • 「わずかに最適化された」Active Recordでは、11097個のオブジェクトが割り当てられます。
  • 「大きく最適化された」Active Recordでは、5098個のオブジェクトが割り当てられます。

上のいずれについても、差は4桁を上回ります。

ナイーブな実装とlazyな実装でのオブジェクトアロケーション数の差

Active RecordがSequelを大きく上回っていると謳っている機能のひとつに、組み込みのlazinessがあります。

何らかの理由で背後のActive Recordで余分なSELECTが行われていた場合、Active Recordのカラムは実際に使われるまでは日付に「キャスト」されることはありません。Sequelはこの点を認識しており、かつ慎重を期しています。

いいえ、Sequelは型変換を遅延実行(defer)しません。型変換はモデルレベルではなく、データセットの取得レベルで発生します。Sequelはその代わりにlazy_attributesプラグインを提供します。これはクエリの途中ではカラムをSELECTせず、必要に応じて新しいクエリを実行…(略)

Sequelが信じられないほど高速かつ高効率であるにもかかわらず、極めて慎重に検討しなければActive RecordからSequelへの移行が途方もなく困難なのは、この理由に尽きます。

高効率なlazyセレクタの「最速」サンプルというものはありません。今回の場合、idを1000個消費しているので、アロケーションが1020個程度に収まる極めて効率の高い実装であれば、Topicオブジェクトのアロケーションは仕方がないと予想するでしょう。26000個ものアロケーションは想定していません。

それをとりあえず試しに実装してみたのが以下のコードです(以下は本記事のアイデアを証明するために書いたものであり、productionレベルのシステム向けではありません)。

$conn = ActiveRecord::Base.connection.raw_connection

class FastBase

  class Relation
    include Enumerable

    def initialize(table)
      @table = table
    end

    def limit(limit)
      @limit = limit
      self
    end

    def to_sql
      sql = +"SELECT #{@table.columns.join(',')} from #{@table.get_table_name}"
      if @limit
        sql << -" LIMIT #{@limit}"
      end
      sql
    end

    def each
      @results = $conn.async_exec(to_sql)
      i = 0
      while i < @results.cmd_tuples
        row = @table.new
        row.attach(@results, i)
        yield row
        i += 1
      end
    end

  end

  def self.columns
    @columns
  end

  def attach(recordset, row_number)
    @recordset = recordset
    @row_number = row_number
  end

  def self.get_table_name
    @table_name
  end

  def self.table_name(val)
    @table_name = val
    load_columns
  end

  def self.load_columns
    @columns = $conn.async_exec(<<~SQL).column_values(0)
      SELECT COLUMN_NAME FROM information_schema.columns
      WHERE table_schema = 'public' AND
        table_name = '#{@table_name}'
    SQL

    @columns.each_with_index do |name, idx|
      class_eval <<~RUBY
        def #{name}
          if @recordset && !@loaded_#{name}
            @loaded_#{name} = true
            @#{name} = @recordset.getvalue(@row_number, #{idx})
          end
          @#{name}
        end

        def #{name}=(val)
          @loaded_#{name} = true
          @#{name} = val
        end
      RUBY
    end
  end

  def self.limit(number)
    Relation.new(self).limit(number)
  end
end

class Topic2 < FastBase
  table_name :topics
end

続いて以下を用いて測定します。

a = []
Topic2.limit(1000).each do |t|
   a << t.id
end
a
Total allocated: 84320 bytes (1012 objects)

つまり、オブジェクトアロケーション数が1012個の同じようなAPIを管理できるということです。オブジェクトアロケーション数が26000個にも達するAPIではなく。

それは果たして問題か

簡単なベンチマークを取ってみればわかります。

Calculating -------------------------------------
               magic    256.149  (± 2.3%) i/s -      1.300k in   5.078356s
                  ar     75.219  (± 2.7%) i/s -    378.000  in   5.030557s
           ar_select    196.601  (± 3.1%) i/s -    988.000  in   5.030515s
            ar_pluck      1.407k (± 4.5%) i/s -      7.050k in   5.020227s
                 raw      3.275k (± 6.2%) i/s -     16.450k in   5.043383s
             raw_all    284.419  (± 3.5%) i/s -      1.421k in   5.002106s

Railsが75回繰り返す間に、上でmagicと書かれている私たちの実装では256回の繰り返しを実行しています。この実装は、繰り返しにおいてRailsの実装よりも著しく改善されており、速度の向上とメモリ割り当ての著しい削減によるプロセスメモリの削減の両面で成果を出しています。余分なSELECTという理想的とは言い難い手法を用いているにもかかわらず、です。実際私たちの実装は、慎重に1カラムだけをSELECTしたRailsすら上回るほど高速です。

言ってみれば、Rubyにまったく手を加えずに達成できるRails 3×3です🎊。

もうひとつ興味深いのは、Railsが提供しなければならないターボの利いたpluckが、生SQLと比べてかなり遅い点です。実際Discourseでは、まさにこの理由でpluckにモンキーパッチを当てました(これのRails 5.2版もあります)。

この肥大化が起きる理由

メモリのプロファイルを見てみると、この肥大化が起きる理由が複数見当たります。

  1. Railsのlazinessが微妙:ここでの1000個もの文字列アロケーションは普通は決して見かけないものです。これでは「lazyアロケーション」ではなく半端な「lazyキャスティング」です。
  2. どの行もオブジェクトアロケーションを3個ずつ追加している(記録とマジックのために): 追加されるのはActiveModel::Attribute::FromDatabaseActiveModel::AttributeSetActiveModel::LazyAttributeHashです。カラムへのインデックスを結果セットに保持する配列を1つ使い回せば、これらはいずれも不要になります。
  3. Railsは、取り出したデータが既に「正しいフォーマット」(数値など)である場合にも、キャストをヘルパーオブジェクトにディスパッチすることにこだわる: これにより余分な帳簿が生成されます。
  4. 使われるあらゆるカラム名がクエリで2度アロケーションされる: これは簡単にキャッシュして再利用可能でしょう(SELECTされたそれらのカラム名をクエリビルダが認識していれば、その結果セットを再度問い合わせる必要はありません)

ではどうするべきか

Active Recordの内部を注意深く点検し、行あたりのオブジェクトアロケーションを著しく削減する実装を検討する必要があるように思えます。また、PG gemのネイティブ型キャストを活用してデータベースから文字列を再度取得することを避け、単に数値に逆変換することも試すべきです。

本記事で評価に用いたスクリプトはGistでご覧いただけます。

関連記事

Ruby/Railsのプロ開発者としての5年間を振り返る(翻訳)

Rails: ActiveRecord::Relationで生SQLは避けよう(翻訳)


Rails: Active Recordのコールバックを避けて「Domain Event」を使おう(翻訳)

$
0
0

概要

原著者の許諾を得て翻訳・公開いたします。

参考: Domain Event
参考: ドメイン イベント: 設計と実装 | Microsoft Docs

Rails: Active Recordのコールバックを避けて「Domain Event」を使おう(翻訳)

最近Marcinの書いた「Railsアプリの最大のコードの臭いはActiveRecordのコールバックである」記事にあるように、コールバックはあっという間に制御不能になってしまう可能性があります。この記事がRedditに投稿された後、実に興味深いやりとりをここで見かけました。

重要なモデルがひとつあり、そこにはビジネス上重大なデータがあってめったに変更されることはないが、さまざまなコントローラに散らばった自動化ビジネスロジックによって変更されることがあるとしよう。

このデータが変更されたときに何らかのアラートや通知を送信したいとする(メールとかチャットとか別テーブルにエントリを足すとか)。理由は、非常に重要かつ変更のまれなデータであり、多くの人が変更を知っておくべきだから。

こんなとき次のAとBのどちらを選ぶ?

A: 変更のたびにそのモデルからのメール送信を許可し、「変更時に通知」というコア機能をカプセル化して、変更が発生する実際の場所に配置する。

B: コントローラ内で指定のモデルファイルが変更されるあらゆる箇所に個別に呼び出しを挿入する。

私ならAを選ぶだろう。こちらの方が将来の変化に強く、しかも的確だから。さらに今後プログラマーがエラーを埋めるリスクも軽減でき、「変更時に外部に通知する」という単体の責務を僅かなコストでそのモデルファイルに追加できる。

コメ主の指摘は非常に興味深く、かつ非常に有益です。私自身、数か月前に以下のようなコールバックを使っていました。

class Order < ActiveRecord::Base
  after_commit do |order|
    Resque.enqueue(IndexOrderJob,
      order.id,
      order.shop_id,
      order.buyer_name,
      order.buyer_email,
      order.state,
      order.created_at.utc.iso8601
    )
  end
end

Elasticsearchデータベースのインデックス作成をスケジューリングするために、問題を手っ取り早く解決したのですが、私たちのコードベースにそれ以上の改善がもたらされないことは承知の上でした。しかしこのときは、今後このコードを取り除くのに役立つ可能性のある作業を同時に並行してやっていたことも承知していました。

こうしたコールバックには否定し難いメリットがあると同時に、いくつもの問題を抱え込んでしまいます。それについて書きたいと思います。

コールバックを正しくするのは簡単ではない

上ととてもよく似た以下のコードがあるとします。

class Order < ActiveRecord::Base
  after_save do |order|
    Elasticsearch::Model.client.index(
      id: id,
      body: {
        id:              id.to_s,
        shop_id:         shop_id,
        buyer_name:      buyer_name,
        email:           buyer_email,
        state:           state,
        created_at:      created_at
    })
  end
end

パッと見には何の問題もなさそうですが、もしこのトランザクションがロールバックされると、(手動でオープンした巨大なトランザクションの中でOrderがsaveされて)2番目のデータベースのインデックス化されたステートに誤りが生じるかもしれません。そのまま進めるか、after_commitに切り替わることになります。

さらに、Elasticsearchで例外が発生すればただちにDBトランザクションもロールバックされるでしょう。それをよしとする考え方(DB不整合が発生せず、ElasticsearchにもSQL DBにも何も残らない)もあれば、よくないとする考え方(重要性の低いDBエラーのせいでユーザーの発注が止まってしまい利益が損なわれる)もあります。

そこで今度はafter_commitに乗り換えてみましょう。この特定のニーズにはこちらの方が合っていそうです。そしてドキュメントには次のように書かれています。

コールバックが他のシステムとのやりとりに有用なのは、データベースのステートが不変な場合にのみ実行されることが保証されるからです。たとえばafter_commitがキャッシュをクリアするフックの置き場所として手頃なのは、キャッシュをトランザクション内でクリアすると、データベース更新が完了する前にキャッシュの再生成がトリガされてしまう可能性があるからです。

言い換えると、そうしたフックをサードパーティのシステムやAPIやDBと統合するのであればafter_commitの方がより安全性の高い選択肢となります。副作用がSQL DBにも保存されるのであれば、after_saveafter_updateでも十分です。

class Order < ActiveRecord::Base
  after_commit do |order|
    Elasticsearch::Model.client.index(
      id: id,
      body: {
        id:              id.to_s,
        shop_id:         shop_id,
        buyer_name:      buyer_name,
        email:           buyer_email,
        state:           state,
        created_at:      created_at
    })
  end
end

そういうわけで私たちはafter_commitを使うことを覚えました。さて、おそらくテストの大半はトランザクションで占められているので、テストはDBトランザクション内で実行するのが最も高速です(そのテストではフックが発火しないため)。現在関心のあるテストがほんの数件しかない場合、これは嬉しい点です。嬉しくないのは、Elasticsearchに保存されているデータが多くのユースケースで必要になる場合です。こうなったら、トランザクションを用いない方式でテストを実行するか、test_after_commit gemを使うか、Rails 5にアップグレードしなければなりません。

歴史的には(レガシRailsアプリから読み取った範囲では)after_commitコールバックで発生した例外は握りつぶされて単にロガーに出力されます。すべてがコミットされてしまった後では何もできないからです。これはRails 4.2以降で修正されましたが、スタックトレースは以前ほど良好ではないかもしれません。

ほとんどの技術的問題への対応方法はいくつもあるので、それらについてひととおり知っておく必要があります。これらの例外は最も困った問題であり、何らかの形で取り扱う必要があります。

コールバックは癒着を増やす

Railsの多くの問題は癒着から来ていると私は直感します。Railsの技術的な層はデフォルトでは不十分です。私たちにあるのはビュー(ビューは本記事と何の関係もありませんが)とコントローラとモデルです。すなわち、操作で副作用をトリガしたいと思った場合、そのコードの置き場所はコントローラかモデルが唯一のデフォルト選択肢となります。コードをどちらに置いてもそれなりに問題があります。

副作用(API呼び出し、キャッシュ、セカンダリDBの統合、メール送信)をコントローラに配置すると、テストを正しく行おうとするときに問題が生じるかもしれません。理由は2つあります。コントローラはHTTPインターフェイスと密結合しているので、副作用をトリガするにはテストでHTTP層を用いて副作用とやりとりする必要があります。テストでコントローラをインスタンス化してメソッドを直接呼び出すのは簡単ではありません。そこはフレームワークが管理している領域だからです。

かといって副作用をモデルに配置すると、今度は別の問題が生じます。副作用がハードコードされてしまうので、別途結合テストを(明示的に)用いない限りこのドメインモデルのテストは困難です。つまり「遅いテストで我慢する」か「テストを毎回モックやスタブで塞ぐ」かのどちらかしかありません。

RailsコミュニティでService Objectに関するおびただしい記事が見つかる理由はこれです。アプリが複雑になり始めると、開発者はメール送信やサードパーティAPIへの通知などの関心事を「after save」的な副作用のところに置きたがります。別のコミュニティやアーキテクチャではこの種のコード部品をTransaction Script(訳注: Martin Fawler氏の用語)と呼んだりAppplication/Domain/Infrastructure Service(訳注: ドメイン駆動開発(DDD)の用語)と呼んだりすることがあります。しかしどちらもデフォルトのRailsにはありません。開発者がこぞってブログ記事やgem(1つや2つではありません)あるいはこの層をしっかり備えている新しいフレームワーク(hanamitrailblazer)を頼りにサービスの「車輪の再発明」に走る理由はここにあります。新しいフレームワークに移行せずにこの層をアプリのコードに導入する方法については弊社のFearless Refactoring bookをぜひご覧ください。本書は、システムに高度な概念を導入する前の重要なステップです。

コールバックはデータ変更の意図がわからなくなる

コールバックが呼び出されればデータが変更されたことはわかりますが、変更された理由はわからずじまいです。ユーザーがかけた発注のせいなのか、別の処理でPOSを操作した人のせいなのか、それとも支払いが原因か、返金が原因か、キャンセルが原因なのか、知りようがありません。あえてstate属性を元に変更をかけるのは、多くの場合アンチパターンです。変更された理由がわからなくても(コールバックで何らかのデータを送信しているという理由で)問題にならないこともありますが、それ以外の場合には問題になる可能性があります。

モバイルからのAPI呼び出しや、Webブラウザから別のエンドポイントを介してUserが登録されたときに、ユーザーに「ようこそメール」を送信したいとしましょう。さらにユーザーがFacebookから登録した場合にも送信したいとします。ただしユーザーをシステムにインポートするときには送信したくない(新しいパートナー企業が顧客を連れてこちらのプラットフォームに移籍することを決めたという理由で)とします。つまりこの4つの状況のうち3つに対して(メール送信という)副作用が欲しいのですが、残りの1つでは副作用が欲しくありません。発生したイベントに対して何を行いたいかという意図がわかる方がよいでしょう。after_createはその目的には向いていません。

Domain Eventとは

Active Recordのコールバックの代わりに私が推奨しているのは、UserRegisteredViaEmailUserJoinedFromFacebookUserImportedOrderPaidといった「Domain Event」をパブリッシュして、イベントに対して応答するハンドラをそこにサブスクライブするという方法です。この方法では、さまざまなpub/sub gem(whisperなど)を使うことも、rails_event_store gemを用いてデータベースへの保存や将来インスペクション/デバッグ/ログ出力などを使えるようにすることもできます。

このアプローチについて詳しくお知りになりたい方は、2 years after the first domain event – the Saga patternのスライドや動画をご覧いただけます。Domain Eventのパブリッシュ方法や、それを用いた副作用のトリガ方法について解説しています。このアプローチはActive Recordコールバックの代わりに用いることができます。

今後アプリで何か変更が生じれば必ずイベントがパブリッシュされるようになります。変更が、指定のモデルのどこで生じるのかを探し回ることもありません。変更が生じる場所はすべて明らかだからです。

追伸: Rails 5では更に深刻です

関連記事

Ruby: gemが生成するコードを無名モジュールとprependで動かす(翻訳)

Rails: beforeバリデーションをやめてセッターメソッドにしよう(翻訳)

Rubyのオブジェクト作成方法を改変する(翻訳)

$
0
0

概要

原著者の許諾を得て翻訳・公開いたします。

Rubyのオブジェクト作成方法を改変する(翻訳)

Rubyの素晴らしさのひとつに、必要に応じてほぼ何でもカスタマイズできるという点があげられます。カスタマイズは便利であると同時に危険も伴うので、うっかりすると簡単に自分の足を撃ち抜いてしまいますが、十分気をつければ相当強力なソリューションを生み出すこともできます。

「Ruby Magic」では、便利さと危険は名コンビであると考えます。それではRubyのオブジェクト初期化方法を調べ、デフォルトの振る舞いを改変してみましょう。

クラスからの新規オブジェクト作成の基本

最初に、Rubyがオブジェクトを作成する方法を見てみましょう。新しいオブジェクト(インスタンス)を作成するには、そのクラスでnewを呼び出します。他の言語と異なり、Rubyのnewは言語のキーワードではなくメソッドであり、次のように他のメソッドとまったく同じように呼び出されます。

class Dog
end

object = Dog.new

このnewメソッドに引数を渡すことで、新しく作成されたオブジェクトをカスタマイズできます。引数として渡したものは、種類を問わずイニシャライザに渡されます。

class Dog
  def initialize(name)
    @name = name
  end
end

object = Dog.new('Good boy')

繰り返しますが、Rubyのイニシャライザは他の言語のような特殊な構文やキーワードではなく、単なるメソッドです。

ということは、Rubyの他のメソッドと同様に、こうしたイニシャライザメソッドにもちょっかいを出せるのではないでしょうか?もちろん可能です!

単独のオブジェクトの振る舞いを改変する

特定のクラスから派生するどのオブジェクトからも、常にログを出力したいとしましょう(メソッドがサブクラスでオーバーライドされたときにもです)。これを実現する方法のひとつは、そのオブジェクトのシングルトンクラスにモジュールを1つ追加することです。

module Logging
  def make_noise
    puts "Started making noise"
    super
    puts "Finished making noise"
  end
end

class Bird
  def make_noise
    puts "Chirp, chirp!"
  end
end

object = Bird.new
object.singleton_class.include(Logging)
object.make_noise
# Started making noise
# Chirp, chirp!
# Finished making noise

上の例ではBird.newBirdオブジェクトが1つ作成され、シングルトンクラスを使って、そのオブジェクトにLoggingモジュールがincludeされます。

シングルトンクラスとは

Rubyでは特定のオブジェクトだけで使える固有のメソッドを利用できます。Rubyはこの機能をサポートするために、そのオブジェクトと実際のクラスの間に無名クラスを1つ追加します。メソッドが呼び出されると、実際のクラスにあるメソッドよりも、このシングルトンクラスで定義されているメソッドが優先されます。このシングルトンクラスは他のどのオブジェクトとも異なる固有のものなので、そこにメソッドを追加しても、実際のクラスから派生するオブジェクトには何の影響も生じません。詳しくはProgramming Ruby guideをご覧ください。

オブジェクトが作成されるたびにそのシングルトンクラスをいちいち変更するのはちょっとイケてません。そこでLoggingクラスのincludeをイニシャライザに移して、作成されるすべてのオブジェクトに追加されるようにしましょう。

module Logging
  def make_noise
    puts "Started making noise"
    super
    puts "Finished making noise"
  end
end

class Bird
  def initialize
    singleton_class.include(Logging)
  end

  def make_noise
    puts "Chirp, chirp!"
  end
end

object = Bird.new
object.make_noise
# Started making noise
# Chirp, chirp!
# Finished making noise

この方法はうまくいきますが、Birdのサブクラス(Duckなど)を作成する場合は、イニシャライザでsuperを呼んでLoggingの振る舞いを維持する必要があります。メソッドをオーバーライドする場合は常にsuperを正しく呼び出すのがよいとする考えもあるのですが、それなしでできる方法がないかどうか探してみましょう。

サブクラスでsuperを呼ばないと、Loggerクラスはincludeされません。

class Duck < Bird
  def initialize(name)
    @name = name
  end

  def make_noise
    puts "#{@name}: Quack, quack!"
  end
end

object = Duck.new('Felix')
object.make_noise
# Felix: Quack, quack!

では代わりにBird.newをオーバーライドしましょう。前述のとおりnewはクラスに実装されたメソッドのひとつに過ぎないので、これをオーバーライドしてsuperを呼べば、新しく作成されるオブジェクトを望みのままに改変できるのです。

class Bird
  def self.new(*arguments, &block)
    instance = super
    instance.singleton_class.include(Logging)
    instance
  end
end

object = Duck.new('Felix')
object.make_noise
# Started making noise
# Felix: Quack, quack!
# Finished making noise

しかしイニシャライザでmake_noiseを呼ぶとどうなるのでしょうか?残念ながらLoggingモジュールはシングルトンクラスにincludeされないので、結果は期待どおりになりません。

ありがたいことに解決方法がひとつあります。allocateを呼べば、デフォルトの.newの振る舞いをゼロから作り出せます。

class Bird
  def self.new(*arguments, &block)
    instance = allocate
    instance.singleton_class.include(Logging)
    instance.send(:initialize, *arguments, &block)
    instance
  end
end

allocateを呼んでクラスから新規作成されたオブジェクトは初期化されていないので、そこに追加の振る舞いをincludeしてその後でオブジェクトのinitializeメソッドを呼べばよいのです。initializeはデフォルトではprivateメソッドなので、sendを最後の手段として使わなければなりません。

Class#allocateの真実

allocateは他のメソッドとは異なり、オーバーライドは不可能です。Ruby内部ではallocateのメソッドディスパッチで通常の方法を使っていないので、newをオーバーライドせずにallocateをオーバーライドするだけでは動作しません。しかしallocateを直接呼び出してしまえば、再定義されたメソッドが呼び出されます。RubyのClass#newClass#allocateについて詳しくはRubyのドキュメントをご覧ください。

今回改変した理由

Rubyがクラスからオブジェクトを作成する方法を改変することはさまざまな危険を伴いますので、思わぬ部分に影響が生じる可能性があります。

とは言うものの、オブジェクト生成方法の改変に意味のあるユースケースもあります。実際、ActiveRecordでは別のinit_from_dbというメソッドでallocateを用いて初期化プロセスを変更し、保存されてないオブジェクトをビルドするのではなくデータベースからオブジェクトを作成しています。ActiveRecordでは、種類の異なるSTI(Single Table Instance)同士のレコードをbecomesで変換するときにもallocateを使っています。

オブジェクト作成の挙動を変えて遊ぶときに最も重要なのは、Rubyがオブジェクトを作成するときの仕組みを深く理解し、別のソリューションを受け入れることです。本記事を皆さまが楽しんでいただければ幸いです。

皆さまがRubyのデフォルトのオブジェクト作成方法を変更してどんなものを実装したかをお知らせいただけるとうれしく思います。どうぞお気軽に@AppSignalまでお知らせください。

関連記事

Railsのワナ: モデルでbooleanメソッドをオーバーライドするな(翻訳)

Rails: ActiveRecordのコールバック/セッター/派生データについて再び(翻訳)

$
0
0

概要

原著者の許諾を得て翻訳・公開いたします。

Rails: ActiveRecordのコールバック/セッター/派生データについて再び(翻訳)

Arkencyではこれまで数度に渡って、Railsで使えるコールバック以外の手法についての記事をお送りし、どんな問題が発生する可能性があるかを考察してきました。

しかし、まだまだ多くのシナリオでコールバックが使われているのを今も目にします。そこで今回はもう少し別の例を使って、今一度コールバックについて書いてみたいと思います。

コントローラでメソッドが2回呼び出される

class Controller
  def update
    @cart = Cart.find(params[:id])
    @cart.update_attributes!(...)
    @cart.update_tax
    head :ok
  end
end

私はこの手のパターンをかなり見かけてきました。ActiveRecord::Baseの(既存の)定義済みメソッドが一部の属性の設定に使われています。ツリーの奥深くのオブジェクトを編集するためにaccepts_nested_attributes_forと組み合わせて使われることもしばしばです。

そして、後になってtaxsumcounterdiscountなどの派生データを再計算しなければならなくなった場合の対応方法は、アプリによってまちまちです。たとえば、米国の売上税(sales tax)は出荷元の住所によって変動することがあります。そのため、出荷元住所を設定したら、アプリで使われているOrderSaleCartなどの税金も再計算したいことがあります。

普通これらの計算結果をデータベースに保持しておく理由は、価格や税金やディスカウント額などが将来変更された場合に金額が変わっては困るからです。そのため、現在の値から派生するデータを算出して保存します。他に、データベース上の計算を簡単かつ高速に出力したいという理由付けもあります。

そのような場合には、update_attributes!attributes=で出荷元住所を設定してからupdate_taxを読んで再計算をトリガするよりも、shipping_address=セッターのように意図が明白なpublicメソッドを1つだけ使う方がよいのです。

私がpublicなインターフェイスを使うときには必ず、メソッドの呼び出し順序や引数がどのように変わったとしても最終的に正しいステートを得られる(あるいは、オブジェクトの使い方が誤った場合にはそれがはっきりわかる例外を発生する)よう自分に課していることを申し上げておかなければなりません。オブジェクトを書くときは、順序に依存しないようにするか、内部ステートを保つことで誤った順序を防止できるようにしましょう。私が求めるのは「可換(commutative)」すなわち交換可能性であると信じています。

こうしておけば、リファクタリングもずっと簡単になります。派生データが常に正しく再計算されるので、たとえば住所の前または後でディスカウントを設定するようチェックアウト処理を変更しても大丈夫です。10個の値を変更できる1つの大画面を2つの小画面に分割して、値をそれぞれに振り分ける場合にも問題なく動作するので、安心して作業できます。

モデル内で値を再計算するコールバック

典型的な例をもうひとつご紹介しましょう。

class Order
  before_save :set_amount

  def add_line(...)
    # ...
  end

  private

  def set_amount
    self.amount = line_items.map(&:amount).sum
  end
end

これも先ほどと同様の問題を抱えています。save!を呼び出すとset_amountメソッドが自動的に呼び出されるよう軽く自動化してあります。しかし、これでは次のようなテストを書けません。

order.add_line(product)
expect(order.amount).to eq(product.price)

この場合、次のように再計算やsaveを手動でトリガする必要があります・

order.add_line(product)
order.save!
expect(order.amount).to eq(product.price)
order.add_line(product)
order.set_amount       # privateにできない
expect(order.amount).to eq(product.price)

これでは面白くも何ともありません(少なくとも私は)。

回避する方法はないものでしょうか。そのために、amountや taxなどの派生データを再計算するadd_lineremove_lineupdate_lineなどの「意図が明確な」メソッドを追加しましょう。このようなドメイン操作はコールバックのようなところに隠さず、明示的にしましょう。Railsではたいていの場合、superを呼び出してゲッターやセッターを上書きしてから作業を続行できることを思い出しましょう。

class Wow < ActiveRecord::Base
  def column_1=(val)
    super(val)
    self.sum = column_1 + column_2
  end

  def column_2=(val)
    super(val)
    self.sum = column_1 + column_2
  end
end

この手法は、再評価の必要な計算が多数ある場合に特に便利です。

class Wow < ActiveRecord::Base
  def column_1=(val)
    super(val)
    compute_derived_calculations
  end

  def column_2=(val)
    super(val)
    compute_derived_calculations
  end

  private

  def compute_derived_calculations
    self.sum = column_1 + column_2
    self.discounted = sum * percentage_discount
    self.tax = (sum - discounted) * 0.02
    self.total = sum - discounted + tax
  end
end

今日はこの手の値に6つも出くわしました😉

これらの問題に取り組むべき理由

これらの問題の根本的な原因は、ActiveRecordのサブクラスがデフォルトで持っているpublicなメソッドが多すぎることだと信じています。あらゆる属性や関連付けにもれなくpublicメソッドがついてきて、誰でも好きな場所で変更できてしまいます。このような状況に置かれた開発者は、アプリで実際に使うAPIを絞り込み、縮小して予測可能にすることに責任を持つ必要があります。

他の言語(他のフレームワークとする方がRubyの責任でないという意味で正確だと思いますが)のコードでは、ルールを保護して派生データの算出をトリガするカプセル化メソッドの方がはるかに一般的に使われています。Railsの場合は、何でもカラムで設定しておいて、バリデーション中やオブジェクトの保存時に面倒を見るのが普通なので、そのためのコールバックに強く依存します。私は、オブジェクトがいついかなるときでも問題が生じないようにしておくのが好みです。

遠回しなたとえ

ここまでご理解いただけましたでしょうか。ところで皆さんはReact.jsをお使いですか?React.jsのrenderは、コンポーネントがstatepropsにのみ依存する純粋関数である場合に最大の効果を発揮します。React.jsでrenderを呼び出した結果は派生データになり、引数が同じであれば常に同じ結果を得られます。

以下のようなメソッドについても同じように考えられます。

  def compute_derived_calculations
    self.sum = column_1 + column_2
    self.discounted = sum * percentage_discount
    self.tax = (sum - discounted) * 0.02
    self.total = sum - discounted + tax
  end

column_1や column_2などに値を設定できます。

  def column_2=(val)
    super(val)
    compute_derived_calculations
  end

その他に、sumdiscountedtaxtotalなどの自動で再計算される派生値があります。いずれもわかりきったことではありますが、ActiveRecordがあるとこの辺を簡単には実現できないので、もう少し頑張る必要があります。

凝集度を高めて「集約」を作り出す

本ブログをお読みの方でDomain-Driven Design(電子書籍)をお読みいただいた方であれば、本記事で説明したリファクタリングによってよりよい「集約(aggregate)」を実現できるということにお気づきかと思います。内部ルールはこの集約によって常に保護されます。

詳しく知りたい方へ

本記事をお楽しみいただけましたら、ぜひ私たちのニュースレターの購読をお願いします。私たちが日々追求している、開発者を驚かさないメンテ可能なRailsアプリの構築方法を皆さんにお届けいたします。

以下の記事も参考にどうぞ。

私たちの最新書籍『Domain-Driven Rails』をぜひチェックしてみてください。巨大で複雑なRailsアプリを扱っている方に特におすすめします。

関連記事

Rails: Active Recordのコールバックを避けて「Domain Event」を使おう(翻訳)

Ruby: 年に1度だけ発生する夏時間バグ(翻訳)

Rails: ActiveRecordのスコープで`present?`を使うとパフォーマンスが落ちることがある(翻訳)

$
0
0

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

Rails: ActiveRecordのスコープでpresent?を使うとパフォーマンスが落ちることがある(翻訳)

RailsではあらゆるRubyオブジェクトで#present?メソッドが利用できるので、このメソッドは多用される傾向があります。たとえば、ビューで配列をループで回す前に配列に要素があるかどうかをチェックしてからデータを表示するなどです。

しかし、#present?をスコープで使うと、思わぬパフォーマンス低下が生じることがあります。

次のように書くのではなく

Active Relation(Active Recordクラスのスコープ)で#present?を使う。

books = Book.recently_released
if books.present?
  books.paperbacks.each { |book| puts book.title }
end

次のように書く

#any?#exists?を使う。

books = Book.recently_released
if books.any?
  books.paperbacks.each { |book| puts book.title }
end

そうする理由

Active Relationで#present?を呼び出すと、そのスコープのActive Recordオブジェクトがすべてメモリ上の配列に読み込まれ、それから(おそらく非常に巨大な)配列が空かどうかがチェックされます。

多くの場合これで問題はありませんが、極端なケース(モデルが複雑な場合やデータセットが巨大な場合)ではメモリ使用量が大きく跳ね上がってパフォーマンスが低下するかもしれません。この現象は、それらのオブジェクトが使われるかどうかにかかわらず発生します。

#any?を使えば、配列を一切ビルドしない別のもっとシンプルなSQLコマンドが実行されます。一般に、SQL呼び出しを追加するときのパフォーマンス低下は、全オブジェクトをメモリに読み込む場合と比べればたかが知れています。

そのリレーションが既に利用されている(つまりオブジェクトがメモリに読み込まれている)のであれば、#present?を実行してもペナルティは受けません。この場合#present?は新しい配列を読み込むのではなく、既存の配列を使います。

アプリでのデータベースの使い方が変わるとなれば、きっと皆さんも自分たちのアプリで現実のパフォーマンスにどう影響するかを監視したいと思うでしょう。その場合、#any?で発行される余分なSQLリクエストによるパフォーマンス低下が、データに#present?を使ったときのパフォーマンス低下を下回らないことを確認する必要があります。

Railsでattendance gemを用いれば、Active Relationsの#present?で常に#any?を使うようデフォルトの振る舞いを変更するモンキーパッチを当てることも可能です。attendanceは少々そっけないツールですが、このgemでまとめて解決した場合の自分たちのアプリのパフォーマンスをじっくり監視したくなることでしょう。

そうしない理由があるとすれば

#any?を用いる場合、SQLクエリを余分に発行することになるので、パフォーマンスが必ずしも改善するとは限りません。レコードを事前に読み込む方が効率よくできるのであれば、そうしましょう。

Railsで、attendance gemと同様の修正(Active Relationの#present?の動作を変更する)である#10539がいったんマージされましたが、その後2b76313で取り消され、既存の振る舞いが採用されました。このため、この振る舞いについては各自が注意して#any?を明示的に使わなければなりません。

ここから、このパフォーマンスチューニングは非常に微妙であることがわかります。皆さんのアプリで必ずしも効果を上げるとは限らないでしょう。

関連記事

Rails: `present?`より便利なActiveSupportの`presence`(翻訳)

Rails: pluckでメモリを大幅に節約する(翻訳)

Rails: スコープはスコープを返すべき(翻訳)

$
0
0

概要

原著者の許諾を得て翻訳・公開いたします。

Rails: スコープはスコープを返すべき(翻訳)

RailsアプリをコーディングしていてRailsというレールに乗っている期間が長くなればなるほど、アプリを楽にメンテできるようになり、つらさが軽減されます。

レールに乗るよい方法の1つは、標準のRails APIのパターンに沿って進めることです。そうやって進めるときに自分を導いてくれるパターンの1つが、よく使われるクエリをスコープ化することです。

次のように書くのではなく

スコープ内でオブジェクトを1つ返すメソッドを書く。

class Message < ActiveRecord
  scope :sent, -> { where.not(sent_at: nil) }
  scope :recently_sent, -> { sent.order(sent_at: :desc) }
  scope :most_recently_sent, -> { recently_sent.first } # オブジェクトかnilを返す
end

常に次のように書くこと

名前付きスコープからはActiveRelationを1つ返すようにする。

class Message < ActiveRecord
  scope :sent, -> { where.not(sent_at: nil) }
  scope :recently_sent, -> { sent.order(sent_at: :desc) }

  def self.most_recently_sent
    recently_sent.first
  end
end

そうすべき理由

このように書き換えることで、モデルの使われ方を変えずにコードの構成を改善できます。

.where.orderを呼ぶとActive Relationの「スコープ」が1つ返されますが、これは別のスコープと互いにチェインできます。これによって、あらゆる名前付きスコープが互いにチェイン可能になっているこの振る舞いをメンテするときに、柔軟性と再利用性が高まります。

驚き最小の原則は、コードを編成するうえで確かなヒューリスティクスとして役立ちます。

そうすべきでない理由があるとすれば

同じことは、単にスコープ同士を注意深く扱うことでも実現できるといえばできるのですが、1つのスコープ内で.first.lastを使うときのルールというものは存在しません。

このヒューリスティクスは、自分や同僚が今後作成するであろうスコープで、コードを上手に組み立てるのにうってつけのガイドとなります。ぜひとも使いましょう。

関連記事

Rails: pluckでメモリを大幅に節約する(翻訳)

Rails: `present?`より便利なActiveSupportの`presence`(翻訳)

Rails API: ActiveRecord::Attributes::ClassMethodsの#attributeと#define_attribute(翻訳)

$
0
0

概要

MITライセンスに基づいて翻訳します。

y-yagiさんの以下の記事も参考になります。

ActiveRecord::Attributes::ClassMethodsのインスタンスpublicメソッド

attribute(name, cast_type = Type::Value.new, **options)

attributeメソッドは、型を1つ持つ属性(attribute)をこのモデルに定義し、必要に応じて既存の属性の型を上書きします。このメソッドを用いて、モデルに代入される値をSQLと相互変換する方法を制御できます。また、ActiveRecord::Base.whereに渡される値の振る舞いも変更されます。これにより、実装の詳細やモンキーパッチに頼らずに、自分のドメインオブジェクトを多くのActive Recordで使えるようになります。

  • name: 属性メソッドの定義に用いる名前や、この属性の永続化先となるカラムを定義します。
  • cast_type: :string:integerなどのシンボルか、この属性で使われる型オブジェクトを指定します。独自の型オブジェクトを提供する方法についてはこの後のコード例をご覧ください。

オプション

**optionsでは以下のオプションを利用できます。

  • default: 値が渡されない場合のデフォルト値です。このオプションが指定されていない場合、直前のデフォルト値があればそれがデフォルト値になり、ない場合はnilがデフォルト値になります。
  • array: (PostgreSQL限定)arrayを型とすべきであることを指定します。

  • range:(PostgreSQL限定)rangeを型とすべきであることを指定します(以下のコード例を参照)。

コード例

Active Recordが検出する型をオーバーライドできます。

# db/schema.rb
create_table :store_listings, force: true do |t|
  t.decimal :price_in_cents
end

# app/models/store_listing.rb
class StoreListing < ActiveRecord::Base
end

store_listing = StoreListing.new(price_in_cents: '10.1')

# 変更前
store_listing.price_in_cents # => BigDecimal(10.1)

class StoreListing < ActiveRecord::Base
  attribute :price_in_cents, :integer
end

# 変更後
store_listing.price_in_cents # => 10

デフォルトの型も指定できます。

# db/schema.rb
create_table :store_listings, force: true do |t|
  t.string :my_string, default: "original default"
end

StoreListing.new.my_string # => "original default"

# app/models/store_listing.rb
class StoreListing < ActiveRecord::Base
  attribute :my_string, :string, default: "new default"
end

StoreListing.new.my_string # => "new default"

class Product < ActiveRecord::Base
  attribute :my_default_proc, :datetime, default: -> { Time.now }
end

Product.new.my_default_proc # => 2015-05-30 11:04:48 -0600
sleep 1
Product.new.my_default_proc # => 2015-05-30 11:04:49 -0600

属性の背後にデータベースカラムがなくても構いません。

# app/models/my_model.rb
class MyModel < ActiveRecord::Base
  attribute :my_string, :string
  attribute :my_int_array, :integer, array: true
  attribute :my_float_range, :float, range: true
end

model = MyModel.new(
  my_string: "string",
  my_int_array: ["1", "2", "3"],
  my_float_range: "[1,3.5]",
)
model.attributes
# =>
  {
    my_string: "string",
    my_int_array: [1, 2, 3],
    my_float_range: 1.0..3.5
  }

独自の型を作成する

値の型に定義されるメソッドに応答する限り、独自の型を定義することもできます。以下のコード例のdeserializecastはあなたのオブジェクトで呼び出され、データベースやコントローラからのraw入力を取ります。ここで期待されるAPIについてはActiveModel::Type::Valueを参照してください。利用する型オブジェクトは、既存の型かActiveModel::Type::Valueを継承することを推奨します。

class MoneyType < ActiveRecord::Type::Integer
  def cast(value)
    if !value.kind_of?(Numeric) && value.include?('$')
      price_in_dollars = value.gsub(/\$/, '').to_f
      super(price_in_dollars * 100)
    else
      super
    end
  end
end

# config/initializers/types.rb
ActiveRecord::Type.register(:money, MoneyType)

# app/models/store_listing.rb
class StoreListing < ActiveRecord::Base
  attribute :price_in_cents, :money
end

store_listing = StoreListing.new(price_in_cents: '$10.00')
store_listing.price_in_cents # => 1000

独自の方を作成する方法について詳しくは、ActiveModel::Type::Valueのドキュメントをご覧ください。利用する型をシンボルとして参照できるよう登録する方法について詳しくは、ActiveRecord::Type.registerをご覧ください。シンボルの代わりに型オブジェクトを直接渡すこともできます。

クエリ送信

ActiveRecord::Base.whereが呼び出されると、このモデルクラスで定義された型を用いて値をSQLに変換し、利用する型オブジェクト上でserializeを呼び出します。次の例をご覧ください。

class Money < Struct.new(:amount, :currency)
end

class MoneyType < Type::Value
  def initialize(currency_converter:)
    @currency_converter = currency_converter
  end

  # 値はdeserializeかcastの結果となる
  # この場合Moneyのインスタンスとなる前提
  def serialize(value)
    value_in_bitcoins = @currency_converter.convert_to_bitcoins(value)
    value_in_bitcoins.amount
  end
end

# config/initializers/types.rb
ActiveRecord::Type.register(:money, MoneyType)

# app/models/product.rb
class Product < ActiveRecord::Base
  currency_converter = ConversionRatesFromTheInternet.new
  attribute :price_in_bitcoins, :money, currency_converter: currency_converter
end

Product.where(price_in_bitcoins: Money.new(5, "USD"))
# => SELECT * FROM products WHERE price_in_bitcoins = 0.02230

Product.where(price_in_bitcoins: Money.new(5, "GBP"))
# => SELECT * FROM products WHERE price_in_bitcoins = 0.03412

dirtyトラッキング

ある属性の型は、dirtyトラッキングが行われると変更される可能性があります。changed?メソッドやchanged_in_place?メソッドはActiveModel::Dirtyから呼び出されます。これらのメソッドについて詳しくはActiveModel::Type::Valueのドキュメントをご覧ください。


  • ソースコード
# File activerecord/lib/active_record/attributes.rb, line 195
def attribute(name, cast_type = Type::Value.new, **options)
  name = name.to_s
  reload_schema_from_cache

  self.attributes_to_define_after_schema_loads =
    attributes_to_define_after_schema_loads.merge(
      name => [cast_type, options]
    )
end

define_attribute( name, cast_type, default: NO_DEFAULT_PROVIDED, user_provided_default: true )

define_attributeメソッドは、attributeメソッドを支える低レベルAPIであり、型オブジェクトのみを受け取って、スキーマが読み込まれるのを待たずにただちに動作します。自動スキーマ検出や#attributeは、どちらも背後でdefine_attributeを呼び出します。define_attributeメソッドが提供されていることでプラグイン作者もこのメソッドを利用できますが、アプリのコードではdefine_attributeメソッドではなくattributeメソッドを使うべきです。

  • name: 定義される属性名です。Stringが期待されます。
  • cast_type: この属性で用いる型オブジェクト

  • default: 値が渡されない場合のデフォルト値です。このオプションが指定されていない場合、直前のデフォルト値があればそれがデフォルト値になり、ない場合はnilがデフォルト値になります。procを渡すことも可能であり、その場合新しい値が必要になるたびにprocが1回呼び出されます。

  • user_provided_default: デフォルト値をcastdeserializeでキャストすべきかどうかを指定します。


  • ソースコード
# File activerecord/lib/active_record/attributes.rb, line 223
def define_attribute(
  name,
  cast_type,
  default: NO_DEFAULT_PROVIDED,
  user_provided_default: true
)
  attribute_types[name] = cast_type
  define_default_attribute(name, default, cast_type, from_user: user_provided_default)
end

GitHub

関連記事

[Rails 5.1] ‘form_with’ APIドキュメント完全翻訳

Railsは2019年も「あり」か?#2 Railsの長所と向いている用途(翻訳)

$
0
0

Railsを使っているアプリ(2019年版)

実際のところ、相当たくさんあります。

訳注: 本セクション内の太字のサービスはいずれもRuby on Railsで動いています。

現時点のGitHubのユーザー数は全世界で2,200万人にのぼり、6,100万ものリポジトリを抱えています。コードリポジトリとしては世界最大規模です。

Airbnbも最初期からRuby on Railsを使っています。数百万ものユーザーが、世界65,000以上の地域にある部屋を毎月予約しています。

スタートアップの方ならBasecampという名前をご存知か、既にお使いかもしれません。Basecampは単にRuby on Railsを積極的に使っているのみならず、Ruby on RailsがBasecampで誕生したことを皆さんが知ったら驚くでしょう。さらに、Railsと相性のよいStimulusJSというクールなJSフレームワークも同社が開発したものです。

Shopifyといえばeコマースプラットフォームの巨人ですね。

私たちが最近手がけた、オークションのリアルタイム入札をオンラインで行うアプリの開発は実に興味深いものでした。このアプリではバックエンドにRuby on Rails、フロントエンドにVue.jsをそれぞれ用いましたが、魔法のように見事に動きました。詳しくはArtinfo – Portfolioをご覧ください。

GitHubHerokuCodeClimateTravisCIShopifyはいずれもRailsを用いています。Railsコミュニティの影響力の大きさは、数多のコミュニティ中でも過去最大です。他のコミュニティはいずれもRailsコミュニティを追いかけているに過ぎず、Railsコミュニティの力量によって達成されたこの成熟ぶりを他のコミュニティが追い抜こうにも、数年分は引き離されているのが現状です。
Fabio Akita, Akita on Railsより

皆さんが普段よくお使いのWebアプリのうち、バックエンドにRuby on Railsを用いている例なら、まだまだいくらでもリストアップできます。手短に言えば、Ruby on Railsは今も広く使われていますし、Railsのテクノロジーもそれなりに人気を集めています。別にRailsで作った途端に目覚ましいパフォーマンスを発揮するからではありません。パフォーマンスについては皆さんの使い方次第です。

Railsの長所

ここまでご覧いただいたように、RubyやRuby on Railsがこれまで人気を獲得してきたことには理由があります。Railsにはいくつもの強みがあり、そう簡単に超えられるものではありません。

1. 開発プロセスが短期間で済む

プロジェクトの開発を短期間に終えるという点において、Railsに敵うものはありません。MVP(Minimum Viable Product)アプリをリリースしたいスタートアップや企業にとって、Railsはまさにうってつけのテクノロジーです。Ruby on Railsではプロジェクトや製品を驚くほど短期間で、しかも容易に開発できます。Ruby on Railsでアプリを開発する場合、他と比べて40%は期間を短縮できます。

2. ビジネスロジックを実装しやすい

Ruby on Railsアプリでは、複雑なビジネスロジックを容易に実装できます。APIがなる早で欲しいときにも大丈夫。Rails開発者ならたちどころに作れます。あとはVueやReactなどのフロントエンドフレームワークでぺったんぺったんすれば、Webアプリの一丁上がりです。

3. ライブラリが豊富

「欲しいgemは必ずある」

Rubyでは、コミュニティで開発されたおびただしい数のgemライブラリが何かと称賛の対象になります。チーム開発では、マイナーだがたまに必要となる実装にいちいち気を取られずに、できるだけ実際のロジックに専念すべきです。gemのおかげで、さまざまな機能や、アプリと外部サービスをつなげる「ブリッジ」を簡単に実装できます。ありがたいことに、ほとんどのgemは無料で商用利用できます。

ある機能を開発していて、あるいはアプリで内で外部アプリを実装しようとして、もし行き詰まることがあれば、おそらくそれに適したgemが既にあるでしょう。Rubyのライブラリのおかげで開発者の苦しみの多くが取り除かれ、市場に出すまでに必要な期間が大きく短縮されます。

RubyやRuby on Railsがドンピシャリとはまる用途やプロジェクトは、実にたくさんあります。私たちが作った、Railsを自社アプリや製品で積極的に使っている企業のリストがこちらにありますので、どうぞご覧ください。

Railsに向いているプロジェクト

同じことを実現するにもさまざまなテクノロジーが使えますが、その中から最適のものを選び出すのはとても困難です。そういうわけで、ささやかながら私たちがRuby on Railsを選ぶ以下のカテゴリリストを作りました。

1. 「普通の」Webアプリ

Ruby on Railsは、普通のWebアプリ向けのソリューションとして今もよい選択肢です。ユーザー数百万とか巨大トラフィックとかを期待しないのであれば、Ruby on Railsが向いている可能性があります。さまざまなデメリットにもかかわらず、Railsのテクノロジーには多くのアプリを支えてきた実績と信頼があります。

2. eコマース

先ほど例に挙げたShopifyを思い出しましょう。eコマース界の巨人であるShopifyを支えているのは、愛しのRuby on Railsフレームワークです。Rails製のeコマースフレームワークといえば、Spree Commerceもお忘れなく。

eコーマス機能で必要になりそうなgemがひととおりあるので、Ruby on Railsですぐにでも自分の店舗を簡単に立ち上げられますし、支払い用ゲートウェイ、マーケティングメールでのキャンペーン、ヘルプデスクなどもセットアップできます。

3. カスタムデータベースソリューション

Ruby on Railsフレームワークは、画期的な新ビジネスモデルに欠かせない高度なデータベース構造の扱いに長けています。Railsフレームワークには優秀なORM(Object Relational Mapping)であるActive Recordが備わっているので、開発者はSQLを使わずにデータベースを簡単に操作できます。何より、RailsではPostgreSQLなどのデータベースマネージメントシステムをスムーズに統合できます。

4. CMS(コンテンツマネジメントシステム)

Ruby on Railsのエコシステムには、SEOで使いやすい優秀なCMS向けツールが多数あります。コンテンツ中心のWebサイトがふさわしいとお考えの場合は、Jekyllをお試しください。あなたが今読んでいる私たちのサイトもJekyllで作られています。NetlifyとJekyllの合わせ技について詳しくはこちらをご覧ください。

5. コンセプトが定まる前のアプリ

Ruby on Railsでアプリを構築していてありがたい点は、事前にみっちり計画を立てなくても、開発を進めながら機能を追加できることです。Railsが多くのスタートアップに重宝されている理由がこれです。

6. プロトタイプが急遽必要な場合

Ruby on Railsがプロトタイプ作成を得意している点も、重要なメリットのひとつです。最小限ながらも実用的な機能を驚くほど短期間に開発できます。MVPアプリを作ることで、ユーザーが求めているものや、ビジネスで着目すべき点を明らかにできます。

Ruby on Railsとプロトタイプ作成

歴史を振り返れば、Railsでビジネスを始めたスタートアップは、Twitter、Airbnb、Hulu、Netflixなど枚挙に暇がありません(詳しくはこちらをどうぞ)。その後ビジネスが拡大して別のフレームワークに乗り換えたスタートアップもあれば、Railsを使い続けているスタートアップもあります。スタートアップはRailsの力を得ることで、小規模アプリやMVPアプリを時間をかけずに構築できます。短期間で開発を終えることで、その分多くのユーザーを早いうちに獲得し、アプリの収益増大を早めることもできます。

ここで肝心なことは、Railsのいくつかの限界と折り合いをつけることができるならば、「SNS」から「オンラインショップ」「オークションサイト」「情報システム」に至るあらゆるアプリを、現在はもちろん将来に渡って開発できるようになるという点です。もちろんRubyやRailsは決してありとあらゆる仕事をこなすわけではなく、できる仕事はほんの一握りですが、その仕事を実に見事にやってのけます。


関連記事

Railsは2018年も現役か?: 前編(翻訳)


月刊Railsウォッチサマリー: 2018/12(社内勉強会)

$
0
0

こんにちは、hachi8833です。BPS社内勉強会でmorimorihogeさんが発表した「月刊Railsウォッチサマリー」の2018/12版を元にお送りします。

反応がよければ月刊として定着させたいと思いますので、皆さまからのフィードバックやご要望を@morimorihogeまたは@hachi8833までぜひお寄せください🙇


morimorihogeコメント:
Railsウォッチを毎週やっている中では、時によっては半分以上がマニアックな話題に終始してしまうことがあり、普段のWeb開発業務に直接役立つ知識を追いかけている人にとってはノイズに思えてしまう部分も多いと思います(ノイズの中にも面白いネタが転がっているので意図的にやってるのですが)。
「面白さ」を追求する週刊Railsウォッチに対して「実用性」を追求した月刊サマリーとでもご理解下さい😉

※なお、ピックアップしているトピックや内容の重要度については多分に僕個人の価値観が含まれるため、業務でのご利用の際は皆様一次ソースをご確認の上自己責任でご利用下さい。

⚓週刊Railsウォッチ(20181203)

週刊Railsウォッチ(20181203)Railsのglobalidとは、AWS LambdaがRubyに対応、JSはPromiseを最初に学べほか

この回は「公開つっつき会」の日でした。

⚓主な話題

  • Rails本体
    • ⚓: ActionCableのコードがCoffeeScriptからES2015にリライトされた(後述
    • ⚓: RailsでActiveJobが入ったときに、gid://形式でオブジェクトを一意に参照できるglobalidという仕組みが導入されていた
  • その他
    • ⚓: AWS LambdaのCurtom RuntimeでRubyを含むさまざまな言語が簡単に動作するようになった
    • ⚓: JS初心者がES2015を学ぶなら最初にPromiseをやろう

⚓ES2015とJavaScriptの今後

勉強会では、上述のCoffeeScript -> ES2015リライトに関連して、morimorihogeさんによるES2015やJavaScriptの今後の見通しについて以下が示されました。少なくとも「将来CoffeeScriptやSprocketに戻ることはないだろう」という見解です。

CoffeeScript
今後新規案件で新しく書くことはないと思う方がよさそう
jQuery
「とりあえずjQueryを入れる」はやめる方がよさそう
・急ぎの案件や、技術的負債を見越した案件、ライブラリの関係でjQueryを使わざるを得ない場合はこの限りではない
TypeScript
現時点ではES2015を生で書くことの方が多いように思える
Webpacker
今が移行の苦しい時期
・もう少しこなれてくれば使えるようになるかも
・Webpacker本家のバージョンに完全追従していないなどはかなりつらい
・Webpackerがつらいなら生Webpackにする手も
morimorihogeコメント:
非Railsメインな方も読んでいるかもしれないので念のため補足しておくと、WebpackerはRailsでwebpackを簡単に扱えるライブラリです。
ぼちぼちリリースの迫るRails 6からはWebpackerがrails new時のアセット管理のデフォルトツールになる予定なので、世のRailsエンジニアは追いかけておく必要があります。

一方、Webpackerにはかなりハマりどころも多く、本家webpack側のバージョンアップに追随するのが少し遅かったり、ちゃんと使おうとすると結局ある程度しっかりとしたwebpackの知識も必要になってくるということから、webpackを簡単に使うためにWebpackerを導入したのに結局勉強するものが増えてしまったという悲しい気持ちになることもあります。

また、昨今ではフロントエンドエンジニアとバックエンドエンジニアが分業するスタイルが一般化してきましたが、フロントエンジニアにとってはWebpackをいつも通り使いたいだけなのに、なんでWebpacker使わないといけないんや!Rubyわからん!!というhateを生み出す要因になってしまうところもあると思います。

というわけで、フロントとバックエンドを開発するメンバーなりチームが明確に分かれていて、かつフロントエンジニアがRailsにそれほど慣れていない場合には素のwebpackの方がチーム間は仲良くできるのではないかなあ、と思っていたりします。お互い適切な距離感が大事。

#今気づきましたがwebpackは全部小文字、Webpackerは頭大文字なんですね

webpackは全部小文字、Webpackerは頭大文字

気が付きませんでした💦

⚓週刊Railsウォッチ(20181210)

週刊Railsウォッチ(20181210)update_columnは要注意、DBカラムコメントは書こう、個人情報扱いの注意点、Capistranoはやっぱりいいほか

⚓主な話題

  • Ruby 2.6リリース直前の話題
    • ⚓: 2.6.0-rc1リリース
    • ⚓: 2.6の例外ハンドリングの改善
    • ⚓: 2.6のrefinement改善
  • Rails
    • ⚓: Rails 5.2.2リリース(バグ修正) — 現在開発中のプロジェクトはアップデートしよう
    • ⚓: ActiveRecordの更新系フックの話題(後述
    • ⚓: データベーステーブルのカラムコメントを書くかどうかの話題(後述
    • ⚓: Railsでのセキュアデータの扱いの話題(後述
    • ⚓: Capistranoの話題(後述

⚓ActiveRecordの更新系メソッド

ActiveRecordの更新系メソッドには注意を要するものがあるという話題になりました。ここでいう更新系メソッドは、#save#update#update_attributesなどです。

  • ActiveRecordの更新系メソッドの中には、コールバックを呼ばない(validationをスキップする)ものがある
    • #update_column
    • #update_all
    • #update
    • etc.
  • validationがスキップされると、DBの論理整合性がたちまち失われてしまう
  • RDBMS側でCONSTRAINTによる制約をかける方法もあるが、ビジネスロジックに関連する制約や複雑な制約はどうしてもアプリケーションのコードに置かれるので、すべてをCONSTRAINTでまかなうのは難しい
  • validationを誤ってスキップしないよう、すべてのRailsエンジニアは更新系のメソッドを適切に選ぶ必要がある
morimorihogeコメント:
生SQLを書いていた時代からWebアプリケーション開発をしていた人たちはこのあたりそれほど問題にならないと思います(というかそこまでORM(Object-Relational Mapper。RailsだとActiveRecordに相当)を信頼してない)が、ORMを使ってしか開発したことがないような人が踏みがちなやつです。

RDBMSから見ると整合性があるけど、アプリケーションから見ると不整合なデータというのはRailsだと割と作りがちで、かつ発生してしまった場合の障害も割合危険なものになることが多い(アプリケーションエラーになってしまうケースが多い)ため、コードレビューの際には注意してチェックしたいところです。

⚓DBテーブルのカラムコメント

データベーステーブルのカラムを作成するときには、適切なカラムコメントを日本語で追加しておくと自分やチームを助けるという話題です。morimorihogeさんによる勉強会での説明の要点を以下にまとめました。


中規模〜大規模なシステムでテーブル数やカラム数が増えてくると、英語のカラム名を見ただけでは日本語名をすぐに想像できなくなることがよくあります。XXX_atやXXX_keyのような類似の英語カラム名が増えてくると、確認の手間も増えてしまいます。

また、Excel形式のスキーマ仕様書も納品する場合、データベースのスキーマ更新のたびに仕様書を手動で更新するのはまったく非効率です。

幸い、一般的なRDBMSではスキーマ定義時にカラムにコメントを追加する機能があります。これを活用すれば、開発中にカラムの日本語説明を簡単に参照できて開発の効率が向上しますし、A5:SQL Mk-2などの整形ツールを援用してスキーマ仕様書を自動生成することもできます。

現在のRailsでは、スキーマにカラムコメントを書いておけばデータベースに反映されるようになっています(Rails 4以前ではgemが必要でした)。

morimorihogeコメント:
SIerアレルギーの過激な人なんかは「Excel仕様書なんていらない。schema.rbのソース見ろ」みたいなことをいう人もたまにいるのですが、アプリケーションコードが何の言語で書かれていようが気にしないけどDBの中身がどうなっているかは外部連携やビジネスロジックの検討上知りたいというケースは多々あります。

また、初期はRailsアプリのコードが読める身内メンバーだけで開発していたが、システムの運用拡大に伴い「DB仕様書見せて」というケースは比較的多いので、いざという時にチョロく生成できるための先行投資としては悪くないと思います。

⚓Railsでのセキュアデータの扱い

ウォッチで紹介した記事「Securing Sensitive Data in Rails」はRailsを中心に、Webアプリでセキュアなデータを扱うときの注意点を運用も含めてひととおり紹介しています。以下はこのトピックに関するmorimorihogeさんの説明のまとめです。


  • Railsに限らず、セキュアなデータの扱いについて求められる水準は上がっている
  • 特に開発経験の浅い人は、こうした記事に一度は目を通しておいて欲しい
  • データがRailsやライブラリの機能レベルで漏洩する可能性もあるため、重要なデータの扱いについて仕様書に記載があるかどうかにかかわらず、Railsのどの機能でどういう漏洩が発生しうるかを実装サイドで理解しておく必要がある
    • 重要なデータがサーバーログに含まれていた、など

⚓Capistranoの話題

BPS社内の標準デプロイツールであるCapistranoは今も便利なツールであるという話題でした。Capistranoは既に空気のように当たり前に使われているので、その分最近Capistranoに関する新しい話題を見かけなくなっているという指摘になるほどと思いました。

勉強会では、その他にCapistranoによるデプロイをインフラエンジニアとアプリエンジニアのどちらが設定すべきかという話題にも触れました。以下はmorimorihogeさんの説明のまとめです。


  • Capistranoのデプロイ設定は、どちらかというとアプリエンジニアの仕事だと考える
  • アプリケーションの細かい設定は、インフラエンジニアだけではできないものであり、インフラエンジニアの作業範囲を超えると考える
    • インフラエンジニアは文字通りインフラを準備することが業務
  • スムーズな連携作業のためにも、アプリエンジニアはCapistranoのデプロイ設定を書けるようになって欲しい
morimorihogeコメント:
インフラエンジニアとアプリエンジニアの責任分界点は割と会社やプロジェクトチームによっても異なるケースを見かけますので、この辺りは唯一の正解があるわけではないと思います。

弊社ではインフラエンジニアよりもアプリエンジニアの方が数が多いこともあり、なるべくアプリエンジニア側でできることはやってもらう運用にしています。

⚓週刊Railsウォッチ(20181217)

週刊Railsウォッチ(20181217)Railsのafter_*系フックは要注意、早すぎるDRYとYAGNI、mruby 2.0リリースほか

⚓主な話題

  • Rails本体の改修
    • ⚓: 大きなものはなさそう
  • その他
    • ⚓: Rails Developers Meetup 2018 Day 4の話題(後述

⚓Rails Developers Meetup 2018 Day 4

「Rails Developers Meetup」(Railsdm)というカンファレンスの特徴などについてmorimorihogeさんが解説しました。

  • Railsを中心に据えていて、業務に直接役立つ発表が多い
    • RubyKaigiほどマニアックではない
  • Railsのベテラン開発者、経験の浅い開発者のどちらにもおすすめできる
  • 発表内容がバラエティに富んでいて、技術寄りでない発表もあり

私(hachi8833)も、RailsdmはRailsの開発者にとって参加する価値が高いと思いました。実際、今年の3/22に予定されている最初のRailsdm 2019は発売したその日にチケットが完売していました。

⚓週刊Railsウォッチ(20181225)

週刊Railsウォッチ(20181225)Rails 6新機能第2弾「Action Mailbox」、url_forは慣れが要る、Ruby製サーバーレスフレームワークJetsほか

⚓主な話題

  • Rails
    • ⚓: 新機能Action Mailboxの登場 — しばらくは様子見かも
    • ⚓: URLHelperによるurl_for名前解決の話題(後述

⚓URLHelperによるurl_for名前解決

Railsつっつき会と本勉強会の両方で、しきりに「#url_forは闇が深い」という話が出ました。以下はつっつきと勉強会でのmorimorihogeさんの説明をまとめたものです。

  • RailsのURLHelperのメソッドである#url_forはルーティング形式の引数をURLに変換するためのもので、*_path*_urllink_toform_forなどのビューヘルパーの内部で多用されている
    • #url_forは単に奥が深いというより、後方互換性などによって闇が深くなっている面がある
  • #url_forは実に多種多様なパターンで引数を受け取れる
    • #url_forの挙動を理解しようとしても、少々実装を追いかけたぐらいでは理解できない
    • #url_forを使いこなせるようになると引数を簡潔に書けるようになる

勉強会では、#url_forの多種多様な引数の渡し方とどう取り組むかについてmorimorihogeさんから次のような話もありました。

  • 引数の渡し方は、参加するRailsプロジェクトによって異なることが多い
    • (新しく実装するなら1つの書き方だけ覚えていれば済む)
  • さまざまなプロジェクトに参加するには、引数の渡し方のパターンをある程度網羅的に知っておく必要がある
  • そのためにも、さまざまな引数の渡し方を少しずつでも学んで慣れておくとよい
morimorihogeコメント:
url_forの書き方は少なくとも同一プロジェクトの中ではある程度統一した方が良いと思います。書き方がばらばらだと単に読みにくいというのもありますが、ソースコード検索でも見落としが出やすくなってしまうため、Routingの見直し時に更新漏れが発生してエラー原因になったりする危険性もあります。

この辺りは既に走っているプロジェクトであれば、既存ソースを見ながら郷に従うのが正義だと思います。

バックナンバー

月刊Railsウォッチサマリー: 2018/11(社内勉強会)

Rubyの式展開(string interpolation)についてまとめ: `#{}`、`%`、Railsの`?`

$
0
0

1. 一般的な「string interpolation」とは

まずは一般的な話から。プログラミング言語で文字列を出力するときに、文字列の一部を変数の値や式の評価結果に差し替えたいことが非常によくあります。

"信号: 赤"
"信号: 黄"
"信号: 青"

上の文字列リテラル(" "で囲まれている部分)のうち、「赤」「黄」「青」を何らかの方法で変数から送り込んで差し替える操作を一般にstring interpolationと呼びます。

"信号: ●"      # ←この●を「赤」「黄」「青」に差し替えたい

3つの文字列リテラルを使い分けるより、●を「赤」「黄」「青」だけ差し替える方が楽ですし、文字列リテラルが無駄に長くならずに済みます。

なお、次のような文字結合によるベタなやり方は、結果は同じでもstring interpolationとは呼ばれないようです。

●  = "赤"        # 「赤」「黄」「青」のいずれかが入る

"信号: " + ●    #=> "信号: 赤" など

上はいずれも説明用の擬似言語であり、特定の言語ではありません。

他の言語でのstring interpolation

string interpolationは言語によってさまざまな構文があり、1つの言語に複数の構文があることもあります。置き換えの場所は「プレースホルダ(placeholder)」とも呼ばれます。

string interpolationは他の言語では多くの場合「文字列補間」という訳語が使われていますが、Rubyでは「式展開」と訳されるのが普通です。

参考: 文字列補間 - Wikipedia

他の言語では、以下もstring interpolationとだいたい同じ意味で使われます。

  • 変数置換: variable interpolation
  • 変数補間: variable substitution
  • 変数置換: variable expansion

本記事では以後「式展開」で統一します。

Rubyの式展開とは

参考: 式展開 — リテラル (Ruby 2.6.0)

Rubyで文字列の式展開というと以下のような形で説明されることがよくあります。二重引用符" "の中に#{ }と変数を記述すると変数の値が展開されます。なお、式展開が効くのは二重引用符の中だけであり、一重引用符' 'の中では式展開は無効です。

year = "2019"
puts "Year: #{year}"
#=> Year: 2019

#{ }の中にはRubyのどんな式でもそのまま書けます(引用符も書けます)。以下のようにリテラルも書こうと思えば書けますが、この形ではリテラルにする意味はないと思います。

puts "Year: #{'The last year of Heisei'}"
#=> Year: The last year of Heisei

Rubyの#{ }では#to_sが自動的に効く」という重要な特性があります。このおかげで、#{ }の中にどんな式を置いても文字列に変換されます。詳しくは以下をご覧ください。

Rubyでの文字列連結に「#+」ではなく式展開「#{}」を使うべき理由

式展開のバリエーション(1)

あまり使わないと思いますが、式展開#{}の中に#でコメントを書くこともできます。ただしその#から行末の改行までがすべてコメントとみなされるため、式展開の閉じ}"次の行に書く必要があります

puts "Year: #{year # 平成最後の年
}"
#=> Year: 2019

式展開のバリエーション(2)

これもあまり使わないと思いますが、式展開に入れる変数が$で始まる変数(グローバル変数)や@で始まる変数(インスタンス変数やクラス変数)の場合、#{}{}省略できます。

@year=2019
puts "Year: #@year"
#=> Year: 2019

式展開のバリエーション(3)

式展開#{}の前にバックスラッシュ\を置くと、式展開を抑制できます。

puts "Enter the \#{year}"
#=> Enter the #{year}

式展開のバリエーション(4)– %を使う場合

%記号を用いて、式展開と書式設定を一度に行うこともできます。書式設定のためのものなので、式展開と呼んでよいかどうかというのはありますが、利便性のためここに書きました。

puts "Year: %{year}" % { year: 2019 }
#=> Year: 2019

%記号について詳しくは以下の記事をご覧ください。

Ruby: パーセント記号 `%` の使い方まとめ

参考: Rubyスタイルガイドでの引用符の使い分け

上述のとおり、Rubyでは二重引用符" "の中で式展開が効き、一重引用符' 'の中では効きません。

RubocopのRubyスタイルガイド↓では、二重引用符と一重引用符をスタイル上使い分ける際にこの点も意識するとよいでしょう。

Rubyスタイルガイドを読む: 数値、文字列、日時(日付・時刻・時間)

Railsで生SQLを書くときの?による式展開

SQLを生書きするときに使える疑問符?による式展開(プレースホルダ)はRubyではなく、Railsがサポートしている機能です。Railsでは?ですが、プレースホルダの書式はWebフレームワークやRDBMSによって異なることがあります。

RailsのActive Recordでは、以下のように生SQLの中で変数を渡す箇所に?を置き、カンマに続けて変数を書くことで、?の部分に変数(この場合params[:name])が展開されます。このとき変数の内容が適切にサニタイズ(sanitize: 機能を持つ文字や式を安全のためにエスケープすること)されます。

Person.where('name = ? AND hidden_at IS NULL', params[:name])

ユーザーから受け取った文字列を変数として生SQLを書く場合、変数のサニタイズは必須です。さもないとSQLインジェクションという危険な脆弱性の原因となります(参考↓)。

Rails: ActiveRecord::Relationで生SQLは避けよう(翻訳)

RailsのActive Recordにはsanitize_sql_arrayをはじめとするsanitize_sql_*系メソッドがあります。生SQLを書く場合、特に変数を複数使う場合は、ぜひこれを使うべきです。

Railsのmigrationで生SQLを使う+パラメータを使う

参考: ActiveRecord::Sanitization::ClassMethods

sanitize_sql_*系メソッドは以前はprivateメソッドでしたが、Rails 5.2からはpublicになったので気兼ねなく使えます↓。

Rails 6のB面に隠れている地味にうれしい機能たち(翻訳)

$
0
0

概要

原著者の許諾を得て翻訳・公開いたします。


evilmartians.comより

Rails 6のB面に隠れている地味にうれしい機能たち(翻訳)

概要: 次回のメジャーアップグレードの中から、運用実績のある成熟したアプリケーションでも使いたくなるような、あまり知られていない機能を発掘したいと思います。昔の音楽に例えれば、「ヒットチャート上位」に顔を出すような売れ線の機能ではなく、LPレコードのB面やレアコレクションに隠れている名曲のような、新しいリリースの「地味だけど絶妙に役立つ」機能に目を向けてみたいと思います。

Rails 6で最も喧伝されているAction MailboxAction Textのような機能につい目を奪われがちですが、アップグレードするだけで利用できるWYSIWYGテキストエディタが、ある程度の期間運用されている現実のRailsアプリで今すぐに重宝するとは考えにくいでしょう。

一方、マルチデータベースのサポートパラレルテストのような、それほど前面に出ていない機能がただちに生産性向上に役立つようなこともあります。Rails 6には、そうした一見の価値のある機能が目白押しです。

私は数年前にAction Cableの改良に携わって以来、Railsフレームワークの開発を追いかけており、無数のプルリクに目を通しています。Rails 6 RC版リリースの数か月前には、既にある中規模クラスのRails 4アプリをRails 6向けに書き直す権限を与えられました。

私はAnyCableを手がけていることもあって、なんとかCableのたぐいは私の専門分野と言えます。

私は熱狂的な音楽ファンでもあるので、Railsという大規模フレームワークのリリースを目撃するのは、さながら(音楽の)レコード発売日に居合わせるような心持ちです。ヘビロテされる大ヒット曲もあれば、B面に埋もれたままになったり、ファンが発掘してレア物としてありがたがる曲もあります。

本記事では、このたびリリースされるRailsの舞台裏で息を潜めているgemを拾い上げてみたいと思います。新しいgemもあれば、数年の時を耐え忍んでRails 6にマージされたgemもありますし、プルリク一発のコードもあれば、Rails 6.xまでおあずけのgemもあります。

Action Cableのテスト

Rails 5のメジャーな機能であるAction Cableは、WebSocketsをすぐに利用でき、JavaScriptライブラリも同梱されていました。Action CableはRails wayの「設定より規約」に沿っていて構文も親しみやすいのですが、テスト駆動アプローチのサポートが抜け落ちていました。つまり、チャンネルのテストを書くための公式な方法が提供されていなかったのです。

Rails 6では、Action CableのJavaScript部分がついにCoffeeScriptとおさらばし、#34177でES6に書き直されました。

ある日、私は#23211を再オープンして修正する機会がありました。このプルリクはRails 5に取り込まれることになっていたのですが、最終的な曲目からは漏れてしまいました。その代り、シングル盤レコード(つまりaction-cable-testing gemのことです)をリリースし、3年越しでついに#33659でRails 6にマージされたのです。

というわけで、新しいRails 6プロジェクトで(--skip-action-cableを付けずに)rails newを実行すれば、app/channelsフォルダに加えてtest/channelsフォルダも作成されるようになりました。

サンプルではRSpecが使われています。Action Cableの統合はaction-cable-testing gemで実装されていますが、RSpec 4でマージされる予定です(#2113)。

さて、Action Cableではどこをテストすればよいのでしょうか?現実のアプリで使われている事例を見てみましょう。

Action Cableの接続周りであれば、次のように認証に関連するロジックをテストしたいでしょう。

# spec/channels/application_cable/connection_spec.rb
require "rails_helper"

# `type: :channel`でAction Cableテスティングヘルパーを追加する
# 現時点ではaction-testing-cable gemを使うが、RSpec 4に同梱されるはず
RSpec.describe ApplicationCable::Connection, type: :channel do
  let(:user) { create(:user) }

  it "cookieでの接続に成功する" do
    # "virtual"リクエストcookieをセット
    cookies.signed[:user] = user.id

    # `connect`メソッドはサーバーへのwebsocketクライアント接続を表す
    connect "/websocket"

    # idが正しく設定されたことをチェックできるようになった
    expect(connection.current_user).to eq user
  end

  it "cookieなしの接続は拒否する" do
    # cookieが渡されない場合は接続を拒否することをテストする
    expect { connect "/websocket" }.to have_rejected_connection
  end

  it "存在しないユーザーからの接続は拒否する" do
    cookies.signed[:user] = -1

    expect { connect "/websocket" }.to have_rejected_connection
  end
end

その他のAction Cableプリミティブである「チャネル」のテストもさらに興味深いものになりました。チャネルはWebSocketのコントローラとみなすことができます。

以下のテストで使っているPresenceChannelクラスは、実際のアプリでもさまざまなページに渡るユーザーのアクティビティを正確にトラッキングするのに使われています。#subscribe向けに以下のテストシナリオがあります。

  • ユーザーがそのチャンネルに接続するときは、プレゼンストラッキングシステムに登録されていなければならない
  • ユーザーがそのチャンネルに接続するときは、対応するストリームでサブスクライブされなければならない(通知を受け取るため)
  • ユーザーがそのチャンネルに接続するときは、「ユーザーが参加しました」という通知をストリームに送信しなければならない

真新しいAction Cableテストユーティリティを用いて、次のテストを書きます。

require "rails_helper"

RSpec.describe PresenceChannel, type: :channel do
  # `let_it_be`ヘルパーは`test-prof` gemが提供
  let_it_be(:projectschool) { create(:project) }
  let_it_be(:user) { create(:user, project: project) }

  before do
    # `stub_connection`は、渡されたidで
    # Connectionインスタンスを初期化する 
    stub_connection current_user: user
  end

  describe "#subscribe" do
    subject do
      # `subscribe`ヘルパーは、記載されているチャンネルへ
      # のサブスクライブアクションを実行する
      subscribe
      # `subscription`はサブスクライブされたチャネルのインスタンス
      subscription
    end

    it "プレゼンスストリームにサブスクライブする" do
      expect(subject).to be_confirmed
      expect(subject).to have_stream_for(project)
    end

    it "現在のユーザーをオンラインリストに登録する" do
      subject
      # Presence::OnlineUsersはこのアプリ特有のバックエンド実装
      # (プレゼンスのデータを保存する)
      expect(Presence::OnlineUsers.for(project)).to match_array([user])
    end

    it "オンライン通知を送信する" do
      expect { subject }
        .to have_broadcasted_to(project)
        .with(
          type: "presence",
          event: "user-presence-changed",
          user_id: user.id,
          status: "online"
        )
    end
  end

  # `unsubscribe`アクションもほぼ同一のシナリオ
  describe "#subscribe" do
    before do
      # unsbscribeを呼ぶ前に最初にsubscribeweしなければならない
      subscribe
    end

    it "現在のユーザーをオンラインリストから削除する" do
      expect(Presence::OnlineUsers.for(project)).to match_array([user])

      unsubscribe
      expect(Presence::OnlineUsers.for(project)).to eq([])
    end

    it "オフライン通知を送信する" do
      expect { unsubscribe }
        .to have_broadcasted_to(project)
        .with(
          type: "presence",
          event: "user-presence-changed",
          user_id: user.id,
          status: "offline"
        )
    end
  end
end

皆さんのアプリケーションに足りなかったAction Cableテストをご自由に追加してみてください!

Active Storageの将来を垣間見る

Active Storageは、Rails 5.2で新たに加わった新しいフレームワークの一員です。

私がActive Storageを使い始めたのはRails 6 beta 1からです。Active Storageには、すぐ使えるダイレクトアップロードのようなおいしい機能もいくつかありますが、まだ荒削りな部分がたくさん残っています。

グッドニュース: Active Storageは急速に進化を遂げており、改良方法が絶え間なく提案されています。

バッドニュース: 私たちが最も待ち望んでいるプルリクは、惜しくもRails 6の最初の安定版リリースには間に合いませんでした。しかし、それまでは提案されている変更を以下のような別実装で使ってみることもできます。

  • 添付ファイルのサイズやcontent typeのバリデーション(#35390)。現時点ではactive_storage_validations gemで実装されています。
  • 添付ファイルごとに複数の異なるサービスを使い分ける(#34935)。このプルリクがマージされれば、添付ファイルの種類ごとに異なるサービスを使い分けられるようになります(モデルごとに異なるS3バケットを使うなど)。
class User < ActiveRecord::Base
  has_one_attached :avatar, service: :s3
  has_one_attached :contract, service: :super_encrypted_s3
end
  • 添付ファイルを、現在のようにリダイレクトを経由せず、プロキシ経由で送信する(#34477)。これにより、CDNを簡単にセットアップして、最終的にユーザーがアップロードするアセットをより高速に送信できるようになります。別の#34581プルリクでは、同じ目的のpublic_service_urが提案されています。
  • 画像のvariantに名前を付ける機能(#35290)。現在は、添付ファイルのvariant(訳注: サイズ違いの画像)を作成するときにuser.avatar.variant(resize_to_limit: "50x50")のように正確なオプションを指定しなければなりません。named variants機能によってuser.avatar.variant(:thumb)のように書けるようになります。

named variants機能はRailsへのマージ待ち状態ですが、私たちが独自に実装したものもありますので、どうぞご覧ください。

Active Recordのinsert_all

#35077でActive RecordのバルクINSERTがサポートされます。

この機能がこれまで提案されていなかったことも驚きですが、私たちは既にいくつかのgem(activerecord-importがその道では最も有名です)を使っていました。

多数のレコードを一括でINSERTする方が、レコードを1つずつ保存するよりも明らかに効率が高くなります。

  • 必要なSQLクエリが1つで済む
  • モデルオブジェクトをインスタンス化する必要がない(メモリ使用量のコストはかかる)

ただし大きなトレードオフが1つあります。#insert_allメソッドではコールバックやバリデーションが一切呼び出されません。ご利用は計画的に!

この機能のおまけとして、すぐ使えるUPSERTステートメントがサポートされます。UPSERTPostgreSQLなどほとんどのリレーショナルデータベースでサポートされています。UPSERTINSERTUPDATEとして使った場合を考えてみましょう。INSERTしようとしているレコードがuniqueness制約に引っかかると、例外を出さずに既存レコードのUPDATE操作にフォールバックします。

PostgreSQLのINSERTのドキュメントに書かれていないxmax ='0'という裏技を使うと、どのレコードが実際に(UPDATEではなく)INSERTされたかをトラッキングできます。詳しくはStack Overflowをご覧ください。

今私が手がけているプロジェクトのコードを少しお目にかけましょう。このプロジェクトには「ユーザーの一括招待」機能があり、その背後にはInvitation(user_id, event_id, rsvp:bool, disposable:bool)モデルがあります。あるユーザーがイベントに他のユーザーを大勢招待すると、未招待のユーザーごとにinvitationのレコードを1件作成します。ユーザーが招待済みの場合は、invitationプロパティを更新したいと考えています(ここでUPSERTが活躍します)。

Invitation.pg_batch_insert(
  columns, # INSERTするカラムのリスト
  values, # 値のリスト(配列の配列)
  on_conflict:
    # (user_id, event_id)ペアでuniqueness制約をかけている
    "(user_id, event_id) DO UPDATE "\
    "SET disposable = (events.disposable AND EXCLUDED.disposable), "\
    "rsvp = (events.rsvp OR EXCLUDED.rsvp)",
  returning: "user_id, (xmax = '0') as inserted"
)

このコードでは少し前に書いたコードをmixinしてあり、とても役に立っています。

以上でおしまいです。後はRails 6がinsert_allメソッドやupsert_allメソッドを提供してくれます。

# 上と同じ機能
Invitation.upsert_all(
  # 注意: Railsではハッシュの配列が入力として期待されている
  values.map { |v| columns.zip(v).to_h },
  unique_by: %i[event_id user_id],
  update_sql: "disposable = (events.disposable AND EXCLUDED.disposable), "\
              "rsvp = (events.rsvp OR EXCLUDED.rsvp)",
  returning: "user_id, (xmax = '0') as inserted"
)

なお、上の例のupdate_sqlオプションやreturningオプションのプルリクは現時点ではマージされていない(#35636)ので、今後の更新情報をフォローしてください。

「dirty」ストアアクセサ

Action Cableテストのプルリクはお披露目されるまでに3年も待ち続けていましたが、この記録の上をいくのは間違いなく#19333の「ストアアクセサのdirtyトラッキングメソッド群」でしょう。何しろ提案が出されたのは2015年です。

このプルリクがめでたくRails 6にマージされたことで、Store属性を「素の」Active Record属性と同じように変更できるようになりました。

class Account < ApplicationRecord
  store_accessor :settings, :color
end

acc = Account.new
acc.color_changed? #=> false

acc.color = "red-n-white"
acc.color_changed? #=> true

この機能ができるまでの歴史を簡単に振り返ってみましょう。

Railsでは、いわゆる“dirty”属性(saveされていない変更)をトラッキングする方法が提供されています。dirtyは2008年のRails 2.1で導入されました。Rails 3.2では、単一カラムにシリアライズ保存されたデータ(多くはJSON)への読み書きメソッドを作成する方法として、いわゆるストアアクセサが追加されました

しかし、ストアアクセサが真の力を発揮したのは、PostgreSQLでJSONBデータ型がサポートされた後のことです。JSONBは、構造化されていないデータを効率よくコンパクトに、かつインデックス化可能な方法で保存する方法を提供します。

従来、ストアアクセサの変更トラッキングは次のような感じで行われていました(この例は実際に使われているコードベースから引用しました)。

class RangeQuestion < ActiveRecord::Base
  after_commit :recalculate_answers_scores, on: :update, if: :answer_was_changed?
  # RangeQuestionでは`min`値と`max`値に収まる正しい回答を期待する
  store_accessor :options, :min, :max
  # なおストアアクセサは通常の属性と同じ方法でバリデーションできる
  validates :min, :max, presence: true, numericality: { only_integer: true }
end

従来のanswer_was_changed?メソッドでは、options属性の変更全体をトラッキングしなければならず、以下のように扱いが面倒でした。

def answer_was_changed?
  # 詳しくはActiveRecord::AttributeMethods::Dirtyを参照
  return false if saved_change_to_attribute?("options")

  prev_options = saved_change_to_attribute("options").first

  prev_options.dig("min") != min || prev_options.dig("max") != max
end

これと同じようなことをコードのあちこちで行わなければならなかったので、私はあるときストアアクセサで*_changed?をextendすることを思いつきました。Rails 6からは以下のように書くだけで済みます。

def answer_was_changed?
  saved_change_to_min? || saved_change_to_max?
end

随分良くなったと思いませんか?こんなシンプルな機能のマージに時間がかかった理由は、主に当時コアコントリビュータだったSean Griffinが、ストアアクセサをあまり知られていない Attributes APIを用いる機能をフル装備した属性に昇格させることを望んでいたことです。残念なことにこの構想は実現せず、当面その見通しもなさそうです。なおSeanは最近Railsコアチームからリタイアしました

Active Recordのその他の素敵な小物たち

  • optimizer hintsのサポート(#35615)。これは、指定のクエリの最大実行時間に上限を設定するMySQLの機能です。
User.optimizer_hints("MAX_EXECUTION_TIME(5000)").all
#=> SELECT /*+ MAX_EXECUTION_TIME(5000) */ `users`.* FROM `users`

ちなみに、Active Recordは実行タイムアウトエラーを認識してStatementTimeout例外をraiseするようになりました(#31129)。これで例外を好きなだけキャッチできます。

  • クエリにannotateでコメントを追加できるようになった。
Post.for_user(user).annotate("fetching posts for user ##{user.id}").to_sql
#=> SELECT "posts".* FROM "posts" WHERE ... /* fetching posts for user #123 */
  • enumのネガティブスコープが自動で生成されるようになった(35381)。
class User < ApplicationRecord
  enum role: {
    member: "member",
    manager: "manager",
    admin: "admin"
  }
end

User.not_member == User.where.not(role: :member) #=> true
  • 以下のさまざまなショートカットが追加された:
  • seedの冒頭でModel.delete_allを実行する必要がなくなった。(railsコマンドの)db:truncate_allですべてのテーブルをDROPせずに内容をクリアできます。
# データベース内の全テーブルをtruncateする
# メモ: `be`は`bundle exec`のエイリアスを設定したものです(よろしければ皆さんもどうぞ)
$ be rails db:truncate_all

# truncateとdb:seedを以下のコマンド一発で実行できます
$ be rails db:seed:replant

この機能は、ステージングやレビュー用アプリで、データベースをDROPせずにseedを再実行したい場合に特に便利です(さすがにproductionのDBでseedを再実行するべきではありませんよね?)。

環境ごとのcredential

Rails 5.2から導入されたcredentialによって、評判のよろしくない.envファイルで重要なデータを管理せずに済むよう、新たなRails wayに則って重要なデータを扱えるようになっています。credentialを用いることで、サードパーティサービスの暗号化済みキーをバージョン管理システムに直接登録できるようになりました。

ただし現在のRailsでは、すべての環境で同一の暗号化済みファイルを使うようになっていたため、development環境とproduction環境で異なるキーを使おうとすると少々トリッキーになります。Rails 6ではこの点が環境ごとのcredential(#33521)によって最終的に解決されました。

私の作ったanyway_configでもcredentialをサポートしました。このgemを使うことで、アプリの設定データをさまざまなデータソース(credentialやYAMLファイルや環境変数)に直接アクセスせずに透過的に利用できます。

テーブル形式でないルーティング表示

アプリケーションのルーティングで頭を抱えたことのある方は、私の友人であるBenoitの新作rails routes --expandedをぜひお試しください(#32130)。

これであの邪魔っけなテーブル形式とおさらばです!

$ rails routes -g direct_uploads --expanded

Prefix            | rails_direct_uploads
Verb              | POST
URI               | /rails/active_storage/direct_uploads(.:format)
Controller#Action | active_storage/direct_uploads#create

Active Jobの小ネタ

Active Jobにもさまざまな改良が行われていて私の目を惹きつけました。

  • timezoneメタデータをジョブに追加することで、キューに入ったのと同じタイムゾーンでジョブを実行できるようになった(#32085)。ところで、ジョブ実行中は現在のロケールも保持されることをご存知ですか?
  • ジョブがキューに入った時刻を示すenqueued_atフィールドが新たにタイムスタンプとして追加された。これにより(私たちの作ったYabeda gemなどを用いて)パフォーマンス上きわめて重要な特性(ジョブがキューに入ってから実行されるまでの待ち時間)を測定できます。

Actionable Errorsによるエラー画面の操作性向上

最後は、私のもうひとりの友人であるGenadi Samokovarovが作った新機能で締めくくりたいと思います(#34788)。Genadiはweb-consoleの作者であり、Rails開発者をActionable Errors APIで幸せにしようとしています。

ブラウザ上でボタンをクリックするだけで、実行し忘れていたマイグレーションを実行できます

ブラウザ上でボタンをクリックするだけで、実行し忘れていたマイグレーションを実行できます

この機能は通常のRails例外ページにボタンを追加し、ブラウザのエラーページでマイグレーションを実行してActiveRecord::PendingMigrationErrorエラーを解決できるようにします。

カスタム例外にアクションを追加して機能を拡張するのも自由自在です!


ご覧のとおり、Rails 6には素敵な機能が山ほど盛り込まれています。こうした機能はさほどアナウンスされていませんが、名もないプルリクたちによって、皆さんが待ちに待った機能が実装されたり、あるいは既に愛用している有名な機能が強化されたりすることで、productionのRailsアプリが見違えるほど変わることもあります。

私見では、今回のRailsアップグレードは、特に長年に渡って成熟したプロジェクトから関心を寄せられると思います。正直に申し上げると、Rails 5が登場したときは、大量のRails 4コードベースをアップグレードするに足る理由が見当たりませんでした。Rails 6への移行は最終的に正しいものになると感じています。

お知らせ

スタートアップ企業をワープ速度まで加速すべく飛来した外宇宙のエンジニアたちに告ぐ: Evil Martiansのフォームまで連絡を乞う。

おたより発掘

関連記事

Rails 5.2新機能を先行チェック!Active Storage/ダイレクトアップロード/Early Hintsほか(翻訳)

Rails: 高速リアルタイム検索API「algolia-search-rails」gem README(翻訳)

$
0
0

概要

MITライセンスに基づいて翻訳・公開いたします。


algolia.comより

Rails: algolia-search-rails gem README(翻訳)

Algolia Searchは、最初のキーストロークを入力した時点でリアルタイムで結果を返せる、ホスト型検索エンジンです。

このgemはalgoliasearch-client-rubyを元に作られたもので、Algolia Search APIを自分好みのORMに簡単に統合できます。

Rails 3.x、4.x、5.xはすべてサポート対象です。

algoliasearch-rails-exampleサイトで、autocomplete.jsベースのオートコンプリート機能やInstantSearch.jsベースのインスタント検索結果ページをご覧いただけますので、ご興味がありましたらどうぞ。

API ドキュメント

完全なリファレンスはAlgoliaのWebサイトで参照いただけます。

訳注: 目次は省略しました

セットアップ

インストール方法

gem install algoliasearch-rails

Gemfileに以下を追加します。

gem "algoliasearch-rails"

続いて以下を実行します。

bundle install

設定

config/initializers/algoliasearch.rbファイルを作成し、APPLICATION_IDAPI_KEYをセットアップします。

AlgoliaSearch.configuration = { application_id: 'YourApplicationID', api_key: 'YourAPIKey' }

このgemは、ActiveRecordMongoidSequelと互換性があります。

タイムアウト

初期化時に以下のオプションを設定することで、さまざまなタイムアウトスレッショルドを設定できます。

AlgoliaSearch.configuration = {
  application_id: 'YourApplicationID',
  api_key: 'YourAPIKey',
  connect_timeout: 2,
  receive_timeout: 30,
  send_timeout: 30,
  batch_timeout: 120,
  search_timeout: 5
}

注意

このgemでは、インデックス作成タスクのトリガーにRailsのコールバックを多用しています。after_validationbefore_saveafter_commitといったコールバックをバイパスするメソッドが使われていると、変更がインデックスに反映されません。たとえば、update_attributeメソッドはバリデーションチェックを行いません。アップデート時にバリデーションを行うには、update_attributesをお使いください。

AlgoliaSearchモジュールによって注入されるメソッド名の冒頭にはすべてalgolia_が追加され、それらに関連する短いエイリアス名も追加されます(定義されていない場合)。

Contact.algolia_reindex! # <=> Contact.reindex!

Contact.algolia_search("jon doe") # <=> Contact.search("jon doe")

利用法

インデックスのスキーマ

以下のコードは、Contactインデックスを作成してContactモデルに検索機能を追加します。

class Contact < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch do
    attribute :first_name, :last_name, :email
  end
end

送信する属性を指定する(ここでは:first_name:last_name:emailに限定します)ことも、指定しない(この場合すべての属性が送信される)こともできます。

class Product < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch do
    # すべての属性が送信される
  end
end

add_attributeメソッドを用いて、モデルのすべての属性に加えて別の属性を送信することもできます。

class Product < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch do
    # すべての属性の他にextra_attrも送信される
    add_attribute :extra_attr
  end

  def extra_attr
    "extra_val"
  end
end

関連性の高さ

私たちの提供する設定では、インデックス全体の関連性の高さ(relevancy)をチューニングするさまざまな方法が使えます。その中でも最も重要性が高いのは、「検索可能な属性(searchable attributes)」と、「レコードの人気(record popularity)」を反映するいくつかの属性です。

class Product < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch do
    # Algoliaレコードのビルドに使う属性のリスト
    attributes :title, :subtitle, :description, :likes_count, :seller_name

    # 検索したい属性を`searchableAttributes`設定で定義する
    # (旧attributesToIndex)(ここでは`title`、`subtitle`、`description`)。
    # 重要性の高い順にリストアップする必要がある。
    # `description`に`unordered`とタグ付けすることでその属性のマッチ位置への影響を回避している。
    searchableAttributes ['title', 'subtitle', 'unordered(description)']

    # `customRanking`設定はランキングの基準(criteria)を定義するもので、
    # 2つのレコードのテキスト関連性が等しいかどうかを比較するのに用いられる。
    # これはそのレコードの人気(popularity)を反映する。
    customRanking ['desc(likes_count)']
  end

end

インデックス化

特定のモデルをインデックス化するには、そのクラスで単にreindexを呼び出します。

Product.reindex

すべてのモデルをインデックス化する場合は以下のようにします。

Rails.application.eager_load! # 全モデルが読み込み済みであること(development環境では必須)

algolia_models = ActiveRecord::Base.descendants.select{ |model| model.respond_to?(:reindex) }

algolia_models.each(&:reindex)

フロントエンド検索(リアルタイムエクスペリエンス)

従来の検索ロジックや機能は、バックエンドで実装される傾向がありました。この方法は、ユーザーが検索クエリを手入力して検索を実行し、結果ページにリダイレクトするという検索エクスペリエンスであれば事足りました。

検索をバックエンドで実装する必然性はもはやありません。現実には、ほとんどの場合ネットワークの遅延や処理の遅延が重なってパフォーマンスが悪化します。そこで、私たちが開発したJavaScript API Clientの利用を強くおすすめします。あらゆる検索リクエストをユーザーのブラウザやスマートフォンやクライアントから直接発行することで、トータルの検索遅延を削減しつつ、サーバーの負荷も同時に軽減します。

私たちのJS APIクライアントはgemに組み込まれているので、JavaScriptマニフェストの手頃な場所(Rails 3.1以降ならapplication.jsなど)でalgolia/v3/algoliasearch.minrequireするだけで準備できます。

//= require algolia/v3/algoliasearch.min

あとは以下のようなJavaScriptコードでできます。

var client = algoliasearch(ApplicationID, Search-Only-API-Key);
var index = client.initIndex('YourIndexName');
index.search('something', { hitsPerPage: 10, page: 0 })
  .then(function searchDone(content) {
    console.log(content)
  })
  .catch(function searchFailure(err) {
    console.error(err);
  });

先ごろ(2015年3月)JavaScriptクライアントの新しいバージョン(V3)をリリースしました。V2をお使いの方は移行ガイドをお読みください

バックエンド検索

注意: クエリをサーバーから送信せずにエンドユーザーのブラウザから直接クエリ送信するのであれば、JavaScript API Clientを使うことをおすすめします。

1件の検索はORMに沿ったオブジェクトを返しますが、そのときにデータベースからの再読み込みが発生します。トータルの遅延とサーバーの負荷を削減するためにも、クエリ実行はJavaScript API Clientで行うことをおすすめします。

hits =  Contact.search("jon doe")
p hits
p hits.raw_answer # 元の生JSON answerを取得する

各ORMオブジェクトにはhighlight_result属性が1つずつ追加されます。

hits[0].highlight_result['first_name']['value']

データベースからのオブジェクト再読み込みを行わずにAPIから生JSON answerを取り出したい場合は、次の方法が使えます。

json_answer = Contact.raw_search("jon doe")
p json_answer
p json_answer['hits']
p json_answer['facets']

検索パラメータは、インデックス設定から静的に指定することも、または検索時にsearchメソッドの第2引数でsearch parametersを動的に指定することもできます。

class Contact < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch do
    attribute :first_name, :last_name, :email

    # インデックス設定に保存されているデフォルトの検索パラメータ
    minWordSizefor1Typo 4
    minWordSizefor2Typos 8
    hitsPerPage 42
  end
end
# 動的な検索パラメータ
p Contact.raw_search('jon doe', { hitsPerPage: 5, page: 2 })

バックエンドのページネーション

私たちは、あらゆる検索の実行(すなわちページネーションも)をフロントエンドのJavaScriptで行うことを強くおすすめしていますが、ページネーションのバックエンドとしてwill_paginatekaminariもサポートします。

:will_paginateを用いる場合は以下のように:pagination_backendで指定します。

AlgoliaSearch.configuration = { application_id: 'YourApplicationID', api_key: 'YourAPIKey', pagination_backend: :will_paginate }

これで、searchメソッドを呼び出せばたちどころにページネーションされた結果が表示されます。

# コントローラ
@results = MyModel.search('foo', hitsPerPage: 10)

# ビュー(will_paginateを使う場合)
<%= will_paginate @results %>

# ビュー(kaminariを使う場合)
<%= paginate @results %>

タグ付け

tagsメソッドで以下のようにレコードにタグを追加できます。

class Contact < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch do
    tags ['trusted']
  end
end

以下のように動的な値も使えます。

class Contact < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch do
    tags do
      [first_name.blank? || last_name.blank? ? 'partial' : 'full', has_valid_email? ? 'valid_email' : 'invalid_email']
    end
  end
end

結果セットを特定のタグで絞り込むには、クエリ発行時に{ tagFilters: 'tagvalue' }または{ tagFilters: ['tagvalue1', 'tagvalue2'] }を検索パラメータとして指定します。

ファセット

検索結果でさらにfacetsメソッドを呼ぶことで、ファセットを取得できます。

class Contact < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch do
    # [...]

    # ファセットで使える属性のリストを指定する
    attributesForFaceting [:company, :zip_code]
  end
end
hits = Contact.search('jon doe', { facets: '*' })
p hits                    # ORM-compliant array of objects
p hits.facets             # extra method added to retrieve facets
p hits.facets['company']  # facet values+count of facet 'company'
p hits.facets['zip_code'] # facet values+count of facet 'zip_code'
raw_json = Contact.raw_search('jon doe', { facets: '*' })
p raw_json['facets']

ファセットの検索

以下のようにファセットの値も検索できます。

Product.search_for_facet_values('category', 'Headphones') # {value, highlighted, count}の配列

このメソッドには、クエリで使える任意のパラメータを渡せます。これによって、そのクエリにマッチしそうな結果だけを返すように調整できます。

# 「red Apple products」(およびそれらの個数のみ)を含むカテゴリだけを返す
Product.search_for_facet_values('category', 'phone', {
  query: 'red',
  filters: 'brand:Apple'
}) # 「red Apple products」にリンクするphoneカテゴリの配列

グループ化(group by)

グループ化をdistinctに行う方法について詳しくはこちらをご覧ください。

class Contact < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch do
    # [...]

    # レコードをグループ化する属性を指定する
    # (ここではcompanyでレコードをグループ化する)
    attributeForDistinct "company"
  end
end

地理的な検索(geo-search)

レコードの地理上の位置で絞り込むにはgeolocメソッドを使います。

class Contact < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch do
    geoloc :lat_attr, :lng_attr
  end
end

結果セットをSan Joseの周囲50km以内に絞り込むには、クエリ発行時に{ aroundLatLng: "37.33, -121.89", aroundRadius: 50000 }を検索パラメータとして指定します。

オプション

自動インデックスと非同期実行

インデックスは、レコードが1件保存されるたびに「非同期的に」反映され、レコードが1件削除(destroy)されるたびにインデックスから「非同期に」削除されます。具体的には、ADDやDELETEを伴うネットワーク呼び出しは同期的にAlgolia APIに送信されますが、Algoliaのエンジンでの処理は非同期的に行われます。つまり、直後だと結果が反映されない可能性があります。

自動インデックスやインデックスからの自動削除の設定は、以下のオプションで無効にできます。

class Contact < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch auto_index: false, auto_remove: false do
    attribute :first_name, :last_name, :email
  end
end

自動インデックスを一時的に無効にする

自動インデックスは、without_auto_indexスコープで一時的に無効にできます。これはパフォーマンス上の理由でよく使われます。

Contact.delete_all
Contact.without_auto_index do
  1.upto(10000) { Contact.create! attributes } # このブロック内では自動インデックスが動かない
end
Contact.reindex! # バッチ操作を用いる

キューとバックグラウンドジョブ

自動インデックスや自動削除の処理を設定することで、キューを用いてこれらの処理をバックグラウンド実行できます。デフォルトではActive Job(Rails 4.2以降)のキューが用いられますが、独自のキューイングメカニズムを定義することもできます。

class Contact < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch enqueue: true do # ActiveJobは`algoliasearch`キューでトリガされる
    attribute :first_name, :last_name, :email
  end
end

考慮すべき点

更新や削除をバックグラウンドで行う場合、ジョブの実際の実行時より前のタイミングでデータベースにレコードの削除がコミットされる可能性があります。万一、レコードを削除するためにレコードをデータベースから読み込むと、ActiveRecord#findがRecordNotFoundで失敗します。

このような場合は、ActiveRecordからのレコード読み込みをバイパスしてインデックスを直接操作する方法があります。

class MySidekiqWorker
  def perform(id, remove)
    if remove
      # レコードがデータベースから削除された可能性があれば
      # ActiveRecord#findで読み込めない
      index = Algolia::Index.new("index_name")
      index.delete_object(id)
    else
      # レコードは存在するはず
      c = Contact.find(id)
      c.index!
    end
  end
end

Sidekiqの場合

Sidekiqの場合は次のようにします。

class Contact < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch enqueue: :trigger_sidekiq_worker do
    attribute :first_name, :last_name, :email
  end

  def self.trigger_sidekiq_worker(record, remove)
    MySidekiqWorker.perform_async(record.id, remove)
  end
end

class MySidekiqWorker
  def perform(id, remove)
    if remove
      # レコードがデータベースから削除された可能性があるので
      # ActiveRecord#findで読み込めない
      index = Algolia::Index.new("index_name")
      index.delete_object(id)
    else
      # レコードは存在するはず
      c = Contact.find(id)
      c.index!
    end
  end
end

DelayedJobの場合

delayed_jobの場合は次のようにします。

class Contact < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch enqueue: :trigger_delayed_job do
    attribute :first_name, :last_name, :email
  end

  def self.trigger_delayed_job(record, remove)
    if remove
      record.delay.remove_from_index!
    else
      record.delay.index!
    end
  end
end

同期処理とテストについて

次のオプションを設定することで、インデックス化とインデックスからの削除を同期的に行うよう強制できます(この場合、gemはwait_taskメソッドを呼ぶことで、メソッドから戻ったときにこの操作に対応します)。ただし、この操作は非推奨です(テスト目的を除く)。

class Contact < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch synchronous: true do
    attribute :first_name, :last_name, :email
  end
end

インデックス名をカスタマイズする

デフォルトではクラス名がインデックス名に使われます(「Contact」など)。index_nameオプションでインデックス名をカスタマイズできます。

class Contact < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch index_name: "MyCustomName" do
    attribute :first_name, :last_name, :email
  end
end

インデックス名に環境を追加する

以下のオプションを用いて、Railsの現在の環境をインデックス名の末尾に追加できます。

class Contact < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch per_environment: true do # インデックス名は"Contact_#{Rails.env}"となる
    attribute :first_name, :last_name, :email
  end
end

属性定義のカスタマイズ

複雑な属性値をブロックで指定できます。

class Contact < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch do
    attribute :email
    attribute :full_name do
      "#{first_name} #{last_name}"
    end
    add_attribute :full_name2
  end

  def full_name2
    "#{first_name} #{last_name}"
  end
end

注意: この種のコードを用いて属性を追加で定義すると、その直後から属性の変更をこのgemで検出不可能になってしまいます(このgemではRailsの#{attribute}_changed?メソッドで変更を検出しています)。その結果、レコードの属性が変更されていない場合にもレコードがAPIにプッシュされます。次のように_changed?メソッドを作成することでこの振る舞いを回避できます。

class Contact < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch do
    attribute :email
    attribute :full_name do
      "#{first_name} #{last_name}"
    end
  end

  def full_name_changed?
    first_name_changed? || last_name_changed?
  end
end

ネステッドオブジェクトやネステッドリレーションについて

リレーションシップの定義

追加の属性を定義し、JSONに沿った任意のオブジェクト(配列、ハッシュ、配列とハッシュの組み合わせのいずれか)を返すネステッドオブジェクトを簡単に埋め込むことができます。

class Profile < ActiveRecord::Base
  include AlgoliaSearch

  belongs_to :user
  has_many :specializations

  algoliasearch do
    attribute :user do
      # ネステッド"user"オブジェクトを`name` + `email`に制限
      { name: user.name, email: user.email }
    end
    attribute :public_specializations do
      # public specializationの配列をビルド(`title`と`another_attr`のみを含む)
      specializations.select { |s| s.public? }.map do |s|
        { title: s.title, another_attr: s.another_attr }
      end
    end
  end

end

ネステッドな子オブジェクトの変更を反映させる

Active Recordの場合

Active Recordでは、touchafter_touchで行います。

# app/models/app.rb
class App < ApplicationRecord
  include AlgoliaSearch

  belongs_to :author, class_name: :User
  after_touch :index!

  algoliasearch do
    attribute :title
    attribute :author do
      author.as_json
    end
  end
end

# app/models/user.rb
class User < ApplicationRecord
  # belongs_to関連付けを使う場合は
  # - `touch: true`を使うこと
  # - `after_save`フックは定義しないこと
  has_many :apps, foreign_key: :author_id

  after_save { apps.each(&:touch) }
end
Sequelの場合

Sequelではtouchプラグインで変更を反映できます。

# app/models/app.rb
class App < Sequel::Model
  include AlgoliaSearch

  many_to_one :author, class: :User

  plugin :timestamps
  plugin :touch

  algoliasearch do
    attribute :title
    attribute :author do
      author.to_hash
    end
  end
end

# app/models/user.rb
class User < Sequel::Model
  one_to_many :apps, key: :author_id

  plugin :timestamps
  # この関連付けは利用不可(これはafter_saveをトリガしない)
  plugin :touch

  # ここでtouchされる必要のある関連付けを定義する
  # 効率はよくないが、after_saveをトリガできるようになる
  def touch_associations
    apps.map(&:touch)
  end

  def touch
    super
    touch_associations
  end
end

カスタムobjectID

objectIDは、デフォルトではそのレコードのidに基づきます。:idオプションを指定すればこの振る舞いを変更できます(ただしuniqフィールドを使うこと)。

class UniqUser < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch id: :uniq_name do
  end
end

制約でデータのサブセットのみをインデックス化する

:ifオプションや:unlessオプションを用いて、レコードのインデックス化に制約を追加できます。

これによって、条件付きインデックス化や、ドキュメントごとのインデックス化ができるようになります。

class Post < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch if: :published?, unless: :deleted? do
  end

  def published?
    # [...]
  end

  def deleted?
    # [...]
  end
end

注意: これらの制約を使うと、インデックスをデータベースと同期するために直ちにaddObjects呼び出しやdeleteObjects呼び出しが実行されるようになります。その場合、ステートレスなgemからはオブジェクトが制約とマッチするかどうかを認識できなくなるか、一切マッチしなくなるので、私たちはADD操作やDELETE操作を送信するよう強制しています。_changed?メソッドを作成することでこの振る舞いを変更できます。

class Contact < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch if: :published do
  end

  def published
    # trueかfalseを返す
  end

  def published_changed?
    # 「published」ステートが変更された場合にのみtrueを返す
  end
end

以下のいずれかの方法で、レコードのサブセットをインデックス化できます。

# will generate batch API calls (recommended)
MyModel.where('updated_at > ?', 10.minutes.ago).reindex!
MyModel.index_objects MyModel.limit(5)

サニタイザ

sanitizeオプションで属性をすべてサニタイズできます。属性に含まれるHTMLタグはすべて取り除かれます。

class User < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch per_environment: true, sanitize: true do
    attributes :name, :email, :company
  end
end

Rails 4.2以降をご利用の場合は、rails-html-sanitizerへの依存も必要です。

gem 'rails-html-sanitizer'

UTF-8エンコーディング

force_utf8_encodingオプションで属性をすべて強制的にUTF-8エンコーディングにできます。

class User < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch force_utf8_encoding: true do
    attributes :name, :email, :company
  end
end

注意: このオプションはRuby 1.8と互換性がありません。

例外処理

raise_on_failureオプションで、Algolia APIへのアクセスを試行中にraiseされる可能性のある例外を無効にできます。

class Contact < ActiveRecord::Base
  include AlgoliaSearch

  # development環境でのみ例外をraiseする
  algoliasearch raise_on_failure: Rails.env.development? do
    attribute :first_name, :last_name, :email
  end
end

設定例

以下は、実際に使われている設定例です(HN Searchより)。

class Item < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch per_environment: true do
    # the list of attributes sent to Algolia's API
    attribute :created_at, :title, :url, :author, :points, :story_text, :comment_text, :author, :num_comments, :story_id, :story_title

    # integer version of the created_at datetime field, to use numerical filtering
    attribute :created_at_i do
      created_at.to_i
    end

    # `title` is more important than `{story,comment}_text`, `{story,comment}_text` more than `url`, `url` more than `author`
    # btw, do not take into account position in most fields to avoid first word match boost
    searchableAttributes ['unordered(title)', 'unordered(story_text)', 'unordered(comment_text)', 'unordered(url)', 'author']

    # tags used for filtering
    tags do
      [item_type, "author_#{author}", "story_#{story_id}"]
    end

    # use associated number of HN points to sort results (last sort criteria)
    customRanking ['desc(points)', 'desc(num_comments)']

    # google+, $1.5M raises, C#: we love you
    separatorsToIndex '+#$'
  end

  def story_text
    item_type_cd != Item.comment ? text : nil
  end

  def story_title
    comment? && story ? story.title : nil
  end

  def story_url
    comment? && story ? story.url : nil
  end

  def comment_text
    comment? ? text : nil
  end

  def comment?
    item_type_cd == Item.comment
  end

  # [...]
end

インデックス

手動でのインデックス化

index!インスタンスメソッドでインデクス化をトリガできます。

c = Contact.create!(params[:contact])
c.index!

インデックスからの手動削除

remove_from_index!インスタンスメソッドでインデックスからの削除をトリガできます。

c.remove_from_index!
c.destroy

再インデックス化

このgemでは、全オブジェクトの再インデックス化方法を2とおり提供しています。

アトミックな再インデックス化

reindexクラスメソッドは、該当の全オブジェクトを<INDEX_NAME>.tmpという一時インデックスを作成してから、この一時インデックスを(アトミックにインデックス化完了した)最終インデックスに移動することによって、(削除済みオブジェクトも考慮に入れて)全レコードを再インデックス化します。これは、全コンテンツを再インデックス化する最も安全な方法です。

Contact.reindex

注意: インデックス固有のAPIキーを利用している場合は、<INDEX_NAME><INDEX_NAME>.tmpの両方を許可してください。

警告: このようなアトミックな再インデックス化は、モデルのスコープやフィルタがかかっている状態で行うべきではありません。理由は、この操作によってインデックス全体が置き換わり、フィルタされたオブジェクトだけが残ってしまうためです。例: MyModel.where(...).reindexではなくMyModel.where(...).reindex!とすること(末尾の!は必ず付けること!!!)。

正規の再インデックス化

対象の全オブジェクトを(一時インデックスを使わず、除外されたオブジェクトを削除することもなく)インプレースで再インデックス化するには、reindex!クラスメソッドを使います。

Contact.reindex!

インデックスをクリアする

インデックスをクリアするには、clear_index!クラスメソッドを使います。

Contact.clear_index!

背後のインデックスにアクセスする

indexクラスメソッドを呼び出すことで、背後のindexオブジェクトにアクセスできます。

index = Contact.index
# index.get_settings, index.partial_update_object, ...

primary/replica

add_replicaメソッドを使ってreplicaインデックスを定義できます。primary設定をreplicaで継承したい場合はreplicaのブロックでinherit: trueをお使いください。

class Book < ActiveRecord::Base
  attr_protected

  include AlgoliaSearch

  algoliasearch per_environment: true do
    searchableAttributes [:name, :author, :editor]

    # `author`のみで検索する目的でreplicaインデックスを定義する
    add_replica 'Book_by_author', per_environment: true do
      searchableAttributes [:author]
    end

    # 他はメインブロックと同じで並び順だけカスタマイズした
    # replicaインデックスを定義する
    add_replica 'Book_custom_order', inherit: true, per_environment: true do
      customRanking ['asc(rank)']
    end
  end

end

replicaで検索するには以下のコードを使います。

Book.raw_search 'foo bar', replica: 'Book_by_editor'
# または
Book.search 'foo bar', replica: 'Book_by_editor'

単一のインデックスを共有する

1つのインデックスを複数のモデルで共有するのがよいこともあります。これを実装するには、背後のどのモデルでも決してobjectIDがコンフリクトしないようにする必要が生じます。

class Student < ActiveRecord::Base
  attr_protected

  include AlgoliaSearch

  algoliasearch index_name: 'people', id: :algolia_id do
    # [...]
  end

  private
  def algolia_id
    "student_#{id}" # teacherとstudentのIDがコンフリクトしないようにすること
  end
end

class Teacher < ActiveRecord::Base
  attr_protected

  include AlgoliaSearch

  algoliasearch index_name: 'people', id: :algolia_id do
    # [...]
  end

  private
  def algolia_id
    "teacher_#{id}" # teacherとstudentのIDがコンフリクトしないようにすること
  end
end

注意: 複数のモデルを元にした1つのインデックスを対象とする場合、MyModel.reindexは絶対に使わないでください。使うのはMyModel.reindex!だけです。reindexメソッドは、再インデックス化をアトミックに行う目的で一時インデックスを用います。これが使われると、生成されるインデックスにはモデルの現在のレコードしか含まれなくなってしまいます(他のレコードが再インデックス化されません)。

複数のインデックスを対象に設定する

add_indexメソッドを用いることで、1つのレコードを複数のインデックスでインデックス化できます。

class Book < ActiveRecord::Base
  attr_protected

  include AlgoliaSearch

  PUBLIC_INDEX_NAME  = "Book_#{Rails.env}"
  SECURED_INDEX_NAME = "SecuredBook_#{Rails.env}"

  # すべての本を'SECURED_INDEX_NAME'インデックスに保存する
  algoliasearch index_name: SECURED_INDEX_NAME do
    searchableAttributes [:name, :author]
    # securityをタグに変換する
    tags do
      [released ? 'public' : 'private', premium ? 'premium' : 'standard']
    end

    # publicな(つまりreleasedだがpremiumではない)本を
    # 'PUBLIC_INDEX_NAME'インデックスに保存する
    add_index PUBLIC_INDEX_NAME, if: :public? do
      searchableAttributes [:name, :author]
    end
  end

  private
  def public?
    released && !premium
  end

end

追加のインデックスで検索するには、次のコードを使います。

Book.raw_search 'foo bar', index: 'Book_by_editor'
# or
Book.search 'foo bar', index: 'Book_by_editor'

テスト

テストの注意点

specを実行するために、ALGOLIA_APPLICATION_IDALGOLIA_API_KEYの環境変数を設定してください。テストで作成および削除したインデックスは、productionアカウントでは決して使わないでください

可能なら次のようにdisable_indexingオプションを設定し、API呼び出しでインデックス化操作(追加/更新/削除)をすべて無効にしておきましょう。

class User < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch per_environment: true, disable_indexing: Rails.env.test? do
  end
end

class User < ActiveRecord::Base
  include AlgoliaSearch

  algoliasearch per_environment: true, disable_indexing: Proc.new { Rails.env.test? || more_complex_condition } do
  end
end

またはAlgolia API呼び出しをモック(mock)にしてもよいでしょう。私たちは、algolia/webmockを使えるサンプル設定をWebMockで提供しています。

require 'algolia/webmock'

describe 'With a mocked client' do

  before(:each) do
    WebMock.enable!
  end

  it "ここでは一切APIを呼び出してはならない" do
    User.create(name: 'My Indexed User')  # モック化済み(APIは呼び出されない)
    User.search('').should == {}          # モック化済み(APIは呼び出されない)
  end

  after(:each) do
    WebMock.disable!
  end

end

おたより発掘

関連記事

インタビュー: 超高速リアルタイム検索APIサービス「Algolia」の作者が語る高速化の秘訣(翻訳)

ActiveRecord::QueryMethodsのselectメソッドについて深掘りしてみた

$
0
0

こんにちは。BPSに入社してちょうど1年になりましたshin1rokです。

入社時に目標にしていた「TechRachoに技術系の記事を投稿する」を果たすべく、ActiveRecord::QueryMethodsselectメソッドを深掘りしてみます。

環境

  • Ruby: 2.6.3
  • Rails: 5.2.3

ローカルにRailsを読むためだけの小さいアプリを作り、RubyMineのコードジャンプとブレークポイントを駆使して探索しました。

そもそも(および深掘りの視点)

selectメソッドはこのようにModelを拡張する形でAttribute(?)を追加することができます。

※アソシエーションはUser has_many posts

irb(main):014:0> user = User.joins(:posts).select('users.id, posts.id as post_id').first
  User Load (0.6ms)  SELECT  users.id, posts.id as post_id FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id" ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1]]
=> #<User id: 1, post_id: 30>
irb(main):016:0> user.post_id
=> 30

このときuserインスタンスにidメソッドは定義されていますが、post_idメソッドは定義されていません。

irb(main):023:0> user.methods.find { |m| m == :id }
=> :id
irb(main):024:0> user.methods.find { |m| m == :post_id }
=> nil

なぜだろうか?と思いつつも、仕事でコードを書くときには意識しなくていい部分なので、無視していました。

夏のTechRachフェアが開催され良い機会なので、Railsのコードを読んで post_idメソッドがどこで定義されているのか 探してみようと思います。

Railsダンジョンへ

以下を実行した時のselectメソッド以降を呼び出し順に探索していきます。

class UsersController < ApplicationController
  def index
    puts User.joins(:posts).select('users.id, posts.id as post_id').limit(2)
  end
end

※件数を減らすためにlimit(2)しています。

selectメソッド

def select(*fields)
  if block_given?
    if fields.any?
      raise ArgumentError, "`select' with block doesn't take arguments."
    end

    return super()
  end

  raise ArgumentError, "Call `select' with at least one field" if fields.empty?
  spawn._select!(*fields)
end

rails/query_methods.rb#L220-L231 · rails/railsより

selectメソッドはArrayに対しても使うことができるのですが、その部分がif block_given?です。

ActiveRecordのselectspawn._select!(*fields)の部分です。
spawnは生み出すという意味があるので、spawn._select!(*fields)でSQLのSELECT文を生成しようとしているように見えます。

spawnメソッド

def spawn #:nodoc:
  @delegate_to_klass ? klass.all : clone
end

rails/spawn_methods.rb#L10-L12 at 5-2-stable · rails/railsより

spawnselectのレシーバ(User.joins(:posts))のようです。

_select!メソッド

def _select!(*fields) # :nodoc:
  fields.flatten!
  self.select_values += fields
  self
end

rails/query_methods.rb#L233-L237 · rails/railsより

引数をレシーバのselect_valuesに設定して、selfを返しています。

recordsメソッド

def records # :nodoc:
  load
  @records
end

rails/relation.rb#L199-L202 · rails/railsより

_select!のあとなんやかんやがあってrecordsメソッドのloadが呼ばれます。

注) なんやかんや: ActiveRecord::Relationは遅延評価されるため。なんやかんやの部分は追いきれませんでした。

loadメソッド

def load(&block)
  exec_queries(&block) unless loaded?

  self
end

rails/relation.rb#L421-L425 · rails/railsより

まだloadしていないのでexec_queries(&block)が実行されます。

exec_queriesメソッド

def exec_queries(&block)
  skip_query_cache_if_necessary do
    @records =
      if eager_loading?
        apply_join_dependency do |relation, join_dependency|
          if ActiveRecord::NullRelation === relation
            []
          else
            relation = join_dependency.apply_column_aliases(relation)
            rows = connection.select_all(relation.arel, "SQL")
            join_dependency.instantiate(rows, &block)
          end.freeze
        end
      else
        klass.find_by_sql(arel, &block).freeze
      end

    preload = preload_values
    preload += includes_values unless eager_loading?
    preloader = nil
    preload.each do |associations|
      preloader ||= build_preloader
      preloader.preload @records, associations
    end

    @records.each(&:readonly!) if readonly_value

    @loaded = true
    @records
  end
end

rails/relation.rb#L546-L576 · rails/railsより

急に長くなったので面食らいますが、注目するところはklass.find_by_sql(arel, &block).freezeです。
eager_loadとpreloadは今回は関係ないので無視すると、@recordsklass.find_by_sql(arel, &block).freezeを入れて、@recordsをreturnしています。

このときklassにはUserクラスが入っています。

0> klass
=> User(id: integer, email: string, encrypted_password: string, reset_password_token: string, reset_password_sent_at: datetime, remember_created_at: datetime, confirmation_token: string, confirmed_at: datetime, confirmation_sent_at: datetime, unconfirmed_email: string, created_at: datetime, updated_at: datetime, url_name: string)

find_by_sqlメソッド

def find_by_sql(sql, binds = [], preparable: nil, &block)
  result_set = connection.select_all(sanitize_sql(sql), "#{name} Load", binds, preparable: preparable)
  column_types = result_set.column_types.dup
  attribute_types.each_key { |k| column_types.delete k }
  message_bus = ActiveSupport::Notifications.instrumenter

  payload = {
    record_count: result_set.length,
    class_name: name
  }

  message_bus.instrument("instantiation.active_record", payload) do
    result_set.map { |record| instantiate(record, column_types, &block) }
  end
end

rails/querying.rb#L40-L54 · rails/railsより

result_setはActiveRecord::Resultクラスのオブジェクトで、mapのブロック引数の値はこうなっています。

0> result_set
=> #<ActiveRecord::Result:0x00007fc08d695230 @columns=["id", "post_id"], @rows=[[1, 30], [1, 29]], @hash_rows=[{"id"=>1, "post_id"=>30}, {"id"=>1, "post_id"=>29}], @column_types={"id"=>#<ActiveModel::Type::Integer:0x00007fc08f0c59c0 @precision=nil, @scale=nil, @limit=8, @range=-9223372036854775808...9223372036854775808>, "post_id"=>#<ActiveModel::Type::Integer:0x00007fc08f0c59c0 @precision=nil, @scale=nil, @limit=8, @range=-9223372036854775808...9223372036854775808>}>
0> result_set[0]
=> {"id"=>1, "post_id"=>30}

column_typesにはpost_idがどのような型なのかを定義する情報がはいっています。

0> column_types
=> {"post_id"=>#<ActiveModel::Type::Integer:0x00007fcd66236ff0 @precision=nil, @scale=nil, @limit=8, @range=-9223372036854775808...9223372036854775808>}

ActiveSupport::Notifications.instrumenter.instrumentはブロックで渡された内容の実行時間を計測します。

result_set.map do |record| instantiate(record, column_types, &block)をActiveRecord::RelationオブジェクトでラップしたものがUser.joins(:posts).select('users.id, posts.id as post_id').limit(2)とイコールになります。

0> result_set.map {|record| instantiate(record, column_types, &block)}
=> [#<User id: 1, post_id: 30>, #<User id: 1, post_id: 29>]

0> User.joins(:posts).select('users.id, posts.id as post_id').limit(2)
  CACHE User Load (0.0ms)  SELECT  users.id, posts.id as post_id FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id" LIMIT $1  [["LIMIT", 2]]
  ↳ /Users/shin1rok/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/ruby-debug-ide-0.7.0/lib/ruby-debug-ide/command.rb:138
=> #<ActiveRecord::Relation [#<User id: 1, post_id: 30>, #<User id: 1, post_id: 29>]>

instantiateメソッド

def instantiate(attributes, column_types = {}, &block)
  klass = discriminate_class_for_record(attributes)
  attributes = klass.attributes_builder.build_from_database(attributes, column_types)
  klass.allocate.init_with("attributes" => attributes, "new_record" => false, &block)
end

rails/persistence.rb#L68-L72 · rails/railsより

klass.allocate.init_with("attributes" => attributes, "new_record" => false, &block)がActiveRecord::Relationオブジェクトの各要素です。

0> klass.allocate.init_with("attributes" => attributes, "new_record" => false, &block)
=> #<User id: 1, post_id: 30>

0> klass.allocate.init_with("attributes" => attributes, "new_record" => false, &block).post_id
=> 30

ここまででpost_idをUserモデルのインスタンスに割り当てている処理が特定できたので今回の探索はここまでにします。

続きは君の目で確かめてくれ!

余談

user_idpost_idのペアが欲しかったのでselectを使ったのですが、

id_pairs = User.joins(:posts).select('users.id, posts.id as post_id')
id_pairs.group_by(&:id).each do |user_id, pairs|
  p user_id
  p pairs
end

ActiveRecord::Relationオブジェクトを作らない分速くなるので、こちらの方が良かったですね。
pluckに関連先のカラムを書くという発想がありませんでした😇

id_pairs = User.joins(:posts).pluck('users.id, posts.id')
id_pairs.group_by(&:first).each do |user_id, pairs|
  p user_id
  p pairs
end

Rails: メールをActive Recordのコールバックで送信しないこと(翻訳)

$
0
0

概要

原著者の許諾を得て翻訳・公開いたします。

Rails: メールをActive Recordのコールバックで送信しないこと(翻訳)

Railsアプリケーションで何かとやってみたくなることのひとつといえば、メール送信でしょう。

モデルのインスタンスが変更または作成されたときにメールを送信するというのが、よくあるパターンです。

次のようにしないこと

モデルのコールバックにメール送信を仕込む。

class BookReview < ApplicationRecord
  after_create :send_email_to_author

  private

  def send_email_to_author
    AuthorMailer.
      with(author: author).
      review_notification.
      deliver_now
  end
end

次のようにすること

メールをコントローラで送信する。

class BookReviewsController < ApplicationController
  def create
    BookReview.create(comment_params)
    AuthorMailer.
      with(author: author).
      review_notification.
      deliver_now
  end
end

コールバックで送信すべきでない理由

後でコードに触るときに理解しやすくするためであり、後で自分がびっくりしないためです。

上のコード例で考えてみましょう。本にブックレビューを付けるときに、その本の著者に必ずしもメールを送る必要があるとは限りません。1つ目のサンプルコードでは、ブックレビューを1つ作成する副作用としてメールが発射されてしまいます。

場合によってはrails consoleなどで何か操作するときに、ユーザーが著者にメールを送信せずに本のレビューを作成する必要が生じるかもしれません。コールバックをスキップするメソッドを駆使する方法も一応可能ですが、そこから先は泥沼です。

「やることリスト」をコントローラのアクションの中に置いてそこで全部見えるようにしておく方が、明確かつ手続き的です。この場合、ブックレビューの作成の後に行う別の操作としてメール送信を書いておけば、後でコードを見返したときに意図がずっと明確になります。

それだけではありません。無関係な機能をデバッグするために、モデルのコールバックたちをもれなくチェックして回るのは、認知に大きな負荷がかかってつらい作業になります。自分の頭で把握しておかなければならないコンテキストがぐっと増えてしまうからです。

コールバックで送信する理由があるとすれば

ドキュメントではいつも、メール送信をモデルのコールバックで行うことは「Rails Way」であるとみなされて上のようなコード例が付いていたりします。そして、シンプルなケースであれば実際にはこれといった問題は生じません。しかし、やがてアプリケーションが複雑になってくれば、このアプローチのつらみだけが前面に出てくるようになるのです。

「モデルはファットに、コントローラは薄くすることを目指すべき」とよく言われることもあって、私たちは多くの機能をモデル層に盛り込むようになります。これはもちろん一般的にはよいアドバイスではあります。しかしこれはどちらかというと、コールバックを量産する副作用を使いまくってもよいということではなく、アプリケーションの操作を明確にすることでコントローラ層から複雑さを排除しようという話です。

自分の感覚では、モデル変更に伴ってメールを送信する程度であれば、コールバックベースの抽象化が混乱するほど複雑にはならないと思います。大事なのは、コントローラのアクションが呼び出されると、アプリケーションのユーザーに重要なことが2つ発生することを明確に示すことです。

私は、コントローラのメソッドが複雑になってきたときには、それらをコールバックに押し込めるよりも素のRubyの「Service Object」に移す方が好みです。

おたより発掘

関連記事

Rails: :before_validationコールバックの逸脱した用法を改善する(翻訳)

Rails: Active Recordのコールバックを避けて「Domain Event」を使おう(翻訳)

Rails APIドキュメント: Active Recordのトランザクション(翻訳)

$
0
0

概要

MITライセンスに基づいて翻訳・公開いたします。

Rails APIドキュメント: Active Recordのトランザクション(翻訳)

トランザクションとは、それが1件のアトミックな操作としてすべて成功した場合に限りSQLステートメントが永続化する、保護的なブロックです。古典的な例としては「出金が成功した場合にのみ入金ができる(またはその逆の)2つの口座間での振替」があります。トランザクションはデータベースの一貫性を強制し、プログラムのエラーやデータベースの破損からデータを保護します。つまり、「すべて一括実行される」か「一切実行されない」かのどちらかでなければならないステートメントが複数ある場合は、基本的にトランザクションブロックを使うべきです。

以下の例をご覧ください。

ActiveRecord::Base.transaction do
  david.withdrawal(100)
  mary.deposit(100)
end

このコード例では、withdrawaldepositのどちらも例外をraiseしない場合に、Davidからお金を取り出してMaryに渡します。例外が発生するとROLLBACKを強制的に実行して、データベースをトランザクション開始前の状態に戻します。ただし、このオブジェクトは、トランザクション開始前のステートに戻されたインスタンスデータを「持たない」ことにご注意ください。

⚓ 1つのトランザクション内に異なるActive Recordクラスがある場合

transactionクラスのメソッドは、何らかのActive Recordクラス上で呼び出されますが、そのトランザクションブロック内部にあるこのオブジェクトは、必ずしもそのクラスのインスタンスである必要はありません。その理由は、トランザクションはモデル単位ではなく「データベースコネクション単位」だからです。

以下の例で言うと、balanceレコードは、transactionAccountクラスで呼び出された場合であってもトランザクショナルにsaveされます。

Account.transaction do
  balance.save!
  account.save!
end

transactionメソッドは、モデルのインスタンスメソッドとしても利用できます。たとえば以下のようにも書けます。

balance.transaction do
  balance.save!
  account.save!
end

⚓ Transactionsは複数のデータベースコネクションに分散されない

ひとつのトランザクションの操作は、ひとつのデータベースコネクション上で行われます。クラス固有のデータベースが複数ある場合、トランザクションはそれらのデータベース間でのやりとりを保護しません。これを回避する方法のひとつは、改変するモデルのクラスごとにトランザクションを開始することです。

Student.transaction do
  Course.transaction do
    course.enroll(student)
    student.units += course.units
  end
end

これは解決方法としては今ひとつですが、完全に分散した(複数の)トランザクションがActive Recordのスコープを越えるようになります。

⚓ savedestroyは自動的にトランザクションでラップされる

#save#destroyは、どちらもひとつのトランザクション内にラップされ、バリデーションやコールバックで行うあらゆる操作はこのトランザクションの保護下に置かれます。これによって、トランザクションが依存する値をチェックするためのバリデーションを使うことも、after_*コールバックで例外をraiseしてロールバックすることもできます。

それにより、データベースの変更は「操作が完了するまで」そのデータベースコネクションの外部からは見えなくなります。たとえば、ある検索エンジンのインデックスをafter_saveコールバック内で更新しようとする場合、このインデクサは更新済みレコードを参照しません。唯一の例外はafter_commitコールバックで、更新がひとたびコミットされればトリガーされます。詳しくは以下をご覧ください。

⚓ Exceptionハンドリングとロールバック

もうひとつ忘れてはならないのが、あるトランザクションブロック内で発生した例外は(ROLLBACKがトリガーされた後で)伝搬することです。すなわち、これらの例外はアプリケーションコード内でキャッチできるようにしておくべきです。

ひとつの例外はActiveRecord::Rollbackです。これはraiseの時点でROLLBACKをトリガーしますが、そのトランザクションブロックによって再度raiseされることはありません。

警告: ActiveRecord::StatementInvalid例外をトランザクションブロック内部でキャッチしてはいけません。ActiveRecord::StatementInvalid例外は、エラーがデータベースレベルで発生したことを表します(一意性制約に違反した場合など)。データベースによっては、ひとつのトランザクション内部でのデータベースエラーによってそのトランザクション全体が利用不能になり、最初からやり直すまで利用できなくなるものがあります(PostgreSQLなど)。この問題を説明するためのコード例を以下に示します。

# Numberモデルに`i`というuniqueカラムがあるとする
Number.transaction do
  Number.create(i: 0)
  begin
    # unique制約エラーをraiseする...
    Number.create(i: 0)
  rescue ActiveRecord::StatementInvalid
    # (ここは無視する)
  end

  # PostgreSQLではここでトランザクションが利用不能になる。
  # 以下のステートメントはunique制約に違反しなくなったとしても
  # PostgreSQLエラーになる。
  Number.create(i: 1)
  # => "PG::Error: ERROR:  current transaction is aborted, commands
  #     ignored until end of transaction block"
end

ActiveRecord::StatementInvalidが発生したら、トランザクション全体をやり直すべきです。

⚓ ネステッドトランザクション

transactionの呼び出しはネストできます。デフォルトでは、ネステッドトランザクション(nested transaction)のブロック内にあるデータベースステートメントはすべて「親トランザクションの一部」になります。たとえば、以下の振る舞いに驚くかもしれません。

User.transaction do
  User.create(username: 'Kotori')
  User.transaction do
    User.create(username: 'Nemu')
    raise ActiveRecord::Rollback
  end
end

上のコードは”Kotori”と”Nemu”を両方とも作成します。その理由は、ネストしたブロック内では ActiveRecord::Rollback例外がROLLBACKを発行しないからです。これらの例外はトランザクションブロック内でキャプチャされるので、親ブロックからは例外が見えず、実際のトランザクションがコミットされます。

ネステッドトランザクションでROLLBACKされるようにするために、実際のサブトランザクションにrequires_new: trueを渡す方法が考えられます。そして何かが失敗すると、データベースはサブトランザクションの冒頭までロールバックし、親トランザクションはロールバックしません。これを上述のコード例に追加すると以下のようになります。

User.transaction do
  User.create(username: 'Kotori')
  User.transaction(requires_new: true) do
    User.create(username: 'Nemu')
    raise ActiveRecord::Rollback
  end
end

今度は”Kotori”だけが作成されます。この方法はMySQLとPostgreSQLで動作し、SQLite3 3.6.8以上でもサポートされています。

多くのデータベースは、「真の」ネステッドトランザクションをサポートしていません。本ドキュメント執筆時点では、真のネステッドトランザクションをサポートしていることを私たちが把握できているのはMicrosoft SQL Serverだけです。このため、Active RecordではMySQLやPostgreSQLのsavepointを用いてネステッドトランザクションをエミュレートしています。savepointについて詳しくは以下をご覧ください。

参考: MySQL :: MySQL 5.6 リファレンスマニュアル :: 13.3.4 SAVEPOINT、ROLLBACK TO SAVEPOINT、および RELEASE SAVEPOINT 構文

⚓ コールバック

トランザクションのコミットやロールバックに関連するコールバックは、after_commitafter_rollbackの2種類です。

after_commitコールバックは、あるトランザクション内でレコードがsaveまたはdestroyされると、そのトランザクションがコミットされた直後に呼び出されます。after_rollbackコールバックは、あるトランザクション内でレコードがsaveまたはdestroyされると、そのトランザクションまたはsavepointがロールバックされた直後に呼び出されます

これらのコールバックは、データベースが永続的なステートにある場合にのみ実行されることが保証されるので、他のシステムとやりとりするうえで有用です。たとえばafter_commitは、キャッシュをクリアするフックをかけるのに適しています(トランザクション内部でキャッシュをクリアすれば、データベースが更新される前にキャッシュの再生成をトリガーできるようになる)。

⚓ 注意事項

MySQLでは、savepointを用いてエミュレートされるDDL(Data Definition Language: データ定義言語)をネステッドトランザクションブロック内で使いません。したがって、こうしたブロック内部で’CREATE TABLE’のようなステートメントを実行してはいけません。その理由は、MySQLがDDL操作の実行時にすべてのsavepointを自動的に解放してしまうためです。transactionが完了したときに以前作成したsavepointを解放しようとすると、savepointが自動的に解放済みになっているためデータベースエラーが発生します。以下はこの問題を説明するためのコード例です。

Model.connection.transaction do                           # BEGIN
  Model.connection.transaction(requires_new: true) do     # CREATE SAVEPOINT active_record_1
    Model.connection.create_table(...)                    # active_record_1 now automatically released
  end                                                     # RELEASE SAVEPOINT active_record_1
                                                          # ブブー!データベースエラーです!
end

TRUNCATEもMySQLのDDLステートメントのひとつである点にご注意ください。

関連記事

Rails 5.1〜6.1: ‘form_with’ APIドキュメント(翻訳)

The post Rails APIドキュメント: Active Recordのトランザクション(翻訳) first appeared on TechRacho.


肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)

$
0
0

更新情報:
2013/11/19: 初版公開
2021/01/08: 訳文見直し、追記

こんにちは、hachi8833です。今回は、自分が知りたかった、Active Recordモデルのリファクタリングに関する記事を翻訳いたしました。1年前の記事なのでRails 3が前提ですが、Rails 4以降でも基本的には変わらないと思います。リンクは可能なものについては日本語のものに置き換えています。

なお、ここでご紹介したオブジェクトは、app以下にそれぞれ以下のようにフォルダを追加してそこに配置します。

注記: 以下は使われそうなフォルダを列挙しただけであり、実際にはこの一部しか使いません。

refactor

  1. Value Object
  2. Service Object
  3. Form Object
  4. Query Object
  5. View Object
  6. Policy Object
  7. Decorator

⚓ 肥大化したActive Recordモデルをリファクタリングする7つの方法(翻訳)

元記事: 7 Patterns to Refactor Fat ActiveRecord Models
Posted by @brynary on Oct 17th, 2012 (Code Climate Blog)

Railsアプリケーションの品質を高めるためにチーム内でCode Climateを使用していれば、モデルの肥大化を自然と避けるようになるでしょう。モデルが肥大化(ファットモデル)すると、大規模アプリケーションのメンテナンスが困難になります。ファットモデルは、コントローラがドメインロジックで散らかってしまうよりは1段階だけましであるとはいえ、たいていの場合単一責任の原則 (Single Responsibility Principle: SRP)の適用に失敗した状態であると言えます。

SRPの適用は、元々難しいものではありません。ActiveRecordクラスは永続性と関連付けを扱うものであり、それ以外のものではありません。しかしクラスはじわじわ成長していきます。永続性について本質的に責任を持つオブジェクトは、やがて事実上ビジネスロジックも持つようになるのです。1年2年が経過すると、User クラスには500行ものコードがはびこり、パブリックなインターフェイスには数百ものメソッドが追加されることでしょう。それに続くのはコールバック地獄です。

アプリケーションに何か本質的に複雑な要素を追加したら、ちょうどケーキのタネをケーキ型に流し込むのと同じように、それらを小規模かつカプセル化されたオブジェクト群(あるいはより上位のモジュール)に整然と配置することが目標になります。ファットモデルは、さしずめタネをケーキ型に流し込むときに見つかるダマ(混ざらなかった粉の固まり)のようなものでしょう。これらのダマを砕いて、ロジックが等分に広がって配置されるようにしなければなりません。これを繰り返し、それらが最終的にシンプルできちんと定義されたインターフェイスを持つ一連のオブジェクトとなって、それらが見事に協調動作するようにしましょう。

そうは言っても、きっとこう思う人もいることでしょう。

“でもRailsでちゃんとOOPするのってめちゃくちゃ大変ぢゃなくね?!”

私も以前は同じことを思ってました。でも若干の調査と実践の結果、RailsというフレームワークはOOPを妨げてなどいないという結論に達しました。スケールできないでいるのはRailsのフレームワークではなく、従来のRailsの慣習・流儀の方です。より具体的に言えば、Active Recordパターンできちんと扱える範囲を超えるような複雑な要素を扱うための定番の手法がまだないのです。幸いにも、オブジェクト指向における一般的な原則とベストプラクティスというものがあるので、Railsに欠けている部分にこれらを適用することができます。

⚓ (その前に)ファットモデルからミックスインで展開しないこと

肥大化したActiveRecordクラスから単に一連のメソッドを切り出してconcernsやモジュールに移動するのはよくありません。移動したところで、後でまた1つのモデルの中でミックスインされてしまうのですから。いつだったか、こんなことを言っていた人がいました。

“app/concerns ディレクトリを使っているようなアプリケーションって、だいたい後から頭痛の種になる(=concerning)んだよね”

私もそう思います。ミックスインよりも、継承で構成する方がよいと思います継承よりコンポジションの方がよいと思います。このようなミックスインは、部屋に散らかっているガラクタを引き出しに押し込めてピシャリと閉めたのと変わりません。一見片付いているように見えても、引き出しの中はぐちゃぐちゃ、どこに何があるのかを調べるだけでも大変です。ドメインモデルを明らかにするために必要な分解と再構成を実装するのも並大抵ではありません。
これはもうリファクタリングするしかないでしょう。

⚓ 1. Value Objectに切り出す

Value Objectは、異なるオブジェクト同士であっても値が等しければ等しいと見なされる、シンプルなオブジェクトです。Value Objectは変更不可能であるのが普通です。Rubyの標準ライブラリにはDateURIPathname などのValue がありますが、Railsアプリケーションでもドメイン固有のValue Objectを定義できますし、そうすべきです。ActiveRecordからValue Objectへの展開は、すぐにもメリットの得られるリファクタリングです。

Railsでは、ロジックが関連付けられている属性が1つ以上ある場合にはValue Objectが有用です。単なるテキストフィールドやカウンタ以上の要素は、何でもValue Objectの候補になりえます。

ちょうど著者が仕事をしている某テキストメッセージングアプリケーションには、PhoneNumber というValue Objectがあります。そして某EコマースアプリケーションではMoneyクラスを必要としています。私たちのCode Climateには RatingというValue Objectがあり、受け取ったクラスやモジュールのランキングをAからFまでの段階で表します。ここではRuby のStringクラスのインスタンスを使うこともできます(実際使っていました)が、このRatingを使用すると以下のように振る舞いとデータを一体化することができます。

class Rating
  include Comparable

  def self.from_cost(cost)
    if cost <= 2
      new("A")
    elsif cost <= 4
      new("B")
    elsif cost <= 8
      new("C")
    elsif cost <= 16
      new("D")
    else
      new("F")
    end
  end

  def initialize(letter)
    @letter = letter
  end

  def better_than?(other)
    self > other
  end

  def <=>(other)
    other.to_s <=> to_s
  end

  def hash
    @letter.hash
  end

  def eql?(other)
    to_s == other.to_s
  end

  def to_s
    @letter.to_s
  end
end

次にすべてのConstantSnapshotRatingのインスタンスをパブリックなインターフェイスに公開します。

class ConstantSnapshot < ActiveRecord::Base
  # …

  def rating
    @rating ||= Rating.from_cost(cost)
  end
end

このパターンは、ConstantSnapshotがスリムになるだけでなく、他にも多くの利点があります。

  • #worse_than?メソッドと#better_than?メソッドは、レートを比較する場合には<>などのRubyの組み込み演算子よりも適切です。
  • #hash#eql?を定義しておけばRatingをハッシュキーとして使用できます。Code Climateではこれを用いて、定数をレートごとにEnumberable#group_byでグループ化しています。
  • #to_sメソッドを定義してあるので、Ratingを簡単に文字列やテンプレートに変換できます。
  • このクラス定義は、ファクトリーメソッドを導入する場合にも便利です。矯正コスト (=クラスの「臭い」を除去するのにかかる時間) に見合う正しい Rating を得られます。

⚓ 2. Service Objectに切り出す

アクションによってはService Objectを用いて操作をカプセル化できることもあります。著者の場合、以下の基準に1つ以上マッチすればService Objectの導入を検討します。

  • アクションが複雑になる場合 (決算期の終わりに帳簿をクローズする、など)
  • アクションが複数のモデルにわたって動作する場合 (eコマースの購入でOrder, CreditCard, Customer を使用する、など)
  • アクションから外部サービスとやりとりする場合 (SNSに投稿する、など)
  • アクションが背後のモデルの中核をなすものではない場合 (一定期間ごとに古くなったデータを消去する、など)
  • アクションの実行方法が多岐にわたる場合 (認証をアクセストークンやパスワードで行なう、など)。これはGoF (Gang of Four)の書籍で言うStrategyパターンです。

例として、User#authenticateメソッドを取り出して UserAuthenticatorに配置しましょう。

class UserAuthenticator
  def initialize(user)
    @user = user
  end

  def authenticate(unencrypted_password)
    return false unless @user

    if BCrypt::Password.new(@user.password_digest) == unencrypted_password
      @user
    else
      false
    end
  end
end

このとき、SessionsController は以下のような感じになります。

class SessionsController < ApplicationController
  def create
    user = User.where(email: params[:email]).first

    if UserAuthenticator.new(user).authenticate(params[:password])
      self.current_user = user
      redirect_to dashboard_path
    else
      flash[:alert] = "Login failed."
      render "new"
    end
  end
end

⚓ 3. Form Objectに切り出す

1つのフォーム送信で複数のActive Recordモデルを更新する場合、Form Objectを使用して集約することができます。Form Objectを使えば、(個人的には使用を避けたい) accepts_nested_attributes_forよりもずっときれいなコードになります。CompanyUserを同時に作成するユーザー登録フォームを例にとってみましょう。

class Signup
  include Virtus

  extend ActiveModel::Naming
  include ActiveModel::Conversion
  include ActiveModel::Validations

  attr_reader :user
  attr_reader :company

  attribute :name, String
  attribute :company_name, String
  attribute :email, String

  validates :email, presence: true
  # …その他のバリデーション …

  # フォームそのものは決して永続化しない
  def persisted?
    false
  end

  def save
    if valid?
      persist!
      true
    else
      false
    end
  end

private

  def persist!
    @company = Company.create!(name: company_name)
    @user = @company.users.create!(name: name, email: email)
  end
end

これらのオブジェクトではVirtus gemのActive Record的な属性機能を利用しています。Form ObjectはActive Recordと同様に振る舞うので、コントローラは通常と変わらないものになります。

class SignupsController < ApplicationController
  def create
    @signup = Signup.new(params[:signup])

    if @signup.save
      redirect_to dashboard_path
    else
      render "new"
    end
  end
end

Form Objectは、上のようなシンプルな例ではうまくいきますが、永続性のロジックが含まれていてフォームが複雑になるのであれば、Service Objectも併用するのがよいでしょう。

Form Objectを導入することでさらにボーナスが付きます。バリデーションのロジックはコンテキストに依存しがちですが、Active Record自身の中でバリデーションを走らせるという融通の効かない方法に代えて、バリデーションロジックを実際に必要な場所で定義できます。

⚓ 訳追記(2021/01/08)

Rails 5からはActive Recordでattributes APIも使えるようになりました。記事末尾の関連記事もどうぞ。

参考: Rails 5のActive Record attributes APIについて | 日々雑記

Rails 5.2からはActive Modelでもattributes APIを使えるようになりました。

参考: 【Rails】「ActiveModel::Attributes」が便利という話 - 日々の学びのアウトプットするブログ

⚓ 4. Query Objectに切り出す

スコープやクラスメソッドなどのActiveRecordサブクラスが乱雑に定義された、複雑なSQLクエリがある場合は、Query Objectに切り出すことを検討します。1つのQuery Objectは、ビジネスロジックに基づいた結果セットを1つだけ返す責務を担当します。

たとえば、誰も訪問していないお試しを検索するQuery Objectは以下のような感じになります。

class AbandonedTrialQuery
  def initialize(relation = Account.scoped)
    @relation = relation
  end

  def find_each(&block)
    @relation.
      where(plan: nil, invites_count: 0).
      find_each(&block)
  end
end

このオブジェクトを使ってバックグラウンドで以下のようにメールを送信します。

AbandonedTrialQuery.new.find_each do |account|
  account.send_offer_for_support
end

ActiveRecord::RelationインスタンスはRails 3によってファーストクラスオブジェクトとして扱われるため、Query Objectでも多くの機能を使えます。このおかげで、コンポジションを使ってクエリを結合できます。

old_accounts = Account.where("created_at < ?", 1.month.ago)
old_abandoned_trials = AbandonedTrialQuery.new(old_accounts)

この種のクラスは個別にテストする必要はありません。オブジェクトのテストとデータベースのテストは同時に行うようにし、それによって正しい行が正しい順序で返されることと、join(結合)やeager loadingがすべて動作する(N + 1クエリ問題などを回避できているなど)ことを確認します。

⚓ 5. View Objectを導入する

表示にしか使わないようなロジックが必要な場合、それはモデルに置くべきではありません。「仮にアプリケーションのUIががらりと変わったら(たとえば音声駆動UIになったとしたら)、その時にもこれをモデルに置く必要があるだろうか」と自問自答してみましょう。モデルに置く必要のない表示ロジックであることがわかったら、ヘルパーに置くか、できればなるべくView Objectに置くようにしましょう。

たとえば、Code ClimateではRails on Code Climateなどでコードベースのスナップショットに基づいたクラスのレート付けを行う円グラフを使用していますが、これらは次のようにView Objectにカプセル化できます。

class DonutChart
  def initialize(snapshot)
    @snapshot = snapshot
  end

  def cache_key
    @snapshot.id.to_s
  end

  def data
    # @snapshotからデータを取り出してJSON構造に変換するコードを置く
  end
end

ところで、私はビューとERBテンプレート(またはHamlやSlim)が一対一対応していることが多いのに気が付きました。そこで、Railsで使えるTwo Step Viewパターンを実装できないか調べ始めたのですが、今のところこれについては明快なソリューションを見つけられずにいます。

⚓ メモ

Railsコミュニティでよく使われている「Presenter」という用語についてですが、この用語が他の用法と重複したり誤解を招いたりする可能性があるため、著者はこの用語を避けるようにしています。Presenterという語は、本記事で言うところのForm Objectを説明するためにJay Fieldsによって導入されました。また、運の悪いことにRailsでは「View」という用語もいわゆる「(ビューの)テンプレート」を指すものとして使われています。曖昧さを避けるため、著者はView Objectを「Viewモデル」と書くことがあります。

⚓ 6. Policy Objectに切り出す

複雑な読み出し操作はそのオブジェクト自身で行なうのがふさわしいことがあります。私はこのような場合にPolicy Objectを検討します。Policy Objectを使うことで、本質的でないロジック (分析用にどのユーザーをアクティブとみなすか、など) を、中核となるドメインオブジェクトから切り離すことができます。以下は例です。

class ActiveUserPolicy
  def initialize(user)
    @user = user
  end

  def active?
    @user.email_confirmed? &&
    @user.last_login_at > 14.days.ago
  end
end

このPolicy Objectには1つのビジネスルールがカプセル化されています。このビジネスルールでは、emailが確認済みで、かつ2週間以内にログインしたことがあるユーザーをアクティブなユーザーとみなすようになっています。Policy Objectは、複数のビジネスルール (特定のデータへのアクセスを許可するAuthorizer など) をカプセル化することもできます。

Policy ObjectはService Objectと似ていますが、私は「Service Objectは書き込み操作用」「Policy Objectは読み出し操作用」と使い分けています。これらはQuery Objectとも似ていますが、Policy Objectはメモリに読み込み済みのドメインモデルについて操作を行なうのに対し、Query Objectは特定の結果セットを返す「SQLの実行」に特化している点が異なります。

⚓ 7. Decoratorに切り出す

Decoratorは既存の操作に関する機能を階層化することによって、コールバックとよく似た機能を果たします。Decoratorは、特定の環境でしか実行したくないコールバックロジックがある場合や、ある機能をモデルに含めるとモデルの責務が増え過ぎる(=モデルが肥大化する)場合に便利です。

あるブログ投稿にコメントが付くと誰かのFacebookウォールに自動的に投稿されるようにしたとします。この場合、このロジック自体をCommentクラスにハードコードしなければならないわけではありません。コールバックに負わせる責務が多すぎると、テストの実行が遅くなり、不安定になるという形で兆候が現れます。こうした副作用は、何の関連もないテストケースから取り除くべきでしょう。

Facebookへの投稿ロジックをDecoracorに展開する方法を以下に示します。

class FacebookCommentNotifier
  def initialize(comment)
    @comment = comment
  end

  def save
    @comment.save && post_to_wall
  end

private

  def post_to_wall
    Facebook.post(title: @comment.title, user: @comment.author)
  end
end

このDecoratorをコントローラで以下のように使います。

class CommentsController < ApplicationController
  def create
    @comment = FacebookCommentNotifier.new(Comment.new(params[:comment]))

    if @comment.save
      redirect_to blog_path, notice: "Your comment was posted."
    else
      render "new"
    end
  end
end

DecoratorはService Objectとは異なります。Service Objectは既存のインターフェイスに対する責務を階層化しますが、これをDecorator化すると、FacebookCommentNotifier インスタンスをあたかも単なるCommentであるかのように取り扱います。

Rubyは、メタプログラミングを使用してDecoratorを簡単に作成するための仕組みを標準ライブラリに多数備えています。

⚓ 最後に

複雑なモデル層をうまく取り扱うためのツールは、Railsアプリケーションにも多数存在します。これらのツールを使用するためにRailsを捨てる必要などありません。Active Recordsは素晴らしいライブラリですが、これだけに頼っていてはどんなパターンも失敗します。ActiveRecordsは、極力永続的な振る舞いにとどめておくようにしてください。本記事で紹介したテクニックを一部だけでも適用して、自分のアプリケーションのドメインモデルに固まっているロジックを分散させることができれば、アプリケーションのメンテナンスはずっと容易になるでしょう。

本記事で紹介したパターンの多くはシンプルです。これらのオブジェクトは、いずれも「昔ながらのPORO(Plain Old Ruby Object: シンプルなRubyオブジェクト)」であって、ただその使い方が異なるだけです。これはOOPの一部であり、OOPの美しさをなすものです。問題を解決するのにフレームワークやライブラリだけに頼る必要はありません。手法に適切な名前を付けることも重要な技法です。

本記事で紹介した7つの手法はいかがでしたでしょうか。お気に入りの手法は見つかりましたでしょうか。それはどんな理由でしょうか。皆さまのコメントをお待ちしています。

追伸: この記事を気に入っていただけましたら、元記事の下にあるフォームからCode Climateのニュースレターをぜひ購読してみてください。OOPやRailsアプリケーションのリファクタリングなど、今回の記事のようなトピックを扱っています。記事のボリュームは控えめにしています。

より詳しい情報

本記事をレビューしてくれた皆様に感謝します: Steven Bristol, Piotr Solnica, Don Morrison, Jason Roelofs, Giles Bowkett, Justin Ko, Ernie Miller, Steve Klabnik, Pat Maddox, Sergey Nartimov, Nick Gauthier

関連記事

Service Objectがアンチパターンである理由とよりよい代替手段(翻訳)

Rails5: ActiveRecord標準のattributes APIドキュメント(翻訳)

The post 肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳) first appeared on TechRacho.

ActiveRecordで日付・時刻の範囲検索をシンプルに書く方法

$
0
0

更新情報

  • 2016/08/19: 初版公開
  • 2021/08/26: 更新

こんにちは、hachi8833です。

Active Recordで日付範囲を指定して読み出そうとすると、おそらく次のようなコードになるでしょう。

Pattern.where(“updated_at BETWEEN ? AND ?”, from, to)

社内のSlackチャンネルのログを遡ってて、Active Recordでwhere(updated_at: range_obj_start..range_obj_end)のように、Rangeオブジェクトを#whereの値指定として渡せるというやりとりを見つけたので、確認してみました。

範囲演算子とは

RubyのRangeオブジェクトでは、.....という範囲演算子を使えます。

条件式以外の場所では式1から式2までの範囲オブジェクトを返します。範囲オブジェクトはRangeクラスのインスタンスです。...で生成された範囲オブジェクトは 終端を含みません
範囲式 (Ruby 3.0.0 リファレンスマニュアル)より(強調は筆者)

終端を含まないのは.....のどっちだったかときどきわからなくなったりしますね。

なお、数学用語では端の値を含む範囲を「閉区間」、端の値を含まない範囲を「開区間」と呼んでいます(Wikipedia: 区間(数学))。

Range#newでオブジェクトを生成できます。ここでの範囲演算子は..なので閉区間ですね。

Range.new(Time.zone.now, Time.zone.now.tomorrow)

pry-rails gemを導入したRailsコンソールで出力しました。

range_new

.....で日時を範囲指定

範囲演算子を思い出したところで、適当なRailsプロジェクトをbundle exec rails cでコンソール起動し、Active Recordの適当なモデル(ここではPatternというモデル)のupdated_atカラムに次のクエリをそれぞれ実行してみます。両者の違いはRubyの範囲指定子.....だけです。

Pattern.where(updated_at: Time.zone.today.beginning_of_day..Time.zone.today.end_of_day).to_sql
Pattern.where(updated_at: Time.zone.today.beginning_of_day...Time.zone.today.end_of_day).to_sql

AR_range

1番目の..のSQLでは、ストレートにBETWEENを使っています。
2番目の...のSQLでは、end_of_dayに終端を含まないよう、<を使って自動展開しています。

SELECT `patterns`.* FROM `patterns` WHERE (`patterns`.`updated_at` BETWEEN '2016-08-18 00:00:00' AND '2016-08-18 23:59:59')
SELECT `patterns`.* FROM `patterns` WHERE (`patterns`.`updated_at` >= '2016-08-18 00:00:00' AND `patterns`.`updated_at` < '2016-08-18 23:59:59')

もし終端値のクラス(DateとかDatetimeとか)に応じて<<=を切り替えようとすると、クラスのチェックが必要になるので煩雑になってしまいます。

BETWEEN>=<の切り替えなら、終端値のクラスを気にせず、大小関係が定義されている値の範囲にシンプルに適用できます。ささやかですが、うまい処理ですね。

参考

関連記事

ActiveRecordのRangeHandlerクラスとRubyの範囲メソッドRange#exclude_end?

The post ActiveRecordで日付・時刻の範囲検索をシンプルに書く方法 first appeared on TechRacho.

Rails: JOINすべきかどうか、それが問題だ — #includesの振舞いを理解する(翻訳)

$
0
0

概要

原著者の許諾を得て翻訳・公開いたします。

原文ではシェークスピアの古典劇『ハムレット』のセリフが多数引用されています。引用されたセリフのリンクをマウスオーバーするとシェークスピアの原文がポップアップします。

actの基本的な意味は「演技(する)」「(舞台の)場面」であり、タイトルはこれにかかっています。

  • 2017/09/25: 初版公開
  • 2021/09/22: 更新

訳注

k0kubunさんの以下の記事も合わせて読むことをおすすめします。Rails 5以降は#left_outer_joins(またはエイリアスの#left_joins)が使えます。また、#includesがActiveRecord::Baseを生成するために効率が落ちることがある点も指摘しています。

Rails:「JOINすべきかどうか、それが問題だ 」#includesの振舞いを理解する(翻訳)

ORMを日常的に使っていれば、リレーションに沿ってオブジェクトにクエリをかけようとしたときに、ありがちな問題を何度も踏んで苦しんだ経験がきっとあるでしょう。たとえば、Ruby on Railsプロジェクトでエンティティ同士にごく簡単なリレーションが設定されているところを想像してみてください。

class User
  has_many :books
end
class Book
  belongs_to :user
end
u1 = User.create(name: 'Guava')
u2 = User.create(name: 'Foo')
u3 = User.create(name: 'Bar')

Book.create(title: 'Hamlet', author: 'Shakespeare', user: u1)
Book.create(title: 'King Richard III', author: 'Shakespeare', user: u2)
Book.create(title: 'Macbeth', author: 'Shakespeare', user: u3

ここで、本1冊ごとにユーザーを取得しようとしたらどうなるでしょうか。

books = Book.all
user_names = books.map { |book| book.user.name }

Railsコンソールの出力を見ると、何だか残念なことが起こっている様子です。

Book Load (0.7ms) SELECT “books”.* FROM “books”
User Load (0.2ms) SELECT “users”.* FROM “users”
WHERE “users”.”id” = ? LIMIT ? [[“id”, 1], [“LIMIT”, 1]]
User Load (0.1ms) SELECT “users”.* FROM “users”
WHERE “users”.”id” = ? LIMIT ? [[“id”, 2], [“LIMIT”, 1]]
User Load (0.1ms) SELECT “users”.* FROM “users”
WHERE “users”.”id” = ? LIMIT ? [[“id”, 3], [“LIMIT”, 1]]

“我がクエリに何が起こったのじゃ?”

「我が友よ」、これはN+1クエリ問題そのものです。最初のクエリ(N+1の「1」の方)はサイズNのコレクションをひとつ返しますが、コレクションの1つ1つで(つまりN+1の「N」の方で)データベースへのクエリが実行されます。

幸いこの例では本は3冊しかありませんが、このクエリのパフォーマンスは著しく低下する可能性があります。本が数百万冊になったときを想像してみてください。コレクションの大きさによっては、下手をするとコンピュータが爆発するかも!というのは冗談ですが、アプリはきっと止まってしまうことでしょう。そしてこのクエリ爆発がハードウェアのせいにされてしまうと、さらにまずいことになるでしょう。

もちろん、Nクエリは特定のidカラム(インデックス)にヒットするので、(クエリがデータベースでひとたび処理された後なら)Nクエリは十分高速になり、パフォーマンスは向上します。

しかし騙されてはいけません。たった2つ(下手をすると1つ)のクエリと引き換えにN+1クエリを許してしまえば、常にこの問題が発生します。データベースとのやりとりにおけるI/Oコスト(特にデータベースがアプリと別のマシンで動作している場合)の犯人はここにいます。

本記事では、Active Recordでの開発について、N+1クエリ問題を回避するための3つのメソッドとそれぞれの戦略を詳しく見ていきます。3つのメソッドとは、#preload#eager_load#includesです。

“#preload”ひとつにも天の摂理が働いておるのだよ”

問題を解決する方法のひとつは、クエリを2つに分けることです。1つめのクエリは関連データを取得するクエリ、2つ目のクエリは最終的な結果を取得するクエリという具合です。

books = Book.all
user_names = books.preload(:user).map { |book| book.user.name }

上のようなコードから、以下のような結果を得られます。

Book Load (0.3ms) SELECT “books”.* FROM “books”
User Load (0.4ms) SELECT “users”.* FROM “users” WHERE “users”.”id” IN (1, 2, 3)

「おお何たること」、N+1よりさらに遅いではないか!大丈夫、一般的にはそうなりません。この例だけを見れば確かに元より遅くなっていますが、これは単にシードデータに本が3冊しかないからです。つまり、#preloadで2つのクエリを実行するのに0.7msかかっているのに、N=3では(私のPCでは)0.4msしかかかっていません。ご注目いただきたいのは、これらのN+1クエリはPostgreSQLのインデックステーブル機能(idを主キーとして使う)のおかげで強烈に速くなっていることです。ほとんどの場合、2つのクエリに分ける方がN+1に圧勝します。

しかし何事にも裏というものがあります。次のように、クエリにほんのちょっぴりフィルタをかけてみるとどうなるでしょうか?

books.preload(:user).where('users.name="Guava"')
# => 
# => no such column: user.name: SELECT “books”.* FROM “books” WHERE (user.name = Guava)

クエリでusers.nameカラムが見つからないとActive Recordに怒られてしまいました。しかしカラムがなくなったわけではありません。#preload(この名前がそもそもヒントです)は、別のクエリで関連付けを事前に読み込んでないと、読み込みやフェッチができないのです。クエリでusers.nameを使いたければ、2つのテーブルをJOINする必要があるでしょう。つまり、#preloadは絶対的な解決法ではないということです。では、クエリの中で関連付けにアクセスする必要がある場合はどうすればよいのでしょうか?そこで話は次の戦略につながります。

“JOINじゃ、JOINじゃ!JOINと引き換えにこの王国をくれてやるわい!”1

先ほどの戦略の問題は、クエリ内で別のテーブルにあるカラムにアクセスできないことです。その理由は、#preload常にクエリを分割してしまうためです。しかし「恐れてはならぬ」のです。#eager_loadが存在しているのには、ちゃんと理由があります。#eager_loadが関連付けからデータを読み込むときには、LEFT JOINを使って1つのクエリだけで関連するレコードをすべて取り出します。つまり、次のように書けます。

user_names = books.eager_load(:user).map { |book| book.user.name }

#=> SQL (0.4ms) SELECT “books”.”id” AS t0_r0, “books”.”title” AS t0_r1, “books”.”author” AS t0_r2, 
#=> “books”.”books_id” AS t0_r3, “books”.”user_id” AS t0_r4, “books”.”created_at” AS t0_r5, 
#=> “books”.”updated_at” AS t0_r6, “users”.”id” AS t1_r0, “users”.”name” AS t1_r1, 
#=> “users”.”created_at” AS t1_r2, “users”.”updated_at” AS t1_r3 FROM “books” 
#=> LEFT OUTER JOIN “users” ON “users”.”id” = “books”.”user_id”

最初にご注目いただきたいのは、Railsのログに出力されているLEFT OUTER JOINです。「誰に断ってかような無礼を働くのか…」。しかし物知り博士タイプのActive Recordは、自分はSQL文をこんなに知っているぞとドヤ顔で見せびらかそうとするものなので、そこを気にしてはなりません。しかし#eager_loadが1つのクエリで常にLEFT OUTER JOINを使うという事実は記憶に値します(OUTERのことはご心配なく: LEFT JOINと同じです)。

次に、Active Recordは2つのテーブルをメモリ上に読み込む(ここにご注目!)ことで、関連付けられたテーブル(users)のフィールドにアクセスできるようになることにご注目ください。これは、#preloadで起きた問題そのものです。つまり、以下のコードを実行すれば正常に動きます。

books.eager_load(:user).where('users.name = "Guava"').map { |book| book.author }

もうひとつ興味深いのは、これは#joinsとは違うものであるという点です。では#joinsではどうなるのでしょうか?

  1. #joinsではLEFT OUTER JOINではなくINNER JOINが使われる。
  2. 目的が異なる: 関連付けとともにレコードを読み込むのではなく、クエリの結果をフィルタするために使われる。関連付けのeager loadingを行わないので、N+1クエリを防げません。
  3. 関連付けられたテーブルのフィールドにアクセスせずにクエリをフィルタしたい場合には問題なく利用できる。ずばりその理由は、#joinsは単に結果をフィルタするだけであり、関連付けられたテーブルを読み込んだり展開したりしないからです。

この3つから、#joins#preload#eager_loadと(そして後述する#includesとも)併用してもよいことがわかります。目的が異なるので、これは正当な利用法です。

いずれにしろ私たちは、どれにするか決めなければなりません。LEFT JOINで1つのクエリだけを生成する#eager_loadか、それとも、先ほどのようにクエリを分割してから関連付けられたデータをフェッチする#preloadか。あなたならどちらにしますか?この愛すべき問題を作り出してくれたのはActive Recordなのです(私はActive Recordへの感謝を忘れたことはありません❤)から、Active Recordの#includesがこの問題をどのように解決(または少なくともジレンマを軽減)するかを見ていくことにしましょう。

“物事にいいも悪いもない: #includesすればそうなるのだ”

前述の#includesの使いみちは一体何なのかが気になる方もいると思います。前述のとおり、Rails 4以前の#includesは、それぞれの場合にどちらのeager loading戦略を選択するかという責務を委譲するのに使われていました。#includesは基本的にWHEREやORDERの条件を監視して、関連付けられたテーブルへの参照があるかどうかを監視し、参照がある場合は#eager_loadを(前述のとおり明らかにテーブルのJOINが必要です)、参照がない場合は単に#preloadを使います。次の例をご覧ください。

books.includes(:user).where('users.name="Guava"')
#=>
#=> SELECT "books".”id” AS t0_r0, "books"."title" AS t0_r1, 
#=> "books."author" AS t0_r2, "books"."books_id" AS t0_r3, 
#=> "books"."user_id" AS t0_r4, "books"."created_at" AS t0_r5, 
#=> "books"."updated_at" AS t0_r6, "users"."id" AS t1_r0, 
#=> "users"."name" AS t1_r1, "users"."created_at" AS t1_r2, 
#=> "users"."updated_at" AS t1_r3 FROM "books" 
#=> LEFT OUTER JOIN "users" ON "users"."id"= "books"."user_id" 
#=> WHERE (users.name="Guava")

これで、関連付けられたテーブルがWHERE条件にない場合のデフォルトの動作は次のようになります。

books.includes(:user).where(author: 'Shakespeare')
#=>
#=> SELECT "books".* FROM "books" WHERE "books"."author" = ? [["author", "Shakespeare"]]
#=> SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3)

しかしRailsチームはRails 4以降このあたりを諦めてしまった様子です。非推奨メッセージに「完璧なSQLパーサーがない限り、問題の発生は避けられない。私たちはSQLパーサーなど書きたくないので、この機能は削除する(doing this without writing a full-blown SQL parser is inherently flawed. Since we don’t want to write an SQL parser, we are removing this functionality)」という一文があります。シンプルで簡潔な#includesは、Rails 5以降#preloadと完全に同じ動作になってしまいました。前の例はRails 5でもエラーをスローしますが、これは#preloadが「JOINされなかった関連テーブル内のカラムにはアクセスできない」と通知するからです。

訳注

非推奨メッセージ全体は次のとおりです。

Currently, Active Record recognizes the table in the string, and knows to JOIN the comments table to the query, rather than loading comments in a separate query. However, doing this without writing a full-blown SQL parser is inherently flawed. Since we don’t want to write an SQL parser, we are removing this functionality. From now on, you must explicitly tell Active Record when you are referencing a table from a string:

ただしここでひとつ注意があります。関連付けられたテーブルを#includesでJOINしたい場合は、たとえば次のように#referencesメソッドで関連テーブルを明示する必要があります。

books.includes(:user).where('users.name="Guava"').references(:user)

#=> SQL (0.4ms)  SELECT "books"."id" AS t0_r0, "books"."title" AS t0_r1, 
#=> "books"."author" AS t0_r2, "books"."books_id" AS t0_r3, 
#=> "books"."user_id" AS t0_r4, "books"."created_at" AS t0_r5, 
#=> "books"."updated_at" AS t0_r6, "users"."id" AS t1_r0, 
#=> "users"."name" AS t1_r1, "users"."created_at" AS t1_r2, 
#=> "users"."updated_at" AS t1_r3 FROM "books" 
#=> LEFT OUTER JOIN "users" ON "users"."id" = "books"."user_id" 
#=> WHERE (users.name="Guava")

私個人の意見ですが、Rails 4以前の#includesは、枕の下半分のようにクール(=「ひんやりしてる」のシャレ: 最近流行りの言い回し)だったと思います。実装上の困難から#includesの動作が変更されたのは十分理解できますが、#referencesメソッドが存在しているということ自体が、#eager_loadを使っても真のDRYにはならず、コードも明確にならないという事実そのものを示しています。#includesを呼ばないと#referencesは呼び出せませんし、#referencesなしで#includesを呼ぶと常にpreload戦略が選択されてしまいます。

それなら、query.includes(:user).references(:user)のようなだるい書き方をしなくても普通に#eager_loadを呼ぶだけでいいのではないか、あるいは、金魚のフンみたいな#includesを使わずに単に#preloadを呼べばいいのではないか、その方が意図も明確になるのではないか、という疑問が生じます。これに関する回答をいくつか読んでみましたが、私にも何とも言いようがありません(単に私も腑に落ちてないだけなのですが)。さらに言えば、#includeはどちらの戦略に委譲するかという決定を下さなければならない分オーバーヘッドが生じ、先の2つのメソッドより若干速度が落ちます。いずれにしろ、Railsチームは実に頭の切れる連中なので、きっと何かいいアイデアを思いついてくれることでしょう。

まとめ: “美しき人に美しき花を手向けようではないか: さらばじゃ”

  • #preload#eager_load#includesは似た者同士であり、いずれもeager loading戦略を取ります。
  • #joinsは上のどれとも違っており、関連付けを読み込まず(訳注: AR::Relationのオブジェクト化を指していると考えられます)、INNER JOINでクエリをフィルタします。
  • #preload: 関連付けられたテーブルの読み込みで、常にクエリを分割します。
  • #eager_load: 関連付けられたテーブルの読み込みで、常にLEFT JOINを使います。
  • #includes: Rails 4より前は(そこそこ)賢くできていて、eager loadingとpreloadingからよりよい戦略を見つけてくれました。Rails 4以降は、#referencesで明示的にLEFT JOINの利用を指定しない限りpreloading戦略を使います。
  • #references: #includesなしでは利用できません。逆に#referenceなしの#includesではpreloadが呼ばれます。

“Active Recordにはコードだけではわからないことがいくらでもあるのだよ、ホレーショ”

ハムレット王子は、自らの義父となった叔父のクローディアス王を殺すべきかどうかという重大なジレンマに直面して「生きるべきか死すべきか(To be or not to be?)」とつぶやきました。それはともかく、本記事で申し上げたいのは、N+1クエリつぶしを面倒臭がってはならないということです。私たちが本当に知りたいのは「クエリでJOINすべきかどうか、するならどの程度JOINすべきなのか」なのですが、ハムレットの苦悩と同様、これは難しい問いかけです。本記事が、Active Recordのeager loading戦略について皆さまの疑問を少しでも解消し、オブジェクトの関連付けをクエリにするときに合理的な決定を下せるようになれば幸いです。

Sergio Fontes、Filipe W. Lima、Leonardo Brito、Chico Carvalhoに感謝申し上げます。

関連記事

Rails: N+1クエリを「バッチング」で解決するBatchLoader gem(翻訳)

Rails: render_async gemでレンダリングを高速化(翻訳)

Railsの`CurrentAttributes`は有害である(翻訳)


  1. 訳注: この引用だけ、ハムレットではありません(リチャード三世)。 

The post Rails: JOINすべきかどうか、それが問題だ — #includesの振舞いを理解する(翻訳) first appeared on TechRacho.

Railsのdefault_scopeは使うな、絶対(翻訳)

$
0
0

概要

原著者の許諾を得て翻訳・公開いたします。


  • 2017/10/31: 初版公開
  • 2021/11/04: 更新

Railsのdefault_scopeは使うな、絶対(翻訳)

あるモデル全体にスコープを適用したい場合、default_scopeが利用できます。詳しくはRailsガイド: Active Recordクエリインターフェイス 14.スコープ(日本語)かRails APIドキュメントをご覧ください。


投稿を非表示にできる機能を持つブログシステムを書き始めるときを考えてみます。

次のように書かないこと

default_scopeを使う:

# app/models/post.rb
class Post < ActiveRecord::Base
  default_scope { where(hidden: false) }
end

次のように書くこと

明示的にスコープを指定する:

# app/models/post.rb
class Post < ActiveRecord::Base
  scope, :published -> { where(hidden: false) }
end

これで次のように書けます。

Post.published

default_scopeを使うべきでない理由

理由は2つあります。どちらも後になってコードが混乱したりバグつぶしに明け暮れたりすることを避けるのが目的です。

default_scopeを追加すると、モデルの初期化が影響を受けます。上の例で言うと、開発者が期待するかどうかにかかわらずPost.newするとデフォルトでhidden = falseになってしまいます。

いったん定義されたdefault_scopeを使わないようにするのはつらい作業です。default_scopeが不要な場面で削除するには、unscopedしたスコープ(!)を使わなければならず、しかも適用されていた関連付けなどの条件はすべて削除されてしまいます。

: Post.first.comments.unscopedとすると、Postの最初のコメントだけではなく、データベース内のすべてのコメントを返します。

default_scopeよりも明快な解決法は、名前付きスコープを明示的に使うことです。default_scopeを使えばバグつぶしに何時間も費やすことになるでしょう。default_scopeは使わないでください。

default_scopeを使ってもよさそうな場面はありますか?

どうかこればかりは私を信じてください。使えばきっと痛い目にあいます。

関連記事

よくある?Rails失敗談 default_scope編

論理削除用paranoia gemがあるRailsプロジェクトで物理削除する方法

The post Railsのdefault_scopeは使うな、絶対(翻訳) first appeared on TechRacho.

Railsで重要なパターンpart 2: Query Object(翻訳)

$
0
0

概要

原著者の許諾を得て翻訳・公開いたします。


  • 2017/10/25: 初版公開
  • 2022/03/24: 更新

Railsで重要なパターンpart 2: Query Object(翻訳)

Query Object(または単にQuery)パターンもまた、Ruby on Rails開発者が肥大化したActiveRecordモデルを分割し、コントローラをスリムで読みやすくするのに非常に有用なパターンです。本記事はRuby on Railsを念頭に置いていますが、このパターンは他のフレームワーク(特にMVCベースでActiveRecordパターンを適用できるもの)にも簡単に適用できます。

どんなときにQuery Objectパターンを使うか

ActiveRecordリレーションで実行しなければならないクエリが複雑になったら、Query Objectパターンの利用を検討すべきです。スコープをこの目的に使うのは通常おすすめできません。

経験から言うと、スコープが複数のカラムとやり取りする場合や、他のテーブルとJOINする場合は、Query Objectへの移行を検討すべきです。これにより、モデルに定義するスコープの数を必要最小限に減らせるという副次的効果も得られます。同様に、スコープのチェインを扱う場合は常にQuery Objectの利用を検討すべきです(関連記事)。

Query Objectパターンを最大限に活用するための注意点

1. 命名規則をひとつに定めること

素晴らしいQuery Objectクラスに楽に名前を付けられるよう、基本的な命名規則をいくつか定めましょう。規則のひとつとして考えられるのは、Queryオブジェクト名の末尾にQueryを追加することです。こうすることで今扱っているものがActiveRecordの子孫ではなくQueryであることを常に意識できます。

その他に、モデル名を複数形にすることで、Queryがどのオブジェクトと協調動作するよう設計されているかを示す方法も考えられます。たとえばRecentProjectUsersQueryというQuery Objectは、呼び出されるとUserのリレーションを返すことが明確にわかります。

どの規則を選ぶにしても、パターンに基づいたクラスの命名法が一貫していれば、新規導入クラスの命名に迷う時間を減らせるので、メリットを得られる機会が増えます。

2. リレーションを返す.callメソッドをQuery Objectの呼び出しに使うこと

Service Objectの場合は、Service Objectを使う専用メソッドの命名方法にある程度選択の余地がありますが、対照的に、RailsでQuery Objectパターンを最大限に活用するには、リレーションオブジェクトを返す.callメソッドを実装すべきです。

この規則に従うことで、必要に応じてQuery Objectで簡単にスコープを構成できるようになります(関連記事)。

3. オブジェクトなどのリレーションは常に第1引数で受け取ること

導入するQuery Objectの呼び出しでは、第1引数でリレーションを受け取るのがよい方法です。Query Objectをスコープとして利用するときに第1引数のリレーションが必須(2.の推奨事項を参照)になりますし、Query Objectをチェインできるので柔軟性も高まります。

Query Objectの使いやすさを損なわないためには、デフォルトのエントリリレーションを設定して、引数なしでもQuery Objectを利用できるようにしましょう。また、Query Objectが返すリレーションは、常にリレーションQuery Objectが提供されたときと同じ主題(テーブル)を持つことも重要です。

4. 追加オプションを受け取れるようにすること

追加オプション受け取りの必要性は、既存のQuery Objectや新規Query Objectの導入時にサブクラス化することである程度回避できますが、いずれQuery Objectで追加オプションを受け取る必要が生じます。

Query Objectで追加オプションを受け取れるようにしておけば、結果をどのように返すかというロジックをカスタマイズできるので、Query Objectを柔軟なフィルタとして効果的に利用できます。コードが読みにくくならないよう、追加オプションは必ずキーワード引数かハッシュとして渡し、デフォルト値も設定しておくことをおすすめします。

5. 読みやすいクエリメソッドを書くことに集中すること

Queryのコアロジックを.callメソッド自身の中に保存する場合であっても、Query Objectの別のメソッドに保存する場合であっても、常に読みやすさを心がけるべきです。他の開発者はQuery Objectの意図を確認する際にクエリメソッドを調べるので、少し手間をかけてでもクエリメソッドを読みやすくしておけば、Query Objectを活用しやすくなります。

6. Query Objectを名前空間でグループ化すること

プロジェクトの複雑さや、ActiveRecordをどの程度利用するかによって多少異なりますが、いずれQuery Objectはどんどん増えていきます。

コードを整理するよい方法のひとつは、互いによく似たQuery Objectを名前空間でグループ化することです。Queryが扱うモデルの名前でグループ化しても構いませんし、十分な理由付けがなされていれば何を使っても構いません。これまでと同様、Query Objectのグループ化方法も1つに決めておくことで、新規導入するクラスの適切な配置が楽に決まります。

Query Objectをすべてapp/queriesディレクトリに保存する方法もおすすめです。

7. すべてのメソッドを.callの結果に委譲することも検討すること

Query Object用のmethod_missingを実装して全メソッドを.callメソッドの結果に委譲する方法も考えられます。この方法の場合、Query Objectは単に通常のリレーションとして用いられます(つまり RecentProjectUsersQuery.call.where(first_name: “Tony”)ではなくRecentProjectUsersQuery.where(first_name: “Tony”)になります)。

しかし、この方法を選ぶ際には、メタプログラミングと同様に十分な検討と理由付けを行うべきです。

まとめ

Query Objectパターンは、実装の複雑なクエリ/リレーション/スコープを抽象化できるシンプルなパターンであり、テストも簡単になります。上述のシンプルな規則に従うことで、可読性や柔軟性を失わずにこのパターンを簡単に利用できるようになります。開発者自身はもちろん、何より将来そのコードを使う他の開発者にとってメリットになります。

そのようなQuery Objectの実装例を以下に示します。

module Users
  class WithRecentlyCreatedProjectQuery
    DEFAULT_RANGE = 2.days

    def self.call(relation = User.all, time_range: DEFAULT_RANGE)
      relation.
        joins(:projects).
        where('projects.created_at > ?', time_range.ago).
        distinct
    end
  end
end

Query Objectパターンをシンプルに抽象化したい場合は、rails-patterns gemが提供するラッパーの導入をご検討ください。

Selleo/pattern - GitHub

関連記事

Railsで重要なパターンpart 1: Service Object(翻訳)

肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)

The post Railsで重要なパターンpart 2: Query Object(翻訳) first appeared on TechRacho.

Viewing all 68 articles
Browse latest View live