【Ruby on Rails チュートリアル】サンプルアプリケーションにメッセージ機能を追加する

Ruby on Rails チュートリアル (Rails 5.1 第4版)14.4.1 サンプルアプリケーションの機能を拡張するを実践していきます。
今回はメッセージ機能を実装しました。

チュートリアルには以下のようなヒントが記載されています。

  • Messageモデルを追加する
  • 新規マイクロポストにマッチする正規表現を追加する

2つ目のヒントについてはチュートリアル側が想定している仕様が思いつかなかったので無視します。

仕様

Twitterのダイレクトメッセージ機能を参考に以下のように決定しました。

  • Messageモデルを追加する。
  • トークへの参加を表すMembershipモデルを作成する。
  • メッセージのやり取りを表すTalkモデルを作成する。
  • トークから退出することができる。
  • 最後の参加者の退出と同時にトークを削除する。
  • メッセージのやり取りは1対1とする。
  • メッセージは140文字まで。
  • 自分のメッセージを削除することができる。
  • ユーザーページにメッセージボタンを配置する。
  • Homeページに作成したトークの一覧を表示するタブを追加する。

f:id:iberiko665:20190217230158p:plain
完成イメージ(メッセージボタン)
f:id:iberiko665:20190219222019p:plain
完成イメージ(トークの一覧)
f:id:iberiko665:20190219222023p:plain
完成イメージ(トークページ)

実装

今回の仕様ではメッセージのやり取りは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_idtalk_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_attalk_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は複数のメッセージを発信でき、複数のトークに参加できるようにしたいので、messagesmembershipshas_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には複数の参加者が存在するので、membershas_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モデルの関連は同じです。
それぞれtalkuserbelongs_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

ルーティングの追加

モデルを作成したのでルーティングを追加していきます。 membershipmessagetalkありきで作成するので、talksmemberとして追加しています。

# 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について学習していきます。

Everyday Rails - RSpecによるRailsテスト入門

【Ruby on Rails チュートリアル】サンプルアプリケーションに返信機能を追加する

Ruby on Rails チュートリアル (Rails 5.1 第4版) を一通り完走したので、14.4.1 サンプルアプリケーションの機能を拡張するを実践していきます。
今回は返信機能を実装しました。

実装に当たってチュートリアルには以下のようなヒントが記載されています。

  • micropostsテーブルにin_reply_toカラムを追加する
  • including_repliesスコープをMicropostモデルに追加する
  • 一意のユーザー名を表す方法を追加する
    • idと名前を組み合わせる
    • ユーザー登録の項目に一意のユーザー名を追加する

仕様

  1. ユーザのidと名前を組み合わせて一意のユーザー名を表現する。(以下リプライ名とする)
    例. @1-michael-hartl (名前の空白はハイフンで置換)
  2. マイクロポストのcontentリプライ名 (半角スペース)で始まる場合、リプライとして処理する。
    例. @1-michael-hartl test content
  3. 自分へのリプライは出来ない。
  4. リプライは以下のユーザーのステータスフィードにのみ表示される。
    • 送信ユーザー
    • 送信ユーザーのフォロワー
    • 宛先ユーザー
  5. 宛先ユーザーに指定できるのは1リプライにつき1ユーザーのみ。

f:id:iberiko665:20181206030624p:plain
完成イメージ

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

次にリプライに対するバリデーションとして以下を追加します。

  1. 宛先ユーザーが存在している。
  2. 宛先ユーザーが有効化済みである。(User#activated = true)
  3. 宛先ユーザーとcontent_objectのリプライ名が一致している。
  4. 差出ユーザー(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