- 投稿日:2021-06-23T23:29:09+09:00
Railsチュートリアル 第7章 リスト7.23のテストでArgumentError: wrong number of arguments (given 2, expected 1)
はじめに Railsチュートリアル7章のリスト7.23のテストで ArgumentError: wrong number of arguments (given 2, expected 1) とエラーが出たので、解決した方法をまとめておきます。 自分の環境 - ryby: 3.0.1 - rails: 6.1.3.2 エラーが出た原因 ruby3.0以上を使っていると、キーワード引数の書き方が変わったため、書き方の問題でひっかかったぽいです。 エラーがでたコードとエラーメッセージ require 'test_helper' class UsersSignupTest < ActionDispatch::IntegrationTest test "invalid signup information" do get signup_path assert_no_difference 'User.count' do post users_path, params: { user: { name: "", email: "user@invalid", password: "foo", password_confirmation: "bar" } } end assert_template 'users/new' end end $ rails test Running via Spring preloader in process 4852 Started with run options --seed 46915 ERROR["test_invalid_signup_information", #<Minitest::Reporters::Suite:0x00007f81d2250d18 @name="UsersSignupTest">, 2.133824999909848] test_invalid_signup_information#UsersSignupTest (2.13s) Minitest::UnexpectedError: ArgumentError: wrong number of arguments (given 2, expected 1) test/integration/users_signup_test.rb:8:in `block (2 levels) in <class:UsersSignupTest>' test/integration/users_signup_test.rb:7:in `block in <class:UsersSignupTest>' 19/19: [=========================================] 100% Time: 00:00:02, Time: 00:00:02 Finished in 2.36285s 19 tests, 37 assertions, 0 failures, 1 errors, 0 skips 解決法 自分の場合はrubyのバージョンを2.7.3に変更しました。 どこかの記事で、8行目のpost users_path,の,を削除すればテストが通ったみたいなのですが、その方法では10章で詰むみたいです。 いったんはバージョン下げて対応しました。
- 投稿日:2021-06-23T18:34:12+09:00
NoMethodErrorを解決した話 (Railsチュートリアル )
Qiita初投稿です。宜しくお願いします。 今回は、NoMethodErrorの解決にかなりの時間を費やしてしまったので、紹介していこうと思います。 エラー文 NoMethodError (undefined method `log_in' for ~~~~~) 原因 ApplicationコントローラにSessionヘルパーモジュールを読み込む際に、 helloメソッドに書き込んでしまっていたので、正常に動作していなかった。 正) application_controller.rb class ApplicationController < ActionController::Base include SessionsHelper end 誤) application_controller.rb class ApplicationController < ActionController::Base def hello include SessionsHelper end end 解決するまでの過程 エラーを見る限り、メソッドがないということなのでlog_inメソッドが定義されているかを確認した。 sessions_helper.rb module SessionsHelper def log_in(user) session[:user_id] = user.id end end log_inメソッドは定義されていることを確認してから、よく分からなくなってしまい、詰まってしまった。その後は、そもそもメソッドとは何か?をRuby on Rails ガイドで調べることに。 Railsのコントローラは、ApplicationControllerを継承したRubyのクラスであり、他の>クラスと同様のメソッドが使えます。アプリケーションがブラウザからのリクエストを受け取ると、ル>ーティングによってコントローラとアクションが指名され、Railsはそれに応じてコントローラのイ>ンスタンスを生成し、アクション名と同じ名前のメソッドを実行します。 振り返りつつ、チュートリアルに沿ってもう一度確認していると、何故かSessionヘルパーモジュールがhelloメソッドの中にあるのに気がついた。 感想 エラーを出すと、どうしても視野が狭くなってしまい、普段気付けることに気付きにくくなってしまう。今回のことを生かして、次からは、少し離れたところから見るつもりで、冷静に対処していきたい。
- 投稿日:2021-06-23T18:25:00+09:00
[Ruby on Rails] N+1問題の解決法(joinsメソッド+groupメソッド編)
はじめに 今回ポートフォリオ(以後PF)制作で、 日本各地の名所を投稿できるサイトを制作しました。 実際に製作したサイトと、コード(GitHub)は下記のURLからご覧ください。 ・サイトURL : https://japansiteinfo.com (今後予告なく公開停止する場合があります。ご了承ください。) ・GitHubのURL : https://github.com/yuta-pharmacy2359/dwc_JapanSiteInfo_app 今回は前回から引き続き「N+1問題」に関して、includesメソッドで解決できないものについて joinsメソッドおよびgroupメソッドを用いた解決法を紹介したいと思います。 本題 1. 前回のおさらい&今回扱う問題 前回の記事(https://qiita.com/yuta-pharmacy2359/items/cf30a20fbea9347c0b72) では、includesメソッドで「N+1問題」を解決できない以下の6機能のうち、 ・(ユーザー詳細画面における)1人のユーザーが獲得した総いいね数表示 ・(キーワード一覧画面における)1つのキーワードにおけるスポット評価の平均値表示 ・ランキング機能(ユーザー1人ごとの総獲得いいね数)における1人のユーザーの総いいね数表示 ・ランキング機能(ユーザー1人ごとの総獲得いいね数)における1人のユーザーの総スポット数表示 ・フォロー数、フォロワー数表示 ・(フォロー・フォロワー画面における)各ユーザーの最終更新日表示 最上段の「(ユーザー詳細画面における)1人のユーザーが獲得した総いいね数表示」について、joinsメソッドを用いた解決法を紹介しました。 こちらは「基本的にいいねを集計するスポットは当該ユーザーのもののみである(テーブル結合後にグループ分けしたりする必要がない)」ため、spotsテーブルとfavoritesテーブルを結合した後はcountメソッドでそのレコード数を集計すればOKでした。 一方、それ以外に関しては「一覧画面などでユーザー(またはスポット)全体から必要なデータを適宜抽出して表示する」必要があるため、単にテーブル結合後にcountメソッドなどを利用するだけでは解決することができません。 参考までに、「1人のユーザーが獲得した総いいね数表示」について、ユーザー詳細画面とランキング(その他一覧系)画面における違いを下図に示しました。 図の通り、ランキング画面では、user_idごとの獲得いいね数を集計する必要があるため、countメソッドの使用前にuser_idごとにグループ分けする必要があります。 (なお、図では比較のため「ランキング画面における1人のユーザーが獲得した総いいね数」を取り上げましたが、ランキング機能については「N+1問題」以外にも要説明事項がいくつかあるので、また別の記事で紹介します。) そこで今回は、「(キーワード一覧画面における)1つのキーワードにおけるスポット評価の平均値表示」を例に、joinsメソッドで複数のテーブルを結合した後にgroupメソッドでテーブル内のデータを仕分けて集計する方法を紹介します。 2. 機能概要と発生した問題点 当PFでは、投稿するスポットに任意のキーワードを付与することができる仕様となっています。 さらに、上図のように、スポット詳細画面に表示されるキーワードはそのキーワード詳細画面のリンクとなっており、そこで同じキーワードを持ったスポットの一覧を見ることができます。 また、キーワード一覧画面には、そのキーワードを持ったスポットの評価の平均値が表示される仕様になっています。 その機能をテーブル同士の繋がりで表したのが下図です。 (データの繋がりが見やすくなるよう、テーブルの内容は上図から変えています。ご了承ください。) 当PFでは、1つのスポットにつき複数のキーワードを付与することができる仕様(同時に、他のスポットに既出のキーワードを付与可能)であるため、spotsテーブルとkeywordsテーブルは「多対多の関係」となります。 そのため、それら2つのテーブルの間にkeyword_relationshipsテーブルという中間テーブルを設けています。 そしてキーワード詳細画面では、中間テーブルを介してkeyword_idが一致しているスポットが抽出され表示されます。 (下図の例では、キーワードid=1である「東京タワー」に紐づくスポットとして、id=1,6,9,12のスポットが抽出されます。) さらにキーワード一覧画面では、その抽出されたスポットの評価の平均値を算出し表示しています。 (下図の例では、(5 + 2 + 4 + 5) / 4 = 4がid=1のキーワードにおけるスポットの評価平均値となります。) 「N+1問題」を考慮しないときのindexのビューファイルおよびコントローラーファイルでの記述は以下の通りとなります。 view/keywords/index.html.erb <% @keywords.each do |keyword| %> <tr> (中略) <td> <%= keyword.spots.count %>スポット </td> <% if keyword.spots.average(:rate).present? %> <td><%= keyword.spots.average(:rate).round(2) %></td> <!-- 以下は評価を星マークで表示するためのJavaScriptの記述。詳細は別記事で紹介予定 --> <script> 評価を星マークで表示するためのJavaScriptの記述。詳細は別記事で紹介予定 if(!$("#star-rate-<%= keyword.id %> img").length) { $('#star-rate-<%= keyword.id %>').raty({ size: 36, starOff: '<%= asset_path('star-off.png') %>', starOn: '<%= asset_path('star-on.png') %>', starHalf: '<%= asset_path('star-half.png') %>', readOnly: true, score: <%= keyword.spots.average(:rate) %>, }); } </script> <% else %> <td>評価なし</td> <% end %> </tr> <% end %> controllers/keywords_controller.rb def index # Ransack(検索・ソート機能が利用できるgem)を利用している関係で、以下の記述となっています。 @q = Keyword.ransack(params[:q]) @q.sorts = 'updated_at desc' if @q.sorts.empty? @keywords = @q.result.page(params[:page]) end 一般的に平均値を表示したい場合は、以下のように記述します。 モデル名.average(:カラム名) また、その後ろのroundメソッドは、「表示する数値を小数第(引数)桁までに指定する」メソッドです。 今回の場合は引数が2ですので小数第2位まで表示することとなります。 そして、スポットの評価は「無評価(nil)」でも許容される設定にしており、もしキーワードに紐づくスポットが全て「無評価」であった場合は評価の平均値が存在しない(nil)ことになるため、その場合の分岐としてelse以下で「評価なし」を表示するようにしています。 この記述では、以下のようなアクセスが行われます。 Keyword Load (0.3ms) SELECT “keywords”.* FROM “keywords” ORDER BY “keywords”.”updated_at” DESC LIMIT ? OFFSET ? [[“LIMIT”,10],[“OFFSET”,0]] KeywordRelationship Load (0.2ms) SELECT "keyword_relationships".* FROM "keyword_relationships"."keyword_id" IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) [["keyword_id", 10], ["keyword_id", 9], ["keyword_id", 8], ["keyword_id", 7], ["keyword_id", 6], ["keyword_id", 5], ["keyword_id", 4], ["keyword_id", 3], ["keyword_id", 2], ["keyword_id", 1]] Spot Load (0.2ms) SELECT "spots".* FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? ORDER BY "spots"."id" DESC LIMIT ? [["keyword_id", 10], ["LIMIT", 1]] (0.2ms) SELECT COUNT(*) FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 10]] (0.1ms) SELECT AVG("spots"."rate") FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 10]] CACHE (0.0ms) SELECT AVG("spots"."rate") FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 10]] CACHE (0.0ms) SELECT AVG("spots"."rate") FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 10]] Spot Load (0.2ms) SELECT "spots".* FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? ORDER BY "spots"."id" DESC LIMIT ? [["keyword_id", 9], ["LIMIT", 1]] (0.2ms) SELECT COUNT(*) FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 9]] (0.1ms) SELECT AVG("spots"."rate") FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 9]] CACHE (0.0ms) SELECT AVG("spots"."rate") FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 9]] CACHE (0.0ms) SELECT AVG("spots"."rate") FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 9]] Spot Load (0.2ms) SELECT "spots".* FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? ORDER BY "spots"."id" DESC LIMIT ? [["keyword_id", 8], ["LIMIT", 1]] (0.2ms) SELECT COUNT(*) FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 8]] (0.1ms) SELECT AVG("spots"."rate") FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 8]] CACHE (0.0ms) SELECT AVG("spots"."rate") FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 8]] CACHE (0.0ms) SELECT AVG("spots"."rate") FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 8]] ・・・ (以下、keyword_id = 1まで上記の繰り返し) さすがに途中で省略させていただきました(笑) まず、キーワード一覧画面では「そのキーワード内の最新のスポットの画像」と「そのキーワードを持つスポット数」を表示しているため、keywordsテーブルと同時にspotsテーブルにもアクセスが行われることになります。 (「そのキーワードを持つスポット数」については「とある理由」でSpot Loadの真下の行にもう一回アクセスが行われた形跡がありますが、そちらも当記事の最後の方で取り上げていますのでご安心ください。) さらに、問題のスポット評価平均値表示の部分では一つのキーワードにつき3回ずつアクセスが行われています。その内訳ですが、 indexのviewファイルにおいて、 ・<% if keyword.spots.average(:rate).present? %> ・<%= keyword.spots.average(:rate).round(2) %> ・<%= keyword.spots.average(:rate) %> の部分でそれぞれアクセスが行われています。 さすがに表示内容に対してアクセス回数が多すぎるので、次項で一つずつ解消していきます。 3. 解決法(前半) まず、spotsテーブルを何度も読み込んでしまう部分に関しては、以前も紹介したincludesメソッドで解決することができます。 keywordのコントローラーファイルにおいて、 controllers/keywords_controller.rb def index @q = Keyword.ransack(params[:q]) @q.sorts = 'updated_at desc' if @q.sorts.empty? @keywords = @q.result.page(params[:page]).includes(:spots) end 最後の@keywordsの定義の部分で、末尾にincludes(:spots)を追加すればOKです。 (繰り返しにはなりますが、includesメソッドの引数はモデル名ではなく関連名です。不安な場合はkeywordのモデルファイル(models/keyword.rb)でhas_manyの部分を確認してみてください。) この状態で再度キーワード一覧画面にアクセスすると、以下のようにSQLが発行されていることがわかります。 Keyword Load (0.2ms) SELECT “keywords”.* FROM “keywords” ORDER BY “keywords”.”updated_at” DESC LIMIT ? OFFSET ? [[“LIMIT”,10],[“OFFSET”,0]] KeywordRelationship Load (0.2ms) SELECT "keyword_relationships".* FROM "keyword_relationships"."keyword_id" IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) [["keyword_id", 10], ["keyword_id", 9], ["keyword_id", 8], ["keyword_id", 7], ["keyword_id", 6], ["keyword_id", 5], ["keyword_id", 4], ["keyword_id", 3], ["keyword_id", 2], ["keyword_id", 1]] Spot Load (0.2ms) SELECT "spots".* FROM "spots" WHERE "spots"."id" IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) [["id", 12], ["id", 11], ["id", 10], ["id", 9], ["id", 8], ["id", 7], ["id", 6], ["id", 5], ["id", 4], ["id", 3], ["id", 2], ["id", 1]] (0.2ms) SELECT COUNT(*) FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 10]] (0.1ms) SELECT AVG("spots"."rate") FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 10]] CACHE (0.0ms) SELECT AVG("spots"."rate") FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 10]] CACHE (0.0ms) SELECT AVG("spots"."rate") FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 10]] (0.2ms) SELECT COUNT(*) FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 9]] (0.1ms) SELECT AVG("spots"."rate") FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 9]] CACHE (0.0ms) SELECT AVG("spots"."rate") FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 9]] CACHE (0.0ms) SELECT AVG("spots"."rate") FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 9]] (0.2ms) SELECT COUNT(*) FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 8]] (0.1ms) SELECT AVG("spots"."rate") FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 8]] CACHE (0.0ms) SELECT AVG("spots"."rate") FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 8]] CACHE (0.0ms) SELECT AVG("spots"."rate") FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 8]] ・・・ (以下、keyword_id = 1まで上記の繰り返し) includesメソッドを用いたことによって、3行目でspotsテーブルにアクセスしている部分の「N+1問題」が解消されていることがわかります。 しかし、4行目以降のスポット評価平均値計算の部分についてはまだ解消されていません。 これを解消するためには、冒頭でも述べたjoinメソッドおよびgroupメソッドの合わせ技が必要となります。 4. 解決法(後半) それではキーワード一覧画面における「N+1問題」を完全に解決するための記述法を紹介します。 まずはコントローラーファイルにおける記述です。 controllers/keywords_controller.rb def index @q = Keyword.ransack(params[:q]) @q.sorts = 'updated_at desc' if @q.sorts.empty? @keywords = @q.result.page(params[:page]).includes(:spots) @rate_avg = @keywords.joins(:spots).group("keywords.id").average(:rate) end これまでと違うのは一番下の@rate_avgの行です。 まずjoinsメソッドですが、「関連するテーブル同士を内部結合するメソッド」です。 (具体的な説明に関しては https://qiita.com/yuta-pharmacy2359/items/cf30a20fbea9347c0b72 の記事をご覧ください。) 今回はkeyword_relationshipsテーブルという中間テーブルの存在がありますが、基本的な考え方は同じです。 3つのテーブルで、カラムの内容が一致しているもの(spotsテーブルのidとkeyword_relationshipsテーブルのspot_id、keywordsテーブルのidとkeyword_relationshipsテーブルのkeyword_id)で対応させます。 各テーブルが上図のように対応するはずです。 さらにkeyword_relationshipsテーブルはspotsテーブルおよびkeywordsテーブルの仲介役に過ぎず最終的に必要なデータではないこと、またこのままではspotsテーブルおよびkeywordsテーブルの関係が見づらいので、以下のように書き換えます。 だいぶ両者の関係が見やすくなりました。 そして今回求めているのは「1つのキーワードにおけるスポット評価の平均値」なので、上図の赤色で囲った部分が最終的に必要な情報となります。 ここで、前回 (https://qiita.com/yuta-pharmacy2359/items/cf30a20fbea9347c0b72) 取り上げた「1人のユーザーが獲得した総いいね数表示」では、joinsメソッドで結合した時点で既に集計対象であるユーザーのidがユーザー詳細画面で表示されているユーザーに限定されており、あとはそのままcountメソッドでレコード数を集計すればOKでした。 一方今回は、上図の通り集計対象がキーワード一覧画面に表示する全てのキーワード(上図の例ではid=1~10)であり、集計前にキーワードのidごとにグループ分けする必要があります。 そんな時に活躍するのがgroupメソッドです。 groupメソッドは「指定したカラムのデータの種類(または条件式)ごとにデータをまとめるメソッド」です。 基本的な定義は以下の通りです。 モデル名.group(:カラム(または"条件式")) そしてこのgroupメソッドですが、あくまで「データを引数で指定した法則に従ってまとめるだけ」のメソッドであるため、それ単体で利用されることはほとんどなく、countメソッドやavgメソッドなど、集計系のメソッドと併用することが多いです。 それを踏まえると、改めてgroupメソッドの基本的な定義は以下の通りになります。 # avgメソッドを使用する場合 モデル名.group(:カラム(または"条件式")).avg(:カラム(または"条件式")) 続いてそれぞれの引数に関してですが、今回の場合は以下の2条件 ・キーワードのidごとに集計したい ・スポットの評価の平均値を求めたい と、spotsおよびkeywordsそれぞれのテーブルの関係性を考慮すると、 ・groupメソッドの引数: "keywords.id" ・avgメソッドの引数: :rate となります。イメージとしては下図のようになるかと思います。 ということで、先ほども載せましたが、この部分は @rate_avg = @keywords.joins(:spots).group("keywords.id").average(:rate) と記述することができます。 さらに留意していただきたいのは、groupメソッドと集計系のメソッドを併用した場合の返り値はハッシュの形であるということです。 今回の例の場合、上式の返り値は以下のようになります。 { 1=>0.4e1, 2=>0.433333e1, 3=>0.366666e1, 4=>0.4e1, 5=>0.3e1, 6=>0.45e1, 8=>0.1e1, 9=>0.1e1, 10=>0.5e1 } (ターミナル上ではこのように指数表記で表示されます。例えばキーワードid=1では、0.4e1は4と同値です。また、id=7のように値がnilであった場合は表示されません。) ということで、最後の課題はこのハッシュからどうやって必要な値を取り出すかということになります。 ここで活躍するのがfetchメソッドです。 fetchメソッドは、ハッシュから引数に指定したキーの値を取り出すメソッドです。 基本的な定義は以下の通りです。 ハッシュ.fetch(key) たとえば、今回の例では、key=1の場合は0.4e1(表示上は4.0)、key=2の場合は0.433333e1(表示上は4.33)が返ってくるというわけです。 一方、存在しないキー(今回の例では特にkey=7)を引数に取るとエラーとなりますので注意してください。 それを踏まえて、ビューファイルのほうを確認してみましょう。 view/keywords/index.html.erb <% @keywords.each do |keyword| %> <tr> (中略) <td> <%= keyword.spots.size %>スポット </td> <% if @rate_avg.has_key?(keyword.id) %> <td><%= @rate_avg.fetch(keyword.id).round(2) %></td> <script> if(!$("#star-rate-<%= keyword.id %> img").length) { $('#star-rate-<%= keyword.id %>').raty({ size: 36, starOff: '<%= asset_path('star-off.png') %>', starOn: '<%= asset_path('star-on.png') %>', starHalf: '<%= asset_path('star-half.png') %>', readOnly: true, score: <%= @rate_avg.fetch(keyword.id) %>, }); } </script> <% else %> <td>評価なし</td> <% end %> </tr> <% end %> まず、if文の部分ですが、先ほども述べた通り、fetchメソッドでは存在しないキーを引数に取ることができないため、まずは@rate_avgのハッシュの中に各キーワードのidがキーとして存在するかどうかをhas_key?メソッドで確認します。 存在する場合は、その直後の文でハッシュからfetchメソッドでそれぞれのキーワードidに対応するスポット評価平均値を取り出して表示します。ない場合はelse以下の文で「評価なし」を表示します。 あとは大方N+1問題を解決しない時の記述と同じですが、もう1点だけ注意すべき記述があります。 <%= keyword.spots.size %>スポットの部分です。 一見、「1つのキーワードに紐づくスポットの数」なので<%= keyword.spots.count %>スポットと記述したくなりますが、countメソッドの性質に落とし穴があります。 countメソッドは、キャッシュを利用しない関係上、表示するキーワードごとに毎回データベースにアクセスしてしまいます。 (参考: https://www.lanches.co.jp/blog/3199) そのため、countメソッドを利用した場合、ターミナルのログを確認すると、 (0.3ms) SELECT AVG(rate) AS average_rate, keywords.id AS keywords_id FROM "keywords" INNER JOIN "keyword_relationships" ON "keyword_relationships"."keyword_id" = "keywords"."id" INNER JOIN "spots" ON "spots"."id" = "keyword_relationships"."spot_id" GROUP BY keywords.id ORDER BY "keywords"."updated_at" DESK LIMIT ? OFFSET ? [["LIMIT", 10],["OFFSET", 0]] Keyword Load (0.2ms) SELECT “keywords”.* FROM “keywords” ORDER BY “keywords”.”updated_at” DESC LIMIT ? OFFSET ? [[“LIMIT”,10],[“OFFSET”,0]] KeywordRelationship Load (0.2ms) SELECT "keyword_relationships".* FROM "keyword_relationships"."keyword_id" IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) [["keyword_id", 10], ["keyword_id", 9], ["keyword_id", 8], ["keyword_id", 7], ["keyword_id", 6], ["keyword_id", 5], ["keyword_id", 4], ["keyword_id", 3], ["keyword_id", 2], ["keyword_id", 1]] Spot Load (0.2ms) SELECT "spots".* FROM "spots" WHERE "spots"."id" IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) [["id", 12], ["id", 11], ["id", 10], ["id", 9], ["id", 8], ["id", 7], ["id", 6], ["id", 5], ["id", 4], ["id", 3], ["id", 2], ["id", 1]] (0.2ms) SELECT COUNT(*) FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 10]] (0.2ms) SELECT COUNT(*) FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 9]] (0.2ms) SELECT COUNT(*) FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 8]] (0.2ms) SELECT COUNT(*) FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 7]] (0.2ms) SELECT COUNT(*) FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 6]] (0.2ms) SELECT COUNT(*) FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 5]] (0.2ms) SELECT COUNT(*) FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 4]] (0.2ms) SELECT COUNT(*) FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 3]] (0.2ms) SELECT COUNT(*) FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 2]] (0.2ms) SELECT COUNT(*) FROM "spots" INNER JOIN "keyword_relationships" ON "spots"."id" = "keyword_relationships"."spot_id" WHERE "keyword_relationships"."keyword_id" = ? [["keyword_id", 1]] このように、各キーワードにおけるスポット数の集計で、1つのキーワードごとにいちいち1回ずつアクセスを行うという無駄が発生してしまいます。 前回紹介した「(ユーザー詳細画面における)1人のユーザーが獲得した総いいね数」のように、集計対象が単一である場合ならcountメソッドでも全く問題ありませんが、一覧画面のように集計対象が複数ある場合は、countメソッドではなくsizeメソッドを使用するようにしましょう。 そこまで対処したあとに再度キーワード一覧画面を表示すると、 (0.3ms) SELECT AVG(rate) AS average_rate, keywords.id AS keywords_id FROM "keywords" INNER JOIN "keyword_relationships" ON "keyword_relationships"."keyword_id" = "keywords"."id" INNER JOIN "spots" ON "spots"."id" = "keyword_relationships"."spot_id" GROUP BY keywords.id ORDER BY "keywords"."updated_at" DESK LIMIT ? OFFSET ? [["LIMIT", 10],["OFFSET", 0]] Keyword Load (0.3ms) SELECT “keywords”.* FROM “keywords” ORDER BY “keywords”.”updated_at” DESC LIMIT ? OFFSET ? [[“LIMIT”,10],[“OFFSET”,0]] KeywordRelationship Load (0.2ms) SELECT "keyword_relationships".* FROM "keyword_relationships"."keyword_id" IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) [["keyword_id", 10], ["keyword_id", 9], ["keyword_id", 8], ["keyword_id", 7], ["keyword_id", 6], ["keyword_id", 5], ["keyword_id", 4], ["keyword_id", 3], ["keyword_id", 2], ["keyword_id", 1]] Spot Load (0.2ms) SELECT "spots".* FROM "spots" WHERE "spots"."id" IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) [["id", 12], ["id", 11], ["id", 10], ["id", 9], ["id", 8], ["id", 7], ["id", 6], ["id", 5], ["id", 4], ["id", 3], ["id", 2], ["id", 1]] これでキーワード一覧における「N+1問題」を全て解消することができました。 終わりに 長くなりましたが、joinメソッドとgroupメソッドを併用した「N+1問題」の解決方法を紹介しました。 当記事を含め3記事にわたって「N+1問題の解決法」について取り上げましたが、これで大方解決できるかと思いますので、当問題でお悩みの方はぜひ試してみてください。
- 投稿日:2021-06-23T17:56:49+09:00
AWS FargateでRailsアプリとSidekiqを動かすやり方
FargateでRailsコンテナと別にSidekiqを動かしたかったので試してみました。 その手順を書いていきます。 なお、RailsにSidekiqを導入するやり方については、本記事では紹介しません。 前提 RailsアプリをFargateで動かすまでにやり方については以下の記事に書いております。 Sidekiqの設定確認 Sidekiq.configure_server do |config| config.redis = { url: "redis://#{ENV.fetch("REDIS_URL", "localhost:6379")}", namespace: "sidekiq" } end Sidekiq.configure_client do |config| config.redis = { url: "redis://#{ENV.fetch("REDIS_URL", "localhost:6379")}", namespace: "sidekiq" } end この REDIS_URLは環境変数で定義出来るようにしています。 Jobは以下のようなものを設置しています。 class Batch::TestJob include Sidekiq::Worker sidekiq_options queue: "default" def perform logger.info "========Batch::TestJob===========" end end Jobはこれで動きます。 Batch::TestJob.perform_async ElastiCacheの導入 Redisを用意するため、ElastiCacheを導入します。 ElastiCache用セキュリティグループの作成 AWS管理画面からセキュリティグループの作成を行います。 セキュリティグループの入力する値は以下です。 フォーム 値 セキュリティグループ名 fargate-test-redis 説明 fargate-test-redis VPC fargate-test インバウンドルール タイプ カスタムTCP インバウンドルール ポート範囲 6379 インバウンドルール ソース fargate-test イメージ 入力が完了したら、作成して完了です。 ElastiCacheの作成 AWS管理画面の ElastiCache ページから「今すぐ始める」を押します。 フォーム 値 クラスターエンジン redis ロケーション Amazon クラウド 名前 fargate-test 説明 fargate-test-redis エンジンバージョンの互換性 6.x(最新) ポート 6379 パラメータグループ default.redis6.x ノードのタイプ cache.t3.micro1 パラメータグループ default.redis6.x レプリケーション数 1 マルチAZ チェック サブネットグループ 新規作成 サブネットグループ名 fargate-test-redis サブネットグループ 説明 fargate-test-redis サブネットグループ VPC fargate-testのもの サブネットグループ サブネット プライベートサブネット2つ(ap-northeast-1a,ap-northeast-1c) アベイラビリティーゾーンの配置 指定なし セキュリティグループ 先程作ったfaragate-test-redisのセキュリティグループ イメージ 上記のように入力が完了したら、「作成」ボタンを押して作成を行います。 SSMパラメータストアにREDIS_URLを登録 ElastiCacheの作成が完了したら、ElastiCacheの「プライマリエンドポイント」をパラメータストアに登録します。(クラスタモードを有効にしている場合は「設定エンドポイント」を登録) System Managerの画面からパラメータストアを選択します。 パラメータストアの作成を行います。 プライマリエンドポイント 入力する内容は以下です。 名前 タイプ KMS キーソース KMS キー ID 値 /fargate-test/redis-url 安全な文字列 現在のアカウント alias/aws/ssm ElasitCacheのプライマリエンドポイント イメージ 自動デプロイでSidekiqコンテナを動かす アプリケーションに書いたtask-definition.jsonを編集 ↑こちらの記事で自動デプロイの設定がすでに済んでいる前提で進めます。 deploy/task-definition.jsonの編集 元のtask-definition.json { "ipcMode": null, "executionRoleArn": "arn:aws:iam::318288222771:role/ecsTaskExecutionRole", "containerDefinitions": [ { "dnsSearchDomains": null, "environmentFiles": null, "logConfiguration": { "logDriver": "awslogs", "secretOptions": null, "options": { "awslogs-group": "/ecs/fargate-test", "awslogs-region": "ap-northeast-1", "awslogs-stream-prefix": "ecs" } }, "entryPoint": [], "portMappings": [ { "hostPort": 80, "protocol": "tcp", "containerPort": 80 } ], "command": [ "/app/entrypoint.sh" ], "linuxParameters": null, "cpu": 0, "environment": [ { "name": "RAILS_ENV", "value": "production" }, { "name": "RAILS_LOG_TO_STDOUT", "value": "true" }, { "name": "RAILS_SERVE_STATIC_FILES", "value": "true" } ], "resourceRequirements": null, "ulimits": null, "dnsServers": null, "mountPoints": [], "workingDirectory": null, "secrets": [ { "valueFrom": "/fargate-test/db-host", "name": "DB_HOST" }, { "valueFrom": "/fargate-test/db-password", "name": "DB_PASSWORD" }, { "valueFrom": "/fargate-test/db-username", "name": "DB_USERNAME" }, { "valueFrom": "/fargate-test/rails-master-key", "name": "RAILS_MASTER_KEY" } ], "dockerSecurityOptions": null, "memory": null, "memoryReservation": null, "volumesFrom": [], "stopTimeout": null, "image": "318288222771.dkr.ecr.ap-northeast-1.amazonaws.com/fargate-test:latest", "startTimeout": null, "firelensConfiguration": null, "dependsOn": null, "disableNetworking": null, "interactive": null, "healthCheck": null, "essential": true, "links": null, "hostname": null, "extraHosts": null, "pseudoTerminal": null, "user": null, "readonlyRootFilesystem": null, "dockerLabels": null, "systemControls": null, "privileged": null, "name": "fargate-test" } ], "placementConstraints": [], "memory": "512", "taskRoleArn": "arn:aws:iam::318288222771:role/ecsTaskExecutionRole", "compatibilities": [ "EC2", "FARGATE" ], "taskDefinitionArn": "arn:aws:ecs:ap-northeast-1:318288222771:task-definition/fargate-test:5", "family": "fargate-test", "requiresAttributes": [ { "targetId": null, "targetType": null, "value": null, "name": "com.amazonaws.ecs.capability.logging-driver.awslogs" }, { "targetId": null, "targetType": null, "value": null, "name": "ecs.capability.execution-role-awslogs" }, { "targetId": null, "targetType": null, "value": null, "name": "com.amazonaws.ecs.capability.ecr-auth" }, { "targetId": null, "targetType": null, "value": null, "name": "com.amazonaws.ecs.capability.docker-remote-api.1.19" }, { "targetId": null, "targetType": null, "value": null, "name": "com.amazonaws.ecs.capability.task-iam-role" }, { "targetId": null, "targetType": null, "value": null, "name": "ecs.capability.execution-role-ecr-pull" }, { "targetId": null, "targetType": null, "value": null, "name": "ecs.capability.secrets.ssm.environment-variables" }, { "targetId": null, "targetType": null, "value": null, "name": "com.amazonaws.ecs.capability.docker-remote-api.1.18" }, { "targetId": null, "targetType": null, "value": null, "name": "ecs.capability.task-eni" } ], "pidMode": null, "requiresCompatibilities": [ "FARGATE" ], "networkMode": "awsvpc", "cpu": "256", "revision": 5, "status": "ACTIVE", "inferenceAccelerators": null, "proxyConfiguration": null, "volumes": [] } 編集後のtask-definition.json やることは、"containerDefinitions"にSidekiqのコンテナも作ることです。 { "ipcMode": null, "executionRoleArn": "arn:aws:iam::#####:role/ecsTaskExecutionRole", "containerDefinitions": [ { "dnsSearchDomains": null, "environmentFiles": null, "logConfiguration": { "logDriver": "awslogs", "secretOptions": null, "options": { "awslogs-group": "/ecs/fargate-test", "awslogs-region": "ap-northeast-1", "awslogs-stream-prefix": "ecs" } }, "entryPoint": [], "portMappings": [ { "hostPort": 80, "protocol": "tcp", "containerPort": 80 } ], "command": [ "/app/entrypoint.sh" ], "linuxParameters": null, "cpu": 0, "environment": [ { "name": "RAILS_ENV", "value": "production" }, { "name": "RAILS_LOG_TO_STDOUT", "value": "true" }, { "name": "RAILS_SERVE_STATIC_FILES", "value": "true" } ], "resourceRequirements": null, "ulimits": null, "dnsServers": null, "mountPoints": [], "workingDirectory": null, "secrets": [ { "valueFrom": "/fargate-test/db-host", "name": "DB_HOST" }, { "valueFrom": "/fargate-test/db-password", "name": "DB_PASSWORD" }, { "valueFrom": "/fargate-test/db-username", "name": "DB_USERNAME" }, { "valueFrom": "/fargate-test/rails-master-key", "name": "RAILS_MASTER_KEY" }, { // パラメータストアからREDIS_URLを追加 "valueFrom": "/fargate-test/redis-url", "name": "REDIS_URL" } ], "dockerSecurityOptions": null, "memory": null, "memoryReservation": null, "volumesFrom": [], "stopTimeout": null, "startTimeout": null, "firelensConfiguration": null, "dependsOn": null, "disableNetworking": null, "interactive": null, "healthCheck": null, "essential": true, "links": null, "hostname": null, "extraHosts": null, "pseudoTerminal": null, "user": null, "readonlyRootFilesystem": null, "dockerLabels": null, "systemControls": null, "privileged": null, "name": "fargate-test" }, // ↓ここから追加 { "dnsSearchDomains": null, "environmentFiles": null, "logConfiguration": { "logDriver": "awslogs", "secretOptions": null, "options": { "awslogs-group": "/ecs/fargate-test", "awslogs-region": "ap-northeast-1", "awslogs-stream-prefix": "ecs" } }, "entryPoint": [], // portMappingsの記述は削除 // Sidekiqを動かすコマンドに変更 "command": [ "bundle", "exec", "sidekiq", "-C", "config/sidekiq.yml" ], "linuxParameters": null, "cpu": 0, "environment": [ { "name": "RAILS_ENV", "value": "production" }, { "name": "RAILS_LOG_TO_STDOUT", "value": "true" }, { "name": "RAILS_SERVE_STATIC_FILES", "value": "true" } ], "resourceRequirements": null, "ulimits": null, "dnsServers": null, "mountPoints": [], "workingDirectory": null, "secrets": [ { "valueFrom": "/fargate-test/db-host", "name": "DB_HOST" }, { "valueFrom": "/fargate-test/db-password", "name": "DB_PASSWORD" }, { "valueFrom": "/fargate-test/db-username", "name": "DB_USERNAME" }, { "valueFrom": "/fargate-test/rails-master-key", "name": "RAILS_MASTER_KEY" }, { // パラメータストアからREDIS_URLを読み込む "valueFrom": "/fargate-test/redis-url", "name": "REDIS_URL" } ], "dockerSecurityOptions": null, "memory": null, "memoryReservation": null, "volumesFrom": [], "stopTimeout": null, "image": "#####.dkr.ecr.ap-northeast-1.amazonaws.com/fargate-test:latest", "startTimeout": null, "firelensConfiguration": null, "dependsOn": null, "disableNetworking": null, "interactive": null, "healthCheck": null, "essential": true, "links": null, "hostname": null, "extraHosts": null, "pseudoTerminal": null, "user": null, "readonlyRootFilesystem": null, "dockerLabels": null, "systemControls": null, "privileged": null, // コンテナ名をfargate-test-sidekiqにする "name": "fargate-test-sidekiq" } // ↑ここまで追加 ], "placementConstraints": [], "memory": "512", "taskRoleArn": "arn:aws:iam::#####:role/ecsTaskExecutionRole", "compatibilities": [ "EC2", "FARGATE" ], "taskDefinitionArn": "arn:aws:ecs:ap-northeast-1:#####:task-definition/fargate-test:5", "family": "fargate-test", "requiresAttributes": [ { "targetId": null, "targetType": null, "value": null, "name": "com.amazonaws.ecs.capability.logging-driver.awslogs" }, { "targetId": null, "targetType": null, "value": null, "name": "ecs.capability.execution-role-awslogs" }, { "targetId": null, "targetType": null, "value": null, "name": "com.amazonaws.ecs.capability.ecr-auth" }, { "targetId": null, "targetType": null, "value": null, "name": "com.amazonaws.ecs.capability.docker-remote-api.1.19" }, { "targetId": null, "targetType": null, "value": null, "name": "com.amazonaws.ecs.capability.task-iam-role" }, { "targetId": null, "targetType": null, "value": null, "name": "ecs.capability.execution-role-ecr-pull" }, { "targetId": null, "targetType": null, "value": null, "name": "ecs.capability.secrets.ssm.environment-variables" }, { "targetId": null, "targetType": null, "value": null, "name": "com.amazonaws.ecs.capability.docker-remote-api.1.18" }, { "targetId": null, "targetType": null, "value": null, "name": "ecs.capability.task-eni" } ], "pidMode": null, "requiresCompatibilities": [ "FARGATE" ], "networkMode": "awsvpc", "cpu": "256", "revision": 5, "status": "ACTIVE", "inferenceAccelerators": null, "proxyConfiguration": null, "volumes": [] } GithubActionsの編集 元の.github/workflows/deploy.yml on: pull_request: push: branches: - master name: Deploy to Amazon ECS jobs: deploy: name: Deploy runs-on: ubuntu-18.04 environment: production steps: - name: Checkout uses: actions/checkout@v2 - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v1 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ap-northeast-1 - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v1 - name: Build, tag, and push image to Amazon ECR id: build-image env: DOCKER_BUILDKIT: 1 ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} ECR_REPOSITORY: fargate-test #ECRのリポジトリ名 IMAGE_TAG: ${{ github.sha }} run: | docker build \ -f docker/production/Dockerfile \ --cache-from=$ECR_REGISTRY/$ECR_REPOSITORY:latest \ --build-arg BUILDKIT_INLINE_CACHE=1 \ -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG \ -t $ECR_REGISTRY/$ECR_REPOSITORY:latest . docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" - name: 【Rails】 Fill in the new image ID in the Amazon ECS task definition id: task-def-with-rails uses: aws-actions/amazon-ecs-render-task-definition@v1 with: task-definition: deploy/task-definition.json container-name: fargate-test image: ${{ steps.build-image.outputs.image }} - name: Deploy Amazon ECS task definition uses: aws-actions/amazon-ecs-deploy-task-definition@v1 with: task-definition: ${{ steps.task-def-with-rails.outputs.task-definition }} # 上のid: task-def-with-railsを利用する service: fargate-test cluster: fargate-test wait-for-service-stability: true codedeploy-appspec: deploy/appspec.yml codedeploy-application: AppECS-fargate-test-fargate-test codedeploy-deployment-group: DgpECS-fargate-test-fargate-test 編集後のdeploy.yml 元の.github/workflows/deploy.ymlを編集します。 on: pull_request: push: branches: - master name: Deploy to Amazon ECS jobs: deploy: name: Deploy runs-on: ubuntu-18.04 environment: production steps: - name: Checkout uses: actions/checkout@v2 - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v1 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ap-northeast-1 - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v1 - name: Build, tag, and push image to Amazon ECR id: build-image env: DOCKER_BUILDKIT: 1 ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} ECR_REPOSITORY: fargate-test #ECRのリポジトリ名 IMAGE_TAG: ${{ github.sha }} run: | docker build \ -f docker/production/Dockerfile \ --cache-from=$ECR_REGISTRY/$ECR_REPOSITORY:latest \ --build-arg BUILDKIT_INLINE_CACHE=1 \ -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG \ -t $ECR_REGISTRY/$ECR_REPOSITORY:latest . docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest echo "::set-output name=image::$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" - name: 【Rails】 Fill in the new image ID in the Amazon ECS task definition id: task-def-with-rails uses: aws-actions/amazon-ecs-render-task-definition@v1 with: task-definition: deploy/task-definition.json container-name: fargate-test image: ${{ steps.build-image.outputs.image }} # ↓ここから追加 - name: 【Sidekiq】 Fill in the new image ID in the Amazon ECS task definition id: task-def-with-sidekiq uses: aws-actions/amazon-ecs-render-task-definition@v1 with: task-definition: ${{ steps.task-def-with-rails.outputs.task-definition }} # 上のid: task-def-with-railsを利用する container-name: fargate-test-sidekiq # コンテナ名を記述 image: ${{ steps.build-image.outputs.image }} # ↑ここまで追加 - name: Deploy Amazon ECS task definition uses: aws-actions/amazon-ecs-deploy-task-definition@v1 with: # ↓sidekiqコンテナの設定を追加したtask定義を読み込むように編集 task-definition: ${{ steps.task-def-with-sidekiq.outputs.task-definition }} # 上のid: task-def-with-sidekiqを利用する service: fargate-test cluster: fargate-test wait-for-service-stability: true codedeploy-appspec: deploy/appspec.yml codedeploy-application: AppECS-fargate-test-fargate-test codedeploy-deployment-group: DgpECS-fargate-test-fargate-test これで完成 これで完成です。 Mainブランチにマージしてデプロイを行うと、Sidekiqも動くようになっているかと思います。
- 投稿日:2021-06-23T17:55:40+09:00
Fontawesomeを導入したら文字化けしていた
Fontawesomeの導入は多分これが一番わかりやすい。 ローカルサーバーを立ち上げて、確認したら文字化けしていたので、なんでだろうと思ったら、読み込みの順番が悪かったみたい。 app/assets/stylesheets/application.scss *= require_tree . *= require_self */ @import "bootstrap"; @import "font-awesome"; @import "font-awesome-sprockets"; こうしてたんですけど、正しくは app/assets/stylesheets/application.scss *= require_tree . *= require_self */ @import "bootstrap"; @import "font-awesome-sprockets"; @import "font-awesome"; こっちじゃないといけない。
- 投稿日:2021-06-23T16:13:20+09:00
[ Rails & Nuxt ] AWS ECSを使ってデプロイをする
No. タイトル 1 Dockerで開発環境を構築する 2 ログイン認証を機能を実装する 3 記事投稿機能を実装する 4 AWS ECSを使ってデプロイする 5 Circle CIを使って自動テスト•デプロイをする はじめに Rails & Nuxtでポートフォリオを作成するシリーズの第4弾になります。 全5部構成でDocker,CircleCI,AWS等のモダンな技術を組み込んだ作品を完成させる予定です。 本章ではAWSのECSを利用してアプリのデプロイを行います。 以下、完成イメージ 事前学習 ↓AWSのネットワークの仕組みについて ↓ECSについて VPC作成 最初にリージョンを東京に切り替えておいて下さい。 name CIDRブロック sample-vpc 10.20.0.0/16 サブネット作成 後に作成するロードバランサーではサブネットが2つ必要な為、Nuxt,Rails,RDSでそれぞれ2つずつサブネットを用意します。 name CIDRブロック Region/AZ sample-front-subnet-1a 10.20.1.0/24 ap-northeast-1a sample-front-subnet-1c 10.20.2.0/24 ap-northeast-1c sample-back-subnet-1a 10.20.3.0/24 ap-northeast-1a sample-back-subnet-1c 10.20.4.0/24 ap-northeast-1c sample-rds-subnet-1a 10.20.5.0/24 ap-northeast-1a sample-rds-subnet-1c 10.20.6.0/24 ap-northeast-1c IGWの作成 VPC > インターネットゲートウェイより、sample-igwという名前でIGW作成。 その後、[アクション] > [VPCにアタッチ]でsample-vpcに関連づける。 ルートテーブルの作成 VPC > ルートテーブル > [ルートテーブルの作成]より作成画面に移ります。 name sample-front-route sample-back-route sample-rds-route IGW,サブネット,ルートテーブルの関連付け ルートテーブル > アクション > ルートを編集 > ルートを追加より、frontとbackのルートテーブルに対して、0.0.0.0に先ほど作ったIGWを関連付ける。 ルートテーブル > アクション > サブネットの関連付けを編集 より以下の表のように関連付け サブネット ルートテーブル sample-front-subnet-1a sample-front-route sample-front-subnet-1c sample-front-route sample-back-subnet-1a sample-back-route sample-back-subnet-1c sample-back-route sample-rds-subnet-1a sample-rds-route sample-rds-subnet-1c sample-rds-route セキュリティグループの作成 セキュリティグループ名 タイプ プロトコル ポート範囲 ソース sample-front-sg HTTP TCP 80 0.0.0.0/0 SSH TCP 22 My IPアドレス HTTPS TCP 443 0.0.0.0/0 カスタム TCP 3000 0.0.0.0/0 sample-back-sg カスタム TCP 8000 0.0.0.0/0 HTTPS TCP 443 0.0.0.0/0 sample-rds-sg MYSQL/Aurora TCP 3306 0.0.0.0/0 Route53 ドメインの取得 お名前.comより、ドメインの取得をします。Route53からでも取得可能ですが、値段が高いです。 あくまで練習用なので、適当に安いドメイン(.xyzとか)を取得すればOKです。 お名前.comでのドメイン取得方法については、以下を参照してください。 今回は下記を取得したという前提で進めていきます。 ドメイン名 役割 sample.com front sample-api.com back ホストゾーンの作成 Route53 > ホストゾーン > [ホストゾーンの作成] ホストゾーン詳細より、NSレコードの値/トラフィックのルーティング先をコピーして下さい。 お名前.comのネームサーバにNSレコードの値を登録 先ほどコピーしたNSレコードをお名前.comのDNSサーバーに登録します。 $: dig sample.com(取得したドメイン) +short NS 上記コマンドをターミナルから打ち、無事登録したNSレコードが表示されていれば反映されています。 ACMを使ってドメインに証明書を発行する sample.comとsample-api.comについて、それぞれSSL証明書を取得します。 ACM > 証明書のリクエスト > パブリック証明書のリクエストを選択。 ドメイン名 sample.com or sample-api.com 検証方法 DNS検証 タグ なし リクエスト後、検証画面に移るので、Route53でのレコード作成を押します。 そして、しばらくして(30分くらい)検証保留中から、発行済みステータスとなれば成功です。 RDSの作成 サブネットグループの作成 RDS > サブネットグループ > [DBサブネットグループを作成] RDSの作成にはサブネットグループが必要なので、先に作成しておきます。 サブネットはsample-rds-subnet1aとsample-rds-subnet1cを選択 グループ名はsample-subnet-groupとします。 RDSの作成・設定 設定 備考 作成方法 標準作成 エンジンのタイプ Maria バージョン 10.4.3 テンプレート 無料利用枠 DB インスタンス識別子 sample-rds マスターユーザー名 sample(任意) マスターパスワード samplepassword(任意) DB インスタンスクラス情報 db.t2.micro ストレージタイプ 汎用SSD ストレージ割り当て 20 VPC 先ほど作ったVPC(sample-vpc) サブネットグループ 先ほど作ったグループ(sample-subnet-group) 既存のVPCセキュリティグループ 先ほど作ったグループ(sample-rds-sg) アベイラビリティゾーン 指定なし 最初のデータベース名 app_production あとはデフォでOKです。 しばらくすると利用可能ステータスとなるので、そうしたらRDS作成完了です。 database.yml、credentials.yml.encの修正 ./back/config/database.yml production: <<: *default database: app_production host: <%= Rails.application.credentials.rds[:host] %> username: <%= Rails.application.credentials.rds[:username] %> password: <%= Rails.application.credentials.rds[:password] %> これらの値は知られたくないので、credentials.yml内に環境変数として定義します。 $: docker-compose run -e EDITOR="vi" back rails credentials:edit ----------------------------------- rds: host: エンドポイント #RDS詳細画面のエンドポイントの欄の値 username: sample #設定したユーザーネーム password: samplepassword #設定したパスワード エンドポイントは↓の値をコピーしてください。 ロードバランサーの作成 EC2 > ロードバランサー > [ロードバランサーの作成]より作成画面へ移ります。 ロードバランサーの設定 front 種類の選択 Application Load Balancer ロードバランサーの設定 名前 sample-front スキーム インターネット向け IPアドレスタイプ ipv4 リスナー HTTP & HTTPS VPC sample-vpc アベイラビリティゾーン sample-subnet-front1a & 1c セキュリティ設定の構成 証明書タイプ ACM から証明書を選択する 証明書の名前 sample.com セキュリティポリシー デフォルト セキュリティグループ sample-front-sg ターゲットグループ 名前 sample-front-tg ターゲットの種類 インスタンス プロトコル HTTP ポート 8000 プロトコルバージョン HTTP 1.1 ヘルスチェック プロトコル HTTP パス / ターゲットの登録 スキップ back 種類の選択 Application Load Balancer ロードバランサーの設定 名前 sample-back スキーム インターネット向け IPアドレスタイプ ipv4 リスナー HTTP & HTTPS VPC sample-vpc アベイラビリティゾーン sample-subnet-back1a & 1c セキュリティ設定の構成 証明書タイプ ACM から証明書を選択する 証明書の名前 sample-api.com セキュリティポリシー デフォルト セキュリティグループ sample-back-sg ターゲットグループ 名前 sample-back-tg ターゲットの種類 インスタンス プロトコル HTTP ポート 3000 プロトコルバージョン HTTP 1.1 ヘルスチェック プロトコル HTTP パス / ターゲットの登録 スキップ ※ヘルスチェックのパスを"/"としていますが、ルートパスに何かしら200レスポンスを返すページを用意しておいて下さい。しないとエラーになります。 ターゲットはECSに登録した際に自動で追加されます。 Route53にAレコードを追加 Route53 > ホストゾーン > [レコードを作成] - レコードタイプ: A - 値: エイリアス - エンドポイント: Application Load Balancer - リージョン: 東京 - ロードバランサー: sample-front または sample-back sample.comとsample-api.com両方のドメインで行って下さい アプリを本番環境用に変更 AWSにデプロイするにあたり、本番環境用にいくつかファイルを変更します。 書き換えるのが面倒ならば、Dockerfile.dev,Dockerfile.proなど環境毎に分けて作成すると良いかもです。 back/Dockerfile FROM ruby:2.6.3-alpine3.10 ENV RUNTIME_PACKAGES="linux-headers libxml2-dev make gcc libc-dev nodejs tzdata mysql-dev mysql-client yarn" \ DEV_PACKAGES="build-base curl-dev" \ HOME="/app" \ LANG=C.UTF-8 \ TZ=Asia/Tokyo WORKDIR ${HOME} COPY Gemfile ${HOME}/Gemfile COPY Gemfile.lock ${HOME}/Gemfile.lock RUN apk update && \ apk upgrade && \ apk add --update --no-cache ${RUNTIME_PACKAGES} && \ apk add --update --virtual build-dependencies --no-cache ${DEV_PACKAGES} && \ bundle install -j4 && \ apk del build-dependencies && \ rm -rf /usr/local/bundle/cache/* \ /usr/local/share/.cache/* \ /var/cache/* \ /tmp/* \ /usr/lib/mysqld* \ /usr/bin/mysql* ADD . ${HOME} EXPOSE 8000 CMD ["bundle", "exec", "rails", "s", "-b", "0.0.0.0", "-p", "8000", "-e", "production"] front/Dockerfile FROM node:16.3.0-alpine ENV HOME="/app" \ LANG=C.UTF-8 \ TZ=Asia/Tokyo ENV HOST 0.0.0.0 WORKDIR ${HOME} RUN apk update && \ apk upgrade && \ npm install -g n && \ yarn install &&\ rm -rf /var/cache/apk/* ADD . ${HOME} EXPOSE 3000 RUN yarn run build CMD ["yarn", "start"] ./front/nuxt.config.js axios: { //baseURL: "http://localhost:3000", //ベストプラクティスとしてはURLを環境変数で設定した方が良いですが、今回は手直しします。 baseURL: "https://sample-api.com" }, ECRにリポジトリを作成・イメージのプッシュ ECRの作成・設定 ECR > [レポジトリの作成]よりレポジトリを作成します。 可視設定はプライベート、レポジトリ名はsample-front,sample-backとします。 作成後[プッシュコマンドの表示]を押すとコマンドが表示されるので、それに従いターミナルで実行します。尚、このコマンドを実行するにはAWS-CLIの事前インストールが必要なので、やっておきましょう。 実行後、ECRレポジトリ内にイメージが追加されたのを確認して下さい。 ECSの作成・設定 以下、個人的な用語整理 クラスター: ECSの適用範囲を決める タスク定義: docker-compose.yml タスク: docker-compose.ymlによって作成されたコンテナ群 サービス:クラスターとタスク定義を紐付ける ECSクラスターの作成 ECS > クラスター > [クラスターの作成]より作成画面に移ります。 クラスターテンプレート EC2 Linux + ネットワーキング クラスター名 sample-cluster プロビジョニングモデル オンデマンドインスタンス EC2 インスタンスタイプ t3.small インスタンス数 1 EC2 AMI ID* Amazon Linux2 ボリュームサイズ デフォ値 キーペア 任意 (無しにするとSSH接続でデバックできません) VPC sample-vpc サブネット sample-front-subnet1a & 1c パブリック IP の自動割り当て 適用 セキュリティグループ sample-front-sg コンテナインスタンスの IAM ロール デフォ値 タスク定義の作成 ECS > タスク定義 > [新しいタスク定義の作成]より作成画面に移ります。 front 設定 Value 起動タイプの互換性 EC2 タスク定義名 sample-front タスクロール ecsTaskExecutionRole ネットワークモード ホスト タスク実行ロール ecsTaskExecutionRole タスクメモリ (MiB) 700 タスク CPU (単位) 256 コンテナ名 sample-front イメージURL ECR sample-frontレポジトリのURI ポート 8000 back 設定 Value タスク定義名 sample-back タスクロール ecsTaskExecutionRole ネットワークモード ホスト タスク実行ロール ecsTaskExecutionRole タスクメモリ (MiB) 700 タスク CPU (単位) 256 コンテナの追加 コンテナ名 sample-back イメージURL ECR sample-backレポジトリのURI ポート 3000 サービスの作成 ECS > クラスター > sample-service >作成より作成画面に移ります。 front 設定 Value 起動タイプ EC2 タスク定義 sample-front サービス名 sample-front-service サービスタイプ REPLICA タスクの数 1 次のステップ ロードバランシングの種類 Application Load Balancer ヘルスチェックの猶予期間 30 サービス用の IAM ロールの選択 ecsServiceRole ロードバランサー名 sample-front ロードバランサーに追加 ターゲットグループ名 sample-front-tg Service Auto Scaling サービスの必要数を直接調整しない back 設定 備考 起動タイプ EC2 タスク定義 sample サービス名 sample-back-service サービスタイプ REPLICA タスクの数 1 次のステップ ロードバランシングの種類 Application Load Balancer ヘルスチェックの猶予期間 30 サービス用の IAM ロールの選択 ecsServiceRole ロードバランサー名 sample-back ロードバランサーに追加 ターゲットグループ名 sample-back-tg Service Auto Scaling サービスの必要数を直接調整しない 以上で完了です。最後に少しだけやることがあります。 コンテナ内に入ってdb:migrateする EC2インスタンスの中にssh接続し、その上でdockerコンテナ内に入り込みrails db:migrateします。 RDSを作成時に初期DBとして[app_production]を設定したため、db:createの必要ありません。 ssh -i {pemファイル} ec2-user@{インスタンスのpublicIP} EC2インスタンス内 docker ps で、現在起動しているコンテナが表示されます。 railsを起動しているコンテナのCONTAINER IDを指定して、以下コマンドを打つと、Dockerコンテナ内部に入れます。 EC2インスタンス内 docker exec -it {CONTAINER ID} sh ここで、環境を指定してdb:migrateします。 Dockerコンテナ内 rails db:migrate RAILS_ENV=production 起動確認 https://sample.comにアクセスすると、ホーム画面が表示されると思います! うまくいかない場合... 記入ミス等、ケアレスミスしてないか見直す 英語の記事がないか調べてみる docker logs {コンテナid}でエラー原因を探る タスク詳細画面でエラー原因を見てみる などなど...
- 投稿日:2021-06-23T16:09:23+09:00
[ Rails & Nuxt ] Devise_token_auth とAuth Moduleでログイン認証機能を実装する
No. タイトル 1 Dockerで開発環境を構築する 2 ログイン認証を機能を実装する 3 記事投稿機能を実装する 4 AWS ECSを使ってデプロイする 5 Circle CIを使って自動テスト•デプロイをする はじめに Rails & Nuxtでポートフォリオを作成するシリーズの第2弾になります。 全5部構成でDocker,CircleCI,AWS等のモダンな技術を組み込んだ作品を完成させる予定です。 本章では簡単なログイン認証機能の実装を行います。 事前知識 or 参考資料 Devise-token-auth Deviseと組み合わせてRailsにおけるトークン認証を実現するgemです。 ↓公式ドキュメント Auth Module Vuexを使ってログイン状態やユーザー情報を管理してくれます。 最新バージョンでは名前がnuxt-authに変更されていますが、今回は安定版を使用します。 ↓公式ドキュメント Axios HTTPの非同期通信を簡単に行うことができるJavascriptライブラリ ↓公式ドキュメント CORS Cross Origin Resource Sharingの略。自分以外のどのオリジンからのCRUDリクエストを受け付けるか、受け付けないかをフィルターし、セキュリティを高めるためのものです。 Rails側のセットアップ Gemの追加 Gemfile #devise関連 gem 'devise', '4.8.0' gem 'devise_token_auth', '1.1.5' gem 'devise-i18n', '1.9.4' #CORS設定 gem 'rack-cors', '1.1.1' deviseとdevise_token_authを使用して、認証機能を実装します。 devise-i18nはdeviseの出力メッセージを翻訳してくれるgemです。 install $: docker compose build $: docker compose run back rails g devise:install $: docker compose run back rails g devise_token_auth:install User auth $: docker compose run back rails g devise:i18n:locale ja devise-i18n 設定 application/config #deviseの出力メッセージを日本語にする。 config.i18n.default_locale = :ja ルーティング 設定 devise_for: usersの部分は、current_userなどのdevise gemに用意されたメソッドを使えるようにする為の記述です。 devise_token_authのルーティングは:apiという名前空間内に設定します。/api/authというパスでアクセス出来ます。 routes.rb Rails.application.routes.draw do devise_for :users namespace :api do mount_devise_token_auth_for 'User', at: 'auth' end end CORS対策 Nuxt側のオリジンlocalhost:8000からの接続を許可します。 :exposeの部分で、access_token等、ユーザー認証に必要な要素をHeaderに含めるよう指定します。 config/initializers/cors.rb Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do origins 'http://localhost:8000' resource '*', headers: :any, :expose => ['access-token', 'expiry', 'token-type', 'uid', 'client'], methods: [:get, :post, :put, :patch, :delete, :options, :head] end end devise_token_authの設定 config.change_headers_on_each_request:リクエスト毎にtokenを更新するかどうか。 token_lifespan: tokenの有効期間 headers_name: 認証に使うヘッダー要素の名前の定義 config/initializers/devise_token_auth.rb DeviseTokenAuth.setup do |config| config.change_headers_on_each_request = false config.token_lifespan = 2.weeks config.headers_names = {:'access-token' => 'access-token', :'client' => 'client', :'expiry' => 'expiry', :'uid' => 'uid', :'token-type' => 'token-type' } end コントローラーの設定 application_controllerに以下の記述を追加させることで、devise_token_authのコントローラーを適用させます。 configure_permitted_parametersの部分は、strong_parameterを変更しています。今回は新規登録の時に名前も登録したいので、:nameパラメーターを許可します。ちなみにデフォではメールアドレスとパスワードの2つです。 controllers/application_controller.rb class ApplicationController < ActionController::Base include DeviseTokenAuth::Concerns::SetUserByToken skip_before_action :verify_authenticity_token before_action :configure_permitted_parameters, if: :devise_controller? def configure_permitted_parameters devise_parameter_sanitizer.permit(:sign_up, keys: [:name]) end end 最後にmigrationをしてRails側のセットアップは完了です。 $: docker compose run back rails db:migrate フロントサイド authモジュールの追加 $: docker-compose run front yarn add @nuxtjs/auth インストールが完了したら、configのmodule内に追加します。 nuxt.config.js modules: [ 'bootstrap-vue/nuxt', '@nuxtjs/axios', '@nuxtjs/auth', ], axiosの設定 baseURLは、axiosがHTTPリクエストを送信時のベースとなるURLを指定します。 Railsの立ち上げホストであるlocalhost:3000を指定します。 nuxt.config.js axios: { baseURL: "http://localhost:3000" }, devise_token_authは通信時にaccess-token、client、uidを用いてユーザー認証を行います。その為、axiosでRailsAPIと送受信をする際に、これらのパラメーターをセットしておく必要があります。 pluginsディレクトリ配下にaxios.jsを作成して下さい。 plugins/axios.js export default function({ $axios }) { $axios.onRequest(config => { config.headers.client = window.localStorage.getItem("client") config.headers["access-token"] = window.localStorage.getItem("access-token") config.headers.uid = window.localStorage.getItem("uid") config.headers["token-type"] = window.localStorage.getItem("token-type") }) $axios.onResponse(response => { if (response.headers.client) { localStorage.setItem("access-token", response.headers["access-token"]) localStorage.setItem("client", response.headers.client) localStorage.setItem("uid", response.headers.uid) localStorage.setItem("token-type", response.headers["token-type"]) } }) } configのpluginsに以下を追加し、いま作成したプラグインを適用させます。 nuxt.config.js plugins: [ '~/plugins/axios.js' ], authの設定 nuxt.config.js auth: { redirect: { login: '/login', //middleware:authを設定したURLにアクセスがあった場合の、リダイレクト先。 logout: '/', //ログアウト後のリダイレクト先 callback: false, home: '/' ///ログイン後のリダイレクト先。 }, strategies: { local: { endpoints: { //ログイン処理に関する設定 login: { url: '/api/auth/sign_in', method: 'post',propertyName: 'access_token'}, //ログアウト処理に関する設定 logout: { url: '/api/auth/sign_out', method: 'delete' }, //ログイン時にユーザー情報を保存するか。 user: false }, } }, }, 続いて認証に必要な各ページを作成していきます。 ホームページ index.vue <template> <b-container> <b-col offset-md="1" md="10" class="mt-3"> <b-jumbotron class="pb-5"> <template #header>Hello World!</template> <div v-if="this.$auth.loggedIn"> <h2>ログイン済み</h2> </div> <div v-if="!this.$auth.loggedIn"> <h2>未ログイン</h2> </div> <hr class="my-4"> <b-button v-if="!this.$auth.loggedIn" variant="primary" to="/signup">サインアップ</b-button> <b-button v-if="!this.$auth.loggedIn" variant="info" to="/login">ログイン</b-button> <b-button v-if="this.$auth.loggedIn" variant="success" to="/update">アカウント情報変更</b-button> <b-button v-if="this.$auth.loggedIn" variant="danger" @click="logout">ログアウト</b-button> </b-jumbotron> </b-col> </b-container> </template> <script> export default({ data: function () { return { } }, methods: { async logout() { await this.$auth.logout() .then( ()=>{ localStorage.removeItem("access-token") localStorage.removeItem("client") localStorage.removeItem("uid") localStorage.removeItem("token-type") } ) } }, }) </script> <style></style> いくつか補足。 - this.$auth.loggedInはログイン済みがどうかを真偽値で返します。 - this.$auth.logoutは先ほど設定したloguoutの処理、即ちhttp://localhost:3000/api/sign_outにdeleteリクエストを送り、ルートページにリダイレクトします。 - ログアウト時にlocalStorageに保存されているaccess-token等のアイテムを削除します。 非ログイン時 ログイン後 サインアップページ signup.vue <template> <b-container> <b-col offset-md="1" md="10" class="mt-3"> <h3 class="text-center">登録ページ</h3> <Notification :message="error" v-if="error" class="mb-4 pb-3" /> <b-form @submit.prevent="signup"> <b-form-group label="名前:"> <b-form-input placeholder="Enter your nickname" required v-model="name" type="text"></b-form-input> </b-form-group> <b-form-group label="メールアドレス:"> <b-form-input placeholder="Enter email" required v-model="email" type="email"></b-form-input> </b-form-group> <b-form-group label="パスワード:"> <b-form-input placeholder="Enter password" required v-model="password" type="password"></b-form-input> </b-form-group> <b-form-group label="パスワード確認用:"> <b-form-input placeholder="password confirmation" required v-model="password_confirmation" type="password"></b-form-input> </b-form-group> <b-button block type="submit" variant="primary">Submit</b-button> </b-form> </b-col> </b-container> </template> <script> export default{ data: function () { return { name: '', email: '', password: '', password_confirmation: '', error: null } }, methods: { async signup() { try{ await this.$axios.post('/api/auth',{ name: this.name, email: this.email, password: this.password, password_confirmation: this.password_confirmation }) await this.$auth.loginWith('local', { data: { password: this.password, email: this.email }, }) }catch(e){ this.error = e.response.data.errors.full_messages } } } } </script> <style></style> 送信ボタンが押されると、まずは/api/authにpostリクエストを送信し、新規ユーザーの登録を行います。続けてユーザー登録に成功した場合、loginWithメソッドを使ってログイン処理を実行します。 ログインに成功するとプラグインに記述した処理が実行され、access_token等のアイテムがlocalStorageに保存されます。 <Notification :message="error" v-if="error" class="mb-4 pb-3" /> これはリクエストが失敗した場合、エラーメッセージを表示する用のコンポーネントを呼び出しています。エラー内容を:messageを通じて渡しています。 componentsディレクトリ配下にNotification.vueを作成します。 components/Notification.vue <template> <b-alert show variant="danger"> <div v-for="m in message" :key="m.id"> <span>{{ m }}</span> </div> </b-alert> </template> <script> export default { name: 'Notification', props: ['message'] } </script> こんな感じ ログインページ login.vue <template> <b-container> <b-col offset-md="1" md="10" class="mt-3"> <h3 class = "text-center">ログイン</h3> <Notification :message="error" v-if="error" class="mb-4 pb-3" /> <b-form @submit.prevent="login"> <b-form-group label="メールアドレス:"> <b-form-input placeholder="Enter email" required v-model="email" type="email"></b-form-input> </b-form-group> <b-form-group label="パスワード:"> <b-form-input placeholder="Enter password" required v-model="password" type="password"></b-form-input> </b-form-group> <b-button block type="submit" variant="primary">送信</b-button> </b-form> </b-col> </b-container> </template> <script> export default { data: function () { return { email: '', password: '', error: null, } }, methods: { async login() { await this.$auth.loginWith('local', { data: { password: this.password, email: this.email } }) .then( (response) => { }, (error) => { this.error = error.response.data.errors } ) } } } </script> <style></style> ユーザー情報変更ページ update.vue <template> <b-container> <b-col offset-md="1" md="10" class="mt-3"> <h3 class = "form-title text-center">ユーザー情報変更</h3> <Notification :message="error" v-if="error" class="mb-4 pb-3" /> <b-form @submit.prevent="update"> <b-form-group label="名前:"> <b-form-input placeholder="Enter your nickname" required v-model="name" type="text"></b-form-input> </b-form-group> <b-form-group label="メールアドレス:"> <b-form-input placeholder="Enter email" required v-model="email" type="email"></b-form-input> </b-form-group> <b-form-group label="パスワード:"> <b-form-input placeholder="Enter password" required v-model="password" type="password"></b-form-input> </b-form-group> <b-form-group label="パスワード確認用:"> <b-form-input placeholder="password confirmation" required v-model="password_confirmation" type="password"></b-form-input> </b-form-group> <b-button block type="submit" variant="primary">Submit</b-button> </b-form> </b-col> </b-container> </template> <script> export default{ middleware: 'auth', data: function () { return { name: '', email: '', password: '', password_confirmation: '', error: null } }, methods: { async update() { try{ await this.$axios.$put('/api/auth',{ name: this.name, email: this.email, password: this.password, password_confirmation: this.password_confirmation }) this.$router.push('/') }catch(e){ this.error = e.response.data.errors.full_messages } } } } </script> <style></style> middleware: 'auth'はこのページへのアクセスをログイン済みのユーザーのみに制限します。 終わりに 以上でログイン機能の実装は完了です、お疲れ様でした。 最後に開発の役に立つGoogleアプリを2点紹介して終わります。 Advanced REST client GETやPOSTなどのhttpリクエストの通信をチェックしてくれるツール。 Vue.js devtools Googleブラウザ上でVuexの状態を確認できるツール。
- 投稿日:2021-06-23T15:57:15+09:00
[ Rails & Nuxt ] Dockerで開発環境を構築する
No. タイトル 1 Dockerで開発環境を構築する 2 ログイン認証を機能を実装する 3 記事投稿機能を実装する 4 AWS ECSを使ってデプロイする 5 Circle CIを使って自動テスト•デプロイをする はじめに ※初投稿なので多少読みづらいのはご容赦ください! 本記事は私がエンジニアとして就活するにあたり、ポートフォリオを完成させるまでの過程を簡単にまとめたものです。解説書というよりは手順書という感覚で読んで頂ければと思います。 全5部構成でDocker,CircleCI,AWS等モダンな技術を組み込んだ作品を完成させる予定です。 本章ではDockerをローカル環境に導入して、開発環境を構築するフレーズまで進めていきます。 事前知識 Dockerって何やねんって方は以下の学習をおすすめします! ↓ AWS公式の解説。前半部分でDockerの概念について分かりやすく解説されている。 ↓ Dockerの基礎をハンズオン形式で学べます。 Dockerのインストール Dockerの環境構築 まず最初に以下のディレクトリ&ファイルを作ってください。 Gemfile.lockの中身は空で大丈夫です。 sample |-docker-compose.yml |-front | |-Dockerfile | |-back |-Dockerfile |-Gemfile |-Gemfile.lock back/Dockerfile FROM ruby:2.6.3-alpine3.10 ENV RUNTIME_PACKAGES="linux-headers libxml2-dev make gcc libc-dev nodejs tzdata mysql-dev mysql-client yarn" \ DEV_PACKAGES="build-base curl-dev" \ HOME="/app" \ LANG=C.UTF-8 \ TZ=Asia/Tokyo WORKDIR ${HOME} COPY Gemfile ${HOME}/Gemfile COPY Gemfile.lock ${HOME}/Gemfile.lock RUN apk update && \ apk upgrade && \ apk add --update --no-cache ${RUNTIME_PACKAGES} && \ apk add --update --virtual build-dependencies --no-cache ${DEV_PACKAGES} && \ bundle install -j4 && \ apk del build-dependencies && \ rm -rf /usr/local/bundle/cache/* \ /usr/local/share/.cache/* \ /var/cache/* \ /tmp/* \ /usr/lib/mysqld* \ /usr/bin/mysql* front/Dockerfile FROM node:16.3.0-alpine ENV HOME="/app" \ LANG=C.UTF-8 \ TZ=Asia/Tokyo ENV HOST 0.0.0.0 WORKDIR ${HOME} RUN apk update && \ apk upgrade && \ yarn install &&\ rm -rf /var/cache/apk/* Gemfile source 'https://rubygems.org' gem 'rails', '6.1.3.2' docker-compose.yml version: "3" services: db: image: mariadb:10.4 environment: MYSQL_ROOT_PASSWORD: password MYSQL_DATABASE: touhou ports: - '3306:3306' restart: always volumes: - sample-db:/var/lib/mysql back: build: ./back command: /bin/sh -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0' " volumes: - ./back:/app:cached stdin_open: true tty: true depends_on: - db ports: - 3000:3000 front: build: ./front command: yarn run dev volumes: - ./front:/app:cached ports: - 8000:3000 volumes: sample-db: 作成し終わったら以下のコマンドを実行します。 #Dockerfileを基にコンテナを作成 [sample]$: docker-compose build #RailsAPIの作成 [sample]$: docker-compose run back rails new . -f -d mysql --api --skip-test #Nuxtアプリの作成 [sample]$: docker-compose run front npx create-nuxt-app@v3.6.0 front2 # 以下のような選択画面が出ます。 ? Project name --> Sample ? Programming language --> javascript ? Package manager --> Yarn ? UI framework --> bootstrap-vue ? Nuxt.js modules --> Axios ? Linting tools --> ESlint ? Testing framework --> None ? Rendering mode --> Universal ? Development tools --> None ? Github name --> 任意 ? Version control system --> Git Nuxtアプリを作成後、front2ディレクトリの中身を全てfrontディレクトリに移動させて下さい。そしたらfront2は削除してOKです。 データベースの設定 mariadbのコンテナを使用するよう書き換えます。 config/database.yml default: &default adapter: mysql2 encoding: utf8mb4 pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> username: root password: password host: db development: <<: *default database: app_development test: <<: *default database: app_test production: <<: *default database: app_production $: docker-compose build $: docker-compose run back rails db:create $: docker-compose run back rails db:migrate 以上で構築完了です! コンテナを立ち上げるとlocalhost:3000またはlocalhost:8000にアクセス出来るのが確認出来ると思います。 $: docker-compose up localhost:8000 localhost:3000 終わりに 以上で完了です、お疲れ様でした。 次回からポートフォリオの中身を作成していきます。良ければそちらもご覧ください!
- 投稿日:2021-06-23T15:49:00+09:00
ルーティングを書く順番
新規投稿ページの作成のため、ルーティングの設定で私は最初 get "posts/:id" => "posts#show" #showは投稿内容の詳細ページへのアクション get "posts/new" => "posts#new" #newは新規投稿ページへのアクション と設定していた。 しかしリンクから新規投稿ページにアクセスしたところ下図のようなエラーが生じた。 エラー内容を見ていると、どうやらPostコントローラーのshowアクションに関してエラーが生じているように見える。(NoMethodError in Posts#showより) しかしアクセスしたリンクは新規投稿ページへのリンクだったはずである。どうしてこのようなエラーが生じてしまったのだろうか。 その理由はルーティングの順番にある。 :idという変数は何に対してもマッチしてしまう。例えば以下のようなルーティングを設定しているとしよう。 get "posts/index" => "posts#index" get "posts/:id" => "posts#show" get "posts/new" => "posts#new" 新規投稿ページへアクセスしようとする際、ルーティングに対して上から順番にnewのファイルに関するルーティングを探す。変数:idは先ほど述べたように何に対してもマッチしてしまうため get "posts/:id" 部分は get "posts/new"としてしまう。 ゆえにget "posts/new" => "posts#show"として処理されてしまうためアクセス時にエラーが生じてしまった。 ではエラーが起こらないようにするにはどうすれば良いか。 何に対しても:idはマッチしてしまうので、解決策としては:idと書かれているルーティングは最後に書けば良い。
- 投稿日:2021-06-23T15:07:44+09:00
svnを使用したら、zshにコマンドがないというエラーが出た
自分は特にM1Macを使っているので、ネットによくあるコマンドを使った時によくこの手のエラーに遭遇する。 で、今回は この記事をもとに特定のディレクトリから直接ダウンロードしようとしたら、zshでコマンドがないとのこと。 結論、 brew install svn で解決。 要するにsvnがインストールされていないわけです。 追記 svnはめちゃくちゃ便利。 Githubの特定のディレクトリからコマンド一発でダウンロードできるのはありがたい。。。。
- 投稿日:2021-06-23T12:15:16+09:00
【Rails】bundle installができない時の解決方法
超初心者向けです。 gemをインストールする際にbundle installを実行した時の、エラーの解決方法を紹介します。 エラー内容 ターミナル Fetching source index from https://rubygems.org/ Retrying fetcher due to error (2/4): Bundler::HTTPError Could not fetch specs from https://rubygems.org/ Retrying fetcher due to error (3/4): Bundler::HTTPError Could not fetch specs from https://rubygems.org/ bundle install を実行しところ、随分待たされた挙句に上記のエラーが発生。 試したこと gem update --system 私の場合、試してもエラーが発生。 大体は解決するそうですが、、、 解決方法 PCを再起動、もしくは、通信環境が良いところで再度チャレンジ。
- 投稿日:2021-06-23T12:01:32+09:00
Railsアプリのリンクにショートカットキーを割り当てたいときに絶対に実現できる記事
shortcuts.jsという、アプリに自身で決めたショートカットキーを指定することができるjsのファイルが配布されているが、 どの記事もアラートを出すような内容だったから、今回はRailsのlink_toをクリックするときのショートカットキーを作成していく。 【ステップ1】shortcuts.jsをゲット http://www.openjs.com/scripts/events/keyboard_shortcuts/index.php このサイトのCodeというセクションでjsのコードが表示されるリンクがあるから、そこからコードをゲットする。 その後に、そのコードをペーストしたshortcuts.jsを作成する。 一応コードも載せておく。 shortcuts.js shortcut = { 'all_shortcuts':{},//All the shortcuts are stored in this array 'add': function(shortcut_combination,callback,opt) { //Provide a set of default options var default_options = { 'type':'keydown', 'propagate':false, 'disable_in_input':false, 'target':document, 'keycode':false } if(!opt) opt = default_options; else { for(var dfo in default_options) { if(typeof opt[dfo] == 'undefined') opt[dfo] = default_options[dfo]; } } var ele = opt.target; if(typeof opt.target == 'string') ele = document.getElementById(opt.target); var ths = this; shortcut_combination = shortcut_combination.toLowerCase(); //The function to be called at keypress var func = function(e) { e = e || window.event; if(opt['disable_in_input']) { //Don't enable shortcut keys in Input, Textarea fields var element; if(e.target) element=e.target; else if(e.srcElement) element=e.srcElement; if(element.nodeType==3) element=element.parentNode; if(element.tagName == 'INPUT' || element.tagName == 'TEXTAREA') return; } //Find Which key is pressed if (e.keyCode) code = e.keyCode; else if (e.which) code = e.which; var character = String.fromCharCode(code).toLowerCase(); if(code == 188) character=","; //If the user presses , when the type is onkeydown if(code == 190) character="."; //If the user presses , when the type is onkeydown var keys = shortcut_combination.split("+"); //Key Pressed - counts the number of valid keypresses - if it is same as the number of keys, the shortcut function is invoked var kp = 0; //Work around for stupid Shift key bug created by using lowercase - as a result the shift+num combination was broken var shift_nums = { "`":"~", "1":"!", "2":"@", "3":"#", "4":"$", "5":"%", "6":"^", "7":"&", "8":"*", "9":"(", "0":")", "-":"_", "=":"+", ";":":", "'":"\"", ",":"<", ".":">", "/":"?", "\\":"|" } //Special Keys - and their codes var special_keys = { 'esc':27, 'escape':27, 'tab':9, 'space':32, 'return':13, 'enter':13, 'backspace':8, 'scrolllock':145, 'scroll_lock':145, 'scroll':145, 'capslock':20, 'caps_lock':20, 'caps':20, 'numlock':144, 'num_lock':144, 'num':144, 'pause':19, 'break':19, 'insert':45, 'home':36, 'delete':46, 'end':35, 'pageup':33, 'page_up':33, 'pu':33, 'pagedown':34, 'page_down':34, 'pd':34, 'left':37, 'up':38, 'right':39, 'down':40, 'f1':112, 'f2':113, 'f3':114, 'f4':115, 'f5':116, 'f6':117, 'f7':118, 'f8':119, 'f9':120, 'f10':121, 'f11':122, 'f12':123 } var modifiers = { shift: { wanted:false, pressed:false}, ctrl : { wanted:false, pressed:false}, alt : { wanted:false, pressed:false}, meta : { wanted:false, pressed:false} //Meta is Mac specific }; if(e.ctrlKey) modifiers.ctrl.pressed = true; if(e.shiftKey) modifiers.shift.pressed = true; if(e.altKey) modifiers.alt.pressed = true; if(e.metaKey) modifiers.meta.pressed = true; for(var i=0; k=keys[i],i<keys.length; i++) { //Modifiers if(k == 'ctrl' || k == 'control') { kp++; modifiers.ctrl.wanted = true; } else if(k == 'shift') { kp++; modifiers.shift.wanted = true; } else if(k == 'alt') { kp++; modifiers.alt.wanted = true; } else if(k == 'meta') { kp++; modifiers.meta.wanted = true; } else if(k.length > 1) { //If it is a special key if(special_keys[k] == code) kp++; } else if(opt['keycode']) { if(opt['keycode'] == code) kp++; } else { //The special keys did not match if(character == k) kp++; else { if(shift_nums[character] && e.shiftKey) { //Stupid Shift key bug created by using lowercase character = shift_nums[character]; if(character == k) kp++; } } } } if(kp == keys.length && modifiers.ctrl.pressed == modifiers.ctrl.wanted && modifiers.shift.pressed == modifiers.shift.wanted && modifiers.alt.pressed == modifiers.alt.wanted && modifiers.meta.pressed == modifiers.meta.wanted) { callback(e); if(!opt['propagate']) { //Stop the event //e.cancelBubble is supported by IE - this will kill the bubbling process. e.cancelBubble = true; e.returnValue = false; //e.stopPropagation works in Firefox. if (e.stopPropagation) { e.stopPropagation(); e.preventDefault(); } return false; } } } this.all_shortcuts[shortcut_combination] = { 'callback':func, 'target':ele, 'event': opt['type'] }; //Attach the function with the event if(ele.addEventListener) ele.addEventListener(opt['type'], func, false); else if(ele.attachEvent) ele.attachEvent('on'+opt['type'], func); else ele['on'+opt['type']] = func; }, //Remove the shortcut - just specify the shortcut and I will remove the binding 'remove':function(shortcut_combination) { shortcut_combination = shortcut_combination.toLowerCase(); var binding = this.all_shortcuts[shortcut_combination]; delete(this.all_shortcuts[shortcut_combination]) if(!binding) return; var type = binding['event']; var ele = binding['target']; var callback = binding['callback']; if(ele.detachEvent) ele.detachEvent('on'+type, callback); else if(ele.removeEventListener) ele.removeEventListener(type, callback, false); else ele['on'+type] = false; } } 【ステップ2】shortcuts.jsをRailsアプリのディレクトリに入れる app/javascript/の配下にshortcuts.jsを置く。 自分の場合は、分かりやすいように app/javascript/shortcuts/shortcuts.jsというように shortcutsフォルダを作成して、その下にshortcuts.jsを置いた。 【ステップ3】application.jsでshortcuts.jsを読み込む app/javascript/packs/application.jsに以下を追記する。 application.js require ('shortcuts/shortcuts') これで下準備は完了 【ステップ4】ショートカットキーを割り当てたいリンクにidをつける shortcuts.jsとlink_toを結びつけるには同じidで連携する必要がある。 ショートカットキーを割り当てたいlink_toやbtnにidをつける。 下の例では、オプションの一番最後に追記した。 sample.html.slim / link_toの場合 = link_to '追加', new_task_path, remote: true, class: "nav-link btn", :style=>"color:#6558F5; border: 1px solid #6558F5;", id: 'task-add-shortcut' 【ステップ5】ショートカット作成のjsのコードを作成する 先ほど作成したshortcuts.jsのコードの下に追記する。 大まかなコードの書き方は決まり文句である。中身を変更したら別の処理が作れる。 shortcut.js // タスク追加ショートカット shortcut.add("Ctrl+N",function() { var a = document.getElementById("task-add-shortcut"); a.click(); },{ 'type':'keydown', 'propagate':true, 'target':document }); ・addの横の第一引数に割り当てたいショートカットキーと指定。 ・getElementByIdの引数に割り当てたい先程のリンク(link_toなど)のidを指定。 工程は以上。 ページをリロードして、ショートカットを試してみてください。
- 投稿日:2021-06-23T11:41:37+09:00
今更だけどdeviseのPrefixについて整理してみた
はじめに deviseで自動作成されるルーティングである「sessions」「password」「registrations」に関する備忘録です。 Prefixによる処理の記述はよく行いますが、「Prefixを見ただけでどんな処理が実行されるか?」をパッとイメージできなかったので整理しました。 ミスがあれば、コメントで教えてください。 開発環境 Ruby 2.7.2 Rails 6.1.3 PostgreSQL 13.2 事前準備 まずはログイン機能をもつサンプルアプリを下記コマンドで作成します。 DBはPostgreSQL、テストファイルは不要なので-Tを付けます。 rails new sample -d postgresql -T gem 'devise'の記述をGemfileに追加し、bundle installをします。 次に、下記コマンドを実行し、ログイン機能に対応したUserモデルを作成します。 rails g devise:install rails g devise User rails db:migrate 最後に、下記コマンドでDBの作成とマイグレーションを実行します。 rails db:create rails db:migrate http://localhost:3000/users/sign_inにアクセスして、ログイン画面が表示されれば準備OKです。 deviseで作成されたPrefixについて 現段階のuser関連のルーティング rails routes | grep userを実行し、現段階のuser関連のルーティングを確認します。 コマンドを実行すると、下記が表示されます。 各行の一番最初の文字列がPrefixですね。 new_user_session GET /users/sign_in(.:format) devise/sessions#new user_session POST /users/sign_in(.:format) devise/sessions#create destroy_user_session DELETE /users/sign_out(.:format) devise/sessions#destroy new_user_password GET /users/password/new(.:format) devise/passwords#new edit_user_password GET /users/password/edit(.:format) devise/passwords#edit user_password PATCH /users/password(.:format) devise/passwords#update PUT /users/password(.:format) devise/passwords#update POST /users/password(.:format) devise/passwords#create cancel_user_registration GET /users/cancel(.:format) devise/registrations#cancel new_user_registration GET /users/sign_up(.:format) devise/registrations#new edit_user_registration GET /users/edit(.:format) devise/registrations#edit user_registration PATCH /users(.:format) devise/registrations#update PUT /users(.:format) devise/registrations#update DELETE /users(.:format) devise/registrations#destroy POST /users(.:format) devise/registrations#create この結果を見てみると、作成されたPrefixは「user_session」「user_password」「user_registration」に関連していることがわかります。 大雑把ですが、それぞれこんな役割があります。 user_session:ログイン・ログアウト関連 user_password:パスワードの更新・再発行などパスワード関連 user_registration:新規登録やユーザーデータの更新・削除など、登録情報関連 VERBがGETのものに限りますが、「http://localhost:3000/~~~ 」の~~~を上記のURLに変更して、どんな画面にアクセスするか確認してみてください。 user_session user_sessionに関するルーティングは、以下の通りです。 new_user_session GET /users/sign_in(.:format) devise/sessions#new user_session POST /users/sign_in(.:format) devise/sessions#create destroy_user_session DELETE /users/sign_out(.:format) devise/sessions#destroy new_user_session:ログインページへのアクセス user_session:ログイン処理 destroy_user_session:ログアウト処理 user_password new_user_password GET /users/password/new(.:format) devise/passwords#new edit_user_password GET /users/password/edit(.:format) devise/passwords#edit user_password PATCH /users/password(.:format) devise/passwords#update PUT /users/password(.:format) devise/passwords#update POST /users/password(.:format) devise/passwords#create new_user_password:パスワード再発行画面へのアクセス edit_user_password:パスワード編集画面へのアクセス user_password:パスワードの更新や削除などの処理 user_registration cancel_user_registration GET /users/cancel(.:format) devise/registrations#cancel new_user_registration GET /users/sign_up(.:format) devise/registrations#new edit_user_registration GET /users/edit(.:format) devise/registrations#edit user_registration PATCH /users(.:format) devise/registrations#update PUT /users(.:format) devise/registrations#update DELETE /users(.:format) devise/registrations#destroy POST /users(.:format) devise/registrations#create new_user_registration:アカウント登録ページへのアクセス edit_user_registration:アカウント編集ページへのアクセス user_registration:アカウント編集、更新、削除の処理 最後に 今回はdeviseで自動作成されるルーティングのPrefixについてまとめました。 Prefixから処理の内容をイメージするために整理しましたが、もう少し勉強が必要な箇所がある(特に、passwordらへん)ので、引き続き勉強を続けていきます。 参考記事:【Rails】devise関連のルーティングまとめ
- 投稿日:2021-06-23T08:00:52+09:00
ransackで入力した値が、営業時間内か検索する。
はじめに 現在デートスポットのレビューサイトを作成中です。 検索した時間が、営業時間内に入っていれば表示する検索機能を実装しようとしたら、なかなか実装できませんでした。 その実装方法を記事にしてみようと思います。 modelの概要 create_table "date_spots", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.integer "genre_id" t.string "name" t.datetime "opening_time" t.datetime "closing_time" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false end 始業時間(opening_time)と終業時間(closing_time)がデートスポットごとに決められています。 私が実装したい検索機能は、入力した時間が、openinng_timeとclosing_timeの間ならば検索できるというものです。 viewの概要 検索フォームのviewのコードになります。 <div> <%= search_form_for @date_spot_search_params, as: :date_spot_search do |f| %> <%= f.collection_select :opening_time_lteq, BusinessTime.all, :value_time, :time, {selected: @date_spot_search_params.opening_time_lteq.to_s, prompt: "来店時間"} %> <%= f.submit '検索' %> <% end %> </div> BusinessTimeAllはActiveHashで作成した時間の文字列になっています。 朝6:00から、次の日の深夜5時まで時間が登録されています。 一部分を抜粋して表示します。 {time: '00:00', value_time: '2000-01-02 00:00:00 UTC'}, {time: '00:30', value_time: '2000-01-02 00:30:00 UTC'}, {time: '01:00', value_time: '2000-01-02 01:00:00 UTC'}, {time: '02:00', value_time: '2000-01-02 02:00:00 UTC'}, {time: '03:00', value_time: '2000-01-02 03:00:00 UTC'}, {time: '04:00', value_time: '2000-01-02 04:00:00 UTC'} timeカラムはセレクトボックスに表示するために用意されたカラムで、value_timeは値として使用するために用意したカラムです。 value_timeで注目していただきたいのが、00:00時以上になると日付を変化させている点です。 日付を変化させることで、時系列を明確にしています。 このアプリケーションの営業時間カラムでは日付は表示しないので、日付は固定させています。 同じ画面にデートスポットモデル以外の検索機能も実装しているため、独自の検索キーを設定しています。 @date_spot_search_params = DateSpot.ransack(params[:date_spot_search], search_key: :date_spot_search) これを踏まえて、viewの3行目のopening_time_lteqという部分に注目していただきたいです。 <%= f.collection_select :opening_time_lteq, BusinessTime.all, :value_time, :time, {selected: @date_spot_search_params.opening_time_lteq.to_s, prompt: "来店時間"} %> これは、始業時間がtext_boxに入力した時間以下のデートスポットを全て検索するというマッチャになります。 例えば、8時から23時までの営業時間のデートスポットがあったとすると、text_boxに9時と入力すると、始業時間は8時なので検索されることになります。 始業時間以上のお店は検索できますが、閉店時間をすぎていても、このままだと検索されてしまします。 閉店時間以内に入力した時間が入っているかも同時に検索しなければなりません。 そこでコントローラで一工夫しました。 controller概要 application_controller include SessionsHelper include UsersHelper #デートスポットの名前検索の際に使用する def set_q_for_date_spot @date_spot_search_params = DateSpot.ransack(params[:date_spot_search], search_key: :date_spot_search) end #ユーザーの名前検索の際に使用する def set_q_for_user # 同時に1画面で同じパラメータを検索するため、違うパラメータを用意する。 @user_search_params = User.ransack(params[:q]) end date_spots_controller before_action :set_q_for_date_spot def index date_spot_search_params_decided = @date_spot_search_params.result.ransack(closing_time_gteq: @date_spot_search_params.opening_time_lteq) @date_spots = date_spot_search_params_decided.result end date_spots_controllerの方で解説したいと思います。 まず、3行目にあるdate_spot_search_params_decidedは、application_controllerで定義した変数@date_spot_search_paramsを使用しています。 この変数にはformから送られてきた、値が格納されています。 viewの部分で説明した通り、formで入力された値以下の始業時間のデートスポットが全て格納されています。 ここからがポイントです。 そこからさらに入力された値以上の閉店時間のデートスポットを検索します。 そのためのコードがこちらになります。 @date_spot_search_params.result.ransack(closing_time_gteq: @date_spot_search_params.opening_time_lteq) closing_time_gteqで対象の値以上の閉店時間を持つデートスポットを検索するようにします。 対象の時間は@date_spot_search_params.opening_time_lteqにformでviewで入力された値が入っています。 あとはdate_spot_params_decided.resultを行うだけで、求めていた結果が表示されました。 まとめ formに開始時間と終了時間を入力し、検索対象がその範囲に入っているかを検索する記事は多数発見しましたが、自分のように来店したい時間が営業時間の範囲に入っているか検索したい人の記事は少ないようでしたので、この記事を書きました。 まだまだ、エンジニアとしての日が浅く知識として不十分な部分がありますので、何か間違いがあるかもしれません。 間違いがあれば指摘していただけると幸いです。
- 投稿日:2021-06-23T06:43:00+09:00
ド忘れしていたRubyの基礎5選④
どうも、三町哲平です。 ド忘れしていたRubyの基礎5選の第4弾です。 今回は、コチラの問題を解く際を想定 Reverse Integer - LeetCode 解き方は、コチラを参照 Ruby Solution - LeetCode Discuss 1. 後置if # 普通のif文 if foo bar end # 後置ifを使ったif文 bar if foo 可読性の観点からも後置ifの多様には注意が必要です。 引用及び参考記事:【Ruby】乱用厳禁!?後置ifで書くとかえって読みづらくなるケース - Qiita 2. ** 四則演算 p 5**3 # => "125" べき乗されます。 ※上記の場合、5の3乗 引用及び参考記事:四則演算 - 数値と四則演算 - Ruby入門 3. ppライブラリ オブジェクトなどを見やすく出力するためのライブラリです。 pによるpretty-printされてない出力 #<PP:0x81a0d10 @stack=[], @genspace=#<Proc:0x81a0cc0>, @nest=[0], @newline="\n", @buf=#<PrettyPrint::Group:0x81a0c98 @group=0, @tail=0, @buf=[#<PrettyPrint::Gro up:0x81a0ba8 @group=1, @tail=0, @buf=[#<PrettyPrint::Text:0x81a0b30 @tail=2, @wi dth=1, @text="[">, #<PrettyPrint::Group:0x81a0a68 @group=2, @tail=1, @buf=[#<Pre ttyPrint::Text:0x81a09f0 @tail=1, @width=1, @text="1">], @singleline_width=1>, # <PrettyPrint::Text:0x81a0a7c @tail=0, @width=1, @text=",">, #<PrettyPrint::Break able:0x81a0a2c @group=2, @gensace=#<Proc:0x81a0cc0>, @newline="\n", @indent=1, @ tail=2, @sep=" ", @width=1>, #<PrettyPrint::Group:0x81a09c8 @group=2, @tail=1, @ buf=[#<PrettyPrint::Text:0x81a0950 @tail=1, @width=1, @text="2">], @singleline_w idth=1>, #<PrettyPrint::Text:0x81a0af4 @tail=0, @width=1, @text="]">], @singleli ne_width=6>], @singleline_width=6>, @sharing_detection=false> ppによるpretty-printされた出力 #<PP:0x40d0688 @buf= #<PrettyPrint::Group:0x40d064c @buf= [#<PrettyPrint::Group:0x40d05d4 @buf= [#<PrettyPrint::Text:0x40d0598 @tail=2, @text="[", @width=1>, #<PrettyPrint::Group:0x40d0534 @buf=[#<PrettyPrint::Text:0x40d04f8 @tail=1, @text="1", @width=1>], @group=2, @singleline_width=1, @tail=1>, #<PrettyPrint::Text:0x40d053e @tail=0, @text=",", @width=1>, #<PrettyPrint::Breakable:0x40d0516 @genspace=#<Proc:0x40d0656>, @group=2, @indent=1, @newline="\n", @sep=" ", @tail=2, @width=1>, #<PrettyPrint::Group:0x40d04e4 @buf=[#<PrettyPrint::Text:0x40d04a8 @tail=1, @text="2", @width=1>], @group=2, @singleline_width=1, @tail=1>, #<PrettyPrint::Text:0x40d057a @tail=0, @text="]", @width=1>], @group=1, @singleline_width=6, @tail=0>], @group=0, @singleline_width=6, @tail=0>, @genspace=#<Proc:0x40d0656>, @nest=[0], @newline="\n", @sharing_detection=false, @stack=[]> 引用及び参考記事:library pp (Ruby 3.0.0 リファレンスマニュアル) 引用及び参考記事:【ruby】p pp puts print 違い。 - Qiita 4. * 可変長引数 *をつければ引数を、配列に指定できます。 #複数の引数を配列にする def array(*a) pp a end > array(1,2) #=> [1, 2] > array(1, 2, 3) #=> [1, 2, 3] 引用及び参考記事:メソッドの引数にアスタリスク - Qiita 5. ** オプション引数 **をつければ引数を、ハッシュに指定できます。 #複数の引数をハッシュにする def array(**a) pp a end > array(b: 1, c: 2) #=> {:b=>1, :c=>2} > array(b: 1, c: 2, d: 3) #=> {:b=>1, :c=>2, :d=>3} 引用及び参考記事:メソッドの引数にアスタリスク - Qiita
- 投稿日:2021-06-23T06:26:53+09:00
Railsのcallbackでハマったこと
一時的にcallbackを外したい時に、全部で3つのcallbackがあったとしてA,Bだけskip_callbackでcallbackを外して、Cだけ残し、あとから、A, Bをset_callbackで復活させた場合、 以前はA, B, Cの順で呼ばれてたのに、C, A, B に変わってしまう。以下のようにしないといけない。 skip A skip B なにか処理 skip C set C set B set A callbackの中でself.reloadを実行すると、後続のcallbackでは、xxxx_was などのdirty attributesが取れなくなってしまう
- 投稿日:2021-06-23T00:26:05+09:00
マークダウン形式への変換に便利!「reverse_markdown」
参考 Github reverse_markdown × マークダウン形式を変換ではなく ○ マークダウン形式に変換したい そんな時に便利なgemでした。 実装経緯 ウィジウィグ使用して保存されているHTML形式データを取り出してマークダウン形式に変換 それをCSV出力 (※今回ウィジウィグ・CSV出力については割愛させていただきます。) 上記を行う事があったため reverse_markdown(gem) を使用して実装してみた。ありがたや... 忘れないようにメモ。 結論 テスト的に下記のようなHTML文を変換すると... <h3>項目タイトル</h3> <p><a href="https://www.test.co.jp/">test</a></p> <p><img alt="【画像出典元】" height="165" src="https://www.test.com/"></p> <p> </p> <p> </p> <p> </p> <p><u>小見出し線</u></p> <p><strong>太字</strong></p> <p> </p> <p>改行<br /> 改行</p> <p><a href="http://www.yahoo.co.jp" rel="nofollow noopener" target="_blank"><img alt="test" loading="lazy" src="sakura.jpg" /></a></p> <p><a href="sakura.jpg" rel="noopener noreferrer" target="_blank"><img alt="test" src="test.jpg" style="max-width:100%;" /></a></p> こうなった(CSV出力データ) 1.項目タイトル ### 項目タイトル ## 項目タイトル [test](https://www.test.co.jp/)  <u>小見出し線</u> **太字** 改行 改行 [](http://www.test.co.jp) [](test.jpg) マークダウン形式を変換してくれるサイト(Qiita)にコピペしたらこんな感じ(下記) test 小見出し線 太字 改行 改行 わかったこと(箇条書き) brタグ、pタグは変換時に消える(gsubで対応すればなんとかなる) figureタグに囲まれている画像データ(img要素)は変換されない その他は試してないので分からない。(ご自身でお試しください。) 使い方(基本機能のみ) ReverseMarkdown.convert 変数 #変数にはHTML要素を格納した変数 私はこんな感じで使用させていただきました。 他にもオプションなどがある様でしたが、今回は基本的なものだけを使わせていただきました。 興味がある方ははじめに記載した参考Github「reverse_markdown」からご確認ください。
- 投稿日:2021-06-23T00:26:05+09:00
HTMLをマークダウン形式に変換「reverse_markdown」
参考 Github reverse_markdown × マークダウン形式 → HTML形式 変換ではなく ○ HTML形式 → マークダウン形式 変換したい そんな時に便利なgemでした。 実装経緯 ウィジウィグ使用して保存されているHTML形式データを取り出してマークダウン形式に変換 それをCSV出力 (※今回ウィジウィグ・CSV出力については割愛させていただきます。) 上記を行う事があったため reverse_markdown(gem) を使用して実装してみた。ありがたや... 忘れないようにメモ。 結論 テスト的に下記のようなHTML文を変換すると... <h3>項目タイトル</h3> <p><a href="https://www.yahoo.co.jp/">test</a></p> <p><img alt="【画像出典元】" height="165" src="https://www.google.com/"></p> <p> </p> <p> </p> <p> </p> <p><u>小見出し線</u></p> <p><strong>太字</strong></p> <p> </p> <p>改行<br /> 改行</p> <p><a href="http://www.yahoo.co.jp" rel="nofollow noopener" target="_blank"><img alt="test" loading="lazy" src="sakura.jpg" /></a></p> <p><a href="sakura.jpg" rel="noopener noreferrer" target="_blank"><img alt="test" src="test.jpg" style="max-width:100%;" /></a></p> こうなった(CSV出力データ) 1.項目タイトル ### 項目タイトル ## 項目タイトル [test](https://www.yahoo.co.jp/)  <u>小見出し線</u> **太字** 改行 改行 [](http://www.yahoo.co.jp) [](test.jpg) マークダウン形式を変換してくれるサイト(Qiita)にコピペしたらこんな感じ(下記) test 小見出し線 太字 改行 改行 わかったこと(箇条書き) brタグ、pタグは変換時に消える(gsubで対応すればなんとかなる) figureタグに囲まれている画像データ(img要素)は変換されない その他は試してないので分からない。(ご自身でお試しください。) 使い方(基本機能のみ) ReverseMarkdown.convert 変数 #変数にはHTML要素を格納した変数 私はこんな感じで使用させていただきました。 他にもオプションなどがある様でしたが、今回は基本的なものだけを使わせていただきました。 興味がある方ははじめに記載した参考Github「reverse_markdown」からご確認ください。
- 投稿日:2021-06-23T00:09:06+09:00
[Ruby] Arrayメソッドで使うfirstとlastとそれ以外について
Rubyには配列メソッドとしてfirstとlastというものが存在します。 [0,1,2].first => 0 [0,1,2].last => 1 それぞれ配列の要素の最初と最後を取得するメソッドです。 それ以外のやり方としてsecond,third,fourth,fifthがあります。 使い方は以下のようになります [1,2,3,4,5].second => 2 [1,2,3,4,5].third => 3 [1,2,3,4,5].fourth => 4 [1,2,3,4,5].fifth => 5 それぞれ配列の2,3,4,5番目から要素を取得します。
- 投稿日:2021-06-23T00:09:06+09:00
[Rails] Arrayメソッドで使うfirstとlastとそれ以外について
環境 ruby 2.6.6 Rails 6.0.3.6 本題 Rubyには配列メソッドとしてfirstとlastというものが存在します。 [0,1,2].first => 0 [0,1,2].last => 1 それぞれ配列の要素の最初と最後を取得するメソッドです。 それに加えてRailsには新たなメソッドsecond,third,fourth,fifthがあります。 ※これらはRailsのメソッドであり,Rubyの組込みメソッドではありません。 使い方は以下のようになります [1,2,3,4,5].second => 2 [1,2,3,4,5].third => 3 [1,2,3,4,5].fourth => 4 [1,2,3,4,5].fifth => 5 それぞれ配列の2,3,4,5番目から要素を取得します。