- 投稿日:2020-12-08T22:35:32+09:00
GCPの非同期プロダクトからのリクエストを認証する
こちらはVISITS advent calendar 14日目の記事です。
GCPのいくつかのプロダクトでは、処理の後に予め登録しておいたエンドポイントをHTTPで呼び出すことができます。
時限的に処理を開始したい場合や何かのイベントの後に特定の処理を行いたい場合などに、プロダクトと独立させて処理を実行できるため色々と融通が効きます。
受ける側もHTTPさえ受けられれば通常のWebアプリケーションでも問題ないので便利なのですが、リクエストを送ってきた相手が本当にGCPのプロダクトなのか確認する必要があります。
これについては、2019年4月頃よりサービスアカウントを用いたトークン認証(OAuth, OIDC)ができるようになったようです。
いくつか上記の認証を紹介する記事はあったのですが、具体的な認証の実装をしているものがあまり見当たらなかったので今回rubyで書いてみることにしました。
執筆にあたっては以下の記事を参考にさせていただきました。
この記事だけ読んで一通り設定できるようにしたいため、いくつか内容が重複するところあるかと思いますがご容赦ください。
- GCP からの HTTP リクエストをセキュアに認証する
- Automatic OIDC: Using Cloud Scheduler, Tasks, and PubSub to make authenticated calls to Cloud Run, Cloud Functions or your Server
GCPのHTTP認証
GCPでは以下のプロダクトについては、エンドポイントを登録しておくことで後処理をHTTPで投げられるようになっています。
- Cloud Scheduler
- Cloud Tasks
- Cloud Pub/Sub
上記のプロダクトからは以下のようなプロダクトに対して処理を投げることができます。
- Cloud Run
- Cloud Functions
- Cloud Endpoints
- その他任意のサーバー/サービス
GCPのプロダクトを組み合わせた場合、基本的に認証はGCP側でよしなにやってくれるため便利です。
またGCE/GAE/GKEといったHTTPを受けられるようなプロダクトや、GCP外の自前のサーバー等でも可能です。
ただし、この場合はエンドポイントを公開しているサーバー側で認証を対応する必要があります。認証の流れ
実際に認証する際は以下のような流れになります。
- 認証用のサービスアカウントを作成する
- 受信側で認証する
- 認証機構を持つプロダクトの場合:サービスアカウントにIAMで関連のロールを付与
- 自前の場合:送られてくるidトークンを認証する
- 送信側のプロダクトにサービスアカウントを紐付ける
1. 認証用のサービスアカウントを作成する
まずはサービスアカウントを作成します。
予めロールを設定するプロダクトが分かっていれば、それにあったプロダクトのロールをここで設定しますが、後ほど設定も可能なので後回しでも大丈夫です。今回は
gcp-oidc-auth@{project-id}.iam.gserviceaccount.com
のような名前にしました。2. 受信側で認証する
続いて受信側を設定します。
送信側の設定をする際に受信側のendpointを指定するので、先に受信側を用意しておく必要があります。2-1. 認証機構を持つプロダクトの場合
GCPプロダクトで認証できる場合は、さきほど作成したサービスアカウントに受信側プロダクトのロールを付与します。
今回は例としてCloud Runを取り上げます。なおCloud Endpointsに関しては、違った手順で認証を構成することになります。
Cloud Runサービスの作成
まずはCloud Runに飛んでサービスを作成します。
リージョンやサービス名は適当に決めて次へ。
コンテナを指定するところは、適当なイメージを選択します。
詳細設定内にあるサービスアカウントは、あくまでCloud Runが何かGCPのAPIを叩くときに使うサービスアカウントになります。
1で作成したものは送信側に設定するものなので、ここではCloud Run用(もっと言うとCloud Runのサービスごと)のサービスアカウントを割り当てた方が良いと思われます。3つ目にHTTPのトリガーを指定しますが、ここで「認証を必要とする」を選択して、Cloud Runサービスを作成します。
IAMの設定
最後の「認証を必要とする」では、IAMにて送信側に設定するサービスアカウントに適切なロールを付与する必要があります。
ロールは受信側プロダクトに依存したものを付与する必要があります。
- Cloud Run:
Cloud Run 起動元
- Cloud Functions:
Cloud Functions 起動元
2-2. 自前でIDトークンを認証する
自前でIDトークンを検証する場合は、トークンの中身について把握する必要があります。
サービスアカウントを紐付けると、そのGCPプロダクトからのリクエストのAuthorizationヘッダーにBearer Tokenがjwt形式で渡ってきます。
IDトークンをdecodeするとこのような形になります。
この場合1つ目のjsonがペイロード、2つ目がヘッダーになっています。[ { "aud": "https://hogehoge.com/path/to/endpoint", "azp": "11.................52", "email": "hoge-service-account@{project-id}.iam.gserviceaccount.com", "email_verified": true, "exp": 1606186661, "iat": 1606183061, "iss": "https://accounts.google.com", "sub": "11.................52" }, { "alg"=>"RS256", "kid"=>"dedc012d07f52aedfd5f97784e1bcbe23c19724d", "typ"=>"JWT" } ]ペイロードについては公式の説明がありますので、より詳しくはそちらを参照ください。
キー 内容 aud jwtのaudクレーム。 Cloud Scheduler
の場合デフォルトで受信側endpointのURLが入る。azp 独自のクレーム。認証された送信者のクライアントIDを指すらしいが、OAuthにおいてwebアプリとAndroidアプリなどで同じ人なのに違うIDで管理される場合などに使うらしい。今回は対象外か。 独自のクレーム。サービスアカウントが入ってくる。 email_verified ユーザー認証が済んでいればtrue。おそらくOAuthで一般ユーザーが送信する場合は認証済みでないケースは想定されるが、今回のサービスアカウントの場合は基本的にtrueのはず。 exp jwtのexpクレーム。ライブラリを使えば基本期限切れのチェックはやってくれる。 iat jwtのiatクレーム。 iss jwtのissクレーム。ID tokenの場合 https://accounts.google.com
かaccounts.google.com
のどちらかになる。sub jwtのsubクレーム。Googleのアカウント全体でアカウントを特定できる、ユニークなasciiコード列が入るとのこと。 ヘッダーについては、IDトークンでは現在のところRS256が使われているようです。
kidは署名に用いられた鍵を表しており、Googleが公開しているDiscoveryのjwks_uriから取得できる鍵リストの中から、一致するものをdecodeに用います。
この鍵リストは定期的に変わるようなので、cacheするとしても一定期間で取り直した方が良さそうです。IDトークンの検証手順
IDトークンの検証手順についても公式で以下の5stepで説明されています。
- Google発行の証明書が用いられているか検証する
- issクレームがgoogleのもの (
https://accounts.google.com
またはaccounts.google.com
) か検証する- audクレームが送信側のプロダクトごとに設定される項目と一致するか検証する
- expクレームが有効期限内か検証する
- hdパラメータを設定している場合、hdクレームが正しいか検証する
この他、サービスアカウントの場合はemailクレームも想定したものか検証した方が良さそうです。
実装
rubyでやる場合は
googleauth
gem(v0.13.0以降)を利用すると便利です。
自前でやる場合は証明書の管理なども面倒ですが、その辺も全部やってくれます。endpointを指定するということでサーバーが必要になるので、今回はRailsで書きました。
Railsで認証を行う場合はControllerにおいて、
ActionController::HttpAuthentication::Token::ControllerMethods
をincludeすると
authenticate_with_http_token
(自前で例外など処理する)authenticate_or_request_with_http_token
(失敗時の処理はお任せ)などで簡単にtokenが取得できるようになります。
app/controllers/application_controller.rbclass ApplicationController < ActionController::Base include ActionController::HttpAuthentication::Token::ControllerMethods before_action :authenticate! private attr_reader :oidc_token_hash def authenticate! authenticate_or_request_with_http_token do |token, _options| @oidc_token_hash = Google::Auth::IDTokens.verify_oidc(token, aud: request.url) @oidc_token_hash['email'] == ENV.fetch('GCP_SERVICE_ACCOUNT_EMAIL') # 設定値の管理はENV以外でもOK rescue Google::Auth::IDTokens::VerificationError => _e false end endaud/iss/(azp)のクレームはgem側でやってくれるため、emailを独自にチェックするだけで済みました。
例外処理ですが、verify_oidcはトークンがおかしい場合などにVerficationError、公開鍵周りの問題でKeySourceErrorを発生させます。
前者は入力側の問題なので400系(ここではfalseを返すので401になる)として返しておき、後者は公開鍵取得に失敗した等クライアント側はどうしようもケースということで500系として検知できるようにしておきました。
この辺りの例外の取り扱いは提供するサービスのポリシーに合わせてください。今回は例だったのでhtmlを返す形を取っていますが、通常GCPからのリクエストを処理したい場合はapi的な処理が多いと思いますので、
ActionController::API
を継承しつつauthenticate_with_http_token
で自前で処理するのも良いと思います。3. 送信側のプロダクトにサービスアカウントを紐付ける
続いて作成したサービスアカウントを送信側プロダクトに紐付けます。
今回は例としてCloud Schedulerを取り上げます。Cloud Schedulerの場合はAuthヘッダーでOIDCトークンを選択するところが重要です。(公式ドキュメントはこちら)
ターゲットはHTTPを選択肢、URLには受信側のendpointを指定します。
またAuthヘッダーではOIDCトークンを選択し、サービスアカウントには最初に作ったアカウントを指定します。なお、ターゲットのURLが
*.googleapis.com
なGoogle APIのときは、AuthヘッダーにOAuthトークンを使用するようです。一番下にある対象の項目は後の
aud
クレームの値になります。
空欄の場合はターゲットのURLが入ります。後はcronでも手動でもいいので実行し、認証が成功するかを確認します。
Cloud Pub/SubやCloud TasksなどもHTTPターゲットの設定とサービスアカウントが設定できるので、同様の設定で大丈夫です。
おわりに
ということでGCPの非同期系プロダクトからのリクエストを認証する設定の流れについてでした。
受信側にもし認証機構をもつプロダクトを割り当てられる場合はそちらを選択した方が楽ではありますが、idトークンの認証自体もそれほど複雑ではないので、ちゃんと導入してセキュアな状態を保ちたいですね。
- 投稿日:2020-12-08T22:06:05+09:00
【Rails】ymlファイルの中でerbを使い、動的に値を取得してrakeタスクの引数にする
- 投稿日:2020-12-08T21:59:21+09:00
[Rails] モデルの作成方法
- 投稿日:2020-12-08T21:27:24+09:00
deviseを利用した新規登録時のユーザー情報の保存とフラッシュメッセージについて
はじめに
deviseを利用したuser周りの設定の中で、私が困ったことについて解決方法を記述しようと思います。
今回なかなか思った通りにできずに困ったことはユーザーの新規登録です。新規登録ページの作成はできましたが、そこから先で躓いてしまいました。
- 登録したい新規ユーザーの情報が登録されない
- 新規登録ページで情報入力後、ボタンを押してもページが変わらない
- 新規登録ができた場合とできなかった場合の動作を変えたい
- 新規登録時のフラッシュメッセージの表示がされない
以上を解決した方法について備忘録として記事を書きました。
Rails 5.2.4.4
Ruby 2.5.1
を使用しています。ルーティングの設定
最初に躓いたのはユーザー情報が保存されないことです。sendボタンを押すとそのまま動かなくなり、Sequel Proを確認しても情報の保存がされていませんでした。
ルーティングとコントローラーに問題があると考えたので、まずはroutes.rbを確認します。
現状ではおそらく既にご自身で設定したトップページなどへのルーティングと、devise導入時に自動で記述されたコードで以下のようになっていると思います。
config/routes.rbdevise_for :users root "トップページ" resources :その他のページここに追記をしていきます。
新規登録はdeviseで自動生成されたregistrationsにあたるので以下のように記述をします。新規登録には関係ありませんが、ついでにログインに必要なsessionsについても記述しておきましょう。また、resources :usersという記述も追加します。config/routes.rbdevise_for :users, controllers: { registrations: 'users/registrations', sessions: 'users/sessions' } root "トップページ" resources :その他のページ resources :usersコントローラーの設定
次にコントローラーを確認します。
registrations_controller.rbを開きます、こちらはdeviseで自動生成されたファイルです。
中を見ると class ~ end の中身が全てコメントアウトされているのが確認できます。こちらにコードを記述をしていきます。今回やりたいことはユーザー新規登録なので、newとcreateを記述します。
app/controllers/uses/registrations_controller.rbdef new @user = User.new end def create @user = User.new(user_params) if @user.save redirect_to root_path else render :new end end private def user_params params.require(:user).permit(:email, :password, :password_confirmation) endcreateの中では複数のことをしているので注意点の記述や解説をします。
まず
app/controllers/uses/registrations_controller.rb@user = User.new(user_params)この(user_params)についてははprivate以下で記述しています。
app/controllers/uses/registrations_controller.rbdef user_params params.require(:user).permit(:email, :password, :password_confirmation) endこのpermitの後の部分では新規登録時に必要なカラムを記述します。例えば私の場合はニックネームも登録できるようにしたかったので、以下のように記述をしました。
app/controllers/uses/registrations_controller.rbdef user_params params.require(:user).permit(:nickname, :email, :password, :password_confirmation) endご自身の登録したい情報によって記述を変更してください。
次の記述です。
app/controllers/uses/registrations_controller.rbif @user.save redirect_to root_path else render :new endこの記述は新規登録ができた場合とできなかった場合の条件分岐をしています。
こちらでは登録ができた場合はトップページへ飛び、できなかった場合にはまたユーザー新規登録ページへと戻ってくるように記述しています。redirect_to と render は似たような動きをしますが使い分けが必要です。こちらの記事がとてもわかりやすく解説されているので、気になる方は目を通してみると勉強になると思います。
https://qiita.com/morikuma709/items/e9146465df2d8a094d78ここまでで以下の問題が解決しました。
- 登録したい新規ユーザーの情報が登録されない
- 新規登録ページで情報入力後、ボタンを押してもページが変わらない
- 新規登録ができた場合とできなかった場合の動作を変えたい
新規登録ページから情報を入力して登録ができること、登録が失敗した場合にはまた新規登録ページに戻ってくることを確認してください。問題なく行えていれば成功です。
しかし今のままでは新規登録が成功してもトップページに飛ぶだけなので、ちゃんと登録ができたのかユーザーにはとてもわかりにくい状態です。
最後にフラッシュメッセージを表示できるようにします。フラッシュメッセージを表示する
deviseでは設定をすれば簡単にフラッシュメッセージを表示できます。
こちらの記事がフラッシュメッセージの導入方法についてわかりやすく解説してあります。
https://qiita.com/hari00702/items/4e100b9dc78d19e8e316しかし、ユーザー新規登録時の登録完了のメッセージはその中には入っていません。
そこでregistrations_controller.rbのcreateに以下のように追記します。app/controllers/uses/registrations_controller.rbdef create @user = User.new(user_params) if @user.save redirect_to root_path, notice: 'ユーザー新規登録を完了しました' #追記 else render :new end endnotice: の後ろの部分の記述がそのままフラッシュメッセージになるので好みのメッセージを入れてください。
新規登録ページから実際に登録を行ってみて、ページ上部にメッセージが表示されていれば成功です。これで残りの
- 新規登録時のフラッシュメッセージの表示がされない
についても解決できました。
参考
- 投稿日:2020-12-08T21:10:33+09:00
Rspec問題2
事前準備
ターミナル上で下記コマンドを実行してcloneする。
git clone https://github.com/Shu-Hos/pictweet_error.git cd pictweet_error bundle install yarn install rails db:create rails db:migrate問題1
ターミナルで下記コマンドを実行してください。
bundle exec rspec spec/models/user_spec.rbすると
NoMethodError:
のエラーが発生するかと思います。
undefined method name=' for #<User:xxxxxxx>’
まずはこちらをヒントなしで解決しましょう。問題2
上記のエラーが改善されて再度こちらのコマンドを実行してください
bundle exec rspec spec/models/user_spec.rbしかしテストの結果は、
Failure/Error:expect(user.errors[:nickname]).to include("is too long (maximum is 6 characters)")
が残ってしまっているかと思います。
こちらを修正してuserのモデル単体テストを成功させてください。
ヒント
binding.pryでuserの中身を確認すると条件分岐の結果がtrueになっているかつエラーメッセージが空になっているかと思います。
バリデーションが怪しいかも?
問題3
問題2まで解き終えたら次はtweetのmodel単体テストです。
以下のコマンドを実行してください。bundle exec rspec spec/models/tweet_spec.rbすると正常系のテストコードがすべてfailuresになってしまいます。
こちらの修正をお願いします。
ヒント
binding.pryで@tweetの中身を確認してみましょう。問題4
次はtweets_controllerの単体テストです。
以下のコマンドを実行してください。bundle exec rspec spec/requests/tweets_spec.rbすると、
ActionController::UrlGenerationError
が発生するかと思います。
エラー文をよく読んでこちらの問題を解決しましょう。
ヒント
エラー文はPNo route matches {:action=>"show", :controller=>"tweets"}, missing required keys: [:id]
と出ているはずです。必要に応じてrails routesコマンドでshowアクションのパスを確認してみましょう。
ここからはtweet機能の結合テスト問題です。
問題5
以下のコマンドを実行してください。
bundle exec rspec spec/system/tweets_spec.rbログインページでログインができずに
Capybara::ElementNotFound:Unable to find field "email" that is not disabled
というエラーが発生するかと思います。
まずこちらを解決しましょう。
ヒント
ElementNotFoundと出ているので検証ツールで要素の中身を確認してみましょう。
問題6
こちらで最後の問題になります!
再度以下のコマンドを実行してください。bundle exec rspec spec/system/tweets_spec.rb以下のエラーが起きたかと思います。
Failure/Error: expect(page).to_not have_no_selector 'form'expected to find css "form" but there were no matches
ヒント
テストの実行文とexpectedの実行結果の英文の意味を調べてみると良いかも知れないです。
お疲れさまです!!
何かご不明点とかあれば、今後ぜひ教えてください!
- 投稿日:2020-12-08T20:58:10+09:00
formオブジェクトで複数テーブルへ値の保存
formオブジェクトを使った複数テーブルへの値の保存を1週間位試行錯誤してやっと実装できたので、記事に残しておこうと思います。
色々な方の実装方法を真似て作ったので解釈が間違っているところや、理解が不十分なところもありますが、自分はこう解釈してこの記述をしているということで書いていきます。前提 プログラミング初学者の備忘録的な感じで書いており、間違いがある場合がありますので、ご注意ください。 解釈やそもそもの定義など間違っている部分等ありましたらご指摘していただけると幸いです。 ターミナルでのファイル生成の記述については全て省略しています。動作環境
macOS Catalina 10.15.7
Rails 6.0.3.4
Ruby 2.6.5p114formオブジェクトって何?
デザインパターンの1つで、1つの投稿フォームから複数のモデルに関連するデータを更新できるものです。
簡単に言うと、1つの投稿フォームから複数のテーブルへの保存の処理をするまとめ役みたいな感じです。
そのため、formオブジェクトにはform_withメソッドに対応する機能と、バリデーションを行う機能をもたせることが必要になります。
form_withで複数のテーブルに保存するデータをformオブジェクトに送り、
届いたデータに対して、各モデルのバリデーションをformオブジェクトで行った後に複数のテーブルへデータを保存すると言った流れです。formオブジェクトを使ったフォームの実装
今回は以下2つをポイントに実装を行いました。
formオブジェクトで記事の新規投稿、更新処理ができる。
1つの入力フォームから複数のタグの新規登録、更新処理ができる。
記事の新規投稿のみに比べ、更新処理も実装するとかなり手間がかかりました。ER図と実際の投稿フォーム
ER図はこのような感じで、赤枠の部分をformオブジェクトで実装しました。
formオブジェクトを使い、1つの投稿フォームでarticleとtagを保存するような設計です。
具体的な動きとしては、下図のようなフォームで記事にタグを付けて投稿し、記事とタグをそれぞれのテーブルに保存します。
ArticleとTagを紐付けるために中間テーブルとして、article_tag_relationsテーブルを作っています。モデルについて
各モデルは以下のように記述しました。
マイグレーションファイルもカラムの部分のみをモデルの下に書いています。Articleモデル(app/models/article.rb)
class Article < ApplicationRecord has_many :article_tag_relations, dependent: :destroy has_many :tags, through: :article_tag_relations end マイグレーションファイル t.string :title, null: false t.text :output, null: false t.string :action t.integer :user_idTagモデル(app/models/tag.rb)
class Tag < ApplicationRecord has_many :article_tag_relations, dependent: :destroy has_many :articles, through: :article_tag_relations validates :tag_name, uniqueness: true end マイグレーションファイル t.string :tag_name, uniquness: trueArticleTagRelationモデル(app/models/article_tag_relation.rb)
class ArticleTagRelation < ApplicationRecord belongs_to :article belongs_to :tag end マイグレーションファイル t.references :article, foreign_key: true t.references :tag, foreign_key: trueモデルについては2つのポイントがあり
dependent: :destroy
1つは、articleが削除されたとき、tagが削除されたときには中間テーブルのarticle_tag_relationから削除された値が含まれるレコードも削除されるようにしました。
validates :tag_name, uniquness: trueもう1つは、同じ名前のtagが複数保存されないようにしています。
コントローラーについて
コントローラーは以下のように記述しました。
class ArticlesController < ApplicationController before_action :authenticate_user!, only: [:new, :edit, :update, :destroy] before_action :set_article, only: [:show, :edit] def index @articles = Article.all.order('created_at DESC') end def new @article_tag = ArticleTag.new end def create @article_tag = ArticleTag.new(article_params) tag_list = params[:article][:tag_name].split(',') if @article_tag.valid? @article_tag.save(tag_list) redirect_to articles_path else render :new end end def show end def edit @article = Article.find(params[:id]) @article_tag = ArticleTag.new(article: @article) end def update @article = Article.find(params[:id]) @article_tag = ArticleTag.new(article_params, article: @article) tag_list = params[:article][:tag_name].split(',') if @article_tag.valid? @article_tag.save(tag_list) redirect_to article_path(@article) else render :edit end end def destroy @article = Article.find(params[:id]) redirect_to root_path if @article.destroy end private def article_params params.require(:article).permit(:title, :output, :action, :user_id, :article_id, :tag_name, :tag_id).merge(user_id: current_user.id) end def set_article @article = Article.find(params[:id]) end endコントローラーについては特に変わったところはなく、
tag_listがformオブジェクトで値を保存するために定義したsaveメソッドに使われるくらいです。formオブジェクトの作成
formオブジェクトはapp/formsディレクトリを作成し、その直下にarticle_tag.rbというファイル名で作成しました。
class ArticleTag include ActiveModel::Model attr_accessor :title, :output, :action, :tag_name, :user_id, :tag_id, :article_id with_options presence: true do validates :title, length: { maximum: 40 } validates :output, length: { maximum: 400 } end # レコードに値があるかないかでcreateかupdateかに分岐させる delegate :persisted?, to: :article def initialize(attributes = nil, article: Article.new) @article = article attributes ||= default_attributes super(attributes) end def save(tag_list) ActiveRecord::Base.transaction do @article.update(title: title, output: output, action: action, user_id: user_id) current_tags = @article.tags.pluck(:tag_name) unless @article.tags.nil? old_tags = current_tags - tag_list new_tags = tag_list - current_tags old_tags.each do |old_name| @article.tags.delete Tag.find_by(tag_name: old_name) end new_tags.each do |new_name| article_tag = Tag.find_or_create_by(tag_name: new_name) @article.tags << article_tag article_tag_relation = ArticleTagRelation.where(article_id: @article.id, tag_id: article_tag.id).first_or_initialize article_tag_relation.update(article_id: @article.id, tag_id: article_tag.id) end end end def to_model article end private attr_reader :article, :tag def default_attributes { title: article.title, output: article.output, action: article.action, tag_name: article.tags.pluck(:tag_name).join(',') } end endinclude ActiveModel::Model
ActiveModel::Modelというモジュールをincludeメソッドで与えます。
この記述によって ArticleTagクラスがモデルとしての機能を行えるようになります。
form_withへの対応やバリデーションを行うために必要な記述です。delegateメソッド
指定したオブジェクトにメソッドの実行を委譲させるものです。
委譲:あるオブジェクトの操作を一部他のオブジェクトに代替させる手法
言葉が難しいです。。。delegate :メソッド名, to: :委譲先のオブジェクトここではpersisted?メソッドをarticleというオブジェクトに委譲しています。
to_modelメソッドと合わせて、
レコードに値が存在しないときにcreateアクション
レコードに値が存在するときはupdateアクションを動かすために必要な記述になります。to_model
モデルであるためには、to_modelを定義する必要があります。
コントローラーやview helperにモデルが渡ったときにto_modelを呼んでモデルを操作するためです。理解が浅いためうまく説明できないのですが、delegateメソッドで値があるときと無いときに応じてPOSTやPATCHの処理を切り替え、
to_modelメソッドはアクションのURLを適切な場所に切り替えているということらしいです。initializeメソッド
initializeメソッドはnewメソッドでインスタンスを生成する時に初期値で設定する値などを定義するメソッドです。
attributesは属性値の意味で、attributes ||= default_attributes super(attributes)arrtibutesが存在すればその値を、nilであれば、default_attributesをattributesに代入するといった記述です。
superは、スーパークラスを呼び出す記述です。
initializeをここでは再定義(オーバーライド)していますが、オーバーライドする前のinitializeメソッドを引数をattributesとして呼び出しています。
単にnewメソッドでインスタンスを生成するわけではなく、
attributesが存在すればその値を使ってインスタンスを生成
存在しなければ、default_attributesを使ってインスタンスを生成するといった記述です。
createアクションとupdateアクションを値のあるなしで使い分けるための記述を1つにまとめるために、initializeの再定義を行っていると思います。
投稿のみであれば、ここの記述は必要ありません。
更新にも対応するためにレコードに保存された値を取得するために定義しています。default_attributes
privateメソッド以下にあるdefault_attributesは投稿フォームに入力する値のdefault値を定義しました。
articleとtagを使ってdefalut値を設定するためにattr_readerでarticleとtagを読み込んでいます。
ここでは書き込む必要はなく、値として読み込むだけの処理のためattr_readerで十分になります。saveメソッド
saveメソッドは新規投稿と更新の両方をこのメソッド1つで行うことができます。
initializeを再定義したので、newアクションでdefault_attributesの値が入ったレコードが生成されます。
フォームの入力値をarticle_paramsとして取り出して、以下のコードで入力した値に更新するという処理にして新規登録します。@article.update(title: title, output: output, action: action, user_id: user_id)新規登録の場合は、defalut_attributesとして全ての値がnilのレコードが生成され、フォームの入力値(article_params)で更新するといった流れです。
更新処理の場合は、保存されたレコードの値がdefault_attributesとしてあり、フォームの入力値(article_params)で更新すると言った流れです。
タグの部分の記述については後述します。ActiveRecord::Base.transaction
トランザクションの処理を記述する時に使うものです。
トランザクションとは、分割できないワンセットの処理単位のことです。
この中に書かれた処理で途中で例外処理(エラー)があったときには途中までの処理や結果はやらなかったことにするというものです。
ここではActiveRecord::Base.transaction doからendまでの処理
つまり、articleとtagの新規登録、更新処理がトランザクションになっています。
articleとtagの新規登録や更新する時にどこかで処理が失敗した場合、途中までやっていた処理はすべてなかったことにするということです。タグの扱いについて
タグについては複数のタグを登録、編集できる機能にしました。
tag_list(フォームに入力したタグ)
current_tags(現在保存されているタグ、更新の場合のみ使われる変数)
old_tags(現在保存されていて、そのまま残すタグ)
new_tags(新しく保存されるタグ)
の4つを配列で定義し、配列内の各要素を保存するという流れです。tag_list = params[:article][:tag_name].split(',')上記の記述では、フォームで送信されたparamsからタグの値を取り出します。
このときsplit(',')では入力した値を,で区切って要素に分解して配列にするといった処理が行われます。
例えばタグを入力するフォームに朝,昼,夜と入力した場合、
tag_list = ["朝","昼","夜"]と入ることになります。
新規登録の場合
current_tags = @article.tags.pluck(:tag_name) unless @article.tags.nil?unless @article.tag.nil?
となっており、タグが空でない場合にcurrent_tagsが定義されるため新規登録の場合は定義されません。
new_tags = tag_listとなり以下の処理に移ります。new_tags.each do |new_name| article_tag = Tag.find_or_create_by(tag_name: new_name) @article.tags << article_tag article_tag_relation = ArticleTagRelation.where(article_id: @article.id, tag_id: article_tag.id).first_or_initialize article_tag_relation.update(article_id: @article.id, tag_id: article_tag.id) end2行目:find_or_create_byメソッドではTagモデルを通じてTagsテーブルから、tag_nameがnew_nameのものを探し、なければその値を保存します。
3行目:次の行で保存されたarticle_tagを@article.tags、つまり投稿した記事のタグの配列に格納します。
4行目:中間テーブルに値を保存する処理です
first_or_initializeメソッドは新規登録の場合はinitializeつまり新しくレコードが生成され、更新の場合はレコードは生成されません。
5行目:生成したレコード、元々あったレコードをupdateメソッドで更新する
と言った流れで新規登録されます。更新の場合
更新の場合はtag_listとcurrent_tagsを使って、
編集された時に削除されたタグを中間テーブルから削除し、新しく追加されたタグをTagsテーブルと中間テーブルに保存する必要があります。current_tags = @article.tags.pluck(:tag_name) unless @article.tags.nil? old_tags = current_tags - tag_list new_tags = tag_list - current_tags例として、元々登録していたタグをtag1,tag2とします。
1行目:投稿した@articleに紐づくtag達を配列形式で取得します。
tagsテーブルからpluckメソッドでtag_nameというカラムを指定し、投稿した記事につけたタグのtag_nameの値を配列に格納します。
2行目:元々登録していたけれど、編集によって削除されたタグをold_tagと定義しています。
例えば、元々tag1,tag2があって編集画面でタグの欄をtag1だけにした場合はtag_listはタグのフォームに入力された値であるため、
old_tags = ["tag1", "tag2"] - ["tag1"] =["tag2"]となります。
3行目:元々登録されていなかった新規のタグをnew_tagsと定義しています。
例えば、元々tag1,tag2があって編集画面でtag1,tag2,tag3とした場合、
new_tags = ["tag1", "tag2", "tag3"] - ["tag1", "tag2"] = ["tag3"]となります。old_tagsについての処理
old_tagsはフォームから削除され、投稿につけなくなったタグです。
投稿に紐づくタグとして以下の記述で削除する必要があります。old_tags.each do |old_name| @article.tags.delete Tag.find_by(tag_name: old_name) endnew_tagsについての処理
new_tagsは新たに追加したタグなので、新規のタグの場合はTagsテーブルに保存する必要があります。
また、投稿に紐づくタグとして新たに中間テーブルに保存する必要があります。
処理の内容については新規登録の場合の説明と全く一緒です。まとめと感想
簡単なまとめ
formオブジェクトは1つのフォームから複数のテーブルに値を保存するために使われるデザインパターンの1つ
複数のタグを保存するにはpluckメソッドやsplitメソッドをうまく使って配列に格納し、eachメソッドを使ってそれぞれのタグに保存処理を行う感想
delegateとto_modelメソッドについてなんとなく意味は分かった気がするが、formオブジェクトに記述して細かい部分でどう動いているのか完全には理解できていないので、もう少し理解を深める。
to_modelメソッドはActiveModel::Conversationに含まれるメソッドということで、他にもよく使っているメソッドがあるため今後勉強していく。実装内容をすべて書いたのでものすごく長くなりました。
解釈間違い等ありましたらコメントしていただけると幸いです。
記事にわかりやすくまとめる技術も学んでいかなければ。。。参考記事
formオブジェクトについて
https://product-development.io/posts/rails-design-pattern-form-objects
https://tomo-bb-aki0117115.hatenablog.com/entry/2020/10/29/232822タグ付け機能
https://qiita.com/E6YOteYPzmFGfOD/items/bfffe8c3b31555acd51dトランザクションについて
https://wa3.i-3-i.info/word142.html
- 投稿日:2020-12-08T20:55:57+09:00
Rails 6: ActionCableとVue.jsで非同期処理を行うサンプル
環境: Rails 6.0、Vue.js 2.6、ソース: https://github.com/kazubon/cable60
ActionCable、ActiveJob、およびVue.jsを使って、次のような非同期処理を行う画面を作ります。
必要なライブラリ
GemfileにRedisとSidekiqを追加して、bundle install してください。
gem 'redis', '~> 4.0' gem 'sidekiq'Redisをインストールしていない場合は、インストールして起動しておきます。
% brew install redis % brew services start redis
ActionCableの準備
cable.ymlで、development環境の設定を async から redis に変えます。サンプルでは、ActiveJobのジョブの中でActionCableのブロードキャストを行いますが、async だと効かないからです。
config/cable.ymldevelopment: adapter: redis url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> channel_prefix: cable60_developmentコントローラでは、ActionCable用の接続の識別子となるランダム文字列をクッキーに入れます。このサンプルでは、「1ユーザー - 1識別子 - 1ストリーム」とします。1対多の送信は行いません。
app/controllers/application_controller.rbclass ApplicationController < ActionController::Base before_action :set_cable_code private # Action Cable用ユーザー識別 def set_cable_code cookies.signed[:cable_code] ||= SecureRandom.hex end endconnection.rbではクッキーから識別子 cable_code を取り出します。
app/channels/application_cable/connection.rbmodule ApplicationCable class Connection < ActionCable::Connection::Base identified_by :cable_code def connect if cookies.signed[:cable_code] self.cable_code = cookies.signed[:cable_code] end end end end
bin/rails g channel user
で user_channel.rb を作っておき、識別子 cable_code を stream_from にそのまま渡します。app/channels/user_channel.rbclass UserChannel < ApplicationCable::Channel def subscribed stream_from cable_code if cable_code end def unsubscribed end endActiveJobの準備
config/environments下のdevelopment.rbとproduction.rbを修正し、ActiveJobではSidekiqを使うことを指定します。
config/environments/development.rbconfig.active_job.queue_adapter = :sidekiqコントローラでは、「開始」ボタンで呼び出す update アクションを書いておきます。SampleJobというジョブにクッキーの識別子を渡して非同期処理をさせます。
app/controllers/samples_controller.rbclass SamplesController < ApplicationController def show end def update SampleJob.perform_later(cookies.signed[:cable_code]) render json: {} end end
bin/rails g job sample
でSampleJobを作っておいて、performメソッドにサンプル用の処理を書きます。20回スリープしながら現在のパーセンテージを進めるだけのものです。ActionCableのブロードキャストを使い、識別子 cable_code に対してハッシュ(JavaScriptのオブジェクト)を送信します:
{ type: 処理の種類に付けた名前, progress: パーセンテージ, processing: 処理中かどうか }
。app/jobs/sample_job.rbclass SampleJob < ApplicationJob queue_as :default def perform(cable_code) 20.times do |idx| sleep 0.2 ActionCable.server.broadcast(cable_code, type: 'sample', progress: (idx + 1) * (100 / 20), processing: true) end ActionCable.server.broadcast(cable_code, type: 'sample', progress: 100, processing: false) end endJavaScriptでデータを受け取り、進行状況を表示する
JavaScript側では、ActionCableからデータを受け取るためのオブジェクトを作ります。
Vue.observable
を使うことで、sampleプロパティを変更したらVueのテンプレートに反映するようにします。ほかに非同期処理を扱う画面が増えたら、fooとかbarとかプロパティを増やすことを想定しています。
app/javascript/channels/cable_data.jsimport Vue from 'vue'; export default Vue.observable({ sample: { }, // foo: { }, // bar: { }, })UserChannelに対応するuser_channel.jsを修正します。receivedでデータを受け取ったら、ActionCable用のオブジェクトcableDataのプロパティにそのまま入れます。
オブジェクトのtypeプロパティの値がcableDataの各プロパティの名前に対応していることにします。
app/javascript/channels/user_channel.jsimport consumer from "./consumer" import cableData from "./cable_data"; consumer.subscriptions.create("UserChannel", { connected() { }, disconnected() { }, received(data) { switch(data.type) { case 'sample': cableData.sample = data; break; // case 'foo': // cableData.foo = data; // break; // case 'bar': // cableData.bar = data; // break; } } });非同期処理の進行状況を表示するVueコンポーネントです。ActionCable用のオブジェクト cableData.sampleのprogressとprocessingの値を画面に反映させます。
なお、ここではBootstrapのProgressを使っています。
app/javascript/sample.vue<template> <div> <div class="form-group row"> <div class="progress"> <div class="progress-bar" role="progressbar" :aria-valuenow="progress" aria-valuemin="0" aria-valuemax="100" :style="`width: ${progress}%`"></div> </div> </div> <div class="form-group row"> <button type="button" class="btn btn-primary" @click="startProcess" :disabled="processing">開始</button> </div> </div> </template> <script> import Axios from 'axios' import cableData from "./channels/cable_data" export default { data() { return { }; }, computed: { progress() { return cableData.sample.progress || 0; }, processing() { return cableData.sample.processing; } }, methods: { startProcess() { cableData.sample = { progress: 0, processing: true }; Axios.patch('/sample'); } } } </script> <style scoped> .progress { width: 100%; } </style>サンプルのVueコンポーネントをマウントするpacks下のJavaScriptです。
require("channels")
ががあることを確認しましょう。app/javascript/packs/application.jsimport 'bootstrap'; import '../stylesheets/application'; require("@rails/ujs").start() require("turbolinks").start() // require("@rails/activestorage").start() require("channels") import Vue from 'vue'; import TurbolinksAdapter from 'vue-turbolinks'; import Sample from '../sample.vue'; import '../axios_config'; Vue.use(TurbolinksAdapter); document.addEventListener('turbolinks:load', () => { if(document.getElementById('sample')) { new Vue(Sample).$mount('#sample'); } });
bin/rails s
でサーバーを起動し、別のターミナルでbundle exec sidekiq
を起動すれば、非同期処理の動作を確認できます。Vue.observable を使わない場合
上記のcable_data.jsで、Vue.observableを使わずにJavaScriptのオブジェクトをそのままエクスポートしても、ActionCableのデータを扱えます。
app/javascript/channels/cable_data.jsexport default { sample: { } }この場合は、Vueコンポーネントでdataを使ってオブジェクトを渡せば、sampleプロパティの変更が反映されます(リアクティブになります)。
app/javascript/sample.vue<script> import Axios from 'axios' import cableData from "./channels/cable_data" export default { data() { return { cableData: cableData }; }, computed: { progress() { return this.cableData.sample.progress || 0; }, processing() { return this.cableData.sample.processing; } }, methods: { startProcess() { this.cableData.sample = { progress: 0, processing: true }; Axios.patch('/sample'); } } } </script>
- 投稿日:2020-12-08T19:39:43+09:00
RailsでHTMLファイルを出力する
やりたいこと
テンプレートに沿ったHTMLファイルを出力したい!
前提条件
以下のコマンドでBooksControllerを作成し、その中にHTMLファイルを出力するアクションを作成する。
rails g controller booksテンプレートファイルを作成する
今回は以下のディレクトリを作成し、その中にテンプレートファイルを保存する。
app/views/books/templatehtml_template.html.erb<!DOCTYPE html> <head> <meta charset="UTF-8"> <title>本の詳細</title> </head> <body> <table> <tr> <th>タイトル</th> <td><%= @title %></td> </tr> </table> </body> </html>アクションを作成する
以下のようなアクションを作成する。
books_controller.rbdef htmlfile_download @title = "本の題名" # 指定したファイルの中身を文字列で返す # layoutオプションの値をfalseにしておくと、レイアウトが適用されていない状態で取得できる template = render_to_string('books/template/html_template', layout: false) # HTMLファイルを生成 send_data(template, filename: "ファイル名.html") end以上です。
おまけ
render_to_stringで取ってきた値は以下のようになっています。
<!DOCTYPE html> <head> <meta charset="UTF-8"> <title>本の詳細</title> </head> <body> <table> <tr> <th>タイトル</th> <td>本の題名</td> </tr> </table> </html>
- 投稿日:2020-12-08T19:19:59+09:00
Active Storageで動画をアップする!!
Active Storageで動画をアップする!!
Active Storageを使用。動画をアップロードし、validatesで形式を指定する!!
お師匠方初めまして!
Active Storageはかなり便利で画像の際かなり使い勝手が良いですよね。
今回は動画にチャレンジでバリデーションまで設定しました。
他にも皆さんのオススメなどあったら聞きたいです^^https://gyazo.com/ab51ad2729703e1b77f831205fef7550
該当するソースコード
#app/models/post.rb class Post < ApplicationRecord belongs_to :user has_many :comments, dependent: :destroy has_one_attached :video with_options presence: true do validates :title validates :price, format: { with: /\A[-]?[0-9]+(\.[0-9]+)?\z/} validates_inclusion_of :price, in: 500..5000 validates :video end validate :video_type private def video_type if !video.blob.content_type.in?(%('video/quicktime video/quicktime')) errors.add(:video, '動画は携帯で撮影したmov形式でアップロードしてください') end end end#app/views/posts/index.html.erb class PostsController < ApplicationController #省略 <video src=<%= rails_blob_path(post.video) %> type="video/mov", controls></video>
- 投稿日:2020-12-08T19:19:59+09:00
Active Storageで動画をアップ
★Active Storageは画像、動画が投稿できる
❶バリデーションに形式を記述
#app/models/post.rb class Post < ApplicationRecord #省略 validate :video_type private def video_type if !video.blob.content_type.in?(%('video/quicktime video/quicktime')) errors.add(:video, '動画は携帯で撮影したmov形式でアップロードしてください') end end end❷ビューファイルにvideoタグを記述
#app/views/posts/index.html.erb class PostsController < ApplicationController #省略 <video src=<%= rails_blob_path(post.video) %> type="video/mov", controls></video>
- 投稿日:2020-12-08T18:51:59+09:00
[Rails]ransackでの月度検索の実装(Custom Predicates)
概要
ransackを使用している際、月度検索を使用したくなったので以下の通り実装した。
↓例:2020年12月を選択したら、2020/12/1-2020/12/31まで検索する
実装
検索用の述語(Predicate)を作成する。
add_predicateで述語を追加、述語名は「during_month」で実装。# config/initializers/ransack.rb Ransack.configure do |config| config.add_predicate :during_month, :arel_predicate => :between, :formatter => proc { |v| unless v.month == 12 # 12月度以外の処理 #月末日をenddayに代入 v.monthを+1して最後にdateを1引くことで月末日を取得している。 endday = Time.zone.parse("#{v.year}-#{v.month + 1}-1").to_date - 1 else # 12月度の処理 # 12月度の時はv.monthはインクリメントしない(エラー回避) # v.yearを+1で翌年に、monthは1月固定、 # 最後に1日に固定したdateを1引くことで12月の末日をenddayに代入している。 endday = Time.zone.parse("#{v.year + 1}-1-1").to_date - 1 end #検索月の初日から月末日まで Time.zone.parse("#{v.year}-#{v.month}-1").to_date..endday }, :type => :date endView側の実装
<%= search_form_for "検索オブジェクト", url: "アクション" do |f| %> <%= f.label :search_day, "期間"%> <%= raw sprintf( f.date_select( :search_day_during_month, discard_day: true, use_month_numbers: true, date_separator: '%s'), '年 ') + '月' %> <div class="actions ransack-submit"><%= f.submit "検索" %></div> <% end %>参考
ransackで年度検索を実装する
https://qiita.com/tanakaworld/items/c7a2613589dadbb2ef4dCustom Predicates · activerecord-hackery/ransack Wiki
https://github.com/activerecord-hackery/ransack/wiki/Custom-Predicates
- 投稿日:2020-12-08T18:06:33+09:00
rails コマンドへ独自コマンドを組み込む方法
Rake タスクって何だか変ですよね。テストも書きづらいし、できることなら書きたくないですよね。
Ruby には Thor というイケてる Gem があり、これを利用するとイケてるコマンドライン・ユーティリティを書くことができます。Thor はイケてるので、サブコマンドも書くことができます。Thor を利用する場合、コマンドライン・ユーティリティは、Thor クラスを継承したクラスとして作成するので、テストも簡単で、特別な知識は不要です。
Rake タスクではなくて、Thor を利用できたら素敵ですよね。
実は rails コマンドは Thor をすでに利用しているんです。そして、Rails エンジンの場合、Thor を使って独自コマンドを提供する標準的な方法が用意されているようですが、Rails アプリケーションの場合、標準的な方法はありません。自分でなんとかするしかありません。
以降では自分でなんとかする方法を説明します。generator のアシストは受けられないので、全て手作業でファイルを修正したり、ファイルを作成したりする必要があります。
前提
本記事で作成したサンプルは https://github.com/sunny4381/rails_command_extension に置いておきます。
このサンプルは Rails チュートリアルの第14章のソースコードを元にしています。Rails チュートリアルではUser
とMicropost
の二つのモデルが登場しますので、本記事もこの2つのモデルを操作してみたいと思います。Rails::Command::Base
早速、独自コマンドを作成していきます。独自コマンドは、直接 Thor を継承せずに、Rails が Thor をラップしたクラス Rails::Command::Base を提供しているので、このクラスを継承するようにします。
早速、独自コマンドを実装しましょう。以下のような
main_command.rb
ファイルを作成し、このファイルに独自コマンドを実装していきます。lib/commands/main/main_command.rbrequire "rails/command" module SampleApp module Command class MainCommand < Rails::Command::Base namespace "sample" @command_name = "sample" def hello say "hello" end end end end
namespace
と@command_name
を指定して、コマンド名を明示的に指定しています。この 2 つの指定がなければsample_app:main
なんていう冗長なコマンド名になってしまいます。そして、
hello
というコマンドを実装しており、bin/rails sample_app:hello
と実行することを意図しています。コマンドの実装方法の詳細については、Thor の Wiki を参照ください。
bin/rails の変更
次に独自コマンドを rails コマンドに認識させる必要があります。このため
bin/rails
を修正して、独自コマンドを組み込みます。次のように修正します。bin/rails#!/usr/bin/env ruby APP_PATH = File.expand_path('../config/application', __dir__) require_relative '../config/boot' #### ↓↓↓↓↓↓↓↓追加 # 独自コマンドの組み込み require_relative '../lib/commands/main/main_command' #### ↑↑↑↑↑↑↑↑追加 # run rails command require 'rails/commands'独自コマンドを実装したファイル
lib/commands/main/main_command.rb
をrequire_relative
で読み込んでいます。独自コマンドを rails コマンドのコマンド一覧へ登録する処理がRails::Command::Base
にありますので、読み込むだけで rails コマンドに登録されます。試しに
bin/rails
を実行してみます。$ bin/rails ... routes runner sample_app:hello secret secrets:edit ...
多数のコマンドが出力されるので少しわかりづらいですが、rails の標準コマンドに混じって
sample_app:hello
と独自コマンドが表示されています。試しに独自コマンドを実行してみます。
$ bin/rails sample_app:hello hello
モデルの操作とサブコマンド
コンソールに文字列を表示するような単純な処理ならこのままでも問題ありませんが、Rails アプリケーションが初期化されていないので、モデルを検索したり、作成したり、削除したりすることはできません。
Rails アプリケーションの初期化方法と合わせて、モデルを作成する独自コマンドを
sample_app
のサブコマンドとして追加する方法をみていきます。まず、
main_command.rb
を修正してサブコマンドを追加します。lib/commands/main/main_command.rbrequire "rails/command" require_relative '../user/user_command' require_relative '../micropost/micropost_command' module SampleApp module Command class MainCommand < Rails::Command::Base namespace "sample_app" @command_name = "sample_app" subcommand "user", SampleApp::Command::UserCommand subcommand "micropost", SampleApp::Command::MicropostCommand end end endRails アプリケーションが初期化されていないので、クラスのオートロードは効きません。
require_relative
を用いて明示的にuser_command.rb
とmicropost_command.rb
を読み込む必要があります。クラスを読み込んだ後、Thor のsubcommand
命令でuser
とmicropost
という2つのサブコマンドを追加しています。
user
サブコマンドの実体user_command.rb
は次のように実装します。lib/commands/user/user_command.rbmodule SampleApp module Command class UserCommand < Rails::Command::Base desc "list", "list users." def list require_application_and_environment! say say "#{'Name'.ljust(14)} #{'Email'.ljust(32)} Updated At" say "-" * 80 User.all.each do |user| say "#{user.name.ljust(14)} #{user.email.ljust(32)} #{user.updated_at.iso8601}" end end end end end
user_command.rb
では、ユーザー一覧を表示するlist
というコマンドを定義しています。このコマンドはbin/rails sample_app:user list
と実行することを意図しています。
list
の先頭でrequire_application_and_environment!
を呼び出し Rails アプリケーションを初期化し、続いて User をデータベースから読み込み、コンソールに出力しています。
なお、Rails アプリケーションの初期化後は、オートロードが効くようになるので、User モデルを明示的に読み込む必要はありません。
micropost
サブコマンドの実体micropost_command.rb
を次のように実装します。lib/commands/micropost/micropost_command.rbmodule SampleApp module Command class MicropostCommand < Rails::Command::Base desc "list", "list microposts." def list require_application_and_environment! say say "#{'Name'.ljust(14)} #{'Content'.ljust(14)} Created At" say "-" * 80 Micropost.all.each do |post| say "#{post.user.name.ljust(14)} #{post.content.ljust(14)} #{post.created_at.iso8601}" end end end end endほぼ
user_command.rb
と同じで、こちらの方は Micropost モデルの一覧をコンソールに出力しています。サブコマンドを追加できたら試しに実行してみます。
$ bin/rails sample_app:user list Name Email Updated At -------------------------------------------------------------------------------- sample sample@example.jp 2020-12-05T05:33:11ZRails チュートリアルを少し進め、ユーザーを登録したら、上のように出力されます。
rails コマンドのその他の実行方法
単に
rails
と実行した場合もbundle exec rails
などと実行した場合もbin/rails
ファイルが実行されますので、bin/rails sample_app:user list
に代えてbundle exec rails sample_app:user list
と実行することもできます。要検討・改善点など
- Rails の作法にならって
Rails::Command::Base
を継承したApplicationCommand
というクラスを作成し、ApplicationCommand
クラスを継承するようにした方が良いのかも?help
コマンドがなからず追加されるが、help
コマンドを実行するとエラーになる。例bin/rails sample_app:help
やbin/rails sample_app:user help
など。
- 理由は標準の
help
コマンドが Rails エンジンしかサポートしてない。Rails アプリケーションは全く考慮されていない。- 改善方法としては
ApplicationCommand
クラスでhelp
コマンドを独自実装するのが良いのかなと考えています。
- 投稿日:2020-12-08T16:39:05+09:00
【個人開発】SOFT SKILLSで紹介されているタスク管理法に特化したWebアプリを作ってみた
Rails1ヶ月チャレンジ 1つ目:PomoTask (タスク管理ツール)
※Railsの勉強として、1ヶ月に1個アプリを作っています
作ったWebアプリのリンク:https://pomo-task.herokuapp.com/
はじめに
SOFT SKILLSで紹介されていた時間管理法をご存知ですか?
このタスク管理法はポモドーロテクニック、カンバンなどを融合していて、自宅での作業が効率的に行えるようになっています。
とても良い方法ですが、不満な点が1つだけありました。それがツールの使いやすさです。SOFT SKILLSではKanban Flowが紹介されていましたが、このサービスでは個人の時間管理に特化しているわけではないので、少し不満が出てきます。
- 集団での利用を想定して作られているため、個人では使いづらい
- ポモドーロタイマーが使いづらい
- タスクの締め切りを把握しにくい
- 全体のデザインは硬め
この点を解決すべく他のサービスを探していましたが、自分の好みに合うものがなかなか見つかりませんでした。なので自分で作ることに決めました。
また、前からRailsを扱えるようになりたいと思っていたということもあります。毎月新しいプロジェクトを作ると良いと聞いたので、その1つ目です。RubyもRailsも始めて1ヶ月なので至らない点が多いと思いますが、どうかご覧いただければ幸いです。
作ったもの
先日、PomoTaskというWebアプリをリリースしました。
https://pomo-task.herokuapp.com/
推しポイント
- ポモドーロタイマーの使い心地
- START, SKIP, STOPのみの簡単な操作性
- 何回目かが分かる
- 音とブラウザ通知で開始、終了1分前、終了をお知らせ
- 目標
- その日、週のポモドーロ数が分かる
- 年、四半期、月、週の目標を確認できる
- 週目標は常に見られる
- 締め切りの見やすさ
- 曜日ルーチン
- 毎週行うタスクは自動で追加できる
- 集中力を高めるための仕掛け
- ポモドーロタイマーの色が変わる
- 集中力を高めるためのコツをヘルプに掲載
- 色、視認性
- タスクの背景色
おわりに
最低限使えるレベルのものが作れたような気がします。とりあえず今後は、新しいWebアプリを作ったり、PomoTaskに機能を追加しようと考えています。
付けるべき機能や改善した方がいい点があれば、コメントしていただけると嬉しいです。
参考資料
- SOFT SKILLS ソフトウェア開発者の人生マニュアル - ジョン・ソンメズ
- 自分を操る超集中力 - メンタリストDaiGo
- どんな仕事も「25分+5分」で結果が出る ポモドーロ・テクニック入門 - フランチェスコ・シリロ (勉強に使った参考書:現場で使える Ruby on Rails 5速習実践ガイド)
- 投稿日:2020-12-08T16:06:58+09:00
[Rails]複数モデルの検索機能
はじめに
検索機能をつけていきたいと思います。
簡易的なものですがポートフォリオ作成等の参考にしてください。今回は検索対象をユーザーか投稿かを選べるようにする且、検索方法を完全一致・部分一致から選択できるようにしたいと思います。(前方一致・後方一致もアレンジで付け加えられるように少しだけ説明します。)
また検索結果を一覧にして新しいページに表示させます。尚、gem ransackは使いません。
こんな人に向けて
1.検索機能の大枠を実装したい人。
2.gem ransackを使いたくない人。
3.実装方法のイメージが湧かない初学者。1.実装をはじめる前に
まずどのように検索していくかの手順を先に説明していきます。
①検索フォームで入力内容を受け取り、コントローラに送る
②コントローラとモデルがデータベースからデータを受け取る。
③ビューに表示させる。簡単にまとめると上記のような流れです。
またこれから実装するにあたって
・if-else文
・whereメソッド
の2点は基本であり重要でもあります。
分からない人は参考になるサイトがネットにたくさんあるので、そちらを見ながら進めてください。2.検索機能の実装
2-1.コントローラーの作成
それでは実装していきたいと思います。
まずコントローラーを作成します。コントローラ名とアクション名は分かりやすければ何でもOKです。(searchやfinder等)
今回はFindersコントローラにfinderアクションをつくります。ターミナルrails g controller Finders finderこれでコントローラ・アクション・ビューが自動作成されました。
2-2.ルートの設定
検索ボタンが押された際にどこのコントローラの何のアクションにリクエストが飛ぶのか設定します。
尚、finderアクションを使っているためresourcesは使用できません。config/routes.rbget 'finder' => "finders#finder"ターミナルで
rails routes
と入力してみましょう。
finder GET /finder(.:format) finders#finder
と出てくるはずです。
これでfinder_path
でリクエストされた際に、findersコントローラのfinder
アクションに飛ぶようになりました。2-3.検索フォームの作成
アクションの中身を書く前に検索フォームから作成します。
form_withを使って入力された内容を受け取っていきます。
検索フォームを設置するページは人によって異なるので、ひとまずはapplication.html.erb
に書きます。
別のページに設置したとしても後の記述は変わりません。app/views/layouts/application.html.erb<%= form_with url: finder_path, method: :get, local: :true do |f| %> <%= f.select :range, options_for_select([['User'], ['Post']]) %> <%= f.select :search, options_for_select([["完全一致","perfect_match"], ["部分一致","partial_match"]]) %> <%= f.text_field :word %> <%= f.submit "検索" %> <% end %>分解して説明します。
<%= form_with url: finder_path, method: :get, local: :true do |f| %> //findsコントローラのfindアクションに送る情報 <% end %>
form_with ~ do |f|
は入力内容を受け取る定型文です。
最後のf
の部分はform
でもa
でも大丈夫ですが、多くの人はf
を使っています。
url: finder_path
で受け取る情報をどこに送るかを指定しています。
method: :get
はgetメソッドを使うことを宣言しています。
rails routes
にfinder GET /finder(.:format) finders#finder
とあるようにGETメソッドが指定されています。
local: :true
同期通信なのでこのように書きます。<%= f.select :range, options_for_select([['User'], ['Post']]) %> <%= f.select :search, options_for_select([["完全一致","perfect_match"], ["部分一致","partial_match"]]) %>
f.select :range
:選択されたものをrangeとしてアクションに送るようにしています。
f.select
はdo |f|
の記述によって変わり、do |form|
とするならばform.select
とする必要があります。
options_for_select( [ ['要素1'],['要素2'] ] )
:これでタブが作成されます。
要素1・要素2は文字列として扱うのでシングル(ダブル)クォーテーションで囲んでください。
options_for_select( [ ['要素1','A'], ['要素2','B'] ] )
とすることも可能で、要素1とAは同じものとして扱われます。タブには最初の要素1・要素2が表示されます。
また2つの要素だけでなくもっと増やしたい場合は
( [ ['要素1'],['要素2'],['要素3'],['要素4']・・・ ] )
としてください。<%= f.text_field :word %>入力されたものをwordとしてアクションに送るようにしています。
<%= f.submit "検索" %>入力結果を送信します。
"検索"
でボタン内の文字を変えています。検索フォームは完成です。
2-4.モデルの追記
finderアクションで使用する、searchesとwordsの引数を受け取るlooksメソッドをモデルに作成します。
app/models/user.rbdef self.looks(searches, words) if searches == "perfect_match" @user = User.where("name LIKE ?", "#{words}") else @user = User.where("name LIKE ?", "%#{words}%") end endまず条件分岐させ検索方法を変えます。
またwhereメソッドを使いデータベースから該当するのものを全て受け取り@user
に保管します。2-3で説明したように要素を増やし、条件分岐を加え、
"#{words}"
の部分を書き換えることで前方一致や後方一致の検索もできます。2-5.アクションの記述
app/controllers/finders_controller.rbdef finder @range = params[:range] search = params[:search] word = params[:word] @users = User.looks(search, word) end先ほどのフォームで入力された情報をここで受け取ります。
@range = params[:range]
、search = params[:search]
、word = params[:word]
:それぞれ検索フォームで選択・入力された情報を変数に代入しています。後にビューでも使うので@range
と@users
はインスタンス変数にしています。@users = User.looks(search, word)2-4で作ったlooksメソッドを使い、検索結果を
@users
に代入しています。フォームで選択された検索方法は
f.select :search
→params[:search]
→search
→User.looks(search, word)
→def self.looks(searches, words)
→if searches == "perfect_match"
と送られていることになります。
params[:search]
→search
→User.looks(search, word)
の部分をまとめてapp/controllers/finders_controller.rbdef finder @range = params[:range] @users = User.looks(params[:search], params[:word]) endとすることもできます。上記の書き方で進めていきます。
2-6.アクション内での条件分岐
2-5のままではユーザーの検索しかできません。
なのでif文を使い、ユーザーか投稿かを切り替えられるようにします。app/controllers/finders_controller.rbdef finder @range = params[:range] if @range == "User" @users = User.looks(params[:search], params[:word]) else @posts = Post.looks(params[:search], params[:word]) end end分解して説明します。
if @range == "User" //ユーザーから探す処理(user.rbのlooksメソッドを使用) else //投稿から探す処理(post.rbのlooksメソッドを使用) end
@range
にはUserかPostが入っています。
それをif-else文で分けて各モデルのlooksメソッドを使います。2-5で
user.rb
に作成したlooksメソッドを一部書き換えてapp/models/post.rb
にも記述します。app/models/post.rbdef self.looks(searches, words) if searches == "perfect_match" @post = Post.where("name LIKE ?", "#{words}") else @post = Post.where("name LIKE ?", "%#{words}%") end endこれで検索対象も切り替えられるようになりました。
最後にビューを作成して完成です。
2-7.ビューの作成
検索結果一覧を表示するページをつくります。
既存のページに表示することもできますが、今回は新しくページを作成します。app/views/finders/finder.html.erb<% if @range == "User" %> <% @users.each do |user| %> <%= user.name %> //例(ユーザーの名前を表示) <% end %> <% else %> <% @posts.each do |post| %> <%= post.title %> //例(投稿のタイトルを表示) <%= post.text %> //例(投稿の本文を表示) <% end %> <% end %>分解して説明します。
<% if @range == "User" %> //検索対象がUserのとき、ユーザーを一覧表示 <% else %> //検索対象がPostのとき、投稿を一覧表示 <% end %>アクションと同じようにif-else文で条件分岐させています。
<% @users.each do |user| %> <%= user.name %> //例(ユーザーの名前を表示) <% end %> -------------------------------------------------- <% @posts.each do |post| %> <%= post.title %> //例(投稿のタイトルを表示) <%= post.text %> //例(投稿の本文を表示) <% end %>finderアクションでそれぞれ変数定義しましたが、
@users
@posts
には検索に該当するデータが全て含まれています。
それをeach文で繰り返し表示させるよう指示しています。これで完成です。
3.最後に
今回は検索機能の大枠をつくりました。これを活かして細かい部分はアレンジができます。
ぜひ試してください。また実装方法はいろいろありますので、当記事だけでなく他の記事も参考にしてみてください。
- 投稿日:2020-12-08T16:06:58+09:00
検索方法・検索対象を選択できる検索機能を実装する方法(ransack未使用)
はじめに
検索機能をつけていきたいと思います。
簡易的なものですがポートフォリオ作成等の参考にしてください。今回は検索対象をユーザーか投稿かを選べるようにする且、検索方法を完全一致・部分一致から選択できるようにしたいと思います。(前方一致・後方一致もアレンジで付け加えられるように少しだけ説明します。)
また検索結果を一覧にして新しいページに表示させます。尚、gem ransackは使いません。
こんな人に向けて
1.検索機能の大枠を実装したい人。
2.gem ransackを使いたくない人。
3.実装方法のイメージが湧かない初学者。1.実装をはじめる前に
まずどのように検索していくかの手順を先に説明していきます。
①検索フォームで入力内容を受け取り、コントローラに送る
②コントローラとモデルがデータベースからデータを受け取る。
③ビューに表示させる。簡単にまとめると上記のような流れです。
またこれから実装するにあたって
・if-else文
・whereメソッド
の2点は基本であり重要でもあります。
分からない人は参考になるサイトがネットにたくさんあるので、そちらを見ながら進めてください。2.検索機能の実装
2-1.コントローラーの作成
それでは実装していきたいと思います。
まずコントローラーを作成します。コントローラ名とアクション名は分かりやすければ何でもOKです。(searchやfinder等)
今回はFindersコントローラにfinderアクションをつくります。ターミナルrails g controller Finders finderこれでコントローラ・アクション・ビューが自動作成されました。
2-2.ルートの設定
検索ボタンが押された際にどこのコントローラの何のアクションにリクエストが飛ぶのか設定します。
尚、finderアクションを使っているためresourcesは使用できません。config/routes.rbget 'finder' => "finders#finder"ターミナルで
rails routes
と入力してみましょう。
finder GET /finder(.:format) finders#finder
と出てくるはずです。
これでfinder_path
でリクエストされた際に、findersコントローラのfinder
アクションに飛ぶようになりました。2-3.検索フォームの作成
アクションの中身を書く前に検索フォームから作成します。
form_withを使って入力された内容を受け取っていきます。
検索フォームを設置するページは人によって異なるので、ひとまずはapplication.html.erb
に書きます。
別のページに設置したとしても後の記述は変わりません。app/views/layouts/application.html.erb<%= form_with url: finder_path, method: :get, local: :true do |f| %> <%= f.select :range, options_for_select([['User'], ['Post']]) %> <%= f.select :search, options_for_select([["完全一致","perfect_match"], ["部分一致","partial_match"]]) %> <%= f.text_field :word %> <%= f.submit "検索" %> <% end %>分解して説明します。
<%= form_with url: finder_path, method: :get, local: :true do |f| %> //findsコントローラのfindアクションに送る情報 <% end %>
form_with ~ do |f|
は入力内容を受け取る定型文です。
最後のf
の部分はform
でもa
でも大丈夫ですが、多くの人はf
を使っています。
url: finder_path
で受け取る情報をどこに送るかを指定しています。
method: :get
はgetメソッドを使うことを宣言しています。
rails routes
にfinder GET /finder(.:format) finders#finder
とあるようにGETメソッドが指定されています。
local: :true
同期通信なのでこのように書きます。<%= f.select :range, options_for_select([['User'], ['Post']]) %> <%= f.select :search, options_for_select([["完全一致","perfect_match"], ["部分一致","partial_match"]]) %>
f.select :range
:選択されたものをrangeとしてアクションに送るようにしています。
f.select
はdo |f|
の記述によって変わり、do |form|
とするならばform.select
とする必要があります。
options_for_select( [ ['要素1'],['要素2'] ] )
:これでタブが作成されます。
要素1・要素2は文字列として扱うのでシングル(ダブル)クォーテーションで囲んでください。
options_for_select( [ ['要素1','A'], ['要素2','B'] ] )
とすることも可能で、要素1とAは同じものとして扱われます。タブには最初の要素1・要素2が表示されます。
また2つの要素だけでなくもっと増やしたい場合は
( [ ['要素1'],['要素2'],['要素3'],['要素4']・・・ ] )
としてください。<%= f.text_field :word %>入力されたものをwordとしてアクションに送るようにしています。
<%= f.submit "検索" %>入力結果を送信します。
"検索"
でボタン内の文字を変えています。検索フォームは完成です。
2-4.モデルの追記
finderアクションで使用する、searchesとwordsの引数を受け取るlooksメソッドをモデルに作成します。
app/models/user.rbdef self.looks(searches, words) if searches == "perfect_match" @user = User.where("name LIKE ?", "#{words}") else @user = User.where("name LIKE ?", "%#{words}%") end endまず条件分岐させ検索方法を変えます。
またwhereメソッドを使いデータベースから該当するのものを全て受け取り@user
に保管します。2-3で説明したように要素を増やし、条件分岐を加え、
"#{words}"
の部分を書き換えることで前方一致や後方一致の検索もできます。2-5.アクションの記述
app/controllers/finders_controller.rbdef finder @range = params[:range] search = params[:search] word = params[:word] @users = User.looks(search, word) end先ほどのフォームで入力された情報をここで受け取ります。
@range = params[:range]
、search = params[:search]
、word = params[:word]
:それぞれ検索フォームで選択・入力された情報を変数に代入しています。後にビューでも使うので@range
と@users
はインスタンス変数にしています。@users = User.looks(search, word)2-4で作ったlooksメソッドを使い、検索結果を
@users
に代入しています。フォームで選択された検索方法は
f.select :search
→params[:search]
→search
→User.looks(search, word)
→def self.looks(searches, words)
→if searches == "perfect_match"
と送られていることになります。
params[:search]
→search
→User.looks(search, word)
の部分をまとめてapp/controllers/finders_controller.rbdef finder @range = params[:range] @users = User.looks(params[:search], params[:word]) endとすることもできます。上記の書き方で進めていきます。
2-6.アクション内での条件分岐
2-5のままではユーザーの検索しかできません。
なのでif文を使い、ユーザーか投稿かを切り替えられるようにします。app/controllers/finders_controller.rbdef finder @range = params[:range] if @range == "User" @users = User.looks(params[:search], params[:word]) else @posts = Post.looks(params[:search], params[:word]) end end分解して説明します。
if @range == "User" //ユーザーから探す処理(user.rbのlooksメソッドを使用) else //投稿から探す処理(post.rbのlooksメソッドを使用) end
@range
にはUserかPostが入っています。
それをif-else文で分けて各モデルのlooksメソッドを使います。2-5で
user.rb
に作成したlooksメソッドを一部書き換えてapp/models/post.rb
にも記述します。app/models/post.rbdef self.looks(searches, words) if searches == "perfect_match" @post = Post.where("name LIKE ?", "#{words}") else @post = Post.where("name LIKE ?", "%#{words}%") end endこれで検索対象も切り替えられるようになりました。
最後にビューを作成して完成です。
2-7.ビューの作成
検索結果一覧を表示するページをつくります。
既存のページに表示することもできますが、今回は新しくページを作成します。app/views/finders/finder.html.erb<% if @range == "User" %> <% @users.each do |user| %> <%= user.name %> //例(ユーザーの名前を表示) <% end %> <% else %> <% @posts.each do |post| %> <%= post.title %> //例(投稿のタイトルを表示) <%= post.text %> //例(投稿の本文を表示) <% end %> <% end %>分解して説明します。
<% if @range == "User" %> //検索対象がUserのとき、ユーザーを一覧表示 <% else %> //検索対象がPostのとき、投稿を一覧表示 <% end %>アクションと同じようにif-else文で条件分岐させています。
<% @users.each do |user| %> <%= user.name %> //例(ユーザーの名前を表示) <% end %> -------------------------------------------------- <% @posts.each do |post| %> <%= post.title %> //例(投稿のタイトルを表示) <%= post.text %> //例(投稿の本文を表示) <% end %>finderアクションでそれぞれ変数定義しましたが、
@users
@posts
には検索に該当するデータが全て含まれています。
それをeach文で繰り返し表示させるよう指示しています。これで完成です。
3.最後に
今回は検索機能の大枠をつくりました。これを活かして細かい部分はアレンジができます。
ぜひ試してください。また実装方法はいろいろありますので、当記事だけでなく他の記事も参考にしてみてください。
- 投稿日:2020-12-08T15:54:40+09:00
Gem 'Gimei'
Gimeiとは
日本人の名前やフリガナ、住所などを自動生成してくれるGem。
有名なGemでFakerがあるが、Fakerでは対応できないフリガナを使うことができる。
Gimei使い方
開発環境とテスト環境で利用するのでgroup :development, :test doの内部でgemを指定、Gemfileを編集したらアプリケーションのディレクトリでbundle installを実行。
Gemfilegroup :development, :test do # 省略 gem 'rspec-rails' gem 'factory_bot_rails' gem 'faker' gem 'gimei' endコンソールで実行するとこんな感じで名前が生成される。
console[1] pry(main)> japanese_user = Gimei.name # 省略 [2] pry(main)> japanese_user.last.kanji => "島村" [3] pry(main)> japanese_user.last.katakana => "シマムラ"FactoryBotと組み合わせて架空のユーザーを生成する。
インスタンスを生成せずにGimei.name.first.kanji
などを入れると、名前とフリガナが一致しなくなる。factories/users.rbFactoryBot.define do factory :user do # インスタンスを生成 japanese_user = Gimei.name first_name { japanese_user.first.kanji } first_name_kana { japanese_user.first.katakana } last_name { japanese_user.last.kanji } last_name_kana { japanese_user.last.katakana } end endconsole[1] pry(main)> FactoryBot.create(:user) # 以下実行結果が表示される。
- 投稿日:2020-12-08T15:27:05+09:00
【Rails】表の合計値算出方法(aggregate関数)
はじめに
Railsで表の合計値を算出するにあたって、aggregate関数なるものが便利でスマートだったので、記事にしてみました。
開発環境
IDE:Cloud9
Ruby:2.6.3
Rails:5.2.4実例をみてみる
やりたいこと
ER図
aggregate関数を使わない場合
controllers/carts.rbdef new @carts = Cart.where(user_id: current_user.id) end例えば、たんぱく質(protain)の合計値を表示したい場合
views/carts/new.html.rb<% sum = 0 %> <% @carts.each do |cart| %> <% sum += cart.food.protain %> <% end %> <%= sum %>これだと、1つの項目(上記の場合たんぱく質)を表示するのに、5行も必要となってしまい、見ため的にあまりスマートとはいえない。
views/carts/new.html.rb<td>合計</td> <td> <% sum = 0 %> <% @carts.each do |cart| %> <% sum += cart.food.calorie %> <% end %> <%= sum %> </td> <td> <% sum = 0 %> <% @carts.each do |cart| %> <% sum += cart.food.protain %> <% end %> <%= sum %> </td> <td> <% sum = 0 %> <% @carts.each do |cart| %> <% sum += cart.food.fat %> <% end %> <%= sum %> </td> <td> <% sum = 0 %> <% @carts.each do |cart| %> <% sum += cart.food.carbon %> <% end %> <%= sum %> </td>表全体を表示すると、割とfat感がある。
そこでaggregate関数を用いて、もっとスマートに記述する。aggregate関数を使う方法
コントローラはさっきと一緒。
controllers/carts.rbdef new @carts = Cart.where(user_id: current_user.id) endカートモデルに以下を記述する。
model/cart.rbdef self.aggregate(column) self.all.map { |cart| cart.food[column] }.sum endviews/carts/new.html.rb<td>合計</td> <td> <%= @carts.aggregate(:calorie) %> </td> <td> <%= @carts.aggregate(:protain) %> </td> <td> <%= @carts.aggregate(:fat) %> </td> <td> <%= @carts.aggregate(:carbon) %> </td>めっちゃすまーと。
- 投稿日:2020-12-08T14:57:12+09:00
Rails database.ymlの記載内容の適用のされ方に関するメモ
記事の目的
データベースの接続の設定ファイルであるdatabase.ymlの記載内容の適用のされ方に関するメモ
default: &default adapter: mysql2 encoding: utf8mb4 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> username: <%= ENV["DB_USERNAME"] %> password: <%= ENV["DB_PASSWORD"] %> host: localhost development: <<: *default database: book_action_development test: <<: *default database: book_action_test production: <<: *default database: book_action_production username: root password: <%= Rails.application.credentials.db[:password] %> host: <%= Rails.application.credentials.db[:hostname] %>勘違い
あるエラーが起きて、
default部分に記載のusername: <%= ENV["DB_USERNAME"] %>と
productionに記載のusername: root
が競合してしまうと思っていた。実際
競合はせず、productionに記載のusername: rootが優先されるそう。
まとめ
なぜそうなるかは詳しく調べられていないので、今後調べる。
- 投稿日:2020-12-08T14:23:42+09:00
Basic認証 Part 1
始めに
今回は、RailsアプリケーションにBasic認証を導入する方法について説明します。練習としてデプロイしている自身のアプリに対してサーバーの負荷を増やしたくないときなどは、Basic認証を導入して閲覧できるユーザーを制限しましょう。
Basic認証
Basic認証とは、HTTP通信の規格に備え付けられているユーザー認証の仕組みです。
サーバーと通信が可能なユーザーとパスワードをあらかじめ設定しておき、それを知っているユーザーのみがWebアプリーションを利用できるようにすることができます。Ruby on Railsには、Basic認証を導入するためのメソッドが用意されており、簡単に実装することができます。
便利な世の中ですね(笑)authenticate_or_request_with_http_basic
Ruby on RailsでBasic認証を実装するためのメソッドです。
ブロックを開き、ブロック内部でusernameとpasswordを設定することでBasic認証を利用できます。サンプル# 'admin'というユーザー名と、'password'というパスワードでBasic認証できるように設定 authenticate_or_request_with_http_basic do |username, password| username == 'admin' && password == 'password' endBasic認証のRailsアプリケーションへの導入
authenticate_or_request_with_http_basicメソッドを利用して、開発中のRailsアプリケーションにBasic認証を導入しましょう。
RailsアプリケーションにBasic認証を導入しよう
Basic認証によるログインの要求は、全てのコントローラで行いたいです。そこで、Basic認証の処理をapplication_controller.rbにて、private以下にメソッドとして定義し、before_actionで呼び出すように実装しましょう。
app/controllers/application_controller.rbclass ApplicationController < ActionController::Base before_action :basic_auth private def basic_auth authenticate_or_request_with_http_basic do |username, password| username == 'admin' && password == '2222' end end endこれで、どのページにアクセスしてもBasic認証が要求されるようになりました。今回は、adminというユーザー名と2222というパスワードでBasic認証ができるように設定しています。
Basic認証の動作を確認しよう
application_controller.rbの記述が完了したら、実際にアプリケーションを起動し、Basic認証の動作が期待したものになっているか確認してみましょう。
'rails s'をした上で、適当なページにアクセスすると、ユーザー名とパスワードの入力を求めるポップアップウインドウが表示されます。間違ったユーザー名とパスワードではBasic認証できないことを確かめるために、あえて適当な文字を入力してみましょう。以下のGifでは、ユーザー名にhogehoge、パスワードに1111と入力しています。
正しく実装できていれば、もう一度Basic認証用のポップアップウインドウが表示されます。 間違った情報ではログインできないことを確認できたので、今度は正しい情報を入力してログインできるかどうかを確かめてみましょう。ユーザー名にadmin、パスワードに2222と入力して、もう一度ログインを試みてください。
ルーティングに対応したページが表示されれば、Basic認証に成功しています。
ひとまず長いので、Part2続きます。
ここまでで、問題点が2つほどあるので考えてみてくだい。
- 投稿日:2020-12-08T14:11:18+09:00
CSRFについて
はじめに
Webアプリケーションには、利用者自らの操作では取り消しのできない重要な処理があります。ここでいう「重要な処理」とはECサイトでのクレジットカードでの決済やメールの送信、パスワードの変更などのことをいいます。
こういった重要な処理の過程で不備があると、クロスサイトリクエストフォージェリ(以下CSRFと省略)の脆弱性が生じる場合があります。CSRFは「シーサーフ」もしくは「シーエスアールエフ」と呼ばれます。CSRF(クロスサイトリクエストフォージェリ)
本章では掲示板への投稿やメールの送信、ECサイトでの商品購入など本来外部より実行されてはいけない重要な処理を、なんらかの攻撃手法を用いて実行させてしまうという攻撃手法であるCSRF(クロスサイトリクエストフォージェリ)の概要と対策方法を学習していきます。
CSRFとは
Webサイトにスクリプトや自動転送(HTTPリダイレクト)を仕込むことによって、利用者に意図せず別のWebサイト上で何らかの操作(掲示板への書き込みや銀行口座への送金など)を行わせる攻撃手法のことをいいます。
CSRFの脆弱性が存在すると以下のような被害を被る可能性があります。利用者のアカウントによる物品の購入
利用者の退会処理
利用者のアカウントによる掲示板への書き込み
利用者のパスワードやメールアドレスが変更
CSRF脆弱性の影響は「重要な処理」の悪用に限られるため、CSRFの脆弱性を個人情報の取得等に用いることはできません。CSRFの攻撃例
利用者が罠サイトを閲覧することによってパスワードが変更されてしまう例
利用者がexample.jpにログインしている
攻撃者は罠を作成
利用者が罠を閲覧する
罠のJavaScriptによる、被害者のブラウザ上で攻撃対象サイトに対し、新しいパスワードabcdefがPOSTメソッドにより送信される
パスワードが変更されるCSRFの対策
一般的な対策方法
CSRF攻撃を防ぐには、「重要な処理」に対するリクエストが利用者の意図によるものかどうかを確認することが必要
このためCSRF対策としては、以下の2点が考えられる。CSRF対策の必要なページを区別する
正規利用者の意図したリクエストを区別できるように実装するCSRF対策の必要なページを区別する
CSRF対策はすべてのページに行う必要はありません。むしろ、対策の必要がないページの方が多いのです。対策に必要なページは、他のサイトから勝手に実行されては困るようなページです。例えば、ECサイトの物品購入ページや、パスワード変更など個人情報の編集確定画面などが挙げられます。
CSRFの対策としては、まず実装するWebアプリケーションのどのページに脆弱性対策が必要なのか設計段階で明らかにすることです。
以下の図のように、機能一覧の中でCSRFの対策が必要なページを色分けすると良いと思います。正規利用者の意図したリクエストを区別できるように実装する
CSRF対策で必要なことは、正規利用者の意図したリクエストなのかどうかということです。
ここでの意図したリクエストとは、利用者が対象のアプリケーション上で「実行」ボタンなどを押して、「重要な処理」のリクエストを発行することをいいます。
正規のリクエストかどうかを判断する方法は3種類あります。秘密情報の埋め込み
・パスワードの再入力
・Refererのチェック
・Refererによるチェックは少し難しいので、上の二つについて説明します。秘密情報の埋め込み
登録画面や注文確定画面などのCSRF攻撃への対策が必要なページに対して、第三者の不正利用者が知り得ない秘密情報を要求するようにすれば、不正リクエストによる重要な処理が実行されることはありません。このような目的で使用される秘密情報のことをトークンといいます。
パスワードの再入力
こちらは文字通り重要な処理が確定する前に、再度パスワードを入力してもらいます。これはCSRF対策の他にも物品の購入などに先立って、利用者の意思の念押しをしたり、共用のPCにおいて正規の利用者以外の利用者が、重要な処理を実行するのを防いだりする効果があります。
CSRFの攻撃例として、とりあげたパスワード変更ページにも現在のパスワードを再入力させることによりCSRF攻撃を防ぐことが可能です。
しかし、対策が必要なページ以外でパスワードの再入力を求めるページが複数あると煩雑なアプリケーションになってしまうため、注意が必要です。RailsでのCSRF対策方法
それではRailsではどのようにCSRF対策を行っていけばいいのでしょうか。
実は、Rails側できちんと対策を行ってくれています。基本的には開発者はなにもしなくても大丈夫です。
それでは実際にRailsがどのようにCSRF対策を行っているか見ていきましょう。ChatSpaceのapplication_controller.rbを開いてみましょう。
app/controllers/application_controller.rbclass ApplicationController < ActionController::Base # 省略 protect_from_forgery with: :exception # 省略 endprotect_from_forgery with: :exceptionという記述があるかと思います。これがRailsアプリケーション内でCSRF対策を行うというという命令になります。
こちらをすべてのコントローラの親であるapplication_controller.rbに記述することによって、その子コントローラすべてに先ほど説明したようなCSRF対策をRails側で行ってくれます。
なおRails5.2以降では、ActionController :: Baseで、はじめからCSRF対策機能が有効になっているので、application_controller.rbにこの記述がなくなります。CSRF対策としてどのような事が行われているかというと、まずサイトのHTMLに一意のトークンを埋め込みます。これと同じトークンを、セッションcookie(クッキー)にも保存しています。ユーザーがPOSTリクエストを送信すると、HTMLに埋められているCSRFトークンも一緒に送信されます。あとは、サーバ側でページのトークンとセッション内のトークンを比較し、両者が一致することを確認したらリクエストを受け付けます。
まとめ
CSRFとはアプリケーション内で重要な処理が行われる場所で発生する攻撃手法である
CSRFを防ぐにはトークンを埋め込んだり、パスワードを再入力させたり、ページ遷移の際正規利用者しか知り得ない情報を用いることが有効
RailsでCSRF対策を行う際はコントローラ内にprotect_from_forgeryメソッドを記述する
- 投稿日:2020-12-08T12:32:12+09:00
AmazonLinux2 + Ruby on Rails6でSQLite古いエラー
エンタメ系企業の社内もろもろを担当しているakibinです。
Twitterアカウント @AkibinMusic
Youtubeチャンネル内容
AmazonLinux2を立てて、Railsをインストールしたけどアプリ起動したら以下がエラーが出た場合の対処方法を書き書きします。
【エラー】
Your version of SQLite (3.7.17) is too old. Active Record supports SQLite >= 3.8.なぜエラーが出るのか
AmazonLinux2にはsqlite3のバージョン3.7がデフォルトでインストールされており、これがyumで利用しているらしく、削除することはできません。がしかし、Rails 6を動かすには、sqlite3のバージョン3.8以上が必要なので、古い!と↑で怒ってるんですね。
対処方法
既存のSQLiteはほっておいて、3.8以上のSQLiteを別途インストールします。
# アプリ用のユーザのホームディレクトリで作業(私は) $ cd /home/appuser # 3.34.0をダウンロード $ wget https://www.sqlite.org/2020/sqlite-autoconf-3340000.tar.gz $ tar xzvf sqlite-autoconf-3340000.tar.gz $ cd sqlite-autoconf-3340000 # もとから入っているsqliteと競合しないように /opt/sqlite/sqlite3にインストールする $ ./configure --prefix=/opt/sqlite/sqlite3 $ make $ sudo make install # バージョン確認 $ /opt/sqlite/sqlite3/bin/sqlite3 --version 3.34.0 2020-12-01 16:14:00 a26b6597e3ae272231b96f9982c3bcc17ddec2f2b6eb4df06a224b91089fed5bアプリで使用するSQLiteとして、今回インストールしたSQLiteをgemで指定する。
# アプリ用のディレクトリに移動 $ cd /var/www/rails/webapp # gemで新しいSQLiteを指定 $ gem install sqlite3 -- --with-sqlite3-include=/opt/sqlite/sqlite3/include \ --with-sqlite3-lib=/opt/sqlite/sqlite3/lib # 指定が正しくできているか確認 $ irb irb(main):001:0> require 'sqlite3' => true irb(main):002:0> SQLite3::SQLITE_VERSION => "3.34.0" # できてる irb(main):003:0> exitあと新しいSQLiteにパスを通す必要があるので以下作業実施。
$ echo 'export LD_LIBRARY_PATH="/opt/sqlite/sqlite3/lib"' >> .bash_profile $ source .bash_profileこちらの記事参照してSQLite部分のみ抜粋させていただきました!わかりやすかったです!
今年も終わるは早いわ…
- 投稿日:2020-12-08T10:47:15+09:00
master.keyが無い問題の解決への道 [config/master.key: No such file or directory]
最初に
カレンダー企画2020の8日目
プログラミングの勉強を始めて3ヵ月程経ったので学んだことのメモをアウトプットとして記事に残します。
これからプログラミングの世界に入る人の手助けになれたら嬉しい限りです。
間違っていたり、言葉が違っていたり、誤解されるような言葉があったら教えてください^^
言葉を長々と読みづらかったら申し訳ありません。少しずつなれてがんばります。deployをしようとした時にmaster.keyがなくて困った
こんなのがエラーで出てくる^^;
ターミナルconfig/master.key: No such file or directory環境
Rails 5.2.4
無くなった原因
git push時にmaster.keyは除外されるようになっているのでGitHub上には残らない(GitHubにあがるとまずい^^;)
git cloneを行ったりするとmaster.keyがない状態になる。(私はこれが原因でした!)
他にもあるのかもですが分からないです!解決
- アプリケーションのディレクトリに移動
config/credentials.yml.enc
というファイルがあると思う。それを削除します- そうするとconfigの中には
master.key
とcredentials.yml.enc
がない状態になる- これをターミナルで実行
ターミナルsudo EDITOR="vi" rails credentials:edit
- するとconfigの中に
master.key
とcredentials.yml.enc
現れる。もし、このあとdeployをする場合はpushをしてdeploy環境下でpullを忘れずに!
credentials.yml.enc
の中身が変わっているのでこれをしないとまた違うエラーが出るよ!(経験済!^^)そもそも
master.key
とcredentials.yml.enc
って何??
credentials.yml.enc
が暗号化されているよう…
master.key
を使って復号化するみたい…2つはセットでどちらかが欠けてもダメってことですね!(調べて見て鍵と鍵穴の関係で説明されていました!)
とくにmaster.key
は大事なので保管方法にも気をつけた方が良いみたいです!これを見て勉強しました!(良かったらどうぞ)
参考サイト:https://techtechmedia.com/credentials-masterkey-rails/#masterkey-2最後に
自分が詰まったエラーだったのでメモのつもりで残しました!
- 投稿日:2020-12-08T09:44:38+09:00
[解決](Rails, Vue)CORSエラーAccess to XMLHttpRequest at 'http://localhost:3000' from origin 'http://localhost:8080' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
なぜこのエラーが出るのか。
https://qiita.com/att55/items/2154a8aad8bf1409db2b
解決方法(Rails, Vue)
railsのgem
gemfile#gem rack-coes
のコメントアウトを外し、
$bundle install↓
config/initializers/cors.rbにある記述も下のようにコメントアウト。config/initializers/cors.rb# Be sure to restart your server when you modify this file. # Avoid CORS issues when API is called from the frontend app. # Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin AJAX requests. # Read more: https://github.com/cyu/rack-cors Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do origins 'http://localhost:3000', 'https://localhost:8080/' resource '*', headers: :any, methods: [:get, :post, :put, :patch, :delete, :options, :head] end endoriginsは、サーバーサイド(API),フロントの順で書きます。
↓
Dockerの場合は、再度コンテナを立ち上げて
↓
完了。
- 投稿日:2020-12-08T08:21:14+09:00
gemのバージョン指定について
railsアプリケーションを作成するにあたって
gemファイル内のバージョン指定について理解が不十分だったので整理してみました。Gemfileとは
・これは何?
Bundlerというrubyのライブラリ管理システムのファイル。
・何ができる?
railsアプリで使用するライブラリを管理することができる。バージョンの基本の書き方
Gemfilegem 'gem名', 'バージョン', 'オプション'x.y.zの表記の意味
x:メジャーバージョン
重大な変更。新機能の追加や、多くのAPI変更が含まれる。
y:マイナーバージョン
新機能の追加やAPIの追加が含まれることがある。
z:パッチバージョン
バグの修正が含まれる。バージョン指定の指定について
固定
'x.y.z'
Gemfilegem 'sqlite3', '1.3.6'〜以上
'>=x.y.z'
Gemfilegem 'sqlite3', '>=1.3.6'x.x.x以上、x.y+1.0未満(メジャーアップデート不可)
'~>x.y.z'
Gemfilegem 'sqlite3', '~>1.3.6'以下と同義ですね。
Gemfilegem 'sqlite3', '>=1.3.6', '<1.4.0'x.y.z以降で最新のもの
'>=x.x.x'
Gemfilegem 'sqlite3', '>=0.8.5'参考
https://blog.yuhiisk.com/archive/2017/04/24/specify-the-version-of-gemfile.html
https://haayaaa.hatenablog.com/entry/2018/10/29/235952
- 投稿日:2020-12-08T07:38:31+09:00
初めてのwebアプリ実装
某プログラミングスクールの実装課題を完了したので、記録を残しておきます。
なお、私がかけた時間はトータル24時間強でした。スクールの定める予定時間を大幅に上回っていたため、恐らくかなりエラーに苦しめられた方だと思います。。なぜ記録を残すのか
将来の私のために、初めてアプリを作った時の新鮮な感情を残しておきたいため。
また、同じように初めての実装で悩んでいて、挫折しそうな人に、考え方の参考として見ていただきたいため。実装を通しての学び
実装を通しての学びは以下の3つです。
- アウトプットの重要性
- プログラミングの勉強においてエラー対処は利点しかない
- エラーの解決手順
1.アウトプットの重要性
これはどんな学問に対しても当てはまることです。ただ、学生の頃の試験勉強と比較すると、プログラミングの勉強の方が、よりアウトプットは重要だと思いました。
理由としては、答えは一つではないものの、答えを導き出すためには明確な論理がある、からです。
プログラミングにおいて、答えは一つではなく、明確な論理を持って実装に当たる必要があります。
答えは非常に多くのパターンがあり、実装のためには様々なことを頭の中から出せる状態にしておける必要があると思います。また、勉強したことを実行すれば100点が必ず取れるような物でもありません。様々な要因からエラーが必ずおきます。勉強したことをアウトプットできる状態にして、さらに予測不能なエラーに対処できる力を身に付ける。これがプログラミングにおけるアウトプットの意義だと思います。
2.プログラミングの勉強においてエラー対処は利点しかない
これはどんな人にとっても間違いないことだと思います。
なぜなら、エラーでは、不備の箇所を明確にしてくれるからです。
例えば、数学の問題を間違えたとして、どこが分かっていないかは、自分で考える必要があります。反対にプログラミングは、どこが間違っているかを明確にしてくれて(エラー文が表示されている場合)、どのように修正するべきかも提案してくれたりします(Did you mean)。
エラーってポジティブ要素に聞こえてきませんか?笑3.エラーの解決手順
MVC実装程度のアプリ開発におけるエラー解決手順ですが、私が実際にエラー対処する中での解決手順です。
1)エラー文の解析
簡単なエラー文やDid you mean?が出ていれば、即解決です。複雑なエラー文であっても、検索すれば、簡単に解決できることも。
2)エラー文で指摘されているファイルのチェック
スペルミスや構文ミス(特にend抜け)がないかをチェック
3)MVCの流れをチェック
1),2)で解決しない場合は、 MVCの処理がおかしくなっているはずなので、流れをチェックします
4)分かっている人に質問する
1)~3)を実施して不可能な場合は大人しく諦めましょう。ただし、質問の仕方としては、参考資料を持って、こうしようとしたけど、思い通りにいかなかった、などしっかり考えた証を見てもらいながらだといいと思います。1)〜3)については時間をかければかけるほど、4)での定着率は高まります。私はdevise導入時に、マイグレーションの
記述を消しすぎていることに気づかず、6時間以上ストップしていました。もう一生userモデルのマイグレーションファイルの記述をおざなりにすることはないと思います。まとめ
グダグダと書いてきましたが、結論、僕が伝えたいことは、エラー発生はポジティブ要素だということです。
エラーにかける時間が長ければ長いほど、特にだと思います。
実装の達成感はとてつもないです。とにかくやり切ることが成長につながると思います。
- 投稿日:2020-12-08T01:37:06+09:00
DeviseのUserテーブルにUpdateアクションでカラムを更新しようとするとうまくいかない現象について
事の発端
Deviseで作ったUserモデルのテーブルにカラムを更新しようとするとできなかったことが始まりです。
意外なところで詰まったなぁと思ったので健忘録としてまとめます。
user_controller.rbdef update @user = User.find(params[:id]) if @user.update(user_params) redirect_to user_path(@user.id) else render :show end end僕は月の走行距離をマイページにて追加したかった為詳細ページにform_withを構えています。
show.html.erb<%= form_with(model: @user, local: true, class: "goal-form") do |f| %> <%= f.text_field :distance, placeholder: "目標を記入する", class: "form__text" %> <%= f.submit "設定する", class: "btn btn-primary" %> <% end %>送られてくる値も間違ってなかったのでなんでやねんと思っていたとこでした。
そこで以下のエラーを見つけました。
そもそもUserテーブルの編集にはPasswordの入力が必要だということ。
知りませんでした。
これまでユーザーの編集を行うことがなかったんです。そこでPasswordを入力せずにユーザーの編集を行う方法を見つけました。
カラムを更新するには新たなコントローラの作成やメソッドが必要なようです。まずは、users/registrations_controller.rbを作成します。
registrations_controller.rbclass Users::RegistrationsController < Devise::RegistrationsController before_action :configure_account_update_params, only: [:update] protected def configure_account_update_params devise_parameter_sanitizer.permit(:account_update, keys: [:name]) end end次にルーティングを設定します。
route.rbdevise_for :users, controllers: { registrations: 'users/registrations' }Userモデルを編集。メソッドを追加します。
user.rbメソッドを追加 def update_without_current_password(params, *options) params.delete(:current_password) if params[:password].blank? && params[:password_confirmation].blank? params.delete(:password) params.delete(:password_confirmation) end result = update_attributes(params, *options) clean_up_passwords result end endUserモデルで定義したメソッドを呼び出します。
registrations_controller.rbclass Users::RegistrationsController < Devise::RegistrationsController before_action :configure_account_update_params, only: [:update] protected 追加 def update_resource(resource, params) resource.update_without_password(params) end def configure_account_update_params devise_parameter_sanitizer.permit(:account_update, keys: [:distance]) endまた、モデルにpasswordのバリデーションがかかっているとまだエラーになると思うので外しておきましょう。
user.rbwith_options presence: true do validates :nickname validates :email validates :password ←消す endhttps://gyazo.com/d2738db841e41a0679d16fae6836ace9
できました。ほぼコピペです…
ここで初見だった方々が多かったのでまとめてみました。
・:account_update・・・Updateをするときに指定する引数。
・blank?・・・中身が空もしくは存在しないときにtrueを返す。
・update_attributes・・・一つのカラムのみを変更できる。しかし、バリデーションがスルーされる為、エラーの判定位が出ない。(これに苦しめられた)キリがないのでここまで。
完成
これから、jsで今日走った距離が引かれて減っていくような機能を導入したいなと思います。
お疲れ様でした。参考文献
https://qiita.com/j-sunaga/items/8d6769dfd04da5d3eed5
https://qiita.com/somewhatgood@github/items/b74107480ee3821784e6
- 投稿日:2020-12-08T01:11:53+09:00
【メモ】backgroundの使い方
- 投稿日:2020-12-08T00:58:42+09:00
ensure は実行されるとは限らない
この記事は、Ruby Advent Calendar 2020 の 8 日目の記事です。
昨日は @udzura さんの mruby 本を支えた Continuous Delivery でした。概要
Thread#raise
を使う場合にensure
が実行されないケースがあります。
代表例としてはTimeout.timeout
があります。Rails をはじめとするライブラリはクリーンアップ処理を
ensure
に頼っているケースが多いので、リクエスト中にTimeout.timeout
した場合、そのプロセスは後続リクエストを正常に処理できない可能性があります。
次のリクエストを処理する前に終了させたほうがよいです。rack middleware やライブラリ内でこれらを使用していないかには注意したほうがよいでしょう。
例えば、rack-timeout を使ってデフォルト設定のままの場合には、必ずterm_on_timeout: true
にしましょう。詳細
次のコードを実行してみてください。
ensure
できなかった場合にのみ無限ループから抜けます。require 'timeout' class Foo def stack @stack ||= [] end def foo stack.push('hi') ensure stack.pop end end foo = nil loop do foo = Foo.new begin Timeout.timeout(Float::EPSILON) do loop do foo.foo end end rescue Timeout::Error break unless foo.stack.empty? end end p foo.stack # => ["hi"]まさにこのような場合でも確実に
ensure
するためにThread.handle_interrupt
があります。https://docs.ruby-lang.org/ja/master/method/Thread/s/handle_interrupt.html
require 'timeout' class Foo def stack @stack ||= [] end def foo Thread.handle_interrupt(Timeout::Error => :never) do begin stack.push('hi') ensure stack.pop end end end end foo = nil loop do foo = Foo.new begin Timeout.timeout(Float::EPSILON) do loop do foo.foo end end rescue Timeout::Error break unless foo.stack.empty? end end p foo.stack # ここまでこないつまり、
ensure
の位置でこのThread.handle_interrupt
をアプリケーションコードとライブラリのコードすべてに書いて回る必要があります。もっと言うと、すべての行でいつでも例外が上がってくるかもしれないということなので、そのようなことが起きても内部状態が壊れないよう考慮したコーディングが必要になります。これについて、Rails ではきりがないため考慮しないこととしています。
https://github.com/rails/rails/pull/17607#issuecomment-70538060
ところで、ActiveRecord は scope 周りで
ensure
を利用している部分があります。これを利用して、Timeout 前のリクエストで投げようとしていたクエリの条件を Timeout 後のスコープに入れ込むことが可能になります。
つまり、
User.countというコードを、
User.where(name: "foo").countに化けさせることができます。
以下が再現させるコードの例です。
保存して$ ruby foo.rb
のようにすればそのまま実行できます。require "bundler/inline" gemfile do source "https://rubygems.org" gem "rails", "6.0.3" gem "sqlite3" gem "rack-timeout", require: "rack/timeout/base" end require "rack/test" require "active_record/railtie" require "action_controller/railtie" ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") ActiveRecord::Base.logger = Logger.new(File::NULL) ActiveRecord::Base.logger.level = Logger::ERROR ActiveRecord::Schema.define do create_table :users, force: true do |t| t.string :name end end class User < ActiveRecord::Base scope :sleep_a_while, -> { all.tap { sleep(rand / 1000000.0) } } end User.create!(name: "foo") User.create!(name: "bar") class TestApp < Rails::Application config.root = __dir__ config.hosts << "example.org" config.session_store :cookie_store, key: "cookie_store_key" secrets.secret_key_base = "secret_key_base" config.logger = ActiveRecord::Base.logger Rails.logger = config.logger config.middleware.insert_before Rack::Runtime, Rack::Timeout, service_timeout: 0.1 config.exceptions_app = ->(*) { [500, {}, ['']] } routes.draw do get "/" => "test#index" get "/timeout" => "test#timeout" end end class TestController < ActionController::Base include Rails.application.routes.url_helpers def index render plain: User.count end def timeout loop do User.where(name: "foo").sleep_a_while end end end require "minitest/autorun" class BugTest < Minitest::Test include Rack::Test::Methods def test_index get "/" # User.count が 2 であることを確認 assert_equal "2", last_response.body until last_response.body == "1" get "/timeout" get "/" end get "/" # User.count の結果は常に 2 でないといけないが、1 になっているためテストは失敗する assert_equal "2", last_response.body end private def app Rails.application end endこのように、
ensure
に頼って書かれているコード全般がTimeout.timeout
と組み合わせることで壊れる可能性があります。その他
8年前に ko1 さんが
ensure
とTimeout.timeout
の組み合わせの危険性についてアドベントカレンダーで書かれていました。
http://atdot.net/~ko1/diary/edit_comment.cgi?mode=long&year=2012&month=12&day=6また、rack-timeout ではこういった timeout のリスクについて記しています。
https://github.com/sharpstone/rack-timeout/blob/f4b14a534b37d425ec4dba10d9cacf29ba012f9f/doc/risks.mdとはいえ、rack-timeout を使うにあたってはデフォルトで
term_on_timeout: true
のほうが望ましいと思われるので issue を立てています。
https://github.com/sharpstone/rack-timeout/issues/169