【Ruby on Rails チュートリアル】サンプルアプリケーションにメッセージ機能を追加する
Ruby on Rails チュートリアル (Rails 5.1 第4版) の14.4.1 サンプルアプリケーションの機能を拡張するを実践していきます。
今回はメッセージ機能を実装しました。
チュートリアルには以下のようなヒントが記載されています。
- Messageモデルを追加する
- 新規マイクロポストにマッチする正規表現を追加する
2つ目のヒントについてはチュートリアル側が想定している仕様が思いつかなかったので無視します。
仕様
Twitterのダイレクトメッセージ機能を参考に以下のように決定しました。
Message
モデルを追加する。- トークへの参加を表す
Membership
モデルを作成する。 - メッセージのやり取りを表す
Talk
モデルを作成する。 - トークから退出することができる。
- 最後の参加者の退出と同時にトークを削除する。
- メッセージのやり取りは1対1とする。
- メッセージは140文字まで。
- 自分のメッセージを削除することができる。
- ユーザーページにメッセージボタンを配置する。
Home
ページに作成したトークの一覧を表示するタブを追加する。
実装
今回の仕様ではメッセージのやり取りは1対1としていますが、今後の拡張を考えて複数人でのやり取りを考慮した実装を行っていきます。
例によってテスト等は記載していません。
ソースコードの全体を見たい方はこちら→GitLab
テーブルの作成
テーブル作成のためにマイグレーションファイルを作成していきます。
最初にtalks
テーブルを作成します。
特にフィールドはありません。
# db/migrate/20190118132605_create_talks.rb class CreateTalks < ActiveRecord::Migration[5.1] def change create_table :talks do |t| t.timestamps end add_index :talks, :updated_at end end
次にトークの参加を表すmemberships
テーブルを作成します。
user_id
とtalk_id
の組み合わせは一意にしたいのでインデックスにunique
オプションを付与しています。
# db/migrate/20190118132703_create_memberships.rb class CreateMemberships < ActiveRecord::Migration[5.1] def change create_table :memberships do |t| t.references :talk, foreign_key: true t.references :user, foreign_key: true t.timestamps end add_index :memberships, [:user_id, :talk_id], unique: true end end
最後にmessages
テーブルを作成します。
時系列順にメッセージの一覧を表示したいのでupdated_at
とtalk_id
にインデックスを追加しています。
# db/migrate/20190118132746_create_messages.rb class CreateMessages < ActiveRecord::Migration[5.1] def change create_table :messages do |t| t.references :talk, foreign_key: true t.references :user, foreign_key: true t.string :content t.timestamps end add_index :messages, [:updated_at, :talk_id] end end
user
モデルとtalk
モデルの関連
モデル作成の前にuser
モデルとtalk
モデルの関連を追加していきます。
user
は複数のメッセージを発信でき、複数のトークに参加できるようにしたいので、messages
、memberships
をhas_many
メソッドで定義しています。
また、参加しているトークをtalks
で取得できるように、has_many
メソッドで定義し、through
オプションにmemberships
を指定しました。
このあたりはチュートリアルのrelationship
モデルの関連付けを参考にしています。
# app/models/user.rb class User < ApplicationRecord ... has_many :memberships, dependent: :destroy has_many :talks, class_name: "Talk", through: :memberships has_many :messages, dependent: :destroy ... end
モデルの関連
テーブルができたので、モデルの関連を追加していきます。
先にuser
モデルとtalk
モデルの関連を追加したので、talk
モデルも同じように追加していきます。
talk
には複数の参加(membership
)と、メッセージを持たせたいので1対多の関係を表すhas_many
メソッドで定義しています。
また、talk
には複数の参加者が存在するので、members
をhas_many
メソッドで定義し、through
オプションにmemberships
を指定しています。
# app/models/talk.rb class Talk < ApplicationRecord has_many :memberships, dependent: :destroy has_many :members, class_name: "User", through: :memberships, source: :user has_many :messages, dependent: :destroy end
membership
モデルとmessage
モデルの関連は同じです。
それぞれtalk
とuser
をbelongs_to
メソッドで定義しています。
membership
# app/models/membership.rb class Membership < ApplicationRecord belongs_to :talk belongs_to :user end
# app/models/message.rb class Message < ApplicationRecord belongs_to :talk belongs_to :user end
モデルのバリデーション
続いてモデルのバリデーションを追加します。
talk
モデルにはフィールドが無いのでバリデーションもありません。
membership
モデルにはテーブル作成時に行った、ユーザーとトークの一意性のバリデーションを追加しています。
# app/models/membership.rb class Membership < ApplicationRecord ... validates :user_id, uniqueness: { scope: :talk_id } end
message
モデルではそれぞれのフィールドの存在性と、メッセージの文字数制限のバリデーションを追加しています。
# app/models/message.rb class Message < ApplicationRecord ... validates :talk_id, presence: true validates :user_id, presence: true validates :content, presence: true, length: { maximum: 140 } end
membership
モデルのafter_destroy
コールバック
仕様で定めた、最後の参加者の退出と同時にトークを削除する
を実装するために、memberships
が空であればtalk
を削除するメソッド(destroy_empty_talk
)を追加し、after_destroy
コールバックに指定しています。
# app/models/membership.rb class Membership < ApplicationRecord after_destroy :destroy_empty_talk ... private def destroy_empty_talk talk.destroy if talk.reload.memberships.empty? end end
ルーティングの追加
モデルを作成したのでルーティングを追加していきます。
membership
とmessage
はtalk
ありきで作成するので、talks
のmember
として追加しています。
# config/routes.rb Rails.application.routes.draw do ... resources :talks, only: [:show, :create] do member do post :memberships, :messages end end resources :memberships, only: :destroy resources :messages, only: :destroy end
コントローラーの作成
コントローラーを作成し、ルーティングで追加したアクションを実装していきます。
# app/controllers/talks_controller.rb class TalksController < ApplicationController before_action :logged_in_user before_action :correct_member, only: [:show, :messages] def show @messages = @talk.messages @message = Message.new end def create @talk = Talk.new @talk.memberships.build(user_id: current_user.id) @talk.memberships.build(user_id: params[:member_id]) @talk.save redirect_to @talk end def messages @message = Message.new(message_params) # トークの更新日時を更新 @talk.touch if @message.save redirect_to @talk else @messages = @talk.messages render "show" end end private def message_params params[:message].merge!({ user_id: current_user.id, talk_id: @talk.id }) params.require(:message).permit(:user_id, :talk_id, :content) end def correct_member @talk = current_user.talks.find_by(id: params[:id]) redirect_to root_url if @talk.nil? end def correct_user @message = current_user.messages.find_by(id: params[:id]) redirect_to root_url if @message.nil? end end
# app/controllers/memberships_controller.rb class MembershipsController < ApplicationController before_action :logged_in_user before_action :correct_user, only: :destroy def create @talk = Talk.find(params[:talk_id]) params[:guest_users].each do |guest_user| if @talk.member?(guest_user) else @talk.add_member(guest_user) end end end def destroy @membership.destroy flash[:success] = "Left the talk" redirect_back(fallback_location: root_url) end private def correct_user @membership = current_user.memberships.find_by(id: params[:id]) redirect_to root_url if @membership.nil? end end
# app/controllers/messages_controller.rb class MessagesController < ApplicationController before_action :logged_in_user before_action :correct_user, only: :destroy def destroy @talk = @message.talk @message.destroy flash[:success] = "Message deleted" redirect_to @talk end private def correct_user @message = current_user.messages.find_by(id: params[:id]) redirect_to root_url if @message.nil? end end
ビューの作成
home
ページにタブを追加し、フィードタブ、メッセージタブを追加しています。
# app/views/logged_in_home.html.erb ... <ul class="nav nav-tabs"> <li class="active"><a href="#feed-tab" data-toggle="tab">Micropost Feed</a></li> <li><a href="#talks-tab" data-toggle="tab">Messages</a></li> </ul> <div class="tab-content"> <div class="tab-pane active" id="feed-tab"> <%= render 'shared/feed' %> </div> <div class="tab-pane" id="talks-tab"> <%= render 'shared/talks' %> </div> </div> ...
チュートリアルのフィードページを参考に、トークの一覧をパーシャルとして作成します。
# app/views/shared/_talks.html.erb <% if @talks.any? %> <ol class="talks"> <%= render @talks %> </ol> <%= paginate @talks, param_name: :talks_page %> <% end %>
トーク本体です。
# app/views/talks/_talk.html.erb <li id="talk-<%=talk.id%>"> <% message = talk.latest_message %> <% membership = talk.memberships.find_by(user: current_user) %> <%= gravatar_for(message.nil? ? talk.memberships.first.user : message.user, size: 50) %> <%= link_to 'leave', membership_path(membership), class: "delete", method: :delete, data: { confirm: "You sure?" } %> <%= link_to(talk) do %> <span class="title"><%= talk.title(current_user, 50) %></span> <span class="content"><%= message.content unless message.nil? %></span> <span class="timestamp"> Posted <%= time_ago_in_words(talk.updated_at) %> ago. </span> <% end %> </li>
トークページです。 メッセージの一覧と、メッセージフォームを作成します。
# app/views/talks/show.html.erb <% title = @talk.title(current_user, 50) %> <% provide(:title, title) %> <h1><%= title %></h1> <div class="row" > <div class="col-md-8 col-md-offset-2"> <ul class="messages"> <%= render @messages %> </ul> <%= form_for @message, url: messages_talk_path(@talk), html: { class: 'message-form'} do |f| %> <%= render 'shared/error_messages', object: f.object %> <div class="field"> <%= f.text_area :content, class: "content" %> </div> <%= f.submit "Send", class: "btn btn-primary" %> <% end %> </div> </div>
メッセージ本体です。
# app/views/messages/_message.html.erb <li id="message-<%= message.id %>"> <% user = message.user %> <% class_suffix = current_user?(user) ? 'right' : 'left' %> <%= link_to gravatar_for(user, size: 50, additional_class: "gravatar-#{class_suffix}"), user %> <div class="balloon balloon-<%= class_suffix %>"> <% unless current_user?(user) %> <span class="user"><%= link_to user.name, user %></span> <% end %> <span class="content"><%= message.content %></span> <span class="timestamp"> Posted <%= time_ago_in_words(message.created_at) %> ago. <% if current_user?(user) %> <%= link_to "delete", message_path(message), class: "delete", method: :delete, data: { confirm: "You sure?" } %> <% end %> </span> </div> </li>
おわりに
前回の投稿からだいぶ時間が空いてしまいましたが、自分の仕様通りに実装できました。
次回は今まで作成したテストをRspec
で書き直していこうと思います。
まずは以下の書籍でRspecについて学習していきます。
【Ruby on Rails チュートリアル】サンプルアプリケーションに返信機能を追加する
Ruby on Rails チュートリアル (Rails 5.1 第4版) を一通り完走したので、14.4.1 サンプルアプリケーションの機能を拡張するを実践していきます。
今回は返信機能を実装しました。
実装に当たってチュートリアルには以下のようなヒントが記載されています。
- micropostsテーブルにin_reply_toカラムを追加する
- including_repliesスコープをMicropostモデルに追加する
- 一意のユーザー名を表す方法を追加する
- idと名前を組み合わせる
- ユーザー登録の項目に一意のユーザー名を追加する
仕様
- ユーザのidと名前を組み合わせて一意のユーザー名を表現する。(以下リプライ名とする)
例.@1-michael-hartl
(名前の空白はハイフンで置換) - マイクロポストの
content
がリプライ名 (半角スペース)
で始まる場合、リプライとして処理する。
例.@1-michael-hartl test content
- 自分へのリプライは出来ない。
- リプライは以下のユーザーのステータスフィードにのみ表示される。
- 送信ユーザー
- 送信ユーザーのフォロワー
- 宛先ユーザー
- 宛先ユーザーに指定できるのは1リプライにつき1ユーザーのみ。
Gemの追加
リプライの表示のためにactive_decoratorを利用しているのでGemfileを修正します。
# Gemfile gem 'active_decorator', '1.1.1'
実装
テスト等は記載していません。
ソースコードの全体を見たい方はこちら→GitLab
値オブジェクト(Value Object)の作成
まずはcontent
とリプライ名
の関係を表す値オブジェクトを作成します。
# app/values/micropost_content.rb class MicropostContent REPLY_CONTENT_REGEX = /\A(@(?<user_id>\d{1,})-(?<user_name>[^\s]+)\s)?/i def initialize(micropost_content) @micropost_content = micropost_content end def reply_name m = @micropost_content.match(REPLY_CONTENT_REGEX) ReplyName.new(m[:user_id], m[:user_name]) end def content @micropost_content.sub(REPLY_CONTENT_REGEX, '') end def reply? reply_name.valid? end def blank? content.blank? end end
リプライ名
はUser
モデルでも使用したいので別クラスに切り出します。
# app/values/reply_name.rb class ReplyName include Comparable attr_reader :user_id def initialize(user_id, user_name) @user_id = user_id @user_name = user_name end def <=>(other) to_s <=> other.to_s end def to_s "@#{user_id}-#{@user_name.gsub(/\s/, '-')}" end def valid? !!user_id && !!@user_name end end
次にcomposed_of
メソッドでモデルの仮想カラムとして利用できるようにします。
# app/models/micropost.rb composed_of :content_object, class_name: "MicropostContent", mapping: %w(content micropost_content)
# app/models/user.rb composed_of :reply_name, mapping: [ %w(id user_id), %w(name user_name) ]
これで以下のような記述が可能になります。
$ micropost = Micropost.first $ micropost.content_object.class #=> MicropostContent $ micropost.content_object.reply_name.class #=> ReplyName $ user = User.first $ user.reply_name.class #=> ReplyName
Micropostsテーブルにin_reply_toカラム(宛先ユーザー)を追加
以下のようなマイグレーションを作成しました。
# db/migrate/20181110102729_add_in_reply_to_column_to_microposts.rb class AddInReplyToColumnToMicroposts < ActiveRecord::Migration[5.1] def change add_reference :microposts, :in_reply_to, foreign_key: { to_table: :users } add_index :microposts, [:in_reply_to_id, :created_at] end end
foreign_key
で外部キー制約を付与していますが、今回のようにカラム名から参照先テーブルを推測できない場合はto_table
オプションで明示する必要があります。
Micropostモデルに関連付けを追加
belongs_to
メソッドを使ってin_reply_to
カラムの関連付けを行います。
宛先ユーザーはマイクロポストの場合nil
となるため、optional: true
を付与しています。
# app/models/micripost.rb belongs_to :in_reply_to, class_name: "User", foreign_key: "in_reply_to_id", optional: true
宛先ユーザーの取得は、リプライの場合のみbefore_validation
コールバックで行います。
# app/models/micripost.rb before_validation :assign_in_reply_to private def assign_in_reply_to if content_object.reply? self.in_reply_to = User.find_by(id: content_object.reply_name.user_id) end end
Micropostモデルにバリデーションを追加
まずはcontent
カラムに対するバリデーションを修正します。
リプライの場合、content
は以下のようになるため、リプライの内容
に対して存在性(presence
)のバリデーションを追加する必要があります。
リプライ名 (半角スペース)リプライの内容
validates
メソッドのpresence
オプションは対象オブジェクトのblank?
メソッドで判定している為、MicropostContent#blank?
メソッドを実装し、content_object
カラムに対してバリデーションするよう書き換えています。
# app/models/micropost.rb # validates :content, presence: true, length: { maximum: 140 } validates :content, length: { maximum: 140 } validates :content_object, presence: true
# app/values/micropost_content.rb def blank? content.blank? end
次にリプライに対するバリデーションとして以下を追加します。
- 宛先ユーザーが存在している。
- 宛先ユーザーが有効化済みである。(
User#activated = true
) - 宛先ユーザーと
content_object
のリプライ名が一致している。 - 差出ユーザー(
user
カラム)と宛先ユーザーが異なる。
複数のカラムが登場するためValidator
を作成します。
# app/validators/reply_validator.rb class ReplyValidator < ActiveModel::Validator def validate(record) if record.in_reply_to.nil? || !record.in_reply_to.activated # 1,2 record.errors.add('content', "User ID does not exist or account is anactivated.") elsif record.in_reply_to.reply_name != record.content_object.reply_name # 3 record.errors.add('content', "Reply Name is invalid.") elsif record.in_reply_to == record.user # 4 record.errors.add('content', "can not reply to myself.") end end end
Validator
の指定はvalidates_with
メソッドで行います。
リプライの場合のみ処理したいのでif
オプションを付与しています。
# app/models/micropost.rb validates_with ReplyValidator, if: -> { content_object.reply? }
Micropostモデルにリプライを含めたスコープを追加
ユーザーのid
を引数としたクラスメソッドとして定義しています。
# app/models/micropost.rb # リプライを含めたスコープ def self.including_replies(user_id) where("user_id = :user_id OR in_reply_to_id = :user_id", user_id: user_id) end
リプライはユーザーのステータスフィードに表示したいのでUser#feed
メソッドを修正します。
# app/models/user.rb # ユーザーのステータスフィードを返す def feed following_ids = "SELECT followed_id FROM relationships WHERE follower_id = :user_id" #Micropost.where("user_id IN (#{following_ids}) # OR user_id = :user_id", user_id: id) Micropost.where("user_id IN (#{following_ids})", user_id: id) .or(Micropost.including_replies(id)) end
リプライの表示
リプライはリプライ名
を対象ユーザーページへのリンクとして表示したいのでジェネレータでdecolator
を作成します。
$ rails g decorator micropost_decorator create app/decorators/micropost_decorator.rb invoke test_unit create test/decorators/micropost_decorator_test.rb
# app/decorator/micropost_decorator.rb module MicropostDecorator def decorated_content if reply? link_to("@#{in_reply_to.name}", in_reply_to) + " " + content_object.content else content end end end
view
ファイルを修正します。
# app/views/microposts/_micropost.html.erb <span class="content"> #<%= micropost.content %> <%= micropost.decorated_content %> # ここ <%= image_tag micropost.picture.url if micropost.picture? %> </span>
おわりに
チュートリアルを完走してから、手探りで実装を進めてきましたが何とか完成までこぎつけることができました。
できるだけシンプルなコードになるように実装しています。
コードについて不備等ありましたらコメントにてご指摘いただけると助かります。
参考記事
Value Object
Rails tips: Value Objectパターンでリファクタリング(翻訳)
Rails における値オブジェクトと ActiveRecord の composed_of - Qiita
before_validaton
Rails: :before_validationコールバックの逸脱した用法を改善する(翻訳)
Rails: `before_validation`コールバックで複雑なステートを正規化する(翻訳)
Validator
【Rails】カスタムバリデータの使い方 - TASK NOTES
【Rails】まだValidatorのテストで消耗してるの? - Qiita
Decorator
Rails Viewの表示のためにDecoratorを用意してHelperとModelを助ける - Qiita
ruby on rails - Test view by rspec with draper Decorator - Stack Overflow