20191203のRailsに関する記事は22件です。

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

変更方法

②ロードバランサ

  1. 負荷分散メニューの下部の青文字「詳細設定メニュー」を押下
  2. 「バックエンドサービス」タブを選択
  3. 編集ボタンを押下
  4. バックエンドサービス詳細の上部の方にある鉛筆マーク image.png
  5. これで30秒→300秒に image.png

④nginx

nginxのタイムアウト初期値は60秒。290秒に変更する。
fastcgi_read_timeout proxy_read_timeout をセットする。

    server {
        listen 10443;
        server_name xxxx.hogehoge.com;

        # タイムアウトまでの秒数を変更
        fastcgi_read_timeout 290;
        proxy_read_timeout   290;
    }

まとめ

4分ほどかかる処理を作ってしまうのが、そもそもアンチパターンですね。
ただ、その処理をリファクタリングするのに時間をかけてユーザーを待たせる可能性があるのなら、長い時間を待たせて動かしてもらうのも対策の1つになり得ると思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【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をあてがってあげましょう。

b31635ca63e9307306ce485f2179b203.png

無事にactiv時だけカレントマークつきました!

終わりに

逆の方法として、すでについているクラスを消すは簡単な方法でしたが、
読み込みの際に、ズラ〜っと表示され、
読み込み完了後に、カレント箇所だけアイコンがついている状態になる。
読み込み完了後は良いですが、途中経過が気持ち悪かったので、
どうしてもちゃんと必要な箇所にだけアイコンをつける方式で実装したかったのです。
なので小技発見できてよかった!
これが本当に良い方法かはわかりませんが。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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を更新しても、反映されない問題が発生します。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

メモ

ストロングパラメータ

指定したキーのみを受け取るので、データを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
|
|
-------footer

body header content footer をノードと言う

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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?メソッド red

app/models/user.rb
class 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.rb
class 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.rb
class 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?メソッド red

app/models/user.rb
class 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.rb
class 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.rb
class 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周目やった時にはあまり残せていなかったので、その辺ののメモも今後たくさん取っておきたいと思う。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

#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 date

Original by Github issue

https://github.com/YumaInaura/YumaInaura/issues/2804

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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.rb
class 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()

end

TrailBlazerにおけるModelの役割は、RailsのModelの役割と基本的に同じです。
Controllerから指示を受けてデータのやり取りを行うか、Operationからの指示を受けるかというほどの違いで、永続化したいデータをDBに保存したり、その値を取得するポータルを提供することがメインのお仕事です。
以下のように、associationやscopeを記載します。

app/models/dinosaur.rb
class 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(savesync)をメインに、フォーム関連の処理をModelに代わって引き受けます。
contractは、Reform::Fromクラスを継承しており、必要項目のpropertyvalidates内容の定義を行う役割を持ちます。

app/concepts/dinosaur/contract/create.rb
module 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.rb
class CommentCell < Cell::ViewModel
  def show
    render # app/concepts/comment/show.hamlをレンダリングする
  end
end

Representable

シリアライズ(デシリアライズ)用のGemです。
受け取ったオブジェクトをJSONやXML、YAMLに変換するほか、JSONからオブジェクトへの変換も可能です。
Operationの中に記述するほか、別ファイルに専用クラスを定義する方法があります。
例として、以下のようなオブジェクトをフォームから受け取るためには、

{
  "name" => "Velociraptor",
  "color"  => "Blue"
}

(方法1) Operationファイル内に記述する

app/concepts/dinosaur/operation/create.rb
class Dinosaur::Create < TrailBlazer::Operation
  ...
  representer do
    property :name
    property :color
  end
end

