パーフェクトRuby on Rails 6章 メモ・雑感

# 6 Railslアプリケーション開発

## 雑感

この章は本格的なアプリケーションの開発を通した実践的な内容でした。`OAuth`の利用による外部サイトと連携したアプリケーションの認証機能やコントローラの粒度を上げる指針などかなり実務に近い内容だったので早いうちに基礎から学べてよかったと感じています。認証機能などは個人開発にも活かせそうな内容ですね。
以下、個人的に押さえておきたかったポイントのメモ・忘備録です。(基本的なMVCの実装に関する内容は意図して省いています。)

## 6.1 アプリケーションの作成と下準備

### Hamlの導入

Railsデフォルトのテンプレートエンジン`ERB`から、より簡潔な記述が可能な`Haml`への移行をするには以下の手順

- `gem`をインストールして`bundle install`

```Gemfile.rb
略
gem 'hamlit-rails', '~> 0.2.3'
```

- `ERB`->`Haml`へ変換する`gem`を追加して`bundle install`

```Gemfile.rb
略
gem 'html2haml', '~> 2.2.0' # hamlテンプレートエンジンに依存しているので削除する
```

- `ERB`ファイル変換コマンドを実行(メッセージにはyを返答する)

```
% bin/rails hamlit:erb2haml
```

- コマンド終了後に、`heml2haml`gemを削除し`bundle install`

## 6.2 GitHubでログイン機能を作る

OAuthを利用することでアプリケーションにGitHubのパスとIDを渡さずに認証機能を実装することができる

### 1. GitHubアプリケーションの登録

1. GitHubへログイン
1. プロフィールアイコンをクリックして`Settings`へ
1. 左側リストから`Developer settings`へ
1. リストの`OAuth Apps`へ
1. `New OAuth App`を選択し必要項目を入力して登録する
    - `Authorization callback URL`は認証後に遷移するURL(`例: http://localhost:3000/auth/github/callback`)であり、それ以外の登録情報は認証ページに表示される内容である

### 2. RailsアプリケーションにOAuthを利用したログイン機能を実装

#### `gem`を追加して`bundle install`

```Gemfile.rb
略
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`を設定する(シークレットはなければ新規に作成する)

```config/initializers/omniauth.rb
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
```

:::note info
production環境用の設定は`./config/credential.yml.enc`へ記述して暗号化して保存すると上記設定ファイルのように取得できる

```
EDITOR="code --wait" bin/rails credentials:edit
```

```./config/credential.yml.enc
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|

<details><summary>OmniAuthを利用して認証機能を実装する場合に必要なカラム</summary>

最低限必要な基本的なカラム：

- `provider` - 認証プロバイダー名（例：google、github）を保存
- `uid` - プロバイダーが提供する一意のユーザーID

任意で追加することが多いカラム：

- `name` - ユーザー名
- `email` - メールアドレス
- `avatar_url` - プロフィール画像URL
- `oauth_token` - アクセストークン
- `oauth_expires_at` - トークンの有効期限

</details>

マイグレーションファイルの例:

```db/migrate/20250111033040_create_users.rb
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認証ページへ遷移できる

```haml
%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
```

さらに認証後のルーティングを定義

```diff_ruby:config/routes.rb
Rails.application.routes.draw do
  root "welcome#index"
+  # GitHubアプリケーションに登録した認証後のリダイレクトURLにログインアクションを割り当てる
+  get "/auth/:provider/callback", to: "sessions#create"
  delete "/logout", to: "sessions#destroy"  
end
```

認証後のログインアクションを実装

```diff_ruby:app/controllers/sessions_controller.rb
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
```

```diff_ruby:app/models/user.rb
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`コマンドでモデル、コントローラ、ルーティングを同時に作成できる

:::note info
カラムの型を指定しなかった場合は自動的にstring型となる
:::

```
% bin/rails g resource event owner_id:bigint name place start_at:datetime end_at:datetime
```

