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

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

$
0
0

概要

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

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

あるモデル全体にスコープを適用したい場合、default_scopeが利用できます。詳しくはRailsガイド: Active Recordクエリインターフェイス 14.スコープ(日本語)かRailsドキュメントをご覧ください。


投稿を非表示にできる機能を持つブログシステムを書き始めるときを考えてみます。

次のように書かないこと

default_scopeを使う:

# app/models/post.rb
class Post < ActiveRecord::Base
  default_scope { where(hidden: false) }
end

次のように書くこと

明示的にスコープを指定する:

# app/models/post.rb
class Post < ActiveRecord::Base
  scope, :published -> { where(hidden: false) }
end

これで次のように書けます。

Post.published

なぜdefault_scopeがだめなのか

理由は2つあります。どちらも後になってコードが混乱したりバグつぶしに明け暮れたりすることを避けるのが目的です。

default_scopeを追加すると、モデルの初期化が影響を受けます。上の例で言うと、開発者が期待するかどうかにかかわらずPost.newするとデフォルトでhidden = falseになってしまいます。

いったん定義されたdefault_scopeを使わないようにするのは大変です。default_scopeが不要な場面で削除するには、unscopedしたスコープ(!)を使わなければならず、しかも適用されていた関連付けなどの条件はすべて削除されてしまいます。

: Post.first.comments.unscopedとすると、Postの最初のコメントだけではなく、データベース内のすべてのコメントを返します。

default_scopeより明確な解決法は、明示的な名前付きスコープを使うことです。default_scopeを使えばバグつぶしに何時間も費やすことになるでしょう。default_scopeは使わないでください。

default_scopeを使ってもよさそうな場面はありますか?

どうかこればかりは私を信じてください。使えばきっと痛い目にあいます。

関連記事

よくある?Rails失敗談 default_scope編

論理削除用paranoia gemがあるRailsプロジェクトで物理削除する方法


Rails: 日付や時刻のカラム名を命名規則に合わせよう(翻訳)

$
0
0

概要

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

Rails: 日付や時刻のカラム名を命名規則に合わせよう(翻訳)

Railsでは、ActiveRecordモデルで使われるupdated_atcreated_atというマネージドのタイムスタンプがデフォルトで使えます。

しかし、さまざまなアプリのschema.rbやマイグレーションを調べてみると、モデルで何とか_dateのようなフィールド名をよく見かけます。

次のように書かないこと

データベースカラム名にdatetimeという語を含める:

class NaughtyMigration < ActiveRecord::Migration[5.1]
  add_column :users, :logged_in_date, :datetime
  add_column :users, :logged_out_time, :date
end

訳注: :datetime:dateは誤りを示すためにわざと入れ替えてあるそうです。

次のように書くこと

時刻にはat、日付にはonというサフィックスを追加する:

class AwesomeMigration < ActiveRecord::Migration[5.1]
  add_column :users, :logged_in_at, :datetime
  add_column :users, :logged_out_on, :date
end

理由

変数名にtimedateという語を追加するのは冗長であり、コードがうるさく見えます。文字列変数にfirst_name_stringという名前を付けないのと同じです。

Railsの慣習では、due_onのように書くことで日付を期待していることが伝わります。このように、データベースに保存されているデータを扱うコードを読む人に、どのようなデータが期待されているかという意図を即座に伝えることができます。

私は、コードが読みやすくなるのであれば、_untilのように書くこともあります。

個人的には、命名の制約がある分適切な名前を考えるのが面倒ではありますが。

関連記事

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

Railsのsecret_key_baseを理解する(翻訳)

Rails: Form ObjectとVirtusを使って属性をサニタイズする(翻訳)

Ruby: delegate.rb標準ライブラリの動作を解明する(翻訳)

$
0
0

概要

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

以下のRailsの#delegate_missing_toメソッドの記事も参考にどうぞ。

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

Ruby: delegate.rb標準ライブラリの動作を解明する(翻訳)

CC BY-SA 3.0 Nick Youngson

オブジェクト指向(OO)プログラミングについてよく言われるのは「オブジェクト間でのメッセージのやりとり」であるということです。オブジェクト指向によって、問題解決に必要な正しい名詞や動詞が見つけやすくなるということもよく言われます。私は、ひとつのプログラムを劇の舞台に見立てることを何かと好みます。出演者はその舞台で演じ、互いに会話を交わすわけです。しかし、あるキャラが別のキャラに話すときに、間に別の出演者をはさむこともあります。この場合、間に立つ俳優は、相手のキャラへのメッセージを託される(委譲される)ことになります。それではプログラミングの委譲(delegate)の話に戻りましょう。

委譲とは、あるオブジェクト(レシーバ)のメンバ(プロパティやメソッド)を、送信元オブジェクトとは別のコンテキストで評価することを指す。
Wikipedia英語版より

この定義は、先の演劇的なアナロジーとかなり似ています。つまり委譲を、オブジェクトが単にメッセージを右から左に転送するという形で、メッセージを相手のオブジェクトに渡すことであると定義しています。なぜ委譲が必要になるのでしょうか?

私は前回の記事で、Rubyの標準ライブラリの中からSimpleDelegatorに触れたとき、これ自体を記事にする価値があると思いました。最初に、delegate.rbで強力かつ柔軟なインターフェイスを設計する方法をご紹介します。次にソースコードを読んで、舞台裏で行われているマジックについて少し説明します。

おすすめ映画を表示する

ここではおすすめ映画エンジンの一部を開発しているとしましょう。この単純化された世界でMovieについて知っておくべきことは、iMDbRotten Tomatoesからエンジンが取得した(数値化された)レーティング(スコア)であるということだけです。2つのサイトのスコアは同じスケールに正規化されていると仮定し、外部スコアの平均値に基づくaverage_scoreという1つの数値だけを返すと仮定します。これをコードで表すと次のようになります(gist: movie.rb)。

class Movie
  attr_reader :imdb_score, :rotten_tomatoes_score

  def initialize(name, imdb_score, rotten_tomatoes_score)
    @name = name
    @imdb_score = imdb_score
    @rotten_tomatoes_score = rotten_tomatoes_score
  end

  def average_score
    (@imdb_score + @rotten_tomatoes_score) / 2
  end
end

次に、(おそらく何らかの回帰モデリングの後に)Moviesのarrayを保存するクラスが必要です。このクラスをRecommendedMoviesとし、ここで関連するクエリを実行できるようにします(gist: recommended_movie.rb)。

class RecommendedMovies
  def initialize(movies)
    @movies = movies
  end

  def best_by_imdb
    @movies.max_by(&:imdb_score)
  end

  def best_by_rotten_tomatoes
    @movies.max_by(&:rotten_tomatoes_score)
  end

  def best
    @movies.max_by(&:average_score)
  end
end

かなり素直なコードです。arrayを生のまま使わないなど、明確で使いやすいインターフェースを備える専用オブジェクトを作成したのは我ながら上出来です。それでは適当なデータを渡して値を取り出してみましょう。

north_by_northwest = Movie.new('North by Northwest', 85, 100)
inception = Movie.new('Inception', 88, 86)
the_dark_knight = Movie.new('The Dark Knight', 90, 94)

recommended_movies = RecommendedMovies.new([north_by_northwest, inception, the_dark_knight])

recommended_moviesへのクエリはシンプルです。

recommended_movies.best
 => #<Movie:0x007fbcf7048948 @name="North by Northwest", @imdb_score=85, @rotten_tomatoes_score=100>

責務を限定する

RecommendedMoviesは期待どおり動作しましたが、ひとつ大きな欠点があります。arrayを与えて初期化したにもかかわらず、元のArrayの振舞いが完全に失われてしまっているからです。たとえばrecommended_movies.countを実行すると、NoMethodErrorが返ります。RecommendedMoviesArray(とEnumerable)の素晴らしい機能をすべて使えればメリットははかりしれないだけに、この制約は残念です。このクラスにmethod_missingを実装すれば、Rubyの標準ライブラリであるdelegate.rbで見つけたエレガントな解決法が使えるでしょう。

このライブラリによって2つの具体的な解決を得ることができます。なお、どちらも継承によって実装されます。DelegateClassそのものも詳しく調べる価値がありますが、簡易版のSimpleDelegatorでも十分この2つの解決を得られます。使い方は次のような感じになります(recommended_movies_with_delegation.rb)。

require 'delegate'

class RecommendedMovies < SimpleDelegator
  def best_by_imdb
    max_by(&:imdb_score)
  end

  def best_by_rotten_tomatoes
    max_by(&:rotten_tomatoes_score)
  end

  def best
    max_by(&:average_score)
  end
end

これだけで十分です。以前の機能はそのまま動作するうえ、元のarrayのメソッドもすべて使えるようになりました。ここではarrayに対してDecoratorパターンを適用しました。さっきと同じデータを与えると、今度はrecommended_movies.count3を返すようになりました。しかもインスタンス変数moviesの宣言と参照が不要になったので、initializeメソッドを省略できたことにご注目ください。まるで、RecommendedMoviesを初期化するとRecommendedMoviesインスタンスのselfがarrayになったかのようです。

訳注: Decoratorパターンについては以下の記事もご覧ください。

肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)

舞台裏の仕掛け

delegate.rbのソースコードはここで参照できます。このソースの行番号を示しながら解説しますので、別タブで開きながらお読みください。lキーを押すと特定行にジャンプします。このファイルには興味深いコメントがいくつか書かれていますので、皆さんがコードを掘るときに読んでみることをおすすめします。それでは、SimpleDelegatorの使われ方をボトムアップで分析します。

SimpleDelegatorの継承によって、RecommendedMoviesのancestorsチェインは次のようになります。

recommended_movies.class.ancestors
 => [RecommendedMovies, SimpleDelegator, Delegator,
#<Module:0x007fed5005fc90>, BasicObject]

継承しないバージョンのancestorsチェイン [RecommendedMovies, Object, Kernel, BasicObject]から大きく変わっているのがわかります。その理由は、SimpleDelegatorDelegatorという別のクラスを継承しており(line 316)、それがさらにBasicObjectを継承しているからです(line 39)。ObjectKernelがチェインに含まれていない理由がこれでわかります。#<Module:0x007fed5005fc90>(自分のPCで実行した場合の表示は少し異なります)という見慣れないものがありますが、これは無名モジュールで、Delegatorクラスで定義およびインクルードされます(line 53)。この無名モジュールはKernelモジュールの縮小版として振舞うもので、Kernelを複製して一時変数に保存し(line 40)、いくつかのメソッドをundefineする操作(line 44line 50)をその変数のクラスレベルで実行します(line 41)。こうした変更の後、最終的に更新されたKernelDelegateにインクルードされます。これで先ほどのancestorsチェインを理解できました。

「透明な」初期化

前述のとおり、更新したRecommendedMoviesクラスではinitializeメソッドを省略しています。Rubyでは新しいオブジェクトで自動的にinitializeを呼び出します(クラスでnewを呼んだ後など)が、私はinitializeメソッドを実装しなかったので、initializeメソッド呼び出しは期待どおりancestorsチェインの上の階層に上昇します。initializeメソッドはSimpleDelegatorには実装されていませんが、Delegatorには実装されています(line 71)。ここではobjという引数が1つ渡されることが期待されていますが、これはRecommendedMoviesインスタンス作成時に与えられた引数(ここではMovieオブジェクトのArray)であり、メッセージの委譲先オブジェクトです。

内部では、Delegator#initializeは単に__setobj__メソッドを呼び出し、同じobjをもう一度引数として渡します。しかしDelegator__setobj__を実装していないので、そのような呼び出しを受信するとエラーがraiseされるでしょう(line 176)。その理由は、Delegateが抽象クラスとして使われるためです。Delegateのサブクラスは__setobj__を実装すべきであり、実際SimpleDelegatorには実装されています(line 340)。SimpleDelegator#__setobj__objを単にdelegate_sd_objというインスタンス変数に保存します(sdはSimpleDelegatorを表します)。
本記事の例で、selfrecommended_moviesのまま変わらなかったことを思い出しましょう。

委譲完了!

先ほどお見せしたとおり、一度生まれたrecommended_moviesオブジェクトはarrayとしても扱うことができます。このオブジェクトでbestメソッドを呼び出すと、RubyはそのメソッドをオブジェクトのクラスRecommendedMoviesから探し、私たちの代わりにそれを実行します。しかしcountを呼び出してもクラスに見当たらないので、Rubyはancestorsチェインを上昇してメソッドを探索しますが、残念ながらcountはどのancestorsクラスにも定義されていません。

ここでmethod_missingの出番となります。Rubyは、通常のメソッド探索でメソッドを見つけられずに終了しても、すぐにはNoMethodErrorをスローせず、method_missingで探索を再開します。ancestorsチェインにあるいずれかのクラスでメソッドが定義されていれば、そのメソッドが呼び出され、そうでない場合は、チェインのトップレベルで探索を終了してNoMethodErrorをスローします。

このコンテキストでは、Delegatorクラスでmethod_missingが定義されています(line 78)。ここではまず__getobj__を呼び、委譲の対象となるオブジェクトをフェッチします(line 80)。そして__getobj__SimpleDelegatorで実装されています(line 318)。このメソッドは本質的に、@delegate_sd_objに保存されている対象オブジェクトを返し、次に、実行したいメソッドを対象オブジェクトで呼び出そうとします(line 83)。対象オブジェクトがメソッドに応答しない場合、Delegate#method_missingKernelが応答できるかどうかをチェックして呼び出します(line 85)。どちらも応答しない場合はsuperを呼びます(line 87)。ここまで来てやっとNoMethodErrorになります。実に長い旅でした。

Delegate#method_missingには他にもコードが含まれていますが、ここが動作のコアです。Paolo Perrotta『メタプログラミングRuby 第2版』p66(訳注: 英語版のページ)では、Blank Slateを「最小限のメソッドのみを備えた薄いクラス」と定義しています。RubyのDelegateクラスはこのテクニックを使うときにBasicObjectを継承することで、予想外の挙動による驚きを排除しています。しかしそれと同時に、method_missingの実装が賢いおかげで委譲先オブジェクトが特定のメソッドに応答するかどうかを確認できること、そして委譲先オブジェクトは(多くのRubyオブジェクトと同様)Objectを継承することも覚えてきましょう。やりとりは複雑ですが、最終的に得られるインターフェイス(例のRecommendedMoviesクラスなど)は非常にシンプルかつ直感的です。自分のコードをじっくり読んで、このパターンを適用できる場所を探してみれば、意外にたくさんあることに気づくでしょう。そして多くの場合、楽しくリファクタリングできることでしょう。


本記事についてお気づきの点やご質問がありましたら、ぜひ(元記事の)コメント欄までどうぞ。この記事が面白かった/役に立った場合は、元記事の下にある👏 をクリックして応援をお願いします。

関連記事

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

肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)

[保存版]人間が読んで理解できるデザインパターン解説#2: 構造系(翻訳)

Railsのトランザクションと原子性のバグ(翻訳)

$
0
0

注記: 本記事のコード例ではhas_and_belongs_to_many関連付け(通称HABTM)が使われていますが、Railsでこれを使ったリレーションは悪手とされています。現在のRailsでは代わりにhas_many :through関連付けを使うのが一般的です。

概要

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

Railsのトランザクションと原子性のバグ(翻訳)

Ruby on Railsのトランザクションは、熟練Rails開発者もつまづくことがあるほど扱いが微妙です。次のいくつかの例では、単純なトランザクションすら意図と違う動きをする可能性があることと、それによって気づかないうちに原子性(訳注: atomicity、不可分性とも)が損なわれることを示します。

設定

本記事では、SurveyとQuestionというモデルを持つシンプルなアプリを使って調べます。Surveyには名前が1つ必要で、Questionには何らかのテキストが必要だとします。例では、SurveyとQuestionの間には多対多(has_and_belongs_to_many)のリレーションが設定されています。

rails new sample
cd sample
rails generate model survey name:string
rails generate model question text:string
rails generate migration CreateJoinTableSurveyQuestion survey question
rake db:create
rake db:migrate
  • app/models/survey.rb
class Survey < ApplicationRecord
  validates :name, presence: true
  has_and_belongs_to_many :questions
  accepts_nested_attributes_for :questions
end
  • app/models/question.rb
class Question < ApplicationRecord
  validates :text, presence: true
  has_and_belongs_to_many :questions
end

コード例A

has_manyhas_and_belongs_to_many関連付けによって提供されるヘルパーメソッドは興味深い挙動を示します。次のスニペットをご覧ください。

survey = Survey.create(name: "Shapes")
question = Question.create(text: "九角形の辺はいくつあるか?")

survey.attributes = { name: "", question_ids: [question.id] }
survey.save
BEGIN
INSERT INTO "questions_surveys" ("survey_id", "question_id") VALUES (..., ...)
COMMIT
BEGIN
ROLLBACK

上のとおり、attributesquestion_ids=(またはquestions=)の代入が行われると、バリデーションの外部でCOMMITされるINSERT文がただちに実行され、その後ROLLBACKします。

以下のようにattributes=saveupdateに差し替えると、期待どおりに原子性が保たれます。updateは内部でwith_transaction_returning_statusのすべての変更をラップしています(with_transaction_returning_statusは、トランザクションにラップされたブロックを1つ取るメソッドで、ブロックが「真らしい」と評価された場合はCOMMITを実行し、ブロックが「偽らしい」と評価された場合はROLLBACKします)。

survey = Survey.create(name: "Shapes")
question = Question.create(text: "九角形の辺はいくつあるか?")

survey.update({ name: "", question_ids: [question.id] })
BEGIN
SELECT "questions".* FROM "questions" WHERE "questions"."id" IN (...)
SELECT "questions".* FROM "questions" INNER JOIN "questions_surveys" ON "questions"."id" = "questions_surveys"."question_id" WHERE "questions_surveys"."survey_id" = ...
INSERT INTO "questions_surveys" ("survey_id", "question_id") VALUES (..., ...)
ROLLBACK

次のように書くこともできます。

survey = Survey.create(name: "Shapes")
question = Question.create(text: "九角形の辺はいくつあるか?")

survey.with_transaction_returning_status do
  survey.attributes = { name: "", question_ids: [question.id] }
  survey.save
end
BEGIN
SELECT "questions".* FROM "questions" WHERE "questions"."id" IN (...)
SELECT "questions".* FROM "questions" INNER JOIN "questions_surveys" ON "questions"."id" = "questions_surveys"."question_id" WHERE "questions_surveys"."survey_id" = ...
INSERT INTO "questions_surveys" ("survey_id", "question_id") VALUES (..., ...)
ROLLBACK

コード例B

transactionメソッドは期待どおりに動作しないことがあります。次のスニペットをご覧ください。

survey = Survey.create(name: "Numbers")
question = Question.create(text: "プランク定数の値はいくつか?")

Survey.transaction do
  survey.update({ name: "", question_ids: [question.id] })
end
BEGIN
SELECT "questions".* FROM "questions" WHERE "questions"."id" = ...
SELECT "questions".* FROM "questions" INNER JOIN "questions_surveys" ON "questions"."id" = "questions_surveys"."question_id" WHERE "questions_surveys"."survey_id" = ...
INSERT INTO "questions_surveys" ("survey_id", "question_id") VALUES (..., ...)
COMMIT

興味深いことに、コード例Aの修正方法はここでは効きません。デフォルトでネストするトランザクションでは、親のトランザクションだけが使われます。

以下のようにActiveRecord::Rollback例外をraiseするか、親のトランザクションでjoinable: falseを指定することで、半端な変更が保存されないようになります。親のトランザクションでjoinable: falseを指定すると、多くのリレーショナルデータベースが備えるメカニズムとしての保存ポイントが内部で使われます。

survey = Survey.create(name: "Numbers")
question = Question.create(text: "プランク定数の値はいくつか?")

Survey.transaction do
  unless survey.update({ name: "", question_ids: [question.id] })
    raise ActiveRecord::Rollback
  end
end
BEGIN
SELECT "questions".* FROM "questions" WHERE "questions"."id" = ...
SELECT "questions".* FROM "questions" INNER JOIN "questions_surveys" ON "questions"."id" = "questions_surveys"."question_id" WHERE "questions_surveys"."survey_id" = ...
INSERT INTO "questions_surveys" ("survey_id", "question_id") VALUES (..., ...)
ROLLBACK

次のように書くこともできます。

survey = Survey.create(name: "Numbers")
question = Question.create(text: "プランク定数の値はいくつか?")

Survey.transaction(joinable: false) do
  survey.update({ name: "", question_ids: [question.id] })
end
BEGIN
SAVEPOINT active_record_...
SELECT "questions".* FROM "questions" WHERE "questions"."id" = ...
SELECT "questions".* FROM "questions" INNER JOIN "questions_surveys" ON "questions"."id" = "questions_surveys"."question_id" WHERE "questions_surveys"."survey_id" = ...
INSERT INTO "questions_surveys" ("survey_id", "question_id") VALUES (..., ...)
ROLLBACK TO SAVEPOINT active_record_...
COMMIT

まとめ

上の例から、いくつかの規則が得られます。

  1. 代入可能な関連付けを扱うときは、attributes APIをじかに使うことを避け、updatecreateを使うこと。
  2. (トランザクションの)ネストを扱う場合は、ROLLBACKを伝搬させる例外を使うか、親トランザクションをJOINできないようにすること。

関連記事

Ruby on Rails のhas_many 関連付けのフィルタテクニック4種(翻訳)

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

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

Railsの`Object#try`がダメな理由と効果的な代替手段(翻訳)

$
0
0

概要

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

原文タイトルは、よくあるヨーダのセリフのもじりです。

RailsのObject#tryがダメな理由と効果的な代替手段(翻訳)

Object#tryは、Railsアプリでnil値を扱う可能性がある場合をカバーするときや、与えられたメソッドがオブジェクトで必ずしも実装されていないといった場合に柔軟なインターフェイスを提供するときに、かなりよく使われています。#tryのおかげでNoMethodErrorを回避できるのですから、これで問題はなくなったように見えます。NoMethodError例外が起きなくなったからといって、本当に問題がなくなったと言えるのでしょうか?

実際にはそうとは言えません。Object#tryにはいくつかの重大な問題がつきまといますし、たいていの場合もっとよいソリューションをかなり簡単に実装できるのです。

Object#tryのしくみ

Object#tryの基本となるアイデアはシンプルです。nilオブジェクトにメソッド呼び出しを行った場合や、そのオブジェクトにメソッドが実装されていないnilでないオブジェクトにメソッド呼び出しを行った場合に、NoMethodError例外をraiseせず、単にnilを返すというものです。

たとえば、最初のユーザーのメールアドレスを取りたいとします。ユーザーが1人もいない場合に失敗しないようにするために、次のように書くことができます。

user.first.try(:email)

このとき、さまざまな種類のオブジェクトを渡せる一般的なサービスを実装していたとしたらどうでしょう。たとえば、オブジェクトを保存した後、オブジェクトがたまたまそのための正しいメソッドを実装した場合に通知を送信するにはどうしたらよいでしょうか。Object#tryを使うと次のように書けます。

class MyService
  def call(object)
    object.save!
    object.try(:send_success_notification, "MyServiceから保存されました")
  end
end

コードを見れば、このメソッドに引数を与えられることもわかります。

途中でnilを受け取る可能性のあるステップごとに、何らかのメソッドをチェインする必要があるときにはどうすればよいでしょうか。ご心配なく、Object#tryでできます。

payment.client.try(:addresses).try(:first).try(:country).try(:name)

Object#tryの何が問題なのか

一見するとObject#tryはさまざまなケースを扱えそうですが、使うとどんな問題が起きるのでしょうか。

その答えは「たくさん起きる」です。多くの場合、Object#tryの最大の問題は、そもそも問題が決して発生せず、かつその問題がnilである場合に問題を「解決」してしまうことです。他にも、Object#tryを使うと意図がはっきりしなくなる点も挙げられます。次のコードが何をしようとしているかおわかりでしょうか。

payment.client.try(:address)

見た目どおり、支払い(payment)にクライアントがいない場合にnilになるケースを扱っているのでしょうか。それとも、単にクライアントがたまたまnilになったときにNoMethodErrorで失敗したくないから「念のため」使っているだけなのでしょうか。もっと悪い場合を考えると、クライアントがたまたまポリモーフィック関連付けになっていて、しかもaddressesメソッドがモデルに実装されているとは限らないとしたらどうでしょう。あるいは、データ完全性にある問題が生じ、何らかの理由でクライアントが削除された支払いがいくつか発生してしまい、しかも残っていないとしたらどうでしょう。

Object#tryの使いみちの可能性があまりに多いため、上のコードを見ただけでは真の意図を知ることは不可能です。

幸いなことに、Object#tryを取り除いて明確で表現力の高いコードにできる別のさまざまなソリューションがあります。そうしたソリューションを使えば、コードのメンテナンス性と読みやすさがより高まり、バグが発生しにくくなり、二度と意図があいまいにならないようにできます。

Object#tryを使わないソリューション

Object#tryの利用状況に応じた「パターン」をいくつかご紹介します。

1. デメテルの法則を尊重する

デメテルの法則は、構造的な結合を回避するのに便利な規則です(個人的には「法則」というほどではない気がします)。要するに、仮想のオブジェクトAは自分に直接関連することにのみ関心を持つべきであり、協同または関連する相手の内部構造に立ち入るべきではないという規則です。この規則は「メソッド呼び出しのドット.は1つだけにする」と解釈されることが多いのですが、デメテルの法則は本来ドットの数の規則ではなく、オブジェクト間の結合についての規則なので、操作や変換のチェインについてはまったく問題にはなりません。たとえば次の例は法則に違反しません。

input.to_s.strip.split(" ").map(&:capitalize).join(" ")

しかし次の例は違反です。

payment.client.address

デメテルの法則を尊重することで、多くの場合明確でメンテナンス性の高いコードを得ることができます。法則に違反する十分な理由がない限り、法則を守って密結合を回避するようにすべきです。

先のpayment/client/addressを使ったコード例に戻ります。次のコードはどのようにリファクタリングできるでしょうか。

payment.client.try(:address)

最初に行うのは、構造的な結合を減らしてPayment#client_addressメソッドを実装することです。

class Payment
  def client_address
    client.try(:address)
  end
end

これでさっきよりずっとよくなりました。payment.client.try(:address)で無理やりaddressを参照するのではなく、payment.client_addressを実行するだけで済みます。Object#tryが1箇所だけになったので既に1つ改善されました。リファクタリングを続けましょう。

ここから先は2つの選択肢があります。clientがnilになるのが正当か、そうでないかです。clientがnilになるのが正しいのであれば、自信を持って明示的にnilを早期に返します(訳注: いわゆるguard構文です)。こうすることで、clientが1つもないのは有効なユースケースであることがはっきりします。

class Payment
  def client_address
    return nil if client.nil?

    client.address
  end
end

clientは決してnilになってはならないのであれば、先のguard構文をスキップできます。

class Payment
  def client_address
    client.address
  end
end

このような委譲はかなり一般的に行われます。Railsではこういう場合にうまい解決法があるでしょうか?答えは「イエス」です。ActiveSupportは、まさにこういう場合にうってつけのActiveSupport#delegateマクロを提供しています。このマクロを使えば、nilをさっきとまったく同じように扱える委譲を定義できます。

最初の例では、nilになってもよいユースケースを次のように書き換えます。

class Payment
  delegate :address, to: :client, prefix: true, allow_nil: true
end

nilになってはならない場合は次のように書き換えます。

class Payment
  delegate :address, to: :client, prefix: true
end

これで先ほどよりもずっとコードが明確になり、結合も弱まりました。Object#tryをまったく使わずにエレガントに解決するという目的を達成できたのです。

しかし、他の場所ではpaymentにclientがないことを予測しきれていない可能性がまだ残されています(完了していないトランザクションのpaymentなど)。たとえば、トランザクションが完了したpaymentのデータを表示するときになぜかNoMethodError例外でつまづいてしまうことがあります。このような場合に、必ずしもdelegateマクロでallow_nil: trueオプションが必要になるとは限りません。もちろん、Object#tryを使わなければならないということでもありません。この場合の解決法はいくつか考えられます。

2. スコープ付きデータを操作する

完了したトランザクションのpaymentを扱うときにclientが存在することを保証するなら、単に正しいデータセットを扱えるようにするのが手です。Railsアプリではこういう場合に、PaymentコレクションにActiveRecordの何らかのスコープ(with_completed_transactionsなど)を適用します。

Payment.with_completed_transactions.find_each do |payment|
  do_something_with_address(payment.client_address)
end

完了していないトランザクションのpaymentでclientのaddressを使って何かする計画はまったくないので、ここでnilを明示的に取り扱う必要はありません。

にもかかわらず、paymentの作成にclientが常に必須になっているとしても、このコードでNoMethodErrorが発生する可能性は残されています(関連付けられたclientレコードが誤って削除されてしまった場合など)。この場合は修正が必要になるでしょう。

3. データ完全性

特にPostgreSQLなどのRDBMSを使っている場合、データの完全性を確実にする方法はかなりシンプルです。ここで押さえておくべきは、テーブルの新規作成時に適切な制約を追加することです。これはデータベースレベルで行う必要があることを忘れてはなりません。モデルでのバリデーションは簡単にバイパスされてしまうことがあるため、まったく不十分です。clientnilになってしまう問題を回避するには、paymentsテーブル作成時にNOT NULL制約とFOREIGN KEY制約を追加して、clientがまったく割り当てられない状況や、関連付けが一部のpaymentに残っているclientレコードが削除されるような状況を防ぐべきです。

create_table :payments do |t|
  t.references :client, index: true, foreign_key: true, null: false
end

以上で制約の追加はオシマイです。制約の追加を忘れないようにすることで、nilで起きる予想外のユースケースの多くを回避できます。

4. 明示的な変換で型を確定する

私は次のようなかなり風変わりなObject#tryの使い方を何度か目にしたことがあります。

params[:name].try(:upcase)

このコードから、params内のnameキーから何らかの文字列が取り出せることを期待しているのが読み取れます。それならto_sメソッドで明示的に変換することで文字列型を確定させればよいのではないでしょうか。

params[:name].to_s.upcase

これで意図がわかりやすくなりました。

