Rails初心者が勉強のためにサービスを作ってみた

初めての投稿になります。
スマホアプリ開発がメインですが、サーバーサイドの勉強のとしてRailsでサービスを作ったのでまとめました。
一通り終えてから一気に書いているため、抜けや誤りがあるかもしれませんがご容赦ください。

# 概要

## どんなもの？
[楽天ブックス書籍検索API][]を使って書籍検索をしたり、キーワードを登録しておいて日次バッチで検索して結果の更新があったら通知してくれるというもの。
定期的に新しい技術書を探すときに同じキーワードで探しているので、勝手にやってくれると助かるなと思ったので。

## 実装した機能
- ユーザー登録／編集
- ログイン／ログアウト
- APIキー登録
- 書籍検索
- キーワード登録
- お気に入り
- 管理画面
- メール送信
- バッチ処理（キーワード検索＆通知）

## 開発環境
- ruby 2.5.1p57
- rails 5.2.1
- psql (PostgreSQL) 10.5
- heroku 7.19.4

セットアップ時はこれが最新だったと思います。

## サーバー
[Heroku][]を使用することにしました。
理由はコストがかからないためです（金銭的にも時間的にも）。
Railsの勉強用、かつインフラの勉強がしたいわけではないのでこれで十分でした。

アドオンは下記のものを導入しています。

- [Heroku Postgres][]：データベース
- [Heroku Scheduler][]：バッチ処理
- [Send Grid][]：メールサーバー

## その他
- [Bootstrap4][]：画面構築用のフレームワーク

## 完成画面（一部）

<img width="974" alt="search.png" src="https://qiita-image-store.s3.amazonaws.com/0/42159/98cf9891-d5c5-2045-fcd9-643dc9d41658.png">

# 開発を始める前に

以下の準備を行っておく必要があります。

## Mac
開発PCには前述の開発環境をセットアップしておきます。
インストール方法はすでにいろいろ記事がありますので割愛します。

## Heroku
Herokuは初めて使うのでアカウントを用意します。
一部のアドオンの利用のため、クレジットカード登録も行います。
基本料金はないので課金されない範囲で使います。

## Git
HerokuではGitリポジトリが必要になります。
最初から公開を意識してGitHubに作成します。

## API
[Rakuten Developers][]のアカウントを用意し、アプリの登録を行います。

# データベース設計

データベースの構成を最初に見ていただくとイメージが湧きやすいと思いますので紹介しておきます。
ちゃんと設計して作成したわけではなく、手探りで作成していくうちに下図のようになりました。

<img width="623" alt="database.png" src="https://qiita-image-store.s3.amazonaws.com/0/42159/39b6a645-597b-87cf-3039-24c7d4485998.png">

# Railsアプリケーションの作成

ここから開発の作業に入っていきます。
おおむね下記の手順でプロジェクト作成からデプロイまでが行なえます。

1. GitHubリポジトリ作成／クローン
2. Railsアプリ作成
3. Gitコミット／プッシュ
4. Herokuログイン
5. Herokuアプリ作成 
6. Herokuへのデプロイ
7. Herokuアプリを開く

**コマンド**

```
# 2.　Railsアプリケーションの作成
rails new booksearch-XXXXX —database=postgresql
# 4.　ログイン
heroku login
# 5. アプリケーションの作成
heroku create
# 6. デプロイ
git push heroku master
# 7. アプリを開く
heroku open
```

ここまでくれば、ひとまず空のRailsアプリケーションがデプロイ出来たことになります。
あとは各機能を追加して適宜デプロイしていくだけです。

詳細な実装を紹介する前に、開発でよく使うコマンドを紹介します。
これらは実装の紹介では登場しませんが、実際には何度も使用しました。

## Railsコマンド

```
# データベースの作成
rake db:create
```
「rails new」の際に作られていると思いますが、ちょくちょく構成を見直して作り直してました。
本来カラム追加でいちから作り直す必要はないはずですが、初版なので作り直してました。

