- 投稿日:2021-03-06T23:42:29+09:00
名前空間付きのモデルの関連付けでハマった話
背景
schoolモデルにcoachを持たせるのでcoachモデルのインスタンスを作成する際に「new」ではなく「build」に置き換えようとしたときの話。
前提
筆者
- railsを初めて触って一ヶ月の初心者。
- コードを書いているがなぜ動いているのかあまり分かっていない。
- 間違いがあれば指摘していただけると幸いです。
開発環境
- 端末:macbook pro
- OS:macOS Big Sur 11.2.2
- ruby:2.7.2
- rails:6.1.3
schoolユーザーでログインしているアカウントのみcoachを作成できるように。
そのためにshoolsのnamespace内にcoachを入れようと考えました。なので、これらのモデルを作成する際にはscaffoldを使って以下のように作成。
rails g scaffold schools/coaches name:string
モデル同士を関連付ける。
school.rbclass School < ApplicationRecord has_many :schools_coaches endschools/coach.rbclass Schools::coach < ApplicationRecord belongs_to :school endnewをbuildに置き換える。
coaches_controller.rbdef new @schools_coach = current_school.schools_coaches.build endこれではうまく行きませんでした。
エラー uninitialized constant School::SchoolsCoach
と怒られてしまいます。原因はテーブル名
どうやらテーブル名がschools_coachesとなっていたことが原因なようです。
has_many :schools_coaches
とするとSchoolsCoach
モデルを探しに行ってしまうことが原因のようです。訂正
school.rbclass School < ApplicationRecord has_many :coaches endcoach.rbclass Coach < ApplicationRecord belongs_to :school endcoaches_controller.rbdef new @schools_coach = current_school.schools_coaches.build endモデル名を変更したことでviewも変更しなければならなかった。
_form.html.erb<%= form_with(model: @schools_coach, url: schools_coaches_path) do |form| %>modelのみ指定していたが、それだとform_withさんはCoachモデルをみて'coach/new'や'coach/1/edit'のパスだと勘違いしてしまうのでurlでパスを指定してあげる。
これでうまく動いた。他の解決策
他の解決策もたくさんあるみたいです。
has_manyにclassnameを追記
まとめ
モデル名はできるだけnamespaceをつけない方がややこしくないかも。
つける場合はclassnameをつけるなどの対策が必要。
- 投稿日:2021-03-06T23:40:54+09:00
AWSへデプロイ後に、暗号化したパスワードでログインできない現象について
AWSへデプロイ後に、bcryptで暗号化したパスワードでログインできない現象について
プログラミング初学者です。Ruby on Railsで作成したアプリをAWSのEC2インスタンスにデプロイした後で、作成したアカウントにログインできない現象が発生しました。パスワード周りのエラーの解消に丸一日かかってしまいましたので、また同じ現象で困らないためにもこの記事にまとめておきたいと思います。
発生した環境
Rubyバージョン:2.5.8
Railsバージョン:6.1.1
gem bcryptバージョン:3.1.16発生した現象
デプロイ後に新規登録したアカウントに、始めはログインできていたのですが、しばらく経ってからログインしようとすると「メールアドレスかパスワードに誤りがあります」と表示されログインできなくなりました。
試したこと
ログを確認
$ vim log/production.logいくつかのエラーログを確認しましたので、順に確認していきました。
Mysql2::Error
ActiveRecord::StatementInvalid (Mysql2::Error: Unknown column 'password_digest' in 'field list'):
bcryptでパスワードの暗号化を行なった際に、'password_digest'カラムをデータベースのusersテーブルに追加していました。mysqlにログインしたところ、確かに'password_digest'カラムは存在して値が保存されていました。
ArgumentError
ArgumentError (wrong number of arguments (given 0, expected 1)):
app/controllers/users_controller.rb:41:in `login'値が入るはずの場所に値が入っていないことが確認できました。
該当の場所には下記コードがありました。app/controllers/users_controller.rbif @user && @user.authenticate(params[:password])どうやら入力したパスワードが正常に認識されていないことがわかりました。
NoMethodError
NoMethodError (undefined method `encrypted_password=' for #User:0x000000000572e6d8):
'encrypted_password'は何のことかとわからなかったので、検索するとDeviseを導入した時に追加されるという情報がありました。確認すると、確かにGemfileに「gem 'devise'」を追加していました。そこで、encrypted_passwordを利用したパスワードの暗号化に切り替えることにしました。
password_digestからencrypted_passwordへの切り替え
まずmysqlでusersテーブルのカラム名を変更します。
mysql> alter table users change column password_digest encrypted_password char(255);user.rbを編集します。
app/models/user.rbclass User < ApplicationRecord has_secure_password #ここを削除します validates :name, {presence: true} validates :email, {presence: true, uniqueness: true} validates :image_name, {presence: true} validates :password, {presence: true} #ここも不要になるので削除します devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable, :omniauthable endusers_controller.rbを編集します。
エラーが発生していた、ログイン認証の行の認証メソッドを書き換えます。app/controllers/users_controller.rbif @user && @user.valid_password?(params[:password]パスワードを変更する部分が少々手こずりました。
params[:password]に保存されている新しいパスワードを手動で暗号化して更新しています。app/controllers/users_controller.rbif params[:password] hashed_password = BCrypt::Password.create(params[:password]) update_password_sql = "update users set encrypted_password = '#{hashed_password}' where id =#{@current_user.id};" ActiveRecord::Base.connection.execute(update_password_sql) endコードの編集が終わったらサーバーを再起動します。
nginxを再起動します。$ sudo service nginx restart続いてunicornを再起動します。unicornで走っているスレッドを確認します。
$ ps -ef | grep unicorn | grep -v grep3行の走っているスレッド番号が表示されます。
下は一行目の一例です。ユーザー名 番号 1 0 11:16 ? 00:00:00 unicorn_rails master -c /var/www/アプリのディレクトリ/config/unicorn.conf.rb -D -E production …スレッドを停止します。
$ kill 番号もう一度上記の確認コマンドを打ち込んで何も表示されなければ、スレッドが終了しています。
unicornを起動します。$ bundle exec unicorn_rails -c /var/www/アプリのディレクトリ/config/unicorn.conf.rb -D -E production確認コマンドを打ち込んでスレッド番号が表示されれば、再起動できています。
まとめ
丸一日かかってしまったので、思い出せる限り全部の工程をまとめました。
某プログラミング学習サイトでパスワードの暗号化を習い、簡単にできるものだと思っていたら思わぬ落とし穴がありました。Twitter連携などに使用するDeviseを導入することで、パスワード暗号化の機能が重複してエラーを発生するようです。簡単に習っただけでは実践には足りない、実際にエラーに遭遇することで腕を磨くものだと、今後の良い戒めになりました。
- 投稿日:2021-03-06T22:49:55+09:00
非同期通信(バックエンド)
非同期通信
リクエスト後にブラウザが再読み込みされず一部分のみが更新される通信方法。
待ち時間や画面の切り替わりがなくストレスが少なく操作できます。
SNSの既読機能だったり、いいね機能だったり多用されています。Ajax
JavaScriptを使用して非同期通信を行う処理のこと
("Asynchronous JavaScript + XML"の略)メモアプリを実装しながら投稿機能を非同期通信で、また既読機能をつけてみましょう。
新規アプリケーションを作成
console% rails _6.0.0_ new first_app -d mysql % rails db:createルーティングを設定
config/routes.rbRails.application.routes.draw do root to: 'posts#index' post 'posts', to: 'posts#create' endpostモデルを作成
% rails g model postマイグレーションファイル作成
db/migrate/....class CreatePosts < ActiveRecord::Migration[6.0] def change create_table :posts do |t| t.text :content t.boolean :checked t.timestamps end end endboolean型
trueまたはfalseの真理値を判断する型です。
既読機能実装時に「既読か未読か」をboolean型で管理します。
% rails db:migratepostsコントローラーを作成
% rails g controller posts indexアクションを記述
class PostsController < ApplicationController def index @posts = Post.all.order(id: "DESC") end def create Post.create(content: params[:content]) redirect_to action: :index endビューを編集
<h1>AjaxApp</h1> <%= form_with url: "/posts", method: :post,id: "form" do |form| %> <%= form.text_field :content %> <%= form.submit '投稿する' , id: "submit" %> <% end %> <% @posts.each do |post| %> <div class="post"> <div class="post-date"> 投稿日時:<%= post.created_at %> </div> <div class="post-content"> <%= post.content %> </div> </div> <% end %>memo_appのappディレクトリにあるjavascriptディレクトリに
memo.jsとchecked.jsというファイルを作成app/javascript/packs/application.jsrequire("@rails/ujs").start() require("turbolinks").start() require("@rails/activestorage").start() require("channels") require("../checked") require("../memo")非同期通信の実装方法
非同期通信時のレスポンス内容はHTMLではなくデータを返却します。
それをJavaScriptで受け取り、すでに表示されているHTMLを部分的にレンダリングするような仕組みです。動きの図
エンドポイント
Ajaxでやり取りする際の、データ返却のアクションを実行するためのURLのことです。
Ajaxを実現するためには、コントローラーでのレスポンスを、HTMLではなくjsonなどのデータ形式で返却する必要がありデータを取得する時にアクセスするためのURLを、エンドポイントといいます。
既読機能の実装の際に、エンドポイントには「どのメモを既読にしたか」を判別するため「メモのid」というパラメーターを渡す必要があります。
URLパラメーター
サーバーに情報を送るために記載するURL末尾の文字列のことで、送信した情報は、今までparams[:id]などで取得し、使用してきました。
非同期通信では、このURLパラメーターを活用し、サーバーへデータを送ります。queryパラメーター
queryパラメーターとは、
http://sample.jp/?fruit=orangeのように、「?」以降に情報をかくURLパラメーターです。
「?」以降の構造は、?<変数名>=<値>となっています。このURLでparams[:fruit]とすると、orangeが戻り値となります。
エンドポイントを、queryパラメーターで記述
既読機能に必要なパラメーターは、「どのメモを既読したか」を判別するためのメモのidです。
メモのidを取得できるようにルーティングに設定します。
下記を追記しましょう。config/routes.rbRails.application.routes.draw do root to: 'posts#index' post 'posts', to: 'posts#create' get 'posts', to: 'posts#checked' endqueryパラメーターを使用した場合、/posts/?id=1とリクエストを行うと、params[:id]にてパラメーターを取得することができます。
pathパラメーター
http://tweets.jp/tweets/1のように指定するURLパラメーターです。
queryパラメーターとの使い分けは、pathパラメーターで指定するのは「リソースを識別する場合」です。既読機能のエンドポイントは、queryパラメーターで設定しました。しかし、今回のように渡す情報が一意の情報であればpathパラメーターの方が適しています。
routes.rbRails.application.routes.draw do root to: 'posts#index' post 'posts', to: 'posts#create' get 'posts/:id', to: 'posts#checked' end今回のように、postのidであれば'posts/:id'のように記載するpathパラメーターの方が認識もしやすく、記述も単純です。
データ形式
コンピューター上でデータをやり取りする際の形式のことです。プログラミングではJSONが多く使用されます。
XML
JSONと同じくデータをやり取りする際に使用する形式の1つです。
JSONはRubyのハッシュに似ていますが、XMLはHTMLに似ています。
JSONはJavaScriptObjectNotationの略であり、JavaScriptにおけるオブジェクトの表記です。そのため、データをJavaScriptのオブジェクト指向で取り扱う場合に相性が良いです。text.json{ "name": "json表記", "format": { "json": "JavaScriptにおけるオブジェクトの表記", "xml": "HTMLに似ています" } }text.xml<name>xml表記</name> <format> <json>JavaScriptにおけるオブジェクトの表記</json> <xml>HTMLに似ています</xml> </format>Ruby on Railsは、デフォルトではHTMLをレスポンスとして返却する仕組みになっているので
JSONでデータを返却するにはコントローラーの記述を工夫する必要があります。その際にrenderメソッドを使用します。
既読機能のcheckedというアクションを定義
app/controllers/posts_controller.rbclass PostsController < ApplicationController def index @posts = Post.all.order(id: "DESC") end def create Post.create(content: params[:content]) redirect_to action: :index end def checked post = Post.find(params[:id]) if post.checked post.update(checked: false) else post.update(checked: true) end item = Post.find(params[:id]) render json: { post: item } end end①先ほど設定したURLパラメーターから、既読したメモのidが渡されるように設定するので、そのidを使用して該当するレコードを取得しています。
②if文で、post.checkedという既読であるか否かを判定するプロパティを指定し、既読であれば「既読を解除するためにfalseへ変更」し、既読でなければ「既読にするためtrueへ変更」します。
ActiveRecordのupdateというメソッドを使用して更新しています。最後に、更新したレコードをitem = Post.find(params[:id])で取得し直し
render json:{ post: item }でJSON形式(データ)としてchecked.jsに返却しています。バックエンド側の処理はいったんここまで次はフロントにいきます。
- 投稿日:2021-03-06T22:37:05+09:00
【発展】Active Storageで複数の画像を投稿しよう!(プレビュー機能)
Active Storage利用して複数の画像を投稿できる機能とJavaScriptを用いてプレビューできる機能を実装します。
今回も初心者向けにレシピ投稿アプリを例に作成していきます。完成イメージ
※なお今回は前回の記事の発展的な内容になっております。
Active Storageの導入方法についてこちら
プレビュー機能についてはこちら
今回は上記の機能が実装されている前提で話を進めていきます。
Active Storage関連ファイルの修正
まずはActive Storage周りのファイルから修正していきます。
アソシエーションの修正
レコードとファイルを1対1の関係で紐づけるためにhas_one_attachedメソッドを使用していましたが、今回は1対多の関係に変更するのでhas_many_attachedメソッドに修正します。Railsガイド
また、imageを複数形のimagesに変更します。
app/models/recipe.rbclass Recipe < ApplicationRecord has_many_attached :images #ここを修正 end投稿フォームの修正
file_fieldのimageをimagesに修正します。
また、name属性を追加し送信する際に必要な画像の配列を設定します。app/views/recipes/new.html.erb<%= form_with model: @recipe, local: true do |f| %> #中略 <div class="form-group"> <label class="text-secondary">画像</label><br> <%= f.file_field :images, name: 'recipe[images][]' %> #ここを修正 </div> #以下略 <% end %>コントローラーの修正
画像の配列を受け取れるようにストロングパラメーターを修正します。
app/controllers/recipes_controller.rbclass RecipesController < ApplicationController #中略 private def recipe_params params.require(:recipe).permit(:title, :text, :category_id, :time_required_id, images: []) #images: []に修正 end endバリデーションの設定
今回、投稿できる画像を3枚までにしたいので、独自のバリデーションメソッドを作成していきます。Railsガイド
app/models/recipe.rbclass Recipe < ApplicationRecord has_many_attached :images #ここから追加 validate :image_length #カスタムメソッドなので"validate" private def image_length if images.length >= 4 errors.add(:images, "は3枚以内にしてください") end end #ここまで追加 end以上でActive Storage関連の修正は完了です。
preview.jsの修正
今回は以下のGIFのようにひとつ画像を選択すると新しく画像選択フォームが出現する仕様にします。
まずは、前回作成したpreview.jsを確認してみましょう。
app/javascript/packs/preview.jsif (document.URL.match(/new/)){ document.addEventListener('DOMContentLoaded', () => { const createImageHTML = (blob) => { const imageElement = document.getElementById('new-image'); const blobImage = document.createElement('img'); blobImage.setAttribute('class', 'new-img') blobImage.setAttribute('src', blob); imageElement.appendChild(blobImage); }; document.getElementById('recipe_image').addEventListener('change', (e) => { const imageContent = document.querySelector('img'); if (imageContent){ imageContent.remove(); } const file = e.target.files[0]; const blob = window.URL.createObjectURL(file); createImageHTML(blob); }); }); }こちらを修正していきます。
今回は、以下のようなHTMLを作成することを前提にpreview.jsを編集していきます。
/recipes/new<div id="new-image"> <!-- ここから --> <div class="image-element"> <img class="new-img" src="xxxxxxx..."> <input id="recipe_image_n" class="recipe-images" name="recipe[images][]" type="file"> </div> <!-- ここまでを複製していくイメージ --> </div>前回まではid="new-image"のdiv要素に直接プレビュー画像を挿入していましたが、今回は新たなdiv要素を作成しその中に画像を挿入していきます。
そして、classにimage-elementを設定しquerySelectorAllメソッドとlengthメソッドで作成された要素の数を定数imageElementNumに格納します。
app/javascript/packs/preview.jsif (document.URL.match(/new/)){ document.addEventListener('DOMContentLoaded', () => { const ImageList = document.getElementById('new-image'); //追記 const createImageHTML = (blob) => { const imageElement = document.createElement('div'); //new-imageからdivに変更 //ここから imageElement.setAttribute('class', "image-element") let imageElementNum = document.querySelectorAll('.image-element').length //ここまで追記 const blobImage = document.createElement('img'); blobImage.setAttribute('class', 'new-img') blobImage.setAttribute('src', blob); imageElement.appendChild(blobImage); ImageList.appendChild(imageElement); //追記 }); }; document.getElementById('recipe-images').addEventListener('change', (e) => { //recipe-imagesに修正 //ここから //const imageContent = document.querySelector('img'); //if (imageContent){ //imageContent.remove(); //} //ここまで削除 const file = e.target.files[0]; const blob = window.URL.createObjectURL(file); createImageHTML(blob); }); }); }次に新しく画像選択フォームを作成する記述をしていきます。
inputのidに先ほど宣言したimageElementNumを使いinput要素が何番目の要素かを判別します。app/javascript/packs/preview.js//中略 document.addEventListener('DOMContentLoaded', () => { const ImageList = document.getElementById('new-image'); const createImageHTML = (blob) => { const imageElement = document.createElement('div'); imageElement.setAttribute('class', "image-element") let imageElementNum = document.querySelectorAll('.image-element').length const blobImage = document.createElement('img'); blobImage.setAttribute('class', 'new-img') blobImage.setAttribute('src', blob); //ここから const inputHTML = document.createElement('input'); inputHTML.setAttribute('id', `recipe_image_${imageElementNum}`); inputHTML.setAttribute('class', 'recipe-images'); inputHTML.setAttribute('name', 'recipe[images][]'); inputHTML.setAttribute('type', 'file'); //ここまで追記 imageElement.appendChild(blobImage); imageElement.appendChild(inputHTML); //追記 ImageList.appendChild(imageElement); //以下略これで、画像を選択すると新しく画像選択フォームが出現するようになりました。
2枚目以降にもイベント発火するよう処理を記述していきます。
1度目に発火するイベントとほとんど同じ記述になります。app/javascript/packs/preview.js//中略 const createImageHTML = (blob) => { const imageElement = document.createElement('div'); imageElement.setAttribute('class', "image-element") let imageElementNum = document.querySelectorAll('.image-element').length const blobImage = document.createElement('img'); blobImage.setAttribute('class', 'new-img') blobImage.setAttribute('src', blob); const inputHTML = document.createElement('input'); inputHTML.setAttribute('id', `recipe_image_${imageElementNum}`); inputHTML.setAttribute('class', 'recipe-images'); inputHTML.setAttribute('name', 'recipe[images][]'); inputHTML.setAttribute('type', 'file'); imageElement.appendChild(blobImage); imageElement.appendChild(inputHTML); ImageList.appendChild(imageElement); //ここから inputHTML.addEventListener('change', (e) => { const file = e.target.files[0]; const blob = window.URL.createObjectURL(file); createImageHTML(blob); }); //ここまで追記 }; //以下略ここまでで複数の画像投稿に対応したプレビュー機能の実装が完了しました。
しかし、これでは1枚画像を選択するたびに新しく画像選択フォームが出現してしまうので何枚でも画像選択が可能になってしまいます。
モデルにバリデーションをかけているので投稿は保存されません。
条件分岐で制限をかけよう
input要素の数える定数imageElementNumを使い、3枚選択すると新しく画像選択フォームが出現しないように条件分岐していきましょう。
app/javascript/packs/preview.js//中略 const inputHTML = document.createElement('input'); inputHTML.setAttribute('id', `recipe_image_${imageElementNum}`); inputHTML.setAttribute('class', 'recipe-images'); inputHTML.setAttribute('name', 'recipe[images][]'); inputHTML.setAttribute('type', 'file'); imageElement.appendChild(blobImage); if (imageElementNum < 2) { //追記 imageElement.appendChild(inputHTML); } //追記 ImageList.appendChild(imageElement); inputHTML.addEventListener('change', (e) => { const file = e.target.files[0]; const blob = window.URL.createObjectURL(file); createImageHTML(blob); }); //以下略app/javascript/packs/preview.jsif (document.URL.match(/new/)){ document.addEventListener('DOMContentLoaded', () => { const ImageList = document.getElementById('new-image'); const createImageHTML = (blob) => { const imageElement = document.createElement('div'); imageElement.setAttribute('class', "image-element") let imageElementNum = document.querySelectorAll('.image-element').length const blobImage = document.createElement('img'); blobImage.setAttribute('class', 'new-img') blobImage.setAttribute('src', blob); const inputHTML = document.createElement('input'); inputHTML.setAttribute('id', `recipe_image_${imageElementNum}`); inputHTML.setAttribute('class', 'recipe-images'); inputHTML.setAttribute('name', 'recipe[images][]'); inputHTML.setAttribute('type', 'file'); imageElement.appendChild(blobImage); if (imageElementNum < 2) { imageElement.appendChild(inputHTML); } ImageList.appendChild(imageElement); inputHTML.addEventListener('change', (e) => { const file = e.target.files[0]; const blob = window.URL.createObjectURL(file); createImageHTML(blob); }); }; document.getElementById('recipe-images').addEventListener('change', (e) => { const file = e.target.files[0]; const blob = window.URL.createObjectURL(file); createImageHTML(blob); }); }); }次回は投稿された複数の画像をスライド形式で表示する実装を行っていきます。
- 投稿日:2021-03-06T22:37:05+09:00
【発展】Active Storageで複数の画像を投稿しよう! (プレビュー機能も実装)
Active Storage利用して複数の画像を投稿できる機能とJavaScriptを用いてプレビューできる機能を実装します。
今回も初心者向けにレシピ投稿アプリを例に作成していきます。完成イメージ
※なお今回は前回の記事の発展的な内容になっております。
Active Storageの導入方法についてこちら
プレビュー機能についてはこちら
今回は上記の機能が実装されている前提で話を進めていきます。
Active Storage関連ファイルの修正
まずはActive Storage周りのファイルから修正していきます。
アソシエーションの修正
レコードとファイルを1対1の関係で紐づけるためにhas_one_attachedメソッドを使用していましたが、今回は1対多の関係に変更するのでhas_many_attachedメソッドに修正します。Railsガイド
また、imageを複数形のimagesに変更します。
app/models/recipe.rbclass Recipe < ApplicationRecord has_many_attached :images #ここを修正 end投稿フォームの修正
file_fieldのimageをimagesに修正します。
また、name属性を追加し送信する際に必要な画像の配列を設定します。app/views/recipes/new.html.erb<%= form_with model: @recipe, local: true do |f| %> #中略 <div class="form-group"> <label class="text-secondary">画像</label><br> <%= f.file_field :images, name: 'recipe[images][]' %> #ここを修正 </div> #以下略 <% end %>コントローラーの修正
画像の配列を受け取れるようにストロングパラメーターを修正します。
app/controllers/recipes_controller.rbclass RecipesController < ApplicationController #中略 private def recipe_params params.require(:recipe).permit(:title, :text, :category_id, :time_required_id, images: []) #images: []に修正 end endバリデーションの設定
今回、投稿できる画像を3枚までにしたいので、独自のバリデーションメソッドを作成していきます。Railsガイド
app/models/recipe.rbclass Recipe < ApplicationRecord has_many_attached :images #ここから追加 validate :image_length #カスタムメソッドなので"validate" private def image_length if images.length >= 4 errors.add(:images, "は3枚以内にしてください") end end #ここまで追加 end以上でActive Storage関連の修正は完了です。
preview.jsの修正
今回は以下のGIFのようにひとつ画像を選択すると新しく画像選択フォームが出現する仕様にします。
まずは、前回作成したpreview.jsを確認してみましょう。
app/javascript/packs/preview.jsif (document.URL.match(/new/)){ document.addEventListener('DOMContentLoaded', () => { const createImageHTML = (blob) => { const imageElement = document.getElementById('new-image'); const blobImage = document.createElement('img'); blobImage.setAttribute('class', 'new-img') blobImage.setAttribute('src', blob); imageElement.appendChild(blobImage); }; document.getElementById('recipe_image').addEventListener('change', (e) => { const imageContent = document.querySelector('img'); if (imageContent){ imageContent.remove(); } const file = e.target.files[0]; const blob = window.URL.createObjectURL(file); createImageHTML(blob); }); }); }こちらを修正していきます。
今回は、以下のようなHTMLを生成することを前提にpreview.jsを編集していきます。
/recipes/new<div id="new-image"> <!-- ここから --> <div class="image-element"> <img class="new-img" src="xxxxxxx..."> <input id="recipe_image_n" class="recipe-images" name="recipe[images][]" type="file"> </div> <!-- ここまでを複製していくイメージ --> </div>前回まではid="new-image"のdiv要素に直接プレビュー画像を挿入していましたが、今回は新たなdiv要素を作成しその中に画像を挿入していきます。
そして、classにimage-elementを設定しquerySelectorAllメソッドとlengthメソッドで作成された要素の数を定数imageElementNumに格納します。
app/javascript/packs/preview.jsif (document.URL.match(/new/)){ document.addEventListener('DOMContentLoaded', () => { const ImageList = document.getElementById('new-image'); //追記 const createImageHTML = (blob) => { const imageElement = document.createElement('div'); //new-imageからdivに変更 //ここから imageElement.setAttribute('class', "image-element") let imageElementNum = document.querySelectorAll('.image-element').length //ここまで追記 const blobImage = document.createElement('img'); blobImage.setAttribute('class', 'new-img') blobImage.setAttribute('src', blob); imageElement.appendChild(blobImage); ImageList.appendChild(imageElement); //追記 }); }; document.getElementById('recipe-images').addEventListener('change', (e) => { //recipe-imagesに修正 //ここから //const imageContent = document.querySelector('img'); //if (imageContent){ //imageContent.remove(); //} //ここまで削除 const file = e.target.files[0]; const blob = window.URL.createObjectURL(file); createImageHTML(blob); }); }); }次に新しく画像選択フォームを作成する記述をしていきます。
createElementメソッドでinput要素を生成し、setAttributeで属性を追加していきます。
そして、inputのidに先ほど宣言したimageElementNumを使いinput要素が何番目の要素かを判別します。app/javascript/packs/preview.js//中略 document.addEventListener('DOMContentLoaded', () => { const ImageList = document.getElementById('new-image'); const createImageHTML = (blob) => { const imageElement = document.createElement('div'); imageElement.setAttribute('class', "image-element") let imageElementNum = document.querySelectorAll('.image-element').length const blobImage = document.createElement('img'); blobImage.setAttribute('class', 'new-img') blobImage.setAttribute('src', blob); //ここから const inputHTML = document.createElement('input'); inputHTML.setAttribute('id', `recipe_image_${imageElementNum}`); inputHTML.setAttribute('class', 'recipe-images'); inputHTML.setAttribute('name', 'recipe[images][]'); inputHTML.setAttribute('type', 'file'); //ここまで追記 imageElement.appendChild(blobImage); imageElement.appendChild(inputHTML); //追記 ImageList.appendChild(imageElement); //以下略これで、画像を選択すると新しく画像選択フォームが出現するようになりました。
2枚目以降にもイベント発火するよう処理を記述していきます。
1度目に発火するイベントとほとんど同じ記述になります。app/javascript/packs/preview.js//中略 const createImageHTML = (blob) => { const imageElement = document.createElement('div'); imageElement.setAttribute('class', "image-element") let imageElementNum = document.querySelectorAll('.image-element').length const blobImage = document.createElement('img'); blobImage.setAttribute('class', 'new-img') blobImage.setAttribute('src', blob); const inputHTML = document.createElement('input'); inputHTML.setAttribute('id', `recipe_image_${imageElementNum}`); inputHTML.setAttribute('class', 'recipe-images'); inputHTML.setAttribute('name', 'recipe[images][]'); inputHTML.setAttribute('type', 'file'); imageElement.appendChild(blobImage); imageElement.appendChild(inputHTML); ImageList.appendChild(imageElement); //ここから inputHTML.addEventListener('change', (e) => { const file = e.target.files[0]; const blob = window.URL.createObjectURL(file); createImageHTML(blob); }); //ここまで追記 }; //以下略ここまでで複数の画像投稿に対応したプレビュー機能の実装が完了しました。
しかし、これでは1枚画像を選択するたびに新しく画像選択フォームが出現してしまうので何枚でも画像選択が可能になってしまいます。
モデルにバリデーションをかけているので投稿は保存されません。
条件分岐で制限をかけよう
input要素の数える定数imageElementNumを使い、3枚選択すると新しく画像選択フォームが出現しないように条件分岐していきましょう。
app/javascript/packs/preview.js//中略 const inputHTML = document.createElement('input'); inputHTML.setAttribute('id', `recipe_image_${imageElementNum}`); inputHTML.setAttribute('class', 'recipe-images'); inputHTML.setAttribute('name', 'recipe[images][]'); inputHTML.setAttribute('type', 'file'); imageElement.appendChild(blobImage); if (imageElementNum < 2) { //追記 imageElement.appendChild(inputHTML); } //追記 ImageList.appendChild(imageElement); inputHTML.addEventListener('change', (e) => { const file = e.target.files[0]; const blob = window.URL.createObjectURL(file); createImageHTML(blob); }); //以下略app/javascript/packs/preview.jsif (document.URL.match(/new/)){ document.addEventListener('DOMContentLoaded', () => { const ImageList = document.getElementById('new-image'); const createImageHTML = (blob) => { const imageElement = document.createElement('div'); imageElement.setAttribute('class', "image-element") let imageElementNum = document.querySelectorAll('.image-element').length const blobImage = document.createElement('img'); blobImage.setAttribute('class', 'new-img') blobImage.setAttribute('src', blob); const inputHTML = document.createElement('input'); inputHTML.setAttribute('id', `recipe_image_${imageElementNum}`); inputHTML.setAttribute('class', 'recipe-images'); inputHTML.setAttribute('name', 'recipe[images][]'); inputHTML.setAttribute('type', 'file'); imageElement.appendChild(blobImage); if (imageElementNum < 2) { imageElement.appendChild(inputHTML); } ImageList.appendChild(imageElement); inputHTML.addEventListener('change', (e) => { const file = e.target.files[0]; const blob = window.URL.createObjectURL(file); createImageHTML(blob); }); }; document.getElementById('recipe-images').addEventListener('change', (e) => { const file = e.target.files[0]; const blob = window.URL.createObjectURL(file); createImageHTML(blob); }); }); }次回は投稿された複数の画像をスライド形式で表示する実装を行っていきます。
- 投稿日:2021-03-06T22:21:21+09:00
RailsのAPI開発で使える!JWTを理解して認証機能を実装する!
背景
以前、vue.js × Rails APIでのSPA開発に挑戦しました。その際、認証にJWTを使ったのですが、ネットの記事を参考に実装したために仕組みやコードを完全に理解できていませんでした。そのためJWTの認証について改めて記事にしてまとめたいと思いました。railsでvue,reactなどと連携させてSPA開発したい方は参考になるかと思います。
ポートフォリオに関する記事
【ポートフォリオ】Rails API × vue.js × AWS × Docker × CircleCi × terraformでポートフォリオを作成しました
目次
- JWTについて
- JWTで認証をする流れ
- 実際のrailsコード解説
- vue.jsとの連携
1. JWTについて
■JWTとはなにか
JWTとはJSON Web Tokenの略で、JSON形式で表されたトークンです。著名・暗号化によってセキュアな通信ができます。
■JWTの中身
JWTは下記のうようにピリオド区切りで3つの要素に分かれています。
{①ヘッダー要素}.{②ペイロード要素}.{③署名要素}
- ①のヘッダー要素は、データの型やルールを指定します。
- ②のペイロード要素には、属性情報が入ります。例えばuser_idやemailやtokenの有効期限などです。
- ③の著名要素は、改ざんがされていないか確認するための情報です。
以上がざっくりとして説明です。アプリケーション実装のためには上記の大枠の把握だけで問題ないです。もっと詳しく知りたい場合は下記の参考記事を参照ください。
JWT(JSON Web Token)の「仕組み」と「注意点」
2. JWTで認証をする流れ
■図による説明
ざっくりですが説明です。
- まずはフロント側からログインフォームからユーザー名・メールアドレス・パスワードなどログインに必要な情報を送ります。
- その情報をバックエンドで受け取り、登録しているユーザー名とパスワードが一致していた場合に、tokenを発行してレスポンスします。発行されたtokenは秘密鍵で暗号化してJWTとして送られます。
- そのtokenをlocalstorageに保存して、常に使える状態にします。vue.jsであればvuex, react.jsであればreduxに保存します。ログインしてないとできないリクエストは、このtokenをヘッダーにのせてリクエストします。
- バックエンド側はヘッダー情報をもとに認証・認可を行い、リクエストを返します。
- 帰ってきたリクエストをフロント側で表示等します。
上記の流れを理解すれば、バックエンド側の実装も理解しやすくなります。すなわちバックエンド側はjwtのtoken発行、そのルーティングとモデルの設定をすればよいのです。では早速具体的なコードを見ていきましょう。
実際のコードrails解説
■gemのインストール
gemfilegem 'active_model_serializers' gem 'jwt'
- jwtに関してはそのままjwtというgemがあります。
- active_model_serializersはレスポンスを簡単にそしてきれいにjson形式に整形してくれるgemです。railsでAPI開発をする際はわりかし頻繁に使われます。
■ルーティングの設定
config/routes.rbRails.application.routes.draw do root 'home#index' namespace :api do resources :users, only: %i[create] resource :session, only: %i[create destroy] end end
- 基本的にapi開発はapiやv1をurlにつけて管理することが多いです。そうすることでバージョンの更新がしやすくなるからです。
- 今回はuserの登録はusers、usersの情報をもとに認証を行うのがsessionsの役割です。
ルーティングのnamespaceなどの指定に関しては下記記事を参照してください。
Railsのroutingにおけるscope / namespace / module の違い■モデルの設定
xxx_create_users.rbclass CreateUsers < ActiveRecord::Migration[6.0] def change create_table :users do |t| t.string :name, null: false t.string :email, null: false t.string :password_digest, null: false t.timestamps end add_index :users, :email, unique: true end endmodels/user.rbclass User < ApplicationRecord has_secure_password validates :name, presence: true validates :email, presence: true, uniqueness: true validates :password_digest, presence: true end
- 特に難しいことはないですが、簡単に説明するとuser登録にはname,email,passwordが必要で、ログインにはemail,passwordが必要です。passwordはセキュリティの関係上,password_digestで暗号化して保存しています。
■コントローラの設定
app/contorollers/application_controller.rbclass ApplicationController < ActionController::Base protect_from_forgery with: :null_session class AuthenticationError < StandardError; end rescue_from ActiveRecord::RecordInvalid, with: :render_422 rescue_from AuthenticationError, with: :not_authenticated def authenticate raise AuthenticationError unless current_user end def current_user @current_user ||= Jwt::UserAuthenticator.call(request.headers) end private def render_422(exception) render json: { error: { messages: exception.record.errors.full_messages } }, status: :unprocessable_entity end def not_authenticated render json: { error: { messages: ['please login'] } }, status: :unauthorized end end
- application controllerではエラーに関する処理を記載しています
- 「protect_from_forgery with:」メソッドは自動でCSRF対策の設定です。「null_session」のオプションはTokenが一致しなかった場合にsessionを空にするというオプションです。
- 「class AuthenticationError < StandardError; end」はStandardErrorは例外束ねているクラスです。それをAuthenticationErrorへ継承しています。
- 「rescue_from」は例外の処理です。「rescue_from AuthenticationError, with: :not_authenticated」の意味はAuthenticationErrorが起こった場合に,not_authenticatedメソッドを実行するという意味です。
- 「rescue_from ActiveRecord::RecordInvalid, with: :render_422」はActiveRecord::RecordInvalid(railsが用意しているバリデーションのエラー)が起こった場合に,render_422をするという意味です。
- 「authenticate」は現在ログイン中のuserでなければエラーを発生させるメソッドです。raiseはエラーを発生させるメソッドで、currentuserでない場合はAuthenticationErrorを発生させます。
- 「current_user」は現在ログイン中のuserかどうかを判定するメソッドです。Jwt::UserAuthenticator(この後説明するサービスファイル)で定義したcallメソッドを呼びます。引数にはリクエストのヘッダー情報を送ります。またuser情報が取得できた場合は@current_userに代入され、できない場合はfalseを返します。
- render_422, not_anthentiatedはjson形式でエラーメッセージとstatusを返すメソッドです。
app/controllers/api/sessions_controller.rbclass Api::SessionsController < ApplicationController def create user = User.find_by(email: session_params[:email]) if user&.authenticate(session_params[:password]) token = Jwt::TokenProvider.call(user_id: user.id) render json: ActiveModelSerializers::SerializableResource.new(user, serializer: UserSerializer).as_json.deep_merge(user: { token: token }) else render json: { error: { messages: ['mistake emal or password'] } }, status: :unauthorized end end private def session_params params.require(:session).permit(:email, :password) end end
- 「user = User.find ・・・」でusersカラムから、リクエストされてきたemail情報をもとに特定のuserを見つけます。
- 「user&.authenticate」はuserがnilでない場合にautheicateを実行するという意味です。(ぼっち演算子)
- 「token = JWT」でTokenProviderというサービスファイルで定義したcallメソッドを呼び出し、引数であるuser_idをもとにトークンを取得しています。
- 次に処理内容を「render json」でjson形式でレスポンスしているのですが、少し長いので分解して解説します。「ActiveModelSerializers::SerializableResource.new(user, serializer: UserSerializer)」の箇所は新しくserializerインスタンスを作成しています。普通であればserializerファイルを作っていればこんな記述をしなくてよいのですが,sessionの中身はuserモデルと同じなので、user情報とuserのserializerを引数にとり、その情報をもとにserializerインスタンスを作成しているのです。「.as_json」はserializerの形式を指定しています。「.deep_merge(user: { token: token })」はuserserializerにuserのもっているtoken情報を結合するよという意味です。「.merge」はコントローラ内でもよく使うと思うのですが、ハッシュの中にハッシュがあるような場合は「.deep_merge」を使う必要があります。
- その他の「render json: {error~」や「sessionparams」の定義は難しくないので大丈夫だと思います。
app/controllers/api/users_controller.rbclass Api::UsersController < ApplicationController def create user = User.new(user_params) user.save! render json: user, serializer: UserSerializer end private def user_params params.require(:user).permit(:name, :email, :password, :password_confirmation) end end
- こちらも簡単ですので説明省略します。
■サービスの設定
app/services/jwt/user_authenticator.rbmodule Jwt::UserAuthenticator extend self def call(request_headers) @request_headers = request_headers begin payload, = Jwt::TokenDecryptor.call(token) User.find(payload['user_id']) rescue StandardError nil end end private def token @request_headers['Authorization'].split(' ').last end end
- 「extend self」は、レシーバが「module Jwt::UserAuthenticator」そのものであり、module内で定義したメソッドが「Jwt::UserAuthenticator.メソッド名」として使えるようになるという意味です。
- ちなみにここで定義されたcallメソッドはapplication.rbのcurrentuserメソッドで呼ばれました。引数としてrequestのヘッダー情報が渡されています。
- 「begin ~ rescue ~ end」は例外処理です。begin内でエラーが起こりそうなアクション、resucueがエラーが起こったときのアクションです。「rescue StandardError」としてあるのは、特定のエラーを指定しています。つまりStandard errorが発生したときにnilを返す、今回でいうとcurrent_userがnilになります。ちなみにStandardErrorはプログラムで発生するよくあるエラーがまとまっているクラスです。明記しなくてもデフォルトで設定されるかも。。
- 「payload,= Jwt::TokenDecryptor.call(token)」はサービスのTokenDecryptorファイルにあるcallメソッドを呼び出し、payloadに格納しています。また「payload,」は間違えではありません。「payload,_」と書くことも多いですが、このアンダーバーにヘッダー情報が格納されます。実際にこのコードではtokenを暗号化しているのですが、その際にpayloadの属性情報と一緒に、ヘッダー情報を返します。
- 「User.find...」は取得したpayloadのuser_idの情報をもとにuserを探します。そしてapplicationコントローラのcurrent_userに代入されます。
- プライベートメソッドのtokenには「request_headers['Authorization'].split(' ').last」と書いてあり、ヘッダーのauthrizationのtokenのみを取得しています。request_headersの中身は「Authorization: Bearer tokentokentoken...」となっているので、tokenだけを取得するには上記のようにします。
app/services/jwt/token_decryptor.rbmodule Jwt::TokenDecryptor extend self def call(token) decrypt(token) end private def decrypt(token) JWT.decode(token, Rails.application.credentials.secret_key_base) rescue StandardError raise InvalidTokenError end end class InvalidTokenError < StandardError; end
- プライペートメソッドのdecryptは、引数のtokenをもとに復号化しています。復号にはrailsのrailsの秘密鍵が必要になるので第2引数で指定しています。
- つまりcallは復号化のメソッドです。「services/jwt/user_authenticator.rb」で特定のuserを探すために暗号化されたtokenを複合しているのです。
- resucue以下は例外処理です。StandardErrorが起こった際にraiseでInvalidTokenErrorという自分で定義したエラーを起こしています。InvalidTokenErrorはStandardErrorを継承しています。
app/services/jwt/token_provider.rbmodule Jwt::TokenProvider extend self def call(payload) issue_token(payload) end private def issue_token(payload) JWT.encode(payload, Rails.application.credentials.secret_key_base) end end
- 「issue_token」メソッドは、引数のpayload(今回でいうとuser_idのこと)をもとに暗号化しています。暗号化するにはrailsの秘密鍵が必要になるので第2引数で指定しています。
- つまりcallは暗号化のメソッドです。これはsessionコントローラから呼ばれるメソッドですが、user_idをもとにtokenを暗号化してレスポンスします。
以上で終わりです。あとはpostmanなどで正常にリクエストできるか確かめるだけです。
ファイル構造に沿って流れを確認すると、ログイン時はログインリクエストが送られる→ルーティングでsessionコントローラに振り分けられる→リクエストのemailとpassword情報が正しければ暗号化してレスポンスするという流れです。またログイン中じゃないとできないアクションは、リクエストが送られる→ルーティングで該当のコントローラに振り分けられる→コントローラ内でcurrent_userが呼ばれる→リクエストを暗号化し特定のuserを探す→userがいた場合はcurrent_userに格納するという流れです。
4. vue.jsとの連携(補足)
■vuexでlocalhostに保存する。
vueの細かい動きは説明しませんが、vuexにjwtのtoken情報を保存するコードを説明します。localhostに保存するのがセキュリティ上どうなのかというところは正直わかりません。
vuexの基本に関しては前回記事を書いたのでそれを参照ください。
【Rails × VueでSPA開発】Vue Router・Vuexを学ぶ:store.vue import axios from 'axios' const state = { currentUser: null, }; const getters = { currentUser: state => state.currentUser, }; const mutations = { SET_CURRENT_USER: (state, user) => { state.currentUser = user; localStorage.setItem('currentUser', JSON.stringify(user)) axios.defaults.headers.common['Authorization'] = `Bearer ${user.token}` }, CLEAR_CURRENT_USER: () => { state.currentUser = null localStorage.removeItem('currentUser') location.reload() } }; const actions = { async login({ commit }, sessionParams) { const res = await axios.post(`/api/session`, sessionParams) commit("SET_CURRENT_USER", res.data.user); }, logout({ commit }) { commit("CLEAR_CURRENT_USER"); }, }; export default { namespaced: true, state, mutations, actions, getters };
- actionsでrailsのsessionsにリクエストを送り、レスポンスをresに格納します。そしてcommitしてmutationsのSET_CURRENT_USERに渡します。
- そしてSET_CURRENT_USERでstateのcurrentuserに値を渡し、それを「localStorage.setItem('currentUser', JSON.stringify(user))」でlocalstorageに保存します。「axios.defaults.headers.common['Authorization'] =
Bearer ${user.token}
」でaxiosのデフォルトの通信にlocalstrageに保存した情報をのせて、認証します。- CLEAR_CURRENT_USERはログアウトのメソッドです。localstorageにあるユーザー情報を消せば、認証できなくなるのでログアウトという意味になります。
まとめ
いままでrailsの認証はdeviseに乗っかっていったので裏で何が起こっているかわからなかったですが、jwtを実装したことで裏の動きがわかるようになりました。Rails APIは他にもdevise_token_authやfirebaseAuthを利用して認証する方法が考えられますが、認証のベースがわかっていればなんとかなると思ってます。またjwtもまだ奥が深そうなので勉強したいと思います。とくにセキュリティに関しては無知ですので、、、
- 投稿日:2021-03-06T21:47:23+09:00
rails webpacker:install node_modules/@rails/webpacker/node_modules/node-sass: Command failed.
この記事について
rails webpacker:install
を実行しようとすると以下のエラーが発生(node-sass部分)。
解決できたので備忘録投稿$ rails webpacker:install e なんやかんや warning "webpack-dev-server > webpack-dev-middleware@3.7.3" has unmet peer dependency "webpack@^4.0.0 || ^5.0.0". [4/4] ? Building fresh packages... [-/2] ⠐ waiting... error /Users/ketchamash/hello_rails/node_modules/@rails/webpacker/node_modules/node-sass: Command failed. Exit code: 1環境
Mac Catalina
ruby 2.6.3
rails 6.0.3解決方法 & 参考記事
色々調べていると、nodejsのバージョンもしくはnode-sassバージョンに原因があることは分かった。どのバージョン同士でやったらええねん状態でしたが、こちらの記事でうまいこといきました。
nodejsとnode-sassのバージョンの対応表
nodejs -v node-sassのサポート -v 14 4.14+ 13 4.13+, <5.0 12 4.12+ 11 4.10+, <5.0 10 4.9+ 実行コマンド
これでちゃんとインストールできやした〜。
※
nodebrew
はNode.jsのバージョンを管理するためのツール$ nodebrew install v14.15.0 $ nodebrew use v14.15.0 $ yarn add node-sass@4.14.0 $ rails webpacker:install Webpacker successfully installed ? ?
- 投稿日:2021-03-06T21:12:28+09:00
[Rails] strftime vs I18n.localize vs to_s(:Time::DATE_FORMATS) どれを使うべきか?
結論
I18n.localize
がオススメ理由
- 日時表示専用のメソッドだから(= スコープが小さく、オーバーライドしやすいから )
strftimeがダメな理由
undefined method strftime for nil:NilClass
エラーを回避するために無闇に&
をつけるから- 日時を表示するのに長ったらしいメソッド名・時刻フォーマットを書くのはめんどくさいから
to_s(:Time::DATE_FORMATS)がダメな理由
- スコープが広く、オーバーライドできない(すべきでない)から
to_s
にnil
を渡すと空文字を返す。$ nil.to_s => ""しかし、
to_s(:Time::DATE_FORMATS)
にするとArgumentError
が発生する。$ Time::DATE_FORMATS[:sample_format] = "%Y年%m月%d日" => "%Y年%m月%d日" $ Time.now.to_s(:sample_format) => "2021年01月01日" $ nil.to_s(:sample_format) => ArgumentError: wrong number of arguments (given 1, expected 0)
to_s
で時刻フォーマットを指定できるが、nil
を渡してもエラーを吐かないto_s
の使いやすさがなくなった。
to_s
に&
を毎回書くのは美しくないし、strftime
とやってる事が変わらなくなる。
I18n.localize
もnil
が渡るとエラーが起きる。config/locales/ja.ymlja: time: formats: default: "%Y年%m月%d日"$ I18n.l(Time.now) => "2021年01月01日" $ I18n.l(nil) => I18n::ArgumentError: Object must be a Date, DateTime or Time object. nil given.なので
- 諦めて永遠と
&
を使い続ける(論外)begin rescue
でエラーをキャッチする(論外)- オーバーライドして
nil
をパスさせるかどれかの対応が必要になる。
1は論外。美しくない。
2も論外。毎回begin resucue
するのはダルい。美しくない。
必然的に3で対応することになるが、to_s
は影響範囲が広く懸念が大きい。
なので、日時表示専用メソッドでスコープが小さく影響範囲が狭いI18n.localize
をオーバーライドするのがオススメ
オーバーライドの方法は、以下を参考にしてほしい。
http://hamasyou.com/blog/2014/02/19/rails-i18n-localize/この記事を書こうと思った経緯と所感
I18n.localize
が日時表示用のメソッドならば、これ使えばいいんじゃね?」って思ってました。
しかし、デフォルトではnil
をパスできないと知って、他のメソッドを調べました。
その際に、to_s
メソッドの引数に時刻フォーマットを設定すれば日時を表示できると知りました(to_sメソッド万能スギィィ!!!)
しかし、
to_s
でフォーマットを指定できるが、nil
を渡してもエラーを吐かないto_s
の使いやすさがなくなりました。。。
&
でエラー回避できますが、strftimeとやってることは変わらないし、コードが美しくないと思いました。
このように、コードの保守性とメソッドスコープの観点からI18n.localize
をオーバーライドするのがベストだと考えました。参考文献
- 投稿日:2021-03-06T21:09:40+09:00
【Rspec】requests spec・コントローラーのテストについて〜adminユーザー作成やeditアクションのテスト〜
requests spec・コントローラーのテストについての話
モデルと結合テストは問題なく終わり、コントローラーのテストについてあれこれ調べていたがなかなか自分に理解できるものがなく、困っていました。
公開されているGitHubのコードを見てもコントローラーのテストコードをやっている人が少なかったというのも一つの要因なのですが、試行錯誤してなんとか完成しました。
正直これでいいのかちょっとわからない部分もあるので、もし知見がある方がこの記事を見て下さったらコメント頂けると嬉しいです。adminユーザーの生成で困ったこと
DBにadminカラムを作って
true
かfalse
かで判別しています。
FactoryBotでユーザーを生成するために書いたコードが下記のものです。spec/factories/users.rbFactoryBot.define do factory :user do nickname { '野比のび太' } sequence(:email) { |n| "tester#{n}@example.com" } password { 'q11111' } password_confirmation { password } profile { 'なんとかしてよドラえもん' } admin { false } ⬅️一般ユーザー end factory :admin_user do nickname { 'ドラえもん' } sequence(:email) { |n| "tester#{n}@example.com" } password { 'a11111' } password_confirmation { password } profile { 'いつまでも子供じゃないんだよしっかりしろよ' } admin { true } ⬅️adminユーザー end endそしてusers_spec.rbには以下のように記述。
spec/requests/users_spec.rbRSpec.describe UsersController, type: :request do let(:user) { FactoryBot.create(:user) } let(:admin_user) { FactoryBot.create(:user, admin: true) } #let(:admin_user) { FactoryBot.create(:admin_user) }⬅️この書き方でもダメでしたこれでadminユーザーが作成されるはずだと思っていたのですが
binding.pry
で止めて中身を見るとadmin_userが作成されていませんでした。
色々書き方を変えてやってみたところ、let
を使わずにbefore do
にしたらうまくいきました。spec/requests/users_spec.rbRSpec.describe UsersController, type: :request do before do @user = FactoryBot.create(:user) @admin_user = FactoryBot.create(:user, admin: true) #@admin_user = FactoryBot.create(:admin_user)⬅️この書き方でもダメでした endひとまずこれで一般ユーザーとadminユーザーを分けることができるようになりました。
editアクションのテストで困ったこと
コントローラーのテストをする上でまず困ったのがeditアクションのテストでした。
authenticate_user!
を使用しているためコントローラーのテスト実行時に、
「ログインしていないユーザー」とみなされてしまってリダイレクトされてしまうのです。
リクエストが正常のレスポンスではなくなってしまうと、テスト項目の
expect(response.status).to eq 200
ここの200が302になってしまいます。また、編集できる時とできない時、両方のテストをしなければいけないため、コントローラーのテストコード上でログインをさせることができれば正常な返り値が得られると考えました。
deviseのメソッドをテストコード上でも使えるようにするには
テストコード上でログインさせるメソッドとして結合テストコード時に使っていたmoduleでやってみたところ、うまくいきませんでした。
調べた結果、コントローラーのテストではdeviseのメソッドを使用できるとのことだったのでそちらに切り替えました。spec/rails_helper.rbRSpec.configure do |config| #deviseのsign_inメソッドが使えるようになる config.include Devise::Test::IntegrationHelpers, type: :request endこれで
sign_in
というメソッドが使用できるようになりました。
自分の場合は@user
と定義しているのでそのまま使います。spec/requests/users_spec.rbrequire 'rails_helper' RSpec.describe UsersController, type: :request do before do @user = FactoryBot.create(:user) end describe 'GET #edit' do context 'userがログインしているとき' do before do sign_in @user ⬅️ここでログイン end it 'editアクションにリクエストすると正常にレスポンスが返ってくる' do get edit_user_path(@user) expect(response.status).to eq 200 ⬅️編集画面に遷移できている end it "editアクションにリクエストするとレスポンスに登録済みuserの名前が存在する" do get edit_user_path(@user) expect(response.body).to include @user.nickname end it "editアクションにリクエストするとレスポンスに登録済みuserのプロフィールが存在する" do get edit_user_path(@user) expect(response.body).to include @user.profile end end context 'userがログインしていないとき' do it 'editアクションにリクエストすると正常にレスポンスが返ってくる' do get edit_user_path(@user) expect(response.status).to eq 302 ⬅️トップページにリダイレクトしている end end end endこれでうまくいきました。
admin_userにしたいときはbefore doで@admin_user
を定義して、
sign_in @admin_user
とすることで他のコントローラーでも問題なく処理ができました。
テストコードをやるとアプリケーションの仕様がよく理解できると言いますが、理由がよくわかりました。
細かいところに気がついたり、仕様そのものを一から見直す結果になって理解が深まったと実感できました。参考にさせていただきました
@iwkmsy9618様
[Rails]Request Specでのログインの実施ありがとうございました。
- 投稿日:2021-03-06T20:11:27+09:00
gem ffi 1.14.2 エラーの解決方法 そのn番目
この記事について
bundle install
実行時にffi 1.14.2
のところでこけてしまう。環境
- Mac Catalina
- ruby 2.6.3
- rails 6.0.3
エラー内容
Fetching ffi 1.14.2 Installing ffi 1.14.2 with native extensions Gem::Ext::BuildError: ERROR: Failed to build gem native extension. current directory: /Users/ketchamash/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/ffi-1.14.2/ext/ffi_c /Users/ketchamash/.rbenv/versions/2.6.3/bin/ruby -I /Users/ketchamash/.rbenv/versions/2.6.3/lib/ruby/2.6.0 -r ./siteconf20210306-53720-1m14ww7.rb extconf.rb checking for ffi_prep_closure_loc() in -lffi... yes checking for ffi_prep_cif_var()... yes checking for ffi_raw_call()... yes checking for ffi_prep_raw_closure()... yes creating extconf.h creating Makefile current directory: /Users/ketchamash/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/ffi-1.14.2/ext/ffi_c make "DESTDIR=" clean current directory: /Users/ketchamash/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/ffi-1.14.2/ext/ffi_c make "DESTDIR=" compiling AbstractMemory.c compiling ArrayType.c compiling Buffer.c compiling Call.c compiling ClosurePool.c compiling DynamicLibrary.c compiling Function.c Function.c:847:17: error: implicit declaration of function 'ffi_prep_closure_loc' is invalid in C99 [-Werror,-Wimplicit-function-declaration] ffiStatus = ffi_prep_closure_loc(closure->pcl, &fnInfo->ffi_cif, callback_invoke, closure, code); ^ Function.c:847:17: note: did you mean 'ffi_prep_closure'? /Library/Developer/CommandLineTools/SDKs/MacOSX10.14.sdk/usr/include/ffi/ffi.h:269:1: note: 'ffi_prep_closure' declared here ffi_prep_closure( ^ 1 error generated. make: *** [Function.o] Error 1 make failed, exit code 2 なんやかんや An error occurred while installing ffi (1.14.2), and Bundler cannot continue. Make sure that `gem install ffi -v '1.14.2' --source 'https://rubygems.org/'` succeeds before bundling. In Gemfile: spring-watcher-listen was resolved to 2.0.1, which depends on listen was resolved to 3.4.1, which depends on rb-inotify was resolved to 0.10.1, which depends on ffi解決のために試したこと
1.
gem install ffi -v '1.14.2
を実行2.Qiitaで参考にさせて頂いた記事
自分の場合、こちらでは解決できませんでした。
解決できた参考ページ
gemのインストール時に
--disable-system-libffi
オプションをつけて実行するとインストールできた。gem install ffi -- --disable-system-libffi解決できた理由?
libffiライブラリが上手く使うためのオプションなのかな?
- 投稿日:2021-03-06T19:09:36+09:00
RailsでQRコードを生成
前提条件
・ruby 2.6.6
・rails 6.0.3.4
・macOS Catalina バージョン10.15.7概要
webアプリ内で一般的な会員システムに搭載されているmyQRコードを作成したいと思い、実装したので忘れないようここに残すことにした。
難しいことは特になく、比較的簡単に実装が完了した。gemの導入
webアプリ内にQRコードを生成させるためには
・gem 'rqrcode'
・gem 'chunky_png'
の2つを導入する必要がある。Gemfilegem 'rqrcode' gem 'chunky_png'ターミナルbundle
これで前準備は完了。
次にコードを書いていく。QRコードを生成させる
今回は個人的な理由だが、各ユーザーのマイページに設置したいため、user_helper.rbにコードを記述する。
app/helpers/users_helper.rbmodule UsersHelper #ここから require 'chunky_png' def qrcode_tag(url, _options = {}) qr = ::RQRCode::QRCode.new(url) ChunkyPNG::Image.from_datastream(qr.as_png.resize(250, 250).to_datastream).to_data_url end #ここまで end引数でurlを渡すことで、pngを生成する。
また注意して欲しいのが、gemを導入しているときはターミナルでwebアプリを再起動してからじゃないと、エラーが起きてうまく作動しないので注意。(自分も忘れていて何でエラーが起きているかわからなかった。)Viewに表示させる
QRコード生成の準備ができたので、ViewでQRコードを表示させるコードを書いていく。自分の場合は何度もいうがマイページに設置したいので users/show.html.erb に記述していく。
users/show.html.erb<%= image_tag qrcode_tag url %>とは言っても表示させたい場所に上記のコードを記述するだけで完了。
urlの部分に、生成されるQRコードを読み取ったときに遷移させるページのURLを記述すれば、読み込んだときにそのページに遷移するQRコードが生成される。以上。参考文献
・QRコードの生成・埋め込み
https://qiita.com/koteko/items/d6d033997c544c47b718
・https://github.com/whomwah/rqrcode
・https://github.com/wvanbergen/chunky_png
- 投稿日:2021-03-06T19:00:07+09:00
RailsにAjaxでデータ保存する時に困ったこと
記事の目的
Railsでオリジナルアプリを作成中にAjaxでデータ保存出来ずに困ったこと、どのように解決したのかを忘れないために書いておきます。
開発環境
- macOS Big Sur 11.1
- VScode
- Ruby 2.6.5
- Rails 6.0.3.4
- mysql 14.14
- JavaScript
- gem 3.0.3
- heroku 7.47.12
オリジナル読書アプリ
読書アプリケーションを作りました。
https://github.com/hiro-mu/book_smart.git困ったこと
実装したのは読書アプリによくあるようなドラッグした部分の背景色が変わる機能です。
この機能の実装は
1. 文字列がドラッグされた場合にイベントが発生し、その文字列をデータベースに保存
2. データベースに存在する文字列が表示されている場合、replaceメソッドでブラウザ上のHTMLに背景色を変えるCSSを追加
と言う流れで行っています。
難しかったところはこの部分です。highlight.jsXHR.open("GET", `/highlights/create?book_id=${bookId}&pagenum=${pageNum}&text=${selectedStr}`, true);本来であればデータベースに変更を加えるリクエストなので、POSTを使うべきです。しかし、保守性の観点からRailsに外部からのデータベースを変更させるPOSTやPUTのリクエストは受け付けないという機能があるらしく、データベースに保存出来ませんでした。(厳密にはRailsはDELETE、PATCH、PUTのHTTPメソッドは全てPOSTとして扱うようです)
ようやく見つけた解決策は、HTTPメソッドはGETにしておいて、パラメータをURLに含めて送ってしまおうという手法でした・・・もっといい解決策があれば知りたいです。学んだこと
CSRFなどの不正なリクエストのためにRailsに備わっているセキュリティ対策によって自身が苦しめられました。非常に勉強になりました。
- 投稿日:2021-03-06T18:42:43+09:00
【Rails】「simple_form」と「FormObject」を使って、複数テーブルの情報を同一フォームで保存
はじめに
現在Railsで写真投稿サービスの実装をしています。
一つのフォームで複数のテーブル情報を保存したいときに、accepts_nested_attributes_for
というメソッドが用意されますが、あまり推奨されていないみたいです。(以下参考
- 週刊Railsウォッチ(20191105前編)DHHも消したいaccepts_nested_attributes_forほか|TechRacho〜エンジニアの「?」を「!」に〜
- accepts_nested_attributes_forを使わず、複数の子レコードを保存する | Money Forward Engineers' Blog
そこで今回は、simple_formとFormObjectを使ってフォームを作成しました。
自分のようなユースケースの記事がなかったので、参考になればと思い記事にします。動作環境
Ruby '2.6.6'
Rails '6.0.3.5'
SimpleForm '5.1.0'
CarrierWave '2.1.1'
MiniMagick '4.11.0'テーブル
Post
登録時にpost_images(子テーブル)
とpost_categories(中間テーブル)
の情報も同時に保存されるフォームモデル
app/models/post.rbclass Post < ApplicationRecord has_many :post_categories has_many :categories, through: :post_categories has_many :post_images, dependent: :destroy belongs_to :user end実装したコード
view(投稿フォーム)
app/views/posts/_form.html.slim.box.box-primary = simple_form_for @form, url: posts_path do |f| .box-body = f.input :image, as: :file = f.input :caption = f.input :body, as: :text = f.input :categories, as: :check_boxes, collection: Category.all, include_hidden: false .box-footer = f.button :submit, '投稿する', class: %w[btn btn-primary]
simple_form_for
の引数にはControllerで定義した変数をセットし、urlを指定- 複数選択できる
categories
ではinclude_hidden: false
を指定することで、配列に空のデータが入らないようにしているcontroller
app/controllers/posts_controller.rbdef new @form = PostsForm.new end def create @form = PostsForm.new(post_params) if @form.save redirect_to posts_path, success: t('defaults.message.created', item: Post.model_name.human) else flash.now['danger'] = t('defaults.message.not_created', item: Post.model_name.human) render :new end end private def post_params params.require(:posts_form).permit( :body, :image, :caption, { categories: [] } ).merge(user_id: current_user.id) end
- アクションの
new
とcreate
ではFormObject名で変数を定義- ストロングパラメータの
require
キーもFormObject名で指定- 今回は
categories
カラムのみ配列で受け取れる形になっているFormObject
app/forms/posts_form.rbclass PostsForm include ActiveModel::Model include ActiveModel::Attributes extend CarrierWave::Mount mount_uploader :image, PostImageUploader attribute :body, :string attribute :image, :string attribute :caption, :string attribute :categories attribute :user_id, :integer validates :image, :categories, presence: :true def initialize(params = {}) super(params) end def save return false if invalid? post = Post.new(post_params) # 画像の複数登録仕様にはなっていない post.post_images.build(post_images_params).save! categories.each do |category| post.post_categories.build(category_id: category).save! end post.save ? true : false end private def post_params { body: body, user_id: user_id } end def post_images_params { image: image, caption: caption } end end
include ActiveModel::Attributes
によってattribute
の作成が簡単に- バリデーションもモデルではなくFormObjectに記述
initialize
で値を初期化- コントローラ側からセットされたデータを、
save
メソッド内で複数のテーブルに保存- 配列の受け取りには注意が必要で、
each
文で一つ一つ登録されるように記述終わりに
たくさんのエラーに詰まりながら、最終的にこの記述で落ち着きました。
複雑なフォームも、できあがってみたらシンプルでスッキリできたと思います。
もし改善した方がいい点などあればコメントで教えてください。
- 投稿日:2021-03-06T18:42:43+09:00
【Rails】「simple_form」と「FormObject」を使って、複数テーブルの情報を同時保存
はじめに
現在Railsで写真投稿サービスの実装をしています。
一つのフォームで複数のテーブル情報を保存したいときに、accepts_nested_attributes_for
というメソッドが用意されますが、あまり推奨されていないみたいです。(以下参考
- 週刊Railsウォッチ(20191105前編)DHHも消したいaccepts_nested_attributes_forほか|TechRacho〜エンジニアの「?」を「!」に〜
- accepts_nested_attributes_forを使わず、複数の子レコードを保存する | Money Forward Engineers' Blog
そこで今回は、simple_formとFormObjectを使ってフォームを作成しました。
自分のようなユースケースの記事がなかったので、参考になればと思い記事にします。動作環境
Ruby '2.6.6'
Rails '6.0.3.5'
SimpleForm '5.1.0'
CarrierWave '2.1.1'
MiniMagick '4.11.0'テーブル
Post
登録時にpost_images(子テーブル)
とpost_categories(中間テーブル)
の情報も同時に保存されるフォームモデル
app/models/post.rbclass Post < ApplicationRecord has_many :post_categories has_many :categories, through: :post_categories has_many :post_images, dependent: :destroy belongs_to :user end実装したコード
view(投稿フォーム)
app/views/posts/_form.html.slim.box.box-primary = simple_form_for @form, url: posts_path do |f| .box-body = f.input :image, as: :file = f.input :caption = f.input :body, as: :text = f.input :categories, as: :check_boxes, collection: Category.all, include_hidden: false .box-footer = f.button :submit, '投稿する', class: %w[btn btn-primary]
simple_form_for
の引数にはControllerで定義した変数をセットし、urlを指定- 複数選択できる
categories
ではinclude_hidden: false
を指定することで、配列に空のデータが入らないようにしているcontroller
app/controllers/posts_controller.rbdef new @form = PostsForm.new end def create @form = PostsForm.new(post_params) if @form.save redirect_to posts_path, success: t('defaults.message.created', item: Post.model_name.human) else flash.now['danger'] = t('defaults.message.not_created', item: Post.model_name.human) render :new end end private def post_params params.require(:posts_form).permit( :body, :image, :caption, { categories: [] } ).merge(user_id: current_user.id) end
- アクションの
new
とcreate
ではFormObject名で変数を定義- ストロングパラメータの
require
キーもFormObject名で指定- 今回は
categories
カラムのみ配列で受け取れる形になっているFormObject
app/forms/posts_form.rbclass PostsForm include ActiveModel::Model include ActiveModel::Attributes extend CarrierWave::Mount mount_uploader :image, PostImageUploader attribute :body, :string attribute :image, :string attribute :caption, :string attribute :categories attribute :user_id, :integer validates :image, :categories, presence: :true def initialize(params = {}) super(params) end def save return false if invalid? post = Post.new(post_params) # 画像の複数登録仕様にはなっていない post.post_images.build(post_images_params).save! categories.each do |category| post.post_categories.build(category_id: category).save! end post.save ? true : false end private def post_params { body: body, user_id: user_id } end def post_images_params { image: image, caption: caption } end end
include ActiveModel::Attributes
によってattribute
の作成が簡単に- バリデーションもモデルではなくFormObjectに記述
initialize
で値を初期化- コントローラ側からセットされたデータを、
save
メソッド内で複数のテーブルに保存- 配列の受け取りには注意が必要で、
each
文で一つ一つ登録されるように記述終わりに
たくさんのエラーに詰まりながら、最終的にこの記述で落ち着きました。
複雑なフォームも、できあがってみたらシンプルでスッキリできたと思います。
もし改善した方がいい点などあればコメントで教えてください。
- 投稿日:2021-03-06T16:54:27+09:00
rails マイグレーションファイルの削除について
マイグレーションファイルの削除
何回も削除しているが削除のたびに見直しているのでQiitaに残しておきます。
現状のマイグレーションファイルの状態を確認
rails db:migrate:statusup状態の場合は削除や編集はできないのでdown状態にします。
今回はロールバックを使用。rails db:rollback再度statusで確認してみると
マイグレーションの削除
あとは手動でdownになっているマイグレーションファイルを削除するだけです。削除したら以下のコマンドを入力して完了になります。
rails db:migrateupになっているマイグレーションファイルを削除するとめんどくさくなるそうです。
- 投稿日:2021-03-06T16:17:40+09:00
Mac Ruby・Ruby on Rails環境設定
はじめに。
環境設定なんて新しい端末を買った時にしかしないのであまり必要に感じないかもしれないですが、一応ここに残しておきます。
必要な設定
- GoogleChromeのダウンロード
- VScodeのダウンロード
- 拡張機能
- Japanese Language Pack for Visual Studio Code
- HTML Snippets
- Ruby
- zenkaku
- Code Spell Checker
- Command Line Toolsのダウンロード
- Homebrewのダウンロード
- Rubyのダウンロード
- MySQLのインストール
- Railsのインストール
- Node.jsのインストール
- yarnのインストール
1.GoogleChromeのダウンロード
まずはGoogleChromeをダウンロードします。
Macであれば最初からSafariが入っていますがGoogleChromeの方が使っている人が多いのでダウンロードしていきましょう。2.VScodeのダウンロード
VScodeとはテキストエディタのことであり、プログラムのコードをファイルに書く場所のことです。
今回VScodeをテキストエディタで使う理由として、拡張機能が豊富でありRuby on Railsのコードを書く上で最適であること、人気ランキングで1位をいう理由で使わせてもらいます。真ん中左側にDownloadの文字があるのでダウンロードを行ってください。
こちらはよく使うので忘れずにドックに追加しておきましょう。2-1.拡張機能
次はダウンロードしたVScodeに拡張機能を追加していきます。
それぞれ追加する拡張機能の意味を軽く説明します。1. Japanese Language Pack for Visual Studio Code
これは日本語表記に変えてくれる機能です。あって便利です。
2. HTML Snippets
HTMLタグ、CSSタグの入力を自動で補完してくれます。
3. Ruby
Rubyの構文をチェックしてくれて、間違っているところを教えてくれます。
4. zenkaku
半角スペースのところを全角スペースにしてしまっている場所を教えてくれます。
これは本当に便利!5. Code Spell Checker
コードのスペルを確認してくれます。
拡張機能追加の仕方
起動したウィンドウ画面左側の、アイコンメニュー内にある四角のアイコンをクリックします
左上の「Search Extensions in Marketplace」にそれぞれの拡張したい拡張機能名を入力すると表示されます。
それぞれの拡張したい拡張機能名を選択し、install(またはインストール)をクリックします。
最後に拡張機能がちゃんと反映されるようにVScodeを再起動して終わりです。(再起動しなくても大丈夫なやつもありますが念のため)
3.Command Line Toolsのダウンロード
Command Line Toolsとは、Webアプリケーションの開発に必要なソフトをダウンロードするために必要な機能です。
インストールしましょう。
ターミナル% xcode-select --installインストール ⇨ 同意 ⇨ 完了 でインストール完了です。
4.Homebrewのダウンロード
Homebrewとは、ソフト管理ツールです。
Macのモデルによってインストール方法が異なるのできおつけてください。
- Macのモデル確認方法
- 左上のりんごマークを押す ⇨ このマックについて
- Macの情報が出るのでIntelモデルかM1モデルかを確認
Intelモデル
ターミナル% cd % /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"インストールに多少時間が掛かりますが気長に待ちましょう。
パスワードの入力を求められたらパスワードを打ちましょう。打っても文字が表示されませんがちゃんと打てていますので大丈夫です。ターミナル% Press RETURN to continue or any other key to aboutこの表示がでたらエンターキーを押しましょう。
ターミナル% brew -v #インストールされているかの確認 % brew update #アップデート % sudo chown -R `whoami`:admin /usr/local/bin #権限の確認(パスワード再入力)これで終わりです。
5.Rubyのダウンロード
5-1. rbenv と ruby-buildのインストール
土台となる、rbenvとruby-buildを、事前にインストールしたHomebrewを用いてインストールします。
ターミナル% brew install rbenv ruby-build #rbenvとruby-buildのインストール % echo 'eval "$(rbenv init -)"' >> ~/.zshrc #どこからでも使えるようにするコマンド % source ~/.zshrc #zshrc の変更反映5-2. readlineのインストール
ターミナルのirb上で日本語入力を可能にする設定を行うためにインストールしましょう。
ターミナル% brew install readline #readlineのインストール % brew link readline --force #どこからでも使えるようにするコマンド5-3. rbenvを利用してRubyをインストール
ターミナル% RUBY_CONFIGURE_OPTS="--with-readline-dir=$(brew --prefix readline)" % rbenv install 2.6.5 #2.6.5とは、Rubyのバージョンのことです % rbenv global 2.6.5 #使用するバージョンの指定 % rbenv rehash #変更の反映6.MySQLのインストール
▷MySQLとはデータ管理をするツールです。様々な種類がありますが今回はMySQLをつかいます。
6-1. MySQLのインストール
ターミナル% brew install mysql@5.66-2. MySQLの自動起動設定
本来PC再起動のたびに起動し直す必要がありますが、面倒で手間がかかるので自動起動するようにしましょう。ターミナル% mkdir ~/Library/LaunchAgents % ln -sfv /usr/local/opt/mysql\@5.6/*.plist ~/Library/LaunchAgents % launchctl load ~/Library/LaunchAgents/homebrew.mxcl.mysql\@5.6.plist6-3. MySQLコマンドをどこからでも実行できるようにする
どこからでもMySQLを操作するためのコマンドmysqlを実行できるようにしましょう。ターミナル% echo 'export PATH="/usr/local/opt/mysql@5.6/bin:$PATH"' >> ~/.zshrc #mysqlのコマンドを実行できるようにする設定 % source ~/.zshrc #設定を読み込むコマンド % which mysql #mysqlのコマンドが打てるか確認する % mysql.server status #MySQLの状態を確認するコマンド7.Ruby on Railsのインストール
RailsとはRubyのフレームワークのことです。一緒にGem(拡張機能)を管理するためのbundlerもインストールしましょう。
ターミナル% gem install bundler --version='2.1.4' #Rubyのgemを管理するbundlerをインストール % gem install rails --version='6.0.0' #railsのインストール % rbenv rehash #rbenvに変更反映8.Node.jsのインストール
Railsを動かすためにはNode.jsが必要です。Homebrewを用いてインストールしパスを指定しましょう。
ターミナル% brew install node@14 #インストール % echo 'export PATH="/usr/local/opt/node@14/bin:$PATH"' >> ~/.zshrc % source ~/.zshrc9.yarnのインストール
ターミナル% brew install yarn以上になります。何か問題があればコメント御願いします。
- 投稿日:2021-03-06T14:17:13+09:00
【随時更新】ポートフォリオ・SaaS開発の参考に!超本格的なRails製オープンソースたち!【初心者もおすすめ】
「プログラマーになってすごいサービスを作りたい!」
という動機でプログラミングを始めてみたものの、いざ勉強を始めてみると
「どんな技術を使って開発したらいいの?」
「どこまで作り込めばいいのだろう?」
「一応完成したけどまだ足りないところがあるのでは?」と不安に感じたことは一度はありますよね?
そんな方に強くオススメしたいRailsアプリケーションのGitHubリポジトリたちを紹介します。
ここで紹介するRailsアプリケーションはオープンソースにもかかわらず、
実際にWebサービスとして運用されていたり、有料で提供されていたりするものもありますので、必ずサービス開発の参考になるはずです!ぜひ、一読してWebサービス開発の参考にしてみてください!
対象読者
・プログラミング初学者さん
・個人サービスの開発に取り掛かる前の方
・サービス開発の参考資料を探している方
・よりよいサービス開発の参考にしたい方GitHubリポジトリたち
ChatWoot
オープンソースのライブチャットソフトです。
ホームページやランディングページに埋め込むお問い合わせチャットや、それに対するメッセージの管理システムを提供しているサービスです。Vue.js × Ruby on Railsで開発されていて、このコードを勉強してマネするだけでかなり本格的なサービスの開発に繋げられると思います!
初見、かなり難しそうに感じますが、Gemfileを見て「devise」など初学者さんでもわかるライブラリなどがたくさん使われていますので、そういった分かる部分から読み進めていってもいいかもしれません。GitHubリポジトリURL
https://github.com/chatwoot/chatwoot公式ページ
https://www.chatwoot.com/
引用元:https://www.chatwoot.com/static/dashboard-screen-b294bdd1d718312290ec49b6c2a13428.png
引用元:https://www.chatwoot.com/static/widget-ghost-99d99a87a95d9c1731b79af6584218ef.png使われている主な技術
- Ruby on Rails
- Vue.js
- Docker
- CircleCI など
GitLab
GitHubのようにコードを管理することができるサービス。
かなりざっくりとGitHubとの違いを挙げると、自社で用意したサーバーなどにGitHubと同じような環境を構築できるので、セキュリティや情報管理の観点からGitHubは使いたくない!という企業さんに使われることが多いサービスです。
※その他にもGitLabの良いところはたくさんありますが、割愛します!こちらもVue.js × Ruby on Railsで開発されたサービスですが、chatwootと違い、Railsのapp/views内のHTMLファイルのコードも充実しているため、まだJSフレームワークでのフロントエンド開発は視野に入れていないプログラマーさんでも読みやすいところは多いのかな、と思います。
ただし、hamlで書かれているため、人によっては読みにくさを感じるかも…GitHubリポジトリ
https://github.com/gitlabhq/gitlabhq/公式ページ
https://about.gitlab.com/
引用元:https://about.gitlab.com/images/solutions/solutions-create.png使われている主な技術
- Ruby on Rails
- Vue.js
- Docker など
solidus
オープンソースのeコマース構築サービス。
他のリポジトリと比べるとディレクトリ構成が少し特殊かな、という印象です。
こちらはフロントエンドも含めてRuby on Railsで開発されているようですので、その点においては参考にしやすいリポジトリなのかな、と思います。こちら(http://demo.solidus.io/) からでもページを見ることも可能です。
GitHubリポジトリ
https://github.com/solidusio/solidus公式ページ
https://spreecommerce.org/使われている技術
- Ruby on Rails
- CircleCI など
まとめ
こちらの内容は随時更新していきます!
他にも実際にサービスとして提供されている良いオープンソースコードがございましたら教えていただきますと幸いです!
- 投稿日:2021-03-06T13:41:18+09:00
Rails フォロー機能まとめ
記事作成理由
1:プログラミング初学者の私が見返すため。
2:私のような初学者の学習に少しでも役立てればと思ったため。※誤った記述や知識があればお知らせいただけると幸いです。
開発環境
・ruby: 2.6.3
・rails: 5.2.4.5
・OS: macOS Catalina ver10.15.7
・Cloud9
前提条件
・Userモデル,usersテーブルがある
・Bootstrap導入済み
・deviseを使ってログイン機能実装済み
実装の流れ
1:Relationshipモデルの作成
2:中間テーブル(relationshipsテーブル)の作成
3:アソシエーションの設定
4:アクションの定義
5:Viewファイルに記述
1:Relationshipモデルの作成
ターミナル.rails g model Relationship
2:中間テーブル(relationshipsテーブル)の作成
マイグレーションファイルにカラムを追加
db/migrate/○○○○_create_relationships.rbclass CreateRelationships < ActiveRecord::Migration[5.2] def change create_table :relationships do |t| t.references :follower, foreign_key: {to_table: :users} t.references :followed, foreign_key: {to_table: :users} t.timestamps t.index [:follower_id, :followed_id], unique: true end end endターミナル.rails db:migrateマイグレーションファイルの反映忘れずに!
ーー解説ーー
relationshipsテーブルの中身はこのようになります。
カラム タイプ オプション follower_id integer foreign_key: {to_table: :users} followed_id integer foreign_key: {to_table: :users} relationshipsテーブルは中間テーブルであるため、
followerカラムとfollowedカラムはt.references
をつけて生成します。
(ポイント!)
今回生成したカラム (follower_id と followed_id) は自由に命名することができます。
他記事ではfollowing_idやfollows_idなど様々な命名がされています。自分が1番理解しやすいように命名しましょう。
(色々な記事を調べて、様々な命名を見ると頭が混乱します。僕だけかもしれませんが・・・)
また、follower、followedどちらも外部キーとして設定します。foregin_key(外部キー)
ここでforegin_key :true
としてしまうと、followersテーブルやfollowedsテーブルなど存在しないテーブルを参照してしまいエラーになります。
それを避けるために{to_table: :users}
として、usersテーブルのidを参照するよう紐づかせています。
t.index [:follower_id, :followed_id], unique: true
これにより、2つのカラムの中身が同じ組み合わせでデータを保存することを防ぎます。
=同じユーザーを2回フォローできないようにしています。
中間テーブル概念についてはこちら2つの記事がとても参考になりました。
【Railsでフォロー機能を作ろう】
【初心者向け】丁寧すぎるRails『アソシエーション』チュートリアル
3:アソシエーションの設定
relationship.rbclass Relationship < ApplicationRecord belongs_to :follower, class_name: "User" belongs_to :followed, class_name: "User" validates :follower_id, presence: true validates :followed_id, presence: true end
ーー解説ーー
■2,3行目
relationshipsテーブルでforeign_keyとしてfollower_id , followed_id を設定しているため、モデル名もFollower
,Followed
とします。しかし、FollowerモデルもFollowedモデルも実在しない為、
class_name: "User"
とオプションを追記し、関連するモデル(本当のモデル名)を指定します。
※このとき、"User"は文字列扱いになるため、ダブルクォーテーションで囲んでます。■4,5行目
validatesの説明は割愛します。
(わからない方は「バリデーション」)でお調べください。
user.rbclass User < ApplicationRecord # フォローするユーザーから見た中間テーブル has_many :active_relationships, class_name: "Relationship", foreign_key: "follower_id", dependent: :destroy # フォローされているユーザーから見た中間テーブル has_many :passive_relationships, class_name: "Relationship", foreign_key: "followed_id", dependent: :destroy # 中間テーブルactive_relationshipsを通って、フォローされる側(followed)を集める処理をfollowingsと命名 # フォローしているユーザーの情報がわかるようになる has_many :followings, through: :active_relationships, source: :followed # 中間テーブルpassive_relationshipsを通って、フォローする側(follower)を集める処理をfollowingsと命名 # フォローされているユーザーの情報がわかるようになる has_many :followers, through: :passive_relationships, source: :follower end
ーー解説ーー
ここが鬼門です。がんばっていきましょう。
4つのhas_manyがありますが、上から2つずつに分けて説明します。まず中間テーブルの見方を2つに分けます。
1:フォローするユーザーから見た視点
2:フォローされるユーザーから見た視点本来であれば has_many :relationships とすればいいのですが、
見方が2つあるため、2通りの定義をしなければなりません。そのため、ここでは
フォローする側をactive_relationships
と命名し、(2行目)
フォローされる側をpassive_relationships
と命名しています。(7行目)
↓
※先ほどuser.rb
で作成したモデル(Follower,Followed)と同様、これらも好きに命名することができます。
他記事を見て比較する時など、命名が異なるケースがあるため、注意しましょう。
そして、参照元のモデルはどちらもRelationshipモデル
であるため、
class_name: "Relationship"
とオプションを追加します。しかし、このままではRelationshipsテーブルのどちら(follower , followed)を参照すればいいのかがわかりません。
そこで、下記のように指定してあげます
active_relationships
は、foreign_key: "follower_id"
を参照
passive_relationships
は、foreign_key: "followed_id"
を参照
dependent: :destroy
は、とあるUserが削除された時にそれに紐づくrelationshipのデータも削除されるようにしています。
次に、こちらの文ですが、
has_many :followings, through: :active_relationships, source: :followed
has_many :followers, through: :passive_relationships, source: :follower
これらはコードのコメントに記載の通り、定義したテーブルを通って(through)、sourceで指定したモデルの参照元からデータを集めることができます。
これにより
@user.followings
(userがフォローしている人の情報)
や
@user.followers
(userがフォローされている人の情報=userをフォローしている人の情報)
といった表記が可能になります。※くどいようですが、、
followings
やfollowers
も好きに命名することができます。
他記事を見て比較する時など、命名が異なるケースがあるため、注意しましょう。これでアソシエーションは完成です。
4:アクション定義
user.rb と relationships.controller.rbにアクションを定義します。
user.rb#下記3つのアクションを追記 class User < ApplicationRecord # フォローする def follow(user_id) active_relationships.create(followed_id: user_id) end # フォローを外す def unfollow(user_id) active_relationships.find_by(followed_id: user_id).destroy end # すでにフォローしているのか確認 def following?(user) followings.include?(user) end endrelationships_controller.rbclass RelationshipsController < ApplicationController before_action :authenticate_user! def create current_user.follow(params[:user_id]) redirect_to request.referer end def destroy current_user.unfollow(params[:user_id]) redirect_to request.referer end # フォローしている人一覧 def follower user = User.find(params[:user_id]) @users = user.followings end # フォローされている人一覧 def followed user = User.find(params[:user_id]) @users = user.followers end end
5:Viewファイルに記述
ここからはあくまで例になります。
定義している変数などによって記述の仕方は変わります。
※一部抜粋
ーーフォロー数、フォロワー数表示、フォローボタンーーusers/index.html.erb<tbody> <% @users.each do |user| %> <tr> <td><%= attachment_image_tag user, :profile_image, :fill, 40, 40, fallback: "no_image.jpg", size:"40x40" %></td> <td><%= user.name %></td> <td><%= "フォロー数: #{user.active_relationships.count}" %></td> <td><%= "フォロワー数: #{user.passive_relationships.count}" %></td> <td> <% if current_user.id != user.id %> <% if current_user.following?(user) %> <%= link_to 'フォローを外す', user_relationships_path(user.id), method: :DELETE %> <% else %> <%= link_to 'フォローする', user_relationships_path(user.id), method: :POST %> <% end %> <% end %> </td> <td><%= link_to "Show", user_path(user.id) %></td> </tr> <% end %> </tbody>
ーーフォローしている人一覧ーーrelationships/follower.html.erb<tbody> <% @users.each do |user| %> <tr> <td><%= attachment_image_tag user, :profile_image, :fill, 40, 40, fallback: "no_image.jpg" , size:"40x40" %></td> <td><%= user.name %></td> <td>フォロー数:<%= user.followings.count %></td> <td>フォロワー数:<%= user.followers.count %></td> <td><%= link_to "show", user_path(user.id) %> </td> </tr> <% end %> </tbody>
ーーフォローされている人一覧ーーrelationships/followed.html.erb<tbody> <% @users.each do |user| %> <tr> <td><%= attachment_image_tag user, :profile_image, :fill, 40, 40, fallback: "no_image.jpg" , size:"40x40" %></td> <td><%= user.name %></td> <td>フォロー数:<%= user.followings.count %></td> <td>フォロワー数:<%= user.followers.count %></td> <td><%= link_to "show", user_path(user.id) %> </td> </tr> <% end %> </tbody>
以上です。
閲覧ありがとうございました!誤字、脱字、誤り等ございましたら教えていただけると幸いです。
- 投稿日:2021-03-06T13:19:32+09:00
コールバックで before_save ではなく before_validation を使わないといけないパターン
仕様
- Producer (製造者) は token という String の属性を持つ。
- token は必須属性で、Producer の新規作成時に、ユニークかつランダムな英数字を自動的にセットしたい。
コード
app/models/producer.rbrequire 'securerandom' class Producer < ApplicationRecord validates :token, uniqueness: true # before_create :set_token # 駄目なパターン before_validation :set_token, on: :create def set_token self.token = SecureRandom.alphanumeric(24) self.set_token unless self.valid? end end
before_create
で set_token を呼び出そうとした場合は、先に validation が走るので、 token の uniquenss 制約でエラーが発生する場合がある。
最初から全てのレコードに token が設定されていれば問題ないが、途中から導入した場合、set_token が呼び出される前の validation で nil が重複することになってしまう。教訓
コールバック周りで想定と違う挙動が発生した場合は、心を落ち着けて Rails ガイドを見直すこと。
- 投稿日:2021-03-06T12:35:40+09:00
【Ruby on Rails】結合テストコードについてまとめ(Capybara)
初学者です。
テストコードがなぜか大好きです。今回は結合テストコードについてまとめます。
Capybaraという標準で導入されているGemを利用しています。前提条件
- FactoryBotを導入済みである
上記については以下の記事にまとめています。
【Ruby on Rails】FactoryBotとFakerについてまとめテストコードの種類
単体テストコード
モデルやコントローラーなど
機能ごと
に問題がないか確認します。
例えばバリデーションがきちんと動作しているかなどです。結合テストコード
ユーザーがブラウザで操作する
一連の流れ
を再現して問題がないか確かめます。
例えば、ユーザー登録で「名前とメールアドレスとパスワードを入力するとトップページに遷移して表示がユーザーの名前に変わっている」などの一連の動作を確認します。正常系
ユーザーが開発者の意図する操作を行った時の挙動
を確認するテストコードです。
例えば、ユーザー登録で問題なく全てのデータが入力された場合などです。異常系
ユーザーが開発者の意図しない操作を行った時の挙動
を確認するテストコードです。
例えば、ユーザー登録で正しい値が入っていないと登録できないかどうかなどです。準備
今回はUserモデルの結合テストコードを例にしていきます。
まずは下記コマンドで結合テストコードを書くファイルを準備します。
ターミナルrails g rspec:system users
spec/system
配下にusers_spec.rb
というファイルが生成されていればOKです。テストコード
解説はあとにして先に記述例を載せます。
spec/system/users_spec.rbrequire 'rails_helper' RSpec.describe 'ユーザー新規登録', type: :system do before do @user = FactoryBot.build(:user) end context 'ユーザー新規登録ができるとき' do it '正しく情報を入力すればユーザー新規登録ができてトップページに移動する' do # トップページに移動する visit root_path # トップページに新規登録ページへ遷移するボタンがあることを確認する expect(page).to have_content('新規登録') # 新規登録ページへ移動する visit new_user_registration_path # ユーザー情報を入力する fill_in 'name', with: @user.nickname fill_in 'Email', with: @user.email fill_in 'Password', with: @user.password fill_in 'Password confirmation', with: @user.password_confirmation # 登録ボタンを押すとユーザーモデルのカウントが1増えることを確認する expect{ find('input[name="button"]').click }.to change { User.count }.by(1) # トップページへ遷移することを確認する expect(current_path).to eq(root_path) # カーソルを合わせるとログアウトボタンが表示されることを確認する expect( find('.user_nav').find('span').hover ).to have_content('ログアウト') # 新規登録ページへ遷移するボタンやログインページへ遷移するボタンが表示されていないことを確認する expect(page).to have_no_content('新規登録') expect(page).to have_no_content('ログイン') end end context 'ユーザー新規登録ができないとき' do it '誤った情報ではユーザー新規登録ができずに新規登録ページへ戻ってくる' do # トップページに移動する visit root_path # トップページに新規登録ページへ遷移するボタンがあることを確認する expect(page).to have_content('新規登録') # 新規登録ページへ移動する visit new_user_registration_path # ユーザー情報を入力する fill_in 'name', with: '' fill_in 'Email', with: '' fill_in 'Password', with: '' fill_in 'Password confirmation', with: '' # 登録ボタンを押してもユーザーモデルのカウントが増えないことを確認する expect{ find('input[name="button"]').click }.to change { User.count }.by(0) # 新規登録ページへ戻されることを確認する expect(current_path).to eq('/users') end end end RSpec.describe 'ログイン', type: :system do before do @user = FactoryBot.create(:user) end context 'ログインができるとき' do it '保存されているユーザーの情報と合致すればログインができる' do # トップページに移動する visit root_path # トップページにログインページへ遷移するボタンがあることを確認する expect(page).to have_content('ログイン') # ログインページへ遷移する visit new_user_session_path # 正しいユーザー情報を入力する fill_in 'Email', with: @user.email fill_in 'Password', with: @user.password # ログインボタンを押す find('input[name="button"]').click # トップページへ遷移することを確認する expect(current_path).to eq(root_path) # カーソルを合わせるとログアウトボタンが表示されることを確認する expect( find('.nav_bar').find('span').hover ).to have_content('ログアウト') # サインアップページへ遷移するボタンやログインページへ遷移するボタンが表示されていないことを確認する expect(page).to have_no_content('新規登録') expect(page).to have_no_content('ログイン') end end context 'ログインができないとき' do it '保存されているユーザーの情報と合致しないとログインができない' do # トップページに移動する visit root_path # トップページにログインページへ遷移するボタンがあることを確認する expect(page).to have_content('ログイン') # ログインページへ遷移する visit new_user_session_path # ユーザー情報を入力する fill_in 'Email', with: '' fill_in 'Password', with: '' # ログインボタンを押す find('input[name="button"]').click # ログインページへ戻されることを確認する expect(current_path).to eq(new_user_session_path) end end endまず
describe
で新規登録の場合とログインの場合
で項目分けします。次に
context
でそれぞれ正常系と異常系
で項目分けします。そのあと、それぞれの流れを書いていきます。
FactoryBot
については新規登録はbuild
ですが、ログインはユーザー情報があらかじめあることが前提なのでcreate
にしています。では内容について細かく解説していきます。
visit
トップページに移動する
をvisit root_path
としています。
visitは遷移する
という意味で利用します。page
visitで訪れた先のページの情報が格納されています。例えばカーソルを合わせてはじめて見ることができる文字列はpageの中に含まれません。
have_content
トップページに新規登録ページへ遷移するボタンがあることを確認する
をexpect(page).to have_content('新規登録')
としています。
visitで訪れたpageの中に該当の文字列があるかどうかを判断する時に利用します。fill_in
ユーザー情報を入力する
はfill_in
を利用しています。
fill_in フォームの名前, with: 入力する文字列
と記述することでフォームへの入力を行うことができます。find().click
find(クリックしたい要素).click
と記述することでクリックの動作ができます。
登録ボタンを押すとユーザーモデルのカウントが1増えることを確認する
を登録ボタンを押す
の動作をfind('input[name="button"]').click
で表しています。
この場合はinput
要素のname="button"
を指定していることになります。change
expect{ 動作 }.to change { モデル名.count }.by(1)
で登録ボタンを押すとユーザーモデルのカウントが1増えることを確認する
の
カウントが1増える
を表しています。current_path
トップページへ遷移することを確認する
をexpect(current_path).to eq(root_path)
としています。
current_path
は今いるページ
という意味です。hover
find(要素).hover
で特定の要素にカーソルをあわせたときの動作を表すことができます。
カーソルを合わせるとログアウトボタンが表示されることを確認する
をexpect(find('.nav_bar').find('span').hover).to have_content('ログアウト')
としています。
今回の場合は.nav_bar
の中のspan
要素という感じで二重に探しています。have_no_content
サインアップページへ遷移するボタンやログインページへ遷移するボタンが表示されていないことを確認する
をexpect(page).to have_no_content('新規登録')
とexpect(page).to have_no_content('ログイン')
としています。
存在しないことを確認するという意味です。以上です。
- 投稿日:2021-03-06T10:00:21+09:00
Ruby言語とは
本日は、Ruby言語の特徴とメリットデメリットについて
記事を書きたいと思います。特徴
Rubyは、1995年に公開されたプログラミング言語。
日本で開発され、日本初の国際規格で認定されたプログラミング言語。
開発者は、「まつもとゆきひろ」さん。
言語の名前の由来は宝石のルビーからきている。・インタプリタ方式
実行エンジンがプログラムソースを解析して1行ずつ実行する方式である、インタプリタ方式を採用している。
なので、プログラム作成中に実行できるため、エラーが発見しやすい・汎用性が高い
汎用性が高く、Web開発以外の現場でも使用されている。その原因は、Windows、Linux、Mac OS等いろいろな環境で動作可能なマルチプラットフォームであるため。
gemと呼ばれるライブラリ群が充実している点が挙げられる。このため、Webシステム以外でも幅広く活用できる。メリット
メリットは、文法や書式はシンプルなものを使っているので、初心者でも勉強しやすい。
コードの記述する量も他の言語に比べると圧倒的に少ないため、開発効率も高い。
日本発のプログラミング言語というのもあり学習環境が充実している。
書籍なども多く販売されている。デメリット
デメリットでは、処理速度が遅いこと。
インタプリタ方式を採用しているため、先ほど少し話たがコードを1行ずつ訳しながら
実行するため、プログラム全体を訳してから実行するコンパイル方式と比べると処理速度
は遅くなる。
コンパイル方式はコードが複雑なため、記述に時間がかかってしまう。Ruby on Railsとは
RubyとRuby on Railsは混同されがちだが別物。
Ruby on Railsとは、Webアプリケーションフレームワークのひとつ。
フレームワークとは、最小コストでwebアプリケーションの開発ができる仕組みのこと。
つまり、Webアプリケーション開発で、「必要となる作業やリソースを事前に仮定し、
用意してある便利なもの」■Ruby on Railsの理念
・DRY(Don`t Repeat Yourself)
同じコードを繰り返し書くことを避け、保守管理しやすい状態に保ちバグを減らす。・CoC(Convention Over Configuration)
Ruby on Rails自体で規約を用意しているため、規約に則ったコードを書くことで記述量を少なくすることができ、スピーディーな開発が可能。Rubyはプログラミング言語でRuby on Railsは開発を効率的に行えるフレームワークと覚えよう。
ちなみに、Ruby言語を使用し作られた有名なものは、Huluやクックパッドなどがある。以上がRubyについての説明です。
今回は、自分が使用している言語について記事を書いてみたが他の言語についての
記事も書いていきたいなと思いました。
- 投稿日:2021-03-06T09:57:19+09:00
【Rails】seeds.rbでサンプルデータ作成時に画像も一緒に生成する(ActiveStorage使用)
環境
macOS: Big Sur Ver11.2.2
Rails: 6.0.0
Ruby: 2.6.5前提
Railsで開発中しており、deviseを使ってユーザー登録関係を実装済み(Userモデル)。
さらに、UserモデルはActiveStorageにより画像を1枚添付できるようになっている(必ず添付しなければならない制約をつけてます)。
モデルのコードはこんな感じで、 has_one_attached と :image属性にpresence: trueが指定されているのがわかります。class User < ApplicationRecord extend ActiveHash::Associations::ActiveRecordExtensions belongs_to :prefecture belongs_to :category has_one_attached :image # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable # パスワードは半角英数混合で8文字 validates :password, format: { with: /\A(?=.*?[a-z])(?=.*?\d)[a-z\d]{8}\z/i } with_options presence: true do validates :shop_name validates :address validates :business_hours validates :holiday validates :image validates :phone_number, format: { with: /\A0[1-9]\d{0,3}[-(]\d{1,4}[-)]\d{4}\z/ } with_options numericality: { other_than: 0 } do validates :category_id validates :prefecture_id end end endやりたいこと
ユーザーに紐づく画像をトップページに表示させたかったので、サンプルデータを20~30件くらい作って一気に表示させてみようと思いました。
新規登録されたユーザーの画像がトップページにズラッと並ぶ感じです。試したこと①
seeds.rbに以下のとおり記述。
20.times do |n| user = User.create!( shop_name: "お店#{ n + 1 }", email: "seed#{ n + 1 }@seed.com", password: "pass1234", password_confirmation: "pass1234", category_id: rand(1..10), prefecture_id: rand(1..47), address: "仮町仮番地123-1", business_hours: "9時〜17時", holiday: "月曜", phone_number: "090-1234-5678", image: open("./db/fixtures/seed.jpg") end結果、「ファイルが見つからない、、」というエラー。
試したこと②
20.times do |n| user = User.create!( shop_name: "お店#{ n + 1 }", email: "seed#{ n + 1 }@seed.com", password: "pass1234", password_confirmation: "pass1234", category_id: rand(1..10), prefecture_id: rand(1..47), address: "仮町仮番地123-1", business_hours: "9時〜17時", holiday: "月曜", phone_number: "090-1234-5678", ) user.image.attach(io: File.open(Rails.root.join('app/assets/images/test.jpg')), filename: 'test.jpg') end結果、「imageがバリデーションに引っかかりました!」という指摘を受けました。
試したこと③
20.times do |n| user = User.create!( shop_name: "お店#{ n + 1 }", email: "seed#{ n + 1 }@seed.com", password: "pass1234", password_confirmation: "pass1234", category_id: rand(1..10), prefecture_id: rand(1..47), address: "仮町仮番地123-1", business_hours: "9時〜17時", holiday: "月曜", phone_number: "090-1234-5678", user.image.attach(io: File.open(Rails.root.join('app/assets/images/test.jpg')), filename: 'test.jpg') end結果、「userに使っているimageというメソッドはないよ」と怒られました。
解決策
以下の記述方法を発見し、無事に画像データを持つユーザーのサンプルデータを作成できました。
20.times do |n| user = User.create!( shop_name: "お店#{ n + 1 }", email: "seed#{ n + 1 }@seed.com", password: "pass1234", password_confirmation: "pass1234", category_id: rand(1..10), prefecture_id: rand(1..47), address: "仮町仮番地123-1", business_hours: "9時〜17時", holiday: "月曜", phone_number: "090-1234-5678", image: ActiveStorage::Blob.create_and_upload!(io: File.open(Rails.root.join("db/fixtures/seed.jpg")), filename: "seed.jpg") ) end最後に
ググって出てきた情報の多くは、画像をユーザーを作成した後からattachする場合のやり方で、自分のようにユーザー作成時点で画像が必須という設計とは前提が異なっていました。
なので3つの方法全てがエラーになったんですね。
ちなみに後から画像をattachする方法は、Railsガイドに書いてあります。解決策のcreate_and_upload!メソッドはこちらに掲載されています。
あまり見たことがない公式リファレンス?だったので、これは初心者には厳しいな、、と言う印象でした。この記事が誰かの役に立てば幸いです!
補足
seeds.rb ファイルを編集などして、再度サンプルデータを流すときは、以下のコマンドを実行すればデータベースの中身をリセットしてサンプルデータの流し込みまで一括でやってくれます。
% rails db:reset
参考リンク
- 投稿日:2021-03-06T09:24:13+09:00
【Rails】データが作成された時間によってビューの表示を条件分岐する
環境
macOS: Big Sur Ver11.2.2
Rails: 6.0.0
Ruby: 2.6.5やりたいこと
作成中のWebサービスのトップページに新着のお店を掲載しています。
そこで、3日以内に登録された店舗であれば「NEW!!」の表示をしたいなあと思い、条件分岐の方法を探りました。結論
今回実装したのは、3日以内に登録された新規ユーザーであれば「NEW!!」の表示をするというもの。
結論、以下の記述で簡単に条件分岐できました。<div class="contents-box"> <div class="image-box"> <%= image_tag user.image, class: "contents-image" %> </div> <h3 class="contents-title"> <%= user.shop_name %> </h3> <p class="contents-price"> <%= user.category.name %> </p> <%# お店の登録が3日以内であればnewマークを表示 %> <% if user.created_at > 3.days.ago %> <p><i class="fas fa-star"></i>NEW!!</p> <% end %> </div>実装した結果がこちら!
テストデータなので、全部NEW!!が表示されていますが、ご容赦ください。
以上!
- 投稿日:2021-03-06T08:59:44+09:00
Rakeタスクのテストを書いてみる
はじめに
開発環境にダミーデータを入れてテストする際に、「SQLのINSERT文を実行!」などはせず、rakeを使ってコマンドラインからデータを作成するようにしてみましたので、記録として残します。
rakeタスクファイルの作成
※ 今回はuserのインスタンスを作成するケースで進めていきます。
rails g task task_userrakeファイルの編集
task_user.rbnamespace :task_users do desc "create user" task :create_user, ["name", "email", "password"]=> :environment do |task, args| user = User.new( name: args[:name], email: args[:email], password: args[:password] ) user.save! endRSpecでテストを書く
※説明上rakeファイルの編集から記載してありますが、実際にコードを書いていく際はテストから書いた方がいいです。
task_users_spec.rbrequire 'rails_helper' require 'rake' describe "TaskUsers" do before(:all) do @rake = Rake::Application.new Rake.application = @rake Rake.application.rake_require 'tasks/task_users' #specディレクトリ内の/lib以下のパスを指定します Rake::Task.define_task(:environment) end before(:each) do @rake[task_name].reenable end context "create user" do let(:task_name) { 'task_user:create_user' } let(:name) { "kantai" } let(:email) { "hogehoge@.hoge.jp" } let(:password) { Faker::Alphanumeric.alphanumeric(number: 10) } # ランダムな数列を作成しています # matcherは、「Userのレコードが1増えているかどうか」という条件で設定しています。 it "created user" do expect{ @rake[:task_name].invoke(name, email, password).to change(User, :count).by(1) end endコマンドで実行
rake 'task_users:create_user[name,email,password]'おわりに
なかなかrakeを使ってコマンドからレコードを作成するケースは少ないかと思いますが、チームでドキュメントとして残しておくことで、レコード作成を属人化することなく誰でも作成しやすくできるのが良い点かなと思います。
参考
- 投稿日:2021-03-06T08:30:21+09:00
ローカル環境でのGithub連携方法
はじめに
ローカル環境でのGithub連携方法についてまとめました。
railsアプリをGithubに連携させるためには、gitの知識も必要なため、gitを含めて説明します。到達点
以下の2点を達成する
・gitが使えるようになる
・railsアプリをGithubに連携させる流れ
① railsアプリを作成
② Github新規登録し、リポジトリを作成
③ gitコマンドを行い、連携させる① railsアプリを作成
手前味噌ではありますが、私の記事を参照すると、ローカル環境でのrails環境構築ができます。
ローカル環境開発 Ruby on Railsの環境構築方法
② Github新規登録し、リポジトリを作成
Githubを新規登録し、新しいリポジトリを作成する。
以下の画像の赤枠の New もしくは New repository をクリックしてください。
Repository nameは、自身のアプリ名(rails new
アプリ名
)を入力し、Create Repositoryをクリックしてください。③ gitコマンドを行い、連携させる
$ cd 自分のアプリ名 $ git init $ git status #作業ディレクトリでの現在の状態を表示する $ git diff #差分を見るgit addでファイルをstagedという状態にします
git add する前に変更した箇所とインデックスとの変更点を必ず確認しましょう
$ git add .. #すべてのファイル・ディレクトリ $ git diff #差分を見るgit commitでローカルレポジトリにコミットします
git commit する前に差分を確認しましょう
$ git commit -m "Initialize repository" $ git diff #差分を見るgit commit -m"コミット名" でコミット名が作成できます。
git push する前にも差分を確認しましょう$ git remote add origin http://Github.com/GitHubで作成したID/リポジトリ名.git $ git push -u origin mastergit pushで、ローカルレポジトリの内容がリモートに反映されます。
Githubのページからも確認しましょう。参考記事
- 投稿日:2021-03-06T03:25:19+09:00
Docker(Mac)でgRPC FUSE有効時にMySQL8コンテナが起動しない問題について調査報告と解決策
はじめに
Docker環境でRailsアプリケーションを開発する際にDocker Desktop for MacのgRPC FUSEを有効にした状態でコンテナを起動すると、MySQL8のコンテナが起動しない問題が発生した。
こんな感じ
db_1 | 2021-03-05T17:42:39.341701Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started. db_1 | 2021-03-05T17:42:40.003650Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended. db_1 | 2021-03-05T17:42:40.011792Z 1 [ERROR] [MY-011087] [Server] Different lower_case_table_names settings for server ('2') and data dictionary ('0'). db_1 | 2021-03-05T17:42:40.012090Z 0 [ERROR] [MY-010020] [Server] Data Dictionary initialization failed. db_1 | 2021-03-05T17:42:40.012378Z 0 [ERROR] [MY-010119] [Server] Aborting db_1 | 2021-03-05T17:42:40.618413Z 0 [System] [MY-010910] [Server] /usr/sbin/mysqld: Shutdown complete (mysqld 8.0.23) MySQL Community Server - GPL.
この問題はDocker 2.4.0から報告されている有名な問題らしく、gRPC FUSEをオフにすることで改善できる問題とのことだった。
参考:Mysql not starting in a docker container on MacOS after docker updateしかし、(本来ここについても調査すべきなのかもしれないがw)gRPC FUSEをオフにすることで、docker-composeを起動中にアプリケーションに編集を加えるとコンテナやアプリケーションがフリーズして再起動を必要とする…ということが起きてしまったため、
なんとかgRPC FUSEを有効にした状態でMySQL8コンテナを起動することはできないか、自分なりに調査をしてみました。
あくまでも「私はこのように調査したよ、このように対処したよ」という報告のような内容の記事ですので、ここに書いてある内容が正しいノウハウ、知識という認識で読まれるのはオススメしません。
この記事をきっかけに正しい知恵をいただいたり、もしくはこの問題についての議論が進むことを望みます。実行環境
- MacBook Pro 16inch 2019
macOS 11.2.2
Docker Desktop for Mac 3.1.0 ※gRPC FUSEを有効にする
docker-composeファイルの設定
docker-compose.ymldb: image: mysql:8.0.23 command: mysqld --default-authentication-plugin=mysql_native_password volumes: - ./mysql/mysql:/var/lib/mysqlいろいろわかったこと(原因は不明)
再ビルドしても起動しなかった
再度ビルドをしてみてクリーンな状態で起動してみてはどうか、と考えたが改善しなかった。
--no-cacheオプションも試しましたが関係ありませんでした。別のログインユーザーは正常に起動した
私のMacBookにはプライベート用とそれ以外用とでユーザーアカウントをわけている。
今回、テストしてみたところ、同じRailsアプリケーション、同じdokker-composeファイルにもかかわらず、プライベート用のユーザーアカウントでは問題なくMySQL8コンテナを起動することができた。
インストールしているアプリケーションや開発環境の内容はほぼいっしょ(のはず)のため、もう一方のユーザーアカウントで起動しなかった理由はわからず…インターン生のPCでも正常に起動した
私の勤務している会社で働いてくれているインターン生のMacBookでも正常に起動していた。
こちらのMacBookは最近使い始められたばかりで、特に設定やアプリのインストールがされているわけではないので、それが原因で起動した説…はあるかもしれない。。。Dockerのバージョンを上げてみたが改善せず…
無駄でした…なお、バージョンを下げるのはなんだか気分が乗らなかったのでやめた。
docker-compose run db bashでコンテナに入ってみた => データベースが読み込まれていなかった
gRPC FUSEをオフの状態でMySQLコンテナに入り、MySQLを起動すると、
RailsのActiveRecordで作成したデータベーステーブルをshow databases;で見つけることができた。しかし、gRPC FUSEを有効にしてから同様の処理をすると…そもそもMySQLにすらログインさせてもらえなかった。
つまり、データベースが読み込まれていない、ということだ。なるほど…ということは、MySQLのデータを永続化させるために設定しているVolumesが怪しいのでは…?
MySQLコンテナのvolumesの記述を削除すると…起動した
MySQLデータを永続化させるためのvolumes設定を削除してみたところ、、、ついに、MySQLコンテナが正常に動作してくれた。
しかし、このままでは毎回コンテナ起動時にデータベースを作成しなければいけない。うーん、どうしたらいいものか。そして見つけた、自分なりの改善策
MySQLのデータのマウント先を名前付きVolumesに設定 => 起動した!
Volumesが怪しいと思いいろいろ調べてみたところ、こちらの記事を見つけることができた。
Docker上のMySQLのデータをVolumeでホストのディレクトリにマウントすると権限周りで面倒なことになる
Dockerのvolumeでpermission deniedが発生した場合の解決法Macでは発生しない問題(?)についての記事ではあったのですが、この記事を見て名前付きVolumesを設定したらどうなるだろう、と思った。
docker-composeファイルの設定のうち、volumesの設定を以下のように変更してみました。
docker-compose.ymldb: image: mysql:8.0.23 command: mysqld --default-authentication-plugin=mysql_native_password volumes: - mysql_data:/var/lib/mysql volumes: mysql_data: # 名前付きVolumesを定義すると…
動いた!!!(´;ω;`)
データもしっかり永続化できているので、docker-compose downしてから再度コンテナを起動させても、問題なくデータベースを読み込むことができた。
改めて思うと、永続化データをプロジェクトディレクトリ内にマウントする必要性も特に感じないので、名前付きVolumesに設定で全然問題ないんですよね。次からこのやり方で統一していこうと思います。
。。。しかし、今回MySQLコンテナをgRPC FUSE有効時にも正常動作させるのにかなり苦労したが、名前付きVolumesを使わずとも正常に動作する環境があるのも事実…なので完全な問題解決、理解には至っていないのだ。。。おわりに
とりあえずの対処法は発見できたものの、まだこの問題の根本的な解消には至っていないと考えています。Dockerに対しての理解が浅いために、この程度までの調査、分析しかできませんでした。
今年に入ってこの問題に関する記事が新規で上がっていないところを見ると、皆さんすでに解決済み?なのかもしれませんが、もしかしたら今も困っている方がいらっしゃるかもしれません。もし、この問題の原因、解決法に詳しい方がいらっしゃいましたらコメント欄でご教授いただけますと幸いです。
この記事が、DockerのgRPC FUSE問題に苦しむ方の解決のきっかけにつながることを願います。
- 投稿日:2021-03-06T00:13:21+09:00
【Rails】git push herokuをしてもEverything up-to-dateと言われる【Heroku】【Git】
症状
git push herokuをやっても、herokuに変更が反映されませんでした。
git push heroku Everything up-to-date「Everything up-to-date」は変更するデータないよと言われているようです。
原因を調べると、gitも更新されていなかったことがわかりました。
解決策
コミットはしたものの変更をマージしていなかったことが分かりました。。
以下のコマンドを良しなに入力して解決しました。初歩的なミスでしたが、解消に時間がかかったので書きました。
git merge ブランチ名