想定読者
- Railsで読書系ポートフォリオを作っている方
$ ruby -v
ruby 2.6.5
$ rails -v
Rails 5.2.4.1
その1.APIを叩くロジックはcontrollerから切り分ける
まず以下記事(私の前記事です)のようにAPIを叩くわけですが、これをcontrollerに書いたらあっという間にFat controllerになりました。
Ruby on RailsでGoogle Books APIを叩く
APIを叩くロジックは、以下を参考にmodlueとしてapp/libに置きました。
Rails 5 で自作のモジュールを読み込む方法
APIを叩く自作module
module GoogleBooksApi
def get_json_from_url(url)
JSON.parse(Net::HTTP.get(URI.parse(Addressable::URI.encode(url))))
end
# ①検索するAPIを叩く
def url_from_keyword(keyword)
"https://www.googleapis.com/books/v1/volumes?q=#{keyword}&country=JP&maxResults=20"
end
# ②IDから本の情報を取得するAPIを叩く
def url_from_id(googlebooksapi_id)
"https://www.googleapis.com/books/v1/volumes/#{googlebooksapi_id}"
end
end
controllerにincludeする
class BooksController < ApplicationController
include GoogleBooksApi
(略)
end
books_controller内で、GoogleBooksAPIというmoduleをincludeしたので、そのmoduleの関数が使えます。
app/libというディレクトリが自作なので、
「そもそもgoogle_books_api.rb
を読み込んでくれるの?」
と不安に思うかもしれません。
しかし、実はRailsでは自動的にapp/〇〇(ディレクトリ)/〇〇.rb
を読み込んでくれるという仕様があるようです。
※ さらに階層が深くなるとNGみたいです。
その2.APIの構造のうち、itemを理解する
Google Books APIからは下記のように色々な情報が入ってるので、APIに慣れていないと混乱するかもしれません。
https://www.googleapis.com/books/v1/volumes?q=Rails
先に結論を書きます。
① https://www.googleapis.com/books/v1/volumes?q=#{keyword}
では以下が取り出せます。
{ (他のハッシュ),
"items" => [{ item1 }, { item2 }, { item3 }, ....] }
②https://www.googleapis.com/books/v1/volumes/#{googlebooksapi_id}
では以下が取り出せます。
{ item }
①本を検索するAPIも、②IDから本情報を取得するAPIも、結局itemを取り出せるわけです。
(このitemの中に、本1つの情報が入っています)
このitemに対してのロジックを書くだけで、①本を検索するAPIに対しても、②IDから本情報を取得するAPIに対しても、共通のロジックを使うことができます。
よって①本を検索するAPIに対しては、以下のように処理するのが良いと思われます。
(検索するAPIから得たjson文字列)["items"].each do |item|
(itemに対するロジック)
end
その3.検索時にはActiveRecordのオブジェクトを作らないようにする
これは私がやってしまったアンチパターンです。
最終的には検索した本をActionViewで使う際に、
render @books
あるいは
@books.each do |book|
のような繰り返し処理を書きたいかと思います。
その際に、以下のようにActiveRecordのオブジェクトを大量生成するロジックを作ってしまいました。
ActiveRecordから@booksを作る(アンチパターン)
def search
@books = []
(items).each do |item|
(中略)
@books << Book.new(
author: (itemから引っ張ってきた著者)
title: (itemから引っ張ってきたタイトル)
(その他 略)
)
end
end
Fat controllerになる以外にも、これの問題点は2つあります。
- ActiveRecordのインスタンス生成(
Book.new
)はコストが高いのに、それを繰り返し処理させている - モデルと同じattirbuteを使わなくてはいけない
1の解説
実際にやってみるとわかります。
検索し始めてから結果が表示されるまで5〜10秒くらいかかってて、UXが悪かったです。
参考(理解はできてません笑):
ActiveRecordのパフォーマンス・チューニング
2の解説
例えば検索結果としては詳細な情報が表示できるようにしたいが、アプリのDBに保存させるつもりは無い、というような場合があります。
パッと思いつくのは、
averageRating: 4.0
amount: 3960.0
みたいな情報でしょうか。レーティングや値段はその時々で変わるので、DBに保存しようとは思わないでしょう。
こういう場合は検索結果表示のときだけbook.averageRating
でレーティングを返せるようにし、DBには保存はしない、という設計が思いつきます。
しかし、ActiveRecordを使うとDBにも同一カラムが存在する必要がある、というわけです。
2つの問題点の原因
実は1,2とも原因は共通していて、要はActiveRecordはO/Rマッパーであるからです。
- 検索結果はDBに保存するわけではありません(=DBを使いません)
- ActiveRecordはView <-> DBの仲介役(O/Rマッパー)です。
- よってActiveRecordを使う必要はありません。(少なくともそういう設計になってません)
ということになります。
その4.APIの情報は、オレオレクラス内に格納する
その3に対して、では具体的にどうするかをお伝えします。
結論から言うと、以下のようなオレオレクラスを作成しました。
オレオレクラス
class GoogleBook
attr_reader :googlebooksapi_id, :author, :buy_link, :description, :image, :published_at, :title
class << self
include GoogleBooksApi
def new_from_id(googlebooksapi_id)
url = url_of_creating_from_id(googlebooksapi_id)
item = get_json_from_url(url)
new(item)
end
def search(keyword)
url = url_of_searching_from_keyword(keyword)
json = get_json_from_url(url)
books = []
if items = json['items']
items.each do |item|
books << GoogleBook.new(item)
end
end
books
end
end
def initialize(item)
@item = item
@volume_info = @item['volumeInfo']
retrieve_attribute
end
def retrieve_attribute
@googlebooksapi_id = @item['id']
@author = @volume_info['authors'].first
@buy_link = @item['saleInfo']['buyLink']
@description = @volume_info['description']
@image = @volume_info['imageLinks']['smallThumbnail']
@published_at = @volume_info['publishedDate']
@title = @volume_info['title']
end
end
重要なポイントはitemを引数にinitializeができるようにすることです。
その2でも述べたように、itemに対して同じ処理をするように心がければ、共通のロジックを用いることができます。
使用例
これにより、以下のようにオブジェクト志向っぽく扱えるようになります。
book = GoogleBook.new_from_id("axicQgAACAAJ")
book.title
=> "影響力の武器"
book.author
=> "ロバート・B. チャルディーニ"
books = GoogleBook.search("影響力の武器")
=> [ book1, book2, book3, .... ](例)
book = books.first
book.id
=> "axicQgAACAAJ"
book.title
=> "影響力の武器"
このクラスはインスタンス生成にかかるコストは大したことはありません。
よってその3のような、ActiveRecordのインスタンスを複数生成時に発生していたコストも解消できています。
注意点
books = GoogleBook.search
のbooksのクラスは、ただのArrayです。そのままではgem kaminariによるpaginateとか、render @books
とかが出来ません。
以下のように続けて書くことで、kaminariのpaginateを利用できます。
@books = Kaminari.paginate_array(books).page(params[:page])
その5.リソース登録時にはモデルにロジックを書く
追記: Fat Modelになってしまうので、あまりいい設計では無かったです。記事の下の方で追記いたしましたので、そちらをご参照いただければと思います。
上記で作ったGoogleBookクラスを使って、いざDBに本を保存するロジックを書こうとすると、これまたFat controllerになりがちです。
def create
google_book = GoogleBook.new_from_id(取ってきたid)
@book = Book.new(
author: google_book.author
title: google_book.title
(その他 略)
)
if @book.save
(以下略)
end
controllerというのは、DBの情報を知りすぎない、というのが良い設計らしいです。上のような書き方は「DBにこれとこれが入るんでしょ」って言ってしまっています。
実装
def create
google_book = GoogleBook.new_from_id(取ってきたid)
@book = current_user.books.build
@book = @book.substitute_for_googlebook(google_book)
if @book.save
(以下略)
end
def substitute_for_googlebook(google_book)
self.author = google_book.author
self.description = google_book.description
self.googlebooksapi_id = google_book.googlebooksapi_id
self.published_at = google_book.published_at
self.title = google_book.title
self.buy_link = google_book.buy_link
self.image = google_book.image
self
end
割とcontrollerはスッキリできたのでは無いでしょうか。
といいながら実はその5はあんまり自信無いです笑
もうちょっと上手くできる気がします。
追記
その5に関して、新たにいいアイディアが浮かびました。結論としては、その4のGoolgeBookクラスにリソース登録のロジックを書く、というものです。
class GoogleBook
(略)
def build_book_by_user(user)
user.books.build(
author: @author,
description: @description,
image: @image
googlebooksapi_id: @googlebooksapi_id,
published_at: @published_at,
title: @title,
buy_link: @buy_link
)
end
end
これにより、Bookクラス(ActiveRecord)のインスタンスが返ってきます。
def create
google_book = GoogleBook.new_from_id(取ってきたid)
@book = google_book.build_book_by_user(current_user)
if @book.save
(以下略)
end
createの処理も以前よりはオーソドックスかつスッキリした書き方にできると思います。