GoogleBooksAPIだけで本リソースの取得をする設計を行い、失敗した話

## 対象読者

- 本に関するRailsのポートフォリオを作っている方々
- Google Books APIを用いたポートフォリオを作っている方々

外部APIによるリソースを使ったサービスのシステム設計に関する話です。

## 元々の設計

- users, books, likesの3つのテーブルがありました。
- booksの情報はGoogle Books APIから取得し、いちいちbooksテーブルに保存する設計にしていました。

![book_db.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/496036/64d41402-1a89-c481-7313-b99f17d244b2.png)

##### 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に変えれば解決！と思いました。

![book_db2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/496036/6fba6aaa-87cd-0388-249a-7221b757a427.png)

##### 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を叩く

```ruby:app/controllers/books_controller.rb
class BooksController < ApplicationController
  def show
    @book = GoogleBook.new(googlebooksapi_id: params[:id])
  end
end
```

自分の過去の記事で似たようなことしてるので、興味があればご参照ください。
[【Ruby on Rails】Google Books APIを叩く際の5つのTips](https://qiita.com/kumackey/items/bd369a23360c94452d33)

#### 2. likesテーブルのinteger型→string型に変える

```ruby:db/migrate/20202020202020_change_type_of_book_id.rb
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を作成

```ruby:lib/tasks/update_book_id_to_googlebooksapi_id.rake
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を入力してください
```

```ruby:app/models/like.rb
class Like < ApplicationRecord
  belongs_to :book
  belongs_to :user
end
```

どうやらLikeモデルの`belongs_to :book`は外部キー制約におけるヴァリデーションと同じような働きをしてくれるみたいです。
以下のように修正し、テストの失敗を解消させました。

```ruby:app/models/like.rb
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が使えないので、自力で実装

```ruby:app/models/user.rb
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メソッドは使えません。
ここは仕方なく、似たようなメソッドを自力で実装しました。

```ruby:app/models/user.rb
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は１日1000回までという制限があるため、この仕様だとすぐに制限がかかってしまうと考えられます。
というか、サービスとしてスケールしたときを考えると、Google Books APIは回数が少なめですね（今更)

### 4. 実装を通じ、あまり標準的な設計と思えなかった

これはそこまで大きい問題ではないのですが、Railsのレールに乗っかれてないとは感じました。
特に`has_many :like_books`が使えないと知った時には、レールから外れてる感を強く感じました。
ある程度標準的な設計の方が、機能実装もリファクタリングも楽に実現できるだろうと思います。

以上4つの問題点から、この新設計は取り止めにし、元の設計に戻しました。

## 教訓

外部APIによる情報は必ずしも自分のDBで情報を持つべきだ、というわけではないではありません。
しかし、少なくとも今回のようなGoogle Books API + 本に関するサービスにおいては、

- 複数のリソースをまとめて、安いコストで取る方法がない
- サービスの根幹を支える情報であるため、外部APIを使うには依存度が高くなりすぎる
- APIの回数制限が厳しい

という性質から、自前データベースでもテーブルとして情報を持っておくべきだと考えました。