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

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のメソッド名でしりとりやってみた

Rails tips: トランザクションをネストする(翻訳)

$
0
0

概要

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

Rails tips: トランザクションをネストする(翻訳)

Ruby on Railsにおけるトランザクションの概要や、アプリに機能追加するときに便利なトランザクションのコールバックについては既にご説明しました。今回はトランザクションのネストについて詳しく見ていきたいと思います。

ネストしたトランザクションを作成する

トランザクションは単なるRubyのブロックなので、次のように簡単にネストできます。

Post.transaction do
  Post.create!(title: 'Playing football')
  Post.transaction do
    Post.create!(title: 'Swimming')
  end
end

ネストしたトランザクションのロールバック

トランザクションをネストできたら、ロールバックもできるようにしたくなるでしょう。しかし驚いたことに、以下のコードではロールバックできません。

Post.transaction do
  Post.create!(title: 'Playing football')
  Post.transaction do
    Post.create!(title: 'Swimming')
    raise ActiveRecord::Rollback
  end
end

このコードを動かすには、本物のサブトランザクションを取得する必要があります。さもないとトランザクションの作成を取り消せません。これを行うにはrequires_new: trueオプションを渡します。このオプションは、PostgreSQL、MySQL、SQLite(3.6.8以降)で利用できます。

Post.transaction do
  Post.create!(title: 'Playing football')
  Post.transaction(requires_new: true) do
    Post.create!(title: 'Swimming')
    raise ActiveRecord::Rollback
  end
end

さらに詳しい情報

Active Recordは、MySQL/PostgreSQL/MS-SQL(真のネステッドトランザクションをサポートする場合のみ)でSAVEPOINTを用いることでトランザクションのネストをエミュレーションします。SAVEPOINTについて詳しくは、MySQLのSAVEPOINT、ROLLBACK TO SAVEPOINT、および RELEASE SAVEPOINT 構文をご覧ください。


RSpec & TDDの電子書籍を無料でダウンロード

もっと稼ぎたい方や会社をさらに発展させたい方へ: テスティングのスキルの重要性にお気づきでしょうか?テストを正しく書き始めることが、唯一のファーストステップです。無料でダウンロードいただける私の書籍『RSpec & Test Driven Developmentの無料ebook』をどうぞお役立てください。

関連記事

アトミックなトランザクションで冪等APIを強化する(翻訳)

Rails tips: ActiveRecordのトランザクションの概要(翻訳)

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つの方法(翻訳)


RailsのPostgreSQL上でマルチテナントのジョブキューシステムを独自構築する(翻訳)

$
0
0

概要

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

RailsのPostgreSQL上でマルチテナントのジョブキューシステムを独自構築する(翻訳)

長期間運用されるプロセス(画像のリサイズ、レジュメのスキャン、負荷分析など)を必要とするWebアプリでは、バックグラウンドジョブキューシステムが重要になります。RabbitMQ(メッセージキュー)、Celery、ActiveMQ、Sidekiqなどのソリューションはよく設計されていて、業界でかなり人気を集めています。

本記事では、Ruby/Rails + Postgresを用いて弊社のB2B SaaSアプリ向けのマルチテナントジョブキューを設計/構築する方法をご紹介いたします。ここでは高度な問題についてその理由や解決のために行ったことを解説するとともに、理解を助けるためにいくつか特定のコードを見ていくことにします。

背景と要件

Holistics.ioはSQLベースのBIプラットフォームであり、データチームがエンドユーザー向けにレポートを自動ビルドする機能やダッシュボードの提供を支援します。小規模なスタートアップからテック業界の未上場企業/新規上場企業に至るさまざまなお客様にご利用いただいています。

バックエンドスタック構成:

  • RubyとRails(PostgreSQLデータベース使用)
  • haproxy、nginx、Unicorn
  • Sidekiq(およびRedis): バックグラウンドジョブエンジンとして

しくみ: このプラットフォームで誰かがリクエストを送信するとSQLクエリが生成され、顧客のデータベースに送信され、結果を待ち、それを元にチャートが生成されます。

Holistics Editor Demo

分析用SQLクエリの実行には時間がかかる(数秒から数分)ため、同期的なWebリクエストを使うのはよくありません。このため、リクエストを扱うバックグラウンドジョブキューシステムが必要になります。

弊社の場合、ジョブキューへの要件は次のようになります。

  1. ジョブの情報を永続化すること: バックグラウンドジョブごとに基本的な統計情報(ステータス、実行時間、開始時間、終了時間、結果レコード数など)をトラッキングする必要があります。
  2. マルチテナントであること: 顧客ごとの独自ジョブキューは互いに影響を及ぼしてはならず、ジョブキューごとにサイズを変更可能であること(顧客Aは5つのスロットでコンカレントジョブを5つ実行でき、顧客Bは3つのスロットで3つのコンカレントスロットを実行できるなど)

  3. 信頼性: 顧客が必要とする分析はジョブキューに依存するため、ジョブキューは高い信頼性で実行できる必要があります。ジョブはキューに送信した順序で取り出されなければならず、かつ散発的に発生するエラー(ネットワークの問題など)を防ぐためにリトライメカニズムも必要です。

PostgreSQLでマルチテナントジョブキューを構築する

数回の開発を繰り返した後、Rails/RubyとPostgreSQLで構築されたジョブキューシステムが稼働しています。このシステムは、ジョブを管理したり、処理されるキューからジョブをピックアップしたりします。ピックアップされたジョブは、実際にバックグラウンド実行を担当するSidekiqに渡されます。

リクエストのワークフローは次のような感じになります。

  1. ユーザーがWebサーバーにリクエストを送信すると、Webサーバーは新規ジョブを作成してジョブキューエンジンにプッシュし、ジョブIDをクライアントに返す。
  2. ジョブキューエンジンは次に処理するジョブをピックアップし、Sidekiqにプッシュする。
  3. Sidekiqはジョブをピックアップして実行し、結果をキャッシュに書き込んでjobsテーブル内のジョブステータスを更新する。
  4. クライアントはsuccesserrorのいずれかになるまでWebサーバーにジョブのステータス問い合わせを繰り返す。successの場合はキャッシュから結果を取り出してクライアントに返す。

Holistics Job Queue Flow

別のジョブキューを立てる理由とPostgreSQLを使う理由

PostgreSQL上に別のジョブキューシステムを実装することに頑張るのは「車輪の再発明」ではないかといぶかる方もいらっしゃるかもしれません。その主な理由(そして弊社のproductionデータベースであるPostgreSQLを使う理由)は次のとおりです。

  1. 永続性: 打ち上げ花火のような他のジョブキューシステムと異なり、弊社ではあらゆるジョブを統計情報やステータス付きでもれなく保管し、後で顧客にお見せする必要が生じます。そのためにはPostgreSQL上でジョブキューシステムを手作りするのが便利です。
  2. 独自のキューイングロジック: 弊社のスケジューリングロジックには多数のカスタムロジックが含まれているため、既存の他のジョブキューの利用方法が複雑になっています。上述のマルチテナンシーのロジックや、テナントのユーザーアカウントごとに最大コンカレントジョブ数の上限を設定するロジックなどが含まれます。

既存のジョブキューシステムを流用して要件を満たそうとすると、上の2つがさらに複雑になります。

一方、この方法にはいくつかのデメリットがあることも認識しています。

  1. スケーラビリティ: コンカレントリクエスト数が増加したときに水平スケールできません。ただしB2Bアプリにおける増加は、B2Cほど極端にはならないはずです。
  2. パフォーマンス: jobsテーブルが肥大化すると(弊社では実際に肥大化しました)、ジョブのキューイングロジックが遅くなる可能性があります。しかし適切なインデックス化とDBメンテナンス/チューニングを行えば乗り切れます。弊社では同時にテーブルのパーティショニングも行って、アクティブなデータセットが小さくなるようにしました。

ジョブの保存と送信

ユーザーがリクエストを1件送信するときに、ジョブごとのメタデータを保存する何らかのしくみが必要です。そのためのjobsテーブルを作成しました。

CREATE TABLE jobs (
  id INTEGER PRIMARY KEY,
  source_id INTEGER,
  source_type VARCHAR,
  source_method VARCHAR,
  args JSONB DEFAULT '{}',
  status VARCHAR,
  start_time TIMESTAMP,
  end_time TIMESTAMP,
  created_at TIMESTAMP,
  stats JSONB DEFAULT '{}'
)

弊社の設計では、ジョブのステータスは以下の値を取ることができます。

  • created: 最初に作成されたとき
  • queued: ピックアップされてSidekiqにプッシュされたとき
  • running: Sidekiqワーカーがジョブをピックアップして実行を開始したとき
  • success: ジョブが成功したとき
  • error: ジョブが失敗したとき

ここで興味深いのは、弊社のキューレイヤーが本質的に二重である(独自の論理ジョブキューとSidekiq、後者も本質的にはジョブキュー)ため、ジョブのステータスにcreatedqueueの両方を用意していることです。

start_time - queue_timeはゼロに限りなく近い最小値になる必要がありますが、この数値が増加すると(ジョブがSidekiqにプッシュされたが、ジョブを扱うSidekiqのワーカー数が不足している場合)、Sidekiqワーカー数を増やす必要が生じていることがわかります。

PostgreSQLでジョブキューロジックを扱う

ジョブキューシステムでは以下のサポートが必要になる可能性があります。

  • 実行可能が「宣言」(claim)されていない次の実行可能ジョブを検出して宣言する機能
  • 2つのプロセスが同じジョブを実行可能と宣言できないようにする機能、および各ジョブが(正確に1度だけ)最終的に必ず実行されるようにする機能: これは見かけよりずっと困難であることがわかりました
  • 不測の事態(ネットワーク障害やワーカーノードの障害)によってジョブの処理がなぜか失敗した場合に、ジョブのロジックが失敗せずにジョブを元のキューに戻す機能

PostgreSQLや、上述の制約をサポートするSQLを用いてジョブキューを構築するのは、想像以上に困難であることがわかってきました。主な困難としては、ジョブをtakenとしてマーキングした後、ワーカーが何らかの理由(タイムアウト、メモリ不足、ネットワーク障害など)でジョブの処理に失敗したときにジョブが解放され、ジョブが失われてしまう流れを解明できなかったことです。

弊社ではいくつか別のアプローチを試した結果、PostgreSQL 9.5の機能であるSKIP LOCKEDにたどり着きました。この機能はこの目的に特化した設計になっています。詳しくはCraig Ringerの記事「What is SKIP LOCKED for in PostgreSQL 9.5?」をご覧ください。

考え方は次のとおりです。

  • ある1つのトランザクション内で、これまでロックされたことのない利用可能な次の行を取得するSQLクエリを作成し、続いてそこに行レベルロックをかけ、トランザクションが続いている間ロックを保持する(この間、宣言は有効)と、他のプロセスはその行に対して宣言できなくなる。
  • ジョブが成功したら、行のステータスを更新してsuccessにし、トランザクションを終了する。
  • プロセスがクラッシュしたら、トランザクションは自動的にabortされ、行の宣言は取り消される。

この後に実際のコードを掲載していますのでご覧ください。

マルチテナンシーのサポート

さらに弊社の場合、要求されるキューが顧客ごとに異なっています。これは、以下のような(tenant_id)が一意のテナントキューテーブルで表現しました。

CREATE TABLE tenant_queues (
  id INTEGER PRIMARY KEY,
  tenant_id INTEGER,
  num_slots INTEGER
)

ジョブ重複防止とリトライのメカニズム

ユーザーがレポートページでうっかり更新ボタンをクリックしたために、新しいジョブが生成されてバックグラウンドに送信され、システムで不要な過負荷が生じるという問題がたびたび発生していました。

このために、ジョブ重複防止のメカニズムも構築しました。ジョブ送信のたびにジョブが一意かどうか(ジョブが生成するDBへのクエリが同一かどうか)をチェックします。このチェックは、ジョブ送信後に10分間、未終了のジョブに対して行われます。重複が検出された場合は、単に古いジョブIDを返します。

同様に、リトライメカニズムも追加しました。これはプロセスがクラッシュした場合(メモリ不足などの不測の事態)にジョブを自動で再実行します。再実行する最大ジョブ数は設定可能です。

コードをひととおり眺めてみる

基本的に次のSQLで考えてみます。最初に利用可能になる次のジョブ(作成順)を検出するクエリです。クエリのテナントにはスロットの空きがまだあり、誰もジョブの実行を宣言しておらず(SKIP LOCKED)、自分自身で宣言しています(FOR UPDATE)。

-- キューがいっぱいかどうかを調べるため、キューごとに実行中のジョブ数を検出する
WITH running_jobs_per_queue AS (
  SELECT
    tenant_id,
    count(1) AS running_jobs from jobs
  WHERE (status = 'running' OR status = 'queued') -- runningまたはqueued
  AND created_at > NOW() - INTERVAL '6 HOURS' -- 6時間以上実行されているジョブは無視
  group by 1
),
-- いっぱいになったキューを検出する
full_queues AS (
  select
    R.tenant_id
  from running_jobs_per_queue R
  left join tenant_queues Q ON R.tenant_id = Q.tenant_id
  where R.running_jobs >= Q.num_slots
)
select id
from jobs
where status = 'created'
  and tenant_id NOT IN ( select tenant_id from full_queues )
order by id asc
for update skip locked
limit 1

ここでは、次のジョブをピックアップして実行のためにSidekiqに渡すqueue_next_job()メソッドを定義します。このメソッドはトランザクションにラップされる点にご注意ください。これはステータスをqueuedに更新している途中で他のプロセスがジョブに対して「宣言」できないようにし、絶対に2回ピックアップされないようにするためです。

class Job
  def queue_next_job()
    ActiveRecord::Base.transaction do
      ret = ActiveRecord::Base.connection.execute queue_sql

      return nil if ret.values.size == 0
      job_id = ret.values[0][0].to_i
      job = Job.find(job_id)

      # バックグランドワーカーに送信
      job.status = 'queued' && job.save
      JobWorker.perform_async(job_id)
    end
  end
end

弊社側のJobWorker(Sidekiqによって実行される)では、単にステータスをrunningに設定して実際に実行します。

# 簡略化したコード
class JobWorker
  include Sidekiq::Worker
  def perform(job_id)
    job = Job.find(job_id)
    job.status = 'running' && job.save
    obj = job.source_type.constantize.find(job.source_id)
    obj.call(job.source_method, job.args)

    job.status = 'success' && job.save

  rescue
    job.status = 'error' && job.save
  ensure
    Job.queue_next_job()
  end
end

queue_next_job()ensureブロック内で呼ばれている点にご注目ください。スーパバイザープロセスがキューを監視していることが多い他のジョブキューシステムと異なり、ピックアップしたジョブを次の空きワーカーに渡しています。弊社の場合はスーパバイザー/ワーカーという概念を用いていないので、現在のジョブ終了直後にqueue_next_jobを呼ぶことで現在のワーカーをシンプルに利用し、バックグラウンドワーカーの実行はSidekiqに任せています。

他のジョブキュー 弊社のジョブキュー
マスター 専用プロセスでリクエストを受信 既存のRailsまたはSidekiqプロセスによるSQL + インライン
ワーカー 専用のプロセスまたはスレッド Sidekiqに渡す

Sidekiqについてのメモ: Sidekiqは素晴らしいバックグラウンドジョブワーカーシステムです。弊社ではこれまでSidekiqを利用してきましたし、今後も利用するでしょう(実際、弊社は有償ユーザーです)。弊社のPostgreSQLジョブキューはSidekiqの上で動作し、ジョブのビジネスロジックをより精密に扱う一方、Sidekiqは実際のジョブ実行を担当します。

また、弊社のジョブの性質(実行時間が極めて長く、メモリ不足が生じる可能性がある)のため、プロセスレベルモニターの追加と、Sidekiqプロセスでのメモリスレッショルド管理メカニズムの追加についてSidekiqにいくつかアドバイスを送りました。これについて別記事でご紹介できればと思います。

バックグラウンドジョブのロジックを抽象化する

弊社のジョブシステムの一部として、Rubyにある.asyncメソッドチェインを導入しました(Rubyのメタプログラミングを活用しています)。このメソッドによって同期と非同期を極めてシンプルに切り替えることができます。

以下のコードでは、DataReport#executeは1回書き込み可能で、同期的に実行することも非同期的に実行することもできます。

report = DataReport.find(report_id)

# 通常: 同期的に実行(`execute`メソッドの戻り値を返す)
report_results = report.execute

# 非同期実行(整数のジョブIDを返す)
job_id = report.async.execute

これによって、Sidekiqを利用するオブジェクトからSidekiqを完全に隠蔽することもできるようになりました。別のバックグラウンドワーカーシステムをサポートする新しいアダプタも簡単に書けます。この手法がJavaScriptの「Promise」のコンセプトに似ている点にご注目ください。

さらに、上述のjobsテーブルの(source_type, source_id, source_method)という組み合わせにもご注目ください。これは基本的にはポリモーフィズムであり、ジョブを分割してシームレスに別の種類のジョブにする方法です。

まとめ

