Quantcast
Channel: hachi8833の記事一覧|TechRacho by BPS株式会社
Viewing all articles
Browse latest Browse all 1759

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

$
0
0

概要

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

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

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

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

ルートのコントローラに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を書く(permalink

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

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です。私自身はまだ使う機会がありませんが。

ところで、私は主に2つの理由から「よいコードは常にそれ自身が語る」(訳注: コメントを避けてコードに語らせる)という言説にあまり賛成できません。

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

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

3. decoratorやpresenterを使う(permalink

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

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

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

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

5. PostgreSQLのarrayは、よほどシンプルでない限り使わないこと(permalink

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

PostgreSQLのarrayは、うんと小規模な場合にしか使わないことにします。

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

6. Postgres JSONBはいいヤツ(permalink

PostgreSQLのarrayとは対照的に、PostgreSQLのJSONBは大好きです。データベースにスキーマがあることのメリットは明らかなので、私たちは皆スキーマを持つデータベースが大好きです。しかしながら、スキーマを事前に予測できない場合、スキーマレスデータベースのシンプルさがどうしても必要になることがあります。私は次のような場合にJSONBを使うことがよくあります。

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

訳注: hydrationは化学用語の「水和/水和物」の借用で、シリアライズに少し似た概念です。

7. aasm gemはいいヤツ、ただしステートを変えて初期化しないこと(permalink

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

8 .メールアドレスのバリデーションを頑張るよりgemを使う(permalink

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

9. decoratorやpresenterを使って、ビューに渡すインスタンス変数をなるべく1つだけにする(permalink

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

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

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

クラスレベルで厳密なAPIを書くことがコードの品質を高めることにつながりますが、開発者はそのことを忘れがちです(私もですね!)。

11. 単に認証したい場合はDevise gemを使わないこと(permalink

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

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

私は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)を使う(permalink

もうおわかりですよね😃

14. PostgreSQL全文検索は単純な用途に向いている(permalink

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

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

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

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

次に、作成するすべてのサービスで、コンストラクタ(initialize)をprivateにします。つまり、この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のエラーメッセージを好みの形に変換する(permalink

RailsでAPIを書くと、エラーメッセージはたいていJSONAPI形式に従います。つまり、メッセージ(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レベルでも同じように一意性制約をかけるべきですよね、わかっております。しかしここでは仮に、ユーザーが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を書く(ワンライナーであっても)(permalink

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

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

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

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

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

私は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以外のデータベースをメインで使うのであれば十分な理由付けが必要(permalink

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

  • スキーマレスである: スキーマレスは機能の1つだと思うかもしれませんが、実際には大きな欠点です。データベースにスキーマがあることで、スキーマを必要に応じて少しずつ変更できますし、ツールや保証も得られます。たとえば、SQLにinteger型のカラムが1つあるとすると、これをstring型やtext型に変更することも、デフォルト値の設定やNULL禁止の設定もできます。これはスキーマレスなデータベースでは不可能であり、プログラミング言語を用いて高度なレベルで自作する必要があります。スキーマレスなデータベースでは、属性の追加や削除も不可能です。基本的に最初のスキーマに縛られてしまうので、一から作り直して正しく移行できることを自力で確認するか、アプリケーションレベルで扱うことになります。
  • トランザクションが使えない
  • ACIDでない
  • クエリが少し大きくなったときの速度も大したことはないと感じる

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

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

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

これで、このルートにuser_idを送信すると、このメソッドをbefore_filterとして追加することで、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: Form ObjectとVirtusを使って属性をサニタイズする(翻訳)

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

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

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


Viewing all articles
Browse latest Browse all 1759

Trending Articles