Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

3年以上かけて培ったRails開発のコツ集大成(翻訳)

概要

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


  • 2017/11/20: 初版公開
  • 2023/06/08: 訳文を更新

3年以上かけて培ったRails開発のコツ集大成(翻訳)

順序は特に決まっていません。

🔗 1. トップレベルにrescue_fromを書く

ルートコントローラにrescue_fromを書くと、その下で発生したすべての例外をキャッチできるので非常に便利です。Webアプリにこれを追加すると、リクエスト/レスポンスのサイクルで実行されるほとんどのコードがさらに便利になります。

シンプルなAPIを例に考えます。rescue_fromを使えば、レコードが見つからない(ActiveRecordがActiveRecord::RecordNotFoundをスローする)場合のアプリの振る舞いを明示的に指定できます。

rescue_from ActiveRecord::RecordNotFound do
  api_error(status: 404, errors: 'Resource not found!')
end

🔗 2. コントローラにload_resourceを書く

以前同僚が使っていたのを見て以来採用している別のパターンです。必要なリソースのフェッチをコントローラのメソッド内で行う代わりに、共通のコントローラのフィルタを使い、アクションの実行に応じてフェッチするというものです。

class UsersController
  before_action :load_resource

  def index
    # @usersで何かする
  end

  def show
    # @userで何かする
  end

  def create
    # @userで何かする
  end

  def update
    # @userで何かする
  end

  def destroy
    # @userで何かする
  end

  private
    def load_resource
      case params[:action].to_sym
      when :index
        @users = paginate(apply_filters(User.all, params))
      when :create
        @user = User.new(create_params)
      when :show, :update, :destroy
        @user = User.find(params[:id])
      end
    end
end

これの発展版がdecent_exposureです。私自身はまだ使う機会がありませんが。

hashrocket/decent_exposure - GitHub

ところで、私は主に以下の2つの理由から「よいコードは常にそれ自身が語る」のような言説にあまり賛成できません。

  • ある開発者にとってよいコードであっても、別の開発者にとっては悪いコードになることもあります(スタイルは人それぞれなので、それ自体が悪いのではありません)。
  • 時間や予算の制約から、手っ取り早く修正してissueをクローズするしかないという状況はいくらでもありえます。最善の(そして最も自明な)ソリューションだと労力も10倍ということがわかっていてもですになるでしょう。

というわけで、「コードが匂ってるな」と思ったら、恥ずかしがらずにどしどしコメントしましょう😃。

追記(2018/03/28)

2はその後議論になっています。ご利用は計画的に。

🔗 3. DecoratorやPresenterを使う

しばらく前から、「モデルをファットにして、その分コントローラを薄くせよ」という言説をRailsコミュニティで見かけます。「コントローラを薄くせよ」については同意しますが、ファットモデルについては同意できません😃。
モデルもできるだけ薄くするべきであり、特殊な場合にしか使わないようなプロパティをモデルで自動生成しないことです。そのような場合はラッパークラスを使って(皆さん、これがDecoratorですよ!)必要なメソッドだけを公開しましょう。

PresenterはDecoratorと似ていますが、複数のモデルを扱う点だけが異なります。

🔗 4. モデル配下のワーカーを名前空間化してafter_commitで呼び出す

Userというモデルがあるとしましょう。あるモデルに関連するバックグラウンドジョブの90%は、Userモデルの作成/更新/削除で発生します(ここでデータが変更されるからです)。ここから、User::CreateWorkerUser::UpdateWorkerUser::DestroyWorkerという3つの汎用ワーカーを導き出せます。利用可能な場合にはこれらのワーカーをActiveRecordコールバックやprevious_changesと組み合わせて使ってみましょう。ワーカーの呼び出しはafter_commitで行います。after_commitを使う理由についてはこちらをご覧ください。

🔗 5. PostgreSQLの配列型は、よほどシンプルでない限り使わないこと

参考: §8.15. 配列 -- PostgreSQL 15ドキュメント

PostgreSQLの配列型はクールですが、私の経験では、時間を節約するより問題を生み出す方が多くなります。PostgreSQLの配列型を使うと(何らかのIDを保存するなど)、後でそのテーブルを目にするたびに必ず私の頭が爆発しました。データベーステーブルのコストは高くありませんが、JOINのコストが高いのです。

