6 Railslアプリケーション開発
雑感
この章は本格的なアプリケーションの開発を通した実践的な内容でした。OAuth
の利用による外部サイトと連携したアプリケーションの認証機能やコントローラの粒度を上げる指針などかなり実務に近い内容だったので早いうちに基礎から学べてよかったと感じています。認証機能などは個人開発にも活かせそうな内容ですね。
以下、個人的に押さえておきたかったポイントのメモ・忘備録です。(基本的なMVCの実装に関する内容は意図して省いています。)
6.1 アプリケーションの作成と下準備
Hamlの導入
RailsデフォルトのテンプレートエンジンERB
から、より簡潔な記述が可能なHaml
への移行をするには以下の手順
-
gem
をインストールしてbundle install
略
gem 'hamlit-rails', '~> 0.2.3'
-
ERB
->Haml
へ変換するgem
を追加してbundle install
略
gem 'html2haml', '~> 2.2.0' # hamlテンプレートエンジンに依存しているので削除する
-
ERB
ファイル変換コマンドを実行(メッセージにはyを返答する)
% bin/rails hamlit:erb2haml
- コマンド終了後に、
heml2haml
gemを削除しbundle install
6.2 GitHubでログイン機能を作る
OAuthを利用することでアプリケーションにGitHubのパスとIDを渡さずに認証機能を実装することができる
1. GitHubアプリケーションの登録
- GitHubへログイン
- プロフィールアイコンをクリックして
Settings
へ - 左側リストから
Developer settings
へ - リストの
OAuth Apps
へ -
New OAuth App
を選択し必要項目を入力して登録する-
Authorization callback URL
は認証後に遷移するURL(例: http://localhost:3000/auth/github/callback
)であり、それ以外の登録情報は認証ページに表示される内容である
-
2. RailsアプリケーションにOAuthを利用したログイン機能を実装
gem
を追加してbundle install
略
gem 'omniauth', '~> 1.9.1' # 認証用のライブラリ
gem 'omniauth-github', '~> 1.4.0' # GitHub認証用のライブラリ
gem 'omniauth-rails_csrf_protection', '~> 0.1.2' # CSRF対策用のライブラリ
config/initializers/omniauth.rb
を作成する
作成したGitHubアプリケーションのページに表示されているClient ID
とClient Secret
を設定する(シークレットはなければ新規に作成する)
Rails.application.config.middleware.use OmniAuth::Builder do
if Rails.env.development? || Rails.env.test?
provider :github, "自分のクライアントID", "自分のクライアントシークレット"
else
provider :github,
Rails.application.credentials.github[:client_id],
Rails.application.credentials.github[:client_secret]
end
end
production環境用の設定は./config/credential.yml.enc
へ記述して暗号化して保存すると上記設定ファイルのように取得できる
EDITOR="code --wait" bin/rails credentials:edit
secret_key_base: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
github:
client_id: "本番用のクライアントID"
client_secret: "本番用のクライアントシークレット"
本番起動時は上記ファイルの復号のためにRAILS_MASTER_KEY
環境変数を設定して起動する(内容はconfig/master.key
と同じ)
ログインユーザ用のモデルファイルに必要なカラムを追加
カラム名 | 意味 |
---|---|
provider | OmniAuthの認証で使用するプロバイダ名(今回の場合はgithub) |
uid | providerごとに与えられるユーザ識別用の文字列 |
name | GitHubのユーザ名 |
image_url | GitHubのアイコン画像のURL |
OmniAuthを利用して認証機能を実装する場合に必要なカラム
最低限必要な基本的なカラム:
-
provider
- 認証プロバイダー名(例:google、github)を保存 -
uid
- プロバイダーが提供する一意のユーザーID
任意で追加することが多いカラム:
-
name
- ユーザー名 -
email
- メールアドレス -
avatar_url
- プロフィール画像URL -
oauth_token
- アクセストークン -
oauth_expires_at
- トークンの有効期限
マイグレーションファイルの例:
class CreateUsers < ActiveRecord::Migration[6.0]
def change
create_table :users do |t|
# NOT NULL制約を追加しておくことで想定外の不整合を防ぐ
t.string :provider, null: false
t.string :uid, null: false
t.string :name, null: false
t.string :image_url, null: false
t.timestamps
end
# 一意のデータにはユニークインデックスを付与
add_index :users, %i[provider uid], unique: true
end
end
ログイン処理の実装
OmniAuthはデフォルトで/auth/:provider
というリンクを通してサービスプロバイダーの認証ページに遷移する。GitHubの場合はビューファイルに以下のようなリンクを記述する(Hamlの例)ことでGitHub認証ページへ遷移できる
%ul.navbar-nav
- if logged_in?
%li.nav-item
= link_to "ログアウト", logout_path, class: "nav-link", method: :delete
- else
%li.nav-item
= link_to "GitHubでログイン", "/auth/github", class: "nav-link", method: :post
さらに認証後のルーティングを定義
Rails.application.routes.draw do
root "welcome#index"
+ # GitHubアプリケーションに登録した認証後のリダイレクトURLにログインアクションを割り当てる
+ get "/auth/:provider/callback", to: "sessions#create"
delete "/logout", to: "sessions#destroy"
end
認証後のログインアクションを実装
class SessionsController < ApplicationController
def create
+ # request.env['omniauth.auth']にはGitHubから渡された情報が格納されている
+ user = User.find_or_create_from_auth_hash!(request.env['omniauth.auth'])
+ session[:user_id] = user.id
+ redirect_to root_path, notice: 'ログインしました'
end
def destroy
reset_session
redirect_to root_path, notice: 'ログアウトしました'
end
end
class User < ApplicationRecord
+ def self.find_or_create_from_auth_hash!(auth_hash)
+ provider = auth_hash[:provider]
+ uid = auth_hash[:uid]
+ nickname = auth_hash[:info][:nickname]
+ image_url = auth_hash[:info][:image]
+
+ # find_or_create_by!メソッドは、例外を発生させるため、!をつけている
+ User.find_or_create_by!(provider: provider, uid: uid) do |user|
+ user.name = nickname
+ user.image_url = image_url
+ end
+ end
end
6.3 イベントの登録機能を作る
イベント用モデルのリソースを一括で作成
rails g resouce
コマンドでモデル、コントローラ、ルーティングを同時に作成できる
カラムの型を指定しなかった場合は自動的にstring型となる
% bin/rails g resource event owner_id:bigint name place start_at:datetime end_at:datetime
イベント作成用アクションをコントローラへ追加
class EventsController < ApplicationController
skip_before_action :authenticate, only: :show
def show
@event = Event.find(params[:id])
end
+ # この時点でオブジェクトを生成(保存はしない)
+ # ビューファイルで適切なフォームのURL/HTTPメソッドの自動設定やエラーハンドリングが容易になる
+ def new
+ @event = current_user.created_events.build
+ end
+ def create
+ @event = current_user.created_events.build(event_params)
+ if @event.save
+ redirect_to @event, notice: '作成しました' # ブラウザ上でもリダイレクト
+ end
+ # 失敗した時はデフォルトのcreateビューを表示(次項で解説)
+ end
+ private
+ def event_params
+ params.require(:event).permit(:name, :place, :content, :start_at, :end_at)
+ end
end
form_with
メソッドのバリデーションエラー時の設定
ビューファイルでform_with
メソッドを使用するとデフォルトでdata-remote="true"
の属性を持つform
タグを生成する。この属性が有効であればフォーム情報送信がAjaxで非同期的に行われ、部分的にDOMを変更し画面遷移が高速になる。
しかし、formからAjaxリクエストを送信した場合のレスポンスの扱いは自動化されておらず、開発者が実装する必要がある。
通常、Ajaxリクエストのレスポンスでリダイレクト用のステータスコードが返却されてもブラウザの画面は変化しないが、turbolinks gem
によってAjaxかつGET以外のリクエストが届いた後にredirect_to
を実行した場合はリダイレクト相当の挙動をする。
上記コントローラはこの仕様によってイベント作成成功時はイベント詳細ページへリダイレクトする挙動となる。一方、@event.save
がバリデーションエラーとなった場合はredirect_to
が実行されずデフォルトのビューをレンダリングする。
SJR(Server-generated JavaScript Responses)の作成方法
Ajaxでのバリデーションエラー時の対応はSJRによって対応するのが推奨されている。エラーメッセージを返却するビューをapp/views/events/create.js.erb
としてJavaScriptで表現する(ERBはJavaScriptのコードをそのまま書けるのでここではhamlは使われていない)
<%# Ajaxでのフォームエラー時はjsを返却する(SJR) %>
<%# 引数でerrors部分テンプレートを呼び出す %>
<%# jメソッドで改行やクォートをエスケープして文字列をjsで扱えるようにする %>
document.getElwmentById("errors").innerHTML = "<%= j render("errors", errors: @event.errors) %>";
エラー用のテンプレート:
.alert.alert-danger
%ul.mb-0
- errors.full_messages.each do |message|
%li= message
form_withでAjaxの挙動をオフにしたい場合
form_with
へlocal: true
のオプションを追加すると、data-remote="true"
の属性は追加されなくなる。
全てのform_with
で適用したい場合はconfig/application.rb
に設定を追加する
config.action_view.form_with_generates_remote_forms = false
アプリケーション固有の辞書データの作成
デフォルトではf.label :name
で表示されるラベルや、バリデーションエラー時のメッセージは全て英語となっている。
日本語出力に変換するためには以下の手順を踏む
-
config/application.rb
の設定を変更する
略
module AwesomeEvents
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 6.0
config.time_zone = "Tokyo"
+ config.i18n.default_locale = :ja # エラーメッセージを日本語化
end
end
- 辞書データ用のgemを追加し、
bundle install
gem 'rails-i18n', '~> 6.0.0' # Railsの辞書ファイル
- 辞書データを
config/locales
配下に作成する
ja:
activerecord:
models:
event: イベント
attributes:
event:
name: 名前
place: 場所
start_at: 開始時間
end_at: 終了時間
content: 内容
6.4 イベントの閲覧機能を作る
日時のフォーマット
.card.mb-2
%h5.card-header 開催時間
.card-body
-# lメソッドはI18nモジュールのメソッドで、引数に日時を指定すると、その日時を指定したフォーマットで表示する
-# ここでは:longを指定している
%p.card-text= "#{l @event.start_at, format: :long} - #{l @event.end_at, format: :long}"
6.5 イベントの編集・削除機能を作る
確認用ダイアログの表示
data: { confirm:}
オプションでボタンを押した際に確認用ダイアログを表示できる
.col-4
- if @event.created_by?(current_user)
= link_to "イベントを編集する", edit_event_path(@event), class: "btn btn-info btn-lg btn-block"
-# data: { confirm:}で削除ボタンを押した際に確認ダイアログを表示する
= link_to "イベントを削除する", event_path(@event), class: "btn btn-danger btn-lg btn-block", method: :delete, data: { confirm: "本当に削除しますか?" }
6.6 登録されたイベントへの参加機能・参加キャンセル機能を作る
複合ユニークインデックスの作成
%i[event_id user_id]
で複合キーを指定することで複合インデックスを作成し、ユニーク制約を付与して複合ユニークインデックスとする
User
とEvent
の中間モデルTicket
に定義する場合:
class CreateTickets < ActiveRecord::Migration[6.0]
def change
create_table :tickets do |t|
t.references :user
t.references :event, null: false, foreign_key: true, index: false
t.string :comment
t.timestamps
end
+ # %i[event_id user_id]で複合キーを指定し、組み合わせがユニークであることを保証
+ add_index :tickets, %i[event_id user_id], unique: true
end
end
モーダルの作成
ビューファイル内でモーダルを作成できる。form_with
を追加することで簡易的なイベント参加用ビューを作成できる(new
アクション用のビューが不要となる)
-#"data-toggle: "modal""属性は、クリック時にモーダルウィンドウを表示するための属性
-# "data-target" 属性は、クリック時に表示されるモーダルウィンドウのIDを指定
%button.btn.btn-primary.btn-lg.btn-block{ "data-toggle": "modal", "data-target": "#createTicket" }
参加する
%div.modal.fade#createTicket
.modal-dialog
.modal-content
.modal-header
%h4.modal-title#dialogHeader 参加コメント
-# "data-dismiss"属性は、ボタンがクリックされたときにモーダルを閉じるためのBootstrap属性
-# ×は、HTMLエンティティで「×」を表し、閉じるボタンとして使用される
%button.close{ type: "button", "data-dismiss": "modal" } ×
= form_with(model: @event.tickets.build, url: event_tickets_path(@event)) do |f|
.modal-body
#createTicketErrors
= f.text_field :comment, class: "form-control"
.modal-footer
%button.btn.btn-default{ type: "button", "data-dismiss": "modal" }
キャンセル
= f.button "送信", class: "btn btn-primary"
上記の場合、ビューの作成が不要なのでnew
アクションには直接URLを叩かれた場合のエラーハンドリングのみを追加
class TicketsController < ApplicationController
# ログイン状態で直接URLを叩かれた場合にエラーを発生させる
# 未ログイン状態の挙動は、ApplicationControllerで定義
def new
raise ActionController::RoutingError, 'ログイン状態で TicketController#new にアクセス'
end
end
ビューをレンダリングしないアクション内での変数
下記のdestroy
のようにビューへ変数を渡す必要がない場合は、インスタンス変数ではなくローカル変数を使用する
class TicketsController < ApplicationController
略
# このアクションは必ずリダイレクトされるため、ビューに渡すインスタンス変数は不要
def destroy
# !でレコードが見つからない場合と削除失敗時に例外を発生させる
ticket = current_user.tickets.find_by!(event_id: params[:event_id])
ticket.destroy!
redirect_to event_path(params[:event_id]), notice: 'このイベントの参加をキャンセルしました'
end
end
6.7 退会機能を作る
コントローラを増やす指針
例えば、レコード削除用の確認ページをレンダリングするアクション(create
のnew
,update
のedit
に相当)が必要になった場合、通常の7アクション以外の新しいアクションを新設しなければならない。
そのような基本以外のアクションを作成したくなった場合はモデル名とは別名のコントローラを作ることを検討しても良い。
例: retirementsコントローラ(ユーザー退会処理用のコントローラ)
- create
で退会処理
- new
で退会確認用ページのレンダリング
トランザクションの中断
例えば、モデルレコード削除時のコールバックにthrow(:abort)
を記述するとその削除処理自体を中断させることができる。
class User < ApplicationRecord
before_destroy :check_all_events_finished
略
def check_all_events_finished
now = Time.zone.now
if created_events.where(":now < end_at", now: now).exists?
errors[:base] << '公開中の未終了イベントが存在します'
end
if participating_events.where(":now < end_at", now: now).exists?
errors[:base] << '未終了の参加イベントが存在します'
end
+ # throw :abortでコールバックを中断し、例外を発生させる
+ throw :abort unless errors.empty?
end
end
削除時に関連レコードの外部キーをnull
にする
関連レコードの外部キーにNOT NULL
制約を付与していない場合、nullfy
を指定してレコード削除時に関連レコードの外部キーをnull
にできる
class User < ApplicationRecord
before_destroy :check_all_events_finished
# nullifyで削除した際に関連付けられたレコードの外部キーをnullにする
+ has_many :created_events, class_name: 'Event', foreign_key: 'owner_id', dependent: :nullify
+ has_many :tickets, dependent: :nullify
has_many :participating_events, through: :tickets, source: :event