- 投稿日:2019-12-03T23:57:08+09:00
Google Compute Engine&Nginx&Railsタイムアウト考察
diffeasy Advent Calendar 2019の3日目の記事です。
タイムアウト対策
Webでの大量データのPDF、CSVダウンロードなど、API実行すると処理が圧倒的に長くなるとタイムアウトになります。根本のロジックを見直す、非同期処理やバッチ処理を検討することが必達です。
ただ、根本対策に時間を要してしまう可能性があったため、暫定対策としてのインフラ側のタイムアウト値設定を伸ばすことをやってみたのでまとめます。
4分(240秒)ぐらいかかる処理だったため、余裕を持って5分(300秒)ほど処理できるようにしたものです。
(WebAPIとしてありえないとは突っ込まないでほしい。一旦。。)
前提となる環境
- GoogleComputeEngine(Linux CentOS)
- GCPロードバランサ
- nginx
- Rails(APIモード)
- Puma
- Vue.js(axiosでAPI実行)
処理順序は?
基本的に処理が進むにつれてタイムアウト値は短くする必要があります。
①API URL実行
②GCPロードバランサ
③GoogleComputeEngine(Linux CentOS)
④nginx
⑤puma(Rails)タイムアウト値を持っているのは?
今回の構成では以下2つのみがデフォルト保有しているものでした。
この2つを変更することでタイムアウト値を変えることが出来ます。
②ロードバランサ
④nginx変更方法
②ロードバランサ
④nginx
nginxのタイムアウト初期値は60秒。290秒に変更する。
fastcgi_read_timeoutproxy_read_timeoutをセットする。server { listen 10443; server_name xxxx.hogehoge.com; # タイムアウトまでの秒数を変更 fastcgi_read_timeout 290; proxy_read_timeout 290; }まとめ
4分ほどかかる処理を作ってしまうのが、そもそもアンチパターンですね。
ただ、その処理をリファクタリングするのに時間をかけてユーザーを待たせる可能性があるのなら、長い時間を待たせて動かしてもらうのも対策の1つになり得ると思います。
- 投稿日:2019-12-03T23:35:41+09:00
【jQuery】activの時にFontAwesomeをつけたい
コトの顛末
アクティブ状態の箇所にカレントとしてFontAwesomeのアイコンをつけたかったのですが、
jQueryでaddClassすると思ったようなレイアウトにならない事案が発生。
hamlで直書きして調整するとあんなに楽なのになぁ...ということで小技発見したのでメモがてら書きます。
問題の事案
FontAwesomeのclassをjQueryでaddClassすると、classはちゃんとつくのですが、
そのclassが付与されたところのレイアウトを合わせるのが大変だったので楽な方法ないかな〜と思ってました。下記のように空の<i>タグを用意して、
index.hmlt.haml%i.faそれにclassつけたら擬似的に直書きと同じことができる!って思ったんですが...
ブラウザで表示を確認すると、空タグのせいかレンダリングされたコードの中にいないため、
jQueryのaddClassが不発になるという事案が発生しました。このような処理で小技を実行しました。
まず、普通に FontAwesomeのアイコン使いたいところに書き込みます。
これでスタイルを調整しておきます。index.hmlt.haml%i.fa.fa-playでも、アクティブじゃない時は classの「fa-play」が必要ないので削除します。
しかし!このままでは空タグ扱いとなり、ブラウザ上で表示されない(つまり
jQueryが効かない)ため、スペースのエンティティタグを入れます。index.hmlt.haml%i.fa 「&nbsp」これを入れることにより、空欄判定にならず、jQueryが効きます。
test.js$(".fa").addClass("fa-play");こんな感じのjQueryをあてがってあげましょう。
無事にactiv時だけカレントマークつきました!
終わりに
逆の方法として、すでについているクラスを消すは簡単な方法でしたが、
読み込みの際に、ズラ〜っと表示され、
読み込み完了後に、カレント箇所だけアイコンがついている状態になる。
読み込み完了後は良いですが、途中経過が気持ち悪かったので、
どうしてもちゃんと必要な箇所にだけアイコンをつける方式で実装したかったのです。
なので小技発見できてよかった!
これが本当に良い方法かはわかりませんが。
- 投稿日:2019-12-03T22:34:57+09:00
railsアプリではクラスインスタンス変数の注意する
クラスインスタンス変数の注意
右辺の実行結果をキャッシュするために以下の手法が使われる事があると思います。
1回目のメソッド実行時に実行結果をインスタンス変数@fooでキャッシュしておいて、2回目のメソッド実行時にはインスタンス変数の中身を返しています。Class Foo def @foo ||= bar end endインスタンスメソッドの場合、インスタンス変数はFooクラスのオブジェクト毎に保持されます。
railsアプリ内のコードでこれをクラスメソッド内で使用する場合は注意が必要です。Class Foo self.def @foo ||= bar end endクラスメソッド内でインスタンス変数はClassクラスのオブジェクト毎に保持され、
Classクラスのオブジェクトはrailsアプリケーション起動時から同じものを使い続けます。
その為、DB問合せ結果をキャッシュしてしまったりすると、アプリケーション起動後DBに初めて問い合わせした値を保持し続けてしまう為、その後別のリクエストでDBを更新しても、反映されない問題が発生します。
- 投稿日:2019-12-03T22:01:55+09:00
メモ
ストロングパラメータ
指定したキーのみを受け取るので、データをDBに入れるかどうかを判別させるために使う。
ex params.permit(:キー名,キーの値)java script
list.push(***); 配列に要素を追加する。
list.pop(); 配列の最後の要素削除
⇅ 逆の関係
list.shift(); 配列の最初の要素削除オブジェクトの作成
let object = {};関数を使うとき
returnを記述する。
returnは関数内で処理をした結果戻り値として返す。関数とは
入力された 値に対して 出力して 返すこと引数 → 関数 → 戻り値
function calc(num1,num2){
return num1*num2;
}let num1 = 5;
let num2 = 7;
console.log(calc(num1,num2));DOM (document object model)
htmlを階層上に表現したデータでjava script
body ---------header
|
|
------content
|
|
-------footerbody header content footer をノードと言う
- 投稿日:2019-12-03T21:46:30+09:00
Railsチュートリアル 第11章 リスト11.26のテストでテスト結果がRedになるはずのところでGreenになってしまった。
過去の2つの記事との脈絡が無いのが申し訳ないのですが、現在Railsチュートリアルの2周目を行っていて、あまりにもQiitaへの投稿をこまめにやっていなかったのが自分でも気になり、練習がてら自分がハマってしまったことについて投稿する練習をしてみようと思い投稿しました。
内容的にあまりにも初歩的な内容のため誰かの参考になるような記事では無いと思います。問題
リスト11.26からリスト11.30までテストの結果がRailsチュートリアルが意図するものと違う結果になってしまっていた。
リスト11.26
https://railstutorial.jp/chapters/account_activation?version=5.1#code-generalized_authenticated_p本来このリスト11.26ではRailsチュートリアルの説明通り引数の数が合わないことが原因となるArgumentErrorが出るはずであったが、何故かErrorは出ずにGreenになってしまった。
リスト 11.26: 抽象化された
authenticated?メソッド redapp/models/user.rbclass User < ApplicationRecord . . . # トークンがダイジェストと一致したらtrueを返す def authenticated?(attribute, token) digest = send("#{attribute}_digest") return false if digest.nil? BCrypt::Password.new(digest).is_password?(token) end . . . endリスト 11.26のキャプションに記したとおり、この時点ではテストスイートは redになります。
リスト 11.27: red
$ rails tここでRedになるはずだったのだが、
何故か自分の実際の環境ではGreenとなってしまった。$ rails t Running via Spring preloader in process 84886 Started with run options --seed 28770 42/42: [=======================================================================================================] 100% Time: 00:00:02, Time: 00:00:02 Finished in 2.68394s 42 tests, 172 assertions, 0 failures, 0 errors, 0 skipsエラーであればエラーメッセージや結果に基づいてググるなりなんなりして解決出来ると考えたが、エラーではなく何も問題が無いと言われてしまうと今の自分の力では原因の特定は無理だと考えた。その後仕方なく進めて11.28などの作業をしてみた段階で新たなエラーが出たりしたらまたその時考えようと思い、11.28や11.29のリストの変更作業を行ってみた。その結果、リスト11.29ではGreenになるはずだったものが
Pry#input_array is deprecated. Use Pry#input_ring instead 19:34:42 - INFO - Running: test/models/user_test.rb Running via Spring preloader in process 4899 Started with run options --seed 25892 ERROR["test_authenticated?_should_return_false_for_a_user_with_nil_digest", UserTest, 0.628540000000612] test_authenticated?_should_return_false_for_a_user_with_nil_digest#UserTest (0.63s) ArgumentError: ArgumentError: wrong number of arguments (given 2, expected 1) app/models/user.rb:39:in `authenticated?' test/models/user_test.rb:77:in `block in <class:UserTest>' 12/12: [=========================================================================================================================] 100% Time: 00:00:00, Time: 00:00:00 Finished in 0.71992s 12 tests, 19 assertions, 0 failures, 1 errors, 0 skipsとなってしまった。
該当箇所となるapp/models/user.rb:39:in `authenticated?' test/models/user_test.rb:77:in `block in <class:UserTest>'の部分をいくらコードを見てみてもこの時は何がおかしいのか全く分からず途方に暮れてしまった。リストの内容でググってみても特にここでハマってしまったり自分と似たようなミスをしている人は居ないようで解決の手段が見当たらなかった。
原因
結局今の自力では原因を追求出来なかったのでRailsチュートリアルの11章終了時点でのリポジトリを見せてもらってそことの比較で自分のコードの悪い部分を見つけようと考えた。
またteratailに投稿があった、この内容も自分のコードのどこがおかしいかを考えるいいきっかけになった。
https://teratail.com/questions/214041
この質問とは結果的には自分の環境で起きていた問題とは違う原因や結果だったけど①def current_userが2つ定義されていました。
⇛ 1つ削除しました。の部分を見て自分でも同じように単純に同じ役割で重複しているコードがあるかもしれない?と考えることが出来るようになりました。
11章のuser.rbを確認すると
https://github.com/yasslab/sample_apps/blob/master/5_1_2/ch11/app/models/user.rbお手本となるコードが以下の通りで
app/models/user.rbclass User < ApplicationRecord attr_accessor :remember_token, :activation_token before_save :downcase_email before_create :create_activation_digest validates :name, presence: true, length: { maximum: 50 } VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i validates :email, presence: true, length: { maximum: 255 }, format: { with: VALID_EMAIL_REGEX }, uniqueness: { case_sensitive: false } has_secure_password validates :password, presence: true, length: { minimum: 6 }, allow_nil: true def User.digest(string) cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost BCrypt::Password.create(string, cost: cost) end def User.new_token SecureRandom.urlsafe_base64 end def remember self.remember_token = User.new_token self.update_attribute(:remember_digest, User.digest(remember_token)) end def forget self.update_attribute(:remember_digest, nil) end # 渡されたトークンがダイジェストと一致したらtrueを返す def authenticated?(attribute, token) digest = self.send("#{attribute}_digest") return false if digest.nil? BCrypt::Password.new(digest).is_password?(token) end def activate update_attribute(:activated, true) update_attribute(:activated_at, Time.zone.now) end def send_activation_email UserMailer.account_activation(self).deliver_now end private def downcase_email self.email = self.email.downcase end def create_activation_digest self.activation_token = User.new_token self.activation_digest = User.digest(self.activation_token) # @user.activation_digest => ハッシュ値 end endその時の自分のリスト11.29までの終了時点でのuser.rbのコードと比較すると
user.rbclass User < ApplicationRecord attr_accessor :remember_token, :activation_token before_save :downcase_email before_create :create_activation_digest validates :name, presence: true, length: { maximum: 50 } VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i validates :email, presence: true, length: { maximum: 255 }, format: { with: VALID_EMAIL_REGEX }, uniqueness: { case_sensitive: false } has_secure_password validates :password, presence: true, length: { minimum: 6 }, allow_nil: true # 渡された文字列のハッシュ値を返す def User.digest(string) cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost BCrypt::Password.create(string, cost: cost) end # ランダムなトークンを返す def User.new_token SecureRandom.urlsafe_base64 end # トークンがダイジェストと一致したらtrueを返す def authenticated?(attribute, token) digest = send("#{attribute}_digest") return false if digest.nil? BCrypt::Password.new(digest).is_password?(token) end # 永続セッションのためにユーザーをデータベースに記憶する def remember self.remember_token = User.new_token update_attribute(:remember_digest, User.digest(remember_token)) end # 渡されたトークンがダイジェストと一致したらtrueを返す def authenticated?(remember_token) return false if remember_digest.nil? BCrypt::Password.new(remember_digest).is_password?(remember_token) end # ユーザーのログイン情報を破棄する def forget update_attribute(:remember_digest, nil) end private # メールアドレスをすべて小文字にする def downcase_email self.email = email.downcase end # 有効化トークンとダイジェストを作成および代入する def create_activation_digest self.activation_token = User.new_token self.activation_digest = User.digest(activation_token) end private # メールアドレスをすべて小文字にする def downcase_email # self.email = email.downcase email.downcase! end endお手本のコードと比較すると
authenticated?メソッドは1つしかなく自分のコードを見るとauthenticated?メソッドが2つあって重複していること自体がおかしいのではないか?ということに気付いた。# トークンがダイジェストと一致したらtrueを返す def authenticated?(attribute, token) digest = send("#{attribute}_digest") return false if digest.nil? BCrypt::Password.new(digest).is_password?(token) end ・ ・ ・ # 渡されたトークンがダイジェストと一致したらtrueを返す def authenticated?(remember_token) return false if remember_digest.nil? BCrypt::Password.new(remember_digest).is_password?(remember_token) end対処法
重複しているコードを削除する。
# 渡されたトークンがダイジェストと一致したらtrueを返す def authenticated?(remember_token) return false if remember_digest.nil? BCrypt::Password.new(remember_digest).is_password?(remember_token) endテストの結果
このコードを削除することでGreenとなった。
Pry#input_array is deprecated. Use Pry#input_ring instead 19:57:47 - INFO - Running: test/models/user_test.rb Running via Spring preloader in process 6751 Started with run options --seed 62215 12/12: [=========================================================================================================================] 100% Time: 00:00:00, Time: 00:00:00 Finished in 0.81365s 12 tests, 20 assertions, 0 failures, 0 errors, 0 skipsどうしてこういう初歩的なミスが起きたのか?
リスト 11.26: 抽象化された
authenticated?メソッド redapp/models/user.rbclass User < ApplicationRecord . . . # トークンがダイジェストと一致したらtrueを返す def authenticated?(attribute, token) digest = send("#{attribute}_digest") return false if digest.nil? BCrypt::Password.new(digest).is_password?(token) end . . . endこの時点でこの変更点は書き換えでは無く追加だと思ってしまった。
# 渡されたトークンがダイジェストと一致したらtrueを返す def authenticated?(remember_token) return false if remember_digest.nil? BCrypt::Password.new(remember_digest).is_password?(remember_token) endこのコードがあることに加えて
さらにapp/models/user.rbclass User < ApplicationRecord . . . # トークンがダイジェストと一致したらtrueを返す def authenticated?(attribute, token) digest = send("#{attribute}_digest") return false if digest.nil? BCrypt::Password.new(digest).is_password?(token) end . . . endこのコードを追記しなければならないと勘違いしてしまったが、
実際は追記するのではなくて# 渡されたトークンがダイジェストと一致したらtrueを返す def authenticated?(remember_token) return false if remember_digest.nil? BCrypt::Password.new(remember_digest).is_password?(remember_token) end上記のコードを
app/models/user.rbclass User < ApplicationRecord . . . # トークンがダイジェストと一致したらtrueを返す def authenticated?(attribute, token) digest = send("#{attribute}_digest") return false if digest.nil? BCrypt::Password.new(digest).is_password?(token) end . . . endと書き換えるのがリスト11.26での正しい作業だと気付けなかった。
単純に自分の知識や経験、常識の不足から来るミスだと思うし、同じ名前のメソッドが2つもあることを疑えなかった、おかしいと気付けなかったのが原因だと思う。
ちなみに1周目ではこのエラーを起こさずに普通にこなせていたので、おそらく1周目とは違う考え方で作業やって何事も無くこなせてしまっていたんだと思う。
1周目で起きたトラブルに2周目で対処するとかなら良いことだと思うけど、その逆で1周目の時は無かったエラーやトラブルを2周目でやらかすのはあまり良くないので反省。
1周目では何事も問題無かったのに2周目なら何故起きるんだろう?という手がかりも1周目やった時にはあまり残せていなかったので、その辺ののメモも今後たくさん取っておきたいと思う。
- 投稿日:2019-12-03T20:59:28+09:00
#Rails OR #Ruby + ActiveSupport の String#in_time_zone で 2月29日 30日 31日 が3月にずれるのだが
Rubyとか言語使用に関わる、深い事情ありの話かと思った。
Date.new を利用した方が良いかな。
require 'active_support/core_ext' '2019-2-29'.in_time_zone('Tokyo').to_s # => "2019-03-01 00:00:00 +0900" '2019-2-30'.in_time_zone('Tokyo').to_s # => "2019-03-02 00:00:00 +0900" '2019-2-31'.in_time_zone('Tokyo').to_s # => "2019-03-03 00:00:00 +0900" '2019-2-32'.in_time_zone('Tokyo').to_s # ArgumentError: argument out of range Date.new(2019, 02, 28).in_time_zone('Tokyo') # => Thu, 28 Feb 2019 00:00:00 JST +09:00 Date.new(2019, 02, 29) # ArgumentError: invalid dateOriginal by Github issue
- 投稿日:2019-12-03T20:30:05+09:00
TrailBlazer概要まとめてみた
TrailBlazerを使う機会がありました。
日本語のリソースが少なかったので、軽くではありますがまとめておこうと思います。
まだ理解が若干ふわふわしているので、誤りなどあればご指摘いただけると幸いです!Trailblazerとは?
Rubyのフレームワークに抽象レイヤーを提供するGemの集まりです。
(Railsのほか、HanamiやGrape, Sinatram等にも対応。また「TrailBlazer的設計スタイル」という意味では、どんな言語やフレームワークにも適用可能)そもそものはじまりは、作者のNick Sutterer氏がRailsのMVC抽象レイヤーのあり方に疑問を持ったこと。Railsの手軽さを認める一方、ModelやControllerの肥大カオス化により、のちの保守性が下がることを問題視されたそうです(Nickさんの本意訳)。
Railsの設計思想の特徴ゆえに、迷子になりがちだったビジネスロジックの行き場をModelやController以外の場所に持ってくることができます。
Trailblazerを構成する主なGem
TrailBlazerは、Operation・Reform・Cells・Representableという主に4つのGemから構成されています。
Nickさん自身も述べていらっしゃいますが、すべてのGemを用いる必要はありません。
むしろ、「いつ」「どれくらい」「どの」要素を用いたいかに基づいて、部分的に各Gemを導入できるため、比較的容易に既存アプリケーションと併用することができます。Operation
コントローラーに代わってビジネスロジックを担当するGemです。
stepによる簡潔な処理フローを強みとしています。app/concepts/dinosaur/operation/create.rbclass Dinosaur::Create < TrailBlazer::Operation step Model( Dinosaur, :new ) # あとで登場するFormオブジェクトをビルド step Contract::Build( constant: Dinosaur::Contract::Create ) # ビルドしたFormオブジェクトをValidation step Contract::Validate() # ValidationしたFormオブジェクトをModelに反映 step Contract::Persist() endTrailBlazerにおけるModelの役割は、RailsのModelの役割と基本的に同じです。
Controllerから指示を受けてデータのやり取りを行うか、Operationからの指示を受けるかというほどの違いで、永続化したいデータをDBに保存したり、その値を取得するポータルを提供することがメインのお仕事です。
以下のように、associationやscopeを記載します。app/models/dinosaur.rbclass Dinosaur < ActiveRecord::Base belongs_to :ancient_creatures has_many :types scope :name, ->(name) { where(name: name) } end上記の例ではRailsのActiveRecordを使用していますが、Datamapperなど、ほかのORMでも問題ありません。
*ORM:Object Relational Mapping(オブジェクト関係マッピング)のこと。オブジェクト指向と関係データベースをつなぐことで、関係DBのレコードをオブジェクトとして認識し、直感的な操作を可能にしてくれる。Reform
フォームオブジェクトを作るためのGemです。
ValidationやPersisting(saveかsync)をメインに、フォーム関連の処理をModelに代わって引き受けます。
contractは、Reform::Fromクラスを継承しており、必要項目のpropertyやvalidates内容の定義を行う役割を持ちます。app/concepts/dinosaur/contract/create.rbmodule Dinosaur::Contract class Create < Reform::Form property :name validates :name, presence: true property :type do property :color validates :color, presence: true end end end上記のバリデーションにはRailsのActiveModelを使用していますが、TrailBlazer v2.0以降であれば、dry-validationなども選択可能です。ただ、ActiveModelに関しては将来的に廃止を予定しているとのことです。
Cells
ビューモデル作成用のGemです。
表示関連の処理をModelに代わって引き受け、UIパーツを表現します。
別名「テンプレート・レンダリング」担当。app/concepts/comment_cell.rbclass CommentCell < Cell::ViewModel def show render # app/concepts/comment/show.hamlをレンダリングする end endRepresentable
シリアライズ(デシリアライズ)用のGemです。
受け取ったオブジェクトをJSONやXML、YAMLに変換するほか、JSONからオブジェクトへの変換も可能です。
Operationの中に記述するほか、別ファイルに専用クラスを定義する方法があります。
例として、以下のようなオブジェクトをフォームから受け取るためには、{ "name" => "Velociraptor", "color" => "Blue" }(方法1) Operationファイル内に記述する
app/concepts/dinosaur/operation/create.rbclass Dinosaur::Create < TrailBlazer::Operation ... representer do property :name property :color end end(方法2) 別ファイルにクラスを定義する(下記の例では
DinasourRepresenter)app/concepts/dinosaur/representer/dinasour_representer.rbclass DinasourRepresenter < Representable::Decorator include Representable::Hash # 受け取りたいオブジェクトのフォーマット include Representable::JSON # レスポンスとして返したいフォーマット property :name property :color endJSON形式でレスポンスを返すためには、
DinasourRepresenter.new(dinasour).to_jsonおつかれさまでした!
お付き合いくださり、ありがとうございました!
今年も残り少し、よろしくおねがいします!参考文献
TrailBlazer作者Nickさんの本
TrailBlazer Github
TrailBlazer Official
TrailBlazer Operation以下のQiita記事を参考にさせていただきました!ありがとうございました!
@kazekyoさま
@kouheiszkさま
@yk-nakamuraさま
- 投稿日:2019-12-03T18:43:02+09:00
さくらVPSでCentOS7 11.RailsプロジェクトをGitで共同開発
はじめに
自由にテスト出来るLinuxのサーバーがほしくて、さくらVPSで構築してみました。
順次手順をアップしていく予定です。前回インストールしたRuby On Railsを共同開発できるようにGitで管理したいと思います。
目次
- 申し込み
- CentOS7インストール
- SSH接続
- Apache・PHPインストール
- MariaDBインストール
- FTP接続
- sftp接続
- phpMyAdminインストール
- 環境のバックアップ
- Ruby On Railsインストール
- RailsプロジェクトをGitで共同開発
11.RailsプロジェクトをGitで共同開発
前回、Ruby On Railsインストール時に作成したテスト用プロジェクトHelloWorkdを共同開発できるようにGitで管理したいと思います。
共同開発用のマシンは、ローカルのWindows10のマシンにします。
構成は、こんな感じです。
共有リポジトリは、GitHub等を使う方法もありますが、今回はプロジェクトの有るさくらVPSサーバー内に作成します。Gitでは、開発マシンのソースを直接本番ソースにアップするのではなく、一度共有リポジトリにアップしたものを本番ソースから取りに行く形になります。
本番ソースも開発と同様ローカルポジトリですので、開発側がアップした変更は手動で本番ソースに取り込む必要があります。
これだと不便ですので、共有リポジトリが変更を受け取った時自動的に本番ソースが取りに行くように設定します。さくらVPSにGitインストール
インストール
$ sudo yum install git-allインストール後の設定
ユーザ名とメールアドレスを設定します
$ git config --global user.name "sakura" $ git config --global user.email sakura@kogueisya.comgitの出力をカラーリング
$ git config --global color.ui autoディフォルトエディタの設定
$ git config --global core.editor vimエイリアス設定(「checkout」を「co」に設定)
$ git config --global alias.co checkout確認
$ git config user.name sakura $ git config -l user.name=sakura user.email=sakura@kougeisya.com color.ui=auto core.editor=vim alias.co=checkout core.repositoryformatversion=0 core.filemode=true core.bare=false core.logallrefupdates=true $ cd $ cat .gitconfig [user] name = sakura email = sakura@kougeisya.com [color] ui = auto [core] editor = vim [alias] co = checkout共有リポジトリ(ベアリポジトリ)作成
さくらVPSサーバーに共有リポジトリを作成します。
共有リポジトリ用ディレクトリ作成
/optの中に「プロジェクト名.git」というフォルダを作成します。
$ sudo mkdir -p /opt/helloworld.git/-pは/optフォルダがなければ作成します。
所有者変更
フォルダの所有者を 「4.Apache・PHPインストール」で作成したWebコンテンツ操作用のグループ(webadmin)にします。
$sudo chown root:webadmin /opt/helloworld.git/ $sudo chmod 2775 /opt/helloworld.git/ -Rベアリポジトリを作成
$ cd /opt/helloworld.git $ git init --bare --share Initialized empty shared Git repository in /opt/helloworld.git/---bareは、ベアリポジトリを作成するオプション
--shareは、ベアリポジトリを複数のユーザによって共有可能にするオプション本番ソース
前回「10.Ruby On Railsインストール」で作成したHelloWorldプロジェクトにローカルリポジトリを作成し、リモートリポジトリにプッシュします。
リポジトリのセットアップ
$ cd /var/www/app/HelloWorld $ git init Reinitialized existing Git repository in /var/www/app/HelloWorld/.git/除外ファイル指定
railsコマンド実行時に作成される.gitignoreファイルにリポジトリから除外するファイルを指定するためのルールが記載されています。
このファイルに以下を追加します。$ vi .gitignore# Ignore other unneeded files. doc/ *.swp *~ .project .DS_Store .idea .secretファイルをインデックスに追加(addコマンド)
プロジェクトのファイルをコミット待ちの変更が格納される「ステージングエリア」という一種の待機場所に追加
$ git add .確認
ステージングエリアにあるファイルのリスト表示$ git statusコミット
ローカルリポジトリへの変更反映
$ git commit -m "Initalize repository"コミットメッセージの履歴参照
$ git log commit d3f7327c74c7175b75ca7d78f4a1cd576f5b6d9a Author: sakura <sakura@kougeisya.com> Date: Mon Dec 2 15:49:37 2019 +0900 Initialize repositoryリモートリポジトリにプッシュ
リモートリポジトリのアドレスに「origin」という名前を付けて記録
(「origin」にしたのは、pushやpullコマンドは実行時にリモートリポジトリ名を省略するとoriginという名前を使用する為)$ git remote add origin /opt/helloworld.gitリポジトリをプッシュ
$ git push -u origin master Counting objects: 6674, done. Delta compression using up to 2 threads. Compressing objects: 100% (6272/6272), done. Writing objects: 100% (6674/6674), 29.86 MiB | 8.65 MiB/s, done. Total 6674 (delta 909), reused 0 (delta 0) To /opt/helloworld.git * [new branch] master -> master Branch master set up to track remote branch master from origin.-uオプションは、「git push -u origin master」とすると次回から「git push」だけでpushしてくれます。
本番ソース反映の自動化
本番ソースも開発と同様ローカルポジトリですので、開発側がアップした変更は手動で本番ソースに取り込む必要があります。
これだと不便ですので、共有リポジトリが変更を受け取った時自動的に本番ソースが取りに行くように自動化します。自動化にはフックを使います。
フックは、Gitでコマンドを実行する直前もしくは実行後に特定のスクリプトを実行する為の仕組みです。フックの種類(サーバーサイド)
フック 概要 pre-receive クライアントからpushを受け取った直後に起動。
更新内容の確認が可能。post-receive push内容の適用終了後起動。
通知を行う場合に便利。update pushによる更新対称ブランチごとに起動。 共有リポジトリの中にある「hooks」の中に、フック名のスクリプトファイルを作成することで実現できます。
今回は、開発のローカルリポジトリからプッシュされ、その内容が適用された後に本番のローカルリポジトリからプルするスクリプトを実行したいので、「/opt/helloworld.git/hokks/post-receive」というスクリプトファイルを作成します。
スクリプトの内容は、本番ソースのドキュメントルートに移動し、publlを実行します。$ vi /opt/helloworld.git/hooks/post-receive#!/bin/sh cd /var/www/app/HelloWorld/ git --git-dir=.git pullスクリプトファイルを他のユーザから実行できるようにパーミッションを設定します。
$ chmod 755 /opt/helloworld.git/hooks/post-receive以上でサーバー側の設定は完了です。
開発用ローカルマシン(Windows10)の設定
各ツールのインストール
Ruby、Rails、Git等をインストールしていきます。
Rubyインストール
下記サイトからダウンロードします。
最新版の「Ruby+Devkit 2.6.5-1 (x64)」をダウンロードしました。
https://rubyinstaller.org/downloads/
ダウンロードしたファイルを実行すると下記の画面が表示されます。
「◎I accept the License」を選択し[Next>]をクリックします。
下記画面が表示されたら、チェックボックス3つともチェックをいれ、[Install]をクリックします。
下記画面が表示されたらそのまま[Next>]をクリックします。
下記画面のようにインストールが開始されますので、終わるまでしばらく待ちます。
インストールが終了すると、下記画面が表示されます。
そのまま[Finish]をクリックすると画面が閉じ、インストールが完了します。
引き続き下記の画面が開きます。
1,2,3と入力して[Enter]キーを押すとインストールが開始されます。
インストールが完了すると下記画面のように「Which components shall be installed? If unsure press ENTER []」と表示されますので、[ENTER]キーを押すと画面が閉じます。
Railsに必要なgemをインストール
SQLite3インストール
コマンドプロンプトを開き、以下を実行します。
> ridk exec pacman -S mingw-w64-x86_64-sqlite3下記画面のように「インストールを行いますか? [Y/n]」と聞いてきますので「Y」を入力します。
プロンプトに戻ったらインストール終了です。
引き続き以下のコマンドを実行します。> gem install sqlite3 --platform rubynokogiriインストール
コマンドプロンプトで以下を実行します。
> ridk exec pacman -S mingw-w64-x86_64-libxslt下記画面のように「インストールを行いますか? [Y/n]」と聞いてきますので「Y」を入力します。
プロンプトに戻ったらインストール終了です。
引き続き以下のコマンドを実行します。> gem install nokogiri --platform ruby -- --use-system-librariesNode.jsインストール
下記サイトからダウンロードします。
https://rubyinstaller.org/downloads/
「LTS推奨版」「Windows Installer (.msi)」の「64-bit」をダウンロードしました。
ダウンロードしたファイルを実行すると下記の画面が表示されますので[Next]をクリックします。
下記画面が表示されたら「□I accept the terms in the License Agreement」にチェックを入れ[Next]をクリックします。
下記画面が表示されますので、そのまま[Next]をクリック。
下記画面が表示されたら「Automatically install the ・・・」にチェックをいれて[Next]をクリック。
下記画面の[Install]クリックで、インストールが開始されます。
インストールが終わると下記画面が開きますので[Finish]をクリックすると画面が閉じます。
引き続き下記の画面が開きます。
「継続するには何かキーを押してください ...」と2回表示されますので都度[Enter]キーを押してください。
下記のように「Tyoe ENTER to ext:」と表示されたら完了です。
[Enter]キーを押すと画面が閉じます。
Bundlerインストール
コマンプロンプトを開き下記のコマンドを実行します。
> gem install bundlerGitインストール
GUIでGitが使えるTortoiseGitを使います。
画面のキャプチャは取っていませんでしたm(__)mGit For Windows
下記サイトの[Download]ボタンよりダウンロード
ダウンロードしたファイルを実行し、画面に従いインストール
https://gitforwindows.org/
TortoiseGit
下記サイトのfro 64-bit Windowsの「Download TortoiseGit 2.9.0-64.bit(~19.5Mib)」をクリックしてダウンロード
ダウンロードしたファイルを実行し、画面に従いインストール
https://tortoisegit.org/download/
メニュー等を日本語に設定
下記サイトからLanguage PacksのJapanese 64Bitの[Setup]をクリックしてダウンロード
ダウンロードしたファイルを実行し、画面に従いインストール
https://tortoisegit.org/download/
インストール後、デスクトップの適当な場所で右クリックし表示されるメニューから[TortoisGit]-[設定]を選択
開いた画面の[全般][TotoiseGit][言語(Language)]より「日本語(日本)」を選択、[OK]ボタンをクリック
以上でWIndows10の設定は完了です。クローンを作成
共有リポジトリからクローンを作成します。
作成場所は、C:\Prj\HelloWorldにします。TortoiseGitを利用してクローン作製
デスクトップ等適当な場所で右クリックし、表示されたメニューから[Gitクローン (複製)...]を選択します。
下記の設定画面が開きます
URLには以下の値を設定します。
"ssh://" + ユーザー名 + "@" + さくらVPSサーバーのアドレス + ":" + SSHポート番号 + "共有リポジトリディレクトリ"ディレクトリは、クローンを作成するフォルダ名を設定します。
ここでは、「C:\Prj\HelloWorld」にしました。
フォルダは無ければ作成されます。Putty鍵のロードにチェックを入れ、[...]をクリックし、秘密鍵を選択します。
ここで選択する秘密鍵は、「 7.sftp接続」の設定時に作成した秘密鍵ファイルです。[OK]をクリックすると、秘密鍵のパスフレーズを聞いてきますので、「 7.sftp接続」で設定したパスフレーズを入力し[OK]をクリックします。
動作確認
サーバーを起動してみる
コマンドプロンプトを起動し、ディレクトリの移動。
> cd \Prj\HelloWorldとりあえずサーバーを立ち上げてみたら、railsコマンドがみつからないので、「bundle install」しろと出ました。
>bundle exec rails s bundler: command not found: rails Install missing gem executables with `bundle install`「bundle install」を実行すると、派手にエラーが出ました(T0T)
>bundle install Fetching gem metadata from https://rubygems.org/. Retrying fetcher due to error (2/4): Bundler::PermissionError There was an error while trying to read from `C:/Users/sugi/.bundle/cache/compact_index/rubygems.org.443.29b0360b937aa4d161703e6160654e47/info/mini_portile2`. It is likely that you need to grant read permissions for that path. . Retrying fetcher due to error (3/4): Bundler::PermissionError There was an error while trying to read from `C:/Users/sugi/.bundle/cache/compact_index/rubygems.org.443.29b0360b937aa4d161703e6160654e47/info/thread_safe`. It is likely that you need to grant read permissions for that path. . Retrying fetcher due to error (4/4): Bundler::PermissionError There was an error while trying to read from `C:/Users/sugi/.bundle/cache/compact_index/rubygems.org.443.29b0360b937aa4d161703e6160654e47/info/websocket-driver`. It is likely that you need to grant read permissions for that path. . There was an error while trying to read from `C:/Users/sugi/.bundle/cache/compact_index/rubygems.org.443.29b0360b937aa4d161703e6160654e47/info/globalid`. It is likely that you need to grant read permissions for that path.どうもファイルの読み取り権限に問題があるようです。
調べてもよくわからなかったので、ちと乱暴ですが管理者としてコマンドプロンプトを実行してみました。
再度「bundle install」したら、無事動きました。
サーバー起動に再チャレンジ。
>bundle exec rails server -b 0.0.0.0 => Booting Puma => Rails 5.2.4 application starting in development => Run `rails server -h` for more startup options Please add the following to your Gemfile to avoid polling for changes: gem 'wdm', '>= 0.1.0' if Gem.win_platform? Please add the following to your Gemfile to avoid polling for changes: gem 'wdm', '>= 0.1.0' if Gem.win_platform? *** SIGUSR2 not implemented, signal based restart unavailable! *** SIGUSR1 not implemented, signal based restart unavailable! *** SIGHUP not implemented, signal based logs reopening unavailable! Puma starting in single mode... * Version 3.12.1 (ruby 2.6.5-p114), codename: Llamas in Pajamas * Min threads: 5, max threads: 5 * Environment: development * Listening on tcp://0.0.0.0:3000 Use Ctrl-C to stopコントローラーを作成しプッシュしてみる
コントローラを作成
>bundle exec rails generate controller hello Please add the following to your Gemfile to avoid polling for changes: gem 'wdm', '>= 0.1.0' if Gem.win_platform? Please add the following to your Gemfile to avoid polling for changes: gem 'wdm', '>= 0.1.0' if Gem.win_platform? create app/controllers/hello_controller.rb invoke erb create app/views/hello invoke test_unit create test/controllers/hello_controller_test.rb invoke helper create app/helpers/hello_helper.rb invoke test_unit invoke assets invoke coffee create app/assets/javascripts/hello.coffee invoke scss create app/assets/stylesheets/hello.scssapp/controllers/hello_controller.rbにアクションメソッドindexを追加
class HelloController < ApplicationController def index render plain: 'こんにちは、世界!' end endconfig/routes.rbにルーティング設定
Rails.application.routes.draw do # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html get 'hello/index', to: 'hello#index' endローカルサーバーを起動し動作確認
> bundle exec rails sプッシュ
変更したファイルをさくらVPSサーバの共有リポジトリにプッシュします。
その前に、ローカルリポジトリに変更をコミットします。HelloWorldフォルダの上で右クリックし、表示されたメニューより[Gitコミット(C)->"master"...]を選択。
下記のようにコミット画面が表示されますので、「メッセージ」を入力し、追加ファイルにチェックを入れ[コミット]ボタンをクリックします。
下記画面のようにコミットが実行され、成功と表示されるとコミットは成功です。
コミットが成功したら、共有リポジトリへプッシュします。
上記画面の[プッシュ]ボタンをクリックすると下記のプッシュ画面が開きます。
[OK]をクリックし、サーバーの共有リポジトリにプッシュします。
さくらVPSサーバーでの動作確認
Git確認
共有リポジトリへのプッシュ確認
$ cd /opt/helloworld.git $ git log commit 941e28b8e44d6dfcbe2ba2bdf5189cba76b92b38 Author: Kouichi Sugimoto <sugi@kougeisya.com> Date: Tue Dec 3 18:06:20 2019 +0900 Windowsからのプッシュテスト commit d3f7327c74c7175b75ca7d78f4a1cd576f5b6d9a Author: sugi <sugi@kougeisya.com> Date: Mon Dec 2 15:49:37 2019 +0900 Initialize repositoryプッシュはうまくいっているようです。
開発リポジトリから共有リポジトリにプッシュがあると、自動的に本番サーバーがプルするように設定していました。
これが動いているかログをチェックします。$ cd /var/www/app/HelloWorld $ git log commit 941e28b8e44d6dfcbe2ba2bdf5189cba76b92b38 Author: Kouichi Sugimoto <sugi@kougeisya.com> Date: Tue Dec 3 18:06:20 2019 +0900 Windowsからのプッシュテスト commit d3f7327c74c7175b75ca7d78f4a1cd576f5b6d9a Author: sugi <sugi@kougeisya.com> Date: Mon Dec 2 15:49:37 2019 +0900 Initialize repositoryhello_controller.rbが出来ているかチェック
$ cd /var/www/app/HelloWorld/app/controllers $ ls application_controller.rb concerns hello_controller.rb動作確認
本番ソースのサーバーを起動します。
$cd /var/www/app/HelloWorld $ rails server -b 0.0.0.0 => Booting Puma => Rails 5.2.4 application starting in development => Run `rails server -h` for more startup options Puma starting in single mode... * Version 3.12.1 (ruby 2.6.5-p114), codename: Llamas in Pajamas * Min threads: 5, max threads: 5 * Environment: development * Listening on tcp://0.0.0.0:3000 Use Ctrl-C to stopブラウザで確認
無事動きました。
これでGitを使った共同開発の環境が出来ました。次回
次回は、Pythonのインストールの予定です。
- 投稿日:2019-12-03T18:19:50+09:00
rails-tutorial第9章
8章補足。
sessionを変数と考えていたが、正確には
sessionメソッドを使うと、ユーザーIDなどをブラウザに一時的に保存できるということである。発展的なログイン機構
sessionだけを使ったログインだとサーバーやブラウザを閉じてしまうと、またログインが必要となる。そこを改善できないものだろうか。
ベターなのは、ユーザーの意思で、期限のあるセッションか、永続的なセッションかを選べるようにするといい。
cookiesとsessionの違い。
超わかりやすい説明。
cookiesは診察券、session idは整理番号と考えるとわかりやすい。
cookiesはクライアント側に保存されるので、その情報から、以前何を買ったとかショッピングカートに入れたとかがわかる。session idもcookieに保存されるのだが、session idはブラウザとサーバーの通信状態を呼ぶから、
別のページに移動したり、別のデバイスから入ろうとすると、その時点でsession idはsessionというハッシュから削除されてしまう。なので、たとえcookieからsessionidを盗み出せたとしても、別のページに移動したり、別のデバイスから入ろうとすると、その時点でsession idはsessionというハッシュから削除されてしまうという理由から、そんなsession idはそもそもハッシュに保存されていませんよーとなってしまう。
ただ、cookieは診察券みたいなものなので、「あ、前回はこの病気を見てもらったんですねー」というようなことができる。
以上!!!!!!!!!!!!!!!!!!!!!!
sessionはサーバとブラウザが相互にやり取りをして、どちらかが切れたら終了というものだった。
cookieの実装方法
cookieはクライアント側に記憶トークン(rememberトークンともいう)を付与し、それをパスワードダイジェストのように、ハッシュ化したものをDBに保存する。
そのため、まずは記憶トークンをハッシュ化したものを保存する場所をDBに作っていこう。
$ rails generate migration add_remember_digest_to_users remember_digest:string
ここでも、rails g migration add_追加するカラム名toテーブル名 カラム名:データ型
という風に指定してあげると、rails側で以下のようなファイルを勝手に作ってくれる。db/migrate/[timestamp]_add_remember_digest_to_users.rbclass AddRememberDigestToUsers < ActiveRecord::Migration[5.0] def change add_column :users, :remember_digest, :string end endこれを確認したら rails db:migrate
記憶トークンに使われるランダムな文字列をどうやって作るか?
Ruby標準ライブラリのSecureRandomモジュールにあるurlsafe_base64メソッドなら、この用途にぴったり合いそうです3。このメソッドは、A–Z、a–z、0–9、"-"、"_"のいずれかの文字 (64種類) からなる長さ22のランダムな文字列を返します (64種類なのでbase64と呼ばれています)。典型的なbase64の文字列は、次のようなものです。
$ rails console >> SecureRandom.urlsafe_base64 => "q5lt38hQDc_959PVoo6b7A"SecureRandomクラスのクラスメソッドってことだよね。
この文字列をクライアント側に送って、さらに、この文字列をハッシュ化したものをDBに保存する。
トークン生成用のメソッドの定義
app/models/user.rbclass User < ApplicationRecord before_save { self.email = email.downcase } validates :name, presence: true, length: { maximum: 50 } VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i validates :email, presence: true, length: { maximum: 255 }, format: { with: VALID_EMAIL_REGEX }, uniqueness: { case_sensitive: false } has_secure_password validates :password, presence: true, length: { minimum: 6 } # 渡された文字列のハッシュ値を返す def User.digest(string) cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost BCrypt::Password.create(string, cost: cost) end # ランダムなトークンを返す def User.new_token SecureRandom.urlsafe_base64 end endランダムなトークンを作るためだけに、インスタンスを生成するのは勿体無い。
なので、user.rbにクラスメソッドとして定義すれば良い。ただ、今の状態だと、記憶トークンdigestを参照することができるが、記憶トークンの平文を参照することができない。
記憶トークンの平文は、password_digest実装時のpasswordやpassword_confirmationのような仮想的な属性である。これを実装するにはどうすればいいだろうか?
実は上記のような仮想的な属性はゲッター、セッターの実装ができる。一時的に保存できるがDBに保存はされない。
これは、
attr_accessor :remember_tokenとすることで、自動的にゲッターとセッターを実装してくれる。
言い換えると、attr_accessorはメソッドを定義するメソッドと言える。attr_accessor :remember_tokenを実装
app/models/user.rbclass User < ApplicationRecord attr_accessor :remember_token before_save { self.email = email.downcase } validates :name, presence: true, length: { maximum: 50 } VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i validates :email, presence: true, length: { maximum: 255 }, format: { with: VALID_EMAIL_REGEX }, uniqueness: { case_sensitive: false } has_secure_password validates :password, presence: true, length: { minimum: 6 } # 渡された文字列のハッシュ値を返す def User.digest(string) cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost BCrypt::Password.create(string, cost: cost) end # ランダムなトークンを返す def User.new_token SecureRandom.urlsafe_base64 end # 永続セッションのためにユーザーをデータベースに記憶する def remember self.remember_token = User.new_token update_attribute(:remember_digest, User.digest(remember_token)) end end次は上記の最後に定義されているrememberメソッドを見ていこう
rememberメソッドはユーザーがチェックボックスにチェックを入れてログインをした。
その時に呼び出されるメソッドである。ここで注意点
rememberはインスタンスメソッド。これが呼び出されてる時は必ず呼び出し元がいるということ。で、self.remember_tokenのselfには rememberメソッドの呼び出し元が代入される。
selfの省略について
update_attributeはself.update_attributeの省略形である。
しかし、一つ前のself.remember_tokenのselfは省略してはいけない。どのようなルールがあるのだろうか?
省略してはいけないのは、
def remember self.remember_token = User.new_token update_attribute(:remember_digest, User.digest(remember_token)) end1文目は、selfを省略してしまうと、remember_tokenというローカル変数にUser.new_tokenを代入するという意味になってしまう。
つまり、代入文であり、代入文の左辺だった時はselfが必要。
update_attributeはメソッド(インスタンスメソッド)であることが明白なので、selfを省略してもOK
次は、
cookieからsessionの状態を復元する機能を実装しよう。
ログインした時にはユーザー自身が入力したemailからユーザーインスタンスをfindし、入力したパスワードを元に@user.authenticateをして認証することができた。
しかし、cookieの場合は、emailが存在しないので、どうやってユーザーインスタンスを引っ張ってくるかが課題になる。
これを解決するために、署名付きユーザーidというものを使う。
これは、cookieを送る時に、@user.idを暗号化したものを一緒に送る。
これを、元の@user.idに復号化してあげて、
そこから得たuser.idを使って、find_byしてインスタンスを引っ張ってくる。
で、authenticateメソッドはパスワードを比較するためのメソッドなので、記憶トークンには使えない。
なので、自分で定義する必要がある。app/models/user.rbclass User < ApplicationRecord attr_accessor :remember_token before_save { self.email = email.downcase } validates :name, presence: true, length: { maximum: 50 } VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i validates :email, presence: true, length: { maximum: 255 }, format: { with: VALID_EMAIL_REGEX }, uniqueness: { case_sensitive: false } has_secure_password validates :password, presence: true, length: { minimum: 6 } # 渡された文字列のハッシュ値を返す def User.digest(string) cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost BCrypt::Password.create(string, cost: cost) end # ランダムなトークンを返す def User.new_token SecureRandom.urlsafe_base64 end # 永続セッションのためにユーザーをデータベースに記憶する def remember self.remember_token = User.new_token update_attribute(:remember_digest, User.digest(remember_token)) end # 渡されたトークンがダイジェストと一致したらtrueを返す def authenticated?(remember_token) BCrypt::Password.new(remember_digest).is_password?(remember_token) end endrememberメソッドをsession_controllerのcreateアクションに実装する。
app/controllers/sessions_controller.rbclass SessionsController < ApplicationController def new end def create user = User.find_by(email: params[:session][:email].downcase) if user && user.authenticate(params[:session][:password]) log_in user remember user redirect_to user else flash.now[:danger] = 'Invalid email/password combination' render 'new' end end def destroy log_out redirect_to root_url end endこれは、ユーザーがメールとパスワードを入れてログインしたら、記憶トークンをハッシュ化したものをDBに保存するよーって処理。
ただ、ここでremember userというように引数を取っていることにお気づきだろうか?
実はこのメソッドはsession_helperに定義された別のメソッドだったのだ!!!
実はこのremember(user)メソッドで記憶トークンをクライアント側に送るなどの処理もしている。
このメソッドを定義していこう
app/helpers/sessions_helper.rbmodule SessionsHelper # 渡されたユーザーでログインする def log_in(user) session[:user_id] = user.id end # ユーザーのセッションを永続的にする def remember(user) user.remember cookies.permanent.signed[:user_id] = user.id cookies.permanent[:remember_token] = user.remember_token end # 現在ログインしているユーザーを返す (いる場合) def current_user if session[:user_id] @current_user ||= User.find_by(id: session[:user_id]) end end # ユーザーがログインしていればtrue、その他ならfalseを返す def logged_in? !current_user.nil? end # 現在のユーザーをログアウトする def log_out session.delete(:user_id) @current_user = nil end endsessionメソッドと同様、:user_idをキーにして、user.idを代入できる。
この場合、ユーザーIDが生のテキストとしてcookieに保存される。署名付きcookieを使うためには、cookies.signedメソッドを使用する。
cookieをブラウザに保存する前に暗号化を行う。じゃあ、結局どうやってsession状態を復元するんだよ。
current_userメソッドを変える。
app/helpers/sessions_helper.rbmodule SessionsHelper # 渡されたユーザーでログインする def log_in(user) session[:user_id] = user.id end # ユーザーのセッションを永続的にする def remember(user) user.remember cookies.permanent.signed[:user_id] = user.id cookies.permanent[:remember_token] = user.remember_token end # 記憶トークンcookieに対応するユーザーを返す def current_user if (user_id = session[:user_id]) @current_user ||= User.find_by(id: user_id) elsif (user_id = cookies.signed[:user_id]) user = User.find_by(id: user_id) if user && user.authenticated?(cookies[:remember_token]) log_in user @current_user = user end end end # ユーザーがログインしていればtrue、その他ならfalseを返す def logged_in? !current_user.nil? end # 現在のユーザーをログアウトする def log_out session.delete(:user_id) @current_user = nil end end簡単にいうと、
sessionでログインできればそれでログインし、
できなければ、cookieを使ってログインしてというメソッド。if (user_id = session[:user_id])は
user_idにsession[:user_id]を代入した結果、値が存在すればという条件式になる。elsif (user_id = cookies.signed[:user_id])
また、このコードのsignedは暗号化された文字列を復号化する役割がある。
つまり、signedは暗号化もできるし、復号化することもできる。ちなみに
if user && user.authenticated?(cookies[:remember_token])のcookies[:remember_token]は、クライアント側に保存されているもの。
つまり、DBに保存されているユーザーインスタンスの記憶トークンのハッシュ化された値と、クライアント側に保存されている記憶トークンをハッシュ化したものを比較してくれている。
もし、if文もelsif文も失敗したら、nilが返ってくるという仕様。
そのため、logged_in?メソッドをそのまま使える。この時点でテストは失敗している。
ユーザーを忘れる。
これはrememberメソッド、remember(user)メソッドの全く逆のことを実装すれば良い
app/models/user.rbclass User < ApplicationRecord attr_accessor :remember_token before_save { self.email = email.downcase } validates :name, presence: true, length: { maximum: 50 } VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i validates :email, presence: true, length: { maximum: 255 }, format: { with: VALID_EMAIL_REGEX }, uniqueness: { case_sensitive: false } has_secure_password validates :password, presence: true, length: { minimum: 6 } # 渡された文字列のハッシュ値を返す def User.digest(string) cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost BCrypt::Password.create(string, cost: cost) end # ランダムなトークンを返す def User.new_token SecureRandom.urlsafe_base64 end # 永続セッションのためにユーザーをデータベースに記憶する def remember self.remember_token = User.new_token update_attribute(:remember_digest, User.digest(remember_token)) end # 渡されたトークンがダイジェストと一致したらtrueを返す def authenticated?(remember_token) BCrypt::Password.new(remember_digest).is_password?(remember_token) end # ユーザーのログイン情報を破棄する def forget update_attribute(:remember_digest, nil) end endこれはrememberメソッドと対になるメソッド。
DBの値をnilにしたので、
次は焼いたクッキーを消すメソッドを実装しよう。app/helpers/sessions_helper.rbmodule SessionsHelper # 渡されたユーザーでログインする def log_in(user) session[:user_id] = user.id end . . . # 永続的セッションを破棄する def forget(user) user.forget cookies.delete(:user_id) cookies.delete(:remember_token) end # 現在のユーザーをログアウトする def log_out forget(current_user) session.delete(:user_id) @current_user = nil end endforget(user)はremember(user)の対になるメソッド。
また、log_outメソッドに forget(current_user)を追加しないと、cookieを使ってまたログインできてしまう。さっきテストで失敗してしまったのは、ログアウトしたらログアウトパスが本来0個のはずなのに、cookieをつかったログインが成功してしまい、1つ発見されてしまったからだろう。
実はこれだけじゃあ実装は終わらないぜ
目立たないバグ潰し
二つのログイン済みのタブがあり、どちらか一方をログアウトさせ、もう片方もログアウトさせようとするとエラーが起こる。
これは1回目でcurrent_userがなくなり、2回目で、nilにforgetメソッドを呼び出そうとしているため、NoMethodErrorが起こってしまうからだ。これを解決するには、log_outメソッドを使えるのはlog_inしている時のみという条件をつける。
app/controllers/sessions_controller.rbclass SessionsController < ApplicationController . . . def destroy log_out if logged_in? redirect_to root_url end endこれで1つ目のバグは解決。
2つ目のバグを解決しよう!!!
cookieの暴走問題
1つはSafari、もう1つはChromeでログインする。
そうするとどちらにもcookieが付与された状態になる。これで、Chromeのタブでログアウトを実行。
さらに、Safariのタブを消してしまう。
それで、Safariでもう一度アプリのページを開こうとするとエラーになってしまう。
これは、Chromeのログアウトの時点でDBのremember_digestはnilになっている。
そしてSafariのcookie情報でcurrent_userを見つけようとするもDBの値がnilになっているので見つけられず、例外を出してエラーを出すようになってしまうかららしい。bcryptによるもの。じゃあ、どうやって解決する?
まずは回帰バグを防ぐためにテストコードを書こう。
test/models/user_test.rbrequire 'test_helper' class UserTest < ActiveSupport::TestCase def setup @user = User.new(name: "Example User", email: "user@example.com", password: "foobar", password_confirmation: "foobar") end . . . test "authenticated? should return false for a user with nil digest" do assert_not @user.authenticated?('') end end最後のテストは、authenticated?()メソッドの引数にnilや空文字を入れたらfalseを返すでしょ?というテストである。ちなみにcookieの暴走バグはfalseすら返さず例外を出しているために起きている。
テスト結果は以下。
ERROR["test_authenticated?_should_return_false_for_a_user_with_nil_digest", UserTest, 0.47229603000005227] test_authenticated?_should_return_false_for_a_user_with_nil_digest#UserTest (0.47s) BCrypt::Errors::InvalidHash: BCrypt::Errors::InvalidHash: invalid hash app/models/user.rb:32:in `new' app/models/user.rb:32:in `authenticated?' test/models/user_test.rb:70:in `block in <class:UserTest>' 21/21: [===========================================================================================] 100% Time: 00:00:00, Time: 00:00:00 Finished in 0.57426s 21 tests, 50 assertions, 0 failures, 1 errors, 0 skipsちなみに、failuresは期待された値にならなかった時。
errorsは期待された値とか関係なく、例外などが出た時に表示される。cookieの暴走バグ解決法
これの解決方法は、remember_digestがnilの時はbcryptを実行せずに、falseを返してあげれば良い
app/models/user.rbclass User < ApplicationRecord . . . # 渡されたトークンがダイジェストと一致したらtrueを返す def authenticated?(remember_token) return false if remember_digest.nil? BCrypt::Password.new(remember_digest).is_password?(remember_token) end # ユーザーのログイン情報を破棄する def forget update_attribute(:remember_digest, nil) end endこれで、cookieの暴走バグを解決できる。
returnを実行されると、それがメソッドの戻り値になるので、以降のメソッド処理は実行されなくなる。チェックボックスの実装
まずはチェックボックスをログインフォームに実装しよう
app/views/sessions/new.html.erb<% provide(:title, "Log in") %> <h1>Log in</h1> <div class="row"> <div class="col-md-6 col-md-offset-3"> <%= form_for(:session, url: login_path) do |f| %> <%= f.label :email %> <%= f.email_field :email, class: 'form-control' %> <%= f.label :password %> <%= f.password_field :password, class: 'form-control' %> <%= f.label :remember_me, class: "checkbox inline" do %> <%= f.check_box :remember_me %> <span>Remember me on this computer</span> <% end %> <%= f.submit "Log in", class: "btn btn-primary" %> <% end %> <p>New user? <%= link_to "Sign up now!", signup_path %></p> </div> </div>チェックボックスを実装すると、
params[:session][:remember_me]の値が、チェックされてる時は'1'
チェックされてない時は'0'となる。これを利用していこう。
if params[:session][:remember_me] == '1' remember(user) else forget(user) endこのように条件分岐していけばいいのだが、
これを三項演算子を使うとスマートに実装することができる。app/controllers/sessions_controller.rbclass SessionsController < ApplicationController def new end def create user = User.find_by(email: params[:session][:email].downcase) if user && user.authenticate(params[:session][:password]) log_in user params[:session][:remember_me] == '1' ? remember(user) : forget(user) redirect_to user else flash.now[:danger] = 'Invalid email/password combination' render 'new' end end def destroy log_out if logged_in? redirect_to root_url end end基本的にチェックボックスにチェックをつけないとcookieは焼かれないが、一応万全を期すためにforget()メソッドを呼び出している。
Remember meのテストを書こう。
まずテスト環境でログインしたユーザーを作るためにtest_helperにメソッドを定義していく
test/test_helper.rbENV['RAILS_ENV'] ||= 'test' . . . class ActiveSupport::TestCase fixtures :all # テストユーザーがログイン中の場合にtrueを返す def is_logged_in? !session[:user_id].nil? end # テストユーザーとしてログインする def log_in_as(user) session[:user_id] = user.id end end class ActionDispatch::IntegrationTest # テストユーザーとしてログインする def log_in_as(user, password: 'password', remember_me: '1') post login_path, params: { session: { email: user.email, password: password, remember_me: remember_me } } end end下のクラス定義されてるやつはなんぞや??
統合テストは基本的にブラウザでできることができるようなテスト、
だからこのようにいちいち情報を入力してログインしてもらう必要がある。
そのためメソッドを分けて定義しているつまり、上のlog_in_asはケーステスト用のメソッド。
下のlog_in_asは統合テスト用のメソッドとなっている。ちなみに password: 'password'と remember_me: '1'はデフォルト値として設定されている。
チェックボックスの統合テストをしよう
test/integration/users_login_test.rbrequire 'test_helper' class UsersLoginTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) end . . . test "login with remembering" do log_in_as(@user, remember_me: '1') assert_not_empty cookies['remember_token'] end test "login without remembering" do # クッキーを保存してログイン log_in_as(@user, remember_me: '1') delete logout_path # クッキーを削除してログイン log_in_as(@user, remember_me: '0') assert_empty cookies['remember_token'] end endここではテスト通る。
raiseを理解する。
raiseはテストの途中で例外を発生させる機能。
app/helpers/sessions_helper.rbmodule SessionsHelper . . . # 記憶トークンcookieに対応するユーザーを返す def current_user if (user_id = session[:user_id]) @current_user ||= User.find_by(id: user_id) elsif (user_id = cookies.signed[:user_id]) raise # テストがパスすれば、この部分がテストされていないことがわかる user = User.find_by(id: user_id) if user && user.authenticated?(cookies[:remember_token]) log_in user @current_user = user end end end . . . endつまり、テストがパスしてしまうと、raise以下のテストが実行されていないことがわかる。
これは問題。
解決するには、raise以下の使うテストを追加してあげれば良い
本当にここテストされてるかなー?って思ったらraiseを使ってみよう
メンテナンスモードについて
開発者側からはアプリに入れてクライアント側からは入れなくしたい時は、メンテナンスモードをonにする。
heroku maintenance:on
これを解除するには、
$ heroku maintenance:off
- 投稿日:2019-12-03T16:23:12+09:00
【Rails】has_manyをhas_oneに変えた時の挙動
has_manyで複数のレコードが紐づいている状態で、has_oneに変えるとどのレコードが紐づくのかふと気になったので試してみた。
言い換えると、has_many関係でarticle.commentsが複数取れる状況だった時に、has_oneに変更してarticle.commentを実行すると、どのレコードを取ってくるのかを見てみた。結論
created_at, updated_atの値に関係なく、idの一番若いレコードと紐づく
一番新しいレコードを取ってくるのかなぁと思ったらそうではなかった。
上の結論に至るまでの検証を下にまとめていく。前提
ArticleモデルとCommentモデルを用意し、Article
has_manyCommentsの関係をとるとする。article.rbclass Article < ApplicationRecord has_many :comments # has_one :comment #検証用 endcomment.rbclass Comment < ApplicationRecord belongs_to :article end用意したレコードはarticle1個、comment3個。commentは全てarticleに紐づいてるものとする。
article#<Article:0x00007f979bad9f00 id: 1, title: "title1", created_at: Tue, 03 Dec 2019 04:54:13 UTC +00:00, updated_at: Tue, 03 Dec 2019 04:54:13 UTC +00:00>comment[#<Comment:0x00007f9798e79f28 id: 1, content: "comment1", article_id: 1, created_at: Tue, 03 Dec 2019 04:55:15 UTC +00:00, updated_at: Tue, 03 Dec 2019 04:55:15 UTC +00:00>, #<Comment:0x00007f9798e79d98 id: 2, content: "comment2", article_id: 1, created_at: Tue, 03 Dec 2019 04:55:23 UTC +00:00, updated_at: Tue, 03 Dec 2019 04:55:23 UTC +00:00>, #<Comment:0x00007f9798e79c08 id: 3, content: "comment3", article_id: 1, created_at: Tue, 03 Dec 2019 04:55:26 UTC +00:00, updated_at: Tue, 03 Dec 2019 04:55:26 UTC +00:00>]検証1 上記条件でhas_many => has_oneに変更
上記の状態で article.rb の
has_manyをコメントアウトし、has_oneに変更
Article.first.comment実行[1] pry(main)> Article.first.comment結果
id = 1のcomment
result=> #<Comment:0x00007f979b488638 id: 1, content: "comment1", article_id: 1, created_at: Tue, 03 Dec 2019 04:55:15 UTC +00:00, updated_at: Tue, 03 Dec 2019 04:55:15 UTC +00:00>=> 一番若いidを取ってくる
結論の通り。だけどこれだけではupdated_atとcreated_atの影響の可能性も捨てきれないので、もうちょい掘り下げる。
検証2 update_atの値を変える
id=1のcommentをアップデートし、
updated_atを他二つより最近のものにする
has_oneに変更し、Article.first.comment実行[1] pry(main)> Comment.first.update_attributes(content: "comment1A") [2] pry(main)> Article.first.comment結果
id = 1のcomment
result=> #<Comment:0x00007f979b675568 id: 1, content: "comment1A", article_id: 1, created_at: Tue, 03 Dec 2019 04:55:15 UTC +00:00, updated_at: Tue, 03 Dec 2019 04:59:09 UTC +00:00>=> updated_atの値に関係なく一番若いidを取ってくる
updated_atの値を見てわかる通り。検証3 id=1 のCommentレコードを削除
id=1のcommentを削除
has_oneに変更し、同様に実行[1] pry(main)> Comment.first.delete [2] pry(main)> Article.first.comment結果
id = 2のcomment
result=> #<Comment:0x00007f979bbe3540 id: 2, content: "comment2", article_id: 1, created_at: Tue, 03 Dec 2019 04:55:23 UTC +00:00, updated_at: Tue, 03 Dec 2019 04:55:23 UTC +00:00>=> 存在しているレコードの中で一番若いidを取ってくる
commentのidが2と3しかない状態なのでこのようになる検証4 再び id=1 のCommentレコードを追加
検証3を終えた状態 (id=1のcommentを消した状態) で
id=1のcommentを再びcreate。articleに紐づける
has_oneに変更し、同様に実行[1] pry(main)> Comment.create(id:1, content:"comment1", article_id:1) [2] pry(main)> Article.first.comment結果
id = 1のcomment
result=> #<Comment:0x00007f979b88a678 id: 1, content: "comment1", article_id: 1, created_at: Tue, 03 Dec 2019 05:05:59 UTC +00:00, updated_at: Tue, 03 Dec 2019 05:05:59 UTC +00:00>=> created_atの値に関係なく一番若いidを取ってくる
今までは"idが一番若い = created_atの値が一番古い"が成り立ってたたため、これでcreated_atの値が関係ないことがわかった。
一度消したのでid=2をとってくるかと思いきやid=1だった。終わりに
結論の通り、has_manyからhas_oneに変更した場合、idの一番若いものを取ってくることがわかった。直感的には一番新しいレコードを取ってきそうな気はするけど、そうではないっぽいので気をつけたい。検証不足、間違い等ありましたらコメントお願いしますm(_ _)m
- 投稿日:2019-12-03T16:14:03+09:00
ハッカソンの開催情報を自動でお知らせするBotをGithub Actionsに移行して運用費が0円になりました
新着のハッカソン・ゲームジャム・アイディアソン・開発合宿の情報を自動的にお知らせしているBotがあります。
よかったらフォローしてください!!このBotの頭の中身についてはこちらの記事にて紹介しました。
最新のハッカソンの開催情報を自動で集めて、お知らせするBotを作ったので頭の中を紹介
また、具体的なソースコードも公開していますのでこちらを参照してください。
今回はこのHackathonPortalをこれまで、AWS Lightsail (Ubuntu)サーバーにて稼働していたものを Github Actions に完全に移行したので、その内容について紹介します。
Github Actionsの本来の使い方について
本来の Github Actions の正しい使い方はCI/CDがメインで主に以下のような開発支援として用いられます。
- 自動的にデプロイ
- 自動テストの実行
- issueやプルリクのお掃除
など
今回は Github Actions の schedule機能 にのみ着目して活用しました。
また Github Actions の本来の使い方については こちら などにまとめています。Github Actionsにて自動的にデプロイする環境作成(Webサイト編)
そもそもなぜ、Github Actionsに移行したのか?
- Github Actions で稼働させる間は月額費用が0円になるため
- Github Actions の費用はPublicリポジトリならいくら使っても0円です。(privateは無料枠を超えたら費用がかかります。詳しくはこちら)
- 元々,HackathonPortalはPublicリポジトリで稼働しています。
- 下記の仕様の関係上、 schedule機能 さえあれば、要件を満たせるので Github Actions の使用に適していたため
仕様
HackathonPortalの仕様を簡単にまとめると以下になります
- 1日に1回イベント公開されたイベントの情報を集める →
cronを活用- 集めた情報をデータベースに保存する
- 保存したデータベースの情報を整理して、Twitterなどに発信する
基本的に定期的に
Batchが実行されればいいので Github Actions の schedule機能 を使えば実現できます。実現させるにあたって苦労したこと
データベース
Github Actions ではMySQLなどのミドルウェアを使用することはできますが、 Github Actions では毎回実行されるたびにデータが空っぽになります。
そのため Github Actions のみで完結させようとした場合、これまでためたデータをどこかに保持するためておく仕組みが必要です。
外部のデータベースを活用するなど考えましたがいずれの場合も
- 結局、月額費用がかかる
- 無料のデータベースは容量が少ない
- 何かと制約が多い
ということで結局これまでためたデータを全てGitの管理下に置いてGithubに保管してもらうことで対応しました。
具体的には以下のフローを Github Actions の中で行なっています
- MySQL稼働
- MySQLの中にデータを挿入する
- データを集める → 発信する
mysqldumpしてデータを全てSQLファイルで出力するgit pushで Githubにアップロードこうすることで Github Actions (Github) のみで完結する仕組みを構築しました。
実際に設定している内容については こちら を参照
データ容量におけるGithub上の仕様
Github上では
100MBを超えるデータをpushすることができません。
(詳しくは こちら)1ファイル
100MBを超えるデータを保存する場合は Githubでは Git LFS を使用する必要があります。
しかし、 Git LFS を使用してのデータ管理は月間1GBを超えると月額費用が発生してしまします。今回、月額費用0円での運用を実現したかったので、
mysqldumpで抽出するSQLを1ファイル100MB未満になるように分割してpushするようにしました。そのために以下のようなコマンドをBatchの中で実行しています。mysqldump データベース名 テーブル名 -u ユーザー名 -pパスワード --no-create-info -c --order-by-primary --skip-extended-insert --skip-add-locks --skip-comments --compact > SQLファイル.sql上記のように
mysqldumpをするときに
--no-create-infoでデータのみを取得し--skip-extended-insertで一行ずつINSERT文を抽出--skip-add-locks --skip-comments --compactでコメントなどの不要なものを全て排除したSQLファイルを出力します。そして以下のように出力したSQLファイルを
splitコマンドを使い分割しています。split -l 10000 -d --additional-suffix=.sql fulldump.sql テーブル名/上記の場合10000行ごとに分割しています。これにより1ファイル 大体
100MB以下になるように分割しています。※
splitコマンドの--additional-suffixオプションはLinuxでは使用可能ですが、Mac OSでは使用することができません。Mac OSで--additional-suffixオプションを使用する場合はgsplitをbrew install gsplitにてインストールしてgsplitコマンドにすることで使用することができます。実行されるブランチをmaster以外に切り替える
基本的に Github Actions の schedule機能 は
defaultブランチ(最初はmasterブランチ)の内容が実行されます。
今回データを蓄積するブランチはmasterブランチ以外で行いたかったです。そのため、Github Actions が実行された瞬間にブランチを切り替えるには Github Actionsが実行されるymlにて使用されている、actions/checkout (uses: actions/checkout@v1の部分) で、with:とともにブランチを一緒に指定することで実行できました。(ref: ブランチ名)以下にその部分を記述します。
jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 with: ref: crawled-dataGithub Actions内からGithubへpushする
Github Actions の中からGithubへのpushをそのまま行おうとするとエラーとなってしまいます。
今回、pushを可能にするためにPersonal access tokensを発行し、User Name(Githubのアカウント名) とともに以下のように設定します。git remote set-url origin "https://${User_Name}:${Personal_Access_Tokens}@github.com/TakuKobayashi/hackathon_portal.git"この状態で
git push origin ブランチ名を行うことで、Github Actions の中からGithubへのpushを実現しています。
※
Personal access tokensの発行の仕方はこちらを参照してください。課題
Github Actionsで稼働しているマシンスペックの問題
Github Actionsで稼働しているマシンのストレージ容量は
14GBです。【参考】
mysqldumpしたSQLファイルの容量が14GBを超えるようなデータ量となるとき、別の方法を考える必要があります。
(現状1GBも超えていないのでまだ大丈夫)最後に
ハッカソン・ゲームジャム・アイディアソン・開発合宿への参加に興味がある皆さんは是非フォローしてください!!
よろしくお願いします。
- 投稿日:2019-12-03T12:54:31+09:00
Rails 6.0.x標準で Ajax+(jQuery+Partial) でHTML部分更新する世界一シンプルなサンプル
TL;DR (長い! 3行で!)
↓ の記事をRails 6.0.1 版に書き直したものです.
Rails 5.x標準で Ajax+(jQuery+Partial) でHTML部分更新する世界一シンプルなサンプルFront EndがWebpackに変わっており,
・ Coffee Script → JavaScript への変更
・ jQuery導入方法の変更
が大きな変更点です.動作確認環境
$ lsb_release -a No LSB modules are available. Distributor ID: Ubuntu Description: Ubuntu 14.04.6 LTS ← 古いLTSなのでわりとダメ,できれば最新LTS使ってください Release: 14.04 Codename: trusty $ ruby --version ruby 2.6.3p62 (2019-04-16 revision 67580) [x86_64-linux] $ rails --version Rails 6.0.1 $ yarn --version 1.16.0 $ nvm --version 0.35.1 $ node --version v12.13.1NOTICE
nodeのversionが古いと(LTSじゃないと?),$ rails newの途中で ↓ のようなErrorが出ます.error get-caller-file@2.0.5: The engine "node" is incompatible with this module. Expected version "6.* || 8.* || >= 10.*". Got "9.10.1" error Found incompatible module.これ以降,webpackが使えなくなります.
(error Command "webpack" not found.)
$ rails new自体は "Webpacker successfully installed" のメッセージがでて正常終了してるように見えてしまうため気付けない...ざっくりシーケンス
だいたい ↓ のようなシーケンスを実現します.
Client-Server間でSession張って双方向通信とかそういうところはRails Frameworkが全部やってくれます.Front End : UserがInputしたTextをAjaxでBack End側にPost ↓ Back End : PostされたTextとPartialを使って部分的に差し替えるHTMLをRendering ↓ Front End : JavaScriptでAjaxのCallback(部分的に差し替えるHTML)を受け取る ↓ Front End : jQueryでHTMLの部分差し替え
Rails 6 でFront EndがWebpack化されたことで,Rails 5 に比べてReactなども導入しやすくなっています.そのため,Back Endから返すCallbackはpre-renderしたHTMLでなく,Jsonなどでデータを返して,Front End側でrenderする,という方法もあります.
Project初期化 + Default動作確認
$ rails new ajax-testでRails Projectを初期化します.
$ cd ajax-testでRailsのrootに移動して,$ rails serverでサーバを起動して,
http://localhost:3000/
にアクセスして "Yay! You're on Rails!" のDefaultページが出たらOKです.Static Page作成
Ajax動作のベースになるStatic Page部分を実装します.
Controller
$ rails generate controller AjaxTestでControllerのTemplateを作成後,Action(Method)を追加します.
app/controller/ajax_test_controller.rbclass AjaxTestController < ApplicationController def top # NOP. end def update # TBD. end end
top: Topページ表示用,特に処理はありません.
update: Ajaxリクエストを受ける用,実装はあとで追加します.Route Config
ControllerのActionに繋げるためのRoute定義を追加します.
config/routes.rbget 'ajax_test/top', to: 'ajax_test#top', as: 'ajax_test_top' post 'ajax_test/update', to: 'ajax_test#update', as: 'ajax_test_update'
GET: Topページ表示用.
POST: Ajaxリクエスト用.View
ajax/test/topにアクセスしたときに表示するViewを追加します.app/views/ajax_test/top.html.erb<div id="request_ajax_update" > <%= form_tag(ajax_test_update_path, method: :post, remote: true) { %> <%= text_field :data, :text %> <%= submit_tag 'Post AJAX' %> <% } %> </div> <hr> <div id="updated_by_ajax" > DEFAULT </div> <hr>大きく2つのブロックだけです.
・
<div id="request_ajax_update" >
Ajax RequestをPostするFormを持つブロック.
Userが入力したTextをparams[:data][:text]に詰めて,
ajax_test_update_path( =/ajax_test/update) にPOSTします.・
<div id="updated_by_ajax" >
Ajax Callbackを受けてHTMLの部分更新をするブロック.Static Page動作確認
この時点で,
http://localhost:3000/ajax_test/top
にアクセスすると,↓ のようなページが表示されると思います.
ただし,Ajaxの実装がまだ無いので,
Post AJAXボタンをClickしても見た目上は何も起こりませんが,$ rails serverが動いているConsoleにText Fieldに入れた文字列がParametersとして通知されているのを確認できると思います.Parameters: {"data"=>{"text"=>"Hello World !"}, "commit"=>"Post AJAX"}Ajax実装
いままでに作ったStatic PageにAjax実装を組み込んでInteractiveな機能を追加します.
Partial View
部分的に更新するHTMLの部品を追加します.
app/views/ajax_test/_ajax_partial.html.erb<div> Callback Msg = <%= results[:message] %> </div>このPartialで
<div id="updated_by_ajax" >の中身を差し替えます.Controller Action
さきほど作ったControllerの
updateの中身を実装します.app/controller/ajax_test_controller.rbclass AjaxTestController < ApplicationController def top # NOP. end def update post_text = params[:data][:text] results = { :message => post_text } render partial: 'ajax_partial', locals: { :results => results } end endControllerに渡ってきた Parameters の中に格納されているUserが入力したTextを使ってPartialをrenderしています.
Partialのファイル名_ajax_partial.html.erbがajax_partialで使えるあたりはRailsの規約に沿っています.ここで,
res = render_to_string partial: 'ajax_partial', locals: { :results => results } puts resのようなCodeを書いておくとPartialをrenderしたときの実際のOutputが見れます.
<div> Callback Msg = Hello World ! </div>JavaScript実装
Rails 6 からFront EndにWebpackが標準で使われることになり,Coffee ScriptがDefaultで使われなくなっています.
かわりに,
app/javascript/packs/application.js
のようなWebpackに対応したJavaScriptのDir構成に変わっています.
(Rails 5 でWebpack使っていた人にはおなじみの構成かも)ViewにJavaScriptのEntry Pointを追加
ViewのHTMLにJavaScriptの読み込み部分を追加します.
app/views/ajax_test/top.html.erb<div id="request_ajax_update" > <%= form_tag(ajax_test_update_path, method: :post, remote: true) { %> <%= text_field :data, :text %> <%= submit_tag 'Post AJAX' %> <% } %> </div> <hr> <div id="updated_by_ajax" > DEFAULT </div> <hr> <!-- ↓ 追加 --> <%= javascript_pack_tag 'ajax_test' %> <script> register_callback(); </script>
<%= javascript_pack_tag 'ajax_test' %>
はapp/javascript/packs/以下に置いてあるajax_testというファイル(の中身)を読み込む,という指示です.
実際にはWebpackがひとかたまりの.jsにしてしまうので,この名前のファイルを読み込んでいるわけではありません.その後,
<script></script>タグでregister_callback()という関数を呼び出します.
この関数の実装は次で追加します.JavaScript本体の実装
Webpack用Dir構成に沿って,Viewから呼び出せるように ↓ のJavaScriptを追加します.
app/javascript/packs/ajax_test.jsimport * as $ from "jquery"; function register_callback() { $("#request_ajax_update").on( "ajax:complete", function(event) { var res = event.detail[0].response $('#updated_by_ajax').html(res) } ); } window.register_callback = register_callback;Viewから呼び出す
register_callback()関数の中で,AjaxリクエストをPOSTする#request_ajax_updateTagにajax:completeのCallback関数を登録しています.Viewから呼び出せるようにするため,
window.register_callback = register_callbcak;
の行でwindowの名前空間(global)にexportしています.
注) この方法は動作はしますがあまり行儀よくないはずです...ただし,このままでTopページから
Post AJAXをClickしても何も起こりません.
ChromeのDeveloper Tool等でConsole Logをみると,Uncaught Error: Cannot find module 'jquery' at webpackMissingModule (ajax_test.js:1) at Module../app/javascript/packs/ajax_test.js (ajax_test.js:1)のようなError Logがでていて,
jqueryの名前解決ができてないことがわかります.jQueryを使えるようにする
Rails 5 から引き続きRails標準ではjQueryはサポートされていないため,WebpackからjQueryを使えるようにします.
$ yarn add jqueryのCommandでjqueryをInstallします.
必要かもしれない追加
"rails6 + jquery" とかのキーワードでぐぐると,↓ の2つの追加も必要,と出てきますが,手元の環境だと必要ありませんでした.
RailsのVersionによるのか,他の環境によるのか...ref: https://www.botreetechnologies.com/blog/introducing-jquery-in-rails-6-using-webpacker
app/javascript/packs/application.js// 他のrequireがいろいろ... require("jquery") // 追加config/webpack/environment.jsconst { environment } = require('@rails/webpacker') // 追加 const webpack = require('webpack') environment.plugins.prepend('Provide', new webpack.ProvidePlugin({ $: 'jquery/src/jquery', jQuery: 'jquery/src/jquery' }) ) module.exports = environmentAjax動作確認
これで必要な変更はすべてです.
http://localhost:3000/ajax_test/top
にアクセスして,Text Fieldになにか文字列を入力してPost AJAXボタンを押したら,DEFAULTの文字列が更新されて ↓ のような画面になっていれば成功です.ChromeのDeveloper ToolなどでDOM構造を見てみると,↓ のようになっていて,
<div id="updated_by_ajax" >の中身が差し替わっているのが見えます.おわり
Rails 6 でAjaxを使ったシーケンスを一本通すまでをできる限りDefaultのままで実現してみました.
環境依存でたまたまうまく動いている部分などあるかもしれません.もし何かおかしな点がありましたら教えていただけると助かります.---///
- 投稿日:2019-12-03T12:31:24+09:00
Railsチュートリアル 第11章 アカウントの有効化 - AccountActivationsリソース
前提
- アカウントの有効化機能は、セッション機能と同様、RESTfulなリソースとしてモデル化する
- AccountActivationsコントローラーの実装は、Sessionsコントローラーの実装と似た形になる
- ただし、以下のユースケースから、ここまで実装してきたリソースとは異なる形の実装となる
- 「有効化リンクはメールでユーザーに送信される。ユーザーが当該リンクをクリックすることにより、アプリケーションでアカウントの有効化プロセスが開始される」というユースケースである
- Webブラウザでリンクをクリックした場合と同様、このときユーザーから送られるのは
GETリクエストであるGETリクエストが直接のトリガーになるゆえ、「editアクションで直接RDBの内容が変更される」という実装になるAccountActivationsコントローラー
AccountActivationsコントローラーの生成
まずは
rails generate controllerコマンドにより、AccountActivationsコントローラーを生成するところから始まります。この部分は、Sessionsコントローラーと同じ手順ですね。# rails generate controller AccountActivations Running via Spring preloader in process 13245 create app/controllers/account_activations_controller.rb invoke erb create app/views/account_activations invoke test_unit create test/controllers/account_activations_controller_test.rb invoke helper create app/helpers/account_activations_helper.rb invoke test_unit invoke assets invoke coffee create app/assets/javascripts/account_activations.coffee invoke scss create app/assets/stylesheets/account_activations.scssAccountActivationsリソースに関係するルーティング
AccountActivationsリソースのユースケースは、「ユーザーがリンクをクリックすることにより、アプリケーションでアカウントの有効化プロセスが開始される」というものです。当然ながら、「アカウントの有効化プロセスを開始するためのリンク」が必要になってきます。さらに、「RESTfulなリソースとしてモデル化する」という前提があります。というわけで、「アカウントの有効化プロセスを開始するためのリンク」は、「AccountActivationsリソースの
editアクションに紐付けされたリンク」ということになります。edit_account_activation_url(activation_token, ...)AccountActivationsリソースのユースケースは、「ユーザーがリンクをクリックすることにより、アプリケーションでアカウントの有効化プロセスが開始される」というものに限られます。(
editアクションではなく)リソースそのものへのGETや、(Usersリソースとセットでない)単独でのPOST・DELETE、UPDATE、以上のリクエストが送られてくることは想定されません。ゆえに、必要となるルーティングはeditのみに限られます。resources :account_activations, only: [:edit]結果、Railsのルーティング(
config/routes.rb)は、以下のように変更する必要があります。config/routes.rbRails.application.routes.draw do root 'static_pages#home' get '/help', to: 'static_pages#help' get '/about', to: 'static_pages#about' get '/contact', to: 'static_pages#contact' get '/signup', to: 'users#new' post '/signup', to: 'users#create' get '/login', to: 'sessions#new' post '/login', to: 'sessions#create' delete '/logout', to: 'sessions#destroy' resources :users + resources :account_activations, only: [:edit] end演習 - AccountActivationsコントローラー
1. 現時点でテストスイートを実行すると
greenになることを確認してみましょう。# rails test Running via Spring preloader in process 13277 Started with run options --seed 33183 43/43: [=================================] 100% Time: 00:00:15, Time: 00:00:15 Finished in 15.05852s 43 tests, 183 assertions, 0 failures, 0 errors, 0 skips確かにテストは成功しますね。
2. 表 11.2の名前付きルートでは、_pathではなく_urlを使うように記してあります。なぜでしょうか? 考えてみましょう。
ヒント: 私達はこれからメールで名前付きルートを使います。
「AccountActivationsリソースの
editアクションに対応するURLは、メールに記載するURLとして使われる」というのが前提となる、というのが大きなポイントです。
- AccountActivationsリソースの
editアクションには、Railsアプリケーションの外部からアクセスできなければならない- Railsアプリケーションの外部からリソースにアクセスする場合、完全なURLが必要となる
このあたりが理由でしょうか。
AccountActivationのデータモデル
RDBには、有効化トークンをハッシュ化したものを保存する
「生の有効化トークンをRDBに保存する」という運用は、万が一RDBそのものの内容が漏洩した場合、容易に攻撃に悪用される脆弱な運用です。例えば、「攻撃者が新しく登録されたユーザーの有効化トークンを盗み取り、本来のユーザーが使う前に当該トークンを使ってしまう(そして当該ユーザーとしてログインしてしまう)」という攻撃が行われる危険性が想定できます。
というわけで、パスワードや記憶トークンと同様、有効化トークンについても、「RDBに保存するのはハッシュ化した値」という運用を行うこととします。
実装手法
実装手法は、節6.3における
password仮想属性の実装、ならびに、節9.1におけるremember_token仮想属性の実装と類似しています。今回実装するのは、activation_tokenという仮想属性となります。user.activation_token最終的には、以下のようなコードでユーザーの有効化トークンを認証できるようになることを目指します。
user.authenticated?(:activation, token)なお、
authenticated?メソッドそのものの実装にも手を加えていくことになります。Userモデルの実装内容を変更する
Userモデルには、以下3つの属性を新たに追加していくことになります。
activation_digest
- string型
- 有効化トークンに対するダイジェスト
activated
- boolean型
- ユーザーが有効化されたか否か
- デフォルトでは
falseであるactivated_at属性
- datetime型
- ユーザーが有効化された日時
上記属性追加を反映した新たなUserモデルの全体像は、以下のようになります。
新たなUserモデルに対応するマイグレーション
まずはマイグレーションそのものを生成します。
root@705320d4d96d:/var/www/sample_app# rails generate migration add_activation_to_users activation_digest:string activated:boolean activated_at:datetime Running via Spring preloader in process 13329 invoke active_record create db/migrate/[timestamp]_add_activation_to_users.rb
activated属性のデフォルト値をfalseとするため、生成されたマイグレーションを以下のように書き換えていきます。db/migrate/[timestamp]_add_activation_to_users.rbclass AddActivationToUsers < ActiveRecord::Migration[5.1] def change add_column :users, :activation_digest, :string - add_column :users, :activated, :boolean + add_column :users, :activated, :boolean, default: false add_column :users, :activated_at, :datetime end end最後は
rails db:migrateです。# rails db:migrate == [timestamp] AddActivationToUsers: migrating ============================= -- add_column(:users, :activation_digest, :string) -> 0.0169s -- add_column(:users, :activated, :boolean, {:default=>false}) -> 0.0015s -- add_column(:users, :activated_at, :datetime) -> 0.0018s == [timestamp] AddActivationToUsers: migrated (0.0205s) ====================Active Recordのコールバックメソッドにより、ユーザーオブジェクト作成前に有効化トークン・有効化ダイジェストが生成されるようにする
before_createコールバック「オブジェクトの新規作成時にのみ呼び出される」というコールバックです。引数として、メソッド名を表すシンボルを指定(あるいは実行する処理の内容をブロックとして直接記述)します。
今回は、「有効化トークン・有効化ダイジェストを生成する処理(
create_activation_digestメソッドとします)」をbefore_createコールバックの対象とします。before_create :create_activation_digest
create_activation_digestメソッド有効化トークンと、有効化トークンに対応するダイジェストを生成する処理の実体を記述するメソッドです。
private def create_activation_digest self.activation_token = User.new_token self.activation_digest = User.digest(activation_token) end
privateメソッドとの関係
create_activation_digestの前にprivateメソッドが呼び出されているのは、一つの大きなポイントです。Userモデルのbeforeフィルターで呼び出されるメソッドは、Userモデル内でしか使わないので、Userクラス内でprivateメソッドを呼び出して以降に記述すべきとされています。そういえば、UsersコントローラーにStrong Parametersやbeforeフィルターを実装した際にもprivateメソッドを使いましたよね。有効化トークンと、有効化トークンに対応するダイジェストを生成する処理の実体
表題記載の処理の実体は、以下2つの処理となります。
self.activation_token = User.new_token self.activation_digest = User.digest(activation_token)
User#rememberメソッドとの類似点と相違点類似する処理として、永続cookiesに関する記憶トークンと記憶ダイジェストを生成する
User#rememberメソッドを、Railsチュートリアル本文の第9章で定義していました。User#rememberdef remember self.remember_token = User.new_token update_attribute(:remember_digest, User.digest(remember_token)) end
User#new_tokenメソッドの使い方は、rememberでもcreate_activation_digestでも同じです。一方、
User#digestメソッドの使い方は、rememberとcreate_activation_digestで異なります。
rememberにおけるUser#digestメソッドは、update_attributeメソッドの引数として呼び出している
- すでにRDB上に存在するユーザー情報を対象とするため
create_activation_digestにおけるUser#digestメソッドは、実行結果をself.activation_digestに代入している
- まだRDB上に存在しないユーザー情報を対象とするため
- RDB上にユーザー情報が生成されるのは、
create_activation_digestが呼び出された後であるUserモデルへの実装の追加・変更の全体像
Userモデルへの実装の追加・変更の全体像は、以下のようになります。なお、既存の「メールアドレスをすべて小文字にする」という処理も、
downcase_emailというメソッドを呼び出す実装に変更しています。app/models/user.rbclass User < ApplicationRecord - attr_accessor :remember_token, + attr_accessor :remember_token, :activation_token - before_save { email.downcase! } + before_save :downcase_email + before_create :create_activation_digest ...略 + + private + + # メールアドレスをすべて小文字にする + def downcase_email + self.email = email.downcase + end + + # 有効化トークンとダイジェストを作成および代入する + def create_activation_digest + self.activation_token = User.new_token + self.activation_digest = User.digest(activation_token) + end endこの時点でテストは全て成功するはず
特に「RDBに格納されるデータにおいて、メールアドレスが全て小文字であるか」というテストが成功することは、再度確認が必要です。
test/models/user_test.rb(57行目)test "email addresses should be saved as lower-case" do# rails test test/models/user_test.rb:57 Running via Spring preloader in process 13346 Started with run options --seed 25681 12/12: [=================================] 100% Time: 00:00:00, Time: 00:00:00 Finished in 0.20554s 1 tests, 1 assertions, 0 failures, 0 errors, 0 skips「RDBに格納されるデータにおいて、メールアドレスが全て小文字であるか」というテストは、無事成功しました。
続いて全体のテストを実行してみましょう。
# rails test Running via Spring preloader in process 13361 Started with run options --seed 7529 43/43: [=================================] 100% Time: 00:00:06, Time: 00:00:06 Finished in 7.00017s 43 tests, 183 assertions, 0 failures, 0 errors, 0 skipsこちらも無事成功しました。
サンプルユーザーの生成とテスト
サンプルユーザーを最初から有効にしておく
db/seeds.rbUser.create(name: "Example User", email: "example@railstutorial.org", password: "foobar", password_confirmation: "foobar", - admin: true) + admin: true, + activated: true, + activated_at: Time.zone.now) 99.times do |n| name = Faker::Name.name email = "example-#{n+1}@railstutorial.org" password = "password" User.create!( name: name, email: email, password: password, - password_confirmation: password) + password_confirmation: password, + activated: true, + activated_at: Time.zone.now) endfixtureのユーザーを最初から有効にしておく
test/fixtures/users.ymlrhakurei: name: Reimu Hakurei email: rhakurei@example.com password_digest: <%= User.digest('password') %> admin: true + activated: true + activated_at: Time.zone.now mkirisame: name: Marisa Kirisame email: example.example@example.org password_digest: <%= User.digest('password') %> + activated: true + activated_at: Time.zone.now skomeiji: name: Satori Komeiji email: example_example@example.net password_digest: <%= User.digest('password') %> + activated: true + activated_at: Time.zone.now rusami: name: Renko Usami email: example0@example.com password_digest: <%= User.digest('password') %> + activated: true + activated_at: Time.zone.now <% 30.times do |n| %> user_<%= n %>: name: <%= "User #{n}" %> email: <%= "user-#{n}@example.com" %> password_digest: <%= User.digest('password') %> + activated: true + activated_at: Time.zone.now <% end %>サンプルデータを再度生成し直す
# rails db:migrate:reset Dropped database 'db/development.sqlite3' Dropped database 'db/test.sqlite3' Created database 'db/development.sqlite3' Created database 'db/test.sqlite3' ...略 == [timestamp] AddActivationToUsers: migrated (0.0165s) ==================== # rails db:seed #「サンプルデータの入力におけるtypo」というのは案外やってしまいがちなので、この場面は結構緊張する場面です。今回は何事もなく完了しました。
演習 - AccountActivationのデータモデル
1. 本項での変更を加えた後、テストスイートが
greenのままになっていることを確認してみましょう。# rails test Running via Spring preloader in process 13392 Started with run options --seed 56186 43/43: [=================================] 100% Time: 00:00:04, Time: 00:00:04 Finished in 4.90673s 43 tests, 183 assertions, 0 failures, 0 errors, 0 skips特にfixtureの変更時におけるtypoには注意が必要です。
2.1. コンソールからUserクラスのインスタンスを生成し、そのオブジェクトから
create_activation_digestメソッドを呼び出そうとすると (Privateメソッドなので)NoMethodErrorが発生することを確認してみましょう。# rails console --sandbox >> user = User.first >> user.create_activation_digest Traceback (most recent call last): 1: from (irb):2 NoMethodError (private method `create_activation_digest' called for #<User:0x00007fdd8823ae98>) Did you mean? restore_activation_digest!2.2. また、そのUserオブジェクトからダイジェストの値も確認してみましょう。
>> user.activation_digest => "$2a$10$Q.bVywQrrgEJC6Mg0IhdXONY5M/0jQYm4/ZEBwhJfxcag7VBBx6S6"3.1.
downcase!メソッドを使って、リスト 11.3のdowncase_emailメソッドを改良してみてください。リスト 6.34で、メールアドレスの小文字化には
email.downcase!という (代入せずに済む) メソッドがあることを知りました。User#downcase_emaildef downcase_email - self.email = email.downcase + self.email.downcase! end3.2. また、うまく変更できれば、テストスイートは成功したままになっていることも確認してみてください。
# rails test test/models/user_test.rb:57 Running via Spring preloader in process 13416 Started with run options --seed 43627 12/12: [=================================] 100% Time: 00:00:00, Time: 00:00:00 Finished in 0.23544s 1 tests, 1 assertions, 0 failures, 0 errors, 0 skips# rails test Running via Spring preloader in process 13429 Started with run options --seed 15928 43/43: [=================================] 100% Time: 00:00:04, Time: 00:00:04 Finished in 4.22996s 43 tests, 183 assertions, 0 failures, 0 errors, 0 skips
- 投稿日:2019-12-03T11:33:04+09:00
Rails+Vue.jsによるフォームの作例
私の体感では、Railsアプリケーションで開発にかかる時間の半分はテンプレートとJavaScriptで、その大半はフォームです。ややこしいテンプレートはRails側で頑張らずに、Vue.jsのようなJavaScriptのフレームワークに投げてしまう、という作り方を今後のスタイルとしたい。
このサンプルは、ここ1年半ほどRails上でVue.jsをいじくって考えた、現在のところのベターなパターンです。まだ研究中なところもあり、今後変更する可能性もあります。
サンプルプログラムはこちら。簡単なブログアプリケーションです。
https://github.com/kazubon/blog-rails6-vuejs環境
- Rails 5.2/6.0、Webpacker 4、Vue.js 2.6。
- 非SPA、Turbolinksあり。
- jQueryとBootstrapあり。
そこそこ大きな業務アプリケーションを想定(サンプルはブログですが)。Vue.jsではないJavaScriptを使っているなど、いろいろなスタイルのページが混じっているものとする。
ポイント
- newとeditでは、2回リクエストを送る。1回目はふつうのHTMLで、ページの枠だけ受け取る。2回目はVueからAjaxでモデルのデータを受け取り、フォームの入力欄に反映する。
- createとupdateは、Ajaxで呼ぶ。保存に成功したときはJavaScriptでリダイレクトし、失敗したらエラーメッセージを表示する。
- 検索結果の表示ページでも、2回リクエストを送る。1回目はふつうのHTMLで、ページの枠だけ。2回目はVueからAjaxで検索のパラメータを送って記事リストを受け取り、一覧を表示する。
関連記事:
application.js
packs下のapplication.jsの書き方はいろいろ考えられますが、このサンプルではこんな感じです。HTML要素をid属性で探して、対応するVueアプリケーションをマウントします。
グローバル変数vuePropsは、Railsから直接データをVueのpropsに渡すのに使っています。
SessionForm(ログインフォーム、この記事では紹介なし)だけ.vueファイル内のテンプレートを使わずにRailsが出したHTMLをパースしていますが、これは比較研究したかっただけです。
app/javascript/packs/application.jsrequire("@rails/ujs").start(); require("turbolinks").start(); import 'core-js'; import Vue from 'vue'; import TurbolinksAdapter from 'vue-turbolinks' import EntryIndex from '../entries/index'; import EntryForm from '../entries/form'; import EntryStar from '../entries/star'; import SessionForm from '../sessions/form'; Vue.use(TurbolinksAdapter); document.addEventListener('turbolinks:load', () => { let apps = [ { elem: '#entry-index', object: EntryIndex }, { elem: '#entry-form', object: EntryForm }, { elem: '#entry-star', object: EntryStar }, { elem: '#session-form', object: SessionForm } ]; let props = window.vueProps || {}; apps.forEach((app) => { if($(app.elem).length) { if(app.object.render) { // テンプレートあり new Vue({ el: app.elem, render: h => h(app.object, { props }) }); } else { // HTMLをテンプレートに new Vue(app.object).$mount(app.elem); } } }); });newとedit
編集ページのアクションです。HTMLの枠とAjaxでのデータ送信を同じアクションにしていますが、Ajax用を分けて
api/entries_controller.rbのような別コントローラにすることも考えられます。
Entries::Formは形式的に置いているもので、このサンプルの編集ページでは使ってません。app/controllers/entries_controller.rbdef new @entry = Entry.new @form = Entries::Form.new(current_user, @entry) respond_to do |format| format.html format.json { render :edit } end end def edit @entry = current_user.entries.find(params[:id]) @form = Entries::Form.new(current_user, @entry) respond_to :html, :json end編集ページのHTML枠です。editではグローバル変数vuePropsでEntryモデルのidを渡しています。
app/views/entries/new.html.erb<div id="entry-form"></div>app/views/entries/edit.html.erb<script> var vueProps = <%= { entryId: @entry.try(:id) }.to_json.html_safe %>; </script> <div id="entry-form"></div>編集ページのフォーム用のVueです。createdでAjaxを使ってEntryモデルのデータを取得し、フォームにセットします。
app/javascript/entries/form.vue<template> <div> <form @submit="submit"> <div v-if="alert" class="alert alert-danger">{{alert}}</div> <div class="form-group"> <label for="entry-title">タイトル</label> <input type="text" v-model="entry.title" id="entry-title" class="form-control" required maxlength="255" pattern=".*[^\s]+.*"> </div> <div class="form-group"> <label for="entry-body">本文</label> <textarea v-model="entry.body" id="entry-body" cols="80" rows="15" class="form-control" required maxlength="40000"> </textarea> </div> <div class="form-group"> <label for="entry-tag0">タグ</label> <div> <input v-for="(tag, index) in entry.tags" :key="index" v-model="tag.name" class="form-control width-auto d-inline-block mr-2" style="width: 17%" maxlength="255" > </div> </div> <div class="form-group"> <label for="entry-published_at">日時</label> <input type="text" v-model="entry.published_at" id="entry-published_at" class="form-control" pattern="\d{4}(-|\/)\d{2}(-|\/)\d{2} +\d{2}:\d{2}"> </div> <div class="form-group mb-4"> <input type="checkbox" v-model="entry.draft" id="entry-draft" value="1"> <label for="entry-draft">下書き</label> </div> <div class="row"> <div class="col"> <button type="submit" class="btn btn-outline-primary">{{entryId ? '更新' : '作成'}}</button> </div> <div class="col text-right" v-if="entryId"> <button type="button" class="btn btn-outline-danger" @click="destroy">削除</button> </div> </div> </form> </div> </template> <script> export default { props: ['entryId'], data() { return { entry: {}, alert: null }; }, created() { axios.get(this.path() + '.json').then((res) => { this.entry = res.data.entry; this.initTags(); }); }, methods: { path() { return this.entryId ? `/entries/${this.entryId}/edit` : '/entries/new'; }, initTags() { let len = this.entry.tags.length; if(len < 5) { for(let i = 0; i < 5 - len; i++) { this.entry.tags.push({ name: '' }); } } }, // 中略 } } </script>newとeditのアクションで、Vueに渡すjsonデータです。
app/views/entries/edit.jbuilderjson.entry do json.title @entry.title json.body @entry.body json.published_at (@entry.published_at || Time.zone.now).strftime('%Y-%m-%d %H:%M') json.draft @entry.draft json.tags do json.array! @entry.tags do |tag| json.name tag.name end end endcreateとupdate
新規作成と更新のアクションです。成功したときは、jsonでリダイレクト先のパスを返します。失敗したときは、ステータスコード422とメッセージを返します。
ここで使っている
Entries::Formは、バリデーションとデータの保存を行うものです。そのうち別記事で紹介します。app/controllers/entries_controller.rbdef create @entry = Entry.new @form = Entries::Form.new(current_user, @entry, entry_params) if @form.save flash.notice = '記事を作成しました。' render json: { location: entry_path(@entry) } else render json: { alert: '記事を作成できませんでした。' }, status: :unprocessable_entity end end def update @entry = current_user.entries.find(params[:id]) @form = Entries::Form.new(current_user, @entry, entry_params) if @form.save flash.notice = '記事を更新しました。' render json: { location: entry_path(@entry) } else render json: { alert: '記事を更新できませんでした。' }, status: :unprocessable_entity end end # 中略 def entry_params params.require(:entry).permit( :title, :body, :published_at, :draft, tags: [ :name ] ) end編集ページのフォーム用のVueで、フォームを送信するメソッドです。送信時には、HTTPヘッダにmeta属性からCSRF対策のトークンを入れます。
成功したときは指定のパスにリダイレクトします。失敗したときはアラート表示部にメッセージを入れます。
実際のアプリケーションでは、トークンを指定する部分はコードを共通化するべきでしょう。レスポンスの処理部分も共通化したほうがいいかも。
app/javascript/entries/form.vue<script> export default { // 中略 methods: { // 中略 submitPath() { return this.entryId ? `/entries/${this.entryId}` : '/entries'; }, submit(evt) { evt.preventDefault(); if(!this.validate()) { return; } axios({ method: this.entryId ? 'patch' : 'post', url: this.submitPath() + '.json', headers: { 'X-CSRF-Token' : $('meta[name="csrf-token"]').attr('content') }, data: { entry: this.entry } }).then((res) => { Turbolinks.visit(res.data.location); }).catch((error) => { if(error.response.status == 422) { this.alert = error.response.data.alert; } else { this.alert = `${error.response.status} ${error.response.statusText}`; } window.scrollTo(0, 0); }); }, // 中略 } } </script>index
検索フォームと記事一覧を表示するindexアクションです。ここでもHTMLの枠とAjaxのデータを同じアクションにしています。
検索のパラメータには、まとめて扱えるようにキー
qを付けています。これは、まあ私の好みです。ここで使っている
Entries::SearchFormは、パラメータを使って検索を行うものです。これもそのうち別記事で紹介します。app/controllers/entries_controller.rbdef index @user = User.active.find(params[:user_id]) if params[:user_id].present? @form = Entries::SearchForm.new(current_user, @user, search_params) respond_to :html, :json end (中略) def search_params return {} unless params.has_key?(:q) params.require(:q).permit(:title, :tag, :offset, :sort) end検索ページのHTML枠の一部です。グロバール変数vuePropsでVueにユーザーID(ユーザー別一覧の場合)と検索パラメータを渡します。
app/views/entries/new.html.erb<script> var vueProps = <%= { userId: @user.try(:id), query: params[:q] || {} }.to_json.html_safe %>; </script> <div id="entry-index"></div>検索フォームと記事一覧用のVueです。フォーム(search_form.vue)と一覧(list.vue)を子コンポーネントに分けています。
app/javascript/entries/index.vue<template> <div> <search-form :userId="userId" :query="query"></search-form> <list :userId="userId" :query="query"></list> </div> </template> <script> import Form from './search_form'; import List from './list'; export default { props: ['userId', 'query'], components: { 'search-form': Form, 'list': List } } </script>検索フォームのVueです。ここでは特に何もしてません。タグを入力したときに動的に候補を出すような機能を付けるときはここに追加するつもりです。
検索フォームの送信はブラウザーに任せてページ遷移します。
app/javascript/entries/search_form.vue<template> <div> <form action="/entries" method="get" class="form-inline mb-4"> <input type="text" name="q[title]" class="form-control mr-3 mb-2" v-model="query.title" placeholder="タイトル"> <input type="text" name="q[tag]" class="form-control mr-3 mb-2" v-model="query.tag" placeholder="タグ"> <input type="hidden" name="q[sort]" v-model="query.sort"> <input type="hidden" name="user_id" v-model="userId"> <button type="submit" class="btn btn-outline-primary mb-2">検索</button> </form> </div> </template> <script> export default { props: ['userId', 'query'] } </script>検索結果の一覧を出すVueです。createdでAjax送信を行い、記事一覧のデータを配列entriesに入れます。「もっと読む」ボタンを押したときは、オフセットを増やして記事を取得します。
app/javascript/entries/list.vue<template> <div> <div class="text-right mb-3"> {{entriesCount}}件 | <a :href="sortPath('date')" v-if="query.sort == 'stars'">日付順</a> <template v-else>日付順</template> | <a :href="sortPath('stars')" v-if="query.sort != 'stars'">いいね順</a> <template v-else>いいね順</template> </div> <div class="entries mb-4"> <div v-for="entry in entries" :key="entry.id" class="entry"> <div> <a :href="entry.path"> <template v-if="entry.draft">(下書き) </template> {{entry.title}} </a> </div> <div class="text-right text-secondary"> <a :href="entry.user_path">{{entry.user_name}}</a> | <a v-for="tag in entry.tags" :key="tag.id" class="mr-2" :href="tag.tag_path">{{tag.name}}</a> | {{entry.published_at}} | <span class="text-warning" v-if="entry.stars_count > 0">★{{entry.stars_count}}</span> </div> </div> </div> <div v-if="showMore"> <button type="button" @click="moreClicked" class="btn btn-outline-secondary w-100">もっと見る</button> </div> </div> </template> <script> import axios from 'axios'; import qs from 'qs'; export default { props: ['userId', 'query'], data: function () { return { entries: [], entriesCount: 0, offset: 0 }; }, computed: { showMore() { return (this.entries.length < this.entriesCount); } }, created () { this.getEntries(); }, methods: { getEntries() { let params = { q: { ...this.query, offset: this.offset }, user_id: this.userId }; let path = '/entries.json?' + qs.stringify(params); axios.get(path).then((res) => { this.entries = this.entries.concat(res.data.entries); this.entriesCount = res.data.entries_count; }); }, moreClicked() { this.offset += 20; this.getEntries(); }, sortPath(key) { let params = { q: { ...this.query, sort: key }, user_id: this.userId }; return '/entries?' + qs.stringify(params); } } } </script>indexアクションで、検索結果を返すjsonです。
Entries::SearchFormのメソッドを呼び出しています。app/views/entries/index.jbuilderjson.entries do json.array! @form.entries do |entry| json.id entry.id json.title entry.title json.path entry_path(entry) json.user_name entry.user.name json.user_path user_entries_path(entry.user) json.draft entry.draft? json.published_at entry.published_at.try(:strftime, '%Y-%m-%d %H:%M') json.stars_count entry.stars_count json.tags do json.array! entry.tags do |tag| json.id tag.id json.name tag.name json.tag_path( @user ? user_entries_path(@user, q: { tag: tag.name }) : entries_path(q: { tag: tag.name }) ) end end end end json.entries_count @form.entries_countVue.jsと関係ない補足
- createとupdateは、Ajaxで呼ぶほうが楽です。失敗時に
render :newなどでテンプレートを作り直す手間が省けるのは大きい。また、失敗ページでのブラウザーの進む/戻る/リロードの動作が自然になります。- 検索フォームを送信するときは、GETメソッドでページ遷移させて、URLを変化させます。POSTメソッドを使ったりAjaxを使ったりすると、ブラウザーのリロードで検索結果が消えたり、検索結果をブックマークできなくなったりします。
- JavaScriptでフォームの送信を扱うときは、送信ボタンのclickイベントじゃなくて、form要素のsubmitイベントを処理すること。
検討課題
- Vueのテンプレートは.vueファイル内に入れるべきか、Railsが出力したHTMLを使うべきか。既存のアプリケーションでは、テンプレートをすべて.vueファイルに移すのは現実的に無理なので、両方ありとします。
- フォームのAjax送信にrails-ujs(
remote: trueを付けてajax:successイベントを処理)を使うべきか。rails-ujsでもいいんだけど、慣れていない人に分かりにくいのでAxiosで統一したほうがよい。- RailsからVueに直接データを渡す方法(このサンプルのグローバル変数vueProps)については、まだ迷い中。
- 投稿日:2019-12-03T11:19:26+09:00
削除がうまくいかずエラーが出る。。。
ツイートを削除しようとしたら、こんなエラーが出た。
ActiveRecord::StatementInvalid in TweetsController#destroy
Mysql2::Error: Cannot delete or update a parent row: a foreign key constraint fails僕はただツイートを削除したいだけなのに。。。
なぜエラーがでたのか
【結論】
tweetsテーブルとcommentsテーブルで外部キー制約を結んでいたため。外部キーとして、
tweet_idがあるのがわかります。コメントは必ず何かしらのツイートに結びつきます。
勝手にツイートを削除すると、迷子のコメントが現れます!
これがエラーの正体です。
解決策
dependent: :destroyを追記。
ツイートを削除すると同時に、そのツイートに結びついたコメントも削除します。app/models/tweet.rbclass Tweet < ApplicationRecord has_many :comments, dependent: :destroy end
参考
ではまた!
- 投稿日:2019-12-03T11:18:14+09:00
削除ボタンを押したら、確認ダイアログを出そう。
- 投稿日:2019-12-03T11:17:30+09:00
redirect_backで直前のページにバック!
現在ツイート詳細ページ(show.html.haml)でコメントができます。
ツイートにコメントをしたらコメントする前の状態に戻るようにします。
redirect_back(fallback_location: アクション名)で直前のページに戻すことができます。app/controllers/comments_controller.rbdef create tweet = Tweet.find(params[:tweet_id]) @comment = tweet.comments.build(comment_params) if @comment.save redirect_back(fallback_location: tweet_path(tweet)) else flash[:danger] = "テキストを入力してください" redirect_back(fallback_location: tweet_path(tweet)) end end
tweet_pathはshowアクションのprefixです。以上!
参考
ではまた!
- 投稿日:2019-12-03T03:06:33+09:00
rails-tutorial第8章
ログイン機能を作ろう!!
モデルを使わないSessionリソースを扱う。
Sessionsコントローラを作ろう
$ rails generate controller Sessions new
ルーティング設定
config/routes.rbRails.application.routes.draw do root 'static_pages#home' get '/help', to: 'static_pages#help' get '/about', to: 'static_pages#about' get '/contact', to: 'static_pages#contact' get '/signup', to: 'users#new' get '/login', to: 'sessions#new' post '/login', to: 'sessions#create' delete '/logout', to: 'sessions#destroy' resources :users end別にresourcesを使ってないからといってrestfulじゃないわけではない。本質は同じurlにhttpリクエストメソッドで指定してアクションを分けるということ。
また、これにより、login_pathなどの名前付きルートが設定される。
ログインフォーム作る。
app/views/sessions/new.html.erb<% provide(:title, "Log in") %> <h1>Log in</h1> <div class="row"> <div class="col-md-6 col-md-offset-3"> <%= form_for(:session, url: login_path) do |f| %> <%= f.label :email %> <%= f.email_field :email, class: 'form-control' %> <%= f.label :password %> <%= f.password_field :password, class: 'form-control' %> <%= f.submit "Log in", class: "btn btn-primary" %> <% end %> <p>New user? <%= link_to "Sign up now!", signup_path %></p> </div> </div>form_forの引数はsignupの時は@userを渡していたが、:sessionを渡すことにより、
params = {session: {email: ~~~, password: ~~~}}という形で情報を送ることができる。で、urlオプションに名前付きルートを渡せば完成。
次にSessionsコントローラのcreateアクションを実装していこう。
app/controllers/sessions_controller.rbclass SessionsController < ApplicationController def new end def create user = User.find_by(email: params[:session][:email].downcase) if user && user.authenticate(params[:session][:password]) # ユーザーログイン後にユーザー情報のページにリダイレクトする else # エラーメッセージを作成する render 'new' end end def destroy end endfind_byに注目。
find()だと、idでしか探すことができず、()の中に数字しか入れられない。IDがわかっている場合は、findメソッド
IDが不明で、別の条件でレコード検索をしたい場合は、find_byメソッド
このように覚えておこう。注意点!!
user = User.find_by(email: params[:session][:email].downcase) if user.authenticate(params[:session][:password]) . . .上記のように書いてしまうと、致命的な欠陥がある。
find_byはオブジェクトが見つからない時にnilを返すので、もし存在していないメールアドレスを渡してしまうと、、
nil.authenticateとなり、Nomethoderrorが起こってしまう。
なので、
user = User.find_by(email: params[:session][:email].downcase) if user && user.authenticate(params[:session][:password])ユーザーが存在するかつ、アドレスの認証が通れば、という条件をif文に当てている。
ちなみに userが存在しないとわかった時点で、右側の条件式は評価されないという特徴がある。
これにより、nil.authenticateは実行されなくなる。ログイン失敗時のメッセージを表示する。
ActiveRecordを継承しているモデルと、バリデーションを設定すれば@user.errors.full_messagesに実際のエラーメッセージが表示されたが、
Sessionsの場合、モデルでもなければバリデーションもないので、
flashを使ってメッセージを表示する必要がある。
app/controllers/sessions_controller.rbclass SessionsController < ApplicationController def new end def create user = User.find_by(email: params[:session][:email].downcase) if user && user.authenticate(params[:session][:password]) # ユーザーログイン後にユーザー情報のページにリダイレクトする else flash[:danger] = 'Invalid email/password combination' # 本当は正しくない render 'new' end end def destroy end end上記の場合、ちょっとしたバグがある。
それは、flashメッセージがhome画面やhelp画面に移動しても表示されてしまうことだ。なぜこれが起きるのか?
redirect_toの場合は、それでリクエストが一回。
別のページに飛ぼうとすると2回目なので飛ぶ寸前でflashメッセージは消える。しかし、今回は、
render 'new'となっている。
renderはリクエストには入らないため、
例えば、home画面に飛ぶので一回目、
再度リロードすると、2回目なので消える、という流れになってしまう。回帰バグを防ぐためにテストを書こう。
今回もブラウザを行ったり来たりしているテストなので、インテグレーションテストで行う。
$ rails generate integration_test users_login
test/integration/users_login_test.rbrequire 'test_helper' class UsersLoginTest < ActionDispatch::IntegrationTest test "login with invalid information" do get login_path assert_template 'sessions/new' post login_path, params: { session: { email: "", password: "" } } assert_template 'sessions/new' assert_not flash.empty? get root_path assert flash.empty? end endこの状態だとテストは落ちる。
じゃあ、どうやってflashメッセージを1回だけ表示するの?
flashはメソッドなので、flash.now[:danger] = 'Invalid email/password combination'
というようにできる。
.nowは1度目のリクエストが来たらflashメッセージを消す。
これはflashメソッドがrailsで元から設定されたメソッドだからこういうことができる。これで失敗時の実装は完了
成功した時の処理を実装しよう
ユーザーが存在し、アドレスの認証が通れば、ログインしている状態を作り出さないといけない。
sessionという特殊な変数を使ってそれを実現する。
session[:user_id] = user.id
ここに何か値が入れば、ログイン中とし、nilになればログアウトとする。それを実現するためにhelperにloginメソッドを定義しよう。
app/helpers/sessions_helper.rbmodule SessionsHelper # 渡されたユーザーでログインする def log_in(user) session[:user_id] = user.id end endこのhelperメソッドを使ってログイン機能を実装
app/controllers/sessions_controller.rbclass SessionsController < ApplicationController def new end def create user = User.find_by(email: params[:session][:email].downcase) if user && user.authenticate(params[:session][:password]) log_in user redirect_to user else flash.now[:danger] = 'Invalid email/password combination' render 'new' end end def destroy end end最後に、loginしているかどうかは、様々なコントローラで使うので、loginメソッドを書いたhelperをどのコントローラでも使えるようにしておこう。
app/controllers/application_controller.rbclass ApplicationController < ActionController::Base protect_from_forgery with: :exception include SessionsHelper endそのためには、全コントローラの親クラスであるapplication コントローラに
createアクションを実装したら
その次は、session[:user_id]に値が入っていれば、〜〜〜〜、入っていなければ、〜〜〜〜というようにしていく。
また、今どのユーザーがログインしているかがわからないと、showアクションでどのユーザーページを表示するべきかなどの問題が発生してしまうので、なんとかして、sessionの情報から現在ログインしているユーザーを参照する必要がある。
では、現在ログインしているユーザーを返すメソッドをhelperに定義しよう。
app/helpers/sessions_helper.rbmodule SessionsHelper # 渡されたユーザーでログインする def log_in(user) session[:user_id] = user.id end # 現在ログイン中のユーザーを返す (いる場合) def current_user if session[:user_id] @current_user ||= User.find_by(id: session[:user_id]) end end end@current_userとなっているのは、インスタンス変数をview側で使いたいから。
また、@current_userを使いたいたびにfind_byをするのはパフォーマンス上あまり良くない。
なので、 ||= を使って、存在すれば@current_userを返す。 なかったら、find_byを使うというようにしている。これで、1リクエストで最大で1問い合わせにできる。
view側でログインユーザーと非ログインユーザーを分けるには
<% if logged_in? %> # ログインユーザー用のリンク <% else %> # ログインしていないユーザー用のリンク <% end %>というようにrubyのコードを使ってあげれば良い。
そのために、上記のlogged_in?メソッドを定義しよう。
app/helpers/sessions_helper.rbmodule SessionsHelper # 渡されたユーザーでログインする def log_in(user) session[:user_id] = user.id end # 現在ログイン中のユーザーを返す (いる場合) def current_user if session[:user_id] @current_user ||= User.find_by(id: session[:user_id]) end end # ユーザーがログインしていればtrue、その他ならfalseを返す def logged_in? !current_user.nil? end endlogged_in?メソッドはcurrent_userメソッドを呼び出して、インスタンス変数があれば、trueを返し、なければfalseを返すようにする。
このままだと、nilの時にtrueを返してしまう。
!は否定演算子である。true falseが逆になる。
これで、nilの時はfalseをnilじゃない時はtrueを返すようにする。
ログインしているかでviewを分ける
app/views/layouts/_header.html.erb<header class="navbar navbar-fixed-top navbar-inverse"> <div class="container"> <%= link_to "sample app", root_path, id: "logo" %> <nav> <ul class="nav navbar-nav navbar-right"> <li><%= link_to "Home", root_path %></li> <li><%= link_to "Help", help_path %></li> <% if logged_in? %> <li><%= link_to "Users", '#' %></li> <li class="dropdown"> <a href="#" class="dropdown-toggle" data-toggle="dropdown"> Account <b class="caret"></b> </a> <ul class="dropdown-menu"> <li><%= link_to "Profile", current_user %></li> <li><%= link_to "Settings", '#' %></li> <li class="divider"></li> <li> <%= link_to "Log out", logout_path, method: :delete %> </li> </ul> </li> <% else %> <li><%= link_to "Log in", login_path %></li> <% end %> </ul> </nav> </div> </header>ここで注意しなければいけないのは、
<li><%= link_to "Profile", current_user %></li>これは本来
<li><%= link_to "Profile", user_path(@current_user) %></li>となっている。
ただ、これは @current_userに省略することができたよね。
だから、current_userメソッドを書くだけで良い。
もう一つ注意する点が、
<%= link_to "Log out", logout_path, method: :delete %>link_toメソッドはデフォルトではgetリクエストを送るのでmethod: :deleteを書かないと、
/logout に getリクエストを送ってしまう。
なので、link_toでgetリクエスト以外を指定するときは、
method: :deleteのようにオプションを追加しなければいけない。注意点としては、method: と :deleteどちらも:が必要だということ。
bootstrapのドロップダウンを使えるようにするために
app/assets/javascripts/application.js//= require rails-ujs //= require jquery //= require bootstrap //= require turbolinks //= require_tree .2,3行目を書き加えるとドロップダウンが使えるようになる。
ユーザーログインのテスト
テスト時に登録済みユーザーとしてログインしておく必要があります。当然ながら、データベースにそのためのユーザーが登録されていなければなりません。Railsでは、このようなテスト用データをfixture (フィクスチャ) で作成できます。
test/fixtures/users.ymlmichael: name: Michael Example email: michael@example.com password_digest: <%= User.digest('password') %>でもこれじゃあdigestメソッドがないから、ハッシュ化できない。
digestメソッドはUserに関連するときしか使わないので、userモデルに定義する。
app/models/user.rbbefore_save { self.email = email.downcase } validates :name, presence: true, length: { maximum: 50 } VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i validates :email, presence: true, length: { maximum: 255 }, format: { with: VALID_EMAIL_REGEX }, uniqueness: { case_sensitive: false } has_secure_password validates :password, presence: true, length: { minimum: 6 } # 渡された文字列のハッシュ値を返す def User.digest(string) cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost BCrypt::Password.create(string, cost: cost) end end三項演算子について
condition ? expr1 : expr2condition
trueかfalseかを評価する式です。
expr1, expr2
各々の値の場合に実行する式です。 conditionがtrueの場合、演算子はexpr1の値を返します。そうでない場合はexpr2の値を返します。ユーザーログインテストコード
test/integration/users_login_test.rbrequire 'test_helper' class UsersLoginTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) end . . . test "login with valid information" do get login_path post login_path, params: { session: { email: @user.email, password: 'password' } } assert_redirected_to @user follow_redirect! assert_template 'users/show' assert_select "a[href=?]", login_path, count: 0 assert_select "a[href=?]", logout_path assert_select "a[href=?]", user_path(@user) end end@user = users(:michael)はtest/fixtures/users.ymlで作った:michaelを@userに代入している。
また、
assert_redirected_to @user follow_redirect!1行目は、 @userにリダイレクトされますよね?(行き先は〜〜駅ですよね)
2行目は、 assert_redirected_to @userが通った上で@userにリダイレクトされる。ちなみにこの状態でテストは通る。
signup後はそのままログイン済みにしよう。
app/controllers/users_controller.rbclass UsersController < ApplicationController def show @user = User.find(params[:id]) end def new @user = User.new end def create @user = User.new(user_params) if @user.save log_in @user flash[:success] = "Welcome to the Sample App!" redirect_to @user else render 'new' end end private def user_params params.require(:user).permit(:name, :email, :password, :password_confirmation) end endlog_in @userでセッション変数に@user.idを保存する。
ちなみに、log_in @userが使えるのはsessions_helperがapplication controllerにincludeされてるから。ログインのテスト
テストにもテストのhelperが存在する。
そこに、ログインしているかどうかを判断するメソッドを定義しておこう。test/test_helper.rbENV['RAILS_ENV'] ||= 'test' . . . class ActiveSupport::TestCase fixtures :all # テストユーザーがログイン中の場合にtrueを返す def is_logged_in? !session[:user_id].nil? end endユーザー登録後ログイン状態になっているかテスト。
test/integration/users_signup_test.rbrequire 'test_helper' class UsersSignupTest < ActionDispatch::IntegrationTest . . . test "valid signup information" do get signup_path assert_difference 'User.count', 1 do post users_path, params: { user: { name: "Example User", email: "user@example.com", password: "password", password_confirmation: "password" } } end follow_redirect! assert_template 'users/show' assert is_logged_in? end end先ほどymlにログインしたユーザー、test_helperにis_logged_in?メソッドを定義したので、テストは通る。
ログアウト機能を実装
ログアウトメソッドもログインメソッドと同じようにsessions_helperに定義しておく。
app/helpers/sessions_helper.rbmodule SessionsHelper # 渡されたユーザーでログインする def log_in(user) session[:user_id] = user.id end . . . # 現在のユーザーをログアウトする def log_out session.delete(:user_id) @current_user = nil end endここで気をつけるのが、
sessionというのはハッシュな訳で、session[:user_id] = user.id
session = {user_id: user.id}という形になっている。
で、ハッシュの値を消すときは、
hash.delete(key)
というdeleteメソッドにキーを引数に渡すと実現できる。
で、今回sessionでも同じことが起きている。
session.delete(:user_id)
これにより、sessionというハッシュに格納されていた{user_id: user.id}が消える。
じゃあ、なんで@current_user = nilまでする必要があるの?
これは、なるべくサーバーの問い合わせ?的なものを無くして負荷を少なくするためらしい。
destroyアクションの実装
app/controllers/sessions_controller.rbclass SessionsController < ApplicationController def new end def create user = User.find_by(email: params[:session][:email].downcase) if user && user.authenticate(params[:session][:password]) log_in user redirect_to user else flash.now[:danger] = 'Invalid email/password combination' render 'new' end end def destroy log_out redirect_to root_url end endここで注目したいのが、destroyアクションはviewがないので、最後にどのviewに飛ぶかを指定してあげる必要があるということ。これはupdateアクションとかにも言えそうだよね。
ログアウトのテスト
統合テストのストーリーを拡張しよう。
具体的には、ログインテストの中で、ログアウトのテストのアサーションも書いちゃおうというもの。test/integration/users_login_test.rbrequire 'test_helper' class UsersLoginTest < ActionDispatch::IntegrationTest . . . test "login with valid information followed by logout" do get login_path post login_path, params: { session: { email: @user.email, password: 'password' } } assert is_logged_in? assert_redirected_to @user follow_redirect! assert_template 'users/show' assert_select "a[href=?]", login_path, count: 0 assert_select "a[href=?]", logout_path assert_select "a[href=?]", user_path(@user) delete logout_path assert_not is_logged_in? assert_redirected_to root_url follow_redirect! assert_select "a[href=?]", login_path assert_select "a[href=?]", logout_path, count: 0 assert_select "a[href=?]", user_path(@user), count: 0 end endassert_not is_logged_in?
is_logged_in?はtest_helperに定義したから使える。なぜsession変数はなくならない?なくなる?
調べろ。ブラウザとrails sに保存されるから?
- 投稿日:2019-12-03T01:10:49+09:00
ログイン中ユーザーの投稿内容をshowアクション以外で出力する方法
SNSっぽいアプリケーションのマイページで、ログイン中ユーザの投稿一覧を取得したかっただけなのにかなり詰まったので備忘録として残しておきます。
アプリの仕様上showアクションやshow.html.erbファイルを使用できず、index.html.erbで表示させるというレア?パターンではありますが。
環境
Rails 5.2.3
Ruby 2.5.1
gemのdeviseを使用
当記事で使用しているモデル名はplace詰まった所
homes/index.html.erbファイル内にてeach doを利用し投稿内容を全部出力したい。
homes/index.html.erb<% @myplaces.each do |myplace| %> #省略 <% end %>全部のplaceを取得するのはhomesコントローラー内のindexアクションで
./controllers/homes_controller@places = Place.allこう書けばいい。そりゃそうだ。
問題はログイン中ユーザの投稿内容だけを取得したい時。./controllers/homes_controller@myplaces = Place.find(current_user.id)と入力するとundefined method 'each' for nil:NilClassでエラーが発生する。これは@myplacesが空だからeachする元ネタがありませんよっていう怒られ方。できそうな気もするのに。
試しにターミナルでfind(1)として出力してみる。
ターミナルpry(main)> Place.find(1) (0.5ms) SET NAMES utf8, @@SESSION.sql_mode = CONCAT(CONCAT(@@sql_mode, ',STRICT_ALL_TABLES'), ',NO_AUTO_VALUE_ON_ZERO'), @@SESSION.sql_auto_is_null = 0, @@SESSION.wait_timeout = 2147483 Place Load (0.3ms) SELECT `places`.* FROM `places` WHERE `places`.`id` = 1 LIMIT 1 => #<Place:0x00007f831ad85618 id: 1, name: #以下省略問題ない。
idが1であるcurrent_user.idを適当な変数に代入して出力してみる./controllers/homes_controller@user = current_user.idhomes/index.html.erb<%= @user %> #=> 1こっちも問題がない。
なんで出力されないのか不思議で仕方がなかったんですが、驚いた事にこんな書き方だけで解決しました。./controllers/homes_controller@places = Place.all places = @places @myplaces = current_user.placeshomes/index.html.erb<% @myplaces.each do |myplace| %> #省略 <% end %>目ン玉飛び出そうになりました。こんな簡単な記述でよかったんかと。。。普通にidを指定すれば出来るやろって余裕こいてたら痛い目見ました。
原因は今度時間を見つけて探してみます。今度作る時にはハマらないように…
- 投稿日:2019-12-03T00:46:12+09:00
【Rails】bundle install エラー「Errno::EACCES: Permission denied @ rb_sysopen -」の権限視点からの対応策 (mysql2)
備忘録です。
自身の記事ですが【Rails環境構築】MySQL2が原因で「bundle install」失敗した時の対処法のようなPATHの変更でも解決できない状況に陥ったため。環境
OS : Mac OS Catalina 10.15.1
Ruby : v 2.6.3p62
Rails : v 6.0.1
Homebrew : v 2.1.16
Bundle : v 1.17.2
MySQL : v 8.0.18 for osx10.15 on x86_64 (Homebrew)その他
※ username は個人の作業ユーザ名になります。$ whoami usernameエラー
$ bundle install The dependency tzinfo-data (>= 0) will be unused by any of the platforms Bundler is installingfor. Bundler is installing for ruby but the dependency is only for x86-mingw32, x86-mswin32, x64-mingw32, java. To add those platforms to the bundle, run `bundle lock --add-platform x86-mingw32 x86-mswin32 x64-mingw32 java`. ( ↑ bashの設定によって色が変わってたりするものの本稿でのエラーには関係なし) 省略 Installing mysql2 0.5.2 with native extensions Errno::EACCES: Permission denied @ rb_sysopen - /Users/username/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/mysql2-0.5.2/CHANGELOG.md An error occurred while installing mysql2 (0.5.2), and Bundler cannot continue. Make sure that `gem install mysql2 -v '0.5.2' --source 'https://rubygems.org/'` succeeds before bundling. In Gemfile: mysql2対応
1. 上記エラーに記載されたディレクトリへ移動
cd /Users/username/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/extensions/x86_64-darwin-19/2.6.0/mysql2-0.5.2/2. 該当ディレクトリでファイルの詳細を表示(権限確認)
$ ls -la total 64 drwxr-xr-x 10 root staff 320 12 2 22:08 . drwxr-xr-x 69 username staff 2208 12 2 22:08 .. -rw-r--r-- 1 root staff 87 12 2 22:08 CHANGELOG.md -rw-r--r-- 1 root staff 1078 12 2 22:08 LICENSE -rw-r--r-- 1 root staff 24534 12 2 22:08 README.md drwxr-xr-x 4 root staff 128 12 2 22:08 examples drwxr-xr-x 3 root staff 96 12 2 22:08 ext drwxr-xr-x 4 root staff 128 12 2 22:08 lib drwxr-xr-x 10 root staff 320 12 2 22:08 spec drwxr-xr-x 6 root staff 192 12 2 22:08 support3. ディレクトリやファイルの権限を 「root」 → 「username」 に変更&確認
$ sudo chown -R username . $ ls -la total 64 drwxr-xr-x 10 username staff 320 12 2 22:08 . drwxr-xr-x 69 username staff 2208 12 2 22:08 .. -rw-r--r-- 1 username staff 87 12 2 22:08 CHANGELOG.md -rw-r--r-- 1 username staff 1078 12 2 22:08 LICENSE -rw-r--r-- 1 username staff 24534 12 2 22:08 README.md drwxr-xr-x 4 username staff 128 12 2 22:08 examples drwxr-xr-x 3 username staff 96 12 2 22:08 ext drwxr-xr-x 4 username staff 128 12 2 22:08 lib drwxr-xr-x 10 username staff 320 12 2 22:08 spec drwxr-xr-x 6 username staff 192 12 2 22:08 support変更されていればok
4. 「bundle install」(成功 or 再度エラーの対応)
恐らくここで完了する方もいれば、筆者のようにまたエラー発生する方もいるかと思います。
「うわ、また同じエラーだ?」と感じるかもしれませんが、
エラーをよくよく見ると、gem内(1回目)とextensions内(2回目)で異なるので再度ディレクトリへ移動して権限変更を繰り返します。$ bundle install 省略 Errno::EACCES: Permission denied @ rb_sysopen - /Users/username/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/extensions/x86_64-darwin-19/2.6.0/mysql2-0.5.2/gem_make.out An error occurred while installing mysql2 (0.5.2), and Bundler cannot continue. Make sure that `gem install mysql2 -v '0.5.2' --source 'https://rubygems.org/'` succeeds before bundling. In Gemfile: mysql2 $ cd /Users/username/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/extensions/x86_64-darwin-19/2.6.0/mysql2-0.5.2/ $ ls -la total 128 drwxr-xr-x 4 root staff 128 12 2 22:08 . drwxr-xr-x 12 username staff 384 12 2 22:08 .. -rw-r--r-- 1 root staff 1999 12 2 22:08 gem_make.out -rw-r--r-- 1 root staff 58721 12 2 22:08 mkmf.log $ sudo chown -R username . $ ls -la total 128 drwxr-xr-x 4 username staff 128 12 2 22:08 . drwxr-xr-x 12 username staff 384 12 2 22:08 .. -rw-r--r-- 1 username staff 1999 12 2 22:08 gem_make.out -rw-r--r-- 1 username staff 58721 12 2 22:08 mkmf.log$ bundle install 省略 Bundle complete! 17 Gemfile dependencies, 75 gems now installed. Use `bundle info [gemname]` to see where a bundled gem is installed.これにて終了です!
その他
誤解や「このやり方のが断然いい」などあれば、ご指摘頂けると幸いです m(_ _)m
参考記事
rails newでErrno::EACCES: Permission denied @ dir_s_mkdir -の対処法 - Qiita
Errno::EACCES: Permission denied @ dir_s_mkdir が出たので対処した - mimikunの技術メモ
lsコマンドの使い方と覚えたい15のオプション【Linuxコマンド集】
- 投稿日:2019-12-03T00:46:12+09:00
【Rails】 bundle install エラー「Errno::EACCES: Permission denied @ rb_sysopen -」の権限視点からの対応策 (mysql2)
備忘録です。
自身の記事ですが【Rails環境構築】MySQL2が原因で「bundle install」失敗した時の対処法のようなPATHの変更でも解決できない状況に陥ったため。環境
OS : Mac OS Catalina 10.15.1
Ruby : v 2.6.3p62
Rails : v 6.0.1
Homebrew : v 2.1.16
Bundle : v 1.17.2
MySQL : v 8.0.18 for osx10.15 on x86_64 (Homebrew)その他
※ username は個人の作業ユーザ名になります。$ whoami => usernameエラー
$ bundle install The dependency tzinfo-data (>= 0) will be unused by any of the platforms Bundler is installingfor. Bundler is installing for ruby but the dependency is only for x86-mingw32, x86-mswin32, x64-mingw32, java. To add those platforms to the bundle, run `bundle lock --add-platform x86-mingw32 x86-mswin32 x64-mingw32 java`. ( ↑ bashの設定によって色が変わってたりするものの本稿でのエラーには関係なし) 省略 Installing mysql2 0.5.2 with native extensions Errno::EACCES: Permission denied @ rb_sysopen - /Users/username/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/gems/mysql2-0.5.2/CHANGELOG.md An error occurred while installing mysql2 (0.5.2), and Bundler cannot continue. Make sure that `gem install mysql2 -v '0.5.2' --source 'https://rubygems.org/'` succeeds before bundling. In Gemfile: mysql2対応
1. 上記エラーに記載されたディレクトリへ移動
cd /Users/username/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/extensions/x86_64-darwin-19/2.6.0/mysql2-0.5.2/2. 該当ディレクトリでファイルの詳細を表示(権限確認)
$ ls -la total 64 drwxr-xr-x 10 root staff 320 12 2 22:08 . drwxr-xr-x 69 username staff 2208 12 2 22:08 .. -rw-r--r-- 1 root staff 87 12 2 22:08 CHANGELOG.md -rw-r--r-- 1 root staff 1078 12 2 22:08 LICENSE -rw-r--r-- 1 root staff 24534 12 2 22:08 README.md drwxr-xr-x 4 root staff 128 12 2 22:08 examples drwxr-xr-x 3 root staff 96 12 2 22:08 ext drwxr-xr-x 4 root staff 128 12 2 22:08 lib drwxr-xr-x 10 root staff 320 12 2 22:08 spec drwxr-xr-x 6 root staff 192 12 2 22:08 support3. ディレクトリやファイルの権限を 「root」 → 「username」 に変更&確認
$ sudo chown -R username . $ ls -la total 64 drwxr-xr-x 10 username staff 320 12 2 22:08 . drwxr-xr-x 69 username staff 2208 12 2 22:08 .. -rw-r--r-- 1 username staff 87 12 2 22:08 CHANGELOG.md -rw-r--r-- 1 username staff 1078 12 2 22:08 LICENSE -rw-r--r-- 1 username staff 24534 12 2 22:08 README.md drwxr-xr-x 4 username staff 128 12 2 22:08 examples drwxr-xr-x 3 username staff 96 12 2 22:08 ext drwxr-xr-x 4 username staff 128 12 2 22:08 lib drwxr-xr-x 10 username staff 320 12 2 22:08 spec drwxr-xr-x 6 username staff 192 12 2 22:08 support変更されていればok
4. 「bundle install」(成功 or 再度エラーの対応)
恐らくここで完了する方もいれば、筆者のようにまたエラー発生する方もいるかと思います。
「うわ、また同じエラーだ?」と感じるかもしれませんが、
エラー先の場所をよくよく見ると、gem内(1回目)とextensions内(2回目)で異なるので再度ディレクトリへ移動して権限変更を繰り返します。$ bundle install 省略 Errno::EACCES: Permission denied @ rb_sysopen - /Users/username/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/extensions/x86_64-darwin-19/2.6.0/mysql2-0.5.2/gem_make.out An error occurred while installing mysql2 (0.5.2), and Bundler cannot continue. Make sure that `gem install mysql2 -v '0.5.2' --source 'https://rubygems.org/'` succeeds before bundling. In Gemfile: mysql2 $ cd /Users/username/.rbenv/versions/2.6.3/lib/ruby/gems/2.6.0/extensions/x86_64-darwin-19/2.6.0/mysql2-0.5.2/ $ ls -la total 128 drwxr-xr-x 4 root staff 128 12 2 22:08 . drwxr-xr-x 12 username staff 384 12 2 22:08 .. -rw-r--r-- 1 root staff 1999 12 2 22:08 gem_make.out -rw-r--r-- 1 root staff 58721 12 2 22:08 mkmf.log $ sudo chown -R username . $ ls -la total 128 drwxr-xr-x 4 username staff 128 12 2 22:08 . drwxr-xr-x 12 username staff 384 12 2 22:08 .. -rw-r--r-- 1 username staff 1999 12 2 22:08 gem_make.out -rw-r--r-- 1 username staff 58721 12 2 22:08 mkmf.log$ bundle install 省略 Bundle complete! 17 Gemfile dependencies, 75 gems now installed. Use `bundle info [gemname]` to see where a bundled gem is installed.これにて終了です!
その他
誤解や「このやり方のが断然いい」などあれば、ご指摘頂けると幸いです m(_ _)m
参考記事
rails newでErrno::EACCES: Permission denied @ dir_s_mkdir -の対処法 - Qiita
Errno::EACCES: Permission denied @ dir_s_mkdir が出たので対処した - mimikunの技術メモ
lsコマンドの使い方と覚えたい15のオプション【Linuxコマンド集】
- 投稿日:2019-12-03T00:21:02+09:00
Rails Tutorial Memo #5
自分用の備忘録です.
第10章 ユーザーの更新・表示・削除
この章で学べること
- これまで未実装だったedit,update,index,destroyアクションを加え,RESTアクションを完成させる.
- ユーザーが自分のプロフィールを自分で更新できるようにする.
- すべてのユーザーを一覧できるようにする.
- ユーザーを削除し,データベースから完全に消去する機能を追加する.
10.1 ユーザーを更新する
ユーザー情報を編集するパターンは新規ユーザーの作成と極めて似通っている.
newアクションと同じようにeditアクションを作成し,POST リクエストに応答するcreateアクションの代わりに PATCH リクエストに応答するupdateアクションを作成する.
ユーザーの登録は誰でも実行できるが,ユーザー情報を更新できるのはそのユーザー自身のみに限られる.10.1.1 編集フォーム
ユーザー編集ページの正しい URL は
/users/1/editであるため,ユーザーの id はparams[:id]で取り出すことができる.Gravatarへのリンクで
target="_blank"が使われているが,これを使うとリンク先を新しいタブ (またはウィンドウ) で開くようになるため,別のWebサイトへリンクするときなどに便利である(ただしtarget="_blank"にはセキュリティ上の小さな問題もある).<a href="http://gravatar.com/emails" target="_blank">change</a>一緒???
app/views/users/edit.html.erbのform_for(@user)のコードは,app/views/users/new.html.erbのform_for(@user)のコードと完全に同じ.Rails はどうやって POST リクエストと PATCH リクエストを区別している?
A. ユーザーが新規なのか,それともデータベースに存在する既存のユーザーであるかを,Active Record の new_record?論理値メソッドを使って区別できるから.
Rails は
@user.new_record?がtrueのときは POST を,falseのときは PATCH を使う.target="_blank"の危険性
target="_blank"で新しいページを開くときには,セキュリティ上の小さな問題がある.それは,リンク先のサイトが HTML ドキュメントの window オブジェクトを扱えてしまう,という点である.具体的には,フィッシング (Phising) サイトのような,悪意のあるコンテンツを導入させられてしまう可能性がある.
- 対処法
- リンク用のaタグのrel (relationship) 属性に、"noopener"と設定する
rel="noopener"の導入<a href="http://gravatar.com/emails" target="_blank" rel="noopener">change</a>
























































