【増補改訂版】パーフェクト Ruby on Rails を読む - その2

【増補改訂版】パーフェクト Ruby on Rails を読んだので、頭が整理できた部分をなるべく自分の言葉でまとめていきます。

第1章、第2章の内容については以下を御覧ください。

【増補改訂版】パーフェクト Ruby on Rails を読む - その1 - readengineerのブログ

3 章 抑えておきたい Rails の基本機能

テストの種類と実行方法

Rails 6.0 で追加された並列テスト

Rails 6.0 では、デフォルトでテストが並列化される設定となっている。

並列度の指定は test/test_helper.rb に記述されている。

ENV['RAILS_ENV'] ||= 'test'
require_relative "../config/environment"
require "rails/test_help"

class ActiveSupport::TestCase

class ActiveSupport::TestCase
  parallelize_setup do |worker|
    # データベースをセットアップする
  end

  parallelize_teardown do |worker|
    # データベースをクリーンアップする
  end

  # Run tests in parallel with specified workers
  parallelize(workers: :number_of_processors) # <-- ここ

  # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
  fixtures :all

  # Add more helper methods to be used by all tests here...
end

たとえば、以下のように 5 秒間待機するテストを 4 つ実行するとします。 並列なしの場合はテスト完了までに 20 秒以上かかります。

require 'test_helper'

class TaskTest < ActiveSupport::TestCase
  test 'the truth' do
    assert true
  end

  test 'sleep 1' do
    sleep 5
  end

  test 'sleep 2' do
    sleep 5
  end

  test 'sleep 3' do
    sleep 5
  end

  test 'sleep 4' do
    sleep 5
  end
end

しかし実行すると、5.450333s でかんりょうしていました。 4 つのテストが並列で実行されていた事がわかります。

$ bin/rails test
Running via Spring preloader in process 41509
Run options: --seed 41044

# Running:

...........

Finished in 5.450333s, 2.0182 runs/s, 1.6513 assertions/s.
11 runs, 9 assertions, 0 failures, 0 errors, 0 skips

次に並列数を 1 に設定して、同じテストを実行してみます。

ENV['RAILS_ENV'] ||= 'test'
require_relative "../config/environment"
require "rails/test_help"

class ActiveSupport::TestCase
  # Run tests in parallel with specified workers
  parallelize(workers: 1) # <-- ここを 1 にした

  # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
  fixtures :all

  # Add more helper methods to be used by all tests here...
end

すると、やはりテスト完了までに 20 秒以上かかりました。

$ bin/rails test
Running via Spring preloader in process 45358
Run options: --seed 54407

# Running:

...........

Finished in 20.370227s, 0.5400 runs/s, 0.4418 assertions/s.
11 runs, 9 assertions, 0 failures, 0 errors, 0 skips

並列化は2種類の方法があって、「プロセス(デフォ)」または「スレッド」を利用した並列を選択可能。 プロセスはメモリを共有せず、スレッドはメモリを共有する。らしい。

そのため、プロセスを利用して並列化されたテストでは、テスト DB の読み込みについてはプロセスごとに扱うため、設定が必要。

そして 2021 年 11 月時点では RSpec では並列テストができないよう。

Rack と Rails の関係

Rack とは

Ruby の Web アプリフレームワークRailsSinatra など)
    ↕
インターフェース:Rack
    ↕
Web アプリケーションサーバUnicorn や Puma など)

Rack 登場以前は、アプリケーションサーバフレームワークが密結合でデプロイが大変だった。 Python も同じ悩みを抱えていて、WSGI という Web アプリケーション/フレームワーク間のインターフェースの規格が決めた。それに乗っかって Ruby 用に開発されたのが Rack 。

Rack の基本

# 規約:call というメソッドを定義して、env か environment という名前の引数を1つ受取るのが慣例
def call(env)  
  status = 200
  headers = { "Content-Type" => "application/json" }
  bodt = { "hoge": "fuga" }

  # 規約:返り値は http のステータスコード、ヘッダー、レスポンスボディとなる文字列を含んだ配列風オブジェクト
  [status, headers, body]
end

上記の call メソッドの形式で定義されたアプリケーションやミドルウェアが、その中で次の処理を行うアプリケーションやミドルウェアを呼び出すという、入れ子状態になっている。(以下のような玉ねぎで例えられる)

f:id:skgch0702:20211201203910p:plain
Python WSGIフレームワークPylons」のドキュメントより https://docs.pylonsproject.org/projects/pylons-webframework/en/v1.0.1rc1/concepts.html