(方法2) 別ファイルにクラスを定義する(下記の例ではDinasourRepresenter

app/concepts/dinosaur/representer/dinasour_representer.rb
class DinasourRepresenter < Representable::Decorator
  include Representable::Hash # 受け取りたいオブジェクトのフォーマット
  include Representable::JSON # レスポンスとして返したいフォーマット

  property :name
  property :color
end

JSON形式でレスポンスを返すためには、

DinasourRepresenter.new(dinasour).to_json

おつかれさまでした!

お付き合いくださり、ありがとうございました!
今年も残り少し、よろしくおねがいします!

参考文献

TrailBlazer作者Nickさんの本
TrailBlazer Github
TrailBlazer Official
TrailBlazer Operation

以下のQiita記事を参考にさせていただきました!ありがとうございました!
@kazekyoさま
@kouheiszkさま
@yk-nakamuraさま

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

さくらVPSでCentOS7 11.RailsプロジェクトをGitで共同開発

はじめに

自由にテスト出来るLinuxのサーバーがほしくて、さくらVPSで構築してみました。
順次手順をアップしていく予定です。

前回インストールしたRuby On Railsを共同開発できるようにGitで管理したいと思います。

目次

  1. 申し込み
  2. CentOS7インストール
  3. SSH接続
  4. Apache・PHPインストール
  5. MariaDBインストール
  6. FTP接続
  7. sftp接続
  8. phpMyAdminインストール
  9. 環境のバックアップ
  10. Ruby On Railsインストール
  11. 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.com

gitの出力をカラーリング

$ 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 ruby

下記画面のようにプロンプトに戻ったら完了です。

nokogiriインストール

コマンドプロンプトで以下を実行します。

> ridk exec pacman -S mingw-w64-x86_64-libxslt

下記画面のように「インストールを行いますか? [Y/n]」と聞いてきますので「Y」を入力します。

プロンプトに戻ったらインストール終了です。
引き続き以下のコマンドを実行します。

> gem install nokogiri --platform ruby -- --use-system-libraries

下記画面のようにプロンプトに戻ったら完了です。

Node.jsインストール

下記サイトからダウンロードします。
https://rubyinstaller.org/downloads/
「LTS推奨版」「Windows Installer (.msi)」の「64-bit」をダウンロードしました。

ダウンロードしたファイルを実行すると下記の画面が表示されますので[Next]をクリックします。

下記画面が表示されたら「□I accept the terms in the License Agreement」にチェックを入れ[Next]をクリックします。

下記画面が表示されますので、そのまま[Next]をクリック。

下記画面もそのまま[Next]クリック。

下記画面が表示されたら「Automatically install the ・・・」にチェックをいれて[Next]をクリック。

下記画面の[Install]クリックで、インストールが開始されます。

インストールが終わるまでしばらく待ちます。

インストールが終わると下記画面が開きますので[Finish]をクリックすると画面が閉じます。

引き続き下記の画面が開きます。
「継続するには何かキーを押してください ...」と2回表示されますので都度[Enter]キーを押してください。

下記の画面が開き、インストールが開始されます。

下記のように「Tyoe ENTER to ext:」と表示されたら完了です。
[Enter]キーを押すと画面が閉じます。

Bundlerインストール

コマンプロンプトを開き下記のコマンドを実行します。

> gem install bundler

Gitインストール

GUIでGitが使えるTortoiseGitを使います。
画面のキャプチャは取っていませんでしたm(__)m

Git 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.scss

app/controllers/hello_controller.rbにアクションメソッドindexを追加

class HelloController < ApplicationController
  def index
    render plain: 'こんにちは、世界!'
  end
end

config/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 repository

hello_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のインストールの予定です。

前回:Ruby On Railsインストール

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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.rb
class 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.rb
class 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.rb
class 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))
  end

1文目は、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.rb
class 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
end

rememberメソッドをsession_controllerのcreateアクションに実装する。

app/controllers/sessions_controller.rb
class 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.rb
module 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
end

sessionメソッドと同様、:user_idをキーにして、user.idを代入できる。
この場合、ユーザーIDが生のテキストとしてcookieに保存される。

署名付きcookieを使うためには、cookies.signedメソッドを使用する。
cookieをブラウザに保存する前に暗号化を行う。

じゃあ、結局どうやってsession状態を復元するんだよ。

current_userメソッドを変える。

app/helpers/sessions_helper.rb
module 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.rb
class 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.rb
module 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
end

