- 投稿日:2019-01-27T23:39:26+09:00
ActiveStorageのvariantを利用して写真にコメントを重ね合わせる
概要
- 表題まま
- Imagemagickでできることなら、variantを使うとすっきりかけます
環境
- Rails 6 beta 1
- Docker
コード
スクショ
解説
ActiveStorageにはvariantという便利なメソッドがあります。これは、アップロードした画像をリサイズする事ができます。
たとえば、下記のようにimageにアップロードされる場合は、item.image.variant(resize: "100x100")(参考 rails guid) と書くと 100x100の大きさにリサイズできます。
また、variantで処理すると便利なのが processed。item.vaiant(resize: '100x100').processedと書くと最初は動的にimageをリサイズします。しかし、2回目以降は、1回目のときに画像をキャッシュするので、画像の表示を効率的に行うことができます。(参考)class Item < ApplicationRecord has_one_attached :image endvariantは、ImageMagickができることは何でもできます。具体的には、こちら のページのオプションを組み合わせることでカスタムされたvariantを自作できます。
たとえば、今回は以下の箇所でつかっています。
app/models/item.rb# frozen_string_literal: true class Item < ApplicationRecord # 省略 def resize_image(width = 500, height = 500) variation = ActiveStorage::Variation.new( Uploads.resize_to_fit(width: width, height: height, blob: image.blob, message: message) ) ActiveStorage::Variant.new(image.blob, variation) end endapp/models/concerns/uploads.rb# frozen_string_literal: true # REF http://www.carlosramireziii.com/what-options-can-be-passed-to-the-active-storage-variant-method.html?utm_source=rubyflow # REF https://prograils.com/posts/rails-5-2-active-storage-new-approach-to-file-uploads class Uploads class << self def jpeg?(blob) blob.content_type.include? 'jpeg' end def optimize { strip: true } end def optimize_jpeg { strip: true, 'sampling-factor' => '4:2:0', quality: '85', interlace: 'JPEG', colorspace: 'sRGB' } end def optimize_hash(blob) return optimize_jpeg if jpeg? blob optimize end def resize_to_fit(width: nil, height: nil, blob:, message:) n = 5 pointsize = width / n num = n - 1 fill = 'red' gravity = 'north' resize = resize(width: width, height: height) optimize_hash(blob).merge(resize: resize, combine_options: { font: font, fill: fill, gravity: gravity, pointsize: pointsize, draw: "text 0,#{pointsize / 5} '#{new_lined_message(message, num)}' " }) end private def new_lined_message(message, length = 4) message.scan(/.{1,#{length}}/).join("\n") end def font # https://googlefonts.github.io/japanese/ # '.fonts/FontopoNIKUKYU.otf' # '.fonts/ipaexg.ttf' '.fonts/nicomoji-plus_1.11.ttf' end def resize(width:, height:) if width && height "#{width}x#{height}" elsif width "#{width}x" else "x#{height}" end end end end
Uploads#resize_to_fitの resize_to_fitがミソです。特にこの箇所が画像処理をしているところです。
fontでフォントを指定して、fillで文字色を規定して、gravityで文字を表示する場所を指定して、pointsizeで文字の大きさを指定します。
また、fontは、ここを見てgoogleのwebからダウンロードして.fontsフォルダに入れました。
さらに、drawを使って画像に描画したい文字をしていします。こちらで機能を照らし合わせるとわかりやすいです。(resize: resize, combine_options: { font: font, fill: fill, gravity: gravity, pointsize: pointsize, draw: "text 0,#{pointsize / 5} '#{new_lined_message(message, num)}' " }これを実際に使う場合は以下の通り。
<%= image_tag item.resize_image(300, nil).processed %>参考
- http://www.carlosramireziii.com/what-options-can-be-passed-to-the-active-storage-variant-method.html?utm_source=rubyflow
- https://prograils.com/posts/rails-5-2-active-storage-new-approach-to-file-uploads
以上。
- 投稿日:2019-01-27T23:39:26+09:00
ActiveStorageのvariantを利用して画像に文字を重ね合わせる
概要
- 表題まま
- Imagemagickでできることなら、variantを使うとすっきりかけます
環境
- Rails 6 beta 1
- Docker
コード
スクショ
解説
ActiveStorageにはvariantという便利なメソッドがあります。これは、アップロードした画像をリサイズする事ができます。
たとえば、下記のようにimageにアップロードされる場合は、item.image.variant(resize: "100x100")(参考 rails guid) と書くと 100x100の大きさにリサイズできます。
また、variantで処理すると便利なのが processed。item.vaiant(resize: '100x100').processedと書くと最初は動的にimageをリサイズします。しかし、2回目以降は、1回目のときに画像をキャッシュするので、画像の表示を効率的に行うことができます。(参考)class Item < ApplicationRecord has_one_attached :image endvariantは、ImageMagickができることは何でもできます。具体的には、こちら のページのオプションを組み合わせることでカスタムされたvariantを自作できます。
たとえば、今回は以下の箇所でつかっています。
app/models/item.rb# frozen_string_literal: true class Item < ApplicationRecord # 省略 def resize_image(width = 500, height = 500) variation = ActiveStorage::Variation.new( Uploads.resize_to_fit(width: width, height: height, blob: image.blob, message: message) ) ActiveStorage::Variant.new(image.blob, variation) end endapp/models/concerns/uploads.rb# frozen_string_literal: true # REF http://www.carlosramireziii.com/what-options-can-be-passed-to-the-active-storage-variant-method.html?utm_source=rubyflow # REF https://prograils.com/posts/rails-5-2-active-storage-new-approach-to-file-uploads class Uploads class << self def jpeg?(blob) blob.content_type.include? 'jpeg' end def optimize { strip: true } end def optimize_jpeg { strip: true, 'sampling-factor' => '4:2:0', quality: '85', interlace: 'JPEG', colorspace: 'sRGB' } end def optimize_hash(blob) return optimize_jpeg if jpeg? blob optimize end def resize_to_fit(width: nil, height: nil, blob:, message:) n = 5 pointsize = width / n num = n - 1 fill = 'red' gravity = 'north' resize = resize(width: width, height: height) optimize_hash(blob).merge(resize: resize, combine_options: { font: font, fill: fill, gravity: gravity, pointsize: pointsize, draw: "text 0,#{pointsize / 5} '#{new_lined_message(message, num)}' " }) end private def new_lined_message(message, length = 4) message.scan(/.{1,#{length}}/).join("\n") end def font # https://googlefonts.github.io/japanese/ # '.fonts/FontopoNIKUKYU.otf' # '.fonts/ipaexg.ttf' '.fonts/nicomoji-plus_1.11.ttf' end def resize(width:, height:) if width && height "#{width}x#{height}" elsif width "#{width}x" else "x#{height}" end end end end
Uploads#resize_to_fitの resize_to_fitがミソです。特にこの箇所が画像処理をしているところです。
fontでフォントを指定して、fillで文字色を規定して、gravityで文字を表示する場所を指定して、pointsizeで文字の大きさを指定します。
また、fontは、ここを見てgoogleのwebからダウンロードして.fontsフォルダに入れました。
さらに、drawを使って画像に描画したい文字をしていします。こちらで機能を照らし合わせるとわかりやすいです。(resize: resize, combine_options: { font: font, fill: fill, gravity: gravity, pointsize: pointsize, draw: "text 0,#{pointsize / 5} '#{new_lined_message(message, num)}' " }これを実際に使う場合は以下の通り。
<%= image_tag item.resize_image(300, nil).processed %>参考
- http://www.carlosramireziii.com/what-options-can-be-passed-to-the-active-storage-variant-method.html?utm_source=rubyflow
- https://prograils.com/posts/rails-5-2-active-storage-new-approach-to-file-uploads
以上。
- 投稿日:2019-01-27T23:38:01+09:00
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:画面構築用のフレームワーク
完成画面(一部)
開発を始める前に
以下の準備を行っておく必要があります。
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の部分になります。
ジャンルについては少し加工して使うようにしています。book_searcher.rbrequire '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.rbclass 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.rbget '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.rbclass 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.rbclass 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.rbget '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.rbclass 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 endBookモデルは検索結果を入れるために検索の実装時に作成しましたが、データベースとしては、お気に入り登録の際追加を行います。
これは、お気に入り一覧を表示する際に使うキャッシュとして機能します。
検索の時点で登録することも出来ますが、DBのレコード数が増えすぎないようにと考えるとこのタイミングが最適と考えたためです。ルートの設定は下記のように追加しています。
routes.rbget '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:integerNullを許容しないカラムの対応、コマンドでのテーブル作成は同様です。
画面の調整
各種機能をそれぞれ別途作成してきましたが、ここでそれらをつないで画面遷移できるようにします。
また、レイアウトについても多少は見栄えを良くします。
細かくは紹介しませんが、概ね下記の方針で調整しました。
- 共通のナビゲーションのビューを作成し、すべての画面に適用
- 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ファイルを追加します。
scheduler.rakedesc "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 endFCMとあるのは、アプリへのプッシュ通知を想定しているためで、未実装です。
以上まででWebサービスとしては一応の完了となります。
Webサービスとしては動くものが出来ましたが、最終的にスマホアプリで通知を受けられるようにしたいので、スマホアプリ用にWebAPIとして機能するようにします。
API化
やることは概ね以下の作業です。
- ルートになるコントローラを新たに作る
- 認証の仕組みを新たに作る
- 必要なAPI用のコントローラを追加
- ルートの設定も別途行う
API用のルートのコントローラを作成
これまで作成してきたコントローラは、すべて「ActionController::Base」のサブクラスでしたが、APIは「ActionController::API」のサブクラスとなります。
なので、application_controller.rbとは別にapi_controller.rbを作成します。api_controller.rbclass ApiController < ActionController::API end認証の仕組みを作る
先程のApiControllerに認証の仕組みを追加します。
Token認証のために「ActionController::HttpAuthentication::Token::ControllerMethods」をincludeします。api_controller.rbclass 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.rbclass 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.rbRails.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の制限なども考えられる)。。最後に
書籍のサンプルでよくあるネタ・レベルではありますが、多少は実用的・実践的なものがそこそこ簡単に作れたと思います。
全体的な紹介のため細かいところはかなり端折ってしまいましたが、ビューの部分や実際の実装についてはコードを参照していただければと思います(ツッコミどころは多々あると思います)。
いろいろな記事やサイトを参考にさせていただいたのですが、ちゃんと控えておらず、中途半端に調べて載せるのもかえって誤解を招く恐れもあるので記載していません。すみません。ソースコートはこちらにあります。
サービスは継続的に運用していく予定はないので非公開です。長々と失礼いたしました。
- 投稿日:2019-01-27T22:43:11+09:00
RubyでURLエンコーディングする方法
自分のブログの転載記事です。
結論
URLの中で使用する文字列をエスケープするときには
URI.encode_www_formメソッドを使用しよう。調べたこと
最近、株価を分析するwebアプリを個人開発しています。
その中で、銘柄名でGoogle検索したときの結果をスクレイピングしたくなりました。銘柄名と証券コードはStringでDBに登録済みなので、簡単に持ってこられます。
ですが持ってきた文字列をそのままクエリストリングに入れてしまうと、うまく検索できないことがありました。search_url = "https://www.google.co.jp/search?hl=jp&gl=JP&q=日本M&Aセンター"
欲しい結果は取得できていますが、検索ワードが"日本M"で途切れています。
"&"が正しくエスケープできていないのかと思い、調べてみたところ、以下の方法でできました。require 'uri' query = URI.encode_www_form(q: '日本M&Aセンター') => "q=%E6%97%A5%E6%9C%ACM%26A%E3%82%BB%E3%83%B3%E3%82%BF%E3%83%BC" search_url = "https://www.google.co.jp/search?hl=jp&gl=JP&" => "https://www.google.co.jp/search?hl=jp&gl=JP&" search_url += query => "https://www.google.co.jp/search?hl=jp&gl=JP&q=%E6%97%A5%E6%9C%ACM%26A%E3%82%BB%E3%83%B3%E3%82%BF%E3%83%BC"
上記のURLを打ち込むと、"&"が正しくエスケープされているのがわかります。他の文字はエスケープ前の文字に戻っていますが。失敗したケース
ちなみに調べている中で最初に出てきた方法は
URI.encodeを使う方法だったのですが、そのやり方では正しくエスケープされませんでした。require 'uri' search_url = "https://www.google.co.jp/search?hl=jp&gl=JP&q=" => "https://www.google.co.jp/search?hl=jp&gl=JP&q=" search_url += "日本M&Aセンター" => "https://www.google.co.jp/search?hl=jp&gl=JP&q=日本M&Aセンター" URI.encode search_url => "https://www.google.co.jp/search?hl=jp&gl=JP&q=%E6%97%A5%E6%9C%ACM&A%E3%82%BB%E3%83%B3%E3%82%BF%E3%83%BC"最終的に生成された文字列を見比べてみると、"&"がエスケープされていないことがわかります。
# "M&A"が"M%26A"になっている。 success_url = "https://www.google.co.jp/search?hl=jp&gl=JP&q=%E6%97%A5%E6%9C%ACM%26A%E3%82%BB%E3%83%B3%E3%82%BF%E3%83%BC" # "M&A"が"M&A"のまま。 failure_url = "https://www.google.co.jp/search?hl=jp&gl=JP&q=%E6%97%A5%E6%9C%ACM&A%E3%82%BB%E3%83%B3%E3%82%BF%E3%83%BC"Ruby 2.6.0 リファレンスマニュアルによると、
encodeメソッドはobsoleteとのことなので、今後は使用しないほうがよいかもしれません。
- 投稿日:2019-01-27T21:08:43+09:00
Redisでランキングを実装
初めに
Redisでランキングを実装してみました。
こちらのサイトを参考にしました。
RailsでRedisを使うメモ ランキングつくったり
http://o.inchiki.jp/obbr/307設定
デザインはbootstrap
ダミーデータ作成にfakerをつかいました。あとredisを使うので
gemfileに
gem 'redis' gem 'bootstrap' gem 'jquery-rails' gem 'popper_js' gem 'tether-rails' gem 'faker'こんな感じで書いてbundle installしました。
userモデルを作成しました。
bootstrapの設定fakerでのダミーデータ作成は省略
実装
users_controllerを
users_controller.rbclass UsersController < ApplicationController def index @ranking_users_data = (REDIS.zrevrangebyscore "users", 10, 0, withscores: true) end def add_score user = User.find params[:id] REDIS.zincrby "users", 1, user.id end endこんな感じにしました。
REDISに関しては
config/initializers以下にredis.rbを作成して中身に
redis.rbREDIS = Redis.new(host: "localhost", port: 6379)と書くことで使えます。
REDIS.zincrby "users", 1, user.idみたいに書いているところでスコアのカウントをしています。
usersがスコアを計算する大きなまとまりの名前でusersでなくても良いのですが今回はusersと名前を付けています。
そのあとの1が追加する数です
user.idごとにスコアがカウントされていきます。
集められたスコアをソートされた状態で出力するには
REDIS.zrevrangebyscore "users", "+inf", 0, withscores: trueこのコードです。
スコアが+inf(最大?)から0まで表示することになっています。例えばスコアが0から10まで表示したい場合は
REDIS.zrevrangebyscore "users", 10, 0, withscores: trueあくまでスコアなので0から10番目を表示というわけではありません。
自分の場合これだけだと登録されたばかりのuserはredisのusersのランキングの中に入らないので
REDIS.zrevrangebyscore "users", 10, 0, withscores: trueこれで表示されませんでした。
なのでuserが作成されたときに1スコアをたして表示されるようにしました。
models/user.rb
user.rbclass User < ApplicationRecord after_create{ REDIS.zincrby "users", 1, self.id} endroutesは
Rails.application.routes.draw do get 'users/index' post 'users/add_score/:id', to: 'users#add_score' # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html endこのようにして
viewsは
index.html.erb<br> <br> <br> <br> <br> <div class="container"> <table class="table table-dark"> <thead> <tr> <th scope="col">Ranking</th> <th scope="col">Name</th> <th scope="col">Count</th> <th scope="col">Like Button</th> </tr> </thead> <tbody> <% @ranking_users_data.each_with_index do |rank_data,index| %> <tr> <th scope="row"><%= index + 1 %></th> <td><%= user_name(rank_data[0]) %></td> <td class="count"><%= rank_data[1].to_i %></td> <td> <%= button_to "/users/add_score/#{rank_data[0]}", class: "like-btn", id: "addBtn", remote: true do %> <i class="fas fa-heart" style="font-size: 15px;" data-count="<%= index %>"></i> <% end %> </td> </tr> <% end %> </tbody> </table> </div> <script> var allBtn = document.getElementsByClassName('like-btn') var allCount = document.getElementsByClassName('count') var btnLength = allBtn.length var addLikeCount = function(i) { allCount[i].innerHTML = parseInt(allCount[i].innerHTML) + 1 } for(var i = 0; i < btnLength; i++ ) { allBtn[i].addEventListener('click', function(a) { let dataCount = this.getElementsByClassName('fas')[0].dataset.count addLikeCount(dataCount); }); } </script>例のごとく自分はjavascriptをerbの中に書いています。
とりあえず見た目はこんな感じになると思います。
fontawesomeを使っているので
layoutsのheadの中に
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.6.3/css/all.css">を書いて
assets/stylesheets/application.scssに
@import '/*'; @import 'bootstrap'; @import 'tether'; body { background: #95a5a6; } .like-btn { transition: all 0.2s; border: none !important; } .like-btn:hover { background: rgb(224, 0, 37) !important; color: white !important; border: none !important; }こんな感じにしてあると見た目は同じになると思います。
これでクリックされるたびにスコアが追加されていきます。
ちなみに
redisに追加されているデータをみたい場合は
rails cで
REDIS.keysとすれば登録されているkeyが出て
REDIS.zrevrangebyscore "users", "+inf", 0, withscores: trueと入力すればuser.idとスコアが二次配列の形で表示されると思います。
おわり
- 投稿日:2019-01-27T18:14:23+09:00
RailsでRedisを使ってpvカウント
初めに
railsでredisを使ってpv数をカウントする機能を実装してみました。
設定
redisをインストールする
gem 'redis-rails'bundle install
実装
controllersのconcernsに
count_pv.rbファイルを作って
中に
module CountPv extend ActiveSupport::Concern def count_pv(action,controller) redis = Redis.new count = redis.get "count_#{controller}_#{action}" redis.set "count_#{controller}_#{action}", count.to_i + 1 p redis.get "count_#{controller}_#{action}" end endpvカウントしたいコントローラーで
users_controller.rbclass UsersController < ApplicationController include CountPv def index count_pv("index", "users") end def index_a count_pv("index_a","users") end endみたいにする。
これでカウントできる。
終わりに
redisの使い方がわからないんですがこれって
redis-railsをbundle installすればredisって勝手に起動するんですかね?
あとログが全くでないのはデフォルトなのかな?
なんのログも出ないけど一応使えてるのが気持ち悪いし起動しなくても使えるからほんとにredis使えてるのか不安になる(笑)
- 投稿日:2019-01-27T18:04:50+09:00
Twitterログイン認証で404(forbidden)や401(Unauthorized)エラーがでたときの対処法
はじめに
TwitterのAPIを使ってTwitterログイン機能を作っているのですが、認証エラーでハマったのでメモ。
今回は
・404(forbidden)
・401(Unauthorized)
の対処法を解説していきます。401エラーの対処方
401エラーとは、認証エラーのことで、リクエスト側に問題があるということです。
原因: API keyが間違っていた。
consummer keyやconsumer sercretが間違っている可能性があるので、developerページでekeyを間違えて打っていないか確かめて見ましょう。config/initializers/devise.rbconfig.omniauth :twitter, (consummer key), (consumer sercret)さて、これが治ると次は403エラーにたどり着きました。
403エラーの対処法
403エラーは、閲覧が禁止されてると言う意味です。
原因: callbackのurlと
localhostのurlが一致していなかった。developer画面に移動し、アプリの詳細画面に移動して、登録したcallbackのurlを確認してみましょう。
この赤で囲ったurlの部分と、local環境で使いたいurlが一致しないとエラーを吐いてしまいます。
rails routes | grep callbackで、callbackのurlを確認します。user_twitter_omniauth_authorize GET|POST /users/auth/twitter(.:format) omniauth_callbacks#passthru user_twitter_omniauth_callback GET|POST users/auth/twitter/callback(.:format) omniauth_callbacks#twitterここでは
users/auth/twitter/callbackに飛ばしたいので、callback URLは
http://127.0.0.1:3000/users/auth/twitter/callbackとしました。と、ここまでで解決する人もいたりいなかったりすると思うんですが、個人的に一部ハマったポイントとしては、起動しているローカル環境のurlと、developerで設定したcallback_urlが完全一致してないと同じように
403エラーが発生してしまうところです。つまり、
callback_url →
http://127.0.0.1:3000/users/auth/twitter/callback
ローカル環境で立ち上げたurl→
http://localhost:3000/users/auth/twitter/callback
はエラーになります。正しくは、
callback_url →
http://127.0.0.1:3000/users/auth/twitter/callback
ローカル環境で立ち上げたurl →
http://127.0.0.1:3000/users/auth/twitter/callback
です。このように、ドメイン名も設定した
callback_urlと一致させるようにしましょう。確認事項
・keyが間違っていないか?
・developerのcallback_urlが間違っていないか?
・ドメイン名も一致しているか?ここであげたもの以外にもエラーの原因となるものはたくさんあるかと思います。
単にAPI keyが間違っていただとか、リクエストに問題があることも多いので、焦らずひとつづつ潰していきましょう。
- 投稿日:2019-01-27T17:48:27+09:00
[Rails]モデルにScopeを定義し、コントローラーで呼び出す
下記のように同じようなクエリメソッドを繰り返す場合、可読性も保守性も非常に悪いです。そんな時にはモデルにScopeを定義することで同じ記述を一つのメソッドとして使用することができます。
notes_controller.rb@manga_notes = Note.where(category: 0).includes(:user).order("created_at DESC").limit(9) @column_notes = Note.where(category: 1).includes(:user).order("created_at DESC").limit(9) @novel_notes = Note.where(category: 2).includes(:user).order("created_at DESC").limit(9) @photo_notes = Note.where(category: 3).includes(:user).order("created_at DESC").limit(9) @sound_notes = Note.where(category: 4).includes(:user).order("created_at DESC").limit(9) @business_notes = Note.where(category: 5).includes(:user).order("created_at DESC").limit(9)これらをScopeに定義することで一つのメソッドとして定義することができます。
note.rbscope :recent, -> { includes(:user).order("created_at DESC").limit(9) }notes_controller.rb@manga_notes = Note.where(category: 0).recent @column_notes = Note.where(category: 1).recentモデルで定義したScopeをコントローラーで呼び出しています。
note.rbenum category: { マンガ: 0, コラム: 1, 小説: 2, 写真: 3, サウンド: 4, ビジネス: 5, ライフスタイル: 6 } scope :search_with_category, ->(category) { where(category: category) } scope :recent, -> { includes(:user).order("created_at DESC").limit(9) }notes_controller.rb@manga_notes = Note.search_with_category(0).recent @column_notes = Note.search_with_category(1).recent @novel_notes = Note.search_with_category(2).recent @photo_notes = Note.search_with_category(3).recent @sound_notes = Note.search_with_category(4).recent @business_notes = Note.search_with_category(5).recent @lifestyle_notes = Note.search_with_category(6).recent上のようにスコープ同士をマージさせて使用することが可能です。また、スコープに引数を渡すことも可能で、上の例ではenumで定義しているカテゴリ番号を引数をして渡すことで、Noteモデルからそのカテゴリを持つレコードを取得します。
- 投稿日:2019-01-27T16:46:43+09:00
RailsのActionMailerで画像のURLを絶対パスで表示させる
ActionMailerで送信したメールに絶対パスで画像を表示させたい中でハマった。
Mail内で以下のように表示させるために
index.html<img src="http://myfullappurl.dev/assets/myimage.png">development.rbにて以下のようにメーラーを設定
development.rbconfig.action_controller.asset_host = 'myfullappurl.dev' config.action_mailer.asset_host = config.action_controller.asset_host config.action_mailer.default_url_options = { host: 'myfullappurl.dev' }しかしmail内でのhtmlでは
index.html<img src="//myfullappurl.dev/assets/myimage.png">とプロトコルが表示されない。railsコードに
asset_url_helper.rbURI_REGEXP = %r{^[-a-z]+://|^(?:cid|data):|^//}有効なActionMailer URIを定義する正規表現があるようで
development.rbconfig.action_controller.asset_host = 'myfullappurl.dev' config.action_mailer.asset_host = 'http://myfullappurl.dev'にしなければならない。
https://codeday.me/bug/20180720/199491.html
https://stackoverflow.com/questions/29887668/how-to-use-image-url-in-rails-to-show-images-in-production
- 投稿日:2019-01-27T15:43:47+09:00
DBを使わないRails on DockerのWebアプリの作り方
Problem
「とりあえずブレスト」というWebアプリを作ったときに「DBなしでできそう!」となったのだが、docker docsだとpostgreSQLありきたっだのでDBなしで開発を始める手順をまとめておきました。
Simple Conclusion
- Dockerfileやdocker-compose.ymlからDBに関係しそうなところを削除する
- rails newする際に"-O"オプションをつける
Details
まずはdocker docsを参考にDBを使わないDockerfileやdocker-compose.ymlの準備する。
最初にmkdir myappでアプリのディレクトリを作成し、cd myappでアプリのディレクトリへ移動しておく。Dockerfile
myapp/DockerfileFROM ruby:2.5 ENV LANG C.UTF-8 RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs WORKDIR /myapp COPY Gemfile /myapp/Gemfile COPY Gemfile.lock /myapp/Gemfile.lock RUN bundle install -j4 COPY . /myappポイントはapt-getの対象からpostgresql-clientをのぞくこと。
docker-compose.yml
myapp/docker-compose.ymlversion: '3' services: web: container_name: myapp_web build: context: . dockerfile: Dockerfile environment: TZ: Asia/Tokyo command: rails s volumes: - .:/myapp ports: - "3000:3000"こちらはdb serviceをごっそり削除。depends_onするdbを消去するのでweb側のdepends_onも削除。
container_name、dockerfile、TZなどの設定は好みなのでつけなくてもOKです。Rails new
上で作った"Dockerfile"、"docker-compose.yml"以外はdocker docsにそって"Gemfile"と"Gemfile.lock"を作っておく。
4つのファイルを作ったらrails newをしてWebアプリを作成してdocker imageをbuildしますが、ここのrails newでDatabaseを利用しないことを宣言するために"-O"オプションをつける。
色々設定とかしないといけないんだろうなーと思っていたけど"-O"だけで済むなんてさすがRailsさん。$ docker-compose run web rails new . -O $ docker-compose buildこのあと、docker docsではDatabaseのあれやこれやの設定をしていますが、無視してdocker containerを立ち上げる。
$ docker-compose uphttp://localhost:3000へアクセス!
Yay! You're on Rails (without Database) !!
Reference
- 投稿日:2019-01-27T13:10:09+09:00
Rails |onオプションを使ってバリデーションのタイミングを指定
はじめに
こんにちは、waruby510です。
今回、初めてQiitaへの記事を投稿させていただきます。
最近、色んな方の記事に助けてもらってばかりの状況なので、
備忘録&同じ初学者の力になれる内容 を簡単にですが、記事にしてみました。
内容は、タイトル通りですが、
validatesメソッドのonオプションを使って、バリデーションのタイミングを指定してみた
と言う感じです。
もちろん経験者にとっては、かなりイージーな内容になっています。初心者目線でこの記事を書いていますので、ご了承ください。
また、あまり奥深いところまでは書いていないので、今後更新していければと思います。<補足>
※初心者の立場で記事を書いています。最低限の下調べと実際に動くかどうかは確認していますが、もし誤った内容を記載していた場合は、ご指摘いただければと思います。※まだまだ文章を書くことに慣れていません。読みにくいかもしれませんが、どうかお付き合いください。
開発環境
Rails バージョン5.2.2
ruby バージョン2.5.3
※ログイン機能等に関しては、gemのdeviseは使っていません。アプリケーション詳細
- ユーザー登録・編集機能 ← 今回の記事の問題箇所
- 画像投稿、編集、削除機能 & いいね機能
簡単に言うと、インスタのようなアプリです。
発生した問題
ユーザー編集機能を追加した時にある問題に差し掛かりました。
それは、ユーザー編集画面にてパスワード欄を無くしたはずなのに、バリデーションがかかってしまう問題。
仕方なく、編集画面にもパスワード入力欄を設けて対応しましたが、
ユーザー目線で考えたら、使いにくいの一言。実際、userモデルに対しては、以下のようなバリデーションを追加していました。
user.rbclass User < ApplicationRecord #名前とメールアドレスに対するバリデーション validates :name, presence: true, length: {maximum: 30} validates :email, presence: true, length: {maximum: 255},format: { with: /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i },uniqueness: true #略 #パスワードをハッシュ化 has_secure_password #パスワードに対するバリデーション validates :password, presence: true, length: {minimum: 6} #略 end考えられる原因
バリデーションのデフォルトでは、レコードの作成時、更新時。つまり保存が促されるときに実行されるように設定されている。つまり、編集時にパスワードがないとバリデーションがかかってしまいます。
冷静に考えてみると、ただ編集画面のパスワード欄の表示を消しているだけなので、バリデーションがかかるのも当たり前ですよね。笑考えれる対応策としては、バリデーションのかかるタイミングを指定できれば解決できそう。
実際にやったこと
Userモデルのpasswordカラムに対して、
on: :createを追加user.rb#on: :create を追加 validates :password, presence: true, length: {minimum: 6}, on: :create
ここでようやく
onオプションの登場です。
ここまで、長々と書いてきましたが、たったこれだけです......笑バリデーションのタイミングを特定して実行したい場合、
on: メソッド名とオプションを追加することで、そのアクションの時のみバリデーションを実行することができるみたいです。今回は、ユーザー登録時のみパスワードのバリデーションを起動させたいので、
on: :createとします。もしメソッド名を
on: updateにすれば、編集の更新時のみバリデーションがかかるという仕組みなのです。
個人的には結構使いそうな予感。今回は、RailsGuideの下記リンクを参考にしました。
参考リンク: RailsGuide
3.4 :on
:onオプションは、バリデーション実行のタイミングを指定します。ビルトインのバリデーションヘルパーは、デフォルトでは保存時に実行されます。これはレコードの作成時および更新時のどちらの場合にも行われます。バリデーションのタイミングを変更したい場合、on: :createを指定すればレコード新規作成時にのみ検証が行われ、on: :updateを指定すればレコードの更新時にのみ検証が行われます。class Person < ApplicationRecord # 値が重複していてもemailを更新できる validates :email, uniqueness: true, on: :create # 新規レコード作成時に、数字でない年齢表現を使用できる validates :age, numericality: true, on: :update # デフォルト (作成時と更新時のどちらの場合にもバリデーションを行なう) validates :name, presence: true end実際に、編集画面の動作を確認してみました。
見事にパスワードに対するバリデーションが回避されています。
少しはユーザー目線の機能に近づけた気がします。
今の自分の良いところは、初心者ということもあり、ユーザー目線に近い状態で開発ができるということ。この目線は今後も大事にしていきたいと思います。さいごに
結論が呆気なくて、すみませんでした。
自分自身もこれだけ?っていう感じでしたが、とてもいい勉強になりました。ただ、まだまだ便利な使い方が間違いなくあるはずなので、これから学習していく中で整理していき、また記事にできればと思います。
ここまで読んでくださった方、本当にありがとうございます。
今回は、初めてということもあり、Markdown記法に慣れるために色々な記法を使って書いてみました。そのため、無駄に記事が長くなってしまいましたが、個人的にはかなりいい練習ができたと思います。
この記事が誰かのお役に立てれれば本望です。
以上です。
- 投稿日:2019-01-27T12:08:59+09:00
【爆速で習得】Railsでslimを使う方法から基本文法まで
slimは凄い
みなさんはslimを使っていますでしょうか?
slimとは, Ruby製のテンプレートエンジンのことで、
htmlをより簡潔でスマートに書くことができます。例えば,以下のコード。
*.html<h1>タイトル</h1> <div class="content"> <p>文章</p> </div>*.slimh1 タイトル .content p 文章どちらの方がスマートなのかは一目瞭然ではないでしょうか?
今回はRubyも含めて説明していきますが、
書き方の特徴と違いとしては以下のようになります。・<>がいらない
・<%= %> → =
・<% %> → -
・コメント → /
・id指定 → #
・class指定 → .例)
html(erb) slim <p>hoge</p> p hoge <!-- --> / class="content" .content id="content" #content <%= hoge %> = hoge <% if %> - if この記事では,
・slimの導入と設定
・基本文法についてシンプルに説明していきます。
slimの導入
まずは以下のコマンドでslimを入れてください.
gem install slimこれで拡張子が
.html.slimのslimファイルを、みなさんが普段使っている.html.erbとして変換されるようになります。次に
html2slimを入れてください。gem install html2slimこれで
html.erb→html.slimに変換させることができるので、早速変換してみましょう。bundle exec erb2slim app/views app/views依存のerbファイルに加え, slimファイルが追加されたかと思います。
しかし、これではerbファイルが邪魔なので以下のコマンドを打って削除しましょう。
bundle exec erb2slim app/views app/views -d今後自動的に
slimファイルを作成されるようにするためには 、
config/application.rbにあるconfigを以下のようにslimを指定すればOK。config/application.rbmodule App class Application < Rails::Application config.generators.template_engine = :slim #slimに変更 end endこのあと、もしフォーマット関係でエラー出た場合は,一旦サーバーを再起動させましょう。
これで準備が整いました。
基本文法
ここからはよく使うslimの文法について紹介します。
見方としては、slimの文法 → htmlならどうなるかという順になってます。
(色々迷いましたが、逆の方が見やすいよという方いましたらコメントください(m_ _m))htmlタグ
slimの特徴として、
<>がいらないということを覚えておきましょう。(使うこともできます)
もちろんそれに伴い、<>で閉じる必要もありません。*.slimp 段落 a アンカー img src="https://avatars1.githubusercontent.com/u/28925778?v=4" div*.html<p>段落</p> <a>アンカー</a> <img src="https://avatars1.githubusercontent.com/u/28925778?v=4" />クラスやidの指定
クラスやidはそれぞれ、
.,#で表現することができます。*.slimdev.content p.title タイトル dev#content p#title タイトル*.html<dev class="content"> <p class="title">タイトル</hoge> </dev> <dev id="content"> <p id="title">タイトル</hoge> </dev>また、クラスやidは連続して指定できるので、かなり簡潔になることでしょう。
*.slimdev.content#article p.title.text-center タイトル*.html<dev class="content" id="article"> <p class="title text-center">タイトル</hoge> </dev>テキストの書き方
テキストは |(パイプ)を使います。
|より後ろはテキストとして扱われるので、タグの横ではなく、タグの下に記述することで見やすくなるというのが最大のメリット。*.slimp | テキスト*.html<p>テキスト</p>【注意】タグのすぐ後ろに
|をいれると、|も文字として認識されてしまうので注意しましょう。これは、
*.slimp | テキストこうなっちゃいます。
*.html<p>| テキスト</p>また、パイプを使えば、文章が長くなった時でも見やすくなります。
p | テキストテキストテキストテキストテキスト テキストテキストテキストテキストテキスト<p>テキストテキストテキストテキストテキストテキストテキストテキストテキスト</p>改行したいときは通常と同様、
brを使えばOK。*.slimp | テキストテキストテキストテキストテキスト<br> テキストテキストテキストテキストテキスト<br>*.html<p>テキストテキストテキストテキストテキスト<br>テキストテキストテキストテキストテキスト</p>
brをタグ無しで使いたい!って場合には、以下のように書けます。*.slimp | テキストテキストテキストテキストテキスト br | テキストテキストテキストテキストテキスト
br以下は再度|で始めることで、文字として認識させるようにしましょう。※ 補足
パイプに関して言うと、以下の書き方は上二つが同じで、最後のは改行されません。*.slimp | テキストテキストテキストテキストテキスト テキストテキストテキストテキストテキスト p | テキストテキストテキストテキストテキスト テキストテキストテキストテキストテキスト p | テキストテキストテキストテキストテキスト | テキストテキストテキストテキストテキストいろんな書き方を見るかもしれませんが、タグの直後に書かなくても良いというメリットを生かし、自分の見やすいように書くといいかもしれませんね。
|以下は文字("")として認識されるということを覚えておきましょう。ifとかの条件式
ここからは少しRubyが絡みますが、少々お付き合いください(m_ _m)
slimでif文を書くときは、
-で囲って上げる必要があります。- if current_user.nil? li 新規登録 - else li ログイン<% if current_user.nil? %> <li>新規登録</li> <% else %> <li>ログイン</li> <% end %>
end入れ忘れる心配がなくて最高ですね(・∀・)参考: https://qiita.com/nishina555/items/8ed180333fbc8f78e91e
結果をテンプレートに表示したい場合
変数とか出力させたい!ってときは、
=を使います。- sum = 1 + 1 = sum<% sum = 1 + 1 %> <%= sum %>この場合、
=から始めた2だけが表示されます。コメントの書き方
コメントは,
/をつけます。/p テキストまとめてコメントアウトしたい!って場合は、以下のようにすると簡単。
ネスとしてる場合は親要素だけ。
/.content p 文章行頭が揃ってる場合は
/にネストさせる。/ - sum = 1 + 1 = sum参考: https://qiita.com/scivola/items/c9a79c13688a3444a3cc
まとめ
・
<>がいらない
・|以下は文字("")として認識される
・式は-, 出力は=
・コメントは/初めは独特の書き方に見えるかもしれませんが、慣れるとあまりのスリムさから戻れなくなることでしょう。
特に現場では
slimというフォーマットで書く所が多いと思うので, 初心者こそ慣れておくと良いかもしれませんね( ´ ▽ ` )
- 投稿日:2019-01-27T11:50:38+09:00
[RSpec] ネストしたルーティングのPOST, PUT(PATCH)のテストが大変だったというお話
こんなコントローラがありました
class CardsController < ApplicationController before_action :set_card, only: %i(show edit update destroy) before_action :set_list, only: %i(new create) def new @card = Card.new end def create @card = Card.new(card_params) if @card.save redirect_to :root else render :new end end (中略) private def card_params params.require(:card).permit(:title, :memo, :list_id) end def set_card @card = Card.find_by(id: params[:id]) end def set_list @list = List.find_by(id: params[:list_id]) end endこんなルーティングを設定しました
resources :lists, except: %i(index, show) do resources :cards, except: :index endするとこうなりました
list_cards POST /lists/:list_id/cards(.:format) cards#create new_list_card GET /lists/:list_id/cards/new(.:format) cards#new edit_list_card GET /lists/:list_id/cards/:id/edit(.:format) cards#edit list_card GET /lists/:list_id/cards/:id(.:format) cards#show PATCH /lists/:list_id/cards/:id(.:format) cards#update PUT /lists/:list_id/cards/:id(.:format) cards#update DELETE /lists/:list_id/cards/:id(.:format) cards#destroyRequest Specを書きました
RSpec.describe "CardsResponses", type: :request do describe "cards responses" do let(:user) { create(:user01) } let(:list) { create(:list01, user: user) } let(:card) { create(:card01, list: list) } let(:valid_params) { attributes_for(:card01, list: list, title: "CARD000") } (中略) describe "#create" do it "responds [302]" do post list_cards_path(card), params: { card: valid_params } expect(response).to have_http_status 302 end end end end結果
1) CardsResponses cards responses as a login user #create responds [302] Failure/Error: expect(response).to have_http_status 302 expected the response to have status code 302 but it was 200 # ./spec/requests/cards_responses_spec.rb:31:in `block (5 levels) in <main>' # -e:1:in `<main>'成功して
redirect_to :rootに飛ばされるはずが、
失敗してrender :editされてしまっている。つまりは、createに失敗している。
対処
createのテストコードにbinding.pryをはさみました
describe "#create" do it "responds [302]" do post list_cards_path(card), params: { card: valid_params } binding.pry expect(response).to have_http_status 302 end end(仮定1) list_cards_pathに与える変数が間違っている?
letで設定していたのは以下。
let(:user) { create(:user01) } let(:list) { create(:list01, user: user) } let(:card) { create(:card01, list: list) }
POST list_cards_path /lists/:list_id/cards(.:format)なので、
パラメータとして、:list_idが求められている。[3] pry(#<RSpec::ExampleGroups::CardsResponses::CardsResponses::AsALoginUser::Create>) > list_cards_path(card) => "/lists/1/cards"あってる?
[4] pry(#<RSpec::ExampleGroups::CardsResponses::CardsResponses::AsALoginUser::Create>) > list_cards_path(list, card) => "/lists/1/cards.1"おっとこれはおかしい
[5] pry(#<RSpec::ExampleGroups::CardsResponses::CardsResponses::AsALoginUser::Create>) > list_cards_path(list) => "/lists/1/cards"あってる
list has_many cardsの関係なので、お互いのidは関連付けで持ってるから、
どうやらlist、cardどちら指定してもOKみたい。
今回はcardにしておく(仮定2) 与えてるパラメータが間違っている?
そもそもOKなパラメータって何よ?
cards_controllerprivate def card_params params.require(:card).permit(:title, :memo, :list_id) end
title, memo, list_idね。に対して、僕が設定したパラメータはこんなんでした。
let(:valid_params) { attributes_for(:card01, list: list, title: "CARD000") }
factory.rbに登録してあるcard01の関連付けにlistを設定、titleも変えてcard01を呼び出す。これをpry上で実行すると
[7] pry(#<RSpec::ExampleGroups::CardsResponses::CardsResponses::AsALoginUser::Create>) > valid_params => {:title=>"CARD000", :memo=>"memo01", :list=> #<List:0x00007f8d4f1c32c8 id: 1, title: "todo01", user_id: 1, created_at: Sun, 27 Jan 2019 02:24:20 UTC +00:00, updated_at: Sun, 27 Jan 2019 02:24:20 UTC +00:00>}ほーん
require.permitで受け取るパラメータは参照できるのかね?
[8] pry(#<RSpec::ExampleGroups::CardsResponses::CardsResponses::AsALoginUser::Create>) > valid_params[:title] => "CARD000"できる
[9] pry(#<RSpec::ExampleGroups::CardsResponses::CardsResponses::AsALoginUser::Create>) > valid_params[:memo] => "memo01"できる
[10] pry(#<RSpec::ExampleGroups::CardsResponses::CardsResponses::AsALoginUser::Create>) > valid_params[:list_id] => nil!!!!!!!!!!!!!!
できない…
(仮定2のつづき) 関連付けをlist: listで指定したのが間違っていた?
てなわけで、
valid_parmas[:list_id]で参照できるように修正してみる。(修正前)
let(:valid_params) { attributes_for(:card01, list: list, title: "CARD000") }(修正後)
let(:valid_params) { attributes_for(:card01, list_id: list.id, title: "CARD000") }結果
[1] pry(#<RSpec::ExampleGroups::CardsResponses::CardsResponses::AsALoginUser::Create>) > valid_params => {:title=>"CARD000", :memo=>"memo01", :list_id=>1}お!ちょっと変わった。
:list_idもしっかり設定されてる。でも本当に参照できる?
[2] pry(#<RSpec::ExampleGroups::CardsResponses::CardsResponses::AsALoginUser::Create>) > valid_params[:title] => "CARD000"できる
[3] pry(#<RSpec::ExampleGroups::CardsResponses::CardsResponses::AsALoginUser::Create>) > valid_params[:memo] => "memo01"できる
肝心のlist_idは?
[4] pry(#<RSpec::ExampleGroups::CardsResponses::CardsResponses::AsALoginUser::Create>) > valid_params[:list_id] => 1
できる!!!!!!!
てなわけで
pryを終了
Finished in 1.26 seconds (files took 0.38429 seconds to load) 50 examples, 0 failures, 2 pendingやっと通りました〜
結論
POST, PUT, PATCH用のパラメータを設定するとき、
コントローラのpermit.requireで指定しているパラメータを
(pry上で)きちんと参照できる形で設定しないと
弾かれてしまうので注意しないといけない
おわり
- 投稿日:2019-01-27T00:54:03+09:00
【ruby】「DBリソース」と「AWS_S3上の関連するディレクトリ」を一緒に削除する実装(+Rspecテスト)
はじめに
S3上のディレクトリ削除処理とDBリソースと一緒に削除する実装について、色々勉強になったので記事にしておきます。
Tl;Dl;
- S3上のファイルの削除は以下の通り。
- 以下のように
batch_deleteを使うと指定のディレクトリ配下のファイル、ディレクトリを削除できるclass User < ActiveRecord def delete_all_s3_contents! s3 = Aws::S3::Resource.new(client: s3_client) objects = s3.bucket("bucket_name").objects(prefix: "users/#{self.id}") objects.batch_delete! end end
- DBリソースとS3上の削除処理を一緒に行う場合は、以下のようにtransactionを貼れば、どちらかの処理が失敗した時にDBリソース削除はロールバックされるので、データの整合性が担保できる。
class User < ActiveRecord def destroy_with_associating_resources ActiveRecord::Base.transaction do self.destroy! self.delete_all_s3_objects! end true rescue => e Rails.logger.error(e) false end endここから少し詳しく説明
説明したいことは、上述のコードだけで説明終了なのですが、もうちょっと詳しい説明を書きたいと思います。
今回説明する際の例として、 ユーザー削除機能 を考えます。そして、そのユーザー機能の1つとして写真投稿があり、その写真はS3上に保存しているとします。
その前提でユーザー削除機能を実装する場合、 DB内のユーザー情報に加えて、指定ユーザーに関連するS3上のファイルを一緒に削除することが必要 になります。
この記事は このユーザー削除+S3上のファイル削除を考える話 となります。
ユーザーとS3上のファイルアップロード先の関係
ユーザーが投稿した写真は、AWSのS3上の
/bucket_name/contents/users/:idにアップロードするとします。:idはユーザーのID(数字)です。そのため、例えばIDは1のユーザーを削除する場合、S3上の
/bucket_name/contents/users/1/パスのディレクトリをファイルを含めて削除することを意味します。実装
以上の説明を踏まえて、作成した実装が以下の通りです。
class User < ApplicationRecord def delete_all_s3_contents! s3 = Aws::S3::Resource.new(client: s3_client) objects = s3.bucket("bucket_name").objects(prefix: "contents/users/#{self.id}") objects.batch_delete! end def destroy_with_associating_resources ActiveRecord::Base.transaction do self.destroy! self.delete_all_s3_objects! end true rescue => e Rails.logger.error(e) false end private def s3_client @s3_client ||= Aws::S3::Client.new( access_key_id: "ACCESS_KEY_ID", secret_access_key: "SECRET_ACCESS_KEY", region: "ap-northeast-1", ) end endS3上の指定ディレクトリの削除
def delete_all_s3_contents! s3 = Aws::S3::Resource.new(client: s3_client) objects = s3.bucket("bucket_name").objects(prefix: "contents/users/#{self.id}") objects.batch_delete! end指定ディレクトリ削除の流れは、まず
s3.bucket("bucket_name").objects(prefix: "contents/users/#{self.id}")にて、バケット名bucket_nameのcontents/users/#{self.id}ディレクトリにある全てのオブジェクト一覧を取得します。そして、
batch_delete!を呼び出し、先ほど取得したオブジェクト一覧をパラメタとして、一括でS3に削除APIをリクエストします。
s3_clientはプライベートメソッドとして切り出しています。理由は、その方がテストのモックが簡単になるからです。private def s3_client @s3_client ||= Aws::S3::Client.new( access_key_id: "ACCESS_KEY_ID", secret_access_key: "SECRET_ACCESS_KEY", region: "ap-northeast-1", ) endまた、AWSのS3に対するAPIリクエストを行なった際に、失敗したら例外が吐かれる想定で実装しています。実際のコードで例外送出を行う箇所を見つけられなかったので正確ではないですが、以下のS3のSDKのスタブに関する記事をみる限り、失敗時は例外を吐くと認識しています(間違っていたら、ご指摘ください)
https://docs.aws.amazon.com/ja_jp/sdk-for-ruby/v3/developer-guide/stubbing.htmlユーザー削除処理
def destroy_with_associating_resources ActiveRecord::Base.transaction do self.destroy! self.delete_all_s3_objects! end true rescue => e Rails.logger.error(e) false endユーザー削除(
self.destroy!)とS3上のファイル削除(self.delete_all_s3_objects!)の2つに対して、transactionを貼ることで、どちらかが失敗して例外を早出したら、DBロールバックによってリクエスト実行前に戻ります。ここのポイントは、
self.delete_all_s3_objects!を後にすることですね。処理を逆にしてしまうと、S3上のファイル削除後に行われるユーザー削除が失敗した場合に、S3上のファイル削除だけ行われてしまうのでデータの整合性が保つことができなくなります。テスト
次に、S3削除処理(
delete_all_s3_contents!)に対するテストについて説明します。全体の流れは以下の通りです。describe :delete_all_s3_contents! do subject { user.delete_all_s3_contents! } let(:user) { User.create(name: "hoge") } let(:s3_contents) { [ { key: "contents/users/1/IMAGE.png" }, { key: "contents/users/1" }, ] } let(:client) { Aws::S3::Client.new(stub_responses: true) } before do client.stub_responses(:list_objects, contents: s3_contents) client.stub_responses(:delete_objects) allow(user).to receive(:s3_client).and_return(client) end it "指定のパラメタでS3のAPIへリクエストが行われていること" do expect(client).to receive(:list_objects). with({ bucket: "bucket_name", prefix: "contents/#{user.id}", }).and_call_original expect(client).to receive(:delete_objects). with({ bucket: "bucket_name", delete: { objects: s3_contents }, }) subject end end前準備
まず
beforeでの前準備の説明です。まず、APIスタブ用のAWSのS3 clientインスタンスを作成しています。S3Clientはオプションを指定するとスタブができるようになります。
let(:client) { Aws::S3::Client.new(stub_responses: true) }次に、clientから実行するS3のAPIリクエストのスタブを行います。
before do client.stub_responses(:list_objects, contents: s3_contents) client.stub_responses(:delete_objects) allow(user).to receive(:s3_client).and_return(client) endS3のsdkには
stub_responsesというスタブ用のメソッドが用意されています(リファレンス)。今回は
batch_delete!で指定のオブジェクトの削除を行うのですが、実際にS3側へのリクエストはlist_objectsとdelete_objectsの2つが行われます。なので、その2つをスタブしてあげれば良いです。最後に、
userインスタンスのプライベートメソッドs3_clientが呼ばれた際に、モック用のS3 clientインスタンスclientを返すよう定義します。これでS3へのリクエストをスタブすることができました。検証
実際の検証部分は以下の通りです。
it "指定のパラメタでS3のAPIへリクエストが行われていること" do expect(client).to receive(:list_objects). with({ bucket: "bucket_name", prefix: "contents/users/#{user.id}", }).and_call_original expect(client).to receive(:delete_objects). with({ bucket: "bucket_name", delete: { objects: s3_contents }, }) subject end
expectのwithと使うことでパラメタの検証ができるので、「指定のパラメタでS3へリクエストを送っているか?」を検証しています。あと、今回初めて知った点で
and_call_originalです。通常expectで対象となったメソッドはデフォルトではレスポンスを返さなくなります。ただ、今回はlist_objectsのレスポンスを使ってdelete_objectsを実行するのでレスポンスがないと以後の検証ができません。なので、and_call_originalを呼ぶことで、ちゃんとレスポンスを返す指定をしています。おわりに
S3の削除の実装、テストを書きながら、色々勉強になった点をまとめました。今後もテストが書きやすく、わかりやすいコードを書けるように日々精進していきたいです。
参考文献














