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

Rails5「中級」チュートリアル(3-5)投稿機能: 単一の投稿(翻訳)

$
0
0

概要

概要

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

Rails5中級チュートリアルはセットアップが短めで、RDBMSにはPostgreSQL、テストにはRSpecを用います。
原文が非常に長いので分割します。章ごとのリンクは順次追加します。

注意: Rails中級チュートリアルは、Ruby on Railsチュートリアル(https://railstutorial.jp/)(Railsチュートリアル)とは著者も対象読者も異なります。

目次

Rails5「中級」チュートリアル(3-5)投稿機能: 単一の投稿(翻訳)

新しいブランチに切り替えます。

git checkout -b single_post

単一の投稿を表示する

現時点では、show.html.erbテンプレートやそれに対応するコントローラアクションがないので、I'm interestedボタンをクリックするとエラーが表示されます。このボタンをクリックしたら、選択した投稿のページにリダイレクトされるようにしたいと思います。

PostsControllerの内部にshowアクションを作成します。ここでクエリをかけて特定のpostオブジェクトをインスタンス変数に保存します(Gist)。

# controllers/posts_controller.rb
  def show
    @post = Post.find(params[:id])
  end

I'm interestedボタンは、選択した投稿へのリダイレクトを行います。このボタンのhref属性には投稿へのパスが保存されます。投稿を1件取得するためのGETリクエストを送信すると、Railsはshowアクションを呼び出します。このshowアクションの中ではidパラメータ(params)にアクセスします。このidは、特定の投稿を1件取得するためのGETリクエストからのものです。たとえば、ブラウザで/posts/1パスにアクセスすると、id1の投稿を取得するリクエストが送信されます。

portsディレクトリの下にshow.html.erbテンプレートを作成します。

views/posts/show.html.erb

このファイルに以下のコードを追加します(Gist)。

<!-- views/posts/show.html.erb -->
<div id="single-post-content" class="container">
  <div class="row">
    <div class="col-sm-6 col-sm-offset-3">
      <div class="posted-by">Posted by <%= @post.user.name %></div>
      <h3><%= @post.title %></h3>
      <p><%= @post.content %></p>
    </div>
  </div><!-- row -->
</div>

postsディレクトリの下にshow.scssファイルを作成し、以下のCSSを追加してページのスタイルを整えます(Gist)。

// assets/stylesheets/partials/posts/show.scss
#single-post-content {
  background: white;
  height: calc(100vh - 50px);

  h3 { 
    text-align: center;
  }
  p {
    margin: 50px 0;
  }
  .posted-by {
    font-size: 12px;
    font-size: 1.2rem;
    margin: 20px 0;
    color: rgba(0,0,0,0.5);
  }
}

ここではページの高さを100vh-50pxで定義しているので、ページのコンテンツの高さはビューポート(viewport)の高さいっぱいになります。これにより、要素内のコンテンツの量が多くても少なくても、このコンテナの色はブラウザの高さいっぱいまで白になります。vhは「ビューポートの高さ」を表すので、100vhという値を指定すると要素はビューポートの高さの100%まで引き伸ばされます。ナビゲーションバーの高さを100vh-50pxから引いておく必要があります。そうしておかないと、コンテナの高さが50px余分に伸びてしまいます。

これで、I'm interestedボタンをクリックするとページがリダイレクトされて以下のように表示されます。

show.html.erbテンプレートには後で他にも機能を追加しますので、今は変更をcommitしておきましょう。

git add -A
git commit -m "Create a show template for posts

- Add a show action and query a post to an instance variable
- Create a show.scss file and add CSS"

spec

こういうモーダルウインドウや選択した投稿のリダイレクトといった機能が正常に動くかどうかのチェックは、手動で行わず、すべてspecに含めておきましょう。capybaraを使ってユーザーのアプリ操作をシミュレートすることにします。

featuresディレクトリの下にpostsディレクトリを作成します。

spec/features/posts

このディレクトリにvisit_single_post_spec.rbファイルを作成し、feature specをそこに追加します。追加後のファイルは以下のようになります(Gist)。

# spec/features/posts/visit_single_post_spec.rb
require "rails_helper"

RSpec.feature "Visit single post", :type => :feature do
  let(:user) { create(:user) }
  let(:post) { create(:post) }

  scenario "User goes to a single post from the home page", js: true do
    post
    visit root_path
    page.find(".single-post-card").click
    expect(page).to have_selector('body .modal')
    page.find('.interested a').click
    expect(page).to have_selector('#single-post-content p', text: post.content)
  end

end

ここでは、手動での操作手順をすべて定義しています。最初にhomeページを開き、投稿をクリックすると、モーダルウインドウがポップアップすることを期待(expect)しています。I'm interestedボタンをクリックすると、投稿ページにリダイレクトしてコンテンツが表示されることを期待しています。

RSpecのhave_selectorhave_cssなどのマッチャーは、ある要素がユーザーに実際に見える状態になっているとデフォルトでtrueを返します。したがって、投稿でクリックした後にテスティングフレームワークはモーダルウインドウが表示されることを期待します。ある要素がユーザーに見えるかどうかは問題でない場合や、要素がDOMにあるかどうかだけを知りたい場合は、visible: false引数を追加します。

テストを実行してみましょう。

rspec spec/features/posts/visit_single_post_spec.rb

変更をcommitします。

git add -A
git commit -m "Add a feature spec to test if a user can go to a
single post from the home page"

single_postブランチをmasterにmergeします。

git checkout master
git merge single_post
git branch -D single_post

関連記事

新しいRailsフロントエンド開発(1)Asset PipelineからWebpackへ(翻訳)


Rails: Form Objectと`#to_model`を使ってバリデーションをモデルから分離する(翻訳)

$
0
0

概要

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

タイトルは内容に即したものに変えました。

Rails: Form Objectと#to_modelを使ってバリデーションをモデルから分離する(翻訳)

Rails 5.2リリースノートをひととおり読んでみて、ActiveStorageなどに興味を惹かれたので、試してみたくなりました。本記事ではプレリリース版を用いてシンプルなアプリをビルドしました。

目的は、ユーザーがアンケート(questionnaire)を作成して結果を回収できるアプリを作成することです。最初にForm Objectを用いて、アンケートのタイトルと質問リストを取得します。

# db/migrate/create_questionnaires.rb
create_table "questionnaires", force: :cascade do |t|
  t.string "title"
  t.string "questions", default: [], null: false, array: true
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
end
# app/forms/new_questionnaire_form.rb
class NewQuestionnaireForm
  include ActiveModel::Model

  attr_accessor :title, :questions

  validates :title, presence: true

  def save
    Questionnaire.new(title: title, questions: questions).save
  end
end

このように書いてみたかった理由は、Ectoを見たときに、バリデーションをモデルから切り離せるのがとても便利だと思えたからです(条件付きバリデーションが不要になります!)。

コントローラはいたってシンプルで、scaffoldしたコントローラと大差ありません。以下はnewアクションとcreateアクションです。

# app/controllers/questionnaires_controller.rb
class QuestionnairesController < ApplicationController
  def new
    @questionnaire = NewQuestionnaireForm.new
  end

  def create
    @questionnaire = NewQuestionnaireForm.new(questionnaire_params)

    if @questionnaire.save
      redirect_to @questionnaire, notice: 'Questionnaire was successfully created.'
    else
      render :new
    end
  end
end

ビューでは、新しいform_withヘルパーを次のように使っています。

<!-- app/views/questionnaires/_form.html.erb -->
= form_with(model: @questionnaire, local: true) do |form|
  = form.label :title
  = form.text_field :title

アプリを起動してquestionnaires/newにアクセスしてみると、undefined method 'new_questionnaire_forms_path' for ...エラーメッセージが表示されました…。

うう残念。Railsは、form_withに渡したForm Objectのクラス名を受け取ると、対応するコントローラへのパスであると自動的に推論しますが、コントローラの名前だけが合っていません。

この修正方法はいくつか考えられます。form_withヘルパーの投稿先のURLを上書きする方法もあれば、Form Objectの#model_nameを上書きして、今扱っているのがQuestionnaireであるかのように見せかける方法もあります(この方法が有用なこともありますが、この状況ではダーティハックの恐れがあります)。

もっとよい方法を見つけるために、先ほどのForm Objectに立ち戻りましょう。このForm Objectは、Questionnaireという単一のオブジェクトだけを扱っています。しかも#saveメソッドでQuestionnaireを作成しています。先のForm Objectからモデルへの変換をシャドウイング(shadowing)として表すことができそうです。そしてActiveModel::Conversion#to_modelというメソッドがあることに気が付きました。このメソッドを使うようForm Objectを書き直すと次のようになります。

class NewQuestionnaireForm
   include ActiveModel::Model

  def to_model
    Questionnaire.new(title: title, questions: questions)
  end

  def save
    to_model.save
  end
end

questionnaires/newにアクセスすると、今度はちゃんとフォームが表示されます。タイトルを入力して[Submit]ボタンを押すと、データベースにquestionnaireのモデルが新しく作成されます。できました!

関連記事

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

Rails: dry-rbでForm Objectを作る(翻訳)

週刊Railsウォッチ(20180302)Ruby 2.6.0-preview1とWebpack 4.0リリース、爆速検索APIサービスAlgolia、Clowneでモデルをクローンほか

$
0
0

こんにちは、hachi8833です。Ruby生誕25周年おめでとうございます!

3月最初のウォッチ、いってみましょう。最近社内での記事つっつき会が盛り上がっていて嬉しい限りです。

Rails: 今週の改修

Rails 5.2はまだ動いています。「2月中に出したい」は叶いませんでした。以下は5.2のcommitからです。

紀元前の日付の扱いをPostgreSQLに合わせた

# activemodel/lib/active_model/type/date.rb#L44
         def new_date(year, mon, mday)
-          if year && year != 0
+          unless year.nil? || (year == 0 && mon == 0 && mday == 0)
             ::Date.new(year, mon, mday) rescue nil
           end
         end

つっつきボイス: 「出た〜紀元前!」「どこに使うんだろうか」「は〜ん、年を-4みたいにできるようになったのか↓」

# activerecord/test/cases/adapters/postgresql/date_test.rb#L31
+  def test_bc_date_leap_year
+    date = Time.utc(-4, 2, 29).to_date
+    topic = Topic.create!(last_read: date)
+    assert_equal date, Topic.find(topic.id).last_read
+  end

PostgreSQLのdatedatetime同様に無限値を扱えるようにした

# activerecord/lib/active_record/connection_adapters/postgresql/oid/date.rb
+# frozen_string_literal: true
+
+module ActiveRecord
+  module ConnectionAdapters
+    module PostgreSQL
+      module OID # :nodoc:
+        class Date < Type::Date # :nodoc:
+          def cast_value(value)
+            case value
+            when "infinity" then ::Float::INFINITY
+            when "-infinity" then -::Float::INFINITY
+            else
+              super
+            end
+          end
+        end
+      end
+    end
+  end
+end

つっつきボイス: 「どゆこと?」「PostgreSQLって'-infinity'::dateって書き方できるとは」「さすがぽすぐれ: 知らないと使わないだろうけど」「マイナスのInfinityもあると: そりゃそうだ」

# activerecord/test/cases/adapters/postgresql/date_test.rb#L6
+class PostgresqlDateTest < ActiveRecord::PostgreSQLTestCase
+  def test_load_infinity_and_beyond
+    topic = Topic.find_by_sql("SELECT 'infinity'::date AS last_read").first
+    assert topic.last_read.infinite?, "timestamp should be infinite"
+    assert_operator topic.last_read, :>, 0
+
+    topic = Topic.find_by_sql("SELECT '-infinity'::date AS last_read").first
+    assert topic.last_read.infinite?, "timestamp should be infinite"
+    assert_operator topic.last_read, :<, 0
+  end

「そういえばMySQLのdateは確か内部でstringになってて、マイナスすると9999みたいに9並びになっちゃう」「えー」「PostgreSQLは確か型チェックか何かをやってて、9999はまだしも9999-99みたいなのになるとエラーになったと思う」

参考: MySQL 5.6マニュアル 11.3.1 DATE、DATETIME、および TIMESTAMP 型

DATETIME 値の範囲は ‘1000-01-01 00:00:00.000000’ から ‘9999-12-31 23:59:59.999999’ であり

MemCacheとRedisでローカルキャッシュのread_multifetch_multiをサポート

# activesupport/lib/active_support/cache/strategy/local_cache.rb#L123
+          def read_multi_entries(keys, options)
+            return super unless local_cache
+
+            local_entries = local_cache.read_multi_entries(keys, options)
+            missed_keys = keys - local_entries.keys
+
+            if missed_keys.any?
+              local_entries.merge!(super(missed_keys, options))
+            else
+              local_entries
+            end
+          end


つっつきボイス: 「RedisとかMemCacheのAPIで1個ずつアクセスするより一括で取ってくる方が速いんで、そうなるように拡張系の命令を使ったと: まあ書いてあるとおりですねw」

シリアライズのinclude:が効かなくなることがある問題を修正

# activemodel/lib/active_model/serialization.rb#L180
         unless includes.is_a?(Hash)
-          includes = Hash[Array(includes).map { |n| n.is_a?(Hash) ? n.to_a.first : [n, {}] }]
+          includes = Hash[Array(includes).flat_map { |n| n.is_a?(Hash) ? n.to_a : [[n, {}]] }]
         end

つっつきボイス: 「ネステッドなシリアライズかー」「ま、自分はシリアライズでJSONにしちゃうこと多いけど: ハッシュをシリアライズするよりJSONにしとけばRuby以外でも使えるし」「確かにー」

RuboCopの設定でFoo::methodスタイルを禁止に

# .rubocop.yml#L159
+# Prefer Foo.method over Foo::method
+Style/ColonMethodCall:
+  Enabled: true

RuboCopのデフォルト設定ではメソッド呼び出しの::記法は禁止されていないんですね。


つっつきボイス:YAML::loadみたいな記法見たことあったな」「クラスメソッドならこの呼び方も一応できる」

::は、定数(クラスやモジュールも含む)、コンストラクタ(Array()Nokogiri::HTML()など)の参照にのみ使う
Rubyスタイルガイドを読むより


Rubyスタイルガイドを読む: 文法(1)メソッド定義、引数、多重代入

Rails 6ではUTF-8エンコーディングを強制しなくなる

ここからはRails 6のmasterブランチです。

主要なWebサイトのほとんどでTLS 1.0が無効になったことで、IE8以下のブラウザの利用がどんどん困難になった。他のブラウザには影響しないので、UTF-8エンコーディングはデフォルトでは強制しないことにする。
Andrew White


つっつきボイス: 「『これでShift_JISで書けるぜ!』なんてことにはならないだろうけどね」「nilで『強制しない』という意味なのか↓」

# actionview/lib/action_view/railtie.rb#L12
+    config.action_view.default_enforce_utf8 = nil

「昔話になっちゃうけど、ガラケー時代のエンコーディング周りはほんと地獄でしたねー: キャリアに応じてShift_JISで出力したりEUC-JPで出力したりとか、キャリアごとに顔文字変換するとか」「音符がうんこになるバグなんてのもありましたね」「そもそも携帯ネットワークはインターネットでもなければIPネットワークすらないし、RFC準拠とかなかったし」「ひえ〜」「メアドをRFC準拠の正規表現で処理したらユーザーからメアドが通らなくなったって苦情が来たり: 数字で始まったりドット2つ重ね..みたいな非RFCなメアドが使われちゃってた」

参考: 【PHPで作る】初めての携帯サイト構築 – 第3回 携帯サイトの文字コードに気をつける

新機能: "rails routes --expandedを追加

$ rails routes --expanded
--[ Route 1 ]------------------------------------------------------------
Prefix            | high_scores
Verb              | GET
URI               | /high_scores(.:format)
Controller#Action | high_scores#index
--[ Route 2 ]------------------------------------------------------------
Prefix            | new_high_score
Verb              | GET
URI               | /high_scores/new(.:format)
Controller#Action | high_scores#new
--[ Route 3 ]------------------------------------------------------------
Prefix            | blog
Verb              |
URI               | /blog
Controller#Action | Blog::Engine

[ Routes for Blog::Engine ]
--[ Route 1 ]------------------------------------------------------------
Prefix            | cart
Verb              | GET
URI               | /cart(.:format)
Controller#Action | cart#show

こんな感じで出せるそうです。


つっつきボイス: 「これうれしい!」「Route 1はちょいダサかな」「いつものrails routesだとURLがめちゃ長くなりますからね」

新機能: ActiveModel::Attributes#attributesを追加

# activemodel/lib/active_model/attributes.rb#L69
+    def attributes
+      @attributes.to_hash
+    end

つっつきボイス: 「アトリビューツアトリビューツw」「何という名前」「その実態は#to_hashと: ということはattributesはハッシュじゃないのかな」「あと#to_hashすることでオブジェクトが別になるからdupしていることになる」

システムテストのスクショのパスを絶対パスに変更

ログからすぐ開くのに便利だからだそうです。

# actionpack/lib/action_dispatch/system_testing/test_helpers/screenshot_helper.rb#L46
           def image_path
-            @image_path ||= absolute_image_path.relative_path_from(Pathname.pwd).to_s
+            @image_path ||= absolute_image_path.to_s
           end

つっつきボイス: 「こういう変更は地味に大事!マジで」「Pathname.pwdもUnixのシェルコマンドそのまんま」「先週のウォッチで出たrm_rfみたい」

Rails

Rails 5.2.0の良スライド3本立て


つっつきボイス: 「TechRachoでも5.2.0をまとめようと思ってたんですが、重要な部分はこのスライド3本でほぼ網羅されているんで、もういいかなと」「いいと思いまーす」「『ここを見ろ、以上』ということでー」

そのまま一同でしばしスライドつっつきになだれ込みました。

apply_join_dependencyかー」「association scopeのJOINが無視されてた話とか、つらいなー」

Webpack 4.0がリリース


auth0.comより


  • Node JS 4のサポート廃止: 今後はNode >= 8.9.4で
  • さよならCommonsChunkPlugin、こんにちはSplitChunksPlugin
  • WebAssemblyをサポート
  • javascript/autoなどのModule Typeをサポート
  • developmentモードとproductionモードを導入
  • webpack.config.jsでのエントリポイント定義が不要に
  • ビルドの高速化
  • プラグインシステムの刷新
  • コマンド体系がwebpck-cliに全面移行(webpack-cliは今後インストール必須)
  • NoEmitOnErrorsPluginなどの非推奨化

つっつきボイス: 「Node JS 4のサポート廃止は割りと大きいかも: これ系に依存してるのって結構あった気がする」「Zapierは”node.js v4.3.2″ってなってますね」「WebAssemblyもいいな」

書籍『99 Bottles of OOP』


sandimetz.comより

名著『オブジェクト指向設計実践ガイド』でおなじみのSandi Metz女史の新刊が昨年から発売されています。ちょうど翻訳まで終えた記事「Why We Argue: Style — Sandi Metz」の末尾の広告で知りました。セールに気づけばよかった…

同書の章タイトルだけ自分用に雑に訳してみたのですが、場所をとるのでGistに置いてみました。

なお、同書のタイトルは以下ののもじりです。宴会で一気飲みを煽るときに歌うんでしょうか。


つっつきボイス: 「↑ジャケがいい」「酔っぱらいの歌ですな」
「前作『オブジェクト指向設計実践ガイド』は手続き脳が抜けきれてない私にはすごくよかったんで、くやしいけどこの本も定価で買っちゃいました」「ワタイも買ってたのに後忘れてたー」「そういえばJavaのインターフェースって特異だから、あの考え方を前提にするとRubyではどう適用したらいいのか最初よくわかんないかもしれないですね」「それはあるかも」
「たとえばPHPやPerlみたいな世界からRuby脳に変わるときにはオブジェクト指向設計ができてないといろいろ大変っすね: Rubyのライブラリがそもそもオブジェクト指向全開なものが多いし」「とにかくこういう本は一冊読んでおくべき」

参考:

ActiveRecord.no_touchingでtouchingを一時的に無効化する(RubyFlowより)

# 同記事より
user = User.find(user_id)

ActiveRecord::Base.transaction do
  User.no_touching do
    user.photos.find_each do |photo|
      # userはtouchされない
      photo.update!(some_attributes)
    end
  end

  user.touch
end

つっつきボイス: 「む、サイトが開かない」「落ちてるかな?」

今は動いています。

参考: Rails API no_touching

KubernetesデプロイのためにRailsをリファクタリングした(RubyFlowより)

# 同記事より
require 'yaml'
require 'erb'

class ConfigParser
  def self.parse(file, environment)
    YAML.load(ERB.new(IO.read(file)).result)[environment]
  end
end

つっつきボイス: 「Kubernetesはこれだけconfigしないといけない↓のが大変: それぞれenvをどうやって注入するかとか」「そういうコンテナサービスについて気を付けるべき点は例の超定番『The Twelve-Factor App』で言われてますね」

  • ローカルアプリ
  • Dockerコンテナを使うローカルアプリlocal app using docker containers
  • docker-composeを使うローカルアプリ
  • CapistranoをOpenStackとベアメタルサービスにデプロイ
  • Kubernetesからminikubeと実際のクラスタにデプロイ


12factor.netより

そういえばRuby25の会場でtagomorisさんが「クバネーティス」と発音していました。個人的には「クーベルネイテス」の方が何だかドイツ語っぽくてええかなという気もしたり(↓ギリシャ語由来だそうです)。

タイムゾーン関連記事2本(Hacklinesより)

いずれも短い記事です。

# 同記事1より
class ApplicationController
  around_filter :set_time_zone

  private

  def set_time_zone
    old_time_zone = Time.zone
    Time.zone = 'Asia/Kolkata' # current_user.time_zone
    yield
  ensure
    Time.zone = old_time_zone
  end
end
# 同記事2より
  def in_time_zone(zone = ::Time.zone)
    time_zone = ::Time.find_zone! zone
    time = acts_like?(:time) ? self : nil

    if time_zone
      time_with_zone(time, time_zone)
    else
      time || self.to_time
    end
  end

つっつきボイス: 「うんうん、マルチロケールのアプリでこういうのやるよね: 弊社のManga Rebornでもそういうコード書いた覚えある」「記事1はTime.zone使ってて、記事2はTime.zone使うなin_time_zone使えって言ってるー」
Time.zoneってクラス変数だった気がするんだけど、マルチスレッドだと死ぬやつじゃね?」「それともconfigか? configならスレッドスコープ」「あ、両方か↓: config.time_zoneTime.zone」「一時的に使うだけならTime.zoneでやるなということね」

# http://api.rubyonrails.org/classes/ActiveSupport/TimeZone.htmlより
# application.rb:
class Application < Rails::Application
  config.time_zone = 'Eastern Time (US & Canada)'
end

Time.zone      # => #<ActiveSupport::TimeZone:0x514834...>
Time.zone.name # => "Eastern Time (US & Canada)"
Time.zone.now  # => Sun, 18 May 2008 14:30:44 EDT -04:00

参考: Rails API in_time_zone

外部キーなしでActiveRecord関連付けを行う(RubyFlowより)

# 同記事より
def find_target
  # ... あまり関係ないコードも混じってるのであしからず ...
  sc = reflection.association_scope_cache(conn, owner) do
    StatementCache.create(conn) { |params|
      as = AssociationScope.create { params.bind }
      target_scope.merge(as.scope(self, conn)).limit(1)
    }
end

つっつきボイス:unscope?」「unscopeは割りと使いますよ: デフォルトスコープのorderだけ消したりとか」「今はreorderとかあるけど」

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

rails_refactor: 名前リファクタリングgem(Hacklinesより)


つっつきボイス: 「あー、これコントローラ名をリファクタリングするgemか」「IDEの機能一発でできるかなと思ったんですが」「できないんですねこれが: routes.rbとかビューとかあちこちリネームが必要になるんで地味ーに面倒」「ときどき直し漏れがあったり: でgit commitするときに気づくと」
「まーGemfileに追加してまでやることかな?とは思う」「使い捨てですかね」「でもこの機能がRailsに標準で取り込まれるならわかるし、あったらすごくうれしい: rails generate renameみたいな感じで」「あー!それ欲しいかもー!」

searchkick: ElasticsearchとRailsでインテリジェント検索(Awesome Rubyより)

ankaneさん作のgemです。

# 同リポジトリより

# モデル
class Product < ApplicationRecord
  searchkick
end

# インデックスにデータを追加
Product.reindex

# クエリ
products = Product.search("apples", fields: [:name])
products.each do |product|
  puts product.name
end

つっつきボイス: 「へーこういう曖昧マッチが効くのか↓」「Elasticsearchのハンドラ的なやつっぽいけど、結構何でもできそうに見える」

  • stemming – tomatoes matches tomato
  • special characters – jalapeno matches jalapeño
  • extra whitespace – dishwasher matches dish washer
  • misspellings – zuchini matches zucchini
  • custom synonyms – qtip matches cotton swab

Clowne: Rubyモデルをクローンするgem(RubyFlowより)


同記事より

# 同記事より
# app/cloners/order_cloner.rb
class OrderCloner < Clowne::Cloner
  include_association :additional_items
  include_association :order_items, scope: :available

  nullify :payed_at, :delivered_at

  finalize do |source, record, _params|
    record.promotion_id = nil if source.promotion&.expired?
    record.uuid = Order.generate_uuid
    record.total_cents = OrderCalculator.call(record)
  end
end

Clowneは「道化師」(clown)とクローンを掛けてますね。


つっつきボイス: 「ははぁ、上の図↑みたいにリレーションがチェインしているものをクリーンにクローンできるってことか」「たとえばユーザーアカウントの項目が複数のテーブルでできていて、そのアカウントをコピーしたい、でもリレーション先のマスターはコピーしたくない、とか」
「ところで他人が書いたdupとかって基本信じたくないというのは、ありますね」「あーたしかにdeep copy的なのは自分で実装しないとコワイw」「ActiveRecordに特化してそういう部分をやりやすくしてくれるgemなんでしょう、きっと」「複雑なやつをいっぱいクローンしないといけないときとかにはありがたいかも」
「そういえばJavaだとこの手のcloneメソッドは自分で書かないといけないようになってましたね」「ですですー」

Overcommitで各種静的解析を呼んでRubyコードをlintする(Random Rubyより)

Git Hookを使ってコミット時のフックでRuboCopなどを呼んでいます。



同リポジトリより


つっつきボイス: 「社内で使ってた人がいたのを思い出したので」「Overcommitは、基本入れた方がいいですね: CIが重いとつらくなるけど」「CIがfailしたらgit commitが失敗するようにしたり」「それはスバラシイ!」

「お、ちょうどいいところに: Overcommitって使ってみてどうでした?」「…みんなが使ってくれないと意味がないかも…」「あ確かに」「CI側をがっつり設定して、CIがfailするものをプッシュするとすごく怒られるようにしておけば、みんな自主的にこういうツールを使うようになるかも」

VAPIDでプッシュ通知

# 同記事より
# app.rb
post '/push' do
  Webpush.payload_send(
    message: params[:message]
    endpoint: params[:subscription][:endpoint],
    p256dh: params[:subscription][:keys][:p256dh],
    auth: params[:subscription][:keys][:auth],
    ttl: 24 * 60 * 60,
    vapid: {
      subject: 'mailto:sender@example.com',
      public_key: ENV['VAPID_PUBLIC_KEY'],
      private_key: ENV['VAPID_PRIVATE_KEY']
    }
  )
end

正直、プッシュ通知しようとするサイトはむかつきますが。


つっつきボイス: 「RFCだから一応標準」「Webプッシュで認証/承認周りをやってくれるのね」

参考: Web PushをFCMとVAPIDで認証してブラウザにプッシュ通知を送る

Proxyパターンを再考する(Hacklinesより)

#prependをProxyパターンで使うお話です。

# 同記事より
module Proxy
  require 'etc'

  # Dynamically re-creates receiver's class methods, intercepts calls 
  # to them and checks user before invoking parent code
  #
  # @param receiver the class or module which has prepended this module
  def self.prepended(receiver)
    obj_mthds = receiver.instance_methods - receiver.superclass.instance_methods
    obj_mthds.each do |m|
      args = receiver.instance_method(m).parameters # => [[:req, :x], [:req, :y]]
      args.map! {|x| x[1].to_s}
      Proxy.class_eval do
        define_method(m) do |*args|
          puts "*** intercepting method: #{m}, args: #{args}"
          raise "Unauthorised access!" unless Etc.getlogin == 'fred'
          super(*args)
        end
      end
    end
  end
end #module

class Account
  def initialize(balance)
    @balance = balance
  end

  def deposit(amount)
    @balance += amount
  end

  def withdraw(amount)
    @balance -= amount
  end

  def self.interest_rate_for(a_balance)
    a_balance > 10_000 ? '3.2%' : '5.5%'
  end

  prepend Proxy

end

つっつきボイス: 「変更前のコードの方、method_missingしてsendするとかエグいなー: Javaのひとたちはこういうコードが許せなかったりとか?」「いやーそこまではないかもー」「これは認証のプロキシなのか!: 権限がなければraiseして、権限があればデレゲートすると」「天才か?」

「で変更後のコード↑はというと、method_missingを使わずに、prependの中でclass_evaldefine_method使ってやると」「たしかにー: 変更後の方がキレイ」「とってもProxyパターン」

Ruby: Proxyパターンの解説(翻訳)

参考: Module#prepend

CertBot: Let’s Encrypt証明書をもっと楽に導入