ただし、上の2つのコードは同等ではありません。前者はparams[:name]が文字列であれば文字列を返しますが、nilの場合にはnilを返します。後者は常に文字列を返します。場合によってnilが戻ることが期待されるかどうかは元のコードからははっきりしないので(これはObject#tryのあからさまな問題ですが)、ここでは2つの選択肢が考えられます。

  • params[:name]nilならnilを返すことが期待される場合: 文字列の代わりにnilを扱うのはかなり面倒になるので、あまりよいアイデアとはいえませんが、nilを扱う必然性がどうしても生じることもあるかもしれません。そのような場合はguard構文を追加してparams[:name]nilになる可能性があることを明示的に示す方法が考えられます。
return if params[:name].nil?

params[:name].to_s.upcase
  • 文字列を返すことが期待される場合: この場合はguard構文は不要です。先の明示的な変換をそのまま使いましょう。
params[:name].to_s.upcase

もっと複雑な状況では、Form Objectを使うか、dry-rbなどのもっと安全な型管理を導入する(あるいは両方)のがよいかもしれません。ただしこれらは明示的な型変換と本質的に同じなので、設計を損なわない限りは有用だと思います。

5. 正しいメソッドを使う

ネストしたハッシュの取り扱いは、API開発やユーザー提供のペイロードを扱うときにかなりよく見かけるユースケースです。JSONAPI互換のAPIを扱っていて、更新時にclientの名前を取得したいとしましょう。この場合は次のようなペイロードが考えられます。

{
  data: {
    id: 1,
    type: "clients",
    attributes: {
      name: "some name"
    }
  }
}

しかしAPIのユーザーが提供するペイロードが正しいかどうかがどうしてもわからない場合、ペイロードの構造が正しくないという仮定が成り立つことがあります。

こういう場合の残念な対応方法といえば、もうおわかりですね。Object#tryです。

params[:data].try(:[], :attributes).try(:[], :name)

お世辞にも美しいとは言い難いコードです。しかし面白いことに、このコードは実に簡単にきれいに書き直すことができるのです。

1つの方法は、途中のステップに明示的な変換を適用することです。

params[:data].to_h[:attributes].to_h[:name]

さっきよりよくなりましたが、もう少し表現力が欲しいところです。理想的な方法は、こういう場合のための専用メソッドを使うことです。そうした専用メソッドはいくつかありますが、たとえばHash#fetchは、指定のキーがハッシュにない場合の値も指定できます。

params.fetch(:data).fetch(:attributes, {}).fetch(:name)

これでずっとよくなりましたが、ネストしたハッシュを掘ることにもう少し特化したメソッドがあればさらによいでしょう。幸いなことに、Ruby 2.3.0からまさにこのためのHash#digメソッドが使えるようになりました。このメソッドはネストしたハッシュをくまなくチェックし、中間のキーがない場合にも例外をraiseしません。

params.dig(:data, :attributes, :name)

6. 正しいインターフェイスかダックタイピングを使う

最初に使った、必要な場合に通知を送信する例に立ち戻ります。

class MyService
  def call(object)
    object.save!
    object.try(:send_success_notification, "saved from MyService")
  end
end

このコードの改善方法は2とおりあります。

  • サービスを2つ実装する: 1つのサービスは通知を送信し、もう1つは送信しません。
class MyServiceA
  def call(object)
    object.save!
  end
end

class MyServiceB
  def call(object)
    object.save!
    object.send_success_notification("saved from MyService")
  end
end

リファクタリングしたことでコードがずっと明確になり、Object#tryも取り除けました。しかし今度は、MyServiceAを使う必要があるオブジェクトの種類とMyServiceBを使う必要があるオブジェクトの種類を知る方法が必要になります。これはこれで理解できますが、別の問題となる可能性もあります。この場合は2番目の方法がよいでしょう。

  • ダックタイピングを使う: MyServiceに渡されるすべてのオブジェクトに単にsend_success_notificationメソッドを追加します。このメソッドは何もせず、メソッドの内容は空のままにします。
class MyService
  def call(object)
    object.save!
    object.send_success_notification("saved from MyService")
  end
end

この方法なら、オブジェクトで共通する振舞いを明示的に示せるので、そうした振舞いを認識しやすくなるというメリットも得られます。元のObject#tryには多数のドメイン概念が潜んでいるため、コードの意図がわかりにくくなるという問題があります。そうしたドメイン概念が存在しないのではなく、ちゃんと認識されていないということです。Object#tryを使うとドメイン(概念)も損なわれてしまいます。これもぜひ覚えておきたい重要なポイントです。

7.「Null Object」パターン

上の通知送信の例をもう一度使うことにします。モデルの形はある程度残しつつ、少し変更しました。メソッドの引数をmailerにし、それに対してsend_success_notificationを呼びます。

class MyService
  def call(object, mailer: SomeMailer)
    object.save!
    mailer.send_success_notification(object, "saved from MyService")
  end
end

これで、必要に応じていつでも通知を送信できるようになりました。さて、通知を送信したくないときはどうすればよいでしょうか。そんなときの残念な方法といえば、mailernilを渡してObject#tryを使うことです。

class MyService
  def call(object, mailer: SomeMailer)
    object.save!
    mailer.try(:send_success_notification, object, "saved from MyService")
  end
end

Service.new.call(object, mailer: nil)

ここまでお読みになった方は、この方法を使うべきでないことがおわかりいただけると思います。ありがたいことに、Null Objectパターンを適用すれば、何もしないsend_success_notificationメソッドを実装する何らかのNullMailerのインスタンスを渡すことができます。

class NullMailer
  def send_success_notification(*)
  end
end

class MyService
  def call(object, mailer: SomeMailer)
    object.save!
    mailer.send_success_notification(object, "saved from MyService")
  end
end


MyService.new.call(object, mailer: NullMailer.new)

これでObject#tryよりずっとよいコードになりました。

ぼっち演算子&.とは何か

Ruby 2.3.0で新しく導入された&.は「ぼっち演算子」や「safe navigation operator」などと呼ばれます(訳注: 以下ぼっち演算子で統一)。ぼっち演算子は一見Object#tryとよく似ていますが、Object#tryほどあいまいではありません。nil以外のオブジェクトに対してメソッド呼び出しを行い、かつオブジェクトにそのメソッドが実装されていない場合はNoMethodErrorがraiseされます(Object#tryはそうではありません)。次の例をご覧ください。

User.first.try(:unknown_method)  # `user`がnilであるとする
=> nil

User.first&.unknown_method
=> nil

User.first.try(:unknown_method!) # `user`はnilでないとする
=> nil

User.first&.unknown_method
=> NoMethodError: undefined method `unknown_method' for #<User:0x007fb10c0fd498>

ということは、ぼっち演算子なら安全に使えるからよいのでしょうか?そうでもありません。Object#tryの重大な問題が1つ減っただけで、他の問題はそのまま変わらないからです。

しかしながら、私はぼっち演算子を使ってもよいケースが1つあると考えています。次のコード例をご覧ください。

Comment.create!(
  content: content,
  author: current_user,
  group_id: current_user&.group_id,
)

ここでは、current_userに属するコメントを1つ作成したいと考えています。current_userは作者(author)になることがあり、current_userからgroup_idを代入しますが、このgroup_idnilになる可能性があるとします。

上のコードは次のように書き直せます。

Comment.create!(content: content, author: current_user) do |c|
  c.group_id = current_user&.group_id if current_user
end

次のように書き直すこともできます。

comment_params = {
  content: content,
  author: current_user,
}

comment_params[:group_id] = current_user.group_id if current_user

Comment.create!(comment_params)

しかし、書き直したコードが、元のぼっち演算子&.を使ったサンプルより読みやすくなったとは思えません。このように、意図があいまいになるのと引き換えに読みやすさを優先したい場合には、ぼっち演算子&.が有用なこともあります。

まとめ

私は次の理由から、Object#tryの有効なユースケースはひとつもないと信じています。Object#tryを使うと意図があいまいになってしまい、ドメインモデルに負の影響が生じます。Object#tryは問題を美しくない方法で「解決」してしまいますが、同じ問題をもっとスマートに解決できる方法が「デメテルの法則の尊重と委譲」から、「スコープが正しく設定されたデータを扱う」、「データベースに正しい制約を適用する」、「明示的な変換で型を確定させる」、「正しいメソッドを使う」、「ダックタイピングを利用する」「Null Objectパターン」に至るまで数多く存在するという単純な事実があります。ぼっち演算子&.すら、用途を限定すればずっと安全に使うことができます。

訳注: 本記事では言及されていませんが、!付きのObject#try!は実質ぼっち演算子と同じに使えます。
ぼっち演算子が#try!と少し異なるのは、引数付きだとnilのときに引数が評価されないという点です。
参考: Safe Navigation Operator で呼ばれるメソッドの引数はレシーバが nilなら評価されない

関連記事

Rubyスタイルガイドを読む: クラスとモジュール(2)クラス設計・アクセサ・ダックタイピングなど

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

Ruby: 「マジック」と呼ぶのをやめよう(翻訳)

$
0
0

概要

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

文中で言及されている記事「7 Gems Which Will Make Your Rails Code Look Awesome」の日本語版は以下をどうぞ。同記事からの引用もこの日本語版を使いました。

Railsコードを改善する7つの素敵なGem(翻訳)

Ruby: 「マジック」と呼ぶのをやめよう(翻訳)

最近「7 Gems Which Will Make Your Rails Code Look Awesome」というよく書けた良記事を読んでいて、decent_exposure gemのところで次の文言がふと目に留まりました。

マジックがあまり好きでない方はこのライブラリを使わなくてもよいでしょう
(強調は原著者)

この「mで始まる単語」が指していたのはメタプログラミングを使うライブラリです。シンプルなCRUDで十分な場合にexposeメソッドをRailsコントローラクラスに追加して、重要度の低い#index/#show/#newを生成します。

訳注: 「mで始まる単語(m-word)」という英語の言い回しは、ある品の良くない単語を暗に指すことがあります(参考)。

私はこの記事の著者に苦言を呈するつもりはまったくありませんのでご了承ください。単にいくつかの重要な点に言及するきっかけとなったに過ぎません。最近RubyやRailsのコミュニティでは「マジック」と「メタプログラミング」を一種の邪悪な同義語とみなして使うことがかなり広まっているようです。そこで私はあくまで品位を保ちつつ、こうきっぱり申し上げたいのです。

「マジック」と呼ぶのをやめよう

理由についてはこの先をお読みください。

免責事項: 説明に使っているコード例は手作りであり、いかなる特定の既存ライブラリにも基づいていません。このブログで以前起きたような「ワイらの%(ここにライブラリ名が入る)%をコケにするのか」などという反応で論点が見失われないようにするためです。

そもそも、

マジックとは何か?

Wikipediaを引用します。

マジック: 儀式、象徴、振舞い、身振り、言語を用いて、超自然的な力を振るう目的で使われる
Wikipedia: Magic (paranormal)より

次は

超自然的: 自然界の法則では説明できないあらゆるものを含む
Wikipedia: Supernaturalより

上をプログラミングに当てはめると、次のように定式化できます。

コードにおけるマジックとは、何らかの振る舞いが引き起こされるとき、その言語の熟練開発者ですらその原因を追うのが難しいか不可能な状況を指す。

これは定義として完全ではないので、やや理論的な例でこの点を明らかにしてみましょう。

class LandCruiserJ200 < Car
  def type
    :suv
  end

  def engine
    '4.5 L'
  end
end

LandCruiserJ200.new.drive! # "ブルン! ブロロロロロ!"

これはマジックでしょうか?#driveメソッドは上のコードのどこにも見当らないのに、魔法のように動作している???

まともな開発者ならここで苦笑するところでしょう。

馬鹿馬鹿しい、もちろんマジックなんかではありません。単なる継承です。#driveメソッドが親のCarクラスで定義されていることは見当がつきますし、クラスやメソッドのコードを探るのも簡単です(何らかの外部gem由来などの場合は、ドキュメントを探してもよいでしょう)。

もうひとつ例を出しましょう。

class LandCruiserJ200
  include Drivable

  def type
    :suv
  end

  def engine
    '4.5 L'
  end
end

LandCruiserJ200.new.drive! # "ブルン! ブロロロロロ!"

奇っ怪な!継承していないのに、どこから#drive!メソッドが来たのか?マジックだ!

…というか単なる普通のmixinです。Drivableのドキュメントを調べれば、これが使われる理由や詳細はもちろん、#drive!メソッドが何なのかもわかるでしょう。

もうひとつ。

class LandCruiserJ200
  drivable type: :suv, engine: '4.5 L'
end

LandCruiserJ200.new.drive! # "ブルン! ブロロロロロ!"

むむ、今度こそ説明不能な禁断の魔術に違いない!凡人には理解不能ではないか?

…というかたぶんdrivableが実はメソッドで、Classに直接定義されてるか、でなければ何らかのモジュールで拡張されているのでしょう(これはベストプラクティスではありませんが、それでもジュニアを卒業したRubyistなら簡単に見当がつくでしょう)。

LandCruiserJ200.method(:drivable) # => #<Method: Class(Drivable)#drivable>
LandCruiserJ200.method(:drivable).source_location # => /some/gem/drivable/lib/drivable.rb:123

ではどんなものがマジックなのか?

マジックをお見せしましょう。

class LandCruiserJ200Car
end

car = LandCruiserJ200Car.new
# => #<Car model="Land Cruiser J200">
car.drive! # "ブルン! ブロロロロロ!"

上のコードが動くとお考えください。なぜ動くのでしょうか。

私がこの事実と取り組まなければならなくなったら、こんなふうに推測するかもしれません。おそらく現在の何らかのスコープが規約を提供している、それは…うーん、名前がCarになっているものをすべて特別扱いするとか?それともapp/carsフォルダにあるものすべてを特別扱いしている?でなければ、carのクラスの全リストを持っているYAML設定か何かを使っている?

この振る舞いを追加する責務を持つのは一体何だろう、必要になったらドキュメントから探せるのだろうか、期待どおりに動作しない場合はどうやってデバッグすればいいんだろうか。

これは「超自然的な」振る舞いの例になっています(言語の自然な力とは明らかに無関係に何かが起きているなど)。そしてマジックは、「自然なツールや直感が通用しないので、魔導書を丸暗記しなければ魔法のコードを扱えない」というまさにその理由によって、邪悪なものです。

マジックでないものとは何か

ジュニア開発者が理解できないものはマジックに該当しない

「これはマジックが強すぎて経験の浅い開発者には理解できない」という言い方をよく見かけます。(私の記憶ですが)Matzの言うとおり「Rubyは、シンプルに開発するための複雑な言語」であって、その逆ではありません。

自動車のエンジンもコンピュータのCPUも相当複雑ですが、だからといってマジックだということにはなりません。これらを使うべきではないということにはなりませんし、マジックをなるべく使わないようにすべきということにもなりません。

モンキーパッチはマジックに該当しない

そう、モンキーパッチにはいろいろと疑問の余地があります。コアクラスへのモンキーパッチは重大な罪であると考えている人もいるほどです。

しかし別の言語からRubyにやってきた人の中には5.daysを見て「マジックだ」と呼ぶ人もいたりしますが、なぜこの人たちの言説を復唱しなければならないのでしょうか。

これは他の言語の開発者にとっては直感に反することもありますが、Rubyistにとってはそこで何が起こっているか、メソッドの由来の理解やドキュメントや実装の調べ方はおおよそ明らかです。

追記になりますが、Rubyの「オープンクラス」は(コアがオープンであることも含めて)Rubyを進化させるための重要な機能です。それによって新しい概念を実験することもできますし、必要に応じてbackportspolyfillといったgemを使って後方互換性を提供することもできます。

メタプログラミングはマジックに該当しない

そろそろ本記事の冒頭に立ち返ることにしましょう:)

「メタプログラミング」(コード実行中にコードオブジェクトを生成するなど)はRubyの非常に強力な機能であり、Rubyという言語にとって自然なものだからこそ強力たりえます。この「Rubyにとっての自然さ」によって、多くのコード設計で最初にして最善のものとして検討されます。

単に内部の値に委譲するいくつかの数学演算子をクラスに与える必要がある場合、次のようにするでしょう。

%i[+ - * /].each { |op| define_method(op) { ...

他の似たようないくつかのクラスでもこうした振る舞いを宣言的に定義する必要がある場合は、クラスメソッドのシンプルなDSLを追加するでしょう。こんなふうにいろんなことができます。

そして後になって、パフォーマンス上の理由やドキュメントを書きにくいという理由で(最近はメタプログラミングコードのドキュメント化に使えるなかなかいいツールがありますが)、あるいは以前は同じだったコードオブジェクトが同じでなくなってしまったという理由で、決定を変更するかもしれません。

しかしメタプログラミングは、いつでも引き出しから取り出して便利に使える、そうしたツールのひとつに過ぎません。

誰かが「メタプログラミングは不自然だ」と100回繰り返したからといって、メタプログラミングが不自然になることはありません

まとめ

本記事を書いた理由は次のとおりです。

(訳注: マジックに限らず)「mで始まる言葉」はコミュニティにとって有害です。この言葉はRuby世界の内外で自由に使われすぎたために悪いレッテルと化し、「単に好きでないから」とか「単に理解できないから」という理由でさまざまな手法や機能にデタラメに貼り付けられています。こんな状況では、設計を完璧に練り上げた自然なライブラリや言語機能がほとんど「使用禁止」になってしまい、多くのアイデアが立ち腐れてしまいます。

だからこそ、単刀直入に申し上げます。

Rubyistの皆様、「メタプログラミング」の同義語として「マジック」という言葉を使うのをやめましょう。

関連記事

Rubyスタイルガイドを読む: 正規表現、%リテラル、メタプログラミング(最終回)

Ruby: delegate.rb標準ライブラリの動作を解明する(翻訳)

[Rails5] Active Support Core ExtensionsのString#inquiryでメタプログラミング

Rails: ActiveRecord関連付けのpreload/eager-loadをテストする2つの方法(翻訳)

$
0
0

概要

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

Rails: ActiveRecord関連付けのpreload/eager-loadをテストする2つの方法(翻訳)

パフォーマンスに気を配っている開発者なら、#includes#preload#eager_loadなどの読み込みメソッドでN+1クエリを回避する方法をご存知でしょう。しかし自分の仕事が正しかったかどうか、期待する関連付けが本当にpreloadされているかどうかをチェックする方法はあるのでしょうか。そもそもどうやってテストすればよいのでしょうか。方法は2つあります。

Railsアプリに次の2つのクラスがあるとします。1つのorderは複数のorder line(order_lines)を持つことができます。

class Order < ActiveRecord::Base
  has_many :order_lines

  def self.last_ten
    limit(10).preload(:order_lines)
  end
end
class OrderLine < ActiveRecord::Base
  belongs_to :order
end

ここでOrder.last_tenというメソッドを実装しました。これはeager-loadingする関連付けを1つ使って、最新のorderを10件返します。このコードを呼び出した後でちゃんとpreloadされるかどうかを確認してみましょう。

1. association(:name).loaded?

require 'test_helper'

class OrderTest < ActiveSupport::TestCase
  test "#last_ten eager loading" do
    o = Order.new()
    o.order_lines.build
    o.order_lines.build
    o.save!

    orders = Order.last_ten
    assert orders[0].association(:order_lines).loaded?
  end
end

preload(:order_lines)を行ったので、order_linesが読み込まれているのかどうかを知りたいと思います。orders[0]などのOrderオブジェクトを1つ取得する必要があることをチェックするには、オブジェクトの照合を行います。ordersコレクションをチェックしても関連付けが読み込まれているかどうかはわからないため、コレクションのチェックは不要です。

RSpecでのテストは以下のような感じになります。

require 'rails_helper'

RSpec.describe Order, type: :model do
  specify "#last_ten eager loading" do
    o = Order.new()
    o.order_lines.build
    o.order_lines.build
    o.save!

    orders = Order.last_ten
    expect(orders[0].association(:order_lines).loaded?).to eq(true)
    # 次でもよい
    expect(orders[0].association(:order_lines)).to be_loaded
  end
end

2. ActiveSupport::Notificationsでクエリをカウントする

ActiveRecordライブラリにはassert_queriesという便利なヘルパーメソッドがあり、ActiveRecord::TestCaseに含まれているのですが、惜しいことに、ActiveRecord::TestCaseはActiveRecordに含まれていません。これはRailsの内部テストで振舞いをチェックする目的にのみ利用できます。しかし今回の目的に合わせてassert_queriesをエミュレートするのは意外に簡単です。

いくつかのActiveRecordオブジェクトのグラフを操作するが、オブジェクトを返さずに計算値だけを返すという状況を考えてみましょう。このときにN+1問題が発生していないことをどうやって確認すればよいでしょうか。副作用は見当たらず、loaded?かどうかをチェックできるレコードも返されません。何か方法はないものでしょうか。

class Order < ActiveRecord::Base
  has_many :order_lines

  def self.average_line_gross_price_today
    lines = where("created_at > ?", Time.current.beginning_of_day).
      preload(:order_lines).
      flat_map do |order|
        order.order_lines.map(&:gross_price)
      end
    lines.sum / lines.size
  end
end

class OrderLine < ActiveRecord::Base
  belongs_to :order

  def gross_price
    # ...
  end
end

上の状況で、Order.average_line_gross_price_todayがN+1クエリ問題を抱えていないかどうかをどのように確認すればよいでしょうか。order_lines?を読み取るときにorder.order_lines.map(&:gross_price)がSQLクエリをトリガしないことをどのように確認すればよいでしょうか(実はN+1問題が起きています)。

ActiveSupport::Notificationsを使えば、SQL文が実行されるたびに通知を受け取ることができます。

require 'rails_helper'

RSpec.describe Order, type: :model do
  specify "#average_line_gross_price_today eager loading" do
    o = Order.new()
    o.order_lines.build
    o.order_lines.build
    o.save!

    count = count_queries{ Order.average_line_gross_price_today }
    expect(count).to eq(2)
  end

  private

  def count_queries &block
    count = 0

    counter_f = ->(name, started, finished, unique_id, payload) {
      unless %w[ CACHE SCHEMA ].include?(payload[:name])
        count += 1
      end
    }

    ActiveSupport::Notifications.subscribed(
      counter_f,
      "sql.active_record",
      &block
    )

    count
  end
end

上のようにする場合、eager loadingの問題を検出するのに十分な数のレコードを作成しておいてください。order 1件とline 1件だけでは、eager loadingが発生するかどうかにかかわらずクエリの数が同じになってしまうので不十分です。今回はorderのlineが2の場合にのみ、preloadingでのクエリ数(2件、1つはすべてのorder、もう1つはすべてのline)とpreloadingされない場合のクエリ数(3件、1つはすべてのorder、残りは個別のline)に違いが生じることがわかります。修正する前にはテストが失敗することを必ず確認しましょう :)

この方法はもちろん有効ですが、責務を2つの小さなメソッドに分割できたらなおよいでしょう。責務の1つはデータベースから正しいレコードを抽出すること(IOが発生する)、もう1つはデータを変換して計算することです(IOも副作用もなし)。

この種のテストで役に立つRSpecマッチャーとして、db-query-matchers gemをチェックしてみてください。

もっと知りたい方に

本記事を気に入っていただけた方は、日々のRailsプログラミングに役立つ知識をいつも最初に知ることができる弊社のニュースレターをぜひご購読ください。コンテンツはRuby、Rails、Web開発、リファクタリングが中心ですが、その他の話題も扱っています。

大規模で複雑なRailsアプリを手がけている方は、弊社の最新刊『Domain-Driven Rails』もぜひチェックしてみてください。

関連記事

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

TestProf: Ruby/Railsの遅いテストを診断するgem(翻訳)

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

Goby: Rubyライクな言語(1)Gobyを動かしてみる

$
0
0

こんにちは、hachi8833です。
今回から不定期で、Go言語だけで書かれたRubyライクな言語「Goby」について書きます。おそらく日本語で書かれた最初のGoby記事になると思います。

Railsへのコミット経験もある@st0012さんが作ったGobyは現在バージョン0.1.3で、first commitからまだ1年も経過していませんが、st0012さんの驚異的な実装の速さのおかげでかなり早くから基本的な部分をひととおり動かすことができ、HTTP serverやDBアダプタといった基本的なライブラリも装備していて、簡単なWebアプリ(https://sample.goby-lang.org/)やAPIを実際に書くことができます。コミット数は現時点で1800を超えています。

View post on imgur.com

Gobyはその名のとおりRubyから強く影響を受けていて、Rubyと同じ感覚で使えます。Goby実行系はGo言語だけで書かれている(CGOなどのC言語のコードは今のところ含まれていない)ので、C言語を知らないけど気軽に言語系をいじって遊んでみたい方にはぴったりだと思います。Gobyの最適化はこれからですが、その分Goby実行系のソースコードが読みやすいのもありがたい点です。

また、Gobyを知ることでRubyを知るのにも役に立つと思いますし、少なくとも私はそう感じています。実際Gobyのcontributorの中には、Gobyで遊んだ後にRuby本体にパッチを投げた方もいます。

Gobyの特徴については追って順にご紹介しますので、今回はまずGobyの動かし方をご紹介します。

Gobyをどうとらえるか

たとえとしてはとても大ざっぱで恐縮ですが、RubyがサッカーだとすればGobyはさしずめフットサルのようなものと自分は考えています(それならmrubyだよね?というツッコミがありそう…)。フットサルはオフサイドやスローインがないなどルールが若干異なっていますが、同じ感覚でプレーでき、さらに屋内でもプレーできます。何より、サッカーをやる人はいつでもフットサルも楽しむことができます。

Gobyの目的の一つに「マイクロサービスやWebアプリを楽に書けるようにする」というのがあり、Gobyの仕様や標準ライブラリもその点を優先して整備が進められています。Gobyは、Rubyと完全に同じものにする予定は今のところないそうです。実際、明確な意図のもとに少し仕様を変えているところもあり、そうした点については今後の記事でご紹介します。

いろいろ書きましたが、要はGoby実行系のソースをいじるのは私にとって楽しいということです。

Gobyのインストール方法

Gobyのインストールには、Homebrewを使う方法、Go言語ソースをコンパイルする方法、Docker imageを使う方法があります。

Gobyインストール上のポイントは1つ、$GOBY_ROOTの設定です。この環境変数は、Gobyの標準ライブラリをrequireするときなどに必要です。

1. Mac+Homebrew

単に動かすのであれば、Macユーザーはhomebrewでインストールできます。この場合、$GOBY_ROOT環境変数も自動でセットアップされます。インストールの際は最新バージョンをご確認ください。

brew tap goby-lang/goby
brew install goby

gobyを実行して以下が表示されればインストール成功です。

$ goby
Usage of goby:
  -e    Run interactive goby
  -i    Run interactive goby
  -p    Profile program execution
  -v    Show current Goby version

2. ソースからインストール

GobyのGoソースをいじって楽しみたい方、Windows/Linuxの方は、Go言語の環境をセットアップしたうえでGobyのソースからコンパイルします。私はWindowsでGoを動かしたことがないので、ここではMacとLinuxで説明します。ご了承ください。

1. Go言語のセットアップ

Go言語が利用可能な方は1.をスキップできます。

Macの場合、brew install goでGo言語をインストールするのが楽です。
Linuxの場合は以下でGo言語をインストールします。

$ wget https://storage.googleapis.com/golang/go1.9.linux-amd64.tar.gz -O /tmp/golang-1.9.tar.gz
$ sudo tar xv -C /usr/lib -f /tmp/golang-1.9.tar.gz
$ sudo mv /usr/lib/go /usr/lib/go-1.9

ここからはMac/Linux共通です。以下で$GOPATH$GOROOT環境変数をセットアップし、$PATHも設定します。ここでは.bashrcに設定する前提ですが、必要があれば.bash_profileの方に設定します。

Homebrewの場合を除き、$GOPATHは好きな場所に設定できます。~/go~/.goに置かれることが多いようです。$GOPATHは後から変更すると問題が起きやすいので、一度設定したら変えないようにしましょう。

$ echo 'export GOPATH=$HOME/go' >> $HOME/.bashrc
$ echo 'export GOBY_ROOT=$GOPATH/src/github.com/goby-lang/goby' >> $HOME/.bashrc
$ echo 'export PATH=$PATH:$GOPATH/bin' >> $HOME/.bashrc
$ source ~/.bashrc #設定をリロード

go versionを実行して以下のように表示されればGo言語のインストールは完了です。

go version go1.9.2 darwin/amd64

2. Gobyソースのインストールとコンパイル

以下を実行してGobyソースをインストールします。実行はどのディレクトリにいても構いません。

$ go get github.com/goby-lang/goby

以下を実行してGobyの$GOBY_ROOT環境変数を設定します。

$ echo 'export GOBY_ROOT=$GOPATH/src/github.com/goby-lang/goby' >> $HOME/.bashrc
$ source ~/.bashrc #設定をリロード

Gobyのディレクトリに移動してmake installを実行します。

$ cd $GOPATH/src/github.com/goby-lang/goby
$ make install

gobyを実行して以下が表示されればインストール成功です。お疲れさま!

$ goby
Usage of goby:
  -e    Run interactive goby
  -i    Run interactive goby
  -p    Profile program execution
  -v    Show current Goby version

3. Docker imageを取得して環境まるごとインストール

Gobyには公式のDocker imageもあります。
私のMacbookではなぜかDocker for Mac自体がどうしても動いてくれない(泣)ので、Homebrewで素のDockerをインストールして動かしました。Dockerのインストール方法は省略します。

# bash
$ docker pull gobylang/goby
$ docker run -it gobylang/goby

GobyのREPLやサンプルコード

goby -iでインタラクティブモード(REPL)でGobyを使えます。実はREPLの多くは私が実装しました(repl.go)。

  • resetでREPLをリセット、exitで終了できます。
  • Goby REPLでは、キーで履歴をさかのぼったりCtrl-Rで履歴マッチしたりもできます。いわゆるreadline的なことはひととおりできます。
  • お遊びですがREPLを起動すると絵文字のfortuneが3つ表示されます。
    • 素のWindowsだと絵文字が化けたので、何とあのmattnさんがパッチを当ててWindowでfortuneを非表示にしてくれました。感謝!

  • Goby REPLのプロンプトは»、インデント中は¤、出力はが使われていますが、ターミナルからコピーして貼り付けると行冒頭の»¤は自動で削除されるので、コードを楽にREPL上でコピペできます(下)。
# REPLのプロンプト: このままターミナルにコピペできます。
» def foo
¤   42
» end
#»

駆け足紹介

  • クラス名#methodsでメソッド一覧を表示、クラス名#ancestorsで継承パスを表示できます。クラス名#singleton_classで特異クラスを表示できます。
  • moduleキーワードでモジュールを作成し、includeextendできます。
  • 次の特殊定数が使えます: ARGVSTDINSTDOUTSTDERRENV

Gobyのネイティブクラス

Gobyには現時点で以下のネイティブクラスがあります。Rubyでもお馴染みのクラスの他、Goby固有のクラスもあります。なお、ネイティブクラスはほとんどがnewできない仕様です(ユーザーのクラスはnewできます)。

Gobyの標準ライブラリ

Gobyには、上の他にrequireで導入できる標準ライブラリもあります。

  • Concurrent::Arrayrequire 'concurrent/array'): スレッドセーフなArray(新機能)
  • Concurrent::Hashrequire 'concurrent/hash'): スレッドセーフなHash(新機能)
  • DBrequire "db"): データベースアダプタ(現時点ではPostgreSQLのみ対応)
  • Net::HTTPNet::HTTP::Clientrequire "net/http")(サンプルコード
  • Net::HTTP::Clientサンプルコード
  • Net::SimpleServer
  • URLrequire "uri"
  • Jsonrequire 'json'
  • Pluginrequire "plugin"): Go言語の多くのパッケージをプラグイン化して利用できます(現時点ではLinux環境のみ)(サンプルコード

Gobyスクリプトの実行

goby ファイル名.gbでGobyスクリプトを実行できます。

# Goby
def f(from)
  i = 0
  while i < 3 do
    puts(from + ": " + i.to_s)
    i += 1
  end
end

f("direct")

c = Channel.new


thread do
  puts(c.receive)
  f("thread")
end

thread do
  puts("going")
  c.deliver(10)
end

sleep(2) # This is to prevent main program finished before goroutine.
# bash
$ goby channel.gb
direct: 0
direct: 1
direct: 2
going
10
thread: 0
thread: 1
thread: 2

Gobyスクリプトのサンプルとして、上のサンプルWebアプリのコードからmodel.gbも以下に転記してみました。こうしてみるとRubyとほぼ同じ感覚ですね。実際、Rubyのsyntax highlightingがそのまま使えます。

# Goby
require_relative "plugin"

PluginPG.run("create table if not exists list_items (
  id      serial primary key,
  title   varchar(40),
  checked boolean
)")

class ListItem
  attr_reader :id, :title, :checked, :error

  def initialize(params)
    @id      ||= params[:id]
    @title   ||= params[:title]
    @checked ||= params[:checked]
    @error   ||= params[:error]
  end

  def check
    self.class.plugin_db.exec('UPDATE list_items SET checked = true WHERE id = $1', @id)
    @checked = true
  end

  def uncheck
    self.class.plugin_db.exec('UPDATE list_items SET checked = false WHERE id = $1', @id)
    @checked = false
  end

  def update_title(title)
    self.class.plugin_db.exec('UPDATE list_items SET title = $1 WHERE id = $2', title, @id)
  end

  def destroy
    self.class.plugin_db.exec('DELETE FROM list_items WHERE id = $1', @id)
  end

  def valid?
    @error.nil?
  end


  def self.plugin_db
    PluginPG
  end

  def self.all
    plugin_db.query("SELECT * FROM list_items ORDER BY id DESC")
  end

  def self.find(id)
    result = plugin_db.query("SELECT * FROM list_items WHERE id = $1", id).first
    if result
      new({ id: result[:id], title: result[:title], checked: result[:checked] })
    end
  end

  def self.create(params = {})
    validates(params) do |result|
      if result[:error].nil?
        title   = params[:title]
        checked = params[:checked].to_i == 1
        resultID = self.plugin_db.exec("INSERT INTO list_items (title, checked) VALUES ($1, $2)", title, checked)
        new({ id: resultID, title: title, checked: checked })
      else
        new({ error: result[:error] })
      end
    end
  end

  def self.validates(params)
    if params.nil? || params[:title].nil?
      yield({ error: 'Title cannot be empty' })
    elsif params[:title].empty?
      yield({ error: 'Title cannot be empty' })
    else
      if params[:title].length > 40
        yield({ error: 'Title too long (should less than 40 characters)' })
      else
        yield({})
      end
    end
  end
end

GobyのSlackチャンネルとContribution Guideline

以下からGobyのSlackチャンネルにアクセスできます(英語のみ)。知りたいことなどや議論はこちらでどうぞ。

Gobyのバグを見つけたらissueまでお願いします。

Gobyにプルリクしたい方は以下のガイドラインをご覧ください。GitHubでGobyをforkしてローカルにcloneし、ブランチを切って修正したらgit pushしてプルリクするという、通常のプルリク手順です。

誰かGoのtestingパッケージでGobyにベンチマークを追加してくれないかな…

参考: Gobyのテストについて

Gobyディレクトリでmake testを実行するとテストが走ります。このときPostgreSQLアダプタのテストも行われるので、ローカル環境でPostgreSQLをセットアップしておかないとテストを完了できません。なお、Docker imageにもPostgreSQLが入っていないことに今気づいたので、Docker内でapt-get update; apt-get install postgresql-9.6でインストールしてください。

PostgreSQLのインストール方法については省略します。

テストを実行するには、postgresユーザーの権限でデータベースを作成できるようになっている必要があります。事情があって権限を与えられないといった場合は、事前にpsqlコマンドでgoby_testデータベースを作成しておきます。

$ psql
create database goby_test;

週刊Railsウォッチ(20171110)dry-rbでFormObjectを作る、RailsのSQLインジェクション手法サイト、年に1度だけ起きるバグほか

$
0
0

こんにちは、hachi8833です。先週は文化の日でウォッチをお休みいたしました。

11月最初のウォッチ、いってみましょう。

RubyWorld Conference 2017無事終了


2017.rubyworld-conf.orgより

今年も盛り上がったようです。皆様お疲れさまでした。


つっつきボイス: 「今年は残念ながら行けなかったんで、松江の馴染みのおでん屋食べられなかった(´・ω・`)」「今回はクックパッドの発表がいつもより多かったみたいでした」