```
# テーブルの追加
rails db:migrate
```
モデルが追加されたときに実行するコマンドです。

```
# データベース削除
rails db:drop:all
```
いちからやり直したいときに削除します。

```
# データベースクライアントの起動
rails dbconsole
```
主にデバッグの際にデータを確認したいときに使います。SQLコマンドは割愛します。

```
# ルートを確認
rails routes
```
ルートの設定が怪しいときに確認するものです。

## Herokuコマンド

```
# ローカルで実行する
heroku local
```
ローカルでサーバーが立ち上がるという点で「rails server」と同じですが、ポートが異なります。
最終的にHerokuにデプロイするのでこちらを使ったほうが良さそう。

```
# developブランチの内容をデプロイする
git push heroku develop:master
```
開発中は結構需要あります。当然ｆｅａｔｕｒｅブランチでもプッシュされていればOKです。

```
# テーブルの作成
heroku run rake db:migrate
```
これはローカルの場合と同じで、モデルが追加されたら実行する必要があります。
カラム追加時に一旦アドオンごと消して再度追加してこのコマンドを、ということもしばしば。

# 各機能の実装

それでは各機能の実装を見てきましょう。

## APIキー登録

[楽天ブックス書籍検索API][]を呼び出すためのAPIキーを登録する機構を作ります。
ソースコードが非公開ならベタ書きでも良いのですが、今回は最初からソースコードは公開するつもりだったので外部から設定する形を取ります。
※Herokuの環境変数に設定するという方法も検討したのですが、後述のバッチ処理の際に読み出すことが出来なかったため最初に思いついたこの方法を採っています

Scaffoldでサクッと作ってしまいます。

```
rails generate scaffold api_key key:string
```

関連ファイルが一気に作成されたと思います。
テーブルを追加するためのコマンドも実行します。
APIキーを１件登録したいだけなので多少無駄がありますが、とりあえずAPIキーの登録ができるようになりました。
複数件登録できてしまいますが、目をつむります。

## 書籍検索

いちばん重要な部分です。
まずは、モデルを追加します。

```
rails generate model book title:string:index genre:string price:integer author:string publisher:string isbn:string caption:string sales_date:string item_url:string largeimage_url:string
```

一部のNullを許容しないカラムについては、db/migrage/xxx_create_books.rbにて「:null => false」を付与しておきます。
上記の修正を行ってから、先程と同様にテーブル追加のコマンドを実行します。

次に、APIを叩く処理を作ります。
最終的なコードをそのまま載せていますが、通信しているのはsearch()のNet::HTTP.startの部分になります。
ジャンルについては少し加工して使うようにしています。

```book_searcher.rb
require 'net/https'

class BookSearcher

  def search(word, page, genre_id)
    if genre_id.present?
      genre = genre_id
    else
      genre = 'null'
    end
    Rails.logger.debug('BookSearcher::search() genre=' + genre)
    params = URI.encode_www_form({applicationId: get_apikey, format: 'json', formatVersion: 2, keyword: word, hits: 20, page: page, booksGenreId: genre, sort: '-releaseDate'})
    uri = URI.parse("https://app.rakuten.co.jp/services/api/BooksTotal/Search/20170404?#{params}")
    
    response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
      http.open_timeout = 5
      http.read_timeout = 10
      http.get(uri.request_uri)
    end
    begin
      case response
      when Net::HTTPSuccess
        @result = JSON.parse(response.body)
      when Net::HTTPRedirection
        @message = "Redirection: code=#{response.code} message=#{response.message}"
      else
        @message = "HTTP ERROR: code=#{response.code} message=#{response.message}"
      end
    rescue IOError => e
      @message = "e.message"
    rescue TimeoutError => e
      @message = "e.message"
    rescue JSON::ParserError => e
      @message = "e.message"
    rescue => e
      @message = "e.message"
    end
    return @result
  end

  def get_books
    books = Array.new
    @result["Items"].each do |item|
      books << extract_item(item)
    end
    return books
  end

  def get_message
    return @message
  end

  def extract_item(item)
    return Book.new(
      title: item["title"],
      genres: extract_genre(item["booksGenreId"]),
      price: item["itemPrice"],
      author: item["author"],
      publisher: item["publisherName"],
      isbn: item["isbn"],
      caption: item["itemCaption"],
      sales_date: item["salesDate"],
      item_url: item["itemUrl"],
      largeimage_url: item["largeImageUrl"]
    )
  end

  private

  def get_apikey
    return Apikey.first.key
  end

  def extract_genre(param)
    genres = param.split("/")
    result = Array.new
    genres.each do |genre|
      result << genre[0,6]
    end
    return result.uniq
  end
end
```

