私はRailsのメンターをしています。👨🏫
生徒さんからの質問で、自分が感覚的にやってたことに気がつく瞬間があります。
今回は、生徒さんからの質問にあった、
「配列オブジェクトを返すつもりが、配列の配列になってて、エラーになります!解消したいです!」
という内容について、お話しします。👨🎓
配列の配列とは?
例えば、下記のケースです。
def index
tags = Tag.where(name: params[:name])
@posts = tags.map do |tag| # これが配列の配列になっている。
tag.posts
end
@posts = Kaminari.paginate_array(@posts).page(params[:page])
end
@posts
が配列の配列になっていますので、KaminariでNo methodのエラーになります。
データとしては下記みたいな感じ。
[[postA,postB],[postC,postD]]
みなさん、今すぐ説明できますか?
一度考えてみてください。
結論
flat_mapメソッドを利用すれば解消できます。
なぜ配列の配列になるのか、なぜflat_mapで解消できるのか見ていきましょう。
def index
tags = Tag.where(name: params[:name])
@posts = tags.flat_map do |tag| # これで配列の配列が、フラットの配列になります。
tag.posts
end
@posts = Kaminari.paginate_array(@posts).page(params[:page])
end
※記事の最後に、パフォーマンスを考慮したコードを提示します。
実務で使う場合は、記事の最後まで注意深く読んで、最善の方法を採用してください。
配列の配列の問題点
配列の配列になってしまっている理由は、tag.postsが配列を返すメソッドであるためです。
tags.mapを使用して各tagに対してtag.postsを実行すると、このtag.postsはPostモデルのオブジェクトの配列(例: [post1, post2, ...])を返します。
そのため、tags.mapの結果はtagごとのPostの配列を要素とする配列となります。
例えば、3つのtagがあり、それぞれが2つのpostを持っているとします。
tags = [tag1, tag2, tag3]
ここで、
tag1.postsが[post1, post2]、
tag2.postsが[post3, post4]、
tag3.postsが[post5, post6]、
を返すとします。
tags.map { |tag| tag.posts }を実行すると、
次のような結果になります。
[[post1, post2], [post3, post4], [post5, post6]]
上記のように、外側の配列がtags.mapによって作られ、内側の配列はtag.postsの結果です。
これをフラットな配列にするために、flat_mapメソッドを使用できます。
これにより、次のような結果を得られます。
[post1, post2, post3, post4, post5, post6]
これが、配列の配列が生成される理由と、それをフラットな配列に変換する方法です。
パフォーマンスを考慮したコードに改善しよう!
先ほど紹介した、下記のex1コントローラーは、パフォーマンス的に良くない点が2箇所あります。
下記のex1とex2の違いを比べてみましょう。
def index
tags = Tag.where(name: params[:name])
@posts = tags.flat_map do |tag|
tag.posts
end
@posts = Kaminari.paginate_array(@posts).page(params[:page])
end
def index
@posts = Post.joins(:tag).where(tags: { name: params[:name] }).page(params[:page])
end
ex1とex2は、同じ@posts
を返しますが、ex2の方がパフォーマンスが高いです。以下に、ex1が劣る主要な3つの理由を説明します。
ex1がパフォーマンスで劣る、3つの原因
-
N+1問題
ex1のアプローチでは、まずTagモデルから関連するレコードを取得し、次にそれぞれのtagに対してtag.postsでSQLクエリを発行する必要があります。これは複数のSQLクエリを発行することを意味し、N+1問題として知られるパフォーマンスの落とし穴に該当します。 -
データ変換のオーバーヘッド
tag.postsが配列(ActiveRecord::Associations::CollectionProxyオブジェクト)を返すため、取得したデータを配列に変換し、その後再び配列をページネーションするために変換する必要があります。この連続した変換プロセスにより、不要なオーバーヘッドが発生しています。 -
メモリの使用量
ex1の方法では、@posts
で全てのポストデータを取得します。しかしながら、ページネーションで表示されるデータは@posts
の一部のみです。そのため、必要なデータのみを取得するex2の方法の方が、メモリ消費量の面で効率的です。
処理の違いを見てみよう
ex1について
def index
tags = Tag.where(name: params[:name]) # ①
@posts = tags.flat_map do |tag| # ②
tag.posts
end
@posts = Kaminari.paginate_array(@posts).page(params[:page]) # ③
end
-
指定した名前のTagを全て取得します。
例)
発行されるSQL: SELECT * FROM tags WHERE name = '指定した名前';
取得できるデータ: tags = [”りんご”,”ブドウ”,”いちご”,] -
それぞれのタグに関連付けられたポストを配列として取得し、最終的に1つの配列に結合します。
発行されるSQL:
(SELECT * FROM posts WHERE tag_id = りんごのID;
SELECT * FROM posts WHERE tag_id = ぶどうのID;
SELECT * FROM posts WHERE tag_id = いちごのID;)
取得できるデータ:@posts
= [”りんご”,”ブドウ”,”いちご”,] に関するpostデータ(例えば100件のpost) -
取得したポストの配列をページネーションのためにKaminari.paginate_arrayを使用して処理します。
取得できるデータ:@posts
= 1ページに表示させるpostのみを返す。(例えば10件のpost)
- ②の部分で、分かる通り、tagsの分だけ、SQLが発行されいます。(N+1問題)
- postsで100件データを取得しているが、ページネーションで10個のポスト/ページ であれば、90個のデータ取得が無駄になる。
- 計算途中のtagsや
@posts
でメモリや計算処理を無駄に消費しています。
続いて、ex2の処理を見ていきましょう。
ex2について
def index
@posts = Post.joins(:tag).where(tags: { name: params[:name] }).page(params[:page]) # ①
end
- 1つのSQLで、必要なポストのみを取得します。
例)
発行されるSQL:
SELECT posts.* FROM posts
JOIN tags ON posts.tag_id = tags.id
WHERE tags.name = '指定した名前'
LIMIT x OFFSET y;
取得できるデータ:@posts
= 1ページに表示させるpostのみを返す。(例えば10件のpost)
2つの比較
一目瞭然だと思いますが、ex2の方がパフォーマンスに優れていることが分かるでしょう。
ex2では、JOINを使用してPostとTagを結合し、1回のSQLクエリで必要な情報を取得します。これにより、データベースへのアクセス回数が大幅に削減され、パフォーマンスが向上させています。
ex1のような非効率な処理を避けるために気をつけること
-
データベースとのやり取りを最小限に
可能な限り少ない数のクエリで必要な情報を取得しましょう。N+1問題は特に注意が必要です。 -
ORMの特性を理解する
使用しているORM(ActiveRecord)の特性や提供しているメソッドをよく理解し、最も効率的なメソッドを使用しましょう。 -
ページネーション
大量のデータを取得する必要がある場合は、最初からページネーションを考慮してクエリを作成しましょう。これにより、不要なデータの取得やメモリの浪費を避けることができます。 -
適切なデータベースの設計
効率的なクエリを書くためには、適切なデータベースの設計が基盤となります。正規化、インデックスの設定など、データベースの設計段階での選択がクエリの効率に大きく影響します。
最後に
今回はこれで以上です。
説明が間違っている、わかりにくければ、コメント頂ければ嬉しいです!!