Rails: 今週の改修

改良: beforeSendを付けずにRails.ajaxを呼び出せるようになった

// actionview/app/assets/javascripts/rails-ujs/utils/ajax.coffee
-  unless options.beforeSend?(xhr, options)
+  if options.beforeSend? && !options.beforeSend(xhr, options)

// 変更前 Rails.ajax({ type: 'get', url: '/', beforeSend: function() { return true }, success: function() { // do something } // 変更後 Rails.ajax({ type: 'get', url: '/', success: function() { // do something }

つっつきボイス: 「以前は使わないときにもbeforeSend:書かないといけなかったのか」

改善: rescue画面でソースが改行されないようになった


つっつきボイス: 「行数表示と実際の行が一致するようになったのね」「エディタではワードラップする方が好きなんですが、英語圏だとエディタをワードラップしない人が割りと多い気がします」

Railsから非推奨コードを削除

以下の非推奨コードが削除されました。

  • erubis: 今後はerubiになります。
  • evented Redisアダプタ
  • ActiveRecordの以下を削除
    • #sanitize_conditions
    • #scope_chain
    • .error_on_ignored_order_or_limit設定
    • #verify!の引数
    • #indexesname引数
    • ActiveRecord::Migrator.schema_migrations_table_name
    • supports_primary_key?
    • supports_migrations?
    • initialize_schema_migrations_tableinitialize_internal_metadata_table
    • dirty recordへのlock!呼び出し
    • 関連付けでクラスに:class_nameを渡す機能
    • index_name_exists?default引数
    • ActiveRecordオブジェクトの型変換時のquoted_idサポート
  • ActiveSupportの以下を削除
    • halt_callback_chains_on_return_false
    • コールバック用文字列フィルタの:if:unlessオプション

改良: ActionDispatch::SystemTestCaseにフックを追加

y-yagiさんです。

# actionpack/lib/action_dispatch/system_test_case.rb
+
+    ActiveSupport.run_load_hooks(:action_dispatch_system_test_case, self)

#redirect_backallow_other_hostを追加

# actionpack/lib/action_controller/metal/redirecting.rb
-    def redirect_back(fallback_location:, **args)
-      if referer = request.headers["Referer"]
-        redirect_to referer, **args
      else
-        redirect_to fallback_location, **args
-      end
+    def redirect_back(fallback_location:, allow_other_host: true, **args)
+      referer = request.headers["Referer"]
+      redirect_to_referer = referer && (allow_other_host || _url_host_allowed?(referer))
+      redirect_to redirect_to_referer ? referer : fallback_location, **args
     end

つっつきボイス:redirect_backはリファラを見て前画面に戻るやつですね」「前は他のサイトに戻れないよう設定できなかったのか」

Rails

dry-rbでRailsにForm Objectを作る

cucumbersome.netより

# 同記事より
PostcardSchema = Dry::Validation.Schema do
  required(:address).filled
  required(:city).filled
  required(:zip_code).filled
  required(:content).filled
  required(:country).filled
end

つっつきボイス: 「実は今、dry-rbシリーズにどんなgemがあるのかざっくりチェックする記事を準備しているんですが、そのきっかけは同じ作者の@solnicさん(Ruby Prize 2017受賞者)のvirtusのReadmeで以下の記述を見たことでした: よく見たらもう何年も更新されてなくて、最新Rubyでの動作も確認されてないみたいなので」

さらなる進化へ
Virtusを作ったことで、Rubyにおけるデータの扱い、中でもcoercion/型安全/バリデーションについて多くのことを学べました。このプロジェクトは成功を収めて多くの人々が役立ててくれましたが、私はさらによいものを作ることを決心しました。その結果、dry-typesdry-structdry-validationが誕生しました。これらのプロジェクトはVirtusの後継者と考えるべきであり、考慮点やより優れた機能がさらにうまく分割されています。Virtusが解決しようとした同種の現代的な問題について関心がおありの方は、ぜひdryシリーズのプロジェクトもチェックしてみてください。

@solnic
https://github.com/solnic/virtus より抄訳

「なるほど、virtusはとってもいいgemだけど今後はdry-rb系gemがメンテされそう: 次にFormObject作るときはこれ参考にしよう」
「dry-validationはActiveRecordのバリデーション+strong parametersより数倍速いと謳ってますね」「でも本家のバリデーションはRailsの記法にマッチしているからそっちを使いたい」

Service Objectにちょっとうんざり(Awesome Rubyより)


avdi.codesより

# 同記事より
module Perkolator
  def self.process_ipn(params:, redemption_url_template:, email_options:)
    # ...
  end
end

post "/ipn" do
  demand_basic_auth

  redemption_url_template =
    Addressable::Template.new("#{request.base_url}/redeem?token={token}")

  Perkolator.process_ipn(
    params: params,
    redemption_url_template: redemption_url_template,
    email_options: settings.email_options)

  # Report success
  [202, "Accepted"]
end

つっつきボイス: 「Service Objectにクラスメソッドを実装する的な話か」
「Service Objectのメソッドをインスタンスメソッドとして実装するかどうかはケースバイケースなんだけど、一度クラスメソッドとして実装してしまうと後からインスタンスメソッド化するのがものすごく面倒になるのが特に厄介」
「クラスメソッドにしてしまってマルチスレッドが効かなくなるとか、あるあるですな」
「自分の場合、形はクラスメソッドだけど内部でself.newして事実上インスタンスメソッドにすることある」

ほぼほぼすべてのプロジェクトで使う25のgem(RubyFlowより)


hackernoon.comより


つっつきボイス: 「これまでRailsウォッチでいろんなgemを取り上げてきたせいか、初めて見るgemがほとんどありませんでした: それだけ定番ってことですね」「こうやって何度も見ていると、もう見ただけで機能を思い出すようになってくる」
「そうそう、ところでmoney-rails#monetizeってメソッド、これが言いたくて作ったとしか思えない」「『誰がうまいこと言えと』的なやつですね」

class Product < ActiveRecord::Base

  monetize :price_cents

end

RailsでPostgreSQLのパーティショニングを使ってみる(RubyFlowより)


evilmartians.comより

# 同記事より
CREATE OR REPLACE FUNCTION orders_partitioned_view_insert_trigger_procedure() RETURNS TRIGGER AS $BODY$
  DECLARE
    partition TEXT;
    partition_country TEXT;
    partition_date TIMESTAMP;
  BEGIN
...

つっつきボイス: 「もうパーティショニングどうこうというより、PostgreSQLの機能がそもそも凄すぎてそっちに驚く」「『こんな機能があったのか』みたいなのが次から次に出てくるし」
「記事タイトルはCommand & Conquerのもじりかな」「あー、ゲームネタなのか(ゲーム音痴なので)」

⭐rails-sqli.org: RailsのSQLインジェクション手法を一覧できるサイト⭐


rails-sqli.orgより

今気づきましたが、Rails 5Rails 4Rails 3それぞれでSQLインジェクション手法をどっさり紹介しています。

# pluckを使ったインジェクション
params[:column] = "password FROM users--"
Order.pluck(params[:column])
Query
SELECT password FROM users-- FROM "orders"
Result
["Bobpass", "Jimpass", "Sarahpass", "Tinapass", "Tonypass", "supersecretpass"]

つっつきボイス: 「おお、これ( ・∀・)イイ!!: チェックに使える」「いろんなインジェクションがあるんだなー」「何にしろparamsに入力を直接突っ込む時点でアウトですな」「翻訳したい気もするけど翻訳するところがないw」「見ればわかりますからねー」

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

barbeque: Dockerでジョブを実行する、クックパッド製ジョブキューgem

RubyWorld Conference 2017でも言及されていたようです。


つっつきボイス: 「クックパッドは多分ここ最近マイクロサービス化を進めているので、その中でバッチジョブもdocker containerの実行にすればより分散しやすくなるということかな」「Aaron Pattersonさんのインタビュー↓でもGitHubがマイクロサービス化を進めているという話がありましたね」「分散しないとRailsアプリ本体の起動が死ぬほど遅くなる」

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

RailsでのStripe.com決済処理をRSpecでテストする(RubyFlowより)


hackernoon.comより

# 同記事より
require 'rails_helper'
include ActiveJob::TestHelper
RSpec.describe Payments::InvoicePaymentSucceeded, type: :mailer do
  let(:plan) { @stripe_test_helper.create_plan(id: 'free', amount: 0) }
before(:each) do
    @admin = FactoryGirl.create(:user, email: 'awesome@dabomb.com')
    PaymentServices::Stripe::Subscription::CreationService.(
      user: @admin,
      account: @admin.account,
      plan: plan.id
    )
    @event = StripeMock.mock_webhook_event(
      'invoice.payment_succeeded',
      customer: @admin.account.subscription.stripe_customer_id,
      subscription: @admin.account.subscription.stripe_subscription_id
    )
  end
it 'job is created' do
    ActiveJob::Base.queue_adapter = :test
    expect do
      Payments::InvoicePaymentSucceeded.email(@event.id).deliver_later
    end.to have_enqueued_job.on_queue('mailers')
  end
it 'email is sent' do
    expect do
      perform_enqueued_jobs do
        Payments::InvoicePaymentSucceeded.email(@event.id).deliver_later
      end
    end.to change { ActionMailer::Base.deliveries.size }.by(1)
  end

つっつきボイス: 「Stripeは有名な決済サイトですね: こういうところにはテスト用のサンドボックス的環境が用意されているのが普通」
「そういえばPayPalのサンドボックスはよくできてるんですが、惜しいことにドキュメントがものすごく読みづらい」「私も以前試したけど結局よくわからんかった」


stripe.comより

Fat Free CRM: Railsで作られたOSSカスタマーリレーション管理(Awesome Rubyより)

www.fatfreecrm.comより


つっつきボイス: 「日本でこれをカスタマイズして使うのは考えにくいけど、ソースコードが結構きれいでよく書けている感じなので、実際に業務で動くアプリとしてソース読むとかテストコードの書き方や設計を学ぶのにいいかも」
「あまり大きくなさそうだし、git cloneしてIDEでじっくり読んでみようかな」「RedmineやGitLabだと巨大すぎて追うのが大変ですからね」「Rails 5.0.4か」

github.com/fatfreecrm/fat_free_crmより

flipper: 特定の機能を動的にオン/オフ/状態確認できるgem(RubyFlowより)

# github.com/jnunemaker/flipperより
require 'flipper'
require 'flipper/adapters/memory'

Flipper.configure do |config|
  config.default do
    # pick an adapter, this uses memory, any will do
    adapter = Flipper::Adapters::Memory.new

    # pass adapter to handy DSL instance
    Flipper.new(adapter)
  end
end

# 検索が有効かどうかをチェック
if Flipper.enabled?(:search)
  puts 'Search away!'
else
  puts 'No search for you!'
end

puts 'Enabling Search...'
Flipper.enable(:search)

# 検索が有効かどうかをチェック
if Flipper.enabled?(:search)
  puts 'Search away!'
else
  puts 'No search for you!'
end

有料版のFlipper::Cloudもあるようです。


つっつきボイス: 「クックパッドのchanko gemみたいなやつかな」


cookpad.github.io/chankoより

「ところで、flipperがなぜイルカなのかわかります?」「わかんね」「わかんね」「Officeイルカじゃなさそうだけど」「私が子供の頃『わんぱくフリッパー』っていう米国制作の番組が放映されてたんです(年即バレ)」

モデルになったとされるイルカは、不機嫌になるとすぐ芸の道具を全部ひっくり返す癖があったのでflipperというあだ名になったというのを何かで読んだ覚えがあります。

年に1度だけ起きるバグ(RubyFlowより)

とても短い記事です。

# 同記事より
event = test_organizer.create_published_event(starts_at: 25.hours.from_now)

つっつきボイス: 「年1バグとか普通にあるけど、これはどういうやつかな?以前のウォッチで扱ったActiveSupport::Durationでもなさそうだし」「25?」「あーサマータイムか」「サマータイムがある国の開発者(´・ω・)カワイソス」

週刊Railsウォッチ(20170120)Ruby 2.5.0 devリリース、古いMySQLのサポート終了、uniqメソッドが削除ほか

「そういえば米国ではsummer timeではなくDSTって書きますね: 自分もsummertimeというとスタンダードナンバーを連想します」

ジュニア開発者へのRails設計アドバイス(Awesome Rubyより)

以下は見出しから。

  • 純粋なRubyオブジェクトとデザインパターンを恐れず使うべし
  • (暗黙的でない)明示的なコードにすべし
  • アプリを水平分割すべし
  • 機能以外の本当の要件を満たす設計を
  • テストは徹底的に行うべし
  • 継承よりコンポジションを優先すべし
  • 制御フローを尊重すべし

つっつきボイス: 「うん、悪くなさそう: Railsに限らない話もいろいろある」「after_create/after_update/after_destroy/after_commitを避けろというのは大事: after_系フックを使うならちゃんと値を返すべきだし、自分自身を変えないこと」

reek: 「コードの匂い」を検出するgem(Awesome Rubyより)


github.com/troessner/reekより

gem install reekしてオレオレRailsアプリにかけてみたらこんな感じで出ました。ドキュメントのURLも出力してくれます。Rubyに絆創膏。

app/controllers/patterns_controller.rb -- 12 warnings:
  [41, 43]:DuplicateMethodCall: PatternsController#create calls 'format.html' 2 times [https://github.com/troessner/reek/blob/master/docs/Duplicate-Method-Call.md]
  [80, 87]:DuplicateMethodCall: PatternsController#update calls '@pattern[:id]' 2 times [https://github.com/troessner/reek/blob/master/docs/Duplicate-Method-Call.md]
  [80, 87]:DuplicateMethodCall: PatternsController#update calls 'edit_pattern_path(@pattern[:id])' 2 times [https://github.com/troessner/reek/blob/master/docs/Duplicate-Method-Call.md]
  [80, 82]:DuplicateMethodCall: PatternsController#update calls 'format.html' 2 times [https://github.com/troessner/reek/blob/master/docs/Duplicate-Method-Call.md]
  [7]:InstanceVariableAssumption: PatternsController assumes too much for instance variable '@pattern' [https://github.com/troessner/reek/blob/master/docs/Instance-Variable-Assumption.md]
  [35]:TooManyStatements: PatternsController#create has approx 11 statements
...

つっつきボイス:rubocopのcopにして欲しいー: rubocopと他のツールのwarningを調整するの大変だし」

activerecord-import: 一括インポートgem+バリデーション(Awesome Rubyより)

# wikiより
columns = [ :title, :author ]
values = [ ['Book1', 'FooManChu'], ['Book2', 'Bob Jones'] ]

# Importing without model validations
Book.import columns, values, :validate => false

# Import with model validations
Book.import columns, values, :validate => true

# when not specified :validate defaults to true
Book.import columns, values

つっつきボイス: 「前にもウォッチで触れたことありましたが一応」「activerecord-importはbulk insert gemとしては使いやすくて有能なやつですね: バリデーションもやってくれるし」「お、ちょうど今の案件に使えそうダナ」

dckerize: RailsアプリのDockerイメージを作るgem(RubyFlowより)

# 同リポジトリより
$ rails new myapp --database=postgresql
$ cd myapp
$ dckerize up myapp

# DBファイルを設定

$ docker-compose build
$ docker-compose up

つっつきボイス: 「とりあえず動かしてみたんですが、PostgreSQLのコンテナのところでつっかえてしまいました」「うーん、PostgreSQL 9.5.3だでバージョンべた書きだったり、/var/lib/postgresqlに直接つっこんだりしてるしなー: 自分専用なんじゃ?」「そんな感じですね: 自分でdocker-compose書くのがよさそう」「Dockerやったことない人がお試しに動かすきっかけにはなるかも」

Ruby trunkより

提案: Enumerator#next?

class Enumerator
  def next?
    peek
    true
  rescue StopIteration
    false
  end
end

a = [1,2,3]
e = a.to_enum
p e.next?   #=> true
p e.next    #=> 1
p e.next?   #=> true
p e.next    #=> 2
p e.next?   #=> true
p e.next    #=> 3
p e.next?   #=> false
p e.next    #raises StopIteration

つっつきボイス: 「へー、next?って今までなかったのか」「採用されるかどうかはmatz次第みたいです」

2つのArrayの間にカンマがないとnilになる => 仕様どおり(却下)

[2, 2][3, 3] # => nil

つっつきボイス: 「これ、仕様どおりですよね」「Stackoverflowレベルの内容をRubyバグに投げるのは勇者」

Ruby

RubyGems 2.7.0がリリース

今見たらもう2.7.2になっています。

  • 2.7.0
    • Update vendored bundler-1.16.0. Pull request #2051 by Samuel Giddins.
    • Use Bundler for Gem.use_gemdeps. Pull request #1674 by Samuel Giddins.
    • Add command signin to gem CLI. Pull request #1944 by Shiva Bhusal.
    • Add Logout feature to CLI. Pull request #1938 by Shiva Bhusal.
  • 2.7.1
    • Fix gem update –system with RubyGems 2.7+. Pull request #2054 by Samuel Giddins.
  • 2.7.2
    • Added template files to vendoerd bundler. Pull request #2065 by SHIBATA Hiroshi.
    • Added workaround for non-git environment. Pull request #2066 by SHIBATA Hiroshi.

#2065と#2066、ちょうど昨日踏んでしまいましたが、gem update --systemで即修正完了でした。

Ruby 2.5で速度が改善された点(RubyFlowより)


rubyguides.comより

  • 式展開
  • String#prepend
  • Enumerableのメソッド
  • Range#minRange#max
  • String#scan

つっつきボイス: 「式展開の改善は目覚ましい」「他のは誤差っぽいかなー」「何にしろRuby 3×3に向けて着々と進んでいる感じですね」

メモリを意識したRubyプログラミング


gettalong.orgより

# 同記事より
2.4.2 > require 'objspace'
 => true
2.4.2 > ObjectSpace.memsize_of(nil)
 => 0
2.4.2 > ObjectSpace.memsize_of(true)
 => 0
2.4.2 > ObjectSpace.memsize_of(false)
 => 0
2.4.2 > ObjectSpace.memsize_of(2**62-1)
 => 0
2.4.2 > ObjectSpace.memsize_of(2**62)
 => 40

つっつきボイス: 「おー、オブジェクトサイズの変わる境界がいろいろ示されていて面白い」「文字列は23バイト目から変わる、と」「ここらへんはRubyのRVALUEみたいな内部構造に関わってるはず」「近々それ系の翻訳記事出します」「この記事見てて、k0kubunさんのこのツイートを思い出しました↓: 最速のメソッドを身体がつい選んでしまうとかもう常人じゃない感」

RubyとPythonで文字列分割対決(Awesome Rubyより)


chriszetter.comより

# ruby
"".split("-") #=> []
# python
"".split("-") #=> [""]

つっつきボイス: 「Rubyが空の[]を返すのはAWKに近いのか」「著者はPythonにstr.splitの挙動をAWKに近づけるよう提案したけど通らなかったらしいです」

商用で使われるRubyのバージョン分布

何となく年の瀬が近づいた感じがしてきました。


つっつきボイス: 「おー、1.8系はほぼ消滅か」「2.0以上でもう8割ですね」「Rubyが後方互換性を大事にしているおかげでみんな結構気軽にアップグレードしてる感ある」「frozen_string_literalのような変更にもちゃんと猶予期間を設けてますね」

k0kubunさんのyarv-mjitチューンアップ


つっつきボイス: 「やっぱりoptcarrotでやってます」「60fps超えてるー」

SQL

「Mastering PostgreSQL」が発売


masteringpostgresql.comより

著者のメルマガで知りました。


つっつきボイス: 「タイムセールに乗って即買いました」「そういえば好評につきタイムセールを48時間伸ばしたって通知メールに書いてありました」
「PostgreSQL 10の後に出てるからそちらもカバーはしているけど、PostgreSQLそのものを掘り下げる内容」
「この本はボリュームディスカウントもあって、2冊分の価格(179ドル)で50人まで買えますね: 社内で3人以上買うならこれの方がお得」

PostgreSQL 10の嬉しい点5つ(Postgres Weeklyより)


10clouds.comより

  • idカラムの指定がSQL準拠に
  • ネイティブのパーティショニング機能
  • 複数カラムのstatistics
  • 並列性向上
  • JSON/JSONBの全文検索
# 同記事より
# 9
CREATE TABLE foo (id SERIAL PRIMARY KEY, val1 INTEGER);

#10
CREATE TABLE foo (id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, val1 INTEGER);

つっつきボイス: 「おーやっぱすごい」「JSONの全文検索もうれしいけど、バッドプラクティスになりやすいので注意かな」
「ところでPostgreSQLのexplainって、MySQLのよりずっとずっと読みやすくて助かる」「全面同意します!」「PostgreSQLはindex作成中でもクエリかけられるし」

[Rails] RubyistのためのPostgreSQL EXPLAINガイド(翻訳)

JavaScript

JavaScriptのasyncとawaitがひと目でわかる動画(15秒)

社内Slackに投下してもらって知りました。


つっつきボイス: 「これマジでわかりやすい」「マジ」

5分でわかるJavaScriptのpromise


codeburst.ioより

Angular.js 5.0.0リリース


blog.angular.ioより

更新情報がいっぱいありすぎて書ききれない感じです。


つっつきボイス:CLDR対応は大きいかも」


cldr.unicode.orgより

TestCafe: Selenium要らずのWeb結合テストサービス(JavaScript Liveより)


devexpress.github.io/testcafeより

Node.js用です。


www.dedicatedcode.comより


つっつきボイス: 「この元記事の方、コードの配色がすごく読みにくい…」

Frappé Charts: GitHub風グラフ表示ライブラリ(Frontend Focusより)

依存関係なしで使えるそうです。

github.com/frappe/chartsより

CSS/HTML/フロントエンド

フロントエンドチェックリスト


codeburst.ioより

メタタグ/CSS/画像などのチェック項目リストです。


つっつきボイス: 「長い…ここまで増えたら自動化したい」

CSSだけでできる新しめのフォームデザイン(Frontend Focusより)


jonathan-harrell.comより

プレースホルダ文字を動的に移動するなどのテクニックが紹介されています。


jonathan-harrell.comより

FlexGrid: 有料のテーブル作成ライブラリ(Frontend Focusより)


grapecity.comより

非常に凝ったテーブルを作れるJSライブラリです。これも依存なしに使え、AngularとReactでも使えるそうです。


demos.wijmo.comより

その他

最も嫌われているプログラミング言語

stackoverflow.blogより

みっちりと書かれています。


stackoverflow.blogより


つっつきボイス: 「これも面白い記事」「Perlが圧勝」「CoffeeScript…(´・ω・)カワイソス」

番外

ダイヤルアップモデムの音と波形(HackerNewsより)

これはもうリンク先をご覧ください。


つっつきボイス: 「テレホタイム思い出した」「この音知らない人増えたでしょうね(年バレ)」「音は聞いたことあります」

最初のフック音が米国の電話のトーンだったので、大昔にどきどきしながら国際電話かけたときのことをつい思い出してしまいました。

足を鍛えると知能も鍛えられる?

鍛えるなら足というか下半身かなと思いました。

ニューラルネットワークをだます

この亀のフィギュアを銃と誤認させることに成功したそうです。


今週は以上です。

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

週刊Railsウォッチ(20171026)factory_girlが突然factory_botに改名、Ruby Prize最終候補者決定、PhantomJS廃止、FireFoxのFireBug終了ほか

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

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

Rails公式ニュース

Awesome Ruby

RubyFlow

160928_1638_XvIP4h

Postgres Weekly

postgres_weekly_banner

Frontend Weekly

frontendweekly_banner_captured

Hacker News

160928_1654_q6srdR

Rails: Puma/Unicorn/Passengerの効率を最大化する設定(翻訳)

$
0
0

概要

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

画像はすべて元記事からの引用です。

Rails: Puma/Unicorn/Passengerの効率を最大化する設定(翻訳)

まとめ: アプリのサーバー設定はRuby Webアプリのスループットやコストあたりのパフォーマンスに大きな影響を与えます。設定の中でも最も重要なものについて解説します(2846 word、13分)

RubyのWebアプリサーバーは、ある意味で自動車のガソリンに似ています。よいものを使ってもそれ以上速くなりませんが、粗悪なものを使えば止まってしまいます。実際にはアプリサーバーでアプリを著しく高速化することはできません。どのサーバーもだいたい同じようなものであり、取っ替え引っ替えしたところでスループットやレスポンスタイムが向上するわけではありません。しかしダメな設定を使ったりサーバーで設定ミスしたりすれば、たちまち自分の足を撃ち抜く結果になります。クライアントのアプリでよく見かける問題のひとつがこれです。

本記事では、3つの主要なRuby アプリサーバーであるPuma、Unicorn、Passengerについてリソース(メモリやCPU)の使用状況の最適化やスループット(要するに1秒あたりのリクエスト数です)の最大化について解説します。本記事では仮想環境に特化した話はしませんので、「サーバー」と「コンテナ」という言葉を同じ意味で使います。

3つのサーバーの設計は本質的に同じなので、1つのガイドで3つの著名なアプリサーバーをカバーできます。どのサーバーもfork()システムコールを使っていくつもの子プロセスを生成してからリクエストを処理します1。これらサーバーの違いのほとんどは細かい部分にとどまります(本記事ではパフォーマンス最大化が重要な箇所でこうした詳細にも触れる予定です)。

本ガイドを通じて、コストあたりのサーバースループットの最大化を試みます。サーバーリソース(とキャッシュも)が最小の状態から、サーバーが扱える秒あたりのリクエスト数を最大化したいと思います。

パフォーマンス最大化でもっとも重要な設定

アプリサーバーには、パフォーマンスやリソース消費を決定する基本的な設定が4つあります。

  • 子プロセスの数
  • スレッドの数
  • Copy-on-Write
  • コンテナのサイズ

それぞれの設定を見ていきましょう。


* 看護師: 「あなたは31秒前からdynoですね」
* 患者: 「マジで!すぐユーザーのところに戻って、せっかく作ったこのレスポンスを返しに行かなきゃ…」
タイムアウトもそれなりに重要ですが、スループットにはそれほど関連しません。私は今後のためにタイムアウトは変更しないでおきます。

子プロセスの数

Unicorn、Puma、Passengerは、いずれもforkを使う設計になっています2。つまり、アプリのプロセスを1つ作成し、そこから多数のコピーを作成します。これらのコピーは子プロセスと呼ばれます。サーバーごとの子プロセス数は、コストあたりのスループット最大化でおそらく最も重要な設定でしょう3

私が推奨する設定は、すべてのRuby Webアプリで1つのサーバーにつきプロセスを3つ以上実行することです。この設定によってルーティングで最大のパフォーマンスを得られます。PumaとUnicornは、どちらも複数の子プロセスが1つのソケットで直接リッスンする設計になっており、プロセス間のロードバランシングはOSが行います。PassengerはnginxやApacheなどのリバースプロキシを用いて多数のリクエストを1つの子プロセスにルーティングします4。どちらのアプローチも効率はかなり高く、リクエストはアイドリング中のワーカーに素早くルーティングされます。同じことを上位レイヤでのルーティング(ロードバランサーやHerokuのHTTPメッシュを指します)で効率よく行うのは、ルーティング先のサーバーがビジーかどうかをロードバランサー側から確認できないことが多いため、かなり難しくなります5

サーバーが3個、1サーバーあたり1プロセス(つまりプロセスは全部で3個)の編成で考えてみましょう。このときロードバランサーはどのようにして1つのリクエストを3つのサーバーのいずれかに適切にルーティングするのでしょうか。「ランダムに選ぶ」方法や「ラウンドロビン」方式でも可能ですが、その場合アイドリング状態のサーバーへのルーティングは保証されません。たとえばラウンドロビン戦略で、リクエストAがサーバー#1にルーティングされるとします。リクエストBはサーバー#2にルーティングされ、リクエストCはサーバー#3にルーティングされます。


子プロセスが全部ビジーな状態でリクエストを1つ受け取ったときの私の顔。

ここで4つ目のリクエストDが来たとします。リクエストBとCの処理が首尾よく完了したおかげでサーバー#2と#3が暇になっているのに、リクエストAは誰かがCSVをエクスポートしようとしていて完了までに20秒かかるとしたらどうでしょう。ロードバランサーはサーバー#1がビジーであることには構わずリクエストを投げつけるので、リクエストAが完了するまで処理できません。サーバーが完全に死んでいるかどうかを確認する手段はどんなロードバランサーにもありますが、そうした手段はほとんどの場合かなりのタイムラグを伴います(遅延が30秒以上など)。1つのサーバーで実行するプロセス数をもっと増やせば、サーバーレベルではリクエストがビジーなプロセスに割り当てられなくなるため、多くの子プロセスが処理に時間のかかるリクエストで手一杯になってしまうリスクを断ち切ることができます。代わりに、リクエストはワーカーが空くまでソケットレベルまたはリバースプロキシでバックアップされます。これを達成するには、私の経験上1サーバーあたり3プロセス以上が最小値として適切です。リソースの制約のために1サーバーで最小3プロセスを実行できないのであれば、もっと大きなサーバーにしましょう(後述)。

つまり、1つのコンテナでは子プロセスを少なくとも3つは実行すべきです。しかし最大値はどうすればよいでしょうか。これについてはリソース(メモリとCPU)で制限されます。

まずはメモリから考えてみましょう。各子プロセスはある量のメモリを利用します。明らかに、サーバーのRAMがサポートできる個数を上回る子プロセスを追加するべきではありません。


Rubyプロセスの実際のメモリ使用量は対数的に増加します。メモリ断片化が発生するため、増加は水平にならず、単に上限に向かって増加し続けます。

しかし、Rubyアプリの単体プロセスにおける実際のメモリ使用量を調べる方法は単純ではありません。PCやproduction環境でプロセス起動直後の個数を調べる方法では不十分です。理由はいろいろありますが、Ruby Webアプリのプロセスは時間とともにメモリ使用量が増加するからです。ときには生成後の2倍から3倍に達することもあります。Rubyアプリのプロセスで使われるメモリ使用量を正確に測定するには、プロセスの再起動(ワーカーキラー)を無効にしてから、12時間から24時間待ってからpsコマンドで測定します。Herokuユーザーなら、新しい[Heroku Exec]を使って実行中のdynoでpsを実行するか、単にHerokuのメモリ使用量の測定値を1 dynoあたりのプロセス数で割って求めます。多くのRubyアプリは1プロセスあたり200 MBから400 MBのメモリを使いますが、ときには1 GBに達することがあります。


Pumaのワーカーはしばらく経つと…かなり太ります。

メモリ使用量には必ず余裕を見ておいてください。何か子プロセス数を求める公式が欲しいのであれば、(TOTAL_RAM / (RAM_PER_PROCESS * 1.2))を目安にしてください。


レアキャラ「ドット絵DHH」が現れた!
5000デヴィッドに1度だけ出現し、一生使える適切なメモリ使用量を5秒かそこらで受け取る
1いいね = お祈り1回

サーバーやコンテナの上限メモリ量を超えると、メモリが限界に達してスワップが始まるため、速度が大きく低下します。アプリのメモリ使用量を予測可能かつスパイクのない平らな状態にしておきたい理由がこれです。メモリ使用量の急増は、私が「メモリ膨張」と呼んでいる条件です。この問題の解決はまたの機会に別記事で扱いますが、The Complete Guide to Rails Performanceでも扱っています。

次に、サーバーの利用可能なCPUキャパシティを超えないようにしたいと思います。理想的には、CPU使用率100%になる総割り当て時間の5%を超えないことです。これを超えている場合、利用可能なCPUキャパシティでボトルネックが発生していることを示します。多くのRuby on Railsアプリは、クラウドプロバイダのほとんどでメモリリソースがボトルネックになる傾向がありますが、CPUリソースもボトルネックを生じることがあります。どうやって検出すればよいでしょうか。それには、お好みのサーバー監視ツールが使えます。おそらくAWSのビルトインツールなら、CPU使用率が頻繁に上限に達してないかどうかのチェックは十分可能でしょう。


OSのコンテキストスイッチはコストが高いと言っとったじゃないか。productionで実際に使った結果を見ると、あんたがウソ言ってたってことだな。

「CPUの個数より多くの子プロセスを1サーバーに割り当てるべきではない」とよく言われます。その一部は本当ですし、出発点としては適切です。しかし実際のCPU使用率は、自分で監視と最適化を行うべき値です。実際には、多くのアプリのプロセス数は、利用できるハイパースレッド数の1.25〜1.5倍に落ち着くでしょう。

Herokuでは、ログに出力されたCPU負荷の測定値をlog-runtime-metricsで取得します。私は5分間〜15分間の平均負荷をチェックします。値が常に1に近かったり超えたりすることがあるようなら、CPU使用率を超えているので子プロセス数を減らす必要があります。

子プロセス数の設定はどのサーバーでも割りと簡単です。

# Puma
$ puma -w 3 # コマンドラインオプションの場合
workers 3   # config/puma.rbに書く場合

# Unicorn
worker_processes 3 # config/unicorn.rbに書く

# Passenger (nginx/Standalone)
# Passengerのワーカー数は自動で増減します: この設定はあまり便利とは思えなかったので
# 単にmaxとminを一定の数に設定しています。
passenger_max_pool_size 3;
passenger_min_instances 3;

数値を設定ファイルに書く代わりに、WEB_CONCURRENCYなどの環境変数に設定することもできます。

workers Integer(ENV["WEB_CONCURRENCY"] || 3)

まとめると、多くのアプリは使えるリソース量に応じて1サーバーあたり3〜8プロセスを割り当てます。メモリ制約の厳しいアプリや、95パーセンタイル時間(5〜10秒以上)のアプリなら、利用可能なハイパースレッド数の4倍までプロセス数を増やしてもよいでしょう。多くのアプリでは、子プロセスの数を、利用可能なハイパースレッド数の1.5倍を超えないようにすべきです。

スレッド数

PumaやPassenger Enterpriseはアプリでマルチスレッドをサポートするので、このセクションではこの2つのサーバーを対象にします。

スレッドは、アプリの並列性(ひいてはスループット)を軽量なリソースで改善する方法です。Railsは既にスレッドセーフであり、独自のスレッドを作るとかデータベース接続などの共有リソースにグローバル変数でアクセスする($redisのことだよ!)といった妙なことをするアプリはあまりありません。つまり、多くのRuby Webアプリはスレッドセーフということになります。本当にスレッドセーフかどうかを知るには、実際にやってみるしかありません。Rubyアプリのスレッドバグは例外のraiseという派手な方法で顕在化する傾向があるので、簡単に試して結果を見ることができます。

ではスレッド数はいくつにすべきでしょうか。並列性を追加して得られるスピードアップは、プログラムがどの程度並列に実行されるかに依存します。これはアムダールの法則として知られています。MRI(CRuby)の場合、IO待ち(データベースの結果待ちなど)だけが並列化可能です。これは多くのWebアプリでおそらく総時間の10〜25%を占めるでしょう。自分のアプリで、リクエストごとにデータベースで使われる総時間をチェックできます。残念なことに、アムダールの法則によれば、並列性の占める割合が小さい(50%未満)の場合、手頃なスレッド数をさらに増やすメリットはほとんど(あるいはまったく)ありません。そしてこのことは私の経験とも整合します。Noah GibbsもDiscourseホームページのベンチマークでこれをテストした結果、スレッド数は6に落ち着いたそうです。


アムダールの法則

プロセス数の場合は現在の設定による測定値を定期的にチェックして適切にチェックすることをおすすめしますが、スレッド数の場合はそれとは異なり、アプリサーバーのプロセスごとのスレッド数を5に設定して「後は忘れる」でもたいてい大丈夫です。


「設定したら忘れよう」

MRI(CRuby)の場合、スレッド数はメモリに驚くほど大規模な影響を与えることがあります。これはホスト側に複雑な理由がいくつもあるためです(これについては今後別記事を書こうかと思います)。アプリのスレッド数を増やす場合、その前後でメモリ消費を必ずチェックしましょう。各スレッドがスタック空間で余分に消費するメモリが8 MBにとどまると期待しないことです。総メモリ使用量はしばしばこれよりずっと多くなります。

スレッド数の設定方法は次のとおりです。

# Puma: 繰り返しますが、私は「自動」スピンアップ/スピンダウン機能は本当に使わないので
# minとmaxには同じ値を設定しています
$ puma -t 5:5 # コマンドライン・オプション
threads 5, 5  # config/puma.rbに書く場合

# Passenger (nginx/Standalone)
passenger_concurrency_model thread;
passenger_thread_count 5;

JRubyをお使いの方へ: スレッドは完全に並列化されるので、アムダールの法則によるメリットをすべて得られます。JRubyでのスレッド数の設定は、上述したMRIでのプロセス数の設定にむしろ似ていて、メモリやCPUリソースを使い切るところまで増やせば済みます。

Copy-on-writeの振舞い

あらゆるUnixベースのOSではメモリの挙動にcopy-on-writeが実装されています。copy-on-writeはかなりシンプルです。プロセスがforkして子プロセスが作成された時点では、その子プロセスのメモリは親プロセスと完全に共有されます。しかしメモリに変更が生じるとコピーが作成され、その子プロセス専用のメモリになります。子プロセスは(理論的には)共有ライブラリやその他の「読み取り専用」メモリを(独自のコピーを作成する代わりに)親プロセスと共有できるようになっているべきなので、(copy-on-writeは)forkを繰り返すWebサーバーでメモリ使用量を減らすうえで大変役に立ちます。

copy-on-writeは、単に発生するものです5。copy-on-writeは「オフにできません」が、効率を高めます。基本的に私達がやりたいのは、forkの前にアプリをすべて読み込むことであり、多くのRuby Webサーバーでは「プリロード」と呼ばれています。copy-on-writeがあることで変わる点は、アプリが初期化される前と後でfork呼び出しが変わるだけです。

fork後、利用しているデータベースへの再接続も必要です。ActiveRecordの例を以下に示します。

# Puma
preload_app!
on_worker_boot do
  # Rails 4.1で`config/database.yml`を使って`pool`サイズを設定するのは有効
  ActiveRecord::Base.establish_connection
end

# Unicorn
preload_app true
after_fork do |server, worker|
  ActiveRecord::Base.establish_connection
end

# Passengerはデフォルトでプリロードを行うのでオンにする必要はない
# Passengerは自動でActiveRecordへの接続を確立するが、
# 他のDBの場合は以下を行わなければならない
PhusionPassenger.on_event(:starting_worker_process) do |forked|
  if forked
    reestablish_connection_to_database # DBによって異なる
  end
end

理論上は、アプリで使われるすべてのデータベースに対してこれを行わなければなりません。しかし実際には、Sidekiqは実際に何か行うまでRedisへの接続を試行しないので、アプリ起動時にSidekiqジョブを実行しているのでなければ、fork後に再接続する必要はありません。

残念なことに、copy-on-writeのメリットには限りがあります。透過的で巨大なページでは、メモリが1ビット変更されただけでも2 MBものページ全体がコピーされますし、メモリ断片化によっても上限が生じます。しかしそれで問題が生じるわけではないので、プリロードはとにかくオンにしておきましょう。

コンテナのサイズ


もっとメモリよこせや(゚Д゚)ゴルァ!!

一般に、サーバーで利用可能なCPUやメモリの利用率は70〜80%ぐらいにとどめておきたいものです。こうしたニーズはアプリによって異なりますし、CPUコア数とメモリのGB数の比率によっても変わります。あるアプリでは、4 vCPU/4 GB RAMのサーバーでRubyプロセスが6つ動くのがもっとも良好かもしれませんし、メモリ要求がより少なくCPU負荷のより高いアプリなら8 vCPU/2GB RAMがよいかもしれません。コンテナのサイズに完全なものはありませんので、CPUとメモリの比率は実際のproductionでの測定値に基いて選択すべきです。


Rails(有名なWebフレームワーク)とHeroku(RAM 512MB)、どっちが勝つか

サーバーで利用可能な総メモリ容量は、チューニング可能なリソースのうちでおそらく非常に重要なものです。多くのプロバイダは極めて低い値が採用されており、Herokuの標準的なdynoでは512 MBとなっています。Rubyアプリ、特に複雑かつ成熟したアプリは多くのメモリを要求するので、与えるべき総メモリ容量はおそらく非常に重要なリソースでしょう。

多くのRailsアプリで使われるRAMは300 MB以下なので、1サーバーあたり3プロセス以上を常に実行しているとすれば、多くのRailsアプリのRAMは少なくとも1 GBになるでしょう。

サーバーのCPUリソースも同じくチューニング可能な設定として重要です。利用可能なCPUコア数を知る必要がありますし、同時に実行可能なスレッド数も知る必要があります(そもそもサーバーでハイパースレッディングをサポートしているかどうかも知る必要があります)。

子プロセス数のところで解説したように、コンテナは少なくとも子プロセスを3つ以上サポートすべきです。1サーバー(またはコンテナ)あたり8プロセス以上にできればさらに改善されるでしょう。1コンテナあたりのプロセス数を増やせば、リクエストのルーティング改善やレイテンシの逓減に効果を発揮します。

まとめ

Ruby Webアプリサーバーのスループットを最大化する方法の概要を以下にまとめました。短いリスト形式になっているので、以下の手順に沿って進められます。

  1. スレッド数5のワーカー1つが使うメモリ容量を特定する。Unicornをお使いの場合は、明らかにスレッドは不要です。production環境の単一サーバー上でいくつかのワーカーを実行して少なくとも12時間は再起動せずに動かし続けてから、典型的なワーカーのメモリ容量をpsで調べます。
  2. コンテナサイズの値は、上のメモリ容量の少なくとも3倍以上にする。多くのRailsアプリでは1ワーカーあたり最大300 MB〜400 MBのRAMを使いますので、多くのRailsアプリは1コンテナ(サーバー)あたり 1 GB必要になります。これによって、1サーバーあたり3プロセスを実行する余裕のあるメモリ容量になります。実行できる子プロセス数は、(TOTAL_RAM / (RAM_PER_PROCESS * 1.2))に等しくなります。

  3. CPUコア/ハイパースレッド数をチェックする。コンテナのハイパースレッド数(AWSの場合はvCPU)が、メモリがサポート可能な数より少ない場合は、メモリが少なくCPUが多いコンテナに適したコンテナサイズを選択します。実行すべき子プロセス数は、ハイパースレッド数の1.25倍〜1.5倍が理想です。

  4. デプロイ後にCPUとメモリの消費を監視する。使用量を最大化するのに適切な子プロセス数とコンテナサイズを調整します。

関連記事

https://techracho.bpsinc.jp/hachi8833/2017_06_15/41465

RailsConf 2017のパフォーマンス関連の話題(3)「あなたのアプリサーバーの設定は間違っている」など(翻訳)

Rails 5のWebSocket対応アプリでDoS脆弱性を見つけるまで(翻訳)


  1. 3つのアプリサーバーで子プロセスを作成するmasterプロセスは、いずれも実際にはリクエストを処理しません。Passengerでforkが最近実行されていない場合、実際にはしばらくしてからmasterのプリロード処理を終了します。 
  2. JRubyの人なら次のセクションをスキップしてもよいでしょう。 
  3. これはCRubyのGlobal VM Lock(GVL)が原因です。Rubyコードを実行できるのは1度に1つのスレッドに限られるので、Rubyの並行処理を達成するにはプロセスを複数実行する以外に方法はありません。私たちは、サーバーのリソースを超えないようにしながら、サーバーあたりのプロセス数をできるだけ多く実行したいのです。 
  4. 私はPassengerの「least-busy-process-first」ルーティングが実は大好きです。 
  5. メモリをもっと効果的に節約するためにcopy-on-writeをさらに「サポートする」といったことはできません。 

Rubyのメモリ割り当て方法とcopy-on-writeの限界(翻訳)

$
0
0

概要

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

Rubyのメモリ割り当て方法とcopy-on-writeの限界(翻訳)

Unicorn(またはPumaやEinhorn)を実行していると、誰しもある奇妙な現象に気がつくでしょう。マスタからforkした(複数の)ワーカープロセスのメモリ使用量は開始当初は低いにもかかわらず、しばらくすると親と同じぐらいにまでメモリ量が増加します。大規模な本番インストールだと各ワーカーのサイズが100MB以上まで増加することもあり、やがてメモリはサーバーでもっとも制約の厳しいリソースになってしまい、しかもCPUはアイドル状態のままです。

現代的なOSの仮想メモリ管理システムは、まさにこの状況を防止するために設計されたcopy-on-write機能を提供します。プロセスの仮想メモリは4kページごとにセグメント化されます。プロセスがforkした当初の子プロセスは、すべてのページを親プロセスと共有します。子プロセスがページの変更を開始した場合にのみ、カーネルはその呼び出しをインターセプトし、ページをコピーして新しいプロセスに再度割り当てます。

子プロセスが時間とともに共有メモリ中心からコピー中心に移行する様子

だとすると、なぜUnicornのワーカーはもっと多くのメモリを共有しないのでしょうか。多くのソフトウェアが持つ(サイズを増減可能な)静的オブジェクトのコレクションは、一度だけ初期化された後、プログラムのライフタイム終了までメモリ上で変更されないままワーカー全体で共有され続ける筆頭候補になりえます。形式的にはそのとおりなのですが、実際には何も再利用されません。その理由を理解するには、Rubyのメモリ割り当て動作を詳しく調べる必要があります。

スラブとスロット

まずはオブジェクト割り当ての概要をざっと押さえておきましょう。RubyがOSにリクエストするメモリはチャンクに分かれており、内部ではヒープページと呼ばれます。(Rubyの)ヒープページは、OSから渡される4kページ(以後「OSページ」と呼ぶことにします)とは同じではないため、ヒープページという名前は少々不運かもしれませんが、1つのヒープページは仮想メモリ上で多数のOSページにマッピングされます。Rubyは、(OSページが複数の場合も含め)OSページを専有することで、OSページを最大限利用できるようにヒープページのサイズを増減します(通常、4kのOSページ4つが16kのヒープページ1つに等しくなります)。

1つの(Ruby)ヒープ、そのヒープページ、各ページごとのスロット

1つのヒープページは「ヒープ(複数形はheaps)」と呼ばれることもあれば、「スラブ(slab)」や「アリーナ(arena)」と呼ばれることがあるのをご存知かもしれません。私としては曖昧さの少ない後者の2つが好みなのですが、Rubyのあらゆるソースで使われている呼び名に合わせて、以後1つのチャンクを「ヒープページ」、ヒープページが複数集まったコレクション1つを単に「ヒープ」と呼ぶことにします。

1つのヒープページは、1つのヘッダと多数の「スロット」でできています。各スロットにはRVALUEが1つずつあり、これはメモリ上のRubyオブジェクトです(詳しくは後述)。1つのヒープは1つのページを指し、そこから多数のヒープページが互いを指して、コレクション全体に繰り返される結合リスト(linked list)を1つ形成します。

ヒープを利用する

Rubyのヒープは、ruby_setupeval.c)から呼び出されるInit_heapgc.c)によって初期化されます。ruby_setupは1つのRubyプロセスへの主要なエントリポイントです。ruby_setupはこのヒープに沿ってスタックとVMも初期化します。

