###はじめに
2020年3月より某プログラミングスクールにてRailsを学び同8月よりRailsエンジニアとして勤務しております。
2020年の6月に学習の成果というところで、下記の記事を書かせて頂いたところ、
想像以上の閲覧数、LGTMを頂きました。本当にありがとうございます。
しかしながら読み返してみると非常にわかりづらい、コードもぐちゃぐちゃ、今の自分でもよくわからないと感じまして、
今回機能を作り直して改めて
ハッシュタグ機能の実装方法の記事を書きます。
※上記記事と実装方法がかなり異なります。
###この記事での目的
・Gemを使わずにハッシュタグ機能を実装する。
・Arrayのメソッドの使用に慣れる。
・Hashの使用に慣れる。
###この記事ではやらないこと
・modelでのhas_many、belongs_toの使用。
・migration_fileでの明示的(referenceの使用など)な外部キーの設定。
・Arrayメソッドの詳しい解説(こちらは調べてください。。)
・ターミナルで打つコマンドの説明。
・イケてるデザインの実装。
###前提としてお伝えしておきたいこと
休みに概ね1日で実装をしたので高度な設計などはしてません。
一応こういう考え方で実装できますよ。というところをお伝えできればと思っております。
###実装の方針
DBのレコードの情報をそのまま使用せずに使いやすいオブジェクトに変換をして、Viewに表示をする。
#####通常
①モデルからメインのレコードを取得
②必要に応じて①のIDなどで他のモデルからレコードを取得。
③表示
この流れが一般的ですが、今回は以下のような流れで表示まで持っていきます。
#####本記事
①モデルからレコードを取得
②必要に応じて①のIDなどで他のモデルからレコードを取得。
③、①と②を組み合わせたハッシュを作成する。
④、③のデータを表示する。
#####メリット(主観)
処理が煩雑にならない。
最初に大量データを取得してHashオブジェクトに変換するため、都度DBにクエリを走らせなくてよい。
このやり方だからではないのかもしれませんが、実装方針と流れが決まっているのでデバッグが楽。
ArrayとHashの取り扱いに慣れることが出来る。
#####デメリット(主観)
大量のデータ(モデル.all)をDBから1回で取得する必要があるため、レコードがとても多くなったときにDBからのレスポンスが落ちる可能性がある。
それではやっていきましょう。
###実行環境
・Ruby version 2.5.7
・Rails version 5.2.4.4
・DB sqlite3
##完成図
投稿の際に#をつけて投稿することで、
#から始まる文字列がハッシュタグとして保存される。
また、投稿本文の中に存在している#から始まる文字列は表示されない。
ハッシュタグはリンクとなっており、クリックをすることでそのハッシュタグが埋め込まれた投稿一覧を取得できる。
※みなさんが想像するハッシュタグ機能ですのでご安心を。
##作成するファイル一覧
####Model
・post.rb #投稿保存用のモデル
・post_hashtag.rb #投稿とハッシュタグの中間テーブル用のモデル
・hashtag.rb #ハッシュタグ保存用のモデル
#####上記作成用のマイグレーションファイル
####Controller
・posts_controller.rb #投稿全般のアクションを行います。
・hashtags_controller.rb #ハッシュタグに紐づいた投稿を表示、ハッシュタグの一覧を表示など。
####外部メソッドファイル
・controllers/concerns/hashtag_methods.rb #自作メソッドが多いため切り分けて作成しました。
####Views
・posts/index #投稿一覧
・posts/new #投稿ページ
・posts/edit #投稿編集ページ
・posts/show #投稿詳細ページ
・hashtags/index #ハッシュタグ一覧ページ
・hasgtags/show #ハッシュタグに紐づいた投稿一覧ページ
####Routes
routes.rbにて下記を記載
resources :posts #postsコントローラー作成後記入
resources :hashtags, only: [:index, :show] #hashtagsコントローラー作成後記入
##①Modelの作成
###post.rb(投稿テーブル)
class Post < ApplicationRecord
validates :title, presence: true
validates :caption, presence: true
attachment :image #gem refileを使用しているため記入。ハッシュタグ実装には関係ない。
end
####マイグレーションファイル(posts)
class CreatePosts < ActiveRecord::Migration[5.2]
def change
create_table :posts do |t|
t.integer :user_id
t.string :title #投稿のタイトルを入力
t.string :caption #投稿の内容を入力。ここにハッシュタグが入力される前提。
t.string :image_id #画像投稿用
t.timestamps
end
end
end
投稿に一応user_idと画像を持たせたいのでこのような構造になっております。
特になくても大丈夫です。
後述しますが、必要になるのはcaptionカラムのみです。
###hashtag.rb(ハッシュタグ保存テーブル)
class Hashtag < ApplicationRecord
#特別な設定はしておりませんが念の為バリデーションはかけても良いと思います。
end
####マイグレーションファイル(hashtag)
class CreateHashtags < ActiveRecord::Migration[5.2]
def change
create_table :hashtags do |t|
t.string :name #ハッシュタグが保存されるカラム
t.timestamps
end
end
end
###post_hashtag.rb(中間テーブル)
class PostHashtag < ApplicationRecord
validates :post_id, presence: true
validates :hashtag_id, presence: true
end
####マイグレーションファイル(post_hashtag)
class CreatePostHashtags < ActiveRecord::Migration[5.2]
def change
create_table :post_hashtags do |t|
t.integer :post_id
t.integer :hashtag_id
t.timestamps
end
end
end
冒頭でも書きましたが、has_manyなどは一切使いません。
また、マイグレーションファイルで外部キーなどの細かい設定も一切せずに実装します。
中間テーブルにはpost_idとhashtag_idが保存されます。
多対多のリレーションになるので、中間テーブルは必須です。
一つの投稿に複数のハッシュタグが保存されますし、一つのハッシュタグには複数の投稿が紐づかなければなりません。
####例えばPostモデルからHashtagを取得する場合
1、Postのidを判別する。
2、中間テーブルから、1のidが入っているレコードを検索、取得する。
3、2で取得したレコードに含まれるhashtag_idを取得する。
4、3で取得したhashtag_idからHashtagテーブルに対し検索をかけ、レコードを取得する。
このような流れになります。
####逆にHashtagモデルからPostを取得する場合
1、Hashtagのidを判別する。
2、中間テーブルから、1のidが入っているレコードを検索、取得する。
3、2で取得したレコードに含まれるpost_idを取得する。
4、3で取得したpost_idからPostテーブルに対し検索をかけ、レコードを取得する。
ざっくりとした中間テーブルの使用方法です。
これより下記にて作成するメソッドでは上記のような流れを意識して作成をしております。
##②コントローラーの作成
###posts_controller.rb(投稿)
class PostsController < ApplicationController
include HashtagMethods
before_action :authenticate_user!
def index
@posts = Post.all
@hashtags = Hashtag.all
@post_hashtags = PostHashtag.all
@post_objects = creating_structures(posts: @posts,post_hashtags: @post_hashtags,hashtags: @hashtags)
end
def new
@newpost = Post.new
end
def show
@post = Post.find(params[:id])
related_records = PostHashtag.where(post_id: @post.id).pluck(:hashtag_id) #=> [1,2,3] idのみを配列にして返す
hashtags = Hashtag.all
@hashtags = hashtags.select{|hashtag| related_records.include?(hashtag.id)} #hashtagテーブルより中間テーブルで取得したidのハッシュタグを取得。配列に。
@display_caption = @post.caption.gsub(/[##][\w\p{Han}ぁ-ヶヲ-゚ー]+/,"") #実際に表示するキャプション。ハッシュタグが文字列のまま表示されてしまうので、#から始まる文字列を""に変換したものをViewにて表示
end
def edit
@post = Post.find(params[:id])
end
def create
@newpost = Post.new(post_params) #インスタンスの作成
@newpost.user_id = current_user.id
hashtag = extract_hashtag(@newpost.caption) #パラメーターのcaptionの中よりハッシュタグを抽出
@newpost.save! #一度投稿を保存
save_hashtag(hashtag,@newpost) #先ほど抽出したハッシュタグをハッシュタグテーブルへ、作成したpostのidとハッシュタグのidを中間テーブルへ保存
redirect_to posts_path
end
def update
@post = Post.find(params[:id])
strong_paramater = post_params
post_params["image"] = @post.image_id if strong_paramater["image"].to_s.length <= 2 #ハッシュタグの実装には関係ないです。画像情報が空で渡ってきた場合は前に保存してある画像をセットするというものです。
delete_records_related_to_hashtag(params[:id]) #こちらのメソッドで中間テーブルとハッシュタグのレコードを削除
@post.update(post_params)
hashtag = extract_hashtag(@post.caption) #投稿よりハッシュタグを取得
save_hashtag(hashtag,@post) #ハッシュタグの保存
redirect_to posts_path
end
def destroy
post = Post.find_by(id: params[:id]) #削除対象のレコード
post.destroy #投稿を削除
delete_records_related_to_hashtag(params[:id]) #中間テーブルとハッシュタグのレコードを削除
redirect_to posts_path
end
private
def post_params
params.require(:post).permit(:title, :caption,:image)
end
end
今回は保存と表示用に多くのメソッドを自作しているので、外部ファイルにまとめてからコントローラーで呼び出しました。
ファイルの最上部で読んでいる外部ファイルはこちらです。
module HashtagMethods
extend ActiveSupport::Concern
#--------------ハッシュタグ抽出処理 create update アクションの中で実行 ----------------
def extract_hashtag(caption)
if caption.blank? #例外処理のため。引数が空で渡ってきた場合は処理をしない
return
end
# 入力された文字列の中より、#で始まる文字列を配列にして返す
return caption.scan(/[##][\w\p{Han}ぁ-ヶヲ-゚ー]+/) #=> ["#aaa","#bbb"]
end
#--------------ハッシュタグ保存処理 create update アクションの中で実行 ----------------
def save_hashtag(hashtag_array,post_instance)
if hashtag_array.blank? #ハッシュタグを付けずに投稿された時、下のメソッドを実行させないようにする。
return
end
hashtag_array.uniq.map do |hashtag|
# ハッシュタグは先頭の#を外し、小文字にして保存
tag = Hashtag.find_or_create_by(name: hashtag.downcase.delete('#'))
#-------中間テーブルへの保存処理--------
post_hashtag = PostHashtag.new #中間テーブルのインスタンスを作成
post_hashtag.post_id = post_instance.id
post_hashtag.hashtag_id = tag.id
post_hashtag.save!
end
end
#---------ハッシュタグの情報をPostオブジェクトに含めるメソッド------------
def creating_structures(posts: "",post_hashtags: "",hashtags: "")
#引数として必要なのはPostのデータ、中間テーブルの全データ、ハッシュタグの全てのデータです。
#このメソッドはPostのActiveRecordインスタンスをハッシュに変換し、更に一つ一つのオブジェクトに対し、idに紐づくハッシュタグを配列として格納するメソッドです。
array = [] #最終戻り値用
posts.each do |post|
hashtag = [] #中間テーブルのID情報から探したハッシュタグを格納するための配列
post_hash = post.attributes #ActiveRecordインスタンスをハッシュに変換 { key => val, key=> val}
related_hashtag_records = post_hashtags.select{|ph| ph.post_id == post.id } #中間テーブルより投稿idが一致するレコードを取り出す
related_hashtag_records.each do |record|
hashtag << hashtags.detect{ |hashtag| hashtag.id == record.hashtag_id } #上記レコードをもとにハッシュタグを検索し、配列に格納
end
post_hash["hashtags"] = hashtag #投稿一つ一つのデータに['hashtags']のkeyを追加、そこにハッシュタグのデータを格納する
array << post_hash #=> [{"id"=>1, "title"=>"aaaa", "caption"=>"#aaaa", "created_at"=>Sun, 02 May 2021 15:13:42 UTC +00:00, "updated_at"=>Sun, 02 May 2021 15:13:42 UTC +00:00, "user_id"=>1, "image_id"=>"e347a197a5c2e6466db2d5b1673792c0a7b3a37460b1dea00f36b8b366a6", "hashtag"=>[#<Hashtag id: 1, name: "aaaa", created_at: "2021-05-02 15:13:42", updated_at: "2021-05-02 15:13:42">}]
end
return array
end
#---------ハッシュタグの情報をハッシュタグテーブルと中間テーブルから削除するメソッド------------
def delete_records_related_to_hashtag(post_id)
relationship_records = PostHashtag.where(post_id: post_id) #中間テーブルのレコード
if relationship_records.present? #中間テーブルにレコードが保存されていれば
relationship_records.each do |record|
record.destroy #中間テーブルのレコードを削除する
end
end
all_hashtags = Hashtag.all
all_related_records = PostHashtag.all
all_hashtags.each do |hashtag|
#ハッシュタグに紐づくレコードが中間テーブルに保存されていなければ、ハッシュタグを削除する
if all_related_records.none?{ |record| hashtag.id == record.hashtag_id }
hashtag.destroy
end
end
end
end
こちら外部ファイルを作成して、コントローラーの一番上にてincludeすることにより外部ファイルのメソッドが呼び出せるようになります。
controllers/concernsの直下に好きな名前で作成して頂き、そのファイルをコントローラーでincludeしてください。
各メソッドの目的をざっくりと下記にまとめます。
####extract_hashtag(caption)
引数(caption)に入ってきた文字列から、先頭が#で始まる文字列を配列にして返すメソッドです。
####save_hashtag(hashtag_array, post_instance)
ハッシュタグをハッシュタグテーブルに保存する、そして中間テーブルへの保存処理を行っております。
引数で渡ってきたハッシュタグの配列を、重複しないようにハッシュタグテーブルに保存の後、
引数で渡ってきたpost_instance(Post.new)のようなオブジェクトのidをハッシュタグのidとセットにして中間テーブルに保存します。
ですので保存の処理は、
①extract_hashtagにてハッシュタグを抽出。②save_hashtagの引数に①のハッシュタグと①の時に作っているであろうPost.newの値を渡す→保存のような流れになります。
####creating_structures(posts: "",post_hashtags: "",hashtags: "")
投稿と一緒にハッシュタグを表示するために作成したメソッドです。
「構造体を作成する」という意味です。
indexやshowにて投稿を表示したとして、中間テーブルのそのまた向こうにいるハッシュタグをどう取得するかを考えると結構難しいと言いますか、なんか大変そうな気がします。
それこそさっき述べた下記の流れを1オブジェクトに対して毎回行わなければなりません。
1、Postのidを判別する。
2、中間テーブルから、1のidが入っているレコードを検索、取得する。
3、2で取得したレコードに含まれるhashtag_idを取得する。
4、3で取得したhashtag_idからHashtagテーブルに対し検索をかけ、レコードを取得する。
クエリもとんでもない量になりますし、なにより面倒ということで、Post.allの一つ一つのレコードに対してハッシュタグを入れ込んでしまう(合体させてしませえば)いいという考えから作成したメソッドになります。
ActiveRecordInstance(Post.find(params[:id])とかで返ってくるオブジェクト)を一度ハッシュにして、一つ一つのレコードというか、値に対して、新しくhashtagsというkeyを与えます。
そしてそのkeyの中に中間テーブル経由で取得したハッシュタグを入れ込み、最後は大きな配列にまとめて返します。
(post_hash["hashtags"] = hashtag)
array << post_hash
このarray << post_hashのコメントにあるように、hashtagsというkeyの中に配列を埋め込みます。
####delete_records_related_to_hashtag(post_id)
コメントにあるように投稿を削除した時に関連するレコードをまとめて削除するメソッドです。
モデルでよく記載されている dependent destoryを手作業でやったイメージです。
投稿を消す→中間テーブルのレコードを消す
その後、ハッシュタグテーブルのレコード一つ一つが中間テーブルに保存されているかを調べます。
もし中間テーブルに保存されていなければ、それは関連する投稿がないということですのでハッシュタグも削除します。
showアクションにて使用している.pluckというのはActiveRecordインスタンスに使用するメソッドです。
こちらは引数にシンボルで与えた値(カラム名)を配列にして返すというメソッドです。
詳しくは下記記事にて
##③PostのView
<h2>ハッシュタグが埋め込まれている投稿一覧</h2>
<div style = "display: flex; flex-wrap: wrap;">
<% @post_objects.each_with_index do |post,i| %>
<%
display_caption = post["caption"].gsub(/[##][\w\p{Han}ぁ-ヶヲ-゚ー]+/,"")
%>
<div style="border: 1px solid black;margin: 2%;padding: 3%;width: 25%;">
<%= attachment_image_tag @posts[i],:image,style: 'width: 40%;' %>
<div style="border-bottom: 1px solid">
<p style="color: red">title</p>
<%= post["title"] %>
</div>
<div>
<p style="color: red">caption</p>
<p>
<%= display_caption %><br>
<% if post["hashtags"].present? %>
<% post["hashtags"].each do |hashtag| %>
<%= link_to "##{hashtag.name}", hashtag_path(hashtag.id) %>
<% end %>
<% end %>
<p>
</div>
<%= link_to "詳細", post_path(id: post["id"]) %>
<%= link_to "編集", edit_post_path(id: post["id"]) %>
<%= link_to "削除", post_path(id: post["id"]),method: :delete %>
</div>
<% end %>
</div>
@post_objectsには先ほど外部メソッド(creating_structures)で作ったオブジェクトが格納されております。
こちらはハッシュの形でデータが渡ってきますのでtitleもpost.titleではなくpost["title"]という記載になるところに注意が必要です。
display_captionというのはcaptionの中に保存されてしまっているハッシュタグを非表示にしたものです。
gsubを使用し第一引数にマッチするものを第二引数(””)に変換しております。
またattatchment_image_tagを使用する際にはどうやらハッシュの情報ではダメらしく、やむを得ずハッシュに変換する前のActiveRecordのインスタンスを使用しております。
<h2>投稿詳細</h2>
<div style="border: 1px solid black;margin: 2% 0;padding: 3%;">
<%= attachment_image_tag @post,:image,style: 'width: 40%;' %>
<div style="border-bottom: 1px solid">
<p style="color: red">title</p>
<%= @post.title %>
</div>
<div>
<p style="color: red">caption</p>
<p>
<%= @display_caption %><br>
<% if @hashtags.present? %>
<% @hashtags.each do |hashtag| %>
<%= link_to "##{hashtag.name}", hashtag_path(hashtag.id) %>
<% end %>
<% end %>
<p>
</div>
<div>
<%= link_to "編集", edit_post_path(id: @post.id) %>
<%= link_to "削除", post_path(id: @post.id),method: :delete %>
</div>
</div>
投稿詳細ページはindexと違い、ハッシュの形を使用しておりませんので従来のpost.titleという形で情報を出力しております。
<h2>投稿ページです</h2>
<%= form_with model:@newpost, local: true do |f| %>
<div>
<p>画像を選択</p>
<%= f.attachment_field :image %>
<p>タイトル入力</p>
<%= f.text_field :title %>
<br>
<p>投稿内容入力</p>
<%= f.text_area :caption, size:"55x12" %>
<%= f.submit "新規投稿" ,class:'postimage-new-button' %>
</div>
<% end %>
手抜きですみません・・・
<h1>投稿の編集</h1>
<%= form_with model:@post, local: true do |f| %>
<div>
<p>画像を選択</p>
<%= f.attachment_field :image %>
<p>タイトル入力</p>
<%= f.text_field :title %>
<br>
<p>投稿内容入力</p>
<%= f.text_area :caption, size:"55x12" %>
<%= f.submit "更新" ,class:'postimage-new-button' %>
</div>
<% end %>
editにて何故ハッシュタグが表示されるかというと、ハッシュタグはPostモデルのキャプションの中に含まれる文字列をハッシュタグに変換をして裏側で保存するという処理を行っており、キャプションの中に入力されたハッシュタグは表示する際に削除をしているのではなく、単に非表示にしているからです。
##④続いてhashtag_controller
class HashtagsController < ApplicationController
include HashtagMethods
def index
hashtags = Hashtag.all.select(:id,:name) #全てのハッシュタグを取得
hashtag_count = PostHashtag.all.group(:hashtag_id).count #中間テーブルのレコードをhashtag_id毎にグループ化し、数を取得(Viewにて数を表示したいため)
@hashtags = []
hashtags.each_with_index do |hashtag,i| #普通にeach doでも大丈夫です。
hashtag = hashtag.attributes #ハッシュに変換
hashtag["count"] = hashtag_count[hashtag["id"]] #countというkeyを増やし、中間テーブルの数の情報を格納する
@hashtags << hashtag #配列に格納
end
if @hashtags.length > 1
@hashtags = @hashtags.sort{ |a,b| b["count"] <=> a["count"]} #表示はハッシュタグが使用されている投稿の多い順にする
end
end
def show
post_hashtags = PostHashtag.all
relationship_records = post_hashtags.select{ |ph| ph.hashtag_id == params[:id].to_i}.map(&:post_id) #中間テーブルの全レコードより、該当ハッシュタグIDが含まれるものを取得→post_idを配列に格納 #=> [1,3]
all_posts = Post.all
@posts = all_posts.select{ |post| relationship_records.include?(post.id)} #中間テーブルの情報が含まれるPostのレコードを取得する
@post_objects = creating_structures(posts: @posts,post_hashtags: post_hashtags ,hashtags: Hashtag.all) #取得したレコードをハッシュに変換し、ハッシュタグを一つ一つのハッシュに格納する。
end
end
こちらでもincludeにて外部ファイルを呼び出して、hashtag_methods.rbのメソッドを使用できるようにしております。
一度ハッシュに変換をして、そのデータにハッシュタグに紐づく投稿の件数を入れる処理をしています。
##⑤Views(hashtag)
<h2>ハッシュタグ一覧</h2>
<% @hashtags.each do |hashtag| %>
<%
name = hashtag["name"]
count = hashtag["count"]
id = hashtag["id"]
%>
<p>
<%= link_to "##{name}(#{count})", hashtag_path(id) %>
</p>
<% end %>
最低限の機能です。
紐づく投稿が何件なのかが()の中に表示されています。
冗長なコードになるのもあれなので、name、count、idという変数に情報を格納しています。
<h2>ハッシュタグが埋め込まれている投稿一覧</h2>
<div style = "display: flex; flex-wrap: wrap;">
<% @post_objects.each_with_index do |post,i| %>
<%
display_caption = post["caption"].gsub(/[##][\w\p{Han}ぁ-ヶヲ-゚ー]+/,"")
%>
<div style="border: 1px solid black;margin: 2%;padding: 3%;width: 25%;">
<%= attachment_image_tag @posts[i],:image,style: 'width: 40%;' %>
<div style="border-bottom: 1px solid">
<p style="color: red">title</p>
<%= post["title"] %>
</div>
<div>
<p style="color: red">caption</p>
<p>
<%= display_caption %><br>
<% if post["hashtags"].present? %>
<% post["hashtags"].each do |hashtag| %>
<%= link_to "##{hashtag.name}", hashtag_path(hashtag.id) %>
<% end %>
<% end %>
<p>
</div>
<%= link_to "詳細", post_path(id: post["id"]) %>
<%= link_to "編集", edit_post_path(id: post["id"]) %>
<%= link_to "削除", post_path(id: post["id"]),method: :delete %>
</div>
<% end %>
</div>
posts/indexと同じコードです。
これにて完成です。
###まとめ
表示をしたいオブジェクトは自分の使いやすい形で作成してしまおう!!
という考えのもと機能を作成しました。
Postのレコードの中にハッシュタグの情報が無いなら、ハッシュオブジェクトに変換して、ハッシュタグを追加する。
ハッシュタグのレコードの中に紐づく投稿数の情報が無いなら、こちらもハッシュオブジェクトに変換して、投稿数の情報を追加するといった具合です。
####終わりに
長々と読んでくださりありがとうございました。
僕が当初ハッシュタグ機能を実装した際にはRails(フレームワーク)のヘルパーを多く使用したことで、できたけどよく分からないというような状態になってしまったのもあり、今回は地道な実装の方法を取ってみました。
自由度の高いRubyですので、使いやすいデータを自分で作ることも容易いと感じます。
かなり高速で作ったので見つけていないバグがあったり、ここ無駄だよねってところは是非教えてください。
また質問もお待ちしております。