次にコントローラを追加します。

```
rails generate controller search index
```

コントローラの実装は下記のようになります。

```search_controller.rb
class SearchController < ApplicationController

  def index
  end

  def search
    @keywd = params[:keywd]
    if params[:page].present?
      @page = params[:page]
    else
      @page = 1
    end
    searcher = BookSearcher.new
    @result = searcher.search(@keywd, @page, params[:genre])
    @books = searcher.get_books
    @message = searcher.get_message

    respond_to do |format|
      format.html { render 'search/index' }
      format.json { render json: @books, status: :ok }
    end
  end
end
```

:keywd、:page、:genreがビューから渡されるパラメータです。
ビューについては割愛しますが、検索条件も結果もindex.html.erbで表示するようにしました。
結果は２０件表示され、「NEXT」ボタンで２ページ目を読み込むようにしています。

ルートの設定は下記のようにしました。

```routes.rb
get 'search/index'
post 'search/books' => 'search#search'
```

これで書籍検索ができるようになりました。

## ユーザー登録／編集

ユーザーのところもScaffoldで作ってしまいます。

```
rails generate scaffold user username:string password_digest:string token:string role:integer email:string fcm_token:string enable_email_notify:boolean enable_fcm_notify:boolean
```

これまでと同様に、Nullを許容しないカラムについて「:null => false」を付与し、コマンドでテーブルを作成します。
password_digestについてはブラウザからアクセスされたときのDigest認証用、tokenはAPIからアクセスされたときのToken認証用のものです。

新規登録画面へのリンクは、このあと出てくるログイン画面に張り、編集画面へはマイページと上部のナビゲーションにリンクを張ります。

## ログイン／ログアウト

ログインコントローラを追加します。

```
rails generate controller login
```

コードは下記のようになります。一般的なサンプルにあるような内容です。

```login_controller.rb
class LoginController < ApplicationController
  skip_before_action :check_logined

  def auth
    usr = User.find_by(username: params[:username])
    if usr && usr.authenticate(params[:password]) then
      reset_session
      session[:usr] = usr.id
      redirect_to params[:referer]
    else
      flash.now[:referer] = params[:referer]
      @error = 'ユーザ名／パスワードが間違っています。'
      render 'index'
    end
  end

  def logout
    reset_session
    redirect_to '/'
  end
end
```

ユーザーごとにデータを管理することになるため、共通処理としてログイン確認をするための処理をapplication_controller.rbに記述しています。
ログイン画面では当然ながらログインチェックしないためにスキップさせています。

```application_controller.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  before_action :check_logined

  private

  def check_logined
    if session[:usr] then
      begin
        @current_user = User.find(session[:usr])
      rescue ActiveRecord::RecordNotFound
        reset_session
      end
    end
    unless @current_user
      flash[:referer] = request.fullpath
      redirect_to controller: :login, action: :index
    end
  end
end
```

ルートの設定は下記のようにしました。

```routes.rb
get 'login/index'
get 'logout' => 'login#logout'
post 'login/auth'
```

ここでもビューは省略させていただきます。

## お気に入り

一旦、Scaffoldで作ります。

```
rails generate scaffold favorite user:references book:references
```