forget(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.rb
class 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.rb
require '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.rb
class 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.rb
class 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.rb
ENV['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.rb
require '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.rb
module 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

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【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_many Commentsの関係をとるとする。

article.rb
class Article < ApplicationRecord
  has_many :comments
  # has_one :comment #検証用
end
comment.rb
class 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

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ハッカソンの開催情報を自動でお知らせするBotをGithub Actionsに移行して運用費が0円になりました

新着のハッカソン・ゲームジャム・アイディアソン・開発合宿の情報を自動的にお知らせしているBotがあります。
よかったらフォローしてください!!

このBotの頭の中身についてはこちらの記事にて紹介しました。

最新のハッカソンの開催情報を自動で集めて、お知らせするBotを作ったので頭の中を紹介

また、具体的なソースコードも公開していますのでこちらを参照してください。

hackathon_portal

今回はこのHackathonPortalをこれまで、AWS Lightsail (Ubuntu)サーバーにて稼働していたものを Github Actions に完全に移行したので、その内容について紹介します。

Github Actionsの本来の使い方について

本来の Github Actions の正しい使い方はCI/CDがメインで主に以下のような開発支援として用いられます。

  • 自動的にデプロイ
  • 自動テストの実行
  • issueやプルリクのお掃除

など

今回は Github Actionsschedule機能 にのみ着目して活用しました。
また 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 Actionsschedule機能 を使えば実現できます。

実現させるにあたって苦労したこと

データベース

Github Actions ではMySQLなどのミドルウェアを使用することはできますが、 Github Actions では毎回実行されるたびにデータが空っぽになります。
そのため Github Actions のみで完結させようとした場合、これまでためたデータをどこかに保持するためておく仕組みが必要です。
外部のデータベースを活用するなど考えましたがいずれの場合も

  • 結局、月額費用がかかる
  • 無料のデータベースは容量が少ない
  • 何かと制約が多い

ということで結局これまでためたデータを全てGitの管理下に置いてGithubに保管してもらうことで対応しました。

具体的には以下のフローを Github Actions の中で行なっています

  1. MySQL稼働
  2. MySQLの中にデータを挿入する
  3. データを集める → 発信する
  4. mysqldump してデータを全て SQLファイル で出力する
  5. 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 オプションを使用する場合は gsplitbrew install gsplit にてインストールして gsplit コマンドにすることで使用することができます。

実行されるブランチをmaster以外に切り替える

基本的に Github Actionsschedule機能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-data

Github 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 も超えていないのでまだ大丈夫)

最後に

ハッカソン・ゲームジャム・アイディアソン・開発合宿への参加に興味がある皆さんは是非フォローしてください!!
よろしくお願いします。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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.1

NOTICE

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.rb
class AjaxTestController < ApplicationController
  def top
    # NOP.
  end

  def update
    # TBD.
  end
end

top : Topページ表示用,特に処理はありません.
update : Ajaxリクエストを受ける用,実装はあとで追加します.

Route Config

ControllerのActionに繋げるためのRoute定義を追加します.

config/routes.rb
get  '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
にアクセスすると,↓ のようなページが表示されると思います.
default.png

ただし,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.rb
class 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
end

Controllerに渡ってきた Parameters の中に格納されているUserが入力したTextを使ってPartialをrenderしています.
Partialのファイル名 _ajax_partial.html.erbajax_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.js
import * 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_update Tagに 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.js
const { environment } = require('@rails/webpacker')

// 追加
const webpack = require('webpack')
environment.plugins.prepend('Provide',
  new webpack.ProvidePlugin({
    $: 'jquery/src/jquery',
    jQuery: 'jquery/src/jquery'
  })
)

module.exports = environment

Ajax動作確認

これで必要な変更はすべてです.
http://localhost:3000/ajax_test/top
にアクセスして,Text Fieldになにか文字列を入力してPost AJAXボタンを押したら,DEFAULT の文字列が更新されて ↓ のような画面になっていれば成功です.

ajax.png

ChromeのDeveloper ToolなどでDOM構造を見てみると,↓ のようになっていて,<div id="updated_by_ajax" >の中身が差し替わっているのが見えます.

dev_tool.png

おわり

Rails 6 でAjaxを使ったシーケンスを一本通すまでをできる限りDefaultのままで実現してみました.
環境依存でたまたまうまく動いている部分などあるかもしれません.もし何かおかしな点がありましたら教えていただけると助かります.

---///

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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.scss

AccountActivationsリソースに関係するルーティング

AccountActivationsリソースのユースケースは、「ユーザーがリンクをクリックすることにより、アプリケーションでアカウントの有効化プロセスが開始される」というものです。当然ながら、「アカウントの有効化プロセスを開始するためのリンク」が必要になってきます。さらに、「RESTfulなリソースとしてモデル化する」という前提があります。というわけで、「アカウントの有効化プロセスを開始するためのリンク」は、「AccountActivationsリソースのeditアクションに紐付けされたリンク」ということになります。

edit_account_activation_url(activation_token, ...)

AccountActivationsリソースのユースケースは、「ユーザーがリンクをクリックすることにより、アプリケーションでアカウントの有効化プロセスが開始される」というものに限られます。(editアクションではなく)リソースそのものへのGETや、(Usersリソースとセットでない)単独でのPOSTDELETEUPDATE、以上のリクエストが送られてくることは想定されません。ゆえに、必要となるルーティングはeditのみに限られます。

resources :account_activations, only: [:edit]

結果、Railsのルーティング(config/routes.rb)は、以下のように変更する必要があります。

config/routes.rb
  Rails.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_full.png

新たな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.rb
  class 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#remember
def 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メソッドの使い方は、remembercreate_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.rb
  class 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.rb
  User.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)
  end