void
Init_heap(void)
{
    heap_add_pages(objspace, heap_eden,
        gc_params.heap_init_slots / HEAP_PAGE_OBJ_LIMIT);

    ...
}

Init_heapは、スロットのターゲット数を元に初期のページ数を決定します。デフォルト値は10,000ですが、設定や環境変数で調整可能です。

#define GC_HEAP_INIT_SLOTS 10000

設定に応じて、1ページあたりのスロット数が大まかに算出されます(gc.c)。ターゲットサイズは16k(2*14または1 << 14)から始まり、そこからmallocの予約(bookkeeping)1分の数バイトを引き、ヘッダー用の数バイトも引いてから、RVALUE構造体の既知のサイズで割ります。

/* default tiny heap size: 16KB */
#define HEAP_PAGE_ALIGN_LOG 14
enum {
    HEAP_PAGE_ALIGN = (1UL << HEAP_PAGE_ALIGN_LOG),
    REQUIRED_SIZE_BY_MALLOC = (sizeof(size_t) * 5),
    HEAP_PAGE_SIZE = (HEAP_PAGE_ALIGN - REQUIRED_SIZE_BY_MALLOC),
    HEAP_PAGE_OBJ_LIMIT = (unsigned int)(
        (HEAP_PAGE_SIZE - sizeof(struct heap_page_header))/sizeof(struct RVALUE)
    ),
}

64ビットシステムの場合、RVALUEは40バイトを占めます。デフォルトのRubyが最初に408スロット2ごとに24ページを割り当てていることを示すために、一部の計算を省略します。メモリがもっと必要になるとヒープは増大します。

RVALUE: メモリスロット上のオブジェクト

1つのヒープページ内にあるスロット1つにつきRVALUEが1つあり、メモリ上のRubyオブジェクトを表現します。定義は以下のとおりです(gc.cより)。

typedef struct RVALUE {
    union {
        struct RBasic  basic;
        struct RObject object;
        struct RClass  klass;
        struct RFloat  flonum;
        struct RString string;
        struct RArray  array;
        struct RRegexp regexp;
        struct RHash   hash;
        struct RData   data;
        struct RTypedData   typeddata;
        struct RStruct rstruct;
        struct RBignum bignum;
        struct RFile   file;
        struct RNode   node;
        struct RMatch  match;
        struct RRational rational;
        struct RComplex complex;
    } as;

    ...
} RVALUE;

ここは私にとって、Rubyが任意の型を任意の変数に代入できるという神秘のベールが最初に剥がされる場所です。上から、RVALUEとは単にRubyがメモリ上に保持している、取り得るすべての型の巨大なリストにすぎないことが直ちにわかります。すべての型が同じメモリを共有できるようにCの共用体(union)で圧縮されています。共用体には一度に1つずつしか設定できませんが、その共用体全体のサイズは、最大でもリストの個別の型の最大サイズにしかなりません。

スロットを具体的に理解するため、そこに保持される可能性のある型の1つを見てみることにしましょう。以下は典型的なRubyの文字列です(ruby.hより)。

struct RString {
    struct RBasic basic;
    union {
        struct {
            long len;
            char *ptr;
            union {
                long capa;
                VALUE shared;
            } aux;
        } heap;
        char ary[RSTRING_EMBED_LEN_MAX + 1];
    } as;
};

RString構造体を眺めてみると、いくつか興味深い点が浮かび上がってきます。

  • RBasicを含む。これはメモリ上にあるすべてのRuby型を区別しやすくするための共通の構造体です。
  • ary[RSTRING_EMBED_LEN_MAX + 1]を含む共用体は、文字列の内容がOSヒープに保存されるのに対し、短い文字列はRString値にインラインで含まれることがわかります。全体の値は、メモリ割り当ての追加なしにスロットに収まります。

  • ある文字列は別の文字列を参照することがあり(上のVALUE sharedの部分)、割り当てられたメモリを共有します。

VALUE: ポインタ兼スカラー

RVALUEはRuby標準のさまざまな型を保持しますが、すべての型を保持するわけではありません。Ruby C拡張のコードを読んだことがあれば、VALUEというよく似た名前に見覚えがあるでしょう。これは一般にRubyのあらゆる値の受け渡しに使われる型です。VALUEの実装はRVALUEよりややシンプルであり、単なるポインタです(ruby.hより)。

typedef uintptr_t VALUE;

ここでRubyの実装が賢くなります(残念だと思う人もいるかもしれませんが)。VALUEは多くの場合RVALUEへのポインタですが、定数との比較やさまざまなビットシフトを駆使して、ポインタのサイズに収まる、ある種のスカラー型を保持することもあります。

truefalsenilについては簡単です。これらはruby.hに値として事前定義されています。

enum ruby_special_consts {
    RUBY_Qfalse = 0x00,     /* ...0000 0000 */
    RUBY_Qtrue  = 0x14,     /* ...0001 0100 */
    RUBY_Qnil   = 0x08,     /* ...0000 1000 */

    ...
}

いわゆるfixnum(ごく大ざっぱに言うと64ビットに収まる数値)はもう少し複雑です。fixnumはVALUEを1ビット左にシフトして保存し、最下位にフラグを1つ設定します。

enum ruby_special_consts {
    RUBY_FIXNUM_FLAG    = 0x01, /* ...xxxx xxx1 */

    ...
}

#define RB_INT2FIX(i) (((VALUE)(i))<<1 | RUBY_FIXNUM_FLAG)

flonum(浮動小数点など)やシンボルの保存にも類似の手法が使われています。VALUEの型の識別が必要になると、Rubyはポインタの値をフラグのリストと比較します。このリストは、スタックに紐付けられた型を知っています。どのフラグともマッチしない場合は、ヒープに進みます(ruby.hより)。

static inline VALUE
rb_class_of(VALUE obj)
{
    if (RB_IMMEDIATE_P(obj)) {
        if (RB_FIXNUM_P(obj)) return rb_cInteger;
        if (RB_FLONUM_P(obj)) return rb_cFloat;
        if (obj == RUBY_Qtrue)  return rb_cTrueClass;
        if (RB_STATIC_SYM_P(obj)) return rb_cSymbol;
    }
    else if (!RB_TEST(obj)) {
        if (obj == RUBY_Qnil)   return rb_cNilClass;
        if (obj == RUBY_Qfalse) return rb_cFalseClass;
    }
    return RBASIC(obj)->klass;
}

値の特定のいくつかの型をスタック上に保持しておくことで、ヒープ上のスロットを専有せずに済むというメリットが生じ、速度面でも有利です。flonumがRubyに追加されたのは比較的最近であり、flonumの作者は単純な浮動小数点の計算が2倍程度まで高速化すると見積もりました。

衝突の回避

VALUEのスキームは賢くできていますが、あるスカラー値がポインタと衝突しないことをどうやって保証しているのでしょうか。ここで賢さがさらにパワーアップします。RVALUEのサイズが40バイトになっている経緯を思い出しましょう。アラインされたmallocとサイズの組み合わせは、RubyがVALUEに保存する必要のあるRVALUEのアドレスが必ず40で割り切れることを示しています。

2進数では、40で割り切れる数値の最下位3ビットは必ずゼロになります(...xxxx x000)。スタックに紐付けられる型(fixnum、flonum、シンボルなど)をRubyが識別するのに使われるあらゆるフラグは、この3つのビットのいずれかに関連するので、こうした型とRVALUEポインタは確実に住み分けられます。

ポインタに別の情報を含めるのはRubyに限りません。この手法を用いた値は、より一般には「タグ付きポインタ(tagged pointer)」と呼ばれます。

オブジェクトを割り当てる

ヒープについての基礎をいくつか押さえたので、Unicornの賢いプロセスが親プロセスと何も共有しない理由にもう少し迫ってみましょう(鋭い人はもうお気づきかもしれません)。ここから先は、Rubyがあるオブジェクト(ここでは文字列)を初期化する方法を辿ってみたいと思います。

str_new0から始めます(string.cより)。

static VALUE
str_new0(VALUE klass, const char *ptr, long len, int termlen)
{
    VALUE str;

    ...

    str = str_alloc(klass);
    if (!STR_EMBEDDABLE_P(len, termlen)) {
        RSTRING(str)->as.heap.aux.capa = len;
        RSTRING(str)->as.heap.ptr = ALLOC_N(char, (size_t)len + termlen);
        STR_SET_NOEMBED(str);
    }

    if (ptr) {
        memcpy(RSTRING_PTR(str), ptr, len);
    }

    ...

    return str;
}

先ほどRStringを調べたときの推測と似て、Rubyは新しい値が十分短い場合はスロットに埋め込みます。そうでない場合はALLOC_Nを使ってOSのヒープに文字列用の新しいスペースを割り当て、内部のポインタがそのスロット(as.heap.ptr)を指して参照できるようにします。

スロットを初期化する

いくつかの間接参照の層を経て、str_allocgc.cnewobj_ofを呼び出します。

static inline VALUE
newobj_of(VALUE klass, VALUE flags, VALUE v1, VALUE v2, VALUE v3, int wb_protected)
{
    rb_objspace_t *objspace = &rb_objspace;
    VALUE obj;

    ...

    if (!(during_gc ||
          ruby_gc_stressful ||
          gc_event_hook_available_p(objspace)) &&
        (obj = heap_get_freeobj_head(objspace, heap_eden)) != Qfalse) {
        return newobj_init(klass, flags, v1, v2, v3, wb_protected, objspace, obj);
    }

    ...
}

Rubyは、スロットに空きのあるヒープを問い合わせるのにheap_get_freeobj_headを使います(gc.c)。

static inline VALUE
heap_get_freeobj_head(rb_objspace_t *objspace, rb_heap_t *heap)
{
    RVALUE *p = heap->freelist;
    if (LIKELY(p != NULL)) {
        heap->freelist = p->as.free.next;
    }
    return (VALUE)p;
}

Rubyには、スレッドがいくつあっても一度に1つだけが実行されるようにするためのグローバルなロック(GIL)があるので、次のRVALUEをヒープの空きリストから安全に外して次の空きスロットを指し直すことができます。これ以上細かいロックは不要です。

空きスロットを獲得した後は、newobj_initがいくつか一般的な初期化を行ってからstr_new0に処理を戻して文字列固有の設定を行います(実際の文字列のコピーなど)。

eden、tomb、空きリスト

Rubyが空きスロットをheap_edenに問い合わせることを既にお気づきの方もいるかもしれません。edenという名前は聖書の「エデンの園」から命名された3ヒープのことで、Rubyはそこに生きているオブジェクトがあることを知っています。Rubyは2つのヒープをトラックしていますが、そのうちの1つです。

そしてもう1つが「tomb」です。実行後に、生きているオブジェクトがヒープページに残っていないことをガベージコレクタが検出すると、そのページはedenからtombに移動します。Rubyがある時点で新しいヒープページの割り当てが必要になると、OSにメモリ追加を要求する前に、tombから呼び戻すことが優先されます。逆に、tombのヒープページが長時間死んだままの場合、RubyはページをOSに返却します(実際にはそれほど頻繁には発生せず、一瞬で終わります)。

ここまで、Rubyが新しいページを割り当てる方法について簡単に説明しました。OSによって新しいメモリが割り当てられると、Rubyは新しいページをスキャンしていくつか初期化を行います(gc.cより)。

static struct heap_page *
heap_page_allocate(rb_objspace_t *objspace)
{
    RVALUE *start, *end, *p;

    ...

    for (p = start; p != end; p++) {
        heap_page_add_freeobj(objspace, page, (VALUE)p);
    }
    page->free_slots = limit;

    return page;
}

Rubyはそのページの開始スロットから末尾のスロットまでのメモリオフセットを算出し、一方の端から他方の端まで進みながらスロットごとにheap_page_add_freeobjを呼び出します(gc.cより)。

static inline void
heap_page_add_freeobj(rb_objspace_t *objspace, struct heap_page *page, VALUE obj)
{
    RVALUE *p = (RVALUE *)obj;
    p->as.free.flags = 0;
    p->as.free.next = page->freelist;
    page->freelist = p;

    ...
}

このヒープ自身は、空いていることがわかっているスロットへの空きリストポインタを1つトラックしますが、そこからRVALUE自身のfree.nextをたどると新しい空きスロットが見つかります。既知の空きスロットはすべて、heap_page_add_freeobjが構成した長大な連結リストで互いに連鎖しています。

あるヒープにおける、空いているRVALUEを指す空きリストポインタと、連結リストの連鎖

heap_page_add_freeobjが呼ばれてページを初期化します。これはオブジェクトが開放されたときにもガベージコレクタによって呼び出されます。このようにしてスロットが空きリストに戻され、再利用できるようになります。

肥大化したワーカーの事例についての結論

Rubyは精巧なメモリ管理戦略を備えていますが、これらのコードを読んでみると、何かがOSのcopy-on-writeとうまく噛み合っていないことにお気づきかもしれません。Rubyは拡張可能なヒープページをメモリに割り当て、そこにオブジェクトを保存し、可能になったらスロットをガベージコレクションします。空きスロットは注意深くトラックされているのでランタイムは効率よく空きスロットを見つけられます。しかし、これほど洗練されているにもかかわらず、生きているスロットの位置はヒープページ内やヒープページ間でまったく変わらないのです。

オブジェクトの割り当てと解放が常に発生する実際のプログラムでは、すぐに、生きているオブジェクトと死んだオブジェクトが混じり合った状態になってしまいます。ここで思い出されるのがUnicornです。親プロセスが自分自身をセットアップしてからfork可能な状態になるまでの間、親プロセスのメモリはちょうど、利用可能なヒープページ全体に渡って生きているオブジェクトが断片化している典型的なRubyプロセスと似た状態になっています。

ワーカーは、自身のメモリ全体を親プロセスと共有した状態から開始します。残念なことに、子が最初にスロットを1つでも初期化またはGCすると、OSは呼び出しをインターセプトして背後のOSページをコピーします。まもなく、プログラムに割り当てられているあらゆるページでこれが発生し、子ワーカーは親から完全に分岐してしまったメモリのコピーで実行されます。

copy-on-writeは強力な機能ですが、Rubyプロセスのforkにはあまり向いていません。

copy-on-writeへの対策

Rubyチームはcopy-on-writeに精通しており、ある時期に最適化を進めてきました。たとえば、Ruby 2.0ではヒープの「bitmap」が導入されました。Rubyでは「マークアンドスイープ」方式のガベージコレクタが使われており、これはオブジェクト領域全体をスキャンして生きているオブジェクトを見つけるとマーキングし、死んでいるものをすべて片付ける(sweep)というものです。従来のマーキングは1つのページ内の各スロットに直接フラグを設定するのに使われていましたが、これによってあらゆるforkでGCが実行され、マーキングが渡されるとOSページが常に親プロセスからコピーされていました。

Ruby 2.0の変更によって、これらのマーキングフラグがヒープレベルの「bitmap」に移動しました。これは、そのヒープのスロットをマッピングする単一ビットの巨大なシーケンスです。GCがあるforkでこれを渡すと、bitmapで必要なOSページだけがコピーされ、共有期間がより長いメモリをさらに多く利用できるようになりました。

今後の「圧縮」機能

今後導入される変更はさらに期待できます。Aaron Patterson氏はある時期にRuby GCの圧縮の実装について話していてGitHub本番で導入した結果ある程度の成功を収めたとのことです。具体的には、ワーカーがforkする前に呼び出されるGC.compactという名前のメソッドであるようです。

# ワーカーが親からforkするときに常に呼び出される
before_fork do
  GC.compact
end

初期化の一環としてオブジェクトの大量作成を終了できた親プロセスは、まだ生きているオブジェクトを、より長期間安定しそうなページの最小セット上のスロットに移動します。forkしたワーカーは、より長期間親プロセスとメモリを共有できるようになります。

GC圧縮前後の断片化したヒープ

これは、GitHubやHeroku、または私たちがStripeでやっているような巨大なRubyインストールを実行しているすべての人にとって実に期待できる成果です。メモリを大量に使うインスタンスをデプロイしても、メモリは多くの場合、実行可能なワーカー数という限定されたリソースに収まります。GC圧縮は各ワーカーで必要なメモリのかなりの部分を削減する能力を持ちます。メモリ削減によってboxごとに実行できるワーカー数が増加し、boxの総数も削減できます。注意点もほとんどなく、多数のワーカーを実行するコストが直ちに削減されます。

本記事へのコメントや議論はHacker Newsまでどうぞ。

もし誤りがありましたら、プルリク送信をご検討ください。

関連記事

Rails: Puma/Unicorn/Passengerの効率を最大化する設定(翻訳)

Ruby2.0でnil.object_idの値が4から8に変わった理由


  1. mallocの予約は、複数のOSページでヒープページが他のOSページにあふれずにうまく収まるよう埋め合わせられます。ページはOSがプロセスに割り当てる最小単位であるため、これによって非効率なメモリ利用を補えることがあります。 
  2. 鋭い方は、リクエストが10,000件であるにもかかわらず、最初に合計9,792(24 * 408)スロット「だけ」しか割り当てていないことにお気づきかと思います。 
  3. ガベージコレクション(GC)の世界ではedenという名前を付けるのが半ば慣習になっています。Java VMにも「eden space」があります。 

Ruby: 認証gem ‘Rodauth’ README(翻訳)

$
0
0

こんにちは、hachi8833です。

今回は、「Railsアプリの認証システムをセキュアにする4つの方法」でも取り上げられていたRodauthのREADMEを翻訳しました。

現時点では、残念ながらRodauthをRailsで使うためのroda-rails gemがRails 4.2までしか対応していないのと、ルーティングにDSLを使うことから、おそらくRails 5で使うには一手間かける必要がありそうです。

しかし、認証のセキュリティを考えるうえで参考になる情報が多く、ドキュメントの質が(少なくともDeviseと比べて)非常に高いのが特徴です。大きな概要をまず示し、必要な概念も適宜示しながら、先に進むに連れて詳細に説明するという書き方が見事です。

