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

[Rails 5.1] 新機能: delegate_missing_toメソッド(翻訳)

$
0
0

こんにちは、hachi8833です。今回のBigBinaryシリーズは、週刊Railsウォッチでも紹介していなかったRails 5.1の新機能です。

概要

新機能: delegate_missing_to(翻訳)

#method_missingを使うときには、#respond_to_missing?も併用するべきです。しかし、#method_missing#respond_to_missing?を両方使うとコードが冗長になるのも確かです。

DHHが自ら上げた#23824で、冗長なコードの典型的な例を見ることができます。

(訳注: このコード例はそのままAPIドキュメントでも使われています)

class Partition
  def initialize(first_event)
    @events = [ first_event ]
  end

  def people
    if @events.first.detail.people.any?
      @events.collect { |e| Array(e.detail.people) }.flatten.uniq
    else
      @events.collect(&:creator).uniq
    end
  end

  private
    def respond_to_missing?(name, include_private = false)
      @events.respond_to?(name, include_private)
    end

    def method_missing(method, *args, &block)
      @events.public_send(method, *args, &block)
    end
end

DHHは、こうしたコードを改善するために新しいModule#delegate_missing_toメソッドの利用を提案しています。利用例は次のとおりです。

class Partition
  delegate_missing_to :@events

  def initialize(first_event)
    @events = [ first_event ]
  end

  def people
    if @events.first.detail.people.any?
      @events.collect { |e| Array(e.detail.people) }.flatten.uniq
    else
      @events.collect(&:creator).uniq
    end
  end
end

SimpleDelegatorクラスでは不足な理由

BigBinary社では従来RubyのSimpleDelegatorクラスを使っていました。このクラスの問題は、このdelegatorは実行時にどんな種類のオブジェクトでも使われる可能性があるため、呼び出しが委譲される先のオブジェクトを静的に確定できないという点です。

DHHはこのパターンについて次のように言っています

この程度のシンプルな機能のために継承ツリーをハイジャックしなくても済む方がいい。

(訳注: 続きはこうなっています)

継承とsuperを使うのもどうかと思う。#delegate_missing_toなら意図が明確になる。

#delegateメソッドでは不足な理由

Module#delegateメソッドを使う手もあります。しかしその場合は全メソッドをホワイトリスト化しなければなりませんし、ホワイトリストが非常に長くなってしまうこともあります。以下は私たちの実際の案件から引用した実例コードです。

delegate :browser_status, :browser_stats_present?,
         :browser_failed_count, :browser_passed_count,
         :sequential_id, :project, :initiation_info,
         :test_run, :success?,
         to: :test_run_browser_stats

すべてを委譲するならdelegate_missing_to

状況によってはあらゆるmissing methodを委譲したくなることがありますが、#delegate_missing_toならそうした作業をすっきり行えます。なお、この委譲は委譲先のオブジェクトのpublicなメソッドに対してのみ有効であることにご注意ください。

詳しくは#23930のPRをご覧ください。

訳注

現在の5.1-stableの#delegate_missing_toは以下のようになっています。

# 5-1-stable/activesupport/lib/active_support/core_ext/module/delegation.rb#L262
  # オブジェクト内の呼び出し可能なものはすべて対象にできる
  # (例: インスタンス変数、メソッド、定数)
  def delegate_missing_to(target)
    target = target.to_s
    target = "self.#{target}" if DELEGATION_RESERVED_METHOD_NAMES.include?(target)

    module_eval <<-RUBY, __FILE__, __LINE__ + 1
      def respond_to_missing?(name, include_private = false)
        # 見落としのように見えるかもしれないが、privateなものは委譲されないので
        # あえてinclude_privateを渡さないようにしている
        #{target}.respond_to?(name) || super
      end
      def method_missing(method, *args, &block)
        if #{target}.respond_to?(method)
          #{target}.public_send(method, *args, &block)
        else
          super
        end
      end
    RUBY
  end

#23824では#delegate_missing_toが何でも委譲することの是非などについて多少議論が紛糾していました。DHHは最後に「このパターンはdecorator用である」と述べています。

関連記事


[Rails] Devise Wiki日本語もくじ2「認証方法のカスタマイズ」「OmniAuth」(概要・用途付き)

$
0
0

こんにちは、hachi8833です。前回の「ワークフローのカスタマイズ」もくじ」に続き、Devise How-To Wikiの「認証方法のカスタマイズ」部分の目次に概要や用途を加えました。OmniAuthの目次も含まれています。

今後も随時更新いたしますので、原文更新にお気づきの方は@techrachoまでお知らせいただけると助かります。

Devise How-To: 認証方法のカスタマイズ

※新しい順に並べ替えてあります。

ゲストユーザーを作成する

25 May 2017 · 32 revisions
How To: Create a guest user

ゲストのUserオブジェクトを使って、ログインしていない一般ユーザーでもセッションを管理できるようにする方法です。

ここではゲストのオブジェクトを作成してidをsession[:guest_user_id]でデータベースに保存し、current_or_guest_userでゲストかどうかを判定します。

Recaptcha

1 Feb 2017 · 47 revisions
How To: Use Recaptcha with Devise

ReCaptcha gemを使って、ボット除けの画像キャプチャ認証を導入する方法が紹介されています。

パスワードなしでアカウントを編集できるようにする

14 Jan 2017 · 66 revisions
How To: Allow users to edit their account without providing a password

独自のコントローラを作成する方法と、Devise::RegistrationsControllerupdate_resourceをオーバーライドする方法の2つが紹介されています。

サブドメインを使う

2 Dec 2016 · 7 revisions
How To: Use subdomains

Railsアプリでサブドメインを使っている場合にInternet Explorerで生じるエラーの回避方法が紹介されています。設定変更のみで行なえます。

ユーザー名とメールアドレスのどちらでもサインインできるようにする

8 Nov 2016 · 60 revisions
How To: Allow users to sign in using their username or email address

サインインのほか、パスワード変更時にもユーザー名とメールアドレスの両方を使えるようにする方法や、Gmailやme.comで使われている「メールアドレスのユーザー名部分もユーザー名として使えるようにする方法」も紹介されています。

MySQLとMongoid向けの注意事項も含まれています。

LDAPで認証する

27 Oct 2016 · 7 revisions
How To: Authenticate via LDAP

コード例ではwarden(Deviseを支えるRackの認証フレームワーク)を呼んでいます。

(old)シンプルなトークン認証の例

2 Sep 2016 · 14 revisions
How To: Simple Token Authentication Example

この記事は既に古くなっている(TokenAuthenticatableがDeviseから削除されたため)ので、Gistのコードを参照するようにとのことです。

Deviseで単一ユーザーシステムを構築する

10 Aug 2016 · 10 revisions
How To: Set up devise as a single user system

個人ブログアプリなど、ユーザーが1人に限定されたプライベートアプリをDeviseで構築する際に、新規ユーザー登録ページを非公開にする方法です。

ユーザーが1人も登録されていない状態で一般ユーザーが登録ページにアクセスしようとするとトップにリダイレクトされ、ユーザーが1人登録済みの場合はsign_inページにリダイレクトされます。

ユーザーがメールアドレス以外の文字列を使ってサインインできるようにする

12 Mar 2016 · 8 revisions
How To: Allow users to sign in with something other than their email address

メールアドレスをユーザー名に使って欲しくない場合の方法です。config/initializers/devise.rbの設定変更かモデルの変更で行えます。Strong Parameter周りの変更も必要です。あとは必要に応じてビューのレイアウトやdevise.*.ymlのメッセージを変更します。

HTTP Basic認証

25 Jun 2015 · 6 revisions
How To: Use HTTP Auth Basic with Devise

DeviseでいわゆるBASIC認証を行う方法が簡単に紹介されています。なお、BASIC認証やdigest認証はDeviseがなくてもRailsの機能だけでできます。

HTTPS(SSL)を使う

6 Apr 2015 · 11 revisions
How To: Use SSL (HTTPS)

DeviseのビューをHTTPSで保護する方法です。Devise 1.0と1.1で方法が少し異なります。

ただし、この方法で部分的に保護するより、サイト全体をHTTPSで保護することが推奨されています。

メールアドレスだけでユーザー登録を開始できるようにする

26 Oct 2014 · 24 revisions
How To: Email only sign up

フォームでメールアドレスが入力されたら確認メールをユーザーに送信し、ユーザーがメールのリンクからWebサイトに戻ってパスワードを設定できるようにします。

手順1を省略すると、登録フォームでパスワードも入力できるようになります。

Rails 4の場合は、「確認をオーバーライドして、確認中にユーザーが独自のパスワードを選べるようにする」も参照してください。

HTTP 認証

15 Dec 2012 · 2 revisions
How To: Use HTTP Basic Authentication

DeviseでのBASIC認証、digest認証、NginxやApacheと併用する場合の方法が紹介されています。

※BASIC認証やdigest認証はDeviseがなくてもRailsの機能だけでできます。

Deviseでリモート認証する

16 Nov 2012 · 1 revision
How to: Remote authentication with Devise

Remote authentication with deviseへのリンクだけが貼られています。Railsで外部リソースを使う場合の外部リソースの認証方法です。Wardenのstrategyを作成することで行います。

メールアドレスの大文字小文字を区別しないようにする

9 Dec 2011 · 5 revisions
How To: Use case insensitive emails

Deviseの設定を追加するだけで、ユーザー登録、サインイン、パスワードを忘れたときの処理などでメールアドレスの大文字小文字を区別しないようになります。

User.find_by_emailは大文字小文字を区別するので、代わりにUser.find_for_authenticationを使う必要があります。

OmniAuth

※並べ替えは行っていません。

OmniAuth: 概要

12 Feb 2017 · 171 revisions
OmniAuth: 概要原文

Facebookを例に、Rails + Devise + OmniAuthの基本的な利用方法を解説しています。OmniAuth以外の認証をオフにする場合の方法やトラブルシューティングも紹介されています。

171回もの更新が行われており、需要の高さがうかがえます。

OmniAuth認証を複数のモデルで共用する方法

3 Mar 2017 · 8 revisions
OmniAuth認証を複数のモデルで共用する方法原文

Devise + OmnuAuthはデフォルトでは1つのモデルでしか利用できません。これを複数モデルで共有できるようにする方法を紹介しています。

OmniAuth: 結合テスト

12 Nov 2016 · 21 revisions
OmniAuth: 結合テスト原文

OmniAuthをモック化して結合テストに使う方法を紹介しています。元記事はDeviseではなくOmniAuthのWikiです。

Omniauthableでのサインアウトとセッション保持

9 Dec 2013 · 5 revisions
Omniauthable, sign out action and rememberable

Devise + Omniauthableではデフォルトでsign_outルーティングが追加されません。このルーティングの追加方法や、Devise::Controllers::Rememberableでセッションを保持する方法を紹介しています。

補足

  • 原文ではsign inとlog in、sign outとlog outがそれぞれ同じ意味で使われています。訳文では、紛らわしくなる場合を除き、原則として原文に沿ったカタカナを使用します。
  • 同じ手順が複数のカテゴリに含まれていることもあります(原文に従っています)
  • 原文が更新されていることにお気づきの場合は、ぜひ@techrachoまでお知らせください。更新いたします。

関連記事(Devise)

Rails: JOINすべきかどうか、それが問題だ — #includesの振舞いを理解する(翻訳)

$
0
0

概要

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

訳注: actの基本的な意味は「演技(する)」「(舞台の)場面」であり、タイトルはこれにかかっています。

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コスト(特にデータベースがアプリとは別マシンで動作している場合)の犯人はここにいます。

本記事では、ActiveRecordでの開発について、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カラムが見つからないとActiveRecordに怒られてしまいました。しかしカラムがなくなったわけではありません。#preload(この名前がそもそもヒントです)は、別のクエリで関連付けを事前に読み込んでないと、読み込みやフェッチができないのです。クエリでusers.nameを使いたければ、2つのテーブルをJOINする必要があるでしょう。つまり、#preloadは絶対的な解決法ではないということです。では、クエリの中で関連付けにアクセスする必要がある場合はどうすればよいのでしょうか?そこで話は次の戦略につながります。

“JOINじゃ、JOINじゃ!JOINと引き換えにこの王国をくれてやるわい!”

訳注: この引用だけ、ハムレットではありません(リチャード三世)。

先ほどの戦略の問題は、クエリ内で別のテーブルにあるカラムにアクセスできないということです。その理由は、#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です。「誰に断ってこんな無礼を働くのか…」。しかし物知り博士タイプのActiveRecordは、自分はSQL文をこんなに知っているぞとドヤ顔で見せびらかそうとするやつなので、そこは気にしてはなりません。しかし#eager_loadが1つのクエリで常にLEFT OUTER JOINを使うという事実は記憶に値します(OUTERのことはご心配なく: LEFT JOINと同じです)。

次に、ActiveRecordは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か。あなたならどちらにしますか?この愛すべき問題を作り出してくれたのはActiveRecordなのです(私はActiveRecordへの感謝を忘れたことはありません❤)から、ActiveRecordの#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チームは実に頭の切れる連中なので、きっと何かいいアイデアを思いついてくれることでしょう。

query.includes(:user).references(:user)のようなだるい書き方をしなくても普通に#eager_loadを呼ぶだけでいいのではないか、あるいは、金魚のフンみたいな#includesを使わずに単に#preloadを呼べばいいのではないか、その方が意図も明確になるのではないか

“美しき人に美しき花を手向けようではないか: さらばじゃ” — まとめ

  • #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が呼ばれます。

“ActiveRecordにはコードだけではわからないことがいくらでもあるのだよ、ホレーショ”

ハムレット王子は、自らの義父となった叔父のクローディアス王を殺すべきかどうかという重大なジレンマに直面して「生きるべきか死すべきか(To be or not to be?)」とつぶやきました。それはともかく、本記事で申し上げたいのは、N+1クエリつぶしを面倒臭がってはならないということです。私たちが本当に知りたいのは「クエリでJOINすべきかどうか、するならどの程度JOINすべきなのか」なのですが、ハムレットの苦悩と同様、これは難しい問いかけです。本記事が、ActiveRecordのeager loading戦略について皆さまの疑問を少しでも解消し、オブジェクトの関連付けをクエリにするときに合理的な決定を下せるようになれば幸いです。

Sergio Fontes、Filipe W. Lima、Leonardo Brito、Chico Carvalhoに感謝申し上げます。

追記

k0kubunさんの以下の記事も合わせて読むことをおすすめします。Rails 5から#left_outer_joins(またはエイリアスの#left_joins)が使えるそうです。また、#includesがAR::Baseを生成するために効率が落ちることがある点も指摘しています。

関連記事

Rails: N+1クエリを「バッチング」で解決するBatchLoader gem(翻訳)

Rails: render_async gemでレンダリングを高速化(翻訳)

Rails向け高機能カウンタキャッシュ gem ‘counter_culture’ README(翻訳)

Railsの`CurrentAttributes`は有害である(翻訳)

Rein: RailsのActiveRecordでDB制約やデータベースビューを使えるgem(README翻訳)

Rails: ActiveModelSerializersでAPIを作る–Part 1(翻訳)

$
0
0

概要

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

Rails: ActiveModelSerializersでAPIを作る–Part 1(翻訳)

本チュートリアルでは、図書館の業務フロー編成を助けるAPIを作成することにします。このAPIでは、本の貸出、返却、ユーザーの作成、本の作成、著者の作成を行います。さらに、本/著者/ユーザーの作成/読み出し/更新/削除(CRUD)を行う管理ページは管理者だけがアクセスできるようにします。認証はHTTPトークン経由で扱うことにします。

API作成のため、Rails API 5にActiveModelSerializersを併用します。次回は、ここで作成するAPIのフルテストを行う予定です。

それでは、まっさらのRails APIアプリを作るところから始めてみましょう。

$ rails new library --api --database=postgresql

終わったらGemfileを以下の内容に差し替えて、active_model_serializers、faker、rack-corsの3つのgemを追加します。

訳注: 原文のRailsバージョンは5.0.1です。Rails 5.1.4で動作を確認しました。

source 'https://rubygems.org'

git_source(:github) do |repo_name|
  repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?("/")
  "https://github.com/#{repo_name}.git"
end

gem 'rails', '~> 5.0.1'
gem 'pg', '~> 0.18'
gem 'puma', '~> 3.0'
gem 'active_model_serializers', '~> 0.10.0'
gem 'rack-cors'

group :development, :test do
  gem 'pry-rails'
  gem 'faker'
end

group :development do
  gem 'bullet'
  gem 'listen', '~> 3.0.5'
  gem 'spring'
  gem 'spring-watcher-listen', '~> 2.0.0'
end
$ bundle install
  • ActiveModelSerializers: JSONオブジェクトを作成するオブジェクトの作成を支援するライブラリです。今回の場合、Railsのビューはまったく使わないため、オブジェクトを表すシリアライザだけを返します。カスタマイズや再利用を自在に行える素晴らしいライブラリです。
  • RackCors: クロスオリジンリソース共有(CORS)を扱えるようにするRackミドルウェアであり、これを使ってクロスオリジンのAjaxリクエストを行えるようにします。

  • Faker: データのフェイクを作成する強力で素晴らしいライブラリです。

訳注: RackCorsは、現在のRailsではGemfileにコメントアウトの形で追加済みです。

モデルを作成する

ところで、データベーススキーマの設計が必要ですね。今回はusers、books、authors、book_copiesの4つのテーブルが必要です。スキーマはシンプルにしましょう。usersは基本的に、図書館で本を借りる人を表します。

authorsは本の著者、booksは本を表します。book_copiesは、貸出可能な本を表します。スキーマをシンプルにすると申し上げたとおり、ここでは貸出の履歴は保存しないことにします。

それではgenerateしましょう。

$ rails generate model author first_name last_name
$ rails g model book title author:references
$ rails g model user first_name last_name email
$ rails g model book_copy book:references isbn published:date format:integer user:references

ついでにインデックスも追加します。以下のマイグレーションのように、必要な箇所にはnull:falseも追加してください。

class CreateAuthors < ActiveRecord::Migration[5.0]
  def change
    create_table :authors do |t|
      t.string :first_name, null: false
      t.string :last_name, index: true, null: false

      t.timestamps
    end
  end
end
class CreateBooks < ActiveRecord::Migration[5.0]
  def change
    create_table :books do |t|
      t.references :author, foreign_key: true, null: false
      t.string :title, index: true, null: false

      t.timestamps
    end
  end
end
class CreateUsers < ActiveRecord::Migration[5.0]
  def change
    create_table :users do |t|
      t.string :first_name, null: false
      t.string :last_name, null: false
      t.string :email, null: false, index: true

      t.timestamps
    end
  end
end
class CreateBookCopies < ActiveRecord::Migration[5.0]
  def change
    create_table :book_copies do |t|
      t.references :book, foreign_key: true, null: false
      t.string :isbn, null: false, index: true
      t.date :published, null: false
      t.integer :format, null: false
      t.references :user, foreign_key: true

      t.timestamps
    end
  end
end

それぞれのファイルで変更作業が終わったら、データベースを作成してマイグレーションをすべて実行します。

$ rake db:create
$ rake db:migrate

データベーススキーマができましたので、ここからはモデルで使うメソッドを見ていきます。

モデルを更新する

生成したモデルを更新し、リレーションシップバリデーションをすべて追加します。必要な各フィールドがSQLレベルで存在するかどうかのバリデーションも行います。

class Author < ApplicationRecord
  has_many :books

  validates :first_name, :last_name, presence: true
end
class Book < ApplicationRecord
  has_many :book_copies
  belongs_to :author

  validates :title, :author, presence: true
end
class BookCopy < ApplicationRecord
  belongs_to :book
  belongs_to :user, optional: true

  validates :isbn, :published, :format, :book, presence: true

  HARDBACK = 1
  PAPERBACK = 2
  EBOOK = 3

  enum format: { hardback: HARDBACK, paperback: PAPERBACK, ebook: EBOOK }
end
class User < ApplicationRecord
  has_many :book_copies

  validates :first_name, :last_name, :email, presence: true
end

新しいルーティングの作成も忘れないようにしましょう。routes.rbを次のように更新します。

Rails.application.routes.draw do
  scope module: :v1 do
    resources :authors, only: [:index, :create, :update, :destroy, :show]
    resources :books, only: [:index, :create, :update, :destroy, :show]
    resources :book_copies, only: [:index, :create, :update, :destroy, :show]
    resources :users, only: [:index, :create, :update, :destroy, :show]
  end
end

API バージョニング

新しいAPIを作成する上で最も重要なのがバージョニングと言えます。APIに名前空間v1v2を追加するべきです。理由は、次バージョンのAPIはおそらく異なるものになるはずだからです。

バージョンが変わると、何かと互換性が問題になります。古いバージョンの製品を使いたがる顧客は必ずいるものです。今回の場合、古い製品はv1名前空間の下に置き、新しい方はv2の下に置きます。次の例をご覧ください。

my-company.com/my_product/v1/my_endpoint
my-company.com/my_product/v2/my_endpoint

本チュートリアルでは、アプリですべてのバージョンをサポートすることにします。きっと顧客も幸せになれるでしょう。

シリアライザ

前述のとおり、本チュートリアルではJSONのビルドにシリアライザを使います。こうするとオブジェクトになってくれるので、アプリのどこでも使えるようになるのがよい点です。JSONのためにビューを使う必要もなくなりますし、フィールドを自由に増やしたり減らしたりすることもできます。

それでは最初のシリアライザを作成しましょう。

$ rails g serializer user
class UserSerializer < ActiveModel::Serializer
  attributes :id, :first_name, :last_name, :email, :book_copies
end

オブジェクトに含めたいフィールドをattributesで定義できます。

続いてbook_serializerを作成します。

$ rails g serializer book
class BookSerializer < ActiveModel::Serializer
  attributes :id, :title, :author, :book_copies

  def author
    instance_options[:without_serializer] ? object.author : AuthorSerializer.new(object.author, without_serializer: true)
  end
end

上のコードでもattributesを定義していますが、先ほどと異なるのは#authorメソッドがオーバーライドされている点です。シリアライズされたauthorオブジェクトが必要になるときもありますが、必要ではないこともあります。呼び出しの第2パラメータでoptions = {}オプションを使って、どのオブジェクトが必要かを指定できます。ところで、なぜこれが必要なのでしょうか。

今回の場合をチェックしてみましょう。bookオブジェクトを1つ作成すると、その中には、1つ以上のbookを含むauthorも1つ含まれます。各bookはシリアライズ済みなので、authorも1つ返されます。このままだと無限ループに陥ってしまうかもしれません。シリアライズ済みオブジェクトが必要かどうかを指定しなければならない理由は、これです。なお、さらに#index#updateなどのアクションごとにシリアライザを作成することもできます。

同じ要領で、authorbook_copyにもシリアライザを追加しましょう。

class BookCopySerializer < ActiveModel::Serializer
  attributes :id, :book, :user, :isbn, :published, :format

  def book
    instance_options[:without_serializer] ? object.book : BookSerializer.new(object.book, without_serializer: true)
  end

  def user
    return unless object.user
    instance_options[:without_serializer] ? object.user : UserSerializer.new(object.user, without_serializer: true)
  end
end
class AuthorSerializer < ActiveModel::Serializer
  attributes :id, :first_name, :last_name, :books
end

コントローラ

ところで、このままではコントローラがありません。コントローラもルーティングでバージョニングされ、4つのコントローラはほとんど同じ内容です。テーブルごとに基本的なCRUDを追加する必要があります。それではやってみましょう。

module V1
  class AuthorsController < ApplicationController
    before_action :set_author, only: [:show, :destroy, :update]

    def index
      authors = Author.preload(:books).paginate(page: params[:page])
      render json: authors, meta: pagination(authors), adapter: :json
    end

    def show
      render json: @author, adapter: :json
    end

    def create
      author = Author.new(author_params)
      if author.save
        render json: author, adapter: :json, status: 201
      else
        render json: { error: author.errors }, status: 422
      end
    end

    def update
      if @author.update(author_params)
        render json: @author, adapter: :json, status: 200
      else
        render json: { error: @author.errors }, status: 422
      end
    end

    def destroy
      @author.destroy
      head 204
    end

    private

    def set_author
      @author = Author.find(params[:id])
    end

    def author_params
      params.require(:author).permit(:first_name, :last_name)
    end
  end
end
module V1
  class BookCopiesController < ApplicationController
    before_action :set_book_copy, only: [:show, :destroy, :update]

    def index
      book_copies = BookCopy.preload(:book, :user, book: [:author]).paginate(page: params[:page])
      render json: book_copies, meta: pagination(book_copies), adapter: :json
    end

    def show
      render json: @book_copy, adapter: :json
    end

    def create
      book_copy = BookCopy.new(book_copy_params)
      if book_copy.save
        render json: book_copy, adapter: :json, status: 201
      else
        render json: { error: book_copy.errors }, status: 422
      end
    end

    def update
      if @book_copy.update(book_copy_params)
        render json: @book_copy, adapter: :json, status: 200
      else
        render json: { error: @book_copy.errors }, status: 422
      end
    end

    def destroy
      @book_copy.destroy
      head 204
    end

    private

    def set_book_copy
      @book_copy = BookCopy.find(params[:id])
    end

    def book_copy_params
      params.require(:book_copy).permit(:book_id, :format, :isbn, :published, :user_id)
    end
  end
end
module V1
  class BooksController < ApplicationController
    before_action :set_book, only: [:show, :destroy, :update]

    def index
      books = Book.preload(:author, :book_copies).paginate(page: params[:page])
      render json: books, meta: pagination(books), adapter: :json
    end

    def show
      render json: @book, adapter: :json
    end

    def create
      book = Book.new(book_params)
      if book.save
        render json: book, adapter: :json, status: 201
      else
        render json: { error: book.errors }, status: 422
      end
    end

    def update
      if @book.update(book_params)
        render json: @book, adapter: :json, status: 200
      else
        render json: { error: @book.errors }, status: 422
      end
    end

    def destroy
      @book.destroy
      head 204
    end

    private

    def set_book
      @book = Book.find(params[:id])
    end

    def book_params
      params.require(:book).permit(:title, :author_id)
    end
  end
end
module V1
  class UsersController < ApplicationController
    before_action :set_user, only: [:show, :destroy, :update]

    def index
      users = User.preload(:book_copies).paginate(page: params[:page])
      render json: users, meta: pagination(users), adapter: :json
    end

    def show
      render json: @user, adapter: :json
    end

    def create
      user = User.new(user_params)
      if user.save
        render json: user, adapter: :json, status: 201
      else
        render json: { error: user.errors }, status: 422
      end
    end

    def update
      if @user.update(user_params)
        render json: @user, adapter: :json, status: 200
      else
        render json: { error: @user.errors }, status: 422
      end
    end

    def destroy
      @user.destroy
      head 204
    end

    private

    def set_user
      @user = User.find(params[:id])
    end

    def user_params
      params.require(:user).permit(:first_name, :last_name, :email)
    end
  end
end

コードからわかるように、これらのメソッドはrender Author.find(1)などの基本オブジェクトを返します。シリアライザをレンダリングしたいということをアプリにどうやって伝えればよいでしょうか。その答えは、adapter: :jsonを追加することです。こうすると、以後JSONのレンダリングにデフォルトでシリアライザが使われるようになります。詳しくは公式ドキュメントをご覧ください(訳注: 原文にリンクがありませんでした)。

アプリのフェイクデータが欲しいところなので、seeds.rbファイルでFaker gemを使ってデータを追加しましょう。

authors = (1..20).map do
  Author.create!(
    first_name: Faker::Name.first_name,
    last_name: Faker::Name.last_name
  )
end

books = (1..70).map do
  Book.create!(
    title: Faker::Book.title,
    author: authors.sample
  )
end

users = (1..10).map do
  User.create!(
    first_name: Faker::Name.first_name,
    last_name: Faker::Name.last_name,
    email: Faker::Internet.email
  )
end

(1..300).map do
  BookCopy.create!(
    format: rand(1..3),
    published: Faker::Date.between(10.years.ago, Date.today),
    book: books.sample,
    isbn: Faker::Number.number(13)
  )
end

後は以下を実行して、データベースにデータを追加します。

$ rake db:seed

Rack-Cors

前述のとおり、Rack-CORSも使います。これはクロスオリジンAjax呼び出しを実現できるうれしいツールです。なお、これはGemファイルに追加するだけではだめで、application.rbに設定を追加する必要もあります。

require_relative 'boot'

require "rails"
# Pick the frameworks you want:
require "active_model/railtie"
require "active_job/railtie"
require "active_record/railtie"
require "action_controller/railtie"
require "action_mailer/railtie"
require "action_view/railtie"
require "action_cable/engine"
# require "sprockets/railtie"
require "rails/test_unit/railtie"

# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)

module Library
  class Application < Rails::Application
    # Settings in config/environments/* take precedence over those specified here.
    # Application configuration should go into files in config/initializers
    # -- all .rb files in that directory are automatically loaded.

    # Only loads a smaller set of middleware suitable for API only apps.
    # Middleware like session, flash, cookies can be added back manually.
    # Skip views, helpers and assets when generating a new resource.
    config.api_only = true

    config.middleware.insert_before 0, Rack::Cors do
      allow do
        origins '*'
        resource '*', headers: :any, methods: [:get, :post, :put, :delete, :options]
      end
    end
  end
end

上のように設定を追加することで、Railsアプリが外部からの特定のHTTPリクエストを処理するようになります。設定は完全にカスタマイズ可能です。詳しくはこちらをご覧ください(訳注: 原文にリンクがありませんでした)。

Rack-Attack

もうひとつ便利なgemはRack-Attackです。このgemはリクエストをフィルタしたり絞り込んだりすることで、ブロックしたり、ブラックリストに追加したり、トラッキングしたりでき、他にも多くの機能があります。次の設定例をご覧ください。

  Rack::Attack.safelist('allow from localhost') do |req|
    '127.0.0.1' == req.ip || '::1' == req.ip
  end

上のコードは、localhostからのリクエストをすべて許可します。

  Rack::Attack.blocklist('block bad UA logins') do |req|
    req.path == '/' && req.user_agent == 'SomeScraper'
  end

上のコードは、user agentがSomeScraperであるroot_pathからのリクエストをブロックします。

  Rack::Attack.blocklist('block some IP addresses') do |req|
    '123.456.789' == req.ip || '1.9.02.2' == req.ip
  end

上のコードは、指定のIPアドレスからのリクエストをブロックします。

それではRack-attackをGemfileに追加しましょう。

gem 'rack-attack'

Bundlerでgemをインストールします。

$ bundle install

Rack-attackを使うための設定をアプリに追加する必要があります。以下をapplication.rbに追加します。

config.middleware.use Rack::Attack

フィルタ機能を追加するには、config/initializersディレクトリに以下の内容を含むrack_attack.rbというファイルを追加する必要があります。

class Rack::Attack
  Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new

  Rack::Attack.throttle('req/ip', limit: 5, period: 1.second) do |req|
    req.ip
  end

  Rack::Attack.throttled_response = lambda do |env|
    # Using 503 because it may make attacker think that they have successfully
    # DOSed the site. Rack::Attack returns 429 for throttling by default
    [ 503, {}, ["Server Error\n"]]
  end
end

この設定では、基本的にIPアドレスごとに1秒あたり5リクエストまでのアクセスを許可しています。誰かがサーバー側のエンドポイントに1秒あたり5回を超えるリクエストを送信すると、HTTP 503とサーバーエラーをレスポンスとして返します。

トークン – APIキー

最初に書いたように、APIのセキュリティ保護にはHTTPトークンを使います。ユーザーごとに独自のトークンを持ち、これを使ってユーザーをデータベースから検索し、current_userとして設定します。

通常のユーザーは本の貸出と返却のみを行えます。また、ユーザーは借りていない本を返却することはできず、既に借りている本を借りることもできません。usersテーブルにフィールドを1つ追加しましょう。

$ rails g migration add_api_key_to_users api_key:index

usersテーブルにadmimフィールドも追加します。

$ rails g migration add_admin_to_users admin:boolean

マイグレーションファイルを以下のようにカスタマイズします。

class AddAdminToUsers < ActiveRecord::Migration[5.0]
  def change
    add_column :users, :admin, :boolean, default: false
  end
end

マイグレーションを実行します。

$ rake db:migrate

この時点ではAPIトークンのフィールドをユーザーに追加しただけなので、何らかの形でトークンを生成する必要があります。トークン生成は、ユーザー作成前かデータベースへのinsert前に行えます。Userクラスに#generate_api_keyメソッドを追加しましょう。