本記事では、RubyとPostgreSQLとSidekiqを用いたマルチテナントのジョブキューシステムを独自設計した理由とその方法について解説いたしました。完成したジョブキューシステムは柔軟性も信頼性も高く、弊社の要件を満たしています。

  • 送信されたジョブがjobsテーブルで永続化されるようになったので、分析や顧客への公開がやりやすくなった
  • キューイングロジックがマルチテナントで扱えるようになったため、顧客ごとの独自キューが互いに干渉しなくなり、CPUリソースの共有が進んだ
  • コードが適切に抽象化されたことで、開発しやすくなった

今回の作業で、ジョブキューやPostgreSQLのロックメカニズム、そしてRubyのメタプログラミングについて多くのことを学べました:)!

お気づきの点やご意見/ご感想がありましたら、お気軽に原文末尾のコメント欄までどうぞ。

関連記事

Rails: RedisキャッシュとRackミドルウェアでパフォーマンスを改善(翻訳)

JavaScript: 5分でわかるPromiseの基礎(翻訳)

Rails tips: Policy Objectパターンでリファクタリング(翻訳)

$
0
0

概要

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

Rails tips: Policy Objectパターンでリファクタリング(翻訳)

Policy Objectは操作の分離に用いられる素朴なRubyオブジェクトのことです。個人的にこのパターンが好きですが、オブジェクトにPolicy Objectとしてふさわしい名前をつけるために従っておくべきルールがいくつかあります。

Policy Objectの規則

  1. メソッド名の末尾は常に?にする
  2. メソッドはtruefalseのいずれかだけを返す
  3. 渡された属性は変更しない
  4. コードはシンプルな読み出しロジックだけを行う: データベース呼び出しなどは行わない

デモ

サンプルのクラスを作ってみましょう。後ほどPolicy Objectをここに実装します。

class UserService
  def initialize(user)
    @user = user
  end

  def name
    if user.full_name.blank? && user.email.present?
      user.email
    else
      user.full_name
    end
  end

  def account_name
    if user.sign_in_count > 0 && user.role == "admin"
      "Administrator"
    else
      "User"
    end
  end

  private
  attr_reader :user
end

このクラスは「読み取り」と「チェック」という2つの操作に簡単に分けられます。チェック部分はそのままPolicy Objectとして完璧なコードです。Userオブジェクトに対して操作を行っているので、2つのメソッドのためのPolicy Objectを1つ作成できます。

class UserPolicy
  def initialize(user)
    @user = user
  end

  def administrator_account_name?
    user.sign_in_count > 0 && user.role == "admin"
  end

  def use_email_as_name?
    user.full_name.blank? && user.email.present?
  end

  private
  attr_reader :user
end

UserServiceサービス(Service Object)にUserPolicyを実装すると、コードがとてもスッキリしました。

class UserService
  def initialize(user)
    @user = user
  end

  def name
    user_policy.use_email_as_name? ? user.email : user.full_name
  end

  def account_name
    user_policy.administrator_account_name? ? "Administrator" : "User"
  end

  private
  attr_reader :user

  def user_policy
    @_user_policy ||= UserPolicy.new(user)
  end
end

読み取り部分からポリシーのロジックが分離され、UserPolicyをシンプルな方法でスタブできるようになったのでUserServiceクラスがテストしやすくなりました。Rails tips: ロジックをわかりやすい変数に置き換えるリファクタリング(翻訳)【要リンク変更】をこのPolicy Objectに適用することもできます。ロジックを変数に移す代わりに、意味のある名前を持つもっと小さなメソッドにロジックを直接移してみましょう。

class UserPolicy
  def initialize(user)
    @user = user
  end

  def administrator_account_name?
    user_signed_in? && user_is_administrator?
  end

  def use_email_as_name?
    user_does_not_have_full_name? && user_has_email?
  end

  private
  attr_reader :user

  def user_does_not_have_full_name?
    user.full_name.blank?
  end

  def user_has_email?
    user.email.present?
  end

  def user_signed_in?
    user.sign_in_count > 0
  end

  def user_is_administrator?
    user.role == "admin"
  end
end

クラスのコード量は増えたものの、コードが自ら語るようになりました。

Railsでお困りの方にお知らせ

知りたいことがありましたら、twitter または連絡用フォームにてお気軽にお問い合わせください。

RSpec & TDDの電子書籍を無料でダウンロード

もっと稼ぎたい方や会社をさらに発展させたい方へ: テスティングのスキルの重要性にお気づきでしょうか?テストを正しく書き始めることが、唯一のファーストステップです。無料でダウンロードいただける私の書籍『RSpec & Test Driven Developmentの無料ebook』をどうぞお役立てください。

関連記事

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

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

週刊Railsウォッチ(20180323)Rails 5.2.0 RC2リリース、「サーバーレスなRubyが欲しい」、capybara風JSテストフレームワークCypressほか

$
0
0

こんにちは、hachi8833です。明日のRails Developers Meetup 2018準備で青ざめてます。

今週から記事数をできるだけ一定に保つようにいたしました。腹八分目のウォッチ、いってみましょう。

Rails: 今週の改修

Rails 5.2.0 RC2リリース

5.2.0はほぼ仕上がったそうです。

大きな変更はTechRachoでもおおよそ取り上げたので、5.2 RC2リリースノートの中からこれまで取り上げていなかったコミットを中心に見ていきます。

capify!の非推奨化

Capistrano 3でコマンドがcap installに変わったことへの対応です。!なしは元々なかったんですね。

# railties/lib/rails/generators/actions.rb#L229
      def capify!
+        ActiveSupport::Deprecation.warn("`capify!` is deprecated and will be removed in the next version of Rails.")
         log :capify, ""
         in_root { run("#{extify(:capify)} .", verbose: false) }
       end

つっつきボイス: 「そうそう、昔のCapistranoではcapファイルの作成とか設定とかデプロイに使うコマンドがcapifyでしたね」「あれ、CapistranoってRails標準だったかな?」「あ、そういえば: と思ったらGemfileではデフォルトでコメントアウトされて↓るからオプション扱いですね」

# Use Capistrano for deployment
# gem 'capistrano-rails', group: :development

「ところでCapistranoって最近それほど見かけない気がしてましたが」「いやいや、もう当たり前に使われまくっててことさら話題になってないだけ: capファイルは普通にプロジェクトにあるし」「昔はCapistranoの設定方法がよくわからんという声もちょくちょくあったりしましたが、今もう聞かないっすね」

参考: Generators::Actions#capify!

config.ruの古い挙動のサポートを非推奨化

Rails 4以降で生成されるconfig.ruではデフォルトでRails.applicationのインスタンスが使われるようになってからだいぶ経ったことで非推奨化されました。

# railties/lib/rails/commands/server/server_command.rb#L21
-    # TODO: this is no longer required but we keep it for the moment to support older config.ru files.
     def app
       @app ||= begin
         app = super
+        if app.is_a?(Class)
+          ActiveSupport::Deprecation.warn(<<-MSG.squish)
+            Use `Rails::Application` subclass to start the server is deprecated and will be removed in Rails 6.0.
+            Please change `run #{app}` to `run Rails.application` in config.ru.
+          MSG
+        end
         app.respond_to?(:to_app) ? app.to_app : app
       end
     end

つっつきボイス: 「これもむかーしに、Rails起動時にアプリ名じゃなくてRails.applicationを指定できるように変わったので、その絡みでしょうね: Rails 4からだったかな」

後で探してみると、2013年の#9669↓がそれのようです。

# railties/lib/rails/generators/rails/app/templates/config.ru
 # This file is used by Rack-based servers to start the application.

 require ::File.expand_path('../config/environment',  FILE)
-run <%= app_const %>
+run Rails.application

rails runnerの引数で-をサポート

# railties/lib/rails/commands/runner/runner_command.rb#L15
       def self.banner(*)
-        "#{super} [<'Some.ruby(code)'> | <filename.rb>]"
+        "#{super} [<'Some.ruby(code)'> | <filename.rb> | -]"
       end

       def perform(code_or_file = nil, *command_argv)
# @@ -29,7 +29,9 @@ def perform(code_or_file = nil, *command_argv)

         ARGV.replace(command_argv)

-        if File.exist?(code_or_file)
+        if code_or_file == "-"
+          eval($stdin.read, binding, "stdin")
+        elsif File.exist?(code_or_file)
           $0 = code_or_file
           Kernel.load code_or_file
         else

つっつきボイス: 「ははぁ、-を指定することで標準入力が使えると: -自体は標準入力と標準出力のどっちにでも使われうるけどここでは標準入力ですね」

Relationに対するArelメソッド呼び出しが非推奨化

kamipoさんのPRです。

# activerecord/test/cases/relation/delegation_test.rb#L24
+  module DeprecatedArelDelegationTests
+    AREL_METHODS = [
+      :with, :orders, :froms, :project, :projections, :taken, :constraints, :exists, :locked, :where_sql,
+      :ast, :source, :join_sources, :to_dot, :bind_values, :create_insert, :create_true, :create_false
+    ]
+
+    def test_deprecate_arel_delegation
+      AREL_METHODS.each do |method|
+        assert_deprecated { target.public_send(method) }
+      end
+    end
+  end

つっつきボイス: 「今まではAR::Relationに対して直接arelを呼ぶと委譲されてたのが、今後は呼べなくなる方向になるってことか」「非推奨になるArelメソッドのexists↑は?なしの方なんですね: それなら直接呼べなくなってもいいかな」「fromsとかbind_valuesあたりは、ひょっとすると?使うことがあるかもしれないですが」「自分はArel直接呼ぶような事態になったら生SQL書くから別にいいや」

参考: exists?

「つまるところ、Arelは本当はprivateなAPIなんだぞってことを示してるんですかね」「Arelをどうしても使うならArelだけで組み立てて欲しい: AR::Relationで中途半端に組み立てたクエリをArelでいじるのとかやめて欲しいわー」「それで発生するバグつらそうですね…」「AR::Relationでスコープ変えたりするとまたわけわからなくなるし」

「kamipoさんのコミットメッセージ↓にこう書かれているから、relation.arel.#{method}はあっても基本使うなよってことですね」「privateなんだから代わりに呼ぶんじゃないよと」「もう禁止w」「それだけ事故が多かったからですかね」「というよりArel混ぜられると死ぬほど読みにくい!に尽きる」

I removed “Use relation.arel.#{method} instead.” in the message because it’s private API and fixed the next version of Rails to 6.0.
同PRより

ネストしたトランザクションの親のステートをベースにレコードのステートを適用

# active_record/connection_adapters/abstract/transaction.rb#L133
     class SavepointTransaction < Transaction
-      def initialize(connection, savepoint_name, options, *args)
+      def initialize(connection, savepoint_name, parent_transaction, options, *args)
         super(connection, options, *args)
+
+        parent_transaction.state.add_child(@state)
+
         if options[:isolation]
           raise ActiveRecord::TransactionIsolationError, "cannot set transaction isolation in a nested transaction"
         end

つっつきボイス: 「ここまで頑張ってネステッドトランザクション書いたことないなー: そもそもどう処理されるか不安だし」「ただトランザクションって意図せずにネストすることがありうるから、最終的に挙動は知っておいたほうがいいけど」
「そもそも生SQLでもネステッドトランザクション書いたことないしw」「ストアドプロシージャとか、使い所はなくもないですけど、普通の業務で使うかなー?と私も思いますね」「できるのはわかるけど下手するとデッドロックしかねないし」

#0237da2のコミットメッセージを雑に訳してみました:


ネストしたトランザクションがあってレコードが両方でsaveされたとします。外側のトランザクションがcloseするとロールバックします。従来は、外側のトランザクションが「not persisted」とマーキングされていても内側のトランザクションが「persisted」になることがありました。

Post.transaction do
  post_one.save # ロールバックされる

  Post.transaction(requires_new: true) do
    post_two.save # 誤ってpersistedになる
  end

  raise ActiveRecord::Rollback
end

修正のため、このPRではトランザクションの扱いを変更して、子のトランザクションが親にマーキングの状態を確認するようにします。子トランザクションがあると、スタックが空でないため常にSavpointTransactionになります。その状態からparent_transactionを子のSavepointTransaction(親への子の追加はここで行われる)に渡すと、親は内側のトランザクションを「rolledback」とマーキングするのでレコードを「not persisted」とマーキングします。

update_attributes_from_transaction_stateでは、すべてのトランザクションがrolledbackとマーキングされ、内側のレコードがnot persistedと正しくマーキングされるよう、completed?チェックを用いる。

Post.transaction do
  post_one.save # ロールバックされる

  Post.transaction(requires_new: true) do
    post_two.save # 新しい振る舞いではnot persistedと正しくマーキングされる
    on rollback
  end

  raise ActiveRecord::Rollback
end

(同commitのテストを見ながら)「miniTestに#refute_predicateってありますね」「refute: 論破する、だから否定のアサーションか」

# activerecord/test/cases/transactions_test.rb#L372
...
+    refute_predicate topic_one, :persisted?
+    refute_predicate topic_two, :persisted?
+    refute_predicate topic_three, :persisted?
...

参考: refute_predicate

Rails

ActiveRecordをfixtureに変換する(RubyFlowより)

とても短い記事です。


つっつきボイス: 「おーこれあると便利なやつ: これは.to_yaml一発だけど他にもやり方は色々ある」
「マスターデータ的なやつならこんな感じでざっとfixtureにするのはラクでいいですよ」

「fixtureを使う場合問題になるのは外部キーで、ロードするときに面倒くさい」「あー順番に依存しているからか: それは面倒くさいっすね」「yamlってだけで読みづらいのに」
「その点FactoryBotで書けば順番とかよしなにやってくれますからね: fixtureだとそれがない」
「裏技的にMySQLの外部キー制約を一時的にオフにするオプションとかあるけど、そういうことをやり始めると知らないうちにfixtureがひっそりと壊れてたり」「なんやかんやで、fixtureで何でもやろうとするのは無理ありますね」

テスティングアンチパターン: セットアップしたデータの漏れ(Hacklinesより)

# 同記事より: 悪例
describe User do
  let(:user) { create(:user) }
  let(:account) { create(:account, user: user) }
  let(:role) { create(:role, user: user) }
  let(:post) { create(:post, user: user) }

  describe 'account-related stuff' do
    # tests go here
  end
  ... 
end

つっつきボイス:letを全部外側に置くとそのスコープ全部に効いちゃうからやめろよっていう、しごく普通のハナシ」「let嫌いーw: 遅延評価されるところとか」「お、let嫌いの同志を発見!」「let好き勢も社内にいますからねー」「letはキレイに使えるとかっこいいんだけど」「テストの内容によってはランダマイザーの影響とかで数回に1回とか数十回にコケたりすることもあるし」「途中でreloadする羽目になって面倒くさくなるし、reload使うと何だか負けな気がしたり」「基本letで定義したものは参照だけにしてテストの中で変えない方がいい

モデル内の翻訳する文章をJSON化する(Hacklinesより)


同記事より


つっつきボイス: 「これもローカライズ絡みということで」「まあ普通に行われていることですね: mangareborn.jpではyamlでやってたし」「データがでかくなると大変ですが」

loofah: nokogiriベースのHTML/XML操作/サニタイズgem

# 同リポジトリより
span2div = Loofah::Scrubber.new do |node|
  node.name = "div" if node.name == "span"
end

doc = Loofah.fragment("<h1>Title</h1><div>Content</div>")
doc.text    # => "TitleContent"           # probably not what you want
doc.to_text # => "\nTitle\n\nContent\n"   # better

つっつきボイス: 「ほー、HTMLを食わせて要素を置き換えたりto_textしたりサニタイズできる: XPathも使えるみたいだし、うんなかなかよさそうです」「ちなみにこのgemは脆弱性情報↓で知りました」「この種のパーサーはともすると脆弱性が潜むことがあるといえばありますからね」

JetBrains IDEはVMを設定しよう


つっつきボイス: 「JetBrainsにかぎらず、巨大IDEはEclipseでも何でもVMを適切に設定しておけばそう重くなったりしないですよ」

morimorihogeさんのRubyMineでは少なくとも以下ぐらいにVMを設定しているそうです。私のはデフォルトのままだった…

-Xms1024m
-Xmx8192m

ViewModelを分けることについて


つっつきボイス: 「TechRacho記事を引用いただいていたので見つけました」「ViewModelとPresenterを分けるっていう話のようですね」「ViewModelって?」「いわゆるMVVMパターン: JavaScriptの方でよく使われてる」「確かにPresenterが分かれている方がテストはしやすいですね」「正しいモデルを作りにくいけどビューの見た目だけチェックしたいときとか、ViewModelだけ差し替えられるようになってるとテストしやすい」「そういえばこの間の社内勉強会で近年のAndroidの動向を扱ったときもこの辺りの話が出ましたね」

参考: Wikipedia-ja MVVM

GitLabで重大なセキュリティリリース(Hacklinesより)


つっつきボイス: 「おっと、社内のGitLabもアップデートするか」

Ruby trunkより

ヒアドキュメントで改行をエスケープしたときの問題

puts <<~TXT.inspect
  1 \
  2
TXT

# 期待:
"1 2\n"

# 実際:
"1   2\n"

つっつきボイス: 「Rubyのヒアドキュメント記法って6つぐらいなかったっけ?覚えるの大変」「この<<~をSQLクエリで使うと先頭のスペースがなくなるからログが読みやすくなるって確かTechRachoの記事で見ましたよ」「(う、どこだったかな…)」「Rubyではこういうヒアドキュメントが言語仕様として用意されているあたり、ワカッテラッシャル」