### イベント作成用アクションをコントローラへ追加

```diff_ruby:app/controllers/events_controller.rb
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は使われていない)

```app/views/events/create.js.erb
<%# Ajaxでのフォームエラー時はjsを返却する(SJR) %>
<%# 引数でerrors部分テンプレートを呼び出す %>
<%# jメソッドで改行やクォートをエスケープして文字列をjsで扱えるようにする %>
document.getElwmentById("errors").innerHTML = "<%= j render("errors", errors: @event.errors) %>";
```

エラー用のテンプレート:

```app/views/application/_errors.html.haml
.alert.alert-danger
  %ul.mb-0
    - errors.full_messages.each do |message| 
      %li= message
```

<details><summary>form_withでAjaxの挙動をオフにしたい場合</summary>

`form_with`へ`local: true`のオプションを追加すると、`data-remote="true"`の属性は追加されなくなる。
全ての`form_with`で適用したい場合は`config/application.rb`に設定を追加する

```config/application.rb
config.action_view.form_with_generates_remote_forms = false
```

</details>

### アプリケーション固有の辞書データの作成

デフォルトでは`f.label :name`で表示されるラベルや、バリデーションエラー時のメッセージは全て英語となっている。
日本語出力に変換するためには以下の手順を踏む

1. `config/application.rb`の設定を変更する

```diff_ruby: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

```

2. 辞書データ用のgemを追加し、`bundle install`

```Gemfile.rb
gem 'rails-i18n', '~> 6.0.0' # Railsの辞書ファイル
```

3. 辞書データを`config/locales`配下に作成する

```config/locales/ja.yml
ja:
  activerecord:
    models:
      event: イベント
    attributes:
      event:
        name: 名前
        place: 場所
        start_at: 開始時間
        end_at: 終了時間
        content: 内容
```

## 6.4 イベントの閲覧機能を作る

### 日時のフォーマット

```app/views/events/show.html.haml
.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:}`オプションでボタンを押した際に確認用ダイアログを表示できる

```app/views/events/show.html.haml
.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`に定義する場合:

```diff_ruby
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`アクション用のビューが不要となる)

```app/views/events/show.html.haml
-#"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属性
      -# &timesは、HTMLエンティティで「×」を表し、閉じるボタンとして使用される
      %button.close{ type: "button", "data-dismiss": "modal" } &times;
    = 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"
        
```
:::note warn

上記の場合、ビューの作成が不要なので`new`アクションには直接URLを叩かれた場合のエラーハンドリングのみを追加

```app/controllers/tickets_controller.rb
class TicketsController < ApplicationController
  # ログイン状態で直接URLを叩かれた場合にエラーを発生させる
  # 未ログイン状態の挙動は、ApplicationControllerで定義
  def new
    raise ActionController::RoutingError, 'ログイン状態で TicketController#new にアクセス'
  end
end
```

:::

### ビューをレンダリングしないアクション内での変数

下記の`destroy`のようにビューへ変数を渡す必要がない場合は、インスタンス変数ではなくローカル変数を使用する

```app/controllers/tickets_controller.rb
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 退会機能を作る

:::note 
コントローラを増やす指針

例えば、レコード削除用の確認ページをレンダリングするアクション(`create`の`new`,`update`の`edit`に相当)が必要になった場合、通常の７アクション以外の新しいアクションを新設しなければならない。
そのような基本以外のアクションを作成したくなった場合はモデル名とは別名のコントローラを作ることを検討しても良い。

例: retirementsコントローラ(ユーザー退会処理用のコントローラ)
     - `create`で退会処理
     - `new`で退会確認用ページのレンダリング

:::

### トランザクションの中断

例えば、モデルレコード削除時のコールバックに`throw(:abort)`を記述するとその削除処理自体を中断させることができる。

```diff_ruby:app/models/user.rb
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`にできる

```diff_ruby:app/models/user.rb
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
```