class User < ApplicationRecord
  ...

  before_create :generate_api_key

  private

  def generate_api_key
    loop do
      self.api_key = SecureRandom.base64(30)
      break unless User.exists?(api_key: self.api_key)
    end
  end

  ...
end

このままではAPIがセキュリティ保護されず、リクエストにAPIキーが含まれているかどうかがチェックされていませんので、変更が必要です。#authenticate_with_http_tokenメソッドを使うためには、ActionController::HttpAuthentication::Token::ControllerMethodsモジュールのインクルードが必要です。

それが終わったら、コードを追加します。認証はリクエストごとに行う必要があります。ユーザーまたはadminに、リクエストされたトークンが含まれていれば、トークンをインスタンス変数に保存して後で使えるようにします。トークンが含まれていない場合は、HTTP 401でJSONを返します。

さらに、レコードが見つからない場合(存在しない本をリクエストされた場合など)にはrescueするのがベストプラクティスです。こうした場合にも対応するため、アプリケーションエラーをthrowする代わりに、有効なHTTPステータスコードを返す必要があります。ApplicationControllerに以下のコードを追加してください。

class ApplicationController < ActionController::API
  include ActionController::HttpAuthentication::Token::ControllerMethods

  rescue_from ActiveRecord::RecordNotFound, with: :record_not_found

  protected

  def pagination(records)
    {
      pagination: {
        per_page: records.per_page,
        total_pages: records.total_pages,
        total_objects: records.total_entries
      }
    }
  end

  def current_user
    @user
  end

  def current_admin
    @admin
  end

  private

  def authenticate_admin
    authenticate_admin_with_token || render_unauthorized_request
  end

  def authenticate_user
    authenticate_user_with_token || render_unauthorized_request
  end

  def authenticate_admin_with_token
    authenticate_with_http_token do |token, options|
      @admin = User.find_by(api_key: token, admin: true)
    end
  end

  def authenticate_user_with_token
    authenticate_with_http_token do |token, options|
      @user = User.find_by(api_key: token)
    end
  end

  def render_unauthorized_request
    self.headers['WWW-Authenticate'] = 'Token realm="Application"'
    render json: { error: 'Bad credentials' }, status: 401
  end

  def record_not_found
    render json: { error: 'Record not found' }, status: 404
  end
end

APIキーをデータベースに設定する必要があります。Railsコンソールで以下を実行してください。

User.all.each { |u| u.send(:generate_api_key); u.save }

終わったら、システムをセキュリティ保護しましょう。一部のパーツはadminだけがアクセスできるようにする必要があります。ApplicationControllerに以下を追加します。

before_action :authenticate_admin

ここまでできたら、APIをテストします。まずはサーバーを起動しましょう。

$ rails s

それでは、books#showエンドポイントに無効なidでアクセスして、アプリが動作していることを確認します。有効なリクエストを確認するには、HTTPトークンを有効なものに置き換えてください。

$ curl -X GET -H "Authorization: Token
token=ULezVx1CFV5jUsN4TkutL2p/lVtDDDYBqllqf6pS" http://localhost:3000/books/121211

adminアクセスを確認する場合は、adminフラグをtrueに設定します。

idが121211の本がない場合は、以下が返ります。

{“error”:”Record not found.”}

無効なキーでリクエストすると、以下が返ります。

{“error”:”Bad credentials.”}

本を作成するには、以下を実行します。

$ curl -X POST -H "Authorization: Token token=TDBWEkpmV0EzJFI2KRo6F/VL/F15VXYi4r2wtUOo" -d "book[title]=Test&book[author_id]=1" http://localhost:3000/books

Pundit

これで、リクエストにトークンが含まれているかどうかをチェックできるようになりましたが、レコードの更新や作成を行えるユーザー(つまりadmin)かどうかをチェックできていません。これを行うには、Pundit gemを使ってフィルタを少し追加します。

Punditは、本を返却しようとしているユーザーが、実際に本を借りているかどうかをチェックするのに使われます。実のところ、1つのアクションのためだけならPunditは不要です。ここではPunditをカスタマイズしてPunditのスコープにさらに情報を追加できるところをお見せしたいと思います。この手順をご紹介するのは無駄ではないと思います。

Gemファイルに以下を追加してインストールを実行しましょう。

gem 'pundit'
$ bundle install
$ rails g pundit:install

Railsサーバーを再起動してください。

続いて、ApplicationControllerにフィルタとメソッドを追加します。

class ApplicationController < ActionController::API
  include Pundit
  include ActionController::HttpAuthentication::Token::ControllerMethods

  rescue_from ActiveRecord::RecordNotFound, with: :not_found
  rescue_from Pundit::NotAuthorizedError, with: :not_authorized

  before_action :authenticate_admin

  ...

  def current_user
    @user ||= admin_user
  end

  def admin_user
    return unless @admin && params[:user_id]

    User.find_by(id: params[:user_id])
  end

  def pundit_user
    Contexts::UserContext.new(current_user, current_admin)
  end

  def authenticate
    authenticate_admin_with_token || authenticate_user_with_token || render_unauthorized_request
  end

  ...

  def current_user_presence
    unless current_user
      render json: { error: 'Missing a user' }, status: 422
    end
  end

  ...

  def not_authorized
    render json: { error: 'Unauthorized' }, status: 403
  end
end

最初に、Punditをincludeし、403エラーからrescueするメソッドを追加する必要があります。また、リクエストが一般ユーザーかadminかを調べるauthorizeメソッドも追加します。

もうひとつ重要なのは、current_userをadminからのリクエストとして設定するメソッドです。たとえば、adminがユーザーを1人追加して、貸し出された本の情報を変更しようとしているとします。この場合、一般ユーザーからのリクエストと同様、user_idパラメータを渡してインスタンス変数に`current_userを設定する必要があります。

ここで重要なのが、Punditのカスタムコンテキスト(オーバーライドされたpundit_userメソッド)です。

最初に、app/policies/contexts.rbにUserContextクラスを追加しましょう。

module Contexts
  class UserContext
    attr_reader :user, :admin

    def initialize(user, admin)
      @user = user
      @admin = admin
    end
  end
end

見ての通り、ユーザーやadminを設定する、ごく普通のクラスです。

Punditは、デフォルトでApplicationPolicyクラスを生成します。ここで問題なのは、1つのコンテキストにはレコード1件とユーザー1人しか含まれていないということです。ユーザーとadminの両方を扱えるようにするにはどうしたらよいでしょうか。

この場合、user_contextを追加するのがよさそうです。ここではUserContextクラスのインスタンス全体を保存して、ポリシーのクラスにユーザーとadminの両方を設定します。

class ApplicationPolicy
  attr_reader :user_context, :record, :admin, :user

  def initialize(user_context, record)
    @user_context = user_context
    @record = record
    @admin = user_context.admin
    @user = user_context.user
  end

  def index?
    false
  end

  def show?
    scope.where(id: record.id).exists?
  end

  def create?
    false
  end

  def new?
    create?
  end

  def update?
    false
  end

  def edit?
    update?
  end

  def destroy?
    false
  end

  def scope
    Pundit.policy_scope!(user, record.class)
  end

  class Scope
    attr_reader :user_context, :scope, :user, :admin

    def initialize(user_context, scope)
      @user_context = user_context
      @scope = scope
      @admin = user_context.admin
      @user = user_context.user
    end

    def resolve
      scope
    end
  end
end

メインのポリシーを変更したら、app/policiesの下にbook_copy.rbポリシーを追加しましょう。

class BookCopyPolicy < ApplicationPolicy
  class Scope
    attr_reader :user_context, :scope, :user, :admin

    def initialize(user_context, scope)
      @user_context = user_context
      @admin = user_context.admin
      @user = user_context.user
      @scope = scope
    end

    def resolve
      if admin
        scope.all
      else
        scope.where(user: user)
      end
    end
  end

  def return_book?
    admin || record.user == user
  end
end

return_book?メソッドでは、ユーザーがadminか、本を借りた一般ユーザーかをチェックします。Punditは、全レコードを返す#policy_scopeメソッドも追加します。返されるレコードはすなわち、現在貸出可能な本に基づきます。これは#resolveメソッドで定義されます。これで、policy_scope(BookCopy)を実行すると、adminの場合はすべての本のリストが返され、一般ユーザーの場合は自分が借りている本だけが返されます。なかなかよいとは思いませんか?

#borrowメソッドと#return_bookメソッドがまだないので、BookCopiesControllerに追加しましょう。

module V1
  class BookCopiesController < ApplicationController
    skip_before_action :authenticate_admin, only: [:return_book, :borrow]
    before_action :authenticate, only: [:return_book, :borrow]
    before_action :current_user_presence, only: [:return_book, :borrow]
    before_action :set_book_copy, only: [:show, :destroy, :update, :borrow, :return_book]

    ...

    def borrow
      if @book_copy.borrow(current_user)
        render json: @book_copy, adapter: :json, status: 200
      else
        render json: { error: 'Cannot borrow this book.' }, status: 422
      end
    end

    def return_book
      authorize(@book_copy)

      if @book_copy.return_book(current_user)
        render json: @book_copy, adapter: :json, status: 200
      else
        render json: { error: 'Cannot return this book.' }, status: 422
      end
    end

    ...
  end
end

上のコードでは、以下のようにauthenticate_adminフィルタを更新したことで、#return_bookメソッドと#borrowメソッドを除くすべてのアクションでrequireされるようになりました。

skip_before_action :authenticate_admin, only: [:return_book, :borrow]

また、authenticateフィルタを追加して、現在のユーザー(adminまたは一般ユーザー)を設定しています。

before_action :authenticate, only: [:return_book, :borrow]

また、#current_user_presenceメソッドも追加しています。これは、adminがuser_idパラメータを渡したかどうかと、current_userが設定されているかどうかをチェックします。

今度はBookCopyクラスの更新が必要です。ここにも#return_bookメソッドと#borrowメソッドを追加します。

 class BookCopy < ApplicationRecord
   ...

  def borrow(borrower)
    return false if user.present?

    self.user = borrower
    save
  end

  def return_book(borrower)
    return false unless user.present?

    self.user = nil
    save
  end
end

ルーティングの更新もお忘れなく。

...
    resources :book_copies, only: [:index, :create, :update, :destroy, :show] do
      member do
        put :borrow
        put :return_book
      end
    end
...

最後に

チュートリアルのパートIでは、セキュアなAPIの作成法、HTTPトークンの利用法、Rack-attackやPunditの利用法を解説しました。次回はRSpecでのAPIテストについて解説します。

皆さまがこの記事を気に入って、お役に立てていただければと思います。

今回のソースコードはこちらで参照できます。

私たちのブログがお気に召しましたら、ぜひニュースレターを購読して今後の記事の更新情報をお受け取りください。質問がありましたら、元記事にいつでもご自由にコメントいただけます。

関連記事(Rubygem)

Rails: N+1クエリを「バッチング」で解決するBatchLoader gem(翻訳)

Rein: RailsのActiveRecordでDB制約やデータベースビューを使えるgem(README翻訳)

Rails: render_async gemでレンダリングを高速化(翻訳)

週刊Railsウォッチ(20170929)特集: RubyKaigi 2017セッションを振り返る(2)Ruby 2.3.5リリースほか

$
0
0

こんにちは、hachi8833です。RubyKaigi 2017@広島のかわいい公式Tシャツをどうやらどこぞに置き忘れてしまいました(´・ω・`)

RubyKaigiの動画も既にすべて公開され、スライドもほぼ出揃ってきたようです。既に大きな話題になっているので今さらですが、RubyKaigiのNOCWi-Fiチームのそらはさんのインフラ記事がとてもよかったので一応貼っておきます。

ウオッチ特別編は2日目LTと3日目セッションです。それではいってみましょう。

RubyKaigi 2017広島: セッションを振り返る: 2日目: LT 12本勝負

LTは10本のような気がしてましたが、並べてみたら12本でした。昨年と同様、LTにするにはもったいないレベルの内容が多く、大変濃密な1時間でした。以下は通しのLT動画です。

セッションの番号は、先週に続き視聴した順に振っています。今回動画やスライドの埋め込みは控えめにしました。

LT#20: Implementation of Web Standards in Mastodon @_furoshiki


つっつきボイス: 「マストドンって最近流行ってるようでござるな」「スライドでも説明されていますが、分散サーバー型のTwitter的マイクロメッセージのSNSで、フェデレーションという概念がある」「最初OStatusという古い標準をベースにしたGNU socialでMastodonしてたのが、バルス祭りに備えようとしたときにパフォーマンスが出ないわドキュメントもろくにないわで、検討の末ActivityPubという新しい標準に乗り換えたお話」「Web開発者やインフラエンジニアならいろいろ心当たりありそうな話でした」

LT#21: How to develop CRuby easily with Vim @ujm


つっつきボイス: 「vimでマリオ動かしてた」「VimでIDEみたいにRubyコードからc定義へのジャンプやオートコンプリートとかするプラグインを書いたというLTらしいお話でした」

上のスライドMarkdownにあったhttps://github.com/ujihisa/cruby-defs.vimはRubyKaigi中に公開するとありましたが、今見たらリンクが切れてました。数日前のリポジトリと思われる以下がありましたので参考までに。

LT#22: How to specify frozen_string_literal: true. @znz


つっつきボイス:frozen_string_literalマジックコメントを付けまくったら、IO#readでもエラーが出たそうです」「readなのにでござるか?」「IO#readのパラメータが2つあるやつだと2つ目がバッファなんですが、そこで起きてた」

LT#23: Use case of Refinements with black magic @joker1007

joker1007さんのrefinement愛が伝わってきました。


つっつきボイス: 「refinementなら特定のテストの特定の部分だけパッチを当てる、みたいなことができるんですね」「TableSyntaxのテストの書き方とかなかなかトリッキー↓」


スライドより

参考

RubyのRefinement(翻訳: 公式ドキュメントより)

LT#24: A WebSocket proxy server of Niconico comment server by Ruby @GhostBrain


つっつきボイス: 「最初ニコ生で小規模にWeb Socketでプロキシ立てたら『これニコ動でもやれるよね?』と指示が来て、ユーザー数が段違いなので引きつりながらJRubyで切り抜けた話」
「サーバーに入る人より出て行く人が多かったりという怪現象」

LT#25: Auto Completion in Rails::WebConsole @sh19910711


つっつきボイス: 「Railsのブラウザコンソールでオートコンプリートできるようにしたそうです」「ほほー」

今動画を少し見直してみると、Ruby Roguesという技術系Podcastを愛聴しているとスライドに記載されていたので貼ってみました。文字起こしもされていてなかなかよさそうなPodcastです。


devchat.tv/ruby-roguesより

LT#26: My Challenge of embedding mruby into a bare-metal hypervisor @chikuwa_IT


つっつきボイス: 「hypervisorでmrubyしようとしたらFPU(浮動小数点演算ユニット)が使えなかったりしたのでソフトウェアに置き換えたりして切り抜けた」「大昔はFPUなんてないのが当たり前だったからみんなこんなふうにソフトウェアでやってましたけどね: 8088の頃とか」

そういえば私も8080から始めたのでした。

LT#27: Glitching ruby script @shyouhei

glitchingという用語を初めて知りました。glitchは一般には「故障」「誤作動」と訳されるくだけた英語ですが、コードのある部分と別の部分をわざと取り替えるなどして、普通では出てこないようなバグをあぶり出す技法のようです。glitchingのため、ここではamerican fuzzy lopというツールを使ったそうです。


つっつきボイス: 「urabeさんがRuby trunkにときどき『どうやって見つけたのこれ?』みたいな見つけにくそうなバグをちょくちょく上げていたのは、きっとこれ使ったんですね」「アプリ開発者よりは言語やフレームワークメンテナが使いそうな」

こうしたツールをfuzzer(「毛羽立たせる」からの転用)と呼ぶそうです。私はつい楽器エフェクターのファズを連想してしまいました。

LT#28: DNN/GPU with Ruby @ainame


つっつきボイス: 「機械学習用ライブラリdlib(C++)用rubyバインディングを作成した話」「GPUを使いたくてCUDAを呼ぶためにがんばった」「RubyでGC(ガベージコレクション)してもGPU側では自動でGCされないので自分でやってくださいだそうです」「そういえば昔機械学習で遊んでたときにNVIDIAのCUDAダウンロードしたんですが、すごくでかいうえに結局Pythonにバインドできなかった…」

  • 参考: Wikipedia-ja CUDA

LT#29: Migration from hiki to markdown in Rubima @miyohide


つっつきボイス: 「Rubyist Magazineるびまの記法をhikiからmarkdownに変えた話」「昔はいろんなWiki記法が乱立しとりましたなー(今もか)」「今はGitHubのおかげで開発者にはmarkdownが定着したし、記事をもっと書いてもらうために書きやすい環境を整えたということですね」「TechRachoが昨年内部的にmarkdownに切り替えたのも記事作成促進の一環なのでわかるなー」

LT#30: Independence of mruby. @take-cheeze


つっつきボイス: 「まさに今回のオープニングキーノートでも触れられていた『RubyをコンパイルするためにRubyが必要』という部分に手を加えた話」

rakeのファイルを動かすためにminirakeをシングルバイナリ化したそうです。

LT#31: LLVM-based JIT compiler for CRuby @k0kubun


つっつきボイス: 「これはもう普通のセッションでやってもおかしくない内容」「私も同じこと思いました」

どこで見かけたか思い出せませんが、今回のRubyKaigiにはセッション枠の3倍ぐらい応募があったそうで、これを含むいくつかのLTも、もともと通常セッション用だったのかもしれません。泣く泣く絞り込んだ運営側の苦労が偲ばれます。

RubyKaigi 2017広島: セッションを振り返る: 3日目

セッション#32: Compacting GC in MRI @tenderlove


つっつきボイス:Aaron Pattersonさんのこのセッションもよかった: GitHubの中の人として、Unicornのチューニングの一環としてRubyのGC周りを改良した話」「CoW(Copy on Write)はコピーは瞬時に終わる代わりに、後で書き込むときにコピーが始まって遅くなるやつ」

そういえばiOS 11やMac OS High SierraAPFSにもファイル単位でのCoWが行われます。

「メモリはページ単位で管理されますが、実際にはページ内に使われていない領域がけっこう含まれているので、起動時にこんなふうに↓コンパクトにすればfork時にCoWでコピーされる量が明らかに減るし、Unicornの場合実はめったに書き換えは発生しないのでパフォーマンスがよくなる」「初期起動ならコンパクト処理がんばって多少遅くなっても運用上問題はないし」「Ruby側のC実装の問題などで移動できないページもあったりするけど、それでも46%までコンパクトにできたそうです」


スライドより

セッション#33 Introducing the Jet Programming Language @i2y_

並行処理向けで知られるErlang言語のBEAM VMを使って、できるだけRubyライクな言語を作る試みです。今回のRubyKaigiでは、RubexGoby、そしてこのJetとRubyライクな新言語の発表が目立ちました。


github.com/i2y/jetより

BEAM VM上で動くという点ではElixir言語と似ていますが、Erlangの特徴を採り入れつつ、よりRubyらしい記法を目指しているそうです。
その一方で、クラスを継承しないとかブロックが単なるクロージャであるなどの独自の方向性も打ち出しています。


つっつきボイス: 「スライドにGobyが映っていたので個人的にちょっとうれしかった」「Q&AでMatzがとてもうれしそうにたくさん質問してました」


スライドより

セッション#34 Ruby for Distributed Storage System @tagomoris


つっつきボイス: 「BigDamというJavaで書かれた社内の大規模分散ストレージのRuby版を自分で書いたそうです『だって書きやすいから』」

なおbigdam-pool-rubyは現時点でもまだ公開されていません。

セッション#35 Ruby Parser In IRB 20th Anniversary…Now Let Time Resume @aycabta

IRB生誕20周年にかけたRDoc内パーサーのお話です。今のRubyにはRipperという正式なパーサークラスがありますが、昔はなかったのでIRBやRDocなんかが独自のパーサーを持ってしまっていて、そのRDocのパーサーをこの方がRipperに置換えてくれたそうです。パーサーがいくつもあると、本家パーサーが更新されたときに並行してメンテしないといけなくなって相当つらいだろうと想像できました。

偶然ですが、数か月前私がGobyREPL部分を改造していたときに、最初に実装したStanさんから「参考までに、RubyのIRBは独自のパーサーを持ってるよ」と教えてもらい、見に行ったら本当にRubyで書かれた独自パーサーがありました。


つっつきボイス: 「前日のRubyKaraoke平沢進を素晴らしい美声で熱唱している方がいて個人的におおっと思ったのですが、それがこの方でした」「冒頭で登山関連の話が割と長く続いて、どうなるかと思ってドキドキしちゃいました」「こういうドキュメント寄りの作業はなかなかやってくれる人がいないだろうなと思いました: 実際このPR↓に喜びの声が続々あったそうです」

セッション#36 Pattern Matching in Ruby @yotii23


つっつきボイス: 「パターンマッチングとあったので最初正規表現の話かなと思ったんですが、Rubyに%p()というElixir的な新しいパターンマッチング記法を追加する話がどちらかというとメイン」「ワイの大好きな%wみたいなやつですな」

セッション#37 Ruby in office time reboot @gotoken


つっつきボイス: 「RubyやRedMineなどのお馴染みのツールを開発以外の業務にも役立てる話でした」

セッション#38 JRuby at 15 Years: Meeting the Challenges @headius@tom_enebo


つっつきボイス: 「昨年のRubyKaigiではJRuby系のセッションを全然見られなかったので見てみました: JRubyの歴史や概要をおさらいできた」「今年のJRuby関連セッションは昨年より少なかった印象です」

セッション#39 Writing Lint for Ruby @p_ck_


つっつきボイス: 「これは私とmorimorihogeさん両方とも見ました」
「RuboCopがコードを解析する仕組みと、自分で設定を書いてみようというお話でした: 広く使われているツールなのでQ&Aも賑わいましたね」「RuboCopもRipperは使ってないようでした」

RuboCopのメンテナの方だと途中まで気づいてなかった…

セッション#40 How to write synchronization mechanisms for Fiber @m_seki

m_sekiさんは今年もおそ松さんで攻めてます。


つっつきボイス: 「1日目のko1さんのセッションにもあったFiberのお話: 動画で丁寧に解説してくれているので動画の方がわかりやすいと思います」

Fiberやスレッドはやっぱり難しいとうなずき合いました。

週刊Railsウォッチ(20170922)特集: RubyKaigi 2017セッションを振り返る(1)、Rails 4.2.10.rc1リリースほか

セッション#41 Improving TruffleRuby’s Startup Time with the SubstrateVM @nirvdrum


つっつきボイス: 「速いと評判のTruffleRubyをチェックしたくて見てみました: JVM上で動くのでJRubyととても近い関係」「ファミコン(英語圏ではNES)エミュレータのOptcarrotもベンチマークに使ってました」

セッション#42 Towards Ruby 3×3 performance


つっつきボイス: 「GCCなどを長年手がけている方だけあって、CPUのインストラクションコードが出まくる非常に濃厚な内容: さすがの締めくくりでした」「まあこのレベルを扱える、コンパイラの中身まで知りつくした超ベテランは世界にも数えるほどしかいないでしょうな」
「ここでもOptcarrot使われてた: Microsoftが以前フライトシミュレーターでパフォーマンスチェックしてたのをちょっと思い出しました」

Rubyに今後JITが導入されるのか、されるとしたらどんな形になるのか、見守りたいところです。

閉会&来年の開催場所

あらためて、みなさまお疲れさまでした。

Rails

ここから通常運転です。

Rails 4.2.10が正式にリリース

先週お伝えしたrc版から基本変わってないようです。

Rails: 今週の改修(Rails公式ニュースより)

変更: Rails 5.2でsecrets.ymlを非推奨にしてcredentials.yml.encを導入

DHH自身の旗振りです。

The combination of config/secrets.yml, config/secrets.yml.enc, and SECRET_BASE_KEY is confusing. It’s not clear what you should be putting in these secrets and whether the SECRET_BASE_KEY is related to the setup in general.
This PR will deprecate secrets.yml* and instead adopt config/credentials.yml.enc to signify what these secrets are specifically for: Keeping API keys, database passwords, and any other integration credentials in one place.

新機能: with_attached_*スコープを導入

改修そのものは1箇所でした。

# activestorage/lib/active_storage/attached/macros.rb
+      scope :"with_attached_#{name}", -> { includes("#{name}_attachment": :blob) }

修正: ActiveRecord::Associations::Preloaderのメモ化部分

# activerecord/lib/active_record/associations/preloader/association.rb
           def key_conversion_required?
-            @key_conversion_required ||= association_key_type != owner_key_type
+            unless defined?(@key_conversion_required)
+              @key_conversion_required = (association_key_type != owner_key_type)
+            end
+
+            @key_conversion_required
           end

新機能: change_table_commentchange_column_commentがMySQLでも使えるようになった

kamipoさんのPRです。

activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb
+      def change_table_comment(table_name, comment) #:nodoc:
+        comment = "" if comment.nil?
+        execute("ALTER TABLE #{quote_table_name(table_name)} COMMENT #{quote(comment)}")
+      end
+
...
+      def change_column_comment(table_name, column_name, comment) #:nodoc:
+        column = column_for(table_name, column_name)
+        change_column table_name, column_name, column.sql_type, comment: comment
+      end
+

TechRacho記事でご紹介したこのRails 5の機能↓への追加です。

[Rails 5] マイグレーション時にデータベースのカラムにコメントを追加する

修正: ActiveStorageのFirefoxでのアップロード部分

修正: テストでPumaにデフォルト設定が効いていなかった問題

# actionpack/lib/action_dispatch/system_testing/server.rb
-          Capybara.server = :rails_puma
+          Capybara.server = :puma, { Silent: self.class.silence_puma }
         end

Rails

godmin: Rails 4以降で使える管理画面gem


つっつきボイス: 「今は管理画面系gemもたいていRails 5に対応していると思いますが、5が登場したころまともに動いたのがこのgodminぐらいしかなかった」

Railsアプリを物理的に分割(Awesome Rubyより)

ドメイン駆動開発(DDD)に関連してるようです。

「Railsで重要なデザパタ」シリーズ(Awesome Rubyより)


medium.com/selleoより


つっつきボイス: 「このサイト、これも含めていい記事が多いので翻訳しようと思います」

Railsのクラスレベルで使えるアクセサリを比較


medium.com/selleoより

これも上のサイトの別記事です。

Rails製著名サイト15

クックパッドが入っていないのは著者が英語圏の人だからでしょうか。

dpl: CI向けデプロイgem(Awesome Rubyより)

Travis-CI製ですが、さまざまなCIサイトにデプロイできるようです。


つっつきボイス: 「対応CIが多くてサポートが薄まってないといいけど」「BPSの主力リポジトリのGitLabはまだ入ってないかー」

GitLocalizeでRailsのyamlをローカライズする


gitlocalize.comより

短いのですぐ読めます。

Ruby

Ruby 2.3.5リリース(Ruby公式より)

火曜日にさらっとリリースされていました。一応記念写真。

JetBrainsによるRubyKaigiアンケート結果

日本語版もあります。

米国のRubyConfは11月にニュー・オリンズで


rubyconf.orgより

外部APIを扱うときにネーミングルールを守るには(Awesome Rubyより)


rubyblog.proより

よさそうです。

100万行のRubyコードを5秒で読み込むには(Awesome Rubyより)

30分の動画です。今年6月にニューヨークで開催されたGORUCO 2017というRubyカンファレンスのセッションだそうです。初めて知りました。


goruco.comより

目次を見るとRubyKaigi並にエッジの効いた感じのセッションが多く、Railsネタもいくつもあるようです。


つっつきボイス: 「このPolyglotってどの意味なんだろう?」「Polyglot.jsではなさそう: Single track(=セッションの部屋が分かれていない)とあるからそれとの対比で「いろんな内容を扱う」的な感じかな」「あー、それか: 多言語のことでもなさそうだし」

polyglot: {形} : 数カ国語の[に通じた・を話す・で書かれた]

Watir: Seleniumを使う自動テストgem(Awesome Rubyより)


watir.comより

ロゴからして「ウォーター」と読ませたいようです。


つっつきボイス: 「Capybaraの競争相手ですね」

Onigmoが絵文字プロパティをサポート

+* Emoji
+    Emoji
+    Emoji_Component
+    Emoji_Modifier
+    Emoji_Modifier_Base
+    Emoji_Presentation
+

つっつきボイス: 「他の言語のことはよく知らないんですが、RubyのUnicode対応ってかなり手厚いんじゃないかと思います」「だと思いますよ: あの巨大な仕様をちゃんと追っかけて実装するメンテナがいる言語なのは確か」

Ruby trunkより

提案: Rubyの正規表現にデバッグ機能を

RubyKaigiでもおなじみのマーティン先生からの提案です。
私は賛成です。

ARGVのArrayの挙動が微妙に普通のArrayと違う?→仕様どおり:却下

ARGV [0] # NoMethodError: undefined method `ARGV' for main:Object
A [0]    # NoMethodError

SQL

PostgreSQLでJSONを扱う

Dimitri Fontaine氏の記事です。よさそうです。

CSS/HTML/フロントエンド

CSS-in-JSを使わずにCSSを書く極意(Frontend Weeklyより)

これもよさそうです。CSS-in-JS(JSS)は今回初めて知りましたが、日々CSSに苦しめられているコーダーからの支持を集めつつあるようです。

その他

Adobeのブログで秘密キーが漏洩

番外

Hacker NewsのYC Researchがベーシックインカムの大規模実験を提案

何年か前に米国で小規模なベーシックインカムを無作為抽出でやってみたら、なぜか離婚率が急上昇したという記述を何かの本で見た覚えが。

コリオリの力

音商標の登録が開始


今週は以上です。

バックナンバー(2017年度)

週刊Railsウォッチ(20170922)特集: RubyKaigi 2017セッションを振り返る(1)、Rails 4.2.10.rc1リリースほか

今週の主なニュースソース

ソースの表記されていない項目は独自ルート(TwitterやRSSなど)です。

Ruby 公式ニュース

Rails公式ニュース

Awesome Ruby

RubyFlow

160928_1638_XvIP4h

Frontend Weekly

frontendweekly_banner_captured

Rails: ActiveModelSerializersでAPIを作る–Part 2 RSpec編(翻訳)

$
0
0

part1は以下をご覧ください。

Rails: ActiveModelSerializersでAPIを作る–Part 1(翻訳)

概要

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

※元記事はRails 5.0.1以降を使っています。

Rails: ActiveModelSerializersでAPIを作る–Part 2 RSpec編(翻訳)

本シリーズの前回の記事では、Rails 5でソリッドなAPIを構築する手順について解説しました。今回は、構築したAPIのテストと、RSpecで読みやすいクリーンなテストを書く方法について解説いたします。

テストはアプリにおけるきわめて重要な位置を占めています。テストがなければ、アプリのどの部分が正常に動作しているかを調べるだけでも大変になってしまい、コードのリファクタリングはもちろん、既存の機能を損なわずに新機能を追加することすら難しくなってしまいます。
十分に書かれたSpecは、システムのドキュメントにも似ています。そうしたSpecには、アプリの各部分やメソッドがどのように振る舞うかが記述されているものです。

RSpecのセットアップ

テストフレームワークには、MiniTestではなくRSpecを使います。以下のように、RSpec、FactoryGirl、ShouldaMatchersを追加してください。

source 'https://rubygems.org'

git_source(:github) do |repo_name|
  repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?("/")
  "https://github.com/#{repo_name}.git"
end

gem 'rails', '~> 5.0.1'
gem 'pg', '~> 0.18'
gem 'puma', '~> 3.0'
gem 'active_model_serializers', '~> 0.10.0'
gem 'rack-cors'
gem 'rack-attack'
gem 'will_paginate'
gem 'pundit'

group :development, :test do
  gem 'pry-rails'
  gem 'faker'
  gem 'rspec-rails', '~> 3.5'
end

group :test do
  gem 'factory_girl_rails', '~> 4.0'
  gem 'shoulda-matchers', '~> 3.1'
  gem 'simplecov', require: false
end

group :development do
  gem 'bullet'
  gem 'listen', '~> 3.0.5'
  gem 'spring'
  gem 'spring-watcher-listen', '~> 2.0.0'