PostgreSQLの配列型は、よほど小規模な場合にしか使わないことにしています。

  • テーブルに保存する要素が少数にとどまり、かつ要素の平均個数が将来増加しないことがわかっている場合(わずかな変動ならありです)
  • テーブルがIDや関連付けと一切関わりを持たないことがわかっている場合

🔗 6. Postgres JSONBは優秀

PostgreSQLの配列型と対照的に、PostgreSQLのJSONBは大好きです。スキーマが使えるデータベースは皆に愛されていますし、スキーマレスデータベースを上回る長所があることも知られています。

しかし、スキーマを事前に予測できない場合、スキーマレスデータベースのシンプルさがどうしても必要になることがあります。私は次のような場合にJSONBをよく使います。

  • 小さな属性がたくさんあり、しかも親属性で名前空間付けされる可能性もある場合。普通のテーブルでこれをやると、カラムだらけになってしまいます。
  • 保存する正確な内容が事前にわからない場合や、プロトタイプ作成を急ぐ場合。
  • オブジェクトのハイドレーション(hydration)1を作る場合: オブジェクトをJSON形式でデータベースに保存し、同じJSONからオブジェクトを再構成する。

🔗 7. aasm gemは優秀、ただしステートを変えて初期化しないこと

aasm/aasm - GitHub

私はaasm gemが大好きです。ステートマシンでステートや操作を強制可能で、専用のきわめてシンプルなDSLが利用できます。ただし、オブジェクトを初期状態以外のステートで作成するとフックが動作しないという問題が生じます。aasmの内部状態とにらめっこして頑張るか、あきらめてオブジェクトの特定のステートを手動でスキャンすることになります(専用のServiceを作るなど)。

🔗 8 .メールアドレスのバリデーションはgemでやる

メールアドレスのバリデーションに使う正規表現をググると毎回違う正規表現が見つかるのは、もう笑うしかありません。完璧な正規表現を探すのはあきらめて、おとなしくgemを使いましょう。

🔗 9. DecoratorやPresenterを活用して、ビューに渡すインスタンス変数をなるべく1つにする

私にとってRailsの嬉しくない部分です。コントローラからビューにコンテキストを渡すのにインスタンス変数をいくつも使うのは、バッドプラクティスだと思います。Sandi Metzの言うとおり、インスタンス化して渡すオブジェクトは常に1つだけにすべきです。

🔗 10. モデルに保存するインスタンスメソッド名には!を付ける

モデルのメソッドがオブジェクトを変更してデータベースに保存する場合、メソッド名の末尾に必ず!を付けて破壊的であることを示しましょう。

こんな簡単なことなのに、開発者(私も含む)は付け忘れがちです。クラスレベルのAPIを厳密に書くことで、コードの品質を高められるようになります。

🔗 11. 単にシンプルな認証機能が欲しいならDevise gemを使わないこと

Deviseはマジックが多すぎます。

🔗 12. Virtusを使って、ActiveRecordでないモデルの属性をより厳密に定義する

訳注

Virtus gemは現在開発が終了しています。後継のdry-rbをお使いください。

Ruby: Dry-rb gemシリーズのラインナップと概要

私はVirtus gemを多用していましたし、今も使っています。シンプルなPORO(素のRuby: Pure Old Ruby Object)でモデルのように振る舞うオブジェクトを構成でき、属性をある程度厳密に保つこともできます。私は、属性が増えすぎたときに次のようなVirtus向けの独自DSLを書いて属性を操作できるようにすることがよくあります。

# シリアライザなどに定義した属性を再利用できるシンプルなモジュール
module VirtusModel
  extend ActiveSupport::Concern

  included do
    include Virtus.model

    if defined?(self::ATTRIBUTES)
      self::ATTRIBUTES.each do |group|
        group[:attrs].each do |attr|
          attribute(attr, group[:type])
        end
      end
    end
  end

  class_methods do
    def all_attributes
      self::ATTRIBUTES.map{|i| i[:attrs]}.flatten
    end
  end