さらに、READMEから読み取れる筋のよい認証システム設計も参考になると思います。パスワードハッシュの保存場所を完全に隔離した上で可能な限り自由度を高めている点や、機能ごとにカラムを足したり減らしたりするのではなくテーブルを足したり減らしたりする点など、学ぶところが多そうです。

概要

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


http://rodauth.jeremyevans.net/より

Rodauth README(翻訳)

RodauthはRackアプリケーション向けの認証・アカウント管理フレームワークです。ビルドにはRodaとSequelを使っていますが、他のWebフレームワーク・データベースライブラリ・データベースでも利用できます。PostgreSQL・MySQL・Microsoft SQL Serverをデフォルト設定で使うと、データベース関数経由でのアクセスが保護されるようになり、パスワードハッシュのセキュリティを強化できます。

設計上のゴール

  • セキュリティ: デフォルト設定で最大のセキュリティを利用できること
  • 簡潔性: DSLで簡単に設定できること
  • 柔軟性: フレームワークのどの部分でも機能をオーバーライドできること

機能リスト

  • ログイン
  • ログアウト
  • パスワードの変更
  • ログインの変更
  • パスワードのリセット
  • アカウントの作成
  • アカウントの無効化
  • アカウントのバリデーション
  • パスワードの確認
  • パスワードの保存(トークン経由での自動ログイン)
  • ロックアウト(総当たり攻撃からの保護)
  • OTP (TOTP経由の2要素認証)
  • リカバリーコード(バックアップコード経由の2要素認証)
  • SMSコード(SMS経由の2要素認証)
  • ログイン変更のバリデーション(ログイン変更前の新規ログインバリデーション)
  • アカウントの許容期間(ログイン前のバリデーションを不要にする)
  • パスワードの許容期間(パスワードを最近入力した場合はパスワード入力を不要にする)
  • パスワードの強度(より洗練されたチェック)
  • パスワードの使い回し禁止
  • パスワードの有効期限
  • アカウントの有効期限
  • セッションの有効期限
  • シングルセッション(アカウントのアクティブセッションを1つに限定)
  • JWT(他のすべての機能でJSON APIをサポート)
  • パスワードハッシュの更新(ハッシュのcostが変更された場合)
  • HTTP BASIC認証

リソース

Webサイト
http://rodauth.jeremyevans.net
デモサイト
http://rodauth-demo.jeremyevans.net
ソースコード
http://github.com/jeremyevans/rodauth
バグ報告
http://github.com/jeremyevans/rodauth/issues
Google Group
https://groups.google.com/forum/#!forum/rodauth
IRC(チャット)
irc://chat.freenode.net/#rodauth

依存関係

Rodauthがデフォルトで依存しているgemが若干ありますが、それらについてはRodauthの開発上依存しているものであり、運用上はgemなしで実行することも可能です。

tilt、rack_csrf
すべての機能で利用(JSON API onlyモードの場合を除く)
bcrypt
パスワードの一致チェックでデフォルトで利用(カスタム認証でpassword_match?をオーバーライドすればスキップ可能)
mail
パスワードリセット時・アカウント確認時・ロックアウト機能のメール送信で利用
rotp、rqrcode
OTP機能で利用
jwt
JWT機能で利用

セキュリティ

データベース関数経由でのパスワードハッシュアクセス

RodauthでPostgreSQL・MySQL・Microsoft SQL Serverを利用する場合、デフォルトでパスワードハッシュにアクセスするためのデータベース関数を使います。これにより、アプリケーションを実行するユーザーはパスワードハッシュに直接アクセスできないようになっています。この機能によって攻撃者がパスワードハッシュにアクセスするリスクや、パスワードハッシュを他のサイトの攻撃に利用されるリスクを減らします。

本セクションでは以後この機能についてもっと詳しく説明します。なお、Rodauthはこの機能を使わなくても利用できます。異なるデータベースを利用している場合や、データベースの権限が不足している場合などには、この機能を利用できないことがあります。

パスワードはbcryptでハッシュ化され、アカウントテーブルとは別のテーブルに保存されます。また、2つのデータベース関数を追加します。1つはパスワードで使うsaltを取得する関数、もう1つは渡されたパスワードハッシュがユーザーのパスワードハッシュと一致するかどうかをチェックする関数です。

Rodauthでは2つのデータベースアカウントを使います。1つはアプリで使うアカウント用(以下「appアカウント」)、もう1つはパスワードハッシュ用(以下「phアカウント」)です。phアカウントは、渡されたパスワードのsaltを取得するデータベース関数と、渡されたアカウントでパスワードハッシュが一致するかどうかをチェックする関数を設定します。2つの関数は、appアカウントでphアカウントのパーミッションを使って実行されるようになっています。これにより、appアカウントでパスワードハッシュを読み取らずにパスワードをチェックできます。

appアカウントではパスワードハッシュを読み出すことはできない代わりに、パスワードハッシュのINSERT、UPDATE、DELETEはできるので、この機能によって追加されたセキュリティで大きな不便は生じません。

appアカウントでのパスワードハッシュ読み取りを禁止したことによって、仮にアプリのSQLインジェクションやリモートでのコード実行の脆弱性を攻撃された場合であっても、攻撃者によるパスワードハッシュの読み取りはさらに難しくなっています。

パスワードハッシュにこのようなセキュリティ対策を追加した理由は、弱いパスワードを使うユーザーやパスワードを使いまわすユーザーが後を絶たず、あるデータベースのパスワードハッシュが盗み出されると他のサイトのアカウントにまでアクセスされる可能性があるからです。そのため、たとえ保存されている他のデータの重要性が低いとしても、パスワードハッシュの保存方法はセキュリティ上きわめて重要度が高くなっています。

アプリのデータベースに機密情報が他にも保存されているのであれば、他の情報(あるいはすべての情報)についてもパスワードハッシュと同様のアプローチを行うことを検討すべきです。

トークン

アカウントの検証トークン、パスワードリセットのトークン、パスワード保存のトークン、ロックアウトのトークンでは同様のアプローチを採用しています。これらはすべてアカウントID_長いランダム文字列形式のトークンを提供します。トークンにアカウントIDを含めることで、攻撃者は全アカウントに対してトークンを総当り(ブルートフォース)攻撃で推測できなくなり、一度に1人のユーザーに対してしか総当たり攻撃を行えなくなります。なお、トークンがランダム文字列だけで構成されていると、全アカウントに対して総当たり攻撃が可能になる場合があります。

さらに、トークンの比較にはタイミング攻撃に対して安全な関数を採用し、タイミング攻撃のリスクを低減しています。

PostgreSQLデータベースの設定

PostgreSQLでRodauthのセキュリティ設計をすべて利用するには、複数のデータベースアカウントを使います。

  1. データベースのsuperuserアカウント(通常はpostgres)
  2. appアカウント(実際はアプリと同じ名前にします)
  3. phアカウント(実際はアプリ名に_passwordを追加した名前にします)

データベースのsuperuserアカウントは、データベースに関連する拡張(extension)の読み込みに使われます。アプリでは絶対にデータベースのsuperuserアカウントを使ってはいけません。

HerokuのPostgreSQLデータベースについては、上のようなシンプルな方法で複数のデータベースアカウントを設定する方法がありません。もちろん、HerokuでRodauthを使うことはできますが、セキュリティ上の利点は同じにはなりません。ただしこれはセキュリティ上危険ということではなく、パスワードハッシュの保存方法が他のメジャーな認証ソリューションと同じレベルになるということです。

データベースアカウントの作成

アプリがデータベースのsuperuserアカウントを使って実行されているのであれば、最初にappデータベースアカウントの作成が必要です。このアカウント名はデータベース名と同じにしておくのが多くの場合ベストです。

続いてphデータベースアカウントを作成します。このアカウントはパスワードハッシュへのアクセスに使われます。

  • PostgreSQLでの実行例
createuser -U postgres ${DATABASE_NAME}
createuser -U postgres ${DATABASE_NAME}_password

superuserアカウントがデータベース内の全アイテムの所有者になっている場合、上で作成したオーナーシップの変更が必要です。詳しくはhttps://gist.github.com/jeremyevans/8483320をご覧ください。

データベースの作成

一般に、アプリのアカウントはほとんどのテーブルを所有するので、アプリのアカウントがデータベースのオーナーとなります。

createdb -U postgres -O ${DATABASE_NAME} ${DATABASE_NAME}

上の方法はアプリ開発方法として最もセキュアとは言えないため、注意が必要です。セキュリティを最大化したい場合は、テーブルのオーナーとして独自のデータベースアカウントを使い、アプリのアカウントはテーブルのオーナーにならないようにし、正常動作に必要な最小限のアクセス権だけをアプリのアカウントに許可します。

拡張の読み込み

Rodauthのログイン機能で大文字小文字を区別しないログインをサポートするには、citext拡張を読み込む必要があります。

例:

psql -U postgres -c "CREATE EXTENSION citext" ${DATABASE_NAME}

Herokuの場合、citextは標準のデータベースアカウントで読み込まれます。ログインで大文字小文字を区別したいのであれば(ただし一般にはよくないとされています)、PostgreSQLのcitext拡張を読み込む必要はありません。その場合は、マイグレーション内のcitextStringに変更し、メールアドレスに対応できるようにしてください。

デフォルト以外のスキーマを使う

PostgreSQLは、デフォルトでパブリックなスキーマで新規テーブルをセットアップします。ユーザーごとに個別のスキーマを使いたい場合は、次のようにします。

psql -U postgres -c "DROP SCHEMA public;" ${DATABASE_NAME}
psql -U postgres -c "CREATE SCHEMA ${DATABASE_NAME} AUTHORIZATION ${DATABASE_NAME};" ${DATABASE_NAME}
psql -U postgres -c "CREATE SCHEMA ${DATABASE_NAME}_password AUTHORIZATION ${DATABASE_NAME}_password;" ${DATABASE_NAME}
psql -U postgres -c "GRANT USAGE ON SCHEMA ${DATABASE_NAME} TO ${DATABASE_NAME}_password;" ${DATABASE_NAME}
psql -U postgres -c "GRANT USAGE ON SCHEMA ${DATABASE_NAME}_password TO ${DATABASE_NAME};" ${DATABASE_NAME}

スキーマを指定する拡張の読み込み部分のコードの変更が必要です。

psql -U postgres -c "CREATE EXTENSION citext SCHEMA ${DATABASE_NAME}" ${DATABASE_NAME}

phユーザーでマイグレーションを実行する場合、スキーマ変更に対応するいくつかの変更が必要です。

create_table(:account_password_hashes) do
  foreign_key :id, Sequel[:${DATABASE_NAME}][:accounts], :primary_key=>true, :type=>:Bignum
  String :password_hash, :null=>false
end
Rodauth.create_database_authentication_functions(self, :table_name=>"${DATABASE_NAME}_password.account_password_hashes")

# if using the disallow_password_reuse feature:
create_table(:account_previous_password_hashes) do
  primary_key :id, :type=>:Bignum
  foreign_key :account_id, Sequel[:${DATABASE_NAME}][:accounts], :type=>:Bignum
  String :password_hash, :null=>false
end
Rodauth.create_database_previous_password_check_functions(self, :table_name=>"${DATABASE_NAME}_password.account_previous_password_hashes")

また、次のRodauth設定メソッドを使って、アプリのアカウントが個別のスキーマで関数を呼び出すようにします。

function_name do |name|
  "${DATABASE_NAME}_password.#{name}"
end
password_hash_table Sequel[:${DATABASE_NAME}_password][:account_password_hashes]

# disallow_password_reuse でパスワード再利用を禁止する場合:
previous_password_hash_table Sequel[:${DATABASE_NAME}_password][:account_previous_password_hashes]

MySQLデータベースの設定

MySQLにはオブジェクトの所有者という概念がなく、MySQLのGRANTやREVOKEのサポートはPostgreSQLと比べて限定されています。MySQLを使う場合、以下のようにphアカウントにGRANT ALLしてすべてのパーミッションを与え、さらにWITH GRANT OPTIONphアカウントからappアカウントにGRANTできるようにすることをおすすめします。

CREATE USER '${DATABASE_NAME}'@'localhost' IDENTIFIED BY '${PASSWORD}';
CREATE USER '${DATABASE_NAME}_password'@'localhost' IDENTIFIED BY '${OTHER_PASSWORD}';
GRANT ALL ON ${DATABASE_NAME}.* TO '${DATABASE_NAME}_password'@'localhost' WITH GRANT OPTION;

マイグレーションの実行は常にphアカウントで行い、appアカウントには必要に応じてGRANTで特定のアクセス権を与えなければなりません。

MySQLでデータベース関数を追加するには、MySQLの設定にlog_bin_trust_function_creators=1が必要になることがあります。

Microsoft SQL Serverデータベースの設定

Microsoft SQL Serverにはデータベースの所有者という概念はありますが、MySQLの場合と同様、phアカウントをデータベースのスーパーユーザーとして使い、phからGRANTでappアカウントにパーミッションを与えられるようにすることをおすすめします。

CREATE LOGIN rodauth_test WITH PASSWORD = 'rodauth_test';
CREATE LOGIN rodauth_test_password WITH PASSWORD = 'rodauth_test';
CREATE DATABASE rodauth_test;
USE rodauth_test;
CREATE USER rodauth_test FOR LOGIN rodauth_test;
GRANT CONNECT, EXECUTE TO rodauth_test;
EXECUTE sp_changedbowner 'rodauth_test_password';

マイグレーションの実行は常にphアカウントで行い、appアカウントには必要に応じてGRANTで特定のアクセス権を与えなければなりません。

テーブルの作成

異なる2種類のデータベースアカウントを使っているため、マイグレーションもデータベースアカウントごとに実行する必要があります(2つの異なるマイグレーションを実行します)。マイグレーションの例を以下に示します。このマイグレーションを変更して追加カラムをサポートしたり、Rodauthの不要な機能に関連するカラムやテーブルを削除することもできます。

1回目のマイグレーション

PostgreSQLの場合はappアカウントで実行する必要があります。MySQLやMicrosoft SQL Serverの場合はphアカウントで実行する必要があります。

マイグレーションの実行にはSequel 4.35.0以降が必要です。これより前のバージョンのSequelを使っている場合は、:BignumシンボルをBignum定数に変更してください。

Sequel.migration do
  up do
    extension :date_arithmetic

    # アカウントの検証やアカウントの無効化機能で使用
    create_table(:account_statuses) do
      Integer :id, :primary_key=>true
      String :name, :null=>false, :unique=>true
    end
    from(:account_statuses).import([:id, :name], [[1, 'Unverified'], [2, 'Verified'], [3, 'Closed']])

    db = self
    create_table(:accounts) do
      primary_key :id, :type=>:Bignum
      foreign_key :status_id, :account_statuses, :null=>false, :default=>1
      if db.database_type == :postgres
        citext :email, :null=>false
        constraint :valid_email, :email=>/^[^,;@ rn]+@[^,@; rn]+.[^,@; rn]+$/
        index :email, :unique=>true, :where=>{:status_id=>[1, 2]}
      else
        String :email, :null=>false
        index :email, :unique=>true
      end
    end

    deadline_opts = proc do |days|
      if database_type == :mysql
        {:null=>false}
      else
        {:null=>false, :default=>Sequel.date_add(Sequel::CURRENT_TIMESTAMP, :days=>days)}
      end
    end

    # パスワードのリセット機能で使用
    create_table(:account_password_reset_keys) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      String :key, :null=>false
      DateTime :deadline, deadline_opts[1]
    end

    # アカウントの検証機能で使用
    create_table(:account_verification_keys) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      String :key, :null=>false
      DateTime :requested_at, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP
    end

    # ログイン変更の検証機能で使用
    create_table(:account_login_change_keys) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      String :key, :null=>false
      String :login, :null=>false
      DateTime :deadline, deadline_opts[1]
    end

    # パスワード保存機能で使用
    create_table(:account_remember_keys) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      String :key, :null=>false
      DateTime :deadline, deadline_opts[14]
    end

    # ロックアウト機能で使用
    create_table(:account_login_failures) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      Integer :number, :null=>false, :default=>1
    end
    create_table(:account_lockouts) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      String :key, :null=>false
      DateTime :deadline, deadline_opts[1]
    end

    # パスワードの有効期限機能で使用
    create_table(:account_password_change_times) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      DateTime :changed_at, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP
    end

    # アカウントの有効期限機能で使用
    create_table(:account_activity_times) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      DateTime :last_activity_at, :null=>false
      DateTime :last_login_at, :null=>false
      DateTime :expired_at
    end

    # シングルセッション機能で使用
    create_table(:account_session_keys) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      String :key, :null=>false
    end

    # OTP機能で使用
    create_table(:account_otp_keys) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      String :key, :null=>false
      Integer :num_failures, :null=>false, :default=>0
      Time :last_use, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP
    end

    # リカバリーコード機能で使用
    create_table(:account_recovery_codes) do
      foreign_key :id, :accounts, :type=>:Bignum
      String :code
      primary_key [:id, :code]
    end

    # SMSコード機能で使用
    create_table(:account_sms_codes) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      String :phone_number, :null=>false
      Integer :num_failures
      String :code
      DateTime :code_issued_at, :null=>false, :default=>Sequel::CURRENT_TIMESTAMP
    end

    case database_type
    when :postgres
      user = get{Sequel.lit('current_user')} + '_password'
      run "GRANT REFERENCES ON accounts TO #{user}"
    when :mysql, :mssql
      user = if database_type == :mysql
        get{Sequel.lit('current_user')}.sub(/_password@/, '@')
      else
        get{DB_NAME{}}
      end
      run "GRANT ALL ON account_statuses TO #{user}"
      run "GRANT ALL ON accounts TO #{user}"
      run "GRANT ALL ON account_password_reset_keys TO #{user}"
      run "GRANT ALL ON account_verification_keys TO #{user}"
      run "GRANT ALL ON account_login_change_keys TO #{user}"
      run "GRANT ALL ON account_remember_keys TO #{user}"
      run "GRANT ALL ON account_login_failures TO #{user}"
      run "GRANT ALL ON account_lockouts TO #{user}"
      run "GRANT ALL ON account_password_change_times TO #{user}"
      run "GRANT ALL ON account_activity_times TO #{user}"
      run "GRANT ALL ON account_session_keys TO #{user}"
      run "GRANT ALL ON account_otp_keys TO #{user}"
      run "GRANT ALL ON account_recovery_codes TO #{user}"
      run "GRANT ALL ON account_sms_codes TO #{user}"
    end
  end

  down do
    drop_table(:account_sms_codes,
               :account_recovery_codes,
               :account_otp_keys,
               :account_session_keys,
               :account_activity_times,
               :account_password_change_times,
               :account_lockouts,
               :account_login_failures,
               :account_remember_keys,
               :account_login_change_keys,
               :account_verification_keys,
               :account_password_reset_keys,
               :accounts,
               :account_statuses)
  end
end

2回目のマイグレーション

phアカウントで実行します。

require 'rodauth/migrations'

Sequel.migration do
  up do
    create_table(:account_password_hashes) do
      foreign_key :id, :accounts, :primary_key=>true, :type=>:Bignum
      String :password_hash, :null=>false
    end
    Rodauth.create_database_authentication_functions(self)
    case database_type
    when :postgres
      user = get(Sequel.lit('current_user')).sub(/_password\z/, '')
      run "REVOKE ALL ON account_password_hashes FROM public"
      run "REVOKE ALL ON FUNCTION rodauth_get_salt(int8) FROM public"
      run "REVOKE ALL ON FUNCTION rodauth_valid_password_hash(int8, text) FROM public"
      run "GRANT INSERT, UPDATE, DELETE ON account_password_hashes TO #{user}"
      run "GRANT SELECT(id) ON account_password_hashes TO #{user}"
      run "GRANT EXECUTE ON FUNCTION rodauth_get_salt(int8) TO #{user}"
      run "GRANT EXECUTE ON FUNCTION rodauth_valid_password_hash(int8, text) TO #{user}"
    when :mysql
      user = get(Sequel.lit('current_user')).sub(/_password@/, '@')
      db_name = get(Sequel.function(:database))
      run "GRANT EXECUTE ON #{db_name}.* TO #{user}"
      run "GRANT INSERT, UPDATE, DELETE ON account_password_hashes TO #{user}"
      run "GRANT SELECT (id) ON account_password_hashes TO #{user}"
    when :mssql
      user = get(Sequel.function(:DB_NAME))
      run "GRANT EXECUTE ON rodauth_get_salt TO #{user}"
      run "GRANT EXECUTE ON rodauth_valid_password_hash TO #{user}"
      run "GRANT INSERT, UPDATE, DELETE ON account_password_hashes TO #{user}"
      run "GRANT SELECT ON account_password_hashes(id) TO #{user}"
    end

    # disallow_password_reuse 機能で使われる
    create_table(:account_previous_password_hashes) do
      primary_key :id, :type=>:Bignum
      foreign_key :account_id, :accounts, :type=>:Bignum
      String :password_hash, :null=>false
    end
    Rodauth.create_database_previous_password_check_functions(self)

    case database_type
    when :postgres
      user = get(Sequel.lit('current_user')).sub(/_password\z/, '')
      run "REVOKE ALL ON account_previous_password_hashes FROM public"
      run "REVOKE ALL ON FUNCTION rodauth_get_previous_salt(int8) FROM public"
      run "REVOKE ALL ON FUNCTION rodauth_previous_password_hash_match(int8, text) FROM public"
      run "GRANT INSERT, UPDATE, DELETE ON account_previous_password_hashes TO #{user}"
      run "GRANT SELECT(id, account_id) ON account_previous_password_hashes TO #{user}"
      run "GRANT USAGE ON account_previous_password_hashes_id_seq TO #{user}"
      run "GRANT EXECUTE ON FUNCTION rodauth_get_previous_salt(int8) TO #{user}"
      run "GRANT EXECUTE ON FUNCTION rodauth_previous_password_hash_match(int8, text) TO #{user}"
    when :mysql
      user = get(Sequel.lit('current_user')).sub(/_password@/, '@')
      db_name = get(Sequel.function(:database))
      run "GRANT EXECUTE ON #{db_name}.* TO #{user}"
      run "GRANT INSERT, UPDATE, DELETE ON account_previous_password_hashes TO #{user}"
      run "GRANT SELECT (id, account_id) ON account_previous_password_hashes TO #{user}"
    when :mssql
      user = get(Sequel.function(:DB_NAME))
      run "GRANT EXECUTE ON rodauth_get_previous_salt TO #{user}"
      run "GRANT EXECUTE ON rodauth_previous_password_hash_match TO #{user}"
      run "GRANT INSERT, UPDATE, DELETE ON account_previous_password_hashes TO #{user}"
      run "GRANT SELECT ON account_previous_password_hashes(id, account_id) TO #{user}"
    end
  end

  down do
    Rodauth.drop_database_previous_password_check_functions(self)
    Rodauth.drop_database_authentication_functions(self)
    drop_table(:account_previous_password_hashes, :account_password_hashes)
  end
end

マイグレーションを複数ユーザーで手分けして行いたい場合、SequelのマイグレーションAPIを使ってパスワードユーザーのマイグレーションを実行できます。

Sequel.extension :migration
Sequel.postgres('DATABASE_NAME', :user=>'PASSWORD_USER_NAME') do |db|
  Sequel::Migrator.run(db, 'path/to/password_user/migrations', :table=>'schema_info_password')
end

PostgreSQL・MySQL・Microsoft SQL Server以外のデータベースを使う場合や、(データベースの)ユーザーアカウントを複数使えない場合は、単に2つのマイグレーションを1つのマイグレーションにまとめます。

上のマイグレーションを読むとわかるように、Rodauthでは1つのテーブルにさまざまなカラムを追加するのではなく、追加機能ごとにテーブルを追加する設計になっています。

使い方

基本的な使い方

RodauthはRodaのプラグインなので、以下のように他のRodaプラグインと同じ方法で読み込みます。

plugin :rodauth do
end

plugin呼び出しでは、Rodauthの設定用DSLをブロックとして受け取ります。読み込む機能を指定するenableという設定メソッドは省略できません。

plugin :rodauth do
  enable :login, :logout
end

機能が読み込まれた後は、その機能でサポートされる設定用メソッドをすべて利用できるようになります。設定用メソッドには次の2種類があります。

    1. 認証系メソッド

1つ目は認証系メソッド(auth methods)と呼ばれます。これらのメソッドはブロックを1つ取り、Rodauthのデフォルトメソッドをオーバーライドします。ブロック内でsuperを呼ぶとデフォルトの動作を取得できますが、superには明示的に引数を渡す必要があります。なお、beforeフックやafterフックではsuperを呼ぶ必要はありません。

たとえば、ユーザーのログイン時にログ出力を追加したい場合は次のようにします。

plugin :rodauth do
  enable :login, :logout
  after_login do
    LOGGER.info "#{account[:email]} logged in!"
  end
end

ブロック内は、リクエストに関連付けられたRodauth::Authインスタンスのコンテキストになります。このオブジェクトで次のメソッドを使うと、リクエストに関連するあらゆるデータにアクセスできます。

request
RodaRequestのインスタンス
response
RodaResponseのインスタンス
scope
Rodaのインスタンス
session
セッションのハッシュ
flash
flashメッセージのハッシュ
account
アカウントモデルのインスタンス(Rodauthのメソッドで事前に設定済みの場合)

ログイン中のユーザーのIPアドレスをログ出力したい場合は次のようにします。

plugin :rodauth do
  enable :login, :logout
  after_login do
    LOGGER.info "#{account[:email]} logged in from #{request.ip}"
  end
end
    1. 認証値系メソッド

設定用メソッドの2つ目は認証値(auth value)のメソッドです。認証値系メソッドは認証系メソッドと似ていますが、単にブロックを受け取るほかに、ブロック無しで引数を1つ受け取ることもできます。受け取った引数は、その値を単に返すブロックとして扱われます。

たとえば、データベースのテーブルにアカウントを保存するaccounts_tableの場合、次のようにテーブル名をシンボルで渡すことでオーバーライドできます。

plugin :rodauth do
  enable :login, :logout
  accounts_table :users
end

認証値系メソッドはブロックを1つ受け取ることもできるので、リクエストから得られる情報を使ってすべての挙動を上書きできます。

plugin :rodauth do
  enable :login, :logout
  accounts_table do
    request.ip.start_with?("192.168.1") ? :admins : :users
  end
end

Rodauthではどの設定メソッドもブロックを受け取れるので、多くのレガシーシステムを統合するのに十分な柔軟性を備えています。

各機能のドキュメント

サポートされている各機能のオプションやメソッドについては、機能ごとに別ページを設けています。もしリンクが切れていたら、ドキュメントのディレクトリで必要なファイルを参照してください。

Rodauthをルーティングツリーで呼び出す

一般に、以下のようにrodauthをルーティングブロックの早い段階で呼び出すのが普通です。

route do |r|
  r.rodauth

  # ...
end

Rodauthはこれで実行できます。ただしこのままでは、アクセスするユーザーのログインを必須にしたり、サイトにセキュリティを追加したりできません。すべてのユーザーに対してログインを必須にするには、ログインしていないユーザーを以下のように強制的にログインページにリダイレクトします。

route do |r|
  r.rodauth
  rodauth.require_authentication

  # ...
end

ログインを必須にしたいページがサイトの一部に限られている場合は、以下のようにすると、ルーティングツリーの特定のブランチについてだけユーザーがログインしていない場合にリダイレクトできます。

route do |r|
  r.rodauth

  r.on "admin" do
    rodauth.require_authentication

    # ...
  end

  # ...
end

Rodauthをルーティングツリーのルートではなく、ルーティングのブランチ内でだけ実行したい場合があります。その場合は以下のようにRodauthの設定で:prefixを設定してから、ルーティングツリーの該当するブランチでr.rodauthを呼び出します。

plugin :rodauth do
  enable :login, :logout
  prefix "auth"
end

route do |r|
  r.on "auth" do
    r.rodauth
  end

  rodauth.require_authentication

  # ...
end

rodauthメソッド

Rodauthの機能のほとんどはr.rodauth経由で公開されています。これを使って、Rodauthで自分が有効にした機能にルーティングできます(ログイン機能の/loginなど)。しかし、上述したようにこうしたメソッドをrodauthオブジェクトで呼び出したいこともあります(現在のリクエストが認証済みであるかどうかのチェックなど)。

以下のメソッドは、r.rodauthの外でもrodauthオブジェクトで呼び出せるように設計されています。

require_login
セッションでログインを必須にし、ログインしていないリクエストをログインページにリダイレクトします。
require_authentication
require_loginと似ていますが、アカウントが2要素認証用に設定されている場合は2要素認証も必須にします。ログイン済みであっても2要素認証されていない場合は、リクエストを2要素認証ページにリダイレクトします。
logged_in?
セッションがログイン中であるかどうかを返します。
authenticated?
logged_in?と似ていますが、アカウントが2要素認証用に設定されている場合はセッションが2要素認証されているかどうかを返します。
require_two_factor_setup
(2要素認証用)セッションで2要素認証を必須にします。2要素認証されていない場合は、リクエストを2要素認証ページにリダイレクトします。
uses_two_factor_authentication?
(2要素認証用)現在のセッションのユーザーが2要素認証を使えるよう設定されているかどうかを返します。
update_last_activity
(アカウント有効期限用)現在のアカウントの最終活動時刻を更新します。最終活動時刻を基にアカウントの有効期限が切れるようにしてある場合にのみ意味があります。
require_current_password
(アカウント有効期限用)アカウントのパスワードの有効期限が切れた場合に、パスワード変更ページにリダイレクトして現在のパスワードを入力しないと継続できないようにします。
load_memory
(パスワード保存機能用)セッションが認証されていない場合に、remember cookieがあるかどうかをチェックします。有効なremember cookieがある場合はセッションに自動ログインしますが、rememberキー経由でログインしたというマークを付けます。
logged_in_via_remember_key?
(パスワード保存機能用) rememberキーを使って現在のセッションにログインしたかどうかを返します。セキュリティ上重要な操作でパスワードの再入力を必須にしたい場合は、confirm_passwordを使えます。
check_session_expiration
(セッション有効期限用) 現在のセッションの有効期限が切れているかどうかをチェックし、期限切れの場合は自動的にログアウトします。
check_single_session
(シングルセッションの有効期限) 現在のセッションがまだ有効かどうかをチェックし、無効な場合はセッションからログアウトします。
verified_account?
(許容期間の確認の延長) 現在のアカウントが(訳注: メールなどで)確認済みかどうかを返します。falseの場合、ユーザーが「許容期間」に該当しているためにログインを許されていることを示します。
locked_out?
(ロックアウト機能) 現在のセッションのユーザーがロックアウトされているかどうかを返します。

複数の設定を使う

Rodauthでは、同じアプリケーションで複数のrodauth設定の利用をサポートしています。これは、プラグインを読み込んで2度目のログインで別設定の名前を指定するだけで行なえます。

plugin :rodauth do
end
plugin :rodauth, :name=>:secondary do
end

その後は、いつでもルーティングでrodauthを呼び、使いたい設定名を引数で指定できるようになります。

route do |r|
  r.on 'secondary' do
    r.rodauth(:secondary)
  end

  r.rodauth
end

パスワードハッシュをアカウントのテーブルに保存する

Rodauthでは、パスワードハッシュをアカウントと同じテーブルに保存することもできます。これは、パスワードハッシュを保存するカラムを指定するだけで行なえます。

plugin :rodauth do
  account_password_hash_column :password_hash
end

Rodauthでこのオプションを設定すると、パスワードハッシュのチェックをRubyで行うようになります。

PostgreSQL/MySQL/Microsoft SQL Serverでデータベース関数を使わないようにする

RodauthとPostgreSQL/MySQL/Microsoft SQL Serverで、認証用のデータベース関数を使いたくないがハッシュテーブルは従来どおり別テーブルに保存したい場合は、次のようにします。

plugin :rodauth do
  use_database_authentication_functions? false
end

言い換えると、rodauth_get_salt関数とrodauth_valid_password_hash関数を独自に実装すれば、PostgreSQL/MySQL/Microsoft SQL Server以外のデータベースでもこの値をtrueにできます。

認証をカスタマイズする

Rodauthの設定用メソッドの中には、他の種類の認証方法を使えるようにできるものもあります。

認証をカスタマイズすると、ログインの変更やパスワードの変更などのRodauthの機能の使い方がわからなくなったり、カスタム設定を追加する必要が生じたりするかもしれません。ただし以下のカスタマイズ例では、ログイン機能とログアウト機能は正常に機能します。

  • LDAP認証を使う

アカウントがデータベースに保存されている状態でLDAP認証したい場合は、simple_ldap_authenticatorライブラリを利用できます。

require 'simple_ldap_authenticator'
plugin :rodauth do
  enable :login, :logout
  require_bcrypt? false
  password_match? do |password|
    SimpleLdapAuthenticator.valid?(account.username, password)
  end