同じく、Nullを許容しないカラムについて「:null => false」を付与し、コマンドでテーブルを作成します。
お気に入りについては、検索結果のリストから登録したいものを登録して、あとは参照・削除ができれば良いので、ビューのところにたくさん作られたファイルはindex.html.erbを残して削除します。

お気に入りの登録処理はSearchControllerに実装します。

```search_controller.rb
class SearchController < ApplicationController

  def favorite
    prm = Book.new(book_params)
    book = Book.find_by(title: prm.title)

    # 書籍をDBに登録
    unless book.present?
      if prm.save
        book = prm
      else
        @type = "error"
        @msg = "パラメーターエラー"
        # return
      end
    end

    favorite = Favorite.find_by(user_id: @current_user.id, book_id: book.id)
    if favorite.present?
      @type = "info"
      @msg = "登録済みです"
    else
      # お気に入りを登録する
      fav = Favorite.new(user_id: @current_user.id, book_id: book.id)
      if fav.save
        @type = "success"
        @msg = "登録しました"
      else
        @type = "warning"
        @msg = "登録できませんでした"
      end
    end
  end

  private

  def book_params
    params.permit(:title, :genres, :price, :author, :publisher, :isbn, :caption, :sales_date, :item_url, :largeimage_url)
  end

end
```

Bookモデルは検索結果を入れるために検索の実装時に作成しましたが、データベースとしては、お気に入り登録の際追加を行います。
これは、お気に入り一覧を表示する際に使うキャッシュとして機能します。
検索の時点で登録することも出来ますが、DBのレコード数が増えすぎないようにと考えるとこのタイミングが最適と考えたためです。

ルートの設定は下記のように追加しています。

```routes.rb
get 'favorites' => 'favorites#index'
delete 'favorites/:id' => 'favorites#destroy', as: 'favorite'
```
Scaffoldで生成したときの「resources :faivorites」を消して追加定義しているのでちょっとイレギュラーな定義になっています。

## キーワード登録

定期的に検索して更新があったら通知してもらうための検索キーワードの登録です。
これもScaffoldで作ります。

```
rails generate scaffold keyword user:references keyword:string genre:string item_count:integer
```

Nullを許容しないカラムの対応、コマンドでのテーブル作成は同様です。

## 画面の調整

各種機能をそれぞれ別途作成してきましたが、ここでそれらをつないで画面遷移できるようにします。
また、レイアウトについても多少は見栄えを良くします。
細かくは紹介しませんが、概ね下記の方針で調整しました。

- 共通のナビゲーションのビューを作成し、すべての画面に適用
- [Bootstrap4][]を使用して全体の体裁を整える
- 検索結果をテーブルで表示（お気に入り一覧も同じ体裁）
- トップページ、マイページを作り画面遷移を整える
- マイページはログインユーザー自身の情報のみ見える
- 管理画面は管理者だけアクセスでき、全情報を確認できる


以上まででWebアプリケーションとしては一通り動くものになりました。

ここからはバッチ処理とその結果による通知について紹介します。
登録したキーワードに対して毎日バッチ処理で検索を行って件数に変化があったら通知するというものです。
この機能により新しい技術書の発売をチェックできるはずです。

## メール送信

通知をメールで行う機能があるのでメール送信に対応させます。
Railsではメール送信の機能が用意されていて、対応は主に設定になります。
メール送信のサーバーは[Send Grid][]を使います。
[Send Grid][]はアドオンで、導入は無料ですが従量課金のため、クレカ登録が必要になります。

必要な作業は下記のとおりです。実際に送信するのは後述するバッチのところで紹介します。

1. メール設定を記載（development.rb／production.rb）
2. メーラーを生成
3. メーラーの編集（必要なら親クラスも）
4. メール本文の編集

メール設定は下記のような感じです。