クライアントからリクエストを受けると、順番に call メソッドが呼ばれていき、中心に位置する Rack アプリケーションに到達すると、返り値がどんどん返されていき、クライアントへレスポンスを返す。

Rails と Rack の関係

Rails は上記の Rack アプリケーションに、独自のミドルウェアを追加したもの。 ターミナルで bin/rails middleware というコマンドを打つと、搭載されているミドルウェアが、搭載されている順番に表示されるようになっている。

$bin/rails middleware

use Webpacker::DevServerProxy
use Rack::MiniProfiler
use ActionDispatch::HostAuthorization
use Rack::Sendfile
use ActionDispatch::Static
use ActionDispatch::Executor
use ActiveSupport::Cache::Strategy::LocalCache::Middleware
use Rack::Runtime
use Rack::MethodOverride
use ActionDispatch::RequestId
use ActionDispatch::RemoteIp
use Sprockets::Rails::QuietAssets
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use WebConsole::Middleware
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
use ActionDispatch::Reloader
use ActionDispatch::Callbacks
use ActiveRecord::Migration::CheckPending
use ActionDispatch::Cookies
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use ActionDispatch::ContentSecurityPolicy::Middleware
use ActionDispatch::PermissionsPolicy::Middleware
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
use Rack::TempfileReaper
run RailsTest::Application.routes

ミドルウェアの前後に独自の処理を行いたい場合は、自作のミドルウェアも作って任意の位置に仕込むことが可能。 具体的な用途はわかっていない。

DB を管理する

複数の DB を扱う

大規模な Web アプリケーションになると DB へのアクセスが応答速度低下のネックになりやすい。 その場合の対処として、DB を複数用意して負荷分散をするケースがある。

これまでは gem(Octopus など) を使って複数 DB の利用を可能にしていたが、Rails6 からは標準機能で利用できるようになった。

Rails ガイドによると、現状対応している機能は以下の通り。

・複数の「writer」データベースと、それぞれに対応する1つの「replica」
・モデルでのコネクション自動切り替え
・HTTP verbや直近の書き込みに応じたwriterとreplicaの自動スワップ
・マルチプルデータベースの作成、削除、マイグレーション、やりとりを行うRailsタスク

引用:Active Record で複数のデータベース利用 - Railsガイド

ここで、4つ目は rails db:setup のようなコマンドが使えるよ、という意味。 その他の3つが主要な機能になる。

  • 複数の DB を用意して、モデルごとに振り分ける

まず DB を複数用意するには、
db/sub_migrate というディレクトリを作成し、 config/database.yaml を以下のように書き換える。

default: &default
  adapter: sqlite3
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  timeout: 5000

development:
  primary:
    <<: *default
    database: db/development.sqlite3
  sub:
    <<: *default
    database: db/development_sub.sqlite3
    migration_paths: db/sub_migrate

省略

この時点で rails db:create をするとそれぞれの DB が作成される。
rails db:create:sub とかすると、sub の DB だけ作成されたりする。他のコマンドも同様。

sub に登録したいモデルに対して、接続先を指定する必要がある。
まずモデルを作成する際には、以下のように保存先 DB を指定することが可能。

bin/rails g model Publisher name:string --database=sub

さらに以下のように Application Record を継承する基底クラスを作成し、モデルがそれを継承する様に定義する。(もちろん各モデルで以下の establish_connection を定義しても OK )

# 新たに手動作成したファイル。例えば models/sub_base.rb など
class SubBase < ApplicationRecord
  self.abstract_class = true

  establish_connection :sub
end
# 新たに bin/rails g model で作成したモデル。ここでは Publisher モデル
class Publisher < SubBase
end

以上で設定は終了。
あとは Publisher.create(name: "hoge") のようにレコードを作成すると、設定通り sub の DB に保存してくれる。

  • 書き込み先に primary 、読み込み元に replica を指定する

実際の需要が多いのはこちら。
続いては DB の replica を用意する。

その場合は以下のように config/database.yml を編集する。( mysql の例)

default: &default
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  timeout: 5000
  username: root
  password: 
  host: 127.0.0.1

development:
  primary:
    <<: *default
    database: db/development.sqlite3
    port: 33061
  primary_replica:
    <<: *default
    database: db/development_sub.sqlite3
    port: 33062
    replica: true  # <--- これで `rails db:xxx` のコマンドが反映されるようになる

省略

それぞれの操作で接続先を明示する必要がある。 すべてのモデルに反映する場合は、app/models/application_record.rb で以下のように設定する。

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  connects_to database: {
    writing: primary,
    reading: primary_replica
  }
end