end

データベースにアカウントがない状態でLDAPの有効なユーザーがログインできるようにしたい場合は、次のようにします。

require 'simple_ldap_authenticator'
plugin :rodauth do
  enable :login, :logout

  # LDAPで認証するのでbcryptライブラリをrequireしない
  require_bcrypt? false

  # セッションの値を:loginキーに保存する
  # (デフォルトの:account_idキーだとわかりにくいため)
  session_key :login

  # セッションの値で与えられたログインを使う
  account_session_value{account}

  # このログインそのものをアカウントとして使う
  account_from_login{|l| l.to_s}

  password_match? do |password|
    SimpleLdapAuthenticator.valid?(account, password)
  end
end
  • Facebook認証を使う

JSON APIでのFacebook認証の例を以下に示します。この設定では、クライアント側にJSONでPOSTリクエストを送信するコードがあることが前提です。このPOSTリクエストは/loginに送信され、FacebookでユーザーのOAuthアクセストークンを設定するaccess_tokenパラメータを含むとします。

 require 'koala'
 plugin :rodauth do
  enable :login, :logout, :jwt

  require_bcrypt? false
  session_key :facebook_email
  account_session_value{account}

  login_param 'access_token'

  account_from_login do |access_token|
    fb = Koala::Facebook::API.new(access_token)
    if me = fb.get_object('me', :fields=>[:email])
      me['email']
    end
  end

  # there is no password!
  password_match? do |pass|
    true
  end
end
  • その他のWebフレームワーク

Rodauthは、アプリケーションでRoda Webフレームワークが使われていなくても利用できます。これは、Rodauthを使うRodaミドルウェアを追加することで行なえます。

require 'roda'

class RodauthApp < Roda
  plugin :middleware
  plugin :rodauth do
    enable :login
  end

  route do |r|
    r.rodauth
    rodauth.require_authentication
    env['rodauth'] = rodauth
  end
end

use RodauthApp

RodauthはRodaアプリに対し、Rodaがレイアウト提供の目的で使われることを期待します。そのため、Rodauthを他のアプリ用のミドルウェアとして使う場合、Rodauthから使えるviews/layout.erbファイルがないのであれば、おそらくRodaのrenderプラグインの追加も必要になります。その場合、Rodauthがアプリと同じレイアウトを使えるようプラグインを適切に設定する必要もあるでしょう

ミドルウェア内部のルーティングブロックでenv['rodauth'] = rodauthを設定すると、Rodauthメソッドを簡単に呼び出せる方法をアプリに導入できるようになります。

Rodaを使わないアプリでのRodauth導入例をいくつか示します。

Rodauthでは、TOTP(Time-Based One-Time Passwords: RFC 6238)経由での2要素認証を使えます。Rodauthで2要素認証をアプリに統合する方法は、アプリでの必要に応じてさまざまなものがあります。

2要素認証のサポートはOTP機能の一部なので、ログイン機能に加えてOTP機能も有効にする必要があります。一般に、2要素認証を実装する場合は2要素認証を2種類用意し、プライマリの2要素認証が利用できない場合にセカンダリの2要素認証を提供するべきです。RodauthではSMSコードとリカバリーコードをセカンダリ2要素認証としてサポートします。

アプリで2要素認証をサポートし、かつ2要素認証を必須にしたくない場合は次のようにします。

plugin :rodauth do
  enable :login, :logout, :otp, :recovery_codes, :sms_codes
end
route do |r|
  r.rodauth
  rodauth.require_authentication

  # ...
end

OTP認証を全ユーザーで必須にし、アカウントを持っていないユーザーに対してOTP認証の設定を要求する場合の設定です。

route do |r|
  r.rodauth
  rodauth.require_authentication
  rodauth.require_two_factor_authentication_setup

  # ...
end

認証を必須にする場合の一般的な方法と同様に、特定のブランチでのみ2要素認証を必須にし、サイトの他の場所ではログイン認証を必須することもできます。

route do |r|
  r.rodauth
  rodauth.require_login

  r.on "admin" do
    rodauth.require_two_factor_authenticated
  end

  # ...
end

JSON APIサポート

プラグインに:jsonオプションを渡してJWT機能を有効にすると、JSONレスポンス取り扱いのサポートを追加できます。

plugin :rodauth, :json=>true do
  enable :login, :logout, :jwt
end

JSON APIをビルドするのであれば、:json => :onlyを渡すことでRodauthで通常読み込まれるHTML関連のプラグイン(render、csrf、flash、h)を読み込まないようにできます。

plugin :rodauth, :json=>:only do
  enable :login, :logout, :jwt
end

ただし、メール送信機能はデフォルトでrenderプラグインに依存していることにご注意ください。:json=>:onlyを使う場合は、renderプラグインを手動で読み込むか、*_email_body設定オプションでメールの本文を指定する必要があります。

JWT機能を導入すると、Rodauthに含まれるその他のJSON APIサポートもすべて利用できるようになります。

rodauthオブジェクトにカスタムメソッドを追加する

設定のブロック内でauth_class_evalを使うと、rodauth`オブジェクトから呼び出せるカスタムメソッドを追加できます。

plugin :rodauth do
  enable :login

  auth_class_eval do
    def require_admin
      request.redirect("/") unless account[:admin]
    end
  end
end

route do |r|
  r.rodauth

  r.on "admin" do
    rodauth.require_admin
  end
end

外部の機能を使う

有効にする設定メソッドは、Rodauthの外部にある機能を読み込めます。この外部機能のファイルは、rodauth/features/feature_nameからrequireできるディレクトリに置く必要があります。このファイルは以下の基本構造をとる必要があります。

module Rodauth
  # :feature_nameは、有効にしたい機能を指定する引数
  # :FeatureNameはオプションで、inspect出力を読みやすくする定数名を設定するのに使う
  Feature.define(:feature_name, :FeatureName) do
    # 認証値系メソッドを固定値で定義するショートカット
    auth_value_method :method_name, 1 # method_value

    auth_value_methods # 認証値メソッドごとに1つの引数

    auth_methods       # 認証メソッドごとに1つの引数

    route do |r|
      # この機能のルーティングへのリクエストをこのブロックで受ける
      # ブロックはRodauth::Authインスタンスのスコープで評価される
      # rはリクエストのRoda::RodaRequestインスタンス

      r.get do
      end

      r.post do
      end
    end

    configuration_eval do
      # メソッド固有の追加設定を必要に応じてここで定義する
    end

    # auth_methodsとauth_value_methodsのデフォルトの挙動を定義する
    # ...
  end
end

機能の構成方法の例については、Rodauthの機能のコードを参照してください。

ルーティングレベルの挙動をオーバーライドする

Rodauthのすべての設定メソッドは、Rodauth::Authインスタンスの挙動を変更します。しかし場合によってはルーティング層の扱いをオーバーライドしたくなることもあります。これは、r.rodauthを呼び出す前に以下のように適切なルーティングを追加するだけで簡単に行なえます。

route do |r|
  r.post 'login' do
    # ここにカスタム POST /login ハンドリングを記述する
  end

  r.rodauth
end

Rodauthテンプレートをプリコンパイルする

Rodauthは自分自身のgemフォルダにあるテンプレートを使います。fork型のWebサーバーを使っていて、コンパイル済みテンプレートを事前に読み込んでメモリを節約したい場合や、アプリをchrootしたい場合は、Rodauthのテンプレートをプリコンパイルすることでメリットを得られます。

plugin :rodauth do
  # ...
end
precompile_rodauth_templates

0.9.xからのアップグレード

Rodauthを0.9.xから現在のバージョンにアップグレードする場合の注意点です。

account_valid_passwordデータベース関数を使っていた場合はこれを削除し、上のマイグレーションに記載されている2つのデータベース関数を追加する必要があります。以下のコードをマイグレーションに追加することでこの作業を行えます。

require 'rodauth/migrations'
run "DROP FUNCTION account_valid_password(int8, text);"
Rodauth.create_database_authentication_functions(self)
run "REVOKE ALL ON FUNCTION rodauth_get_salt(int8) FROM public"
run "REVOKE ALL ON FUNCTION rodauth_valid_password_hash(int8, text) FROM public"
run "GRANT EXECUTE ON FUNCTION rodauth_get_salt(int8) TO ${DATABASE_NAME}"
run "GRANT EXECUTE ON FUNCTION rodauth_valid_password_hash(int8, text) TO ${DATABASE_NAME}"

類似のプロジェクト

以下はすべてRailsに特化しています。

  • Devise
  • Authlogic
  • Sorcery

著者

Jeremy Evans (code@jeremyevans.net

関連記事

Railsアプリの認証システムをセキュアにする4つの方法(翻訳)

[Rails] Devise Wiki日本語もくじ1「ワークフローのカスタマイズ」(概要・用途付き)

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

$
0
0

こんにちは、hachi8833です。

先ごろRubyWorld ConferenceRuby Prize 2017を受賞された@solnicことPiotr Solnicaさんが近年力を入れているのがdry-rbシリーズです。

@solnicさんはあのVirtusの作者でもあるのですが、現在VirtusのREADMEには、Virtusの機能を分割改良してdry-rbにしたとあります。

Virtusを作ったことで、Rubyにおけるデータの扱い、中でもcoercion/型安全/バリデーションについて多くのことを学べました。このプロジェクトは成功を収めて多くの人々が役立ててくれましたが、私はさらによいものを作ることを決心しました。その結果、dry-typesdry-structdry-validationが誕生しました。これらのプロジェクトはVirtusの後継者と考えるべきであり、考慮点やより優れた機能がさらにうまく分割されています。Virtusが解決しようとした同種の現代的な問題について関心がおありの方は、ぜひdryシリーズのプロジェクトもチェックしてみてください。

@solnic
https://github.com/solnic/virtus より抄訳

Virtusは長く使われていて枯れているせいか、何年も更新されていません。
また、Virtusの動作が確認されているのはRuby 1.9.3/2.0.0/2.1.2/jrubyであるとのことなので、今後はdry-rbを使うのがよさそうです。そこで、とりあえずどんなgemがあるのかというレベルで少し調べてみました。

なお、dry-rbにはDiscourseで作られたフォーラムがあり、質問にはこちらを使って欲しいそうです。

dry-rbシリーズのラインナップ


dry-rb.orgより

dry-rbシリーズのgem同士の依存関係だけ、とりあえず.gemspecファイルを元にがんばって図にしてみました(draw.ioで作成)。ざっと見た限りでは、dry-validationとdry-structを使えばVirtusとだいたい同じことができそうです。これらをインストールすると、dry-coreやdry-typeなどの依存gemもインストールされます。

以下はhttp://dry-rb.org/gems/の記載順です。多くのgemは互いに依存関係にあります。11月7日時点のものなので、今後変わる可能性があります。gem名直後のかっこ内はバージョン番号です。

dry-validation(0.11.1)

述語論理に基づいたバリデーションを行うgemです。ドキュメントには、ActiveRecord/ActiveModel::Validationとstrong_parametersより数倍速いとあります(使うかどうかはまた別の問題です)。以下のようなユースケースを含む多くの使いみちがあります。

  • Form params
  • “GET” params
  • JSON/YAMLドキュメント
  • アプリの設定(ENVに保存されているなど)
  • ActiveRecord/ActiveModel::Validationsの置き換え
  • strong-parametersの置き換え
# dry-rb.orgより
schema = Dry::Validation.Form do
  required(:name).filled
  required(:age).filled(:int?, gt?: 18)
end

schema.("name" => "Jane", "age" => "30").to_h
# => {name: "Jane", age: 30}

schema.("name" => "Jane", "age" => "17").messages
# => {:age=>["must be greater than 18"]}

依存gem

  • ‘concurrent-ruby’, ‘~> 1.0’
  • ‘dry-configurable’, ‘~> 0.1’, ‘>= 0.1.3’
  • ‘dry-equalizer’, ‘~> 0.2’
  • ‘dry-logic’, ‘~> 0.4’, ‘>= 0.4.0’
  • ‘dry-types’, ‘~> 0.12.0’
  • ‘dry-core’, ‘~> 0.2’, ‘>= 0.2.1’

dry-types(0.12.2)

さまざまなビルトイン型を用いて柔軟な型システムを提供します。利用できる型リストにはTypes::Form::TimeTypes::Maybe::Strict::IntTypes::Maybe::Coercible::Arrayといった膨大な種類の型が含まれています。

# dry-rb.orgより
require 'dry-types'
require 'dry-struct'

module Types
  include Dry::Types.module
end

class User < Dry::Struct
  attribute :name, Types::String
  attribute :age,  Types::Int
end

User.new(name: 'Bob', age: 35)
# => #<User name="Bob" age=35>

依存gem

  • ‘concurrent-ruby’, ‘~> 1.0’
  • ‘dry-core’, ‘~> 0.2’, ‘>= 0.2.1’
  • ‘dry-container’, ‘~> 0.3’
  • ‘dry-equalizer’, ‘~> 0.2’
  • ‘dry-configurable’, ‘~> 0.1’
  • ‘dry-logic’, ‘~> 0.4’, ‘>= 0.4.2’
  • ‘inflecto’, ‘~> 0.0.0’, ‘>= 0.0.2’

dry-struct(0.4.0)

structに似たオブジェクトを扱える属性DSLです。constructor_typeで以下を指定することでさまざまな挙動のstructを定義できます。

  • :permissive: デフォルト
  • :schema
  • :strict
  • :strict_with_defaults
  • :weak:symbolized

機能が絞り込まれており、属性のwriterは提供されていません(データオブジェクトとしての利用)。そうした目的にはdry-typesが向いているそうです。

# dry-rb.orgより
require 'dry-struct'

module Types
  include Dry::Types.module
end

class User < Dry::Struct
  attribute :name, Types::String.optional
  attribute :age, Types::Coercible::Int
end

user = User.new(name: nil, age: '21')

user.name # nil
user.age # 21

依存gem

  • ‘dry-equalizer’, ‘~> 0.2’
  • ‘dry-types’, ‘~> 0.12’, ‘>= 0.12.2’
  • ‘dry-core’, ‘~> 0.4’, ‘>= 0.4.1’
  • ‘ice_nine’, ‘~> 0.11’

dry-transaction(0.10.2)

ビジネストランザクションを記述するDSLを提供します。トランザクションをモジュール化して再利用したり、トランザクションに引数でステップを追加したりできます。

# dry-rb.orgより
class CreateUser
  include Dry::Transaction(container: Container)

  step :process, with: "operations.process"
  step :validate, with: "operations.validate"
  step :persist, with: "operations.persist"
end

依存gem

  • “dry-container”, “>= 0.2.8”
  • “dry-matcher”, “>= 0.5.0”
  • “dry-monads”, “>= 0.0.1”
  • “wisper”, “>= 1.6.0”

dry-container(0.6.0)

dry-auto_inject gemと組み合わせることで依存関係逆転の法則を利用できる、シンプルでスレッドセーフなIoC(制御の反転)コンテナです。ソフトウェアの密結合を避けるのに使います。

# dry-rb.orgより
container = Dry::Container.new
container.register(:parrot) { |a| puts a }

parrot = container.resolve(:parrot)
parrot.call("Hello World")
# Hello World
# => nil

依存gem

  • ‘concurrent-ruby’, ‘~> 1.0’
  • ‘dry-configurable’, ‘~> 0.1’, ‘>= 0.1.3’

dry-auto_inject(0.4.4)

コンテナへの依存性注入を支援するmixinです。dry-containerとの組み合わせが良好ですが、#[]に応答できるコンテナなら何でもinjectionできます。

# dry-rb.orgより
# Set up a container (using dry-container here)
class MyContainer
  extend Dry::Container::Mixin

  register "users_repository" do
    UsersRepository.new
  end

  register "operations.create_user" do
    CreateUser.new
  end
end

# Set up your auto-injection mixin
Import = Dry::AutoInject(MyContainer)

class CreateUser
  include Import["users_repository"]

  def call(user_attrs)
    users_repository.create(user_attrs)
  end
end

create_user = MyContainer["operations.create_user"]
create_user.call(name: "Jane")

依存gem

  • ‘dry-container’, ‘>= 0.3.4’

dry-equalizer(0.0.11)

等しいかどうかをチェックする各種メソッドを追加します。依存しているgemはありません。コアファイルはequalizer.rb 1つだけと、dry-rbシリーズの中では最もシンプルなgemのようです。RubyのModule Builderパターン #2で紹介されているように、equalizer.rbではModule Builderパターンが使われています。

# dry-rbより
class GeoLocation
  include Dry::Equalizer(:latitude, :longitude)

  attr_reader :latitude, :longitude

  def initialize(latitude, longitude)
    @latitude, @longitude = latitude, longitude
  end
end

point_a = GeoLocation.new(1, 2)
point_b = GeoLocation.new(1, 2)
point_c = GeoLocation.new(2, 2)

point_a.inspect    # => "#<GeoLocation latitude=1 longitude=2>"

point_a == point_b           # => true
point_a.hash == point_b.hash # => true
point_a.eql?(point_b)        # => true
point_a.equal?(point_b)      # => false

point_a == point_c           # => false
point_a.hash == point_c.hash # => false
point_a.eql?(point_c)        # => false
point_a.equal?(point_c)      # => false

dry-system(0.8.1)

システム設定の自動読み込みや依存関係の自動解決などを行います。以下のコードを見てもdry-containerやdry-auto_injectが使われているらしいことがわかります。
このライブラリはdry-webにも使われています。

# dry-rb.orgより
require 'dry/system/container'

class Application < Dry::System::Container
  configure do |config|
    config.root = Pathname('./my/app')
  end
end

# now you can register a logger
require 'logger'
Application.register('utils.logger', Logger.new($stdout))

# and access it
Application['utils.logger']

依存gem

  • ‘inflecto’, ‘>= 0.0.2’
  • ‘concurrent-ruby’, ‘~> 1.0’
  • ‘dry-core’, ‘>= 0.3.1’
  • ‘dry-equalizer’, ‘~> 0.2’
  • ‘dry-container’, ‘~> 0.6’
  • ‘dry-auto_inject’, ‘>= 0.4.0’
  • ‘dry-configurable’, ‘~> 0.7’, ‘>= 0.7.0’
  • ‘dry-struct’, ‘~> 0.3’

dry-configurable(0.7.0)

スレッドセーフな設定機能をクラスに追加するmixinです。settingというマクロで設定します。

# dry-rb.orgより
class App
  extend Dry::Configurable

  # Pass a block for nested configuration (works to any depth)
  setting :database do
    # Can pass a default value
    setting :dsn, 'sqlite:memory'
  end
  # Defaults to nil if no default value is given
  setting :adapter
  # Pre-process values
  setting(:path, 'test') { |value| Pathname(value) }
  # Passing the reader option as true will create attr_reader method for the class
  setting :pool, 5, reader: true
  # Passing the reader attributes works with nested configuration
  setting :uploader, reader: true do
    setting :bucket, 'dev'
  end
end

App.config.database.dsn
# => "sqlite:memory"

App.configure do |config|
  config.database.dsn = 'jdbc:sqlite:memory'
end

App.config.database.dsn
# => "jdbc:sqlite:memory"
App.config.adapter
# => nil
App.config.path
# => #<Pathname:test>
App.pool
# => 5
App.uploader.bucket
# => 'dev'

依存gem

  • ‘concurrent-ruby’, ‘~> 1.0’

dry-initializer(2.3.0)

パラメータやオプションの初期化を定義するDSLです。Rails向けのdry-initializer-railsもあります。依存gemはなく、これも非常にシンプルなソースです(initializer.rb)。

# dry-rb.orgより
require 'dry-initializer-rails'

class CreateOrder
  extend Dry::Initializer
  extend Dry::Initializer::Rails

  # Params and options
  param  :customer, model: 'Customer' # use either a name
  option :product,  model: Product    # or a class

  def call
    Order.create customer: customer, product: product
  end
end

customer = Customer.find(1)
product  = Product.find(2)

order = CreateOrder.new(customer, product: product).call
order.customer # => <Customer @id=1 ...>
order.product  # => <Product @id=2 ...>

dry-logic(0.4.2)

組み立て可能な述語論理機能を提供します。dry-typeやdry-validationでも使われています。

# dry-rb.orgより
require 'dry/logic'
require 'dry/logic/predicates'

include Dry::Logic

# Rule::Predicate will only apply its predicate to its input, that’s all

user_present = Rule::Predicate.new(Predicates[:key?]).curry(:user)
# here curry simply curries arguments, so we can prepare
# predicates with args without the input
# translating this into words: check the if input has the key `:user`

min_18 = Rule::Predicate.new(Predicates[:gt?]).curry(18)
# check the value is greater than 18

has_min_age = Operations::Key.new(min_18, name: [:user, :age])
# in this example the name options is been use for accessing
# the value of the input

user_rule = user_present & has_min_age

user_rule.(user: { age: 19 }).success?
# true

user_rule.(user: { age: 18 }).success?
# false

user_rule.(user: { age: 'seventeen' })
# ArgumentError: comparison of String with 18 failed

user_rule.(user: { })
# NoMethodError: undefined method `>' for nil:NilClass

user_rule.({}).success?
# false

依存gem

  • ‘dry-core’, ‘~> 0.2’
  • ‘dry-container’, ‘~> 0.2’, ‘>= 0.2.6’
  • ‘dry-equalizer’, ‘~> 0.2’

dry-matcher(0.6.0)

柔軟で高い表現力を持つパターンマッチャーを提供します。依存gemはありません。
dry-monads gemと組み合わせるとEitherMatcherというものも使えるようになります。

# dry-rb.orgより
require "dry-monads"
require "dry/matcher/either_matcher"

value = Dry::Monads::Either::Right.new("success!")

result = Dry::Matcher::EitherMatcher.(value) do |m|
  m.success do |v|
    "Yay: #{v}"
  end

  m.failure do |v|
    "Boo: #{v}"
  end
end

result # => "Yay: success!"

dry-monads(0.3.1)

Rubyの例外ハンドリングとは別のエラーハンドリングをモナド(monad)で提供します。
モナドはHaskellで中心となる概念だそうです。

# dry-rb.orgより
require 'dry-monads'

M = Dry::Monads

maybe_user = M.Maybe(user).bind do |u|
  M.Maybe(u.address).bind do |a|
    M.Maybe(a.street)
  end
end

# If user with address exists
# => Some("Street Address")
# If user or address is nil
# => None()

# You also can pass a proc to #bind

add_two = -> (x) { M.Maybe(x + 2) }

M.Maybe(5).bind(add_two).bind(add_two) # => Some(9)
M.Maybe(nil).bind(add_two).bind(add_two) # => None()

依存gem

  • ‘dry-equalizer’
  • ‘dry-core’, ‘~> 0.3’, ‘>= 0.3.3’

dry-view(0.4.0)

Webのビューをビューコントローラ/レイアウト/テンプレートの3つの構成で記述できます。

  • ビューコントローラ
# dry-rb.orgより
require "dry-view"

class HelloView < Dry::View::Controller
  configure do |config|
    config.paths = [File.join(__dir__, "templates")]
    config.layout = "app"
    config.template = "hello"
  end

  expose :greeting
end
  • レイアウト (templates/layouts/app.html.erb)
<html>
  <body>
    <%= yield %>
  </body>
</html>
  • テンプレート (templates/hello.html.erb):
<h1>Hello!</h1>
<p><%= greeting %></p>

これで、レンダリングするビューコントローラを#callします。

view = HelloView.new
view.(greeting: "Greetings from dry-rb")
# => "<html><body><h1>Hello!</h1><p>Greetings from dry-rb!</p></body></html>

依存gem

  • “tilt”, “~> 2.0”
  • “dry-core”, “~> 0.2”
  • “dry-configurable”, “~> 0.1”
  • “dry-equalizer”, “~> 0.2”

dry-core(0.4.1)

dry-rbシリーズやROM(rom-rb: Ruby Object Mapper)共通のサポート用モジュールです。

  • キャッシュ
  • クラス属性(下サンプルコード)
  • クラスビルダー
  • 特殊定数(EMPTY_ARRAYUndefinedなど)
  • 機能の非推奨化サポート
  • 指定したタイミングでの拡張の読み込み
# dry-rb
require 'dry/core/class_attributes'

class MyClass
  extend Dry::Core::ClassAttributes

  defines :one, :two

  one 1
  two 2
end

class OtherClass < MyClass
  two 'two'
end

MyClass.one # => 1
MyClass.two # => 2

OtherClass.one # => 1
OtherClass.two # => 'two'

依存gem

  • ‘concurrent-ruby’, ‘~> 1.0’

dry-web-roda(0.9.1)

dry-rbとrom-rb(データベース永続化用)をルーティングツリーキットのRoda gemと連携させたシンプルなWebスタックです。

本筋ではありませんが、ちょっとだけ動かしてみました。
以下を実行すると、Railsっぽくプロジェクトが生成されます(構成はだいぶ違いますが)。

gem install dry-web-roda
dry-web-roda new my_app

bundle install --path vendor/bundleしてからshotgun -p 3001 -o 0.0.0.0 config.ruするとhttp://0.0.0.0:3001/でアプリが開きました。shotgunって何だろうと思ったら、Rackを自動でリロードするgemでした。

依存gem

  • “dry-configurable”, “~> 0.2”
  • “inflecto”, “~> 0.0”
  • “roda”, “~> 2.14”
  • “roda-flow”, “~> 0.3”
  • “thor”, “~> 0.19”

関連記事

Railsコードを改善する7つの素敵なGem(翻訳)

Rails: Form ObjectとVirtusを使って属性をサニタイズする(翻訳)

RubyのModule Builderパターン #2 Module Builderパターンとは何か(翻訳)

週刊Railsウォッチ(20171117)Rails開発3年分のコツ集大成、PostgreSQL 10.1でセキュリティ問題修正ほか

$
0
0

こんにちは、hachi8833です。Firefox Quantumで泣きましたか?私は拡張使ってませんでした。

11月中旬のウォッチ、いってみましょう。

Rails: 今週の改修

今週の公式更新情報はありません。

タイムゾーンがあいまいになることがある問題を修正

# 修正前
"2014-10-26 01:00:00".in_time_zone("Moscow")
#=> TZInfo::AmbiguousTime: 26/10/2014 01:00 is an ambiguous local time.

# 修正後
"2014-10-26 01:00:00".in_time_zone("Moscow")
#=> Sun, 26 Oct 2014 01:00:00 MSK +03:00
# activesupport/lib/active_support/values/time_zone.rb
     def period_for_local(time, dst = true)
-      tzinfo.period_for_local(time, dst)
+      tzinfo.period_for_local(time, dst) { |periods| periods.last }
     end

ほとんど何も説明がありません。普段はしないのですが、y-yagiさんブログをチェックすると以下のように書かれています。Rubyの挙動に合わせてActiveSupportが修正されたとのことです。

Europe/Moscowのようにタイムゾーンが複数ある値を指定した場合に、TZInfo::AmbiguousTimeが発生していたのを、発生しないよう修正しています。

「同じ地域に複数のタイムゾーンがある」というのがよくわからなかったので、#31128で修正されたという#17395を見ると、「最近(2014年)ロシアのタイムゾーン変更がtzinfoに反映された」とあります。さらに調べると、Time-j.netで以下の記述を見つけました。

ロシアのタイムゾーンは2014年10月26日(日)から以下の図のようにUTC+2 ~ UTC+12 の11個のタイムゾーンになりました。世界一広い国だけあって国内の最大の時差が10時間あります。
サマータイムについては、実施していません。2010年までは実施していましたが、2011年にサマータイムを標準時間としてサマータイムを廃止し、2014年10月26日(日)に本来の標準時間に戻りました。
2016年には、以下の地域で1時間時計の針を進めるタイムゾーンの変更がありました。

www.time-j.netより

そしてtimeanddate.comで、上のソースと同じ日付の2014/10/26 2:00に変更が行われていることがわかりました。このエッジケースに対応したということのようです。

ロシアのタイムゾーンが近年こんなにガッツンガッツン変更されているとは知りませんでした。ロシア国民とライブラリメンテナの苦労が伺えます。

rails newでできる.gitignoreにmaster keyが含まれていなかったのを修正

# railties/lib/rails/generators/rails/encryption_key_file/encryption_key_file_generator.rb

+      def ignore_key_file_silently(key_path, ignore: key_ignore(key_path))
+        append_to_file ".gitignore", ignore if File.exist?(".gitignore")
+      end

つっつきボイス: 「おっとっと、気をつけないとmaster keyをリポジトリに突っ込んじゃうやつだ」

Rails

3年かけて培ったRails開発のコツ


blog.kollegorna.seより

たくさん載ってます。

  • トップレベルのコントローラではrescue_fromを使う
  • コントローラではload_resourceを使う
  • ほか続々

つっつきボイス: 「よく使うテクニックもいろいろありそうなので、ざっと見ておいて損はなさそう」「これ翻訳しますね」

Rack DeflateをオンにしてRailsのページサイズを80%削減(Ruby Weeklyより)


schneems.comより

Railsの設定にRack::Deflateを追加することで100msほど高速化したそうです。

# 同記事より
config.middleware.insert_after ActionDispatch::Static, Rack::Deflater

つっつきボイス: 「要するにzipしてるってことね」

なぜService Objectが思ったほど普及しないのか(Ruby Weeklyより)


aaronlasseigne.comより

この記事は、先週のウォッチでご紹介したEnough With the Service Objects Already(Service Objectにちょっとうんざり)に答える内容です。


つっつきボイス: 「確かにこのコード先週も見たなー↓」「『彼の主張には一理あるかもしれない』だそうです」「Service Objectは使いみちが広い分、増えたときに設計がぐらつかないようにしないといけないし、YAGNIになる可能性もあるかも」

# 同記事より
class IpnProcessor
  def process_ipn(...)
    # controller code here
  end
end

SprocketからWebpackに移行する方法(Ruby Weeklyより)


rossta.netより

手順が具体的でよさそうです。


つっつきボイス: 「Webpack使う機会がまだなかった…」「Webpackいいですよー」「そうそう、Webpack使うとjavascript_pack_tagが挿入される↓」

<!-- application.html.erb -->

<html>
    <body>
        <!-- ... -->
        <%= javascript_pack_tag 'vendor' %>
        <%= javascript_include_tag 'vendor' %>

        <%= javascript_pack_tag 'application' %>
        <%= javascript_include_tag 'application' %>
    </body>
</html>

:before_validateコールバックの変わった使い方(Ruby Weeklyより)


karolgalanciak.comより

# 同記事より
class MyModel
  before_validate :strip_url

  private

  def strip_url
    self.url = url.to_s.strip
  end
end

つっつきボイス::before_validateは一見バッドプラクティスに見えたりするけど、データのフォーマットはバリデーション前にやらないと意味がないこともある」

この間翻訳させていただいた記事「RailsのObject#tryがダメな理由と効果的な代替手段」の著者でもあります。

Railsの`Object#try`がダメな理由と効果的な代替手段(翻訳)

Ruby/Railsプログラミングでよくある5つの間違い


business2community.comより

  • method_missingの使いすぎ
  • gemに頼りすぎ
  • アプリのロジックがビューに漏出する
  • 「コントローラを薄くする」ことにこだわりすぎる
  • SQLインジェクションを放置する

つっつきボイス:method_missingって、Railsアプリではまず書かないかな: gem作るとかフレームワーク作るならともかく」「アプリのロジックがビューに漏れるというのも、このぐらいだったら許容範囲なんじゃないかなー↓: 程度問題だけど」

# 同記事より
<h2>
Congratulations
<% if winning_player %>
<%= winning_player.name %>
<% else %>
Contestant
<% end %>
</h2>

「DHHが薄いコントローラを推してるんですよね」「ファットコントローラは確かによくないけど」

あなたが知らないかもしれないRailsの5つのコツ

# 同記事より
params[:sort].presence_in(sort_options) || :by_date

つっつきボイス:Object#presence_inは知らなかったナー」「後はおなじみといえばおなじみかな」

テストは簡潔に書くべきか言葉をつくすべきか

# 元記事より
# アプローチ1
describe '#[]' do
  subject { [1, 2, 3].method(:[]) }

  its_call(0) { is_expected.to ret 1 }
  its_call(1..-1) { is_expected.to ret [2, 3] }
  its_call(:b) { is_expected.to raise_error TypeError }
end

# アプローチ2
describe '#[]' do
  subject { [1, 2, 3] }

  it 'returns the value at a given index' do
    expect(subject[0]).to eq 1
  end
  it 'returns a list of values between a range on indexes' do
    expect(subject[1..-1]).to eq [2, 3]
  end
  it 'raises TypeError when passed a symbol' do
    expect { subject[:b] }.to raise_error TypeError
  end
end

つっつきボイス: 「う、実はアプローチ1って個人的には好き」「テストでエラーになったときに、テストを修正すべきなのかコードを修正すべきなのかわからないと困っちゃう」「普通にアプローチ2かな」
「これと直接関係ないけど、過去の経緯でプロジェクトによってテストの書き方が大きく違ってたりすると大変」「its_callなんてのがあるのか」「自分はlet好きなのでlet使う派」「私はletキライ」「お、派閥が分かれてるんですね」