end
# モデルの例
class Model < ActiveModelSerializers::Model
  ATTRIBUTES = [
    {
      attrs: [
        :id, :name, :header_text, :is_visible, :filtering_control,
        :data_type, :description, :caregory, :calculation
      ],
      type: String
    },
    {
      attrs: [
        :display_index, :min_value, :max_value, :value_type,
        :number_of_forcast_years
    ],
      type: Integer
    },
    {
      attrs: [:category], type: Array
    },
    {
      attrs: [:is_multi_select],
      type: Virtus::Attribute::Boolean
    }
  ].freeze

  include VirtusModel
end

さまざまな属性の種類を列挙することも、属性のグループにある種のタグを追加することもできます。おかげで私はニッコニコです😃。

なお、Railsのattributes APIができたので、これで同じか似たようなことができるのではないかと考えています😃。

🔗 13. 外部API参照などの重たい処理にはメモ化(memoization)を使う

もうおわかりですよね😃。

🔗 14. PostgreSQL全文検索はシンプルな用途だけにしておくこと

Casecommons/pg_search - GitHub

pg_searchは驚くほど簡単にセットアップできます。tvectorsなどでPostgreSQL全文検索を最適化しなければならない場合は、素直にElasticSearchを使いましょう。PostgreSQLでそれ以上時間をかけるのは無駄です。

🔗 15. 2017年にもなって未だにService Objectとは何かがちゃんと定義されていない

多くの人が同意してくれるService Objectのもっと明確な定義と、どのように実装すべきかを今も探し続けています。

私たちが最近手がけた案件では、あるパターンに従うことで再利用が楽になりました。最初に、モジュールを1つ作成します。これをincludeすると、performという名前のクラスメソッドを作成します。

次に、作成するすべてのサービスで、コンストラクタ(initialize)をprivateにします。つまり、コンストラクタのpublicなperformクラスメソッドだけを呼ぶということです(もちろんRubyのような動的言語ではその気になればprivateメソッドも呼べますが、単に呼びにくくするだけの処置です)。

module PerformerService
  def self.included(base)
    base.send(:define_singleton_method, :perform) do |url|
      begin
        return self.send(:new, url).send(:perform)
      rescue Exception => e
        Rails.logger.error("#{self.class}: Exception raised: #{e}")
      end

      return nil
    end
  end
end
class UrlParser
  include PerformerService
  private
    def initialize(url)
      @url = url
    end
    def perform
      # ここですごいことをやる
    end
end

UrlParser.perform('https://kollegorna.se')

🔗 16. ActiveRecordのエラーメッセージを好みの形に変換する

