ハンズオンで触っていく予定のアプリケーションを見ていきましょう。 ここでは実装について、Rails や周辺ツールについての説明を交えつつ説明します。
Bundler という gem パッケージ管理ツールの設定ファイル。 Ruby アプリケーションは(当然 Rails アプリケーションも)依存 gem パッケージを Bundler で管理するのが普通です。 Rails そのものも gem パッケージとして提供されています。
Gemfile ではアプリケーションで利用する gem パッケージを指定します。
gem 'rails', '~> 6.0.3'
gem 'mysql2'
gem 'ridgepole'
gem 'puma', '~> 4.1'
gem 'sass-rails', '>= 6'
gem 'rails', '~> 6.0.3'
は 'rails'
が gem 名、 '~> 6.0.3'
がバージョンを指定しています。
(公式の)gem パッケージは https://rubygems.org/ でホスティングされており、
rails gem だと https://rubygems.org/gems/rails を見るとリリースされている gem のバージョンやその gem が依存している gem の情報などが確認できます。
'~> 6.0.3'
というバージョン指定は少し複雑で >= 6.0.3
かつ < 6.1
という意味で、6.0.3
, 6.0.4
といったバージョンを含みます。
gem 'mysql2'
のような指定はバージョン番号を指定していません。
上記のように Gemfile で対象パッケージを指定したアプリケーションのディレクトリにて
bundle install
コマンドを実行すると対象 gem とそれらの gem が依存している gem がインストールされ、Gemfile.lock
が作成されます。
この時インストールされる gem のバージョンは Gemfile 内で指定された gem で矛盾のない適切なバージョンとなります。
Gemfile しかない状態でインストールされる gem のバージョンは bundle install
を実行する時期によって異なります。
しかし各開発者が bundle install
するタイミング、また本番サーバにデプロイされるタイミングで違うバージョンの
gem がインストールされては困ります。そのため初めて bundle install
を実行すると
インストールされた gem のバージョンが記録された
Gemfile.lock
というファイルが生成されます。
Gemfile.lock
がある状態で bundle install
を実行すると必ず同じバージョンの gem がインストールされるので常に同じバージョンのパッケージをインストールすることができます。
Gemfile.lock
に記述された gem のバージョンについては bundle update
コマンドを実行することでアップデートできます。
Gemfile を変更していなくても、対象 gem の新規リリースがある場合などバージョンが更新されることもあります。
Gemfile でのバージョン指定を更新した場合にも bundle update
を実行し、gem のバージョンを更新します。
Gemfile に記述するバージョン指定については全て細かく指定することもできますが、
ある程度大きめに指定して
bundle update
による gem 更新をしやすくしておくのがオススメです。
具体的にどういう指定をすべきかは時と場合によりますが、重要度や バージョンアップポリシーが不安定な gem ほど対象バージョンの指定を狭くする というのがよくある方針です。
以下のような group :development
ブロックで指定された gem は development 時、つまり手元での開発時のみ利用される開発用の gem パッケージです。
group :development do
gem 'web-console', '>= 3.3.0'
gem 'listen', '~> 3.2'
end
また group :development, :test
のような指定では development 時と test 時に有効となります。
tinypad では(development, test を除くと)以下の gem が指定されています。
つまり Gemfile から tinypad は Rails を利用しているウェブアプリケーションだろうなというのがわかります。
Rails アプリケーションのルーティングが記述された設定ファイルです。 この設定ファイルを読むとアプリケーションがどのようなリクエストに対応しているかわかります。
記述方法などは https://railsguides.jp/routing.html にまとまっています。
Rails.application.routes.draw do
resource :session, only: %w[new create destroy]
resources :users
resources :recipes do
resources :tsukurepos, only: %w[new create destroy]
end
resources :images, only: %w[show]
root to: 'top#index'
end
Rails の一番基本的なリソースベースのルーティング記述方法です。
https://railsguides.jp/routing.html#リソースベースのルーティング-railsのデフォルト
Rails がこの記述方法をデフォルトとしているのは二つの基本理念
が強く現れている部分です。つまり Rails は URL は REST に沿って設計すべきであるという規約で設計されたフレームワークであり、規約に基づいていると記述が簡潔になるように設計されています。
実際の設定は以下のようなルーティングに対応します。
resource :session, only: %w[new create destroy]
GET /session/new
-> SessionsController#new
POST /session
-> SessionsController#create
DELETE /session
-> SessionsController#destroy
resources :images, only: %w[show]
GET /images/:id
-> ImagesController#show
これは resources, resource を利用しなかった場合以下のような記述と同じ意味となります。
get "/session/new", to: "sessions#new", as: "new_session"
post "/session", to: "sessions#create", as: "session"
delete "/session", to: "sessions#destroy", as: "session"
get "/images/:id", to: "images#show", as: "image"
db/ 以下にはマイグレーションなどデータベース関連のファイルが格納されてるはず、なんですが、 このアプリケーションでは Rails のデフォルトの migration 機能を使わず ridgepole という gem を導入しています。 Rails 基本の db migration 機能は適用前後の差分を管理するという意図を持っており、実際そのへんの違いが分かりやすいものの、変更が多すぎると運用が大変難しくなるという問題があります。一方で ridgepole は変更履歴の管理を VCS に任せ、 DB スキーマを状態として捉えることで長期に渡る運用を比較的楽にしてくれます。そのため、クックパッドを多くの社内サービスからは ridgepole を重宝されているので今回のハンズオンでも同じ構成を取りました。
構成としては Schemafile を起点とし、 db/*.schema
を読み込みます。
各 schema ファイルは 1 テーブルに対応しており、中身は Ruby DSL です。試しに db/recipes.schema
を開いてみましょう。
create_table "recipes", id: :integer, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC", force: :cascade do |t|
t.string "title", limit: 255, null: false
t.string "description", limit: 512, null: false
t.integer "user_id", null: false
t.integer "image_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
add_index "recipes", ["user_id"], name: "users_idx", using: :btree
add_index "recipes", ["image_id"], name: "images_idx", using: :btree
recipes というテーブルの定義、及びそのテーブルのカラム定義、貼ってるインデックスに関する情報が乗っています。
ridgepole はこの定義と実際データベース上のテーブルを突き合わせてどういう変更が必要なのか判断し必要な SQL を生成します。
例えば上記のテーブルから description というカラムを消して bin/rails ridgepole:dry-run
を実行すると
bin/ridgepole --apply --dry-run --config config/database.yml --env development --file db/Schemafile
Apply `db/Schemafile` (dry-run)
remove_column("recipes", "description")
# ALTER TABLE `recipes` DROP COLUMN `description`
description というカラムを落とすための SQL が発行されることがわかります。
この状態で bin/rails ridgepole:apply
を実行すると実際カラムが削除されます。
Tips: staging/production 環境のデータベースを変更したい場合、 scripts/hako-oneshot.sh <iam username>-<staging|production>.jsonnet bin/rails ridgepole:apply
を実行してください。これは hako を経由して ECS Task を実行し、そのタスク上で DB 変更作業を実施します。
db/<table name>.schema
というファイルを追加し、そこにテーブルの仕様を記述すればいいです。
DSL は Rails のスキーマで使われるものと同じなため、そちらを参照してください。
https://railsguides.jp/active_record_migrations.html#スキーマダンプの種類
このファイルには初期データを投入するための記述をします。
seeds.rb に記述した内容は bin/rails db:seed
で実行されます。
https://railsguides.jp/active_record_migrations.html#マイグレーションとシードデータ
tinypad の seeds.rb にはいくつかのユーザーやレシピ、つくれぽのデータをいくつか生成します。 中身は普通の Rails コードです。
# ...
users = []
10.times do |i|
users << User.create!(name: "chef#{i}", password_digest: DigestGenerator.digest("password"))
end
# ...
app/models/ 以下には「モデル」を定義するクラスが格納されます。 Rails アプリケーションの場合、モデルのクラスのその多くは Active Record のクラスです。
https://railsguides.jp/active_record_basics.html
tinypad で定義してるモデルクラスはいくつかあります。
User
, Recipe
, Tsukurepo
などの定義を見るとマッピングするテーブル名やカラム名などが一切記述されていないことに気づくかと思います。
class User < ApplicationRecord
has_many :recipes, dependent: :destroy
has_many :tsukurepos, dependent: :destroy
end
これはまさに「同じことを繰り返すな」「設定より規約」の思想に従い「カラム名や型などは Rails がデータベースから自動的に取得する」「User
というクラス名なら users
テーブルを扱うモデルのはず」というように自動的に設定したりデフォルト値を決めているため記述が簡潔になっている例です。
User, Recipe モデルではそれぞれ has_many :recipes
, belongs_to :user
という記述があります。
これらはモデル間の「ユーザーは複数のレシピを持つことができる」「レシピは一つのユーザーに属する」というアソシエーションが定義しています。
https://railsguides.jp/association_basics.html
Rails で用いられるフォームオブジェクトパターンで利用するクラスたちです。 このアイデアが最初提案されたのはこちらの記事になります。 https://thoughtbot.com/blog/activemodel-form-objects
主に以下の状況で用いられることが多いです。
実装の仕方は色々ありますが、フォームを一つの ActiveRecord モデルのように扱うことでフォームの状態を保持する、フォームの中身を検証する責任を持つならばフォームオブジェクトと理解しても問題ありません。 このパターンのメリットとしては以下のようなメリットがあります。
このハンズオンコードで使われているフォームクラスは以下のようなインタフェースを持っています
class SampleForm
# 検証メソッドが使えるようにします
# 参考:
# - https://railsguides.jp/active_model_basics.html#validationsモジュール
# - https://railsguides.jp/active_record_validations.html
include ActiveModel::Validations
attr_accessor :text
attr_reader :sample
# ActiveModel::Validations が追加する検証メソッドです
validates :text, presence: true, length: { maximum: 255 }
# フォームの元データを受け取ります
def initialize(sample)
@sample = sample
@text = sample.text
end
# フォームが提出されたときにそのデータを反映するメソッドです
def apply(params)
@text = params[:text]
end
# 保存して成功したら true を、失敗したら false を返します
def save
# valid? は validate と同義で、検証を実行しその結果を true/false で返します。
# 検証に失敗する場合、 errors というオブジェクトに失敗の詳細を格納してくれます
return false unless valid?
@sample.text = @text
@sample.save!
return true
end
# ビューでフォームタグを作る時使う form_with/form_for は対象オブジェクトの
# persisted? というメソッドの結果からフォームの転送先を変えるので宣言しておく必要があります。
# ActiveRecord はこのメソッドを実装しており、 DB に保存済みの場合 true、
# そうじゃない場合は false を返します。ですのでその値をそのまま使いましょう
def persisted?
@sample.persisted?
end
end
使うときは、フォームを作る(new)、提出されたフォームの中身を反映する(apply)、保存する(save)という流れになります。
class SampleController < ApplicationController
# ...
def create
@sample_form = SampleForm.new(Sample.new)
@sample_form.apply(params.permit(:text))
if @sample_form.save
redirect_to @sample_form.sample, notice: 'Recipe was successfully created.'
else
render :new
end
end
def update
@sample = Sample.find(params[:id])
@sample_form = SampleForm.new(@sample)
@sample_form.apply(params.permit(:text))
if @sample_form.save
redirect_to @sample, notice: 'Sample was successfully updated.'
else
render :edit
end
end
end
先の routes.rb の解説で先行していくつかのコントローラが登場しましたが、クライアントからのリクエストを受ける処理を記述するところがコントローラです。
https://railsguides.jp/action_controller_overview.html
tinypad には以下のいくつかのコントローラが存在します。
RSpec で記述するテストケース、設定などは spec/ 以下に配置します。
テスト間で共通の設定等は spec/spec_helper.rb, spec/rails_helper.rb に書きます。
ドキュメントは https://relishapp.com/rspec にまとまっているのでそちらを参照してください。
RSpec 記法や rspec
コマンドの使い方などのコア機能については RSpec Core を、RSpec での Rails のテストの書き方等は RSpec Rails のドキュメントを参照してください。
ディレクトリの構成は以下のようになっています。
FactoryBot はテストデータの作成を手伝ってくれる gem です。
https://github.com/thoughtbot/factory_bot https://github.com/thoughtbot/factory_bot_rails
自分でテストデータを作ってもいいですが、 FactoryBot を導入することで以下の方なメリットがあります
例えば、Rails では以下のように使えます。
FactoryBot.define do
factory :user do
name { 'default' }
end
end
FactoryBot.create(:user) # => User モデルのレコードを作り、それに紐づくインスタンスを返す
FactoryBot.build(:user) # => User モデルのインスタンスを作るがレコードを作らない
FactoryBot.create(:user, name: 'shia') # => デフォルトのデータの name を 'shia' に置き換えしてレコードを生成する
詳細な使い方に関しては公式ドキュメントを参照してください。 https://github.com/thoughtbot/factory_bot/blob/master/GETTING_STARTED.md
bundle exec rspec # すべてのテストを実行する
bundle exec rspec spec/models/ # spec/models/ 以下のテストのみ実行する
bundle exec rspec spec/models/recipe_form_spec.rb # spec/models/recipe_form_spec.rb のテストのみ実行する
bundle exec rspec spec/models/recipe_form_spec.rb:16 # spec/models/recipe_form_spec.rb の 16 行目に書かれてるテストのみ実行する
以下の記述は GET /recipes
の正常系のテストを記述したものです。
RSpec.describe "/recipes", type: :request do
let(:image) do
FactoryBot.create(:image)
end
let(:user) do
FactoryBot.create(:user)
end
describe "GET /index" do
it "renders a successful response" do
FactoryBot.create(:recipe)
get recipes_url
expect(response).to be_successful
end
end
# ...
describe
ではテストする対象に GET /index
という名称をつけつつ、そのブロック以下をひとまとめにしています。let(:image) { ... }
は { ... }
の評価結果を image
として参照できるように宣言しています。
image
が呼び出させるまでは image
は作成されません。let!(:image) { ... }
のように !
をつけると呼び出されなくても先に image
が作成されます。it
のブロックでは実際の検証したい処理内容(example と呼びます)を記述します。
get recipes_url
は GET /recipes
というリクエストを送りますexpect(response).to be_successful
はレスポンスが成功することを検証しますつまりこの記述は GET /recipes
のリクエストが送られてきた時に tinypad が期待したレスポンスを返してくれることを検証するテストコードです。
このように Rails アプリケーションに対しリクエストを送った時の振る舞いを記述するテストを Request spec と呼びます。
Ref: