初めての投稿になります。
スマホアプリ開発がメインですが、サーバーサイドの勉強のとして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:画面構築用のフレームワーク
完成画面(一部)
開発を始める前に
以下の準備を行っておく必要があります。
Mac
開発PCには前述の開発環境をセットアップしておきます。
インストール方法はすでにいろいろ記事がありますので割愛します。
Heroku
Herokuは初めて使うのでアカウントを用意します。
一部のアドオンの利用のため、クレジットカード登録も行います。
基本料金はないので課金されない範囲で使います。
Git
HerokuではGitリポジトリが必要になります。
最初から公開を意識してGitHubに作成します。
API
Rakuten Developersのアカウントを用意し、アプリの登録を行います。
データベース設計
データベースの構成を最初に見ていただくとイメージが湧きやすいと思いますので紹介しておきます。
ちゃんと設計して作成したわけではなく、手探りで作成していくうちに下図のようになりました。
Railsアプリケーションの作成
ここから開発の作業に入っていきます。
おおむね下記の手順でプロジェクト作成からデプロイまでが行なえます。
- GitHubリポジトリ作成/クローン
- Railsアプリ作成
- Gitコミット/プッシュ
- Herokuログイン
- Herokuアプリ作成
- Herokuへのデプロイ
- 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
開発中は結構需要あります。当然featureブランチでもプッシュされていればOKです。
# テーブルの作成
heroku run rake db:migrate
これはローカルの場合と同じで、モデルが追加されたら実行する必要があります。
カラム追加時に一旦アドオンごと消して再度追加してこのコマンドを、ということもしばしば。
各機能の実装
それでは各機能の実装を見てきましょう。
APIキー登録
楽天ブックス書籍検索APIを呼び出すためのAPIキーを登録する機構を作ります。
ソースコードが非公開ならベタ書きでも良いのですが、今回は最初からソースコードは公開するつもりだったので外部から設定する形を取ります。
※Herokuの環境変数に設定するという方法も検討したのですが、後述のバッチ処理の際に読み出すことが出来なかったため最初に思いついたこの方法を採っています
Scaffoldでサクッと作ってしまいます。
rails generate scaffold api_key key:string
関連ファイルが一気に作成されたと思います。
テーブルを追加するためのコマンドも実行します。
APIキーを1件登録したいだけなので多少無駄がありますが、とりあえず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の部分になります。
ジャンルについては少し加工して使うようにしています。
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
コントローラの実装は下記のようになります。
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で表示するようにしました。
結果は20件表示され、「NEXT」ボタンで2ページ目を読み込むようにしています。
ルートの設定は下記のようにしました。
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
コードは下記のようになります。一般的なサンプルにあるような内容です。
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に記述しています。
ログイン画面では当然ながらログインチェックしないためにスキップさせています。
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
ルートの設定は下記のようにしました。
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に実装します。
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のレコード数が増えすぎないようにと考えるとこのタイミングが最適と考えたためです。
ルートの設定は下記のように追加しています。
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はアドオンで、導入は無料ですが従量課金のため、クレカ登録が必要になります。
必要な作業は下記のとおりです。実際に送信するのは後述するバッチのところで紹介します。
- メール設定を記載(development.rb/production.rb)
- メーラーを生成
- メーラーの編集(必要なら親クラスも)
- メール本文の編集
メール設定は下記のような感じです。
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のタスクを登録する仕組みで、実行のサイクルを、一日ごと、一時間ごと、10分ごとから選べます。
2つのバッチを追加します。
- キーワードの一覧をみて、実際に検索してみて、件数が変わってたら通知する(1日ごと)
- 定期的にサイトにアクセスする(1時間ごと)
2つ目については、Herokuのスリープを回避するためのものでここでは説明は省きます。
アクセスがないとスリープしてしまい、起こすのに時間がかるので、無料の範囲で日中は起こしておこうというものです。
Herokuは30分アクセスがないとスリープするので、10分ごとでないと半分はスリープ状態ですが、、
「Heroku スリープ」あたりで検索するとそこそこ情報が得られます。
rakeのタスクは、lib/tasksの下に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を作成します。
class ApiController < ActionController::API
end
認証の仕組みを作る
先程のApiControllerに認証の仕組みを追加します。
Token認証のために「ActionController::HttpAuthentication::Token::ControllerMethods」をincludeします。
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のコマンドは使用しません。
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用に別途定義します。
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の制限なども考えられる)。。
最後に
書籍のサンプルでよくあるネタ・レベルではありますが、多少は実用的・実践的なものがそこそこ簡単に作れたと思います。
全体的な紹介のため細かいところはかなり端折ってしまいましたが、ビューの部分や実際の実装についてはコードを参照していただければと思います(ツッコミどころは多々あると思います)。
いろいろな記事やサイトを参考にさせていただいたのですが、ちゃんと控えておらず、中途半端に調べて載せるのもかえって誤解を招く恐れもあるので記載していません。すみません。
ソースコートはこちらにあります。
サービスは継続的に運用していく予定はないので非公開です。
長々と失礼いたしました。