fixtureのユーザーを最初から有効にしておく

test/fixtures/users.yml
  rhakurei:
    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.3downcase_emailメソッドを改良してみてください。

リスト 6.34で、メールアドレスの小文字化にはemail.downcase!という (代入せずに済む) メソッドがあることを知りました。

User#downcase_email
  def downcase_email
-   self.email = email.downcase
+   self.email.downcase!   
  end

3.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
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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.js
require("@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.rb
  def 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.jbuilder
json.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
end

createとupdate

新規作成と更新のアクションです。成功したときは、jsonでリダイレクト先のパスを返します。失敗したときは、ステータスコード422とメッセージを返します。

ここで使っているEntries::Formは、バリデーションとデータの保存を行うものです。そのうち別記事で紹介します。

app/controllers/entries_controller.rb
  def 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.rb
  def 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.jbuilder
json.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_count

Vue.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)については、まだ迷い中。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

削除がうまくいかずエラーが出る。。。

ツイートを削除しようとしたら、こんなエラーが出た。

ActiveRecord::StatementInvalid in TweetsController#destroy

Mysql2::Error: Cannot delete or update a parent row: a foreign key constraint fails

ダウンロード (6).png

僕はただツイートを削除したいだけなのに。。。


なぜエラーがでたのか

【結論】

tweetsテーブルcommentsテーブルで外部キー制約を結んでいたため。



【commentsテーブル】
21b53daebb42568420a0e4d42c965cf9.png

外部キーとして、tweet_idがあるのがわかります。

コメントは必ず何かしらのツイートに結びつきます。
勝手にツイートを削除すると、迷子のコメントが現れます!
これがエラーの正体です。


解決策

dependent: :destroyを追記。
ツイートを削除すると同時に、そのツイートに結びついたコメントも削除します。

app/models/tweet.rb
class Tweet < ApplicationRecord
  has_many :comments, dependent: :destroy
end



参考

https://qiita.com/kemako/items/e98174b9747e0b2c75a6



ではまた!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

削除ボタンを押したら、確認ダイアログを出そう。

data: { confirm: "削除しますか?" }を追記

xxx.html.haml
= link_to "削除", path, method: :delete, data: { confirm: "削除しますか?" }

最後に加えればOK!
めっちゃ簡単に確認ダイアログが作成できた!嬉しい!!