certbot.eff.orgより: (CC EY

Let’s Encrypt Ruby on Rails and Nginxにしようかと思ってたのですが、記事中のこちらの方が気になったので。

主催しているEFFはElectronic Frontier Foundationという非営利団体です。


つっつきボイス: 「CertBotは有名っすね」「あれLet’s Encrpyptコマンドじゃなかったっけ?」「↓名前変わってた…」「ややこしいのう」

Certbot クライアント(旧・Let’s Encrypt クライアント)
https://letsencrypt.jp/command/より

「まあapt-cache searchしてあるほうを入れればいいかとw」「多分ディストリビューション公式のパッケージ使う方がそのdistroのapache/nginxバージョンにちゃんと合わせられてるので、機能的な問題とかない限り、無理に最新にこだわらない方が安心して使えそう」

参考: Wikipedia-ja 電子フロンティア財団

dev.toは記事のソーシャル画像をこうやって自動生成している(Hacklinesより)

# 同記事より
  def enter_urls
    urls.each do |url|
      3.times do
        enter_url_and_click_preview("#{url}?#{rand(10_000)}=#{rand(10_000)}")
      end
      enter_url_and_click_preview(url)
    end
  end

つっつきボイス: 「SEO業界では記事の冒頭に何か大きい画像を置いとくとアクセスが増えるみたいなのがありますね」「神社の御札みたいなw」「まあ確かにそういう画像があると視線の取っ掛かりにはなりそう」「文字ばっかりの記事よりはデザイン的な座りもよくなりそう」「randかよ↑」

Railsコンソールで便利なヘルパーメソッドを書く(RubyFlowより)

オーストラリアの方だそうです。

# 同記事より
module ConsoleMethods
  def load_jwt!
    token = ::JWT.create(user_id: 'USER_ID_HERE')
    RequestLocals.store[:jwt] = token.jwt
  end
end

つっつきボイス: 「へーConsoleMethodsを拡張するとRailsコンソールでいろいろやれるのか」「Railsコンソールをそこまで使うかどうかというのはあるけど、利用頻度が高いならこうやってヘルパー化しとくといいかも」

RailsをOracleに接続する手順(RubyFlowより)

# 同記事より
default: &default
  adapter: oracle_enhanced
  database: "(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(Host=localhost)(Port=1521))(CONNECT_DATA=(SID=xe)))"

development:
  <<: *default
  username: "myproject_development"
  password: "myproject_development"

test:
  <<: *default
  username: "myproject_test"
  password: "myproject_test"

production:
  <<: *default
  username: "myproject_production"
  password: "myproject_production"

oracle-enhancedアダプタはdb:createができないらしいので、SQLで直接やってます。


つっつきボイス: 「来たなオラクル: 考えたくないヤツ」

Algolia: 爆速リアルタイム検索APIサービス


algolia.comより

とりあえずデモはめちゃめちゃ速いです。


つっつきボイス: 「そのデモサイトで適当に映画のタイトルぶっこんでみてください」「おーマジ速いわこれ!」「しかもUIカッケ~」「日本語だとあんまり引っかからないかも?」
「こういうの真面目にスクラッチから設計して作ってこれだけの性能出すのはタイヘン」「Solrとかカテゴリテーブルとか色々設計してSQLをWHERE文なりGROUP文なりを地獄のような感じで設計するとか」「有料だけどこれ使って速くなるなら使ってもええんじゃね?と思う」「裏でElasticsearchとか動いてるのかな」


algolia.com/productより

redis-cell: Redisをコマンドラインで使える

とりあえず手元で動かしてみました。Java 8入れろと言われました。


つっつきボイス:libredis_cell.soを読み出せる」「soできる!」「これで差し替えっぽく使えるという感じかな」

MonitでRailsアプリを監視(RubyFlowより)


mmonit.com/monit/より

監視というとやっぱりイヌですね。


つっつきボイス: 「Monitなつかし!」「こないだSupervisor入れようと思ってたら環境古すぎてMonit入れたけどなっ」「Monitは定番、かつめちゃ歴史長い: いわゆるスーパバイザです」「↓こういう処理とか、やりたいと思うことはひととおり書ける優秀なヤツ」「configはところどころよくわからないけど」

<!-- 同記事より -->
<!-- config/deploy/shared/monit.erb -->

check host <%= fetch(:website_url) %> with address <%= fetch(:website_url) %>
  if failed
    icmp type echo count 5 with timeout 15 seconds
  then alert

Datadog: サイト監視・分析サービス


datadoghq.comより

いっぬがカワイイ。無料プランで5ホストまでできるそうです。


つっつきボイス: 「こっちの方がかわいかったので」「そこ!?」「このDatadogも定番: サービス監視/サイト監視にも使うし、メトリック監視にも使われる」

DHHとconcerns


同ツイートより


つっつきボイス: 「おー大量のconcern!」「mixinがとてもうまく書かれているとこうなる」「しかし1回しか使わないようなmixinをconcernにするかと言われると…」「ですねー」

「これ厳密にはconcernじゃないかもしれないっす: Basecampのディレクトリ構成って、app/の下にこの場合はRecordings(複数形)というディレクトリを作って、その下にこういうのが置かれていました」「へー!」「concernで置きまくるんじゃなくて、あるモデルの配下にそれ用のモジュールを入れるみたいなフォルダ構成なんで、それならまあありかなとは思いました」「あーモデル名と同じフォルダが別にあってその下に置くと: それならワカル」「つまりincludeだけどあちこちで使われるんじゃなくて、あくまでそのモデル用のmixinか」「RuboCopの『1クラス100行制限』で怒られないためにはこのぐらい分散しないと」「例のYouTubeの番組を見た限りではそんな感じでした」

「このincludeは互いの依存関係のあるなしに応じてきれいに書き分けられているからとても読みやすい」「ここまでキレイに書けたらスゴイ」「includeは順序変わると動かなくなったりしがちですね」

「ところでDHHのYouTubeチャンネル、早くも一週間ほど更新止まってますね(´・ω・`)」「こりゃスターウォーズばりに次回作待たされるかもww」

「あとDHHの書き方なんですが、もう全然TDDじゃないんですよ」「お?」「モデルのテストすら全部振る舞いベースで、unitテストを書かないんで、スゲーなーと」「まあ確かに、自分で書いていない、提供されている機能だけを使うんだったらそのunitテストってなくてもいいんじゃね?って思ったりするな」「おー」「実はunitテスト懐疑派w」「たとえばモデルをcreateするテストとかは必要だと思うけど、そういうのはunitテストというより機能テストに近いんじゃないかな」

ここまでいける


つっつきボイス: 「ちゃんとメンテするとこのぐらいになるという見本」「しかし20msecはスゴイ」

Ruby trunkより

Ripperの新機能使いたいからnobuさんのパッチ使わせて

irbでRipperを使いたいそうです。確か今のirbはRubyで書かれた独自パーサーでした。

参考: Rubyリファレンスマニュアル Ripper

Ruby 2.5.0の「逆順バックトレース」がつらすぎる

「Railsで開発していると、バックトレースが逆順だったりそうでないところがあって頭がおかしくなりそう」だそうです。


つっつきボイス: 「何で逆順にしたんだろね?」「コールスタックを登っていく感じを出したかったとか?」

Ruby

Ruby 2.6.0-preview1がリリース(Ruby公式ニュースより)

Ruby生誕25周年のその日にリリースされました。

  • JITで--jit-verbose=1を指定可能に
  • 以下を含む新メソッド
  • パフォーマンス向上
    • Proc#call
    • block.callにブロックパラメータを渡した場合
    • ブロック渡しのパフォーマンス全般
  • その他
    • $SAFEがプロセスグローバルになり、0を渡せるようになった
    • ERB.newの引数の変更

とりあえず記念写真。

SinatraベースのSmashingとWordPressとGitHubウィジェットでイケてるダッシュボードを作った(RubyFlowより)


同記事より

マジックコメント一発でRubyを最適化

# 同記事より
# frozen_string_literal: true

HASH = {
  "mike": 123
}

def getmike
  HASH["mike"]
end

コメント欄で「細かい点がちょっと違うんじゃ?」とツッコまれています。


つっつきボイス: 「Ruby 2.2以降はハッシュキーのstring literalを自動でfreezeするのかー」「String.newと空文字""リテラルのエンコーディングは違う…だと?↓String.new使わんけどなっ」「本編記事よりコメントの方が情報量多いかも」

String.new.encoding
# => #<encoding:ascii-8bit>

"".encoding
# => #<encoding:utf-8>

オブジェクト指向だと何ができるようになるか(Ruby Weeklyより)

これもSandi Metz女史のがっつり読み物系記事です。

Rack::Proxyクイックツアー(RubyFlowより)

Rack Proxyはちょっと工夫するといい感じになるそうです。

  • サブドメインベースで複数のアプリにパススルーする
  • 移動したページへの面倒なリダイレクトルールを扱うのに便利
  • 単一のAPIリクエストを複数のコンカレントバックエンドリクエストに分散し、結果をマージする
  • 認証/承認をリクエストのプロキシより優先的に内部の信頼できるバックエンドに無条件に投げる
  • あるドメインから別のバックエンドへのプロキシでCORSが複雑になるのを回避する

つっつきボイス: 「Rackミドルウェアでやるのは確かに簡単で便利: 裏を返すと、Nginxの設定がいかに死ぬほど特殊で面倒くさいかということでもある」「それこそApacheのmod_rewriteに匹敵する地獄感を味わえますよ」「sendmail.cfとか」

参考: Mozilla オリジン間リソース共有 (CORS)

Rubyで文字列の重複を削減する(Ruby Weeklyより)

# 同記事より
if ENV['RAILS_ENV'] != "production"
  exec "RAILS_ENV=production ruby #{__FILE__}"
end

require 'memory_profiler'

MemoryProfiler.report do
  # this assumes file lives in /scripts directory, adjust to taste...
  require File.expand_path("../../config/environment", __FILE__)

  # we have to warm up the rails router
  Rails.application.routes.recognize_path('abc') rescue nil

  # load up the yaml for the localization bits, in master process
  I18n.t(:posts)

  # load up all models so AR warms up internal caches
  (ActiveRecord::Base.connection.tables - %w[schema_migrations versions]).each do |table|
    table.classify.constantize.first rescue nil
  end
end.pretty_print

frozen_string_literal: true以外にもいくつかRubyの文字列のdupを防ぐ方法を試しています。


つっつきボイス: 「この+""って書き方↓いつも不思議でしょうがないんだよなー」「どうしてこうなった感」

buffer = String.new
buffer.encoding => Encoding::ASCII-8BIT

# vs 

# String @+ is new in Ruby 2.3 and up it allows you to unfreeze
buffer = +""
buffer.encoding => Encoding::UTF-8

Rubyで分散ファイル同期(Hacklinesより)

Ruby Distributed File SyncでRDFSだそうです。Dropbox的なことをやってみたかったとのこと。


つっつきボイス: 「こういうDropbox的なオープンソースソフトウェアありましたねー: 名前思い出せん…SynkThinkだ!」「割りとよくできますこれ: Win/Linuxでも使えるし」


syncthink.comより

Ruby 3のGuildとは


engineering.universe.comより


つっつきボイス: 「Guildの情報が意外に少なかったんですが、これが割りとまとまってるっぽかったので」

TruffleRuby Native

メモリ喰いの代わりに爆速で知られるTruffleRubyのネイティブオプションについての記事です。

MJIT記事の極めつけ

英語版まで出してます。


つっつきボイス: 「この間の翻訳記事↓より後に公開されててちょっとほっとしました」「MJITの作者自らの記事には勝てないっしょw」

Rubyの新しいJIT「MJIT」で早速遊んでみた(翻訳)

こういう書き方


つっつきボイス: 「『とかしてなくて』w」「自分もアクセサでやっちゃったりするかなー: メンバ生で触るの怖いときとか」「まあ隠しとけと言えばそれまでだけど」

MatzのRuby25スピーチまとめ

RubyKaigi 2018 CFP締め切りの駆け込みっぷりが凄い

サイト: RubyKaigi 2018 closed


rubykaigi.orgより

あるあるとはいえ、笑ってしまいました。

SQL

「Why upgrade PostgreSQL?」: PostgreSQLのバージョンごとの修正点を比較表示できるサイト(Postgres Weeklyより)


つっつきボイス: 「これ便利かも」「出力はベタベタっすね…」「読みづらっ!」

(動画)KubernetesネイティブのPostgreSQL(Postgres Weeklyより)

AWS RDSがPostgreSQL 10をサポート(Postgres Weeklyより)

JavaScript

2018年のJavaScript事情(JavaScript Weeklyより)

Reduxの原則に沿ってReact.jsでフロントエンド検索ウィジェットを作る

Mozillaのsource/mapのパフォーマンスをRustとWebAssemblyで改善(JSer.infoより)

TypeScript Deep Dive


つっつきボイス: 「ところで音声読み上げってヒアリングの練習にいいかも」「最近の英語音声読み上げのクオリティ凄いし」

Vue.jsとVuexで自動セーブする

CSS/HTML/フロントエンド

簡単なものはいずれ面倒になる(Frontend Weeklyより)

制約条件の理論をソフトウェアに適用する(Frontend Weeklyより)

「制約条件の理論」は経営寄りの概念だそうです。

参考: Wikipedia-ja 制約条件の理論

Utility-First CSSと関数型的CSS(Frontend Weeklyより)

Go言語だけでフロントエンドする

先週取り上げたhttps://easydatawarehousing.github.io/ferro/にも通じそう(こちらはRubyとOpalとFerro)。

30 Seconds of CSS: CSSスニペットあんちょこサイト(GitHub Trendingより)


atomiks.github.ioより

スニペット数はまだそんなにありませんが、1週間ほどで★1600超えです。


つっつきボイス: 「お、これも中々便利じゃん?」「この間の30 seconds of codeに通じますね」

Elm言語: ブラウザベースの純粋関数型言語

Sandi Metz氏が最近関心を持っているとのことなので。

その他

SourceTree Windows版が更新

Diesel: Rust向けORM & クエリビルダ


つっつきボイス: 「社内のRust勢向けにと思って」

Dockerイメージを小さくする3つのコツ

どのネットワークかと思ったら

開発手法

ITエンジニアには筋トレが大事

私も痛感しています。


つっつきボイス: 「筋肉といえば、ウェブ魚拓の人の筋肉の付き方はヤバイですね」「ガチムチやん…」

コアもOSもシェルも自作

Six Degrees of Wikipedia: Wikipediaの記事のつながりをグラフ表示

よくあるやつといえばそれまでですが、めちゃめちゃ速いです。

Hemingway.app: 英文添削できるエディタ

ちなみに世にあるエッセイ向け英文スタイルガイドの種本のほとんどは、The Elements of Styleだったりします。もう100年近いロングセラー。

論文だとChicago Manual of Styleが定番だったかな。

「パスワード無期限」が推奨に

番外

GitHub issueをブログ代わりに

内容よりそっちがびっくりだったので。

prompts: JSらしいプロンプト(GitHub Trendingより)

AppleのクラウドはGoogleのクラウド

参考: Appleのクラウドサービス「iCloud」がGoogleのクラウドを利用していることが正式に明らかに - GIGAZINE

敷き詰め問題

これ系の問題はたいてい難問になってる気がします。


つっつきボイス: 「先に進むとヤケクソで並べたようなヤツとかあるなー確かに」「草」「この直感に反する感がタマラン」

「プログラマーの誓い」


私はプログラマーとして、以下の誓約を果たすことを誓います。
* 仕事を誠実に、かつ倫理から逸れぬよう進めます。人々に害をなすいかなる要求にも従いません。
* 先人の学びを尊重し、かつ自分の学びを後進と共有します。
* プログラミングは技術であると同時に、科学であり、思いやりであり、共感でもあることを決して忘れず、精巧なアルゴリズムや技術上の論争よりも理解することの方が重要であることも決して忘れません。
* 「わかりません」と言うことを恥じず、困ったときは的確に支援を求めます。
* ユーザーのプライバシーを尊重します。プライバシー情報は、たとえ世界中が知っていようとも自分には明かされないものだからです。
* あらゆる物事を生涯にわたって注意深く扱い、自分がミスをする可能性があることを謙虚に認めます。
* コンピュータのためでなく、人間のためにコードを書くことを決して忘れません。
* 自分の書くコードや自分の行いがもたらす可能性のある結果について常に配慮し、社会的な問題と技術上の問題、どちらの困難についても一方を軽んずることなく同様に尊重いたします。
* 誇りを失わず、勤勉に仕事に専念いたします。
* 自分がいつかは間違いを犯し、間違ったまま進む可能性があることを認めます。先入観に囚われず、誰の言葉であっても決して侮ることなく尊重し注意深く耳を傾けます。
同リポジトリより: 大意

ひと目で分かるヒポクラテスの誓いのもじりですね。

蛇腹型ロボット

Snake-inspired robot uses kirigami to move | Harvard John A. Paulson School of Engineering and Applied Sciences

マヤ文字作文


つっつきボイス: 「そういえば最近カクヨムで読んでるこの小説↓、解説も凝ってて言語マニアにはたまらんかもですヨ」


今週は以上です。

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

週刊Railsウォッチ(20180223)Ruby25開催、Rails6のパラレルテスト、書籍「RSpecによるRailsテスト入門」更新ほか

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

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

Ruby 公式ニュース

Rails公式ニュース

Ruby Weekly

Awesome Ruby

Random Ruby

RubyFlow

160928_1638_XvIP4h

Hacklines

Hacklines

Postgres Weekly

postgres_weekly_banner

Frontend Weekly

frontendweekly_banner_captured

JavaScript Weekly

javascriptweekly_logo_captured

JavaScript Live

jslive_logo_captured

JSer.info

jser.info_logo_captured

Github Trending

160928_1701_Q9dJIU

Rails5「中級」チュートリアル(3-6)投稿機能: 特定のブランチ(翻訳)

$
0
0

概要

概要

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

Rails5中級チュートリアルはセットアップが短めで、RDBMSにはPostgreSQL、テストにはRSpecを用います。
原文が非常に長いので分割します。章ごとのリンクは順次追加します。

注意: Rails中級チュートリアルは、Ruby on Railsチュートリアル(https://railstutorial.jp/)(Railsチュートリアル)とは著者も対象読者も異なります。

目次

Rails5「中級」チュートリアル(3-6)投稿機能: 特定のブランチ(翻訳)

各投稿は、ある特定のブランチに属します。別ブランチのための特定のページを作成しましょう。

新しいブランチに切り替えます。

git checkout -b specific_branches

homeページのサイドメニュー

homeページのサイドメニューの更新に取りかかりましょう。特定のブランチへのリンクを追加します。index.html.erbファイルを開きます。

views/pages/index.html.erb

#side-menu要素の中にリンクをいくつか追加することにします。ファイルの中身をパーシャルに切り出しておきましょう。今やっておかないと、たちまち乱雑になってしまいます。
#side-menu要素と#main-content要素をカットしてそれぞれ別のパーシャルファイルに貼り付けます。pagesディレクトリの下にindexディレクトリを作成し、要素に対応するパーシャルファイルをそこに作成します。作成後のファイルは以下のようになります(Gist)。

<!-- views/pages/index/_side_menu.html.erb -->
<div id="side-menu"  class="col-sm-3">
</div><!-- side-menu -->
<!-- views/pages/index/_main_content.html.erb -->
<div id="main-content" class="col-sm-9">
  <%= render @posts %>
</div><!-- main-content -->

homeページテンプレート内でこれらのパーシャルファイルをレンダリングします。このファイルは以下のようになります(Gist)。

<!-- views/pages/index.html.erb -->
<%= render 'posts/modal' %>

<div class="container">
  <div class="row">
    <%= render 'pages/index/side_menu' %>
    <%= render 'pages/index/main_content' %>
  </div><!-- row -->
</div><!-- container -->

変更をcommitします。

git add -A
git commit -m "Split home page template's content into partials"

_side_menu.html.erbパーシャルにリンクのリストを追加します。追加後は以下のようになります(Gist)。

<!-- views/pages/index/_side_menu.html.erb -->
<div id="side-menu"  class="col-sm-3">
  <ul id="links-list">
    <%= render 'pages/index/side_menu/no_login_required_links' %>
  </ul>
</div><!-- side-menu -->

これで順序なしリストが追加されます。このリストの中で、リンクを持つ別のパーシャルをレンダリングしましょう。このリンクは、サインインしているかどうかにかかわらずすべてのユーザーに表示されます。このパーシャルファイルを作成してリンクを追加します。

indexディレクトリの下にside_menuディレクトリを作成します。

views/pages/index/side_menu

このディレクトリの下に_no_login_required_links.html.erbパーシャルを作成し、以下のコードを追加します(Gist)。

<!-- views/pages/index/side_menu/_no_login_required_links.html.erb -->
<li id="hobby">
  <%= link_to hobby_posts_path do %>
    <i class="fa fa-user-circle-o" aria-hidden="true"></i> Find a hobby buddy
  <% end %>
</li>

<li id="study">
  <%= link_to study_posts_path do %>
    <i class="fa fa-graduation-cap" aria-hidden="true"></i> Find a study buddy
  <% end %>
</li>

<li id="team">
  <%= link_to team_posts_path do %>
    <i class="fa fa-users" aria-hidden="true"></i> Find a team member
  <% end %>
</li>

ここでは、投稿の特定のブランチへのリンクをいくつか足しているだけです。hobby_posts_pathなどのパスをどこから得たらよいかわからない場合は、routes.rbファイルをご覧ください。さっきcollectionのネストをルーティングのresources:posts宣言の中に書いてあります。

ここでi要素の属性を注意深く見てみると、faクラスがあることに気づくでしょう。このクラスがあることでFont Awesomeのアイコンが宣言されます。Font Awesomeライブラリのセットアップはまだですが、幸いセットアップはとても簡単です。メインのapplication.html.erbファイルのhead要素の中に以下を追加します。

<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">

これで以下のようにサイドメニューが表示されるはずです。

変更をcommitします。

git add -A
git commit -m "Add links to the home page's side menu"

小さい画面(幅767px1000px)でのBootstrapコンテナの表示がつぶれすぎていてよろしくないので、この幅の範囲で広げましょう。mobile.scssファイルに以下のコードを追加します(Gist)。

// assets/stylesheets/responsive/mobile.scss
...
@media only screen and (min-width:767px) and (max-width: 1000px) {
  .container {
     width: 100% !important;
  }
}

変更をcommitします。

git add -A
git commit -m "set .container width to 100%
when viewport's width is between 767px and 1000px"

ブランチページ

サイドメニューのリンクのひとつをクリックしてみるとエラーが表示されます。まだPostsControllerにアクションがなく、このコントローラに対応するテンプレートもありません。

PostControllerhobbystudyteamアクションを定義します(Gist)。

# controllers/posts_controller.rb
...
  def hobby
    posts_for_branch(params[:action])
  end

  def study
    posts_for_branch(params[:action])
  end

  def team
    posts_for_branch(params[:action])
  end
...

どのアクションもposts_for_branchを呼び出しています。このメソッドはアクション名に応じて特定ページのデータを返します。このメソッドをprivateスコープで定義しましょう(Gist)。

# contorllers/posts_controller.rb
...
private

def posts_for_branch(branch)
  @categories = Category.where(branch: branch)
  @posts = get_posts.paginate(page: params[:page])
end
...

@categoriesインスタンス変数は、特定のブランチから取り出したすべてのカテゴリです。たとえば、hobbyブランチページを開いたとすると、hobbyブランチに属するすべてのカテゴリが取り出されます。

投稿を取得して@postsインスタンス変数に保存するのにget_postsを使っており、その後ろにpagenateメソッドがチェインされています。paginateメソッドはwill_paginate gemによって提供されます。まずget_postsメソッドを定義しましょう。PostsControllerprivateスコープに以下を追加します(Gist)。

# controllers/posts_controller.rb
...
def get_posts
  Post.limit(30)
end
...

現時点のget_postsメソッドは投稿をきっかり30件取り出しますが、投稿の種類が絞り込まれていません。ここはもう少し改良できそうなので、後でこのメソッドに戻ることにします。

will_pagenate gemを追加してページネーションを利用できるようにします。

gem 'will_paginate', '~> 3.1.0'

以下を実行します。

bundle install

後足りないのはテンプレートだけです。テンプレートはどのブランチでも似たようなものなので、同じコードを何度も書くのではなく、すべてのブランチで共通する一般的な構造を備えたパーシャルを作成しましょう。postsディレクトリの下に_branch.html.erbファイルを作成します(Gist)。

<!-- posts/_branch.html.erb -->
<div id="branch-main-content" class="container">
  <div class="row">
    <h1 class="page-title"><%= page_title %></h1>
    <%= render 'posts/branch/create_new_post', branch: branch %>
  </div><!-- row -->

  <div class="row">
    <%= render 'posts/branch/categories', branch: branch %>
  </div>

  <div class="row">
    <div class="col-sm-12" id="feed">
      <%= render @posts %>
      <%= render no_posts_partial_path %>
    </div>
  </div><!-- row -->

  <div class="infinite-scroll">
    <%= will_paginate @posts %>
  </div>
</div><!-- container -->

ページの冒頭でpage_title変数が出力されていることがわかります。_branch.html.erbパーシャルをレンダリングするときにこの変数を引数として渡します。次に、リンクを表示する_create_new_postが出力されます。ユーザーはこのリンク先で新しい投稿を作成できます。branchディレクトリの下にこのパーシャルファイルを作成しましょう(Gist)。

<!-- posts/branch/_create_new_post.html.erb -->
<div class="col-sm-12">
  <div class="col-sm-8 col-sm-offset-2">
    <%= render create_new_post_partial_path, branch: branch %>
  </div><!-- col-sm-8 -->
</div><!-- col-sm-12 -->

レンダリングするパーシャルファイルの決定にはcreate_new_post_partial_pathヘルパーメソッドを使うことにします。posts_helper.rbファイルに以下のメソッドを実装します(Gist)。

# helpers/posts_helper.rb
...
  def create_new_post_partial_path
    if user_signed_in?
      'posts/branch/create_new_post/signed_in'
    else
      'posts/branch/create_new_post/not_signed_in'
    end
  end
...

create_new_postディレクトリを新しく作り、対応するパーシャルを2つその下に作成します(GistGist)。

<!-- posts/branch/create_new_post/_signed_in.html.erb -->
<div class="new-post-button-parent">
  <span>Cannot find anyone? Try to: </span>
  <%= link_to "Create a new post",
              new_post_path(branch: branch),
              :class => "new-post-button" %>
</div>
<!-- posts/branch/create_new_post/_not_signed_in.html.erb -->
<div class="text-center login-branch">
  To create a new post you have to
  <%= link_to 'Login',
              login_path,
              class: 'login-button login-button-branch' %>
</div>

次に_branch.html.erbファイルでカテゴリのリストを表示します。_categories.html.erbパーシャルファイルを作成します(Gist)。

<!-- posts/branch/_categories.html.erb -->
<% branch_path_name = "#{params[:action]}_posts_path" %>

<div class="col-sm-12">
  <ul class="categories-list">
    <%= render all_categories_button_partial_path,
               branch_path_name: branch_path_name %>
    <% @categories.each do |category| %>
      <li class="category-item">
        <%= link_to category.name,
                    send(branch_path_name, category: category.name),
                    :class => ("selected-item" if params[:category] == category.name) %>
      </li>
    <% end %>
  </ul>
</div><!-- col-sm-12 -->

このファイルでは、レンダリングするファイルの決定にall_categories_button_partial_pathヘルパーメソッドを使っています。このメソッドをposts_helper.rbファイルで定義しましょう(Gist)。

# helpers/posts_helper.rb
 ...
   def all_categories_button_partial_path
    if params[:category].blank?
      'posts/branch/categories/all_selected'
    else
      'posts/branch/categories/all_not_selected'
    end
  end
  ...

デフォルトではすべてのカテゴリが選択されます。params[:category]が空の場合は、ユーザーが選んだカテゴリが何もないことを表し、すなわちデフォルト値のallが選択されます。対応するパーシャルファイルを作成しましょう(GistGist)。

<!-- posts/branch/categories/_all_selected.html.erb -->
<li class="category-item">
  <%= link_to "All",
              send(branch_path_name),
              :class => "selected-item"  %>
</li>
<!-- posts/branch/categories/_all_not_selected.html.erb -->
<li class="category-item">
  <%= link_to "All", send(branch_path_name) %>
</li>

このsendメソッドは、文字列で表されるメソッドを呼び出すのに使われています。この方法によって柔軟性が高まり、メソッド呼び出しが動的になります。ここでは、現在のコントローラアクションに応じて異なるパスを生成しています。

次に、_branch.html.erbの内部で投稿をレンダリングしてno_posts_partial_pathヘルパーメソッドを呼び出します。投稿が見つからない場合はメソッドがメッセージを表示します。

posts_helper.rbに以下のヘルパーメソッドを追加します(Gist)。

# helpers/posts_helper.rb
...
def no_posts_partial_path
  @posts.empty? ? 'posts/branch/no_posts' : 'shared/empty_partial'
end
...

ここでは三項演算子を用いてコードを少しすっきりさせています。私は投稿が何もない場合にはメッセージを表示したくないと考えています。renderメソッドには空文字列を渡せないので、代わりに空のパーシャルへのパスを渡しています。空のパーシャルは何も表示したくないときに使います。

ビューにsharedディレクトリを作成して空のパーシャルを作成します。

views/shared/_empty_partial.html.erb

続いて、branchディレクトリの下にメッセージ表示用の_no_posts.html.erbパーシャルを作成します。(Gist)。

<!-- posts/branch/_no_posts.html.erb -->
<div class="text-center">Currently there are no published posts</div>

最後に、投稿数が多い場合にはgemのwill_paginateメソッドを用いて投稿を複数ページに分割します。

hobby/study/teamアクションに対応するテンプレートをそれぞれを作成します。それらのテンプレートで_branch.html.erbパーシャルファイルをレンダリングして特定のローカル変数を渡します(GistGistGist)。

<!-- posts/hobby.html.erb -->
<%= render 'posts/modal' %>
<%= render partial: 'posts/branch', locals: {
    branch: 'hobby',
    page_title: 'Find a person with the same hobby',
    search_placeholder: 'E.g. guitar playing, programming, cooking'
  } %>
<!-- posts/study.html.erb -->
<%= render 'posts/modal' %>
<%= render partial: 'posts/branch', locals: {
    branch: 'study',
    page_title: 'Find a person who studies the same field as you',
    search_placeholder: 'E.g. nutrition, calculus, astrophysics'
  } %>
<!-- posts/team.html.erb -->
<%= render 'posts/modal' %>
<%= render partial: 'posts/branch', locals: {
    branch: 'team',
    page_title: 'Find a person with similar interests as yours to your team',
    search_placeholder: 'E.g. musician for a band, developer for a project'
  } %>

これでブランチページのいずれかを表示すると、以下のように表示されます。

ページを下までスクロールすると、ページネーションもできるようになっています。

ブランチページの作成作業がだいぶ増えてきたので、ここで変更をcommitしましょう。

git add -A
git commit -m "Create branch pages for specific posts

- Inside the PostsController define hobby, study and team actions.
  Define a posts_for_branch method and call it inside these actions
- Add will_paginate gem
- Create a _branch.html.erb partial file
- Create a _create_new_post.html.erb partial file
- Define a create_new_post_partial_path helper method
- Create a _signed_in.html.erb partial file
- Create a _not_signed_in.html.erb partial file
- Create a _categories.html.erb partial file
- Define a all_categories_button_partial_path helper method
- Create a _all_selected.html.erb partial file
- Create a _all_not_selected.html.erb partial file
- Define a no_posts_partial_path helper method
- Create a _no_posts.html.erb partial file
- Create a hobby.html.erb template file
- Create a study.html.erb template file
- Create a team.html.erb template file"

spec

ヘルパーメソッドをspecでカバーしましょう。posts_helper_spec.rbファイルは次のような感じになります(Gist)。

# spec/helpers/posts_helper_spec.rb
require 'rails_helper'

RSpec.describe PostsHelper, :type => :helper do

  context '#create_new_post_partial_path' do
    it "returns a signed_in partial's path" do
      helper.stub(:user_signed_in?).and_return(true)
      expect(helper.create_new_post_partial_path). to (
        eq 'posts/branch/create_new_post/signed_in'
      )
    end

    it "returns a signed_in partial's path" do
      helper.stub(:user_signed_in?).and_return(false)
      expect(helper.create_new_post_partial_path). to (
        eq 'posts/branch/create_new_post/not_signed_in'
      )
    end
  end

  context '#all_categories_button_partial_path' do
    it "returns an all_selected partial's path" do
      controller.params[:category] = ''
      expect(helper.all_categories_button_partial_path).to (
        eq 'posts/branch/categories/all_selected'
      )
    end

    it "returns an all_not_selected partial's path" do
      controller.params[:category] = 'category'
      expect(helper.all_categories_button_partial_path).to (
        eq 'posts/branch/categories/all_not_selected'
      )
    end
  end

  context '#no_posts_partial_path' do
    it "returns a no_posts partial's path" do
      assign(:posts, [])
      expect(helper.no_posts_partial_path).to (
        eq 'posts/branch/no_posts'
      )
    end

    it "returns an empty partial's path" do
      assign(:posts, [1])
      expect(helper.no_posts_partial_path).to (
        eq 'shared/empty_partial'
      )
    end
  end
end

このspecもかなりシンプルです。ここではstubメソッドを用いてメソッドの戻り値を定義しました。paramsの定義は、controller.params[:param_name]のようにコントローラを選択してシンプルに行っています。最後に、インスタンス変数の代入にはassignメソッドを使っています。

変更をcommitします。

git add -A
git commit -m "Add specs for PostsHelper methods"

画面デザインの変更

ブランチページに表示する投稿のデザインを変えてみたいと思います。homeページではカード形式のデザインを使っています。ブランチページでリスト形式のデザインを作成し、ユーザーが多数の投稿を効率よく閲覧できるようにしてみましょう。

postsディレクトリの下にpostディレクトリを作成し、そこに_home_page.html.erbパーシャルファイルを作成します。

posts/post/_home_page.html.erb

_post.html.erbパーシャルの内容をカットし、この_home_page.html.erbファイルに貼り付けます。_post.html.erbパーシャルファイルには以下のコードを追加します(Gist)。

<!-- posts/_post.html.erb -->
<%= render post_format_partial_path, post: post %>

ここで呼んでいるpost_format_partial_pathヘルパーメソッドは、現在のパスに応じて、投稿をどのデザインでレンダリングするかを選択します。ユーザーがhomeページにいる場合はhomeページ向けのデザインでレンダリングし、ブランチページにいる場合はブランチページ向けのデザインでレンダリングします。_post.html.erbファイルの内容を_home_page.html.erbに移動したのはそのためです。

postディレクトリに_branch_page.html.erbファイルを作成し、ブランチページ向けの画面デザインを定義する以下のコードを貼り付けます(Gist)。

<!-- posts/post/_branch_page.html.erb -->
<div class="single-post-list" id=<%= post_path(post.id) %>>
  <%= truncate(post.title, :length => 60) %>
  <div class="post-content">
    <div class="posted-by">Posted by <%= post.user.name %></div>
    <h3><%= post.title %></h3>
    <p><%= post.content %></p>
    <%= link_to "I'm interested", post_path(post.id), class: 'interested' %>
  </div>
</div>

レンダリングするパーシャルファイルを決定するpost_format_partial_pathヘルパーメソッドをposts_helper.rbで定義します(Gist)。

# helpers/posts_helper.rb
def post_format_partial_path
  current_page?(root_path) ? 'posts/post/home_page' : 'posts/post/branch_page'
end

投稿のレンダリングはhomeページのテンプレート内で行われるため、担当するコントローラが異なります。このため、このままではpost_format_partial_pathヘルパーメソッドはhomeページで呼び出せません。このメソッドをhomeページのテンプレート内で使えるようにするには、ApplicationHelper(helper/application_helper.rb)の内側にPostsHelperをインクルードします。

include PostsHelper

spec

post_format_partial_pathヘルパーメソッドのspecを追加します(Gist)。

# helpers/posts_helper_spec.rb
context '#post_format_partial_path' do
  it "returns a home_page partial's path" do
    helper.stub(:current_page?).and_return(true)
    expect(helper.post_format_partial_path).to (
      eq 'posts/post/home_page'
    )
  end

  it "returns a branch_page partial's path" do
    helper.stub(:current_page?).and_return(false)
    expect(helper.post_format_partial_path).to (
      eq 'posts/post/branch_page'
    )
  end
end

変更をcommitします。

git add -A
git commit -m "Add specs for the post_format_partial_path helper method"

CSS

ブランチページの投稿スタイルをCSSで記述しましょう。CSSのpostsディレクトリの下に以下の内容でbranch_page.scssファイルを作成します(Gist)。

// stylesheets/partials/posts/branch_page.scss
.single-post-list {
  min-height: 45px;
  max-height: 45px;
  padding: 10px 20px 10px 0px;
  margin: 0 10px;
  border-bottom: solid 3px rgba(0, 0 , 0, 0.05);
  border-bottom-right-radius: 10%;
  transition: border-color 0.1s;
  overflow: hidden;
  &:hover {
    cursor: pointer;
  }
}

.page-title {
  margin: 30px 0;
  text-align: center;
  background-color: white !important;
  font-weight: bold;
  a {
    color: black;
  }
  a:hover {
    text-decoration: underline;
  }
}

.categories-list {
  margin: 10px 0;
  padding: 0;
}

.category-item {
  display: inline-block;
  margin: 15px 0;
  a {
    font-size: 16px;
    font-size: 1.6rem;
    color: rgba(0,0,0,0.7);
    border: solid 2px rgba(0,0,0,0.4);
    border-radius: 8%;
    padding: 10px;
  }
  a:hover, .selected-item {
    background: $navbarColor;
    color: white;
    border: solid 2px white;
    border-radius: 0px;
  }
}

.new-post-button-parent {
  text-align: right;
  span {
    font-size: 12px;
    font-size: 1.2rem;
  }
}

.new-post-button {
  display: inline-block;
  background: $navbarColor;
  color: white;
  padding: 8px;
  border-radius: 10px;
  font-weight: bold;
  border: solid 2px $navbarColor;
  margin: 10px 0;
  &:hover, &:active, &:focus {
    background: white;
    color: black;
  }
}

.login-branch {
  margin: 10px 0;
}

.login-button-branch {
  padding: 5px 10px;
  border-radius: 10px;
  &:hover, &:active, &:visited, &:link {
    color: white;
  }
}

#branch-main-content {
  background: white;
  height: calc(100vh - 50px);
}

#feed {
  background-color: white;
}

base/default.scssに以下を追加します(Gist)。

// assets/stylesheets/base/default.scss
.login-button, .sign-up-button {
  background-color: $navbarColor;
  color: white !important;
}

小画面デバイスでの表示を修正するため、responsive/mobile.scssに以下を追加します(Gist)。

// assets/stylesheets/responsive/mobile.scss
...
@media screen and (max-width: 550px) {
  .page-title {
      font-size: 20px;
      font-size: 2rem;
  }
  .new-post-button-parent {
    text-align: center;
    span {
      display: none !important;
    }
  }
  .post-button {
    padding: 5px;
  }
  .category-item {
    a {
      padding: 5px;
    }
  }
}

@media screen and (max-width: 767px) {
  .single-post-list {
    min-height: 65px;
    max-height: 65px;
    padding: 10px 0;
  }
}
...

訳注: application.scssに以下を追加する必要もあります。

// assets/stylesheets/application.scss
@import "partials/posts/*";

これでブランチページは次のように表示されるはずです。

変更をcommitします。

git add -A
git commit -m "Describe the posts style in branch pages

- Create a branch_page.scss file and add CSS
- Add CSS to the default.scss file
- Add CSS to the mobile.scss file"

検索バー

投稿リストを閲覧できるだけではなく、特定の投稿を検索できるようにもしたいと思います。_branch.html.erbパーシャルファイルのcategories rowの直前に以下を追加します(Gist)。

<!-- posts/_branch.html.erb -->
...
<div class="row">
  <%= render  'posts/branch/search_form',
              branch: branch,
              search_placeholder: search_placeholder %>
</div><!-- row -->
...

branchディレクトリの下に_search_form.html.erbパーシャルファイルを作成し、以下のコードを追加します(Gist)。

<!-- posts/branch/_search_form.html.erb -->
<div class="col-sm-12">
  <%= form_tag(send("#{branch}_posts_path"),
               :method => "get",
               id: "search-form") do %>
    <i class="fa fa-search" aria-hidden="true"></i>
    <%= text_field_tag  :search,
                        params[:search],
                        placeholder: search_placeholder,
                        class: "form-control" %>
    <%= render category_field_partial_path %>
  <% end %>
</div><!-- col-sm-12 -->

上のコードでは、sendメソッドを使ってPostsControllerの特定のアクションへのパスを現在のブランチに応じて動的に生成しています。また、特定のカテゴリが選択されている場合にはカテゴリ用のデータフィールドも送信します。ユーザーがカテゴリのひとつを選択すると、そのカテゴリに該当する結果だけを返します。

posts_helper.rbファイルにcategory_field_partial_pathヘルパーメソッドを定義します(Gist)。

# helpers/posts_helper.rb
...
  def category_field_partial_path
    if params[:category].present?
      'posts/branch/search_form/category_field'
    else
      'shared/empty_partial'
    end
  end
...

search_formディレクトリを作成し、その下に_category_field.html.erbパーシャルファイルを作成して以下のコードを追加します(Gist)。

<!-- posts/branch/search_form/_category_field.html.erb -->
<%= hidden_field_tag :category, params[:category] %>

検索フォームのスタイルを整えるため、branch_page.scssファイルに以下を追加します(Gist)。

// assets/stylesheets/partials/posts/branch_page.scss
.fa-search {
  position:absolute;
  bottom:14px;
  left:10px;
  width:20px;
  height:10px;
}

#search-form {
  position:relative;
  input {
    border: solid 2px rgba(0,0,0,0.2);
    border-radius: 10px;
    box-shadow: none;
    outline: 0;
  }
  input:focus {
    border: solid 2px rgba(0,0,0,0.35);
  }
  input#search {
    padding: 15px;
    width: 100%;
    height:20px;
    margin: 10px 0;
    padding-left: 30px;
  }
}

これで、ブランチページの検索フォームが以下のように表示されるはずです。

変更をcommitします。

git add -A
git commit -m "Add a search form in branch pages

- Render a search form inside the _branch.html.erb
- Create a _search_form.html.erb partial file
- Define a category_field_partial_path helper method in PostsHelper
- Create a `_category_field.html.erb` partial file
- Add CSS for the the search form in branch_page.scss"

このフォームはまだ機能していません。検索機能を使える何らかのgemを追加してもよいのですが、まだデータは複雑ではないので、簡単な検索エンジンを独自に作成することもできます。ここではPostモデルでスコープを用いてクエリをチェインできるようにし、コントローラに条件ロジックを追加します(次のセクションではこのコードをService Objectに切り出してコードをすっきりさせる予定です)。

まずはPostモデルでスコープを定義しましょう。手始めに、post.rbファイルでdefault_scopeを定義します。このスコープでは投稿を作成日で降順ソートし、最新の投稿がトップに来るようにします(Gist)。

# models/post.rb
...
default_scope -> { includes(:user).order(created_at: :desc) }
...

訳注: default_scopeについては次の記事もどうぞ。

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

変更をcommitします。

git add -A
git commit -m "Define a default_scope for posts"

default_scopeが正常に機能していることを確認するため、specに含めましょう。post_spec.rbファイルに以下を追加します(Gist)。

# spec/models/post_spec.rb
context 'Scopes' do
  it 'default_scope orders by descending created_at' do
    first_post = create(:post)
    second_post = create(:post)
    expect(Post.all).to eq [second_post, first_post]
  end
end

変更をcommitします。

git add -A
git commit -m "Add a spec for the Post model's default_scope"

それでは検索バーが機能するようにしてみましょう。posts_controller.rbget_postsメソッドの内容を以下で置き換えます(Gist)。

# controllers/posts_controller.rb
def get_posts
  branch = params[:action]
  search = params[:search]
  category = params[:category]

  if category.blank? && search.blank?
    posts = Post.by_branch(branch).all
  elsif category.blank? && search.present?
    posts = Post.by_branch(branch).search(search)
  elsif category.present? && search.blank?
    posts = Post.by_category(branch, category)
  elsif category.present? && search.present?
    posts = Post.by_category(branch, category).search(search)
  else
  end
end

ビューのときと同様、コントローラにこういうロジックを置くのはあまりよくありません。ここをもっとスッキリさせたいので、この後のセクションでこのメソッドのロジックを切り出す予定です。

このコードでは条件ロジックがいくつか連続しています。ユーザーからのリクエストに応じて、データをクエリするときのスコープを切り替えています。

Postモデルに以下のスコープを定義します(Gist)。

# models/post.rb
...
  scope :by_category, -> (branch, category_name) do
    joins(:category).where(categories: {name: category_name, branch: branch})
  end

  scope :by_branch, -> (branch) do
    joins(:category).where(categories: {branch: branch})
  end

  scope :search, -> (search) do
    where("title ILIKE lower(?) OR content ILIKE lower(?)", "%#{search}%", "%#{search}%")
  end
...

関連付けられたテーブルのレコードへのクエリには[joins]https://railsguides.jp/active_record_querying.html#joins)メソッドを使います。また、与えられた文字列を元に基本的なSQL文法を用いてレコードを検索しています。

これでサーバーを再起動していずれかのブランチページを表示すれば、検索バーが使えるようになっているはずです。また、カテゴリボタンをクリックしてカテゴリでフィルタすることも、特定のカテゴリを選択している状態で検索することでそのカテゴリに属する投稿だけを検索することもできます。

変更をcommitします。

git add -A
git commit -m "Make search bar and category filters
in branch pages functional

- Add by_category, by_branch and search scopes in the Post model
- Modify the get_posts method in PostsController"

これらのスコープをspecでカバーしましょう。post_spec.rbファイルの Scopes contextに以下を追加します(Gist)。

# spec/models/post_spec.rb
  it 'by_category scope gets posts by particular category' do
    category = create(:category)
    create(:post, category_id: category.id)
    create_list(:post, 10)
    posts = Post.by_category(category.branch, category.name)
    expect(posts.count).to eq 1
    expect(posts[0].category.name).to eq category.name
  end

  it 'by_branch scope gets posts by particular branch' do
    category = create(:category)
    create(:post, category_id: category.id)
    create_list(:post, 10)
    posts = Post.by_branch(category.branch)
    expect(posts.count).to eq 1
    expect(posts[0].category.branch).to eq category.branch
  end

  it 'search finds a matching post' do
    post = create(:post, title: 'awesome title', content: 'great content ' * 5)
    create_list(:post, 10, title: ('a'..'c' * 2).to_a.shuffle.join)
    expect(Post.search('awesome').count).to eq 1
    expect(Post.search('awesome')[0].id).to eq post.id
    expect(Post.search('great').count).to eq 1
    expect(Post.search('great')[0].id).to eq post.id
  end

変更をcommitします。

git add -A
git commit -m "Add specs for Post model's
by_branch, by_category and search scopes"

無限スクロール機能

ブランチページのいずれかを表示すると、最下部に以下のページネーションが表示されています。

[Next]のリンクをクリックすると、現在より古い記事のページにリダイレクトされます。こうする代わりに、FacebookやTwitterのフィードのように無限スクロールさせることもできます。この場合下にスクロールするだけで、ページの再読み込みやリダイレクトを行わなくても以前の投稿がリストの下に追加されます。驚くことに、この機能はJavaScriptを少し書くだけでとても簡単に実現できるのです。ユーザーがページ最下部までスクロールすると、「次のページ」からデータを取得するAJAX リクエストが常に送信され、リストの最下部に追加されます。

まずはAJAXリクエストとその条件を設定するところから始めましょう。ユーザーがある閾値まで下スクロールすると、AJAXリクエストが発火するようにします。javascripts/postsディレクトリの下にinfinite_scroll.jsファイルを作成し、以下のコードを追加します(Gist)。

// assets/javascripts/posts/infinite_scroll.js
$(document).on('turbolinks:load', function() {
  var isLoading = false;
  if ($('.infinite-scroll', this).length > 0) {
    $(window).on('scroll', function() {
      var more_posts_url = $('.pagination a.next_page').attr('href');
      var threshold_passed = $(window).scrollTop() > $(document).height() - $(window).height() - 60;
      if (!isLoading && more_posts_url && threshold_passed) {
        isLoading = true;
        $.getScript(more_posts_url).done(function (data,textStatus,jqxhr) {
          isLoading = false;
        }).fail(function() {
          isLoading = false;
        });
      }
    });
  }
});

訳注: 原文コードままだと動かなかったため、上のJavaScriptコードは修正してあります(参考)。

isLoadingは、一度に1件のリクエストだけが送信されるようにするための変数です。リクエストが進行中の場合、他のリクエストは開始されません。

最初にページネーション機能の有無と、表示する投稿が他にもあるかどうかをチェックします。次に、次ページへのリンク(ここからデータを取り出します)を取得します。続いてAJAXリクエストを呼び出すときの閾値(threshold)を設定します。ここではウィンドウ最下部から60pxまでを閾値に設定しています。すべての条件がパスしたら、getScript()関数で次のページからデータを読み込みます。

getScript()関数はJavaScriptファイルを読み込むので、どのファイルでレンダリングするかをPostsControllerで指定しなければなりません。レンダリングするファイルは、posts_for_branchメソッドの中でrespond_toの形で指定します(Gist)。

# controllers/posts_controller.rb
respond_to do |format|
  format.html
  format.js { render partial: 'posts/posts_pagination_page' }
end

このコントローラが.jsファイルを用いて応答しようとすると、posts_pagination_pageテンプレートがレンダリングされます。このパーシャルファイルは、新たに取り出した投稿をリストの末尾に追加します。投稿をappendしてページネーション要素を更新するパーシャルファイルを作成しましょう(Gist)。

<!-- posts/_posts_pagination_page.js.erb -->
$('#feed').append('<%= j render @posts %>');
<%= render update_pagination_partial_path %>

posts_helper.rbファイルにupdate_pagination_partial_pathヘルパーメソッドを追加します(Gist)。

# helpers/posts_helper.rb
def update_pagination_partial_path
  if @posts.next_page
    'posts/posts_pagination_page/update_pagination'
  else
    'posts/posts_pagination_page/remove_pagination'
  end
end

ここではwill_paginate gemのnext_pageメソッドを用いて、この後読み込める投稿がまだあるかどうかを決定しています。

対応するパーシャルファイルをそれぞれ作成します(GistGist)。

<!-- posts/posts_pagination_page/_update_pagination.js.erb -->
$('.pagination').replaceWith('<%= j will_paginate @posts %>');
<!-- posts/posts_pagination_page/_remove_pagination.js.erb -->
$(window).off('scroll');
$('.pagination').remove();

これで、いずれかのブランチページで下にスクロールすれば過去の投稿が自動的にリストの下に追加されるはずです。

ページネーションのメニューを表示する必要もなくなったので、CSSで隠しましょう。branch_page.scssファイルに以下を追加します。

# stylesheets/partials/posts/branch_page.scss
...
.infinite-scroll {
  display: none;
}
...

変更をcommitします。

git add -A
git commit -m "Transform posts pagination into infinite scroll

- Create an infinite_scroll.js file
- Inside PostController's posts_for_branch method add respond_to format
- Define an update_pagination_partial_path
- Create _update_pagination.js.erb and _remove_pagination.js.erb partials
- hide the .infinite-scroll element with CSS"

spec

update_pagination_partial_pathヘルパーメソッドをspecでカバーしましょう(Gist)。

# spec/helpers/post_helper_spec.rb
context '#update_pagination_partial_path' do
  it "returns an update_pagination partial's path" do
    posts = double('posts', :next_page => 2)
    assign(:posts, posts)
    expect(helper.update_pagination_partial_path).to(
      eq 'posts/posts_pagination_page/update_pagination'
    )
  end

  it "returns a remove_pagination partial's path" do
    posts = double('posts', :next_page => nil)
    assign(:posts, posts)
    expect(helper.update_pagination_partial_path).to(
      eq 'posts/posts_pagination_page/remove_pagination'
    )
  end
end

ここでは、postsインスタンス変数とそこにチェインされるnext_pageメソッドをdoubleを用いてシミュレートしています。RSpecのモックについて詳しくはこちらをご覧ください。

変更をcommitします。

git add -A
git commit -m "Add specs for the update_pagination_partial_path
helper method"

この時点で、下スクロールすると投稿が下に追加されることを確認できるfeature specを書くこともできます。infinite_scroll_spec.rbファイルを作成します(Gist)。

# spec/features/posts/infinite_scroll_spec.rb
require "rails_helper"

RSpec.feature "Infinite scroll", :type => :feature do
  Post.per_page = 15

  let(:check_posts_count) do
    expect(page).to have_selector('.single-post-list', count: 15)
    page.execute_script("$(window).scrollTop($(document).height())")
    expect(page).to have_selector('.single-post-list', count: 30)
  end

  scenario "User scrolls down the hobby page
            and posts list will be appended with older posts", js: true do
    create_list(:post, 30, category: create(:category, branch: 'hobby'))
    visit hobby_posts_path
    check_posts_count
  end

  scenario "User scrolls down the study page
            and posts list will be appended with older posts", js: true do
    create_list(:post, 30, category: create(:category, branch: 'study'))
    visit study_posts_path
    check_posts_count
  end

  scenario "User scrolls down the team page
            and posts list will be appended with older posts", js: true do
    create_list(:post, 30, category: create(:category, branch: 'team'))
    visit team_posts_path
    check_posts_count
  end

end

上のspecファイルではブランチページをすべてカバーしており、3つのページでこの機能が正常に動作することを確認しています。per_pagewill_paginate gemのメソッドです。ここではPostモデルを選択してページのデフォルト投稿数を設定するのに使っています。

このファイルのコード量を減らすためにcheck_posts_countメソッドを定義しています。同じコードを異なるspecで繰り返すのではなく、単一のメソッドに切り出しています。ページを開いたときに投稿が15件表示されることが期待されています。続いてexecute_scriptメソッドを用いてJavaScriptを実行し、ブラウザのスクロールバーを最下部までスクロールしています。スクロールが終わったら、最後に投稿が15件追加されることが期待されています。これで、ページには投稿が30件表示されます。

変更をcommitします。

git add -A
git commit -m "Add feature specs for posts' infinite scroll functionality"

homeページの更新

現在のhomeページには投稿が数件ランダムに表示されているだけです。これを改修して、すべてのブランチから投稿を数件表示できるようにしましょう。

_main_content.html.erbファイルの内容を以下で置き換えます(Gist)。

<!-- pages/index/_main_content.html.erb -->
<div id="main-content" class="col-sm-9">
  <h3 class="page-name"><%= link_to 'Hobby', hobby_posts_path %></h3>
  <div class="row">
    <%= render @hobby_posts %>
    <%= render no_posts_partial_path(@hobby_posts) %>
  </div><!-- row -->

  <h3 class="page-name"><%= link_to 'Study', study_posts_path %></h3>
  <div class="row">
    <%= render @study_posts %>
    <%= render no_posts_partial_path(@study_posts) %>
  </div><!-- row -->

  <h3 class="page-name"><%= link_to 'Team member', team_posts_path %></h3>
  <div class="row">
    <%= render @team_posts %>
    <%= render no_posts_partial_path(@team_posts) %>
  </div><!-- row -->
</div><!-- main_content -->

ブランチごとに投稿を区切るセクションを作成しました。

PagesControllerindexアクションにインスタンス変数をいくつか定義しましょう。定義後のアクションは次のようになります(Gist)。

# controllers/pages_controller.rb
  def index
    @hobby_posts = Post.by_branch('hobby').limit(8)
    @study_posts = Post.by_branch('study').limit(8)
    @team_posts = Post.by_branch('team').limit(8)
  end

先ほどno_posts_partial_pathヘルパーメソッドを作成しましたが、再利用しやすいように少々変更する必要があります(現在はブランチページでしか使えません)。このメソッドにpostsパラメータを追加すると次のようになります(Gist)。

# helpers/posts_helper.rb
def no_posts_partial_path(posts)
  posts.empty? ? 'posts/shared/no_posts' : 'shared/empty_partial'
end

postsパラメータを追加したことで、インスタンス変数は単純な変数に置き換えられ、パーシャルのパスも変わりました。そこで_no_posts.html.erbパーシャルファイルのパスも以下のように変更します。

posts/branch/_no_posts.html.erb

上のパスを以下に変更します。

posts/shared/_no_posts.html.erb

また、posts/_branch.html.erbファイルのno_posts_partial_pathメソッドを、@postsインスタンス変数を引数として渡すように変更します。

スタイルも少し追加しましょう。default.scssファイルに以下を追加します(Gist)。

// assets/stylesheets/base/default.scss
...
.container {
  padding: 0;
}

.row {
  margin: 0;
}

home_page.scssに以下を追加します(Gist)。

// assets/stylesheets/partials/home_page.scss
.page-name {
  margin: 15px 0px 15px 0px;
  text-align: center;
  background-color: white !important;
  font-weight: bold;
  a {
    color: black;
  }
  a:hover {
    text-decoration: underline;
  }
}
...

これでhomeページが以下のように表示されるはずです。

訳注: specの更新が原文で漏れていたので、以下に補います。

# /spec/helpers/posts_helper_spec.rb
  context '#no_posts_partial_path' do
    it "returns a no_posts partial's path" do
      expect(helper.no_posts_partial_path([])).to (
        eq 'posts/shared/no_posts'
      )
    end

    it "returns an empty partial's path" do
      expect(helper.no_posts_partial_path([1])).to (
        eq 'shared/empty_partial'
      )
    end
  end

変更をcommitします。

git add -A
git commit -m "Add posts from all branches in the home page

- Modify the `_main_content.html.erb file
- Define instance variables inside the PagesControllers index action
- Modify the `no_posts_partial_path helper method to be more reusable
- Add CSS to style the home page"

関連記事

新しいRailsフロントエンド開発(1)Asset PipelineからWebpackへ(翻訳)

Railsアンチパターン: Decoratorの肥大化(翻訳)

$
0
0

概要

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

パターン名は英語表記としています。

Railsアンチパターン: Decoratorの肥大化(翻訳)

RailsでDecoratorを用いるとさまざまなメリットが得られます。モデルはスリムになり、ビューもすっきりし、手続き臭のする従来のビューヘルパーは過去のものになります。

RailsプロジェクトにDecoratorパターンを適用するとき、ともするとモデルとDecoratorを1対1で対応させたい誘惑にかられます。たとえばArticleのプレゼンテーションロジックはすべてArticleDecoratorに置く、という具合です。Decoratorが小さいうちなら、これは正当なアプローチでしょう。

しかしこのDecoratorの要件は時とともに注ぎ足されてさらに多くのメソッドが集結し、責務は増加し、いつしかDecoratorはゆっくりと肥大化していきます。ぎっしり詰まったメソッド群の中からいずれバグが顔を出すでしょう。

単一のDecoratorに何もかも詰め込んでいては、Railsのビューヘルパーを使っていたときと大差ありません。Decoratorを最大限に活用したいのであれば、オブジェクト指向プログラミングの原則をDecoratorにも適用することが重要です。

本記事では、これまで私がDecoratorでよく目にするコードの臭いをいくつかご紹介します。

変更の分散(divergent changes)

大規模なDecoratorでは機能が広範囲に渡っていることがよくあります。要件が変更されると、1つのDecoratorがいくつもの異なる要件に合わせようとしてたびたび自らを変更していたりします。これが「変更の分散」と呼ばれるコードの臭いです。

この臭いは、単一責任の原則に違反していることを示しています。プレゼンテーションロジックの面倒を見ることは、ここでは単一責任にはカウントされません;-)。機能の1つの部分について責任を持つメソッド群が互いに強く関連しているのであれば、おそらくそれらを独自のクラスに切り出すべきでしょう。

機能の羨望(feature envy)

不要なロジックをビューから切り出すとき、目についた最寄りのDecoratorに置いて済ませていることがよくあります。これをやってしまうと、Decoratorが他のオブジェクトに越境し始めるようになり、しまいには自分自身ではなく他のオブジェクトのことで手一杯になってしまいます。

これによってオブジェクト間に多数の結合が発生し、リファクタリングが困難になります。この種の機能は、もっと適切なDecoratorに移す必要があります。

プンプン匂うDecorator

以下は、Git commitのプレゼンテーションロジックを扱うDecoratorです。簡単のため、コードのある部分を大胆に省略してあります。私はDraper gemを用いていますが、他のgemや自作のDecoratorを使っていても同じです。

class CommitDecorator < Draper::Decorator
  delegate_all

  def author_link
    h.link_to(author.name, h.profile_path(author.username))
  end

  def parent_link
    h.link_to(parent.truncated_sha, h.project_commit_path(project, parent.sha))
  end

  def diff_stats
    h.t('commits.show.diff_stats_html',
        changed: diffs.count,
        additions: diffs.sum(&:additions),
        deletions: diffs.sum(&:deletions))
  end

  def file_changes
    diffs.map do |diff|
      DiffLine.new(
        status_class_for(diff), diff.path, diff.additions, diff.deletions)
    end
  end

  private

  DiffLine = Struct.new(:status_class, :path, :additions, :deletions)

  def status_class_for(diff)
    if diff.deleted?
      'deletion'
    elsif diff.added?
      'addition'
    else
      'change'
    end
  end
end

commitのコントローラは次のような感じだとします。

class CommitsController < ApplicationController
  decorates_assigned :commits, :commit

  def index
    @commits = find_project.commits
  end

  def show
    @commit = find_project.find_commit_by_sha(params[:sha])
  end

  private

  def find_project
    Project.find(params[:project_id])
  end
end

以下の2つのシナリオが想定されています。

  • プロジェクトのcommitリストが表示される
  • 変更されたファイルのcommitが1件表示される

問題点

このDecoratorには、先にご紹介した2種類の「コードの臭い」が両方とも出現しています。

ちっぽけな情報しか持っていないcommitの概要を表示したいだけであれば、1つのDecoratorに機能を満載するのはいかにもイケてない感じがします。

このDecoratorは他のオブジェクトの細かな部分にかなりちょっかいを出しています。特に、diff内部の詳細と非常に強く結合しています。

ちょっかいを出さないようにする

特にヤバイのは、ファイル変更情報を集めるためにdiffの詳細に立ち入っている点です。取り急ぎこの部分をDiffDecoratorに切り出すリファクタリングを行いましょう。

class DiffDecorator < Draper::Decorator
  delegate_all

  def status_class
    if deleted?
      'deletion'
    elsif added?
      'addition'
    else
      'change'
    end
  end
end

以下のCommitDecoratorがスッキリと変わったことにご注目ください。file_changesはデコレーションされたdiffと同義になりました。どうしても必要というほどではありませんが、ここではalias_methodを用いてインターフェイスを揃えています。

class CommitDecorator < Draper::Decorator
  delegate_all
  decorates_association :diffs

  alias_method :file_changes, :diffs

  def author_link
    h.link_to(author.name, h.profile_path(author.username))
  end

  def parent_link
    h.link_to(parent.truncated_sha, h.project_commit_path(project, parent.sha))
  end

  def diff_stats
    h.t('commits.show.diff_stats_html',
        changed: diffs.count,
        additions: diffs.sum(&:additions),
        deletions: diffs.sum(&:deletions))
  end
end

authorへのリンクと親commitは、このDecorator内でもうまく切り離されました。親自身もcommitオブジェクトなので、CommitDecoratorへのlinkメソッドが暗に追加されます。

class AuthorDecorator < Draper::Decorator
  delegate_all

  def link
    h.link_to(name, h.profile_path(username))
  end
end

class CommitDecorator < Draper::Decorator
  delegate_all
  decorates_association :diffs
  decorates_association :author
  decorates_association :parent

  alias_method :file_changes, :diffs

  delegate :link, to: :author, prefix: true
  delegate :link, to: :parent, prefix: true

  def link
    h.link_to(truncated_sha, h.project_commit_path(project, sha))
  end

  def diff_stats
    h.t('commits.show.diff_stats_html',
        changed: diffs.count,
        additions: diffs.sum(&:additions),
        deletions: diffs.sum(&:deletions))
  end
end

これでCommitDecoratorは、author_linkparent_linkをいくつか委譲するだけとなりました。

「変更の分散」に対処する

私は、あるモデルのデフォルト(または基本となる)Decorator的なものを用意する手法を常に好んでいます。このDecoratorでは、別のコンテキストでよく用いられるメソッドを定義します。これはCommitDecoratorと呼ぶのに相応しいでしょう。commit.decorateが呼び出されれば、デフォルトDecoratorによって常に自動でデコレーションされます。

デコレーションされる機能によっては、デコレーションを必要とするコンテキストが1つしかないこともあります。私は、その種の機能を独自のDecoratorクラスに配置して明示的に利用し、共通機能の中に埋もれないようにしておくのが好みです。

たとえば、commitのサマリーをデフォルトDecoratorでデコレーションできれば十分だとしましょう。しかし詳細なcommitは明らかに独自のデコレータを必要としています。

module Commits
  class DetailedCommitDecorator < Draper::Decorator
    delegate_all

    def initialize(*args)
      super(CommitDecorator.new(*args))
    end

    def diff_stats
      h.t('commits.show.diff_stats_html',
          changed: diffs.count,
          additions: diffs.sum(&:additions),
          deletions: diffs.sum(&:deletions))
    end
  end
end

私はいつも、コンテキストに特有なこの種のDecoratorを、デコレーションするクラスで名前空間化するようにしています。これならDecoratorの数が増えても概要を十分把握できます。

継承でDecoratorを用いる方法は、Decoratorの本質に逆らっているように感じられますし、親クラスのメソッドをオーバーライドするときに混乱しがちなので好きではありません。継承されたメソッドをオーバーライドするのであれば、モデルで定義済みのメソッドをオーバーライドする方法ではない、別のアプローチが必要になるかもしれません。

そういうわけで、私は最初に元のcommitオブジェクトをCommitDecoratorでラップしています。これはCommits::DetailedCommitDecorator.new(CommitDecorator.new(commit))みたいな方法と同等とも言えますが、両方のDecoratorにその都度適用しなければならないことを気にしたくありません。私のDecoratorはどれもdelegate_allを使っていることにご注意ください。このdelegate_allは、Decoratorが知らないメソッドから、デコレーションされるオブジェクトへ委譲するためのものです。

詳細なcommitの機能を切り出したことで、CommitDecoratorがこんなにシンプルになりました。

class CommitDecorator < Draper::Decorator
  delegate_all
  decorates_association :diffs
  decorates_association :author
  decorates_association :parent

  alias_method :file_changes, :diffs

  delegate :link, to: :author, prefix: true
  delegate :link, to: :parent, prefix: true

  def link
    h.link_to(truncated_sha, h.project_commit_path(project, sha))
  end
end

最後の仕上げに、コントローラで適切なDecoratorを適用します。

class CommitsController < ApplicationController
  decorates_assigned :commits
  decorates_assigned :commit, with: Commits::DetailedCommitDecorator

  def index
    @commits = find_project.commits
  end

  def show
    @commit = find_project.find_commit_by_sha(params[:sha])
  end

  private

  def find_project
    Project.find(params[:project_id])
  end
end

まとめ

Decoratorを従来のビューヘルパー代わりに用いることは避けましょう。Decoratorからコードの臭いが立ち昇ったら適切に処理し、メソッドや責務が複雑にならないようにしましょう。Decoratorクラスを新しく導入することを恐れてはいけませんが、Decoratorが存在してもよい正当な理由がある場合にのみ行いましょう。

皆さんのDecoratorからはどんな臭いがしますか?ぜひ原文末尾のコメント欄までお寄せください。

関連記事

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

Rails: テストのリファクタリングでアプリ設計を改良する(翻訳)

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

Ruby:「プリマドンナメソッド」の臭いの警告を私が受け入れるまで(翻訳)

$
0
0

概要

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

Ruby:「プリマドンナメソッド」の臭いの警告を私が受け入れるまで(翻訳)

まあ聞いてください。もともとこの記事のタイトルは「プリマドンナメソッドの臭いの警告に対抗するには」だったのですが、記事を書き上げる前に、David A. Blackの力強いメッセージを目にしたことで考えを改めました。以下はそこに至るまでに私のたどった旅路です。

これまでの仕事で、私は忠実に「コードの臭い/品質」の標準に厳密に従ってきました。ありとあらゆるプルリクをrubocopreekfastererでパスさせることこそ私にとって無上の幸せです。ときにはワインボトルの封を切ってRuby Criticをかけ、設計上のありとあらゆるまずい決定をあぶり出すことさえありました。

しかし、「正しい」コードの臭いを私が認めなければ一体どうなるでしょう?

「プリマドンナメソッド(Prima Donna method)」というコードの臭いは、少なくともここ10年の間Rubyのコーディング慣習にしっかり組み込まれており、Rubyコード品質チェッカーの主要なコンポーネントとして一般に認識されています。

「プリマドンナメソッド」とは、要は末尾に!の付くメソッドでありながら、それに対応する「!なしメソッド」がないものを指します(Rails開発者なら#save#save!を思い浮かべてください)。

訳注: Prima Donnaは「彼氏募集中=パートナーがいない」の連想かなと思ったりしましたが、社内で「思わせぶりだからなんじゃ?」という意見をもらって、その方がありそうに思えました。ご存じの方はTwitterまでどうぞ🙇

David A. Blackの名著『The Well Grounded Rubyist』は、プリマドンナメソッドが「コードの臭い」である理由を雄弁に述べています。

!で終わるメソッド名の!とは「このメソッドは危険である」ことを示す。より正確には「このメソッドは、(!が末尾にない)同等の別メソッドの危険なバージョンである」ことを示す。「危険」という言葉は相対的なものであり、!なしの同様のメソッドが!ありのメソッドに対応して存在していなければ、!という記号には何の意味もない。

従って、たとえばgsub!というメソッドはgsubメソッドの危険なバージョンであり、exit!exitの危険なバージョンであり、flatten!flattenの危険なバージョンである、という具合になる。

参考までに、この原則に違反する例は次のようになります。

class Foo

  def bar!
    puts 'Bar!'
  end

end

違反しない同等のコードは以下のようになります。

class Foo

  def bar
    puts 'Bar!'
  end

  def bar!
    puts 'Bar!'
  end

end

このコードの臭いは、メソッドの内容については一切関知していないことにもご注目ください。これは!付きのメソッド名が送信されていることに警鐘を鳴らしているに過ぎません。開発者はプログラミング経験を改善する共通言語を必要としていますし、メソッドがどのように命名されるべきかについて一定の期待を持つものです。

しかし、ワインを満載した我がRuby Criticのとあるセッションで、コードベースのいくつかのメソッドから違反が発見されました。それらの違反は、以下のいずれかの解決方法を必要としていました。

  1. 使いもしない(!なしの)相方メソッドを作成する
  2. yaml設定を変えて、この臭いを検出するlinterを無視する
  3. メソッド名に!を付けないようにする

しかしここが問題でした。そのときの私には「どの解決法にしたところでコードは少しも改善されないじゃないか」と思えました。臭いを無視することは、それ自体が私にとって臭いですし、意味のないメソッドを追加することも同様です。メソッド名を変更すると、私にとってのメソッド名のニュアンスが失われてしまう可能性がありました。

!に関する現状の方言チックな警告は、私にとってますます「新種の」危険な振る舞いに思えてきました。私には、以下のメソッドを分割する価値があるとは思えませんでした。

class Purchase < ApplicationRecord
  def void!
    # #update_columnを使うべきかどうかという話はまた別の記事で...
    update_column(:void, true)
  end
end

私はこのメソッド名のままで十分明快だと思いましたし、!記号が付いていれば今後「データベースを変更する操作である」ことが臨時の開発者にも伝わるだろうと思いました。そういったわけで、私の新発見と意見をコミュニティに伝えてみたのです。しかし、Black氏からの以下の的確なメッセージを目にしたとき、はっとさせられました。

破壊的なメソッド名だという理由で!を付けてはいけません。メソッド名に!を付けていいのは、その変更が本当に「危険」であり、かつそれと同等な「危険でない」!なしメソッドも存在する場合に限られます。破壊的なメソッド群が気まぐれに!で終わったり終わってなかったりと一貫していなければ、!の意味がずれ、ぼやけてしまいます。そしてしまいには、!の意味が完璧に失われてしまうでしょう。

破壊的メソッドを書いたときに、メソッド名だけではその危険性をうまく伝えられないことに気づくと、つい!を付けて意味を明確にしたい誘惑にかられるかもしれません。しかしそのやり方はダメです。破壊的メソッド名に!を付けないと破壊的であることがうまく伝わらないとしたら、それはそのメソッド名が元からダメなのであり、!を貼り付けてごまかしてはなりません。

こうした批判や「コードの臭い」の警告を「が間違ってる…だと?俺もプロなんだけどな!」と自分勝手に解釈するのは簡単です。しかしながら、ときには気持ちの高ぶりをぐっとこらえ、自分のコードスニペットが臭いを放つのを目にしたときの激情を冷静に再検討する価値はあります。

当初私の記事で鳴らそうと思っていた「警鐘」とは違うものになりました。「どんなメソッドでも破壊的になる可能性がある」のです。!は、既存のメソッドの破壊的なバージョンであることだけを伝えるためのものです。

関連記事

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

Railsアンチパターン: Decoratorの肥大化(翻訳)

Rails5「中級」チュートリアル(3-7)投稿機能: Service Object(翻訳)

$
0
0

概要

概要

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

Rails5中級チュートリアルはセットアップが短めで、RDBMSにはPostgreSQL、テストにはRSpecを用います。
原文が非常に長いので分割します。章ごとのリンクは順次追加します。

注意: Rails中級チュートリアルは、Ruby on Railsチュートリアル(https://railstutorial.jp/)(Railsチュートリアル)とは著者も対象読者も異なります。

目次

Rails5「中級」チュートリアル(3-7)投稿機能: Service Object(翻訳)

前述のとおり、コントローラ内にロジックを配置するとあっという間に複雑になってしまい、テストが複雑になってしまいます。そういうわけで、こうしたロジックを他の場所に切り出すのはよい考えです。私はそのためにデザインパターンを用いています。具体的にはService Objectと呼ばれるデザインパターンです(単にServiceとも呼ばれます)。

現時点のPostsControllerには以下のメソッドがあります(Gist)。

# controllers/posts_controller.rb
def get_posts
  branch = params[:action]
  search = params[:search]
  category = params[:category]

  if category.blank? && search.blank?
    posts = Post.by_branch(branch).all
  elsif category.blank? && search.present?
    posts = Post.by_branch(branch).search(search)
  elsif category.present? && search.blank?
    posts = Post.by_category(branch, category)
  elsif category.present? && search.present?
    posts = Post.by_category(branch, category).search(search)
  else
  end
end

Serviceを使ってこの大量の条件ロジックを取り除きたいと思います。Service Object(Service)デザインパターンは、単なる基本的なRubyのクラスです。Service Objectは、処理したいデータをこれに渡して、定義済みのメソッドを呼び出し、欲しい戻り値を受け取るという非常にシンプルなものです。

RubyではClassのinitializeメソッドにデータを渡します。これは他の言語で言う「コンストラクタ」に相当します。そしてクラス内で、定義済みのすべてのロジックを扱うメソッドを作成します。実際に作ってコードの様子を見てみましょう。

appディレクトリの下にservicesディレクトリを作成します。

app/services

このディレクトリの下にposts_for_branch_service.rbファイルを以下の内容で作成します(Gist)。

# services/posts_for_branch_service.rb
class PostsForBranchService
  def initialize(params)
    @search = params[:search]
    @category = params[:category]
    @branch = params[:branch]
  end

  # get posts depending on the request
  def call
    if @category.blank? && @search.blank?
      posts = Post.by_branch(@branch).all
    elsif @category.blank? && @search.present?
      posts = Post.by_branch(@branch).search(@search)
    elsif @category.present? && @search.blank?
      posts = Post.by_category(@branch, @category)
    elsif @category.present? && @search.present?
      posts = Post.by_category(@branch, @category).search(@search)
    else
    end
  end

end

前述したように、これはRubyの普通のクラスであり、パラメータを受け取るinitializeメソッドと、ロジックを扱うcallメソッドがあります。このロジックは、get_postsから持ってきたものです。

後は、get_postsメソッド内でこのクラスのオブジェクトを作成し、callメソッドで呼び出します。get_postsメソッドは次のような感じになります(Gist)。

# controllers/posts_controller.rb
  def get_posts
    PostsForBranchService.new({
      search: params[:search],
      category: params[:category],
      branch: params[:action]
    }).call
  end

変更をcommitします。

git add -A
git commit -m "Create a service object to extract logic
from the get_posts method"

spec

Serviceなどのデザインパターンのありがたい点は、単体テストが書きやすいことです。callメソッドのspecを書いて条件ごとにテストすればよいのです。

specディレクトリの下にservicesディレクトリを作成します。

spec/services

そのディレクトリの下に、posts_for_branch_service_spec.rbファイルを以下の内容で作成します(Gist)。

# spec/services/posts_for_branch_service_spec.rb
require 'rails_helper'
require './app/services/posts_for_branch_service.rb'

describe PostsForBranchService do

  context '#call' do
    let(:not_included_posts) { create_list(:post, 2) }
    let(:category) { create(:category, branch: 'hobby', name: 'arts') }
    let(:post) do
      create(:post,
              title: 'a very fun post', 
              category_id: category.id)
    end
    it 'returns posts filtered by a branch' do
      not_included_posts
      category
      included_posts = create_list(:post, 2, category_id: category.id)
      expect(PostsForBranchService.new({branch: 'hobby'}).call).to(
        match_array included_posts
      )
    end

    it 'returns posts filtered by a branch and a search input' do
      not_included_posts
      category
      included_post = [] << post
      expect(PostsForBranchService.new({branch: 'hobby', search: 'fun'}).call).to(
        eq included_post
      )
    end

    it 'returns posts filtered by a category name' do
      not_included_posts
      category
      included_post = [] << post
      expect(PostsForBranchService.new({branch: 'hobby', category: 'arts'}).call).to(
        eq included_post
      )
    end

    it 'returns posts filtered by a category name and a search input' do
      not_included_posts
      category
      included_post = [] << post
      expect(PostsForBranchService.new({name: 'arts', 
                                        search: 'fun', 
                                        branch: 'hobby'}).call).to eq included_post
    end
  end
end

このファイルの冒頭でposts_for_branch_service.rbファイルが読み込まれ、callメソッドの各条件がテストされます。

変更をcommitします。

git add -A
git commit -m "Add specs for the PostsForBranchService"

関連記事

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

新しいRailsフロントエンド開発(1)Asset PipelineからWebpackへ(翻訳)

Ruby内部の文字列を共有してスピードアップする(翻訳)

$
0
0

概要

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

Ruby内部の文字列を共有してスピードアップする(翻訳)

メモリを削減し、かつスピードもアップするようなパッチを書き上げられることはそう多くはありません。いつもならメモリとスピードのトレードオフでにらめっこするところですが、今回はうまいことに1つのパッチで両方を実現できました。そこで本記事では、Rubyに投げた#14460チケットについて書きたいと思います。このパッチは「起動後の」Railsアプリのメモリ使用量を4%削減し、requireを約35%高速化します。

実はこのパッチを書いていたときはメモリ使用量の削減を狙っていたのですが、やってみるとメモリ使用量の削減と同時に実行速度も向上しました。なので本当はタイトルを「Rubyのメモリ使用量の削減」とでもしたかったところですが、既に同じタイトルで記事を書いていたのでした。

文字列の共有による最適化

前回の記事でも触れたように、Rubyのオブジェクトは40バイトに制限されています。しかし文字列は40バイトよりずっと長くなることがあります。文字列はどんなふうに格納されているのでしょうか?文字列を表す構造体をご覧いただければ、そこにchar *があるのがわかります。

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;
};

文字列構造体のptrフィールドは1バイトの配列を指しており、そこが私たちの文字列になります。つまり、文字列の実際のメモリ使用量はオブジェクトで約40バイトの他に、文字列の長さ分増えます。この配置を図示すると次のような感じになります。

char配列を指すRString

この場合、実際のメモリ割り当てはRStringオブジェクトと「hello world」char配列の2つになります。RStringオブジェクトの方はGC(ガベージコレクション)で割り当てられた40バイトのRubyオブジェクトであり、char配列の方はシステムのmalloc実装によって割り当てられたものです。

ちょっとメモ: 別な最適化手法として「埋め込み」も行われています。横道に逸れない程度にご説明すると、「埋め込み」は「十分小さな」文字列を単にRString構造体内部に直接保存することを指します。これについては別記事でご説明してもよいのですが、本記事では常に2種類の異なるメモリ割り当てが存在するということにしておきます。

このchar配列で文字列の途中を指すことによって部分文字列を表現します。たとえば、Rubyオブジェクトが2つあって、1つは「hello world」文字列を表し、もう1つは「world」文字列を表すとすると、これらを1つのchar配列バッファだけに割り当てます。

char配列を共有するRStrings

この例の割り当ては3つしかありません。うち2つはRuby文字列オブジェクト用にGCから割り当てられ、1つはchar配列用にmallocで割り当てられます。ObjectSpaceモジュールを使ってスライス後のオブジェクトのメモリサイズを調べると、この最適化を実際に観察できます。

>> require 'objspace'
=> true
>> str = "x" * 9000; nil
=> nil
>> ObjectSpace.memsize_of str
=> 9041
>> substr = str[30, str.length - 30]; nil
=> nil
>> str.length
=> 9000
>> substr.length
=> 8970
>> ObjectSpace.memsize_of substr
=> 40

上の例では、最初に9000文字の文字列が割り当てられます。続けて文字列のメモリサイズを測定すると、文字用の9000文字の他に、Rubyオブジェクトのオーバーヘッド分を含め、メモリサイズの合計は9041文字になっています。次に元の文字列から最初の30文字を部分文字列としてスライスします。元の文字列は9000文字なので、予想どおり部分文字列は8970文字となりました。この部分文字列のサイズを調べてみると、わずか40バイトしかありません。この理由は、新しい文字列でのメモリ割り当ては、新しいRubyオブジェクト分しか必要とせず、新しいオブジェクトは(上の図のように)元の文字列の文字バッファの途中を指しているだけだからです。

この最適化が使えるのは文字列だけではありません。次のように配列でも使えます。

>> list = ["x"] * 9000; nil
=> nil
>> ObjectSpace.memsize_of(list)
=> 72040
>> list2 = list[30, list.length - 30]; nil
=> nil
>> ObjectSpace.memsize_of(list2)
=> 40

実際、関数型プログラミングではデータ構造がイミュータブルなので、この最適化が非常に有効です。イミュータブルでない(=変更可能な)言語では、元の文字列が変更される場合についても取り扱わなければならなくなりますが、データ構造がイミュータブルな言語ならここが大きく最適化されます。

文字列共有による最適化の限界

文字列共有による最適化にも限界というものがあります。この最適化を利用するには、常に文字列の端まで進めなければなりません。言い換えると、文字列の(端にかからない)途中の部分を取り出す最適化はできないのです。先のサンプル文字列で、先頭から15文字、末尾から15文字を除いた真ん中の部分を取り出して、メモリサイズを観察してみましょう。

>> str = "x" * 9000; nil
=> nil
>> str.length
=> 9000
>> substr = str[15, str.length - 30]; nil
=> nil
>> substr.length
=> 8970
>> ObjectSpace.memsize_of(substr)
=> 9011

最初の例と比べると、上の例では部分文字列のメモリサイズがずっと大きくなってしまいました。これはRubyが部分文字列を保存するためのバッファを作成しなければならなかったためです。教訓「文字列をスライスするときは、常に左から右へ行うこと」。

ここで1つ興味深い点について考えてみたいと思います。次のプログラムの末尾にあるsubstrのメモリサイズはいくつになるでしょうか?このプログラムは実際にどれだけのメモリを消費するでしょうか?このstrオブジェクトはまだ「生きている」のでしょうか?生きているとしたらどうやって見つければよいでしょうか?

require 'objspace'

str = "x" * 9000
substr = str[30, str.length - 30]
str = nil
GC.start

# substrのメモリサイズはいくつになるでしょう?
# このプログラムの実際のメモリ消費量はどのぐらいでしょう?
# `str`はGC後も生きているでしょうか?
# ヒント: `ObjectSpace.dump_all`で調べましょう
# (試す場合は`--disable-gems`を付けて実行するのがおすすめです)

ここまでご説明した最適化は、Cの文字列に対してもRubyの場合とまったく同じ方法で機能します。この最適化を、Rubyのメモリ使用量削減とrequireの高速化に用います。

メモリ使用量削減とrequireの高速化

requireの高速化手法の説明が終わりましたので、この問題を見てみることにしましょう。その後で、文字列共有の最適化を適用して実際にrequireのパフォーマンスを改善します。

Rubyでは、プログラムでファイルがrequireされるたびにファイルが既にrequire済みかどうかをチェックしなければなりません。グローバル変数$LOADED_FEATURESには、それまでにrequireされたすべてのファイルリストが保存されます。もちろん、このリストを上から下まで愚直に探索していたら、リストが大きくなるほど速度がガタ落ちになってしまうので、Rubyでは$LOADED_FEATURESリストのエントリ探索のためのハッシュを保持しています。このハッシュはloaded_features_indexと呼ばれ、仮想マシンのこの構造体に保存されます。

このハッシュのキーは、特定のファイルをrequireするときにrequireに渡される文字列です。ハッシュの値は、実際にrequireされたファイルの$LOADED_FEATURES配列のインデックスです。すなわち、たとえばシステムに/a/b/c.rbというファイルがあるとすると、ハッシュのキーは以下になります。

  • “/a/b/c.rb”
  • “a/b/c.rb”
  • “b/c.rb”
  • “c.rb”
  • “/a/b/c”
  • “a/b/c”
  • “b/c”
  • “c”

読み込みパスをうまく工夫すると、上のどの文字列でも/a/b/c.rbの読み込みに使える可能性が生じるので、インデックスにはこれらをすべて保持しておく必要があります。たとえば、ruby -I / -e"require 'a/b/c'"ruby -I /a -e"require 'b/c'"'などを実行すると、いずれも同じファイルを指します。

loaded_features_indexハッシュはfeatures_index_add関数でビルドされます。この関数の一部をちょっと覗いてみましょう。

static void
features_index_add(VALUE feature, VALUE offset)
{
    VALUE short_feature;
    const char *feature_str, *feature_end, *ext, *p;

    feature_str = StringValuePtr(feature);
    feature_end = feature_str + RSTRING_LEN(feature);

    for (ext = feature_end; ext > feature_str; ext--)
        if (*ext == '.' || *ext == '/')
            break;
    if (*ext != '.')
        ext = NULL;
    /* これで`ext`は、`feature`の末尾にある%r{^\.[^./]*$}にマッチする
    文字列だけを指す(文字列がない場合はNULLになる) */

この関数はパラメータとしてfeatureoffsetを1つずつ受け取ります。featureは、requireされたファイルのフルネーム(拡張子などすべてを含む)です。offsetは、読み込まれたfeatureのリストのインデックスで、この文字列はそこにあります。この関数の冒頭では、文字列を末尾から先頭に向かってスキャンし、ピリオド.かスラッシュ/があるかどうかを調べます。.があればそのファイルには拡張子があることが認識され(Rubyでは拡張子のないファイルでもrequireできることにご注意!)、/があれば拡張子がないと仮定します。

    while (1) {
        long beg;

        p--;
        while (p >= feature_str && *p != '/')
            p--;
        if (p < feature_str)
            break;
        /* *p == '/'の場合: `feature`で'/'が見つかるたびにここを通る */
        beg = p + 1 - feature_str;
        short_feature = rb_str_subseq(feature, beg, feature_end - p - 1);
        features_index_add_single(short_feature, offset);
        if (ext) {
            short_feature = rb_str_subseq(feature, beg, ext - p - 1);
            features_index_add_single(short_feature, offset);
        }
    }

続いて、文字列を末尾から先頭に向かってスキャンし、/があるかどうかを調べます。/が見つかるたびに、rb_str_subseqを用いて部分文字列を取り出し、features_index_add_singleを呼び出してその部分文字列を登録します。rb_str_subseqは、先ほどRubyで行ったのと同じ方法で部分文字列を取得し、同じ最適化を適用します。

if (ext)条件は拡張子のあるファイルを扱いますが、問題はまさにここから始まるのです。この条件はfeatureの部分文字列を受け取りますが、文字列の端まで進むわけではありません。ファイルの拡張子は除外しなければならないのです。つまり、ここでは背後の文字列がコピーされます。そういったわけで、rb_str_subseqへの2回の呼び出しによってメモリ割り当てが合計3回発生します。うち2つはRubyオブジェクト(この関数はRubyオブジェクトを返します)で、「拡張子のない部分文字列」の場合は文字列をコピーするためのmallocが1回発生します。

この関数はfeatures_index_add_singleを呼ぶことで部分文字列をインデックスに追加します。features_index_add_single関数から一箇所抜粋したいと思います。

    features_index = get_loaded_features_index_raw();
    st_lookup(features_index, (st_data_t)short_feature_cstr, (st_data_t *)&this_feature_index);

    if (NIL_P(this_feature_index)) {
        st_insert(features_index, (st_data_t)ruby_strdup(short_feature_cstr), (st_data_t)offset);
    }

このコードはインデックス内の文字列を探索し、文字列がインデックスに見当たらない場合はインデックスに文字列を追加します。呼び出し元では新しいRuby文字列にメモリが割り当てられますが、この文字列がGCされる可能性があるため、この関数でruby_strdupを呼び出して文字列をコピーし、ハッシュのキーとします。ここで重要なのは、このハッシュのキーはRubyオブジェクトではなく、Rubyオブジェクトから来たchar *ポインタである点です(先ほどのchar *フィールドです)。

メモリ割り当てを数えてみましょう。ここまではRubyオブジェクト2つ(1つはファイル拡張子あり、もう1つはファイル拡張子なし)、共有されない部分文字列用のmallocが1つ、そして文字列をハッシュにコピーするとmallocがさらに2つ増えます。つまり、メモリ割り当てはfeatures_index_add内のwhileループを繰り返すたびに5回(Rubyオブジェクト2つとmallocが3つ)ずつ行われます。

このような状況は図で説明するのがよいでしょう。以下の図はメモリ割り当てと互いの関係を示したものです。

trunkのメモリ割り当て

この図では、/a/b/c.rbパスをインデックスに追加してハッシュエントリが8つ生成されたときのメモリ内の配置の様子が示されています。

青いノードは、インデックスにパスを追加する呼び出しを行う前から「生きている」メモリ割り当てを、赤いノードはインデックス追加途中の一時メモリ割り当て(ある時点で解放される)を、黒いノードは、インデックスにパスを追加中に割り当てられたがインデックスへのパス追加完了後も「生きている」メモリ割り当てをそれぞれ表します。実線矢印は実際の参照を表します。点線は関係を示しますが実際には参照ではありません(ある文字列が別の場所からruby_strdupされたときなど)。

この図には多数のノードがあってかなり込み入っていますが、これをすっきりクリーンアップしましょう!

文字列共有の最適化を適用する

最適化の様子をわかりやすく示すため、CコードをRubyコードに移植しました。

$features_index = {}

def features_index_add(feature, index)
  ext = feature.index('.')
  p = ext ? ext : feature.length

  loop do
    p -= 1
    while p > 0 && feature[p] != '/'
      p -= 1
    end
    break if p == 0

    short_feature = feature[p + 1, feature.length - p - 1] # 新しいRubyオブジェクト
    features_index_add_single(short_feature, index)

    if ext # ファイル拡張子がある場合は切り出す
      short_feature = feature[p + 1, ext - p - 1] # 新しいRubyオブジェクト + malloc
      features_index_add_single(short_feature, index)
    end
  end
end

def features_index_add_single(str, index)
  return if $features_index.key?(str)

  $features_index[str.dup] = index # malloc
end

features_index_add "/a/b/c.rb", 1

既に説明したとおり、文字列共有の最適化は、共有されている文字列の端を部分文字列が含んでいる場合にしか効きません。つまり部分文字列は、元の文字列の左側からしか取り出せません。

最初に行える変更は、文字列を「拡張子あり」「拡張子なし」の2つの場合に分けることです。「拡張子なし」の場合、if文は文字列を末尾までスキャンしないので、常に新しい文字列を割り当てます。拡張子を含まない新しい文字列を作成するようにすれば、mallocの場合を1つ除外できます。

$features_index = {}

def features_index_add(feature, index)
  no_ext_feature = nil
  p              = feature.length
  ext            = feature.index('.')

  if ext
    p = ext
    no_ext_feature = feature[0, ext] # 新しいRubyオブジェクト + malloc
  end

  loop do
    p -= 1
    while p > 0 && feature[p] != '/'
      p -= 1
    end
    break if p == 0

    short_feature = feature[p + 1, feature.length - p - 1] # 新しいRubyオブジェクト
    features_index_add_single(short_feature, index)

    if ext
      len = no_ext_feature.length
      short_feature = no_ext_feature[p + 1, len - p - 1] # 新しいRubyオブジェクト
      features_index_add_single(short_feature, index)
    end
  end
end

def features_index_add_single(str, index)
  return if $features_index.key?(str)

  $features_index[str.dup] = index # malloc
end

features_index_add "/a/b/c.rb", 1

この変更によって、関数は新しい文字列を割り当てる代わりに、常にどちらの文字列も末尾までスキャンします。「左からスキャンを開始できる」文字列が2つになったので、ループ内で新しい部分文字列のmallocを回避できました。この「拡張子を含まない」新しい文字列を割り当てる変更についてはこちらでご覧いただけます。

以下の図は、スライスを1つ取り出してから文字列を共有した後のメモリ内の配置と関係を示したものです。

スライス共有後のメモリ割り当て

この図から、「拡張子なし」の部分文字列を最初に割り当ててからスライスを取り出すことによって文字列バッファを排除できたことがわかります。

このパッチでは他にも2つの最適化を適用しています。残念ながらこれはC言語固有の手法なのでRubyで説明するのは簡単ではありません。

Rubyオブジェクトのメモリ割り当てを排除する

既存のコードでは文字列のスライスにRubyを使っているので、新しいRubyオブジェクトが1つ割り当てられます。文字列が2つになったことで常に左側から文字列を取れるようになりました。つまり、Cのポインタを用いて部分文字列を「作成」できるようになったのです。文字列をスライスするのにRuby APIに頼らず、Cのポインタで部分文字列の開始地点をシンプルに指しています。インデックスを管理するハッシュテーブルではCの文字列をキーに使っているので、Rubyオブジェクトを引き回す代わりに、単に文字列へのポインタを渡しています。

-       short_feature = rb_str_subseq(feature, beg, feature_end - p - 1);
-       features_index_add_single(short_feature, offset);
+       features_index_add_single(feature_str + beg, offset);
        if (ext) {
-           short_feature = rb_str_subseq(feature, beg, ext - p - 1);
-           features_index_add_single(short_feature, offset);
+           features_index_add_single(feature_no_ext_str + beg, offset);
        }
     }
-    features_index_add_single(feature, offset);
+    features_index_add_single(feature_str, offset);
     if (ext) {
-       short_feature = rb_str_subseq(feature, 0, ext - feature_str);
-       features_index_add_single(short_feature, offset);
+       features_index_add_single(feature_no_ext_str, offset);

文字列を指すポインタを使うことで、コードがシンプルになりました。feature_strは「ファイル拡張子あり」の文字列の先頭を指すポインタであり、feature_no_ext_strは「ファイル拡張子なし」の文字列の先頭を指すポインタです。begは、スライス対象の文字列の先頭から数えた文字数を表します。後は、それぞれのポインタの先頭にbegを足したものをfeatures_index_add_singleに渡すだけで済みます。

以下の図では、「add single」関数が背後のchar *ポインタに直接アクセスできるおかげで中間のRubyオブジェクトが不要になったことがわかります。

部分文字列へのポインタ実装後のメモリ割り当て

malloc呼び出しを排除する

最後にruby_strdup呼び出しを排除しましょう。既に述べたとおり、新しいRuby文字列の割り当てが発生する可能性があります。このRuby文字列はGCによって解放されるかもしれないので、ruby_strdupで文字列のコピーをインデックスのハッシュ内に取っておかなければなりませんでした。渡されるfeature文字列は$LOADED_FEATURESグローバル配列にも保存されるようになり、この配列はGCされないので、文字列のコピーが不要になりました。しかし、「拡張子なし」の方は新しい文字列を作成していたので、GCされる可能性がありました。これらの文字列がGCされないようにすれば、コピーは一切不要になります。

2つの文字列を「生かしておく」ために、仮想マシン上に配列を1つ追加しました(仮想マシンはプロセスの実行中ずっと生きています)。

     vm->loaded_features = rb_ary_new();
     vm->loaded_features_snapshot = rb_ary_tmp_new(0);
     vm->loaded_features_index = st_init_strtable();
+    vm->loaded_features_index_pool = rb_ary_new();

続いて、割り当ての直後にrb_ary_pushを用いてこの新しい文字列を配列に追加しました。

+       short_feature_no_ext = rb_fstring(rb_str_freeze(rb_str_subseq(feature, 0, ext - feature_str)));
+       feature_no_ext_str = StringValuePtr(short_feature_no_ext);
+       rb_ary_push(get_loaded_features_index_pool_raw(), short_feature_no_ext);

これでインデックスのハッシュ内の文字列が生きたまますべて共有されるようになりました。つまり、GC発生時に文字列が解放されることなく、安全にruby_strdupを削除できるようになったのです。

     if (NIL_P(this_feature_index)) {
-       st_insert(features_index, (st_data_t)ruby_strdup(short_feature_cstr), (st_data_t)offset);
+       st_insert(features_index, (st_data_t)short_feature_cstr, (st_data_t)offset);
     }

この変更後、Ruby文字列オブジェクト内に潜むchar配列をハッシュのキーが直接指すようになり、そのおかげでメモリコピーが完全に不要になりました。

キーに文字列インデックスを用いる

この新しいアルゴリズムでは2つの割り当てが行われます。1つは元の文字列をコピーした「拡張子なし」用、1つはそれをラップするRStringオブジェクトです。「loaded features index pool」配列に保存された新規作成文字列はGCから保護され、文字列をコピーする必要なしに文字列の配列を直接指せるようになりました。

「loaded features」配列に追加されるどのファイルについても、割り当てにO(N)Nは文字列内のスラッシュ数)が必要だったのを、文字列のスラッシュ数にかかわらず2つの割り当てだけで常にまかなえるように変更されました。

おしまい

文字列共有によって、Railsの起動プロセス中のシステムコールを76,000以上削減し、メモリのフットプリントを4%削減し、requireを35%高速化することに成功しました。来週は巨大なアプリで統計を取ってこの変更がどれだけうまくいっているかを見てみたいと思います。

お読みいただきありがとうございました!

関連記事

Rubyのヒープをビジュアル表示する(翻訳)

Ruby: mallocでマルチスレッドプログラムのメモリが倍増する理由(翻訳)

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


週刊Railsウォッチ(20180309)RubyGems.orgのTLS 1.0/1.1接続非推奨、2年に1度のRailsアンケート、DockerのMoby Project、Ruby拡張をRustで書けるruruほか

$
0
0

こんにちは、hachi8833です。スマホを電車に置き忘れてしょんぼりしてます(´・ω・`)。

3月のウォッチ、いってみましょう。今回はつっつき成分は少なめです。

Rails: 今週の改修

5.2リリース直前だけに、大きな修正は見当たらない感じでした。5.2-stableと6.0向けmasterの両方に多数の同じ修正が当たっています。

まずは5.2-stableから。

関連付けの作成と検索の挙動を一貫させた

これは#29722の別案で、かつ#29601#a1fcbd9の復活です。
現時点では、関連付けの作成と通常の関連付け探索ではstore_full_sti_classが反映されておらず、eager loadingとpreloadingはこの設定を考慮しています。これでは、store_full_sti_class = falseの場合にeager loadingやpreloadingではポリモーフィックなレコードが作成されても検索できなくなります。
関連付けの作成と検索の挙動は一貫すべきです。
同PRより

# activerecord/lib/active_record/associations/preloader/association.rb#:121
             if reflection.type
-              scope.where!(reflection.type => model.base_class.sti_name)
+              scope.where!(reflection.type => model.base_class.name)
             end
# activerecord/lib/active_record/reflection.rb#L196
         if type
-          klass_scope.where!(type => foreign_klass.base_class.sti_name)
+          klass_scope.where!(type => foreign_klass.base_class.name)
         end

つっつきボイス:store_full_sti_classってメソッドかなと思ったら設定(属性)だったんですね」

# activerecord/lib/active_record/inheritance.rb#L43
  module Inheritance
    extend ActiveSupport::Concern

    included do
      # Determines whether to store the full constant name including namespace when using STI.
      # This is true, by default.
      class_attribute :store_full_sti_class, instance_writer: false, default: true
    end

その後#sti_nameString#demodulizeと追ってみました。

demodulizeは名前空間の部分を取り払うメソッドか↓」

'ActiveSupport::Inflector::Inflections'.demodulize # => "Inflections"
'Inflections'.demodulize                           # => "Inflections"
'::Inflections'.demodulize                         # => "Inflections"
''.demodulize                                      # => ''

cookie内の期限切れ情報の問題を修正

# actionpack/lib/action_dispatch/middleware/cookies.rb#L491
       private
         def expiry_options(options)
-          if options[:expires].respond_to?(:from_now)
-            { expires_in: options[:expires] }
+          if request.use_authenticated_cookie_encryption
+            if options[:expires].respond_to?(:from_now)
+              { expires_in: options[:expires] }
+            else
+              { expires_at: options[:expires] }
+            end
           else
-            { expires_at: options[:expires] }
+            {}
           end
         end

期限切れ情報を無効にするときにuse_authenticated_cookie_encryptionをチェックするようになりました。

Capybara 3.xに対応

# Gemfile#L16
-gem "capybara", "~> 2.15"
+gem "capybara", ">= 2.15", "< 4.0"

つっつきボイス: 「今のCapybaraは3.0.0.rc1がついこの間出たところなんですね」

PDFプレビューとしてmutoolsの他にPopplerも提供

MuPDFツールのライセンスが厳しいので、GPLライセンスのPopplerも使えるようにしたそうです。なお、検索してたらMUTOOLSという無関係な音楽制作ツールが出てきました。

# activestorage/lib/active_storage/engine.rb#L16
     config.active_storage = ActiveSupport::OrderedOptions.new
-    config.active_storage.previewers = [ ActiveStorage::Previewer::PDFPreviewer, ActiveStorage::Previewer::VideoPreviewer ]
+    config.active_storage.previewers = [ ActiveStorage::Previewer::PopplerPDFPreviewer, ActiveStorage::Previewer::MuPDFPreviewer, ActiveStorage::Previewer::VideoPreviewer ]
     config.active_storage.analyzers = [ ActiveStorage::Analyzer::ImageAnalyzer, ActiveStorage::Analyzer::VideoAnalyzer ]
     config.active_storage.paths = ActiveSupport::OrderedOptions.new

参考: Poppler : PDFのコマンドラインツール | PDF

ActiveStorageで自分自身の置き換え時に添付blobがパージされないように修正

ここからはmasterブランチです。

# activestorage/lib/active_storage/attached/one.rb#L65
     private
       def replace(attachable)
-        blob.tap do
+        unless attachable == blob
+          previous_blob = blob
+
           transaction do
             detach
             write_attachment build_attachment_from(attachable)
           end
-        end.purge_later
+
+          previous_blob.purge_later
+        end
       end

has_one/belongs_toリレーションシップのdependent:を修正 => reject?

あるクラスのbelongs_tohas_oneリレーションシップでdependent: :destroyオプションを有効にした場合は、依存先クラスが削除された場合にこのクラスを削除すべきではない。
同commitより

# 同issueの再現手順より
class Parent
  has_one :child, dependent: :destroy
end

class Child
  belongs_to :parent, inverse_of: :child
  before_destroy { throw :abort }
end

c = Child.create
p = Parent.create(child: c)

p.destroy

# 期待する動作                  子がabortされても親はdestroyされない
p.destroyed? # false
p.child.destroyed? # false

# 実際の動作

p.destroyed? # true           #親がdestroyされてる
p.child.destroyed? # false

URI.unescapeでエスケープありなしのUnicodeを混在できるように修正

# 同commitより
    URI.unescape("\xe3\x83\x90")  # => "バ"
    URI.unescape("%E3%83%90")  # => "バ"
    URI.unescape("\xe3\x83\x90%E3%83%90")  # =>
                                         # Encoding::CompatibilityError
# activesupport/lib/active_support/core_ext/uri.rb#L13
       # YK: My initial experiments say yes, but let's be sure please
       enc = str.encoding
       enc = Encoding::UTF_8 if enc == Encoding::US_ASCII
-      str.gsub(escaped) { |match| [match[1, 2].hex].pack("C") }.force_encoding(enc)
+      str.dup.force_encoding(Encoding::ASCII_8BIT).gsub(escaped) { |match| [match[1, 2].hex].pack("C") }.force_encoding(enc)
     end

つっつきボイス:\xe3\x83\x90%E3%83%90を復元できてなかったのか」「引用符がにかかってて読みにくいですねー」

Rails起動時の明示的なハンドラオプション-uを追加

ハンドラ(PumaとかThinとか)の指定と環境の指定-eが混じってやりにくかったからだそうです。

$ bin/rails server thin     # 従来

$ bin/rails server -u thin  # 今後

Rails

globalize: ActiveRecordをi18n化するgem


同リポジトリより

# 同リポジトリより
I18n.locale = :en
post.title # => Globalize rocks!

I18n.locale = :he
post.title # => גלובאלייז2 שולט!

つっつきボイス: 「これ某案件のGemfileに入ってる…」「Readmeにはi18nのデファクトって書いてありますね」

これまでウォッチに登場した他のi18n関連gemをとりあえずリストアップしてみました。

PodCast: React on RailsとWebpackerの話(Hacklinesより)

★3700超えです。npmからyarnを使うように変わっています。react_on_railsのリポジトリを見ると「Webpacker 3.0に対応」とあります。先週4.0が出たばかりでしたね。

参考: react_on_rails導入

activerecord-turntable: シャーディングgem(Awesome Rubyより)

# 同リポジトリより
                  +-------+
                  |  App  |
                  +-------+
                      |
       +---------+---------+---------+---------+
       |         |         |         |         |
  `--------` `-------` `-------` `-------` `-------`
  | Master | |UserDB1| |UserDB2| |UserDB3| | SeqDB |
  `--------` `-------` `-------` `-------` `-------`

    development:
      clusters:
        user_cluster: # <-- cluster name
          algorithm: range_bsearch # <-- `range`, `range_bsearch` or `modulo`
          seq:
            user_seq: # <-- sequencer name
              seq_type: mysql # <-- sequencer type
              connection: user_seq_1 # <-- sequencer database connection setting
          shards:
            - connection: user_shard_1 # <-- shard name
              less_than: 100           # <-- shard range(like mysql partitioning) If you are using a modulo algorithm, it doesn't need it.
            - connection: user_shard_2
              less_than: 200
            - connection: user_shard_3
              less_than: 2000000000

つっつきボイス: 「ドリコムさんのgemです」「ドリコムさんはこういうのをgemに切り出す文化があるって聞いたことありました」「請負開発だと難しいところ…」「以前にPostgreSQLのシャーディング記事↓を翻訳したのを思い出しました」

ハンズオン: PostgreSQLシャーディング(翻訳)

be_jsonなどのJSON向けRSpecマッチャー(RubyFlowより)

★はまだ少ないですが。

# 同記事より
expect(response).to be_json('meta' => {})
expect(response).to be_json include('meta' => include('next' => end_with('offset=200')))
expect(response).to be_json hash_excluding('total')

つっつきボイス: 「こういうマッチャーが欲しい気持ちってむちゃくちゃよくわかりますね: レスポンスをこうやってjqっぽくテストするみたいな」「jqは癖強いけど速くて好きです♡」「be_jsonってメソッド名、JSONであるかどうかをチェックする、みたいに見えてちょい紛らわしいかも」

「ところでRSpecって何とかして英語的にしようとしてむしろハマっているような気がしません?」「もしかするとminiTestでよかったんじゃないかみたいな: この間のRuby25のMatzの対談でもそんな話が出てましたね: RSpecのマッチャーはいいと思いますが」「RSpecのマッチャーってそのままminiTestには持っていけないんですよね?」「やったことないけどできなさそうな気がします」「RSpecの構文ってDRYに書けDRYに書けって誘ってる気がする」

Rails: テスティングアンチパターン –後編(翻訳)

rack-reducer: Rackアプリで使えるURL書き換えgem(RubyFlowより)

# 同リポジトリより
# app/controllers/artists_controller.rb
class ArtistsController < ApplicationController
  def index
    @artists = Artist.reduce(params)
    @artists.all.to_json
  end
end

# app/models/artist.rb
class Artist < ActiveRecord::Base
  extend Rack::Reducer # makes `self.reduce` available at class level

  # Configure by calling
  # `reduces(some_initial_scope, filters: [an, array, of, lambdas])`
  #
  # Filters can use any methods your initial dataset understands.
  # Here it's an ActiveRecord query, so filters use AR query methods.
  reduces self.all, filters: [
    ->(name:) { where('lower(name) like ?', "%#{name.downcase}%") },
    ->(genre:) { where(genre: genre) },
    ->(order:) { order(order.to_sym) },
  ]
end

つっつきボイス: 「Rackミドルウェアかと思ったら、Rackを使うアプリで使えるgemですね: Sinatraでも使えるとか」「あ、ほんとだ」「でもこの名前だとRackミドルウェアかと思っちゃう(汗」

Service Objectがアンチパターンな理由(RubyFlowより)

# 同記事より
class Rating < ActiveRecord::Base
  belongs_to :user
  belongs_to :media

  def self.rate(user, media, rating)
    rating_record = Rating.find_or_initialize_by(user: user, media: media)
    rating_record.rating = rating
    rating_record.save

    # do some extra stuff here like run algorithmic data processing,
    # add social activities to timelines, etc.
  end
end

# And the updated controller code:
media = Media.find(params[:media_id])
rating = params[:rating].to_i
Rating.rate(current_user, media, rating)

つっつきボイス: 「結論はconcernsとPOROにしようってことみたいです」「この記事にもモーフィアスの絵が」「みんな好きですねー」

「先月の社内勉強会で『Service Objectは実は1種類ではないのでは』『少なくとも2種類あるのではないか』って話が出ましたけど、今のところそういう記事を英語圏でも見かけないんですよ」「推理小説の犯人が実は2人いる!とかみたいな?」「11人いる!とかw」

ruby_server_timing: RailsのサーバーメトリックスをChrome’s Developer Toolsで(GitHub Trendingより)


同リポジトリより


つっつきボイス: 「Chromeのコンソールで見られるのはちょっと便利そうっすね: ところでこれはScoutのサービスから取ってきてるみたいだけど有料?」「リポジトリにはScoutのアカウントは不要って書いてるから大丈夫なのかな?」


github.com/scoutappより

faster_path gemが0.3.1になってさらに高速化(RubyFlowより)

昨年のウォッチで取り上げたfaster_path gemです。Rustで書かれています。

FasterPath Rust Implementation Ruby 2.5.0 Implementation Time Shaved Off
FasterPath.absolute? Pathname#absolute? 91.9%
FasterPath.add_trailing_separator Pathname#add_trailing_separator 31.2%
FasterPath.children Pathname#children 13.2%
FasterPath.chop_basename Pathname#chop_basename 54.5%
FasterPath.cleanpath_aggressive Pathname#cleanpath_aggressive 73.8%
FasterPath.cleanpath_conservative Pathname#cleanpath_conservative 70.7%
FasterPath.del_trailing_separator Pathname#del_trailing_separator 80.6%
FasterPath.directory? Pathname#directory? 11.3%
FasterPath.entries Pathname#entries 8.4%
FasterPath.extname File.extname 41.2%
FasterPath.has_trailing_separator? Pathname#has_trailing_separator 67.6%
FasterPath.plus Pathname#join 66.4%
FasterPath.plus Pathname#plus 81.4%
FasterPath.relative? Pathname#relative? 84.1%
FasterPath.relative_path_from Pathname#relative_path_from 69.8%

同リポジトリより


つっつきボイス: 「これは速い: 置き換えは自分でやらないといけないんですかね?」「refinementできるみたいです↓」

require "faster_path/optional/refinements"
using FasterPath::RefinePathname

ruru: Rustで書けるRuby拡張(RubyFlowより)


同リポジトリより

# 同リポジトリより
#[macro_use]
extern crate ruru;

use ruru::{Boolean, Class, Object, RString};

methods!(
   RString,
   itself,

   fn string_is_blank() -> Boolean {
       Boolean::new(itself.to_string().chars().all(|c| c.is_whitespace()))
   }
);

#[no_mangle]
pub extern fn initialize_string() {
    Class::from_existing("String").define(|itself| {
        itself.def("blank?", string_is_blank);
    });
}

つっつきボイス: 「こんな感じで書けるんですねRust」「externとかマクロとかあって心がちょっとざわつく」「Ruby本家がさっきのfaster_pathみたいなのを標準で取り込んだりしないかなと思ったり: やらないだろうけど」

参考: プログラミング言語Rust

Railsについてのアンケート募集: 第5回(Hacklinesより)

Planet Argon社が、2年に一度ぐらいのペースで取っているRailsの使われ方などについてのアンケートです。


2016年度アンケートより


つっつきボイス: 「アンケート長いんで今は回答しなかったんですが、過去の回答が結構面白いかも」「おー、例外トラッキングツールはAirBrakeがトップ、とか↑」「GitHubの★とかとまた違った角度でいいかも」


planetargon.comより

その他Rails小物記事

Ruby trunkより

提案: ハッシュの値を省略できるようにしたい(継続)

x = 1
y = 2
h = {x:, y:}
p h #=> {:x=>1, :y=>2}
def login(username: ENV["USER"], password:)
  p(username:, password:)
end

login(password: "xxx") #=> {:username=>"shugo", :password=>"xxx"}

つっつきボイス: 「2つ目のはワカル気がしますけど、1つ目はどうかなー?」「こういうことができる実装の言語がどこかにあったような気がする…ES6かなと思ったら違った」

実際にはないメソッドがRandom::Formatterドキュメントにある

Range#===内部でinclude?じゃなくてcover?を使うようにしたい(継続)

#12612とかぶっているのを承知で再度上げています。こういうのを#===で書きたいようです。

IPAddress("172.16.10.1")..IPAddress("172.16.11.255")

参考: Range#cover?


つっつきボイス: 「このzverokさんの記事は何度か翻訳したことがあったので↓」

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

Ruby

RubyGems.orgがTLS 1.0/1.1を非推奨に(Ruby Weeklyより)


rubygems.orgより

OpenSSL versions 1.0.0t以下にリンクされたRubyや、JVM 6以下にリンクされたJRubyからRubyGems.orgに接続できなくなるそうです。

GitentialでGitHubリポジトリの昨年のRuby開発をビジュアル表示してみた(Awesome Rubyより)


同サイトより


同サイトより


つっつきボイス: 「見せ方が結構きれいだなと思って」「各コミッターの活動状況がわかるし」「オープンソースリポジトリのチェックによさそう」

ruby-advisory-db: bundler-auditとrubysec.comの脆弱性情報データベース

この間のウォッチで扱ったhttps://rubysec.com/サイトの脆弱性情報がここに集められているそうです。

RSpecを自作してDSLを理解する(Ruby Weeklyより)

# 同記事より
class Describe
  attr_reader :context_name

  def initialize(context_name, &block)
    @context_name = context_name
    instance_eval &block
  end

  def describe(context_name, &block)
    Describe.new(context_name, &block)
  end

  def it(context_name, &block)

  end
end

def describe(context_name, &block)
  Describe.new(context_name, &block)
end

describe NumberService do
  describe '#number' do
    it 'returns 12' do

    end
  end
end

Ruby言語の関数型プログラミング的側面(Hacklinesより)

高階関数やカリー化↓などをRubyでやってみる記事です。

クソコードを変態コードにしてやった【勉強会報告】

Rubyで遺伝的アルゴリズム(Hacklinesより)

CAPACITY = 20

def fitness
  weights = [2, 3, 6, 7, 5, 9, 4]
  values  = [6, 5, 8, 9, 6, 7, 3]

  w = weights
      .map
      .with_index { |w, idx| value[idx].to_i * w }
      .inject(:+)

  v = values
      .map
      .with_index { |v, idx| value[idx].to_i * v }
      .inject(:+)

  w > CAPACITY ? 0 : v
end

つっつきボイス: 「chromosome(染色体)とかsurvival of fittest(適者生存)とかそっち系の用語が出てきますね」「遺伝的アルゴリズムって言葉を久々に聞いた気がしました」

参考: Wikipedia-ja 遺伝的アルゴリズム

TensorFlowとRubyでCAPTCHAを解かせる(Awesome Rubyより)

CAPTCHAは機械学習で突破できるようになってしまったから昨今はもう意味がないという記事をこの間見かけましたが、さもありなんでした。

# 同記事より
require 'tensorflow'
# Loading Saved Model
saved_model = Tensorflow::Savedmodel.new
saved_model.LoadSavedModel(Dir.pwd + '/break-captcha-protobuf', ['serve'], nil)

# Read the image file and specify the image contents in a Tensor
image_file = File.new(Dir.pwd + '/break-captcha-protobuf/captcha-1.png', "r")
feeds_tensor = Tensorflow::Tensor.new(image_file.read)

参考: TensorFlow
参考: 機械学習を使ってCAPTCHAをわずか15分で突破するチャレンジが行われる - GIGAZINE

steep: Ruby 2.5を型付けするgem(Ruby Weeklyより)

class Foo
  # @implements SuperFoo
  # @type const Helper: FooHelper

  # @dynamic name
  attr_reader :name

  def do_something(string)
    # ...
  end

  def bar(symbol = :default, size:)
    Helper.run_bar(symbol, size: size)
  end
end
$ steep check lib/foo.rb
foo.rb:41:18: NoMethodError: type=FooHelper, method=run_bar
foo.rb:42:24: NoMethodError: type=String, method==~

つっつきボイス: 「作者のSoutaro Matsumotoさんは昨年のRubyKaigiでもRubyの型付けについて発表してて↓、論文の形でも発表してたりします」「アノテーションとかで型を指定する感じなんですね: こういうのをしなくていいのがRubyのいいところなのに、と思ったりもしますが」

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

image_optim: 外部ツールを使い分けて画像を最適化するgem(Ruby Weeklyより)

ORMを使いこなすには(Hacklinesより)

最近読んだ本(主にRuby関連)(Hacklinesより)

pwned: haveibeenpwned.comのAPIにRubyでアクセス(Awesome Rubyより)


haveibeenpwned.comより

# 同リポジトリより
password = Pwned::Password.new("password")
password.pwned?
#=> true
password.pwned_count
#=> 3303003

haveibeenpwned.comは、メアドを入力するとそのパスワードが抜かれているかどうかを調べるサイトのようです。前からあるようですが、自分のメアドを入れるのはちょっと勇気が要りそう。

参考: 自分のメアドが流出していないか確認できるウェブサービス「Have I been pwned?」 | YoutaChannel

GrpcをRubyで理解する(RubyFlowより)

Grpcサーバーとクライアントの動かし方を紹介しています。

# 同記事より
server = GRPC::RpcServer.new
server.add_http2_port("0.0.0.0:50051", :this_port_is_insecure)
server.handle(Handler.new)
server.run_till_terminated

ウィーンで開催のEuRuKo 2018 CFP募集

昨年のウォッチでも取り上げたEuRuKo、2017年度はこんな感じでした。

SQL

あなたの知らないPostgreSQL 10: CREATE STATISTICS編(Postgres Weeklyより)

Explainのテクニックをいくつか紹介しています。

# 同記事より
EXPLAIN ANALYZE SELECT * FROM tbl where col1 = 1;                            
                                                QUERY PLAN                                                 
-----------------------------------------------------------------------------------------------------------
 Seq Scan on tbl  (cost=0.00..169247.80 rows=9584 width=8) (actual time=0.641..622.851 rows=10000 loops=1)
   Filter: (col1 = 1)
   Rows Removed by Filter: 9990000
 Planning time: 0.051 ms
 Execution time: 623.185 ms
(5 rows)

つっつきボイス: 「これ翻訳打診してみますね」

PostgreSQLでMeltdown脆弱性をベンチマーク(Postgres Weeklyより)


同記事より


つっつきボイス: 「retpolineっていうのがパッチの名前なのか」

以下によると、”return”と”trampoline”の造語だそうです。

参考: Retpoline – Google’s fix for Spectre

JavaScript

webpack-server: webpack-dev-serverの後継


webpack-contrib/webpack-serveより

webpack-dev-serverはメンテナンスモードになったとのことです。

参考: webpack-dev-server の後継らしい webpack-serve について調べた - 備忘録β版

React-vis: React向けビジュアル表示ライブラリ


同リポジトリより

Uber製です。

dom-to-image: DOMノードから画像を生成(GitHub Trendingより)

# 同リポジトリより
var node = document.getElementById('my-node');

domtoimage.toPixelData(node)
    .then(function (pixels) {
        for (var y = 0; y < node.scrollHeight; ++y) {
          for (var x = 0; x < node.scrollWidth; ++x) {
            pixelAtXYOffset = (4 * y * node.scrollHeight) + (4 * x);
            /* pixelAtXY is a Uint8Array[4] containing RGBA values of the pixel at (x, y) in the range 0..255 */
            pixelAtXY = pixels.slice(pixelAtXYOffset, pixelAtXYOffset + 4);
          }
        }
    });

つっつきボイス: 「サンプルやデモが見当たらないっすね」「画像見たいのにー」

Propel ML: numpy風differentialプログラミングライブラリ(JavaScript Weeklyより)

import { grad, linspace, plot } from "propel";

f = x => x.tanh();
x = linspace(-4, 4, 200);
plot(x, f(x),
     x, grad(f)(x),
     x, grad(grad(f))(x),
     x, grad(grad(grad(f)))(x),
     x, grad(grad(grad(grad(f))))(x))


propelml.orgより

Notebookでとりあえず動かせます。

CSS/HTML/フロントエンド

コンテナクエリの問題(Frontend Focusより)

「飢えたデザイナー」のための倫理

社内勉強会で触れられていたので。先週のウォッチで扱った「プログラマーの誓い」のデザイナー版みたいな位置づけです。

デザイン世界の「失われた世代」(Frontend Weeklyより)

長くてエモめの記事です。後で読んでみます。

Go言語

gofaas: AWS LambdaのGo向けテンプレート(GitHub Trendingより)

# 同リポジトリより
$ make deploy
make_bucket: pkgs-572007530218-us-east-1
Uploading to 59d2ea5b6bdf38fcbcf62236f4c26f21  3018471 / 3018471.0  (100.00%)
Waiting for changeset to be created
Waiting for stack create/update to complete
Successfully created/updated stack - gofaas

ApiUrl  https://x19vpdk568.execute-api.us-east-1.amazonaws.com/Prod

one-file-pdf:ワンバイナリでPDF一発生成(GitHub Trendingより)

ソースも1ファイルです。

Moby Project: Dockerが推進するコンテナシステムフレームワーク(GitHub Trendingより)


同リポジトリより

Dockerが生んだMobyは、車輪を再発明せずに特殊コンテナシステムを組み立てるためのオープンなフレームワークです。さまざまな標準コンポーネントの「LEGOセット」と、それらをカスタムプラットフォームとして組み立てるフレームワークが提供されます。Mobyの中核にあるのは、「コンポーネント」「ツール」「アセンブリ」を提供する特殊コンテナシステムを組み立てるフレームワークです。
mobyproject.orgより大意

Slack-term: CLIでSlackしたい人に(GitHub Trendingより)


同リポジトリより

とりあえず手元で動かしてみました。
リアクションが文字のままなのと、日本語と絵文字が接すると文字化けしましたが、軽快です。


つっつきボイス: 「社内でVimをSlackクライアントにしてた人いましたね、そういえば」「そういえば!」

その他

Google SpreadsheetをWebサーバーにする


つっつきボイス: 「日本語記事ですが一応」「GETとかPOSTとか扱えるんですね…ゴクっ」

参考: Content Service  |  Apps Script  |  Google Developers

codepilot.ai: ローカルやStackoverflowなどのコードを検索するアプリ


同サイトより


とりあえずダウンロードして動かしてみました。あのメソッドどこに書いたっけみたいなときにプロジェクト/言語横断的に検索するのによさそうな雰囲気です。

git-sizer: Gitリポジトリのファイルサイズを簡易アナライズ(GitHub Trendingより)

サイズに着目して、問題ありそうなファイルを見つけるツールです。

# 同リポジトリより
$ git-sizer --verbose
Processing blobs: 1652370
Processing trees: 3396199
Processing commits: 722647
Matching commits to trees: 722647
Processing annotated tags: 534
Processing references: 539
| Name                         | Value     | Level of concern               |
| ---------------------------- | --------- | ------------------------------ |
| Overall repository size      |           |                                |
| * Commits                    |           |                                |
|   * Count                    |   723 k   | *                              |
|   * Total size               |   525 MiB | **                             |
| * Trees                      |           |                                |
|   * Count                    |  3.40 M   | **                             |
|   * Total size               |  9.00 GiB | ****                           |
|   * Total tree entries       |   264 M   | *****                          |
| * Blobs                      |           |                                |
|   * Count                    |  1.65 M   | *                              |
|   * Total size               |  55.8 GiB | *****                          |

Xray: Rustで書かれたElectronベースの実験的テキストエディタ(GitHub Trendingより)

内部で文字列をcopy-on-writeしているというのが気になったので。CRDTは「Conflict-Free Replicated Data Types」のことだそうです。


同リポジトリより

参考: rgasplit-group2016-11.pdf(CRDTの論文)

いいプルリクの書き方(Hacklinesより)


  • 読む側に考えさせない
  • 相手の立場に立てることが開発者として最強
  • 出発点と道筋を示す
  • どこが問題かをはっきり示す
  • 必要な情報(コンテキスト)をちゃんと添える
  • スクショや図を添える
  • etc.

番外

SATySFiはLaTexを超えるか

TextPathView: 筆順をアニメーション表示(GitHub Trendingより)

筆順というより輪郭ですが。
ちなみに日本と中国で漢字の筆順は結構違ってるそうです。

参考: いわゆる「正しい筆順」の幻想


今週は以上です。

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

週刊Railsウォッチ(20180302)Ruby 2.6.0-preview1とWebpack 4.0リリース、爆速検索APIサービスAlgolia、Clowneでモデルをクローンほか

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

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

Ruby 公式ニュース

Rails公式ニュース

Ruby Weekly

Awesome Ruby

Random Ruby

Ruby on Rails Security Project

RubyFlow

160928_1638_XvIP4h

Hacklines

Hacklines

Postgres Weekly

postgres_weekly_banner

Frontend Weekly

frontendweekly_banner_captured

Frontend Focus

frontendfocus_banner_captured

JavaScript Weekly

javascriptweekly_logo_captured

Github Trending

160928_1701_Q9dJIU

Rails tips: RSpecの「スパイ(spy)」の解説(翻訳)

$
0
0

概要

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

Rails tips: RSpecの「スパイ(spy)」の解説(翻訳)

RSpecの「スパイ(spy)」は、モックとスタブの組み合わせです。モックやスタブがよくわからない方は、前回記事「Rails tips: RSpecでシンプルなスタブを使う(翻訳)」をご覧ください。

spyは以下の3つの手順の流れにわけられます。

1. セットアップallowでクラスをスタブして、欲しいレスポンスを取得する。
2. エクササイズ: テストされたメソッドを実行する
3. 検証: expecthave_receivedを用いて、コードがexpectationを満たしているかどうかをテストする

サンプルのクラスで実装を見てみることにしましょう。

class UserService
  def initialize(user:, name:)
    @user = user
    @name = name
  end

  def save_name
    name_service = NameService.new(name: name)
    user.update_attribute(:name, name_service.get_name(format: :short))
  end

  private
  attr_reader :user, :name
end
require 'spec_helper'

describe UserService do
  describe "#save_name" do
    # セットアップ
    name_service = instance_double(NameService, get_name: double)
    user = instance_double(User, update_attribute: double)
    short_name = 'Nick'
    name = 'Nick Martin'
    allow(NameService).to receive(:new).with(name: name).and_return(name_service)
    allow(name_service).to receive(:get_name).with(format: :short).and_return(short_name)
    allow(user).to receive(:update_attribute).with(:name, short_name)

    # エクササイズ
    user_service = UserService.new(user: user, name: name)
    user_service.save_name

    # 検証
    expect(name_service).to have_received(:get_name).with(format: :short)
    expect(user).to receive(:update_attribute).with(:name, short_name)
  end
end

訳注: specにitブロックがありませんが、省略されているようです。

もちろんもっと速い書き方はありますが、特に複雑なテストケースではspyを使うと明快になるので、このテスト全体をいくつかのセクションに分割しました。

Railsでお困りの方にお知らせ

知りたいことがありましたら、twitter または連絡用フォームにてお気軽にお問い合わせください。

関連記事

Rails tips: RSpecでシンプルなスタブを使う(翻訳)

ソフトウェアテストでstubを使うコストを考える(翻訳)

[Rails] RSpecのモックとスタブの使い方

コーディングスタイルで社内が揉める理由(翻訳)

$
0
0

概要

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

コーディングスタイルで社内が揉める理由(翻訳)

本記事は私のChainline Newsletterで最初に公開いたしました


私は長年の間、コードのことで議論になるのはなぜなのか、そして決定的に食い違う意見を建設的な方向に転換する方法はないものかと模索してきました。

私の見解は、非常に特異な文脈の中から生まれました。私は、ありとあらゆる業種の企業に出向き、数日間に渡るオブジェクト指向設計の講義を年に10回から12回は繰り返しています。私は部外者ではありますが、そうした場所で数日間を過ごすだけでも企業の裏舞台の一端を垣間見られます。

そのときに気づいたのですが、ある場所では誰もがおおむね幸せそうにしています。そういう場所ではプログラマーもうまくやっていて、一同は「連帯している」ように感じています。こういう場では、私の業務時間のほとんどを実際にオブジェクト指向設計の講義に充てられます。

かと思うと、社員一同が驚くほど惨めな状況に陥っているような場所もあります。社内のあちこちで意見の相違が噴き出し、プログラマーたちも「派閥」争いに巻き込まれてしまっています。そんな状況でのオブジェクト指向設計の講義は、根深い衝突をどうやって解決するかを広範囲に渡って議論するグループディスカッションの場に変わってしまいます。

トルストイの「幸せな家庭はどこも似たようなものだが、不幸な家庭はそれぞれ独特の不幸を抱えているものだ」という有名な成句は、アンナ・カレーニナの原則として知られています。そしてこの法則は、成功を収めるには膨大な判断基準をすべて満たす必要があることを示しています。幸せになる唯一の道は、そのひとつひとつを満たすことです。残念なことに、そうした判断基準のたったひとつが満たされないだけでも不幸は訪れます。すなわち、幸せな企業はどこも似たようなものですが、不幸な企業はいずれもオリジナリティにあふれた独自の不幸を体現しているのです。

相当な数の講義をこなすうちに、私はさまざまな「不幸の塊」のような職場を体験し、そして幸せとは何かを判断するためのいくつかの共通基準というものを理解し始めました。本ニュースレターでは、その中から1つだけを取り上げてみたいと思います。残りについては今後のニュースレターで扱います。

私が今回取り上げてみたいお題は「構文(syntax)の選択」です。自分の職場がスタイルガイドに同意し、それに従うかどうか、といった問題です。私がこんな世俗的と思える話題について書き始めるのを見て驚く方は、幸運にも今のあなたの職場はよい文法を選択しているとお考えください。悲しげに首を振りながら、この話題の重要性に同意いただいているそこのあなた、心から痛み入ります。

なぜスタイルガイドというものがあるのか?

私が個人的に吟味しなければならないあらゆるコードは、一貫した書式で私の前に現れるべきであると、固く信じています。コードは書かれる回数より読まれる回数の方がずっと多いのですから、コードの最終的なコストは読むときに発生するということになります。そこから、コードは読みやすさのために最適化すべきであるという結論、ひいてはアプリのコードはすべて同じスタイルで統一されるべきであるという決定が導かれます。共通のスタイルに合わせることで、コストを削減します。

ではどのスタイルがベストなのか?

ここまでの話にはほとんどのプログラマーが同意してくれるのですが、ここからが分裂の始まりです。私が考える範囲では、私の個人的なコーディングスタイルこそが明らかにベストだと思うのですが、きっと他の人も自分のコーディングスタイルがベストだと思っていることでしょう。プログラマーのチームにとって、すべてのコードでスタイルを共通のものに揃えるべきであることに同意するところまでは簡単なのですが、そこから共通のスタイルをどれに定めるべきかという段になると驚くほど困難になります。

コーディングスタイル上の選択の大半は(必須ではなく)任意であり、まったく個人の好み次第であることは間違いありません。スタイルガイドを選択するということは、些細な点で意見が真っ二つに分かれるような分野で合意を形成するということです。スタイルそのものが重要なのではなく、スタイルを揃えることが重要なのです。

なぜチーム内で意見が割れるのか

前述したとおり、スタイルガイドがなければ即コストに跳ね返ります。しかし、あるスタイルに反対する人には、反対することで自分の問題を最小限にできるというゴネ得の可能性があります。

私はこれまでに、スタイルガイドの合意形成に失敗してチームが分裂してしまった企業をいくつも訪れました。そこにいるプログラマーたちは口頭での交渉を随分前に諦めてしまい、代わりに変更リクエストを言い訳にして変更箇所の周辺を自分好みのスタイルに変えてしまっていました。コードは競合するスタイルの間で行ったり来たりを繰り返すはめになります。これでは実際の振る舞いがどう変わるかを把握することが難しくなるだけでなく、最後にコードに触った人が次回そこを見たときに激怒してしまいます。

こうした「スタイル戦争」は、表向きはコードの書式の問題のようでいて、実際には権力闘争、マウントの取り合いです。もう少し穏やかに言えば、スタイル戦争はチームの緊張感を煽ってコストを増大させます。もっときつい言い方をすれば、スタイル戦争はチームのやる気をごっそり削いでしまいます。

「俺ぐらいのレベルになれば自分流でもいいんじゃね?」

だめです。

ここではまさしく「誰が決定を下すか」が問われているのであり、そのような返答をしていては職場に亀裂を生むだけです。3つのコーディング派閥がそれぞれ異なるスタイルにこだわっているというのはよくある話です。

自分たちが正しいと信じ込んでいる古参プログラマー派閥が独自路線を突っ走るというのもよくある話です。こういう人々は上から目線での支配を目論み、それに失敗すると、今度はグループの合意を無視してもよい権限を与えられたつもりになって勝手に自分たちの好みのスタイルで進めます。こうなっては誰にリストラされるやら、です。

別の言語の経験者たちが古巣のスタイルをRubyに持ち込もうとするのも、これまたよくある話です。彼らは自分たちにとってコードがわかりやすくなるスタイルを選び、その選択によって他のプログラマー全員が困り果ててしまうという事実を無視します。

最後に、スタイルに対する持論が固まっていない新人がいます。新人はあらゆるスタイルをあれこれ実験するので、コードが一貫しないという特徴があります。皮肉抜きで彼らに幸多かれと祈りたくなりますが、他のメンバーは皆混乱に巻き込まれ、後始末に追われます。

ここでご紹介した「あるある」グループは3つだけですが、あなたの職場でこの種の頭の痛い問題が発生していれば、さまざまなスタイル上の争いが勃発している可能性があります。スタイル派閥がひとたび形成されれば、皆てんでに我が道を突っ走り始め、プログラマーの数だけスタイルができあがってしまいます。このような職場では、私が「このコード片だけを見て、これを誰が書いたか当てられますか?」と質問すると、皆口を揃えて「わかるわかる!」と答えるのです。

どのようにしてチームの合意を形成するか

コーディングスタイルの問題は、実際には2つの問題を抱えています。第1に全員の合意を取り付けなければならないこと、第2に全員がそのスタイルに従わなければならないことです。

あなたの職場でコーディングスタイル上の衝突が長年続いているのであれば、スタイルガイドに丸投げするのがベストです。既にコミュニティがスタイルガイドにさんざんマサカリを投げているのですから、同じ努力を繰り返す理由はありません。「Ruby Style Guide」(他の言語でももちろん可)でググってスタイルガイド何か1つ選択すればよいのです。

訳注: RuboCopベースのスタイルガイドについては【保存版】Rubyスタイルガイドもどうぞ。

外部のスタイルガイドを使えば、内部での無駄な争いを回避してクラウドの知恵を借りることができます。ほとんどのガイドは嬉しい点も残念な点も同じぐらいあるので、どのスタイルを選ぼうとどこかで妥協は必要になります。外部のスタイルガイドを選べば、個人の好みに左右されることも、誰かに一方的に思い通りにされることもなくなります。

スタイルガイドを選定すればおしまいというわけにはいきません。スタイルガイドを定めたら、全員がそれに従わなければなりません。スタイルを徹底する最も簡単な方法は、各自のコードで違反を見つけて警告を発する手順を自動化することです。RubyであればRuboCopを一度チェックしてみましょう。RuboCopは既に設定済みですが、これが最もうまくいくでしょう。

人間による「スタイル警察」は避けましょう。人間スタイル警察が出動すれば、取り締まられる側が機嫌を損ねるだけです。代わりに、コード違反の修正方法をプログラマーに提供しましょう。スタイル違反は、プルリクが送信されるまでに正すべきです。プルリクでの会話は、コードの見た目よりもコードの内容に専念すべきです。

ここでちょっと個人的な話になりますが、私は最近Elm言語を試しています(訳注: ElmはPythonやCoffeeScript風の4スペースによるインデントブロックを採用しています)。何とも座りの良くないRuby流のスタイルではなく、Elmのようなより意識的なコードフォーマットにしたいという思いが募って、エディタに自動Elmフォーマッタをインストールしたのです。当初は自動修正されたコードの見た目に嫌悪感を覚えたものでしたが、ここ数週間のうちに、あれほど積極的にキライだったElmのスタイルをいつしか本気で好きになってしまいました。標準的なElmスタイルに繰り返し接しているうちにじわじわ好みが変わりだし、標準などというものはしょせん自分の慣れでしかないことを自ら証明する形になりました。自分の旧来のやり方を新しい思考法に合わせる方が、その逆よりもずっと簡単なのです。

今あるコードはどうすればいい?

触らないでください。既存のコードのスタイルまで修正する必要などありません。改善するのは今後書くコードの方です。古いコードは、他の理由で手を付けるときまで放っておきましょう。

この戦略の意図は、最も頻繁に使うコードから段階的にスタイルを共通化することです。既存のコードは今後二度と更新されない可能性もあるのですから、誰も顧みないコードなどどうでもよいのです。

手を付ける必要のないコードのスタイルをわざわざ修正するということは、積み残しの作業を片付けるよりも古いコードの見た目を修正する方がビジネス上の価値が高いと宣言しているようなものです(そんなことはありませんよね!)。美観上のためだけに修正することの機会費用には、その間に進められたはずの作業の損失分も含まれます。要は、既に安定している既存コードのスタイルに手を付けないことです(低コストで変更できるなら別ですが)。

新しいスタイルガイドをどうしてもどうしても好きになれないときは?

チームが合意したスタイルガイドをどうしても受け入れたくないのであれば、真っ当な選択肢は2つしかありません。

1つは、その気持をぐっとこらえてスタイルガイドに従うことです。先ほど書いたElmの話でもわかるように、スタイルガイドに従ううちにいつしか自分の考えも変わり、新しいスタイルを好きになるものです。スタイルガイドに実際に従ってコードを書いていれば好きになることは決して不可能ではありません。

さもなければ、そのスタイルガイドには従わないときっぱり腹を決めましょう。そうなれば、今の職を離れて自分好みのスタイルが使える職を探すことになります。新しい職場ではそこのスタイルガイドに従いましょう。

お気づきかと思いますが、どちらの道を進もうと結局何らかのスタイルガイドに従うことになります。「スタイルガイドに従わない」という選択肢はありません。

この話の教訓ですか?自由気ままにコードを書くよりも、すべてのコードのスタイルが揃っていることの方がずっと重要です。スタイルガイドに合意し、それに従いましょう。チームが合意に達しない場合は、その問題からは一歩身を引き、「権力争い」の方を議題に据えて議論を始めましょう。

それでは
Sandi

お知らせ

実用オブジェクト指向設計(POOD)一般向け講座をノースカロライナにて開講

次回の一般向け実用オブジェクト指向設計(POOD)講座は、ノースカロライナ州ダーハムにて5/2〜5/4に開講いたします。同好の士と楽しい3日間を過ごすチャンスです。オブジェクトについての考えががらっと変わる当講座に奮ってご応募ください。

チケットは現在発売中です。売り切れる前にぜひゲットしましょう!

書籍『99 Bottles of OOP』

99 Bottles of OOP』がついに完成いたしました。現在はバージョン1.0.1をお求めいただけます。本書はKatrina Owenとの共著で、ここ数年は同書の執筆で苦心惨憺の日々を送りました。詳しくは豊富なサンプルページ第三者ブックレビューをじっくりご覧いただき、よろしければぜひお買い求めください

訳注: もちろん同書は「Ruby Edition」と銘打たれています。

関連記事

【保存版】Rubyスタイルガイド(日本語・解説付き)総もくじ

YAGNIを実践する(翻訳)

「巨大プルリク1件vs細かいプルリク100件」問題を考える(翻訳)

開発チームを苦しめるマイクロサービス(翻訳)

Rails5「中級」チュートリアル(3-8)投稿機能: 新しい投稿を作成する(翻訳)

$
0
0

概要

概要

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

Rails5中級チュートリアルはセットアップが短めで、RDBMSにはPostgreSQL、テストにはRSpecを用います。
原文が非常に長いので分割します。章ごとのリンクは順次追加します。
翻訳と同時に動作をRails 5.1とRuby 2.5で検証しています。

注意: Rails中級チュートリアルは、Ruby on Railsチュートリアル(https://railstutorial.jp/)(Railsチュートリアル)とは著者も対象読者も異なります。

目次

Rails5「中級」チュートリアル(3-8)投稿機能: 新しい投稿を作成する(翻訳)

ここまでの投稿はseedで作った人工的なものでした。今度はユーザーが投稿できるようにユーザーインターフェイスを追加しましょう。

posts_controller.rbファイルにnewアクションとcreateアクションを追加します(Gist)。

# controllers/posts_controller.rb
...
  def new
    @branch = params[:branch]
    @categories = Category.where(branch: @branch)
    @post = Post.new
  end

  def create
    @post = Post.new(post_params)
    if @post.save 
      redirect_to post_path(@post) 
    else
      redirect_to root_path
    end
  end
...

newアクションでは投稿を作成するフォームで用いるインスタンス変数をいくつか定義しています。@categoriesインスタンス変数の内部には特定のブランチのカテゴリが保存されます。@postインスタンス変数には、Railsフォームで必要となる新しい投稿のオブジェクトが保存されます。

createアクションでは、post_paramsメソッドでデータを持たせたPostオブジェクトを新規作成し、@postに保存します。このpost_paramsメソッドは次のようにprivateスコープ内で定義します(Gist)。

# controllers/posts_controller.rb
...
def post_params
  params.require(:post).permit(:content, :title, :category_id)
                       .merge(user_id: current_user.id)
end
...

この[permit](https://apidock.com/rails/ActionController/Parameters/permit)メソッドは、オブジェクトの属性をホワイトリスト化するのに使われます。これにより、指定の属性を渡すことを明示的に許可します。

PostsControllerの冒頭に次の行を追加します(Gist)。

# controllers/posts_controller.rb
...
before_action :redirect_if_not_signed_in, only: [:new]
...

このbefore_actionはRailsのフィルタです。サインインしていないユーザーが投稿を作成するページにアクセスできるようにしたくありません。そのために、newアクションが呼ばれる前にこのredirect_if_not_signed_inメソッドが呼び出されます。このメソッドは他のコントローラにも同様に必要になるので、application_controller.rbファイルにこのメソッドを定義しておきましょう。ついでにサインインしているユーザーをリダイレクトするメソッドもあれば今後便利なので、どちらも定義しておきましょう(Gist)。

# controllers/application_controller.rb
...
def redirect_if_not_signed_in
  redirect_to root_path if !user_signed_in?
end

def redirect_if_signed_in
  redirect_to root_path if user_signed_in?
end
...

ユーザーが投稿を作成するには、newのテンプレートが必要です。postディレクトリの下にnew.html.erbファイルを作成します(Gist)。

<!--- posts/new.html.erb -->
<div class="container new-post">
  <div class="row">
    <div class="col-sm-6 col-sm-offset-3">
      <h1>Create a new post</h1>
        <%= render 'posts/new/post_form' %>
    </div>
  </div>
</div>

newディレクトリを作成し、その下に_post_form.html.erbファイルを作成します(Gist)。

<!-- posts/new/_post_form.html.erb -->
<%= bootstrap_form_for(@post) do |f| %>
  <%= f.text_field  :title, 
                    maxlength: 100, 
                    placeholder: 'Title', 
                    class: 'form-control',
                    required: true, 
                    minlength: 5,
                    maxlength: 100 %>
  <%= f.hidden_field :branch, :value => @branch %>
  <%= f.text_area :content, 
                  rows: 6,
                  required: true, 
                  minlength: 20,
                  maxlength: 1000,
                  placeholder: 'Describe what you are looking for. E.g. specific interests, expertise level, etc.', 
                  class: 'form-control' %>
  <%= f.collection_select :category_id, @categories, :id, :name, class: 'form-control' %>
  <%= f.submit "Create a post", class: 'form-control' %>
<% end %>

このフォームはかなり素朴な作りです。フィールドの属性を定義し、collection_selectでカテゴリを1つ選択できるようにしています。

変更をcommitします。

git add -A
git commit -m "Create a UI to create new posts

- Inside the PostsController:
  define new and create actions
  define a post_params method
  define a before_action filter
- Inside the ApplicationController:
  define a redirect_if_not_signed_in method
  define a redirect_if_signed_in method
- Create a new template for posts"

フォームをテストするspecを書いてテストします。特定のリクエストを送信後に正しいレスポンスを得られることを確認するため、request specsから書くことにします。specディレクトリの下に以下のディレクトリを作成します。

spec/requests/posts

その下にnew_spec.rbファイルを作成します(Gist)。

# spec/requests/posts/new_spec.rb
require 'rails_helper'
include Warden::Test::Helpers
RSpec.describe "new", :type => :request do

  context 'non-signed in user' do
    it 'redirects to a root path' do
      get '/posts/new'
      expect(response).to redirect_to(root_path)
    end
  end

  context 'signed in user' do
    let(:user) { create(:user) }
    before(:each) { login_as user }

    it 'renders a new template' do
      get '/posts/new'
      expect(response).to render_template(:new)
    end
  end

end

前述したように、request specは結合テストの薄いラッパーを提供しているので、特定のリクエストが送信されたときに正しいレスポンスを取得できるかどうかをテストすることができます。include Warden::Test::Helpersの行は、テスト用のログインを行うlogin_asメソッドを使うために必要になります。

変更をcommitします。

git add -A
git commit -m "Add request specs for a new post template"

これまで作成したページをテストするrequest specを追加することもできます。

同じディレクトリにbranches_spec.rbファイルを作成します(Gist)。

# spec/requests/posts/branches_spec.rb
require 'rails_helper'
include Warden::Test::Helpers
RSpec.describe "branches", :type => :request do

  shared_examples 'render_templates' do
    it 'renders a hobby template' do
      get '/posts/hobby'
      expect(response).to render_template(:hobby)
    end

    it 'renders a study template' do
      get '/posts/study'
      expect(response).to render_template(:study)
    end

    it 'renders a team template' do
      get '/posts/team'
      expect(response).to render_template(:team)
    end
  end

  context 'non-signed in user' do
    it_behaves_like 'render_templates'
  end

  context 'signed in user' do
    let(:user) { create(:user) }
    before(:each) { login_as user }

    it_behaves_like 'render_templates'
  end

end

このようにして、すべてのブランチページのテンプレートがレンダリングできることをチェックします。同じコードを繰り返し避けるために[shared_examples](https://relishapp.com/rspec/rspec-core/docs/example-groups/shared-examples)も使っています。

変更をcommitします。

git add -A
git commit -m "Add request specs for Posts branch pages' templates"

同様に、showテンプレートもレンダリングできることを確認します。同じディレクトリにshow_spec.rbファイルを作成します(Gist)。

# spec/requests/posts/show_spec.rb
require 'rails_helper'
include Warden::Test::Helpers
RSpec.describe "show", :type => :request do

  shared_examples 'render_show_template' do
    let(:post) { create(:post) }
    it 'renders a show template' do
      get post_path(post)
      expect(response).to render_template(:show)
    end
  end

  context 'non-signed in user' do
    it_behaves_like 'render_show_template'
  end

  context 'signed in user' do
    let(:user) { create(:user) }
    before(:each) { login_as user }

    it_behaves_like 'render_show_template'
  end

end

変更をcommitします。

git add -A
git commit -m "Add request specs for the Posts show template"

今度はユーザーが新しい投稿を作成できることを確かめるために、フォームをテストするfeature specを作成しましょう。features/postsディレクトリの下にcreate_new_post_spec.rbファイルを作成します(Gist)。

# spec/features/posts/create_new_post_spec.rb
require "rails_helper"

RSpec.feature "Create a new post", :type => :feature do
  let(:user) { create(:user) }
  before(:each) { sign_in user }

  shared_examples 'user creates a new post' do |branch|
    scenario 'successfully' do
      create(:category, name: 'category', branch: branch)
      visit send("#{branch}_posts_path")
      find('.new-post-button').click
      fill_in 'post[title]', with: 'a' * 20
      fill_in 'post[content]', with: 'a' * 20
      select 'category', from: 'post[category_id]' 
      click_on 'Create a post'
      expect(page).to have_selector('h3', text: 'a' * 20)
    end
  end

  include_examples 'user creates a new post', 'hobby'
  include_examples 'user creates a new post', 'study'
  include_examples 'user creates a new post', 'team'
end

変更をcommitします。

git add -A
git commit -m "Create a create_new_post_spec.rb file with feature specs"

newテンプレートに少し新しいデザインを適用しましょう。

以下のディレクトリに移動します。

assets/stylesheets/partials/posts

new.scssファイルを作成します(Gist)。

// assets/stylesheets/partials/posts/new.scss
.new-post {
  height: calc(100vh - 50px);
  background-color: white;
  h1 {
    text-align: center;
    margin: 25px 0;
  }
  input, textarea, select {
    width: 100%;
  }
}

ブラウザでこのテンプレートを開くと、以下のような基本フォームが表示されるはずです。

変更をcommitします。

git add -A
git commit -m "Add CSS to the Posts new.html.erb template"

最後に、すべてのフィールドに正しく入力されるようにしたいと思います。Postモデルにいくつかバリデーションを追加しましょう。Postモデルに以下のコードを追加します(Gist)。

# models/post.rb
...
validates :title, presence: true, length: { minimum: 5, maximum: 255 }
validates :content, presence: true, length: { minimum: 20, maximum: 1000 }
validates :category_id, presence: true
...

変更をcommitします。

git add -A
git commit -m "Add validations to the Post model"

バリデーションをspecでカバーしましょう。Postモデルのspecファイルを開きます。

spec/models/post_spec.rb

以下を追加します(Gist)。

# spec/models/post_spec.rb
context 'Validations' do
  let(:post) { build(:post) }

  it 'creates successfully' do 
    expect(post).to be_valid
  end

  it 'is not valid without a category' do 
    post.category_id = nil
    expect(post).not_to be_valid
  end

  it 'is not valid without a title' do 
    post.title = nil
    expect(post).not_to be_valid
  end

  it 'is not valid  without a user_id' do
    post.user_id = nil
    expect(post).not_to be_valid
  end

  it 'is not valid  with a title, shorter than 5 characters' do 
    post.title = 'a' * 4
    expect(post).not_to be_valid
  end

  it 'is not valid  with a title, longer than 255 characters' do 
    post.title = 'a' * 260
    expect(post).not_to be_valid
  end

  it 'is not valid without a content' do 
    post.content = nil
    expect(post).not_to be_valid
  end

  it 'is not valid  with a content, shorter than 20 characters' do 
    post.content = 'a' * 10
    expect(post).not_to be_valid
  end

  it 'is not valid  with a content, longer than 1000 characters' do 
    post.content = 'a' * 1050
    expect(post).not_to be_valid
  end
end  

変更をcommitします。

git add -A
git commit -m "Add specs for the Post model's validations"

specific_branchesブランチをmasterブランチにmergeします。

git checkout -b master
git merge specific_branches
git branch -D specific_branches

関連記事

新しいRailsフロントエンド開発(1)Asset PipelineからWebpackへ(翻訳)

Railsで木構造を扱うには

$
0
0

はじめに

「SQLアンチパターン」という本をご存知でしょうか。有名な本なので、エンジニアのほとんどは一度は耳or目にしているかと思いますが、その名の通りSQLのアンチパターンをたくさん紹介している本です。
その本の「ナイーブツリー(素朴な木)」という章では、木構造を隣接リストで実装することがアンチパターンとして紹介されています。またその解決として、いくつかの代替ツリーモデルが紹介されています。SQLの一般論としては理解できたのですが、特にRuby on Railsにおいてはどうしているのかが気になったので調べてまとめました。

環境

  • Ruby 2.5.0
  • Rails 5.1.5
  • SQLite3

扱うもの

本に倣い、スレッド形式のコメントを実装します。
あくまで、主題は木構造なので、木構造を表すためのカラム以外は、主キーであるidと内容のcontentだけとします。
また、シンプルにcontentstring型にしておきます。
ここに、木構造を表すためのカラムを足していって木構造を実現していきます。

目次

隣接リスト

まずは、アンチパターンとされる隣接リストから。
acts_as_treeというgemがあります。もともとはrails/acts_as_treeであったようですが、何かの事情によって変わったようです。

migrationとモデル定義

class CreateComments < ActiveRecord::Migration[5.1]
  def change
    create_table :comments do |t|
      t.string :content, null: false
      t.integer :parent_id, index: true
    end
  end
end
class Comment < ApplicationRecord
  acts_as_tree

  def inspect
    "[#{content}]" # 出力をシンプルにするため
  end
end

コード例

root = Comment.create(content: '隣接リスト最高!')
root.children.create(content: 'シンプルでいいですよね')
child = root.children.create(content: 'アンチパターンですよ、経路列挙モデル使いましょう')
child.children.create(content: '入れ子集合モデルの方が良いですよ')
Comment.create(content: 'こんにちは')

と普通のActiveRecordと同じようにデータを作っていけます。

id content parent_id
1 隣接リスト最高! NULL
2 シンプルでいいですよね 1
3 アンチパターンですよ、経路列挙モデル使いましょう 1
4 入れ子集合モデルの方が良いですよ 3
5 こんにちは NULL

また以下のような、根ノード(親を持たないノード)や葉ノード(子を持たないノード)や世代をキーとしたmapを取得するクラスメソッドが使えるようになります。

Comment.roots
# => [[隣接リスト最高!],[こんにちは]]
Comment.root # Comment.roots.first と同じ
# => [隣接リスト最高!]
Comment.leaves
# => [[シンプルでいいですよね],[入れ子集合モデルの方が良いですよ], [こんにちは。]]
Comment.generations
# => {0=>[[隣接リスト最高!],[こんにちは]],
# 1=>[[シンプルでいいですよね],[アンチパターンですよ、経路列挙モデル使いましょう]],
# 2=>[[入れ子集合モデルの方が良いですよ]]}

またインスタンスメソッドでいえば、#parentで親ノード、#ancestorsで先祖ノード、#childrenで子ノード、#descendantsで子孫ノード、#siblingsで兄弟ノードが取得できます。
他にも、#root?#leaf?で根や葉であるかが確かめられたり、木構造を扱うのに必要そうなメソッドは一通り揃っているように見えます。(詳細: acts_as_tree.rb

さらに、クラス定義で、extend ActsAsTree::TreeViewをしておくと、以下のようなコードで、

Comment.tree_view(:content)

次のように木構造を可視化できます。

root
 |_ 隣接リスト最高!
 |    |_ シンプルでいいですよね
 |    |_ アンチパターンですよ、経路列挙モデル使いましょう
 |        |_ 入れ子集合モデルの方が良いですよ
 |_ こんにちは

これでも十分便利なようですが、本にも書かれているように、再帰クエリが使えない場合にクエリが非効率になるという欠点があり、より効率的なものとしていくつかの代替ツリーモデルが考案されてきました。ということで、それらを見ていきます。

経路列挙モデル

まずは経路列挙モデル。根ノードからのpathを持つことで、データ構造を表します。
(正直1章でアンチパターンとして挙げられている、ジェイウォーク(配列をカンマ区切りの文字列で持つようなこと)に見えます。)

これを扱うためのgemとして、ancestryというものがあります。

migrationとモデル定義

class CreateComments < ActiveRecord::Migration[5.1]
  def change
    create_table :comments do |t|
      t.string :content, null: false
      t.string :ancestry, index: true
    end
  end
end
class Comment < ApplicationRecord
  has_ancestry

  def inspect
    "[#{content}]"
  end
end

コード例

root = Comment.create(content: '隣接リスト最高!')
root.children.create(content: 'シンプルでいいですよね')
# Comment.create(content: 'シンプルでいいですよね', parent: root) でも良い
child = root.children.create(content: 'アンチパターンですよ、経路列挙モデル使いましょう')
grandchild = child.children.create(content: '入れ子集合モデルの方が良いですよ')
hello = Comment.create(content: 'こんにちは')
id content ancestry
1 隣接リスト最高! NULL
2 シンプルでいいですよね 1
3 アンチパターンですよ、経路列挙モデル使いましょう 1
4 入れ子集合モデルの方が良いですよ 1/3
5 こんにちは NULL

例えば、root.descendantsを実行すると

SELECT "comments".* FROM "comments" 
  WHERE ("comments"."ancestry" LIKE '1/%' OR 
         "comments"."ancestry" = '1')

というSQLが発行され、

=> [[シンプルでいいですよね],[アンチパターンですよ、経路列挙モデル使いましょう],[入れ子集合モデルの方が良いですよ]]

と子孫ノードが返ってきます。

また、grandchild.ancestorsを実行すると、

SELECT "comments".* FROM "comments" 
  WHERE "comments"."id" IN (1, 3) 
  ORDER BY coalesce("comments"."ancestry", '')

というSQLが発行され、

=> [[隣接リスト最高!],[アンチパターンですよ、経路列挙モデル使いましょう]]

と先祖ノードが帰ってきます。コードを見ると

ancestry.split('/').map(&:to_i)

とRubyで計算してから、SQLを組み立てているようです。

また、ノードの付け替えもちゃんとでき、

root.update(parent: hello)

rootparenthelloにすると、

id content ancestry
1 隣接リスト最高! 5
2 シンプルでいいですよね 5/1
3 アンチパターンですよ、経路列挙モデル使いましょう 5/1
4 入れ子集合モデルの方が良いですよ 5/1/3
5 こんにちは NULL

のように子孫ノードにも然るべき変更がなされます。
経路列挙モデルでは、子孫ノードのレコードも更新しなくてはならないため、ロジックが複雑になるという欠点がありますが、そこはうまくgemが隠蔽してくれています。(更新コストが大きいというのは依然としてありますが。)

入れ子集合モデル

入れ子集合モデルでは、木を扱う代わりに、下のような集合を扱います。
(区間と言った方がわかりやすいかもしれませんが、入れ子区間モデルは、left, rightが実数であるような、同様のモデルを指します。)
入れ子集合モデル

id content left right
1 隣接リスト最高! 1 8
2 シンプルでいいですよね 2 3
3 アンチパターンですよ、経路列挙モデル使いましょう 4 7
4 入れ子集合モデルの方が良いですよ 5 6
5 こんにちは 9 10

ノードnaがノードnbの子孫ノードであるとき、

nb.left < na.left && na.right < nb.right

が常に成り立つようになっています。
また、葉ノードleafに関しては、

leaf.right - leaf.left == 1

が成り立ちます。

このような入れ子集合モデルを扱うようなgemとして、awesome_nested_setがあります。
migrationファイルは以下のように書きます。デフォルトではleftはlft、rightはrgtというカラム名になっています。入れ子集合モデルにparent_idは、不要なのですが、探索速度を上げるために導入しているようです。

migrationとモデル定義

class CreateComments < ActiveRecord::Migration[5.1]
  def change
    create_table :comments do |t|
      t.string :content, null: false
      t.integer :parent_id, index: true
      t.integer :lft, null: false, index: true
      t.integer :rgt, null: false, index: true

      # 以下はなくても動く
      t.integer :depth, null: false, default: 0
      t.integer :children_count, null: false, default: 0
    end
  end
end
class Comment < ApplicationRecord
  acts_as_nested_set counter_cache: :children_count
end

コード例

root = Comment.create(content: '隣接リスト最高!')
root.children.create(content: 'シンプルでいいですよね')
child = root.children.create(content: 'アンチパターンですよ、経路列挙モデル使いましょう')
child.children.create(content: '入れ子集合モデルの方が良いですよ')
hello = Comment.create(content: 'こんにちは')
id content parent_id lft rgt depth children_count
1 隣接リスト最高! NULL 1 8 0 2
2 シンプルでいいですよね 1 2 3 1 0
3 アンチパターンですよ、経路列挙モデル使いましょう 1 4 7 1 1
4 入れ子集合モデルの方が良いですよ 3 5 6 2 0
5 こんにちは NULL 9 10 0 0

こちらも、ノードの付け替えはでき、

root.move_to_child_of(hello)

とすると、(更新箇所を太文字で表しています。)

id content parent_id lft rgt depth children_count
1 隣接リスト最高! 4 2 9 1 2
2 シンプルでいいですよね 1 3 4 2 0
3 アンチパターンですよ、経路列挙モデル使いましょう 1 5 8 2 1
4 入れ子集合モデルの方が良いですよ 3 6 7 3 0
5 こんにちは NULL 1 10 0 1

と、うまい具合に更新されています。

閉包テーブルモデル

これは、全ての親子関係を別テーブルで持つようなモデルです。
このモデルを扱うgemとして、closure_treeというものがあります。

migrationとモデル定義

class CreateComments < ActiveRecord::Migration[5.1]
  def change
    create_table :comments do |t|
      t.string :content, null: false
      t.integer :parent_id, index: true
    end
  end
end
class Comment < ApplicationRecord
  has_closure_tree

  def inspect
    "[#{content}]"
  end
end

閉包テーブルモデルもparent_idを持つ必要はないのですが、awesome_nested_setと同様に探索速度を上げるためと思われます。
commentsテーブルを作った上で、

rails g closure_tree:migration comment

とすると、

class CreateCommentHierarchies < ActiveRecord::Migration
  def change
    create_table :comment_hierarchies, id: false do |t|
      t.integer :ancestor_id, null: false
      t.integer :descendant_id, null: false
      t.integer :generations, null: false
    end

    add_index :comment_hierarchies, [:ancestor_id, :descendant_id, :generations],
      unique: true,
      name: "comment_anc_desc_idx"

    add_index :comment_hierarchies, [:descendant_id],
      name: "comment_desc_idx"
  end
end

というファイルができるので、ActiveRecord::MigrationActiveRecord::Migration[5.1]に書き換えて、rake db:migrateします。

コード例

root = Comment.create(content: '隣接リスト最高!')
root.children.create(content: 'シンプルでいいですよね')
child = root.children.create(content: 'アンチパターンですよ、経路列挙モデル使いましょう')
child.children.create(content: '入れ子集合モデルの方が良いですよ')
hello = Comment.create(content: 'こんにちは')
id content parent_id
1 隣接リスト最高! NULL
2 シンプルでいいですよね 1
3 アンチパターンですよ、経路列挙モデル使いましょう 1
4 入れ子集合モデルの方が良いですよ 3
5 こんにちは NULL
ancestor_id descendant_id generations
1 1 0
2 2 0
1 2 1
3 3 0
1 3 1
4 4 0
3 4 1
1 4 2
5 5 0

ancestor_idが親ノードのid、descendant_idは子ノードのid、generationsが世代差となっています。

Comment.hash_tree
# => {[隣接リスト最高!]=>{[シンプルでいいですよね]=>{}, [アンチパターンですよ、経路列挙モデル使いましょう]=>{[入れ子集合モデルの方が良いですよ]=>{}}},[こんにちは]=>{}}

ノードの付け替えも可能で、helloの子ノードにrootを加えるのは以下のようにします。

hello.add_child(root)
Comment.hash_tree
# => {[こんにちは]=>{[隣接リスト最高!]=>{[シンプルでいいですよね]=>{}, [アンチパターンですよ、経路列挙モデル使いましょう]=>{[入れ子集合モデルの方が良いですよ]=>{}}}}}

おわりに

以上アンチパターンとされていた隣接リストと、いくつかの代替ツリーモデルを見ていきました。
まだ、実際にこうしたものを扱った経験はないので、どれがベストとは言えませんが、そちらは各々ご判断いただければと思います。
また、横断的に4つのgemを扱ったため、便利なメソッド等紹介しきれていない箇所がいくつもありますので、気になったgemがあれば、ご自身でより詳しく調べていただければと思います。

関連記事

Rubyのメソッド名でしりとりやってみた

Reduxストアの概念をRubyで再実装して理解する(翻訳)

$
0
0

概要

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

なお、reducerは「レデューサー」「リデューサー」の2とおりのカタカナがあるようですが、ネットで多数派の「レデューサー」にしました。

Reduxストアの概念をRubyで再実装して理解する(翻訳)

Reduxは最近のJavaScriptアプリのステート管理ライブラリとして人気が高まっており、実際にはその中心にシンプルなデータストアがあるだけです。Reduxのストアは他と何が違うのでしょうか?データを保存する点では他のストアと同様ですが、Reduxのデータ変更は常に「アクション」を「レデューサー」と呼ばれるものにディスパッチすることで行い、サブスクライブしているリスナーに通知します。Reduxストアの話をするときには、「レデューサー」「アクション」「リスナー」の概念を理解しておく必要があります。

レデューサー(reducer)はRedux世界でよく知られた名前であり、ステートとアクションを引数として受け取り、新しいステートを返す純粋関数を指します。ある関数が「純粋」である場合、関数に副作用がないことを意味します。純粋関数は常に新しい値を返し、受け取った値(関数に渡した元のステート)を変更することは決してありません。よく例として使われるカウンタレデューサーを見てみることにしましょう。カウンタレデューサーは、カウントアップ(increment)やカウントダウン(decrement)に応答する方法を知っており、それ以外のデータを追加しません(Gist)。

counter_reducer = -> (state, action) {
  state ||= 0

  case action[:type]
  when 'increment'
    state += 1
  when 'decrement'
    state -= 1
  else
    state
  end
}

上のRubyコードがJavaScript版と唯一異なっている点は、関数定義でlambda(無名関数)を使っていることと、アクションの定義にRubyのハッシュ(JavaScriptのオブジェクトと対になります)を用いている点です。ステートは常に引数として渡されますが、デフォルト値(ゼロからカウントアップする)も与えています。これで新しいカウンタレデューサは、カウントアップとカウントダウンのアクションに応答し、カウンタの適切な新しい値を返すようになります。ここでもうひとつ注目したい重要な点は、カウンタレデューサーは指定のアクションへの応答方法については何の知識も持たず、元のステートを無変更のまま返していることです。ここが後で重要になってきます。

新しいカウンタは次のように使います。

counter.call(2, { type: 'increment' })
#=> 3
counter.call(nil, { type: 'decrement' })
#=> -1

レデューサーを見たことのない人には上のカウンタの例が冗長に見え、アクションがハッシュで定義されている理由が謎に思えるかもしれません。しかしアクションはこれよりもっと複雑になることもあり、他にも情報を伝える必要が生じる可能性もあります。アクションを定義する追加情報はハッシュ内で他のキーの下に保存されます。つまりReduxストアのアクションは実際には1個のオブジェクトなのです(技術的に言うと上のRubyコードではハッシュになっています)。

レデューサーとアクションを理解できたので、今度はリスナーを定義してみましょう。リスナーは、内部に保持しているのが単なる関数である点を除けばまさしく想像どおりです。リスナー関数は「純粋」である必要はなく、単にストア内でステートが変更されたときの挙動を定義します。

以下の典型的なリスナーもlambdaで定義してみました。

counter_listener = -> () { puts "I am counting numbers" }

ここでは文字列を標準出力に出力しているだけですが、元々のReduxのユースケースではおそらくDOMを更新することになるでしょう。

基本的な知識を一通り押さえることができ、カウンタレデューサーとリスナーもできあがったので、基本的なReduxStoreをRubyベースで実装する準備が整いました(Gist)。

class ReduxStore
  attr_reader :current_state

  def initialize(reducer)
    @reducer = reducer
    @listeners = []
    @current_state = nil
    dispatch({})
  end

  def dispatch(action)
    @current_state = @reducer.call(@current_state, action)
    @listeners.each { |l| l.call }
  end

  def subscribe(listener)
    @listeners.push(listener)
    ->{ @listeners.delete(listener) }
  end
end

新しいReduxStoreは、作成時に渡される任意のレデューサーを使って動作する、一般的なストアです。初期化プロセスの部分は、基本的にはJavaScriptのcreateStore()に相当します。

let createStore(reducer)

createStore()は、渡されたレデューサーに応じたストアを作成して返す関数です。1つのストアで使えるレデューサーは常に1つだけなので、カウンタレデューサーは自分のステートをカウンターのストアに保存します。アクションがディスパッチされると、サブスクライブしているすべてのリスナーに通知(呼び出し)されます。以下は新しいストアの利用例です。

my_counter_store = ReduxStore.new(counter_reducer)
my_counter_store.dispatch({type: 'increment'})

最初にカウンタストアを作成し、次に、ストアのcurrent_stateを変更するincrementアクションをディスパッチします。ステートが変更されたときに何らかの操作を実行するリスナーの使い方を見てみましょう。

my_counter_store.subscribe(counter_listener)
my_counter_store.dispatch({type: 'increment'})
#=> I am counting numbers
my_counter_store.dispatch({type: 'decrement'})
#=>I am counting numbers
puts "Counter is #{my_counter_store.current_state}"
#=>Counter is 3

わずか数行のコードでRedux的なストアが準備できました。しかしReduxストアについてまだ説明していなかったことがひとつあります。Reduxストアは本質的に「アプリの全ステートの保存に用いる」ものであり、1個のレデューサーから返される1個の値のためだけのものではありません。

「ちょっと待った!1つのストアは常に1つのレデューサーの上で動作するって最初に言ってたじゃないの: 今度は複数の値を保存するってどういうこと?」

ストアの挙動をまったく変更せずに、複数のレデューサーが渡すステートを1つのストアに同時に保存できます。そのためには、複数のレデューサーを1つのルート(app)レデューサーにまとめなければなりません。こうすることで複数のストアを保存し、すべてのレデューサーが渡す値を表すツリー状の構造を更新します。先ほど、レデューサーは自分が扱えないアクションを受け取ったときには渡された値を無変更のまま返すと申し上げたのを覚えていますでしょうか?これがまさしく、1つのアクションをルートストアにディスパッチすることで、ツリーのアップデートすべき値だけをアップデートし、それ以外のすべての値を安全に保つ仕組みです。

複数のレデューサーをまとめるクラスメソッドでReduxStoreを拡張してみましょう(Gist)。

class ReduxStore
  def self.combine_reducers(reducers)
    -> (state, action) {
      state ||= {}

      reducers.reduce({}) { |next_state, (key, reducer)|
        next_state[key] = reducer.call(state[key], action)
        next_state
      }
    }
  end
end

これはReduxのcombineReducers()関数に対応するもので、別の関数を返します。返される関数は、実際にステートとアクションをパラメータとして受け取って新しいステートを再度返すレデューサーです。このときだけ、ツリー全体に渡って動作します(Rubyではネストしたハッシュ、JavaScriptではオブジェクトになります)。

実際の動作を見るために、カウンタのレデューサーと組み合わせる別のレデューサーを定義してみましょう(Gist)。

todos_reducer = -> (state, action) {
  state ||= []

  case action[:type]
  when 'add'
    state.push(action[:todo])
  when 'remove'
    state.remove(action[:todo])
  else
    state
  end
}

新しいtodos_reducerにはTODOの項目リストが保存されます。todoという追加パラメータを受け取ることで、アクションをオブジェクトの概念として利用していることにご注目ください。それでは2つのレデューサーを1つにまとめるroot_reducerを作成してみましょう。

root_reducer = ReduxStore.combine_reducers({ counter: counter_reducer, todos: todos_reducer })

レデューサーを再び1つにできたので、アプリのストアを作成できるようになりました。

app_store = ReduxStore.new(root_reducer)
app_store.dispatch({type: 'increment'})
app_store.dispatch({type: 'add', todo: 'Buy milk'})
app_store.dispatch({type: 'increment'})
app_store.current_state
# => {:counter=>2, :todos=>["Buy milk"]}

ルートのレデューサーを作成したことで、1つのルートハッシュに2つのレデューサーのステートを両方とも保存できました。そしてこれが、複数のレデューサーを実装してアクションやリスナーとまとめることで、ツリー状の構造を持つ唯一のストアをビルドする方法です。

私の記事が、Reduxストアとは一体何かについて関心のお持ちの方がReduxの背後のコンセプトを理解する助けになることを願っています。

関連記事

JavaScript: Reduxが必要なとき/不要なとき(翻訳)

[Ruby]クロージャーを使ってブロックを1回だけ実行する

週刊Railsウォッチ(20180316)Rails 5.2のドキュメント更新中、Value Objectの使い方、RubyがTIOBEトップテン復活、Rails「雪だるま」エンコーディングほか

$
0
0

こんにちは、hachi8833です。先週終点で車両の座席に置き忘れたiPhone 7が粉々になって戻ってきて風景がぐらりとかしいだ気がしましたが、補償が効いて本体交換できてケロッと立ち直りました。春ですねぇ。

春たけなわのウォッチ、いってみましょう。

Rails: 今週の改修

5.2はまだ出ていませんが、ドキュメント更新が増えていて、収束に近づいていることを感じさせます。今週も5.2-stableと6.0向けmasterの両方から見繕いました。いずれも変更の可能性がありますので。

Rails 5.1->5.2アップグレードドキュメント

まずは5.2-stableから。CSPとドキュメント周りの改修が目立ちます。

一応現時点のアップグレードドキュメントです↓。今のところ作業量は少なくて済みそう。

Rails 5.1からRails 5.2へのアップグレード
* Bootsnap

#29313でRails 5.2からBootsnap gemが含まれるようになりました。app:updateタスクはboot.rbで設定されます。使いたい場合はGemfileにこのgemを追加し、使わない場合はBootsnapを使わないようにboot.rbを変更してください。

  • cookie値に署名済みまたは暗号化cookieの有効期限が設定されるようになった

セキュリティ向上のため、署名済みまたは暗号化cookieの値に有効期限の情報が埋め込まれるようになりました。これによって、5.2より前のRailsとcookieバージョンの互換性が失われます。5.1以前のcookieが必要な場合や、5.2デプロイを検証中でロールバックの道を残しておきたい場合は、Rails.application.config.action_dispatch.use_authenticated_cookie_encryptionfalseに設定してください。

同コミットより大意


つっつきボイス:bootsnapはShopifyのgemがRailsで標準採用になったやつですね」「Shopfyはカナダのオタワですって」「運用中のサーバーでcookieの互換性が失われると、挙動としてはたとえば強制ログアウトが発生したりとか」「ソシャゲみたいにユーザーがめちゃ多いサービスでcookieが一斉に切れるとヤバイ: ユーザーが再ログインしようとして一気に押しかけて、ログインサーバーに負荷が集中してお亡くなりになったりとか」

その後ロードバランサーなどの話題になりました。

Railsエンジンを場所を変えてマウントできるようになった

# actionpack/lib/action_dispatch/routing/mapper.rb#L652
           def define_generate_prefix(app, name)
             _route = @set.named_routes.get name
             _routes = @set
-            app.routes.define_mounted_helper(name)
+
+            script_namer = ->(options) do
+              prefix_options = options.slice(*_route.segment_keys)
+              prefix_options[:relative_url_root] = "".freeze
+              # We must actually delete prefix segment keys to avoid passing them to next url_for.
+              _route.segment_keys.each { |k| options.delete(k) }
+              _routes.url_helpers.send("#{name}_path", prefix_options)
+            end
+
+            app.routes.define_mounted_helper(name, script_namer)
+
             app.routes.extend Module.new {
               def optimize_routes_generation?; false; end
+
               define_method :find_script_name do |options|
                 if options.key? :script_name
                   super(options)
                 else
-                  prefix_options = options.slice(*_route.segment_keys)
-                  prefix_options[:relative_url_root] = "".freeze
-                  # We must actually delete prefix segment keys to avoid passing them to next url_for.
-                  _route.segment_keys.each { |k| options.delete(k) }
-                  _routes.url_helpers.send("#{name}_path", prefix_options)
+                  script_namer.call(options)
                 end
               end
             }

これは実は昨年のコミットですが、#793c11dのコミットメッセージで目に止まったので。


つっつきボイス: 「同じマウンタブルエンジンを別名でマウントできるようになったと」「マウンタブルエンジン使うのって、Sidekiqの管理画面をマウントするときぐらいだけどなっ」「あとletter_opener導入するとエンジン入ってブラウザで見られますね」

CSPがWelcomeページやmailerプレビュー表示を邪魔しないよう修正

# railties/lib/rails/application_controller.rb#L7
+  before_action :disable_content_security_policy_nonce!
+
+  content_security_policy do |policy|
+    if policy
+      policy.script_src :unsafe_inline
+      policy.style_src :unsafe_inline
+    end
+  end
...
+    def disable_content_security_policy_nonce!
+      request.content_security_policy_nonce_generator = nil
+    end

つっつきボイス: 「こうやって追いかけているとわかりますが、最近のRailsではこういうCSP周りがちょくちょくアップデートされてますね」

CSPをコントローラからオフにできる機能を追加

# actionpack/lib/action_controller/metal/content_security_policy.rb#L16
     module ClassMethods
-      def content_security_policy(**options, &block)
+      def content_security_policy(enabled = true, **options, &block)
         before_action(options) do
           if block_given?
             policy = request.content_security_policy.clone
             yield policy
             request.content_security_policy = policy
           end
+
+          unless enabled
+            request.content_security_policy = nil
+          end
         end
       end

つっつきボイス: 「オンにできるならオフにできないとね」「たしかに」

CSPポリシーインスタンスを常にyieldするように変更

# actionpack/lib/action_controller/metal/content_security_policy.rb#L17
       def content_security_policy(enabled = true, **options, &block)
         before_action(options) do
           if block_given?
-            policy = request.content_security_policy.clone
+            policy = current_content_security_policy
             yield policy
             request.content_security_policy = policy
           end
...
+
+      def current_content_security_policy
+        request.content_security_policy.try(:clone) || ActionDispatch::ContentSecurityPolicy.new
+      end

つっつきボイス:cloneやめて常に同一のCSP設定を参照できるようにしたと: でないと挙動を追ったり変更したりできないですからね」

i18nドキュメント更新


  • 存在しなくなったGlobalize::Backend::Staticへの参照を削除
  • Google Groupsへの参照を削除
  • Globalize3への参照を削除(紛らわしいので)
  • 保存したコンテンツの翻訳方法についてのセクションを追加

本ガイドに記述されているI18n APIは、主にUI文字列の翻訳への利用を意図しています。モデルのコンテンツの翻訳手法をお探しの場合は、UI文字列とは別のソリューションが必要です。
モデルコンテンツの翻訳で役立ついろいろなgemがあります。

  • Globalize: 翻訳用の別テーブルに訳文を保存できます。1つのテーブルが1つの翻訳済みモデルになります。
  • Mobility: 訳文用テーブルやJSON columns(PostgreSQL)などさまざまな形式で訳文を保存できます。
  • Traco: Rails 3や4向けの翻訳可能なカラムを使えるようにします。カラムは元のテーブル自身に保存します。
    ガイド更新箇所より大意(強調はTechRacho編集部)

i18nのサードパーティgemがいくつか公式ガイドに載ったのが目に止まりました。


つっつきボイス: 「お、i18n gemを紹介してくれるようになるのか!」「公式が推してくれるのはうれしいっすね」「今のところGlobalizeがメジャーらしいです」「↑上にも書いてますがUI翻訳のyamlはRailsでサポートするけどコンテンツの方はRailsがサポートすることは今後もないだろうから、こういう形にしたのかも」
「モデルのi18nは自力で実装するとつらいよw: 昔やったけど当時はこういうgemなかったんで」「あ、例のMangaRebornですね」「たとえばサイトにデフォルト言語を設定したりとか、フォールバックする言語を指定したりとか必要になってくるので」「台湾語がなければ中国語、みたいな」
「Mobilityは例のshioyamaさんです↓: 名字のSalzbergをそのままもじってますね」

RubyのModule Builderパターン #1 モジュールはどのように使われてきたか(翻訳)

「こういうi18n gemは、コードを追うまではしないとしても、どういうインターフェイスを用意しているかという部分に注目して比較してみると結構勉強になりますよ: みんなそれぞれ個性があって」

ルーティングガイド更新

Railsのルーティング設定

アプリやエンジンのルーティングはconfig/routes.rbに保存されます。以下は典型的な外観です。

Rails.application.routes.draw do
  resources :brands, only: [:index, :show]
    resources :products, only: [:index, :show]
  end

  resource :basket, only: [:show, :update, :destroy]

  resolve("Basket") { route_for(:basket) }
end

これは普通のRubyソースファイルなので、Rubyのあらゆる機能を用いてルーティングを定義できますが、変数名がルーターのDSLと衝突しないようにご注意ください。
メモ: ルーティング定義を囲むRails.application.routes.draw do ... endブロックは、ルーターのDSLがスコープを確立するために必要なので絶対に削除しないでください。
ガイド更新箇所より大意(強調はTechRacho編集部)


つっつきボイス: 「Railsのルーティングの包括的というか完全なドキュメントが欲しいっすねマジで: 機能はやたらめったらあるけど、知らないと使いようのない機能の多さではRails内ではトップかも」
「今頃変数名にはご注意…だと?」「asとか使うとヘルパーが自動生成されたりとかゴロゴロありますからねー」「まRailsに慣れてくると『この語はキケン』みたいなのをだんだん身体で思い知るけど」「(´・ω・`)」

Railsのルーティングを極める(前編)

「そうそう、sheepみたいに単数形複数形が同じ語を使うと、生成されるヘルパー名が通常と違ってくることあります」「え~~!」

resources :penguins

# 通常は以下が生成される
penguin_path(@penguin)
penguins_path
resources :sheep

# 
sheep_path(@sheep)
sheep_index_path   # 区別のため「_index」が付く

「ActiveSupportにそういう活用形をチェックするメソッドがある↓」「それは知ってたけど…くぅ」「活用形といえばdataは複数形で、単数形はdatum: みんなもう知ってるよね!」

[Rails5] Active Support::Inflectorの便利な活用形メソッド群

ラテン語由来の英単語はたいてい不規則活用になりますね。symposionとsymposiumとか。ちょっと話はそれますが、indexの複数形はindicesが正式とされていますが、近年急速にすたれつつある印象です。

ActiveSupport::Cache::Entryをメモ化してマーシャリングの負荷を軽減

これは5.2-stableとmasterの両方に入っていました。ここからはmasterです。

# activesupport/lib/active_support/cache.rb#L806
+        def marshaled_value
+          @marshaled_value ||= Marshal.dump(@value)
+        end

メモ化といえば、おなじみ「縦縦イコール」ですね。


つっつきボイス: 「kazzさんが以前『たてたてイコール』って呼んでたのが可愛かったのでw」「本当は何て言うんだっけ?」「『オアイコール』?」
「ちなみに以前も話したことあるけど、Marshal.dumpはRubyのバージョンが変わると互換性が失われることがあるので、データベースにそのまま保存すると後で痛い目に遭うかもよ」「怖!」

起動メッセージの無意味な「Exiting」を除去

# railties/lib/rails/commands/server/server_command.rb#L158
           if server.serveable?
             print_boot_information(server.server, server.served_url)
-            server.start do
-              say "Exiting" unless options[:daemon]
-            end
+            after_stop_callback = -> { say "Exiting" unless options[:daemon] }
+            server.start(after_stop_callback)
           else
             say rack_server_suggestion(using)
           end

rails routes --expandedの横線をきれいにした

$ rails routes --expanded
--[ Route 1 ]------------------------------------------------------------
-------
(snip)
--[ Route 42 ]-----------------------------------------------------------
--------
(snip)
--[ Route 333 ]----------------------------------------------------------
---------
(snip)
$ rails routes --expanded
--[ Route 1 ]------------------------------------------------------------
(snip)
--[ Route 42 ]-----------------------------------------------------------
(snip)
--[ Route 333 ]----------------------------------------------------------
(snip)

つっつきボイス: 「前は横棒固定か」「IO.console.winsizeって初めて知った: これならターミナルに合わせて調整できるし」「地味だけどありがたい修正!」

+        previous_console_winsize = IO.console.winsize
+        IO.console.winsize = [0, 23]

参考: Rubyリファレンスマニュアル IO.console

rails routes -gで結果が空の場合のメッセージを修正

  • ActionDispatch::Routingのドキュメント更新
    • -gの記述を追加
    • rails routes--expandedオプションの説明を追加
  • ActionDispatch::Routing::ConsoleFormatter::Baseの導入
    • Baseを作ってSheetExpandedで継承し、コード重複を防止
      • Expandedのコンポーネントで末尾の”\n”を削除
      • Expanded#headerの戻り値を@bufferからnilに変更
    • -gのときのno_routesメッセージがよくなかったので修正
      • -cの場合のメッセージは「Display No routes were found for this controller」
      • -gの場合のメッセージは「No routes were found for this grep pattern」

PRメッセージより大意

# actionpack/lib/action_dispatch/routing.rb#L85
         def normalize_filter(filter)
-          if filter.is_a?(Hash) && filter[:controller]
+          if filter[:controller]
             { controller: /#{filter[:controller].downcase.sub(/_?controller\z/, '').sub('::', '/')}/ }
-          elsif filter
-            { controller: /#{filter}/, action: /#{filter}/, verb: /#{filter}/, name: /#{filter}/, path: /#{filter}/ }
+          elsif filter[:grep_pattern]
+            {
+              controller: /#{filter[:grep_pattern]}/,
+              action: /#{filter[:grep_pattern]}/,
+              verb: /#{filter[:grep_pattern]}/,
+              name: /#{filter[:grep_pattern]}/,
+              path: /#{filter[:grep_pattern]}/
+            }
           end
         end

つっつきボイス: 「最近自分はブラウザで/rails/info/routesで見ちゃうこと多いかなー」「このパスが割りと覚えにくいという」「/aとか打ってルーティングエラー出す方が早いっすね」「たしかに」

Rails

Railsビューをin_groups_ofでリファクタリング(RubyFlowより)

// 同記事より
%table.sponsors{width: "100%;"}
  - sponsors_by_level.levels.each do |level|
    - level.sponsors.in_groups_of(level.sponsors_per_line, false) do |group|
      %tr
        - group.each do |sponsor|
          %td{colspan: 12 / group.size, style: "text-align: center !important;"}
            = link_to sponsor.path do
              = image_tag(sponsor.logo_url, alt: sponsor.name, title: sponsor.name, style: "display: inline; float: none;")
    %tr
      %td{colspan: 12}
        %hr

つっつきボイス: 「ほっほー、in_groups_ofとな」「内部でeach_slice使ってるからこれを直接使う方が早かったかも、だそうです」「改修前のhaml、見たくないやつ…」

参考: in_groups_of
参考: Rubyリファレンス・マニュアル each_slice

RailsのシステムテストでJSエラーをキャッチする方法(RubyFlowより)

WARN: javascript warning
http://127.0.0.1:60979/assets/application.js 9457 Synchronous XMLHttpRequest on the main thread is deprecated because of its detrimental effects to the end user’s experience.
Got 3 failures and 1 other error from failure aggregation block “javascript errrors”:
1) http://127.0.0.1:60481/sso 10:18 Uncaught SyntaxError: Unexpected token ;
同記事より


つっつきボイス: 「テーブル使うのはどうかと思うけどまあそれはおいといて」「どうやらきれいにキャッチする方法がないからコンソールに出力してそっちで見れ、ってことみたい」「たしかに原理的に難しそう」

Railsのメール設定でdeliverdeliver_nowは使うな(Hacklinesより)

deliver_laterにしとけ、だそうです。

# 同記事より
class User
  after_update :send_email
  def send_email
    ReportMailer.update_mail(id).deliver_later
  end
end

つっつきボイス:deliver_nowは同期的なのか: じゃあ使いたくないやつですね」「deliver_laterで非同期になると、それはそれでテストで考慮しないといけない点が増えて大変になるけど: キューに入ったりメールサーバーが応答したりしてもそれだけでよしとできないとか」「キューに入ってコケたかどうか、とか」

参考: Rails API deliver_later

RailsでReduxのフォームを使うには(Awesome Rubyより)


redux-form.comより

# 同記事より
    def create
        authorize resource_plan, :create?
        command = GlobalContainer['plan.services.create_plan_command'] #Plan::CreatePlan.new
        respond_to do |format|
          format.json {
            command.call(resource_plan, params[:plan]) do |m|
              m.success do |plan|
                flash[:notice] = t('messages.created', resource_name: Plan.model_name.human)
                render json: { id: plan.id}, status: :ok, location: settings_plan_path(plan)
              end
              m.failure do |form|
                render json: {
                  status: :failure,
                  payload: { errors: form.react_errors_hash }
                }, status: 422
              end
            end
          }
        end
      end

Redux-Formでうまく書けたそうです。Redux-Formは完成途上らしいので、末尾でFinal Formというフレームワーク非依存JSフォームライブラリも紹介しています。


github.com/final-form/final-formより


つっつきボイス: 「コードの中でRepresenterというのを置いてますね」

HABTMをhas_many throughに置き換える(RubyFlowより)

# 同記事より
class PostTag < ApplicationRecord
  belongs_to :post
  belongs_to :tag
end
class Post < ApplicationRecord
  has_many :post_tags, -> { order(rank: :asc) }
  has_many :tags, through: :post_tags
end
class Tag < ApplicationRecord
  has_many :post_tags
  has_many :posts, through: :post_tags
end

つっつきボイス: 「絵に描いたようなHABTMリファクタリングですが、基本ということで」

参考: 仕事のねた: rails3でHABTMが非推奨になってる

Railsで巨大データをdedupする(Hacklinesより)

# 同記事より
# log.rb
class Log < ActiveRecord::Base
  has_many :user_logs

  def store(data)
    key = Digest::MD5.hexdigest(data)
    log = Log.find_by_checksum(key)
    if log.nil?
      log = Log.new(data: data, checksum: key)
      Log.transaction(requires_new: true) do
        begin
          log.save!
        rescue ActiveRecord::RecordNotUnique => e
          raise ActiveRecord::Rollback
        end
      end
    end
    log
  end
end

ActiveRecordリレーションをyield_selfでコンポジション可能にする(Hacklinesより)

# 同記事より
def call
  base_relation.
    joins(:care_periods).
    yield_self(&method(:care_provider_clause)).
    yield_self(&method(:hospital_clause)).
    yield_self(&method(:discharge_period_clause))
end

private

def care_provider_clause(relation)
  if params.care_provider_id.present?
    relation.where(care_periods: { care_provider_id: params.care_provider_id })
  else
    relation
  end
end
...

つっつきボイス:yield_selfってどっかで見たゾ」「あーこれだ↓」「そうそう、tapしないで書けるというのはちょっといいかも」「tapだとビックリマーク付きのwhere!になりますね: 破壊的にならないからいいだろ?っていう趣旨なのかな」「別に破壊的でもいい気はするけど」「メモリ効率とかの話を別にすれば、イミュータブルな方が望ましくはあるし、where!は基本使いたくはないので気持ちはわかる」

Ruby 2.5の`yield_self`が想像以上に何だかスゴい件について(翻訳)

RailsアプリをHerokuからAWSに移して年8万ドル以上節約した件について(Awesome Rubyより)


つっつきボイス: 「1000万近くって以前どれだけザルだったのかとw」「ちゃんと読んでないけど、HerokuとAWSの違いというより設定が大きかったんじゃ?」

search_flip: ElasticSearchクエリをチェインするクライアント(RubyFlowより)

★はまだ少ないです。


つっつきボイス: 「類似のgemがあるんではないかと思って」「ははあ、searchkickと違ってハッシュ使わずに書けるぞ↓ドヤアってことかな」

# 同リポジトリより
# elasticsearch-ruby
Comment.search(
  query: {
    query_string: {
      query: "hello world",
      default_operator: "AND"
    }
  }
)

# searchkick
Comment.search("hello world",
               where: { available: true },
               order: { id: "desc" },
               aggs: [:username])

# search_flip
CommentIndex.where(available: true)
            .search("hello world")
            .sort(id: "desc")
            .aggregate(:username)

RabbitMQはSidekiqの単なる置き換え以上のものだ(RubyFlowより)


つっつきボイス: 「熱烈にRabbitMQ推してますね: 永続性の保証とかで違ってくるみたい」「Sidekiqだって用途に合ってればとってもいいヨって最後に書いてますね」


rabbitmq.comより


sidekiq.orgより

Railsデプロイ前にこれだけはチェックしたい5項目(RubyFlowより)


  • public/404.htmlとか設定したか
  • HTTPSにしたか
  • URLでデータベース内容がお漏らししないようにしたか
  • 監視設定やったか
  • デプロイを自動化したか
# 同記事より
class User < ApplicationRecord
  has_secure_token :uuid # DBでUNIQUE indexにしておけばベスト

  def to_param
    self.uuid
  end
end

つっつきボイス: 「年バレネタですが『ウルトラ5つの誓い』を思い出しちゃって」

参考: ウルトラ5つの誓いとは (ウルトライツツノチカイとは) [単語記事] - ニコニコ大百科

RubyのValue Objectはこう使おう(RubyFlowより)

# 同リポジトリより
# Good
BigDecimal('100').to_i     # => 数値を変えずに精度だけ下げる
# Bad
Quantity.new(10, 'm').to_i # => コンテキストが失われる: Quantity#amountとする方がずっといい

# Acceptable
Dates::Period.to_activercord # => コンテキストによってはあり
# Questionable
Dates::Period.to_regexp      # => #regexpでいいんじゃね?

つっつきボイス: 「けっこうがっつり書いてあってよさそうです」「なぜGitHubリポジトリなのかはおいといて」「あのzverokさんだ↓」

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

QuickType.io: JSONを貼るとRubyやSwiftやJSのコードに変換するサイト(RubyFlowより)


同サイトより

Rubyの場合、例のdry-rbを使ってくれます。

  • 変換元


同サイトより

  • 変換先


同サイトより


つっつきボイス: 「お、これ便利かも」「Pythonが入ってないのが何となく男らしい」「逆変換もできたらいいな♡」

悪いのはRailsじゃない、Active Recordだっ(Hacklinesより)

# 同記事より
data = [
  { name: "Owner", email: "owner@example.com" },
  { name: "Employee", email: "employee@example.com" },
  ...
]

# Raw SQL
INSERT INTO users (name, email) 
VALUES ("Owner", "owner@example.com"), ("Employee", "employee@example.com")

# Sequel
  db[:users].multi_insert(data)

# ActiveRecord by #import
  User.import(data.first.keys, data.map(&:values))

# Arel
  table = Table.new(:users)
  manager = Arel::InsertManager.new
  manger.into(table)

  manager.columns = [table[:name], table[:email]]
  manager.values = manager.create_values_list(data.map(&values))
  • 結局SQL構文知らないと使えない
  • RubyによるSQL構文チェックがない
  • オブジェクト指向じゃない
  • メンテがつらい
  • モデルにビジネスロジックだのエンティティ構造だの追加アクションだのしょぼいロジック定義が山盛りになる

つっつきボイス: 「PV狙いのタイトルっぽい」「悪いとしたらビュー周りかなと思った」「この人『Arelは悪くない、Sequelいいヤツ』って言ってますけど、Arelについてはちょっとどうかなー」「うーむ」「私は最終的には生SQLが最強だとこっそり信じてますけど」「生SQLは覇者」「ORMでやってても結局SQLチェックしますしね」「文中のRecursive Common Table Expressionって何だったかな(↓)」

参考: COMMON_TABLE_EXPRESSION (TRANSACT-SQL) | Microsoft Docs

私がRailsよりHanamiが好きな理由(Ruby Weeklyより)


hanamirb.orgより

  • Repositoryパターンなところ
  • アクションがクラスであるところ
  • ビュークラスがあるところ

著者はあのRyan Biggさんです↓。

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

SOLIDの原則その2: オープン/クローズの原則

# 同記事より
class UserCreateService
  def initialize(params, validator: UserValidator)
    @params = params
    @validator = validator
  end

  def call
    return false unless validator.new(params).validate
    process_user_data
  end

  attr_reader :params, :validator

  def process_user_data
    ...
  end
end

RubyMineで速攻殺している自動チェック項目3つ(Hacklinesより)

とても短い記事です。


つっつきボイス: 「Cucumberとかスペルチェックはともかく、”Double quoted string”って式展開がない場合はシングルクォートにしろっていうアレですかね?」「RuboCopちゃんに怒られるから基本シングルクォートにする癖がついてる」「実はオフにする方法を知らないけどなっ: 玉突きで変更しないといけないし」

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

海外のRuby/Railsカンファレンス

Ruby/Rails関連おすすめ情報源(RubyFlowより)

書籍やサイトやチュートリアルがずらっと並んでいます。

あるRails開発会社の会社概要(RubyFlowより)

開発ツールや進め方が割りと事細かに書かれています。全部字ばっかりなのが逆に珍しいかも。

ビューやヘルパーは別世界か


つっつきボイス: 「ちょうど最近この辺の話をよくしてたので」「そうそう、#lとか#tみたいにビュー全体で使うようなのをヘルパーに置くのはまだわかるんだけど、『これ汚いからビューから逃したい』みたいなのをグローバルなヘルパーに置くのはどうかな~っていつも思ってる」「さすがヘルパー嫌いマン」「Railsのヘルパーは、WordPressで言うfunctions.phpみたいなものって説明してもらって腑に落ちたことあります」
「a_matsudaさんといえばactive_decoratorの作者ですよね: モデル名と紐付いているデコレータのモジュールをビューに行くまでにこっそりインクルードするみたいな: まさにそういう話ですよね↑」「俺それ正解だと思うよマジで」

「ヘルパーに置いてグローバルになるくらいなら、いっそコントローラに書いちゃいますね: helper_methodっていうメソッド↓があって、これを使うとコントローラにヘルパーを書けちゃうんですよ」「へー!」「知らなかった」「しょっちゅうは使わないけど、ここぞというときに控えめに使う感じで: 内部の挙動はまだよく知らないし本当にいいものかどうかはちょっと微妙なんですが」

参考: Rails API helper_method

class ApplicationController < ActionController::Base
  helper_method :current_user, :logged_in?

  def current_user
    @current_user ||= User.find_by(id: session[:user])
  end

  def logged_in?
    current_user != nil
  end
end

Railsの「雪だるまエンコーディング」問題が修正☃️

先ほど流れてきたので。

その他小粒記事

Ruby trunkより

特殊変数を排除してPathnameを高速化(継続)

Regexp#=~だと$&などの特殊変数を更新する分オーバーヘッドが生じるので、更新しないRegexp#match?に置き換えたとのことです。ベンチマークの書式がびしっと整ってます。

[Ruby] Kernelの特殊変数をできるだけ$記号なしで書いてみる


つっつきボイス: 「ちょっと話それるけど、名前がFileなのにパスっぽいものも渡されたりするとどうかと思うことがある」「それは確かによくないかも」

提案: キーがない場合にraiseする#dig!(継続)

hash = {
    :name => {
        :first => "Ariel",
        :last => "Caplan"
    }
}

hash.dig!(:name, :first) # => Ariel
hash.dig!(:name, :middle) # => nil   ●これをraiseしたい
hash.dig!(:name, :first, :foo) # raises TypeError (String does not have #dig method)

「キーワード引数でできるのでは?」「deep_fetch gemでできる」という回答です。


つっつきボイス: 「ビックリマーク付きの#dig!か」「!はこの場合いいんだろうか?」

Ruby:「プリマドンナメソッド」の臭いの警告を私が受け入れるまで(翻訳)

提案: begin(またはdo)-elseendrescueが抜けてたらsyntax errorにしたい(受理)

begin
  p :foo
else
  p :bar
end

# => :foo
# => :bar

joker1002さんです。特にdoで始まるときにrescueを置き忘れやすいので、syntax errorにしたいとのことです。


つっつきボイス: 「自分的にはwarningのままの方がいいかなーという気がするけど」「そういえばエラー処理のelseensureってどう違うんでしたっけ?」「elseは上のどれでもなかった場合で、eusureは結果にかかわらず必ず実行するやつだったかと」

なおその後acceptされました。

参考: Rubyリファレンスマニュアル begin

Ruby

RubyにもGolangのdeferが欲しいので作ってみた話


つっつきボイス: 「ちょうど上の話にも通じてる」「Goのdeferはブロックの外にも置けてRubyのensureより強力な印象ですね: まだ使ったことないけど」「JavaScriptのPromiseもDeferredって呼ばれてた」

参考: Golang の defer 文と panic/recover 機構について - CUBE SUGAR CONTAINER
参考: 非同期処理とPromise(Deferred)を背景から理解しよう - hifive

licensed: GitHub自ら提供する依存関係のライセンス照合/キャッシュgem(Ruby Weeklyより)

# 同リポジトリより
$ bundle exec licensed status
Checking licenses for 3 dependencies

Warnings:

.licenses/rubygem/bundler.txt:
  - license needs reviewed: mit.

.licenses/rubygem/licensee.txt:
  - cached license data missing

.licenses/bower/jquery.txt:
  - license needs reviewed: mit.
  - cached license data out of date

3 dependencies checked, 3 warnings found.

まだ1か月経ってない新しいgemです。類似のgemを取り上げたことがありました。

rakeタスクをきれいに書くコツ(Hacklinesより)

DHHのYouTubeチャンネルでヒントを得たそうです。

asciidoctor: AsciiDoc形式テキストプロセッサのRuby版(Awesome Rubyより)

= Hello, AsciiDoc!
Doc Writer <doc@example.com>

An introduction to http://asciidoc.org[AsciiDoc].

== First Section

* item 1
* item 2

[source,ruby]
puts "Hello, World!"

AsciiDocはこんな感じ↑で書けるようです。単なるMarkdownの置き換えではないと言ってます。

参考: What is AsciiDoc? Why do we need it? | Asciidoctor
参考: 脱Word、脱Markdown、asciidocでドキュメント作成する際のアレコレ

ヒアドキュメントで式展開#{}を展開させない方法(Hacklinesより)

# 同記事より
venue = "world"
str = <<-'EOF'
I'm the master of the #{venue} !
No you're dead bro..\n
EOF

2018年のRuby GUI開発事情(RubyFlowより)


saveriomiroddi.github.ioより

なお著者はGobyのcontributorであることを今思い出しました。

RubyのリゾルバでSSRFフィルタをバイパスされる脆弱性(Hacklinesより)

# 同記事より
irb(main):008:0> Resolv.getaddresses("127.0.0.1")
=> ["127.0.0.1"]
irb(main):009:0> Resolv.getaddresses("localhost")
=> ["127.0.0.1"]
irb(main):010:0> Resolv.getaddresses("127.000.000.1")
=> [] # 😱

参考: Rubyリファレンスマニュアル Resolv

SCSSコンパイラを自力で書いてみたお(RubyFlowより)


同記事より

RubyがTIOBEのトップ10言語に返り咲く(Hacklinesより)


tiobe.comより


つっつきボイス: 「記事のグラフ見ると、むしろC言語がいったん下がってからガッと上がっているのが気になる」「JavaとC以外はまだ混戦かなー」

Aaron Pattersonさんから

#kind_ofが悪手になる場合

どこかで「#is_a?は今は非推奨」とmatzがツイートしていた気がしましたが、そのエイリアスである#kind_of?もどうやらあまり使って欲しくない様子です。


つっつきボイス: 「Matzが#kind_of?警察やってる」「#is_a?が非推奨になったのは、確か名前がよくなかったからだったような」「『純粋なオブジェクト指向ならメッセージベースでやろうぜ』『クラスなんてものはオブジェクト間の通信には本来不要である』という趣旨なんでしょうね」
「質問者の方もそうだけど、Javaから来ると型チェックしたくなる気持ちはわかる」「Javaにはインターフェイスがあるから」「Rubyにはrespond_to?がある」

私もつい型チェック的思考に傾きかけてたかも。反省。

参考: RubyリファレンスマニュアルObject#respond_to?


「ところでRailsには?なしのrespondo_toというのがあってですね」「紛らわし!」「Railsを先にやると、Rubyのrespond_to?の方でむしろ首を傾げたりとか」「RSpecのrespond_toマッチャーも同じ過ぎるし」「名前がこれだけ似てて意味がまるで違うという」

参考: Rails API respond_to

Ruby生誕25周年記念: コミットのビジュアル表示

SQL

データベースのモデル化アンチパターン3種(Postgres Weeklyより)


  1. Entity Attribute Values
  2. Multiple Values per Column
  3. UUID

つっつきボイス: 「EAVはSQLアンチパターンにも載っている定番中の定番っすね: 一度はやりたくなってしまうやつ」「略語になってるんですね」
「むかーしエンタープライズ系のJavaの本で『EAVはベストプラクティスのひとつである』みたいな記述があったんですが」「マジかーw」「いや、たぶんこれはメモリ構造に乗せてEAVする分にはよかったはず: RDBMSでやるもんじゃないですよもちろん」「後で検索が必要になったときに死ねるやつ」
「2.は今のRDBMSなら普通にできたりしますね: PostgreSQLのArrayとか」

PostgreSQLの全文検索でVACUUMを使うときにやるべきこと(Postgres Weeklyより)


つっつきボイス: 「出たーVACUUM ANALYZE: めちゃめちゃ重い」

PostgreSQLの新機能「シーケンス」のメリットと落とし穴(Postgres Weeklyより)

動画: データベースの隠し技紹介(Postgres Weeklyより)

オーストラリアでこの3月に行われたRubyカンファレンスであるRubyConf Auでの発表です。

JavaScript

prettier: JS界のRuboCop


同リポジトリよりより

★めちゃ多いです。

JavaScriptのエレガントな「ROROパターン」(Frontend Weeklyより)

割りと長い記事です。ROROは「Receive an object, return an object」だそうです。RubyのPOROとは違いました。

スーパー速い「Radi.js」フレームワークを作ったお(JavaScript Weeklyより)

Virtual DOMを使わないことで速くしたそうです。

Glimmer.jsとPreact.jsのパフォーマンス比較(JSer.infoより)

Linkedinの技術ブログです。

CSS/HTML/フロントエンド

Tumult Hype: フロントのアニメーション表示を徹底制御(Hacklinesより)

<a https://tumult.com/hype/”>
同記事より

HoudiniプロジェクトのCSS Paint API(Frontend Focusより)

Chrome 65以降でないと動かないので、brew cuで速攻Chromeをアップグレードしました。以下で「Fail」が出るブラウザではできないそうです。

See the Pen CSS Paint API Detection by Will Boyd (@lonekorean) on CodePen.

動画: Chromeの新機能「Local Overrides」でパフォーマンス上の仮説をテストする(Frontend Focusより)

「King’s Pawn Game」に学ぶUIデザイン(Frontend Weeklyより)


同記事より

UIデザイナー向けの記事です。King’s Pawn Gameは、チェスの序盤の定石のようです。

参考: Wikipedia-en King’s Pawn Game

CSSで四隅を切り欠くには(Frontend Focusより)

See the Pen Notched Boxes by Chris Coyier (@chriscoyier) on CodePen.

World Wide Webが29歳の誕生日(Frontend Focusより)

その他

Stackoverflowのアンケート結果

かなり長いです。

MacのiTerm2で出力を任意のエディタに送り込む(Hacklinesより)

AppleScriptの小ネタです。

若手開発者サバイバルガイド: コードが動かないときにうまく先輩に伝えるには(Hacklinesより)


つっつきボイス: 「これも翻訳打診してみますね」

HomebrewとPythonバージョンの混乱

Mathpix snipping tool: 数式を撮影するとLaTeXに変換するスマホアプリ

よく見たら昨年からiPhoneにインストールしてました。どっちかというとソルバーです。

番外

「いいこと聞いた」と思うかどうかが分かれ目?

ソイレントといえば

私の年だとソイレント・グリーンですが、ゼノギアスの方が有名っぽいですね。

たけのこ

巨星墜つ

モンティ・パイソンに出演したホーキング博士も素敵でした。


今週は以上です。

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

週刊Railsウォッチ(20180309)RubyGems.orgのTLS 1.0/1.1接続非推奨、2年に1度のRailsアンケート、DockerのMoby Project、Ruby拡張をRustで書けるruruほか

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

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

Ruby 公式ニュース

Rails公式ニュース

Ruby Weekly

Awesome Ruby

RubyFlow

160928_1638_XvIP4h

Hacklines

Hacklines

Postgres Weekly

postgres_weekly_banner

Frontend Weekly

frontendweekly_banner_captured

Frontend Focus

frontendfocus_banner_captured

JavaScript Weekly

javascriptweekly_logo_captured

JSer.info

jser.info_logo_captured


Rails tips: Service Objectパターンでリファクタリング(翻訳)

$
0
0

概要

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

Rails tips: Service Objectパターンでリファクタリング(翻訳)

このリファクタリングパターンは非常にシンプルですが、私が作業しているどのアプリでも非常に有効であることがわかってきました。Service Object(訳注: 単にServiceと書かれることもありますが、本記事では略さない方に統一しています)パターンは、クラスやメソッドのコード量が増えてしまったときにいつでも使えます。私はServiceを作るときは以下のルールを守るようにしています。

  • Service Objectのクラス名の末尾には必ずServiceを付ける
  • 単一責任の原則を守る(この原則はService Objectに限らない一般的なルールです)

Service Objectのアイデアをわかりやすく示すために、サンプルのService Objectクラスを作ってみましょう。

module Users
  class EmailDomainService
    def initialize(user)
      @user = user
    end

    def email_domain
      user.email.split("@").last if user.email.present?
    end

    private
    attr_reader :user
  end
end

このクラスは非常にシンプルでありながら、きわめて便利です。Service Objectのクラス内のコード量は抑えておくべきです。Service Objectはささやかなサービスだけを、一度に1つだけ提供すべきだからです。

このパターンは、巨大なクラスを小さなコード片に分解してサービスを分離するリファクタリングに使えます。


RSpec & TDDの電子書籍を無料でダウンロード

もっと稼ぎたい方や会社をさらに発展させたい方へ: テスティングのスキルの重要性にお気づきでしょうか?テストを正しく書き始めることが、唯一のファーストステップです。無料でダウンロードいただける私の書籍『RSpec & Test Driven Developmentの無料ebook』をどうぞお役立てください。

関連記事

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

Rails: Service Objectはもっと使われてもいい(翻訳)

Rails: スコープをモデルの外でチェインするのはやめておけ(翻訳)

$
0
0

概要

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

Rails: スコープをモデルの外でチェインするのはやめておけ(翻訳)

Railsアプリで、次のようにモデルのデータベーススキーマの内部にまで立ち入っている(コントローラ)コードをまれによく見かけます。

class Person < ActiveRecord::Base
  enum gender: { male: 1, female: 2 }
end

class PeopleController < ApplicationController
  def index
    @people = Person.where(gender: Person.genders[:male])
                    .where('age >= 18')
                    .where(right_handed: false)

    respond_to(:html)
  end
end

このコードにはいくつか問題点があります。

  • コントローラがモデルのデータベース構造に関する知識を持ちすぎています。背後の詳細な情報が上位の層に漏れると、背後の構造が変更しにくくなります。
  • メソッド呼び出しがチェインしているので、モックを使ったテストが死ぬほどやりづらくなります。

このような実装の詳細はモデル内にカプセル化されなければなりません。ActiveRecordのスコープの助けを借りて何とかしてみましょう。

class Person < ActiveRecord::Base
  enum gender: { male: 1, female: 2 }

  scope :male,        -> { where(gender: 1) }
  scope :adult,       -> { where('age >= 18') }
  scope :left_handed, -> { where(right_handed: false) }
end

class PeopleController < ApplicationController
  def index
    @people = Person.male.adult.left_handed

    respond_to(:html)
  end
end

生SQLやモデル属性の知識はモデル内にカプセル化されました。これで一件落着…したのでしょうか?

テストの書きやすさはほんの少しだけましになりましたが、異なるスコープを組み合わせる長いメソッドチェインはまだ残っています。コントローラをテストするには、またしてもモック軍団を出動させなければなりません。

class PeopleControllerTest < ActionController::TestCase
  def test_people_index
    adult_finder        = mock
    left_handed_finder  = mock

    Person.expects(:male).returns(adult_finder)
    adult_finder.expects(:adult).returns(left_handed_finder)
    left_handed_finder.expects(:left_handed)

    get :index
    assert_response :success
  end
end

テストコードはexpectationだらけで、しかもかなり脆くなっています。たとえテスト対象コードが正常だったとしても、スコープの順序がちょっと変わっただけでテストは失敗してしまいます。

スコープが複雑になると他にも問題が生じることがあります。スコープはいくらでも自由に組み合わせられますが、その組み合わせから正しいSQLが生成されるとは限りません。その組み合わせを全部テストしていたら心が削られてしまいます。

私は、スコープをモデルの外でがんがんチェインするのではなく、スコープの組み合わせをモデル内で単一のスコープやクラスメソッドにまとめるのが好みです。この方が処理を可能な限り内部化できますし、データベースクエリの最適化などの作業もずっとやりやすくなります。

class Person < ActiveRecord::Base
  enum gender: { male: 1, female: 2 }

  scope :male,        -> { where(gender: 1) }
  scope :adult,       -> { where('age >= 18') }
  scope :left_handed, -> { where(right_handed: false) }

  class << self
    def left_handed_male_adults
      left_handed.male.adult
    end
  end
end

class PeopleController < ApplicationController
  def index
    @people = Person.left_handed_male_adults

    respond_to(:html)
  end
end

スコープはPerson.left_handed_male_adultsクラスメソッドの内部にラップされています。必要ならこのクラスメソッド自身をスコープとして定義することも可能な点にご注目ください。2つの方法の大きな違いは、スコープがActiveRecordリレーションを返すことを保証するかどうかです。

スコープの組み合わせはぐっとシンプルになり、しかもテストに対して頑丈になります。

class PeopleControllerTest < ActionController::TestCase
  def test_people_index
    Person.expects(:left_handed_male_adults)

    get :index
    assert_response :success
  end
end

関連するモデルの外でスコープをチェインするのを避ければ、コードベースにおける結合を弱められ、それによってメンテナンスやリファクタリングもやりやすくなります。

もちろんあらゆるスコープはpublicなので、このスコープもその気になればチェインできます。スコープをモデルの外でチェインしたくなる衝動をぐっとこらえられれば、話は簡単になるのです。

関連

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

Rails tips: モデルのクエリをカプセル化する2つの方法(翻訳)

Rails tips: Parameter Objectパターンでリファクタリング(翻訳)

$
0
0

概要

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

Rails tips: Parameter Objectパターンでリファクタリング(翻訳)

1つのメソッドにやたら多くのパラメータを渡さないようにするのに苦労することがあります。こんなときはParameter Objectパターンの出番です。Parameter Objectはパラメータを属性として持つ素朴なRubyオブジェクトであり、パラメータをいくつも渡す代わりに1つのオブジェクトインスタンスを引数として渡せます。このパターンのメリットがよくわかるサンプルのクラスを見てみましょう。

class Notificator
  def push_update(title, content, category)
    some_api_class.push(title: title, content: content, category: category)
  end
end

上では更新をAPIにプッシュするサンプルクラスが使われています。みてのとおり、このパラメータはAPIメッセージに関連しています。シンプルなクラスでParameter Objectを作成します。

class ApiMessage
  attr_reader :title, :content, :category

  def initialize(title:, content:, category:)
    @title = title
    @content = content
    @category = category
  end
end

それではNotificatorクラスを書き換えて、メソッドで必要な引数が1つで済むようにしてみましょう。

class Notificator
  def push_update(api_message)
    some_api_class.push(title: api_message.title, content: api_message.content, category: api_message.category)
  end
end

コードが読みやすくなり、テストもしやすくなりました。ここで重要なのは、Parameter Objectをごくシンプルなオブジェクトとしての利用にとどめていることです。さもないと、このリファクタリングパターンの意図が失われ、余計な機能と大量のコードを抱えた普通のクラスになってしまいます。


RSpec & TDDの電子書籍を無料でダウンロード

もっと稼ぎたい方や会社をさらに発展させたい方へ: テスティングのスキルの重要性にお気づきでしょうか?テストを正しく書き始めることが、唯一のファーストステップです。無料でダウンロードいただける私の書籍『RSpec & Test Driven Developmentの無料ebook』をどうぞお役立てください。

関連記事

Rails tips: Simple Factoryパターンによるリファクタリングの使いどころ(翻訳)

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

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

Rails5「中級」チュートリアル(4-1-1)インスタントメッセージ: 非公開チャット –前編(翻訳)

$
0
0

概要

概要

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

Rails5中級チュートリアルはセットアップが短めで、RDBMSにはPostgreSQL、テストにはRSpecを用います。
原文が非常に長いので分割します。章ごとのリンクは順次追加します。

注意: Rails中級チュートリアルは、Ruby on Railsチュートリアル(https://railstutorial.jp/)(Railsチュートリアル)とは著者も対象読者も異なります。

目次


訳注: この少し前あたりの手順から、visit_single_post_spec.rbが失敗しています。翻訳中の検証ではscenarioxscenarioに変えてひとまずペンディングしています。お気づきの点がありましたら@hachi8833までお知らせください。

Rails5「中級」チュートリアル(4-1-1)インスタントメッセージ: 非公開チャット – 前編: チャット機能の作成(翻訳)

訳注: conversationは原則「チャット」と訳しています。

このセクションの目標は、2人のユーザーが非公開で会話できるチャット機能の作成です。

新しいブランチを切ります。

git checkout -B private_conversation

モデルを名前空間化する

まず、必要なモデルをいくつか定義しましょう。さしあたって2つの異なるモデルが必要です。1つは非公開チャット用、もう1つはプライベートメッセージ用です。モデル名をPrivateConversationPrivateMessageとすることも一応可能ですが、すぐに小さな問題に突き当たるでしょう。すべてがうまく動いていても、modelsディレクトリの下に同じようなプレフィックスを持つモデル名がいくつもできてしまうところを想像してみてください。間もなくこのディレクトリの管理がつらくてたまらなくなるでしょう。

ディレクトリ内がカオスになるのを避けるために、名前空間化を使います。

名前空間化するとどうなるかを見ていきましょう。非公開チャットに使うモデルを素直に命名すればPrivateConversationとなり、モデルのファイルはprivate_conversation.rbで、modelディレクトリに置かれます。

models/private_conversation.rb

これを名前空間化したものはPrivate::Conversationになります。ファイル名はconversation.rbで、models/privateディレクトリに置かれます。

models/private/conversation.rb

これのどこが便利なのかおわかりでしょうか?privateプレフィックスを持つファイルはすべてprivateディレクトリに保存されます。モデルをメインのmodelsディレクトリの下にベタッと並べると読むのがつらくなります。

こんなとき、Railsはいつものように開発プロセスを楽しくしてくれます。Railsでは、モデルを保存するディレクトリを指定して、名前空間化されたモデルを作成することができます。

名前空間化されたPrivate::Conversationモデルを作成するには、以下のコマンドを実行します。

rails g model private/conversation

Private::Messageモデルも同様に作成します。

rails g model private/message

modelsディレクトリを見てみると、その下にprivate.rbファイルができているのがわかります。これによってデータベースのテーブル名にプレフィックスを付けることが必須になり、モデルが認識されるようになります。個人的には、こうしたファイルをmodelsディレクトリに置いておくのは好きではなく、モデル自身の内部でテーブル名を指定する方が好きです。モデルの内部でテーブル名を指定するには、self.table_name =でテーブル名を文字列で指定しなくてはなりません。私と同じようにデータベースのテーブル名をこの方法で指定すると、このモデルは次のようになります(GistGist)。

# models/private/conversation.rb
class Private::Conversation < ApplicationRecord
  self.table_name = 'private_conversations'
end
# models/private/message.rb
class Private::Message < ApplicationRecord
  self.table_name = 'private_messages'
end

これでmodelsディレクトリの下のprivate.rbファイルは不要になったので、削除して構いません。

1人のユーザーは非公開チャットを複数行え、チャットには多数のメッセージが含まれます。この関連付けをモデル内で定義しましょう(GistGistGist)。

# models/private/conversation.rb
...
has_many :messages, 
         class_name: "Private::Message", 
         foreign_key: :conversation_id
belongs_to :sender, foreign_key: :sender_id, class_name: 'User'
belongs_to :recipient, foreign_key: :recipient_id, class_name: 'User'
...
# models/private/message.rb
...
  belongs_to :user
  belongs_to :conversation, 
             class_name: 'Private::Conversation',
             foreign_key: :conversation_id
...
# models/user.rb
...
has_many :private_messages, class_name: 'Private::Message'
has_many  :private_conversations, 
          foreign_key: :sender_id, 
          class_name: 'Private::Conversation'
...

上のclass_nameメソッドは、関連付けられたモデルの名前を定義するのに使われます。こうすることで関連付けに独自の名前が使えるようになり、名前空間化されたモデルであることが認識されます。class_nameメソッドのもうひとつの使い方は「自分自身へのリレーション」の作成です。これは、何らかの階層的な構造を作成して同じモデルのデータを差別化したいときに便利です。

foreign_keyは、データベーステーブル内の関連付けカラム名を指定するのに使います。テーブル内のカラムはbelongs_to関連付け側でのみ作成されますが、このカラムを認識させるために、で2つのモデルの同じ値でforeign_keyを定義しました。

非公開チャットは2人のユーザー間で行えるようにします。ここでは2人のユーザーをそれぞれsenderrecipientとします。user1user2のような名前にしようと思えばできますが、2人のどちらが最初にチャットを開始したかがわかると何かと便利なので、ここではsenderがチャットの作成者となります。

マイグレーションファイルでデータのテーブルを定義します(Gist)。

# db/migrate/CREATION_DATE_create_private_conversations.rb
class CreatePrivateConversations < ActiveRecord::Migration[5.1]
  def change
    create_table :private_conversations do |t|
      t.integer :recipient_id
      t.integer :sender_id

      t.timestamps
    end
    add_index :private_conversations, :recipient_id
    add_index :private_conversations, :sender_id
    add_index :private_conversations, [:recipient_id, :sender_id], unique: true
  end
end

private_conversationsにはユーザーのidを保存することになります。ユーザーidは、belongs_to関連付けやhas_many関連付けが機能するのに必要ですし、2人のユーザー間のチャットを作成するのにももちろん必要です(Gist)。

# db/migrate/CREATION_DATE_create_private_messages.rb
class CreatePrivateMessages < ActiveRecord::Migration[5.1]
  def change
    create_table :private_messages do |t|
      t.text :body
      t.references :user, foreign_key: true
      t.belongs_to :conversation, index: true
      t.boolean :seen, default: false

      t.timestamps
    end
  end
end

メッセージの内容はbodyデータカラムに保存されることになります。2つのモデル間の関連付けを機能させるためのインデックスやidカラムを追加する代わりに、ここではreferenceメソッドで実装をシンプルにしました。

マイグレーションファイルを実行して、developmentデータベースの内部にテーブルを作成します。

rails db:migrate

変更をcommitします。

git add -A
git commit -m "Create Private::Conversation and Private::Message models

- Define associations between User, Private::Conversation
  and Private::Message models
- Define private_conversations and private_messages tables"

「非リアルタイム」チャットウィンドウ

非公開チャットのデータを保存する場所ができましたが、これでおしまいではありません。次はどこから手を付けるべきでしょうか?前のセクションでご説明したように、私は機能の表示を先に作成して、それからそれを動かすロジックを書く方法が好みです。私にとっては、動かしたい画面表示が先にある方が達成すべき作業がはっきりするからです。ユーザーインターフェイスができていれば、これこれこういう操作を行ったときにどう動くべきかは画面を見ればわかるので、それを細かな手順に分割するのは簡単です。形になっていないものを相手にプログラミングする方が面倒だと思います。

非公開チャットのユーザーインターフェイスを作るためにPrivate::Conversationsコントローラを作成します。先ほどもアプリで名前空間化を行いましたので、関連するパーツもすべて同じように名前空間化したいと思います。こうすることで、ソースコードを直感的に眺めやすくなり、理解もしやすくなります。

rails g controller private/conversations

Railsのジェネレータはなかなかカワイイやつです。名前空間化されたモデルや名前空間化されたビューを生成してくれるので、即開発に取りかかれます。

新しいチャットを作成する

新しいチャットを開始する何らかの方法が必要です。このアプリでは、自分と好みの似通った人と会話したいと考えるのが自然でしょう。この機能を配置するのに便利な場所は、単一の投稿ページの内部です。

posts/show.html.erbテンプレートの中に、チャットを開始するフォームをひとつ作成します。<p><%= @post.content %></p>の下に以下を追加します(Gist)。

<!-- posts/show.html.erb -->
...
<%= render contact_user_partial_path %>
...

このヘルパーメソッドをposts_helper.rbで定義します(Gist)。

# helpers/posts_helper.rb
...
  def contact_user_partial_path
    if user_signed_in?
      @post.user.id != current_user.id ? 'posts/show/contact_user' : 'shared/empty_partial'
    else
      'posts/show/login_required'
    end
  end
...

このヘルパーメソッドのspecを作成します。

# spec/helpers/posts_helper_spec.rb
...
context '#contact_user_partial_path' do
  before(:each) do
    @current_user = create(:user, id: 1)
    helper.stub(:current_user).and_return(@current_user)
  end

  it "returns a contact_user partial's path" do
    helper.stub(:user_signed_in?).and_return(true)
    assign(:post, create(:post, user_id: create(:user, id: 2).id))
    expect(helper.contact_user_partial_path).to(
      eq 'posts/show/contact_user' 
    )
  end

  it "returns an empty partial's path" do
    helper.stub(:user_signed_in?).and_return(true)
    assign(:post, create(:post, user_id: @current_user.id))

    expect(helper.contact_user_partial_path).to(
      eq 'shared/empty_partial'
    )
  end

  it "returns an empty partial's path" do
    helper.stub(:user_signed_in?).and_return(false)
    expect(helper.contact_user_partial_path).to(
      eq 'posts/show/login_required'
    )
  end
end
...

showディレクトリを作成して、対応するパーシャルファイルを作成します(GistGist)。

<!-- posts/show/_contact_user.html.erb -->
<div class="contact-user">
  <%= render leave_message_partial_path %>
</div><!-- contact-user -->
<-- posts/show/_login_required.html.erb -->
<div class="text-center">
  To contact the user you have to <%= link_to 'Login', login_path %> 
</div>

posts_helper.rbファイルでleave_message_partial_pathヘルパーメソッドを定義します(Gist)。

# helpers/posts_helper.rb
def leave_message_partial_path
  if @message_has_been_sent
    'posts/show/contact_user/already_in_touch'
  else
    'posts/show/contact_user/message_form'
  end
end

ヘルパーメソッドのspecを作成します(Gist)。

# spec/helpers/posts_helper_spec.rb
...
context '#leave_message_partial_path' do
  it "returns an already_in_touch partial's path" do
    assign('message_has_been_sent', true)
    expect(helper.leave_message_partial_path).to(
      eq 'posts/show/contact_user/already_in_touch'
    )
  end

  it "returns an already_in_touch partial's path" do
    assign('message_has_been_sent', false)
    expect(helper.leave_message_partial_path).to(
      eq 'posts/show/contact_user/message_form'
    )
  end
end
...

今だけ、PostsController@message_has_been_sentインスタンス変数を定義することにします。この変数は、ユーザーへの最初のメッセージが送信されたかどうかを決定します。

contact_userを作成し、leave_message_partial_pathヘルパーメソッドに対応するパーシャルファイルをその下に作成します(GistGist)。

<!-- posts/show/contact_user/_already_in_touch.html.erb -->
<div class="contacted-user">
  You are already in touch with this user
</div>
<!-- posts/show/contact_user/_message_form.html.erb -->
<%= form_tag({controller: "private/conversations", action: "create"},
              method: "post",
              remote: true) do %>
  <%= hidden_field_tag(:post_id, @post.id)  %>
  <%= text_area_tag(:message_body,
                    nil,
                    rows: 3,
                    class: 'form-control', 
                    placeholder: 'Send a messsage to the user') %>
  <%= submit_tag('Send a message', class: 'btn send-message-to-user') %>
<% end %>

今度はPostsControllershowアクションを設定しましょう。アクション内に以下を追加します(Gist)。

# controllers/posts_controller.rb
...
if user_signed_in?
  @message_has_been_sent = conversation_exist?
end
...

このコントローラのprivateスコープでconversation_exist?メソッドを定義します(Gist)。

# controllers/posts_controller.rb
...
def conversation_exist?
  Private::Conversation.between_users(current_user.id, @post.user.id).present?
end
...

このbetween_usersメソッドは、2人のユーザー間の非公開チャットの存在を問い合わせます。これをPrivate::Conversationモデルでスコープとして定義しましょう(Gist)。

# models/private/conversation.rb
...
scope :between_users, -> (user1_id, user2_id) do
  where(sender_id: user1_id, recipient_id: user2_id).or(
    where(sender_id: user2_id, recipient_id: user1_id)
  )
end
...

このスコープが機能しているかどうかをテストしなければなりません。テストデータベース内にサンプルが必要なので、specを書く前にprivate_conversationファクトリーを定義しておきましょう(Gist)。

# spec/factories/private_conversations.rb
FactoryBot.define do
  factory :private_conversation, class: 'Private::Conversation' do
    association :recipient, factory: :user
    association :sender, factory: :user

    factory :private_conversation_with_messages do
      transient do
        messages_count 1
      end

      after(:create) do |private_conversation, evaluator|
        create_list(:private_message, evaluator.messages_count, 
                     conversation: private_conversation)
      end
    end
  end
end

ファクトリーをネストさせることで、その親の設定を使ってファクトリーを作成してからそれを変更できるようになります。また、private_conversation_with_messagesファクトリーでメッセージを作成するので、private_messageファクトリーの定義も必要です(Gist)。

# spec/factories/private_messages.rb
FactoryBot.define do
  factory :private_message, class: 'Private::Message' do
    body 'a' * 20
    association :conversation, factory: :private_conversation
    user
  end
end

準備がすべて整いましたので、between_usersスコープをspecでテストします(Gist)。

# spec/models/private/conversation_spec.rb
...
context 'Scopes' do
  it 'gets a conversation between users' do
    user1 = create(:user)
    user2 = create(:user)
    create(:private_conversation, recipient_id: user1.id, sender_id: user2.id)
    conversation = Private::Conversation.between_users(user1.id, user2.id)
    expect(conversation.count).to eq 1
  end
end
...

Private::Conversationsコントローラでcreateアクションを定義します(Gist)。

# controllers/private/conversation_controller.rb
...
def create
  recipient_id = Post.find(params[:post_id]).user.id
  conversation = Private::Conversation.new(sender_id: current_user.id, 
                                           recipient_id: recipient_id)
  if conversation.save
    Private::Message.create(user_id: recipient_id, 
                            conversation_id: conversation.id, 
                            body: params[:message_body])
    respond_to do |format|
      format.js {render partial: 'posts/show/contact_user/message_form/success'}
    end
  else
    respond_to do |format|
      format.js {render partial: 'posts/show/contact_user/message_form/fail'}
    end
  end
end
...

ここでは、投稿の著者と現在のユーザーの間でのチャットを作成しています。問題がなければ、アプリで現在のユーザーが書いたメッセージが作成され、対応するJavaScriptパーシャルをレンダリングすることで画面に表示されるようになります。

そのためのパーシャルを作成します(GistGist)。

<!-- posts/show/contact_user/message_form/_success.js.erb -->
$('.contact-user').replaceWith('\
    <div class="contact-user">\
        <div class="contacted-user">Message has been sent</div>\
    </div>');
<!-- posts/show/contact_user/message_form/_fail.js.erb -->
$('.contact-user').replaceWith('<div>Message has not been sent</div>');

Private::ConversationsコントローラとPrivate::Messagesコントローラへのルーティングを作成します(Gist)。

# routes.rb
...
namespace :private do 
  resources :conversations, only: [:create] do
    member do
      post :close
    end
  end
  resources :messages, only: [:index, :create]
end
...

今はまだアクションが少ないので、このような場合はonlyメソッドで書くのが便利です。namespaceメソッドを使うと、名前空間化されたコントローラへのルーティングを簡単に作成できます。

.contact-userフォーム全体のパフォーマンスをfeature specでテストします(Gist)。

# spec/features/posts/contact_user_spec.rb
require "rails_helper"

RSpec.feature "Contact user", :type => :feature do
    let(:user) { create(:user) }
    let(:category) { create(:category, name: 'Arts', branch: 'hobby') }
    let(:post) { create(:post, category_id: category.id) }

  context 'logged in user' do
    before(:each) do
      sign_in user 
    end

    scenario "successfully sends a message to a post's author", js: true do
      visit post_path(post)
      expect(page).to have_selector('.contact-user form')

      fill_in('message_body', with: 'a' * 20)
      find('form .send-message-to-user').trigger('click')

      expect(page).not_to have_selector('.contact-user form')
      expect(page).to have_selector('.contacted-user', 
                                      text: 'Message has been sent')
    end

    scenario 'sees an already contacted message' do
      create(:private_conversation_with_messages, 
              recipient_id: post.user.id, 
              sender_id: user.id)
      visit post_path(post)
      expect(page).to have_selector(
        '.contact-user .contacted-user', 
        text: 'You are already in touch with this user')
    end
  end

  context 'non-logged in user' do
    scenario 'sees a login required message to contact a user' do
      visit post_path(post)
      expect(page).to have_selector('div', text: 'To contact the user you have to')
    end
  end
end

変更をcommitします。

git add -A
git commit -m "Inside a post add a form to contact a user

- Define a contact_user_partial_path helper method in PostsHelper.
  Add specs for the method
- Create _contact_user.html.erb and _login_required.html.erb partials
- Define a leave_message_partial_path helper method in PostsHelper.
  Add specs for the method
- Create _already_in_touch.html.erb and _message_form.html.erb
  partial files
- Define a @message_has_been_sent in PostsController's show action
- Define a between_users scope inside the Private::Conversation model
  Add specs for the scope
- Define private_conversation and private_message factories
- Define routes for Private::Conversations and Private::Messages
- Define a create action inside the Private::Conversations
- Create _success.js and _fail.js partials
- Add feature specs to test the overall .contact-user form"

branch_page.scssファイルにCSSを追加して、フォームのスタイルを少し変更します(Gist)。

// stylesheets/partials/posts/branch_page.scss
...
.send-message-to-user {
  background-color: $navbarColor;
  padding: 10px;
  color: white;
  border-radius: 10px;
  margin-top: 10px;
  &:hover {
    background-color: black;
    color: white;
  }
}

.contact-user {
  text-align: center;
}

.contacted-user {
  display: inline-block;
  border-radius: 10px;
  padding: 10px;
  background-color: $navbarColor;
  color: white;
}
...

この単一投稿ページを表示すると、以下のようにフォームが表示されるはずです。

メッセージを投稿の著者に送信すると、フォームは消えます。

投稿の著者と既にやりとりしたことがある場合は、以下のように表示されます。

変更をcommitします。

git add -A
git commit -m "Add CSS to style the .contact-user form"

チャットウィンドウを表示する

上ではメッセージを1件送信して新しいチャットを作成しました。今はこれ以外に何もできない状態なのでこの機能は何の役にも立ちません。メッセージを読み書きできるチャットウィンドウが必要です。

開いているチャットのidは「セッション」の内部に保存されます。これによって、ユーザーがチャットを終了するかセッションが死ぬまでアプリのチャットを継続できるようになります。

Private::ConversationsControllercreateアクション内で、チャットを保存できたときの処理にadd_to_conversations unless already_added?を追加してください。次にこのメソッドをprivateスコープで定義します(Gist)。

# controllers/private/conversations_controller.rb
...
private

def add_to_conversations
  session[:private_conversations] ||= []
  session[:private_conversations] << @conversation.id
end

これによってチャットのidがセッションに保存されます。チャットidがセッションに追加されていないかどうかを確認するalready_added?メソッドをprivateに配置します(Gist)。

# controllers/private/conversations_controller.rb
def already_added?
  session[:private_conversations].include?(@conversation.id)
end

最後に、ビュー内でチャットにアクセスできる必要があるので、createアクション内のconversation変数(3か所)を@conversationインスタンス変数に書き換えてください。

訳注: 原文にありませんが、変更後のPrivate::ConversationsControllerコントローラを以下に示します。

# controllers/private/conversations_controller.rb
class Private::ConversationsController < ApplicationController
  def create
    recipient_id = Post.find(params[:post_id]).user.id
    @conversation = Private::Conversation.new(sender_id: current_user.id,
                                             recipient_id: recipient_id)
    if @conversation.save
      Private::Message.create(user_id: current_user.id,
                              conversation_id: @conversation.id,
                              body: params[:message_body])

      add_to_conversations unless already_added?

      respond_to do |format|
        format.js {render partial: 'posts/show/contact_user/message_form/success'}
      end
    else
      respond_to do |format|
        format.js {render partial: 'posts/show/contact_user/message_form/fail'}
      end
    end
  end

  private

  def add_to_conversations
    session[:private_conversations] ||= []
    session[:private_conversations] << @conversation.id
  end

  def already_added?
    session[:private_conversations].include?(@conversation.id)
  end
end

チャットウィンドウのテンプレート作成の準備が整いましたので、ウィンドウのパーシャルを作成します(Gist)。

<!-- private/conversations/_conversation.html.erb -->
<% @recipient = private_conv_recipient(conversation) %>
<% @is_messenger = false %>
<li class="conversation-window" 
    id="pc<%= conversation.id %>" 
    data-pconversation-user-name="<%= @recipient.name %>" 
    data-turbolinks-permanent>
  <div class="panel panel-default" data-pconversation-id="<%= conversation.id %>">
    <%= render 'private/conversations/conversation/heading', 
                conversation: conversation %>

    <!-- Conversation window's content -->
    <div class="panel-body">
      <%= render 'private/conversations/conversation/messages_list', 
                  conversation: conversation %>
      <%= render 'private/conversations/conversation/new_message_form', 
                  conversation: conversation,
                  user: user %>
    </div><!-- panel-body -->
  </div>
</li><!-- conversation-window -->

private_conv_recipientを用いてチャットのrecipient(受け側)を取得しているので、Private::ConversationsHelperでヘルパーメソッドを定義します(Gist)。

# helpers/private/conversations_helper.rb
...
# チャットの相手となるユーザーを取得
def private_conv_recipient(conversation)
  conversation.opposed_user(current_user)
end
...

opposed_userメソッドが使われているので、Private::Conversationモデルでこのメソッドを定義します(Gist)。

# models/private/conversation.rb
...
def opposed_user(user)
  user == recipient ? sender : recipient
end
...

このメソッドは、非公開チャットの相手ユーザーを返します。このメソッドが正しく機能していることをspecで確認しましょう(Gist)。

# spec/models/private/conversation_spec.rb
...
context 'Methods' do
  it 'gets an opposed user of the conversation' do
    user1 = create(:user)
    user2 = create(:user)
    conversation = create(:private_conversation,
                           recipient_id: user1.id,
                           sender_id: user2.id)
    opposed_user = conversation.opposed_user(user1)
    expect(opposed_user).to eq user2
  end
end
...

次に、足りないパーシャルを_conversation.html.erbファイルで作成します(GistGist)。

<!-- private/conversations/conversation/_heading.html.erb -->
<div class="panel-heading conversation-heading">
  <span class="contact-name-notif"><%= @recipient.name %></span>  
</div> <!-- conversation-heading -->

<!-- Close conversation button -->
<%= link_to "X", 
            close_private_conversation_path(conversation), 
            class: 'close-conversation', 
            title: 'Close', 
            remote: true, 
            method: :post %>
<!-- private/conversations/conversation/_messages_list.html.erb -->
<div class="messages-list">
  <%= render load_private_messages(conversation), conversation: conversation %>
  <div class="loading-more-messages">
    <i class="fa fa-spinner" aria-hidden="true"></i>
  </div>
  <!-- messages -->
  <ul>
  </ul>
</div>

load_private_messagesヘルパーメソッドをPrivate::ConversationsHelperに定義します(Gist)。

# helpers/private/conversations_helper.rb
...
# if the conversation has unshown messages, show a button to get them
def load_private_messages(conversation)
  if conversation.messages.count > 0 
    'private/conversations/conversation/messages_list/link_to_previous_messages'
  else
    'shared/empty_partial'
  end 
end
...

上はそれまでのメッセージを読み込むリンクを追加します。これに対応するパーシャルファイルをmessages_listディレクトリの下に作成します(Gist)。

<!-- private/conversations/conversation/messages_list/_link_to_previous_messages.html.erb -->
<%= link_to "Load messages", 
            private_messages_path(:conversation_id => conversation.id, 
                                  :messages_to_display_offset => @messages_to_display_offset,
                                  :is_messenger => @is_messenger),
            class: 'load-more-messages', 
            remote: true %>

このメソッドがすべて問題なく動くことを確認するspecを書くのもお忘れなく(Gist)。

# spec/helpers/private/conversations_helper_spec.rb
...
context '#load_private_messages' do
  let(:conversation) { create(:private_conversation) }

  it "returns load_messages partial's path" do
    create(:private_message, conversation_id: conversation.id)
    expect(helper.load_private_messages(conversation)).to eq (
      'private/conversations/conversation/messages_list/link_to_previous_messages'
    )
  end

  it "returns empty partial's path" do
    expect(helper.load_private_messages(conversation)).to eq (
      'shared/empty_partial'
    )
  end
end
...

チャットのウィンドウはアプリ全体でレンダリングすることになるので、Private::ConversationsHelperヘルパーメソッドにアクセスできる必要があります。このヘルパーメソッドにアプリのどこからでもアクセスできるようにするには、ApplicationHelperに以下を追加します(訳注: 原文から脱落していたコードを補いました)。

# helpers/private/conversations_helper.rb
...
include Private::ConversationsHelper

def private_conversations_windows
  params[:controller] != 'messengers' ? @private_conversations_windows : []
end
...

続いて、チャットの新しいメッセージフォームで使うパーシャルファイルがまだないので、作成します(Gist)。

<!-- private/conversations/conversation/_new_message_form.html.erb -->
<form class="send-private-message">
  <input name="conversation_id" type="hidden" value="<%= conversation.id %>">
  <input name="user_id" type="hidden" value="<%= user.id %>">
  <textarea name="body" rows="3" class="form-control" placeholder="Type a message..."></textarea>
  <input type="submit" class="btn btn-success send-message">
</form>

このフォームの機能は、もう少し後で動くようにする予定です。

それでは、ユーザーが個別の投稿からメッセージを1件送信したら、アプリのチャットウィンドウで表示されるようにする機能を作成しましょう。

_success.js.erbファイルを開きます。

posts/show/contact_user/message_form/_success.js.erb

末尾に以下を追加します。

<%= render 'private/conversations/open' %>

このパーシャルファイルの目的は、アプリにチャットウィンドウを追加することです。そのためのパーシャルファイルを定義します(Gist)。

// private/conversations/_open.js.erb 
var conversation = $('body').find("[data-pconversation-id='" + 
                                "<%= @conversation.id %>" + 
                                "']");
var chat_windows_count = $('.conversation-window').length + 1;

if (conversation.length !== 1) {
  $('body').append("<%= j(render 'private/conversations/conversation',\
                                  conversation: @conversation,\
                                  user: current_user) %>");
  conversation = $('body').find("[data-conversation-id='" + 
                                "<%= @conversation.id %>" + 
                                "']");
}

// チャットの作成後にチャットウィンドウをトグルする
$('.conversation-window:nth-of-type(' + chat_windows_count + ')\
   .conversation-heading').click();
// mark as seen by clicking it
setTimeout(function(){ 
  $('.conversation-window:nth-of-type(' + chat_windows_count + ')').click();
 }, 1000);
// focus textarea
$('.conversation-window:nth-of-type(' + chat_windows_count + ')\
   form\
   textarea').focus();

// すべてのチャットウィンドウを再配置する
positionChatWindows();

このコールバック用パーシャルファイルは、今後さまざまなシナリオで再利用することになります。同じウィンドウを何度もレンダリングするのを避けるため、レンダリング前にウィンドウが既にあるかどうかをチェックし、それからウィンドウを拡大してメッセージフォームに自動的にフォーカスを移動します。ファイル末尾にあるpositionChatWindows()関数は、チャットウィンドウの位置がすべて適切な場所に配置されるようにするために呼び出されます。配置を修正しないと、複数のウィンドウが同じ場所に表示されてしまい、使い物にならなくなります。

それでは、assetsディレクトリの下に、チャットウィンドウの表示や配置を扱うファイルを作成しましょう(Gist)。

// assets/javascripts/conversations/position_and_visibility.js
$(document).on('turbolinks:load', function() { 
    chat_windows_count = $('.conversation-window').length;
    // 最新のチャットウィンドウが未設定で、チャットウィンドウが既に存在する場合
    // last_visible_chat_window変数を設定
    if (gon.last_visible_chat_window == null && chat_windows_count > 0) {
        gon.last_visible_chat_window = chat_windows_count;
    }
    // igon.hidden_chatsがない場合は値を設定
    if (gon.hidden_chats == null) {
        gon.hidden_chats = 0;
    }
    window.addEventListener('resize', hideShowChatWindow);

    positionChatWindows();
    hideShowChatWindow();
});

function positionChatWindows() {
    chat_windows_count = $('.conversation-window').length;
    // 新しいチャットウィンドウが追加された場合、
    // 表示可能な最新のチャットウィンドウとして設定し、
    // viewportの幅に応じて
    // hideShowChatWindow関数で表示をオンオフできるようにする
    if (gon.hidden_chats + gon.last_visible_chat_window !== chat_windows_count) {
        if (gon.hidden_chats == 0) {
            gon.last_visible_chat_window = chat_windows_count;
        }
    }

    // 新しいチャットウィンドウが追加されたときにリストの一番左に配置する
    for (i = 0; i < chat_windows_count; i++ ) {
        var right_position = i * 410;
        var chat_window = i + 1;
        $('.conversation-window:nth-of-type(' + chat_window + ')')
            .css('right', '' + right_position + 'px');
    }
}

// viewportの右側に接近したら常に最新のチャットウィンドウを隠す
function hideShowChatWindow() {
    // チャットウィンドウが1つもない場合は関数を終了
    if ($('.conversation-window').length < 1) {
        return;
    }
    // 最も左にあるチャットウィンドウのオフセットを取得
    var offset = $('.conversation-window:nth-of-type(' + gon.last_visible_chat_window + ')').offset();
    // チャットウィンドウの左のオフセットが50より小さい場合、
    // そのチャットウィンドウを隠す
    if (offset.left < 50 && gon.last_visible_chat_window !== 1) {
        $('.conversation-window:nth-of-type(' + gon.last_visible_chat_window + ')')
            .css('display', 'none');
        gon.hidden_chats++;
        gon.last_visible_chat_window--;
    }
    // 一番左のチャットウィンドウのオフセットが550より大きく、
    // かつ非表示のチャットがある場合は、非表示チャットを表示する
    if (offset.left > 550 && gon.hidden_chats !== 0) {
        gon.hidden_chats--;
        gon.last_visible_chat_window++;
        $('.conversation-window:nth-of-type(' + gon.last_visible_chat_window + ')')
            .css('display', 'initial');
    }
}

cookieの設定や取得のために、関数を独自に作成したりJavaScript間のデータ管理を独自に行ったりする代わりに、gon gemを使う方法があります。本来このgemはサーバーサイドからJavaScriptにデータを送信するためのものですが、アプリ全体を通してJavaScriptの変数をトラッキングするのにも便利であることに気づきました。このgemの指示を読んでインストールとセットアップを行います。

訳注: Gemfileに以下を追加し、bundle installを実行します。

gem 'gon'

viewportの幅はイベントリスナーでトラッキングします。チャットがviewportの左側に近づくと、チャットは非表示になります。非表示のチャットウィンドウを表示するのに十分な空きスペースができると、ふたたびチャットウィンドウを表示します。

ページが表示されると、再配置や表示/非表示を行う関数を呼び出して、すべてのチャットウィンドウが正しい位置に表示されるようにします。

ここでは、Bootstrapのpanelコンポーネントを用いて、チャットウィンドウの展開/折りたたみを簡単に行えるようにします。ウィンドウはデフォルトでは折りたたまれていて操作できないので、表示/非表示をトグルできるようにするために、javascriptsディレクトリの下に以下のtoggle_window.jsファイルを作成します(Gist)。

// javascripts/conversations/toggle_window.js
$(document).on('turbolinks:load', function() { 

    // when conversation heading is clicked, toggle conversation
    $('body').on('click', 
                 '.conversation-heading, .conversation-heading-full', 
                 function(e) {
        e.preventDefault();
        var panel = $(this).parent();
        var panel_body = panel.find('.panel-body');
        var messages_list = panel.find('.messages-list');

        panel_body.toggle(100, function() {
        }); 
    });
});

conversation_window.scssファイルを作成します。

assets/stylesheets/partials/conversation_window.scss

チャットウィンドウのスタイルを設定するCSSを追加します(Gist)。

// assets/stylesheets/partials/conversation_window.scss
textarea {
  resize: none;
}

.panel {
  margin: 0;
  border: none !important;
}

.panel-heading {
  border-radius: 0;
}

.panel-body {
  position: relative;
  display: none;
  padding: 0 0 5px 0;
}

.conversation-window, .new_chat_window {
  min-width: 400px;
  max-width: 400px;
  position: fixed;
  bottom: 0;
  right: 0;
  list-style-type: none;
}

.conversation-heading, .conversation-heading-full, .new_chat_window {
  background-color: $navbarColor !important;
  color: white !important;
  height: 40px;
  border: none !important;
  a {
    color: white !important;
  }

}

.conversation-heading, .conversation-heading-full {
  padding: 0 0 0 15px;
  width: 360px;
  display: inline-block;
  vertical-align: middle;
  line-height: 40px;
}

.close-conversation, .add-people-to-chat, .add-user-to-contacts, .contact-request-sent {
  color: white;
  float: right;
  height: 40px;
  width: 40px;
  font-size: 20px;
  font-size: 2.0rem;
  border: none;
  background-color: $navbarColor;
}

.close-conversation, .add-user-to-contacts {
  text-align: center;
  vertical-align: middle;
  line-height: 40px;
  font-weight: bold;
}

.close-conversation {
  &:hover {
    border: none;
    background-color: white;
    color: $navbarColor !important;
  }
  &:visited, &:focus {
    color: white;
  }
}

.form-control[disabled] {
  background-color: $navbarColor;
}

.send-private-message, .send-group-message {
  textarea {
    border-radius: 0;
    border: none;
    border-top: 1px solid rgba(0, 0, 0, 0.2);
  }
}

.loading_svg {
  display: none;
}

.loading_svg {
  text-align: center;
}

.messages-list {
  z-index: 1;
  min-height: 300px;
  max-height: 300px;
  overflow-y: auto;
  overflow-x: hidden;
  ul {
    padding: 0;
  }
}

.message-received, .message-sent {
  max-width: 300px;
  word-wrap: break-word;
  z-index: 1;
}

.message-sent {
  position: relative;
  background-color: white;
  border: 1px solid rgba(0, 0, 0, 0.5);
  border-radius: 5px;
  margin: 5px 5px 5px 50px;
  padding: 10px;
  float: right;
}

.message-received {
  background-color: $backgroundColor;
  border-color: #EEEEEE;
  border-radius: 5px;
  margin: 5px 50px 5px 5px;
  padding: 10px;
  float: left;
}

.messages-date {
  width: 100%; 
  text-align: center; 
  border-bottom: 1px solid rgba(0, 0, 0, 0.2);
  line-height: 1px; 
  line-height: 0.1rem;
  margin: 20px 0 20px;
  span {
    background: #fff; 
    padding: 0 10px; 
  }

}

.load-more-messages {
  display: none;
}

.loading-more-messages {
  font-size: 20px;
  font-size: 2.0rem;
  padding: 10px 0;
  text-align: center;
}

.send-message {
  display: none;
}

これまでどのHTMLファイルにも定義されていないクラスがこのCSSでいくつか定義されていることにお気づきでしょうか。これらは今後作成するファイルです。viewsディレクトリに作成して、CSSを既存のHTML要素で共有できるようにする予定です。CSSファイルを何度もあちこちに行ったり来たりしなくても済むように、マイナーなHTML要素については、今後のHTML要素で定義されるこれらのクラスをさしあたってここに足しておきます。特定のスタイルがどのように効いているかを知りたければ、いつでもスタイルシートを開いて調べることができます。

これまでは新しく作成されたチャットのidをセッション内に保存していましたが、この辺で、この機能を利用してユーザーがチャットを閉じたりセッションが終了するまでチャットウィンドウを開いておけるようにしましょう。ApplicationControllerでフィルタを定義します。

before_action :opened_conversations_windows

続いて、opened_conversations_windowsメソッドを定義します(Gist)。

# controllers/application_controller.rb
...
def opened_conversations_windows
  if user_signed_in?
    # opened conversations
    session[:private_conversations] ||= []
    @private_conversations_windows = Private::Conversation.includes(:recipient, :messages)
                                      .find(session[:private_conversations])
  else
    @private_conversations_windows = []
  end
end
...

このincludesメソッドは、関連付けられているデータベーステーブルからのデータをインクルードするのに使います。今後はチャットからメッセージを読み込みます。includesメソッドを使わなければ、チャットメッセージのレコードがこのクエリで読み込まれなくなり、N+1クエリが発生することがあります。このクエリでメッセージが読み込まれないと、メッセージ1件1件で追加クエリが発火するかもしれません。N+1クエリが発生するとアプリのパフォーマンスに著しい影響が生じる可能性があります。ここでは100件のメッセージで100件のクエリを発行するのではなく、最初のクエリ1つだけで任意の数のメッセージを取れるようにしています。

application.html.erbファイルで、yieldメソッドの直後に以下を追加します(Gist)。

<!-- layouts/application.html.erb -->
...
<%= render 'layouts/application/private_conversations_windows' %>
...

applicationディレクトリを作成し、その下に_private_conversations_windows.html.erbパーシャルファイルを作成します。

<!-- layouts/application/_private_conversations_windows.html.erb -->
<% private_conversations_windows.each do |conversation| %>
  <%= render partial: "private/conversations/conversation",
             locals: { conversation: conversation, 
                       user: current_user } %>
<% end %>

訳注: この時点でinfinite_scroll_spec.rbの最終行のcount: 30を以下のようにcount: 15に変えないとRSpecが通りませんでした。

# spec/features/posts/infinite_scroll_spec.rb
  let(:check_posts_count) do
    expect(page).to have_selector('.single-post-list', count: 15)
    page.execute_script("$(window).scrollTop($(document).height())")
    expect(page).to have_selector('.single-post-list', count: 15)
  end

これでブラウザでアプリを操作すると、どのページでもチャットが常に表示されるようになります。

変更をcommitします。

git add -A
git commit -m "Render a private conversation window on the app

- Add opened conversations to the session
- Create a _conversation.html.erb file inside private/conversations
- Define a private_conv_recipient helper method in the
  private/conversations_helper.rb
- Define an opposed_user method in Private::Conversation model
  and add specs for it
- Create _heading.html.erb and _messages_list.html.erb files
  inside the private/conversations/conversation
- Define a load_private_messages in private/conversations_helper.rb
  and add specs for it
- Create a _new_message_form.html.erb inside the
  private/conversations/conversation
- Create a _open.js.erbinside private/conversations
- Create a  position_and_visibility.js inside the
  assets/javascripts/conversations
- Create a  conversation_window.scss inside the
  assets/stylesheets/partials
- Define an opened_conversations_windows helper method in
  ApplicationController
- Create a _private_conversations_windows.html.erb inside the
  layouts/application"

関連記事

新しいRailsフロントエンド開発(1)Asset PipelineからWebpackへ(翻訳)

Rails tips: Null Objectパターンでリファクタリング(翻訳)

$
0
0

概要

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

Rails tips: Null Objectパターンでリファクタリング(翻訳)

Null Objectパターンによるリファクタリングは、指定されたオブジェクトが存在するかどうかをチェックして、存在しなかった場合に指定の属性やメソッドのデフォルト値を返す操作に適用できます。このような操作ではif条件が必要になることが多く、そのままではコードが少々読みづらいうえにテストも少しばかりやりにくくなります。Null Objectパターンを使うことでコードが非常にシンプルになり、テストも簡単になります。

Null Objectパターンを使うメリットをわかりやすく示すため、次のような事例を考えてみましょう。UserPostという2つのクラスがあり、UserクラスのオブジェクトはPostクラス上で操作を行います。

class User < ActiveRecord::Base
  has_many :posts

  def latest_post_title
    post = posts.order('created_at DESC').first

    if post.present?
      post.title
    else
      "No posts yet"
    end
  end
end

「単一責任の原則」からほど遠いコードです。ここでは以下の操作を行っています。

  1. 最新のpostをフェッチする
  2. postが存在するかどうかをチェックする
  3. postが存在する場合はposttitleを表示する
  4. postが存在しない場合は適切な情報を表示する

こんなときはNull Objectパターンの出番です。まずは新しいオブジェクトを作成しましょう。

class NoPost
  def title
    "No posts yet"
  end
end

シンプルなロジックを備えた、ごくシンプルなRubyオブジェクトができました。それではUserモデルで以下を行ってリファクタリングしましょう。

  1. クエリを別のメソッドに切り出す
  2. NoPost Null Objectを用いて、最新のpostの代入を別のメソッドに切り出す
  3. メソッドの責務を「最新のposttitleを返す」シンプルな責務に変える

Userクラスにこれらを実装すると、以下のように明快かつ読みやすいクラスに変わりました。

class User < ActiveRecord::Base
  has_many :posts

  def latest_post_title
    lastest_post.title
  end

  private

  def latest_post
    find_latest_post || NoPost.new
  end

  def find_latest_post
    posts.order('created_at DESC').first
  end
end

User#latest_post_titleの内容が明快になり、if条件も消滅しました。もうひとつ重要な点は、このNull Objectに適切な名前をつけて、何がしたいのかが名前からわかるようにすることです。


Railsでお困りの方にお知らせ

知りたいことがありましたら、twitter または連絡用フォームにてお気軽にお問い合わせください。

RSpec & TDDの電子書籍を無料でダウンロード

もっと稼ぎたい方や会社をさらに発展させたい方へ: テスティングのスキルの重要性にお気づきでしょうか?テストを正しく書き始めることが、唯一のファーストステップです。無料でダウンロードいただける私の書籍『RSpec & Test Driven Developmentの無料ebook』をどうぞお役立てください。

関連記事

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

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

Viewing all 1079 articles
Browse latest View live