```
config.action_mailer.raise_delivery_errors = true
config.action_mailer.delivery_method       = :smtp
config.action_mailer.default_url_options   = { host: 'xxxxx.herokuapp.com' }
ActionMailer::Base.smtp_settings           = {
    address:              'smtp.sendgrid.net',
    port:                 '587',
    authentication:       :plain,
    user_name:            ENV['SENDGRID_USERNAME'],
    password:             ENV['SENDGRID_PASSWORD'],
    domain:               'heroku.com',
    enable_starttls_auto: true
}
```

メーラー生成もrailsのコマンドで行います。

```
rails g mailer NotificationMailer notify_result_update
```

必要な関連ファイルが作成されましたね。
メーラーの中身は以下のようにしました。

```
class NotificationMailer < ApplicationMailer
  def notify_result_update(user, keyword, before_count, after_count)
    @user = user
    @keyword = keyword
    @before_count = before_count
    @after_count = after_count

    mail to: user.email,
         subject: '検索結果が更新されました'
  end
end
```

メール本文の編集は割愛しますが、HTLM版とテキスト版をそれぞれ編集します。
NotificationMailerのメンバーが参照できるので変更情報を記述します。

## バッチ処理

[Heroku Scheduler][]を使います。
アドオン自体は無料なのですが、従量課金のため、クレカ登録していないと使えません。
rakeのタスクを登録する仕組みで、実行のサイクルを、一日ごと、一時間ごと、１０分ごとから選べます。

２つのバッチを追加します。

1. キーワードの一覧をみて、実際に検索してみて、件数が変わってたら通知する（１日ごと）
2. 定期的にサイトにアクセスする（１時間ごと）

２つ目については、Herokuのスリープを回避するためのものでここでは説明は省きます。
アクセスがないとスリープしてしまい、起こすのに時間がかるので、無料の範囲で日中は起こしておこうというものです。
Herokuは３０分アクセスがないとスリープするので、１０分ごとでないと半分はスリープ状態ですが、、
「Heroku スリープ」あたりで検索するとそこそこ情報が得られます。

rakeのタスクは、lib/tasksの下にrakeファイルを追加します。

```scheduler.rake
desc "This task is called by the Heroku scheduler add-on"
task :notify_books_result_update => :environment do
  puts "notify_books_result_update start.."
  puts "get keywords..."
  keywords = Keyword.all
  keywords.each do |keyword|
    puts "get user..."
    user = User.find(keyword.user_id)
    if !user.enable_email_notify && !user.enable_fcm_notify
      puts "not notify"
      next
    end
    puts "search books..."
    searcher = BookSearcher.new
    result = searcher.search(keyword.keyword, 1, keyword.genre)
    count = result["count"].to_i
    if count == keyword.item_count
      puts "no change"
      next
    end
    if user.enable_email_notify
      puts "send mail..."
      NotificationMailer.notify_result_update(user, keyword.keyword, keyword.item_count, count).deliver
    end
    puts "update database..."
    keyword.item_count = result["count"]
    keyword.save
  end
end
```

FCMとあるのは、アプリへのプッシュ通知を想定しているためで、未実装です。

以上まででWebサービスとしては一応の完了となります。

Webサービスとしては動くものが出来ましたが、最終的にスマホアプリで通知を受けられるようにしたいので、スマホアプリ用にWebAPIとして機能するようにします。

## API化

やることは概ね以下の作業です。

- ルートになるコントローラを新たに作る
- 認証の仕組みを新たに作る
- 必要なAPI用のコントローラを追加
- ルートの設定も別途行う

### API用のルートのコントローラを作成

これまで作成してきたコントローラは、すべて「ActionController::Base」のサブクラスでしたが、APIは「ActionController::API」のサブクラスとなります。
なので、application_controller.rbとは別にapi_controller.rbを作成します。

```api_controller.rb
class ApiController < ActionController::API
end
```

### 認証の仕組みを作る

先程のApiControllerに認証の仕組みを追加します。
Token認証のために「ActionController::HttpAuthentication::Token::ControllerMethods」をincludeします。

