Rails Tutorialの第14章にある、拡張機能を作る件の続きです。
前回までで返信機能ができました。機能追加の2つ目、メッセージ機能を作ります。
##機能の要件を調査
チュートリアルには
Twitterでは、ダイレクトメッセージを行える機能がサポートされています。この機能をサンプルアプリケーションに実装してみましょう
とあるので、Twitterの機能を確認します。
(ヒント: Messageモデルと、新規マイクロポストにマッチする正規表現が必要になるでしょう)。
MessageモデルとはRailsの機能なのか?ネットで調べることにします。
まずはTwitterの機能確認です。
DMを送信できる条件で、相手が自分をフォローしている必要があります。
この機能は後で作ることにします。
既読を表示する機能もありますが、この機能は諦めます。
DMを受け取った際にプッシュ通知/SMS通知(ショートメール通知)/メール通知のいずれかが届くように設定できます。
この機能も諦めます。
また、特定のアカウントからのDMをミュートすることで、通知を受け取らないようにすることも可能です。
この機能も諦めます。
特定のアカウントからのDMを拒否する機能もあります。相手をブロックできます。この機能も諦めます。
受け取ったDMを削除することはできます。この機能は作ることにします。
送信者が送信後のDMを削除することはできません。
DMとpostは違う画面が作られています。
文字数の最大数は違うのか、調べたところ2015年7月までは140文字それ以降は1万文字でした。
今回は140文字にします。
送信先に複数を使えるか調べたところ、グループを作成して複数のアカウントと会話する機能がありました。2015年1月に出来たとのことなので、この機能は諦めます。
Twitterの機能が分かり、今回作る機能もイメージできました。
MessageモデルとはRailsの機能なのか調べましたが、それらしい記事は見つかりませんでした。チャット機能を自分で作る記事や、リアルタイムチャットを作る記事は見つかりましたが関係なさそうです。
##機能のまとめ
1.メッセージを送ることができる。
2.送信先は複数ではなく1人だけ。
3.文字数は140文字まで。
4.送信できるか制限はせず、誰にでも送信できる。この制限機能は後で作ることとする。
5.既読の表示機能は作らない。
6.DMを受信時の通知機能は作らない。
7.受信したDMを削除することができる。
8.送信者は送信したDMを削除することができない
###モデルの設計
モデルの仕様を作ります。
tutorialのMicropostを作るところを読み直します。13章では最初に13.1でモデルを作っています。
同様にDMのモデルを作ることにします。
列名 | 属性 |
---|---|
id | integer |
content | text |
sender_id | integer |
receiver_id | integer |
created_at | datetime |
updated_at | datetime |
図 DMモデル |
ここでreceiverもUserモデルと関係があります。この関係はfollowのmodelを作ったときと同じではと考え、tutorialを読み直します。
###user削除時の仕様を検討
relationでは、userを削除するとrelationも削除されます。DMでは削除しない仕様に決めました。この点は違います。
relationでは、unfollowするとrelationも削除されます。DMではDMを受信者が削除しても、そのDMが消えるわけではなく、送信者の画面からは消えません。受信者の画面に表示されなくなるだけです。これはどういうことか考えてみます。DMの削除とは、なかったことにするのではない、送った事実は残るわけです。メールと同じで、受信者が受信済みのメールを削除したときに、送信者の送信済みのメールは削除されないです。Twitterで試してみましたが、確かでした。
メールのように、DMが入ったバケツを考えます。送信者と受信者が別々のバケツを持つモデルです。DMを送ると、送信者のバケツに1通、受信者のバケツに1通を入れます。バケツ2個に全く同じメッセージを入れるというのが重複感があると感じました。
なのでバケツは1個にして、受信者が削除したことが分かるように、削除フラグを追加することにします。
trueかfalseを入れる属性をネットで調べ、booleanがあることと、必ずデフォルト値を入れるべきと分かりました。理由はrubyではnilとfalseが同じ扱いになるためです。
https://qiita.com/jnchito/items/a342b64cd998e5c4ef3d
変更後のモデルです。
列名 | 属性 |
---|---|
id | integer |
content | text |
sender_id | integer |
receiver_id | integer |
deleted | boolean |
created_at | datetime |
updated_at | datetime |
図 DMモデル |
###DMモデルの開発
トピックブランチを作ります。
ubuntu:~/environment/sample_app (master) $ git checkout -b create-dm
dmモデルを生成します。
ubuntu:~/environment/sample_app (create-dm) $ rails generate model dm content:text user:references
create db/migrate/20201102003220_create_dms.rb
create app/models/dm.rb
create test/models/dm_test.rb
create test/fixtures/dms.ymlrails generate mode dm content:text user:references
マイグレーションを変更します。
インデックスは、senderを指定して時系列で取り出す場合と、receiverを指定して時系列で取り出す場合の2通りが考えられたので作りました。
deletedフラグのnullの扱いは先ほどのネットの記事を参考にしました。
class CreateDms < ActiveRecord::Migration[5.1]
def change
create_table :dms do |t|
t.text :content
t.integer :sender_id
t.integer :reciever_id
t.boolean :deleted, default: false, null: false
t.timestamps
end
add_index :dms, [:sender_id, :created_at]
add_index :dms, [:receiver_id, :created_at]
end
end
データベースを更新します。
ubuntu:~/environment/sample_app (create-dm) $ rails db:migrate
UserとDMの関連づけをします。
tutorialの14.1.2 「User/Relationshipの関連付け」を読みます。
class User < ApplicationRecord
has_many :microposts, dependent: :destroy
has_many :active_relationships,class_name: "Relationship",
foreign_key: "follower_id",
dependent: :destroy
has_many :passive_relationships, class_name: "Relationship",
foreign_key: "followed_id",
dependent: :destroy
has_many :following, through: :active_relationships, source: :followed
has_many :followers, through: :passive_relationships, source: :follower
has_many :sent_dms,class_name: "Dm",
foreign_key: "sender_id"
has_many :received_dms,class_name: "Dm",
foreign_key: "receiver_id"
class Dm < ApplicationRecord
belongs_to :sender, class_name: "User"
belongs_to :receiver, class_name: "User"
end
関連を整理するために、図を書きます。
id | name |
---|---|
1 | Michael |
2 | Archer |
userモデル |
has_many
sender_id | receiver_id | content |
---|---|---|
1 | 2 | ... |
1 | 3 | ... |
dmモデル |
has_many
id | name |
---|---|
2 | Archer |
3 | ... |
userモデル | |
図 UserとDMの関連 |
###使えるようになるメソッド
使えるようになるメソッドは以下のとおりです。
メソッド | 用途 |
---|---|
user.sent_dms | Userが送ったDMの集合を返す |
sent_dms.sender | senderを返す |
sent_dms.receiver | receiverを返す |
user.sent_dms.create(receiver_id: other_user.id) | userと紐づけてDMを作 |
user.sent_dms.create!(receiver_id: other_user.id) | userと紐づけてDMを作る(失敗時にエラーを出力) |
user.sent_dms.build(receiver_id: other_user.id) | userと紐づた新しいDMオブジェクトを返す |
user.sent_dms.find_by(id:1) | userと紐づいていて、idが1のDMを返す |
コンソールで試してみます。dm1を作ります。
>> user1 = User.first
>> user2 = User.second
>> dm1 = user1.sent_dms.create(receiver_id: user2.id, content: "hoge dm1")
sender,receiverのUserオブジェクトが返されました。
>> dm1.sender
=> #<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2020-10-26 01:37:04", updated_at: "2020-10-26 01:37:04", password_digest: "$2a$10$2TZtcwmSTCfl9Bigz2nYGO8U1YA8ksfNXUr2O/fSGOY...", remember_digest: nil, admin: true, activation_digest: "$2a$10$EaQUKa6hfGEHosjnICR4VuYMxfOxunTOsPGQYUimNLn...", activated: true, activated_at: "2020-10-26 01:37:03", reset_digest: nil, reset_sent_at: nil, unique_name: "Example">
>> dm1.receiver
=> #<User id: 2, name: "Van Zemlak", email: "example-1@railstutorial.org", created_at: "2020-10-26 01:37:04", updated_at: "2020-10-26 01:37:04", password_digest: "$2a$10$H22BJeNVA3hYdEw/a5RArekRy73q/0AtvidwRiVpoUK...", remember_digest: nil, admin: false, activation_digest: "$2a$10$xm7AJE4Q3fzq3gi5tmVnyeld8wahxMHN/dE2Sn2jSUW...", activated: true, activated_at: "2020-10-26 01:37:04", reset_digest: nil, reset_sent_at: nil, unique_name: "Craig1">
userのDMのリストを検索、dmをidで検索します。
>> user1.sent_dms
Dm Load (0.2ms) SELECT "dms".* FROM "dms" WHERE "dms"."sender_id" = ? LIMIT ? [["sender_id", 1], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Dm id: 2, content: "hogehoge", sender_id: 1, receiver_id: 2, deleted: false, created_at: "2020-11-02 02:27:35", updated_at: "2020-11-02 02:27:35">, #<Dm id: 3, content: "hoge dm1", sender_id: 1, receiver_id: 2, deleted: false, created_at: "2020-11-03 00:21:40", updated_at: "2020-11-03 00:21:40">]>
>> user1.sent_dms.find_by(receiver_id: 2)
Dm Load (0.4ms) SELECT "dms".* FROM "dms" WHERE "dms"."sender_id" = ? AND "dms"."receiver_id" = ? LIMIT ? [["sender_id", 1], ["receiver_id", 2], ["LIMIT", 1]]
=> #<Dm id: 2, content: "hogehoge", sender_id: 1, receiver_id: 2, deleted: false, created_at: "2020-11-02 02:27:35", updated_at: "2020-11-02 02:27:35">
自分がrecieverのDMのリストが必要です。followedが同じ構造なので、tutorialを読みます。メソッドはありそうなのでコンソールで試します。
>> user2.received_dms
Dm Load (0.1ms) SELECT "dms".* FROM "dms" WHERE "dms"."receiver_id" = ? LIMIT ? [["receiver_id", 2], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Dm id: 2, content: "hogehoge", sender_id: 1, receiver_id: 2, deleted: false, created_at: "2020-11-02 02:27:35", updated_at: "2020-11-02 02:27:35">, #<Dm id: 3, content: "hoge dm1", sender_id: 1, receiver_id: 2, deleted: false, created_at: "2020-11-03 00:21:40", updated_at: "2020-11-03 00:21:40">]>
モデルはできましたが、要件に必要なメソッドが揃ったのかがあいまいと感じました。
Twitterの画面をもう一度見直します。DMの画面は親子の構造になっており、親の画面は過去にやり取りしたことがあるユーザーのリストです。ユーザーを選ぶとDMのやり取りが一覧化された画面です。
必要なメソッドとして、過去にやり取りしたことがあるユーザーのリストを返すメソッドです。
ネットでfindを検索したところ、whereでOR条件が使えると分かりました。
https://qiita.com/nakayuu07/items/3d5e2f8784b6f18186f2
コンソールで試します。
>> Dm.where(sender_id: 1).or(Dm.where(receiver_id: 1))
Dm Load (0.1ms) SELECT "dms".* FROM "dms" WHERE ("dms"."sender_id" = ? OR "dms"."receiver_id" = ?) LIMIT ? [["sender_id", 1], ["receiver_id", 1], ["LIMIT", 11]]
=> #<ActiveRecord::Relation [#<Dm id: 2, content: "hogehoge", sender_id: 1, receiver_id: 2, deleted: false, created_at: "2020-11-02 02:27:35", updated_at: "2020-11-02 02:27:35">, #<Dm id: 3, content: "hoge dm1", sender_id: 1, receiver_id: 2, deleted: false, created_at: "2020-11-03 00:21:40", updated_at: "2020-11-03 00:21:40">]>
>> Dm.where(sender_id: 2).or(Dm.where(receiver_id: 2))
Dm Load (0.1ms) SELECT "dms".* FROM "dms" WHERE ("dms"."sender_id" = ? OR "dms"."receiver_id" = ?) LIMIT ? [["sender_id", 2], ["receiver_id", 2], ["LIMIT", 11]]
=> #<ActiveRecord::Relation [#<Dm id: 2, content: "hogehoge", sender_id: 1, receiver_id: 2, deleted: false, created_at: "2020-11-02 02:27:35", updated_at: "2020-11-02 02:27:35">, #<Dm id: 3, content: "hoge dm1", sender_id: 1, receiver_id: 2, deleted: false, created_at: "2020-11-03 00:21:40", updated_at: "2020-11-03 00:21:40">]>
この検索では親子の画面の機能まではできません。どの相手のDMも全て混ざっています。
ここで、仕様を深堀りし切れていないことが分かりました。
DMの相手ごとに画面を作る機能は、後で作れれば作ることとして進めることにします。
###モデルのテスト
モデルのテストを作ります。
DMのバリデーションをMicropostを参考に作ります。tutorialの13.1.2 「Micropostのバリデーション」を読みます。
fixtureのDMのファイルの中はサンプルなので削除します。
class DmTest < ActiveSupport::TestCase
def setup
@sender = users(:michael)
@receiver = users(:archer)
@dm = Dm.new(content: "hogehoge1", sender_id: @sender.id, receiver_id: @receiver.id)
end
test "should be valid" do
assert @dm.valid?
end
test "sender should be present" do
@dm.sender_id = nil
assert_not @dm.valid?
end
test "receiver should be present" do
@dm.receiver_id = nil
assert_not @dm.valid?
end
test "contentr should be present" do
@dm.content = nil
assert_not @dm.valid?
end
test "contentr should be at most 140 characters" do
@dm.content = "a" * 141
assert_not @dm.valid?
end
end
micropostと同じ用にバリデーションを追加します。テストがGREENになりました。
class Dm < ApplicationRecord
belongs_to :sender, class_name: "User"
belongs_to :receiver, class_name: "User"
validates :content, presence: true, length: { maximum: 140 }
end
DMを作るときのメソッドを慣習的に正しいやりかたに変更します。
def setup
@dm = @sender.sent_dms.build(content: "hogehoge1", receiver_id: @receiver.id)
end
DMを新しい順に返すようにします。13.1.4 「マイクロポストを改良する」を読みます。
先にテストを書きます。
class DmTest < ActiveSupport::TestCase
...
test "order should be most recent first" do
assert_equal sent_dms(:most_recent), Dm.first
end
end
fixtureに親子関係のデータを作ります。
https://qiita.com/seimiyajun/items/ffefdfc74b9fce76a538
を参考にしました。
morning:
content: "Good morning!"
sender: michael
receiver: archer
created_at: <%= 10.minutes.ago %>
created_atの順に並ぶように設定します。テストがGREENになりました。
class Dm < ApplicationRecord
belongs_to :sender, class_name: "User"
belongs_to :receiver, class_name: "User"
default_scope -> { order(created_at: :desc) }
validates :content, presence: true, length: { maximum: 140 }
end
:destroyを追加するか考えます。
ユーザーが削除されたときには過去のDMは残る仕様に決めていました。
そのテストを追加します。リスト13.20を参考にします。
test "associated dms should not be destroyed" do
@user.save
@user.sent_dms.create!(content: "Lorem ipsum", receiver_id: users(:archer).id)
assert_no_difference 'Dm.count' do
@user.destroy
end
end
end
ここまででモデルはできました。
##所要時間
10/31から11/6までの8.5時間です。