- 投稿日:2021-02-25T23:59:08+09:00
FactoryBot & Fakerの導入 (Rspecでの使い方)
初学者です。備忘録がてらまとめました。
- Ruby2.6.5
- Rails6.0.0
テストコードを効率的に書きたい!
テストコードが冗長になって読みにくい..そんな時に使えるFactoryBot!
例えばUserのログイン機能をテストしたいとします。以下が使ってないverです。
RSpec.describe User, type: :model do describe 'ユーザー新規登録' do it 'nicknameが空では登録できない' do user = User.new(nickname: '', email: 'test@example', password: '000000', password_confirmation: '000000') user.valid? expect(user.errors.full_messages).to include("Nickname can't be blank") end次にFactoruBot使用verです。
RSpec.describe User, type: :model do describe 'ユーザー新規登録' do it 'nicknameが空では登録できない' do user = FactoryBot.build(:user) # Userのインスタンス生成 user.nickname = '' # nicknameの値を空にする user.valid? expect(user.errors.full_messages).to include "Nickname can't be blank" endFactoruBotとは、インスタンスをまとめるGemのことです。別ファイルに書き込んだものを、書くテストコードで使い回しできるんです。
めっちゃ変わった!というわけではありませんが、目視でわかりますね。使ってないと横に伸びてます。
このコードだけだと、効果がわかりづらいのですが、テストコードはあら有る状況を書いていくもの..
FactoryBotを使わないと、チリも詰まればなんとやらと言いますが、まさしくそれで、可読性も下がってしまいます。ですので、テストコードを書く際は積極的に使いましょう!
FactoryBotの導入
Gemの一種ですので、いつもの流れです。
が、記述する箇所に注意してください!
Gemfileのgroup :development, :test do ~ endの中です。
Rspecと同じ箇所ですね。Gemfile
group :development, :test do # Call 'byebug' anywhere in the code to stop execution and get a debugger console gem 'byebug', platforms: [:mri, :mingw, :x64_mingw] gem 'rspec-rails', '~> 4.0.0' gem 'factory_bot_rails' ←ここです endいつものbundle install!
ターミナル
% bundle installインスタンスをまとめるファイルの作成
FactoryBotを書く、もしくはインスタンスをまとめるファイルを作成します。
※ここではUserモデルのテストコードで進めていきます。spec/factories /users.rb ←これを作ります
具体的な書き方
生成したファイル内の記述例がこちらです。
Fakerを使用したほうが良いパターンもあります。詳しくはこの後に記述しています。spec/factories/users.rb
FactoryBot.define do factory :user do nickname {'test'} email {'test@example'} password {'000000'} password_confirmation {password} end endこの設定したインスタンスをテスト内で使う場合、 FactoryBot.build(:user) と書く必要があります!
さっき上で見たテストコードを使って修正します。
spec/models/user_spec.rb
RSpec.describe User, type: :model do describe 'ユーザー新規登録' do it 'nicknameが空では登録できない' do user = FactoryBot.build(:user) # Userのインスタンス生成 user.nickname = '' # nicknameの値を空にする user.valid? expect(user.errors.full_messages).to include "Nickname can't be blank" endこれをさらに可読化させます。
RSpec.describe User, type: :model do before do ←ここに注目! @user = FactoryBot.build(:user) end describe 'ユーザー新規登録' do it 'nicknameが空では登録できない' do @user.nickname = '' @user.valid? expect(@user.errors.full_messages).to include "Nickname can't be blank" end@userとbeforeを活用することで、user = FactoryBot.build(:user)を繰り返し書かずに済みます。
ここで before と build が出てきたので簡単に触れましょう。
before
それぞれのテストコードを実行する前に、先に実行してくれます。コントローラーでもたまに使ってましたね!
今回はこのbeforeくんのおかげでちゃっちゃと記述できています。とっても効率的!build
ActiveRecordのnewメソッドと同じように働きます。
build=組み立てる、築くって意味ですもんね!テストコードがかけたら、下記のコマンドを実行します。
ターミナル
bundle exec rspec spec/models/user_spec.rb値をランダム生成するFaker
ここまでは、FactoryBot内の詳細を自分で記述していました。(email=test@example、nickname=testなど)
ですが、もしemailなどのバリデーションで一意性などの設定をしていた場合、このままだとテストが通らなくなってしまいます。そんな時に使うのがFakerです!
Faker
ランダムな値を生成するGemです。メールアドレス、人名、パスワードなど、さまざまな意図に応じたランダムな値を生成してくれます。
Fakerの公式GitHub
Qiita・FakerのチートシートFakerの導入
こちらもGemfileに記述し、bundle installしましょう!
今回もgroup :development, :test doに書いてくださいね!Gemfile
group :development, :test do # Call 'byebug' anywhere in the code to stop execution and get a debugger console gem 'byebug', platforms: [:mri, :mingw, :x64_mingw] gem 'rspec-rails', '~> 4.0.0' gem 'factory_bot_rails' gem 'faker' endFakerバージョンに記述を変更してみる
emailだけでもいいのですが、せっかくですから三つともFakerバージョンにしてみましょう!
spec/factories/users.rb
FactoryBot.define do factory :user do nickname {Faker::Name.initials(number: 2)} email {Faker::Internet.free_email} password {Faker::Internet.password(min_length: 6)} password_confirmation {password} end endテストしてみましょう。
ターミナル
% bundle exec rspec spec/models/user_spec.rb緑色で通れば成功です!
お疲れ様でした!
- 投稿日:2021-02-25T22:53:33+09:00
【Rails】Twitter APIを使ってトレンドを取得したい
はじめに
現在、TwitterAPIを使って、トレンド情報を取得する機能を実装しています。
まだまだ途中ですが、ひとまずこれまで気づいたことをまとめていきます。
使用するAPIはStandard v1.0です。なお、本記事は初学者によるものです。
アドバイスなどございましたら、コメントいただけると幸いです!環境
- Ruby on Rails 6.0.0
- mac os catalina
- TwitterAPI standard v1.0
導入の手順
- Twitter developerへ登録
- 各API keyを取得
- gem Twitterを導入
Twitter developerへ登録
ぐぐると登録の仕方がたくさん出てくるので、深くは解説しません。
余談ですが、申請時に書く内容は全部日本語で記載しても通りました。各API keyを取得
【概要】
APIを使用するために、認証をする必要があります。
認証には、以下4つのAPIキーが必要です。
- consumer key (API key)
- consumer secret (API secret)
- access token
- access secret
consumerは身分証のようなイメージです。
twitter developperに登録するとアプリごとに発行されます。accessは通行券とか使用するパスポートみたいなイメージです。
使用の許可得てますよ!といったものです。なお、これらのAPIキーは一度発行すると、同じものを再発行することはできないみたいです。
【取得方法】
①developperにログインし、Developper Portalを押します。
②Dashbordからアプリを登録
③アプリ横の鍵マークをクリックし、Access token & secretを発行します。gem Twitterを導入
gem Twitterを導入しAPIを叩いていきます。
このgemを導入してやられている方が多かったので、ひとまずgemを導入してやってみようと考えました。
導入方法と、トレンド情報の取得方法を記載していきます。【導入方法】
①Gemfileに'twitter'を記入し、bundle install
gem 'twitter'$ bundle install②railsを再起動
【取得方法】
①まずは認証(gemのgithubを参考)
controller.rbclient = Twitter::REST::Client.new do |config| config.consumer_key = ENV["TWITTER_API_KEY"] config.consumer_secret = ENV["TWITTER_API_SECRET_KEY"] config.access_token = ENV["TWITTER_ACCESS_TOKEN"] config.access_token_secret = ENV["TWITTER_ACCESS_SECRET_TOKEN"] endAPIkeyはgithubなどにアップするとまずいので、環境変数に設定します。
環境変数の設定方法はこちら②trendsメソッドを使用してトレンド情報を取得
twitterの導入によりAPIを操作するメソッドが使用できます。
トレンド情報取得の場合はtrendsメソッドを使用します。controller.rbclient = Twitter::REST::Client.new do |config| config.consumer_key = ENV["TWITTER_API_KEY"] config.consumer_secret = ENV["TWITTER_API_SECRET_KEY"] config.access_token = ENV["TWITTER_ACCESS_TOKEN"] config.access_token_secret = ENV["TWITTER_ACCESS_SECRET_TOKEN"] end @trends = client.trends@trendsの戻り値はこんな感じです。
@attrs= {:trends=> [{:name=>"#INDvENG", :url=>"http://twitter.com/search?q=%23INDvENG", :promoted_content=>nil, :query=>"%23INDvENG", :tweet_volume=>142604}, {:name=>"#自分をつくりあげたゲーム4選", :url=> ...(略)標準では、フォローしているアカウント、興味関心、位置情報から決まるようです。
個人的にはこのままだと、扱いづらいので日本のトレンド10件だけにしてみます。controller.rbclient = Twitter::REST::Client.new do |config| config.consumer_key = ENV["TWITTER_API_KEY"] config.consumer_secret = ENV["TWITTER_API_SECRET_KEY"] config.access_token = ENV["TWITTER_ACCESS_TOKEN"] config.access_token_secret = ENV["TWITTER_ACCESS_SECRET_TOKEN"] end @trends = client.trends(id = 23424856).attrs[:trends].first(10)gemの公式のドキュメントによると、trends(id = yahooの国ID)とすることで、その国のトレンドに限定して取得できるようです。
(これ見つけるのに苦戦しました)ここから学んだこと
①英語のドキュメントでも、翻訳してトライするべし
最初めちゃくちゃ抵抗ありました。
でも、翻訳してみると、思ったよりわかるかも?
といった感じがありました。
何事もまずはトライ。②gemもrubyファイルの塊
ということは、どこかにメソッドの定義が書いてある?参考
- 本当に知ってる?最低限押さえたいOAuthのマナー
- Getting started
- gem twitter
- gemのドキュメント(trendsのページ)
- TwitterのおすすめトレンドをAPI(Ruby)で取得する
- Twitterのトレンドについてのよくある質問
ありがとうございました!!
- 投稿日:2021-02-25T22:50:02+09:00
Ruby on Rails の each文をマスターしよう!!(初投稿)
アジェンダ
- この記事の対象者
- 開発環境
- シンプルなコード例
- コード全体の流れ
- よくある記述ミス
- つまづきがちなエラー
- ここで抑えるべき用語
- 最後に
この記事の対象者
- Railsを学び始めて1か月程度
- each文って結局どのように動いてるのかわからない
開発環境
- Rails5
- windows10
- vagrant
each文の最もシンプルなコード例
投稿した配列.id: 1 title: タイトル body: ボディ id: 2 title: title body: body id: 3 title: たいとる body: ぼでぃtest.controller.rbdef index @books = Book.all endindex.html.erb<% @books.each.do |book| %> <%= book.id %><br> <%= book.title %><br> <%= book.body %> ------------ <% end %>表示.1 タイトル ボディ ------------ 2 title body ------------ 3 たいとる ぼでぃ ------------全体の流れ
- @booksに投稿をすべて格納する(Book.all)
- <% @books.each do |book| %>によって投稿をひとつひとつにわけて|book|に格納する
- book.title等で、ひとつひとつのレコードを順番に取り出す
※each do ~ endまでにあるものを投稿の数だけ繰り返す。
(上記コード例で「------------」が三回繰り返されている)よくある記述ミス
- <%= @books.each do |book| %>としてしまう
- カラム名など余計なものも表示されてしまう(ここでは割愛するが、実際に自分でやってみるとよい)
- @books をcontrollerで定義していない
- viewファイルとcontrollerの該当アクションのインスタンス変数の数が同じかどうかを確認すること
- <% end %>がない
- syntax errorがでてしまう
つまづきがちなエラー
undefined method `each' for nil:NilClass
- @books.each do の場合@booksに変数が入っていないことが多い
これはeachだけでなくエラー文作成の際に起こるundefined method `errors' for nil:NilClassなどにもいえる。
controllerを再度確認すること。用語
- ブロック変数 → |book|のこと。
最後に
each文は初学者のつまづきやすいポイントです。
ただ一回イメージをつかめればなんてことないもので、使えればとても便利なものなので必ず習得しましょう!また、初投稿のため抜け落ちている部分、間違っている部分等あるかもしれませんが、
その際はご指摘いただけると嬉しいです!
ご覧頂きありがとうございました。
- 投稿日:2021-02-25T22:45:37+09:00
productionモードでサーバーを立ち上げるとcssが反映されない。
producition用のデータベース作成
rails db:create RAILS_ENV=productionアセットのプリコンパイルを行います
rails assets:precompile RAILS_ENV=productionサーバー起動
rails s -e production -b 0.0.0.0...cssが反映されない。。
解決策
config/enviroments/production.rbの
config.assets.compile = falseconfig.assets.compile = trueに変更したらうまくいきました。
アセットファイルがないときにファイルを探して自動コンパイルしてくれる設定のようです。
- 投稿日:2021-02-25T20:56:58+09:00
Bootstrapのhtml見本をhtml.erbで使うときの書き方変換いろいろ【超初心者】
よく忘れるので書き方
ボタンとフォームの書き方ボタン#html <button type="button" class="btn btn-primary">投稿ボタン</button> #html.erb form_withを使ってるとき <%= f.submit '投稿ボタン', class: "btn btn-primary" %>フォーム#html <div class="mb-3"> <label for="content" class="form-label">投稿内容</label> <textarea class="form-control" id="content" rows="3" placeholder="薄くフォームに表示されるやつ"></textarea> </div> #html.erb form_withのフォーム <div class="mb-3"> <%= f.label :content, "投稿内容" %> <%= f.text_area :content, class: "form-control m-3", rows: 3, placeholder: "薄くフォームに表示されるやつ" %> </div>f.text_fieldだとフォームの大きさを変えられないので注意
順次忘れやすいやつを入力していきます
- 投稿日:2021-02-25T19:41:17+09:00
Rails Googleアカウントを利用したSNS認証
経緯
Googleアカウントを利用したSNS認証を実装したいと思い、記事を参考にしながら作業していたが、難航した為、補完した記事を書きたいと思いました。
前提
・devise機能は実装済とする。
・下記参考サイトをメインとし、不足及び不備の補完を行う。参考サイト
・https://qiita.com/akioneway94/items/35641ad30c2acb23b562
手順1 GoogleAPI登録
Googleplatformにログイン後以下の画像の通り操作
ナビゲーションメニューから「APIとサービス」を選択→「認証情報」をクリック→「同意画面を構成」をクリック
・必須項目(アプリ名、ユーザーサポートメール、メールアドレス)を入力して「保存して次へ」をクリック。・スコープ、テストユーザー、概要の画面は何もせず「保存して次へ」をクリック
・APIとサービスの認証情報をクリック→「認証情報を作成」をクリック→「OAuthクライアントID」をクリック承認済みのリダイレクトURIを登録
・開発環境で使用したい場合
開発環境/users/auth/google_oauth2/callback
例:https://a072 ~ amazonaws.com/users/auth/google_oauth2/callback
・本番環境で使用したい場合
http://本番環境ドメイン/users/auth/google_oauth2/callback
※独自ドメインを取得しないと登録できません。手順2 コードを記述
①devise.rbの編集
devise.rb: # ==> OmniAuth # Add a new OmniAuth provider. Check the wiki for more information on setting # up on your models and hooks. # config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo' config.omniauth :google_oauth2, ENV['GOOGLE_CLIENT_ID'], ENV['GOOGLE_CLIENT_SECRET'] :②Gemfileの編集※解説あり
Gemfile.gem 'devise', git: "https://github.com/heartcombo/devise.git", branch: "ca-omniauth-2" : gem 'dotenv-rails' gem 'omniauth', '1.9.1' gem 'omniauth-google-oauth2'その後、bundle install
↓
アプリケーションのルートディレクトリ直下に.envを作成③.envと.ignoreの編集
env.GOOGLE_CLIENT_ID='クライアントID' GOOGLE_CLIENT_SECRET='クライアントシークレット'上記のクライアントID、クライアントシークレットをコピーして.envファイルに貼り付け
ignore..env.ignoreファイルに.envを記述
④routes.rbの編集
routes.rbRails.application.routes.draw do : devise_for :users, controllers: { sessions: 'users/sessions', passwords: 'users/passwords', registrations: 'users/registrations', omniauth_callbacks: "users/omniauth_callbacks" #この行を追加 } :⑤データベースにカラムを追加
$ rails g migration AddOuthColumnToUsers provider:string uid:stringその後rails db:migrate
⑥user.rbの編集
user.rbclass User < ApplicationRecord # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable, # 以下を追加 # google以外の認証をする場合は %i[twitter, facebook] :omniauthable, omniauth_providers: %i[google_oauth2] # クラスメソッドを作成します def self.from_omniauth(auth) where(provider: auth.provider, uid: auth.uid).first_or_create do |user| # deviseのuserカラムに name を追加している場合は以下のコメントアウトも追記します # user.name = auth.info.name user.email = auth.info.email user.password = Devise.friendly_token[0,20] end end end⑦user/omniauth_callbacks_controller.rbの編集
user/omniauth_callbacks_controller.rb# frozen_string_literal: true class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController # callback for google def google_oauth2 callback_for(:google) end def callback_for(provider) # 先ほどuser.rbで記述したメソッド(from_omniauth)をここで使っています # 'request.env["omniauth.auth"]'この中にgoogoleアカウントから取得したメールアドレスや、名前と言ったデータが含まれています @user = User.from_omniauth(request.env["omniauth.auth"]) sign_in_and_redirect @user, event: :authentication set_flash_message(:notice, :success, kind: "#{provider}".capitalize) if is_navigational_format? end def failure redirect_to root_path end end⑧app/views/devise/shared/_links.html.erbの修正
app/views/devise/shared/_links.html.erb#変更前 : <%= link_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider) %><br /> : #変更後 : <%= link_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", user_google_oauth2_omniauth_authorize_path %><br /> :※Gemfileの解説
2021年1月22日現在、ominiauthnの最新バージョンに対して、deviseが対応できていない。よって、ominiauthのバージョン1.~をinstallし、deviseは指定のURL及びbranchにて実装する。
エビデンスサイト
・https://rubygems.org/gems/omniauth/versions?locale=ja
・https://github.com/heartcombo/devise/pulls以上。
- 投稿日:2021-02-25T19:25:55+09:00
rails + docker環境内で時間を日本時間に合わせる
docker-compose.yml
web: &web build: . # command: bundle exec rails s -p 3000 -b '0.0.0.0' volumes: - .:/app - gem_data:/usr/local/bundle ports: - "3000:3000" environment: WEBPACKER_DEV_SERVER_HOST: webpacker WEBPACKER_DEV_SERVER_PUBLIC: 0.0.0.0:3035 TZ: Asia/Tokyo ← こいつを記載application.rb
module App class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. config.load_defaults 6.0 # config.active_record.default_timezone = :local config.i18n.default_locale = :ja # デフォルトのlocaleを日本語(:ja)にする config.time_zone = 'Asia/Tokyo' ← 追加 config.active_record.default_timezone = :local ← 追加 config.i18n.load_path += Dir[Rails.root.join('config', 'locales', '**', '*.{rb,yml}').to_s] initializer(:remove_action_mailbox_and_activestorage_routes, after: :add_routing_paths) { |app| app.routes_reloader.paths.delete_if {|path| path =~ /activestorage/} app.routes_reloader.paths.delete_if {|path| path =~ /actionmailbox/ } } # Settings in config/environments/* take precedence over those specified here. # Application configuration can go into files in config/initializers # -- all .rb files in that directory are automatically loaded after loading # the framework and any gems in your application. config.paths.add 'lib', eager_load: true end endこれだけで日本時間に変更可能。
#dockerfileは書き換えたら docker-compose down、docker-compose upの手順踏まないと反映されません。
- 投稿日:2021-02-25T19:00:08+09:00
【Railsチュートリアル】第10章 ユーザーの更新・表示・削除 10.2まで
はじめに
これまで未実装だったedit、update、index、destroyアクションを加え、RESTアクションを完成させる。
10.1 ユーザーを更新する
PATCHリクエストに応答するupdateアクションを作成する。
10.1.1 編集フォーム
まず、Usersコントローラにeditアクションを追加して、それに対応するeditビューを実装する。
ユーザー編集ページのURLは/users/1/edit。ユーザーidはparams[:id]
変数で取り出すことができる。app/controllers/users_controller.rbdef edit @user = User.find(params[:id]) endアクションを作成したら、ビューを作成する。
editビューは見た目はapp/views/users/new.html.erb
と似ているが、HTMLソースに少し違いがある。<form accept-charset="UTF-8" action="/users/1" class="edit_user" id="edit_user_1" method="post"> <input name="_method" type="hidden" value="patch" /> # 注目すべきは1つ上のコード↑ . . . </form><input name="_method" type="hidden" value="patch" />URL
/users/1
にPATCH
でリクエストをするとユーザーを、Railsが既存のユーザーである(すでにDBに存在する)ということを区別し、更新をしてくれる。演習 1
先ほど触れたように、target="_blank"で新しいページを開くときには、セキュリティ上の小さな問題があります。それは、リンク先のサイトがHTMLドキュメントのwindowオブジェクトを扱えてしまう、という点です。具体的には、フィッシング(Phising)サイトのような、悪意のあるコンテンツを導入させられてしまう可能性があります。Gravatarのような著名なサイトではこのような事態は起こらないと思いますが、念のため、このセキュリティ上のリスクも排除しておきましょう。対処方法は、リンク用のaタグのrel(relationship)属性に、"noopener"と設定するだけです。早速、リスト 10.2で使ったGravatarの編集ページへのリンクにこの設定をしてみましょう。
app/views/users/edit.html.erb. . . <div class="gravatar_edit"> <%= gravatar_for @user %> <a href="https://gravatar.com/emails" target="_blank" rel="noopener">change</a> </div> </div> </div>演習 2
リスト 10.5のパーシャルを使って、new.html.erbビュー(リスト 10.6)とedit.html.erbビュー(リスト 10.7)をリファクタリングしてみましょう(コードの重複を取り除いてみましょう)。ヒント: 3.4.3で使ったprovideメソッドを使うと、重複を取り除けます3 。
リストに沿ってリファクタリングする。
10.1.2 編集の失敗
update
アクションを作成する。def update @user = User.find(params[:id]) # DBからparams[:id]でuserを検索し、@userに代入 if @user.update(user_params) # 更新に成功した場合を扱う。 else render 'edit' # falseの場合はeditビューに再レンダリング end end演習 1
編集フォームから有効でないユーザー名やメールアドレス、パスワードを使って送信した場合、編集に失敗することを確認してみましょう。
確認のみなので省略
10.1.3 編集失敗時のテスト
エラーを検知するための統合テストを実装する。
test/integration/users_edit_test.rbrequire 'test_helper' class UsersEditTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) end test "unsuccessful edit" do # 編集が失敗するときのテスト get edit_user_path(@user) # 編集ページにアクセス assert_template 'users/edit' # editビューがレンダリングされるかどうか検証 patch user_path(@user), params: { user: { name: "", email: "foo@invalid", password: "foo", password_confirmation: "bar" } } # 無効な情報を送信 assert_template 'users/edit' # editビューが再レンダリングされるか検証 # updateアクションがfalseのときは「render 'edit'」が実行されるため end end演習 1
リスト 10.9のテストに1行追加し、正しい数のエラーメッセージが表示されているかテストしてみましょう。ヒント: 表 5.2で紹介したassert_selectを使ってalertクラスのdivタグを探しだし、「The form contains 4 errors.」というテキストを精査してみましょう。
test/integration/users_edit_test.rb. . . test "unsuccessful edit" do get edit_user_path(@user) assert_template 'users/edit' patch user_path(@user), params: { user: { name: "", email: "foo@invalid", password: "foo", password_confirmation: "bar" } } assert_template 'users/edit' assert_select "div.alert", "The form contains 4 errors." end10.1.4 TDDで編集を成功させる
編集の成功に対するテストを実装する。
test/integration/users_edit_test.rbclass UsersEditTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) end . . . test "successful edit" do # 編集が成功するときのテスト get edit_user_path(@user) # 編集ページにアクセス assert_template 'users/edit' # editビューがレンダリングされるか検証 name = "Foo Bar" # nameを更新 email = "foo@bar.com" # emailを更新 patch user_path(@user), params: { user: { name: name, email: email, password: "", password_confirmation: "" } } # 有効な情報を送信 assert_not flash.empty? # flashメッセージが空かどうか assert_redirected_to @user # プロフィールページにリダイレクト @user.reload # リロードする assert_equal name, @user.name # nameと@user.nameが同じかどうか assert_equal email, @user.email #emailと@user.emailが同じかどうか end endパスワードが空でも更新できるようにしているが、バリデーションがかかっているため、まだエラーになる。
app/models/user.rbclass User < ApplicationRecord . . . has_secure_password validates :password, presence: true, length: { minimum: 6 }, allow_nil: true **. . .** end
has_secure_password
がオブジェクト生成時に存在性を検証するため、新規ユーザー登録時に空のパスワードが有効になることは無い。演習 1
実際に編集が成功するかどうか、有効な情報を送信して確かめてみましょう。
確認のみなので省略。
演習 2
もしGravatarと紐付いていない適当なメールアドレス(foobar@example.comなど)に変更した場合、プロフィール画像はどのように表示されるでしょうか? 実際に編集フォームからメールアドレスを変更して、確認してみましょう。
初期設定のアイコン?が表示される。
10.2 認可
ユーザーにログインを要求し、かつ自分以外のユーザー情報を変更できないように制御する。
10.2.1 ユーザーにログインを要求する
Usersコントローラの中でbeforeフィルターを使う。beforeフィルターは、before_actionメソッドを使って何らかの処理が実行される直前に特定のメソッドを実行する仕組みのこと。
app/controllers/users_controller.rbclass UsersController < ApplicationController before_action :logged_in_user, only: [:edit, :update] # editアクション、updateアクションが呼び出されたら、logged_in_userアクションを実行する。 . . . private def user_params params.require(:user).permit(:name, :email, :password, :password_confirmation) end # beforeアクション # ログイン済みユーザーかどうか確認 def logged_in_user unless logged_in? # ログインしているか? flash[:danger] = "Please log in." # falseのときはflashを表示 redirect_to login_url # ログインページへリダイレクト end end endunless 条件式 条件式が偽の時に実行する処理 endunless文は条件式が偽の場合の処理を記述するのに使われる。
test/controllers/users_controller_test.rbrequire 'test_helper' class UsersControllerTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) end . . . test "should redirect edit when not logged in" do get edit_user_path(@user) # editページにアクセス assert_not flash.empty? # flashが表示されていないか? assert_redirected_to login_url # ログインページにリダイレクトされたかどうか? end test "should redirect update when not logged in" do patch user_path(@user), params: { user: { name: @user.name, email: @user.email } } # @user情報を送信 assert_not flash.empty? # flashが表示されていないか? assert_redirected_to login_url # ログインページにリダイレクトされたかどうか? end end演習 1
デフォルトのbeforeフィルターは、すべてのアクションに対して制限を加えます。今回のケースだと、ログインページやユーザー登録ページにも制限の範囲が及んでしまうはずです(結果としてテストも失敗するはずです)。リスト 10.15のonly:オプションをコメントアウトしてみて、テストスイートがそのエラーを検知できるかどうか(テストが失敗するかどうか)確かめてみましょう。
確認のみなので省略。
10.2.2 正しいユーザーを要求する
ユーザーが自分の情報だけを編集できるようにする。
test/controllers/users_controller_test.rbrequire 'test_helper' class UsersControllerTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) @other_user = users(:archer) end . . . test "should redirect edit when logged in as wrong user" do log_in_as(@other_user) # テストユーザー(:archer)としてログイン get edit_user_path(@user) # michaelのeditビューにアクセス assert flash.empty? # flashが表示されて assert_redirected_to root_url # root_urlにリダイレクトされる end test "should redirect update when logged in as wrong user" do log_in_as(@other_user) # テストユーザー(:archer)としてログイン patch user_path(@user), params: { user: { name: @user.name, email: @user.email } } # michaelの情報を更新しようとする assert flash.empty? # flashが表示されて assert_redirected_to root_url # root_urlにリダイレクトされる end end別のユーザーのプロフィールを編集しようとしたらリダイレクトさせたいので、correct_userというメソッドを作成し、beforeフィルターからこのメソッドを呼び出すようにする。
app/controllers/users_controller.rbclass UsersController < ApplicationController before_action :logged_in_user, only: [:edit, :update] before_action :correct_user, only: [:edit, :update] # editアクション、updateアクションが呼び出されたらcorrect_userアクションを実行する . . . private def user_params params.require(:user).permit(:name, :email, :password, :password_confirmation) end # beforeアクション # ログイン済みユーザーかどうか確認 def logged_in_user unless logged_in? flash[:danger] = "Please log in." redirect_to login_url end end # 正しいユーザーかどうか確認 def correct_user @user = User.find(params[:id]) # 受け取ったログイン情報を@userに代入 redirect_to(root_url) unless current_user?(@user) # @userと現在ログインしているユーザーが違う場合はroot_urlにリダイレクトさせる end end演習 1
何故editアクションとupdateアクションを両方とも保護する必要があるのでしょうか? 考えてみてください。
他のユーザーの個人情報の表示、更新ができてしまうから。
演習 2
上記のアクションのうち、どちらがブラウザで簡単にテストできるアクションでしょうか?
editアクション。viewが定義されているから。
10.2.3 フレンドリーフォワーディング
リダイレクト先をユーザーがアクセスしたかったページにする。
編集ページにアクセスし、ログインした後に、(デフォルトのプロフィールページではなく)編集ページにリダイレクトされているかどうかをチェックするテスト。
test/integration/users_edit_test.rbrequire 'test_helper' class UsersEditTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) end . . . test "successful edit with friendly forwarding" do # フレンドリーフォワーディングのテスト get edit_user_path(@user) # ログインがまだの状態で、editビューにアクセス log_in_as(@user) # テストユーザー(michael)でログイン assert_redirected_to edit_user_url(@user) # editビューにリダイレクト name = "Foo Bar" email = "foo@bar.com" patch user_path(@user), params: { user: { name: name, email: email, password: "", password_confirmation: "" } } assert_not flash.empty? assert_redirected_to @user @user.reload assert_equal name, @user.name assert_equal email, @user.email end endユーザーを希望のページに転送するには、リクエスト時点のページをどこかに保存しておき、その場所にリダイレクトさせる必要がある。
app/helpers/sessions_helper.rbmodule SessionsHelper . . . # 記憶したURL(もしくはデフォルト値)にリダイレクト def redirect_back_or(default) redirect_to(session[:forwarding_url] || default) # 送られてきたURLがnilでなければ左側を評価する。 session.delete(:forwarding_url) # 転送用のURLを削除 end # アクセスしようとしたURLを覚えておく def store_location session[:forwarding_url] = request.original_url if request.get? # もしGETリクエストが送られてきたらoriginal_urlをsession[:forwarding_url]に代入する end end演習 1
フレンドリーフォワーディングで、渡されたURLに初回のみ転送されていることを、テストを書いて確認してみましょう。次回以降のログインのときには、転送先のURLはデフォルト(プロフィール画面)に戻っている必要があります。ヒント: リスト 10.29のsession[:forwarding_url]が正しい値かどうか確認するテストを追加してみましょう。
test/integration/users_edit_test.rbrequire 'test_helper' class UsersEditTest < ActionDispatch::IntegrationTest . . . test "successful edit with friendly forwarding" do get edit_user_path(@user) assert_equal session[:forwarding_url], edit_user_url(@user) log_in_as(@user) assert_nil session[:forwarding_url] name = "Foo Bar" email = "foo@bar.com" patch user_path(@user), params: { user: { name: name, email: email, password: "", password_confirmation: "" } } assert_not flash.empty? assert_redirected_to @user @user.reload assert_equal name, @user.name assert_equal email, @user.email end end演習 2
7.1.3で紹介したdebuggerメソッドをSessionsコントローラのnewアクションに置いてみましょう。その後、ログアウトして /users/1/edit にアクセスしてみてください(デバッガーが途中で処理を止めるはずです)。ここでコンソールに移り、session[:forwarding_url]の値が正しいかどうか確認してみましょう。また、newアクションにアクセスしたときのrequest.get?の値も確認してみましょう(デバッガーを使っていると、ときどき予期せぬ箇所でターミナルが止まったり、おかしい挙動を見せたりします。熟練の開発者になった気になって(コラム 1.2)、落ち着いて対処してみましょう)。
[1, 10] in /home/vagrant/work/sample_app2/app/controllers/sessions_controller.rb 1: class SessionsController < ApplicationController 2: 3: # GET /login 4: def new 5: debugger => 6: end 7: 8: # POST /login 9: def create 10: @user = User.find_by(email: params[:session][:email].downcase) (byebug) session[:forwarding_url] "http://localhost:3000/users/1/edit" (byebug) request.get? true
- 投稿日:2021-02-25T18:55:25+09:00
第9章 発展的なログイン機構
はじめに
永続cookie(permanent cookies)を使って[remember me]を実装する。
9.1 Remember me 機能
ユーザーのログイン状態をブラウザを閉じた後でも有効にする[remember me]機能を実装していく。
9.1.1 記憶トークンと暗号化
Cookiesの場合
ブラウザ(cookie)に暗号化したパスワードとDBに入っているハッシュ化しているパスワードが一致するかRailsで認証する。
下記の方針で永続的セッションを作成する。
1. 記憶トークンを保存する場所を用意する。
2. 記憶トークンにはランダムな文字列を生成して用いる。
3. ブラウザのcookiesにトークンを保存するときには、有効期限を設定する。
4. トークンはハッシュ値に変換してからデータベースに保存する。
5. ブラウザのcookiesに保存するユーザーIDは暗号化しておく。
6. 永続ユーザーIDを含むcookiesを受け取ったら、そのIDでデータベースを検索し、記憶トークンのcookiesがデータベース内のハッシュ値と一致することを確認する。db/migrate/[timestamp]_add_remember_digest_to_users.rbclass AddRememberDigestToUsers < ActiveRecord::Migration[6.0] def change add_column :users, :remember_digest, :string end end記憶トークン用のカラムを用意。string(文字列)のremember_digest属性を追加する。
app/models/user.rb# ランダムなトークンを返す def User.new_token SecureRandom.urlsafe_base64 endA–Z、a–z、0–9、"-"、""のいずれかの文字(64種類)からなる長さ22のランダムな文字列を返すクラスメソッド`User.newtoken`を作成する。
マイグレーションは実行済みなので、Userモデルには既に
remember_digest
属性が追加されているが、remember_token
属性はまだ追加されていない。attr_accessor
を使って「仮想の」属性を作成する。app/models/user.rbclass User < ApplicationRecord attr_accessor :remember_token . . . # 永続セッションのためにユーザーをデータベースに記憶する def remember self.remember_token = User.new_token # 自分自身のremember_tokenに新しいtokenを代入する。保存されない update_attribute(:remember_digest, User.digest(remember_token)) # :remember_digestにremember_tokenをハッシュ化したものを保存する。 # 頭のselfが省略されている end end
user.remember_token
メソッドを使ってトークンにアクセスできるようにし、かつ、トークンをDBに保存せずに 実装する。演習 1
コンソールを開き、データベースにある最初のユーザーを変数userに代入してください。その後、そのuserオブジェクトからrememberメソッドがうまく動くかどうか確認してみましょう。また、remember_tokenとremember_digestの違いも確認してみてください。
>> user = User.first (1.9ms) SELECT sqlite_version(*) User Load (1.3ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]] => #<User id: 1, name: "Rails Tutorial", email: "example@railstutorial.org", created_at: "2021-02-13 18:10:29", updated_at: "2021-02-13 18:10:29", password_digest: [FILTERED], remember_digest: nil> >> user.remember (0.1ms) begin transaction User Update (8.2ms) UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ? [["updated_at", "2021-02-23 08:22:41.315576"], ["remember_digest", "$2a$12$y6wygDQ4HwYF6smxv8E1Y.K6Nz.3tNhCHpJVEDKHOBFs9MBu/NuLe"], ["id", 1]] (14.1ms) commit transaction => true >> user.remember_token => "JTiObGhfDhCqI-mYx7jGrw" >> user.remember_digest => "$2a$12$O6z7y1SMzDbXqIBa5OoYrOXv4nKB132fjA4WQ7MQxubweRSg/5nYa"演習 2
リスト 9.3では、明示的にUserクラスを呼び出すことで、新しいトークンやダイジェスト用のクラスメソッドを定義しました。実際、User.new_tokenやUser.digestを使って呼び出せるようになったので、おそらく最も明確なクラスメソッドの定義方法であると言えるでしょう。しかし実は、より「Ruby的に正しい」クラスメソッドの定義方法が2通りあります。1つはややわかりにくく、もう1つは非常に混乱するでしょう。テストスイートを実行して、ややわかりにくいリスト 9.4の実装でも、非常に混乱しやすいリスト 9.5の実装でも、いずれも正しく動くことを確認してみてください。ヒント: selfは、通常の文脈ではUser「モデル」、つまりユーザーオブジェクトのインスタンスを指しますが、リスト 9.4やリスト 9.5の文脈では、selfはUser「クラス」を指すことにご注意ください。わかりにくさの原因の一部はこの点にあります。
確認だけなので省略
9.1.2 ログイン状態の保持
cookies
メソッドを使い、永続セッションを作成する。
value
(値)とオプションのexpires
(有効期限)が必要。有効期限は省略できる。cookies[:remember_token] = { value: remember_token, expires: 20.years.from_now.utc }cookies.permanent[:remember_token] = remember_token # Railsの20年で期限切れになるcookies設定app/helpers/sessions_helper.rb# ユーザーのセッションを永続的にする def remember(user) # 必ず引数を設定する user.remember # DBに書き込む cookies.permanent.signed[:user_id] = user.id # cookieに暗号化したuser.idを代入する(20年で期限切れになる) cookies.permanent[:remember_token] = user.remember_token # cookieにuser.remember_tokenを代入する(20年で期限切れになる) endsigned: 暗号化、復号化するときのメソッド
app/models/user.rbdef authenticated?(remember_token) BCrypt::Password.new(remember_digest).is_password?(remember_token) # ハッシュ値remember_digestと平文remember_tokenがあっているかBCryptがチェック endapp/helpers/sessions_helper.rbdef current_user if (user_id = session[:user_id]) # session[:user_id]をuser_idに代入してnilかどうか確認 @current_user ||= User.find_by(id: user_id) elsif (user_id = cookies.signed[:user_id]) # cookies.signed[:user_id]をuser_idに代入してnilかどうか確認 user = User.find_by(id: user_id) # if user && user.authenticated?(cookies[:remember_token]) # nilかどうか確認(左側)して、引数にcookies内の:remember_tokenを引数にuser.authenticatedする log_in user @current_user = user end end end
session[:user_id]
もcookies.signed[:user_id]
もnilの場合はnilを返す。演習 1
ブラウザのcookieを調べ、ログイン後のブラウザではremember_tokenと暗号化されたuser_idがあることを確認してみましょう。
確認のみなので省略
演習 2
コンソールを開き、リスト 9.6のauthenticated?メソッドがうまく動くかどうか確かめてみましょう。
確認のみなので省略
9.1.3 ユーザーを忘れる
ユーザーがログアウトできるようにする。
user.forget
メソッドでuser.remember
が取り消される(nil
で更新する)app/models/user.rb# ユーザーのログイン情報を破棄する def forget self.update_attribute(:remember_digest, nil) # nilで更新する。削除する。 endapp/helpers/sessions_helper.rb# 永続的セッションを破棄する def forget(user) user.forget # remember_digestを削除 cookies.delete(:user_id) # user_idを削除 cookies.delete(:remember_token) # remember_tokenを削除 end # 現在のユーザーをログアウトする def log_out forget(current_user) # cookie情報を削除 session.delete(:user_id) @current_user = nil end演習 1
ログアウトした後に、ブラウザの対応するcookiesが削除されていることを確認してみましょう。
確認のみなので省略
9.1.4 2つの目立たないバグ
ユーザーがタブを複数開いているとき、複数のブラウザでログインしているときにそれぞれバグが発生する。
前者はユーザーがログイン中の場合にのみログアウトさせる必要があり、後者は
remember_digest
が存在しないときはfalseを返す処理をauthenticated?
に追加する必要がある。# 記憶トークンcookieに対応するユーザーを返す def current_user if (user_id = session[:user_id]) # log_outメソッドによってfalseになる @current_user ||= User.find_by(id: user_id) elsif (user_id = cookies.signed[:user_id]) # log_outメソッドによってfalseになる user = User.find_by(id: user_id) if user && user.authenticated?(cookies[:remember_token]) # 左側の条件式でエラーが出る log_in user @current_user = user end end end #current_userメソッドの評価はniltest/models/user_test.rbrequire 'test_helper' class UserTest < ActiveSupport::TestCase def setup @user = User.new(name: "Example User", email: "user@example.com", password: "foobar", password_confirmation: "foobar") end . . . test "authenticated? should return false for a user with nil digest" do assert_not @user.authenticated?('') end end演習 1
リスト 9.16で修正した行をコメントアウトし、2つのログイン済みのタブによるバグを実際に確かめてみましょう。まず片方のタブでログアウトし、その後、もう1つのタブで再度ログアウトを試してみてください。
確認のみなので省略
演習 2
リスト 9.19で修正した行をコメントアウトし、2つのログイン済みのブラウザによるバグを実際に確かめてみましょう。まず片方のブラウザでログアウトし、もう一方のブラウザを再起動してサンプルアプリケーションにアクセスしてみてください。
確認のみなので省略
演習 3
上のコードでコメントアウトした部分を元に戻し、テストスイートが red から green になることを確認しましょう。
確認のみなので省略
9.2 [Remember me]チェックボック
params[:session][:remember_me] == '1' ? remember(user) : forget(user)もし、params[:session][:remember_me]が「1」だったら(チェックボックスにチェックが入っていたら)、ログイン情報を記憶するためにrememberメソッドを呼び出す。「1」でなかったら記憶しないのでforgetメソッドを呼び出す。
三項演算子
if boolean? (true)var = foo else (false)var = bar end三項演算子を使うと下記のようになる。
var = boolean? ? (true) : (false)演習 1
ブラウザでcookies情報を調べ、[remember me]をチェックしたときに意図した結果になっているかどうかを確認してみましょう。
確認のみなので省略。
演習 2
コンソールを開き、三項演算子を使った実例を考えてみてください(コラム 9.2)。
> def first(type) > type = "fire" ? "ヒトカゲ" : "フシギダネ" > end => :type > first("fire") => "ヒトカゲ" # ゼニガメごめんね9.3 [Remember me]のテスト
test "login with remembering" do log_in_as(@user, remember_me: '1') assert_not_empty cookies[:remember_token] end test "login without remembering" do log_in_as(@user, remember_me: '1') # cookieを保存してログイン # remember me チェックする delete logout_path # ログアウトする log_in_as(@user, remember_me: '0') # cookieを削除してログイン # remember me チェックしない assert_empty cookies[:remember_token] # cookiesには情報が入ってないはず end演習 1
リスト 9.25の統合テストでは、仮想のremember_token属性にアクセスできないと説明しましたが、実は、assignsという特殊なテストメソッドを使うとアクセスできるようになります。コントローラで定義したインスタンス変数にテストの内部からアクセスするには、テスト内部でassignsメソッドを使います。このメソッドにはインスタンス変数に対応するシンボルを渡します。例えばcreateアクションで@userというインスタンス変数が定義されていれば、テスト内部ではassigns(:user)と書くことでインスタンス変数にアクセスできます。本チュートリアルのアプリケーションの場合、Sessionsコントローラのcreateアクションでは、userを(インスタンス変数ではない)通常のローカル変数として定義しましたが、これをインスタンス変数に変えてしまえば、cookiesにユーザーの記憶トークンが正しく含まれているかどうかをテストできるようになります。このアイデアに従ってリスト 9.27とリスト 9.28の不足分を埋め(ヒントとして?やFILL_INを目印に置いてあります)、[remember me]チェックボックスのテストを改良してみてください。17
app/controllers/sessions_controller.rbdef create @user = User.find_by(email: params[:session][:email].downcase) # if user && user.authenticate(params[:session][:password]) if @user && @user.authenticate(params[:session][:password]) log_in @user params[:session][:remember_me] == '1' ? remember(@user) : forget(@user) redirect_to @user else # alert-danger => 赤色のフラッシュ flash.now[:danger] = 'Invalid email/password combination' render 'new' # GET /users/1 => show template # render 'new'(0回目) end endtest/integration/users_login_test.rbtest "login with remembering" do log_in_as(@user, remember_me: '1') assert_equal cookies[:remember_token], assigns(:user).remember_token end9.3.2 [Remember me]をテストする
raiseメソッド: 例外(わざとバグ)を発生させるメソッド。きちんとテストがされているか確認する。
require 'test_helper' class SessionsHelperTest < ActionView::TestCase def setup @user = users(:michael) # michaelを@userに代入 remember(@user) # @userの情報をrememberに入れる end test "current_user returns right user when session is nil" do assert_equal @user, current_user # @userとcurrent_user(ログインしているuser)が一致しているかどうか assert is_logged_in? # ログインしているかどうか end test "current_user returns nil when remember digest is wrong" do @user.update_attribute(:remember_digest, User.digest(User.new_token)) # @userのremember_digestを新しいものに書き換える assert_nil current_user # 新しいものに書き換えたのでcurrent_userはnilを返しているはず end end演習 3
リスト 9.33にあるauthenticated?の式を削除すると、リスト 9.31の2つ目のテストで失敗することを確かめてみましょう(このテストが正しい対象をテストしていることを確認してみましょう)。
確認のみなので省略。
さいごに
第9章からほぼ理解できないまま飛ばす箇所が出てきました。
Railsチュートリアルを最後まで進めたら戻ってこようと思います。
- 投稿日:2021-02-25T18:12:51+09:00
wicked_pdfでコンソールからPDFを作成したい
はじめに
wicked_pdfでコンソールからPDFを作成したいシチュエーションに遭遇したので、備忘録も兼ねてQiitaに記事を残します。
環境
Ruby 2.6.5
Rails 5.2.4
wicked_pdf 1.1.0wicked_pdfとは?
RailsでPDF出力をしたい時に用いるGemで、HTMLを書くようにPDFを作成することができます。
PDFを作成するGemにはPrawnもありますが、PrawnはゴリゴリのDSLなので、自由度は高いですが学習コストがかかります。wicked_pdf本家リポジトリはこちら
https://github.com/mileszs/wicked_pdf前提
app/views/users/profile_pdf.pdf.slimというファイルがあり、中身が
h1 = @user.name h3 メールアドレス p = @user.email h3 プロフィール p = @user.profileのようになっているとします。(雑でごめんなさい、、、)
Userモデルにはnameとemailとprofileがあるとします。
手順
$ rails c controller = ActionController::Base.new # コントローラーのインスタンスを生成(ActionView::Baseのインスタンス生成に必要なため) view = ActionView::Base.new(Rails.root.join('app', 'views'), {}, controller) # ビューインスタンスの生成。renderメソッドを呼ぶために使う view.extend(ApplicationHelper) # viewファイルでHelperを使っている場合に使う。ApplicationHelper以外にもHelperファイルがある場合は、そちらもextendする pdf = WickedPdf.new.pdf_from_string( view.render( template: "users/profile_pdf.pdf.slim", # 自動でviews/配下のファイルを探しに行くため、users/profile_pdf.slimでOK layout: 'layouts/base_pdf_layout.pdf.slim', # layoutファイルを使わない場合は指定の必要なし locals: { @user => User.first }, # slimファイル内で変数を使う場合に指定する。 encoding: 'UTF-8' ) ).force_encoding("UTF-8") save_path = Rails.root.join('tmp', "#{User.first.name}_profile.pdf") # 保存するpathを作成 # Fileクラスを使って保存する処理 # 参考: https://github.com/mileszs/wicked_pdf#super-advanced-usage File.open(save_path, 'wb') do |file| file << pdf endlocalsは@userのみを渡していますが、変数は複数渡すことができます。
force_encodingを行わない場合、文字化けが発生します。(何十分か溶かしました)
生成されたファイルがこちら
まとめ
先ほど紹介したPrawnを用いる場合ですと、ActionControllerやActionViewに頼らずにコンソールからPDF出力ができるのですが、wicked_pdfでとなるとActionControllerやActionViewに頼らないと厳しいのかなと思いました。
「PDFを一括で出してほしい」のような依頼は意外とありますので、誰かの参考になればと思います。
- 投稿日:2021-02-25T17:46:39+09:00
Rubyについて
本日はRubyについて。。。
志望している会社でRubyに力を入れていらっしゃったので、復習というより知識をつけようと。Rubyって何?
・プログラミング言語
・Webアプリ開発に特化
・DBと繋がりやすい
・幅広く使われている
・文法が覚えやすいってな感じ。
そもそもプログラムって実行しなきゃメモ用紙なのよねえ。=Excel的な。
Ruby on Railsって何?
最初、「どっちもRubyやん。違いが分からん」という感じでした。
結論は、「Rubyのフレームワークの一つ」ということ。。
フレームワークとは、要は「頻度の高いツールのハッピーセット」的なものだと考えてる。
それぞれを単品で細かく頼むより楽だし、早いし。
つまり、Ruby(?)のハッピーセットがRuby on Railsって名前で売ってある的な。わかりずらいか。もちろんRubyのフレームワークは他にも「Sinatra」や「HANAMI」なんて可愛い名前のものもある。
それぞれ特徴があるが、一番使われているのが「Ruby on Rails」とされているんだと。参考はこちら
https://eng-entrance.com/ruby-frameworkまとめ
Railsっていう、Webアプリが作りやすくてみんな使ってる言語の一つで、ツールがあらかじめセットになってるものがRuby on Rails。
つまり、Railsハンバーガーのハッピーセット(おもちゃがRails)を頼んでるってことね。
- 投稿日:2021-02-25T17:38:24+09:00
[Rails6] Sprockets::Rails::Helper::AssetNotPrecompiled in エラーが出た
結論
app/assets/config/manifest.js
ファイルにコードを一行追加したら解決しました。app/assets/config/manifest.js//= link_tree ../images //= link_directory ../stylesheets .css #以下を追記 //= link application.css環境
- macOS Catalina
- Ruby 2.7.2
- Rails 6.0.3
実際には下記の記事通りに環境構築をしています
参考
このサイトで「rails 6」でページ検索して出てきた解決案のひとつがうまくいきました!
【Stack Overflow】Rails: Sprockets::Rails::Helper::AssetNotPrecompiled in development
他に試したこと
config/initializers/assets.rb
ファイルに追記
Rails.application.config.assets.precompile += %w( application.css )
この一行を追記rails assets:precompile
コマンド実行後サーバー再起動画像を再取り込み?(SVGリンク切れ?)
いくつか試しましたが、Railsのバージョン違いなのか、これではエラー解消できませんでした。
(前提としては、タイトルのエラー文が出て、画像表示がうまく動作していなかったようです)さいごに
Railsガイド - アセットパイプライン
原因はこれかな?
このあたりを読めば、仮説と検証を考えながら解決できそう。
- 投稿日:2021-02-25T17:38:24+09:00
[Rails6]Sprockets::Rails::Helper::AssetNotPrecompiled in エラーが出た
結論
app/assets/config/manifest.js
ファイルにコードを一行追加したら解決しました。app/assets/config/manifest.js//= link_tree ../images //= link_directory ../stylesheets .css #以下を追記 //= link application.css環境
- macOS Catalina
- Ruby 2.7.2
- Rails 6.0.3
実際には下記の記事通りに環境構築をしています
参考
このサイトで「rails 6」でページ検索して出てきた解決案のひとつがうまくいきました!
【Stack Overflow】Rails: Sprockets::Rails::Helper::AssetNotPrecompiled in development
他に試したこと
config/initializers/assets.rb
ファイルに追記
Rails.application.config.assets.precompile += %w( application.css )
この一行を追記rails assets:precompile
コマンド実行後サーバー再起動画像を再取り込み?(SVGリンク切れ?)
いくつか試しましたが、Railsのバージョン違いなのか、これではエラー解消できませんでした。
(前提としては、タイトルのエラー文が出て、画像表示がうまく動作していなかったようです)さいごに
Railsガイド - アセットパイプライン
原因はこれかな?
このあたりを読めば、仮説と検証を考えながら解決できそう。
- 投稿日:2021-02-25T15:38:04+09:00
form_withのlocal: trueって必要なん?これ何なん?(Ruby on Rails)
はじめに:私が犯した罪と償い
社内研修の一環で、プチIoTシステムを開発しています。
そんな中、自分のエラーの解消に大先輩3人の60分を溶かしてしまったので、せめてもの償いのために、学んだことをメモしておきます。おかげで、
- Railsのメソッド
form_with
の引数local: true
が何の役に立っているのか- アプリ作成時のネットワーク通信のこと
が痛いほどわかりました。
環境
Rails6.0.3, macOS Catalina 10.15.7何のエラー出してたん?
フォームでバリデーションが通らなかった時にエラーメッセージをビューで表示したい。
ただそれだけのことでした。しかし仕込んだそのエラーメッセージが表示されません!うまくいっていなかったビューはこちら。1行目にご注目。
new.html.erb<%= form_with model: @supplier do |form| %> <% if form.object.errors.any? %> <div id="error_explanation"> <div class="alert alert-danger"> エラーが <%= form.object.errors.count %> 個あります。 </div> <ul> <% form.object.errors.full_messages.each do |msg| %> <li><%= msg %></li> <% end %> </ul> </div> <% end %> <div class="mb-3"> <%= form.label :会社名, for: 'company', class: 'col-sm-2 col-form-label' %> <%= form.text_field :name, class: 'form-control', id: 'company', placeholder: '株式会社 喧嘩上等' %> </div> <div class="mb-3"> <%= form.label :メールアドレス, for: 'email', class: 'col-sm-2 col-form-label' %> <%= form.text_field :email, class: 'form-control', id: 'email', placeholder: 'order@example.com' %> </div> <%= form.submit '送信', class: 'btn' %> <% end %>私は、Railsチュートリアル出身なのですが、そこでは
form_with
にlocal: true
という引数を渡していました。しかし、ネットでform_withの使い方を他で改めて調べてみたところ、この引数がなくても新規レコードの登録処理ができそうだったので、省いてみちゃえ!と出来心が全ての事の始まりでした。実際、これでデータベースまでの登録はきちんとできていて、なんだlocalパラメータいらないんじゃん。となっていました。一方.....
if form.object.errors.any?
を入れても、エラー表示は一切できませんでした。ちなみにコントローラはこんな感じ。問題なさそう。
supplier.controller.rbclass SuppliersController < ApplicationController def new @supplier = Supplier.new end def create @supplier = Supplier.new(supplier_params) if @supplier.save redirect_to suppliers_path else render 'new' end end endbinding.irbをビューやコントローラなど至る箇所に埋め込んでは、各変数に値が入っているかどうかをチェック。
form.object
には値もある。@supplier
にしても同じ。errors.any?
もtrueを返している。何故なんだ。タイポもなさそう。先輩たちの時間をどんどん溶かしてしまっています...。解決:「クルクルしてない!」
「クルクルしてない!」
問題の原因が全くわからず途方にくれていた末に、ある先輩が。
フォームの送信ボタンを教えても画面がリロードしないことを変に思ったようで、そこからフォームの動きが怪しいとなりました。そこでlocal: true
を追記したのです。new.html.erb<%= form_with(model: @supplier, local: true) do |form| %> 以下略学び1:
local: true
とは?form_withはRailsの比較的新しいメソッド。このメソッドではデフォルトがAjax通信で、非同期通信になってしまうのです。(これはRails 5.1〜6.0の仕様でRails 6.1からは同期通信がデフォルトに戻っているそうです。当記事はRails6.0.3です。→参考。@jnchito さんありがとうございます!)それはつまり、必要な箇所だけページが更新され、その他の箇所はそのままになるという事。だからエラーメッセージのHTML部分が加えられなかったのです。
ここにlocal: true
と引数を渡す事で、これが通常のHTTPリクエストになり、ページ全体が返ってきてページがリロードされ、エラーメッセージも表示されるようになります。学び2:アプリの開発でネットワークをチェックする
その後、ブラウザの開発者ツールでネットワークを確認しました。
↑Typeがxhrになっとる。xhr=XMLHttpRequest。Ajaxのxの部分。つまりAjax通信になっているということ。
↑suppliersが下の方に。他の要素がリロードされずにそのまま残っているということ。↓
local: true
にしておく(=通常のHTTP通信にする)とこのようなネットワーク動作になります。
suppliersがリストの一番先頭にいます。
ページ内の全ての要素が更新され、suppliersが一番最初に処理されたという事です。
「エンジニア中級者になるには、ネットワークのことを理解すること」と先輩。
最後に
エラーの解消の過程で、他にもbinding.irbの使い方、errorsやfull_messagesにどんなデータが格納されているのかなどいろんなことを勉強できました。
先輩方ありがとうございました!
宣伝ではないですが、周りがこんなエラーに親身に向き合ってくれる先輩ばかりです。
本当にFusicという会社でエンジニアキャリアを始められて幸せすぎる...。
- 投稿日:2021-02-25T15:27:49+09:00
【Rails】1日に記録できる数値の合計を制限するバリデーション
はじめに
投稿3回目です。
文章力が赤ちゃんレベルかつ遅筆なのでまだ慣れないです...
間違い等あればご指摘よろしくお願いします。環境
- Ruby 2.6.3
- Rails 5.2.4
背景/目的
- ポートフォリオで学習内容を記録するサイトを開発中。
- 学習内容は1日に何回でも記録可能であり、項目には学習時間がある。
- 学習時間の合計値が24時間/1日を超えないようにバリデーションを設定する。
- 学習内容(learningsテーブル)の詳細は下記の通り。
カラム名 データ型 説明 date date 学習日 time float 学習時間 結論
app/models/learning.rbclass Learning < ApplicationRecord # 一つのユーザー(user)に対して、複数の学習記録(learning)が結びついている belongs_to :user # validateに定義したメソッドを設定 validate :total_time_cannot_exceed_limit_time, on: :create validate :total_time_cannot_exceed_limit_time_for_edit, on: :update # 1日に記録できる学習時間の合計 LIMIT_TIME_HOUR = 24 # 入力された学習日に既に記録されている学習時間の合計値を取得する def one_day_time_sum(date) user.learnings.where(date: date).sum(:time) end # 入力された学習日に既に記録されている「編集対象の投稿以外の」学習時間の合計値を取得する def one_day_time_sum_unless_target_date(date) user.learnings.where(date: date).where.not(id: id).sum(:time) end # create時のカスタムバリデーション用のメソッドを定義 def total_time_cannot_exceed_limit_time # [学習日が入力済]かつ[学習時間が入力済]かつ[学習日に既に記録された学習時間と入力した学習時間の合計が24時間を超える場合] if date.presence && time.presence && one_day_time_sum(date) + time > LIMIT_TIME_HOUR # エラーメッセージを表示する errors.add(:date, ":#{date.strftime("%Y年%m月%d日")}の学習時間の合計が#{LIMIT_TIME_HOUR}時間を超えています") end end # update時のカスタムバリデーション用のメソッドを定義 def total_time_cannot_exceed_limit_time_for_edit # [学習日が入力済]かつ[学習時間が入力済]かつ[学習日に既に記録された「編集対象の投稿以外の」学習時間と入力した学習時間の合計が24時間を超える場合] if date.presence && time.presence && one_day_time_sum_unless_target_date(date) + time > LIMIT_TIME_HOUR errors.add(:date, ":#{date.strftime("%Y年%m月%d日")}の学習時間の合計が#{LIMIT_TIME_HOUR}時間を超えています") end end endcreateとupdateで処理を分けています。
コメントで処理の内容を記載しましたが、自分で読んでても正直よく分からないです。
例えば、下記のデータが既に保存されており、学習日2021-01-01
・学習時間4
の投稿を新たにする場合、
id 学習日 学習時間 1 2021-01-01 10.6 2 2021-01-01 11.4 入力された学習日
2021-01-01
に既に記録されている学習時間の合計値22
時間を取得し、入力した学習時間4
時間を足して、24時間と比較します。=> この場合26時間なのでエラーメッセージが表示されます。これをupdateでも同じ処理にすると動作がおかしくなります。
例えば、id:1
の学習時間を5
時間に変更する場合、5 + 11.4 =16.4
時間になるのが理想です。
しかし、実際は10.6 + 11.4 + 5 =27
時間でNGになってしまいます。
user.learnings.where(date: date).sum(:time)
だと編集前の学習時間も取得してしまうので、update時はwhere.not(id: id)
を追記し、編集対象の投稿以外の学習時間の合計を取得するようにしています。参考にさせていただきました
ここまで見て頂きありがとうございました。
ネーミングセンスがないのは許していただけると助かります?
- 投稿日:2021-02-25T14:59:51+09:00
Active Hashを使ってidを DBに保存する
ActiveHashを利用する
gem 'active_hash'ActiveHashを導入する。
modelsディレクトリに新しいファイル作成後に、クラスを定義してActiveHash::Baseクラスを継承する。ActiveHashを用いるのに必要なもの。
class Genre < ActiveHash::Base self.data = [ { id: 1, name: '----' }, { id: 2, name: '経済' }, { id: 3, name: '政治' }, { id: 4, name: '地域' }, { id: 5, name: '国際' }, { id: 6, name: 'IT' }, { id: 7, name: 'エンタメ' }, { id: 8, name: 'スポーツ' }, { id: 9, name: 'グルメ' }, { id: 10, name: 'その他' } ] include ActiveHash::Associations has_many :examples endActiveHashを用いてアソシエーションの記述をするので、モジュールをincludeする。
また紐付いているモデルとのアソシエーションを記述する。class Example < ApplicationRecord extend ActiveHash::Associations::ActiveRecordExtensions belongs_to :genre validates :genre_id, numericality: { other_than: 1 } end同様に紐付いているモデル内に対応するアソシエーションを記述する。
こちらでもActiveHashを用いるのに使用するモジュールを取り込む。
また今回の場合はid1は {'---'}となったいるためDBに保存しないようにバリデーションをかけておく。<%= f.collection_select(:genre_id, Genre.all, :id, :name, {}, {class:"genre-select"}) %>プルダウンを生成するために上記の記述をする。
値 役割 :genre_id 保存先のカラム名 Genre.all 配列データ :id 表示する際に参照するDBのカラム名 :name 実際に表示されるカラム名 {} オプションの指定 {class:"genre-select"} htmlオプション 扱うデータを簡単に保存できるために役立ちそうです。
- 投稿日:2021-02-25T14:53:16+09:00
Railsチュートリアル10章まとめ
この章でやること ユーザーの更新・表示・削除
- Usersリソースのうち未実装だったedit、update、index、destroyアクションを加え、RESTアクションを完成させる
- まず、ユーザーが自分のプロフィールを自分で更新できるようにする
- 次に、すべてのユーザーを一覧できるようにする。サンプルデータとページネーション(pagination)も学ぶ
- 最後に、ユーザーを削除し、データベースから完全に消去する機能を追加。ユーザーの削除はどのユーザーにも許可できるものではないので、管理ユーザーという特権クラスを作成し、このユーザーにのみ削除を許可するようにする
10.1 ユーザーを更新する
導入
ユーザー情報を編集するパターンは、新規ユーザーの作成と似ている
新規ユーザー用のビューを出力するnewアクションと同じようにして、ユーザーを編集するためのeditアクションを作成すればよい。
同様に、POSTリクエストに応答するcreateの代わりに、PATCHリクエストに応答するupdateアクションを作成する
最大の違いは、ユーザー登録は誰でも実行できるが、ユーザー情報を更新できるのはそのユーザー自身に限られる
beforeフィルターを使ってこのアクセス制御を実現していくupdating-usersトピックブランチを作成
$ git checkout -b updating-users
10.1.1 編集フォーム
Usersコントローラにeditアクションを追加して、それに対応するeditビューを実装
editアクションはデータベースからユーザーデータを読み込むユーザー編集ページの正しいURLが/users/1/edit。
ユーザーのidはparams[:id]変数で取り出すことができるため@user変数を定義app/controllers/users_controller.rbclass UsersController < ApplicationController def edit @user = User.find(params[:id]) end end 対応するユーザーのeditビューを作成$ touch app/views/users/edit.html.erb
```app/views/users/edit.html.erb<% provide(:title, "Edit user") %> <h1>Update your profile</h1> <div class="row"> <div class="col-md-6 col-md-offset-3"> <%= form_with(model: @user, local: true) do |f| %> <%= render 'shared/error_messages' %> <%= f.label :name %> <%= f.text_field :name, class: 'form-control' %> <%= f.label :email %> <%= f.email_field :email, class: 'form-control' %> <%= f.label :password %> <%= f.password_field :password, class: 'form-control' %> <%= f.label :password_confirmation, "Confirmation" %> <%= f.password_field :password_confirmation, class: 'form-control' %> <%= f.submit "Save changes", class: "btn btn-primary" %> <% end %> <div class="gravatar_edit"> <%= gravatar_for @user %> <a href="https://gravatar.com/emails" target="_blank">change</a> </div> </div> </div>@user変数を使ったため、自動で既に編集フォームに名前やメールが入った状態になる
検証ツールでHTMLをみると
<form accept-charset="UTF-8" action="/users/1" class="edit_user" id="edit_user_1" method="post"> <input name="_method" type="hidden" value="patch" /> . . . </form>次の入力フィールドに隠し属性がある
<input name="_method" type="hidden" value="patch" />
WebブラウザはネイティブではPATCHリクエストを送信できない。
RailsはPOSTリクエストと隠しinputフィールドを利用してPATCHリクエストを偽造している。(hiddenを使っている)railsのform_with(@user)のコードは新規作成newとeditで文章が変わらないが、Railsはどうやって新規ユーザー用のPOSTリクエストとユーザー編集用のPATCHリクエストを区別するのか?
その答えは、Railsは新規ユーザーか、既存のDBにいるユーザーか、
Active Recordのnew_record?論理値メソッドを使って区別できるから。Rails Consoleで確認してみる。
$ rails console >> User.new.new_record? => true >> User.first.new_record? => false※もう少し調べてみた
調査
new_record?理論値メソッド
new_record?はDBにレコードが登録されて「いなければ」true 登録されていればfalse
→レコードが登録されていなければPOSTで送り、レコードが登録されていればPATCHを使うRailsは、form_with(@user)を使ってフォームを構成すると、@user.new_record?がtrueの時にはPOSTリクエスト、falseのときにはPATCHリクエストを使う。
仕上げに、ナビゲーションバーにあるユーザー設定へのリンクを更新する。
Usersリソースの名前付きルートである、edit_user_pathと、current_userというヘルパーメソッドを使うと、実装が簡単。
<%= link_to "Settings", edit_user_path(current_user) %>
app/views/layouts/_header.html.erb<header class="navbar navbar-fixed-top navbar-inverse"> <div class="container"> <%= link_to "sample app", root_path, id: "logo" %> <nav> <ul class="nav navbar-nav navbar-right"> <li><%= link_to "Home", root_path %></li> <li><%= link_to "Help", help_path %></li> <% if logged_in? %> <li><%= link_to "Users", '#' %></li> <li class="dropdown"> <a href="#" class="dropdown-toggle" data-toggle="dropdown"> Account <b class="caret"></b> </a> <ul class="dropdown-menu"> <li><%= link_to "Profile", current_user %></li> <li><%= link_to "Settings", edit_user_path(current_user) %></li> #passを定義 <li class="divider"></li> <li> <%= link_to "Log out", logout_path, method: :delete %> </li> </ul> </li> <% else %> <li><%= link_to "Log in", login_path %></li> <% end %> </ul> </nav> </div> </header>調査 target_blankの調査 なぜ脆弱性があるのか
https://webegins.com/target-blank/
復習 provide メソッド
viewでprovideヘルパーを利用することで、ここのテンプレートからレイアウト側にタイトルを引き渡すことができる。view/home.html.erb <% provide :title, 'タイトルの例' %> レイアウト側からは、yield メソッドを利用することで呼び出せる。 layouts/application.html.erb <title><%= yield(:title) || 'Rails入門' %></title>演習
先ほど触れたように、target="_blank"で新しいページを開くときには、セキュリティ上の小さな問題があります。それは、リンク先のサイトがHTMLドキュメントのwindowオブジェクトを扱えてしまう、という点です。具体的には、フィッシング(Phising)サイトのような、悪意のあるコンテンツを導入させられてしまう可能性があります。Gravatarのような著名なサイトではこのような事態は起こらないと思いますが、念のため、このセキュリティ上のリスクも排除しておきましょう。対処方法は、リンク用のaタグのrel(relationship)属性に、"noopener"と設定するだけです。早速、リスト 10.2で使ったGravatarの編集ページへのリンクにこの設定をしてみましょう。
rel="noopener"
を追加するだけリスト 10.5のパーシャルを使って、new.html.erbビュー(リスト 10.6)とedit.html.erbビュー(リスト 10.7)をリファクタリングしてみましょう(コードの重複を取り除いてみましょう)。ヒント: 3.4.3で使ったprovideメソッドを使うと、重複を取り除けます
→コピペするだけでOK10.1.2 編集の失敗
createアクションと同じような構造で、updateアクションを作成する
editビューのフォームから送信されたparamsハッシュを受け取り、ユーザーを更新する。
無効な情報の場合は編集ページを描写するapp/controllers/users_controller.rbclass UsersController < ApplicationController def create @user = User.new(user_params) if @user.save log_in @user flash[:success] = "Welcome to the Sample App!" redirect_to @user else render 'new' end end def update #createと構造同じ @user = User.find(params[:id]) if @user.update(user_params) # 更新に成功した場合を扱う。 else render 'edit' end end endupdateへの呼び出しで
user_params
を使っている
以前利用したStrong Parametersを使ってマスアサインメントの脆弱性を防止している(user_paramsはprivete以降に記述あり)エラーに関しても、Userモデルのバリデーションとエラーメッセージのパーシャルが既にあるため、自動でエラーメッセージを表示してくれる
演習
編集フォームから有効でないユーザー名やメールアドレス、パスワードを使って送信した場合、編集に失敗することを確認してみましょう。
→確認10.1.3 編集失敗時のテスト
テストのガイドラインに従って、エラーを検知するための統合テストを書く
まずは統合テストを生成
$ rails generate integration_test users_edit最初は編集失敗時の簡単なテストを追加していく
- まず編集ページにアクセスし、editビューが描画されるかどうかをチェック
- 無効な情報を送信してみて、editビューが再描画されるかどうかをチェック
ここで、PATCHリクエストを送るためにpatchメソッドを使っていることに注意
test/integration/users_edit_test.rbrequire 'test_helper' class UsersEditTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) end test "unsuccessful edit" do get edit_user_path(@user) #@userの編集ページを取得 assert_template 'users/edit' #editページが表示されているか patch user_path(@user), params: { user: { name: "", #おかしいparamsをpatchへ送信 email: "foo@invalid", password: "foo", password_confirmation: "bar" } } assert_template 'users/edit' #editビューが描写されているか end endこれで、テストを実行すると green
演習
リスト 10.9のテストに1行追加し、正しい数のエラーメッセージが表示されているかテストしてみましょう。ヒント: 表 5.2で紹介したassert_selectを使ってalertクラスのdivタグを探しだし、「The form contains 4 errors.」というテキストを精査してみましょう。
test "unsuccessful edit" do get edit_user_path(@user) #@userの編集ページを取得 assert_template 'users/edit' #editページが表示されているか patch user_path(@user), params: { user: { name: "", #おかしいparamsをpatchへ送信 email: "foo@invalid", password: "foo", password_confirmation: "bar" } } assert_template 'users/edit' #editビューが描写されているか assert_select "div.alert","The form contains 4 errors." end10.1.4 TDDで編集を成功させる
ユーザーの編集フォームが動作するようにする。
プロフィール画像の編集は、Gravatarで画像のアップロードも既に動作するようになっている快適にテストをするためには、アプリケーション用のコードを「実装する前に」統合テストを書いた方が便利
そのテストを「受け入れテスト(Acceptance Tests)」として呼ばれていて、ある機能の実装が完了し、受け入れ可能な状態になったかどうかを決めるテストとして知られている
同じ手法を使って(テスト駆動開発)ユーザーの編集機能を実装していくテストの内容は上のテストを参考に書いていく
- ユーザー情報を更新する正しい振る舞いをテストで定義(今回は有効な情報を送信するように修正)。
- flashメッセージが空でないかどうかと、プロフィールページにリダイレクトされるかどうかをチェック
- データベース内のユーザー情報が正しく変更されたかどうかも検証
以下のテストコードになる
test/integration/users_edit_test.rbrequire 'test_helper' class UsersEditTest < ActionDispatch::IntegrationTest test "successful edit" do get edit_user_path(@user) assert_template 'users/edit' name = "Foo Bar" email = "foo@bar.com" patch user_path(@user), params: { user: { name: name,#有効な情報 email: email, password: "", password_confirmation: "" } } assert_not flash.empty? #flashが空でないか assert_redirected_to @user #userビューページへ飛んでいるか @user.reload #データテーブルを再度読み込む assert_equal name, @user.name #設定したnameとDBのnameが一致しているか assert_equal email, @user.email endテストがパスするために、updateアクションも変更
app/controllers/users_controller.rbclass UsersController < ApplicationController . def update @user = User.find(params[:id]) if @user.update(user_params) flash[:success] = "Profile updated" redirect_to @user else render 'edit' end end . end1)テストコードのパスワードとパスワード確認が空であることに注目。
ユーザー名やメールアドレスを編集するときに毎回パスワードを入力するのは不便なので、(パスワードを変更する必要が無いときは)パスワードを入力せずに更新できると便利になる
2)また、@user.reloadを使って、データベースから最新のユーザー情報を読み込み直して、正しく更新されたかどうかを確認している点にも注目。(受け入れテストでは先にテストを書くので、効果的なユーザー体験について考えるようになる)このテストはまだ red のまま。
なぜならパスワードの長さに対するバリデーションがあるので、パスワードやパスワード確認の欄を空にしているため引っかかってしまうから。
テストがパスするためにパスワードのバリデーションに対して、空だったときの例外処理を加える必要がある
allow_nil: trueオプションを使ってvalidatesに追加する
→パスワードが空のままでも更新できるようになるapp/models/user.rbclass User < ApplicationRecord attr_accessor :remember_token before_save { self.email = email.downcase } validates :name, presence: true, length: { maximum: 50 } VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i validates :email, presence: true, length: { maximum: 255 }, format: { with: VALID_EMAIL_REGEX }, uniqueness: true has_secure_password validates :password, presence: true, length: { minimum: 6 }, allow_nil: true . . . endパスワードが空のままでも更新できるようになってしまうと、「新規ユーザー登録時に空のパスワードが有効になってしまうのか」と心配になるが、
has_secure_passwordでは(追加したバリデーションとは別に)オブジェクト生成時に存在性を検証するようになっているため、空のパスワード(nil)が新規ユーザー登録時に有効になることはない。
問題であった空のパスワードを入力すると存在性のバリデーションとhas_secure_passwordによるバリデーションがそれぞれ実行され、2つの同じエラーメッセージが表示されるというバグもこれで解決するテストはパスするはず
演習
実際に編集が成功するかどうか、有効な情報を送信して確かめてみましょう。
もしGravatarと紐付いていない適当なメールアドレス(foobar@example.comなど)に変更した場合、プロフィール画像はどのように表示されるでしょうか? 実際に編集フォームからメールアドレスを変更して、確認してみましょう。
→OK Gravatarのデフォルトの画像が表示される10.2 認可
ウェブアプリケーションでは、認証(authentication)はサイトのユーザーを識別することであり、認可(authorization)はそのユーザーが実行可能な操作を管理すること
今時点、どのユーザーでもあらゆるアクションにアクセスできるため、誰でも(ログインしていないユーザーでも)ユーザー情報を編集できてしまう。
ここでユーザーにログインを要求し、かつ自分以外のユーザー情報を変更できないように制御していく
(こういったセキュリティ上の制御機構をセキュリティモデルと呼ぶ)10.2.1 ユーザーにログインを要求する
ログインしてないユーザがURLを直接触って編集できないようにするには、Usersコントローラの中でbeforeフィルターを使う。
app/controllers/users_controller.rbclass UsersController < ApplicationController before_action :logged_in_user, only: [:edit, :update] . . . # beforeアクション # ログイン済みユーザーかどうか確認 def logged_in_user unless logged_in?#logged_inがfalseなら(logged_inはsessions_helperで定義ずみ) flash[:danger] = "Please log in." redirect_to login_url end end endデフォルトでは、beforeフィルターはコントローラ内のすべてのアクションに適用されるため、ここでは:onlyオプション(ハッシュ)を渡すことで、:editと:updateアクションだけにこのフィルタが適用されるように制限をかける
これで直接URLを触ってeditやupdateはできなくなったが、テストが古いのでテストは失敗する
原因は、ログインしていないユーザーのままのテストだからlog_in_asヘルパーを使ってテストでもログインする。
test/integration/users_edit_test.rbrequire 'test_helper' class UsersEditTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) end test "unsuccessful edit" do log_in_as(@user) #@userとしてログイン get edit_user_path(@user) . . . end test "successful edit" do log_in_as(@user) #@userとしてログイン get edit_user_path(@user) . . . end endこれでテストスイートがパスする
しかし、beforeフィルターの実装はまだ終わってないなぜならbeforeフィルターをコメントアウトしてもテストが通ってしまうから
beforeフィルターは基本的にアクションごとに適用していくので、
Usersコントローラのテストもアクションごとに書いていく。具体的には
①正しい種類のHTTPリクエストを使う
②editアクションとupdateアクションをそれぞれ実行させてみる
③flashにメッセージが代入されるかどうか検証
④ログイン画面にリダイレクトされたかどうかHTTPリクエストは、 editにはget、updateにはpatchを書く
test/controllers/users_controller_test.rbrequire 'test_helper' class UsersControllerTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) end . . . test "should redirect edit when not logged in" do get edit_user_path(@user) assert_not flash.empty? assert_redirected_to login_url end test "should redirect update when not logged in" do patch user_path(@user), params: { user: { name: @user.name, email: @user.email } } assert_not flash.empty? assert_redirected_to login_url end endコメントアウトしていた箇所を元に戻すと、テストがパスするはず
演習
デフォルトのbeforeフィルターは、すべてのアクションに対して制限を加えます。今回のケースだと、ログインページやユーザー登録ページにも制限の範囲が及んでしまうはずです(結果としてテストも失敗するはずです)。リスト 10.15のonly:オプションをコメントアウトしてみて、テストスイートがそのエラーを検知できるかどうか(テストが失敗するかどうか)確かめてみましょう。
→エラーになる10.2.2 正しいユーザーを要求する
次はユーザーが自分の情報だけを編集できるようにする。
まずはユーザーの情報が互いに編集できないことを確認するために、サンプルユーザーをもう一人追加
test/fixtures/users.ymlmichael: name: Michael Example email: michael@example.com password_digest: <%= User.digest('password') %> archer: name: Sterling Archer email: duchess@example.gov password_digest: <%= User.digest('password') %>log_in_asメソッドを使って、editアクションとupdateアクションをテストする。
このとき、既にログイン済みのユーザーを対象としているため、ログインページではなくルートURLにリダイレクトしているtest/controllers/users_controller_test.rbrequire 'test_helper' class UsersControllerTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) @other_user = users(:archer) end . . . test "should redirect edit when logged in as wrong user" do log_in_as(@other_user) #違うユーザーとしてログイン get edit_user_path(@user)#@user_pathを取得 assert flash.empty?#flashが空か確認 assert_redirected_to root_url#Homeビューへ帰るか確認 end test "should redirect update when logged in as wrong user" do log_in_as(@other_user)#違うユーザーでログイン patch user_path(@user), params: { user: { name: @user.name, #@userのparamsをcreateへ email: @user.email } } assert flash.empty?#フラッシュが空か assert_redirected_to root_url end end別のユーザーのプロフィールを編集しようとしたらリダイレクトさせたいので、correct_userというメソッドを作成し、beforeフィルターからこのメソッドを呼び出すようにする
beforeフィルターのcorrect_userで@user変数を定義しているため、editとupdateの各アクションから、@userへの代入文を削除している。app/controllers/users_controller.rbclass UsersController < ApplicationController before_action :logged_in_user, only: [:edit, :update] before_action :correct_user, only: [:edit, :update] . . . def edit end def update if @user.update(user_params) flash[:success] = "Profile updated" redirect_to @user else render 'edit' end end . . . private def user_params params.require(:user).permit(:name, :email, :password, :password_confirmation) end # beforeアクション # ログイン済みユーザーかどうか確認 def logged_in_user unless logged_in? flash[:danger] = "Please log in." redirect_to login_url end end # 正しいユーザーかどうか確認 def correct_user @user = User.find(params[:id]) redirect_to(root_url) unless @user == current_user#後にリファクタリング end end今度はテストスイートがgreen になる
最後に、一般的な慣習に倣ってcurrent_user?という論理値を返すメソッドを実装。
correct_userというbefore filterの中で使えるようにしたいので、Sessionsヘルパーの中にcorrect_userメソッドを追加します。このメソッドを使うと今までの
unless @user == current_user
といった部分が、次のようになる
unless current_user?(@user)
app/helpers/sessions_helper.rbmodule SessionsHelper # 渡されたユーザーがカレントユーザーであればtrueを返す def current_user?(user) user && user == current_user endリファクタリングしたので、users_controller.rbを書き換える
app/controllers/users_controller.rbclass UsersController < ApplicationController before_action :logged_in_user, only: [:edit, :update] before_action :correct_user, only: [:edit, :update] . . . # 正しいユーザーかどうか確認 def correct_user @user = User.find(params[:id]) redirect_to(root_url) unless current_user?(@user) end end演習
何故editアクションとupdateアクションを両方とも保護する必要があるのでしょうか? 考えてみてください。
→知らん人が勝手に他のユーザーを変えたら困るから
上記のアクションのうち、どちらがブラウザで簡単にテストできるアクションでしょうか?
→edit DB関係なくテストできる10.2.3 フレンドリーフォワーディング
例えばユーザーの編集ページに行こうとして、ログインせよと弾かれた場合、ログイン後はその編集ページに飛ばしてあげるのが親切
そういう機能を追加していく
フレンドリーフォワーディングのテストは非常にシンプルで、ログインした後に編集ページへアクセスする、という順序を逆にしてあげるだけtest/integration/users_edit_test.rbrequire 'test_helper' class UsersEditTest < ActionDispatch::IntegrationTest test "successful edit with friendly forwarding" do get edit_user_path(@user)#@userの編集ページを取得 log_in_as(@user)#ログインしてなかったからできないので@userでログイン assert_redirected_to edit_user_url(@user)#editページへリダイレクトしたか name = "Foo Bar" email = "foo@bar.com" patch user_path(@user), params: { user: { name: name,#編集 email: email, password: "", password_confirmation: "" } } assert_not flash.empty?#flashが空でないか assert_redirected_to @user#ユーザーのshowページへリダイレクトしたか @user.reload#userのDBを再取得 assert_equal name, @user.name#編集した名前とDBの名前が一致しているか assert_equal email, @user.email end endフレンドリーフォワーディングを実装し、ユーザーを希望のページに転送するには、リクエスト時点のページをどこかに保存しておき、その場所にリダイレクトさせる。
この動作をstore_locationとredirect_back_orの2つのメソッドを使って実現していくapp/helpers/sessions_helper.rbmodule SessionsHelper . . . # 記憶したURL(もしくはデフォルト値)にリダイレクト def redirect_back_or(default) redirect_to(session[:forwarding_url] || default) session.delete(:forwarding_url) #リクエストされたURLが存在する場合はそこにリダイレクトし、ない場合はデフォルトのURLにリダイレクト end # アクセスしようとしたURLを覚えておく def store_location session[:forwarding_url] = request.original_url if request.get? #リクエストが送られたURLをsession変数の:forwarding_urlキーに格納 #ただし、GETリクエストが送られたときだけ end endstore_locationメソッドでは、 リクエストが送られたURLをsession変数の:forwarding_urlキーに格納。ただし、GETリクエストが送られたときだけ。
これによって、例えばログインしていないユーザーがフォームを使って送信した場合、転送先のURLを保存させないようにできる
滅多に起きないが起こり得る話で、例えばユーザがセッション用のcookieを手動で削除してフォームから送信するケースなどで、POSTや PATCH、DELETEリクエストを期待しているURLに対して、(リダイレクトを通して)GETリクエストが送られてしまい、場合によってはエラーが発生する。このため、if request.get?という条件文を使ってこのケースに対応定義したstore_locationメソッドをbeforeフィルター(logged_in_user)をに加える
app/controllers/users_controller.rbclass UsersController < ApplicationController before_action :logged_in_user, only: [:edit, :update] before_action :correct_user, only: [:edit, :update] . # beforeアクション # ログイン済みユーザーかどうか確認 def logged_in_user unless logged_in? store_location flash[:danger] = "Please log in." redirect_to login_url end end endフォワーディング自体を実装するには、redirect_back_orメソッドを使い、
リクエストされたURLが存在する場合はそこにリダイレクトし、ない場合は何らかのデフォルトのURLにリダイレクトさせる
デフォルトのURLは、Sessionコントローラのcreateアクションに追加し、サインイン成功後にリダイレクトredirect_back_orメソッドでは、次のようにor演算子||を使う
session[:forwarding_url] || defaultこのコードは、値がnilでなければsession[:forwarding_url]を評価し、そうでなければデフォルトのURLを使う
またsession.delete(:forwarding_url)という行を通して転送用のURLを削除している点にも注意。
削除しないと次回ログインしたときに保護されたページに転送されてしまい、ブラウザを閉じるまでこれが繰り返されてしまうapp/controllers/sessions_controller.rbclass SessionsController < ApplicationController . . . def create user = User.find_by(email: params[:session][:email].downcase) if user && user.authenticate(params[:session][:password]) log_in user params[:session][:remember_me] == '1' ? remember(user) : forget(user) redirect_back_or user else flash.now[:danger] = 'Invalid email/password combination' render 'new' end end . . . endこれでテストもパスし、認可も終わり
演習
フレンドリーフォワーディングで、渡されたURLに初回のみ転送されていることを、テストを書いて確認してみましょう。次回以降のログインのときには、転送先のURLはデフォルト(プロフィール画面)に戻っている必要があります。ヒント: リスト 10.29のsession[:forwarding_url]が正しい値かどうか確認するテストを追加してみましょう。
users_edit_test.rbtest "successful edit with friendly forwarding" do get edit_user_path(@user) # @userのユーザー編集ページを取得 assert_equal session[:forwarding_url], edit_user_url(@user) # 渡されたURLに転送されているか確認 log_in_as(@user) # @userでログイン assert_nil session[:forwarding_url] # forwarding_urlの値がnilならtrue(deleteが効いてる) name = "Foo Bar" # フォーム欄に値を入力する email = "foo@bar.com" patch user_path(@user), params: { user: { name: name, # 引数としてわざと失敗する値を持ったuserIDをpatchリクエストで送信(更新)する email: email, password: "", password_confirmation: "" } } assert_not flash.empty? # エラー文が空じゃなければtrue assert_redirected_to @user # michaelのユーザーidページへ移動できたらtrue @user.reload assert_equal name, @user.name # DB内の名前と@userの名前が一致していていたらtrue assert_equal email, @user.email # DB内のEmailと@userの名前が一致 end7.1.3で紹介したdebuggerメソッドをSessionsコントローラのnewアクションに置いてみましょう。その後、ログアウトして /users/1/edit にアクセスしてみてください(デバッガーが途中で処理を止めるはずです)。ここでコンソールに移り、session[:forwarding_url]の値が正しいかどうか確認してみましょう。また、newアクションにアクセスしたときのrequest.get?の値も確認してみましょう(デバッガーを使っていると、ときどき予期せぬ箇所でターミナルが止まったり、おかしい挙動を見せたりします。熟練の開発者になった気になって(コラム 1.2)、落ち着いて対処してみましょう)。
→bootstrapがうまく動かなかったので割愛10.3 すべてのユーザーを表示する
ユーザーの一覧ページ(indexページ)を作る
ページネーションも学ぶ10.3.1 ユーザーの一覧ページ
まずはセキュリティモデルから考える
ユーザーのshowページは、サイトを訪れたすべてのユーザーから見えるようにしておくが、
ユーザーのindexページはログインしたユーザーにしか見せないようにし、未登録のユーザーがデフォルトで表示できるページを制限していくindexページを不正なアクセスから守るために、まずはindexアクションが正しくリダイレクトするか検証するテストを書いていく(テスト駆動開発)
test/controllers/users_controller_test.rbrequire 'test_helper' class UsersControllerTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) @other_user = users(:archer) end test "should get new" do get signup_path assert_response :success end test "should redirect index when not logged in" do get users_path #indexページへのパスを取得 assert_redirected_to login_url#ログインページまで戻るか end . . . end次に、beforeフィルターのlogged_in_userにindexアクションを追加して、このアクションを保護
app/controllers/users_controller.rbclass UsersController < ApplicationController before_action :logged_in_user, only: [:index, :edit, :update]#indexを追加 before_action :correct_user, only: [:edit, :update] def index end def show @user = User.find(params[:id]) end . . . end今度はすべてのユーザーを表示するために、全ユーザーが格納された変数を作成し、順々に表示するindexビューを実装
User.allを使ってデータベース上の全ユーザーを取得し、ビューで使えるインスタンス変数@usersに代入させる(データの読み込みの問題はあるが後で直す)app/controllers/users_controller.rbdef index @users = User.all#全てのuserを取得 endeachメソッドを使って,ユーザーごとにliタグで囲むビューを作成する
それぞれの行をリストタグulで囲いながら、各ユーザーのGravatarと名前を表示app/views/users/index.html.erb<% provide(:title, 'All users') %> <h1>All users</h1> <ul class="users"> <% @users.each do |user| %> <li> <%= gravatar_for user, size: 50 %> <%= link_to user.name, user %> </li> <% end %> </ul>app/helpers/users_helper.rbmodule UsersHelper # 渡されたユーザーのGravatar画像を返す def gravatar_for(user, options = { size: 80 }) size = options[:size] gravatar_id = Digest::MD5::hexdigest(user.email.downcase) gravatar_url = "https://secure.gravatar.com/avatar/#{gravatar_id}?s=#{size}" image_tag(gravatar_url, alt: user.name, class: "gravatar") end endCSSも修正
app/assets/stylesheets/custom.scss. . . /* Users index */ .users { list-style: none; margin: 0; li { overflow: auto; padding: 10px 0; border-bottom: 1px solid $gray-lighter; } }ヘッダーにユーザー一覧表示用のリンクを追加(users_path)
app/views/layouts/_header.html.erb<header class="navbar navbar-fixed-top navbar-inverse"> <div class="container"> <%= link_to "sample app", root_path, id: "logo" %> <nav> <ul class="nav navbar-nav navbar-right"> <li><%= link_to "Home", root_path %></li> <li><%= link_to "Help", help_path %></li> <% if logged_in? %> <li><%= link_to "Users", users_path %></li>#追加 <li class="dropdown"> <a href="#" class="dropdown-toggle" data-toggle="dropdown"> Account <b class="caret"></b> </a> <ul class="dropdown-menu"> <li><%= link_to "Profile", current_user %></li> <li><%= link_to "Settings", edit_user_path(current_user) %></li> <li class="divider"></li> <li> <%= link_to "Log out", logout_path, method: :delete %> </li> </ul> </li> <% else %> <li><%= link_to "Log in", login_path %></li> <% end %> </ul> </nav> </div> </header>これでテストgreenに
演習
レイアウトにあるすべてのリンクに対して統合テストを書いてみましょう。ログイン済みユーザーとそうでないユーザーのそれぞれに対して、正しい振る舞いを考えてください。ヒント: log_in_asヘルパーを使ってリスト 5.32にテストを追加してみましょう。
site_layout_test.rbtest "layout links login" do log_in_as(@user) get root_path assert_select "a[href=?]", root_path, count: 2 assert_select "a[href=?]", help_path assert_select "a[href=?]", about_path assert_select "a[href=?]", contact_path assert_select "a[href=?]", signup_path assert_select "a[href=?]", users_path assert_select "a[href=?]", user_path(@user) assert_select "a[href=?]", edit_user_path(@user) assert_select "a[href=?]", logout_path end10.3.2 サンプルのユーザー
Rubyを使ってユーザーを一気に増やす
まず、GemfileにFaker gemを追加
これは、実際にいそうなユーザー名を作成するgem
faker gemは開発環境以外では普通使わないが、今回は例外的に本番環境でも適用させる予定source 'https://rubygems.org' gem 'rails', '6.0.3' gem 'bcrypt', '3.1.13' gem 'faker', '2.1.2' gem 'will_paginate', '3.1.8' gem 'bootstrap-will_paginate', '1.0.0'$ bundle installサンプルユーザーを生成するRubyスクリプト(Railsタスクとも呼ぶ)を追加
db/seeds.rb# メインのサンプルユーザーを1人作成する User.create!(name: "Example User", email: "example@railstutorial.org", password: "foobar", password_confirmation: "foobar") # 追加のユーザーをまとめて生成する 99.times do |n| name = Faker::Name.name email = "example-#{n+1}@railstutorial.org" password = "password" User.create!(name: name, email: email, password: password, password_confirmation: password) endこれでExample Userという名前とメールアドレスを持つ1人のユーザと、それらしい名前とメールアドレスを持つ99人のユーザーを作成できる
データベースをリセットして、Railsタスクを実行(db:seed)
$ rails db:migrate:reset $ rails db:seedシステムによっては数分かかる
(rails serverは止めたほうがいい)終わるとユーザーが100人になってる
演習
試しに他人の編集ページにアクセスしてみて、10.2.2で実装したようにリダイレクトされるかどうかを確かめてみましょう。
→確認10.3.3 ページネーション
今100このアカウントが1つのページに表示されてしまっているので、ページネーション(pagination)を実装して1つのページに30人だけユーザーを表示する
Railsには豊富なページネーションメソッドがあるが、最もシンプルかつ堅牢なwill_paginateメソッドを使っていく
まずはGemfileにwill_paginate gem
とbootstrap-will_paginate gem
を両方含め、Bootstrapのページネーションスタイルを使ってwill_paginateを構成するsource 'https://rubygems.org' gem 'rails', '6.0.3' gem 'bcrypt', '3.1.13' gem 'faker', '2.1.2' gem 'will_paginate', '3.1.8' gem 'bootstrap-will_paginate', '1.0.0' .$ bundle install実行したら、新しいgemが正しく読み込まれるように、Webサーバーを再起動
indexビューにページネーションのコードを追加する
またindexアクションにあるUser.allを、ページネーションを理解できるオブジェクトに置き換える必要もあるapp/views/users/index.html.erb<% provide(:title, 'All users') %> <h1>All users</h1> <%= will_paginate %> <ul class="users"> <% @users.each do |user| %> <li> <%= gravatar_for user, size: 50 %> <%= link_to user.name, user %> </li> <% end %> </ul> <%= will_paginate %>will_paginateメソッドは,usersビューのコードの中から@usersオブジェクトを自動的に見つけ出し、それから他のページにアクセスするためのページネーションリンクを作成してくれる。ただし、このままでは動かず、
will_paginateではpaginateメソッドを使った結果が必要のため必要なpagineteメソッドの動きはこんな感じ
$ rails console >> User.paginate(page: 1) User Load (1.5ms) SELECT "users".* FROM "users" LIMIT 11 OFFSET 0 (1.7ms) SELECT COUNT(*) FROM "users" => #<ActiveRecord::Relation [#<User id: 1,... >> User.paginate(page: 1).length User Load (3.0ms) SELECT "users".* FROM "users" LIMIT ? OFFSET ? [["LIMIT", 30], ["OFFSET", 0]] => 30paginateでは、キーが:pageで値がページ番号のハッシュを引数に取る
User.paginateは、:pageパラメーターに基いて、データベースからひとかたまりのデータ(デフォルトでは30)を取り出す
つまり1ページ目は1から30のユーザー、2ページ目は31から60のユーザーといった具合にデータが取り出される。
pageがnilの場合、 paginateは単に最初のページを返すindexアクション内のallをpaginateメソッドに置き換えると使えるようになる
app/controllers/users_controller.rbclass UsersController < ApplicationController before_action :logged_in_user, only: [:index, :edit, :update] . def index @users = User.paginate(page: params[:page]) end環境によってはRailsを再起動する必要があるが、これでページネーションが動くようになった
演習
Railsコンソールを開き、pageオプションにnilをセットして実行すると、1ページ目のユーザーが取得できることを確認してみましょう。
先ほどの演習課題で取得したpaginationオブジェクトは、何クラスでしょうか? また、User.allのクラスとどこが違うでしょうか? 比較してみてください。
→確認ずみ>> user = User.all >> page = User.paginate(page: 1) >> user.class => User::ActiveRecord_Relation >> page.class => User::ActiveRecord_Relation10.3.4 ユーザー一覧のテスト
ページネーションに対する簡単なテストも書いておく
1 ログイン
2indexページにアクセス
3最初のページにユーザーがいることを確認
4ページネーションのリンクがあることを確認
といった順でテスト
3,4のステップでは、テスト用のデータベースに31人以上のユーザーがいる必要があるそのためfixtureファイルに30人のユーザーを追加する(rubyもサポートされている)
test/fixtures/users.ymlmichael: name: Michael Example email: michael@example.com password_digest: <%= User.digest('password') %> archer: name: Sterling Archer email: duchess@example.gov password_digest: <%= User.digest('password') %> lana: name: Lana Kane email: hands@example.gov password_digest: <%= User.digest('password') %> malory: name: Malory Archer email: boss@example.gov password_digest: <%= User.digest('password') %> <% 30.times do |n| %> user_<%= n %>: name: <%= "User #{n}" %> email: <%= "user-#{n}@example.com" %> password_digest: <%= User.digest('password') %> <% end %>indexページ用の統合テストを生成
$ rails generate integration_test users_indexテストでは、paginationクラスを持ったdivタグをチェックして、最初のページにユーザーがいることを確認
test/integration/users_index_test.rbrequire 'test_helper' class UsersIndexTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) end test "index including pagination" do log_in_as(@user) get users_path assert_template 'users/index' assert_select 'div.pagination' User.paginate(page: 1).each do |user| assert_select 'a[href=?]', user_path(user), text: user.name end end endテストはパスし、ページネーションのテストは完了
演習
試しにリスト 10.45にあるページネーションのリンク(will_paginateの部分)を2つともコメントアウトしてみて、リスト 10.48のテストが red に変わるかどうか確かめてみましょう。
→確認
先ほどは2つともコメントアウトしましたが、1つだけコメントアウトした場合、テストが green のままであることを確認してみましょう。will_paginateのリンクが2つとも存在していることをテストしたい場合は、どのようなテストを追加すれば良いでしょうか? ヒント: 表 5.2を参考にして、数をカウントするテストを追加してみましょう。
→assert_select 'div.pagination', count: 2
でpaginationクラスのdivが2つ存在することを確認する10.3.5 パーシャルのリファクタリング
リファクタリングしていく
まずは、呼び出し側のrender user
をセットapp/views/users/index.html.erb<% provide(:title, 'All users') %> <h1>All users</h1> <%= will_paginate %> <ul class="users"> <% @users.each do |user| %> <%= render user %> <% end %> </ul> <%= will_paginate %>renderをパーシャル(ファイル名の文字列)に対してではなく、Userクラスのuser変数に対して実行している
この場合、Railsは自動的に_user.html.erbという名前のパーシャルを探しにいくので、このパーシャルを作成するapp/views/users/_user.html.erb<li> <%= gravatar_for user, size: 50 %> <%= link_to user.name, user %> </li>さらに改良
renderを@users変数に対して直接実行するapp/views/users/index.html.erb<% provide(:title, 'All users') %> <h1>All users</h1> <%= will_paginate %> <ul class="users">#@userにし、eachを削除した <%= render @users %> </ul> <%= will_paginate %>Railsは@users をUserオブジェクトのリストであると推測し、ユーザーのコレクションを与えて呼び出すと、Railsは自動的にユーザーのコレクションを列挙し、それぞれのユーザーを_user.html.erbパーシャルで出力するようになる
演習
リスト 10.52にあるrenderの行をコメントアウトし、テストの結果が red に変わることを確認してみましょう。
→確認10.4 ユーザーを削除する
ユーザーの一覧ページはOK
残るはdestroyを実装
ユーザーを削除するためのリンクを追加しdestroyアクションも実装
その前に、削除を実行できる権限を持つ管理(admin)ユーザーのクラスを作成する
承認(authorization)においては、このような特権のセットをroleと呼ぶ10.4.1 管理ユーザー
論理値をとるadmin属性をUserモデルに追加し、管理ユーザーを識別する
Userモデルに追加することで、自動的にadmin?メソッド(論理値を返す)も使えるようになる$ rails generate migration add_admin_to_users admin:booleandefault: falseをマイグレーションファイルへ渡す
渡さなくてもnilになり、問題ないが、明示的にfalseにしておくことでrailsと開発者にわかりやすくなるdb/migrate/[timestamp]_add_admin_to_users.rbclass AddAdminToUsers < ActiveRecord::Migration[6.0] def change add_column :users, :admin, :boolean, default: false end end$ rails db:migrateRailsコンソールで動作を確認
$ rails console --sandbox >> user = User.first >> user.admin? #adminメソッドも使えるように => false >> user.toggle!(:admin)#toggle!メソッドはfalseからtrueに反転させる => true >> user.admin? => true最初のユーザーだけをデフォルトで管理者にする
db/seeds.rb# メインのサンプルユーザーを1人作成する User.create!(name: "Example User", email: "example@railstutorial.org", password: "foobar", password_confirmation: "foobar", admin: true)#true # 追加のユーザーをまとめて生成する 99.times do |n| name = Faker::Name.name email = "example-#{n+1}@railstutorial.org" password = "password" User.create!(name: name, email: email, password: password, password_confirmation: password) endデータベースをリセット
$ rails db:migrate:reset $ rails db:seedStrong Parametersの復習
最初のユーザーに初期化ハッシュにadmin: trueを設定することでユーザーを管理者にしているここでは、荒れ狂うWeb世界にオブジェクトを晒すことの危険性を改めて強調している。
もし、任意のWebリクエストの初期化ハッシュをオブジェクトに渡せるとなると、攻撃者は次のようなPATCHリクエストを送信してくるかもしれない。
patch /users/17?admin=1
このリクエストは、17番目のユーザーを管理者に変えてしまう。
ユーザーのこの行為は少なくとも重大なセキュリティ違反となる可能性があるし、それだけでは済まされない。このような危険があるからこそ、編集してもよい安全な属性だけを更新することが重要になる。
これを、Strong Parametersを使って対策
次のように、paramsハッシュに対してrequireとpermitを呼び出す。
def user_params params.require(:user).permit(:name, :email, :password, :password_confirmation)上のコードでは、許可された属性リストにadminが含まれていないことに注目。
これにより、任意のユーザーが自分自身にアプリケーションの管理者権限を与えることを防止できる。
この問題は重大であるため、編集可能になってはならない属性に対するテストを作成してみる。演習
Web経由でadmin属性を変更できないことを確認してみましょう。具体的には、リスト 10.56に示したように、PATCHを直接ユーザーのURL(/users/:id)に送信するテストを作成してみてください。テストが正しい振る舞いをしているかどうか確信を得るために、まずはadminをuser_paramsメソッド内の許可されたパラメータ一覧に追加するところから始めてみましょう。最初のテストの結果は red になるはずです。最後の行では、更新済みのユーザー情報をデータベースから読み込めることを確認します
test/controllers/users_controller_test.rbrequire 'test_helper' class UsersControllerTest < ActionDispatch::IntegrationTest test "should not allow the admin attribute to be edited via the web" do log_in_as(@other_user) #違うユーザーてログイン assert_not @other_user.admin? #違うユーザーにadmin属性がないことを期待 patch user_path(@other_user), params: { user: { password: "password", password_confirmation: "password", admin: true } } assert_not @other_user.reload.admin? end10.4.2 destroyアクション
destroyアクションへのリンクを追加
ユーザーindexページの各ユーザーに削除用のリンクを追加し、続いて管理ユーザーへのアクセスを制限していく
これによって、現在のユーザーが管理者のときに限り [delete] リンクが表示されるapp/views/users/_user.html.erb<li> <%= gravatar_for user, size: 50 %> <%= link_to user.name, user %> <% if current_user.admin? && !current_user?(user) %> | <%= link_to "delete", user, method: :delete, data: { confirm: "You sure?" } %> <% end %> </li>
method: :delete
に注意
また、各リンクをif文で囲い、管理者にだけ削除リンクが表示されるようにしているブラウザはネイティブではDELETEリクエストを送信できないので、RailsではJavaScriptを使って偽造
つまり、JavaScriptがオフになっているとユーザー削除のリンクも無効になる削除リンクが動作するためには、destroyアクションを追加する
destroyアクションは
1該当するユーザーを見つけてActive Recordのdestroyメソッドを使って削除
2ユーザーindexに移動app/controllers/users_controller.rbclass UsersController < ApplicationController before_action :logged_in_user, only: [:index, :edit, :update, :destroy]#loginしないとできないように before_action :correct_user, only: [:edit, :update] . . . def destroy User.find(params[:id]).destroy flash[:success] = "User deleted" redirect_to users_url endまだ終わっておらず、このままではコマンドラインでDELETEリクエストを直接発行するという方法でサイトの全ユーザーを削除してしまうことができてしまう
なのでdestroyアクションへのアクセスに制限をかけるbeforeフィルターを使う
app/controllers/users_controller.rbclass UsersController < ApplicationController before_action :logged_in_user, only: [:index, :edit, :update, :destroy] before_action :correct_user, only: [:edit, :update] before_action :admin_user, only: :destroy . . . private . . . # 管理者かどうか確認 def admin_user redirect_to(root_url) unless current_user.admin?#adminがfalseならrootに返す end end演習
管理者ユーザーとしてログインし、試しにサンプルユーザを2〜3人削除してみましょう。ユーザーを削除すると、Railsサーバーのログにはどのような情報が表示されるでしょうか?
10.4.3 ユーザー削除のテスト
ユーザー用fixtureファイルを修正し、今いるサンプルユーザーの一人を管理者にしてテストを書いていく
test/fixtures/users.ymlmichael: name: Michael Example email: michael@example.com password_digest: <%= User.digest('password') %> admin: true archer: name: Sterling Archer email: duchess@example.gov password_digest: <%= User.digest('password') %> lana: name: Lana Kane email: hands@example.gov password_digest: <%= User.digest('password') %> malory: name: Malory Archer email: boss@example.gov password_digest: <%= User.digest('password') %> <% 30.times do |n| %> user_<%= n %>: name: <%= "User #{n}" %> email: <%= "user-#{n}@example.com" %> password_digest: <%= User.digest('password') %> <% end %>Usersコントローラをテストするために、アクション単位でアクセス制御をテストする
ログアウトのテストと同様に、削除をテストするために、DELETEリクエストを発行してdestroyアクションを直接動作させる
このとき2つのケースをチェック
1つは、ログインしていないユーザーであれば、ログイン画面にリダイレクトされること
1つは、ログイン済みではあっても管理者でなければ、ホーム画面にリダイレクトされることtest/controllers/users_controller_test.rbrequire 'test_helper' class UsersControllerTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) @other_user = users(:archer) end . test "should redirect destroy when not logged in" do assert_no_difference 'User.count' do #User数が変わらないことを確認 delete user_path(@user) #ログインしてない状態で@userを削除 end assert_redirected_to login_url end test "should redirect destroy when logged in as a non-admin" do log_in_as(@other_user) assert_no_difference 'User.count' do #User数が変わらないことを確認 delete user_path(@user) #adminがない状態で削除 end assert_redirected_to root_url end end
assert_no_differenceメソッド
でユーザー数が変化しないことを確認管理者ではないユーザーの振る舞いについて検証するが、管理者ユーザーの振る舞いと一緒に確認できるといい。
そこで、管理者であればユーザー一覧画面に削除リンクが表示される仕様を利用して、今回のテストを追加していくことにする。これにより、後ほど追加する管理者の振る舞いについても簡単にテストが書けそう。
今回のテストで唯一の手の込んだ箇所は、管理者が削除リンクをクリックしたときに、
ユーザーが削除されたことを確認する部分。assert_difference 'User.count', -1 do delete user_path(@other_user) endユーザーが削除されたことを確認
DELETEリクエストを適切なURLに向けて発行し、User.countを使ってユーザー数が 1減ったかどうかを確認まとめるとこうなる
test/integration/users_index_test.rbrequire 'test_helper' class UsersIndexTest < ActionDispatch::IntegrationTest def setup @admin = users(:michael) @non_admin = users(:archer) end test "index as admin including pagination and delete links" do log_in_as(@admin) #adminがある状態でログイン get users_path #indexを取得 assert_template 'users/index' #indexページが表示されるか assert_select 'div.pagination' #pagenationがあるか first_page_of_users = User.paginate(page: 1)#1ページ目のユーザを代入 first_page_of_users.each do |user| #それぞれにリンクなどがあるか確認 assert_select 'a[href=?]', user_path(user), text: user.name unless user == @admin #admin以外のuserにdeleteがあるか確認 assert_select 'a[href=?]', user_path(user), text: 'delete' end end assert_difference 'User.count', -1 do #admin以外のユーザを削除したらUserの数が1減るか delete user_path(@non_admin) end end test "index as non-admin" do log_in_as(@non_admin) #non_adminとしてログイン get users_path #indexページ取得 assert_select 'a', text: 'delete', count: 0 #deleteが1つもないか確認 end end各ユーザーの削除リンクをテストするときに、ユーザーが管理者であればスキップしている点にも注目
これで、削除に関するコードに対して、よくテストできている状態になった
演習
試しにリスト 10.59にある管理者ユーザーのbeforeフィルターをコメントアウトしてみて、テストの結果が red に変わることを確認してみましょう。
→確認10.5 最後に
次の章に進む前に、すべての変更をmasterブランチにマージ
$ git add -A $ git commit -m "Finish user edit, update, index, and destroy actions" $ git checkout master $ git merge updating-users $ git push アプリケーションを本番展開したり、サンプルデータを本番データとして作成することもできます(本番データベースをリセットするにはpg:resetタスクを使います)。 $ rails test $ git push heroku $ heroku pg:reset DATABASE $ heroku run rails db:migrate $ heroku run rails db:seed※注意 ローカル環境でやっていると
git push後のtestで
paninateメソッドが定義されてないとかいうエラーが発生しましたググっても
https://qiita.com/LotK/items/f49a1df5c9d9a510baa2
こういったエラーではなく、おかしかったのですが、
しばらく放置したら直りました。良くわかりません。
今までテストがパスしてたのに、いきなりエラーになった場合はしばらく放置するのも手かと
- 投稿日:2021-02-25T14:19:49+09:00
ダミーデータを条件付きで表示/非表示にする方法
はじめに
本記事は製品情報などのダミーデータを、DBにデータが保存されているかいないかで表示/非表示にする方法を解説するものです。
考えてみれば難しいことではないのですが、とても悩んで時間を使ってしまったので、今後のために残しておくことと、同じようなエラーに遭遇した方の助けになれば良いと思い、記事にしました。環境
- 言語:Ruby
- フレームワーク:Rails
- DB:MySQL
やりたかったこと
DBに保存済みのレコードをトップページに表示し、DBに何も保存されていなければダミーデータの製品を表示させる。
該当箇所のコード(コントローラー)
class ItemsController < ApplicationController before_action :authenticate_user!, only: [:new, :create] def index @items = Item.all.order('created_at DESC') end該当箇所のコード(ビューファイル)
<% if @items == nil %> #製品を表示させるための処理(今回は割愛) <% end %>結果・・・・・
エラーは発生しないものの、ダミーデータが表示されない。。。
製品情報をDBに保存してみたところ、保存した製品自体は表示されるので、DBのデータを取得してトップページに反映させる処理自体は問題ない様子。原因
if文の条件式に
nil
を指定しましたが、if文がDB内を「空」と判断してくれなかった様子解決策①
if文の条件式部分を下記のコードに変更 (配列の要素を指定する)
<% if @items[0] == nil %>nilに対し配列の1つ目、すなわち
[0]
を指定することにより、if文が「配列の1つ目が空」=「DBにデータは保存されていない」という判断をしてくれます。
これでDBにデータが保存されている時は保存されたデータを表示し、DBにデータが保存されていなければダミーデータを表示することができました。解決策②
if文の条件式部分を下記のコードに変更 (empty?メソッドを用いる)
<% if @items.empty %>emptyメソッドは要素が0の時にtrueを返すメソッドです。
これを条件式に指定することにより、DBに解決策①と同様の挙動をしてくれました。さいごに
nilは条件指定で頻繁に使いますが、今回はそこに頭が集中するあまり他の方法論を検討していなかったのが時間を使い過ぎた原因と思っています。
一つの書き方が完答、というわけではありませんし、柔軟に考えるためにも様々な書き方を試してみるのも大事だなと感じました。
- 投稿日:2021-02-25T12:54:04+09:00
Rails環境にESlintとSaddlerでassets/javascriptsを自動でチェックさせる仕組みを作った話
こんにちは
Webアプリケーションでは実装初期にフロントエンド周りの設計やコーディングルールの取り決めをしなかったことによりレガシーとなってしまったJavaScript達に目を向けなければならない時があると思います。
今回はそんなレガシーなJavaScript達と向き合うために作った仕組みについて書いていきます。肥大化してしまったレガシーJavaScript達を1ファイルずつ確認して直していくのは流石に大変なので、まずはレガシー化が進行しないようにする仕組みを作る必要がありました。
やったこと
- git diff から追加 / 変更したファイル名と行数を取得する
- 1で取得したファイルに対してEslintを実行する
- 2の結果から1で取得した行数の指摘だけ残すようにXMLを書き換える
- 3のXMLを使ってPull Requestに指摘内容をコメントする
確認環境
- Rails:5.2.4
- node:14.2.0
- ESlint:6.8.0
- Saddler:1.0.0
- CircleCI
GitHubとCircleCIを連動させているのでルートの
.circleci/
にスクリプトを実装していきます。.circleci/bin/run-eslint.js
#!/usr/bin/env node import * as commander from 'commander'; import * as child_process from 'child_process'; import * as gitDiff from '../src/javascripts/gitDiff.js' import * as lint from '../src/javascripts/lint.js' const command = commander.default; const exec = child_process.exec; command .version('0.1.0') .option('-b, --base <branch name>', 'Base branch', 'origin/master') .option('-t, --target <branch name>', 'Target branch') .parse(process.argv); const args = { baseBranch: command.base, targetBranch: command.target } /** * スクリプトのメイン処理 * * @return void */ const main = () => { exec(gitDiff.getCommand(args), (err, stdout, stderr) => { if (err) { console.log(err); process.exit(); } lint.run(args, gitDiff.getAffectedLines(stdout)); }); } main();mainのスクリプトはこんな感じです。
このmainスクリプトがルートとなって各処理を実行していきます。
1. git diff から追加 / 変更したファイル名と行数を取得する
.circleci/src/javascripts/gitDiff.js
/** * git diffコマンド文字列を返す * * @args {{baseBranch: string, targetBranch: string}} run-eslint.jsに渡された引数群 * @isNameOnly {boolean} --name-onlyオプションを使うかどうかのフラグ * @return {string} git diff コマンド文字列 */ export const getCommand = (args, isNameOnly = false) => { const optionNameOnly = isNameOnly ? '--name-only ': ''; const toBranch = args.targetBranch ? `...${args.targetBranch}`: ''; return `git --no-pager diff --diff-filter=AM ${optionNameOnly}${args.baseBranch}${toBranch}`; } /** * git diffの結果から追加 / 変更のあった行番号を配列にして返す * * @stdout {boolean} git diff の出力結果 * @return {Array} 差分ファイル毎の変更行配列 */ export const getAffectedLines = (stdout) => { let path; let line; let affectedLines = {}; stdout.split("\n").forEach(el => { if (el.startsWith('---')) { return; } else if (el.startsWith('+++')) { path = el.replace('+++ b/', ''); } else if (el.startsWith('@@')) { var start = el.indexOf("-"); var end = el.indexOf(","); line = el.slice(start + 1, end); } else { switch (el[0]) { case '+': if (affectedLines[path]) { affectedLines[path].push(line); } else { affectedLines[path] = [line]; } case ' ': line++; break; case '-': default: break; } } }) return affectedLines; }
getAffectedLines
でgit diff ***
の結果から追加 / 変更したファイル名と行数を取得します。2. 1で取得したファイルに対してEslintを実行する
.circleci/src/javascripts/lint.js
import * as child_process from 'child_process'; import * as xmldom from 'xmldom'; import * as xmlSerializer from 'xmlserializer'; import * as gitDiff from './gitDiff.js' const exec = child_process.exec; const DOMParser = xmldom.default.DOMParser; const XMLSerializer = xmlSerializer.default; const cwd = process.cwd(); /** * lintの結果から変更行配列に存在しない行を取り除いた結果を出力する * * @args {{baseBranch: string, targetBranch: string}} run-eslint.jsに渡された引数群 * @affectedLines {Array} gitDiff.getAffectedLinesから取得した変更行配列 * @return void */ export const run = (args, affectedLines) => { exec(getLintCommand(args), (err, stdout, stderr) => { let doc = new DOMParser().parseFromString(stdout); if (!stdout) return; const fileNodes = doc.documentElement.getElementsByTagName('file'); Array.prototype.forEach.call(fileNodes, fileNode => { filterToLintResult(fileNode, affectedLines); if (!fileNode.childNodes.length) { doc.documentElement.removeChild(fileNode); } }); console.log(XMLSerializer.serializeToString(doc.documentElement)); }); } /** * lintの結果から変更行配列に存在しない行を取り除く * * @fileNode {Object} ESlintの結果から生成したXMLDOMDocument * @affectedLines {Array} gitDiff.getAffectedLinesから取得した変更行配列 * @return void */ const filterToLintResult = (fileNode, affectedLines) => { let childCount = fileNode.childNodes.length; const fileName = fileNode.getAttribute('name').replace(cwd + '/', ''); for (let i= 1; i <= childCount; ++i) { var childNode = fileNode.childNodes[i - 1]; var lineNumber = Number(childNode.getAttribute('line')); if (!affectedLines[fileName].includes(lineNumber)) { fileNode.removeChild(childNode); i = 0; childCount = fileNode.childNodes.length; } } } /** * git diff コマンドにlintコマンドを付与したコマンド文字列を取得する * * @args {{baseBranch: string, targetBranch: string}} run-eslint.jsに渡された引数群 * @return {string} */ const getLintCommand = (args) => { return gitDiff.getCommand(args, true) + '| grep .js | grep -v .json | xargs ./node_modules/eslint/bin/eslint.js -f checkstyle'; }
filterToLintResult
で変更行以外の結果をXMLから削除します。eslintに適用するルールは通常通りルートに.eslintrc.jsonを置いてやれば読み込んでくれます。
.eslintrc.json
{ "env": { "browser": true, "es6": false, "jquery": true }, "extends": "eslint:recommended", "globals": { "Atomics": "readonly", "SharedArrayBuffer": "readonly" }, "parserOptions": { "ecmaVersion": 2018 }, "plugins": [ "jquery", "es5" ], "rules": { "indent": "error", "linebreak-style": [ "error", "unix" ], "es5/no-arrow-functions": "error" } }今回導入するアプリケーションではjQueryのコードも多く残ってるのでjQueryプラグインを使ってます。
これをしないと
$("#selector")
みたいな記述がうまく読み込めないのです。あとはIEでES6の書き方の一部が動かない物があるのでES5準拠でチェックするようにしています。
3のXMLを使ってPull Requestに指摘内容をコメントする
.circleci/bin/run-eslint.sh
#!/bin/bash set -v ./.circleci/bin/run-eslint.js | saddler report --require saddler/reporter/github --reporter Saddler::Reporter::Github::PullRequestReviewComment exit 0あとはmainのスクリプトの実行結果をSaddlerに渡してあげればEslintの指摘をPull Requestにコメントしてくれます。
上記まででcircleCIで実行されるチェックスクリプトが用意できたので試し元々設置してあったsampleファイルに追記してcommitしてみます。
app/javascripts/test.jsconst testFnc = () => { console.log('test'); }; // ここから追記 const testFnc2 = () => { console.log('test2'); };するとcircleCIでビルドされてこのように追加した行のみGitHubコメントで指摘してくれます。
- 投稿日:2021-02-25T12:45:57+09:00
Railsのfind_eachやfind_in_batchesでorderにid以外を指定したい場合の解決方法
はじめに
大量のデータにアクセスして処理を行う場合、
- メモリ不足で処理が中断されないよう、少しずつメモリに展開したい
- 途中で処理が中断されても問題ないよう、一定件数ごとにコミットをしたい
と考えることがあると思います。
そんなときにRailsで役に立つのがfind_eachやfind_in_batchesですね。
ただしこの2つのメソッドには弱点があり、id(主キー/primary key)の昇順(ASC)でしかデータを扱うことができません。
※Rails v6.1.0時点での情報です。
# NOTE: Order can be ascending (:asc) or descending (:desc). It is automatically set to # ascending on the primary key ("id ASC"). # This also means that this method only works when the primary key is # orderable (e.g. an integer or string).そこで、今回は自分が指定したorderで大量データを扱いたい場合の解決方法を紹介いたします。
解決方法
前提
Rankingモデルのrank(順位)順で処理をしたい、とします。
実装
@rank_offset = 0 @batch_size = 1000 def find_rankings_in_batches loop do rankings = Ranking.where('rank > ?', @rank_offset).order(rank: :asc).limit(@batch_size) break if rankings.blank? rankings.each do |ranking| yield(ranking) end @rank_offset = rankings.last.rank end end find_rankings_in_batches do |ranking| # ここに処理を書く end発行されたSQL
1ループ目
SELECT `rankings`.* FROM `rankings` WHERE (rank > 0) ORDER BY `rankings`.`rank` ASC LIMIT 10002ループ目
SELECT `rankings`.* FROM `rankings` WHERE (rank > 1000) ORDER BY `rankings`.`rank` ASC LIMIT 1000rankの昇順かつbatch_size単位で取得できていることがわかります。
注意点
基本的には、orderに指定するカラムはUNIQUE制約が設定されているものにしてください。
batchの切れ目で同じ値が続く場合、処理されないレコードが存在してしまうためです。
- 投稿日:2021-02-25T12:08:46+09:00
RailsアプリをAWSで自動デプロイ【ステップ2 DBの構築】
はじめに
最近Rilsのアプリケーションをデプロイする機会がありましたので、その方法を忘れない内に書き込みます。
何記事に分けて説明してます。
この記事では、RDSの構築まで解説します。現在のAWSの構成
RDSの構築
RDSとは、AWSのフルマネージドなリレーショナルデータベースのサービスです。
プライベートサブネットの作成
VPC:ステップ1で作成したVPCを指定します。 サブネット名:sample-privatesubnet-c アベイラビリティーゾーン:アジアパシフィック (東京) / ap-northeast-1c IPv4 CIDR ブロック:10.0.21.0/24セキュリティーグループ作成
セキュリティグループ名:sample-infra-db 説明:sample-infra-db VPC:ステップ1で作成したVPCを指定します。インバウンドルール
タイプ プロトコル ポート範囲 ソース 説明 MYSQL/Aurora TCP 3306 ステップ1で作成したセキュリティーグループを指定 DBサブネットグループの作成
以下のリンクに移動します。
https://console.aws.amazon.com/rds/名前:sample-infra-subnet-group 説明:sample-infra-subnet-group VPC:ステップ1で作成したVPCを指定します。ステップ1、2で作成したプライベートサブネットの追加を行います。
DBパラメーターグループの作成
DBパラメーターグループとは、DBのパラメーターの設定をテンプレートとして登録できるサービス
パラメータグループファミリー:mysql8.0 グループ名:aws-infra-mysql80 説明:aws-infra-mysql80DBオプショングループの作成
オプショングループとは、DBの機能的部分を設定できるサービス
名前:aws-infra-mysql80 説明:aws-infra-mysql80 エンジン:mysql メジャーエンジンバージョン:8.0RDSの作成
エンジンのオプション
エンジンのタイプ:MySQL バージョン:MySQL8.0.21テンプレート
開発テスト #今回はテスト用で作成するので、開発テスト用を選択しました。設定
DB インスタンス識別子:sample-infra-web 認証情報の設定: マスターユーザー名:root パスワードの自動生成:✅DB インスタンスサイズ
DB インスタンスクラス:バースト可能クラス db.t3.microストレージ
ストレージタイプ:汎用SSD ストレージ割り当て:20 ストレージの自動スケーリング:無効可用性と耐久性
マルチ AZ 配置:スタンバイインスタンスを作成しない接続
Virtual Private Cloud (VPC):VPCを指定 サブネットグループ:ステップ2で作成したものを指定 パブリックアクセス可能:なし VPC セキュリティグループ:既存の選択 既存の VPC セキュリティグループ:ステップ2で作成したセキュリテーグループを指定 アベイラビリティーゾーン:ap-northeast-1a 追加の接続設定: データベースポート:3306追加設定
最初のデータベース名: DB パラメータグループ:aws-infra-mysql80 オプショングループ情報:aws-infra-mysql80 自動バックアップの有効化:✅ バックアップ保持期間:30日 バックアップウィンドウ:選択ウィンドウ 開始時間:21:00 #日本時間の午前6時に開始 期間:0.5 スナップショットにタグをコピー:✅ 暗号を有効化:✅ マイナーバージョン自動アップグレード:✅ メンテナンスウィンドウ:選択ウィンドウ 開始日:日曜日 開始時間:20:00 期間:0.5 削除保護:✅ここまで設定が完了しましたら、作成を行います!
最後に
本記事では、RDSの構築までを解説しました。
次回は、アプリをデプロイするところまで解説しようと思います。
- 投稿日:2021-02-25T11:46:02+09:00
【Rails】seed_fuまとめ
何がうれしいか
- seedデータの一部を変更した時、変更したファイルだけを読み込み、データの更新や追加ができる
- デフォルトの
seeds.rb
の場合、既存のデータを削除してから再度読み込まなきゃいけない- 環境ごとにseedデータを分けやすくなる
インストール
Gemfilegem 'seed-fu'$ bundle install$ mkdir db/fixtures # 必須 # 以下、環境ごとにseedファイルを分けたい場合に作成 $ mkdir db/fixtures/development $ mkdir db/fixtures/productionデータを読み込む
$ touch db/fixtures/development/01_user.rb #ファイル名は自由。アルファベット順に読み込まれる。以下の2通りの書き方がある。データが多い時は2つ目の書き方の方が良さそう。
基本の書き方User.seed do |s| s.id = 1 s.name = '茂野吾郎' s.email = 'shigeno@example.com' s.password = 'password' end User.seed do |s| s.id = 2 s.name = '佐藤寿也' s.email = 'sato@example.com' s.password = 'password' endこの書き方でも良いUser.seed( { name: '茂野吾郎', email: 'shigeno@example.com', password: 'password' }, { name: '佐藤寿也', email: 'sato@example.com', password: 'password' }, )$ rails db:seed_fu
読み込むseedファイルを明示的に指定する
$ rails db:seed_fu
開発環境で上記のコマンドを叩くと、
db/fixtures/development
以下のファイルだけが読み込まれる。
他のディレクトリのファイルを読みたい場合などには以下のようにディレクトリを指定することができる。$ rails db:seed_fu FIXTURE_PATH=db/fixtures/hogehoge【注意】データの同一性はidで判断される
既存のデータが更新されたのか、新規データが追加されたのかは、idを元に判断されている。seedファイル内のデータにidを書かないと、既存のデータを更新したつもりでも新規データが追加されてしまう。
id以外の(ユニークな)データを基準にデータの同一性を判断させたい場合は、以下のように書く。
emailがユニークな場合User.seed(:email) do |s| s.name = 'foo' s.email = 'foo@example.com' s.password = 'password' end # or User.seed(:email, { name: 'foo', email: 'foo@example.com', password: 'password' } )既存のDBからseedファイルを作成するrakeタスク
SeedFu::Writer.write
を使ってseedファイルを作ることができる$ rails g task seed-fu-gen-user
lib.tasks/seed_fu_gen_user.rakenamespace :seed_fu_gen_user do desc 'usersテーブルのデータを元にseedファイルを生成' task create_seed_by_db: :environment do SeedFu::Writer.write('./db/fixtures/user_gen.rb', class_name: 'User') do |w| User.all.each do |x| w << x.as_json(except: %i[created_at updated_at]) end end end end$ ./bin/rake seed_fu_gen_user:create_seed_by_db
※公式のREADMEを読むと、
SeedFu::Writer
にはもろもろ変更が入ったと注意書きがありますが、少なくともver2.3.9では上記の方法で動作確認ができました。参考
- 投稿日:2021-02-25T09:45:57+09:00
AWSにデプロイ後、よく使うコマンド
EC2にSSHで接続し、git pullしてからよく使うコマンドまとめました。
- 使用頻度が多いものを自分用に簡単にまとめました。
環境
rails 5.2.4
ruby 2.6.3
Cloud9
MySQLAWSの構成
EC2にSSHで接続する方法 $ ssh -i ~/.ssh/キー名.pem ec2-user@xx.xx.xx.xx Nginxの再起動する方法 $ sudo systemctl restart nginx アプリ(puma)起動する方法 $ rails s -e production アプリ(puma)停止する方法 $ kill プロセスID どちらも同じです $ kill $(cat tmp/pids/puma.pid)本番環境でbundle installする時のコマンド $ bundle install --path vendor/bundle --without test development CSS・JS変更時に使うコマンド $ bundle exec rails assets:precompile RAILS_ENV=production 本番環境でrails db:migrateする時のコマンド $ bundle exec rails db:migrate RAILS_ENV=productionseeds.rb編集した場合は、本番環境のデータベースを削除してからやり直す方がいいです。
本番環境のデータはなくなるので使う際はご自身で判断してください。$ RAILS_ENV=production DISABLE_DATABASE_ENVIRONMENT_CHECK=1 bundle exec rails db:drop $ mysql -u root -p -h エンドポイント mysql> CREATE DATABASE 作成したいデータベース名; $ bundle exec rails db:migrate RAILS_ENV=production $ bundle exec rails db:seed RAILS_ENV=productionnginxエラーログ確認するコマンド $ sudo tail -f /var/log/nginx/error.log railsのエラーログ確認するコマンド $ sudo tail -f log/production.log全体の把握に時間がかかりたくさんのエラーを経験し心が折れそうになりました。
何度もEC2作り直ししましたのでAWSの知識もより深まりました。
今回は、S3を使っていないため今度はS3を使って構成することを考えてます?
- 投稿日:2021-02-25T07:02:30+09:00
Dockerのコンテナ立ち上げで上手くいかなかった時にやったこと
はじめに
初学者で、今回が初めての投稿になります。
Docker超入門 #5
(動画内容としましては イメージ作成→コンテナ作成→コンテナ起動 の順になります)
こちらの動画の内容に沿って学習した際に起こったエラー、そしてどう対処したかの記事になりますエラー内容
イメージ作成するとこまでは上手く行ったのですがコンテナ作成時にエラー表示が出ました。
結論、今回のエラーはイメージ内の誤字にありました。やって見たこと
まずは作成はできていたものの、「イメージ作成」が上手く行っていないのではと思いファイル内の誤字脱字チェックに入りました。やはり2箇所誤字発見できました。そして今回はイメージファイルにミスがあったものの、イメージが作成出来てしまったのだとわかりました
対処の流れとしては
「①間違って起動したコンテナの停止→そのコンテナの削除→誤字含むイメージの削除」
続いて
「②修正後のイメージ作成→コンテナ立ち上げ→コンテナ起動」で上手く行きましたコンテナの稼働状況チェック→停止→削除
$ docker ps $ docker container stop 対象のコンテナ名 $ docker container rm 対象のコンテナ名イメージのチェック→削除
$ docker images $ docker image rm 対象のイメージ名最後に
もし記事内容に誤り等ございましたら是非ともご指摘ください。
同じような初学者の方の参考になれば幸いです
- 投稿日:2021-02-25T02:32:42+09:00
railsを使ってアプリを作る 登録編
データベースにデータを登録
ルーティングの作成
ルーティングに登録する時はpostで登録する
post "animals/create" => "animals#create"
postで登録する時は、フォームで入力した値をコントローラーで受け取る時に使うらしい
viewの作成
送りたい内容を
<%= form_tag "/animals/create" do %><% end %>
で囲むコントローラーの作成
viewからのデータを取得する
view = Animal.new(name: params[:name])
view.save登録が完了したら、別ページに遷移させる(リダイレクト)
redirect_to "/animals/index"
- 投稿日:2021-02-25T01:47:11+09:00
railsを使ってアプリを作る3
詳細ページ等のid付きのページの作成と表示
mvcの作成
urlが
/details/1
みたいにする場合は、ルーティングの書き方を
get "details/:id" => "details#show
にするdetailsコントローラーにshowメソッドを作成する
show.html.erbのファイルを作成する
idの取得と利用方法
コントローラーでparams[:id] で取得する
取得した値を
@id = params[:id]
とし、viewで<%= @id %>
と書いて使う画面に置くリンクはこんな感じ
<% @details.each do |detail| %>
<%=link_to (animal.content,"/animal/#{detail.id}")%>
<% end %>
- 投稿日:2021-02-25T01:01:37+09:00
ポートフォリオ制作録(Day1:環境構築~デプロイ)
はじめに
- 今日は,railsの開発環境をvscodeで構築し,localhostとheroku上でhello worldが表示されるまで進めました
発生したエラーと解決策
1.herokuへデプロイ出来ない①
デプロイ時にweb pack not foundとエラーがでました。warningの文でnode.jsが古いよとあったので、node.jsをインストール(同時にnpmも)したら解決。
2.herokuへデプロイできない②
チュートリアルのままbundle installをしていたが、その際にインストールされるbundlerのバージョンがherokuに対応していないらしく,デプロイができなかった。bundleのバージョンをherokuに対応しているものにして再インストールして解決
3.application errorが出るよ問題
herokuにデプロイできたがapplicationエラーが発生。エラー文を読むと,postgreのバージョンが変だよとのことなので、指定し直したらできました
参考
- Rails チュートリアル