RailsでAPIを書くと、エラーメッセージはたいていJSON:API形式に従います。つまり、メッセージ(can't be blank)とメッセージが失敗した属性(user_id)が出力されます。

この例ではJSONポインタを使っていませんが、これにも同じアイデアを適用できます。

クライアント側では好みに応じて次の2つの方法でこれらを扱います。フォームに移動してuser_id inputを赤で表示するか、メッセージを連結して「User id can't be blank」などのように読みやすい形に変換するかです。

しかしメッセージに関連する属性がユーザーにとって意味がない場合はどうなるでしょうか。

このアプリで、各ユーザーは新しい投稿(post)を1つ作成できるとします。ただし投稿は1日1回までだとします。モデルで次のようにして一意性を強制します。

validates :user_id, {
  uniqueness: {
    scope: :post_id,
    conditions: -> { where('created_at >= ?', 1.days.ago) },
  }
}

(はい、DBレベルでも同じようにunique制約をかけるべきですよね、承知しております。しかしここでは仮に、ユーザーが2つの異なるサーバー(しかもそれぞれで同じアプリが動いて同じDBにアクセスする)にアクセスして、運よく(運悪く)2つのリクエストを完全に同時に受け取れないと困るので、このエラーについては扱いません)

このときのメッセージは次のようになります。

{
  "title": "リクエストを処理できませんでした",
  "message": "(エラーの詳しい説明)",
  "errors": [
    {
      "attribute": "user_id",
      "message": "は既に使われています"
    }
  ]
}

ユーザーにこれが表示されても困るだけです。1つの方法は、messageオプションを使うことです。

validates :user_id, {
  uniqueness: {
    scope: :post_id,
    conditions: -> { where('created_at >= ?', 1.days.ago) },
  },
  message: 'さんの投稿は1日1回までです'
}

これで、メッセージは['user_id', 'さんの投稿は1日1回までです']のように多少読みやすくなりましたが、両方の属性を使う場合はあまり便利ではありません。

{
  "title": "リクエストを処理できませんでした",
  "message": "(エラーの詳しい説明)",
  "errors": [
    {
      "attribute": "user_id",
      "message": "さんの投稿は1日1回までです"
    }
  ]
}

理想は、このメッセージをbaseに移動することです。このメッセージは特定のモデル属性に依存しない、より一般的なカスタム制約だからです。これは、メッセージにカスタムDSLを追加すればできるようになります。

validates :user_id, {
  uniqueness: {
    scope: :post_id,
    conditions: -> { where('created_at >= ?', 1.days.ago) },
  },
  message: {
    replace: "user_id",
    with: {
      attribute: "base",
      message: "ユーザーの投稿は1日1回までです"
    }
  }
}
def replace_errors(errors)
  errors_array = []
  errors.messages.each do |attribute, error|
    error.each do |e|
      if e.is_a?(Hash) && e[:replace]
        errors_array << {
          attribute: e[:with][:attribute],
          message: e[:with][:message]
        }
      else
        array_hash << {attribute: attribute, message: e}
      end
    end
  end

  return errors_array
end

これで、使いたい属性に合うエラーが出力されます。

{
  "title": "リクエストを処理できませんでした",
  "message": "(エラーの詳しい説明)",
  "errors": [
    {
      "attribute": "base",
      "message": "ユーザーの投稿は1日1回までです"
    }
  ]
}

🔗 17. 値を返すメソッドでは明示的にreturnを書く(ワンライナーであっても)

Rubyコミュニティはreturn文を書かないことにこだわっていると思いますが、私はそこにこだわる理由はない気がしています。実際私は、たとえワンライナーであっても、副作用が目的ではなく、戻り値を目的とすべき場合はreturn文を追加しています。

Rubyのクールさと表現力を云々することよりも、生産性と(ある種の)安全性の方を優先しましょう。

🔗 18. なるべく丸かっこ()を使う(ある種のDSLを使う場合を除く)

これも同様です。丸かっこ()を追加して困ることはありませんし、普段他の言語も使っている同僚も幸せになれます。

🔗 19. env変数に厳密な論理値型を追加する

私はconfig/sercrets.ymlで次のようなスニペットを使うのが好きです。

<%
booly_env = ->(value) {
  return false if value.blank?

  return false if (['0', 'f', 'false'].include?(value.to_s.downcase))
  return true if (['0', 't', 'true'].include?(value.to_s.downcase))
  return true
}
%>

こうすることで、論理値型のenv変数がtruefalseのどちらかだけを取るようになるので、コードで使いやすくなります。

development:
  enable_http_caching:  <%= booly_env[ENV["ENABLE_HTTP_CACHING"] || false] %>

🔗 20. PostgreSQL以外のデータベースをメインで使うなら十分な理由付けが必要

MongoDBはひと頃もてはやされていましたが、やがてMongoDBの欠点が知られるようになりました。

  • スキーマレスである

スキーマレスは機能の1つだと思うかもしれませんが、実際には大きな欠点です。データベースにスキーマがあれば、必要に応じてスキーマを少しずつ変更できますし、ツールや保証も得られます。たとえば、SQLにinteger型のカラムが1つあるとすると、これをstring型やtext型に変更することも、デフォルト値の設定やNULL禁止の設定も可能です。

これはスキーマレスデータベースでは不可能であり、プログラミング言語を用いて高度なレベルで自作する必要があります。スキーマレスデータベースでは、属性の追加や削除も不可能です。基本的に最初のスキーマに縛られてしまうので、一から作り直して正しく移行できることを自力で確認するか、アプリケーションレベルで扱うことになります。

  • トランザクションが使えない
  • ACIDでない
  • クエリが複雑になったときの速度が不十分に思える

メインで使っているデータベースでこんな目に遭っても構いませんか?私はイヤです。個人的にMongoDBの唯一の目玉機能と思えるのは、親ドキュメントに多数のドキュメントを埋め込めることぐらいです。それ以外の機能はおそらくPostgreSQLで事足ります(それにセキュリティアップデートの面倒を見なければならないデータベースシステムが1つで済みます)。

🔗 21. 動的スコープは、他に打つ手がない場合にはよいパターン

Rubyでクロージャ(proclambda)を定義すると、レキシカルなスコープや環境がクロージャにカプセル化されます。

これは、コードのAという場所でprocを定義したとしても、コードのBという場所でそれを渡して呼び出したときに、procが定義されたAのレキシカルスコープ内で定義されているものであれば、変数でも何でも引き続き参照可能であるということです。言い方を変えると「環境について閉じている」ということです。

これを逆にしたらどうなるでしょうか。たとえばコードのAという場所でprocを1つ定義し、そこでprocを呼んでもまったく意味がないが、コードのBという場所でprocを呼びたい場合にクロージャのレキシカルスコープを変更することで、実行結果にBの環境が反映されるようにするとします。

次の例をご覧ください。

CLOSURE = proc{puts internal_name}

class Foo
  def internal_name
    'foo'
  end

  def closure
    proc{puts internal_name}
  end

  def name1
    closure.call
  end

  def name2
    CLOSURE.call
  end

end

puts Foo.new.name1 #=> foo
puts Foo.new.name2 #=> undefined local variable or method `internal_name' for main:Object (NameError)

クロージャの定義時点ではinternal_nameが定義されていないので、当然name2メソッドは失敗します。

しかし、instance_execを使うとprocのバインディング(レキシカルスコープ)を再定義できます。

CLOSURE = proc{puts internal_name}

class Foo
  def internal_name
    'foo'
  end

  def closure
    proc{puts internal_name}
  end

  def name1
    closure.call
  end

  def name2
    instance_exec(&(CLOSURE))
  end

end

puts Foo.new.name1 #=> foo
puts Foo.new.name2 #=> foo

成功です。これは、アプリのある部分に書いたコードを、まったく異なるコンテキストで実行できるということです。しかしこれはどんなときに便利なのでしょうか?このあたりをいろいろハックしてみた結果、非常に有用な使いみちの1つはRailsのルーティングでした。

次のようなルーティングがあるとします。

  namespace :api do
    namespace :v1 do
      resources :company_users, only: [:show] do
        resources :posts, only: [:index] do
          resource :stats, only: [:show]
        end
      end
    end
  end

上から以下のルーティングが生成されます。

/api/v1/company_users/:id
/api/v1/company_users/:company_user_id/posts
/api/v1/company_users/:company_user_id/posts/:post_id/stats

:company_user_idはどうやら不要なので、次のようにしてクライアント側での柔軟性を高めたいと思います。

/api/v1/stats?user_id=:company_user_id&post_id=:post_id

しかしAPIは既に本番で稼働していて変更は困難です。

  namespace :api do
    namespace :v1 do
      resources :company_users, only: [:show] do
        resources :posts, only: [:index] do
          resource :stats, only: [:show]
        end
      end

      resource :stats, only: [:show], defaults: {company_user_id: proc{params[:company_id]}}
    end
  end

「え、ルーティングの中にparamsがある?」そのとおり!理由は、次のスニペットを使って、procのコンテキストをコントローラのコンテキストに再バインドしているからです。

def reshape_hash!
    self.params = HashWithIndifferentAccess.new(params.to_unsafe_h.reshape(self))
end

これで、このメソッドをbefore_filterとして追加しておけば、このルーティングにuser_idを送信するとcompany_user_idとして追加されます。

class Api::V1::StatsController < ApplicationController
  before_action :authenticate_user!
  before_action :reshape_hash!

  def index
    stats = Stats.new(current_user).all(
      user_id: params[:company_user_id], post_id: params[:post_id]
    )

    render json: stats, serializer: StatsSerializer
  end
...

このテクニックをルーティング以外で使ったこともありますが、ほとんどは最後の手段としてです。ご利用は計画的に。

関連記事

Railsで重要なパターンpart 1: Service Object(翻訳)

Railsで重要なパターンpart 2: Query Object(翻訳)

Railsのdefault_scopeは使うな、絶対(翻訳)


  1. 訳注: hydration(水和物化、水分補給)はシリアライズに似た概念です。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。