end
$ bundle install
  • RSpec: RubyやRailsアプリ向けのテスティングフレームワークです。RSpecを使うと、Specをとても簡単に書けるようになります。また、アプリのフロントエンド部分をテストしたい場合は、Capybara gemを追加することもできます。CapybaraはRSpecとシームレスに統合されています(訳注: CapybaraはRails 5.1に統合されましたが、本記事のGemfileには含まれていません)。
  • FactoryGirl: テストで使うファクトリー(テスト用データ)を作成します。ここでは基本的にレコードを作成します。

  • ShouldMatchers: 関連付けやバリデーション、一部のコントローラメソッドのテストを支援します。

システムの各部分について理解できたら、RSpecをインストールして初期化しましょう。

$ rails generate rspec:install

RSpecが正常に動作することを確認します。

$ rspec

Gemsのセットアップ

今回の場合、Gemfileにgemを追加するほかに、アプリに若干の設定を追加する必要もあります。
spec/rails_helper.rbに以下を追記して、FactoryGirlを有効にします。

...

RSpec.configure do |config|
  ...

  config.include FactoryGirl::Syntax::Methods

  ...
end

以下のShouldaMatchers用の設定もspec/rails_helper.rbの末尾に追加します。

Shoulda::Matchers.configure do |config|
  config.integrate do |with|
    with.test_framework :rspec
    with.library :active_record
    with.library :active_model
  end
end

Punditのテストメソッドの追加も必要です。spec/rails_helper.rbの最上部に以下を追加します。

require 'pundit/rspec'

ファクトリーを追加する

それでは必要なファクトリーを準備しましょう。admin、user、author、book、book_copyについてそれぞれファクトリーが必要です。spec/フォルダの下にfactories/というフォルダを作成し、最初のファクトリーとしてadmin_factory.rbを追加します。

訳注: RSpecやFactoryGirlなどを事前にインストールしてからgenerateすると、必要なspecファイルやfactoryファイルも同時に作成されます。

# spec/factories/admin_factory.rb
FactoryGirl.define do
  factory :admin, class: 'User' do
    admin true
    first_name 'Piotr'
    last_name 'Jaworski'
    sequence(:email) { |i| "my-email-#{i}@mail.com" }
  end
end

見てのとおり、ファクトリーのコードはきわめてシンプルです。必要な属性をすべて列挙し、値を指定するだけで定義できます。メールの定義にはsequenceを使っていますが、これは何だかおわかりでしょうか?ファクトリーが作成されるたびに、各レコードにシーケンス値が保存されます。メールアドレスが重複すると困るので、sequenceを使ってメールアドレスが一意になるようにしています。

必要な他のファクトリーも追加してください。

# spec/factories/user_factory.rb
FactoryGirl.define do
  factory :user do
    first_name 'Dummy'
    last_name 'User'
    sequence(:email) { |i| "dummy.user-#{i}@gmail.com" }
  end
end
# spec/factories/author_factory.rb
FactoryGirl.define do
  factory :author do
    first_name 'Dummy'
    sequence(:last_name) { |i| "Author #{i}" }
  end
end
# spec/factories/book_copy.rb
FactoryGirl.define do
  factory :book_copy do
    sequence(:isbn) { |i| "0000#{i}" }
    format 'hardback'
    published Date.today - 5.years
    association(:book)
  end
end
# spec/factories/book_factory.rb
FactoryGirl.define do
  factory :book do
    association(:author)
    sequence(:title) { |i| "Book #{i}" }
  end
end

モデルSpec

これで最初のSpecを書く準備ができました。早速やってみましょう。Authorクラス内のすべての関連付け、バリデーション、メソッドをテストする必要があります。specs/modelsディレクトリを作成し、以下の内容でauthor_spec.rbファイルを作成します。

# spec/models/author_spec.rb
require 'rails_helper'

describe Author do
  subject { create(:author) }

  describe 'associations' do
    it { should have_many(:books) }
  end

  describe 'validations' do
    it { should validate_presence_of(:first_name) }
    it { should validate_presence_of(:last_name) }
  end
end

何がテストされているかおわかりでしょうか?ここでは、バリデーションと関連付けがすべて揃っているかどうかがテストされています。試しにモデルでバリデーションか関連付けをひとつコメントアウトしてみると、以下のようにテストが失敗することがわかります。

1) Author associations should have many books
Failure/Error: it { should have_many(:books) }
Expected Author to have a has_many association called books (no association called books)
# ./spec/models/author_spec.rb:7:in `block (3 levels) in <top (required)>’

次はBookCopyクラスのテストですが、関連付けやバリデーションのテストだけでは足りません。#borrowメソッドと#return_bookメソッドがあるので、これらのテストの必要です。メソッドの場合、「成功」と「失敗」の2つのシナリオをテストする必要があります。book_copy_spec.rbを書いてみましょう。

# spec/models/book_copy_spec.rb
require 'rails_helper'

describe BookCopy do
  let(:user) { create(:user) }
  let(:book_copy) { create(:book_copy) }

  describe 'associations' do
    subject { book_copy }

    it { should belong_to(:book) }
    it { should belong_to(:user) }
  end

  describe 'validations' do
    subject { book_copy }

    it { should validate_presence_of(:isbn) }
    it { should validate_presence_of(:published) }
    it { should validate_presence_of(:format) }
    it { should validate_presence_of(:book) }
  end

  describe '#borrow' do
    context 'book is not borrowed' do
      subject { book_copy.borrow(user) }

      it { is_expected.to be_truthy }
    end

    context 'book is borrowed' do
      before { book_copy.update_column(:user_id, user.id)  }

      subject { book_copy.borrow(user) }

      it { is_expected.to be_falsy }
    end
  end

  describe '#return_book' do
    context 'book is borrowed' do
      before { book_copy.update_column(:user_id, user.id)  }

      subject { book_copy.return_book(user) }

      it { is_expected.to be_truthy }
    end

    context 'book is not borrowed' do
      subject { book_copy.return_book(user) }

      it { is_expected.to be_falsy }
    end
  end
end

お気づきの方もいらっしゃるかと思いますが、私はワンライナーで書くのが好きです。私にはワンライナーの方がクリアかつ読みやすく思えます。私の場合、冒頭でletを使って変数宣言するルールにしています。その次にbeforeブロックやafterブロックを呼び出し、最後にsubjectを宣言します。このルールは私にとってメンテや編成がやりやすく、かつ読みやすいコードになります。

BookSpecはBookCopySpecとほとんど同じですが、静的なクラスメソッドのテストが必要です。

# spec/models/book_spec.rb
require 'rails_helper'

describe Book do
  let(:book) { create(:book) }

  describe 'associations' do
    subject { book }

    it { should have_many(:book_copies) }
    it { should belong_to(:author) }
  end

  describe 'validations' do
    subject { book }

    it { should validate_presence_of(:title) }
    it { should validate_presence_of(:author) }
  end

  describe '.per_page' do
    subject { described_class.per_page }

    it { is_expected.to eq(20) }
  end
end

UserSpecはこれまでと少し異なり、before_saveコールバックのテストが必要です。これを行うには、何かメソッドが呼び出されたかどうかをexpect(instance).to receive(:メソッド名)を使ってチェックします。

もうひとつ、メソッドが期待どおり動作しているかについてもテストしています。ここでは、インスタンス保存時の前と後の挙動をチェックしています。

# spec/models/user_spec.rb
require 'rails_helper'

describe User do
  let(:user) { create(:user) }

  describe 'associations' do
    subject { user }

    it { should have_many(:book_copies) }
  end

  describe 'validations' do
    subject { user }

    it { should validate_presence_of(:first_name) }
    it { should validate_presence_of(:last_name) }
    it { should validate_presence_of(:email) }
  end

  describe '#generate_api_key' do
    let(:user) { build(:user) }

    it 'is called before save' do
      expect(user).to receive(:generate_api_key)
      user.save
    end

    it 'generates random api key' do
      expect(user.api_key).to be_nil
      user.save
      expect(user.api_key).not_to be_nil
      expect(user.api_key.length).to eq(40)
    end
  end
end

コントローラSpec

もっとも重要なのはコントローラのSpecです。エンドポイント側で正常に動作することをテストしなければなりません。Specがないと、メソッドの変更やリファクタリングがとても面倒になります。

Specのもうひとつの責務は、各エンドポイントがどのように振る舞うべきかを示すことです。各エンドポイントの動作がユーザーのロールごとに異なる場合は、考えられるすべての場合をテストすべきです。ここでは、許可を得ていないユーザーが重要なデータにアクセスできないようにしたいと考えています。それではAuthorsControllerのSpecを書いてみましょう。

最初の#indexメソッドで必要なテストは、adminだけがアクセス可能になっているかどうかと、レコードが有効なJSONフォーマットで返されるかどうかです。HTTPトークンを渡すために、以下のbeforeブロックを追加します。

before { request.env['HTTP_AUTHORIZATION'] = "Token token=#{api_key}" }
# spec/controllers/authors_controller_spec.rb
require 'rails_helper'

describe V1::AuthorsController do
  let(:admin) { create(:admin) }
  let(:user) { create(:user) }
  let(:author) { create(:author) }

  before { request.env['HTTP_AUTHORIZATION'] = "Token token=#{api_key}" }

  describe '#index' do
    subject { get :index }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      before { author }

      it { is_expected.to be_successful }
      it 'returns valid JSON' do
        body = JSON.parse(subject.body)
        expect(body['authors'].length).to eq(1)
        expect(body['meta']['pagination']).to be_present
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end
end

次は#showメソッドのテストです。これも一般ユーザーからアクセスできない(adminのみアクセス可能)ようになっている必要があります。シリアライザで指定された属性がJSONで返される必要もあります。