参考: Rubyリファレンスマニュアル: ヒアドキュメント

誤った代入でsegfaultする->#14261で修正済みだった

# 同issueより
def foo
  puts 'hi'
end

foo, true  # 落ちる

つっつきボイス: 「むー、trueの前にカンマがあるからブロックと勘違いされたわけではなさそうだけど」「パーサーの気持ちになるのは難しす…」

CLI向けに文字が全角か半角かを取れるメソッドが欲しい

他の言語やgemにもある↓から言語にも取り入れて欲しいということのようです。

Python: unicodedata.east_asian_width (standard library)
https://docs.python.org/3.6/library/unicodedata.html#unicodedata.east_asian_width

Perl: "East_Asian_Width: *" of Unicode properties (regular expression in language)
https://perldoc.perl.org/perluniprops.html

Go: golang.org/x/text/width
https://godoc.org/golang.org/x/text/width

PHP: mb_strwidth (standard library)
http://php.net/manual/en/function.mb-strwidth.php

JavaScript: eastasianwidth (npm library)
https://www.npmjs.com/package/eastasianwidth

RubyGems: unicode-display_width gem
https://rubygems.org/gems/unicode-display_width

つっつきボイス: 「Unicodeの文字幅って全角と半角しかないんだっけ?」「んー、合字とかを別にすればそうだったかもしれないけど、どうだったかな…」

後で取り急ぎ以下を見つけました。

参考: [Ruby] Unicode 文字列の幅をそれなりにがんばって取得する - あおたくノート

プロポーショナルフォントの場合どうしようもないんだけど Unicode の文字は EastAsianWidth という仕様があって文字毎の幅がいわゆる全角か半角のどちらかになるかが決められている。
決められているといっても一部の文字は「Ambiguous(決定できない)」というふうに決められているのでそういう文字は自分で全角に倒すか半角に倒すか選ぶ必要はある。
同記事より

issueでも言及されているjanlelis/unicode-display_widthで「一応」取れるようです。

Ruby

「サーバーレスなRubyが欲しい」署名運動(Ruby Weeklyより)


つっつきボイス: 「サーバーレスRubyね…、あったらうれしいけどRailsが動くわけじゃないだろうし」「ここで署名している人の半分ぐらい、もしかしてRailsを動かしたいんじゃ?」「AWSのLamdbaでRubyがまともにサーバーレスで動くようになってごく軽いマイクロサービスとか建てられるようになったら確かにうれしい」「LambdaではRubyはまだちゃんと使えないんでしたっけ?」「このサイトのファビコンにLambdaのアイコンがあるぐらいだからまだっぽいですね」「確かまだだったと思う」「JRubyは動くみたいですけどJavaが動くから当然動くし」
「本格的なサーバーレスRubyはまだ難しいんじゃないですかね: gemはどこまで使える?とか、ActiveRecordのこの機能だけ使いたいとか」「ネイティブのgemぐらいなら使えるかもしれないけど」

参考: QuickStart Guide to Using the AWS SDK for Ruby - AWS SDK for Ruby

bundler-stats: gemの依存関係を調べるgem(Ruby Weeklyより)

# 同リポジトリより
> bundle-stats

+------------------------------|-----------------|-----------------+
| Name                         | Total Deps      | 1st Level Deps  |
+------------------------------|-----------------|-----------------+
... omitted stuff here ...
| fog                          | 15              | 6               |
| fancybox2-rails              | 15              | 1               |
| quiet_assets                 | 15              | 1               |
| coffee-rails                 | 18              | 2               |
| angular-rails-templates      | 19              | 3               |
| devise                       | 19              | 6               |
| rspec-rails                  | 20              | 7               |
| sass-rails                   | 21              | 4               |
| foundation-icons-sass-rails  | 22              | 2               |
| rails                        | 29              | 9               |
| angular_rails_csrf           | 30              | 1               |
| ngannotate-rails             | 31              | 2               |
| activeadmin                  | 48              | 12              |
+------------------------------|-----------------|-----------------+

Declared Gems:     35
Total Gems:        113

Unpinned Versions: 30
Github Refs:       1

つっつきボイス: 「こういう依存関係の情報取れるのはちょっとありがたいかも」

Ducalis: RuboCopベースの静的コードアナライザ(RubyFlowより)

# 同リポジトリより
ducalis --ci --repo="author/repo" --id=3575 --dry
ducalis --ci --repo="author/repo" --id=3575
ducalis --ci --adapter=circle # mode for running on CircleCI

参考: Wikipedia-ja 静的コード解析

もう何回言われたか覚えてない「Railsは死んだか: 2018年版」


つっつきボイス: 「2018年版ってのがウケる」「恒例の行事」

Ruby 2.6 preview 1

Noah Gibbsさんの記事とツイッターのやりとりです。

その他の記事/リポジトリ

SQL

pg_badplan: クエリプランと実際のクエリの乖離をチェック(Postgres Weeklyより)

-- 同記事より
\copy zip_codes from ~/src/create-statistics-talk/no_postal_codes_utf.csv with csv header;
COPY 4574

EXPLAIN (ANALYZE, TIMING off)
SELECT * FROM zip_codes WHERE city = 'Oslo' AND county = 'Oslo';
                                      QUERY PLAN                                       
---------------------------------------------------------------------------------------
 Seq Scan on zip_codes  (cost=0.00..108.61 rows=90 width=36) (actual rows=642 loops=1)
   Filter: ((city = 'Oslo'::text) AND (county = 'Oslo'::text))
   Rows Removed by Filter: 3932
 Planning time: 0.357 ms
 Execution time: 0.679 ms
(5 rows)

データベース制約は最後の砦(Postgres Weeklyより)

CitusDataによる平易な解説記事です。

PostgreSQLで行の重複をサーチアンドデストロイする(Postgres Weeklyより)

-- 同記事より
SELECT id, firstname, lastname, startdate, position FROM
  (SELECT id, firstname, lastname, startdate, position,
     ROW_NUMBER() OVER 
(PARTITION BY (firstname, lastname) ORDER BY startdate DESC) rn
   FROM people
  ) tmp WHERE rn = 1;

JavaScript

JSプログラマーはどこで一番よく間違えるか(Frontend Weeklyより)


つっつきボイス: 「single biggest mistakeとある割に記事長いなー: 今読んでられない」「翻訳してみようかしら」

Cypress: フロントエンド向けテスティングフレームワーク


同リポジトリより

Railsでもbin/rake db:purge && cypress openで使えるそうです。


つっつきボイス: 「これはこれは、とってもCapybaraみたい↓」

describe("main navigation", () => {
  it("can navigate", () => {
    cy.visit("/");
    cy.get("[data-cy='nav-blog']").click();
    cy.location("pathname").should("eq", "/blog");

    cy.viewport("iphone-6");
    cy.get("[data-cy='nav-blog']").click();
    cy.location("pathname").should("eq", "/blog");
    cy.get("[data-cy='nav-about']").click();
    cy.location("pathname").should("eq", "/about");
  });
});

サードパーティJavaScript読み込みの影響(Frontend Weeklyより)


同記事より

Googleの解説記事です。

CSS/HTML/フロントエンド

CloudFlare Workers APIでJavaScriptを高速実行(JavaScript Weeklyより)

JavaScript向けの高速なCloudFlare Workers APIがでリリースされたという情報です。ドキュメントによるとHTTPトラフィックを扱う「Cloudflare Service Workers」とは別物だそうです。

センサーAPIがW3CでCRに(Frontend Focusより)


つっつきボイス: 「加速度計、ジャイロスコープ、磁気センサ、方位センサに間接光センサ…」

Wrapparizer: サイトで使われているソフトウェアを簡単に表示


つっつきボイス: 「既存システムがある案件の見積フェーズで有用そう」「Railsは50%で推測してますね」「JavaScriptの方をチェックしているのかも」

その他

Java 10がリリース


同記事より

参考: Java 10が本日付で正式リリース。ローカル変数の型推論、ガベージコレクタが入れ替え可能、不揮発性メモリ対応など。Java 9は早くもサポート期間終了 - Publickey

参考: Java 10新機能まとめ - Qiita

日本で働く外人エンジニア

読み物記事です。日本語混じりなので翻訳が逆に難しそう。


つっつきボイス: 「これはいろいろ面白いー」「忖度忖度」「『現在和訳中です』か…」「↓これもいいですね」

参考: How To Write Letters In Japanese

その手があった

番外

奥村先生が受賞

おめでとうございます。

塩粒以下のコンピュータ

アリさんに埋め込んだりできるでしょうか。


つっつきボイス: 「もう花粉症引き起こせますね」「こういうのが互いに相互連携して動くみたいなやつも出始めてるらしい」「体内で動くのはちょっと怖いかも」

参考: IBMが世界最小のコンピューターを発表、塩の粒より小さいサイズ - GIGAZINE

ブレインスキャナーがウェアラブルに

ポイントは「地磁気の影響をどうやって遮断するか」だったそうです。


今週は以上です。明日のRails Developers Meetup 2018でお会いしましょう。スライドがんばる…

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

週刊Railsウォッチ(20180316)Rails 5.2のドキュメント更新中、Value Objectの使い方、RubyがTIOBEトップテン復活、Rails「雪だるま」エンコーディングほか

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

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

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

JavaScript Live

jslive_logo_captured

JSer.info

jser.info_logo_captured

Rails tips: トランザクションでコールバックを使う(翻訳)

$
0
0

概要

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

Rails tips: トランザクションでコールバックを使う(翻訳)

前回はActiveRecordのトランザクションの概要を学びました。今回はトランザクションのコールバックをもう少し詳しく見てみることにしましょう。

トランザクション内で使われるコールバックには2種類あります。

1. commit後のコールバック

after_commitコールバックは、レコードが保存または削除(destroy)されるたびに、トランザクションがデータベースにcommitされた後で呼び出されます。例を見てみましょう。

class User < ActiveRecord::Base
  after_commit :log_user_activity

  ...

  def log_user_activity
    Rails.logger.info("ユーザー#{id}は更新されました")
  end
end

2. ロールバック後のコールバック

after_rollbackコールバックは、レコードが保存または削除(destroy)されるたびに、トランザクションがロールバックした後で呼び出されます。例を見てみましょう。このロールバックは、トランザクションが失敗した理由をログ出力したい場合にとても便利です。いくつかの属性をログ出力しておいて、後で原因をチェックすればよいのです。

class User < ActiveRecord::Base
  after_rollback :log_transaction_status

  ...

  def log_transaction_status
    Rails.logger.info("ユーザー#{id}は操作を実行できませんでした")
  end
end

トリガーを追加する

どちらのコールバックもレコードが保存または削除(destroy)されるたびに追加されると申し上げましたが、その気になればafter_commitコールバックに他のトリガーを加えることもできます。更新時/作成時/削除時に特定のメソッドをトリガーしたい場合は次のようにします。

after_commit :log_user_activity, on: :create
after_commit :log_user_activity, on: :update
after_commit :log_user_activity, on: :destroy

上の呼び出しでは次のようにエイリアスを使うこともできます。

after_create_commit :log_user_activity
after_update_commit :log_user_activity
after_destroy_commit :log_user_activity

Railsでお困りの方にお知らせ

知りたいことがありましたら、twitter または連絡用フォームにてお気軽にお問い合わせください。

RSpec & TDDの電子書籍を無料でダウンロード

もっと稼ぎたい方や会社をさらに発展させたい方へ: テスティングのスキルの重要性にお気づきでしょうか?テストを正しく書き始めることが、唯一のファーストステップです。無料でダウンロードいただける私の書籍『RSpec & Test Driven Developmentの無料ebook』をどうぞお役立てください。

関連記事

Rails tips: ActiveRecordのトランザクションの概要(翻訳)

アトミックなトランザクションで冪等APIを強化する(翻訳)

Rails: :before_validationコールバックの逸脱した用法を改善する(翻訳)

RailsのVue.jsをWebpackerとJestでテストする(翻訳)

$
0
0

概要

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

RailsのVue.jsをWebpackerとJestでテストする(翻訳)

今やっているプロジェクトで、既存のRailsアプリにVue.jsを統合する方法の調査をを命じられました。とりあえず公式ガイドを読み、チュートリアルを視聴し、ブログを読みまくった結果、Vueコンポーネントを完全に動かせるようになりました。

最後の段階でいくつかテストを書こうとしましたが、残念なことにWebpacker gemにはテスト向け設定が含まれていなかったので、自分で設定せざるを得ませんでした。

驚いたことに、満足な設定方法がドキュメントに見当たらなかったのです。そこで私が何とかこれを動かせるようにしたときの方法を本記事で皆様と共有したいと考えました。

1. Jestのインストール

特にこれといった好みもなかったので、Jestを使うことに決めました。vue-cliも同梱されていたので使いました。

Jestのインストールで必要なのは、プロジェクトのルートディレクトリでyarn add --dev jestを実行することだけです。続いて、package.jsonに自分のテストスクリプトを追加します。

{
  "scripts": {
    "test": "jest",
    ...
  },
  ...
}

これで、yarn testを実行すればテストが走ります。

2. テストの置き場所を定義する

この時点でyarn testを実行しようとすると、config/webpack/test.jsでコケます。これは、Jestがプロジェクト内のテストファイルを探索する方法が原因です。基本的にはプロジェクト全体のうち.spec.js.test.jsにマッチするすべてのファイルを実行します。ここではテストファイルが*.test.jsとマッチしたので、テストとして実行されようとしていたのです。

自分たちのテストを実行する基準を満たすプロジェクト内の他のファイルと同様、Webpack configファイルを使わないようにしたいので、探索するファイルの場所をJestに指定する必要があります。

ここではRspecを使っていることもあり、spec/javascriptsディレクトリを探索場所として指定することにしました。もちろん、自分のプロジェクトに合う場所を自由に指定することもできます。

これを行うには、package.jsonファイルにrootsを追加しなければなりません。

"jest": {
  "roots": [
    "spec/javascript"
  ]
},

package.jsonのサイズがかなり大きいのでこのファイルにこれ以上設定を追加したくない場合は、--config <path/to/js|json>オプションでJest設定を定義できます。この方法にする場合、package.jsonは次のような感じになるはずです。

  {
    "scripts": {
      "test": "jest --config spec/javascript/jest.conf.js",
      ...
    },
    ...
  }

これが動作することを確認するために、以下のようなシンプルなテストをspec/javascript/team.spec.jsファイルを作成してもよいでしょう。

test('there is no I in team', () => {
  expect('team').not.toMatch(/I/);
});

これで再びyarn testを実行すれば、greenの「PASS」が表示され、動作していることがわかるはずです。

3. babelをrescueする

最初のテストが動くようになったので、一歩進めてVueコンポーネントをテストしてみましょう。

最初にやってみるのは、たいていspec/javascript/の下にファイルを作成してmy_component.spec.jsのような名前を付けることでしょう。続いて次のようにimportステートメントで自分のコンポーネントをspecにインポートしてみましょう。

  import MyComponent from '../../app/javascript/my_component.vue';

これをやってみた方は、そのままyarn testでテストを実行してみてください。SyntaxError: Unexpected token importエラーが出力されます。

ここでの問題は、importがECMAScript 6の一部である点です。つまりBabelなどのトランスパイラの助けが必要になるということです。

動くようにするには、yarn add --dev babel-jest babel-preset-es2015で2つのパッケージをインストールし、.babelrcファイルにes2015プリセットを追加する必要があります。