Rubyで「Interactor」パターン

# 同記事より

# DeleteAccount interactor
class DeleteAccount
  include Interactor
  def call
  end
end

# Controller
def destroy
  account = Account.find(params[:id])
  DeleteAccount.call(account: account) # pass whatever you want as a hash
end

つっつきボイス: 「InteractorパターンってFacadeみたいなものなのかな?」「#call使いまくるあたりがそんな感じですね」

以下の記事でもInteractor gemが取り上げられています。

Railsコードを改善する7つの素敵なGem(翻訳)

parallel_tests: テストを並列化するgem

RSpec、Test::Unit、Cucumberのテストを並列化できるそうです。

# grosser/parallel_testsより
rake parallel:test          # Test::Unit
rake parallel:spec          # RSpec
rake parallel:features      # Cucumber
rake parallel:features-spinach       # Spinach

rake parallel:test[1] --> force 1 CPU --> 86 seconds
rake parallel:test    --> got 2 CPUs? --> 47 seconds
rake parallel:test    --> got 4 CPUs? --> 26 seconds
...

つっつきボイス: 「これ使った方います?」「一応データベースもスレッド化してくれるようです: ただ、案件で追加してみたときはテスト動かなくなったんで外しました」「もしかしてテストの方に問題があったのかもw」「プロジェクトの初期段階から使うのがよさそう」

multiverse: Railsで複数のデータベースを扱うgem(Ruby Weeklyより)

ankaneさんの作です。半月足らずで★200超えてます。


つっつきボイス: 「データベース間でJOINできます?」「使ってみないとわかりませんが、さすがに無理かなー」

data-migrate: スキーマの他にデータもマイグレーションするgem

これも半月足らずで★300近くあります。

$> rake -T data
rake data:forward                 # Pushes the schema to the next version (specify steps w/ STEP=n)
rake data:migrate                 # Migrate data migrations (options: VERSION=x, VERBOSE=false)
rake data:migrate:down            # Runs the "down" for a given migration VERSION
rake data:migrate:redo            # Rollbacks the database one migration and re migrate up (options: STEP=x, VERSIO...
rake data:migrate:status          # Display status of data migrations
rake data:migrate:up              # Runs the "up" for a given migration VERSION
rake data:rollback                # Rolls the schema back to the previous version (specify steps w/ STEP=n)
rake data:version                 # Retrieves the current schema version number for data migrations
rake db:forward:with_data         # Pushes the schema to the next version (specify steps w/ STEP=n)
rake db:migrate:down:with_data    # Runs the "down" for a given migration VERSION
rake db:migrate:redo:with_data    # Rollbacks the database one migration and re migrate up (options: STEP=x, VERSIO...
rake db:migrate:status:with_data  # Display status of data and schema migrations
rake db:migrate:up:with_data      # Runs the "up" for a given migration VERSION
rake db:migrate:with_data         # Migrate the database data and schema (options: VERSION=x, VERBOSE=false)
rake db:rollback:with_data        # Rolls the schema back to the previous version (specify steps w/ STEP=n)
rake db:version:with_data         # Retrieves the current schema version numbers for data and schema migrations

つっつきボイス:rake db:migrate:down:with_dataのような感じで使うのね」

webmock: HTTPリクエストのモック/expectation gem

リクエストのheaderやbodyを細かに指定できます。★2600超えです。

require 'webmock/rspec'

expect(WebMock).to have_requested(:get, "www.example.com").
  with(body: "abc", headers: {'Content-Length' => 3}).twice

expect(WebMock).not_to have_requested(:get, "www.something.com")

expect(WebMock).to have_requested(:post, "www.example.com").
  with { |req| req.body == "abc" }
# Note that the block with `do ... end` instead of curly brackets won't work!
# Why? See this comment https://github.com/bblimke/webmock/issues/174#issuecomment-34908908

expect(WebMock).to have_requested(:get, "www.example.com").
  with(query: {"a" => ["b", "c"]})

expect(WebMock).to have_requested(:get, "www.example.com").
  with(query: hash_including({"a" => ["b", "c"]}))

expect(WebMock).to have_requested(:get, "www.example.com").
  with(body: {"a" => ["b", "c"]},
    headers: {'Content-Type' => 'application/json'})

Grill.rb: バーベキューしながらRubyカンファレンス

今年の7月にポーランドで開催されたカンファレンスです。

蚊に食われないかなと心配になってしまいます。

grill rbさん(@grill.rb)がシェアした投稿

ReactやRailsで作った28のアプリリスト(ソース付き)

作者はさまざまです。アプリ開発のヒントにしたり、作りたいアプリが思いつかない学生さんとかにもよいかもしれません。

github-awesome-autocompleteはちょっと便利そう。


github.algolia.comより

Ruby trunkより

提案: ArgumentErrorにメソッドのプロトタイプを表示

[4] pry(main)> Kerk.new.foo1
ArgumentError: wrong number of arguments (0 for 1)
Method prototype:
    def foo1(a)
from /home/esjee/src/printprototype/spec/kerk_class.rb:2:in `foo1'

つっつきボイス: 「ノンジャパニーズの方が日本語で書いているところにほだされてしまいました」「エラーにプロトタイプって必要かしらん」
「sentry-ravenって何だろ?」「raven-rubysentry.ioというエラーレポート集約サイトがありますね」


sentry.ioより

Ruby

ベテランRubyistならPythonコードを5倍速くできることもある


schneems.comより

Rubyに精通していればPythonでも同じ考えがいろいろ通用するという主旨です。Richard Schneemanさんは怒涛のように濃い記事を書いてますね。

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

ガイジン向けRubyKaigiガイド(翻訳)

requireの仕組み(Ruby Weeklyより)


ryanbigg.comより

とても短い記事です。active_support/allというファイルがないのにrequireできる理由を解説しています。
作者のRyan Biggさんは2011年にRuby Heroを受賞しています。

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

一味違う方法でRubyのパフォーマンスをプロファイリング(Ruby Weeklyより)


kollegorna.seより

ruby-prof-flamegraphというgemを援用して次のようなflame graphを生成する記事です。flame graphという呼び名を初めて知りました。関係ありませんが、最近medium.comでよい記事を見かけることが多い気がします。


kollegorna.seより


つっつきボイス: 「このグラフどうやって読むの?」「一番下がThreadだから、呼び出しが上に進んでいる感じですね」

wsdirector: WebSocketをCLIとyamlで操作するgem(Ruby Weeklyより)

yamlでWebSocketのやり取りを書いて実行できます。

  # script.yml
  - client: # first clients group
      name: "publisher" # optional group name
      multiplier: ":scale" # :scale take number from -s param, and run :scale number of clients in this group
      actions: #
        - receive:
            data: "Welcome"
        - wait_all # makes all clients in all groups wait untill every client get this point (global barrier)
        - send:
            data: "test message"
  - client:
      name: "listeners"
      multiplier: ":scale * 2"
      actions:
        - receive:
            data: "Welcome"
        - wait_all
        - receive:
            multiplier: ":scale" # you can use multiplier with any action
            data: "test message"
wsdirector script.yml ws://websocket.server:9876 -s 10

#=> Group publisher: 10 clients, 0 failures
#=> Group listeners: 20 clients, 0 failures

つっつきボイス: 「テスト用なのかな」「そういえばWebSocketまだ使ったことなかったナ」

なおスポンサーはEvil Martiansです。同社ブログの以下の記事を翻訳させていただきました。

TestProf: Ruby/Railsの遅いテストを診断するgem(翻訳)

ぼっち演算子&.の落とし穴


antulik.comより

これもとても短い記事です。

# 同記事より
# 落とし穴を踏んだコード
if start_date &.< start_of_month && end_date.nil?
  # …
end

つっつきボイス: 「演算子結合の優先順位の罠か」「(メソッドチェーンかと思ったら違った…)」

Railsの`Object#try`がダメな理由と効果的な代替手段(翻訳)

PB MEMO: Rubyコミットを追いかけるブログ

Railsコミットをひたすら追うy-yagiさんのなるようになるブログのように、このブログではRubyのコミットをひたすら追いかけています。頭が下がります。

Rubyのインスタンス変数とアクセス制御

Rubyのインスタンス変数とアクセサの関係がちょっとモヤモヤしてたので貼ってみました。


つっつきボイス: 「dispだとdisplayしか思いつかないw」「attr_accessorでアクセサを作ると、代入時に同じ名前のインスタンス変数が作成されるという理解」
protectedはJavaと同じに考えるとハマるやつですな」「access controlとして作ってないんだとすると、どんな意図で作られたんだろう…」

(動画)MatzとRuby 3.0について語る

30分の動画です。駆け足で聞いてみたところ、最初と最後はよもやま話で、12:02あたりからがRuby 3.0の話でした。「パフォーマンス」「concurrency」「型」の3つのコンセプトについて語っています。


つっつきボイス: 「ここ駐車場かな?」「いや、運転してるし」

SQL

PostgreSQL 10の記事が続々出ています。

PostgreSQL 10.1などリリース: セキュリティ問題修正(Postgres Weeklyより)


postgresql.orgより

バージョン9以前に影響するものもあります。

  • CVE-2017-12172: 初期化スクリプトに権限昇格の脆弱性
  • CVE-2017-15098: JSON関数でサーバーのメモリの一部が露出
  • CVE-2017-15099: INSERT ... ON CONFLICT DO UPDATEすると権限無しでSELECTできる

pglogical拡張でPostgreSQL 9.6から10にダウンタイム最小限で移行する(Postgres Weeklyより)


rosenfeld.herokuapp.comより

PostgreSQL 10のテーブル継承と宣言的パーティショニングでスケールする(Postgres Weeklyより)


timescale.comより

PostgreSQLのパーティショニングされたテーブルを10のネイティブパーティショニングに移行する(Postgres Weeklyより)


openscg.comより

PostgreSQL 11の機能を先行紹介するブログ


depesz.comより


つっつきボイス: 「何と気が早い」

JavaScript

webpack-bundle-size-analyzer: Webpackのインストール内訳を分析

Webpackでインストールされたライブラリをこんな感じで表示できます。


github.com/robertknight/webpack-bundle-size-analyzerより

CSS/HTML/フロントエンド

Unicodeについて知っておくべき5つのこと


gojko.netより

  • 画面に表示されないUnicodeポイントはたくさんある
  • 見た目が互いにそっくりなUnicodeポイントはたくさんある
  • 正規化(normalization)はそんなに簡単な話じゃない
  • 表示の長さとメモリのサイズは同じとは限らない
  • Unicodeは単なる静的なデータではない

つっつきボイス: 「右の4つから左の合字を作れる話を思い出しました↓」


http://unicode.org/emoji/charts/emoji-zwj-sequences.htmlより

Source Mapとは何か(Ruby Weeklyより)

// 同記事より
{
  "version":3,
  "file":"application.js",
  "mappings": "AAAA;AACA;AACA;#...",
    "sources": [
      "jquery.source-56e843a66b2bf7188ac2f4c81df61608843ce144bd5aa66c2df4783fba85e8ef.js",
      "jquery_ujs.source-e87806d0cf4489aeb1bb7288016024e8de67fd18db693fe026fe3907581e53cd.js",
      "local-time.source-b04c907dd31a0e26964f63c82418cbee05740c63015392ea4eb7a071a86866ab.js"
    ],
    "names":[]
}

Firefox Quantum(57)から拡張機能はWebExtensionのみになる

Firefox 57リリース後にBPS社内でも小さな悲鳴がいくつか上がっていました。


つっつきボイス: 「私も拡張全滅しましたorz」

WebAssemblyが主要なブラウザでサポート(Frontend Weeklyより)


blog.mozilla.orgより

FirefoxとChromeに続き、SafariとEdgeでもWebAssemblyがサポートされたとのことです。


つっつきボイス: 「Aaron PattersonさんがWebAssemblyに興味持ってると言ってたのを思い出しました: Rubyで動くかしら」「機械語になるから難しそうですね」

dev.toの激速が話題

記事そのものもよさそうです。


つっつきボイス:CDNの効果が大きいのかな」

その他

スレッドとは何か

10年間見逃されていたmanコマンドの脆弱性


sudosatirical.comより

バージョンアップできないAndroid端末の台数をグラフ化

danluu.comより

番外

無電力パワードスーツで工場の事故や怪我を大きく削減


今週は以上です。

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

週刊Railsウォッチ(20171110)dry-rbでFormObjectを作る、RailsのSQLインジェクション手法サイト、年に1度だけ起きるバグほか

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

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

Ruby Weekly

RubyFlow

160928_1638_XvIP4h

Postgres Weekly

postgres_weekly_banner

Frontend Weekly

frontendweekly_banner_captured

Ruby 2.5のパフォーマンス改善点(翻訳)

$
0
0

概要

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

Ruby 2.5のパフォーマンス改善点(翻訳)

Rubyは常に改善を繰り返しており、Ruby 2.5でも同様です。

Ruby 2.5でいくつかの最適化が行われました。

  • サイズの大きな文字列を作成したときの式展開が約72%高速化
  • String#prependの引数が1つだけの場合に約42%高速化
  • Enumerable#sort_byEnumerable#min_byEnumerable#max_byが約50%高速化

ベンチマークを見てみましょう。

文字列の式展開パフォーマンス

この最適化のコミットメッセージに含まれていたコード例を使いました。

require 'benchmark/ips'

Benchmark.ips do |x|
  x.report "Large string interpolation" do |t|
    a = "Hellooooooooooooooooooooooooooooooooooooooooooooooooooo"
    b = "Wooooooooooooooooooooooooooooooooooooooooooooooooooorld"

    t.times { "#{a}, #{b}!" }
  end

  x.report "Small string interpolation" do |t|
    a = "Hello"
    b = "World"

    t.times { "#{a}, #{b}!" }
  end

  x.compare!
end

以下の結果を得ました。

  • Ruby 2.4.1:
Small string interpolation:  3236291.1 i/s
Large string interpolation:  1711633.4 i/s - 上より1.89倍遅い
  • Ruby 2.5:
Small string interpolation:  3125175.1 i/s
Large string interpolation:  2555782.6 i/s - 上より1.22倍遅い

見てのとおり、サイズの大きな文字列でパフォーマンスがかなり向上しました。

String#prependのパフォーマンス

prependメソッドは、arrayの先頭にテキストを挿入します。

Ruby 2.5では、もっともよく使われるケースとして文字列を1つだけ追加する場合の最適化を行いました。

ベンチマーク結果は次のとおりです。

  • Ruby 2.4.1:
String#prepend  3.428M (± 3.2%) i/s - 17.159M in   5.011008s
  • Ruby 2.5:
String#prepend  4.638M (± 3.6%) i/s - 23.276M in   5.025562s

これはなかなかの改善です。

Enumerableのパフォーマンス改善

いくつかのEnumerableメソッドでパフォーマンスが向上しました。

この最適化では<=>メソッドのディスパッチメソッドをスキップすることでパフォーマンスが向上します。

コミットメッセージには以下の記述があります。

Fixnum/Float/Stringオブジェクトへのディスパッチで、<=>メソッドではなくOPTIMIZED_CMP()を使う
同リンクより抄訳

ベンチマーク結果は次のとおりです。

  • Ruby 2.4.2:
Enumerable#sort_by    2.395k (± 6.7%) i/s - 11.952k in   5.014422s
Enumerable#min_by     8.244k (± 6.1%) i/s - 41.405k in   5.042327s
Enumerable#max_by     8.053k (± 6.7%) i/s - 40.180k in   5.015375s
  • Ruby 2.5:
Enumerable#sort_by    5.914k (± 6.7%) i/s  - 29.786k in   5.062584s
Enumerable#min_by     15.668k (± 3.0%) i/s - 78.888k in   5.039748s
Enumerable#max_by     15.544k (± 2.3%) i/s - 78.408k in   5.046709s

50%ほど向上していますね🙂

Range#minRange#max

他にも2つのパフォーマンス改善があります。

1つはRange#minRange#maxです。

ベンチマーク結果は次のとおりです。

  • Ruby 2.4.2
Range#min    7.976M (± 3.0%) i/s - 39.950M in   5.013242s
Range#max    7.996M (± 3.4%) i/s - 40.059M in   5.015984s
  • Ruby 2.5
Range#min   13.154M (± 3.0%) i/s -  65.731M in   5.002094s
Range#max  13.021M (± 2.6%) i/s  -  65.202M in   5.010924s

コミットはこちら

String#scanの改善

コミットメッセージによると、文字列パターンで50%、正規表現パターンで10%パフォーマンスが向上したとのことです。

ベンチマークをチェックしてみましょう。

  • Ruby 2.4.2
String#scan - String pattern
       1.367M (±19.8%) i/s - 6.458M in   4.982047s
String#scan - Regex pattern
       1.228M (±17.0%) i/s - 5.881M in   4.983943s

Ruby 2.5

String#scan - String pattern
      3.944M (±24.4%) i/s - 17.739M in   4.977417s
String#scan - Regex pattern
      1.696M (±17.4%) i/s -  8.103M in   4.982614s

高速化したscanに乾杯!

まとめ

12/25にリリース予定のRuby 2.5で導入される新しい最適化について解説しました。

最適化は、文字列の式展開、EnumerableメソッドString#prependメソッド、String#scanメソッド、そしてRange#max / Range#mixで行われました。

本記事がお役に立てば幸いです。

ぜひ、好みのSNSで本記事をシェアしてください(元記事からどうぞ)🙂

関連記事

ベンチマークの詳しい理解と修正のコツ(翻訳)

RailsConf 2017のパフォーマンス関連の話題(1)BootsnapやPumaなど(翻訳)


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

$
0
0

概要

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

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

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

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

ルートのコントローラにrescue_fromを書くと、その下で発生したすべての例外をキャッチできるので非常に便利です。Webアプリにこれを追加すると、リクエスト/レスポンスのサイクルで実行されるほとんどのコードが一気に便利になります。シンプルなAPIを例に考えます。rescue_fromを使えば、レコードが見つからない(ActiveRecordがActiveRecord::RecordNotFoundをスローする)場合のアプリの振る舞いを明示的に指定できます。

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

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

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

class UsersController
  before_action :load_resource

  def index
    # @usersで何かする
  end

  def show
    # @usersで何かする
  end

  def create
    # @usersで何かする
  end

  def update
    # @usersで何かする
  end

  def destroy
    # @usersで何かする
  end

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

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

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

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

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

3. decoratorやpresenterを使う(permalink

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

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

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

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

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

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

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

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

6. Postgres JSONBはいいヤツ(permalink

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  included do
    include Virtus.model

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

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

  include VirtusModel
end

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

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

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

もうおわかりですよね😃

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

ユーザーはこれを渡されても困ってしまいます。1つの方法は、messageオプションを使うことです。

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

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

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

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

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

  return errors_array
end

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

次の例をご覧ください。

CLOSURE = proc{puts internal_name}

class Foo
  def internal_name
    'foo'
  end

  def closure
    proc{puts internal_name}
  end

  def name1
    closure.call
  end

  def name2
    CLOSURE.call
  end

end

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

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

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

CLOSURE = proc{puts internal_name}

class Foo
  def internal_name
    'foo'
  end

  def closure
    proc{puts internal_name}
  end

  def name1
    closure.call
  end

  def name2
    instance_exec(&(CLOSURE))
  end

end

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

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

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

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

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

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

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

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

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

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

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

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

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

これで、このルートにuser_idを送信すると、このメソッドをbefore_filterとして追加することで、company_user_idとして追加されます。

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

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

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

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

関連記事

Rails: Form ObjectとVirtusを使って属性をサニタイズする(翻訳)

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

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

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

Ruby: ぼっち演算子`&.`の落とし穴(翻訳)

$
0
0

概要

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

Ruby: ぼっち演算子&.の落とし穴(翻訳)

Ruby 2.3で追加された便利な&.演算子(ぼっち演算子)のおかげで、コードをずいぶんきれいにできました。これはRubyの新しい&.!=演算子に記載されているデフォルト演算子と連結して使えます。

ところで、最近&.を使ったためにバグが起きてしまいました。元々のコードは次のとおりです。

if start_date && start_date < start_of_month && end_date.nil?
  # ...
end

リファクタリングして&.を使ったのが次のコードです。

if start_date &.< start_of_month && end_date.nil?
  # ...
end

書き換え後のコードはすっきりましたが、ここにバグがあるのがおわかりでしょうか?かっこを追加して実行順序を確認してみましょう。

if start_date &.< (start_of_month && end_date.nil?)
  # ...
end

問題は、導入した&.の直後にある特殊演算子<が普通のメソッドコールになってしまったことでした。&.<の右辺はメソッドの引数とみなされて最初に実行されてしまいます。

修正するには、次のように前半部分にかっこを追加する必要がありました。

if (start_date &.< start_of_month) && end_date.nil?
  # ...
end

この次リファクタリングで&.を使うときは、振る舞いが変更されないかどうか注意しましょう。

関連記事

Railsの`Object#try`がダメな理由と効果的な代替手段(翻訳)

Rails: dry-rbでForm Objectを作る(翻訳)

$
0
0

概要

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

Rails: dry-rbでForm Objectを作る(翻訳)

現代のRailsでは、Form Objectを作るのは珍しくありません。多くのRuby開発者はVirtusActiveModel::ValidationsをincludeしてForm Objectを作成することに慣れています。本記事では、dry-typedry-validationを使ってForm Objectを作成する方法をご紹介したいと思います。

絵ハガキ(postcard)を作成する簡単なForm Objectを作ってみましょう。このアプリには次の3つのモデルがあります。

  • Country: フィールドはnameis_state_required。2つ目のフィールドは正しいアドレスの作成に使われ、米国などのユーザーは州名の入力が必要です。
  • Country::State: フィールドはnamecountry_id
  • Postcard: フィールドはstate_idcountry_idcontentaddress

完了までの作業を定義する

  • フォームで新しい絵ハガキを作成する(だいたいおわかりですね)
  • 住所、市町村、郵便番号、コンテンツ、国のバリデーションを行う
  • 郵便番号フォーマットのバリデーションを行う
  • コンテンツの長さのバリデーションを行う(ツィートやテキストメッセージ並に短くしたい場合)
  • 選択した国で州名が必要な場合、州名の存在のバリデーションも必要

属性と型

まずは属性の定義から行います。Form ObjectはDry::Types::Structから継承する必要があります。必要なゲッターやコンストラクタはDryで定義されます。

class Postcard
  module Types
    include Dry::Types.module
  end

  class CreateForm < Dry::Types::Struct
    attribute :address, Types::Coercible::String
    attribute :city, Types::Coercible::String
    attribute :zip_code, Types::Coercible::String
    attribute :content, Types::Coercible::String
  end
end

Dry::Types.moduleincludeするだけでDry-typesの型を使えるようになります。Dry-typesでは変更に応じた多くのプリミティブ型を選択できます

Railsモデルを使う場合はもう少し複雑です。これらの型で属性を作成するには、型を登録する必要があり、TypeName = Dry::Types::Definition.new(::MyRubyClass)のように行います。.constructorをブロック付きで呼び出すと、dry-typesで構成される型を指定できます。

定義は以下のような感じになります。

module Types
  include Dry::Types.module
  Country = Dry::Types::Definition.new(::Country)
  CountryState = Dry::Types::Definition.new(::Country::State)
end

これで、CountryCountryStateを型として使えるようになりました。最終的なフォームの定義は次のようになります。

class CreateForm < Dry::Types::Struct
  attribute :address, Types::Coercible::String
  attribute :city, Types::Coercible::String
  attribute :zip_code, Types::Coercible::String
  attribute :content, Types::Coercible::String
  attribute :country, Types::Country
  attribute :state, Types::CountryState
end

これでやっと、シンプルなstructを作成できました。

メモ: dry-typesのstructコンストラクタについて

コンストラクタの種類を指定しないと、strictコンストラクタが生成されます。この場合、属性が見つからないとArgumentErrorをスローします。存在のバリデーションはdry-validationで行うので、より多くの情報を含むコンストラクタであるschemaコンストラクタやsymbolizedコンストラクタを使うことになります。schemaコンストラクタを使うには、クラス本体の中でconstructor_type(:schema)を呼ぶ必要があります。

バリデーション

Form Object内部でバリデーションを実行するには、dry-validation gemを使います。これにはさまざまな述語(メソッド)が含まれており、使い方も簡単です。まずは存在のバリデーションを行ってみましょう。

PostcardSchema = Dry::Validation.Schema do
  required(:address).filled
  required(:city).filled
  required(:zip_code).filled
  required(:content).filled
  required(:country).filled
end

先ほど定義したモデルの属性を渡すスキーマを次のように定義します。

errors = PostcardSchema.call(to_hash).messages(full: true)

それではこの動作を見てみましょう。

  • to_hash(またはto_h): 属性をハッシュベースで生成する
  • .messages(full: true): 完全なエラーメッセージを返す。

フォーマットや長さなど、渡すバリデーションの要件を増やすには、単に.filledメソッドにパラメータを渡します。contentを例に取ると、存在バリデーションの他に、20文字より長いこともバリデーションされます。

required(:content).filled(min_size?: 20)

利用できる述語の全リストはこちらをご覧ください。

バリデーションロジックがさらに複雑な場合

存在や長さのバリデーション機能はdry-validationによって提供されます。残念なことに(?)、実際に動くアプリではこれだけでは不十分です。そのため、dry-validationで独自の述語を書けるようになっています。

まずは簡単なものから。バリデーションに渡されたcountrystateが必要な場合は以下のように書きます。

PostcardSchema = Dry::Validation.Schema do
  configure do
    config.messages_file = Rails.root.join('config/locales/errors.yml')
    def state_required?(country)
      country.is_state_required
    end
  end
# (...)
end

このとおり簡単です。errors.ymlに正しいエラーメッセージを書いておくのをお忘れなく。エラーファイルについて詳しくはこちらをどうぞ。

次はいよいよ、countryで必要になった場合にのみstateの存在をチェックしましょう。stateが存在するかどうかをバリデーションに伝える必要があります。これは、スキーマに以下の行を書くだけでできます。

required(:state).maybe

ルール自体を定義する

ルール自体は次のように定義します。

rule(country_requires_state: [:country, :state]) do |country, state|
  country.state_required? > state.filled?
end

これも見てのとおり簡単です。

  • ルール内で必要となるフィールドに沿ったルール名を渡します。ここではcountryとstateを使います。
  • これらの変数はブロックにyieldされます。
  • 「stateが必要な場合は、stateの存在をチェックする」というようにルールが変換されます。

より高度なルールについて詳しくはこちらをどうぞ。

完成したForm Object

class Postcard
  module Types
    include Dry::Types.module
    Country = Dry::Types::Definition
                .new(::Country)
    CountryState = Dry::Types::Definition
                     .new(::Country::State)
  end

  class CreateForm < Dry::Types::Struct
    constructor_type(:schema)

    ZIP_CODE_FORMAT = /\d{5}/
    MINIMAL_CONTENT_LENGTH = 20

    attribute :address, Types::Coercible::String
    attribute :city, Types::Coercible::String
    attribute :zip_code, Types::Coercible::String
    attribute :content, Types::Coercible::String
    attribute :country, Types::Country
    attribute :state, Types::CountryState


    def save!
      errors = PostcardSchema.call(to_hash).messages(full: true)
      raise CommandValidationFailed, errors if errors.present?
      Postcard.create!(to_hash)
    end

    private

    PostcardSchema = Dry::Validation.Schema do
      configure do
        config.messages_file = Rails.root.join('config/locales/errors.yml')
        def state_required?(country)
          country.is_state_required
        end
      end
      required(:address).filled
      required(:city).filled
      required(:zip_code).filled(format?: ZIP_CODE_FORMAT)
      required(:content).filled(min_size?: MINIMAL_CONTENT_LENGTH)
      required(:state).maybe
      required(:country).filled

      rule(country_requires_state: [:country, :state]) do |country, state|
        country.state_required? > state.filled?
      end
    end
  end
end

モデルやspecを含む完全なプロジェクトは私のGitHubに公開してあります。

適用できるリファクタリング

記事を読みやすくするため、私はオブジェクト自身に関連するものをすべてひとつのファイルに書きました。このような書き方は、おそらく実際のアプリにおけるコードベースの編成法として最適ではありません。次のリファクタリングが考えられます。

  • Typesモジュールを別のモジュールに配置する(場合によってはグローバルスコープに)
  • PostcardSchemaはForm Objectの外部に配置し、UpdateFormなどでも使う
  • ZIP_CODE_FORMATMINIMAL_CONTENT_LENGTHについても同様

まとめ

dry-typesを使うと、アプリで型安全なコンポーネントを書けるようになります。このライブラリでは多数の型が利用可能で、独自定義も簡単です。

私にとって、dry-validationによるアプローチはActiveModelを使ったものよりも明快に感じられます。バリデーションロジックをすべて明確に区切られた場所に集められます。これらのバリデーションは他のフォーム(UpdateForなど)での再利用も簡単です。

dry-rbシリーズの最大の問題は(ROMRodaにも同種の問題があるのですが)、初めてのユーザーが簡単に使えるようなドキュメントがないことです。信じていただけるかどうかはともかく、私はこのForm Objectの作成に2時間かかりました。原因のほとんどは、ドキュメントの問題と、ブログ記事がないことです。本記事が皆さまの2時間を節約するのに役立てばと願っています。

関連記事

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

Rails: Form ObjectとVirtusを使って属性をサニタイズする(翻訳)

肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)

RubyのModule Builderパターン #2 Module Builderパターンとは何か(翻訳)

週刊Railsウォッチ(2017/11/24)GitHubにセキュリティアラート追加、RailsでVue.jsを使う、Railsテスト本2種、node-pruneで瞬間クリーンアップほか

$
0
0

こんにちは、hachi8833です。

Rails: 今週の改修

今週もcommit差分から見繕いました。

klass.all高速化のため不要なspawnを抑制

# activerecord/lib/active_record/scoping/default.rb
              # The user has defined their own default scope method, so call that
               evaluate_default_scope do
                 if scope = default_scope
-                  (base_rel ||= relation).merge(scope)
+                  (base_rel ||= relation).merge!(scope)
                 end
               end
             elsif default_scopes.any?
               base_rel ||= relation
               evaluate_default_scope do
                 default_scopes.inject(base_rel) do |default_scope, scope|
                   scope = scope.respond_to?(:to_proc) ? scope : scope.method(:call)
-                  default_scope.merge(base_rel.instance_exec(&scope))
+                  default_scope.merge!(base_rel.instance_exec(&scope))
                 end
               end
             end

つっつきボイス:mergemerge!に変わったのか」

ActiveRecord::SpawnMethodsを見るとmergespawnがありました。

# actionpack/lib/action_controller/metal/strong_parameters.rb#L718
    def merge(other)
      if other.is_a?(Array)
        records & other
      elsif other
        spawn.merge!(other)
      else
        raise ArgumentError, "invalid argument: #{other.inspect}."
      end
    end

    def merge!(other) # :nodoc:
      if other.is_a?(Hash)
        Relation::HashMerger.new(self, other).merge
      elsif other.is_a?(Relation)
        Relation::Merger.new(self, other).merge
      elsif other.respond_to?(:to_proc)
        instance_exec(&other)
      else
        raise ArgumentError, "#{other.inspect} is not an ActiveRecord::Relation"
      end
    end

after_bundleコールバックを非推奨化

fbd1e98からRailsのプラグインで生成時にbundle installが実行されなくなったため、
after_bundleコールバックはbundle後に実行されなくなった。
このコールバック名と実際の動作が合わなくなったので削除すべきと考える。
#60c550より

# railties/lib/rails/generators/rails/plugin/plugin_generator.rb
       def run_after_bundle_callbacks
+        unless @after_bundle_callbacks.empty?
+          ActiveSupport::Deprecation.warn("`after_bundle` is deprecated and will be removed in the next version of Rails. ")
+        end
+
         @after_bundle_callbacks.each do |callback|
           callback.call
         end

つっつきボイス: 「Rails 5のプラグインって何だろうと思ったら、Railsガイドに書いてあった」

参考: Gem、Railtieプラグイン、Engine(full/mountable)の違いとそれぞれの基礎情報

ActiveStorageルーティングの一部でオプションが無視されていたのを修正

# activestorage/config/routes.rb
Rails.application.routes.draw do
   get "/rails/active_storage/blobs/:signed_id/*filename" => "active_storage/blobs#show", as: :rails_service_blob, internal: true

-  direct :rails_blob do |blob|
-    route_for(:rails_service_blob, blob.signed_id, blob.filename)
+  direct :rails_blob do |blob, options|
+    route_for(:rails_service_blob, blob.signed_id, blob.filename, options)
   end

-  resolve("ActiveStorage::Blob")       { |blob| route_for(:rails_blob, blob) }
-  resolve("ActiveStorage::Attachment") { |attachment| route_for(:rails_blob, attachment.blob) }
+  resolve("ActiveStorage::Blob")       { |blob, options| route_for(:rails_blob, blob) }
+  resolve("ActiveStorage::Attachment") { |attachment, options| route_for(:rails_blob, attachment.blob, options) }
...

つっつきボイス: 「おー、随分抜けてたなー」「テストが見当たらないけど後から足すのかな?」

Rails

書籍『Everyday Rails Testing with RSpec』2017年版の第2章が更新

leanpub.com/everydayrailsrspecより

見逃していましたが、同書は6月にSpec 3.6とRails 5.1向けにメジャーアップデートしていて、それがさらに更新されたということです。英語版を購入した方は無料で更新版をダウンロードできます。

書籍『Rails 5 Test Prescriptions』(ベータ版)


pragprog.comより

Rails 5.1+Webpackに対応しているそうです。おおよその目次は以下です。

  • TDDの寓話
  • TDDの基本
  • RailsのTDD
  • テストを良くする要素
  • モデルのテスト(書籍サンプルPDF
  • テストで日時を扱う
  • ダブル(double)をモックやスタブとして使う
  • CapybaraとCucumberを使った結合テスト
  • JavaScriptの結合テスト
  • JavaScriptの単体テスト
  • Railsの表示要素のテスト
  • MiniTest
  • セキュリティのテスト
  • トラブルシューティング/デバッグ
  • テストの高速化
  • レガシーコードのテスト

つっつきボイス: 「prescription: 処方箋ですね」「このあたりの本、輪読会に向いてそう」

RSpecからController specを消し去る(RubyFlowより)


everydayrails.comより


つっつきボイス: 「コントローラを薄くしてコントローラのテストも薄くするという考え、とても同意できる: コントローラに業務ロジックとか書かなければ、コントローラのテストはアクセスチェックで十分なはず」「昔コントローラのテストをどこまで書くべきか悩んでました」

[Rails] RSpecをやる前に知っておきたかったこと

RailsアプリをAWS Elastic Beanstalkにデプロイする


syndicode.coより


つっつきボイス: 「Elastic Beanstalkってどんなサービスだったかしら」「論理VMレベルのDockerに近いかも: Vagrantに近いと言えばイメージ近いかな」「この記事ではRailsのSECRET_KEY_BASEも設定してますね」「最近の作って捨てるポリシのインフラ界隈では、configはハードコードせずに環境変数としてinjectionせよ、というのがベストプラクティスになりつつあるので、実はRailsのsecret.key.enc方式はそうした流れに逆行しているかな」

参考: よくある質問: AWS Elastic Beanstalk

FastRuby.io: 古いRailsのアップグレード相談サイト

以前のウォッチでもご紹介したhttps://www.upgraderails.com/とちょっと似た感じです。upgraderails.comはアップグレード請負が全面に出ていますが、fastruby.ioは「まずはご相談」という雰囲気で、無料ガイド(PDF)も配布しています。

www.upgraderails.comより

JRubyだとTime.iso8601Time.parseの14倍速かった

早すぎる最適化もたまにはいいことがあるという主旨です。

# 同記事より
# JRuby 9.0.4.0
Warming up --------------------------------------
           No format     1.111k i/100ms
          ISO format    18.031k i/100ms
Calculating -------------------------------------
           No format     16.364k (± 3.3%) i/s -     82.214k
          ISO format    237.077k (± 4.2%) i/s -      1.190M

Comparison:
          ISO format:   237076.7 i/s
           No format:    16364.4 i/s - 14.49x slower

つっつきボイス: 「CRubyだとTime.iso8601に変えても3%しか速くならなかったそうです」「へー、JavaのDateライブラリはとても使いにくくて何度も変わってたのに」「Rubyのパースは柔軟だけどその分遅いのかな」

参考: wikipedia-ja ISO8601

RailsでVue.jsを使う


classandobjects.comより

RailsにVue.jsを導入する手順の解説です。

# 同記事より
app/javascript/
├── components
│   ├── App.vue
│   └── shared
│       └── csrf.vue
├── packs
│   ├── devise
│   │   └── registrations
│   │       └── new.js
└── views
    ├── devise
        └── registrations
            └── new.vue

つっつきボイス:rails new myapp --webpack=vueでVue.jsインストールできるのか↓」「5.1からこれ使ってました」「=jqueryはないというかなしさ」

# Intalling vue
# Rails 5.1+ new application
rails new myapp --webpack=vue

Vue.jsサンプルコード(01〜03)Hello World・簡単な導入方法・デバッグ・結果の表示とメモ化

DHHインタビュー(Ruby Weeklyより)


lifehacker.comより

子供の頃プログラミングを学ぼうとして何度も挫折し、もっぱらゲームに勤しんでたそうです。


つっつきボイス: 「いつもの写真と違いますね」「これも最近の写真じゃないだろうなきっと」「今もTextMateが好きらしいです」「TextMateはPHP時代にかなり長い間使ってたけど、日本語サポートが残念すぎて半角日本語フォントを自作ビルドして入れるとかしないとまともに表示できなかったり、TextMate3がいまいちだったりした辺りで乗り換えたな」

Service Object支援gem 2本立て

active_interaction: ビジネスロジックをCommandパターンで書くgem

# AaronLasseigne/active_interactionより
class BooleanInteraction < ActiveInteraction::Base
  boolean :kool_aid

  def execute
    'Oh yeah!' if kool_aid
  end
end

BooleanInteraction.run!(kool_aid: 1)
# ActiveInteraction::InvalidInteractionError: Kool aid is not a valid boolean
BooleanInteraction.run!(kool_aid: true)
# => "Oh yeah!"

waterfall: チェインを意識した関数型的Service Object gem(Ruby Weeklyより)

apneadiving/waterfallより

# apneadiving/waterfallより
class FetchUser
  include Waterfall

  def initialize(user_id)
    @user_id = user_id
  end

  def call
    chain { @response = HTTParty.get("https://jsonplaceholder.typicode.com/users/#{@user_id}") }
    when_falsy { @response.success? }
      .dam { "Error status #{@response.code}" }
    chain(:user) { @response.body }
  end
end

Flow.new
    .chain(user1: :user) { FetchUser.new(1) }
    .chain(user2: :user) { FetchUser.new(2) }
    .chain  {|outflow| puts(outflow.user1, outflow.user2)  } # report success
    .on_dam {|error|   puts(error)      }                    # report error


apneadiving/waterfallより


つっつきボイス: 「この間のRails開発のコツ記事にもありましたが、みんなService Objectをどうにかしたいんだなと感じました」「このWaterfallというgemの名前は機能に即してて好き: 図もわかりやすいし↑」「ワークフローっぽいですね」「JSのPromiseをちょっと連想しました」
「ところでService ObjectのServiceという言葉、意味が広すぎてあまり好きじゃないです: Domain Objectと呼んで欲しかった」「Domainも相当意味が広い気がしますね」

参考: 混乱しがちなサービスという概念について

Ruby trunkより

リクエスト: Kernel#ppをデフォルトで有効にして欲しい

賛成が集まりつつあります。


つっつきボイス:#ppって使ったことないけど何の略?」「Kernel#pp(pretty print)は普通によく使いますね: printfデバッグ的なことをするときとか」「確かにデフォルトでrequireされるようになったらありがたい」

Ruby

RubyのChain of ResponsibilityパターンとProxyパターン(RubyFlowより)

rubyblog.proより

Rubyデザインパターンの記事2本です。ProxyパターンではVirtual proxy/Protection proxy/Remote proxy/Smart referenceの4つの応用例が示されています。

Rubyでワーカープールを実装する(Ruby Weeklyより)

# 同記事より
worker_1 got #<Proc:0x007fc35a132d18@worker_pool_2.rb:40 (lambda)>
worker_0 got #<Proc:0x007fc35a130a40@worker_pool_2.rb:89 (lambda)>
worker_3 got #<Proc:0x007fc35a1309a0@worker_pool_2.rb:89 (lambda)>
worker_5 got #<Proc:0x007fc35a130950@worker_pool_2.rb:89 (lambda)>
worker_7 got #<Proc:0x007fc35a1308b0@worker_pool_2.rb:89 (lambda)>
worker_9 got #<Proc:0x007fc35a130810@worker_pool_2.rb:89 (lambda)>
worker_5 got #<Proc:0x007fc35a1305b8@worker_pool_2.rb:89 (lambda)>
# reduced output lines...
worker_4 got #<Proc:0x007fc35a130428@worker_pool_2.rb:89 (lambda)>
worker_6 got #<Proc:0x007fc35a130900@worker_pool_2.rb:89 (lambda)>
worker_2 got #<Proc:0x007fc35a130478@worker_pool_2.rb:89 (lambda)>
worker_1 got #<Proc:0x007fc35a1307c0@worker_pool_2.rb:89 (lambda)>
worker_8 got #<Proc:0x007fc35a130018@worker_pool_2.rb:89 (lambda)>
worker_4 got #<Proc:0x007fc35a1304f0@worker_pool_2.rb:89 (lambda)>
worker_0 got #<Proc:0x007fc35a1306f8@worker_pool_2.rb:89 (lambda)>

つっつきボイス: 「ワーカーというとUnicornとかPumaとか」「そういうソースを追うときに役立ちそうですね」

google_translate_diff: Google翻訳APIで巨大な文の差分だけ翻訳するgem(RubyFlowRuby Weeklyより)

# 同記事より
s = "There are 6 pcs <b>Neumann Gefell</b> tube mics MV 101 with MK 102 capsule. It is working with much difference capsules from neumann / gefell.\nAdditionally…"

GoogleTranslateDiff.translate(s, from: :en, to: :es)

=> # Tokenize

["There are 6 pcs ", :text],
 ["<b>", :markup],
 ["Neumann Gefell", :text],
 ["</b>", :markup],
 [" tube mics MV 101 with MK 102 capsule.", :text],
 ["It is working ... / gefell.\n", :text],     # NOTE: Separate sentence
 ["Additionally…", :text]]                     # NOTE: Also, separate sentence

=> # Load from cache and translate missing pieces

["Ci sono 6 pezzi ", :text],                   # <== cache
 ["<b>", :markup],
 ["Neumann Gefell", :text],                    # <== Google ==> cache
 ["</b>", :markup],
 [" Tubi MV 101 con ... ", :text],             # <== Google ==> cache
 ["Sta lavorando cn ... / gefell.\n", :text],  # <== cache
 ["Inoltre…", :text]]                          # <== cache

