はじめまして、こんにちは!
未経験からのエンジニア転職を目指してオリジナルアプリを作成中の初学者です!
作成中のオリジナルアプリに追加機能として、チャットの導入手順を学習の為に記録しておきたいと思います!!
自分の学習の振り返りの為の記録ですので、自分以外にはナンノコッチャな記事ですが、部分的なポイントが同じ目標を持つ方に参考に少しでもなれば良いなと思っております!!
アプリケーション概要
前提条件について整理をしておきます。
- 架空ラーメン店「Fuuun-ramen」のモバイルオーダーWebサイト(ECサイト)
- User機能(お客様)とAdmin機能(お店側)に分かれる
- アプリケーションリンク ※一度ご覧頂けるとありがたいです!
- GitHub
User機能
- 商品、トッピングを買物カゴに保存
- 来店日時の選択、利用人数を選択し、買い物カゴに保存した商品購入(予約)
- ユーザー登録(氏名、住所、クレジットカード保存など)
- お問い合わせフォームの送信
Admin機能
- 商品・トッピング・最新情報の登録、編集
- 期間を指定し、売上・予約・商品ランキングの検索
- 検索結果のCSV出力
環境
- Ruby 2.6.5
- Ruby on Rails 6.0
- MySQL2
追加要件
- ユーザーが予約完了(商品購入)後、ユーザーとお店側でメッセージのやり取りが行える
- 1つの予約に対し、メッセージをやり取りするルームが1つ紐づく
- ユーザー側では、来店日の3日後以降にはルームが非表示となる
- お店側では、来店日の3日後以降でもルームは表示できるが、メッセージ送信はできない
- 今回の実装では、非同期通信によるメッセージ投稿までは実装しない(またの機会に💦)
- ビューを整える部分までは扱わず、バックエンドの動きを中心とした記事内容とする
完成イメージ
設計
実装手順に進む前に、どのような設計・仕様にするのか整理をしておきたいと思います。
①テーブル・モデル
今回の追加機能で必要となるテーブルは5つです!
- users
- admins
- reservations
- rooms
- messages
追加実装前のER図
現在のテーブルは以下となっています。すでにusersテーブル、adminsテーブル、reservationsテーブルは作成済ですので、新規に必要なのはroomsテーブルとmessagesテーブルという事になりそうです。
追加実装するテーブル設計
roomsテーブルとmessagesテーブルを追加し、アソシエーション、カラムを下記設定としたいと思います。
- ResevationとRoomの関係
- チャット機能でよくある関係では、「ユーザー同士のチャット」となる為に、RoomとUserは中間テーブルを用いた多対多の関係になるのですが、私のアプリの場合、「予約に紐づくユーザー1名とお店側のチャット」です。その為、ReservationとRoomは1対1の関係で良さそうです。
- messagesテーブルのカラム
- ユーザーもお店側もメッセージ投稿を行う為、messagesテーブルには「user_id」と「admin_id」を設けました。ユーザーのメッセージでは「admin_id」はnilとなり、お店側のメッセージでは「user_id」がnilとなって、messagesテーブルに保存されます。 messagesマイグレーションファイルの実行の際、それぞれカラムでは「null: false」オプションをつけていません。 また、Messageモデルには、それぞれのカラムに「opitinal: ture」のバリデーション設定を加えています。
今回の実装では省きますが、メッセージ受信通知を行う場合、通知テーブルとのアソシエーションも行う必要がありそうです!!
②ルーティング・コントローラー
「roomsコントローラー」と「messagesコントローラー」の2つ必要ですが、ユーザーとお店側で処理とビューを分ける必要があるので、4つのコントローラーで機能実装したいと思います!
【ユーザー側】
・ ルーム一覧画面(メッセージのやり取りが可能な予約一覧)
・ ルーム詳細画面(メッセージのやり取り)
【管理者側】
・ ルームの一覧画面(メッセージのやり取り可能い関わらず、全ての予約)
・ ルームの詳細画面(メッセージ可能期間を過ぎた場合ではメッセージ送信不可)
- rooms_controller.rb
- messages_controller.rb
- admins/rooms_controller.rb
- admins/messages_controller.rb
- roomsコントローラー
- ルームの一覧を表示する「index」アクションと、ルーム詳細で実際にメッセージをやり取りする「show」アクションが必要となりそうです。
- messagesコントローラー
- ルーム詳細でメッセージの送信・確認するので、「create」アクションだけあれば良さそうです。今回は送信したメッセージについては削除をしない仕様としてますので、「destroy」アクションも不要とします
③ビュー・導線
シンプルなビュー・導線として、下記のような設計で進めたいと思います。レイアウト・デザインは実装の中でよい仕様を探りつつ進めて行きたいと思います!!
実装手順
仕様・設計が決まりましたので、実装を進めて行きたいと思います!!詰まらないといいな💦
実装① モデル作成
rails gコマンドで、マイグレーションファイルとモデルファイルを作成
RoomモデルとMessageモデルを作成します。
% docker-compose exec web rails g model room
Running via Spring preloader in process 160
invoke active_record
create db/migrate/20201107131113_create_rooms.rb
create app/models/room.rb
invoke rspec
create spec/models/room_spec.rb
invoke factory_bot
create spec/factories/rooms.rb
% docker-compose exec web rails g model message
Running via Spring preloader in process 192
invoke active_record
create db/migrate/20201107131258_create_messages.rb
create app/models/message.rb
invoke rspec
create spec/models/message_spec.rb
invoke factory_bot
create spec/factories/messages.rb
開発環境にDockerを用いていますので、railsコマンドの前に「docker-compose」コマンドが付いてます。
マイグレーションファイルを編集・実行
class CreateRooms < ActiveRecord::Migration[6.0]
def change
create_table :rooms do |t|
t.references :reservation, null: false, foreign_key: true #追加
t.timestamps
end
end
end
class CreateMessages < ActiveRecord::Migration[6.0]
def change
create_table :messages do |t|
t.references :room, null: false, foreign_key: true #追加
t.references :user, foreign_key: true #追加 NOT NULL制約はつけない
t.references :admin, foreign_key: true #追加 NOT NULL制約はつけない
t.text :message, null: false
t.timestamps
end
end
end
Messageのマイグレーションファイルでは、外部キーとなるuser_id, admin_idには、null: false
オプションを付けていません。
これはメッセージがユーザーか管理者のどちらか一方に紐づく為、nullを許可しておく為です。
編集後にマイグレーションファイルの実行
% docker-compose exec web rails db:migrate
== 20201107131113 CreateRooms: migrating ======================================
-- create_table(:rooms)
-> 0.0394s
== 20201107131113 CreateRooms: migrated (0.0398s) =============================
== 20201107131258 CreateMessages: migrating ===================================
-- create_table(:messages)
-> 0.0235s
== 20201107131258 CreateMessages: migrated (0.0237s) ==========================
rails db:migrate:status
コマンドで、一応マイグレーションファイルの実行状態を確認しておきます。
% docker-compose exec web rails db:migrate:status
database: myapp_development
Status Migration ID Migration Name
--------------------------------------------------
<省略>
up 20201107131113 Create rooms
up 20201107131258 Create messages
ステータスが「up」となっているので大丈夫ですね!
モデルファイルの編集
アソシエーション、バリデーションを設定していきます。
編集対象のファイルは既存モデル含めて全部で5つです。
has_many :messages, dependent: :destroy
has_many :messages, dependent: :destroy
has_one :room, dependent: :destroy
belongs_to :reservation
has_many :messages, dependent: :destroy
validates :reservation_id, uniqueness: true
belongs_to :user, optional: true
belongs_to :admin, optional: true
belongs_to :room
validates :message, presence: true
Messageモデルには、UserとAdminに対してoptional: true
オプションを付けています。
今回の仕様では、MessageはUserかAdminのどちらか一つのidはnullになります。
マイグレーションファイルでNOT NULL
制約をつけていない為、DBではNULLが許可されますが、Rails側ではbelongs_to
の関係となっている為、nilが許可されなくなってしまいます。このoptional: true
を設定することでnilを許可するように設定しています。
実装② ルーティング
ユーザーとお店側でそれぞれルーティングが必要となるので以下のように設定しました。
Rails.application.routes.draw do
#User
resources :rooms, only: %i(index show)
resource :message, only: :create
#Admin
namespace :admins do
resources :rooms, only: %i(index show)
resource :message, only: :create
end
end
resourcesとresourceの違い
resource :xxxx
と単数系で表記した場合、以下の点で違いが発生します。
・indexアクションが定義されない
・URI Patternにidがふくまれない
今回messageではcreateアクションしか使用せず、idも不要のため、resource
で定義しました。
namespaceを使用する場合
Admin用のルーティングにはnamespace :admins
のdo,endのブロック内で定義しました。
こうすることで、URIにadminを含め、ファイルもadminsディレクトリ配下におくことができます。
それではrails routes
コマンドでルーティングを確認してみます。
Prefix Verb URI Pattern Controller#Action
rooms GET /rooms(.:format) rooms#index
room GET /rooms/:id(.:format) rooms#show
message POST /message(.:format) messages#create
admins_rooms GET /admins/rooms(.:format) admins/rooms#index
admins_room GET /admins/rooms/:id(.:format) admins/rooms#show
admins_message POST /admins/message(.:format) admins/messages#create
ルーティングの設定方法も奥が深いです。ディレクトリ構成、URI、コントローラーをどう使用したいかで、いくつも設定方法があります。
今回使用したnamespace
以外にも、scope
、module
、member
、collection
などがあります。
学習の為に用途や状況でどう使用するのか近日中にまとめたいと思います!
実装③ コントローラー・ビュー
続いてコントローラーを作成します。余計なファイルを生成しないようにrails g controller
コマンドではなく、手動で以下のコントローラーファイル作成しました。
- app/controllers/rooms_controller.rb
- app/controllers/messages_controller.rb
- app/controllers/admins/rooms_controller.rb
- app/controllers/admins/messages_controller.rb
messageルーティングでは、resource :message
と単数形でしたが、コントローラー名は通常の命名規則通りにmessages_controller.rb
と複数形の命名とすることです。
続いてそれぞれのコントローラーの使用するアクションを定義していきます。
class RoomsController < ApplicationController
def index
end
def show
end
end
class MessagesController < ApplicationController
def create
end
end
class Admins::RoomsController < Admins::ApplicationController
def index
end
def show
end
end
class Admins::MessagesController < Admins::ApplicationController
def create
end
end
お店側機能を管理するadminsディレクトリ配下に作成したコントローラーファイルではすでに作成済のAdmins::ApplicationController
を継承しています。
Adminsディレクトリ配下にあるコントローラー群は全てAdmins::ApplicationController
を継承させるように設定しています。ディレクトリ構成は以下となっています。
app/controllers
L application_controller.rb
L rooms_controller.rb
L messages_controller.rb
L admins
L applicaton_controller.rb
L rooms_controller.rb # admins/application_controller.rbを継承
L messages_controller.rb # admins/application_controller.rbを継承
admins/application_controller.rb
では、controllersディレクトリ直下にあるapplication_controller.rb
を継承しています。
admins/application_controller.rb
の中身は以下のように設定してAdmin以外をはじくようにdeviseのauthenticateメソッドを使用しています。
class Admins::ApplicationController < ApplicationController
before_action :authenticate_admin!
end
コントローラーを編集していく前に、ビューファイルも簡単に作成しておきます。以下のディレクトリ構成でファイルを作成します。messageはroomで表示・投稿し、createアクションしかないため、ビューファイルは不要となります。
app/views
L rooms
L index.html.erb
L show.html.erb
L admins
L rooms
L index.html.erb
L show.html.erb
ファイルの中身は一旦簡単な内容を表示させるようにしておきます。
<div>Userチャットルームの一覧</div>
<div>Userチャットルームの詳細</div>
<div>Adminチャットルームの一覧</div>
<div>Adminチャットルームの詳細</div>
一旦ここまでの設定内容で正しく表示できるかlocalhostで確認してみます!
無事に表示できました!
それでは、各コントローラーの中身を一気に作成していきます!!
User側コントローラー
class RoomsController < ApplicationController
before_action :authenticate_user!
before_action :get_two_days_ago
before_action :set_room_or_move, only: :show
def index
# 今日から2日前以降の予約を取得
@reservations = current_user.reservations.where('day >= ?', @two_days_ago).order(day: "DESC")
end
def show
move_to_index_for_expired
@message = Message.new
@messages = Message.where(room_id: @room.id)
end
private
def get_two_days_ago
@two_days_ago = Date.today - 2
end
def set_room_or_move
# ログインユーザーの予約に紐づくルームへのアクセスでない場合、リダイレクト
redirect_to rooms_path, alert: 'メッセージルームに入れませんでした' if Reservation.find(params[:id]).user_id != current_user.id
#予約に紐づくルームを@roomに定義
@room = Room.find_by(reservation_id: params[:id])
# roomが存在するか判定し、存在しない場合にreservation_idを渡して作成する
if @room.nil?
@room = Room.new(reservation_id: params[:id])
redirect_to rooms_path, alert: 'メッセージルームに入れませんでした' unless @room.save
end
end
def move_to_index_for_expired
# 来店日を3日間以降過ぎている場合、ルーム一覧へリダイレクト
if @room.reservation.day < @two_days_ago
redirect_to rooms_path and return
end
end
end
- before_action :authenticate_user!
- ログインしていないユーザーをはじく為、deviseが用意してくれているメソッドを定義しています。
- before_action :get_two_days_ago
- メッセージの送受信が可能な期間を来店日後の2日以内としてますので、その日付をbefore_actionで定義しています。
- before_action :set_room_or_move, only: :show
-
「ルームを新規作成・定義する」か、「ルーム一覧へリダイレクトするか」を判断するメソッドをshowアクション前に実行しています。
まずログインユーザーの予約に紐づくルームではない場合にルーム一覧にリダイレクトさせています。
今回は設計ではルームが新規作成されるタイミングは、「ユーザーが商品購入・予約確定と同時に作成」ではなく、「showアクションにリクエストした時に、予約に紐づくルームが新規作成」としました。すでにルームが作成されている場合には、ルーム作成はスキップされる仕様としています。
- @reservations = current_user.reservations.where('day >= ?', @two_days_ago).order(day: "DESC")
- 今日から2日前以降の予約を取得し、インスタンス変数に定義しています。この予約がメッセージの送受信が可能な予約となります。
- move_to_index_for_expired
- showアクションでは、プライベートメソッドに定義した「move_to_index_for_expired」メソッドを呼び出しています。これは来店日が3日過ぎている予約=>メッセージの送受信期間を過ぎているshowアクションにリクエストした場合に、indexアクションのリダイレクトする設定です。
class MessagesController < ApplicationController
def create
message = Message.new(message_params)
if message.save
redirect_to room_path(message.room.reservation_id)
else
redirect_to room_path(message.room.reservation_id), alert: 'メッセージを送信できませんでした'
end
end
private
def message_params
params.require(:message).permit(:message, :room_id).merge(user_id: current_user.id)
end
end
メッセージ保存後にルーム詳細へのリダイレクト指定が少し特殊な記述になっています。
redirect_to room_path(message.room.reservation_id)
通常であればroomsコントローラーのshowアクションへリダイレクトさせるパスはroom_path(:id)
なのですが、ここではreservation_id
を指定しています。
これはルームへリクエストする際のビューファイル(rooms/index.html.erb)をみて頂くとなぜこのようにする必要があるのか確認できます。
rooms/index.html.erb
<h1>Userチャットルームの一覧</h1>
<% @reservations.each do |reservation| %>
<div>
<%= reservation.user.nickname %>
<%= reservation.day %>
<%= link_to "DM", room_path(reservation) %>
</div>
<% end %>
ルーム一覧からルーム詳細へのリンクはroom_path(reservation)
として設定しています。ルーム一覧では「メッセージのやり取りが可能な予約」が一覧表示されており、実際にはルーム一覧ではなく、予約一覧といえます。ルームはshowアクションへリクエストした際に初めて作成される為、ルーム一覧で表示されている予約でも、ルームがまだ存在していないケースがある為、room_path(reservation)
としてreservationを引数に渡してshowアクションへリンクを設定しています。
その為、ルームへリダイレクトする際にredirect_to room_path(message.room)
としてルームを渡すと想定していない異なるルームにリダイレクトされてしまいます。
少し分かりづらい仕様になってしまいました。ルーム作成のタイミングを商品購入・予約確定と同じタイミングにすればこのようなことはなく、直感的な設定ができたかと思うと、やや設計の失敗かなと感じています💦
User側ビューファイル
一旦表示を簡単に表示をさせる為の記述ですが、リンクやフォームは以下のように記述しています。
<h1>Userチャットルームの一覧</h1>
<% @reservations.each do |reservation| %>
<div>
<%= reservation.user.nickname %>
<%= reservation.day %>
<%= link_to "DM", room_path(reservation) %>
</div>
<% end %>
<h1>Userチャットルームの詳細</h1>
ルームID<%= @room.id %>
<% if @messages.blank? %>
<p>メッセージはありません</p>
<% else%>
<p>メッセージがあります</p>
<% @messages.each do |message| %>
<div>
<% if message.user %>
<span><%= message.user.nickname %>:</span>
<% else %>
<span>スタッフ:</span>
<% end %>
<span><%= message.message %></span>
</div>
<% end %>
<% end %>
<%= form_with model: @message, url: message_path, local: true do |f| %>
<%= f.text_field :message, class:"address-new-form" %>
<%= f.hidden_field :room_id, value: @room.id %>
<%= f.submit "送信", class:"register-green-btn" %>
<% end %>
- f.hidden_field :room_id, value: @room.id
- hidden_fieldはブラウザに表示されていませんが、メッセージ送信時にroom_idをパラメーターに持たせてコントローラーに渡しています。どのルームに紐づくメッセージなのかを分かるようにしています。
Admin側コントローラー/ビューファイル
基本的な構成仕様はUserと同様です。
class Admins::RoomsController < Admins::ApplicationController
before_action :set_room, only: :show
def index
@reservations = Reservation.includes(:user).order(day: "DESC")
end
def show
@message = Message.new
@messages = Message.where(room_id: @room.id)
# 来店日を3日以上過ぎている予約判定の為、変数定義
@close_reservation = @room.reservation if @room.reservation.day < Date.today - 2
end
private
def set_room
#予約に紐づくルームを@roomに定義
@room = Room.find_by(reservation_id: params[:id])
# roomが存在するか判定し、存在しない場合にreservation_idを渡して作成する
if @room.nil?
@room = Room.new(reservation_id: params[:id])
redirect_to rooms_path, alert: 'メッセージルームに入れませんでした' unless @room.save
end
end
end
- authenticate_admin!
- User側のroomsコントローラーとは異なり、`before_action :authenticate_admin!`を定義していません。admins/roomsコントロラーでは、継承しているadmins/applicationコントローラーが`before_action :authenticate_admin!`を定義している為です。
- @close_reservation = @room.reservation if @room.reservation.day < Date.today - 2
- メッセージのやり取りが行える期間を過ぎている場合にインスタンス変数に定義し、ビューファイル側で処理を分ける
private
def message_params
params.require(:message).permit(:message, :room_id).merge(admin_id: current_admin.id)
end
end
```html:app/views/rooms/index.html.erb
<h1>Adminチャットルームの一覧</h1>
<% @reservations.each do |reservation| %>
<div>
<%= reservation.user.nickname %>
<%= reservation.day %>
<%= link_to "DM", admins_room_path(reservation) %>
</div>
<% end %>
<h1>Adminチャットルームの詳細</h1>
ルームID<%= @room.id %>
<% if @messages.blank? %>
<p>メッセージはありません</p>
<% else%>
<p>メッセージがあります</p>
<% @messages.each do |message| %>
<div>
<% if message.user %>
<span><%= message.user.nickname %>:</span>
<% else %>
<span>スタッフ:</span>
<% end %>
<span><%= message.message %></span>
</div>
<% end %>
<% end %>
<% if @close_reservation %>
<p>来店日を3日以上過ぎている為、メッセージ送信はできません</p>
<% else %>
<%= form_with model: [:admins, @message], url: admins_message_path, local: true do |f| %>
<%= f.text_field :message, class:"address-new-form" %>
<%= f.hidden_field :room_id, value: @room.id %>
<%= f.submit "送信", class:"register-green-btn" %>
<% end %>
<% end %>
完成!!
ざっと以上で、メッセージをやり取りする機能を実装できました!!CSSをほとんど当ててませんし、メッセージの即時更新も出来ておりませんが、本日はココまでとしておきます!
今後の実装内容としては、デザイン・レイアウトを整えて、メッセージの即時反映などを進めていきたいと思います!!!
それではまた〜