```api_controller.rb
class ApiController < ActionController::API
  include ActionController::HttpAuthentication::Token::ControllerMethods

  before_action :authenticate!

  private

  def authenticate!
    authenticate_or_request_with_http_token do |token, options|
      User.find_by(token: token).present?
    end
  end

  def current_user
    @current_user ||= User.find_by(token: request.headers['Authorization'].split[1])
  end
end
```

ログイン機能は、ユーザー管理用のコントローラを作成しそこに追加します。
Railsのコマンドは使用しません。

```users_controller.rb
class Api::V1::UsersController < ApiController
  skip_before_action :authenticate!, only: [:login]

  def login
    @user = User.find_by(username: params[:username])
    if @user && @user.authenticate(params[:password])
      render json: @user
    else
      render json: { errors: ['ログインに失敗しました'] }, status: 401
    end
  end
end
```

### 必要なAPI用のコントローラを追加

詳細は割愛しますが、下記のコントローラを作成しました。

- users_controller.rb：認証、ユーザー管理
- search_controller.rb：検索
- keywords_controller.rb：キーワード管理
- favorites_controller.rb：お気に入り管理

継承関係や作成場所は先程のusers_controller.rbと同様です。
APIなので、処理結果はすべてJsonで返却するようにします。

### ルートの設定

ルートもAPI用に別途定義します。

```routes.rb
Rails.application.routes.draw do
  # Webアプリ
  （省略）

  # API
  namespace :api, { format: 'json' } do
    namespace :v1 do
      get 'users' => 'users#index'
      get 'users/me' => 'users#me'
      get 'users/:id' => 'users#show'
      post 'users' => 'users#create'
      put 'users/:id' => 'users#update'
      delete 'users/:id' => 'users#destroy'

      post 'users/login'
      get 'search' => 'search#search'

      get 'keywords' => 'keywords#index'
      post 'keywords' => 'keywords#create'
      put 'keywords/:id' => 'keywords#update'
      delete 'keywords/:id' => 'keywords#destroy'

      get 'favorites' => 'favorites#index'
      post 'favorites' => 'favorites#create'
      delete 'favorites/:id' => 'favorites#destroy'
    end
  end
end
```

# 今後

API化できたので、アプリを作って連携することを考えています。
アプリにプッシュ通知ができてはじめてサービスとして完成したと言えると考えています。
また、これから発売される書籍の通知登録も比較的容易に実現できそうです。
通知内容については、「変更があったこと」しか通知できていないので、内容が伝えられるようにすべきです。
ただ、あまりやり過ぎると無料の範囲で動かせなくなってしまう恐れも（APIの制限なども考えられる）。。

# 最後に

書籍のサンプルでよくあるネタ・レベルではありますが、多少は実用的・実践的なものがそこそこ簡単に作れたと思います。
全体的な紹介のため細かいところはかなり端折ってしまいましたが、ビューの部分や実際の実装についてはコードを参照していただければと思います（ツッコミどころは多々あると思います）。
いろいろな記事やサイトを参考にさせていただいたのですが、ちゃんと控えておらず、中途半端に調べて載せるのもかえって誤解を招く恐れもあるので記載していません。すみません。

ソースコートは[こちら][ソースコード]にあります。
サービスは継続的に運用していく予定はないので非公開です。

長々と失礼いたしました。


[Rakuten Developers]:https://webservice.rakuten.co.jp/
[楽天ブックス書籍検索API]:https://webservice.rakuten.co.jp/api/booksbooksearch/
[Heroku]:https://jp.heroku.com/
[Heroku Postgres]:https://elements.heroku.com/addons/heroku-postgresql
[Heroku Scheduler]:https://elements.heroku.com/addons/scheduler
[Send Grid]:https://elements.heroku.com/addons/sendgrid
[Bootstrap4]:https://getbootstrap.com/
[Heroku CLI]:https://devcenter.heroku.com/articles/heroku-cli
[ソースコード]:https://github.com/takayuki-hara/booksearch-rails