ではまた!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

redirect_backで直前のページにバック!

現在ツイート詳細ページ(show.html.haml)でコメントができます。
alt

ツイートにコメントをしたらコメントする前の状態に戻るようにします。
redirect_back(fallback_location: アクション名)で直前のページに戻すことができます。

app/controllers/comments_controller.rb
def 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です。

以上!



参考

https://railsguides.jp/layouts_and_rendering.html



ではまた!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

rails-tutorial第8章

ログイン機能を作ろう!!

モデルを使わないSessionリソースを扱う。

Sessionsコントローラを作ろう

$ rails generate controller Sessions new

ルーティング設定

config/routes.rb
Rails.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.rb
class 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
end

find_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.rb
class 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.rb
require '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.rb
module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end
end

このhelperメソッドを使ってログイン機能を実装

app/controllers/sessions_controller.rb
class 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.rb
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  include SessionsHelper
end

そのためには、全コントローラの親クラスであるapplication コントローラに

createアクションを実装したら

その次は、session[:user_id]に値が入っていれば、〜〜〜〜、入っていなければ、〜〜〜〜というようにしていく。

また、今どのユーザーがログインしているかがわからないと、showアクションでどのユーザーページを表示するべきかなどの問題が発生してしまうので、なんとかして、sessionの情報から現在ログインしているユーザーを参照する必要がある。

では、現在ログインしているユーザーを返すメソッドをhelperに定義しよう。

app/helpers/sessions_helper.rb
module 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.rb
module 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
end

logged_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.yml
michael:
  name: Michael Example
  email: michael@example.com
  password_digest: <%= User.digest('password') %>

でもこれじゃあdigestメソッドがないから、ハッシュ化できない。

digestメソッドはUserに関連するときしか使わないので、userモデルに定義する。

app/models/user.rb
  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
end

三項演算子について

condition ? expr1 : expr2

condition
trueかfalseかを評価する式です。
expr1, expr2
各々の値の場合に実行する式です。 conditionがtrueの場合、演算子はexpr1の値を返します。そうでない場合はexpr2の値を返します。

ユーザーログインテストコード

test/integration/users_login_test.rb
require '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.rb
class 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
end

log_in @userでセッション変数に@user.idを保存する。
ちなみに、log_in @userが使えるのはsessions_helperがapplication controllerにincludeされてるから。

ログインのテスト

テストにもテストのhelperが存在する。
そこに、ログインしているかどうかを判断するメソッドを定義しておこう。

test/test_helper.rb
ENV['RAILS_ENV'] ||= 'test'
.
.
.
class ActiveSupport::TestCase
  fixtures :all

  # テストユーザーがログイン中の場合にtrueを返す
  def is_logged_in?
    !session[:user_id].nil?
  end
end

ユーザー登録後ログイン状態になっているかテスト。

test/integration/users_signup_test.rb
require '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.rb
module 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.rb
class 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.rb
require '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
end

assert_not is_logged_in?
is_logged_in?はtest_helperに定義したから使える。

なぜsession変数はなくならない?なくなる?

調べろ。ブラウザとrails sに保存されるから?

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ログイン中ユーザーの投稿内容を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.id
homes/index.html.erb
<%= @user %>
#=> 1

こっちも問題がない。
なんで出力されないのか不思議で仕方がなかったんですが、驚いた事にこんな書き方だけで解決しました。

./controllers/homes_controller
@places = Place.all
places = @places
@myplaces = current_user.places
homes/index.html.erb
<% @myplaces.each do |myplace| %>
  #省略
<% end %>

目ン玉飛び出そうになりました。こんな簡単な記述でよかったんかと。。。普通にidを指定すれば出来るやろって余裕こいてたら痛い目見ました。

原因は今度時間を見つけて探してみます。今度作る時にはハマらないように…

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【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 support

3. ディレクトリやファイルの権限を 「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コマンド集】

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【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 support

3. ディレクトリやファイルの権限を 「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コマンド集】

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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.erbform_for(@user)のコードは,app/views/users/new.html.erbform_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>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む