対象読者
- 本に関するRailsのポートフォリオを作っている方々
- Google Books APIを用いたポートフォリオを作っている方々
外部APIによるリソースを使ったサービスのシステム設計に関する話です。
元々の設計
- users, books, likesの3つのテーブルがありました。
- booksの情報はGoogle Books APIから取得し、いちいちbooksテーブルに保存する設計にしていました。
likesテーブル
id | user_id | book_id |
---|---|---|
1 | 1 | 1 |
2 | 1 | 5 |
3 | 2 | 1 |
4 | 2 | 4 |
5 | 6 | 1 |
6 | 1 | 4 |
割と標準的な設計かと思います。
何を考えたか
- Google Books APIの情報をbooksテーブルにいちいち登録するのは冗長じゃないか?と考えました。
- likesテーブルに登録するbook_idを、Google Books APIのIDに変えれば解決!と思いました。
likesテーブル
id | user_id | book_id |
---|---|---|
1 | 1 | W_6FAwAAQBAJ |
2 | 1 | o0JuxK5_1esC |
3 | 2 | W_6FAwAAQBAJ |
4 | 2 | CrzcDwAAQBAJ |
5 | 6 | W_6FAwAAQBAJ |
6 | 2 | CrzcDwAAQBAJ |
CrzcDwAAQBAJ
みたいなのは、Google Books APIにおけるIDです。
全部の本にユニークについています。
考えられうるメリット
- いちいち本の情報をDBに登録する必要がない
- APIから常に新しい情報を取得できるようになる
- 後々の、本に関しての機能追加の際に、新しいテーブルを作らなくて済む
やったこと(結論だけを見たい方は飛ばしてください)
1. books/:id
の:id
にGoogle Books APIのIDを入れれば、books#show
にてAPIを叩く
class BooksController < ApplicationController
def show
@book = GoogleBook.new(googlebooksapi_id: params[:id])
end
end
自分の過去の記事で似たようなことしてるので、興味があればご参照ください。
【Ruby on Rails】Google Books APIを叩く際の5つのTips
2. likesテーブルのinteger型→string型に変える
class ChangeTypeOfBookId < ActiveRecord::Migration[5.2]
def change
remove_foreign_key :likes, :books
remove_index :likes, :book_id
change_column :likes, :book_id, :string
end
end
実際に行いたいのはchange_column :likes, :book_id, :string
でしたが、外部キー制約がついたbook_idの型は変えられないようでした。
なので一度book_idの外部キー制約を外し、型をinteger→stringに変えることに成功しました。
3. 既存のlikesテーブルのbook_idを、Google Books APIのIDに変換するrake taskを作成
namespace :update_book_id_to_googlebooksapi_id do
desc 'likesテーブルのbook_idカラムをGoogle Books APIのIDに変更する'
task update: :environment do
Like.transaction do
Like.all.each do |like|
new_id = like.book.googlebooksapi_id
like.update!(book_id: new_id)
end
end
end
end
デプロイ時にこれを実行すれば良いだろう、と画策していました。
4. Likeモデルのbelongs_toにオプションを付ける
Likeのモデルテストを走らせてみましたが、以下の失敗が発生。
ActiveRecord::RecordInvalid: バリデーションに失敗しました: Bookを入力してください
class Like < ApplicationRecord
belongs_to :book
belongs_to :user
end
どうやらLikeモデルのbelongs_to :book
は外部キー制約におけるヴァリデーションと同じような働きをしてくれるみたいです。
以下のように修正し、テストの失敗を解消させました。
class Like < ApplicationRecord
belongs_to :book, optional: true
belongs_to :user
validates :book_id, presence: true
end
5. has_many :like_books, through: likes, source: :bookが使えないので、自力で実装
class User < ApplicationRecord
has_many :likes, dependent: :destroy
has_many :like_books, through: :likes, source: :book
def like(book)
like_books << book
end
def unlike(book)
like_books.destroy(book)
end
def like?(book)
like_books.include?(book)
end
end
source: :bookのbooksテーブルなんてものは使わないというのが今回の方針のため、この便利なhas_manyメソッドは使えません。
ここは仕方なく、似たようなメソッドを自力で実装しました。
class User < ApplicationRecord
has_many :likes, dependent: :destroy
def like(book)
likes.create(book_id: book.googlebooksapi_id)
end
def unlike(book)
like = likes.find_by(book_id: book.googlebooksapi_id)
like.destroy
end
def like?(book)
likes.all.to_a.map(&:book_id).include?(book.googlebooksapi_id)
end
end
あとは各所を新設計通りに直して、一応完成はしました。
これでbooksテーブルは不要になる!と思いきや・・・
問題点
1. APIを複数回叩くことになり、コストがやばくなった
例えば、「お気に入り(like)した本の一覧」を表示する画面を想像してみてください。
その本の数だけ、Google Books APIを叩く必要があります。
これにより、ページのロード時間が許容できないレベルとなってしまいました。
Google Books APIが、一度叩けば複数のリソースにアクセスできる、という仕様であればこの問題は解消します。しかし、現状ではそういう仕様ではないため、複数回叩く必要性が生じ、この問題は避けられません。
2. Google Books APIに依存しすぎている
例えば、Google Books APIに大きな仕様変更があり、IDに破壊的な変更が加えられた場合。
その場合、likesテーブルにあったbook_idは全て意味のないデータと化します。
本を主題にしたサービスであれば、サービス終了レベルの被害を被ります。
そうならないためにも、本のAPIは他にも種類がありますし、他のAPIからも情報が取得できる方が、安心できそうです。
となると、やはり本の情報は自前のデータベースである程度持っておいた方が良いということになりそうです。
3. APIの回数制限にひっかかる
また、Google Books APIは1日1000回までという制限があるため、この仕様だとすぐに制限がかかってしまうと考えられます。
というか、サービスとしてスケールしたときを考えると、Google Books APIは回数が少なめですね(今更)
4. 実装を通じ、あまり標準的な設計と思えなかった
これはそこまで大きい問題ではないのですが、Railsのレールに乗っかれてないとは感じました。
特にhas_many :like_books
が使えないと知った時には、レールから外れてる感を強く感じました。
ある程度標準的な設計の方が、機能実装もリファクタリングも楽に実現できるだろうと思います。
以上4つの問題点から、この新設計は取り止めにし、元の設計に戻しました。
教訓
外部APIによる情報は必ずしも自分のDBで情報を持つべきだ、というわけではないではありません。
しかし、少なくとも今回のようなGoogle Books API + 本に関するサービスにおいては、
- 複数のリソースをまとめて、安いコストで取る方法がない
- サービスの根幹を支える情報であるため、外部APIを使うには依存度が高くなりすぎる
- APIの回数制限が厳しい
という性質から、自前データベースでもテーブルとして情報を持っておくべきだと考えました。