# spec/controllers/authors_controller_spec.rb
...

  describe '#show' do
    subject { get :show, params: { id: author.id } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it { is_expected.to be_successful }

      it 'returns valid JSON' do
        subject
        expect(response.body).to eq({ author: AuthorSerializer.new(author).attributes }.to_json)
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

...

#createメソッドも同様に、adminだけがアクセス可能です。バリデーションが正常に動作することを確認するために、パラメータが有効な場合と無効な場合の両方のシナリオをテストする必要もあります。

# spec/controllers/authors_controller_spec.rb
...

  describe '#create' do
    let(:author_params) { { first_name: 'First name' } }

    subject { post :create, params: { author: author_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:author_params) { { first_name: 'First name', last_name: 'Last name' } }

        it { is_expected.to be_created }

        it 'creates an author' do
          expect { subject }.to change(Author, :count).by(1)
        end
      end

      context 'with invalid params' do
        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

...

#updateメソッドのフローは、#createメソッドとほとんど同じです。

# spec/controllers/authors_controller_spec.rb
...

  describe '#update' do
    let(:author_params) { {} }

    subject { put :update, params: { id: author.id, author: author_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:author_params) { { first_name: 'Foo' } }

        it 'updates requested record' do
          subject
          expect(author.reload.first_name).to eq(author_params[:first_name])
          expect(response.body).to eq({ author: AuthorSerializer.new(author.reload).attributes }.to_json)
        end

        it { is_expected.to be_successful }
      end

      context 'with invalid params' do
        let(:author_params) { { first_name: nil } }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

...

#destroyメソッドの場合、ユーザーがadminの場合のみレコードを削除できます。

繰り返しになりますが、私はワンライナーで各エンドポイントのHTTPステータスをテストしています。たとえば次のような感じです。

it { is_expected.to be_no_content }
it { is_expected.to be_unauthorized }

しかし、メソッドのHTTPステータスによっては(HTTP 422など)、テストをこの方法では書けないこともあります。もし次のように書ける方法があれば私が知りたいぐらいですが、まだ見たことがありません。

it { is_expected.to be_unprocessable_entity }

このような場合は、次のようにHTTPステータス名を直接渡す必要があります。

it { is_expected.to have_http_status(:unprocessable_entity) }

AuthorControllerのSpec全体は次のようになります。チュートリアルの途中でわからなくなったら、こちらをどうぞ。

# spec/controllers/authors_controller_spec.rb
require 'rails_helper'

describe V1::AuthorsController do
  let(:admin) { create(:admin) }
  let(:user) { create(:user) }
  let(:author) { create(:author) }

  before { request.env['HTTP_AUTHORIZATION'] = "Token token=#{api_key}" }

  describe '#index' do
    subject { get :index }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      before { author }

      it { is_expected.to be_successful }
      it 'returns valid JSON' do
        body = JSON.parse(subject.body)
        expect(body['authors'].length).to eq(1)
        expect(body['meta']['pagination']).to be_present
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#show' do
    subject { get :show, params: { id: author.id } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it { is_expected.to be_successful }

      it 'returns valid JSON' do
        subject
        expect(response.body).to eq({ author: AuthorSerializer.new(author).attributes }.to_json)
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#create' do
    let(:author_params) { { first_name: 'First name' } }

    subject { post :create, params: { author: author_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:author_params) { { first_name: 'First name', last_name: 'Last name' } }

        it { is_expected.to be_created }

        it 'creates an author' do
          expect { subject }.to change(Author, :count).by(1)
        end
      end

      context 'with invalid params' do
        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#update' do
    let(:author_params) { {} }

    subject { put :update, params: { id: author.id, author: author_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:author_params) { { first_name: 'Foo' } }

        it 'updates requested record' do
          subject
          expect(author.reload.first_name).to eq(author_params[:first_name])
          expect(response.body).to eq({ author: AuthorSerializer.new(author.reload).attributes }.to_json)
        end

        it { is_expected.to be_successful }
      end

      context 'with invalid params' do
        let(:author_params) { { first_name: nil } }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#destroy' do
    subject { delete :destroy, params: { id: author.id } }

    before { author }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it 'removes requested record' do
        expect { subject }.to change(Author, :count).by(-1)
      end

      it { is_expected.to be_no_content }
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end
end

BookCopiesController、BooksController、UsersControllerはAuthorsControllerとほとんど同じです。チュートリアルでの説明は省略いたしますが、それぞれのファイルは次のとおりです。

  • BookCopiesControllerSpec:
# spec/controllers/book_copies_controller_spec.rb
require 'rails_helper'

describe V1::BookCopiesController do
  let(:admin) { create(:admin) }
  let(:user) { create(:user) }
  let(:book_copy) { create(:book_copy) }
  let(:book) { create(:book) }

  before { request.env['HTTP_AUTHORIZATION'] = "Token token=#{api_key}" }

  describe '#index' do
    subject { get :index }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      before { book_copy }

      it { is_expected.to be_successful }
      it 'returns valid JSON' do
        body = JSON.parse(subject.body)
        expect(body['book_copies'].length).to eq(1)
        expect(body['meta']['pagination']).to be_present
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#show' do
    subject { get :show, params: { id: book_copy.id } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it { is_expected.to be_successful }

      it 'returns valid JSON' do
        subject
        expect(response.body).to eq({ book_copy: BookCopySerializer.new(book_copy).attributes }.to_json)
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#create' do
    let(:book_copy_params) { { isbn: '00001' } }

    subject { post :create, params: { book_copy: book_copy_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:book_copy_params) { { isbn: '00001', published: Date.today, book_id: book.id, format: 'hardback' } }

        it { is_expected.to be_created }

        it 'creates an book_copy' do
          expect { subject }.to change(BookCopy, :count).by(1)
        end
      end

      context 'with invalid params' do
        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#update' do
    let(:book_copy_params) { {} }

    subject { put :update, params: { id: book_copy.id, book_copy: book_copy_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:book_copy_params) { { isbn: '0000033' } }

        it 'updates requested record' do
          subject
          expect(book_copy.reload.isbn).to eq(book_copy_params[:isbn])
          expect(response.body).to eq({ book_copy: BookCopySerializer.new(book_copy.reload).attributes }.to_json)
        end

        it { is_expected.to be_successful }
      end

      context 'with invalid params' do
        let(:book_copy_params) { { isbn: nil } }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#destroy' do
    subject { delete :destroy, params: { id: book_copy.id } }

    before { book_copy }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it 'removes requested record' do
        expect { subject }.to change(BookCopy, :count).by(-1)
      end

      it { is_expected.to be_no_content }
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end
end
  • BooksControllerSpec:
# spec/controllers/books_controller_spec.rb
require 'rails_helper'

describe V1::BooksController do
  let(:admin) { create(:admin) }
  let(:user) { create(:user) }
  let(:book) { create(:book) }
  let(:author) { create(:author) }

  before { request.env['HTTP_AUTHORIZATION'] = "Token token=#{api_key}" }

  describe '#index' do
    subject { get :index }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      before { book }

      it { is_expected.to be_successful }
      it 'returns valid JSON' do
        body = JSON.parse(subject.body)
        expect(body['books'].length).to eq(1)
        expect(body['meta']['pagination']).to be_present
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#show' do
    subject { get :show, params: { id: book.id } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it { is_expected.to be_successful }

      it 'returns valid JSON' do
        subject
        expect(response.body).to eq({ book: BookSerializer.new(book).attributes }.to_json)
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#create' do
    let(:book_params) { { title: nil } }

    subject { post :create, params: { book: book_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:book_params) { { title: 'Title', author_id: author.id } }

        it { is_expected.to be_created }

        it 'creates a book' do
          expect { subject }.to change(Book, :count).by(1)
        end
      end

      context 'with invalid params' do
        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#update' do
    let(:book_params) { {} }

    subject { put :update, params: { id: book.id, book: book_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:book_params) { { title: 'Title' } }

        it 'updates requested record' do
          subject
          expect(book.reload.title).to eq(book_params[:title])
          expect(response.body).to eq({ book: BookSerializer.new(book.reload).attributes }.to_json)
        end

        it { is_expected.to be_successful }
      end

      context 'with invalid params' do
        let(:book_params) { { title: nil } }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#destroy' do
    subject { delete :destroy, params: { id: book.id } }

    before { book }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it 'removes requested record' do
        expect { subject }.to change(Book, :count).by(-1)
      end

      it { is_expected.to be_no_content }
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end
end
  • UsersControllerSpec:
# spec/controllers/users_controller_spec.rb
require 'rails_helper'

describe V1::UsersController do
  let(:admin) { create(:admin) }
  let(:user) { create(:user) }

  before { request.env['HTTP_AUTHORIZATION'] = "Token token=#{api_key}" }

  describe '#index' do
    subject { get :index }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      before { user }

      it { is_expected.to be_successful }
      it 'returns valid JSON' do
        body = JSON.parse(subject.body)
        expect(body['users'].length).to eq(2)
        expect(body['meta']['pagination']).to be_present
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#show' do
    subject { get :show, params: { id: user.id } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it { is_expected.to be_successful }

      it 'returns valid JSON' do
        subject
        expect(response.body).to eq({ user: UserSerializer.new(user).attributes }.to_json)
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#create' do
    let(:user_params) { { first_name: nil } }

    subject { post :create, params: { user: user_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:user_params) { { first_name: 'Name', last_name: 'Last', email: 'foo@bar.com' } }

        it { is_expected.to be_created }

        it 'creates a user' do
          expect { subject }.to change(User, :count).by(1)
        end
      end

      context 'with invalid params' do
        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#update' do
    let(:user_params) { {} }

    subject { put :update, params: { id: user.id, user: user_params } }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'with valid params' do
        let(:user_params) { { last_name: 'Last' } }

        it 'updates requested record' do
          subject
          expect(user.reload.last_name).to eq(user_params[:last_name])
          expect(response.body).to eq({ user: UserSerializer.new(user.reload).attributes }.to_json)
        end

        it { is_expected.to be_successful }
      end

      context 'with invalid params' do
        let(:user_params) { { first_name: nil } }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end

  describe '#destroy' do
    subject { delete :destroy, params: { id: user.id } }

    before { user }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      it 'removes requested record' do
        expect { subject }.to change(User, :count).by(-1)
      end

      it { is_expected.to be_no_content }
    end

    context 'as user' do
      let(:api_key) { user.api_key }

      it { is_expected.to be_unauthorized }
    end
  end
end

続いて、ユーザーとadminが両方ともアクセス可能なメソッドに取りかかりたいと思います。BookCopiesControllerの#borrow#return_bookメソッドです。

ここではテストケースがたくさんあります。最初に#borrowメソッドの動作を確認しましょう。adminは、user_idパラメータを渡すことで本を借りられます。このパラメータがない場合、adminは本を借りられません。本が貸出中の場合、book_copyは借りられません。一般ユーザーの場合、本が貸出中でなければ本を借りられます。いたってシンプルです。

次は#return_bookメソッドです。adminは、user_idパラメータがある場合のみ本を返却できます。adminの特権として、adminのuser_idは、book_copyのuser_idと一致する必要はありません。貸出中でない本はもちろん返却できません。

一般ユーザーは、自分で借りた本だけを返却できます。自分が借りていない本は返却できません。

# spec/controllers/book_copies_controller_spec.rb
require 'rails_helper'

describe V1::BookCopiesController do
  ...

  describe '#borrow' do
    subject { put :borrow, params: book_copy_params }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'without user_id param' do
        let(:book_copy_params) { { id: book_copy.id } }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end

      context 'with user_id param' do
        let(:book_copy_params) { { id: book_copy.id, user_id: user.id } }

        context 'book is not borrowed' do
          it { is_expected.to be_successful }
        end

        context 'book is borrowed' do
          before { book_copy.update_column(:user_id, user.id) }

          it { is_expected.to have_http_status(:unprocessable_entity) }
        end
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }
      let(:book_copy_params) { { id: book_copy.id } }

      context 'book is not borrowed' do
        it { is_expected.to be_successful }
      end

      context 'book is borrowed' do
        before { book_copy.update_column(:user_id, admin.id) }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end
    end
  end

  describe '#return_book' do
    subject { put :return_book, params: book_copy_params }

    context 'as admin' do
      let(:api_key) { admin.api_key }

      context 'without user_id param' do
        let(:book_copy_params) { { id: book_copy.id } }

        it { is_expected.to have_http_status(:unprocessable_entity) }
      end

      context 'with user_id param' do
        let(:book_copy_params) { { id: book_copy.id, user_id: user.id } }

        context 'book is not borrowed' do
          it { is_expected.to have_http_status(:unprocessable_entity) }
        end

        context 'book is borrowed' do
          context 'user_id matches to a book_copy user_id' do
            before { book_copy.update_column(:user_id, user.id) }

            it { is_expected.to be_successful }
          end

          context 'user_id does not match to a book_copy user_id' do
            let(:another_user) { create(:user) }

            before { book_copy.update_column(:user_id, another_user.id) }

            it { is_expected.to be_successful }
          end
        end
      end
    end

    context 'as user' do
      let(:api_key) { user.api_key }
      let(:book_copy_params) { { id: book_copy.id } }

      context 'book is borrowed' do
        context 'current user is a user who borrowed a book' do
          before { book_copy.update_column(:user_id, user.id) }

          it { is_expected.to be_successful }
        end

        context 'current user is not a user who borrowed a book' do
          let(:another_user) { create(:user) }

          before { book_copy.update_column(:user_id, another_user.id) }

          it { is_expected.to be_forbidden }
        end
      end

      context 'book is not borrowed' do
        it { is_expected.to be_forbidden }
      end
    end
  end

PolicySpec

ポリシーはアプリの重要な部分なので、ポリシーについてもテストが必要です。なお、このアプリにはポリシーはひとつしかありません。Specを書いてみましょう。specs/policiesフォルダを作成し、book_copy_policy_spec.rbに以下の内容を追加します。

# spec/policies/book_copy_policy_spec.rb
require 'rails_helper'

describe BookCopyPolicy do
  let(:user) { create(:user) }

  subject { described_class }

  permissions :return_book? do
    context 'as admin' do
      it 'grants access if user is an admin' do
        expect(subject).to permit(Contexts::UserContext.new(nil, User.new(admin: true)), BookCopy.new)
      end
    end

    context 'as user' do
      it 'denies access if book_copy is not borrowed' do
        expect(subject).not_to permit(Contexts::UserContext.new(User.new, nil), BookCopy.new)
      end

      it 'grants access if book_copy is borrowed by a user' do
        expect(subject).to permit(Contexts::UserContext.new(user, nil), BookCopy.new(user: user))
      end
    end
  end
end

上のコードでは、adminと一般ユーザーの両方について#return_book?をテストしていますが、どちらのテストも必要です。Punditには、こうしたテストをRSpecでシンプルに書くのに便利なメソッドが用意されています。

テストのカバレッジ

テストでは、カバレッジも重要です。カバレッジはテストされたコードの行数を表示したり、テストでのカバーが必要な最小限の行数を表示したりします。SimpleCov gemはこうしたレポートを詳細に作成するのに便利で、カバレッジ率も出力してくれます。

Gemファイルのtestグループに以下を追加してSimpleCovをインストールします。

gem 'simplecov', require: false
$ bundle install

SimpleCovを使うために、rails_helper.rbの最上部に以下を追記します。

require 'simplecov'
SimpleCov.start

これで、テストを実行するとSimpleCovからレポートが出力されます。

$ rspec

テストのカバレッジ率は96.5%になりました。素晴らしい結果です。coverage/index.htmlファイルをブラウザで開けば詳細なレポートを見ることができます。Specでカバーされている各ファイルのカバレッジが表示されます。

最後に

第2回では、API向けにクリアで読みやすいテストの書き方を解説しました。アプリではテストが重要であることをぜひ肝に銘じてください。よいアプリには、アプリの各部分の挙動を説明するSpecが必ずあるものです。

本チュートリアルを皆さまが気に入ってくれれば幸いです。ソースコードはこちらで参照できます。

私たちのブログがお気に召しましたら、ぜひニュースレターを購読して今後の記事の更新情報をお受け取りください。質問がありましたら、元記事にいつでもご自由にコメントいただけます。

関連記事

Rails: ActiveModelSerializersでAPIを作る–Part 1(翻訳)

RSpecで役に立ちそうないくつかのヒント(翻訳)

テストを不安定にする5つの残念な書き方(翻訳)

[Rails 5] rails newで常に使いたい厳選・定番gemリスト(2017年版)

週刊Railsウォッチ(20171006)PostgreSQL 10ついにリリース、Capybaraコードを実画面から生成するnezumiほか

$
0
0

こんにちは、hachi8833です。暑がりさんなので今日も扇風機にあたっています。

それでは10月最初のウォッチ、いってみましょう。なお「Rails: 今週の改修」は本家で更新がありませんでした。

号外: PostgreSQL 10が正式リリース

9月後半にPostgreSQL 10 RC1がリリースされていたので記事にしようと思っていましたが、昨晩正式版がリリースされたので急遽差し替えました。

記念写真を取るためにとりあえずインストールして動かしてみました。MacのHomebrewでインストールしていたPostgreSQL 9をbrew services stop postgresqlでいったん止め、BigSQLのパッケージマネージャでインストールしました。このパッケージマネージャは関連ファイルを1つのフォルダにまとめてくれるので散らからないところが便利でした。

ちょうど11/3文化の日にPostgreSQL Conference Japan 2017が開催されるので、そちらも盛り上がることでしょう。


つっつきボイス: 「PostgreSQL 10の導入は、Rails(特にActiveRecord)でひととおりバグが取れるまで様子見かな」

Ruby trunkより

Tempfile.open()の挙動がおかしい=>バッファならそうなる(却下)

>> Tempfile.open() { |f| (1..10000).each { |i| f.puts i }; `tail -n1 #{f.path}` }
=> "8416"
>> Tempfile.open() { |f| (1..100000).each { |i| f.puts i }; `tail -n1 #{f.path}` }
=> "98835"
>> Tempfile.open() { |f| f.puts "Test\n"; `tail -n1 #{f.path}` }
=> ""

つっつきボイス:(1..10000).eachを回してtail -n1でラス1行だけ取ろうとしたのか: f.puts iでバッファされるから取れる保証がない」「パッチモンスターことnobuさんがツッコんでたバッファってそこなのか」

Ruby

Rubyのヒープをビジュアル表示(Ruby Weeklyより)


tenderlovemaking.comより

RubyとRailsを両方手がけるAaron Pattersonさんの記事です。つい最近日本語ツイートがバズって外部でも有名になりましたね。

# 同記事より
require 'objspace'

x = 100000.times.map { Object.new }
GC.start
File.open('heap.json', 'w') { |f|
  ObjectSpace.dump_all(output: f)
}

つっつきボイス: 「おー、これはこの間のRubyKaigi 2017のスピーチ「Compacting GC in MRI」で使ったテクニックを解説している感じか」「GC(ガベージコレクション)も出てくる」

週刊Railsウォッチ(20170929)特集: RubyKaigi 2017セッションを振り返る(2)Ruby 2.3.5リリースほか

[インタビュー] Aaron Patterson(前編): GitHubとRails、日本語学習、バーベキュー(翻訳)

ガイジン向けRubyKaigiガイド(Ruby Weeklyより)


schneems.comより

とても面白い読み物です。日本人開発者はあまりガイジンに話しかけてくれないということに気づくのに時間がかかったそうです。(゚⊇゚)ガイジソー


つっつきボイス: 「これは翻訳するとよさそう」「はいよろこんでー

来年のRubyKaigiあたりでどなたか試していただきたいのですが、ガイジンの皆さんにコンビニの肉まん食べさせてみたらきっと半分以上の人がハマると思います。次回は5月なのでコンビニにないかな。

著者のRichard Schneemanさんのプロフィールに「2016年度のRuby Hero」とあったので、つっつきメンバーで探してみました。


つっつきボイス: 「Ruby Heroって初めて聞いた」「↓おーこれか」「Code Schoolがスポンサー」


rubyheroes.comより

ヒーローリストにamatsudaさんやko1さんやいろんな人いる」「どうやって選出しているでござるか?」「nominateってリンクがあるから他薦ですね」「く、このリンク効かない…」「選出の時期が来たらフォームが開くんでしょうね」「米国のRailsConferenceで発表されるんですね: 以前のウォッチ↓でも紹介しましたが、今年の開催は4月でした」

週刊Railsウォッチ(20170421)RailsConfが来週アリゾナで開催、コントローラを宣言的に書けるdecent_exposure gemほか

OptCarrotはCPUベンチマークにいいぞ


engineering.appfolio.comより

表示がわかりにくいのですが、RubyKaigi 2017でHow Close is Ruby 3×3 For Production Web Apps?をスピーチしたNoah Gibbsさんの記事でした。TechRachoの翻訳記事でもお馴染みかと思います。

米国から見た日本のRuby事情(翻訳)


つっつきボイス:OptCarrotはRubyで書かれた任天堂のファミコンのエミュレータです」「英語圏だとNES(Nintendo Entertainment System)って呼ばれているのは知ってたんですが、NESの写真みたのはこの記事が初めてでした: ファミコンと相当見た目違う」

「RubyKaigiやいろんなところで、RubyのベンチマークにOptCarrot使ってるのを見かけるので、これを標準原器的に使うという暗黙のコンセンサスがあるのかなと思ってました」「まあ、OptCarrotで好結果を出したとしてもそれによってたとえばRailsが速くなるかというと、ボクはとっても疑問ですけどねw」

「なお言語の処理そのもののパフォーマンスをチェックするときは、ディスクアクセスやネットワークアクセスみたいな二次的な要素はできるだけプログラムから排除しないと、言語のパフォーマンスチェックのはずがネットアクセスのチェックになったりしかねないんですよ」「あー、なるほど: こういうエミュレータならサイズも小さいし継続的に適度な負荷もかかるしネットアクセスもないし」「見てて楽しいし」「この記事でもまさにそういう話してるみたいですね」

Mac OS High SierraのforkでPumaなどに問題が生じる(Ruby Weeklyより)

# エラーメッセージ
objc[81924]: +[__NSPlaceholderDictionary initialize] may have been in progress in another thread when fork() was called.
objc[81924]: +[__NSPlaceholderDictionary initialize] may have been in progress in another thread when fork() was called. We cannot safely call it or ignore it in the fork() child process. Crashing instead. Set a breakpoint on objc_initializeAfterForkError to debug.

つっつきボイス: 「よく見つけたなーこれ: High Sierraまだ入れてないけど」「私も」「ワイもまだ」「Puma以外にも影響出そうだし、そもそもfork()の挙動を変えたとなるともっと影響範囲でかいんではなかろうか」

同issueで参照されているObjective-C and fork() in macOS 10.13には以下の記述があります。

As of macOS 10.13, the Objective-C runtime now supports use between fork() and exec() in applications built with the 10.13 SDK. There are restrictions involving +initialize methods. Previously incorrect code may now be correct, or it may fail consistently due to +initialize behavior.
Note that the Objective-C classes defined by the OS frameworks remain fork-unsafe. As a first approximation it is still incorrect to do anything between fork() and exec().

ページを失念してしまいましたが、このあたりの記事のリンクを追っているうちにFFIという言葉が出てきました。

「FFIって何でしたっけ?」「Foreign function interfaceですね」
ちょうどつっつき会を覗いていたゲストの方から教わりました。ありがとうございます。

RubyのHashインスタンスの奇妙な動作(Ruby Weeklyより)


kate.ioより

# 同記事より#1
class Integer
   def eql?(other)
     true
   end

  def hash
     0
  end
end

table = {}
table[1] = 'one'
table[5] = 'five'
puts table
#=> {1=>"one", 5=>"five"}

つっつきボイス: 「#1のdef hashみたいなことは普通まずしないなー」「#2のキー複製もしないでござる」「そもそもHashのキーを書き換えたいことってあるんだろうか: Hashの実装をhackして遊ぶって感じの記事ですね」
「そういえば以前RubyのHashがらみの記事書いたの思い出した: これこれ↓」「Fixnum, Symbol, String#hashはCの組み込みで、オーバーライドできないと公式ドキュメントに書かれていたんですよ」

RubyにおけるHashの実装を詳しく:(前編)#hashと#eql?とActiveSupport::HashWithIndifferentAccess

Object#tryは有害である(Ruby Weeklyより)

「Considered Harmful」という釣りタイトルっぽいですが、これも良記事です。Object#tryはRailsのActiveSupportにあります。
なお「Do. Or Do Not. There Is No Try」は例によってヨーダのセリフをそのまま使っています。


つっつきボイス: 「まあわかる、ボクも#tryキライだし: でも使いたいときってあるよね」
「Railsのビューなんかで#tryをチェインしまくったりすると後の人がどんどんツラくなっていく: 見たことあるでしょ?」

以下の記事によると#try&.と書くこともでき、少し挙動が異なるそうです。

dry-rb: 次世代Rubyライブラリgem群

日本語情報はあまり見かけませんが、英語圏では人気上昇中のようです。

RubyKaigi 2017でも発表した@shioyamaさんの記事「The Ruby Module Builder Pattern」によると、このdry-rbシリーズでもModule Builderパターンが大々的に使われているそうです。


つっつきボイス: 「悪くなさそう: 使えそうなものあったら使ってみてもいいかな」「RailsとかではなくRubyでプログラム書きたい人向けっぽいですね」
dry-typesのサンプル↓見てると、active_attrとかvirtusを思い出す」

# dry-rb.orgより
class Person < Dry::Struct
  attribute :name, Types::Strict::String
  attribute :age, Types::Strict::Int
end

Person.new(name: "Jane", age: 30)
# => #<Person name="Jane" age=30>

active_attrやvirtusについては以下の記事もどうぞ。

Railsフレームワークで多用される「options = {} 」引数は軽々しく真似しない方がいいという話

Codebeat: コードの自動レビューサービス


codebeat.coより

リポジトリにあるコードを自動レビューしてくれるようです。GitHubやBitBucketあたりは当然として、GitLabも対応しているのがうれしいです。今のところ以下の言語をサポートしています。


codebeat.coより


つっつきボイス: 「お、価格見るとpublicなリポジトリなら無料か: 自分が個人的に何か書くときに使ってみようかな」「ライバルはCodeClimateでしょうね↓」


codeclimate.comより

「関係ないんですが、このCodeClimateという名前、とてもいいと思ってます: プロジェクトに晴れの日もあれば雨の日もあるみたいな実感がこもってて」「一雨来そうだったり、荒れ模様だったり」「名前大事でござる」

Copy-on-Writeの限界: Rubyのメモリ割り当て(Roobykon Ruby Digestより)


brandur.orgより


つっつきボイス: 「これもいい記事: こんなに丁寧にソースと対応して説明してくれてる記事は割とレアかも、特に新しいRubyでというところが」「CRubyのソースを読みたい人向け」
「記事出てくるEdenって面白い名前ですね」「オブジェクトの置かれるヒープのことですね: 探し求めてエデンの園にたどりついた、みたいな意味がこもってるのかな」

「そういえばRubyのsymbolってGCされないんでしたっけ?」「いや、GCはされるけど、メモリアドレスと一体化してるから移動ができない: :hoge.hashとかやってみるとアドレスを表示できます↓」「あと、Cの方でmalloc()したものなんかも移動できない」

[1] pry(main)> :hoge.hash
=> 2920655367118037262

Bundler 2.0の互換性問題


つっつきボイス: 「Bundler 2.0で非互換の変更がいくつかあるみたいです」

「semantic versioningといえば、表面上そうなってても運用が残念なgemってありますね」「たとえばtherubyracerは使うライブラリの参照方法が偶数バージョンと奇数バージョンで違ってたりしてて知らずにアップデートするとドハマリするとか」

Ruby 10年以上やってるけどPythonコードを5倍速くしてみた


schneems.comより

Total time: 1.17439 s
File: perf.py
Function: get_legal_moves_fast at line 53

Total time: 5.80368 s
File: perf.py
Function: get_legal_moves_slow at line 69

つっつきボイス: 「RubyとかPythonというよりリファクタリングが中心かな」

Rails

⭐nezumi: RSpecやCapybaraのテストコードを実画面から生成するChrome拡張(GitHub Trendingより)⭐


つっつきボイス: 「これはとてもよい: 動画を見ればひと目で仕組みわかるし」「おお、モーションまでその場でCapybaraちゃんのコードになってくれる: これならCapybaraで画面テスト書く気になれそう」「即インストールしました」「テスターにもうれしいと思う」
「ただこれで生成したテストコードはコードレビューで直に読むのつらそうなんで、一言『nezumiで生成しました』とどこかに書いておいて欲しいですね」
「それにしてもなぜnezumi?」「capybaraがげっ歯類だからじゃないですかね: 日本人にはもうちょっと清潔感のある名前がよかったかな」

今週の⭐を進呈いたします。おめでとうございます。

GitHubはいかにしてapi.github.comでKubernetesを導入したか

8月のGitHubブログ記事です。


githubengineering.comより


つっつきボイス: 「おー、GitHubもKubernetesしてるのか: 大規模にDockerコンテナ使うとこなら使いたいやつだと思う。Googleが使っている実績があるし」「Kubernetesは設定UIをもうちょっと頑張って欲しいけど」

RailsのCSRF保護を深掘りする


つっつきボイス: 「フレームワーク使ってればCSRFチェックを自力で実装する必要は今どきなくなってきたので、嗜みとして知っておくのにいい内容だと思います」「以前のRailsはCSRFトークンを使いまわしてましたが、セキュリティ上の理由でしなくなった」

Railsのsecret_key_baseを理解する


つっつきボイス: 「こういうsalt生成とかジェネレータ周りのコードは、連番にならず重複もしないコードを書きたいときに便利なので、知っておくといいですよ」
「ただし単独システムならそれでいいけど、Twitterのような大規模に分散したシステムでユーザーIDをユニークにしたい、みたいな用途ならsnowflakeあたりを使うのがメジャーな方法かな」

モスクワで9/23に開催された「RailsClub 2017」

RAILSCLUB 2016 from Evrone.com on Vimeo.

プログラムがロシア語版の方にしか見当たらないので、機械翻訳に手伝ってもらってタイトルだけ取り出してみました。今回のセッション動画はまだ見当たりません。

  • スロット1
    • スレッドは悪ではない
    • Rom-rb 4.0
    • テストも速くなければならない
    • 型の問題を解決する
    • Ruby Masteryへの長い旅
    • Rubyを使った関数プログラミングウェブ
    • Ruby Is Dead
    • バックグラウンドタスクのためのフレームワークのアーキテクチャ
    • アプリケーションを設定する正しい方法
    • エコシステムの構築に必要なもの
  • スロット2
    • Elixir: Rubyではなく、Rubyよりよい
    • Kubernetesクラスタ内のRubyアプリケーション
    • Trailblazerでレガシーコードを取り除く
    • 言語相対性ルビー(「Rubyのここが好きでない」みたいな話?)
    • RubyのVM
    • 同期パラダイムと非同期パラダイムの話

つっつきボイス: 「ロシアとか東欧ってRubyやRailsが盛んという印象ですね」「そういえばDHHもあのあたりの出身だったような気が」

今ぐぐってみたらデンマークでした。

SQL/データベース

MySQL 8.0はUnicode 9.0サポート/スキーマレスJSONなどを追加(DB Weeklyより)

MySQL 8.0.3 rcもリリースされたそうです。


つっつきボイス: 「AWSのAuroraはどうなるかなー: 元になってるMySQLのバージョンが割りと古いけど、Auroraはがっつり本気で作られてるから追従してくれそうな気がする」

100万レコードのテーブルを効果的にページネーション(DB Weeklyより)


つっつきボイス: 「100万レコードはたいていつらいでござる」「記事はMySQLベースですね」
「こういう部分はRDBMSによって変わるところがあるからなー」

PGAudit: PostgreSQLの監査ツール(Postgres Weeklyより)

pghero: PostgreSQLのパフォーマンスチェック用gem

ankaneさんの作です。


github.com/ankane/pgheroより


つっつきボイス: 「簡易パフォーマンス監視に便利そうに見えたので: explainとかもできるし」「NewRelicがあれば足りるかなと思うけど、開発者とは別にDB管理者がいる案件とか、インフラエンジニアがミドルウェアチューニングまでやる場合なんかは事前にインストールしておくといいかも」


newrelic.comより

PostgreSQLでカラムを取り替える: part 1(Postgres Weeklyより)

# 同記事より
update pages
set ordering = case name
                 when 'Red' then (select ordering from pages where name = 'Blue')
                 when 'Blue' then (select ordering from pages where name = 'Red')
               end
where name = 'Red' or name = 'Blue';

つっつきボイス: 「カラムの順序を取り替えるのかと思ったら、カラム内の値を取り替える方でござった」「業務でこういう作業はあまりないと思うけど」

JavaScript

Draggable.js: Shopifyのドラッグ&ドロップライブラリ(HackerNewsより)


shopify.github.io/draggableより

アートワークと色彩がとてもきれいです。サイト自体が楽しいデモになっていて、図形をドラッグ&ドロップで移動したり移動先の色に同化したりします。


つっつきボイス: 「いつもならjQuery.DroppablejQuery.Draggable使うけど、気が向いたら使ってみようかな」「衝突判定もしてるぞな」

CSS/HTML/フロントエンド

HTML 5.1の2nd EditionがRECに

whatwgの仕様との齟齬が取沙汰されて久しいですが、一応。

参考: W3Cのは『欠陥フォーク』!? HTMLスナップショット2016 ── HTML5 Conference 2016セッションレポート

iPhone用ARKitでダンス練習アプリ(JavaScript Liveより)


つっつきボイス: 「これって踊りながらスマホ見られないんじゃ?」

CSS Gridを理解してブロックを自在に操る(Frontend Focusより)

See the Pen 5. 12 column grid with grid layout by rachelandrew (@rachelandrew) on CodePen.

Go言語

Go 1.8.4と1.9.1がリリース: セキュリティ修正

matcha: iOSとAndroidの両方で動くモバイルアプリをGoとReact.jsで書くフレームワーク(GitHub Trendingより)


gomatcha.ioより

今のところMac OSでないとビルドできないそうです。

その他

「この世界はコンピュータシミュレーションではない」らしい

本質的に無理と証明したのかなと思ったら、宇宙全体をメモリーにしても追いつかないぐらい計算が爆発するからそんなシミュレーション無理、という量的な論拠っぽいです。

日本語解説記事: 「我々はコンピューターのシミュレーションの中で生きているわけではない」と研究者

teachable-machine: 機械学習の様子をビジュアル表示/コーディング不要(GitHub Trendingより)


teachablemachine.withgoogle.comより

Googleがやってるそうです。なお、サイトでカメラとマイクをオンにすると自分の顔のキャプチャが始まって素材にされます。後で遊んでみます。

Ruby技術者認定試験は海外にも進出している

どうやらこれのようです↓。

番外

ノーベル物理学賞は重力波の検出、化学賞は原子レベルの生体物質顕微鏡

参考: LIGO、3度目の重力波検出

定番ネタ


つっつきボイス: 「わかるー」「そういえばちょうどこんな記事↓見つけたんだけど、MR出す人は最低でも1.の『変更の目的を書く』と3.の『変更によって期待される結果を書く』はぜひぜひやって欲しいですね: こういう部分はコードだけ読んでもわからないので」
「あとスクショもですね」「Slackや他のところに貼ったスクショをもう一度貼っても全然構わないし、むしろそうして欲しい: 探しに行くの大変なので」


今週は以上です。

バックナンバー(2017年度)

週刊Railsウォッチ(20170929)特集: RubyKaigi 2017セッションを振り返る(2)Ruby 2.3.5リリースほか

今週の主なニュースソース

ソースの表記されていない項目は独自ルート(TwitterやRSSなど)です。

Ruby 公式ニュース

Rails公式ニュース

Ruby Weekly

Awesome Ruby

RubyFlow

160928_1638_XvIP4h

Postgres Weekly

postgres_weekly_banner

Frontend Weekly

frontendweekly_banner_captured

Hacker News

160928_1654_q6srdR

Github Trending

160928_1701_Q9dJIU

TestProf: Ruby/Railsの遅いテストを診断するgem(翻訳)

$
0
0

概要

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

Evil Martion's team blog
evilmartians.com/chroniclesより


  • 参考: TestProfのリポジトリにある図


palkan/test-profより

TestProf: Ruby/Railsの遅いテストを診断するgem(翻訳)

テストを書くことは、開発における重要なプロセスであり、RubyやRailsのコミュニティには特に当てはまります。私たちはテストでgreenが点灯するまで長時間待たされていることに気づいて、初めてテストスイートのパフォーマンスというものに関心を寄せるようになるものです。

私はテストスイートのパフォーマンスの分析に多くの時間を費やし、テストを高速化するテクニックを編み出すとともにツールを開発しました。そうしたノウハウをすべてTestProfという名のメタgemに盛り込みました。TestProfはRubyのテストをプロファイリングするツールボックスです。

開発の動機

テストが遅いと生産性が低下し、無駄な時間を費やす羽目になる

「テストスイートのパフォーマンスがどうしてそんなに重要なんだろうか?」とお思いの方もいらっしゃるでしょうから、議論を始める前にいくつかの統計情報をお見せしたいと思います。

今年初頭に、Ruby開発者にテストのパフォーマンスに関する簡単な聞き取り調査を行いました。

最初によいお知らせです。Ruby開発者のほとんどすべてがテストを書いています(正直、私は驚きませんが)。私はRubyコミュニティのこういうところが好きです。

質問: テストを書いていますか?

調査の結果、テスト実行に10分以上かかっているケースは4分の1程度にとどまりました。かつ、半分近くは5分以内に収まっています。

質問: テストの実行時間は?

これなら大きな心配はなさそうです。それでは、exampleが1000を超えるテストスイートに限定して聞いてみましょう。

質問: テストスイート全体の実行時間は?(exampleが1000以上の場合)

今度はだいぶ残念な結果になりました。テストスイートの約半分は実行に10分以上を要し、ほぼ3割近くが20分以上かかっています。

ところで、私がこれまで経験した典型的なRailsアプリでは、ざっくり6000件から10000件のexampleがありました。

もちろん、変更のたびにテストスイート全体を実行する必要などありません。通常、私が中規模の機能を手がけている場合はコミットあたり100件のexampleを実行しており、実行には1分程度しかかかりません。しかし1分といえども私のフィードバックループは結構な影響を受けますし(Justin Searlsの動画↓をご覧ください)、その間私の貴重な時間は無駄になります。

それでも私たちは、デプロイサイクル中にCIサービスですべてのテストを実行しなければなりません。テスト完了まで10分ばかり(キューでビルドの負荷が生じれば数時間に達することもあります)待たされても平気ですか?私にはそうは思えません。

ビルドの並列処理化で待ち時間を軽減することもできますが、結局コストに跳ね返ります。次のグラフをご覧ください。

質問: CIでの並行ジョブ数は?

たとえば現在のプロジェクトで5xの並列処理を行っているとすると、ジョブ1つあたりの平均RSpec実行時間はexample 1250件あたり2.5分を要します。つまりEPM(examples per minute)は500になります。

最適化を行う前は、800件のexampleで4分を要しました。EPMにしてわずか200です。つまり最適化によってビルドあたり3、4分を節約できたのです。

明らかに、遅いテストはあなたの貴重な時間を奪い、生産性を低下させているのです。

ツールボックス

あなたがテストスイートの遅さに気づいたとしましょう。ではテストが遅い理由をどうやって明らかにしますか?

能書きは以下の動画に任せて、RubyテストのプロファイリングツールボックスであるTestProf gemをご紹介いたします。

TestProfはテストスイートのボトルネックを突き止め、改善方法を示してくれます。

これより、私がTestProfを使ってテストスイートの分析と改善を行ってみます。

一般的なプロファイリング

テストスイートの詳細にいきなり切り込むよりも、まず一般的な情報を集める方が有用なことがしばしばあります。

試しに、テストスイートについて以下の質問に答えてみてください。

  1. テストスイートのどこで時間を食っているか: コントローラか、モデルか、サービスか、ジョブか?
  2. 最も時間のかかっているモジュールやメソッドはどれか

これだけでも面倒な作業ですよね。

質問1の答えが知りたい場合は、TestProfのTag Profilerを使います。これはRSpecの特定のタグ値でグループ化された統計情報を収集してくれます。RSpecはexampleにtypeタグを自動で追加するので、以下のように使えます。

TAG_PROF=type rspec


[TEST PROF INFO] TagProf report for type

          type          time   total  %total   %time           avg

    controller     08:02.721    2822   39.04   34.29     00:00.171
       service     05:56.686    1363   18.86   25.34     00:00.261
         model     04:26.176    1711   23.67   18.91     00:00.155
           job     01:58.766     327    4.52    8.44     00:00.363
       request     01:06.716     227    3.14    4.74     00:00.293
          form     00:37.212     218    3.02    2.64     00:00.170
         query     00:19.186      75    1.04    1.36     00:00.255
        facade     00:18.415      95    1.31    1.31     00:00.193
    serializer     00:10.201      19    0.26    0.72     00:00.536
        policy     00:06.023      65    0.90    0.43     00:00.092
     presenter     00:05.593      42    0.58    0.40     00:00.133
        mailer     00:04.974      41    0.57    0.35     00:00.121
        ...

これで、ボトルネックを調べる際のテストのtypeを絞り込めるようになりました。

RubyProfStackProfのような一般的なRubyプロファイラを使ったことがある方もいると思いますが、TestProfは面倒な設定や改造を一切行わずにテストスイートに対して手軽に実行できます。

TEST_RUBY_PROF=1 bundle exec rake test

# 以下も同じ

TEST_STACK_PROF=1 rspec

TestProfの各種プロファイラが生成するレポートを使って、最も利用頻度の高いスタックパスを突き止めることができ、質問2.にも答えられるようになります。

このプロファイリングについては残念ながらリソース消費が著しく、既に遅いテストスイートの実行がますます遅くなりますので、テストの一部に絞り込んでプロファイルを実行しなければなりません。しかしどうやって絞り込めばよいのでしょうか。実は、ランダムに絞り込めるのです。

TestProfには特殊なパッチが同梱されており、RSpecのexampleグループ(またはMiniTestのスイート)をランダムに選んで実行できます。

SAMPLE=10 bundle exec rspec

後はコントローラのテストのサンプルに対してStackProfを実行し(TagProfでここが最も遅かったので)、出力結果を元に分析すればよいのです。私があるプロジェクトに対して実行した結果を以下に示します。

%self     calls  name
20.85       721   <Class::BCrypt::Engine>#__bc_crypt
 2.31      4690  *ActiveSupport::Notifications::Instrumenter#instrument
 1.12     47489   Arel::Visitors::Visitor#dispatch
 1.04    205208   String#to_s
 0.87    531377   Module#===
 0.87    117109  *Class#new

ここから、test環境におけるSorceryの暗号化設定がproduction環境と同じ制約の強い設定になっていることがわかります。

典型的なRailsアプリの場合、レポートはほとんどの場合以下のようになるでしょう。

 TOTAL    (pct)     SAMPLES    (pct)     FRAME
   205  (48.6%)          96  (22.7%)     ActiveRecord::PostgreSQLAdapter#exec_no_cache
    41   (9.7%)          22   (5.2%)     ActiveModel::AttributeMethods::#define_proxy_call
    20   (4.7%)          14   (3.3%)     ActiveRecord::LazyAttributeHash#[]

ActiveRecordが随分と多く、データベースの利用も突出しています。ではこれをどうやって改善すればよいのでしょうか。続きをご覧ください。

データベースとのやりとり

テストスイートの実行時間にデータベースが占める割合がどのぐらいあるかを把握していますか?まずは自分であたりを付けてから、TestProfであぶり出してみましょう。

私たちはRailsのInstrumentation周りを既に拡張した(ActiveSupportのNotification/Instrumentation機能)ので、EventProfilerの基本的な概要やご紹介は省略いたします。

EventProfはテストスイート実行中にinstrumentationメトリックスを収集し、遅いグループのランキングや、特定のメトリックスに関連するexampleについての一般的な情報をレポートします。現時点では、ActiveSupport::Notificationsのみ無設定で利用できますが、自分のソリューションへの統合は簡単なはずです。

データベース利用量の情報を取得するには、sql.active_recordイベントを使います。この場合のレポートは次のようになります(rspec --profileの出力と非常に似ています)。

EVENT_PROF=sql.active_record rspec ...

[TEST PROF INFO] EventProf results for sql.active_record

Total time: 00:05.045
Total events: 6322

Top 5 slowest suites (by time):

MessagesController (./spec/controllers/messages_controller_spec.rb:3)–00:03.253 (4058 / 100)
UsersController (./spec/controllers/users_controller_spec.rb:3)–00:01.508 (1574 / 58)
Confirm (./spec/services/confirm_spec.rb:3)–00:01.255 (1530 / 8)
RequestJob (./spec/jobs/request_job_spec.rb:3)–00:00.311 (437 / 3)
ApplyForm (./spec/forms/apply_form_spec.rb:3)–00:00.118 (153 / 5)

私の現在のプロジェクトでは、DBが総時間に占める割合はおよそ20%ですが、これは既に十分最適化を行った結果です。最適化前は30%を超えていました。

どのプロジェクトでも共通でチェックに使えるようなメトリックの値というものはありません。これはテストのスタイルによって大きく変わるものであり、単体テストと結合テストのどちらが多いかによって異なります。

なお私たちの場合は結合テストがほとんどだったため、20%という値は決して悪くありません(もちろんさらに改善は可能なはずですが)。

データベースがテスト時間に占める割合が高い典型的な理由とは何でしょうか。そのすべてを列挙するのは無理ですが、一部をリストアップしてみました。

  • 無意味なデータ生成
  • テストの準備が重すぎる(beforesetupのフック)
  • ファクトリーがカスケードしている

最初の項目は、有名な「Model.new vs Model.create問題」(またの名を「FactoryGirlにおけるbuild_stubbed vs create問題」)です。モデルの単体テストでデータベースを叩く必要は普通ないはずなので、データベースにアクセスしなければ済みます。

しかし既にテストコードでデータベースにアクセスしているとしたらどうでしょう。テストで永続性が不要かどうかをどうやって突き止めればよいでしょうか。そこでFactory Doctorの登場です。

Factory Doctorは、不要なデータ作成がいつ行われたかを通知してくれます。

FDOC=1 rspec

[TEST PROF INFO] FactoryDoctor report

Total (potentially) bad examples: 2
Total wasted time: 00:13.165

User (./spec/models/user_spec.rb:3)
  validates name (./spec/user_spec.rb:8)–1 record created, 00:00.114
  validates email (./spec/user_spec.rb:8)–2 records created, 00:00.514

Factory Doctorは残念ながら魔法使いではありませんので(ここではまだ学習が終わっていません)、偽陽性や偽陰性が生じることもあります。

2番目の問題は、ややトリッキーな点です。次の例をご覧ください。

describe BeatleSearchQuery do
  # この検索機能をテストしたいので
  # exampleごとに何らかのデータが必要
  let!(:paul) { create(:beatle, name: 'Paul') }
  let!(:ringo) { create(:beatle, name: 'Ringo') }
  let!(:george) { create(:beatle, name: 'George') }
  let!(:john) { create(:beatle, name: 'John') }

  # この後15件ほどexampleが続く
end

「そんなのfixtureでいいじゃない」とお思いかもしれません。十数個ものモデルが毎日変更されるようなかなり大きなプロジェクトでなければ悪くない方法です。

もうひとつの方法はbefore(:all)フックでデータを1度だけ生成することです。しかしこの方法には1つ注意点があります。before(:all)はトランザクションの外で実行されるので、データベースを手動でクリーンアップしなければなりません。

でなければ、グループ全体を1つのトランザクションに手動で閉じ込めるというのはどうでしょうか。TestProfのbefore_allヘルパーはまさにこれを行っているのです。

describe BeatleSearchQuery do
  before_all do
    @paul = create(:beatle, name: 'Paul')
    @ringo = create(:beatle, name: 'Ringo')
    @george = create(:beatle, name: 'George')
    @john = create(:beatle, name: 'John')
  end

  # この後15件ほどexampleが続く
end

コンテキストを別のグループ(ファイル)とも共有したい場合は、Any Fixtureをご検討ください。これは、(ファクトリーを使って)コードからフィクスチャを生成するのに使えます。

ファクトリーのカスケード問題

ファクトリーのカスケード問題については、これだけで記事になります。今後にご期待下さい!(真っ先に知りたい方はTwitterのフォローをお願いします)

次の記事が公開されるまでの間、EventProfをfactory.createに対して実行し、テストスイートがファクトリでどれだけ重くなるか時間を計ってみるとよいでしょう(警告: かなりショッキングな結果が出ます)。

バックグラウンドジョブ

データベース以外にもさまざまなボトルネックがあります。その中からひとつを取り上げてみましょう。

テストでよく行われるのが、バックグラウンドジョブをインライン化する(Sidekiq::Testing.inline!など)という手法です。

通常、重たい作業はバックグランドに回しますので、すべてのジョブを実行すると実行時間が無条件に長くなります。

TestProfはバックグラウンドに要した時間のプロファイリングもサポートしています(現時点ではSidekiq限定)。以下のようにプロファイルにsidekiq.inlineを指定するだけでできます。

EVENT_PROF=sidekiq.inline bundle exec rspec

これで、無駄になっている時間を正確に知ることができます。次はどうすればよいでしょうか。単にインラインモードをオフにすると、テストのexampleが大量に動かなくなるかもしれません。あまりに多さにすぐには修正しきれないほどです。

解決方法は、インラインをグローバルにオフにし、必要な場合のみオンにすることです。RSpecをお使いの場合は次のようにします。

# 共有コンテキストを追加
shared_context "sidekiq:inline", sidekiq: :inline do
  around(:each) { |ex| Sidekiq::Testing.inline!(&ex) }
end

# 必要な場合にのみ使う
it "do some bg", sidekiq: :inline do
  ...
end

失敗したexampleのひとつひとつにタグを付けて回らなければならないのでしょうか?TestProfならそんな必要はありません。

ツールボックスにはRSpec Stampという特殊なツールが含まれており、特定のタグを自動で追加してくれます。

RSTAMP=sidekiq:inline rspec

ところで、RSpec Stampの内部ではソースファイルをパースしてタグを正しく挿入するためにRipperを用いています。

inline!fake!に移行する方法についてはRSpec Stampのマニュアルをご覧ください。

TestProfはGitHubrubygems.orgで公開されており、いつでもアプリに導入してテストスイートのパフォーマンス向上に役立てることができます。

本記事はTestProfの紹介のみにとどまっており、すべての機能をカバーしているわけではありません。詳しくは以下の追加リソースをどうぞ。

今年9月にモスクワで開催予定のRailsClubでもテストのパフォーマンスについてスピーチしますので、ぜひご来場ください!


スタートアップをワープ速度で成長させられる地球外エンジニアよ!Evil Martiansのフォームにて待つ。

関連記事

FactoryGirlでtraitを使うとintegration test書くのが捗るという話

[Rails] RSpecをやる前に知っておきたかったこと

RSpecで役に立ちそうないくつかのヒント(翻訳)

テストを不安定にする5つの残念な書き方(翻訳)

[Rails] RSpecのモックとスタブの使い方


RubyのModule Builderパターン #1 モジュールはどのように使われてきたか(翻訳)

$
0
0

こんにちは、hachi8833です。今回の翻訳記事は、Rubyならではのデザインパターンとでも言うべき「Module Builderパターン」の詳細な解説です。RubyのModuleが実はクラスであることをうまく利用していて、Railsなどのフレームワーク側で有用性が高いパターンであるように思えました。

元記事が非常に長いので次のように分割しました。

  • #1 モジュールはどのように使われてきたか(本記事
  • #2 Module Builderパターンとは何か
  • #3 Rails ActiveModelでの利用例
    • あとがき: Module Builderパターンという名前について

概要

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

RubyKaigi 2017@広島でも発表されたshioyamaさんことChris Salzbergさんの記事です。このときのセッションでもModule Builderパターンを扱っています。

本記事の図はすべて元記事からの引用です。また、パターン名は英語で表記しました。

RubyのModule Builderパターン #1 モジュールはどのように使われてきたか(翻訳)

最近私はRubyできわめてパワフルなパターンを発見しましたが、今のところあまり知られておらず、その価値もそれほど理解されていないようです1。私はこれにModule Builderパターンと名付け、Mobility gemの設計で多用しています。Mobilityは数か月前に私がリリースした、プラグイン機能を持つ翻訳/多言語フレームワークであり、自分でも非常に重宝しています。そして私は、Mobility開発中に学んだことを皆さんにも共有すべきと感じたのです。

このパターンの中核にあるModule Builderはきわめてシンプルかつエレガントであり、クラスにmixinされるカスタマイズ可能な名前付きモジュールを動的に定義できるという万能性を備えています。これを実現しているのはModuleクラスのサブクラス化であり、これらサブクラスは実行時に初期化され、そこで生成されたモジュールが他のクラスにincludeされます。

Moduleクラスサブクラス化」と聞いて頭がクラクラしてくるようでしたら、ぜひこの先もお読みください。Module Builderは一見深遠な奥義のように見えて、その実非常に有用な側面があります。Module Builderは、dry-rbなどのプロジェクトで大規模に利用されて大きな成果を上げているのですが、Module Builderのせっかくの本質的なシンプルさは高度なコーディングスタイルに覆い隠されてしまい、見えなくなってしまっています。Module BuilderパターンはRailsでもときおり使われています2が、ごく小規模にとどまっています。Rubyistの多くはこのパターンに気づいていないと言ってしまってよいと思います(日常の業務で使ったことが一度でもあるかどうかは別にしても)。

Module Builderについて解説するために、革新的なまでに複雑なコード例を多数ご紹介し、最後にMobilityからMethodFoundというgemに切り出されたコードの中から、私の書いたコードをいくつかご紹介します。これらの例は、まず多くの読者が理解できる中心となるシンプルなコンセプトから始まり、続いて少しばかり高度な話題に進み、現実のアプリがこのパターンから明らかに大きなメリットを得られることを示します。

Rubyのモジュールについて

最初にRubyの「モジュール」についておさらいしておきましょう。モジュールは基本的にメソッドや定数の集まりであり、どのクラスにもincludeでき、コードの重複を軽減し、再利用やコンポジション3を行いやすい単位で機能をカプセル化します。includedextendedといったコールバックを使えば、モジュールがクラスやモジュールにinclude(またはextendなど)されるたびに何らかのカスタムコードを実行できます。

さて、以下のMyModuleというモジュールに#fooというメソッドがあるとしましょう。

module MyModule
  def foo
    "foo"
  end
end

これで、MyModuleincludeしたクラスに#fooメソッドを追加できます。

class MyClass
  include MyModule
end

a = MyClass.new
a.foo
#=> "foo"

これはかなり正統な使い方ですが、大きな限界があります。#fooは事前に定義されているので変えられず、戻り値もハードコードされています。

次にもう少しだけ「現実的な」場合を見てみましょう。Pointというクラスに2つの属性xyがあるとしましょう。話を簡単にするためにStructで定義します。

Point = Struct.new(:x, :y)

それでは、2つの点のxの値とyの値をそれぞれ足し算できるモジュールをひとつ作成しましょう。このモジュールをAddableと呼ぶことにします。

module Addable
  def +(point)
    Point.new(point.x + x, point.y + y)
  end
end

Point.include Addable
p1 = Point.new(1, 2)
p2 = Point.new(2, 3)

p1 + p2
#=> #<Point:.. @x=3, @y=5>

今度は先ほどのモジュールよりも再利用性がやや高まりましたが、それでも頑固な制限が残っています。このモジュールは属性xyを持つPointというクラスがないと機能しないのです。

この定数Pointをモジュールから削除し、self.classに置き換えることでこの点を少し改善できます。

module Addable
  def +(other)
    self.class.new(x + other.x, y + other.y)
  end
end

これでAddableモジュールは、アクセサxyがある任意のクラス4で利用できるようになり、柔軟性がかなり高まりました。さらにRubyはきわめて動的に型を扱えるので、これらの要素に#+メソッドがありさえすれば、(潜在的には)このモジュールを使って2つのデータ型のさまざまな組み合わせを足し算できるようになります。

しかもRubyのモジュールは、superキーワードによる継承やincludeを使ってモジュールのメソッドをコンポジションにできる点にもご注目ください。したがって、この足し算メソッド呼び出しでログを出力したければ、次のようにPointクラスの内部で#+を定義すればよいのです。

class Point < Struct.new(:x, :y)
  include Addable

  def +(other)
    puts "Enter Adder..."
    super.tap { puts "Exit Adder..." }
  end
end

期待どおり、次のようにログが出力されます。クラスのメソッドが最初に呼び出され、モジュール内に定義されたメソッドでsuperされます。

p1 = Point.new(1, 2)
p2 = Point.new(2, 3)

p1 + p2
Enter Adder...
Exit Adder...
#=> #<Point:.. @x=3, @y=5>

モジュールには他にも多くの側面がありますが、ここまではごく一部を紹介するにとどめました。しかし本記事の主眼はModule Builderのビルダーの方であり、モジュールそのものではないので、今度はビルダーを見てみることにしましょう。

「モジュールをビルドするモジュール」をビルドする

「モジュールをビルドするモジュール」をビルドする

モジュールをビルドするモジュール

モジュールが強力である理由は、機能の特定の共有パターンをカプセル化するからです(効果的に使えばの話ですが)。先の例で言うと、この機能は、変数のペア(座標の点や、xyをペアで持つ任意の他のクラス)を足すことです。このような共有パターンを見い出してモジュール(またはモジュールのセット)の形で書くことで、複雑な問題をシンプルかつモジュラリティの高いソリューションによって扱えるようになります。

しかし先のモジュールには「変数xyを備えたクラス」という制限がまだ残っているため、一般性はそれほど高くありません。この制限を外して、任意の変数セットで使えるようにできるとしたらどうでしょう。しかしどうやって?

なお一度定義されたモジュールは設定できなくなるので、制限を外すには他の方法が必要です。1つの方法は、「必要な機能を自分自身がクラスに追加する別のモジュール」をモジュール上に定義することです。このメソッドを#define_adderと呼ぶことにします5

module AdderDefiner
  def define_adder(*keys)
    define_method :+ do |other|
      self.class.new(*(keys.map { |key| send(key) + other.send(key) }))
    end
  end
end

ここでは、Addableモジュールでやったような#+メソッドの定義ではなく、メソッドを定義するメソッドを定義します。このメソッドはモジュールのインスタンスメソッドを動的にクラスに文字どおり「ブートストラップ」する(=自分で自分を持ち上げる)ので、本記事では以後、メソッドを定義するメソッドを「ブートストラップメソッド」と呼ぶことにします。

このブートストラップメソッドは任意の数の引数を取り、splat演算子*で配列keysに割り当てます。配列keysは、足し算する変数の名前(厳密には、変数の値を返すメソッドの名前)になります。次に#+というメソッドを定義します。このメソッドはキーごとに変数の値を足し、その結果を使ってself.class.newでインスタンスをひとつ作成します。

この方法でうまくいくかどうかやってみましょう。いくつかの異なるreader属性を持つ新しいクラスLineItemを作成します。#define_adderをクラスメソッドにする必要があるため、モジュールを(includeではなく)extendします。

class LineItem < Struct.new(:amount, :tax)
  extend AdderDefiner
  define_adder(:amount, :tax)
end

これで、先の例で座標の点を追加したときと同様に、行項目同士を足し算できるようになりました。

l1 = LineItem.new(9.99, 1.50)
l2 = LineItem.new(15.99, 2.40)

l1 + l2
#=> #<LineItem:... @amount=25.98, @tax=3.9>

動きました!しかも、変数名にも変数の個数にも依存していません。つまり、モジュールの柔軟性がさらに高まったのです。

しかしここでひとつ問題があります。先のログ出力コードをPointクラスからコピーし、次のように#+に貼り付けてログ出力を呼び出すことにします。

class LineItem < Struct.new(:amount, :tax)
  extend AdderDefiner
  define_adder(:amount, :tax)

  def +(other)
    puts "Enter Adder..."
    super.tap { puts "Exit Adder..." }
  end
end

ここで再び2つの行項目を足してみると、ログは出力されず、例外が発生してしまいます。

NoMethodError: super: no superclass method `+' for #<struct LineItem amount=9.99, tax=1.5>

何が起こったのでしょうか?

#define_adder#+メソッドをどのように「ブートストラップ」しているのか、よく見てみましょう。#define_adder#define_methodでメソッドをクラス(ここではLineItem)に直接追加しています。クラスは、渡されたメソッドの定義を1つしか持てないため、クラス内で後から#+を定義すると元の定義が飛んでしまいます。メソッドが定義されるときにはincludeも継承も行われていないため、ここには「スーパークラス」はありません。

この問題を解決するには「モジュールをビルドする」というマジックが少々必要です。最初に問題の解決方法を紹介し、次にこの問題をどのように解決しているかを解説します。

module AdderIncluder
  def define_adder(*keys)
    adder = Module.new do
      define_method :+ do |other|
        self.class.new(*(keys.map { |key| send(key) + other.send(key) }))
      end
    end
    include adder
  end
end

先ほどのLineItemを使ったログ出力コードで、AdderDefinerモジュールの代わりにこのAdderIncluderモジュールを使うと、今度は次のとおり正常に動作します。

l1 = LineItem.new(9.99, 1.50)
l2 = LineItem.new(15.99, 2.40)

l1 + l2
Enter Adder...
Exit Adder...
#=> #<LineItem:... @amount=25.98, @tax=3.9>

コードが動作する秘密は、#define_adderでの#+メソッド定義で無名モジュールを使っていることです。これは先ほどのような、呼び出し側のクラスでメソッドを直接定義する手法とは異なります。

Rubyのあらゆるモジュール(これは小文字のmodule、つまりRubyの一般的なモジュールである点にご注意ください)は、あるクラスの単なるインスタンスであることを思い出しましょう。そして「あるクラス」とは、大文字のModuleクラスなのです。Moduleクラスは他のクラスと同様にnewメソッドを持つので、ここではブロック引数をひとつ取り、そのブロックは新しく作成されたモジュールのコンテキストで評価されます。

モジュールをその場で生成したければ、単にModule.newにメソッド定義を含むブロックを渡せばよいのです。このようにして生成したモジュールは、通常の(名前付き)モジュールと同じように利用できます。

mod = Module.new do
  def with_foo
    self + " with Foo"
  end
end

title = "The Ruby Module Builder Pattern"
title.extend mod
title.with_foo
#=> "The Ruby Module Builder Pattern with Foo"

これはテストでよく使われる手軽なトリックで、1度きりのテストでしか使わない使い捨てモジュールのためにグローバル名前空間を汚したくない場合に使います(Classにも使えます)6

AdderIncluderモジュール内のブロックで定義されているメソッドは、AdderDefinerで定義した#+メソッドと同じであり、その意味で2つのモジュールの動作はきわめて似通っています。両者の重要な違いは、AdderDefinerの場合はメソッドをクラス上に定義しているのに対し、AdderIncluderの場合はメソッドを含むモジュールクラスにincludeしている点です。

次のようにancestorsでチェインを表示すれば、このモジュールがLineItemクラス自身の直下にあることを確認できます。

LineItem.ancestors
#=> [LineItem,
#<Module:0x...>,
...

このancestorsチェインを見れば、ここでsuperが正常に動作する理由がはっきりわかります。LineItem#+を呼ぶとこのメソッドからsuperが呼ばれてクラス階層を遡り、ここで使いたい#+が定義されている無名モジュールにたどりつきます。続いて#+で足し算が実行されて結果を返します。この結果は、LineItemの足し算でログ出力コードをコンポジションにしたものです。

この動作は一見奇妙かつ見慣れないものですが、ブートストラップメソッドで無名モジュールを動的にincludeするテクニックは実際非常に便利であり、こうした理由によって広く使われています。特にRailsのように、モデルを定義したりルーティングを設定するだけでdefine_adder的なブートストラップメソッド呼び出しが多数トリガされる柔軟なフレームワークで多用されています。このテクニックは、これと同じレベルの柔軟性を必要とするその他のgem(私のMobilityなど)でも使われています。

このように、モジュールビルドのブートストラップはRubyにおいてきわめて重要なメタプログラミング技法であると考えられます。実際、このテクニックはRailsのようなフレームワークのきわめて動的な特性を支えています。

しかしここからが本題です。私としてはこのブートストラップ技法よりも、これからご紹介するModule Builderの方がはるかに高い能力を示すことを皆さまに理解いただきたいと考えており、その意味でModule Builderパターンはブートストラップよりもいっそう注目に値します。説明のため、先のAdderモジュールに立ち返り、先ほどとは少し違う方法でこのモジュールを定義することにします。

(「#2 Module Builderパターンとは何か」に続く)

図版(#1で使用されているもの)

a. “Drawing Hands” by M.C. Escher. reference
b. “Automated space exploration and industrialization using self-replicating systems.” From Advanced Automation for Space Missions: 5. REPLICATING SYSTEMS CONCEPTS: SELF-REPLICATING LUNAR FACTORY AND DEMONSTRATION. reference

関連記事

[保存版]人間が読んで理解できるデザインパターン解説#1: 作成系(翻訳)

[保存版]人間が読んで理解できるデザインパターン解説#2: 構造系(翻訳)

Rubyで学ぶデザインパターンを社内勉強会で絶賛?連載中


  1. 私がこの「Moduleクラスのサブクラス化」というアイデアを最初に知ったのはPiotr Solnicaによる2012年の記事でした。最近だとEric AndersonAndreas Robeckeの記事でも言及されていますが、それ以外にはほとんど見かけません。 
  2. 記事執筆時点では、ここここで使われています。 
  3. ここでは、モジュールを別の場所でincludeし、メソッドチェインの複雑なコンポジションをsuperで形成するという意味です。このテクニックはMobilityで多用しました。 
  4. 正確に言うと、2つの属性の値を引数として取るイニシャライザも必要です。 
  5. ここでは、includeでモジュールのメソッドをクラスメソッドとしてクラスに追加するというトリックを使っています。このクラスはモジュールがincludeされるときにextendされます。これはよくあるトリックですが、これがちょっと謎に見えるのであれば、先に進む前にこの謎を読み解いてみてください。 
  6. 無名モジュールのこのような「名前空間を汚さない」性質はメタプログラミングでも有用です(定数名の衝突を気にしないで済むため)。 

週刊Railsウォッチ(20171013)Ruby 2.5.0-preview1リリース、RubyGems 2.6.14でセキュリティバグ修正、Bootstrap 4.0がついにBetaほか

$
0
0

こんにちは、hachi8833です。

Rails: 今週の改修

新機能: HTTP/2プッシュ用のEarly Hintsを実装


#30744より

Aaron Patterson氏のロングインタビュー↓でも言及されていた「アーリーレスポンス」がHTTP 103 Early Hintsとして実装されました。

[インタビュー] Aaron Patterson(後編): Rack 2、HTTP/2、セキュリティ、WebAssembly、後進へのアドバイス(翻訳)

とにかく、Kazuhoは新しい標準化を提案することを思いつきました。それは新しいレスポンスコードで、私はそれが101か110だと思っています。これはアーリーレスポンスと呼ばれるもので、HTTP/1の普通のレスポンスコードなのですが、本質的にプッシュ(HTTP/2プッシュ)と同じものです。使っているプロキシがこのレスポンスコードを理解し、それをHTTP/2プッシュに変換するというわけです。
この方法の良い点は、HTTP/1で動くサーバーがあり、そのサーバーの通信相手であるプロキシがHTTP/2で話せるなら、プッシュを行いたいということをプロキシが理解できるという点です。本質的にHTTP/1とHTTP/2のいいとこ取りができるので、HTTP/1サーバーにまったく影響を与えずに引き続きプッシュを行えます。


つっつきボイス: 「上のロングインタビュー時点では@kazuhoさんから提案中だったのが、Aaronさんの期待どおり見事採用されてる」「HTTP/2 Early HintsはRailsに限らない話で、プロトコルが拡張されたことの方が大きい」「ほほー、レスポンスコードは?」「103番ですね」
「Early Hintsについてはこちらのブログ↓見ればひととおりわかります。Web開発者必読

「しかし103番とは随分と若い番号」「HTTP 1xx番台はInformationalなのか: ところでこのWikipediaにはまだ103は書き足されてないから更新必要ですね」
「変更は意外にシンプルだった↓」「環境変数で指定できる: デフォルトはnilだそうです」

# actionpack/lib/action_dispatch/http/request.rb
+    def send_early_hints(links)
+      return unless env["rack.early_hints"]
+
+      env["rack.early_hints"].call(links)
+    end

存在しないAction Cableサブスクリプションの解除メッセージをわかりやすくした

actioncable/lib/action_cable/connection/subscriptions.rb
      def remove(data)
         logger.info "Unsubscribing from channel: #{data['identifier']}"
-        remove_subscription subscriptions[data["identifier"]]
+        remove_subscription find(data)
        end

つっつきボイス: 「これは普通にリファクタリングですね」

テストでRuby 2.5の未使用変数warningが出たのを修正

# railties/test/generators/argv_scrubber_test.rb
-        message = nil
         scrubber = Class.new(ARGVScrubber) {
-          define_method(:puts) { |msg| message = msg }
+          define_method(:puts) { |msg| }
         }.new ["new", "--rc=#{file.path}"]
         args = scrubber.prepare!
         assert_equal ["--hello", "--world"], args

文言修正系PR

# activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
-          raise(ArgumentError, "Postgres requires advisory lock ids to be a signed 64 bit integer")
+          raise(ArgumentError, "PostgreSQL requires advisory lock ids to be a signed 64 bit integer")
-    #  NOTE: This won't affect any <tt>ActiveSupport::TimeWithZone</tt>
-    #  objects that have already been created, e.g. any model timestamp
-    #  attributes that have been read before the block will remain in
-    #  the application's default timezone.
+    # NOTE: This won't affect any <tt>ActiveSupport::TimeWithZone</tt>
+    # objects that have already been created, e.g. any model timestamp
+    # attributes that have been read before the block will remain in
+    # the application's default timezone.

つっつきボイス: 「修正と関係ありませんが、英語圏の人ってPostgresって書くの好きですね: 私は翻訳のときは常にPostgreSQLと書き直してます」「日本語のポスグレは口頭でしか使わない感じですね」

compatibility.rbのリファクタリング(DRY化)

コミットメッセージどおり、重複したコードをprivateにまとめています。

# activerecord/lib/active_record/migration/compatibility.rb
          if block_given?
-            super(table_name, options) do |t|
-              class << t
-                prepend TableDefinition
-              end
-              yield t
+            super do |t|
+              yield compatible_table_definition(t)
             end
            else
...
        private
+          def compatible_table_definition(t)
+            class << t
+              prepend TableDefinition
+            end
+            t
+          end

Rails

Rails 5とGriddlerとMailgunでメールを受信してパースする(RubyFlowより)


hackernoon.comより


つっつきボイス: 「ほほー、自前のメールサーバーを使わずにRailsでメール受信トリガの処理を実行する方法か: 普通メール受信をトリガにして何かするときは、postfixとか使って.forwardあたりでスクリプトに受信メールを流し込んで実行したりするけど」

  • Griddler — Railsでメール受信するgem
  • Mailgun — 開発者向けのAPIベースのメールサービス

「なおAWSを使っていいのであれば、SES(Simple Email Service)S3に受信できるので、S3のfile createのイベントをLambdaに流して同じことができるはず」


そこからNAT越え(NAT traversal)の話題になりました。

ngrokというサービスとプログラムを使うと、双方ともNATの裏にいてもtunnelingできるらしい」「ご家庭でゲームのためにUPnPのNAT越え頑張ったりしますね」「UDPホールパンチングなんてのも」「ゲームやらない人なのでこのあたり知らないことだらけ」

shioyamaさんのMobility gem記事

以前のウォッチでもご紹介したRails多言語化gemです。Module Builderパターンの実例についてここでも熱心に解説されています。
この方の英文は(内容の難しさと量は別にしても)とても読みやすいと思います。

RubyのModule Builderパターン #1 モジュールはどのように使われてきたか(翻訳)

Skinny: Scalaで書かれたRails的フレームワーク


github.com/skinny-framework/skinny-frameworkより

ちょっと珍しいので拾ってみました。★も600越えと意外に伸びています。


つっつきボイス: 「これ見てて思ったけど、Railsの方法論はいろんなフレームワークに広がりましたね」
「Railsが流行ってから他のWebアプリケーションフレームワークも軒並みCLIのscaffold的なgeneratorとかを搭載するようになったし、つくづくRailsの影響は大きかったと思う」「以前PHPフレームワークのSymfony使ってたけど、v2はもうホントRailsっぽいCLI」


symfony.comより

Ruby

Ruby 2.5.0-preview1がリリース(Ruby公式ニュースより)

新機能

  • バックトレースを逆順で見やすく表示
  • トップレベルの定数探索を廃止
  • do/endブロック内でrescue/else/ensureを使えるようになった
  • yield_self

その他2.4以降の変更

  • Onigmoの非包含演算子(absence operator)
  • Bundlerが標準ライブラリの仲間入り
  • rubygemsが2.6.13に
  • rdocが6.0.0.beta2に、従来のIRB lexerに代えてRipperを導入し高速化
  • Unicode 10.0.0をサポート


つっつきボイス: 「Ruby 2.5はbundlerがbundleされた点がとてもうれしいので、ぜひ早く安定版が出て欲しい」

RubyGems 2.6.14でセキュリティバグ修正

Aaron Pattersonさんが書いています。RubyGems 2.6.14にアップデートするには以下を実行します。

gem update --system

良いRubyコードを書くためのあまり知られていない7つの技(RubyFlowより)

良記事です。つっつきも大いに盛り上がりました。このRubyGuidesというサイトの他の記事もよさそうです。


つっつきボイス:

  • Integer#digits「桁をバラして配列で取り出せるのかー: 使いみち思いつかないけど」「数学パズルとかに使えるかな?」
  • #tap「このメソッドは、使う人はすごく使うけど使わない人は全然使わないですね: こんなふうに1回だけ#tapするのは気持ちわかる」
User.new.tap { |user| user.name = "John" }

「でもこのサンプルコードみたいにチェインするのは好きじゃない」「う、読みづらい…」

"hello".bytes.to_a.tap {|arr| p arr }
  .collect {|byte| byte.to_s(16) }.tap {|arr| p arr }
#=> [104, 101, 108, 108, 111]
#=> ["68", "65", "6c", "6c", "6f"]
  • #values_at「Arrayの何番目と何番目と指定していっぺんに取り出せるんですね」「これ、Hash#values_atの方が便利そう↓」「paramsのこれとこれだけ取り出したりできるのか」
h = {1=>"a", 2=>"b", 3=>"c"}
p h.values_at(1,3,4)               #=> ["a", "c", nil]
# [h[1], h[3] ,h[4]] と同じ
h = { a: 1, b: 2, c: 3 }
h.transform_values {|v| v * v + 1 }  #=> { a: 2, b: 5, c: 10 }
h.transform_values(&:to_s)           #=> { a: "1", b: "2", c: "3" }
h.transform_values.with_index {|v, i| "#{v}.#{i}" }
                                     #=> { a: "1.0", b: "2.1", c: "3.2" }
  • Kernel#itself「自分自身にアクセスしたいことってたまにある」「これは考えて使わないとよく分からなくなりそう」
string = 'my string' # => "my string"
string.itself.object_id == string.object_id # => true
  • Enumerable#cycle「テストデータを無限に生成するのとかに使えるかも」
a = ["a", "b", "c"]
a.cycle {|x| puts x }  # print, a, b, c, a, b, c,.. forever.
a.cycle(2) {|x| puts x }  # print, a, b, c, a, b, c.

「あー良い記事でござった」「little-knownダナ確かに」

RubyのGILを理解する


dev.to/enetherより

GILといえば卜部さんのブログにこんなことが書いてありました。

GVL (じーぶいえる) GILとも。Global VM Lock。嫌われもの。だがパフォーマンスを犠牲にしつつもみんなを[BUG]から守ってくれてるんだぞ。もっと感謝すべきだ。
‘10年代のRubyコア用語集より


つっつきボイス: 「よく話題になるやつです: ↑ホントこのとおり」「GってGiantのGだったりもしますね」「まあ意味は変わりませんが」「せっかくスレッドがCPUに分割されてもGILが発生すると一斉に一時停止しちゃう」

RubyのSymbolってなくせないの?


つっつきボイス: 「RubyのSymbolって私好きなので、問題だと思ってませんでした」「frozen_string_literalがある今となっては、もしかするとSymbolっていらないのかも」

「Symbolを持つ言語はLispやSmalltalkなど他にもあるけど、おそらくRubyのSymbolって当初の概念からかなり踏み出して独自路線になっちゃったのかも: Symbol#to_sできちゃうとか」「確かに: Symbolをputsするにはto_sが必要だけど、それが当初の想定を超えて文字列代わりにどんどん使われたり」「RailsのHashWithIndifferentAccessなんてのができたり」
「JavaにはSymbolないので、キーの文字列がたまに一意にならなくて数値に変えたりとつらかったなー」

Ruby Rogueポッドキャスト: Martin FowlerとRubyコードをリファクタリングしよう


devchat.tv/ruby-roguesより

Ruby Rogueはこの間のウォッチでも軽くご紹介しました。
Martin Fowler氏はRefactoring — Ruby Editionの著者です。この本の日本語版があったのに今は絶版なのが何とも残念。


つっつきボイス: 「お、目次もあるし、内容もいい感じ」「このポッドキャストは月一ぐらい?」「もっと頻繁みたいです」「それは凄い: 長く続いてるのは大事」
「そういえば日本語だとrebuild.fmという音声ポッドキャストが随分長くやってますね」「rebuild.fmは毎回いろんなベテランエンジニアの話が聴けるのがよくて、BPS社内にも愛聴者がいたと思う」


rebuild.fmより

「Ruby Rogueも同じ感じでやってるようです: DHHインタビューとか、かと思うと元ストリートパフォーマーからRails開発者に転身した人の話とか、盛りだくさんですね」「字幕があれば英語ヒアリング練習にもいいかも」

bolt: SSHやWinRM経由で多数のコマンドを実行するgem(GitHub Trendingより)

$ bolt command run 'ssh -V' --nodes neptune,mars
neptune:

OpenSSH_5.3p1, OpenSSL 1.0.1e-fips 11 Feb 2013

mars:

OpenSSH_6.6.1p1, OpenSSL 1.0.1e-fips 11 Feb 2013

Ran on 2 nodes in 0.27 seconds

つっつきボイス: 「capあれば同じことできますね」「capって何でしたっけ」「capistranoのコマンド」「あ、そうだった」
「この種のツールはやっぱりsingle binaryで動いてくれる方がうれしいので、自分は使わないかな: Go言語とかで作られてる方が便利だなあ」「perlのスクリプトとか、環境が変わると微妙なバージョン違いで動かなかったりしますね」

  • capistrano — Rubyで書かれたデプロイ/サーバー操作ツール


capistranorb.comより

Rubyはどうやって開発を進めているか


つっつきボイス:伽藍とバザールですね: そうとう昔のですが、今日のOSSの歴史を辿るとまず間違いなく参照される文書: これ以外で伽藍という用語を見たことないけど」
「90年代にオープンソースの隆盛を予言したやつですね: 当時騒いだのは開発者より文化人の方が多かったような覚えが」
「伽藍が読めなかった」「がらん、って大聖堂とかの方がわかりやすいかも」「cathedralだからカテドラルか」


そういえば伽藍という言葉を覚えたのは西遊記でした。六丁六甲、五方掲諦、四値功曹、護駕伽藍。

Ruby trunkより

提案: ファイルが存在する場合にxモードで開いたらraiseすべき->#11258と重複

# C言語: file.txtを作成するが、既に存在していたらraiseする
open('file.txt', 'wx') {|f| f.puts("Some text") }

つっつきボイス: 「openのxモードとか知らなかったー」「wxって最初意味分からなかった」「PHPだけどこんなの見つけた↓」

‘x’ 書き込みのみでオープンします。ファイルポインタをファイルの先頭に置きます。 ファイルが既に存在する場合には fopen() は失敗し、 E_WARNING レベルのエラーを発行します。 ファイルが存在しない場合には新規作成を試みます。 これは open(2) システムコールにおける O_EXCL|O_CREAT フラグの指定と等価です。 このオプションはPHP4.3.2以降でサポートされ、また、 ローカルファイルに対してのみ有効です。
http://php.net/manual/ja/function.fopen.phpより

「manではこう↓」

open(2) システムコールにおける O_EXCL|O_CREAT

「あった、これこれ↓」

O_EXCL
この呼び出しでファイルが作成されることを保証する。このフラグが O_CREAT と 一緒に指定され、 pathname のファイルが既に存在した場合、 open()は失敗する。
https://linuxjm.osdn.jp/html/LDP_man-pages/man2/open.2.htmlより

「他の言語と同じようにPOSIXのopen()に準拠しようよ、ってことか」


そこからPOSIXのシステムコールの名称の話題になりました。

O_EXCLとかO_CREATとかO_TRUNCみたいな、本体が5文字の短いシステムコールは古くからあるやつで、O_NOFOLLOWみたいに長いのはたぶん後から追加されたやつです」「へええ」「昔はこういうところをケチらざるを得なかったんですね」

JavaScript

asyncawaitを4つの例で学ぶ(JavaScript Liveより)

// 同記事より
async function startSynchronous() {
  try {
    const pentaCodeAvatarUrl = await getPentaCodeAvatar();
    const reactAvatarUrl = await getReactAvatar();
    const totalURL = pentaCodeAvatarUrl + reactAvatarUrl;
    console.log(totalURL);
  } catch (e) {
    console.error('Error in startSynchronous (Async Await Based)', e);
  }
}

startSynchronous();

つっつきボイス: 「出たなPromise」「asyncawaitをガンガン使うようなJSコード、納期が超緩い案件ならやってみたい」

amatudaさんのRubyConf Malaysia 2017スライド

CSS/HTML/フロントエンド

Bootstrap 4.0が「ついに」Beta版に


getbootstrap.comより


つっつきボイス: 「公式サイトのトップ画面がついに変わった!」「Bootstrap 4.0で長く待たされた人たちはみんな仏の境地になってますね」「ちょうど今年の学生に『Bootstrap 4.0は当分先です』と教えたところだった」

リモートつっつき: 「Formの書き方が変わり、-xsが廃止になってアイコンフォントも標準付属でなくなったので、3からそのまま入れ替えるとめちゃくちゃに壊れますが、ブレークポイント4段なのがとても良い☺️」
「アイコンフォントの分離は、require(‘boostrap’);時にcssの@importを辿ってWebpackがフォントを読み込もうとして😞 になるのを回避できるので逆に良い」

5分でタイポグラフィを改善するコツ(HackerNewsより)


pierrickcalvez.comより

デザイン寄りの記事です。


つっつきボイス: 「こういうの、日本語の縦書きやルビになると全然違ってきますよねー」「確かに」

Go言語

caddy: Let’s Encryptの証明書を自動で使えるHTTP/2サーバー(GitHub Trendingより)

1か月足らずで驚異の★14,000超えです。


つっつきボイス: 「single binaryで動くので良さそう」「OpenSSLとかもbundleされてるなら嬉しいな」

supervisord: Goで書かれたsupervisor(GitHub Trendingより)

$ supervisord -c supervisor.conf -d

$ supervisord ctl status
$ supervisord ctl stop <worker_name>
$ supervisord ctl start <worker_name>
$ supervisord ctl shutdown

つっつきボイス: 「本家のsupervisord↓と名前が同じでさえなければなー: このままじゃ議論になったときも区別が大変」「コマンドとかconfigは互換保とうとしてるっぽいけど、完全に同じでないと実際に運用しようとするときに問題になりそう」


本家supervisord.orgより

rat: シェルコマンドを組み合わせてターミナルアプリを作るツール(GitHub Trendingより)


github.com/ericfreese/ratより

tig的なCLIツールを作れます。

wow: Go CLI用のかわいいスピナー(GitHub Trendingより)


その他

fd: 改良版findコマンド(GitHub Trendingより)


github.com/sharkdp/fdより

find -iname '*PATTERN*'
fd PATTERN   // 書式がシンプル

Rustで書かれています。カラー表示がうれしいです。

sorting.at: ソートのビジュアル表示


sorting.atより

最初Hacker NewsのSorting Visualizationsにしようかと思いましたがこちらの方がずっと美しくて明快だったので。


つっつきボイス: 「ソートのアルゴリズムを学んでない人にはよさそう」
「ところでソートといえばこの名作動画を思い出しますね↓」「こ、これはw」「なごむー」

cosmos: さまざまな言語で書かれたアルゴリズムとデータ構造コレクション

# https://github.com/OpenGenus/cosmos/blob/master/code/search/binary_search/binary_search.rb より
=begin
    Part of Cosmos by OpenGenus Foundation
=end

def BinarySearch(arr, l, r, x)

    if x.nil?
        return -1
    end

    if r >= l
        mid = (l + r)/2
        if arr[mid] == x
            mid
        elsif arr[mid] > x
            BinarySearch(arr, l, mid-1, x)
        else
            BinarySearch(arr, mid+1, r, x)
        end
    end
end

arr = [3,5,12,56,92,123,156,190,201,222]

number = 12

puts "Position of #{number} is #{BinarySearch(arr, 0, arr.length-1, number)}"

Rubyが少ないのが残念。主催はOpenGenusという団体です。

番外

RoBozzle: 子供も大人も楽しめるパズルゲー


robozzle.comより

Hacker Newsのコメント欄で見つけました。まだ遊んでませんが、ちょっと倉庫番を思い出しました。


つっつきボイス: 「これで思い出したけど、codecombatっていう、ゲームでプログラミングを学ぶサイトも面白いですよ↓: お、今見てみたら前よりずっと進化してる」「ローカライズもかなり頑張ってる感じですね」


codecombat.comより


今週は以上です。

バックナンバー(2017年度)

週刊Railsウォッチ(20171006)PostgreSQL 10ついにリリース、Capybaraコードを実画面から生成するnezumiほか

今週の主なニュースソース

ソースの表記されていない項目は独自ルート(TwitterやRSSなど)です。

Ruby 公式ニュース

Rails公式ニュース

Awesome Ruby

RubyFlow

160928_1638_XvIP4h

Frontend Weekly

frontendweekly_banner_captured

Hacker News

160928_1654_q6srdR

Github Trending

160928_1701_Q9dJIU

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

$
0
0

概要

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

パターンの種別は原則として英語表記にしました。

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

Service Object(単にServiceとも呼ばれます)は、肥大化したActiveRecordモデルを分割(訳注: TechRacho翻訳記事にリンクしました↓)し、コントローラをスリムかつ読みやすくするうえで非常に有用な、Ruby on Rails開発における一種の聖杯とも呼ぶべきパターンです。

肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)

どんなときにService Objectを使うか

このパターンは非常にシンプルかつ強力なので、つい使いすぎてしまうほどです。特に、多数のモデルに対するコールバックやアクセスを含み、多くの手順で構成される複雑なアクションを定義する場所が必要で、かつ他に適切な置き場所がない場合に有用です。Service Objectは、モデルで外部クラスとやりとりするコールバックによって生じる問題(関連記事)を軽減するのにも用いられます。

Service Objectパターンを最大限に利用するための注意点

1. 命名規則を1つに定める

プログラミングで難易度が高いのは、適切かつ意味のわかる名前を割り当てることです。Service Objectの場合、UserCreatorTwitterAuthenticatorCodeObfuscatorなどのように「〜or」で終わる名前を付ける方法が広く採用されています。この命名規則に従おうとすると、OrderCompleterのように英語的に少し無理が生じることがあります。

そして私は、CreateUserAuthenticateUsingTwitterObfuscateCodeCompleteOrderのように、コマンドやアクションを先に書く命名方法ならややわかりやすくなることに気づきました。この方がService Objectの責務が明確になります。

どの方法を選ぶにせよ、一度命名規則を決めたらそこから外れないようにすることが重要です。

2. Service Objectを直接インスタンス化しないこと

Service Objectをインスタンス化しても、単にcallメソッドを実行する以外にあまり使いみちがありません。callメソッドを実行するのであれば、次のように抽象化してService Objectへの呼び出しをより簡潔にすることを検討しましょう。

module Service
  extend ActiveSupport::Concern
  class_methods do
    def call(*args)
      new(*args).call
    end
  end
end

このモジュールをincludeすることで、UserCreator.new(params).callUserCreator.new.call(params)UserCreator.call(params)のように簡潔に記述でき、読みやすさも向上します。このService Objectはインスタンス化も可能なので、内部のステートを取り出す必要が生じた場合にも有用です。

3. Service Objectの呼び出し方法を1つに定める

個人的にはcallメソッドを使うのが好みですが、他の方法を使わない特別な理由があるわけではなく、performrunexecuteも候補として優れています。重要なのは呼び出し方法を常に統一することです。というのも、クラスの責務は既にクラス名に明示されており、これ以上明確にする必要はないからです。

呼び出し方法を統一しておけば、新しいService Objectを実装するたびに名前を考える面倒がなくなりますし、他のプログラマーは実装の詳細をチェックしなくてもService Objectの使い方をすぐに理解できるという効用もあります。

4. Service Objectの責務を1つに絞り込む

これはService Objectの呼び出し方法を1つに統一しておけばある程度実現できますが、それだけではService Objectに複数の責務が混入する可能性が残ります。Service Objectにさまざまなアクションをまとめることもできますが、アクションのセットは1種類に限定するべきです。

Service Objectでありがちなアンチパターンとしては、たとえばManageUserというserviceがユーザーの作成と削除の両方を引き受けてしまうというものがあります。そもそもmanageという言葉だけでは責務があいまいになってしまい、オブジェクトがどんなアクションを実行すべきかを制御する方法も明確になりません。代わりにDeleteUserCreateUserという2つのserviceに分けて導入すれば、コードも読みやすくなり、より自然になります。

5. Service Objectのコンストラクタを複雑にしない

一般に、実装するほとんどのクラスではコンストラクタをシンプルにしておくのがよい考えです。Serviceの主な呼び出し方法はクラスメソッドを呼び出すことですが、コンストラクタの責務を「引数をserviceのインスタンス変数に保存すること」に限定する方がずっとメリットが多くなります。

class DeleteUser
  def initialize(user_id:)
    @user = User.find(user_id)
  end

  def call
    #…
  end
end

上と下を見比べてみましょう。

class DeleteUser
  def initialize(user_id:)
    @user_id = user_id
  end
  def call
    #…
  end
  private
  attr_reader :user_id
  def user
    @user ||= User.find(user_id)
  end
end

下のようにすれば、テストのときにコンストラクタではなくcallメソッドに集中できるようになりますし、コンストラクタに何を置くことができ、何を置けないかという線引きも明確になります。開発者が日々決定しなければならないことはたくさんあるので、こういう点を標準化して決めごとをひとつ減らしましょう。

6. callメソッドの引数をシンプルにする

Service Objectに2つ以上の引数が与えられる場合、引数をわかりやすくするためにキーワード引数の導入を検討するとよいでしょう。Service Objectの引数が1つの場合であっても、キーワード引数にしておくことで読みやすさが向上するでしょう。

UpdateUser.call(params[:user], false)

上と下を見比べてみましょう。

UpdateUser.call(attributes: params[:user], send_notification: false)

7. 結果はステートリーダー経由で返す

Service Objectから何らかの情報を取り出さなければならないような状況はめったにありませんが、その必要が生じた場合に取りうるアプローチはいくつか考えられます。

Service Objectはcallメソッドの結果をたとえば次のように返すことができます。実行が成功した場合はtrue、失敗した場合はfalseを返す、という具合です。

しかし、callメソッドがService Object自身を返すようにすればより柔軟になります。この方法にすると、Service Objectインスタンスのステートを読み出せるようになります。

update_user = UpdateUser.call(attributes: params[:user])
unless update_user.success?
  puts update_user.errors.inspect
end

この方法は、例外のraiseなど、めったに起きない事象を扱うような場合にも効果的にエッジケースとやりとりできます。

begin
  UpdateUser.call(attributes: params[:user])
rescue UpdateUser::UserDoesNotExistException
  puts "そのユーザーは存在しません"
end

8. callメソッドの可読性を下げないようにする

callメソッドはService Objectの中心となるメソッドです。Service Objectでは、callメソッドをできるだけ読みやすく保つことをおすすめします。callメソッドには関連する手順だけを記述し、それ以外のロジックは最小限に抑えるようにします。andorなどを使って、特定の手順のフローを制御してもよいでしょう。

class DeleteUser
  #…
  def call
    delete_user_comments
    delete_user and
      send_user_deletion_notification
  end
private
  #…
end

9. callメソッドをトランザクションでラップすることを検討する

Service Objectが自らの責務を果たすために複数の手順が必要になった場合、手順をトランザクションでラップするとよいでしょう。こうしておけば、その手順に失敗した場合にも常にロールバックできます。

10. Service Objectが増えたら名前空間でグループ化する

Service Objectは遅かれ早かれ何十個にも増えるでしょう。コードをうまく編成するために、名前空間で共通のService Objectをグループ化することをおすすめします。Service Objectをグループ化する名前空間は、「外部サービス」や「高レベルの機能」など考えられるどんな基準で決めてもかまいません。ただし、Service Objectの命名規則や配置を読みやすい素直なものにするのが名前空間の主要な目的であることをお忘れなく。規則を1つにしていれば、適切な配置は自然に定まります。不要な選択肢を増やさないようにするのがコツです。

まとめ

Service Objectはテストを書きやすくするのに役立つシンプルかつ強力なパターンであり、広く応用できます。Service Objectは実装が容易ですが、その分実装がばらつかないよう制御が必要です。Service Objectの適切な命名規則を定めることで呼び出しや結果の受け取りを統一し、Service Objectクラスの内部状態をシンプルかつ読みやすく保てば、コードベースでこのパターンから多くのメリットを得ることができます。

Service Objectパターンで簡単な抽象化が必要になったら、BusinessProcess gemや類似のユースケースを検討しましょう。rails-patterns gemではさらに薄いレイヤを提供しています。

次回のpart 2は「Query Object」です。

関連記事

Railsの`CurrentAttributes`は有害である(翻訳)

肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)

リファクタリングRuby: サブクラスをレジストリに置き換える(翻訳)

Rails: クラスレベルの3つのアクセサを比較する(翻訳)

$
0
0

概要

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

Rails: クラスレベルの3つのアクセサを比較する(翻訳)

attr_accessorは、インスタンス変数のgetterとsetterを提供する非常に有用なマクロです。これと同じ効果をクラス変数でも得られれば、クラス変数でクラスを設定できて便利です。そのための方法として少なくともattr_accessorcattr_accessorclass_attributeの3つのアプローチがあります。継承を使わない場合はどれも同じに使えますが、継承がからむ場合の動作はかなり異なります。

1. attr_accessor: 値を継承したくない場合に使う

この場合は通常のattr_accessorを使いますが、ここではクラスレベルで使います。attr_accessorを使うと、各子クラスに変数が定義されますが、親クラスに設定された値は継承されません。各子クラスの属性の初期値はnilに設定されます。

class Parent
 class << self
   attr_accessor :foo
 end
end
class Child < Parent
end
Parent.foo = 100
Child.foo #=> nil  // 値は継承されない
Child.foo = 200
Child.foo #=> 200
Parent.foo #=> 100 // 子クラスで値を変更しても親クラスの値には影響しない

訳注: attr_accessorはRubyのメソッドです。

2. cattr_accessor: 値をすべてのクラスで共有したい場合に使う

この場合はcattr_accessorで親クラスの値を子クラスに継承します。このとき、cattr_accessor宣言をclass << selfブロックの内側に置く必要はありません。ここで重要なのは、子クラスでクラス変数の値を再定義すると親クラスと子クラスの両方で値が変更されるという点です。

class Parent
  cattr_accessor :foo
end
class Child < Parent
end
class Grandchild < Child
end
Parent.foo = 100
Child.foo #=> 100
Child.foo = 200
Child.foo #=> 200
Parent.foo #=> 200     // 値は継承ツリーの上位に反映される
Grandchild.foo #=> 200 // 値は継承ツリーの下位に反映される

訳注: cattr_accessormattr_accessorのエイリアスです。

3. class_attribute: 継承した値を親クラスに影響を与えずに上書き可能にしたい場合に使う

私はこのシナリオが最も有用だと思います。class_attributeを使うと親クラスの変数値が継承され、継承した値は親クラスに影響することなく子クラスで安全に再定義できます。

class Parent
  class_attribute :foo
end
class Child < Parent
end
class Grandchild < Child
end
Parent.foo = 100
Child.foo #=> 100      // 値は継承される
Child.foo = 200
Child.foo #=> 200
Parent.foo #=> 100     // 値を再定義しても親クラスには影響しない
Grandchild.foo #=> 200 // 再定義した値はその子クラスに継承される

関連記事

TestProf: Ruby/Railsの遅いテストを診断するgem(翻訳)

Rails: N+1クエリを「バッチング」で解決するBatchLoader gem(翻訳)

Rails: render_async gemでレンダリングを高速化(翻訳)

RubyのModule Builderパターン #2 Module Builderパターンとは何か(翻訳)

$
0
0

元記事が非常に長いので次のように分割しました。

概要

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

RubyKaigi 2017@広島でも発表されたshioyamaさんです。このときのセッションでもModule Builderパターンを扱っています。

本記事の図はすべて元記事からの引用です。また、パターン名は英語で表記しました。

RubyのModule Builderパターン #2 Module Builderパターンとは何か(翻訳)

Module Builder

ここまでに扱った3種類のadder系モジュールを振り返ってみましょう。

  1. Addable: 2つの変数xy#+メソッドを追加する。
  2. AdderDefiner: クラスメソッドdefine_adder(任意の変数のセットに#+メソッドを定義する)を追加する。

  3. AdderIncluder: これもクラスメソッドdefine_adder#+メソッドを定義する)を追加するが、このメソッドはsuperでコンポジションにできる。

1.のAddableモジュールはさほど柔軟ではありませんが、今思えば、これはこれでいくつかよい点があります。

  • #+だけをincludeするので、define_adderのような人工的なブートストラップが残らない(AdderDefinerAdderIncluderはそうではない)
  • superで親クラスからメソッドにアクセスできる(AdderIncluderでもできるがAdderDefinerではできない)
  • モジュールに名前があるのでancestorsチェインで簡単に確認できる。AdderDefinerAdderIncluderの場合、それ自身ancestorsチェインに表示されるが、ブートストラップメソッドが呼び出されたかどうか、どのように呼び出されたかの確認が困難(または確認不能)。

このセクションでは、こうしたadder系モジュールの利点を保つ方向で問題を解決する方法をご紹介します。前のセクションで追求した柔軟性もこの方法で得られます。

Module Builder

最初に、Moduleクラスを継承するAdderBuilderクラスを定義します。

class AdderBuilder < Module
  def initialize(*keys)
    define_method :+ do |other|
      self.class.new(*(keys.map { |key| send(key) + other.send(key) }))
    end
  end
end

小さなクラスですが、とても密度の高い内容です。このクラスで行われていることを詳しく説明する前に、動作を確認してみましょう。ここでは、このクラスをインスタンス化してモジュールにしたものをLineItemに(extendではなく)includeします。

class LineItem < Struct.new(:amount, :tax)
  include AdderBuilder.new(:amount, :tax)
end

l1 = LineItem.new(9.99, 1.50)
l2 = LineItem.new(15.99, 2.40)
l1 + l2
#=> #<LineItem:... @amount=25.98, @tax=3.9>

すると、このちっぽけなクラスはAdderDefiner内のブートストラップメソッド#define_adderと同じように動作します。唯一違う点は、この動作のためのクラスメソッドをまったく定義していないことです。

いったいどうやっているのでしょうか?まず、ここではModuleクラスをサブクラス化しています。Moduleクラスはnewを呼べるので、サブクラス化してinitializeなどのメソッドを独自に定義できるだろうと推測できます。そして先に示したとおり、本当にできるのです。

クラスの初期化部分を再録します。

def initialize(*keys)
  define_method :+ do |other|
    self.class.new(*(keys.map { |key| send(key) + other.send(key) }))
  end
end

このコードには2つのレベル(訳注: ネストのこと)があり、両方のレベルを認識することが重要です。1つのレベルでは、Moduleクラスの新しいインスタンスを初期化しています。このインスタンスはそれ自身がいわゆる(小文字の)モジュールであり、他のクラスにmixinできるメソッドコレクションです。次のレベルではこのモジュールを初期化し、メソッドのひとつとして#+を定義しています。

この#+メソッド自身は、Module.newに渡される引数(keysは上述の:amount:taxです)に含まれるメソッド名に沿って定義されます1。このメソッドの中でself.class.newを呼ぶと、この新しいモジュールをincludeするクラスのコンテキストでこのnewが評価され、そこでLineItem(またはPointのようにこのモジュールをincludeしたクラスなら何でもよい)を評価します。

すなわちAdderBuilder.new(:amount, :tax)は、以下と機能的に同等な方法でモジュールを評価します。

Module.new do
  def +(other)
    self.class.new(amount + other.amount, tax + other.tax)
  end
end

上はAddableで使ったコードですが、xyはそれぞれamounttaxに差し替えられます。しかしこのパワーは、モジュールの動的な定義で任意の変数名を指定できるという事実から生み出されています。

そして、このパターンの真の創意はまさしくここに潜んでいるのです。すなわち、モジュールを「メソッドや定数が固定されたコレクション」としてではなく、「欲しいコレクションをその場でビルドできる設定可能なプロトタイプ」として定義できるということです。これならクラスメソッドも不要ですし、モジュールのインクルード手順をブートストラップするメタプログラミング的トリックも不要です。このビルダはモジュールを直接ビルドするので、1ステップでモジュールをインクルードできます。

それだけではありません。先のセクションで解説したテクニックで生成される無名モジュールと異なり、この方法で作成されるモジュールには名前があるので、次のようにancestorsチェインではっきりと確認できます。

LineItem.ancestors
[LineItem, #<AdderBuilder:0x...>, Object, ... ]

これはまさしく、Moduleクラスのサブクラス化で達成したカプセル化の素敵な副産物です2。無名モジュールでは、以下のようにデバッグ時に読み取りにくくなります。

LineItem.ancestors
[LineItem, #<Module:0x...>, Object, ... ]

Module Builderパターンはこのように、無名モジュールのメリット(モジュールのインスタンスを「その場で」定義でき、かつグローバル定数の名前空間を汚さない)を得ながら、通常のモジュールと同様にインスタンスが名前を持つので、デバッグトレースが簡単です。そのうえ、ブートストラップするクラスをincludeするクラスでは新しいメソッドを定義する必要も呼び出す必要もありません。

それだけではありません。先ほどのコードでは、includeする側のクラスにログ出力のコードを少々追加しましたが、これもModule Builderとして書き直せます。これをLoggerBuilderと呼ぶことにします。

class LoggerBuilder < Module
  def initialize(method_name, start_message: "", end_message: "")
    define_method method_name do |*args, &block|
      print start_message
      super(*args, &block).tap { print end_message }
    end
  end
end

後はログ出力するメソッドの名前でLoggerBuilderのインスタンスを作成すれば、includeまたは継承したメソッドのログを出力できるようになります。

class LineItem < Struct.new(:amount, :tax)
  include AdderBuilder.new(:amount, :tax)
  include LoggerBuilder.new(:+, start_message: "Enter Adder...\n",
                                end_message: "Exit Adder...\n")
end

これだけで、足し算できる行項目からログが出力されます。

l1 = LineItem.new(9.99, 1.50)
l2 = LineItem.new(15.99, 2.40)

l1 + l2
Enter Adder...
Exit Adder...
#=> #<LineItem:... @amount=25.98, @tax=3.9>

もちろん、includeまたは継承されるどのメソッドも必要に応じてログ出力できます。

LineItem.include(LoggerBuilder.new(:to_s, start_message: "Stringifying...\n"))

l1.to_s
Stringifying...
=> "#<struct LineItem amount=9.99, tax=1.5>"

他にもいろんな側面がありますが、ここではごく表面的な説明にとどめます。現実に使われている、よりシンプルかつパワフルな実例については、Dry-rbのDry Equalizerをご覧ください。これは、AdderBuilderで使ったModuleクラスのサブクラス化と同じように、Equalizerクラスのインスタンスを使って同等性(equality)メソッドを動的にクラスに定義します。

Module Builderと「コンポジション」

本記事では、クラスにモジュールをincludeする文脈で「コンポジション(composition、composed)」という言葉が多用されていることにお気づきでしょうか。あるメソッドをオーバーライドするモジュールをincludeしてからsuperを呼ぶと、実質的に必ず関数(メソッド)のコンポジションになるのですが、このことは一般には指摘されていません。

訳注: コンポジションについては「Effective Java 16章「継承よりコンポジションを選ぶ」」をご覧ください。

これは、先のセクションで説明したログ出力コードについても同様です。AdderBuilderLoggerBuilderのインスタンスを両方includeすると、#+は関数コンポジションになるので、次のようにも書けます。

class LineItem < Struct.new(:amount, :tax)
  def add(other)
    self.class.new(amount + other.amount, tax + other.tax)
  end

  def log(result)
    print "Enter Adder..."
    result.tap { print "Exit Adder..." }
  end

  def +(other)
    log(add(other))
  end
end

したがって、superは本質的に最後のメソッド呼び出しの出力を受け取って、現在のモジュールのメソッドの結果に組み入れます。

偶然にも、コンポジションでは、あるModule Builderから生成された複数のインスタンスがきわめて興味深い方法で束ねられます。上の例では、ログ出力モジュールと追加用モジュールが束ねられます。Module Builderを使えば、さまざまなモジュールを設定して束ね、メソッドやクラスのより複雑な振舞いをビルドすることができます。

その方法のひとつは、メソッドフローの「分岐」です。モジュールをいくつか定義し、それぞれのモジュールが同じメソッドをオーバーライドします。そしてメソッド内の条件と引数がモジュールのステートと一致したら何か特別な処理を行い、一致しない場合はsuperによって制御フローがクラス階層の上のモジュールに移動する、というものです。

「コンポジションの分岐」といえば、Rubyを使った経験が少しでもある方なら例のmethod_missingメソッドを連想することでしょう。superせずにmethod_missingをオーバーライドする人はまずいないはずです。そんなことをしたら、クラスに定義されていないメソッドまでキャッチしてしまう可能性があり、一般的にはそうした動作を望む人はいないでしょう。

method_missingの典型的なオーバーライドでは、以下のようにsuperするのが普通です。

def method_missing(method_name, *arguments, &block)
  if method_name =~ METHOD_NAME_REGEX
    # ... 何か特別な処理を行う ...
  else
    super
  end
end

上のMETHOD_NAME_REGEXは、「何か特別な処理を行う」べきかどうかを調べる正規表現です。

実際私は、MobilityFallthroughAccessorsというModule Builderでまさにこのフロー分岐を使いました。FallthroughAccessorsの各インスタンスは属性のセット(=ステートを表す)で初期化され、属性の1つにロケールサフィックスを追加したメソッド名(例: フランス語のタイトル属性はtitle_fr)が生成される場所でメソッド呼び出しをインターセプト(intercept: 奪い取る)します。私はこれを「i18nアクセサ」と呼んでいます。メソッド名が一致すれば、そのサフィックスのロケールに含まれる属性値が返され、一致しない場合、制御は引き続きクラス階層を上昇します。

Module Builderでこのようにmethod_missingのコンポジションを行うと、正規表現のネストした条件が一連のモジュール全体に広がります。私はこれをインターセプタ(interceptor)と呼んでいます。これらのインターセプタは全体がカプセル化され、そのクラス自身が外部に依存しないという事実があります。そのためMobilityのi18nアクセサは、翻訳された各属性に自由に「プラグイン」できるようになります。しかも両者は互いに完全に独立しています。

私はこのパターンがMobilityで有用であることに気づき、MethodFoundというgemに切り出しました。MethodFoundは本質的にMethodFound::Interceptorという1つのModule Builderであり、1つの正規表現やproc(ここではステート)に一致するメソッド呼び出しをインターセプトし、メソッド名/正規表現でキャプチャできたマッチ(またはprocの戻り値やメソッド)のすべての引数をブロック(上の「何か特別な処理を行う」の部分)に渡します3。このブロックは、モジュールをincludeするクラスのインスタンスのコンテキストで評価されます。

次に例を示します。

class Greeter < Struct.new(:name)
  include MethodFound::Interceptor.new(/\Asay_([a-zA-Z_]+)\Z/) { |method_name, matches|
    "#{name} says: #{matches[1].gsub('_', ' ').capitalize}."
  }
  include MethodFound::Interceptor.new(/\Ascream_([a-zA-Z_]+)\Z/) { |method_name, matches|
    "#{name} screams: #{matches[1].gsub('_', ' ').capitalize}!!!"
  }
  include MethodFound::Interceptor.new(/\Aask_([a-zA-Z_]+)\Z/) { |method_name, matches|
    "#{name} asks: #{matches[1].gsub('_', ' ').capitalize}?"
  }
end

上のmethod_missingは3つのインターセプタのコンポジションです。Greeterクラスにはこれら以外にトレースがないにもかかわらず、含まれている正規表現マッチャーに対応する3つのモジュールビルダをancestorsで直接表示できます(表示のため、インターセプタはModule#inspectをオーバーライドしています)。

Greeter.ancestors
=> [Greeter,
#<MethodFound::Builder:0x...>,
#<MethodFound::Interceptor: /\Aask_([a-zA-Z_]+)\Z/>,
#<MethodFound::Interceptor: /\Ascream_([a-zA-Z_]+)\Z/>,
#<MethodFound::Interceptor: /\Asay_([a-zA-Z_]+)\Z/>,
Object, ...]

つまり、クラスにないメソッドが呼び出されると、呼び出しはクラス階層を上昇して最初の「ask」インターセプタの正規表現と一致するかどうかをチェックします。一致する場合は値を1つ返し、一致しない場合はその上の階層にある「scream」インターセプタで次の正規表現と一致するかどうかをチェックし、という具合に繰り返します。

結果は次のとおりです。

greeter = Greeter.new("Bob")
greeter.say_hello_world
#=> "Bob says: Hello world."
greeter.scream_good_morning
#=> "Bob screams: Good morning!!!"
greeter.ask_how_do_you_do
#=> "Bob asks: How do you do?"

上の例ではmethod_missingとModule Builderが使われていますが、ここで1つの疑問が生じます。上のような実装をModule Builderを使わずに、しかも同じ程度に柔軟な方法で行うことは果たして可能でしょうか。

仮にModule Builderを使わないことにすると、まずモジュールのステートの置き場所がなくなってしまいます。「普通の」モジュールにはそんなものを置く場所はありません。つまり、インターセプタ内の正規表現やブロックは別の場所に置く必要があるでしょう。これらを自然に置ける場所があるとすれば、includeする側のクラス自身しかありません。

このような方法には多くの問題があり、現実の例を1つ見てみれば問題点が明確になるはずです。

図版(#2で使用しているもの)

c “Proposed demonstration of simple robot self-replication.” reference

関連記事

[保存版]人間が読んで理解できるデザインパターン解説#1: 作成系(翻訳)

[保存版]人間が読んで理解できるデザインパターン解説#2: 構造系(翻訳)

Rubyで学ぶデザインパターンを社内勉強会で絶賛?連載中


  1. 実際には、これらのキーはクロージャを用いて定義されています。 
  2. amounttaxといった変数をincludeするこのModule Builderクラスに#inspectメソッドも定義しておけば、ancestorsチェインがさらに読みやすくなります。 
  3. MethodFoundでは他にも行っていることがあります。正規表現やprocに一致するメソッド名を「キャッシュ」してインターセプタモジュール上で定義し、以後の呼び出しを大幅に高速化しています。method_missingでメソッド名と一致した後にインターセプタ上のメソッドを調べてみればおわかりいただけると思います。 

週刊Railsウォッチ(20171020)Rubyが来年で25周年、form objectでサニタイズ、コアなString解説本ほか

$
0
0

こんにちは、hachi8833です。WPA2プロトコルの脆弱性の次は、特定のセキュリティチップでRSA秘密鍵の脆弱性が見つかったそうです。

10月3回目のRailsウォッチ、いってみましょう。

来年2/24に品川でRuby 25周年記念イベント


http://25.ruby.or.jp/より

Matzを始めとする講演がいくつか行われるそうです(詳細は変更の可能性あり)。

Ruby25 周年記念イベント開催実行委員会
委員長 まつもとゆきひろ(一般財団法人 Rubyアソシエーション理事長)
副委員長 井上 浩(ネットワーク応用通信研究所 代表取締役)
委員
– 前田修吾(ネットワーク応用通信研究所 取締役)
– 高橋征義(一般社団法人 日本Rubyの会 代表理事/株式会社達人出版会 代表取締役)
– 笹田耕一(一般財団法人 Rubyアソシエーション 理事/クックパッド株式会社)
http://25.ruby.or.jp/images/sponsorship.pdfより

Rails: 今週の改修

公式の更新がなかったのでcommitから見繕いました。

headless chrome driverとchromedriver-helper gemを追加

# actionpack/test/abstract_unit.rb
+class DrivenBySeleniumWithHeadlessChrome < ActionDispatch::SystemTestCase
+  driven_by :selenium, using: :headless_chrome
+end
# railties/lib/rails/generators/rails/app/templates/Gemfile
  # Adds support for Capybara system testing and selenium driver
   gem 'capybara', '~> 2.15'
   gem 'selenium-webdriver'
+  # Easy installation and use of chromedriver to run system tests with Chrome
+  gem 'chromedriver-helper'
   <%- end -%>
 end

つっつきボイス: 「上はy-yagiさんでした」「capybaraたんガンバレー」

GROUP BYORDER BYLIMITを使った場合のCOUNT(DISTINCT ...)を修正

# activerecord/lib/active_record/relation/calculations.rb
         if operation == "count"
           column_name ||= select_for_count
           if column_name == :all
-            if distinct && !(has_limit_or_offset? && order_values.any?)
+            if distinct && (group_values.any? || !(has_limit_or_offset? && order_values.any?))
               column_name = primary_key
             end
           elsif column_name =~ /\s*DISTINCT[\s(]+/i

つっつきボイス:#30886の問題が修正されたのか↓」

#30886より
モデル.group(:id).order('1 DESC').limit(30).distinct.count

# 期待するSQL(モデルは`device`)
SELECT COUNT(DISTINCT "devices"."id") AS count_id, "devices"."id" AS devices_id FROM "devices" GROUP BY "devices"."id" ORDER BY 1 DESC LIMIT $1

# 実際のSQL: SELECT COUNT(DISTINCT *)の部分がおかしい
SELECT COUNT(DISTINCT *) AS count_all, "devices"."id" AS devices_id FROM "devices" GROUP BY "devices"."id" ORDER BY 1 DESC LIMIT $1

db/schema.rbでMySQLのauto_increment: trueチェックを追加

# activerecord/lib/active_record/connection_adapters/mysql/schema_dumper.rb
           def prepare_column_options(column)
             spec = super
             spec[:unsigned] = "true" if column.unsigned?
+            spec[:auto_increment] = "true" if column.auto_increment?

to_s(:db)をアルファベット文字列のrangeにも対応

# activesupport/lib/active_support/core_ext/range/conversions.rb
module ActiveSupport::RangeWithFormat
   RANGE_FORMATS = {
-    db: Proc.new { |start, stop| "BETWEEN '#{start.to_s(:db)}' AND '#{stop.to_s(:db)}'" }
+    db: -> (start, stop) do
+      case start
+      when String then "BETWEEN '#{start}' AND '#{stop}'"
+      else
+        "BETWEEN '#{start.to_s(:db)}' AND '#{stop.to_s(:db)}'"
+      end
+    end
   }

つっつきボイス:Range#to_s(:db)ってのがあるとは」「手元のRails環境でrails consoleやってみると確かにString(アルファベット文字)でto_s(:db)がエラーになる↓: これが修正されたということか」

irb(main):003:0> range = (1..100)
=> 1..100
irb(main):004:0> range.to_s
=> "1..100"
irb(main):005:0> range.to_s(:range)
=> "1..100"
irb(main):006:0> range.to_s(:db)
=> "BETWEEN '1' AND '100'"
irb(main):007:0> range = ('a'..'z')
=> "a".."z"
irb(main):008:0> range.to_s(:db)
ArgumentError: wrong number of arguments (given 1, expected 0)
        from (irb):8

「他にも#to_s(db)できるクラスってあるかな: お、ActiveSupport::TimeWithZoneにもある」

    # Returns a string of the object's date and time.
    # Accepts an optional <tt>format</tt>:
    # * <tt>:default</tt> - default value, mimics Ruby Time#to_s format.
    # * <tt>:db</tt> - format outputs time in UTC :db time. See Time#to_formatted_s(:db).
    # * Any key in <tt>Time::DATE_FORMATS</tt> can be used. See active_support/core_ext/time/conversions.rb.
    def to_s(format = :default)
      if format == :db
        utc.to_s(format)
      elsif formatter = ::Time::DATE_FORMATS[format]
        formatter.respond_to?(:call) ? formatter.call(self).to_s : strftime(formatter)
      else
        "#{time.strftime("%Y-%m-%d %H:%M:%S")} #{formatted_offset(false, 'UTC')}" # mimicking Ruby Time#to_s format
      end
    end

RailsのRuboCopを0.50.0にアップデート

# .codeclimate.yml
 engines:
   rubocop:
     enabled: true
-    channel: rubocop-0-49
+    channel: rubocop-0-50

つっつきボイス: 「今さらですが、Railsフレームワーク開発にもRuboCopが使われているんだなと思って」「CodeClimateで回してますな」


codeclimate.comより

Rails

RubyのアプリサーバーがMac OS High Sierraで落ちる理由


blog.phusion.nlより

この間のRailsウォッチでPumaが落ちる問題(#1421)をお伝えしましたが、他のRubyサーバーも含め、原理や対応策を詳しく解説しています。PassengerでおなじみのPhosionなので、Passengerは次の5.1.11でこの問題の回避策を含めるそうです。

High SierraでObjective-Cライブラリが変更されたことで顕在化しましたが、根本的にはマルチスレッドとfork()が絡み合った問題であり、Linuxも無縁ではないそうです。


つっつきボイス: 「High Sierraでは落ちるべきときに即落ちるようになったということか」「マカーだけどMacで本番サーバーを動かすことはないし、最近はDocker使うことも多いのでそっちに移行すればいいんじゃないかしら」

Puma/Unicorn/Passengerの効率を最大化する設定(Awesome Rubyより)


speedshop.coより

以前もTechRachoでパフォーマンス関連の記事を翻訳させていただいたNate Berkopecさんが以下の値の設定について解説しています。

  • 子プロセス数
  • スレッド数
  • Copy-on-write
  • コンテナサイズ

つっつきボイス: 「解説系の記事だけど、もう少しだけ指標をバシッとわかりやすく出してくれたらうれしいかな: Rails serverに特化しているようでもないし」「例のComplete Guide to Rails Performanceの著者なので、そっちを買ってくださいということかもですね」

railsspeed.comより

default_scopeを使ってはいけない理由(Ruby Weeklyより)


andycroll.comより

とても短い記事です。他の記事も小粒に抑えられていて読みやすいです。


つっつきボイス: 「うんうん、ActiveRecordのdefault_scopeは使うと後で困ったことになるやつ」「ちょうどBPSの新人君がこの間default_scope踏んで相当頭にきてた」

よくある?Rails失敗談 default_scope編

書籍: Effective Testing with RSpec 3(Ruby Weeklyより)


pragprog.comより


つっつきボイス: 「RSpec 3の基本を学ぶにはよさそうかな」「ただ、実際業務で欲しいテストケースやマッチャーを本だけで網羅するのはたぶん不可能: たとえばDBが4つあるアプリのテストをどうやって書くか、とか」「そこから先は自分で頑張るかググって調べることになりますね」

form objectで属性をサニタイズする(Awesome Rubyより)


drivy.engineeringより

短いですが良記事です。

# 同記事より
class SanitizedVatNumber < Virtus::Attribute
  def coerce(value)
    value.respond_to?(:to_s) ? value.to_s.gsub(/\s+/, '') : value
  end
end

つっつきボイス: 「おー、こういうの結構好き: form objectVertusは相性がいいし、そこに#coerceを組み合わせるのがうまい」「テストが単純になるのもいい」

参考

肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)

ActiveRecordクエリのトリック


medium.com/rubyinsideより

  1. 関連付けられたテーブルに条件付きでJOIN
  2. nested JOINする別の方法
  3. 存在確認クエリ
  4. サブクエリ
  5. ActiveRecordの基本を思い出そう
  6. 論理値

つっつきボイス: 「新しい話は特にないけど、クエリの使い方の一覧として見ておくといいかも」

Railsで動的なエラーページを作る(RubyFlowより)


pooreffort.comより


つっつきボイス: 「エラーページを動的にしたい気持ちはわかるけど、Railsサーバーが死んだら表示されなくなるのでおすすめしない」

Rails APIにはどのJSON Serializerがいいか(RubyFlowより)


www.carlosramireziii.comより


つっつきボイス: 「Jbuilderは最近それほど評価されてないのをよく見かけるので、ActiveModel::Serializersが市民権を獲得しつつあるのかもしれない」

Rails: ActiveModelSerializersでAPIを作る–Part 1(翻訳)

Aruba: コマンドラインツールをCucumber/RSpec/Minitestでテストするgem(Awesome Rubyより)

1か月ほどで★700個超えです。


つっつきボイス: 「どうやって使うのかなと思ったら、#run_commandなどのhelperを追加してCLIのテストを書けるようにしているのか↓」

# app.cucumber.proの /04_aruba_api/command/find_a_started_command.feature より
require 'spec_helper'

RSpec.describe 'Run command', :type => :aruba do
  before(:each) { run_command('echo hello') }
  let(:command) { find_command('echo hello') }

  before(:each) { stop_all_commands }

  it { expect(command).to be_successfully_executed }
  it { expect(command.commandline).to eq 'echo hello' }
end

ReportsKit: Railsで使える美しいチャート/表(Ruby Weeklyより)


github.com/tombenner/reports_kitより


つっつきボイス: 「この種のChartライブラリは他にもあるけど、JSのobjectでとても深いnested設定オブジェクトを作る代わりにYAMLで設定書ける↓というのはそれだけで嬉しいですね」「サイトでBootstrap使っているのがひと目でわかる」

measure: post
filters:
- author
- created_at
dimensions:
- created_at
- author
chart:
  type: line
  options:
    scales:
      yAxes:
      - stacked: true

Redisの作者だけど15年間英語で苦しみまくってた

英語の学習速度がこんなに遅かったのは、習い始めに英語を目で読んでばかりでリスニングをやってなかったせいだ。おかげで発音がめちゃめちゃなまま単語を覚えてしまった。今英語学んでるやつに言っとく: 話せるようになりたかったら絶対リスニングやれ。

4年前の記事ですが、ほろっときたので翻訳しようと思います。


redis.ioより

Railsのディレクトリを解説(Ruby Weeklyより)

一度見れば十分だと思いますが、Rails本体だけでなく、著名なgemで追加されるディレクトリも解説しているのが珍しいと思いました。

nullalign: NOT NULL制約のつけ忘れを警告するgem(Ruby Weeklyより)

$ bundle exec nullalign
There are presence validators that aren’t backed by non-null constraints.
--------------------------------------------------------------------------------
Model              Table Columns
--------------------------------------------------------------------------------
Album              albums: name, owner_id
AttendanceRecord   attendance_records: group_id, attended_at
CheckinLabel       checkin_labels: name, xml
CheckinTime        checkin_times: campus
CustomField        custom_fields: name, tab_id

作者のTom CopelandさんはRuby Hero 2008の受賞者です。

PgParty: Active RecordのPostgreSQL 10パーティショニングgem(Ruby Weeklyより)

# github.com/rkrage/pg_partyより
class CreateSomeListRecord < ActiveRecord::Migration[5.1]
  def up
    create_list_partition :some_list_records, partition_key: :id do |t|
      t.text :some_value
      t.timestamps
    end

    create_list_partition_of \
      :some_list_records,
      partition_key: :id,
      values: (1..100).to_a

     create_list_partition_of \
       :some_list_records,
       partition_key: :id,
       values: (100..200).to_a
  end
end

つっつきボイス: 「PostgreSQL 10のRange Partitioningはとても簡単に使えるようになってていいらしい」「↓この記事がわかりやすかった」

Vanilla Rails: Railsのgemをどこまで断捨離できるか(Awesome Rubyより)

メリットは「速い」「わかりやすい」「gemにわずらわされない」など、デメリットは「生産性が落ちた」「車輪の再発明」だったそうです。


つっつきボイス: 「rspec-railsとhttpとactive_model_serializersだけは手放せないのね」
httpってgemあるんですね↓: すごく検索しにくそう」「Net::HTTPとは別のHTTPクライアントですが、今ならhttp_clientかな」

Rails 5ブロンズ試験のベータ版が11/12に実施

Ruby trunkより

ubygems.rbを削除(Ruby Weeklyより)

# tool/sync_default_gems.rb
   case gem
    when "rubygems"
 -    `rm -rf lib/rubygems* lib/ubygems.rb test/rubygems`
 +    `rm -rf lib/rubygems* test/rubygems`
      `cp -r ../../rubygems/rubygems/lib/rubygems* ./lib`
 -    `cp -r ../../rubygems/rubygems/lib/ubygems.rb ./lib`
      `cp -r ../../rubygems/rubygems/test/rubygems ./test`

ubygems.rbって何だろうと思ったら、オプションを-rubygemsと書けるようにするためだけのものでした」「あー、あるあるそういうの」「このオプションに別れを惜しむ人が結構いるんですね↓」


e5e1f9より

Ruby

Rubyのreduceの基本を知る

# 同記事より
def select(list, &fn)
  list.reduce([]) { |a, i| fn[i] ? a.push(i) : a }
end
select([1,2,3]) { |i| i > 1 }
# => [2, 3]

つっつきボイス:#inject#reduce(同じだけど)は使うたびにちょっとドキドキする」「#injectって実はパフォーマンスあまりよくなくて、最近は#sumの方が速かったりしますけどね」「reduceの動作をLispで説明しているのがわかりやすいかも↓」

(+ (+ (+ (0) 1) 2) 3)

rotoscope: 高速メソッド呼び出しロガーgem(Ruby Weeklyより)

まだ★は少ないですが、Shopifyなので一応。ちょっとしたデバッグに便利そうです。

# github.com/Shopify/rotoscopeより
require 'rotoscope'

class Dog
  def bark
    Noisemaker.speak('woof!')
  end
end

class Noisemaker
  def self.speak(str)
    puts(str)
  end
end

log_file = File.expand_path('dog_trace.log')
puts "Writing to #{log_file}..."

Rotoscope.trace(log_file) do
  dog1 = Dog.new
  dog1.bark
end

こんなふうにログを取れます。

event,entity,method_name,method_level,filepath,lineno
call,"Dog","new",class,"example/dog.rb",19
call,"Dog","initialize",instance,"example/dog.rb",19
return,"Dog","initialize",instance,"example/dog.rb",19
return,"Dog","new",class,"example/dog.rb",19
call,"Dog","bark",instance,"example/dog.rb",4
call,"Noisemaker","speak",class,"example/dog.rb",10
call,"Noisemaker","puts",class,"example/dog.rb",11
call,"IO","puts",instance,"example/dog.rb",11
call,"IO","write",instance,"example/dog.rb",11
return,"IO","write",instance,"example/dog.rb",11
call,"IO","write",instance,"example/dog.rb",11
return,"IO","write",instance,"example/dog.rb",11
return,"IO","puts",instance,"example/dog.rb",11
return,"Noisemaker","puts",class,"example/dog.rb",11
return,"Noisemaker","speak",class,"example/dog.rb",12
return,"Dog","bark",instance,"example/dog.rb",6

つっつきボイス: 「rotoscope、何かの発表で使っているところを見たような気がする」

GCPのStackdriverでRubyアプリのデバッグとログ出力が可能に


つっつきボイス: 「ここRailsでも使えるかな?」「使えるみたいです↓」「おー、StackDriverでRailsのログ見られるのいいな: Dockerコンテナでログをどうやって収集するかがいつもつらみなんで」

GitHubリポジトリ: stackdriver

Haml 5.0.4がリリース

Rubyで関数型プログラミング(Ruby Weeklyより)

同記事で以下のPFaaOという見慣れないパターンに言及していました。


つっつきボイス: 「pure functionは関数型言語で言う関数のようだ: 入力が同じなら必ず出力が同じ」
「数学だとそういうの何って言いましたっけ…」「単射かな」「単射は英語でinjective mappingなのかー」

k0kubunさんの「YARV-MJIT」記事

RubyKaigi 2017のLTではLLVMでJITをやっていたのが、今度はYARVでやってみたそうです。


つっつきボイス: 「k0kubunさんまじつよい」

mrubyのdo end内でrescueできるようになった

↓このコミットのようです。

書籍: Mastering Ruby Strings and Encodings(Ruby Weeklyより)

この間のTechRacho翻訳記事「ガイジン向けRubyKaigiガイド」の著者であるRichard Schneeman氏の推薦文付きです。

# 同記事より
'é' == 'é' # => false
[128077, 32, 128078].pack('U*')

つっつきボイス: 「これ個人的に好きな内容: 買ってみよう」「Stringだけで1冊書くってすごい」
「普通なら表示できない文字もpackでできると」「packって確かC言語由来でしたっけ」

Rubyカンファレンスを初めて仕切ってみた感想(Ruby Weeklyより)


2017.southeastruby.comより

以前のウォッチでも紹介したSouthEast Ruby 2017です。


つっつきボイス: 「カンファレンスの運営はほんと大変だと思う」「ざっくりだけど、会場費8000ドルといった予算も記事に書かれているのがいいですね: 自分でやってみたい人には参考になるかも」

台湾で2018年4月にRubyとElixirのカンファレンスが開催


2018.rubyconf.twより


つっつきボイス: 「RubyとElixirが並ぶカンファレンス初めて」「Rubyが液化して滴ってるみたいダナー」

JavaScript

JavaScriptの正規表現の奥深い概念


geekstrick.comより

// 同記事より
  var myRegExp = /Geeks Trick/igm;
  alert("Source: " + myRegExp.source +
        "\n Ignore Case : " + myRegExp.ignoreCase +
        "\n Global : " + myRegExp.global +
        "\n Multiline : " + myRegExp.multiline +
        "\n Last Index: " + myRegExp.lastIndex)

つっつきボイス: 「JSの正規表現、もうちょっと知っておきたいので読んでみようかな」「JSの正規表現はlook behindがないのが常々残念です: パフォーマンス悪いのはわかってるんですが」「\p{Katakana}みたいなUnicode文字クラス指定もないし」

CSS/HTML/フロントエンド

Mozillaの新生MDN Web DocsにMicrosoft/Google/W3C/Samsungなども参加


blogs.windows.comより

複数ブラウザ向けのWeb関連ドキュメントがWeb Docsに集約されることになるようです。Appleはリストに載ってませんね。

その他

DockerがKubernetesをネイティブサポート


blog.codeship.comより


つっつきボイス: 「これはもしかするとDocker Swarmの終了が近いのかな? Docker Swarmをproductionで使っているのを見たことないけど」

developerブートキャンプは衰退期に入ったか

ここには引用しませんが、記事からリンクされているReuterの統計情報を見ると2014年をピークに激減しています。

Algorithmia: AI機能のマーケットプレイス


algorithmia.comより

Googleが出資しているそうです。とりあえずアカウントを作って検索してみたところ、「japanese」ではまだ6件でしたが、その場で試すこともできました。


lgorithmia.comより

Alpha Goがゼロから学習して既存の人工知能を破る


deepmind.comより

番外

ネイティブみたいに発音できなくてもいい

この@e_kazumaさんの「今日のタメ口英語」は言葉のセンスが今風かつ非常によくて気に入りました。


今週は以上です。

バックナンバー(2017年度)

週刊Railsウォッチ(20171013)Ruby 2.5.0-preview1リリース、RubyGems 2.6.14でセキュリティバグ修正、Bootstrap 4.0がついにBetaほか

今週の主なニュースソース

ソースの表記されていない項目は独自ルート(TwitterやRSSなど)です。

Ruby 公式ニュース

Rails公式ニュース

Ruby Weekly

Awesome Ruby

RubyFlow

160928_1638_XvIP4h

Frontend Weekly

frontendweekly_banner_captured

Hacker News

160928_1654_q6srdR

RailsのCSRF保護を詳しく調べてみた(翻訳)

$
0
0

概要

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

RailsのCSRF保護を詳しく調べてみた(翻訳)

現在Railsを使っていればCSRF保護を使うことがあるでしょう。この機能はRailsのほぼ初期から存在し、即座に導入して開発を楽にできるRailsの機能のひとつです。

CSRF(Cross-Site Request Forgery)を簡単に説明すると、悪意のあるユーザーがサーバーへのリクエストを捏造して正当なものに見せかけ、認証済みユーザーを装うという攻撃手法です。Railsでは、一意のトークンを生成して送信のたびに真正性を確認することでこの種の攻撃から保護します。

最近私がUnbounceのある機能を使ったとき、CSRF保護と、CSRF保護をクライアント側のJavaScriptリクエストでどう扱うかについて考慮が必要になりました。そのとき、自分がCSRF保護についてほとんど何も知らないどころか、CSRFが何の略語なのかも知らないことに気づきました。

そこで私は、Railsコードベースでこの機能がどのように実装されているかを詳しく調べることにしました。本記事では、RailsでのCSRF保護の動作を追ってみました。レスポンスごとのトークンが最初にどうやって生成されるか、およびサーバーへのリクエストの真正性のバリデーションについても解説いたします。

基本

CSRFには2つの要素で構成されます。最初にサイトのHTMLに一意のトークンを埋め込みます。これと同じトークンはセッションcookieにも保存されます。ユーザーがPOSTリクエストを送信するときに、HTMLに埋められていたCSRFトークンも一緒に送信されます。Railsはページのトークンとセッションcookie内のトークンを比較し、両者が一致することを確認します。

CSRF保護の利用法

Rails開発者はCSRF保護を無料で利用できます。最初に、application_controller.rbファイルでCSRF保護をオンにする以下の1行を有効にします。

protect_from_forgery with: :exception

次に、application.html.erbに次の1行を追加します。

<%= csrf_meta_tags %>

これでおしまいです。この機能は長年Railsに搭載されているので、開発者はこれを利用するかどうか決めるだけでよいのです。しかしこの機能はどのように実装されているのでしょうか?

生成と暗号化

まずは#csrf_meta_tagsから調べてみましょう。これはHTMLに真正性トークンを埋め込むシンプルなビューヘルパーです(gist: csrf_helper.rb)。

# actionview/lib/action_view/helpers/csrf_helper.rb

def csrf_meta_tags
  if protect_against_forgery?
    [
      tag("meta", name: "csrf-param", content: request_forgery_protection_token),
      tag("meta", name: "csrf-token", content: form_authenticity_token)
    ].join("\n").html_safe
  end
end

csrf-tokenタグにご注目ください。すべてのマジックはここで起きます。tagヘルパーは#form_authenticity_tokenを呼んで実際のトークンを取り出します。そしてActionControllerのRequestForgeryProtectionモジュールに進むと面白くなってきます。

RequestForgeryProtectionモジュールは、CSRF関連の一切を取り扱います。中でも有名なのはApplicationControllerで見かける#protect_from_forgeryです。これはリクエストごとにCSRFバリデーションをトリガするフックを設定し、リクエストの真正性を照合できなかった場合のレスポンスを設定します。この他にもCSRFトークンの生成/暗号化/復号化も担当します。このモジュールはスコープが小さい点が気に入りました。ビューヘルパーを別にすれば、CSRF保護の実装は1ファイルに収まっています。

続いて、CSRFトークンがHTMLに達するまでを詳しく見てみましょう。
#form_authenticity_tokenは、セッション自身を含む任意のオプションパラメータを#masked_authenticity_tokenに渡すシンプルなラッパーメソッドです(gist: request_forgery_protection.rb)。読みやすさのためコードの一部を省略しています。

# actionpack/lib/action_controller/metal/request_forgery_protection.rb

# トークンの値を現在のセッションに設定
def form_authenticity_token(form_options: {})
  masked_authenticity_token(session, form_options: form_options)
end

# リクエストごとに異なる真正性トークンのマスキング版を作成する
# マスキングはBREACHなどのSSL攻撃の緩和のため
def masked_authenticity_token(session, form_options: {}) # :doc:
  # ...
  raw_token = if per_form_csrf_tokens && action && method
    # ...
  else
    real_csrf_token(session)
  end

  one_time_pad = SecureRandom.random_bytes(AUTHENTICITY_TOKEN_LENGTH)
  encrypted_csrf_token = xor_byte_strings(one_time_pad, raw_token)
  masked_token = one_time_pad + encrypted_csrf_token
  Base64.strict_encode64(masked_token)
end

Rails 5でフォームごとのCSRFトークンが導入されたため、masked_authenticity_tokenメソッドはやや複雑になっています。本記事では本来の実装である「リクエストごとに1つのCSRFトークンがmetaタグに達する」ところを追うことにします。この場合、上のelse分岐で#real_csrf_tokenの戻り値にraw_tokenが設定されます。

#real_csrf_tokensessionを渡す理由がおわかりでしょうか。このメソッドは実際には2つの動作を実行するからです。1つは暗号化されていない生トークンの生成、もう1つはトークンのセッションcookieへの埋め込みです(gist: equest_forgery_protection.rb)。

# actionpack/lib/action_controller/metal/request_forgery_protection.rb

def real_csrf_token(session) # :doc:
  session[:_csrf_token] ||= SecureRandom.base64(AUTHENTICITY_TOKEN_LENGTH)
  Base64.strict_decode64(session[:_csrf_token])
end

このメソッドは最終的に、アプリのレイアウトの#csrf_meta_tagsが呼び出されると呼び出されることを思い出しましょう。これは昔ながらのRailsマジックです。この賢い副作用によって、セッションcookieのトークンがページのトークンと一致することが保証されます。保証される理由は、ページのトークンのレンダリングが行われるときには必ず同じトークンがcookieに挿入されるからです。

とにかく、#masked_authenticity_tokenの最後の方を見てみましょう(gist: request_forgery_protection.rb)。

# request_forgery_protection.rb
  one_time_pad = SecureRandom.random_bytes(AUTHENTICITY_TOKEN_LENGTH)
  encrypted_csrf_token = xor_byte_strings(one_time_pad, raw_token)
  masked_token = one_time_pad + encrypted_csrf_token
  Base64.strict_encode64(masked_token)

ここでは暗号化が行われます。セッションcookieにはトークンを挿入済みなので、このメソッドはプレーンテキストHTMLで使われるトークンを返す作業にかかります。ここではいくつかの点に注意します(主にSSL BREACH攻撃の緩和のためですが、ここでは立ち入りません)。Rails 4以降はセッションcookie自体を暗号化するようになったため、セッションcookieに含めるトークンそのものは暗号化されない点にご注目ください。

最初に、生トークンの暗号化で使うワンタイムパッドを生成します。ワンタイムパッドは、長さの揃った平文メッセージをランダム生成キーで暗号化する手法で、メッセージの復号には同じキーが必要です。「ワンタイム(1回限り)」と呼ばれる理由は、メッセージごとに異なるキーを用い、利用後は破棄されるからです。Railsでは、CSRFトークンを新しく作成するたびに新しいワンタイムパットを生成し、平文トークンをビットごとのXOR操作で暗号化するためにこの機能を実装しています。このワンタイムパッド文字列は暗号化文字列の前に追加され、HTMLで使えるようBase64でエンコードされます。

CSRFトークン暗号化の仕組みの概要を図示します。デフォルトのトークンは長さは32文字ですが、ここでは12文字にしています。

操作が完了すると、マスクされた真正性トークンはスタックに戻され、アプリでレンダリングされたレイアウトに達します(gist: index.html)。

<!-- index.html -->
<meta name="csrf-param" content="authenticity_token" />
<meta name="csrf-token" content="vtaJFQ38doX0b7wQpp0G3H7aUk9HZQni3jHET4yS8nSJRt85Tr6oH7nroQc01dM+C/dlDwt5xPff5LwyZcggeg==" />

復号化と照合

CSRFトークンの生成と、トークンがHTMLとcookieに達するまでの解説が終わりましたので、次はRailsへのリクエストのバリデーションを見てみることにしましょう。

ユーザーがサイトにフォームを送信すると、フォームの他のデータとともにCSRFトークンが送信されます(デフォルトのparam名はauthenticity_tokenです)。トークンは、HTTPヘッダーX-CSRF-Tokenでも送信できます。

先ほどApplicationControllerに以下を追加したことを思い出しましょう。

protect_from_forgery with: :exception

この#protect_from_forgeryメソッドは、すべてのコントローラアクションのライフサイクルの途中にbefore-actionを追加します。

before_action :verify_authenticity_token, options

このbefore_actionで、リクエストのparamsやヘッダーにあるCSRFトークンと、セッションcookieとの比較を開始します(gist: request_forgery_protection.rb)。

# actionpack/lib/action_controller/metal/request_forgery_protection.rb

def verify_authenticity_token # :doc:
  # ...
  if !verified_request?
    # エラー処理 ...
  end
end

# ...

def verified_request? # :doc:
  !protect_against_forgery? || request.get? || request.head? ||
    (valid_request_origin? && any_authenticity_token_valid?)
end
照合が成功する場合のフローに注目できるよう、一部のコードを省略しています。

いくつかの管理系タスクを実行後(HEADリクエストやGETリクエストなどの照合は不要です)、#any_authenticity_token_valid?の呼び出しで本格的な照合プロセスが開始されます(gist: request_forgery_protection.rb)。

# request_forgery_protection.rb
def any_authenticity_token_valid? # :doc:
  request_authenticity_tokens.any? do |token|
    valid_authenticity_token?(session, token)
  end
end

リクエストはトークンをフォームのparamsまたはヘッダーとして渡すことがあるので、Railsではいずれかのトークンがセッションcookie内のトークンと一致することだけが求められます。

#valid_authenticity_token?はそこそこ長いメソッドですが、要するに#masked_authenticity_tokenの逆操作を行って復号し、トークンを比較するだけです(gist: request_forgery_protection.rb)。

# request_forgery_protection.rb
def valid_authenticity_token?(session, encoded_masked_token) # :doc:
  # ...

  begin
    masked_token = Base64.strict_decode64(encoded_masked_token)
  rescue ArgumentError # encoded_masked_token の Base64は無効
    return false
  end

  if masked_token.length == AUTHENTICITY_TOKEN_LENGTH
    # ...

  elsif masked_token.length == AUTHENTICITY_TOKEN_LENGTH * 2
    csrf_token = unmask_token(masked_token)

    compare_with_real_token(csrf_token, session) ||
      valid_per_form_csrf_token?(csrf_token, session)
  else
    false # 不正なトークン
  end
end

最初に、Base64でエンコードされた文字列を受けて復号し、「マスク済みトークン」を得る必要があります。この後で、トークンのマスクを解除してセッションのトークンと比較します(gist: request_forgery_protection.rb)。

# request_forgery_protection.rb
def unmask_token(masked_token) # :doc:
  one_time_pad = masked_token[0...AUTHENTICITY_TOKEN_LENGTH]
  encrypted_csrf_token = masked_token[AUTHENTICITY_TOKEN_LENGTH..-1]
  xor_byte_strings(one_time_pad, encrypted_csrf_token)
end

#unmask_tokenでトークン復号に必要な暗号マジックを行う前に、マスク済みトークンを必要なパーツ(ワンタイムパッド、暗号化済みトークン自身)に分割しておきます。続いて2つの文字列のXORを取ると、最終的な平文トークンを得られます。

最後に#compare_with_real_token(これはActiveSupport::SecureUtilに依存しています)でトークン同士が一致することを確認します(gist: request_forgery_protection.rb)。

# request_forgery_protection.rb
def compare_with_real_token(token, session) # :doc:
  ActiveSupport::SecurityUtils.secure_compare(token, real_csrf_token(session))
end

ついにリクエストが承認されました!「通るがよい(you shall pass)」

訳注: これはロード・オブ・ザ・リングのセリフのもじりです(本当はyou shall not pass: ここは通さぬ)。

まとめ

Railsには実にさまざまな要素があるので、Railsでは動いて当たり前のCSRF保護でこんなに頭を使ったのは初めてでした。たまにはこうやって魔法のカーテンの向こう側を覗いて実際の動作を眺めてみるのも楽しいものです。

CSRF保護の実装は、コードベースにおける「責任の分割」のよい例になっていると思います。モジュールを1つ作成し、小さくとも一貫したパブリックなインターフェイスで公開することで、コードベースにほぼまったく影響を与えずに背後の実装に任せることができます。Railsチームが長年にわたってフォームごとのトークンなどの新しい機能をCSRF保護に追加してくれているおかげで、CSRF保護が実際に動く様子をこうして見ることができます。

Railsのコードベースを詳しく調べるたびに、多くのことを学べます。本記事が、Railsのマジックに出会って仕組みを探るときのヒントになればと願っています。

関連記事

Railsアプリの認証システムをセキュアにする4つの方法(翻訳)

Ruby: 8/27発表のRubyGems脆弱性と修正方法のまとめ

そのパッチをRailsに当てるべきかを考える(翻訳)


Railsのsecret_key_baseを理解する(翻訳)

$
0
0

概要

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

Railsのsecret_key_baseを理解する(翻訳)

secret_key_baseの値の意味や、Railsアプリでの使われ方を知りたいと思ったことはありませんか。この設定値はRails 4から導入され、developmentやproductionといった環境ごとに定義されるのが普通ですが、値の目的はシンプルです。すなわち、値はアプリのkey_generatorメソッドのsecret入力として使われます

このメソッドはRails.application.key_generatorでアクセスでき、引数を取らず、ActiveSupport::CachingKeyGeneratorインスタンスをひとつ返します。続いて、CachingKeyGeneratorクラスが提供するgenerate_keyメソッドを使ってキーを導出します。secret_key_baseは、さまざまなセキュリティ機能で個別のキーを引き続き使えるようにしつつ、開発者が(訳注: 個別のキーを)設定する負荷を軽減する役割を持っています。

CachingKeyGeneratorクラスは特にActiveSupport::KeyGeneratorクラスをラップします。このクラスは名前のとおり、導出したキーを内部のハッシュにキャッシュして保存します。ハッシュのエントリはsalt入力によってインデックス化されます(gist: rails_app_key_generator.rb)。

# rails_app_key_generator.rb
# アプリのkey_generatorからキーを求める
# 戻り値: #<ActiveSupport::CachingKeyGenerator:0x00000004ae1b00 @key_generator=#<ActiveSupport::KeyGenerator:0x00000004ae1b28 @secret="...", @iterations=1000>, @cache_keys=#<Concurrent::Map:0x00000004ae1ad8 entries=0 default_proc=nil>>
Rails.application.key_generator

# 戻り値: "\a\xDD|\xEB\xE2\xD3\xEC\x05\xCA@C\xFBVD\xFB\xE5\x93o\e(\xDA\x83\x95}\xD3\x15\x91V\xA5y&6"
Rails.application.key_generator.generate_key('salt input', 32)

アプリのkey_generatorsecret_key_baseは、Railsフレームワーク内の3つのコア機能で利用されます。

  1. 暗号化cookieで使うキーの導出: coookies.encryptedでアクセス可能
  2. HMAC署名されたcookieで使うキーの導出: cookies.encryptedでアクセス可能
  3. アプリのすべての名前付きmessage_verifierインスタンスで使うキーの導出

1. 暗号化cookie

このcookieは、暗号化によってコンテンツに完全性と機密性を提供します。Railsのセッションcookieは完全性と機密性のために暗号化cookieからビルドされます。

暗号化方式に応じて、secret_key_baseから1つまたは2つのキーが生成されます。

GCM暗号化方式が使われると、config.action_dispatch.authenticated_encrypted_cookie_saltで定義されたsaltを使ってキーが1つ生成されます。このデフォルト値は"authenticated encrypted cookie"です。

参考: Wikipedia-ja: Galois/Counter Mode (GCM)

CBC暗号化方式が使われると、キーが2つ導出されます。AESアルゴリズムをCBCモードで使っているので、メッセージはMACによる認証(authenticate)も必要です。暗号化キーとベリファイキーは、config.action_dispatch.encrypted_cookie_saltconfig.action_dispatch.encrypted_signed_cookie_saltで定義されたsaltを用いて導出されます。それぞれのデフォルト値は、"encrypted cookie""signed encrypted cookie"です。

参考: Wikipedia-ja: CBCモード (Cipher Block Chaining Mode)
参考: Wikipedia-ja: メッセージ認証コード(MAC: Message Authentication Code)

2. 署名済みcookie

署名済みcookieは、HMACとSHA1ハッシュ関数を用いたセキュリティによってコンテンツに完全性を提供します。署名済みcookieの実装は暗号化cookieの実装に似ており、secret_base_keyから導出したキーを1つ使います。

署名済みcookieで使うキーを導出するときは、config.action_dispatch.signed_cookie_saltで定義された設定値がsaltに使われます。デフォルト値は"signed cookie"です。

3. アプリのmessage_verifier

Railsフレームワークでsecret_key_baseが使われる最後の場所は、アプリのmessage_verifierメソッドであり、これもRails.applicationでアクセス可能です。アプリのkey_generatorメソッドの場合と同様に、このメソッドもverifier_nameのみを引数として取り、引数はインデックス化に使われてMessageVerifierインスタンスに保存されます。この引数は、secret_base_baseからキーを1つ導出する際のsalt入力としても用いられます(gist: rails_app_message_verifier.rb)。

# rails_app_message_verifier.rb
# 戻り値:  #<ActiveSupport::MessageVerifier:0x00000003f4b430 @secret="...", @digest="SHA1", @serializer=Marshal>
Rails.application.message_verifier('bins')

# 戻り値: "BAh7BjoNdmVyaWZpZWRJIgp2YWx1ZQY6BkVU--9c516b0cc3bfcfd759550181541a24ca1294507e"
signed_msg = Rails.application.message_verifier('bins').generate({ verified: 'value' })

# 戻り値: {:verified=>"value"}
Rails.application.message_verifier('bins').verify(signed_msg)

# ActiveSupport::MessageVerifier::InvalidSignature をraiseする
Rails.application.message_verifier('bins').verify("unknown msg")

アプリのmessage_verifierメソッドは、メッセージ完全性機能のための簡単で便利なセキュリティAPIを提供し、いわゆる「remember me」トークンの実装や、署名済みURLによるリソースアクセス制御によく用いられます。このメソッドは、Rails 5.2で新しく導入されたActiveStorageの機能でも使われています。

ActiveSupport::KeyGeneratorについて

ActiveSupport::KeyGeneratorは、PBKDF2というKDF(鍵導出関数)の単なるラッパーです。このKDFのキー導出がパスワードベースであることを考えると、実際には最善のオプションではありません。特に、PBKDF2は人間が作ったパスフレーズを元に、キーのストレッチングと呼ばれるテクニックを反復的に使ってより強力なキーを生成する設計になっています。現実のRailsアプリで用いられるsecret_key_base値には、SecureRandomrake secretsでランダム生成されるセキュアな数値が使われるのが普通なので、人間が作ったパスフレーズよりも既に十分セキュアかつランダムな値になります。

実際には多くのRailsアプリで64バイト長のsecret_key_base値が使われているので、PBKDF2でキーを導出すると、キーから導出される実際の出力の長さは20バイト(160ビット)に限定されます。Rails全体にわたって導出されるキーには、HKDF(HMAC-based Key Derivation Function)を使う方がより適切のようです。HKDFでは「抽出の後拡大(extract-then-expand)」アプローチを採用しているので、より長いキーを生成できるようになります。

次回

私はRailsでのHKDF実装の検討を開始しました。この機能を改善できたらプルリクを作成するつもりです。今後にご期待ください。

関連記事

RailsのCSRF保護を詳しく調べてみた(翻訳)

Railsアプリの認証システムをセキュアにする4つの方法(翻訳)

Ruby: 8/27発表のRubyGems脆弱性と修正方法のまとめ

そのパッチをRailsに当てるべきかを考える(翻訳)

Railsコードを改善する7つの素敵なGem(翻訳)

$
0
0

概要

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

Rubyroid Labsの別記事「Ruby on Railsで使ってうれしい19のgem(翻訳)」も合わせてどうぞ。

Railsコードを改善する7つの素敵なGem(翻訳)

私たちRubyroid Labはアプリのアーキテクチャに多くの情熱を注ぎ込んでいます。手がけているプロジェクトの多くが長期にわたっているので、設計のどこかで少し油断すると、機能を1つ追加するのにプロジェクトをスクラッチからやり直す方が早い、といった事態になりかねません。こんな目には遭いたくないものです。

新しく参加したメンバーがロジック把握のためにソースコードを読みとおすだけでかなり時間がかかるようなら、それはプロジェクトが病んでいることを示す兆候のひとつです。本記事では、コードを整理してチームメンバーの笑顔を取り戻してくれるさまざまなgemをご紹介いたします。

1. interactor固定リンク

何らかの複雑なビジネスロジックを書くときには必ず私たちのリストに入るほど素晴らしいライブラリです。さてinteractorとは何でしょうか?Readmeには「ビジネスロジックのカプセル化というひとつの目的だけを持つシンプルなオブジェクトです」とあります。ビジネスロジックのカプセル化といえば皆さんの大好きなService Objectを連想するかもしれませんが、interactorはずっと機能が豊富です。早速コード例をご覧に入れましょう。

# app/interactors/create_order.rb
class CreateOrder
  include Interactor

  def call
    order = Order.create(order_params)

    if order.persisted?
      context.order = order
    else
      context.fail!
    end
  end

  def rollback
    context.order.destroy
  end
end
# app/interactors/place_order.rb
class PlaceOrder
  include Interactor::Organizer

  organize CreateOrder, ChargeCard, SendThankYou
end

コード例を見れば、このgemの実に素晴らしい機能がいくつもあることにお気づきかと思います。
まず、シンプルなinteractorをいくつかまとめて1つの実行可能なチェインにできるという点です。contextという特殊変数は、異なるinteractor同士でステートを共有するのに使われています。
次に、interactorのひとつが何らかの理由で失敗すると、それまでのinteractorはすべてロールバックします。CreateOrderクラスには#rollbackがあり、たとえばChargeCardSendThankYouが失敗すればorderは破棄されます。
実にクールなgemですね。

2. draper固定リンク

Railsでヘルパーを自作したことがあれば、時間とともにヘルパーが増えて手に負えなくなったことがあるでしょう。こうしたヘルパーは一部のデータの整形表示に使われることがほとんどです。このようなときはDecoratorデザインパターンの出番です。draperの文法は、見ればだいたいわかるようになっています。

# app/controllers/articles_controller.rb
def show
  @article = Article.find(params[:id]).decorate
end
# app/decorators/article_decorator.rb
class ArticleDecorator < Draper::Decorator
  delegate_all

  def publication_status
    if published?
      "Published at #{published_at}"
    else
      "Unpublished"
    end
  end

  def published_at
    object.published_at.strftime("%A, %B %e")
  end
end
<!-- app/views/articles/show.html.erb -->
<%= @article.publication_status %>

上のコードを見ると、published_at属性を特定のフォーマットで表示するのが目的になっています。古典的なRailsウェイでは、こういう場合に2つの選択肢がありました。

1つ目は単にそれ用のヘルパーを書くことです。この場合、すべてのヘルパーが同じ名前空間に属するので、プロジェクトが長期化するに連れていまいましい名前衝突が発生するようになり、デバッグも非常に困難になります。

2つ目はモデルの中にメソッドを書いてそれを使うことですが、モデルのクラスの責務を超えてしまうのでよろしくありません。モデルのデフォルトの責務は「データのやりとり」であり、データの表現方法ではないからです。

こういう場合にdraperを使うのは、よりエレガントに目的を達成できるからです。

次の素晴らしい記事も合わせてご覧ください。

Ruby on Railsで使ってうれしい19のgem(翻訳)

3. virtus固定リンク

シンプルなRubyオブジェクトをそのまま使っても要件を満たせないことがあります。たとえば1つのページに複雑なフォームがいくつもあり、フォームごとに異なるモデルとしてデータベースに保存しなければならないとします。こんなときはvirtusです。次の例をご覧ください。

class User
  include Virtus.model

  attribute :name, String
  attribute :age, Integer
  attribute :birthday, DateTime
end
user = User.new(:name => 'Piotr', :age => 31)
user.attributes # => { :name => "Piotr", :age => 31, :birthday => nil }

user.name # => "Piotr"

user.age = '31' # => 31
user.age.class # => Fixnum

user.birthday = 'November 18th, 1983' # => #<DateTime: 1983-11-18T00:00:00+00:00 (4891313/2,0/1,2299161)>

# mass-assignment
user.attributes = { :name => 'Jane', :age => 21 }
user.name # => "Jane"
user.age  # => 21

見てのとおり、virtusは標準的なOpenStructクラスと似ていますが、ずっと機能が豊富です。ぜひvirtusを隅々まで試してみてください。

virtusは手始めに使うのによいgemですが、もっと高度な技法を使いたい場合は、dry-typesdry-structdry-validation gemもぜひお試しください。

4. cells固定リンク

Nick Suttererの手によるRuby on Railsの高度なアーキテクチャをまだご存じない方は、ぜひ一度チェックしてみましょう。このアーキテクチャのコンセプト全体を既存のアプリに必ずしも適用できるとは限りませんが、ユーザーの種類などに応じた条件をいくつも適用していくうちにビューが複雑になりすぎてしまったらcells gemの出番です。cellsはビューの一部を切り離し、Rubyの通常のクラスとしてコンポーネント化できます。次のコードサンプルをご覧ください。

# app/cells/comment_cell.rb
class CommentCell < Cell::ViewModel
  property :body
  property :author

  def show
    render
  end

  private

  def author_link
    link_to "#{author.email}", author
  end
end
<!-- app/cells/comment/show.html.erb -->
<h3>New Comment</h3>
  <%= body %>

By <%= author_link %>
# app/controllers/dashboard_controller.rb
class DashboardController < ApplicationController
  def index
    @comments = Comment.recent
  end
end
<!-- app/controllers/dashboard/index.html.erb -->
<% @comments.each do |comment| %>
  <%= cell(:comment, comment) %>
<% end %>

上の例では、ダッシュボードに最近のコメントを表示したいと考えています。すべてのコメント表示をアプリ上で同一にすることが前提です。Railsはレンダリングに共有パーシャルを使うこともできますが、代わりにCommentCellオブジェクトを使います。このオブジェクトは、前述のdraperにビューのレンダリング機能を組み合わせたものとみなせますが、もっと機能が豊富です。すべてのオプションの詳細についてはgemのReadmeをご覧ください。

5. retryable固定リンク

現代的なWebアプリにはさまざまな機能が統合されています。確実なAPI呼び出しが使えることもありますが、ファイルのFTPアップロードや何らかのバイナリプロトコルを使わなければならないこともあります。後者の問題は、呼び出しがこれといった理由なしにときどき失敗することです。このような場合にできる最善手はリトライです。次のようなコードを書いて切り抜けなければならなかったことがあるでしょう。

begin
  result = YetAnotherApi::Client.get_info(params)
rescue  YetAnotherApi::Exception => e
  retries ||= 0
  retries += 1
  raise e if retries > 5
  retry
end

こんなときこそretryableの出番です。このgemを使って上のコードを次のように書き直してみましょう。

Retryable.retryable(tries: 5, on: => YetAnotherApi::Exception) do
  result = YetAnotherApi::Client.get_info(params)
end

コードがずっとすっきりしましたね。retryableは他の状況にも使えるので、ぜひチェックしてみてください。

6. decent_exposure固定リンク

(Rubyの)マジックがあまり好きでない方はこのライブラリを使わなくてもよいでしょう。しかしアプリによっては、きわめてシンプルな標準CRUDアクションがたくさん複製されることがあります。こんなときにはdecent_exposureの出番です。何かを管理する新しいコントローラを作成するときを考えてみましょう。scaffoldすると次のような感じになります。

class ThingsController < ApplicationController
  before_action :set_thing, only: [:show, :edit, :update, :destroy]

  def index
    @things = Thing.all
  end

  def show
  end

  def new
    @thing = Thing.new
  end

  def edit
  end

  def create
    @thing = Thing.new(thing_params)

    respond_to do |format|
      if @thing.save
        format.html { redirect_to @thing, notice: 'Thing was successfully created.' }
      else
        format.html { render :new }
      end
    end
  end

  def update
    respond_to do |format|
      if @thing.update(thing_params)
        format.html { redirect_to @thing, notice: 'Thing was successfully updated.' }
      else
        format.html { render :edit }
      end
    end
  end

  def destroy
    @thing.destroy
    respond_to do |format|
      format.html { redirect_to things_url, notice: 'Thing was successfully destroyed.' }
    end
  end

  private

  def set_thing
    @thing = Thing.find(params[:id])
  end

  def thing_params
    params.require(:thing).permit(:for, :bar)
  end
end

コードが60行にもなればもう少ないとは言えません。Rubyistたるものコードはいつもできるだけ最小限に保ちたいものです。decent_exposureを使えば、以下のようなコードを書けます。

class ThingsController < ApplicationController
  expose :things, ->{ Thing.all }
  expose :thing

  def create
    if thing.save
      redirect_to thing_path(thing)
    else
      render :new
    end
  end

  def update
    if thing.update(thing_params)
      redirect_to thing_path(thing)
    else
      render :edit
    end
  end

  def destroy
    thing.destroy
    redirect_to things_path
  end

  private

  def thing_params
    params.require(:thing).permit(:foo, :bar)
  end
end

できました!何ひとつ機能を失わずにコードが30行そこそこにまで減っています。お気付きのとおり、すべてのマジックを発揮しているのはexposeです。内部の詳しい動作を理解するにはgemのドキュメントをご覧ください。

7. groupdate固定リンク

開発者なら誰しも、異なるタイムゾーンを扱うつらさが身に沁みていることでしょう。データベースで集約(aggregation)を行うときは特にそうで、私も「今月のユーザー数を日別で取れるようにせよ: ただし無料ユーザーは除くこと」などといったオーダーはいつも悩みの種です。でもこのgemがあればそんな心配から解放されます。次の例をご覧ください。

User.paid.group_by_week(:created_at, time_zone: "Pacific Time (US & Canada)").count
# {
#   Sun, 06 Mar 2016 => 70,
#   Sun, 13 Mar 2016 => 54,
#   Sun, 20 Mar 2016 => 80
# }

ご紹介した7つのgemで皆様が快適な開発生活を送ることを願っています。質の高いコードを高い表現力で書けるツールを他にもご存知でしたら、ぜひ私どもまでお知らせください。

関連記事

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

Ruby on Railsで使ってうれしい19のgem(翻訳)

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

$
0
0

概要

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

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が提供するラッパーの導入をご検討ください。

関連記事

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

Railsの`CurrentAttributes`は有害である(翻訳)

肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)

リファクタリングRuby: サブクラスをレジストリに置き換える(翻訳)

RubyのModule Builderパターン #3 Rails ActiveModelでの適用例(翻訳)

$
0
0

元記事が非常に長いので次のように分割しました。

概要

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

RubyKaigi 2017@広島でも発表されたshioyamaさんです。このときのセッションでもModule Builderパターンを扱っています。

本記事の図はすべて元記事からの引用です。また、パターン名は英語で表記しました。

RubyのModule Builderパターン #3 Rails ActiveModelでの適用例(翻訳)

Module Builderとカプセル化

ここまでに学んだすべてを投入して、Rubyで最も有名な例のアプリフレームワークのコアで実際に動くいくつかのコードに適用してみることにしましょう。

直前のセクションの議論に続いて、ここではモジュールを使った典型的なステート設定方法(ステートをモジュールに保存する)と、この方法でモジュールを設定する場合の問題点に絞り込みたいと思います。私は、Module Builderならステートを本来あるべき場所、つまりモジュールそれ自身にずっと自然な方法でカプセル化できることを皆さんにご理解いただければと願っています。

ActiveModel::AttributeMethods

今回チェックするモジュールは、ActiveModelのAttributeMethodsです。このモジュールは、プレフィックスやサフィックスをサポートするattribute用メソッドをRailsに追加します1。Railsを少しでも扱ったことがあれば、ActiveModel::Dirtyname_changed?などの変更追跡系メソッドで、このようにプレフィックスやサフィックスを使うパターンに既に慣れ親しんでいることでしょう。このモジュールは、前述のAdderIncluderのようなモジュールブートストラップと、MethodFound::Interceptorのようなmethod_missingによるメッセージインターセプトの両方を実装しています。

メソッドのプレフィックスとアフィックス(訳注: affix: プレフィックスとサフィックスを両方とも指定すること)を1つずつ実装する単純なクラスから始めることにしましょう2

class Person
  include ActiveModel::AttributeMethods

  attribute_method_affix  prefix: 'reset_', suffix: '_to_default!'
  attribute_method_prefix 'clear_'
  define_attribute_methods :name

  attr_accessor :name, :title

  def attributes
    { 'name' => @name, 'title' => @title }
  end

  private

  def clear_attribute(attr)
    send("#{attr}=", nil)
  end

  def reset_attribute_to_default!(attr)
    send("#{attr}=", "Default #{attr.capitalize}")
  end
end

上の属性メソッドは次のように使います。

person = Person.new
person.name = "foo"
person.name
#=> "foo"
person.clear_name
person.name
#=> nil
person.reset_name_to_default!
person.name
#=> "Default Name"

上のコードを見ると、渡されたクラスについてどのようなプレフィックス/サフィックス/アフィックスが定義されているかを知るために、ステートがモジュールのどこかに保存されていることがわかります。そしてこのステートはattribute_method_prefixのコードに保存されることもわかります。これはPerson内で呼び出されてメソッドのプレフィックスを設定します。

def attribute_method_prefix(*prefixes)
  self.attribute_method_matchers += prefixes.map! { |prefix| AttributeMethodMatcher.new prefix: prefix }
  undefine_attribute_methods
end

これによりプレフィックス文字列のarrayがAttributeMethodMatcherクラスのいくつかのインスタンスにmapされ、Personクラスのattribute_method_matchersのarrayに保存されます(アフィックスやサフィックスのメソッドも同様です)。これらのマッチャはモジュールのコア設定ですが、モジュール自身の外部に保存されます

これらのマッチャの中身はどうなっているのでしょうか。理解のために、このクラスの初期化メソッドを見てみましょう。

def initialize(options = {})
  @prefix, @suffix = options.fetch(:prefix, ""), options.fetch(:suffix, "")
  @regex = /^(?:#{Regexp.escape(@prefix)})(.*)(?:#{Regexp.escape(@suffix)})$/
  @method_missing_target = "#{@prefix}attribute#{@suffix}"
  @method_name = "#{prefix}%s#{suffix}"
end

つまり、マッチャは本質的に2つの要素「プレフィックスとサフィックスのペアと、そこから生成される正規表現」でできています(それ以外はほとんどが利便性のためのものです)。この内部ステートは、2つの異なる(しかし関連する)目的に用いられます。

2つの目的のうち1つは重要であるにもかかわらず、満足なドキュメントがまったくありません(このインラインコメントをご覧ください)。第1の目的とは、あるattributesメソッドから返されるキー/値ペアのハッシュ1つを、定義済みのプレフィックス/サフィックス/アフィックスメソッドをすべてサポートするファーストクラス(first-class)属性メソッドに変換する手段を提供することです。上の例では、属性はキーnametitleを含むハッシュを1つ返し、これがclear_attributereset_attribute_to_default!などのメソッドに割り当てられる属性になります。

MethodFoundの場合と同様に、これもmethod_missingを用いて実装されています。マッチャーの正規表現とマッチし、かつ正規表現でキャプチャされた対象がattributesのキーに含まれているメソッド呼び出しは、すべてインターセプトされて属性のハンドラに割り当てられます。このようにしてreset_title_to_default!reset_attribute_to_default!に割り当てられ、属性の名前(title)がその引数になります。

メソッドのインターセプトは属性のハッシュを制約なしに変更できる点では素晴らしいのですが、method_missingはシロップのように遅いのです。どんな属性が欲しいかが事前にわかっているのであれば、一般にはメソッドを明示的に定義する方がよいでしょう。

これがメソッドマッチャの第2の目的です。プレフィックス/サフィックス/アフィックスを設定(属性ごとにプレフィックスやサフィックスのペアを定義する)した後にdefine_attribute_methodsに1つ以上の属性を渡して呼び出すことでマッチャがトリガされます。Personnameの属性を定義しているので、clear_namereset_name_to_default!はそれぞれclear_attributereset_attribute_to_default!に割り当てられます。

これらのメソッドは、Personクラスに追加される別のメソッド(generated_attribute_methods)で定義およびincludeされる1個の無名モジュールに紐付けられます。これらはインスタンスメソッドとしてメソッドインターセプトより優先されるので、clear_titleclear_nameは両方とも同じ結果を返しますが、前者はmethod_missingにフォールスルーし、後者はこの属性メソッドで扱われます(後者の方がずっと高速です)。

ActiveModelのAttributeMethods

ActiveModelのAttributeMethods

次のようにPersonインスタンスのメソッドをgrepすれば、定義済みの属性メソッドを一覧できます。

person = Person.new
person.methods.grep /name/
#=> [:name, :name=, :reset_name_to_default!, :clear_name, ...]

モジュールのancestorsinstance_methodsを調べてみると、生成されたメソッドが(他の場所ではなく)このモジュールで定義されていることも確認できます。

Person.ancestors
#=> [Person, #<Module:Ox...>, ActiveModel::AttributeMethods, ...]
Person.ancestors[1].instance_methods
#=> [:name, :name=, :reset_name_to_default!, :clear_name]

また、前述したmethod_missingのオーバーライドがActiveModel::AttributeMethodsこの箇所、クラス階層における無名モジュールの直後で行われていることもわかります。

属性メソッドの2つの実装(method_missingによるメソッドインターセプトと、メソッド定義による実装)は、それぞれ適切なアクセスパターンが異なる(前者は大規模な属性セットに、後者は小規模な固定の属性セットに向いています)ので、互いに補完し合います。私のMobility gemでは、i18nアクセサの定義でこれと同じアプローチを実際に採用しています3。RailsのAttributeMethodsはクラスメソッドや変数を多数追加しますが、私のMobilityは何ひとつ追加しません。代わりに、すべてのステートはそれらモジュール内に隠蔽されます。

いよいよAttributeMethodsのもうひとつの実装をお見せしましょう。この実装は私のMobility gemと同様、Module Builderを用いてステートをモジュール内にカプセル化します。

MethodFound::AttributeMethods

このセクションでは、これまで議論してきたすべてのアイデアを集約して現実のアプリに適用します。コードの詳細を解説する前に、クラスに属性メソッドを実装する要素をおさらいしておきましょう。

  1. クラス変数(attribute_method_matchers): プレフィックス/サフィックス/アフィックスのマッチャーのarrayを保持する
  2. method_missingのオーバーライド: 一致する属性マッチャにメソッド呼び出しを割り当て、対応するハンドラにattributesハッシュを割り当てる
  3. includeされた無名モジュールgenerated_attribute_methods): 定義済みの属性メソッドを保持する

今度は、上の各コア要素が別の場所に置かれているところを考えてみましょう。

  • メソッドマッチャは、includeする側のクラスで定義されている
  • 生成された属性メソッドは、このクラスにincludeされている無名モジュールで定義されている
  • method_missingAttributeMethods自身に定義され、そのクラスにもincludeされている

これで、私たちの実装で使われる3つの要素が揃いました。3つの要素はメソッドマッチャのクラス変数を通じて互いに結合し、システム実装のさまざまな部分に広がります。しかしそれだけではありません。メソッドマッチャの機能と生成されたメソッドの機能は(この後でも説明するように)本質的に独立していますが、同じ場所(メソッドマッチャはarrayに、生成されたメソッドは無名モジュールに)に保存されます。

ステートを別の方法で分散する、別の実装を考えます。この実装は、Module Builderを用いて以下の要素を単一のモジュールにまとめます。

  • 単独のプレフィックス/サフィックスペア: ここからマッチャの正規表現が生成される
  • 単独のmethod_missingオーバーライド: メソッドを正規表現でインターセプトする
  • 単独のメソッド: 渡されたプレフィックス/サフィックスペアに対応する属性メソッドを定義する

このModule BuilderにはAttributeInterceptorという名前が付けられており、MethodFoundにあります。これは、前のセクションでお見せしたMethodFound::Interceptorを継承し、形式の制約がないデフォルトのインターセプタビルダではなく、プレフィックスやサフィックスを受け取れるようにイニシャライザをカスタマイズします。コードのサイズはそれほど大きくありませんが、ここではコードをまるごと引用するのはやめておき、代わりにクラスで実際に行われている内容に注目します。

MethodFoundインターセプタを持つAttributeMethodsの実装

MethodFoundインターセプタを持つAttributeMethodsの実装

まずはインターセプトを見てみましょう。上のPersonクラスのdefine_attribute_prefix呼び出しやdefine_attribute_affix呼び出しを以下のように属性インターセプタに置き換えることでインターセプトを再現できます。

class Person
  include MethodFound::AttributeInterceptor.new(prefix: 'clear_')
  include MethodFound::AttributeInterceptor.new(prefix: 'reset_', suffix: '_to_default!')

  attr_accessor :name, :title

  def attributes
    { 'name' => @name, 'title' => @title }
  end

  # ...
end

ここでは2つの属性インターセプタをそれぞれインスタンス化してクラスにincludeしています。アフィックスのための特別な対応は不要なので、単にプレフィックス用とサフィックス用のインターセプタをビルドしています。

これらのモジュールがincludeされると、Personは外部からはActiveModelモジュールを用いた場合と完全に同一に振る舞っているように見えます。しかし内部の実装はかなり異なっており、ancestorsでその違いを確認できます。

Person.ancestors
#=> [Person,
 #<MethodFound::AttributeInterceptor: /\A(?:reset_)(.*)(?:_to_default!)\z/>,
 #<MethodFound::AttributeInterceptor: /\A(?:clear_)(.*)(?:)\z/>,
 ...  ]

前述のMethodFoundインターセプタと同様に、2つのモジュールがそれぞれmethod_missingをオーバーライドし、メソッド名が(モジュールに保存されている)正規表現と一致すればコードパスを分岐します。

このようにして得られる分散/ネスト条件セットは、ActiveModelのこの行と同等です。このコードは、一致をチェックする属性メソッドマッチャを列挙します。

matchers.map { |method| method.match(method_name) }.compact

2つの実装の主要な相違点は、この行がActiveModel::AttributeMethods(これはモジュールです)で実行され、かつPerson(これはクラスです)に保存されたクラス変数を用いるのに対し、私のMethodFoundバージョンではsuperを用い、メソッドのコンポジションを通じて、独立した各モジュールで実行されるという点です。

生成された属性メソッドの実装も同じ要領です。AttributeInterceptorにはdefine_attribute_methodsというメソッドがあり、これは1つ以上の属性名を受け取って、各属性メソッドにモジュールのプレフィックスやサフィックスを加えたものをそのモジュール自身に定義します。繰り返しになりますが、このモジュールには自身のプレフィックスやサフィックスが含まれているので、この作業に必要な情報はすべてモジュール内に揃っています。

したがって、機能は真の意味でカプセル化されます。単一のモジュールが自身のプレフィックスやサフィックスを含み、属性メソッドの呼び出しをキャッチするmethod_missingのオーバーライドや、自身の属性メソッドを生成するためのメソッドも含んでいます。4

このModule Builderを使えば、クラスメソッドやクラス変数をひとつも書かずにActiveModelの実装を以下のように再現できます。

class Person
  [ MethodFound::AttributeInterceptor.new(prefix: 'clear_'),
    MethodFound::AttributeInterceptor.new(prefix: 'reset_', suffix: '_to_default!')
  ].each do |mod|
    mod.define_attribute_methods(:name)
    include mod
  end

  #...
end

MethodFoundMethodFound::AttributeMethodsという別のモジュールをincludeしています。このモジュールは上のコードをシンプルにするクラスメソッド群を追加するので、ActiveModel::AttributeMethodsをまるごと置き換えることができます。このモジュールに含まれるdefine_attribute_methodsの実装も興味深いものです。

def define_attribute_methods(*attr_names)
  ancestors.each do |ancestor|
    ancestor.define_attribute_methods(*attr_names) if ancestor.is_a?(AttributeInterceptor)
  end
end

このクラスはマッチャを自分のステートに保存しないので、このモジュールではマッチャを単に列挙してメソッドを定義するという手が使えません。代わりに、自身のancestorsを通じて列挙します。列挙されるのはプレフィックスやサフィックスのペアを持つ各モジュールインスタンスなので、先祖が属性インターセプタの場合にdefine_attribute_methodsメソッドを呼び出します。これによってインターセプタモジュールごとに属性メソッドが生成され、クラスのインスタンスから呼び出せるようになります。

こうして得られた実装では、前述の関連する要素が独立したモジュールにカプセル化されて、しかもクラス変数やクラスメソッドで結合されていません。このようにカプセル化できるということは、まったく新しい属性インターセプタを設計して従来と同じ方法でincludeできるようになったということです。(属性メソッド生成のための)インターフェイスが同じである限り、内部が完全に変わってしまっても変更は不要です。

これは私にとって「モジュールとはそもそもどうあるべきか?」という問いかけに思えます。モジュールは、必要なものをすべて含み、必要な一部の機能のある側面だけを実行できる、独立した交換可能な単位であるべきです。しかしそれだけではなく、こうしたモジュールを構築するインターフェイスは、Ruby自身のオブジェクトモデル(言語そのものと同じぐらい長い歴史を持つモデル)から直接逸脱してしまいます。このような飾りが長年に渡ってRubyの衣装の袖口に縫い付けられていたにもかかわらず、その存在はほぼ誰にも気づかれずじまいだったのです

Module Builderパターンという名前について

私がご紹介した「Module Builderパターン」について、こんなのはRubyの単なるサブクラスで、そのクラスがたまたまModuleクラスだったというだけじゃないか、何を大げさな、とお思いの方もいらっしゃるかもしれません。技術的な観点からは、おっしゃるとおりでしょう。私はこのアイデアの第一発見者でもなければ、最初に記述したわけでもありません。単にFoo < Moduleしてみたらできたということです。

しかし、プログラミング言語を読むのも書くのも人間なのですから、名前は人間にとって大きな意味があります。もし仮にこのメソッドを持つモジュールがancestorsのリストに埋もれたまま見過ごされていたとしたら誰も気づかなかったでしょうし、どこかのブロガーがサブクラス化について書いたことをすっかり忘れてしまったら、この手法を使うこともないでしょう。このパターンがこれまでほぼ誰にも気づかれることなく存在し続けていたという事実そのものが、その証しです。

だからこそ、私はこれにキャッチーな名前を付けたのです。

私もRubyistとして、このパターンは私たちに必要だと考えます。ある巨大なRubyプロジェクトのコードを手にとって、モジュールが現場でどのように用いられ(かつ誤用され)ているかを考えてみてください。モジュールがトリガする多数のコールバックはありとあらゆる余分なステートをクラスにブートストラップし、クラスがincludeするモジュールをがっちりと結合してしまいます。モジュールは自己完結すべきですが、実行時にモジュールを設定できないことを言い訳に、いろんな場所に設定をばらまくことが正当化されてしまっています。

Rubyはこうしたことをもっとうまく扱えるのですから、私たちももっとうまくやれるはずです。だからこそ、肥大化したモジュールに、アプリの隅々にまでおぞましい触手を伸ばすタコのようなモジュールに今一度目を向け、自由になるチャンスを与えてやってください。それがモジュールのためにも、ひいては皆さまのためにも良いことであると信じています。

バックナンバー

関連記事

[保存版]人間が読んで理解できるデザインパターン解説#1: 作成系(翻訳)

[保存版]人間が読んで理解できるデザインパターン解説#2: 構造系(翻訳)

Rubyで学ぶデザインパターンを社内勉強会で絶賛?連載中


  1. 実際には、ActiveRecordのモデルにはActiveRecord::AttributeMethodsincludeされています。これ自身がActiveModel::AttributeMethodsincludeして、本記事で扱うメソッドの一部(特にdefine_attribute_methods)をオーバーライドしています。2つのモジュールの関連や、永続化(persisted)や、非永続化の属性メソッドの扱いはある意味で複雑なのですが、ここで議論されているアイデアはこの両方に関連しています。 
  2. 以後の説明では、標準的なドキュメントでは指摘されていない部分をあえて強調するために、文中でクラス例から引用するコードを少し変えてありますのでご了承ください。 
  3. Mobility gemではFallthroughAccessorsLocaleAccessorsというModule Builderで2つのケースを取り扱っています。私はこれらもMobilityからI18nAccessorsというgemに切り出しました。 
  4. 実際にはActiveModel::AttributeMethodsと同様、属性をエイリアスするalias_attributeも含まれています。 

Rails: Form ObjectとVirtusを使って属性をサニタイズする(翻訳)

$
0
0

概要

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

Form Objectパターンについては「肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)」もご覧ください。

Rails: Form ObjectとVirtusを使って属性をサニタイズする(翻訳)

私たちはDrivyのコードベースで、Form ObjectのビルドにVirtus gemを使っています。これによって以下のメリットを得られました。

  • コントローラやビューにビジネスロジックを含めないようにする
  • 永続しない属性を取り扱える
  • 特定のバリデーションを、モデルに直接追加せずに追加できる
  • カスタムのデータバリデーションをフォームに直接表示できる
  • includeすることで機能をActiveModel::Modelから使える

ユーザー入力のサニタイズ、データの整形、スペースの除去といった作業が必要になったときに、Virtusを使って行える便利な方法をご紹介します。

#coerceを使う

文字列として記録されているVAT番号からスペースをすべて除去したいとします。シンプルなユースケースですが、このコンセプトはもっと複雑な状況にも適用できます。

まず、サニタイズ対象となる属性のカスタム属性オブジェクトを定義する必要があります。そのオブジェクトで#coerceメソッドを使うためにVirtus::Attributeの継承も必要です。次に、実行したい整形処理をメソッドに定義します。

class SanitizedVatNumber < Virtus::Attribute
  def coerce(value)
    value.respond_to?(:to_s) ? value.to_s.gsub(/\s+/, '') : value
  end
end

次はVirtusのForm Objectでvat_number属性(これが更新対象です)をSanitizedVatNumberとして指定します。

class CompanyForm
  attribute :vat_number, SanitizedVatNumber

  def initialize(...)

  end
end

以上でおしまいです。フォームが送信されるとvat_numberがサニタイズされます。

RSpecでテストする

カスタムのVirtus属性に基本的なテストを追加するのも簡単です。RSpecを使う場合は次のようにします。

describe SanitizedVatNumber do
  let(:object) { described_class.build(described_class) }
  subject { object.coerce(value) }


  context 'vat_numberがnilの場合' do
    let(:value)  { nil }

    it { is_expected.to eq('') }
  end

  context 'vat_numberがスペースを含む場合' do
    let(:value)  { 'EN XX 999 999 999' }

    it { is_expected.to eq('ENXX999999999')}
  end
end

まとめ

Form Objectの責務の増加(属性をフォーム内で直接サニタイズするリスクにつながる)を避けることができます。また、Virtusのカスタム#coerceは複数のフォームで再利用でき、単体テストも非常に行いやすくなります。

本記事が気に入った方はぜひDrivyエンジニアリングチームの募集要項をご覧ください。

関連記事

肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)

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

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

Viewing all 1091 articles
Browse latest View live