{
  "presets": ["es2015",
    ["env", {
          ...

さらに一歩進めてみたい方は、moduleDirectoriespackage.jsonに追加すると、モジュールへのフルパスを入力しなくても済むようになります。

"jest": {
  ...
  "moduleDirectories": [
    "node_modules",
    "app/javascript"
  ]
}

これで、先ほどのパスを簡単にできます。

  import MyComponent from '../../app/javascript/my_component.vue';

上のパスは以下のように書けます。

  import MyComponent from 'my_component.vue';

4. Vueが見つからない

ここまでの手順をすべて行った場合、yarn testでテストを実行するとまたしてもSyntaxErrorが表示されます。これは、コンポーネントのimportは成功したものの、.vueファイルフォーマットを理解できていないためです。

幸い、この辺りの面倒を見てくれるvue-jestを使えます。早速yarn add --dev vue-jestを実行してvue-jestをインストールし、READMEの指示に沿ってmoduleFileExtensionstransformmapCoverageを追加します。追加後のpackage.jsonは次のような感じになるはずです。

"jest": {
  ...
  "moduleFileExtensions": [
    "js",
    "json",
    "vue"
  ],
  "transform": {
    "^.+\\.js$": "<rootDir>/node_modules/babel-jest",
    ".*\\.(vue)$": "<rootDir>/node_modules/vue-jest"
  },
  "mapCoverage": true
}

moduleFileExtensionsを使うと、単一のファイルコンポーネントのimport.vue拡張子が不要になります。

  import MyComponent from 'my_component.vue';

上のパスは、以下のように書き換えられます。

  import MyComponent from 'my_component';.

これで、importをシームレスに使えるようになりました。

transformセクションに記述されたルールでは、テストするファイルの変換を担当するパッケージを指定します。ここではすべての.vueファイルの扱いをvue-jestに任せたいので、これらのファイルはJestで扱われる前に純粋なJavaScriptに変換されます。

mapCoverageは、トランスフォーマーが生成するソースマップを使うために設定してあります。Jestはこのソースマップを用いて、カバレッジの試行や、レポート生成時や閾値チェック中に元のソースコードとのマッピングを行います。

最後に、Vue.js公式の単体テストユーティリティライブラリであるvue-test-utilsを追加しましょう。yarn add --dev @vue/test-utilsを実行するだけですぐ使えるようになります。

ついにVueコンポーネントのテストを書けるようになりました🎉

クレジット

本記事は、Jest公式ドキュメントHow to setup JavaScript testing in Rails 5.1 with Webpacker and JestなどWeb上の多数のドキュメントから集めた情報を元に執筆いたしました。

関連記事

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

Rails tips: Value Objectパターンでリファクタリング(翻訳)

$
0
0

概要

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

Rails tips: Value Objectパターンでリファクタリング(翻訳)

Value Objectは、Null Objectパターンと同様Rubyの純粋なオブジェクトです。こうしたオブジェクトは値を表現しますが、ユーザーオブジェクトのようなシステム内で一意のオブジェクトを表現するためのものではありません。Valueオブジェクトは常に値だけを返しますが、Policy Objectのように論理値(true/false)だけを返すのではなく、他の値を返すこともできます(ほとんどの場合文字列です)。このパターンにおけるルールは、「シンプルに保つこと」と、「オブジェクトが生きている間は属性値をしないこと」です。

デモ

今回のリファクタリングパターンの基礎となるアイデアをよりわかりやすく示すために、サンプルのクラスを作ってみましょう。

class Report
  def initialize(emails:)
    @emails = emails
  end

  def data
    emails_data = []

    emails.each do |email|
      emails_data << {
        username: email.match(/([^@]*)/).to_s,
        domain: email.split("@").last
      }
    end

    emails_data
  end

  private
  attr_reader :emails
end

渡されたメールアドレスに対して以下を行います。

  1. メールアドレス値は変更しない
  2. 値を1つだけ返す
  3. 操作はプリミティブなオブジェクト上で行う

Value Objectを作る

上のロジックからValue Objectのクラスを作ると、以下のような感じになります。

class Email
  def initialize(email)
    @email = email
  end

  def username
    email.match(/([^@]*)/).to_s
  end

  def domain
    email.split("@").last
  end

  private
  attr_reader :email
end

Value Objectパターンでリファクタリングする

最終的に得られるのはきわめてシンプルなRubyオブジェクトなので、テストも理解も簡単です。Reportクラスをリファクタリングした結果、とても明快でシンプルなコードになりました。

class Report
  def initialize(emails: emails)
    @emails = emails
  end

  def data
    emails_data = []

    emails.each do |email|
      email_obj = Email.new(Email)

      emails_data << {
        username: email_obj.username,
        domain: email_obj.domain
      }
    end

    emails_data
 end

 private
 attr_reader :emails
end

最後のリファクタリング

Report#dataメソッドがシンプルになってロジックも分離されましたが、まだコードが多少長くなっています。Email#to_hメソッドを作成し、オブジェクトのハッシュ形式を返すようにします。これは私にとって非常に自然な書き方であり、Report#dataをワンライナーに書き換えることができます。

class Email
  def initialize(email)
    @email = email
  end

  def username
    email.match(/([^@]*)/).to_s
  end

  def domain
    email.split("@").last
  end

  def to_h
    { username: username, domain: domain }
  end

  private
  attr_reader :email
end
class Report
  def initialize(emails: emails)
    @emails = emails
  end

  def data
    emails.map { |email| Email.new(email).to_h }
  end

  private
  attr_reader :emails
end

Railsでお困りの方にお知らせ

知りたいことがありましたら、twitter または連絡用フォームにてお気軽にお問い合わせください。

RSpec & TDDの電子書籍を無料でダウンロード

もっと稼ぎたい方や会社をさらに発展させたい方へ: テスティングのスキルの重要性にお気づきでしょうか?テストを正しく書き始めることが、唯一のファーストステップです。無料でダウンロードいただける私の書籍『RSpec & Test Driven Developmentの無料ebook』をどうぞお役立てください。

関連記事

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

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

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

Railsで学ぶSOLID(1): 単一責任の原則(翻訳)

$
0
0

概要

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

翻訳記事の相互リンクは今後更新いたします。

Railsで学ぶSOLID(1): 単一責任の原則(翻訳)

「SOLIDの原則シリーズ」へようこそ。このシリーズ記事では、SOLIDの原則をひとつずつ詳しく説明し、分析します。シリーズの最後にはいくつかのヒントや考察を含む総括記事をお送りしますのでどうぞご期待ください。

それでは始めましょう。「SOLIDの原則」とはそもそも何なのでしょうか?SOLIDとは、オブジェクト指向プログラミング設計における一般的な原則であり、ソフトウェアをより理解しやすくし、拡張性やメンテナンス性やテストのしやすさを高めることを目的としています。

  1. 単一責任の原則(SRP: Single responsibility principle)(本記事)
  2. オープン/クローズの原則(OCP: Open/closed prinsiple)

  3. リスコフの置換原則(LSP: Liskov Substitution Principle)

  4. インターフェイス分離の原則(ISP: Interface Segregation Principle)

  5. 依存関係逆転の原則(DIP: Dependency Inversion Principle)

今回は、最初の原則である「単一責任の原則」について詳しく見ていくことにします。

単一責任の原則(SRP)

クラス(オブジェクト)が担う責任は1つに限定すべきである(かつその責務は完全にカプセル化されるべきである)。

あるいは

クラスを変更するときの理由付けは、1つしかあってはならない。

これをもっと易しく言い換えれば「1つのクラスは1つの仕事しかしない」ということです。

しかし、クラスの責務というものはどうやって決まるのでしょうか?「変更する理由」というのはどのようにして決まるものなのでしょうか?この問いに答えるのは難しく、容易ではありません。

次の文面について考えてみましょう。「クラスが更新される(書き換わる)理由は1つだけにとどまるべきである(ここで言う書き換えの対象はファイルのコードではなく、オブジェクトのメモリ上のステートを指す)」。本記事ではこの理由を追求してみることにします。

: ユーザーを1人作成したいとします。最初に必要なのはデータのバリデーションです。この機能をService Objectで作成してみましょう。おそらく次のようなクラスになるでしょう。

class UserCreateService
  def initialize(params)
    @params = params
  end

  def call
    return false unless valid?
    process_user_data
  end

  private

  def valid?
    # 複雑なバリデーションロジックを書く
    ...
  end

  def process_user_data
    ...
  end
end

このクラスはどんなことをするでしょうか?このクラスは「ユーザー入力のバリデーション」を行い、続いてそれを「処理」(おそらくユーザーをデータベースに保存)しています。「バリデーション」と「処理」という2つの操作はいかにも別物感があり、どうやら2つの異なる責務のようです。しかし上のやり方を修正または改良する前に、なぜ上のようなやり方が良くないのかを明らかにしなければなりません。

何より注目したいのは、2つの処理が強く結合していることです。この2つの処理の一方を別の場所で再利用するのは簡単ではありません。このようなクラスのメンテはつらいものになり、ミスを誘発する可能性が増えてしまいます。そしてテストも面倒になってしまいます(カバーしなければならないケースが増えてしまうため)。

そういうことであれば、バリデーションがらみのコードを別のクラスに切り出すのはいい考えかもしれません。とりあえずやってみましょう。

class UserClassService
  def initialize(params, validator: UserValidator)
    @params = params
    @validator = validator
  end

  def call
    return false unless validator.new(params).validate
    process_user_data
  end

  private

  attr_reader :params, :validator

  def process_user_data
    ...
  end
end
class UserValidator
  def initialize(params)
    @params = params
  end

  def validate
     ... #=> true/false
   end
end

クラスが2つになり、それぞれのクラスが担当する責任は1つだけになっています。最初のクラスは「データの処理」、次のクラスは「データのバリデーション」です。2つのクラスの結合はもう強くありません。バリデーションルールを変更しなければならなくなったとしても、UserValidationクラスだけを変更すれば済みます。同様に、バリデーション後の操作を変更しなければならなくなったとしても、UserCreateServiceクラスに手を付ける必要はもうありません。

しかも、それぞれのクラスを単独で再利用できるようになっています。UserValidationクラスを他のコンテキストで使うことも、UserCreateServiceを別のバリデーションクラスで使うこともできます(ここでは「依存性の注入: dependency injection」を使いました)。

他にもメリットはあるのでしょうか?もちろんです!コードには必ずテストも書くものです。今後はバリデーションと処理サービスのテストを個別に(分離した形で)書き、UserCreateServiceのテストではUserValidatorのレスポンスをモック化することになります。

だがしかし…

ドメイン駆動開発(DDD)という言葉はおそらくご存知かと思います。DDDという概念は、「情報エキスパート」(information expert)や「厚いドメインモデル」(薄いドメインモデルの対概念)といった用語に関連します。

訳注: 既存訳が見当たらないため、ここではrich domain modelを「厚いドメインモデル」、anemic domain modelを「薄いドメインモデル」と中立に訳出しました。

ここで「厚いドメインモデル」について少し考えてみましょう。この概念では、あらゆる振る舞いをそのモデルの「特定のモデル」に強く結合させておくことが前提になっています。そこではデータとそれを操作するロジックが同じ場所に置かれます。オブジェクト指向の話をしているので、これは自明に思えます。

次の例をちょっとご覧ください。

class User < ActiveRecord::Base
  validates :email, :first_name, :last_name, :presence: true

  def notify(notification)
    ...
  end

  def softDelete
    deleted_at = Time.zone.now
  end
end

なるほど、バリデーション、ユーザーへの通知ロジック、ソフトデリートのロジックが同居していて、責務を抱えすぎている趣があります。さてここで問題です。「このコードはSRPに違反しているでしょうか?」

私の回答は決まっています。「いいえ、何も違反していません

その理由は、ドメインモデルの責務が「自身のデータと一貫性」ただそれだけに限定されているからです。

私にとって、これは常にコンテキストによって決まるものです

「おいおい、ついさっき『1つのクラスに2つの操作があるのはダメダメ』って書いたばっかりじゃないの?」とツッコまれるかもしれません。しかし、SRPに違反しているかどうかはやはり「すべてコンテキスト次第」なのです。

Service Objectの目的は、単一の(アトミックな)操作を行うことであり、それ以外の何物でもありません。

まとめ

既にお気づきかと思いますが、この法則を理解するのは簡単なことではありません。主な理由としては、「変更の理由」の定義が難しくなってしまう場合があるのと、主張や解釈の余地が残されていることが挙げられます。実装そのものはとりたてて難しくはありません。

そこで私からは以下のアドバイスを記しておきたいと思います(このアドバイスはあらゆるルールに適用されます)。「ルールXを何が何でも満たそうと頑張らないこと」「そのルールでメリットを得られるように頑張ること」。私がこの原則を用いている理由はこれです。私は間違ってませんよね?

最後になりますが、この原則を正しく適用できれば、きっとコードはクリーンになり、読みやすくメンテしやすいものになるはずですし、理解や分析もやりやすくなるはずです。そうなればテストも同様に書きやすくなり、書いたテストもクリーンかつメンテしやすいものになります。

関連記事

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

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


Hanamiフレームワークに寄せる私の想い(翻訳)

$
0
0

概要

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


leanpub.com/ddrより

Hanamiフレームワークに寄せる私の想い(翻訳)

本記事は元々Redditへのコメントとして投稿されました

この数週間Hanamiでいろいろ遊んでみました。そして今、個人的にはRailsよりよさげに感じています。文章よりコードが見たいぜ!という方は、私がHanamiで構築したささやかなサンプルアプリをどうぞ。

以下は、私がHanamiについて気に入っている点トップスリー(訳注: 実際には4つです)について少し詳しく書いたものです。

1. ActiveRecordではなくRepositoryパターンを採用

Active Recordパターン(訳注: 実装ではなくPofEAAの用語です)では、あらゆるものをモデルに投げることを促します。コールバック、バリデーション、永続性、データベースロジック(クエリ)、そしてビジネスロジックを思い浮かべてください。

(ROMを経由する)Repositoryパターンは対照的に、データベースとアプリの分離を明確にする方向に導きます。私がとりわけ気に入っているのは、バリデーションをdry-validation(ROMと完全に別のライブラリ)で扱う手法です。これによってモデル同士や永続性/バリデーションの層が強制的に分離され、使いたい機能を自由に拾い上げて選べるようになります。

Repositoryパターンで私が最も愛して止まない部分は、ビューでデータベースクエリを作成するという行為が非常に難しくなる点です。このおかげでN+1クエリなどを防止しやすくなります。

さらに、ROMにコールバックなどがないのも私にとって非常に嬉しい点です。コールバックという手法は(DHHの言う)マジックが強すぎ、開発者にとって切れ味が必要以上に良すぎます。明示的に書かれたコードは、理解を大きく助けてくれます。

2. アクションクラス

あらゆるアクションを同じコントローラに投げるのではなく、アクションを独自のクラスに隔離します。これによってアクション間の分離が保たれ、アプリ全体のアーキテクチャがさらに明確になります。

アクション間で何らかの機能を共有したい場合、その機能のためのモジュールを作成してアクションクラスにincludeするだけで簡単に実現できます。たとえば、find_postメソッドをshow/edit/update/destroyアクションで使いたい場合、モジュールでこのメソッドを定義して、使いたいアクションでincludeするだけで完了します。

Railsで上のようなメソッドをヘルパーメソッドとして追加すると、大抵の場合コントローラがとっ散らかってしまいます。アクションやヘルパーメソッドが複雑なため、Railsではコントローラのコードが数百行にもなることは珍しくありません。各アクションが独自のクラスに分離されることで、はるかに作業しやすくなります。

Hanamiのアクションはシンプルなクラスなので、テストをルーティング層から完全に分離することもできます。Railsのcontroller specは元々これを目指していたと私は考えていますが、これが私にはどうしても正しく思えないので、最近はもっぱらrequest specでやっています。Hanamiでは再びアクションを簡単にテストできるようになります。こういうのは大好きです。

3. アクションparamsのバリデーション

一部は前述の内容と関連しています。

Hanamiのアクションではパラメータのバリデーションも用います(dry-validationを援用しているのだろうと考えています)。つまり、アクションごとにパラメータを一意にバリデーションできるということです。皆さんがおそらくRailsでやっているであろう、コントローラでcreate_project_paramsupdate_project_paramsを定義する方法とは異なり、有効なパラメータの構成をまさにアクションクラス内で定義できるのです。

私がHanamiで試してみた範囲ではこれを使う機会はまだ見当たりませんでしたが、Railsアプリでこのようなものが必要になった部分ならいくつも思い当たります。

4. ビュークラス

アクションクラスと同様、Hanamiには「ビュークラス」という概念もあります。ビュークラスは、ビューの「ヘルパーメソッド」のロジックをテンプレートから極めて明確に分離してくれるので、大好きです。

Railsアプリの場合、コントローラ名に沿った名前を持つモジュールの中でヘルパーを定義します。私は次の2つの理由から、このアプローチがどうしても好きになれません。1つ目は、私は普段からヘルパーを1つのビューでしか使っていないこと、2つ目はヘルパーがアプリのビュー全体でグローバルにアクセスできてしまうことです。つまり、別のヘルパーモジュールに同じような名前のヘルパーメソッドを置けないということです。このような設計方針が決定されたのはかなり恐ろしいことです。

Hanamiのビュークラスは、このような小粒のヘルパーメソッドたちを置くのにうってつけの場所ですし、しかもビュークラスは分離された小さなクラスなので、ルーティングやコントローラやモデルと一切関わりを持たないテストを書けます。

訳注: Railsでは、config/application.rbファイルでconfig.action_controller.include_all_helpers = falseを記述することで、ヘルパーメソッドをビュー全体でグローバルにしないようにできます。
参考: ActionController::HelpersおよびRails中級チュートリアル: ヘルパー

まとめ

最初に申し上げたとおり、私にはHanamiが正しいと感じられます。Hanamiは、私たちがRailsですっかり慣れ親しんでいるMVC(Model-View-Controller)アーキテクチャに従っている部分もあるので、親しみやすさも十分ですし、使っていて実に快適です。いくつかの考え方については「Hanami Way」に切り替えなければなりませんが、ひとたび発想を改めてしまえばHanamiの方が優れていることに気がつくことでしょう。

Hanamiは、大規模Railsアプリの開発から学んだ厳しい教訓を実証した、頑丈かつ開発の行き届いたもうひとつのRuby向けWebフレームワークです。

実際、「頑丈かつ開発の行き届いた」という点は本当にうまくいっているので、もし私がproduction向けのWebアプリをスクラッチからRubyで開発することがあれば、私はRailsよりHanamiを使うでしょう。Railsでの開発経験が10年にもおよんでいるにもかかわらず、です。

関連記事

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

Rails5「中級」チュートリアル(3-2)投稿機能: ヘルパー(翻訳)

Railsをバックエンドに持つReduxアプリを作ってみた(翻訳)

$
0
0

概要

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

Railsをバックエンドに持つReduxアプリを作ってみた(翻訳)

ここ何か月もの間(大した期間ではないことはわかってますが)Flatiron Schoolでコーディングを学んでいましたが、同校のカリキュラムの最終プロジェクトで、私はReactとReduxをフロントエンドに、Ruby on Railsをバックエンドに使いました。

何しろ私にとって最初の大きなソロプロジェクトであり、しかもアプリ作成に使ったことのないReduxを使おうというのですから、相当頑張りましたが、間違いなく報われました。今やすっかりRedux信者です。

このアプリの目標は、自分の好きなテレビ番組をすべて保存して、放送時間やチャンネルを表示できるようにすることです。今は第二の「テレビ黄金期」ですので面白いテレビ番組が溢れかえっていますが、とても全部は追いきれません。番組の放送時間/チャンネル/系列ネットワーク/Webサイトやらなんやら、今週の新番組なのかどうかなどを忘れないようにするのは無理です。そこに登場するのが私のMyLineupアプリです。

デモ版アプリはこちらでご覧いただけます。デモ版はログイン不要で、データも入っていますので、ユーザー(そう、皆さんです)が簡単にアプリの機能を知ることができます。アプリのREADMEファイルはGitHubリポジトリでご覧いただけます。

バックエンド

アプリのバックエンドはRuby on Railsで作成しました(GitHubリポジトリ)。バックエンドの責務はデータベースがらみのクエリだけなので、セットアップはかなり簡単でした。また、TVデータの取得に2種類の異なるAPI(trakt.tv APITVmaze API)を使いました。パフォーマンス監視については、NewRelicでバックエンドを最適化しました。

作ったモデルは、UserShowEpisodeUserShowUserEpisodeです。

  • Userはだいたいこうなるだろうという感じです。
class User < ApplicationRecord
  has_many :user_shows
  has_many :shows, through: :user_shows
  has_many :user_episodes
  has_many :episodes, through: :user_episodes
  has_secure_password
end
  • Show
class Show < ApplicationRecord
  has_many :user_shows, dependent: :destroy
  has_many :users, through: :user_shows
  has_many :episodes
end
  • Episode
class Episode < ApplicationRecord
  has_many :user_episodes, dependent: :destroy
  has_many :users, through: :user_episodes
  belongs_to :show
end

UserShowUserEpisodeはそれぞれ、UserShowのjoinテーブルと、UserEpisodeのjoinテーブルです。

class UserShow < ApplicationRecord
  belongs_to :user
  belongs_to :show
end
class UserEpisode < ApplicationRecord
  belongs_to :user
  belongs_to :episode
end

作成したモデルでは、ユーザーが番組を評価できます。また、ユーザーが番組やエピソードを削除したときにデータベースから削除しないようになっています。

モデルごとに、ユーザーのCRUD操作のためのコントローラも必要です。Showsコントローラは、表示のCRUDアクションの他に、外部APIへのリクエストのフェッチCRUDアクションも扱うので、責務は最も大きくなりました。

フロントエンド

上述のとおり、私はフロントエンドを(自分がよく知っている)Reactと、(学び始めて数日しか経っていない)Reduxで作りました。Reactのコンポーネントやステートの構造は快適でしたが、Reduxではまごついてしまいました。しかし私は挑戦せずにはいられない男なので、Reduxを試してみたかったのです。そしてReduxは私にとってかなりよかったことを申し添えなくてはなりません(フロントエンドのリポジトリはこちら)。パフォーマンスの監視にはChrome Performance Dev Toolsを使いました。

ダッシュボード

ログインすると、ユーザーは自分の「Dashboard」にリダイレクトされます。Dashboardには、その日の夜の「ラインナップ」や、その日の夜のおすすめ番組が表示されます。

「My Lineup」に表示されるのは、Reduxストアの「myLineup」から取って来た、その日に放映されるエピソードです。「Other Shows to Watch」には、TVmaze APIから取ってきた情報(その日に放映されるあらゆる番組を取れます)を表示します。リストを使いやすくするために、レーティングが8を超え、かつ「User’s Lineup」にまだない番組だけをフィルタで表示しました。

this.props.onTonight.filter(episode => episode.show.rating.average > 8 && !ids.includes(episode.id))

「Dashboard」を含むコンポーネントをステートフルにしなければなりませんでした。これはReduxの「真の単一情報源」とは逆を行くものですが、よいきっかけでした。これによって結果をページネーションしてユーザーがエピソードのリストをスクロールできるようになりました。フィルタされた結果を分割して、表示するステートに5つずつ保存しました。「Older」をクリックすると、フィルタされた結果に直前の5つの項目を単に表示します。このあたりを手伝ってもらったLindsey Wellsへのリンクを貼っておきます。

「My Lineup」

このプロジェクトで(少なくとも私にとっては)最も重要な部分は、言うまでもなく、ユーザーのラインナップを表示するカレンダーです。番組がいつどの局で放映されるかをカレンダーで正確に知ることができます(サンプルアプリ)。

ここにある番組を片っ端から見る時間があったらよいのにと思います。

ユーザーの番組をすべて表示するのに使ったのはReact Big Calendarです。RBCの使い勝手はそれほどよくありませんが、これを使ってカレンダーのラインナップにユーザーのエピソードをすべて表示することに成功しました。時刻のフォーマットや設定にはMoment.jsを使いました。

ユーザーは、その日の番組や一週間分の番組を知ることができます。カレンダーを進めたり遡ったりもできます。保存した来月分の全番組の概要も表示できます(そこまでして予定を押さえておきたい場合に備えて)。

ラインナップからエピソードを削除することも、エピソードの詳細をモーダルポップアップに表示することもできます。

ページの最下部にはその夜のおすすめ番組が表示され、番組をクリックするとモーダルポップアップでラインアップに直接追加できます。

プレミアカレンダー

Premieresカレンダーは、その週のプレミア(シリーズの最初のエピソード)をすべて表示できるお楽しみ機能です。

これも同様に、1日分または1週間分を表示できます。trakt.tv API から取得した新作番組をフィルタして、レーティングの高い番組だけを表示しています。さらに、「エピソード1」や「シーズン1、エピソード1」の違いがわかるようにして、その番組がTotal Divasのようにシーズンの続きなのか、S.W.A.T.のようにまったくの第1回なのかがわかるようにしました。

これもモーダルポップアップで追加したり番組情報を表示したりできます。

トレンド/高視聴率

これはtrakt.tv APIの楽しい機能です。番組のその瞬間のトレンド(APIでは1時間おきに更新)や、最も視聴率の高い番組を表示でき、時間でフィルタすることもできます。

このユーザーインターフェイスにはSemantic UIを使いました。

検索

検索では、TVmaze APIを呼び出して結果を表示します。表示された番組を追加して番組リストにリダイレクトできます。

ShowsページとShowページ

最後はShowsページとShowページです。Showsページにはユーザーが保存したTV番組がすべて表示されます。ユーザーが番組をクリックすると、番組の個別のページにリダイレクトされます。

個別のページには、ユーザーが選択した番組の情報と、番組の全エピソード(上述のページネーション機能を利用)、およびその番組に関連するおすすめ番組が表示されます。

おすすめ番組はtrakt.tv APIから取り出します。ユーザーがまだ視聴していない番組だけを表示します。

この星はアプリのユーザーが付けたものです。それ以外の情報はすべてTVmaze APIからのものです。

アプリの紹介は以上です。アプリ作成はとても楽しく、今もいくつかの機能を拡張してみたいと思っているほどです。特に、同じ番組を保存した他のユーザーをEpisodeページで表示できるようにしてSNS的な要素を加えたいと思います。今後どうなるかはいずれわかるでしょう。

関連記事

JavaScript: Reduxが必要なとき/不要なとき(翻訳)

Reduxストアの概念をRubyで再実装して理解する(翻訳)

Rails: 多機能ベンチマークgem「derailed_benchmarks」README(翻訳)

$
0
0

概要

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

画像はREADMEからの引用です。

Rails: 多機能ベンチマークgem「derailed_benchmarks」README(翻訳)

RailsアプリやRubyアプリのさまざまなベンチマークを取ることができます。

「な・ん・と・か・し・て」

schneems/derailed_benchmarksより

互換性と要件

このgemはRails 3.2以上、Ruby 2.1以上でテストおよび動作確認しています。一部のコマンドはもっと古いRubyでも動くかもしれませんが、すべてのコマンドの動作についてはサポート外です。

一部のベンチマークについては(すべてではありません)、OSでcurlコマンドが実行できることを確認する必要があります。

$ which curl
/usr/bin/curl
$ curl -V
curl 7.37.1 #...

インストール

Gemfileに以下を追加します。

gem 'derailed_benchmarks', group: :development

続いて$ bundle installを実行します。

このgemのコマンドを実行するとき、コマンドの前にbundle execをつけて実行する必要があるかもしれません。

利用可能なプロファイリング用メソッドをすべて追加するには以下も追加します。

gem 'stackprof', group: :development

ライブラリのインストールにはRuby 2.1以降を使わなければなりません。これより古いバージョンのRubyをお使いの方は何を待っているんでしょうね?

使い方

アプリのベンチマークには2とおりの方法があります。derailed_benchmarksでは、Webアプリを起動してベンチマーク中にリクエストを実行することも、Gemfileにある依存関係の静的な情報を取得することもできます。精度についてはアプリを起動する方法の方が常に高くなりますが、productionローカルでアプリを実行できない事情がある場合は静的情報も役に立ちます。

静的なベンチマーク

このセクションでは、アプリを起動する必要なしにGemfileからメモリ情報を取得する方法について説明します。

このセクションのすべてのコマンドは$ derailed bundle:で始まります。

メモリとパフォーマンスの関係については、How Ruby Uses Memoryをご覧ください。

require時に消費するメモリ

プロジェクトにgemを追加するたびに、起動時のメモリ利用量が増加します。gemごとのメモリ使用量を表示するには、以下を実行します。

$ bundle exec derailed bundle:mem

Gemfileにあるgemが読み込まれ、requireされたときに消費するメモリが表示されます。たとえば、mail gemを使っている場合の出力は次のようになります。

$ bundle exec derailed bundle:mem
TOP: 54.1836 MiB
  mail: 18.9688 MiB
    mime/types: 17.4453 MiB
    mail/field: 0.4023 MiB
    mail/message: 0.3906 MiB
  action_view/view_paths: 0.4453 MiB
    action_view/base: 0.4336 MiB

補足: MiBは、IEEEIECでメガバイトを表すシンボルであり、220 バイト / 1024キビバイト(結果は1024バイト)になります。

上の結果からmailが18MiBを消費していることがわかりますが、その大半はmime/typesで占められています。この情報を用いて、不要な依存関係を取り除けます。また、不要なgemが大量のメモリを消費していることに気がついたら、issueをオープンしてgemの作者に知らせてください(再現手順も添えること)。うまくいけばコミュニティがメモリのホットスポットを特定して影響を軽減できるでしょう。パフォーマンスの問題を修正する前に、問題がどこにあるのかを知っておく必要があります。

デフォルトでは、:defaultグループと"production"グループの結果だけを表示します。他のグループの結果を表示するには以下のように実行します。

$ bundle exec derailed bundle:mem development

CUT_OFF=0.3のように指定することで、これよりメモリ使用量の大きいファイルだけを表示できます。余分な情報を抑制したい場合に便利です。

メモ: この方法では、Gemfileにある項目だけが表示されます。アプリそのものに含まれているファイルは含まれません。アプリそのものに含まれるファイルのメモリ使用量を表示するには、bundle exec derailed exec memを使う必要があります。詳しくは後述します。

複数のライブラリで同じファイルがrequireされていることがありますが、Railsではファイルのrequireは一度しか行わないため、そのファイルのコストは、あるファイルを最初にrequireするライブラリにのみ関連します。この点をわかりやすく表示するため、そのファイルが属しているすべての親で重複エントリを表示します。たとえば、mailfogではどちらもmime/typesrequireしているので、アプリでは次のように表示されます。

$ bundle exec derailed bundle:mem
TOP: 54.1836 MiB
  mail: 18.9688 MiB
    mime/types: 17.4453 MiB (Also required by: fog/storage)
    mail/field: 0.4023 MiB
    mail/message: 0.3906 MiB

トップレベルのライブラリ(ここではmail)を削除してもメモリ使用量が減っていないことがわかりました。出力では最初の2つのエントリ以後は省略されます。

fog/core: 0.9844 MiB (Also required by: fog/xml, fog/json, and 48 others)
fog/rackspace: 0.957 MiB
fog/joyent: 0.7227 MiB
  fog/joyent/compute: 0.7227 MiB

fog/corerequireするファイルをすべて表示したい場合は、CUT_OFF=0 bundle exec derailed bundle:memを実行してすべてを出力してから手動でgrepすることもできます。

更新情報: 上の例のmime/typesは残念な結果が出ていますが、現在は修正されています。Gemfileの冒頭に以下を追加することでメモリを削減できます。

gem 'mime-types', [ '~> 2.6', '>= 2.6.1' ], require: 'mime/types/columnar'

require時に作成されるオブジェクト

memory_profilerを使うと、以下を実行することで依存関係がrequireされたときに作成されるオブジェクトの詳しい情報を取得できます。

$ bundle exec derailed bundle:objects

これによって、依存関係の読み込み時に作成されるオブジェクトの詳しい情報が出力されます。

Measuring objects created by gems in groups [:default, "production"]
Total allocated 433895
Total retained 100556

allocated memory by gem
-----------------------------------
  24369241  activesupport-4.2.1
  15560550  mime-types-2.4.3
   8103432  json-1.8.2

メモリ使用量が甚だしいgemを$ bundle exec derailed bundle:mem で特定できたら、そのgemを別のGemfileに追加して$ bundle exec derailed bundle:objectsを実行することで詳細な情報を取得できます。この情報は、コントリビュータやライブラリ作者がオブジェクト作成のホットスポットを特定して排除するのに役立ちます。

デフォルトでは、このタスクは:defaultグループとproductionグループの結果のみを返します。他のグループでの結果が欲しい場合は以下のように実行します。

$ bundle exec derailed bundle:objects development

メモ: この方法では、Gemfileにある項目だけが表示されます。アプリそのものに含まれているファイルは含まれません。アプリそのものに含まれるファイルのメモリ使用量を表示するには、bundle exec derailed exec memを使う必要があります。詳しくは後述します。

アプリの動的ベンチマーク

このベンチマークでは、Railsアプリを起動してそれに対するベンチマークを試みます。$ bundle exec derailed bundle:*による静的なベンチマークと異なり、この方法では特定のアプリの情報も取れます。この方法のメリットは、詳細な情報を取得することでアプリコードに潜む問題を特定できることで、デメリットはアプリをローカルでproduction環境で実行可能にしなければならない点です。アプリによってはローカルでのproduction環境で簡単に実行できないことがあります。

場合によってはmini-profilerを検討してもよいでしょう。mini-profilerの紹介記事については『mini-profiler walkthrough』をご覧ください。これもよいgemであり、derailed-benchmarkで行えるベンチマークとは少々趣が異なります。

production環境をローカルで実行する

動的なベンチマークを試みる前に、アプリをproductionモードで起動できるようにしておく必要があります。productionモードでベンチマークを行う理由は、デプロイ後のパフォーマンスに近づけるためです。本セクションでは、Railsのデファクトのチュートリアルとは異なるヒントをまとめました。

まずはコンソールで実行できるかどうかを試します。

$ RAILS_ENV=production rails console

おそらく、productionのデータベースに接続できないというエラーが表示されるでしょう。この場合、productionデータベースと同じ名前でローカルにデータベースを作成するか、database.ymldevelopmentグループの情報をproductionグループにコピーするかします。

SECRET_KEY_BASEなどのproduction用環境変数がない可能性もあります。この場合、.envファイルをコミットするか(.envを使っている場合)、以下のように環境変数をコマンドで直接追加します。

$ SECRET_KEY_BASE=foo RAILS_ENV=production rails console

production環境をコンソール起動することに成功したら、今度はサーバーをproductionで起動できるようにする必要があります。

$ RAILS_ENV=production rails server

このとき、SSLの強制やドメインのその他の制限を一時的に無効にする必要があるかもしれません。こうした変更を行ったら、デプロイ前にすべて元に戻すことをお忘れなく(うひゃー!)。

rails_12factor gemを使っている場合はSTDOUTから、あるいは以下を実行してlog/production.logから情報を取得できます。

$ tail -f log/production.log

エラーをすべて修正してサーバーをproductionモードで実行できるようになったら、準備はほぼ完了です。

derailed execを実行する

アプリに対して$ derailed execを実行できるようになりました。この後のいくつかのセクションでは、Rackの設定方法や認証済みリクエストを使う方法についてご紹介します。以下を実行することで利用可能なコマンド一覧を表示できます。

$ bundle exec derailed exec --help
  $ derailed exec perf:allocated_objects  # outputs allocated object diff after app is called TEST_COUNT times
  $ derailed exec perf:gc  # outputs GC::Profiler.report data while app is called TEST_COUNT times
  $ derailed exec perf:ips  # iterations per second
  $ derailed exec perf:mem  # show memory usage caused by invoking require per gem
  $ derailed exec perf:objects  # profiles ruby allocation
  $ derailed exec perf:mem_over_time  # outputs memory usage over time
  $ derailed exec perf:test  # hits the url TEST_COUNT times

ここでは個別のコマンドについては解説せず、よくある問題とそれを診断するのに最適なコマンドを見ていくことにします。derailed_benchmarksの設定で使えるすべての環境変数については、この後別途セクションを設けて解説します。

アプリのメモリリークを調べる

アプリのメモリ総量が増加し続けていてメモリリークが疑われる場合、それが本当にどこにもバインドされない「リーク」なのか、単にメモリ使用量が予想より大きいだけなのかを最初に検証したいでしょう。真のメモリリークではメモリ使用量が際限なく増加し続けますが、多くのアプリのメモリ使用量増加はある時点で「頭打ち」になります。これを診断するには以下を実行します。

$ bundle exec derailed exec perf:mem_over_time

このコマンドは、アプリを起動してリクエストを送信し、メモリをSTDOUT(および./tmp以下のファイル)に出力します。出力は次のような感じになります。

$ bundle exec derailed exec perf:mem_over_time
Booting: production
Endpoint: "/"
PID: 78675
103.55078125
178.45703125
179.140625
180.3671875
182.1875
182.55859375
# ...
183.65234375
183.26171875
183.62109375

この結果を見ると、メモリ使用量は増加し続けているものの、183 MiBあたりで横ばいになっています。以下のようにTEST_COUNT=の値を順次増やしながらこのタスクを実行することもできます。

$ TEST_COUNT=5000 bundle exec derailed exec perf:mem_over_time
$ TEST_COUNT=10_000 bundle exec derailed exec perf:mem_over_time
$ TEST_COUNT=20_000 bundle exec derailed exec perf:mem_over_time

適切な総時間が得られるようにTEST_COUNT=の値を適宜調整します。メモリ増加が天井知らずになっていれば、まさしくメモリリークです。これらの出力結果をファイルからGoogle Documentsにコピーしてグラフ化すれば、メモリ使用量が上昇する様子をよりよく把握できます。

生成した結果をtmpファイルに出力したくない場合は、SKIP_FILE_WRITE=1を指定して実行します。

メモリリークが発生していることがある程度確信できるものの、この方法で確認できなかった場合は、後述の環境変数オプションのセクションを参照して、リクエスト送信先のエンドポイントをさまざまに変えながら試してみるとよいでしょう。

メモリリークを解剖する

メモリリークを突き止めることができた場合や、メモリがどこで使われているかを単に知りたい場合は、以下を実行できます。

$ bundle exec derailed exec perf:objects

上のタスクは、アプリにリクエストを送信し、memory_profileを用いてオブジェクトが作成されている箇所を表示します。このコマンドを1度実行した後、TEST_COUNTの値を増やして再実行すれば、すべての要求ごとにオブジェクトが作成されるホットスポットを最初の実行結果と比較しながら見つけることもできます。

$ TEST_COUNT=10 bundle exec derailed exec perf:objects

この操作はコストが高いので、TEST_COUNTの値をなるべく低くしておきたいと思うことでしょう。ホットスポットを突き止められたら、「how ruby uses memory」を読んでオブジェクトのアロケーションを減らす方法をお読みください。

このコマンドは$ bundle exec derailed bundle:objectsと似ていますが、実行時に作成されたオブジェクトも出力される点が異なります。そのため、実際のproductionのパフォーマンスをデバッグする場合に非常に有用です。$ bundle exec derailed bundle:objectsの方はライブラリ作者がデバッグするのに向いています。

ヒープをダンプする

ランタイム時のメモリ使用量の問題がまだ解決できない場合は、ヒープダンプを生成してからheap_inspect(heapy)で分析することもできます。

$ bundle exec derailed exec perf:heap
Booting: production
Heap file generated: "tmp/2015-10-01T12:31:03-05:00-heap.dump"

Analyzing Heap
==============
Generation:  0 object count: 209307
Generation: 35 object count: 31236
Generation: 36 object count: 36705
Generation: 37 object count: 1301
Generation: 38 object count: 8

Try uploading "tmp/2015-10-01T12:31:03-05:00-heap.dump" to http://tenderlove.github.io/heap-analyzer/

ヒープダンプからデータを取得する方法の詳細については、以下を実行します。

$ heapy --help

起動時のメモリ使用量が大きい場合

Rubyのメモリ使用量は、増加の一途をたどるのが普通です。アプリ起動時のメモリ使用量が大きい場合、増加することはあっても減少することはないでしょう。$ derailed bundle:memで取得した依存関係によって使われるメモリ使用量をデバッグするほかに、自分のファイルがメモリ使用量をどれだけ増やしているかを知りたいこともあるでしょう。

ここでご紹介するタスクも本質的には同じですが、アプリにリクエストを1件だけ送信して、requireが直前の1分間にすべて呼び出されるようにします。これを行うには、以下を実行します。

$ bundle exec derailed exec perf:mem

TOP: 54.1836 MiB
  mail: 18.9688 MiB
    mime/types: 17.4453 MiB
    mail/field: 0.4023 MiB
    mail/message: 0.3906 MiB
  action_view/view_paths: 0.4453 MiB
    action_view/base: 0.4336 MiB

表示するメモリ使用量を足切りしたい場合は、CUT_OFF=0.3のように指定することで、これよりメモリ使用量の大きいファイルだけを表示できます。余分な情報を抑制したい場合に便利です。

アプリ起動時のコード量が極端に多い場合は、低レベルのオブジェクト作成を$ derailed exec perf:objectsでデバッグすることをご検討ください。

アプリが遅い

他にも何か起きているかもしれません。既にオブジェクトのアロケーション削減を詳細に検討したのであれば、アプリのどのコードの実行量が最も多いかを知りたいと思うことでしょう。それがわかれば、どの最適化に時間をかければよいかもわかります。

ひとつの方法は、stack profilerの「サンプリング」を使うことです。このプロファイリングは、指定の期間内にどのメソッドが実行されているかを調べて記録します。実行が終わったら、それらのメソッドが呼び出された回数をすべてカウントして、実行時間に占める割合をメソッドごとにパーセントで表示します。この手法では、実行時間の調査におけるオーバーヘッドはほとんどありません。Ruby 2.1以上であれば、stackprofというgemを使って行えます。ご想像のとおり、このgemはderailed_benchmarksでも実行できます。Gemfileにgem "stackprof", group: :developmentを追加して以下を実行します。

$ bundle exec derailed exec perf:stackprof
==================================
  Mode: cpu(1000)
  Samples: 16067 (1.07% miss rate)
  GC: 2651 (16.50%)
==================================
     TOTAL    (pct)     SAMPLES    (pct)     FRAME
      1293   (8.0%)        1293   (8.0%)     block in ActionDispatch::Journey::Formatter#missing_keys
       872   (5.4%)         872   (5.4%)     block in ActiveSupport::Inflector#apply_inflections
       935   (5.8%)         802   (5.0%)     ActiveSupport::SafeBuffer#safe_concat
       688   (4.3%)         688   (4.3%)     Temple::Utils#escape_html
       578   (3.6%)         578   (3.6%)     ActiveRecord::Attribute#initialize
      3541  (22.0%)         401   (2.5%)     ActionDispatch::Routing::RouteSet#url_for
       346   (2.2%)         346   (2.2%)     ActiveSupport::SafeBuffer#initialize
       298   (1.9%)         298   (1.9%)     ThreadSafe::NonConcurrentCacheBackend#[]
       227   (1.4%)         227   (1.4%)     block in ActiveRecord::ConnectionAdapters::PostgreSQLAdapter#exec_no_cache
       218   (1.4%)         218   (1.4%)     NewRelic::Agent::Instrumentation::Event#initialize
      1102   (6.9%)         213   (1.3%)     ActiveSupport::Inflector#apply_inflections
       193   (1.2%)         193   (1.2%)     ActionDispatch::Routing::RouteSet::NamedRouteCollection::UrlHelper#deprecate_string_options
       173   (1.1%)         173   (1.1%)     ActiveSupport::SafeBuffer#html_safe?
       308   (1.9%)         171   (1.1%)     NewRelic::Agent::Instrumentation::ActionViewSubscriber::RenderEvent#metric_name
       159   (1.0%)         159   (1.0%)     block in ActiveRecord::Result#hash_rows
       358   (2.2%)         153   (1.0%)     ActionDispatch::Routing::RouteSet::Generator#initialize
       153   (1.0%)         153   (1.0%)     ActiveRecord::Type::String#cast_value
       192   (1.2%)         143   (0.9%)     ActionController::UrlFor#url_options
       808   (5.0%)         127   (0.8%)     ActiveRecord::LazyAttributeHash#[]
       121   (0.8%)         121   (0.8%)     PG::Result#values
       120   (0.7%)         120   (0.7%)     ActionDispatch::Journey::Router::Utils::UriEncoder#escape
      2478  (15.4%)         117   (0.7%)     ActionDispatch::Journey::Formatter#generate
       115   (0.7%)         115   (0.7%)     NewRelic::Agent::Instrumentation::EventedSubscriber#event_stack
       114   (0.7%)         114   (0.7%)     ActiveRecord::Core#init_internals
       263   (1.6%)         110   (0.7%)     ActiveRecord::Type::Value#type_cast
      8520  (53.0%)         102   (0.6%)     ActionView::CompiledTemplates#_app_views_repos__repo_html_slim__2939326833298152184_70365772737940

後は個別のメソッドを調べます。

変更の結果パフォーマンスが向上するかをチェックする

コードレベルでのパフォーマンス改善の度合いはマイクロベンチマークである程度見当がつきますが、アプリ全体のスピード向上を知るにはどうしたらよいでしょうか。アプリのパフォーマンス上の変更がどのぐらい有効かを知りたい場合、既存アプリのパフォーマンスと比較できると便利です。そんなときは以下のコマンドが役立ちます。

$ bundle exec derailed exec perf:ips
Endpoint: "/"
Calculating -------------------------------------
                 ips     1.000  i/100ms
-------------------------------------------------
                 ips      3.306  (± 0.0%) i/s -     17.000

このコマンドは、benchmark-ips gemを用いてアプリのエンドポイントにリクエストを送信します。1秒あたりの繰り返し(ips: iterations per second)では、値が大きいほどパフォーマンスが高いことを常に意味します。変更後のコードをこの方法を用いて何度も実行し、続いてトータルのパフォーマンス向上を「ベースライン」コードベース(変更なしの場合)を実行します。実行を何度も繰り返して結果を(標準偏差も含めて)記録すればノイズの抑制に役立ちます。ベンチマークは大変な作業であり、この手法も完璧ではありませんが、何もしないよりはずっとよい結果を得られるのは間違いありません。

気になるのであれば、以下のようにipsなしの純粋なベンチマークを実行することもできます。

$ bundle exec derailed exec perf:test

ただし私ならこの方法は使わないでしょう。ipsありのベンチマークの方が測定方法として良好だからです。

環境変数

どのタスクについても、以下の環境変数を使って設定を与えることができます。

TEST_COUNT: テスト回数の増減

実行回数の多いタスクで、次のようにTEST_COUNTを使って実行回数を指定できます。

$ TEST_COUNT=100_000 bundle exec derailed exec perf:test

WARM_COUNT: 測定前にアプリをウォーミングアップする回数

アプリを長時間に渡って測定する場合、特にJITを使っている場合は、測定時間とは別の「ウォーミングアップ」期間をアプリに与えるものです。WARM_COUNTを用いて、測定開始前のアプリ呼び出し回数を指定できます。

$ WARM_COUNT=5_000 bundle exec derailed exec perf:test
Warming up app: 5000 times
# ...

PATH_TO_HIT: リクエスト送信先のエンドポイントの変更

デフォルトのタスクでは、ルート/に対してリクエストを送信します。別のURLにリクエストを送信したい場合は、PATH_TO_HITを使います。たとえば、users/newを叩くには以下を実行します。

$ PATH_TO_HIT=/users/new bundle exec derailed exec perf:mem

この方法では完全なURLも指定できます。たとえば以下はサブドメインのエンドポイントを叩きます。

$ PATH_TO_HIT=http://subdomain.lvh.me:3000/users/new bundle exec derailed exec perf:mem

注意: 完全なURLは、USE_SERVER変数と併用できません。

HTTPヘッダの設定

次のようにHTTP_<ヘッダ名>環境変数を設定することで、HTTPヘッダを指定できます。

$ HTTP_AUTHORIZATION="Basic YWRtaW46c2VjcmV0\n" \
  HTTP_USER_AGENT="Mozilla/5.0" \
  PATH_TO_HIT=/foo_secret bundle exec derailed exec perf:ips

USE_SERVER: 実際のWebサーバーの指定

すべてのテストは「Webサーバーなし」で実行されます(デフォルトではRack::Mockを直接使います)。Webサーバーを使いたい場合は、USE_SERVERにRack::Server互換サーバー(webrickなど)を設定します。

$ USE_SERVER=webrick bundle exec derailed exec perf:mem

pumaも指定できます。

$ USE_SERVER=puma bundle exec derailed exec perf:mem

この指定によってWebサーバーが起動し、メモリ内ではなくcurlを使ってリクエストを送信します。これは、パフォーマンスの問題がWebサーバーに関連すると考えられる場合に便利です。

メモ: この方法では指定のWebサーバーをRackに直接接続するため、設定済みのpuma.configファイルなどは使われません。設定ファイルを使いたい方からのアイデアやプルリクをお待ちしています。

ActiveRecordを除外する

derailed_benchmarksは、依存関係としてActiveRecord gemが含まれている場合はデフォルトで読み込みます。ActiveRecordはRailsのデフォルトgemなので、rails gemを使えば読み込まれます。別のORMを使っている場合は、railtiesだけを読み込むようにするか、DERAILED_SKIP_ACTIVE_RECORDフラグを設定します。

$ DERAILED_SKIP_ACTIVE_RECORD=true

別の環境で実行する

デフォルトではproduction環境でテストを実行しますが、ローカルのRAILS_ENVproductionに設定された環境でアプリが動かない場合は次のように簡単に変更できます。

$ RAILS_ENV=development bundle exec derailed exec perf:mem

perf.rake

derailed_benchmarksをカスタマイズしたい場合は、ベンチマークを行うプロジェクトのルートディレクトリにperf.rakeファイルを作成する必要があります。

rakeを直接使ってベンチマークを実行できます。

$ cat <<  EOF > perf.rake
  require 'bundler'
  Bundler.setup

  require 'derailed_benchmarks'
  require 'derailed_benchmarks/tasks'
EOF

perf.rakeファイルの内容は次のような感じになります。

$ cat perf.rake
  require 'bundler'
  Bundler.setup

  require 'derailed_benchmarks'
  require 'derailed_benchmarks/tasks'

上の設定を使うと、アプリより先にベンチマークが読み込まれます。これはベンチマークによっては重要なことがありますが、さほど重要でないこともあります。この設定では、ベンチマークが不要な場合に誤って読み込まれることも防げます。

続いて、rakeを使ってコマンドを実行できます。

$ rake -f perf.rake -Tを使って、実行可能なタスクの一覧を見つけることもできます。このコマンドは本質的に、perf.rakeファイルを用いてすべてのタスク一覧を表示しているだけです。

$ rake -f perf.rake -T

Rackのセットアップ

Railsをお使いであれば、特別な設定は不要です。Rackをお使いの場合は、アプリの起動方法の指定が必要です。以下のようにperf.rakeファイルにタスクを追加します。

namespace :perf do
  task :rack_load do
    DERAILED_APP = # ここにコードを書く
  end
end

DERAILED_APP定数には、Raskアプリの名前を設定します。詳しくはプルリク#1をご覧ください。

perf.rakeの設定例は次のようになります。

# perf.rake

require 'bundler'
Bundler.setup

require 'derailed_benchmarks'
require 'derailed_benchmarks/tasks'

namespace :perf do
  task :rack_load do
    require_relative 'lib/application'
    DERAILED_APP = MyApplication::Routes
  end
end

認証

認証が行われているエンドポイントをテストする場合は、認証のバイパス方法をタスクで指定する必要があります。認証はDerailedBenchmarks.authオブジェクトで管理されます。derailed_benchmarksにはDeviseのサポートが組み込まれています。他の認証方法を使っている場合は、独自の認証ストラテジーを記述することもできます。

テストで認証を有効にするには以下を実行します。

$ USE_AUTH=true bundle exec derailed exec perf:mem

認証のカスタマイズ方法については後述します。

Deviseでの認証

Deviseをお使いの場合は、Devise gemの存在を検出して自動で読み込まれる組み込みの認証ヘルパーを使えます。

プロジェクトのルートディレクトリにperf.rakeファイルを作成します。

$ cat perf.rake

ログインユーザーをカスタマイズするには、perf.rakeファイルで以下のように設定します。

DerailedBenchmarks.auth.user = -> { User.find_or_create!(twitter: "schneems") }

有効なユーザーを指定する必要があります。user.rbでのバリデーション方法によっては、パラメータを変える必要があるかもしれません。

Userモデルを使わない認証を行う場合は、独自の認証ストラテジーを書く必要があります。

独自の認証ストラテジー

独自の認証ストラテジーを実装するには、auth_helper.rbを継承するクラスの作成と、setupメソッドとcallメソッドの実装が必要です。Devise認証ヘルパーのサンプルコードについてはauth_helpers/devise.rbをご覧ください。以下のコードをperf.rakeファイルに書けます。

class MyCustomAuth < DerailedBenchmarks::AuthHelper
  def setup
    # 初期化コードをここに書く
  end

  def call(env)
    # リクエストごとのログを何か出力する
    app.call(env)
  end
end

Deviseのストラテジーは、Rackリクエストの内部でテストモードを有効にしてスタブユーザーを挿入することで動作するようになります。Deviseを使っていない場合は、この部分のロジックを複製して独自の認証スキームを作成する必要があります。

クラスを作成したら、クラスの新しいインスタンスにDerailedBenchmarks.authを設定する必要があります。perf.rakeファイルに以下を追加します。

DerailedBenchmarks.auth = MyCustomAuth.new

これにより、USE_AUTH環境変数を使って設定されたすべてのリクエストで、MyCustomAuth#callメソッドが呼び出されるようになります。

ライセンス

MIT

献辞

コマンドの多くは他のライブラリのラッパーですので、チェックしてみてください。$ rake perf:setupのRails初期化コードの一部については、@tenderloveのとあるプロジェクトのコードを使いましたので、ここにお礼を申し上げます。

アリガトソレジャマタ @schneems

関連記事

ベンチマークの詳しい理解と修正のコツ(翻訳)

Rails: RedisキャッシュとRackミドルウェアでパフォーマンスを改善(翻訳)

Rails: メモリ使用量を制限してHerokuのR14エラー修正&費用を節約した話(翻訳)

Rubyのぼっち演算子はRailsの`Object#try`より高速(翻訳)

$
0
0

概要

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

Rubyのぼっち演算子はRailsのObject#tryより高速(翻訳)

&.というsafe navigation演算子(Null条件演算子: Rubyでは「ぼっち演算子」とも呼ばれます)はRuby 2.3で導入されました。この演算子は、nilオブジェクトに対してメソッドが呼び出されたときにnilを返します。この演算子がなかったときは、nilオブジェクトに対して呼び出しを行うと以下のようにエラーになりました。

nil.some_method
#=> NoMethodError: undefined method 'some_method' for nil:NilClass

safe navigationが必要な理由

入力値が正しくない場合にオブジェクトを取得できないことは非常によくあります。このような場合、オブジェクトが取れることを期待してメソッド呼び出しを続行すると、オブジェクトがnilの場合に動かなくなるかもしれません。

safe navigationはこうした状況を避けるために導入されました。これを用いて、呼び出すオブジェクトがnilであってもコードが動き続けるようにできます。safe navigationは、メソッド呼び出しが失敗したときにnilオブジェクトを受け取ってもよい場合に使うべきです。

ActiveSupportのtry

RailsではActiveSupport#tryメソッドが利用でき、上述のsafe navigationと似ています。メソッド名からわかるように、#tryはそのオブジェクトが利用可能な場合にメソッド呼び出しを試みます。nilオブジェクトに対してメソッド呼び出しが行われようとすると、エラーではなくnilを返します。

nil.try(:some_method)
#=> nil

Ruby 2.3のsafe navigation

オブジェクトがnilかどうかわからない場合は、たとえば以下のようにメソッド呼び出しの前にアンパサンド&を追加するだけで使えます。

nil&.some_method
#=> nil

上のコードは例外をスローせずにnilを返します。
このコードは次のコードと同等です。

nil ? nil.some_method : nil

つまりオブジェクトがtrueと評価されればオブジェクトに対してメソッドを呼び出し、falseと評価されればnilを返します。

#tryとsafe navigationのどっちを使うか

Railsで仕事をすることが多い人は#tryを常用していることでしょう。#tryとsafe navigation演算子&.パフォーマンスを比較してみると、Rubyでサポートされているsafe navigation演算子の方が遥かに高速で、しかもActiveSupportなどRailsの機能に依存しません。

関連記事

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

Ruby: ぼっち演算子`&.`の落とし穴(翻訳)

週刊Railsウォッチ(20180330)春のリリースラッシュ: Rails 5.1.6/5.0.7とRuby 2.5.1など、Ruby 2.2は3月でメンテ終了ほか

$
0
0

こんにちは、hachi8833です。Rails Developers Meetup 2018が無事終了しましたね。終わった後は久々のスタミナゼロ状態でした。関係者の皆さま、本当にありがとうございました。

年度末の忙しさの中、桜咲く3月最後のウォッチ、いってみましょう。
今回からh2タグとh3タグにアンカーを付けました。

⚓臨時ニュース

⚓Rails 5.1.6と5.0.7リリース

Rails 5.2.0より先に既存の更新がリリースされました。railtiesのChangelogを見た感じでは、後述のRuby 2.2.10リリースを受けて出されたという一面もあるようです。


rubyonrails.orgより

⚓Ruby 2.5.1リリース: バグ/セキュリティ修正中心(Ruby公式ニュースより)


ruby-lang.orgより

本体および標準ライブラリのバグやセキュリティ修正です。過去のバージョンにもバックポートされました。

記念写真というより証拠写真でしょうか。

中の人のブログ↓も公開されています。


つっつきボイス: 「webrickの脆弱性などなど」「webrickを本番で使う人はまずいないでしょうねー」「確かに」

いるのかな?

参考: ruby/webrick

#unpackって例のC言語のprintfみたいないやらしーやつでしたっけ」「16進まみれのw」「pack/unpackこの間しぶしぶ使ったけど、@指定できるとか知らんわー」

参考: Rubyリファレンスマニュアル String#unpack

⚓Ruby 2.2系は3月いっぱいでメンテナンス期間終了

同じプレスリリースです。極めて重要なセキュリティ修正を除き、今後2.2は更新されなくなります。


つっつきボイス: 「Rubyは毎年リリースされているから終了も毎年あるわけですね: 3月はさよならのシーズンというか」「サポートなくても動くは動くけどなっ」「2.2以下のRuby、あのアプリとかこのアプリとかあったかなー」「それより先にRails 5にアップデートせいという感じか」「Rubyをアップグレードする方が楽かな」

⚓Rails: 今週の改修

今回の公式情報はまとめ的な感じです。masterなので基本的にRails 6向けですね。
公式で紹介されている今回のコミットログはいつになくあっさりしているので、気軽に読めますね。

⚓Active StorageでAWS S3 SDK認証オプションを完全サポート

# activestorage/lib/active_storage/service/s3_service.rb#L9
   class Service::S3Service < Service
     attr_reader :client, :bucket, :upload_options

-    def initialize(access_key_id:, secret_access_key:, region:, bucket:, upload: {}, **options)
-      @client = Aws::S3::Resource.new(access_key_id: access_key_id, secret_access_key: secret_access_key, region: region, **options)
+    def initialize(bucket:, upload: {}, **options)
+      @client = Aws::S3::Resource.new(**options)
       @bucket = @client.bucket(bucket)

       @upload_options = upload

つっつきボイス:**optionsで素直にまるっと渡せるようになったってことか」「渡しても全然大丈夫だったと」「パラメータで{}**が隣り合っていると一瞬ドキっとしちゃいます: どっちに落っこちるんだみたいな」「そんなんばっかしですよもうw」

⚓MySQL2のサポート

# activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb#L6
-gem "mysql2", "~> 0.4.4"
+gem "mysql2", ">= 0.4.4", "< 0.6.0"

バージョン書き換えただけです。

⚓i18nの@virtual_path置換え結果をメモ化

# actionview/lib/action_view/helpers/translation_helper.rb#L124
       private
         def scope_key_by_partial(key)
-          if key.to_s.first == "."
+          stringified_key = key.to_s
+          if stringified_key.first == "."
             if @virtual_path
-              @virtual_path.gsub(%r{/_?}, ".") + key.to_s
+              @_scope_key_by_partial_cache ||= {}
+              @_scope_key_by_partial_cache[@virtual_path] ||= @virtual_path.gsub(%r{/_?}, ".")
+              "#{@_scope_key_by_partial_cache[@virtual_path]}#{stringified_key}"
             else
               raise "Cannot use t(#{key.inspect}) shortcut because path is not available"
             end

つっつきボイス:@virtual_pathとな?やってることはメモ化だけど」「あ、#tのあたりか!: どこでどうなってるのかようわからんやつ」

参考: ActionView::Helpers::TranslationHelper#translate#tはこれのエイリアスです

「ついでに質問しちゃいますけど、ActionViewにあるRails備え付けのビューヘルパーと、自分で書いたビューヘルパーって、どこかで一箇所に集まっちゃうというかまとめてグローバルになっちゃうんですよね?」「そうですね、同じところというかview_contextっていうのがあって、レンダリングではそいつが大もとみたいな感じになってるんですよ」「あー」「そしてそいつにヘルパーどもがインクルードされまくってるんですよ」「なるほどねー!」「自分でビューヘルパーひとつも書いてなくても、既にヘルパーが大家族状態ってことか」

参考: view_context

「ActionViewヘルパーの他にもActionController::HelpersやらAbstractController::Helpersやらもあって、そういうのが大挙してview_contextに押し寄せてる感じなんですよ(爆)」「やべー」「やべー」「インスタンス変数とかもこいつにどんどんアサインされていって居座るし」「おとろしい…」

「Railsでコントローラに書いたインスタンス変数をビューで参照できるなんて、裏でこっそり何かやってないとできるはずがないですからね」「そうそう、その設計思想がどうもなー: ビューに渡したいものがあったら自分で書きたいわ」「ビューに渡したくないインスタンス変数だってあるのに、全部渡されちゃう」「でインスタンス変数を増やすとだんだんわけわからなくなってくると」

⚓追伸

morimorihogeさんから補足情報をいただきました。

⚓ActionDispatch::StaticのパスをUS-ASCIIからASCII-8BITに変更

さようなら.htmlのような日本語ファイル名も使えるようにということのようですが、極力したくないかも。

# actionpack/lib/action_dispatch/middleware/static.rb#L37
       if match = paths.detect { |p|
-        path = File.join(@root, p.dup.force_encoding(Encoding::UTF_8))
+        path = File.join(@root, p.b)
         begin
           File.file?(path) && File.readable?(path)
         rescue SystemCallError
           false
         end

       }
-        return ::Rack::Utils.escape_path(match)
+        return ::Rack::Utils.escape_path(match).b
       end
     end

つっつきボイス: 「static/とかのhtmlファイルなんかが対象ってことですかね」「fixtureにあるactionpack/test/fixtures/公共/foo/さようなら.htmlの「公共」ってsharedのことなんでしょうけど、日本人のセンスではなさそう」

⚓Active JobでQu gemのサポートを廃止

4年も更新のないQuがRails 5.1と互換性がなくなったためで、互換性が戻ることがあればまた追加するようです。

# activejob/test/integration/queuing_test.rb#L84
   test "should supply a provider_job_id when available for immediate jobs" do
-    skip unless adapter_is?(:async, :delayed_job, :sidekiq, :qu, :que, :queue_classic)
+    skip unless adapter_is?(:async, :delayed_job, :sidekiq, :que, :queue_classic)
     test_job = TestJob.perform_later @id
     assert test_job.provider_job_id, "Provider job id should be set by provider"
   end

参考: bkeepers/qu

⚓Ruby 2.6ではURI#unescapeにモンキーパッチを当てない

これは自分で見繕いました。

# activesupport/lib/active_support/core_ext/uri.rb#L3
 require "uri"
 str = "\xE6\x97\xA5"
-parser = URI::Parser.new

-needs_monkeypatch =
-  begin
-    str + str != parser.unescape(str + parser.escape(str).force_encoding(Encoding::UTF_8))
-  rescue Encoding::CompatibilityError
-    true
-  end
-
-if needs_monkeypatch
+if RUBY_VERSION < "2.6.0"
   require "active_support/core_ext/module/redefine_method"
   URI::Parser.class_eval do
     silence_redefinition_of_method :unescape

つっつきボイス: 「Rubyでunescapeをちゃんとできるようになったから、バージョンを見てパッチをやめるってことですね」

⚓Rails

⚓ダウンタイムなしでスキーマ変更するには(Ruby Weeklyより)


つっつきボイス: 「後半ぐらいから『こうだったらいいのに』みたいな雰囲気ですね」「マイグレーション内のafter_deployってRailsのメソッドか?と思ったら見当たらないから手作りというか、こんなのが欲しいってことなんでしょうね」「何をもってしてafter_deployを判断するんだろか?」「マイグレーションにそういうフックがあったとしても怖くて使えないw」

「コメント欄の方も気になっちゃって: こんな条件付きマイグレーション↓ができたらすごくうれしいんだけど方法が見当たらない…と」「それたぶんコントロールしきれないと思うヨ: おとなしく移行のバッチなりrakeタスクなりでやった方がええんではないかしら」「マイグレーション、ただでさえ複雑なのに」

# コメント欄より
class RemoveColumn < ActiveRecord::Migration[7.0]
  on_next_deploy_after RenameColumn
  # ↑こういうのとか↓こういうのとか
  when RenameColumn, deployed_for: 1.week

  def up
     remove_column :posts, :name
  end
end

⚓RailsとTDDとDHHと


つっつきボイス: 「ツイートでリンクされていた永和システムマネジメントさんのブログ記事がとても面白かったので: かの有名なt_wadaさんとの対談シリーズですね」


twop.agile.esm.co.jpより

「『テスト駆動開発自体が自己目的化してしまった』とか」「手段と目的が入れ違ってというか」「何がしたかったのか思い出せないというか」「このあたりとか考えさせられます↓」

だけど、先鋭化したテスト駆動開発や、例えばクリーンアーキテクチャーのような依存を逆転させて清潔にしたレイヤリングとRuby on Railsの強みは明らかに対立関係にあったんですね。
同対談より

「クリーンアーキテクチャってたしかこれ↓じゃ?」「あーそうだった!: morimorihogeさんが勉強会で見せてくれてた」


https://8thlight.com/blog/uncle-bob/2012/08/13/the-clean-architecture.htmlより

「こうしてみるとRailsは元々プロトタイプを素早く作る感じで、そもそもクリーンアーキテクチャとは目指すものが違うというか、だいたいコントローラにアクションが全部集まってるし」「この間のSOLID記事にも反してるし: まあ記事書いた人は反してないって言ってますけどw」「『ここは俺の家だ!』みたいな」

「でDHHが『TDDは死んだ』と宣言してセンセーションを巻き起こしたと」「たしかその当時、ケント・ベックマーティン・ファウラーとDHHの対談がストリーミングで流れてて、『どうしてこうなった』みたいな話をしてましたね」「お、探します」

↓こちらです。Part IVまであってめちゃ長いのでPart Iのみです。

「ところで教条主義って言葉は私の年だと微妙に懐かしいんですけど、これって昔の学生運動のときによく使われた言葉だったんですよね: 『この教条主義者ガー』みたいな感じで」

⚓書籍『Refactoring』(Martin Fowler)第二版がリリース

同書はJava向けですが、Ruby向けのRefactoring for Rubyも定番です。惜しくも日本語版は割とすぐに絶版かつ中古で高値が付いています


つっつきボイス: 「ちょうどそのMartin Fowlerさんの本ですね」「日本語の『リファクタリング:Rubyエディション』、中古で8000円かよ!」「これ、社内に1冊あったはず」「(社内の図書管理システムを参照して)お、あったあった」「これは借りなきゃ損損」「お、図書記録にhachi8833さんが『翻訳がとてもよい』って書いてるゾ」「(忘れてた…)」

⚓Railsで双方向関連付けをテストする(Hacklinesより)

# 同記事より
associations.each do |assoc|

  # stuff from previous example
  if too_hard
    next

  # self join (model has an association to the same model)
  elsif assoc.klass == model
    assert assoc.options[:inverse_of].present?, "Only half of a self-join association is defined in app/models/#{model.to_s.underscore}.rb. The inverse of :#{assoc.name} is not defined."
    assert model.first.respond_to? assoc.options[:inverse_of], "The inverse of :#{assoc.name} is not functioning correctly. Check app/models/#{model.to_s.underscore}.rb."

  # Explicitly defined inverse association. Must have an inverse defined in both models.
  elsif assoc.options[:inverse_of].present?
    assert associated_object = assoc.klass.to_s.singularize.camelize.constantize.first, "#{assoc.name.to_s.camelize} not present in fixtures. Check test/fixtures/#{assoc.name.to_s.pluralize.underscore}.yml.\nAssociation name: #{assoc.name}\nAssociated object: #{assoc.klass}"
    model_method = assoc.options[:inverse_of].to_s
    assert associated_object.respond_to?(model_method) || associated_object.respond_to?(model_method.pluralize), "#{associated_object.class.to_s.camelize} is not properly associated with #{model.to_s} via :#{model_method}. Check for the correct association in app/models/#{model.to_s.underscore}.rb."

  # Using an alias to reference another model. Should probably have inverse relationships explicitly defined.
  elsif assoc.name != assoc.klass
    assert associated_object = assoc.klass.to_s.singularize.camelize.constantize.first, "#{assoc.name.to_s.camelize} not present in fixtures. Check test/fixtures/#{assoc.name.to_s.pluralize.underscore}.yml"
    model_method = model.to_s.underscore
    assert associated_object.respond_to?(model_method) || associated_object.respond_to?(model_method.pluralize), "#{associated_object.class.to_s.camelize} is not properly associated with #{model.to_s} using :#{model_method} as the inverse of :#{assoc.name}. Check for the correct associations and :inverse_of attributes in app/models/#{assoc.klass.to_s.underscore}.rb and app/models/#{model.to_s.underscore}.rb."

  # Normal bidirectional association
  else
    # do the stuff from earlier
  end
end

つっつきボイス: 「な、何だこれはw」「こんなにしてまでアソシエーションが抜けてないかどうかをテストする意味ってあるんだろか?」「コード書き終わったらいらないですよね、確かに」「テスト残す価値ないわ」「文章まだ読んでませんが、もしかしてたとえば外注先のコード品質が信用できなくて、怒りに任せてこういうコードを書いてから発注したとかですかね?」「テストファーストの匂いがする」

「ApplicationRecordにだったらこれっぽいのを書きたいかなと思ったりしますね: cancancanにあるcheck_authorizationなんかそうですけど、これをApplicationRecordに書いておくと、継承先でauthorizeを呼んでないときにエラーになる」「おー!」

「つかアソシエーションがあるかどうかをrespond_to?でテストするのが一番楽じゃないっすか?」「確かにー」「Railsチュートリアルにも確かそんな感じで書いてありましたよ: respond_to? childrenみたいに」「そうじゃなくてもモデル名使えばいいのに」

[翻訳]RailsチュートリアルがRails 5.0に完全対応しました!(第4版)?

⚓コールバックとインスタンス変数

# 『3年以上かけて培ったRails開発のコツ集大成(翻訳)』2.より
class UsersController
  before_action :load_resource

  def index
    # @usersで何かする
  end

  def show
    # @userで何かする
  end

  def create
    # @userで何かする
  end

  def update
    # @userで何かする
  end

  def destroy
    # @userで何かする
  end

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

つっつきボイス: 「以前のTechRacho記事『3年以上かけて培ったRails開発のコツ集大成(翻訳)』でダントツで疑問が多く寄せられた『2. コントローラにload_resourceを書く』です」「うん、これキライ: indexしかないとかならまあわからんでもないけど、caseとかまで書いちゃうのはなぜ?みたいな」「厳密にスコープを分けたいとか常に何らかのスコープを通したいときとかなんですかね:気持ちはワカルけど」「全アクションに業務ロジックを書きたくないとか」「でもたとえばindexがいなくなったら台無し」「DRYに書こうとしすぎた感じですかね」

「怖いのは、新人くんとかにこれを盲目的にやられてしまうことかな」「あーたしかに」「何だかこう、コピペの匂いがするんですよね: これコピペしてやっとけって指導するみたいな」「これならコピペで動いちゃいますからね」
「このコードだとindexをpagenateしてるけど、後で特定のindexでpagenateを使わないように変えようとすると詰む」「CSV出力みたいに、indexをページネーションしないとかざらにありますしね」

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

⚓(社内雑談より)Railsの設計で崩れやすいのは例外処理とロギング

つっつきボイス: 「この間タバコ吸いながらそういう話題になったので」「経験上、例外処理もロギングもまったく念頭にないまま書かれた素人さんコードに、後からそれを足そうとして詰む、みたいなのがよくあるかな」「ロギングはともかく、例外の方はそもそも正常系がぜんぜんちゃんとしていなかったりすると例外の拾いようがなくなって作り直す方が早かったり」「エラーの扱いが全然一貫してなかったりとか」「true/falseで返すなよ、とか」

「ボクが例外処理が好きなのは、異常時にガード的にどんどん終わってくれるからコードがシンプルになるところ: if書かなくていいし」

Rubyスタイルガイドに『例外処理を制御フローに使わないこと』ってあったのを思い出しました」「そりゃやっちゃダメですよねw」「いわゆるgoto的に使うもんじゃない」

参考: Avdi Grimm氏の有名な「Exceptional Ruby」のスライドです。


Exceptional Rubyより

参考: Exceptional Ruby — 同氏の著作です。


exceptionalruby.comより

⚓その他記事など

⚓Ruby trunkより

⚓桁数が多いときにFloat#round(n)の結果がずれる(継続)

# 同issueより
3.0e-31           #=> 3.0e-31
3.0e-31.round(31) #=> 3.0000000000000003e-31

つっつきボイス:Rational#to_fで回避する、と」「Floatって忘れた頃につまずきますよね」

Rationalがデフォルトだったらいいのになんて思ってしまいました。

⚓コロンを変なところに置くと落ちる->(#14261と重複)

def a(x)
  case x
  when A
    :      # これ
  when B, C
  end
end

つっつきボイス: 「おー、これで落ちるのか」「『よくぞ見つけた』みたいなこと書いてる」「と思ったら#14261で修正されてた」「nobuさん、凄っ」

⚓(提案)ハッシュで特定のキーへの最短パスを取るメソッドが欲しい(継続)

# 同issueより
{
  :a => {
    :name => "abc"
  },
  :b => {
    :name => "xyz"
  },
}
deep_key(:name) => [:a]

つっつきボイス: 「これは欲しいときあるかも」「Rubyのハッシュキーの順序が保証されていなかったら大変そうですね」「『他の言語にあるからRubyでもやろうよ』というアピールは効きそう」「たしかPythonも最近順序が保証されるようになってた」

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

⚓Ruby

⚓k0kubunさんのbenchmark/driver

ベンチマークの別記事も見つけました。


つっつきボイス: 「k0kubunさん今回は英語ブログだけ書いてるみたいですね: 両方書くのは大変だからかな」「そういう境地になりたいw」「ちょうど昨日derailed_benchmarksのREADME翻訳を出したところでした↓」

Rails: 多機能ベンチマークgem「derailed_benchmarks」README(翻訳)

⚓あまり知られてないっぽいRubyのフック系メソッドたち



つっつきボイス:includeじゃなくてincludedみたいに過去分詞というか受動態というか」「たとえばincludedは、includeしたときに発動するコールバックみたいな感じですね」「この名前、コールバックっぽく見えないなぁ: on_includeとかなら…」「でも何度も見かけるうちにわかるようになっちゃいましたねw」「method_removedとかmethod_undefinedとか他にもまだまだあるんですね」

「そういえばincludeされるに呼ばれるフックとかもあった気がする: んーと、append_featuresだ↓」「こりゃすげえ」「こんなことまでできちゃうの?って正直びっくりでしたね」「呼び出しコスト大丈夫なのかしらん?」

参考: append_features

「フレームワーク的なものを作るときに欲しいやつで、業務で使ったらヤバイ」「includedは、言ってみればRailsのconcernsみたいなものだし、ありかな」「おー、concernsをそうやって説明してもらったら何だか腑に落ちた(気がする)」

参考: ActiveSupport::Concern

⚓Browser: Rubyでブラウザ情報を検出するライブラリ(Ruby Weeklyより)

# 同リポジトリより
...
browser.device
browser.device.id
browser.device.name
browser.device.blackberry_playbook?
browser.device.console?
browser.device.ipad?
browser.device.iphone?
browser.device.ipod_touch?
browser.device.kindle?
browser.device.kindle_fire?
browser.device.mobile?
browser.device.nintendo?
browser.device.playstation?
browser.device.ps3?
browser.device.ps4?
browser.device.psp?
browser.device.silk?
browser.device.surface?
browser.device.tablet?
browser.device.tv?
browser.device.vita?
browser.device.wii?
browser.device.wiiu?
browser.device.xbox?
browser.device.xbox_360?
browser.device.xbox_one?
...

つっつきボイス: 「Wii UとかXboxまであるじゃないですか」「何だかスゴイ」
「遠い昔に、当時の同僚であらゆるブラウザのUAヘッダをコレクションしてた人がいて、新しい携帯を買うたびにヘッダ取らせろってせがまれたのを思い出した」「今だともうコンプリート不可能ですね」

⚓Module.class_execLiquidを動かす(RubyFlowより)

Object.instance_execModule.class_execは似ているけどちょっと違うみたいです。翻訳リクエストしてみます。

⚓GnuPGをRubyで実装した話(RubyFlowより)

⚓multi.rbって使われてる?

# 元記事より
require 'rubygems'
require 'multi'
multi(:fact, 0) { 1 }
multi(:fact, Integer) { |n| n * fact(n-1) }

for i in (0..5)
  puts "fact(#{i}) ==> #{fact(i)}"
end

Matzのプログラミングのおきてをネットでけちけち読んでいて「マルチメソッド」という見慣れない言葉を見つけたので探してみました。


つっつきボイス: 「マルチメソッドって有名なんですか?」「知らないー」「(コードを見て)ほほう、なるほどー❤」「おーなるほどー❤」「すみません、面白みのところ教えてください🙇

「普通だったらfactメソッド作ってifで分岐するところだけど、Haskell的にifなしで書ける: 0が来たときは1つめのmulti、それ以外が来たときは2つ目のmultiが動くみたいな」「あー!言われてみればすごく数式っぽい: 数学的帰納法みたいな形」「関数型っぽいすね」
「まあでもこの書き方だと、パラメータの違う同じメソッドを複数定義するから、定義があちこちで増えたときにどれに解決するのかとかいろいろ面倒そう」「業務では使わないだろうなー」「処理として2つのmultiがあるところが自分的には残念: その間に何を実行されるかわからないでしょ?」「おー確かに」「処理とは別に定義としてこういうのを書けるんだったらきっと好き」

そういえば日本語記事にも「おもしろいんだがあまり使い勝手は良くなかった」って書いてありました。

⚓その他記事など

⚓SQL

⚓データベースの「トップN」問題を解決する(Postgres Weeklyより)

CitusData社のpostgresql-topnツールを紹介しています。


つっつきボイス: 「トップN問題って、N+1クエリみたいなアンチパターンとは違いますよね」「たぶんだけど、『トップ5の項目をすべてのカテゴリについて取る』みたいなやつなのかな: それなら確かに面倒かも」「Ruby側で処理すると効率悪いっすね」「DB側でできるんだとしたら便利かもー」

⚓pg_repack: PostgreSQLデータベースのテーブル再編成を最小のロックで

公開しそびれた翻訳記事で紹介されていたので。CLUSTERやVACUUM FULLと違って処理中のテーブルの排他ロックを維持せずにできるそうです。

⚓Awesome PostgreSQL: 精選ソフトウェア/ライブラリ/ツール/リソースリスト(Postgres Weeklyより)

Awesome MySQLを見て作ったそうです。どちらも参考になりそうです。

⚓JavaScript

⚓Vue.jsをRailsのフロントエンドとして使う(Ruby Weeklyより)

// 同記事より
import TurbolinksAdapter from 'vue-turbolinks';
import Vue from 'vue/dist/vue.esm'
import App from '../app.vue'

Vue.use(TurbolinksAdapter)

document.addEventListener('turbolinks:load', () => {
  const app = new Vue({
    el: '#hello',
    data: {
      message: "Can you say hello?"
    },
    components: { App }
  })
})

⚓Conditioner.js: プログレッシブエンハンスメントをストレートに実現するJSライブラリ(JavaScript Weeklyより)

プログレッシブエンハンスメントは、核となるコンテンツを最重要視するウェブデザイン戦略である。この戦略では、エンドユーザーのブラウザーやインターネット接続に合わせて、プレゼンテーション面や機能面で微妙に異なる内容や技術的に困難な内容をコンテンツに漸次追加していく。この戦略の利点として挙げられるのは、すべてのユーザーが任意のブラウザーまたはインターネット接続を用いてウェブページの基本的なコンテンツと機能性にアクセスできることと、より高度なブラウザーソフトウェアまたはより広帯域の接続を有するユーザーには同じページの拡張バージョンを提供できることである。
Wikipedia-jaより

参考: Wikipedia-ja プログレッシブエンハンスメント

⚓(動画)Vue.jsコンサルタントが本当は教えたくない7つの秘密パターン(JavaScript Weeklyより)

⚓その他記事など

⚓CSS/HTML/フロントエンド

⚓VisualInspector: 開発とデザインの齟齬をなくすCanvasFlip社のサービス(Hacklinesより)


sitepoint.comより

チーム開発でレイアウトをピクセル単位でびしっと決められるそうです。この種のサービスが最近増えてきているように思えるので。

⚓display: contentsのしくみ(Frontend Weeklyより)


同記事より

CSSのdisplay: contentsの解説記事です。

参考: MDN display

⚓CSSの「Typed Object Model」について(Frontend Weeklyより)

Typed OMに関するGoogleの技術ブログです。文字列の代わりにオブジェクトベースのAPIを使ってJavaScriptからCSSを制御できるようです。

// 同記事より
el.attributeStyleMap.set('padding', CSS.px(42));
const padding = el.attributeStyleMap.get('padding');
console.log(padding.value, padding.unit); // 42, 'px'

記事の真ん中あたりにデモがあります(マウスオーバーすると四角形が回転する)が、私のChrome 65.0.3325.181では動かず、Chrome Canary 67.0.3383.0だと動きました。なお、今BPS社内ではbabaさんのおすすめでCanary版もインストールするのが流行中です。


developers.google.comより

最初わかってませんでしたが、CSS Object Model(CSSOM)を拡張したのがTyped OMという位置づけだと教えてもらいました。

CSSOM値の文字列を意味のある型付きJavaScript表現に変換(および逆変換)するとパフォーマンス上のオーバーヘッドが大きい。本仕様では、CSSの値を型付きJavaScriptオブジェクトとして公開することで効率よく操作できるようにする。
www.w3.org/TR/css-typed-om-1のAbstractより大意

参考: W3C CSS Typed OM Level 1

⚓その他

⚓OracleとGoogleのJavaライセンス裁判

日本語記事です。とてもよくまとまってて助かります。ちょっと前に「GoogleがC#に乗り換えるかも?」みたいな記事を見かけたのですがうまく見つけられなかった。

⚓ワイのLinux環境で使ってるツールを晒す(2018年度版)

知らないツールもいろいろあって楽しめます。「後はMacのAlfredみたいなランチャーやFenêtreみたいなピクチャ・イン・ピクチャ的ツールがLinuxにもあれば…」だそうです。


www.alfredapp.comより

⚓ReactiveX — Observableパターンのライブラリ

社内アプリチームがRxSwiftなどで盛り上がっているので。


つっつきボイス: 「ReactiveXって、何だかActiveXみたいな名前」「Rxなんちゃらのすべての元祖みたいなやつですね」「Observablesパターンが多用されるらしいんですけど」「Operatorsのページ見るとわかるけど、膨大なオペレーターがあるんですよね↓」「ほんとだー」


reactivex.ioより

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

「ところでRubyでObservableパターンってよく使います?」「そういえば、確かそのものズバリのObservableってモジュール↓がありますね: #changedでフラグを立てて、notify_observersを呼ぶと、フラグの立っているクラスでupdateメソッドを呼ぶ形で通知する、みたいな」「おー」「かっけー」

# docs.ruby-lang.org: Observableより
require "observer"

class Ticker          ### 定期的に株価をフェッチ
  include Observable

  def initialize(symbol)
    @symbol = symbol
  end

  def run
    last_price = nil
    loop do
      price = Price.fetch(@symbol)
      print "Current price: #{price}\n"
      if price != last_price
        changed                 # オブザーバーに通知
        last_price = price
        notify_observers(Time.now, price)
      end
      sleep 1
    end
  end
end

factory_botとかのコールバックでこのライブラリが使われてたような覚えがあります: after(:create)あたりとか」

# https://github.com/thoughtbot/factory_bot/blob/master/GETTING_STARTED.md より
factory :user do
  after(:create) { this_runs_first }
  after(:create) { then_this }
end

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

⚓C言語のfor文が変わる?

// リンク先より
        static inline int f (void) {
            for (int i = 0; ; ) {
                long i = 1;   // CではできるがC++では無効
                // ...
                return i;     // (おそらく期待に反して) Cでは1が返る
            }
        }

つっつきボイス: 「次のC?」「どういうこったろ?」「うお、for文の中でlong i = 1なんて書いてる: Cって今までこんな書き方できてたの!」「あれ、そもそもCでfor (int i = 0; ; )みたいな書き方許されてたんだっけか?int i = 0の部分をforより前に書かないとダメだった気がするけど: C言語ワカラン」「時間ないので次へー」

「関係ないんですけど、どの回だったかな、例のTuring Complete FM聞いてたら、『Cの関数宣言の何がイヤって、最初に型書くから途中まで読み進めないとそれが関数定義なのかどうかがわからないところ』って話があったんですよ」「ワカルー」「最近多くの言語が型を後ろに書くようになったのって、きっとそれなんでしょうね」

⚓その他記事など

⚓番外

⚓自分の声で


つっつきボイス: 「自分の声ってたいてい聞きたくないやつですけど」「これちゃんとした日本のメーカーじゃないですか」「自分の声をそっくりに出せるのって、アリバイ工作とかに使えたりして?」「他にもアレやらコレやら(略」

⚓宇宙を閉じ込めたような球体

⚓地味に思われがちな固体物理

⚓請負の翻訳でやらせてもらえないやつ

⚓どこかで見たような…

たぶんこれかな。


今週は以上です。

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

週刊Railsウォッチ(20180323)Rails 5.2.0 RC2リリース、「サーバーレスなRubyが欲しい」、capybara風JSテストフレームワークCypressほか

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

ソースの表記されていない項目は独自ルート(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

Github Trending

160928_1701_Q9dJIU

Viewing all 1381 articles
Browse latest View live