=> # Join back

"Ci sono 6 pezzi <b>Neumann Gefell</b> Tubi MV 101 con capsula MK 102. Sta lavorando con molte capsule di differenza da neumann / gefell.\nInoltre"

やはりというか、差分翻訳のコンテキストは失われてしまうそうです。


つっつきボイス: 「こういうオレオレ翻訳支援ツールって車輪の再発明がものすごく多い世界: ローカライズ業界では訳文の再利用に翻訳メモリというものをよく使ってるんですが、サイズが大きくなって低品質の翻訳が混じるとどんどん残念になってしまう」

Google Translator Toolkitと翻訳メモリ(ノーカット版) : RubyWorld Conference 2013より

sniffer: 外向きHTTPリクエストのアナライザgem


aderyabin/snifferより

1月足らずで★250超えです。これも含め、最近evilmartians.comがスポンサーになっているgemをときどき見かけます。

# aderyabin/snifferより
require 'http'
require 'sniffer'

Sniffer.enable!

HTTP.get('http://example.com/?lang=ruby&author=matz')
Sniffer.data[0].to_h
# => {:request=>
#   {:host=>"example.com",
#    :query=>"/?lang=ruby&author=matz",
#    :port=>80,
#    :headers=>{"Accept-Encoding"=>"gzip;q=1.0,deflate;q=0.6,identity;q=0.3", "Connection"=>"close"},
#    :body=>"",
#    :method=>:get},
#  :response=>
#   {:status=>200,
#    :headers=>
#     {"Content-Encoding"=>"gzip",
#      "Cache-Control"=>"max-age=604800",
#      "Content-Type"=>"text/html",
#      "Date"=>"Thu, 26 Oct 2017 13:47:00 GMT",
#      "Etag"=>"\"359670651+gzip\"",
#      "Expires"=>"Thu, 02 Nov 2017 13:47:00 GMT",
#      "Last-Modified"=>"Fri, 09 Aug 2013 23:54:35 GMT",
#      "Server"=>"ECS (lga/1372)",
#      "Vary"=>"Accept-Encoding",
#      "X-Cache"=>"HIT",
#      "Content-Length"=>"606",
#      "Connection"=>"close"},
#    :body=> "OK",
#    :timing=>0.23753299983218312}}

つっつきボイス: 「おーハッシュで取れる: アプリの動作確認とかでときどき欲しくなるヤツかも」「pryの中で動かせるのがいいですね」

bundlerでcombinationが原因のバグ


depfu.comより

# 同記事より
[1,2,3,4].combination(2).to_a
 => [[1, 2], [1, 3], [1, 4], [2, 3], [2, 4], [3, 4]]

([1] * 26).combination(13).size
 => 10400600

つっつきボイス:#combinationって数学の順列組み合わせの組み合わせでしたっけ」「ですです」「そういえば#productはRSpecでよく使ってます」「組み合わせが爆発してbundlerがめちゃくちゃメモリ食ったのか」「記事の最後で投げているissue #6114がわかりやすそう」「組み合わせ爆発怖い…」

SQL

PostgreSQLのAdvisory lockとその使い方


shiroyasha.ioより

-- 同記事より
SELECT pg_advisory_unlock(23);
SELECT pg_advisory_unlock(112, 345);

SELECT mode, classid, objid FROM pg_locks WHERE locktype = 'advisory';

 mode | classid | objid
------+---------+-------
(0 rows)

つっつきボイス: 「Advisory lockの訳語って勧告的ロックなのね」「自分でunlockしないといけないあたり、何だかRubyのFiberを思い出しました」「強力すぎて怖いなー」「自己責任迫られるヤツ」

Advisory: 顧問

JavaScript

JavaScriptのコスト(JavaScript Weeklyより)


medium.com/dev-channelより

JavaScriptのどこで時間やリソースを食っているかという調査です。


medium.com/dev-channelより

忙しいJS開発者のためのES6いいとこ取り(JavaScript Weeklyより)


thenewstack.ioより

// 同記事より
var id = `Your name is ${firstName} ${lastName}.`
var url = `http://localhost:8080/api/messages/${id}`
let chicken = {
     name: 'Pidgey',
     jobs:['scratch for worms', 'lay eggs', 'roost'],
     showJobs() {
        this.jobs.forEach((job) => {
        console.log(`${this.name} wants to ${job}`);
       });
    }
};
chicken.showJobs();
//Pidgey wants to scratch for worms
//Pidgey wants to lay eggs
//Pidgey wants to roost

つっつきボイス:
「ES6、バッククォートで式展開できるようになってるじゃないの!」「webpackとbabel使うなら普通に使ってよい機能なんで、最近使い始めた」
「式展開を最初に知ったのはRubyだったんですが、他の言語は?」「PHPで使ったことあった」「Perlにもあったかな」「式展開使ってる言語いろいろありますよ: メジャーなLL系言語ならほぼ持ってるんじゃないかな」

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

「fat arrow => はクロージャなのか」「そういえばCoffeeScriptのアローって->=>の2種類あるんですが、たまに使い分け間違えたりしてその後嫌いになったりすることあった」

参考: CoffeeScript -> と => の違い

AngularとReactとVueのどれがいいの?


objectpartners.comより

// 同記事より: Reactの例
export class Counter extends React.Component {
  render() {
    return <div>
      <h2>Current value is { this.state.value }</h2>
      <button>Increment</button>
    </div>
  }

  increment() {
    this.setState({
      value: this.state.value + 1
    });
  }
}

つっつきボイス: 「みんな悩みますよね」「Reactはrendererがあるのかー: 趣味に合わない」「大きなプロジェクトだとAngularなのかな: TypeScriptだし」

「依存性の注入(DI)」なんか知らなくてもいい(JavaScript Liveより)

面接で「DIとは何かと」いう質問があったのがきっかけで書いた記事だそうです。

// 同記事より
class Knight extends React.Component {
  static propTypes = {
    weapon: PropTypes.any.isRequired
  };
  render() {
    return `🐴 ${this.props.weapon}`;
  }
}

つっつきボイス: 「DIといえばもうJavaでしょう: Rubyだと特に必要を感じないなー」

参考: 猿でも分かる! Dependency Injection: 依存性の注入

⭐node-prune: nodeの不要なファイルを一瞬で除去(GitHub Trendingより)⭐

公開後5日しか経過していないのに★2200超えです。Go言語で書かれています。


github.com/tj/node-pruneより


つっつきボイス: 「これみんな欲しかったヤツでしょうね」「npm使ってるとファイルじゃんじゃん増やされるし」「↑図がすべてを表してるw」「node_moduleはブラックホールより重い、と」

試しに動かしてみると、本当に一瞬で完了しました。

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

CSS/HTML/フロントエンド

CSS Writing Modes Level 3がRecommendation間近?

11/23にCRが更新されていました。


w3.orgより


grid項目のアスペクト比


css-tricks.comより

gridのアスペクト比でお悩みの方向けです。

See the Pen Aspect Ratio Boxes Filling by Chris Coyier (@chriscoyier) on CodePen.

その他

GitHubにセキュリティアラート機能が追加

GitHubのPublicなリポジトリでInsights > Code Dependencyを表示するとセキュリティアラートが表示されるようになりました。現時点ではJavaScriptとRubyが対象です。


つっつきボイス: 「これいいなー」「何年も前にGitHubに放置していた自分のリポジトリで見てみたらどっと出てた…」「gem使う前にInsights > Code Dependencyチェック、が合言葉」

開発者にとって重要な5つの問題解決スキル

dev.to記事です。


dev.toより

  1. 大きくて複雑な目標をシンプルな目標に分割できる
  2. 並列を考えられる
  3. 抽象化できる(やりすぎないこと)
  4. 既存のソリューションを再利用できる
  5. データフローに即して考えられる

つっつきボイス: 「いい感じかつ実用的かも」

tmuxinator: tmuxセッションを簡単に作れる(GitHub Trendingより)


github.com/tmuxinator/tmuxinatorより

★7200超えです。


つっつきボイス: 「BPS社内は確かtmux派とbyobu派がいましたね」「(GNU) screen派もいたはず」「自分は使ってないなー」

qt: Go言語とQtバインディングでマルチプラットフォームアプリ


github.com/therecipe/qt/wiki/Galleryより

★3200超えです。WidgetとQMLのどちらでも動きます。
これが本当なら、同一ソースからWin/Mac/iOS/Androidなど向けアプリを一気にビルドできますね。Qtの商用ライセンス料と、Store登録の面倒臭さが何とかなるといいのですが。


つっつきボイス: 「Qtってキューティーじゃなくてキュートって発音するんじゃなかったでしたっけ?」「あー、そうでした(今初めて発音した…)」

ちょっと手元で動かしてみようと思ったのですがまだサンプルをセットアップできていません。QtのSDK削除するんじゃなかった…

primitive: 画像を幾何学図形の集まりで再現する(GitHub Trendingより)


github.com/fogleman/primitiveより

★8000近くあります。TechRachoの画像加工でも早速使っています。

shapecatcher.com: 手書きした文字に似ているUnicode文字をリストアップ

絵文字を絵で検索することもできます。

番外

仕事でしかコード書かない開発者ってどうよ?

記事というよりツイート並の短さですね。怒涛のようにレスが付いています。

どうしてこうなったんだっけ

プラセボは腰痛にも効く


つっつきボイス: 「そういえばデュアルディスプレイやめたら肩こり治ったんですよ」「マジで?!」「どうも首を左右に動かしていたのが肩によくなかったのかも」

おめでとうございます!


今週は以上です。

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

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

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

Ruby Weekly

RubyFlow

160928_1638_XvIP4h

Postgres Weekly

postgres_weekly_banner

Frontend Weekly

frontendweekly_banner_captured

Rubyのヒープをビジュアル表示する(翻訳)

$
0
0

概要

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

Rubyのヒープをビジュアル表示する(翻訳)

前回の記事では、Rubyのオブジェクトがどのようにメモリ上に展開されるかについて軽く触れました。そのときの情報を元に、今回はRubyヒープのダンプを取ってそのヒープの配置や断片化をビジュアル表示するプログラムを書くことにします。

Rubyオブジェクトのレイアウトをざっと復習

単なる復習: Rubyオブジェクトは固定幅です。つまり、あらゆるRubyオブジェクトのサイズは同一(40バイト)になります。オブジェクトは実際にはmallocで割り当てられるのではなく、ページの内部に配置されます。

1つのRubyプロセスには多数のページが含まれ、1つのページには多数のオブジェクトが含まれます。

このオブジェクトはどのページに属するのか?

多くのオブジェクトが1つのページに割り当てられます。各ページは2^14バイト(訳注: 16,384バイト)です。複数のRubyオブジェクトは同時に割り当てられるのではなく、GCが1つのページ(アリーナとも呼ばれます)を割り当てます。

ページのサイズは正確な2^14バイトではありません。あるページを割り当てるとき、OSのメモリページに沿ってページを配置したいので、mallocのトータルサイズは4 KB(OSのページサイズ)の倍数よりやや小さい値にする必要があります。mallocシステムコールには若干オーバーヘッドがあるため、連続するOSページにRubyのページを隙間なく収納できるよう、実際にmallocするサイズを総量から差し引かなければなりません。paddingに使うサイズはsizeof(size_t) * 5なので、1ページの実際のサイズは(2 ^ 14) - (sizeof(size_t) * 5)になります。

各ページには、ページ情報の一部を含むヘッダが1つずつあります。ヘッダのサイズはsizeof(void *)です。

つまり、1つのページに保存できるRubyオブジェクトの最大サイズは((2 ^ 14) - (sizeof(size_t) * 5) - sizeof(void *)) / 40になります。

1ページあたりのオブジェクト数には上限があるため、1つのRubyオブジェクトのアドレスの下位14ビットにビットマスクを適用し(ページサイズは2^14バイトなので、言い換えると14ビットシフトして1ビット残ります)、オブジェクトが実際に配置されるページを算出します。そのビットマスクは~0 << 14です。

あるRubyオブジェクトのアドレスが0x7fcc6c845108の場合、バイナリをASCIIアートで表すと以下のようになります。

11111111100110001101100100001000101000100001000
^---------- ページ アドレス --------^- object id ^

上図の「object id」の部分は、昔ながらのRuby object idではなく、単にそのページ上の個別のオブジェクトを表すビットの一部です。アドレス全体は昔ながらの「object id」と考えられます。

これらの数値をRubyのコードに切り出してみましょう。

require 'fiddle'

SIZEOF_HEAP_PAGE_HEADER_STRUCT = Fiddle::SIZEOF_VOIDP

SIZEOF_RVALUE           = 40
HEAP_PAGE_ALIGN_LOG     = 14
HEAP_PAGE_ALIGN         = 1 << HEAP_PAGE_ALIGN_LOG      # 2 ^ 14
HEAP_PAGE_ALIGN_MASK    = ~(~0 << HEAP_PAGE_ALIGN_LOG)  # ページアドレス取得用マスク
REQUIRED_SIZE_BY_MALLOC = Fiddle::SIZEOF_SIZE_T * 5     # mallocで必要なpadding
HEAP_PAGE_SIZE          = HEAP_PAGE_ALIGN - REQUIRED_SIZE_BY_MALLOC # 実ページサイズ
HEAP_PAGE_OBJ_LIMIT     = (HEAP_PAGE_SIZE - SIZEOF_HEAP_PAGE_HEADER_STRUCT) / SIZEOF_RVALUE

先ほど触れた部分を改めて説明します。Rubyページは、mallocで隙間なく配置されます。言い換えると、あるRubyページが割り当てられるときのアドレスは2^14で割ることができ、ページのサイズは2^14よりごくわずか小さくなります。

それでは、あるオブジェクトアドレスを渡すと、そのオブジェクトが配置されたページのアドレスを返す関数を書いてみましょう。

def page_address_from_object_address object_address
  object_address & ~HEAP_PAGE_ALIGN_MASK
end

それでは3つのオブジェクトアドレスのページアドレスを出力してみます。

p page_address_from_object_address(0x7fcc6c8367e8) # => 140515970596864
p page_address_from_object_address(0x7fcc6c836838) # => 140515970596864
p page_address_from_object_address(0x7fcc6c847b88) # => 140515970662400

この出力から、最初の2つのオブジェクトは同じページにあるが、3番目のオブジェクトは別のページにあることがわかります。

このページにオブジェクトはいくつあるか?

Rubyオブジェクトもアライン(align)されますが、既存のページの内部でアラインされます。アラインされるのは40バイト目(これはそのオブジェクトのサイズでもあります)。つまり、あらゆるRubyオブジェクトが持つ各アドレスはすべて40で割れることが保証されます(これは、数値のようにヒープに割り当てられないオブジェクトについては真ではありません)。

Rubyオブジェクトは決して(訳注: OSによって)割り当てられず、割り当て済みの1つのページ内部に置かれます。そのページは2^14に沿ってアラインされますが、2^14で割れるすべての数が40でも割れるとは限りません。つまり、あるページには他のページよりも多くのオブジェクトが保存される場合があるということです。40でも割れるページには、そうでないオブジェクトより1つ多くオブジェクトが保存されます。

ページアドレスを渡すと、そこに保存できるオブジェクトの数とオブジェクトの場所を算出し、ページの情報を表すオブジェクトを1つ返す関数を書いてみましょう。

Page = Struct.new :address, :obj_start_address, :obj_count

def page_info page_address
  limit = HEAP_PAGE_OBJ_LIMIT # ページあたりの最大オブジェクト数

  # ページには情報を持つヘッダーが1つあるので、その分も考慮する
  obj_start_address = page_address + SIZEOF_HEAP_PAGE_HEADER_STRUCT

  # オブジェクトの開始アドレスがRubyオブジェクトのサイズで割り切れない場合、
  # SIZEOF_RVALUEで割り切れる最初のアドレスを見つけるのに必要な
  # paddingの算出が必要
  if obj_start_address % SIZEOF_RVALUE != 0
    delta = SIZEOF_RVALUE - (obj_start_address % SIZEOF_RVALUE)
    obj_start_address += delta # Move forward to first address

    # このページに実際に保存されているオブジェクト数を算出
    limit = (HEAP_PAGE_SIZE - (obj_start_address - page_address)) / SIZEOF_RVALUE
  end

  Page.new page_address, obj_start_address, limit
end

これでオブジェクトが保存されているページの情報を得られるようになったので、先の例で使ったオブジェクトアドレスのページ情報を調べてみましょう。

page_address = page_address_from_object_address(0x7fcc6c8367e8)
p page_info(page_address)
# => #<struct Page address=140515970596864, obj_start_address=140515970596880, obj_count=408>

page_address = page_address_from_object_address(0x7fcc6c836838)
p page_info(page_address)
# => #<struct Page address=140515970596864, obj_start_address=140515970596880, obj_count=408>

page_address = page_address_from_object_address(0x7fcc6c847b88)
p page_info(page_address)
# => #<struct Page address=140515970662400, obj_start_address=140515970662440, obj_count=407>

同じページにある最初の2つのオブジェクトでは、そのページに408個のオブジェクトを保存できます。3番目のオブジェクトは別のページにあり、そのページには407個のオブジェクトしか保存できません。

それらしく見えないかもしれませんが、ヒープの内容をビジュアル表示するのに必要となる、重要な情報の断片はこれですべて揃いました。

データ取得

あるヒープをビジュアル表示するには、実際にビジュアル表示するためのヒープが必要です。ObjectSpaceを使ってヒープをJSONファイルにダンプし、上のコードとJSONパーサー、そしてChunkyPNGを用いてグラフを生成します。

次がテストプログラムです。

require 'objspace'

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

ここで行っているのは、大量のオブジェクト割り当てとGCの後、heap.jsonというJSONファイルにヒープをダンプするだけです。JSONドキュメントの各行はRubyヒープの1つのオブジェクトに相当します。

今度はJSONファイルを処理するプログラムを書きましょう。ここでは、ページ内にあるオブジェクトをトラックできるようにPageクラスを変更し、JSONドキュメント全体を列挙して、各オブジェクトを対応するページに追加します。

class Page < Struct.new :address, :obj_start_address, :obj_count
  def initialize address, obj_start_address, obj_count
    super
    @live_objects = []
  end

  def add_object address
    @live_objects << address
  end
end

# ページをトラックする
pages = {}

File.open("heap.json") do |f|
  f.each_line do |line|
    object = JSON.load line

    # rootをスキップ(今日はやりたくないので:)
    if object["type"] != "ROOT"
      # オブジェクトのアドレスは基数16で文字列として保存される
      address      = object["address"].to_i(16)

      # ページのアドレスを取得する
      page_address = page_address_from_object_address(address)

      # ページを取得するか新しいページを作成する
      page         = pages[page_address] ||= page_info(page_address)

      page.add_object address
    end
  end
end

ヒープをビジュアル表示する

これで、処理プログラムによってオブジェクトは自身が所属するページごとに分割されました。今度はこのデータをヒープのビジュアル表示に変えましょう。残念なことに、ここでは小さな問題が1つあります。ヒープのダンプから得られる情報は、システムで実際に生存しているオブジェクトの情報です。ヒープの空白領域をどうやってビジュアル表示すればよいのでしょうか。

ヒープの空白部分の割り出しに使える情報が少しばかりあります。1つ目はオブジェクトのアドレスが40で割り切れるということ、2つ目はストレージの最初のアドレスを取得できること(Page#obj_start_address)。3つ目は1つのページに保存できるオブジェクト数を取得できること(Page#obj_count)です。そこで、obj_start_addressから開始してSIZEOF_RVALUEずつ増やせば、JSONファイルから読み取ったアドレスが存在するかどうかがわかるはずです。JSONファイルからアドレスを読み取れれば、それは生存しているオブジェクトであることがわかります。読み取れなければ、そこは空白のスロットということになります。

それでは、ページ上で取得可能なオブジェクトアドレスをすべて列挙するメソッドをPageオブジェクトに1つ追加しましょう。:fullがyieldされたらオブジェクトは存在し、:emptyがyieldされたらオブジェクトは存在しません。

class Page < Struct.new :address, :obj_start_address, :obj_count
  def each_slot
    return enum_for(:each_slot) unless block_given?

    objs = @live_objects.sort

    obj_count.times do |i|
      expected = obj_start_address + (i * SIZEOF_RVALUE)
      if objs.any? && objs.first == expected
        objs.shift
        yield :full
      else
        yield :empty
      end
    end
  end
end

これで、ページからページへ空白スロットをすべてのスロットから区別できるようになりました。ChunkyPNGでPNGファイルを生成しましょう。PNGの各カラムは1つのページを表し、各ページ内の2×2ピクセルの正方形は1つのオブジェクトを表します。オブジェクトが存在する場合はオブジェクトを赤く塗り、空白の場合はそのままにします。

require 'chunky_png'

pages = pages.values

# オブジェクトを2x2ピクセルの正方形で表すので、
# PNGの高さはオブジェクトの最大数の2倍になり、
# 幅はページ数の2倍になる
height = HEAP_PAGE_OBJ_LIMIT * 2
width = pages.size * 2

png = ChunkyPNG::Image.new(width, height, ChunkyPNG::Color::TRANSPARENT)

pages.each_with_index do |page, i|
  i = i * 2

  page.each_slot.with_index do |slot, j|
    # スロットが埋まっている場合は赤くする
    if slot == :full
      j = j * 2
      png[i, j] = ChunkyPNG::Color.rgba(255, 0, 0, 255)
      png[i + 1, j] = ChunkyPNG::Color.rgba(255, 0, 0, 255)
      png[i, j + 1] = ChunkyPNG::Color.rgba(255, 0, 0, 255)
      png[i + 1, j + 1] = ChunkyPNG::Color.rgba(255, 0, 0, 255)
    end
  end
end

png.save('heap.png', :interlace => true)

このコードを実行後、heap.pngというファイルが出力されるはずです。私が生成したファイルは次のとおりです。

この例ではヒープがすべて埋まっているので今ひとつです。今度は比較的空のプロセスからヒープをダンプして様子を見てみましょう。

$ ruby -robjspace -e'File.open("heap.json", "wb") { |t| ObjectSpace.dump_all(output: t) }'

このヒープを処理すれば、次のような出力になります。

これでおしまいです。お読みいただきありがとうございました。

完全なコードはここにアップしています。

<3<3<3<3<3

関連記事

Rubyのメモリ割り当て方法とcopy-on-writeの限界(翻訳)

Rails: Puma/Unicorn/Passengerの効率を最大化する設定(翻訳)

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

[インタビュー] Aaron Patterson(後編): Rack 2、HTTP/2、セキュリティ、WebAssembly、後進へのアドバイス(翻訳)

Viewing all 1079 articles
Browse latest View live