11
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Posted at

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

概要

どんなもの?

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

実装した機能

  • ユーザー登録/編集
  • ログイン/ログアウト
  • APIキー登録
  • 書籍検索
  • キーワード登録
  • お気に入り
  • 管理画面
  • メール送信
  • バッチ処理(キーワード検索&通知)

開発環境

  • ruby 2.5.1p57
  • rails 5.2.1
  • psql (PostgreSQL) 10.5
  • heroku 7.19.4

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

サーバー

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

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

その他

  • Bootstrap4:画面構築用のフレームワーク

完成画面(一部)

search.png

開発を始める前に

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

Mac

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

Heroku

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

Git

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

API

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

データベース設計

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

database.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

開発中は結構需要あります。当然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の部分になります。
ジャンルについては少し加工して使うようにしています。

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で表示するようにしました。
結果は20件表示され、「NEXT」ボタンで2ページ目を読み込むようにしています。

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

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のタスクを登録する仕組みで、実行のサイクルを、一日ごと、一時間ごと、10分ごとから選べます。

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

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

2つ目については、Herokuのスリープを回避するためのものでここでは説明は省きます。
アクセスがないとスリープしてしまい、起こすのに時間がかるので、無料の範囲で日中は起こしておこうというものです。
Herokuは30分アクセスがないとスリープするので、10分ごとでないと半分はスリープ状態ですが、、
「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の制限なども考えられる)。。

最後に

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

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

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

11
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?