20191218のRailsに関する記事は24件です。

Railsで特定のURLからのリクエストを許可してiframeを表示させたい、、、

やりたいこと

Railsでiframeからのリクエストを許可して外部のサイトで表示させたい、、、
でもすべて許可するとなにかと怖いので特定のURLに絞りたい、、、

ということで

Railsでは

response.headers['X-Frame-Options'] = 'SAMEORIGIN'

が設定されていて自身のドメインしかiframeで表示出来ないそう

今回は特定のURLのみ許可したいので以下のように実装

class HogeController < ApplicationController
    after_action :allow_iframe, only: [:hoge]

    def hoge
        render "hoges/index", layout: false
    end

    private
    def allow_iframe
        url = "https://hoge.com"
        response.headers['X-Frame-Options'] = "ALLOW-FROM #{url}"
        response.headers['Content-Security-Policy'] = "frame-ancestors #{url}"
    end
end

注意点

なんと皆がよく使ってるであろうChrome、Safariなど多くのブラウザが
X-Frame-Options ALLOW-FROM
に対応していません1

なので合わせて
Content-Security-Policy frame-ancestors
も記述してやりましょう2

調べてみた感じIE以外対応してたので安心です3

効いてるか確認

Chrome DevToolsのNetwork パネルで確認できます
C__でなにができるの?おススメ案件!___ITフリーランスエンジニアのためのコラム【at-engineer】.png


  1. X-Frame-Options ブラウザ対応表 

  2. X-Frame-Options ALLOW-FROMだけ設定してしまうと未対応ブラウザですべてのURLを許可する状態になってしまいます 

  3. Content-Security-Policyブラウザ対応表 

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

新規アプリケーションをGithubで管理する(Rails)

はじめに

Railsを使ってプログラミングの学習をしているのですが、新規アプリケーションを作成する際、Githubで管理する方法を忘れてしまうので、定着させる意味でも残したいと思います。

環境

OS: Mac
Rails 5.0.7.2
Ruby 2.5.1
MySQL
Github Desktop

手順

1,新規アプリケーションを作成

ターミナル
$ cd myapp #作成したいディレクトリへ移動
$ rails _5.0.7.2_ new my-blog -d mysql #Railsのバージョンを指定、DBはMySQLを指定する。
$ cd my-blog #作成したアプリケーションのディレクトリへ移動
$ rails db:create #データベース作成

2,Github Desktopからローカルリポジトリにする

スクリーンショット 2019-12-18 1.24.07.png
Github Desktop左上のCurrent Repositoryをクリック→AddAdd Existing Repositoryを選択。

スクリーンショット 2019-12-18 1.29.51.png
選択画面が出てくるので、作成したアプリを選択。

Add Repositoryできない場合
ターミナルにて、作成したアプリのリポジトリ→git initのコマンドを打てば解消されます。

スクリーンショット 2019-12-18 1.32.55.png
左下から最初のコミットをする。スクリーンショット 2019-12-18 1.34.44.png
Publish repositoryをクリック。
スクリーンショット 2019-12-18 1.35.12.png
公開してもいいならKeep this code privateのチェックを外し、Publish Repositoryをクリック。

3,Githubのリモートリポジトリが作成される

スクリーンショット 2019-12-18 1.40.05.png
GithubのYour Repositoriesに作成したアプリが登録されていれば完了です。

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

オブジェクト指向設計を実践するためのまとめ

Zeals Advent Calendar 2019 の19日目の記事です。

サーバサイドエンジニアの小寺です。私がまだ初学者に近かった頃、「もっと早く知っておけばよかった!」と心の底から思ったオブジェクト指向設計についての知識を書き残しておこうと思います。

今回は、実務で求められる「保守性の高い読みやすいコード」を実践するために
オブジェクト指向設計実践ガイドを学習し、理解できたところを自分なりに要点をまとめ・メモし、備忘録としたものです。
(※4章までの内容が理解できれば十分実践していけると思いますので4章までしかまとめてないです)

参考書籍

オブジェクト指向設計実践ガイド
本書のサンプルコードを引用しています。

対象

  • オブジェクト指向についてふんわり理解している人
  • 責務がどうのこうのといったくだりをふんわり理解してる人
  • オブジェクト指向設計を理解してステップアップしたい初学者

2章 単一責任を意識する

  • なぜ単一責任が必要なのか
    • 再利用したい
    • 再利用できると未来の予期せぬ変更に対応できる
    • 悪いコードを増殖させたくない

単一責任の見極め方

  • 役割を聞く
# 「オブジェクト指向設計実践ガイド」 p41
class Gear
  attr_reader :chainring, :cog, :rim, :tire
  def initialize(chainring, cog, rim, tire)
    @chainring = chainring
    @cog = cog
    @rim = rim
    @tire = tire
  end
  :
  def gear_inches
    ratio * (rim + (tire * 2))
  end
end
gear = Gear.new(52, 11, 26, 1.5)
gear.ratio
# ギアに対してギア比を聞くのは良い
gear.gear_inches
# ギアに対してgear_inchesを聞くのはしっくりこないような気がする
gear.tire
# ギアに対してタイヤのサイズを聞くのはもっとおかしい

インスタンス変数の隠蔽

# 「オブジェクト指向設計実践ガイド」 p46
class Gear
  def initialize(chainring, cog)
    @chainring = chainring
    @cog = cog
  end

  def ratio
    @chainring / @cog.to_f  # <-- 破滅への道
  end
end

アクセサメソッド(attr_reader)で隠す

# 「オブジェクト指向設計実践ガイド」 p46
class Gear
  attr_reader :chainring, :cog # <--
  def initialize
  :
  def ratio
    chainring / cog.to_f  # <--
  end
end

実装的には以下と同じ

def cog
  @cog
end

実際に得ている恩恵としては未来に複雑な実装が来た時に変更が簡単

def cog
  if 条件
    @cog * 何かの計算
  else
    (@cog * 何かの計算) + 複雑な計算
  end
end

データ構造の隠蔽

# 「オブジェクト指向設計実践ガイド」 p48
class ObscuringReferences
  attr_reader :data
  def initialize(data)
    @data = data
  end

  def diameters
    # 0はリム, 1はタイヤ  <-- 何のデータがどこにあるか知っている状態
    # 配列の構造が変わった時などに怖い
    data.collect {|cell|
      cell[0] + (cell[1] * 2)
    }
  end
end

# 以下のデータが必要になる
@data = [[622, 20],[622, 23],...]

Structクラスを使って構造を包み隠す

# 「オブジェクト指向設計実践ガイド」 p50
class ObscuringReferences
  attr_reader :wheels
  def initialize(data)
    @wheels = wheelify(data)
  end

  def diameters
    # wheelが rim と tire を持ってることだけ知っていればいい
    wheels.collect {|wheel|
      wheel.rim + (wheel.tire * 2)
    }
  end
  # これで誰でも wheel に rim/tire を送れる

  Wheel = Struct.new(:rim, :tire)
  def wheelify(data)
    data.collect {|cell|
      cell[0] + (cell[1] * 2)
    }
  end
end

メソッドから余計な責任を抽出する

メソッドもクラスと同じく単一の責任を持つべき(再利用が簡単になるので)
先ほどの diameters を見てみる

# 「オブジェクト指向設計実践ガイド」 p52
class ObscuringReferences
  :
  def diameters
    # wheelsを繰り返し処理するのと、それぞれの直径を計算する
    # といった2つの責任を持っている
    wheels.collect {|wheel|
      wheel.rim + (wheel.tire * 2)
    }
  end
  :
end

上記のメソッドを簡単に変更できるようにしていく

# 「オブジェクト指向設計実践ガイド」 p52
class ObscuringReferences
  :
  def diameters
    # 直径を計算する処理を別にした
    wheels.collect {|wheel| diamete(wheel) }
  end

  # diameter を呼べるようになった(他で再利用可能になった)
  def diameter(wheel)
    wheel.rim + (wheel.tire * 2)
  end
  :
end

上記はよくある責任が複数あるわかりやすい例。
大抵はこれほど明確ではない。先ほどのGearクラスを思い出してみる。

# 「オブジェクト指向設計実践ガイド」 p53
class Gear
  :
  # 何か不確定でのちにトラブルを起こしそうな気がする
  # このメソッドが2つ以上の責任を持ってしまっている
  def gear_inches
    ratio * rim + (tire * 2)
  end
  :
end

gear_inches に隠れている直径の計算を抽出してみる

# 「オブジェクト指向設計実践ガイド」 p53
class Gear
  :
  # Gearがgear_inchesの計算するのはよし
  def gear_inches
    ratio * diameter
  end

  # しかし、車輪の直径の計算までするのはおかしい
  def diameter
    rim + (tire * 2)
  end
  :
end

といったように、小さなリファクタリングではあるが Gear が持つべきでない責任が明らかになりました。

このように、あらゆるものを単一責任にしていくことで

  • 隠蔽された性質を明らかにする
    • 上記の例だと 「Gear は diameterを持つべきではない」
  • コメントをする必要がない
    • メソッドがコメントの役割を果たす
  • 再利用を促進する
    • 他のプログラマーは複製ではなく再利用をするようになる
  • 他のクラスへの移動が簡単
    • 小さなメソッドは簡単に動かすことができる

といった恩恵が受けれます

クラス内の余計な責任を隔離する

一旦全てを単一責任するとクラスのスコープが明白になってきます。
GearクラスにはいくつかのWheel(車輪)のような振る舞いが隠れていました。
ここで、新しくクラスを作ってもいいのですがそうなると変更のコストが大きいかもしれないのでコストを最小限にしつつGearの単一責任を保つようにします。

# 「オブジェクト指向設計実践ガイド」 p55
class Gear
  attr_reader :chainring, :cog, :wheel
  def initialize(chainring, cog, rim, tire)
    @chainring = chainring
    @cog = cog
    @wheel = Wheel.new(rim, tire)
  end

  def ratio
    chainring / cog.to_f
  end

  def gear_inches
    ratio * wheel.diameter
  end

  # 隠れていた Wheel のような振る舞いを手に入れた
  # 書籍ではこの後追加要望があったタイミングで新しいクラスを定義する
  # 「あとで」決定できる力を取っておくことはアプリケーション開発において重要
  Wheel = Struct.new(:rim, :tire) do
    def diameter
      rim + (tire * 2)
    end
  end
end

本書では、新しくアプリケーションに車輪に関する追加機能の要望が来たタイミングで Wheel クラスを新しく定義するよう書かれています。
アプリケーション開発において未来を予測することは困難なのでできる限り「あとで」決定できる力を残しておくのは非常に重要なことである

3章 依存関係を管理する

依存関係を認識する

オブジェクトが次のものを知っている時、オブジェクト間には依存がある

  • 他のクラスの名前を知っている
    • GearはWheelというクラスが存在すると予想している
  • self以外のメッセージの名前
    • GearはWheelがdiameterに応答できると知っている
  • メッセージが要求する引数
    • Gearは Wheel.newするときに rim とtireが必要なことを知っている
  • 引数の順番
    • 最初が rim で 2番目が tire だと知っている
# 「オブジェクト指向設計実践ガイド」 p60
class Gear
  attr_reader :chainring, :cog, :rim, :tire
  def initialize(chainring, cog, rim, tire)
    @chainring = chainring
    @cog = cog
    # Wheel.newするときに rim とtireが必要なことを知っている
    @rim = rim
    @tire = tire
  end

  def ratio
    chainring / cog.to_f
  end

  def gear_inches
    # Wheelというクラスが存在すると予想している
    # Wheelがdiameterに応答できると知っている
    # 最初が rim で 2番目が tire だと知っている
    ratio * Wheel.new(rim, tire).diameter
  end
  :
end

class Wheel
  attr_reader :rim, :tire
  def initialize(rim, tire)
    @rim = rim
    @tire = tire
  end

  def diameter
    rim + (tire * 2)
  end
  :
end

依存が発生している時、依存が強固になりすぎると2つのオブジェクトは変更することが難しくなってくるので2つのオブジェクトがあたかも1つのオブジェクトとして振る舞うようになってしまう。

疎結合なコードを書く

依存を減らすための技法を紹介する

依存オブジェクトの注入

クラス内でインスタンスを生成するのではなく diameterを知っているオブジェクトを受け取るようにしておく

# 「オブジェクト指向設計実践ガイド」 p66
class Gear
  attr_reader :chainring, :cog, :wheel
  def initialize(chainring, cog, wheel)
    @chainring = chainring
    @cog = cog
    @wheel = wheel
  end

  def ratio
    chainring / cog.to_f
  end

  def gear_inches
    ratio * wheel.diameter
  end
  :
end

# Gearはdiameterを知っているオブジェクトを要求する
Gear.new(52, 11, Wheel.new(26, 1.5)).gear_inches

依存を隔離する

  • インスタンス変数の作成の分離
    • 制約が厳しく、オブジェクトを注入できないような場合
    • クラス内で分離するしか方法がないとき
# 「オブジェクト指向設計実践ガイド」 p68
class Gear
  attr_reader :chainring, :cog, :rim, :tire
  def initialize(chainring, cog, rim, tire)
    @chainring = chainring
    @cog = cog
    # Gearはまだ知りすぎているが、gear_inchesの依存が減っている
    @rim = rim
    @tire = tire
  end
  :
  def gear_inches
    ratio * wheel.diameter
  end

  # wheel が呼ばれるまでインスタンスは作成されない
  def wheel
    @wheel || Wheel.new(rim, tire)
  end
end
  • 脆い外部メッセージを隔離する
    • 外部への参照がクラスに埋め込まれていて、どれが変更されやすい時
# 「オブジェクト指向設計実践ガイド」 p70
def gear_inches
  # 恐ろしい計算
  foo = some_intermedicate_result * wheel.diameter
  # 恐ろしい計算
end

上記のように複雑な状況になった時にメソッドの中に依存が隠れてしまう。

そうした場合以下のように脆い部分を隔離する

# 「オブジェクト指向設計実践ガイド」 p71
def gear_inches
  # 恐ろしい計算
  foo = some_intermedicate_result * diameter
  # 恐ろしい計算
end

def diameter
  # もし、Wheelがdiameterに変更加えた場合副作用はここだけになる
  wheel.diameter
end

引数の順番への依存を取り除く

  • 初期化の際にハッシュ使う
# 「オブジェクト指向設計実践ガイド」 p73
class Gear
  attr_reader :chainring, :cog, :wheel
  def initialize(args)
    @chainring = args[:chainring]
    @cog       = args[:cog]
    @wheel     = args[:wheel]
  end
  :
end

# 冗長にはなったが、引数の順番を知っていなくても大丈夫になった
# ハッシュのkey名に依存はしているが、順番よりは健康的
Gear.new(
  chanring: 52,
  cog:      11,
  wheel:    Wheel.new(26, 1.5)
).gear_inches

安定性の高い引数をいくつか受け取って、安定性の低い引数はオプション引数で受け取るといった手法が多く用いられる

  • 明示的にデフォルト値を設定する
# 「オブジェクト指向設計実践ガイド」 p74
def initialize(args)
  # 真偽値以外の場合はシンプルに || を使える
  @chainring = args[:chainring] || 40
  @cog       = args[:cog] || 18
  @wheel     = args[:wheel]
end

# 真偽値を使う or nilを設定する場合
def initialize(args)
  @chainring = args.fetch(:chainring, 40)
  @cog       = args.fetch(:cog, 40)
  @wheel     = args[:wheel]
end
  • 複数のパラメーターを用いた初期化を隔離する
    • もし、引数を簡単に変更できないような状況下にあった場合ラッパークラスを定義して包み隠すようにする
# 「オブジェクト指向設計実践ガイド」 p77

# Gearがもし外部インターフェースの一部の場合(このクラスを変更できない場合)
module SomeFramework
  class Gear
    attr_reader :chainring, :cog, :wheel
    def initialize(args)
      @chainring = args[:chainring]
      @cog       = args[:cog]
      @wheel     = args[:wheel]
    end
    :
  end
end

# 外部のインタフェースをラップし、自信を変更から守っている
# 他のオブジェクトを生成することが目的のオブジェクトはファクトリーと呼ぶ
module GearWrapper
  def self.gear(args)
    SomeFramework::Gear.new(
      args[:chainring],
      args[:cog],
      args[:wheel]
    )
  end
end

GearWrapper.new(
  chanring: 52,
  cog:      11,
  wheel:    Wheel.new(26, 1.5)
).gear_inches

このテクニックは自分で変更がきかない外部のインターフェースに依存する場合に適している。

依存方向の管理

「自分より変更が少ないものに依存すべし」

4章 柔軟なインターフェースを作る

パブリックインターフェースとは

クラスのパブリックインターフェースを作り上げるメソッドは以下のような特性を備えている

  • クラスの主要な責任を明らかにする
  • 外部から実行されることが想定される
  • 気まぐれに変更されない
  • 他者がそこに依存しても安全
  • テストで完全に文書化されている

パブリックインターフェースを見つける

「アプリケーション例: 自転車旅行会社」
ユースケースとして
「参加者は適切な難易度の、特定の日付の、自転車を借りられる旅行の一覧をみたい」

見当をつけるためにシーケンス図を使う

オブジェクト間でやり取りされるメッセージを気軽に実験することができる

SS 404.png

ここで起きる疑問
「Tripが利用可能な自転車まで調べなくてもいいんじゃないか?」

シーケンス図を描くことによって
「このオブジェクトが〇〇という責任を負うべきなのだろうか?」と疑問が湧くようになる

ある旅行に対して自転車が利用可能かどうかTripクラスが見つけ出すべきでない場合、
Bycycleクラスがありそう。

SS 405.png

  • Tripはsuiable_tripsに責任があり
  • Bycycleはsuitable_bycycle に責任を持つ

変更後は Tripから余計な責任は取り除けたものの Customer に移しただけ
変更後は Customer が何を望むのかと他のオブジェクトがどのようにそれを準備するのかまで知ってしまっている

「どのように」を伝えるのではなく「何を」を頼む

新たなユースケース
「旅行が開始されるためには使われる自転車が全て整備されていることを確実にする」

SS 406.png

TripはMechanicがどのように整備するのか(
clean_bicycleして
pump_tiresして
lube_chainして
check_brakesするという手順
)を知ってしまっている。

Mechanicが新たな整備手順を増やした時は、Tripも変更しなければならない

以下は対案

SS 407.png

Tripにあった責任をほとんどMechanicに渡している
自転車の準備することに関して「どのように」はMechanicの責任になった
TripはMechanicにどんな改善があろうともprepare_bicycleから正しい振る舞いを得ることができる

上記のようにMechanic と Trip の会話が 「どのように」から「何を」に変わった副作用として
Mechanicのパブリックインターフェースのサイズが小さくなった

パブリックインターフェースが小さいということは他のところから依存されるメソッドがわずかしかないことを意味している

コンテキストの独立を模索

旅行の準備には「いつでも」自転車の準備が求められるためTripは「常に」prepare_bicycleメッセージを自身のMechanicへ送らなければならない

SS 408.png

Tripが旅行が準備されることをMechanicに伝え、Mechanicは準備にbicyclesが必要なのでTripにコールバックをし、自転車の整備を行います。

こうして、整備士がどのように自転車を準備するかはMechanicクラスに隔離されました。

オブジェクトを見つけるためにメッセージを使う

SS 405.png

Tripが知りすぎていたのを改善したが、今度はCustomerが知りすぎている。
上記のアプリケーションは要件を満たす新たなオブジェクトを必要としていることがなんとなく見えてきた。

SS 409.png

新たなTripFinderは安定したパブリックインターフェースを提供し、複雑な内部に関しては隠している

シーケンス図便利ー

まとめ

単一責任を意識しながらコードを書けるようになりましょう

  • そのために常にメソッドの役割をメソッド自身に問い続ける
  • 抽出できるものはできる限り抽出しておくと未来に変更が簡単になる

クラスの中で依存関係があることを認識する

  • 状況に応じて疎結合にするための技法を使い分ける

オブジェクト間で交わされるメッセージを中心にアプリケーションを設計する

  • 議論を進める時にはシーケンス図が有効
  • どのようにではなくオブジェクトが「何を」要求するかに注目する

おわり

  • 書籍の中に重要な説明がたくさんあるので本読みましょう(オブジェクト指向設計実践ガイド
    • 今回はあくまでコードベースで要点まとめただけなので
  • 柔軟に進化し続けましょう

明日は20日目の @neuneu39 の番です。お楽しみに!!!

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

railsでSQLログをapplicationログとは別に出力する

railsでSQLログをapplicationログとは別に出力する

applicationログはinfoで出したいけど、別途SQLログも出したい時など

ruby
  ActiveRecord::Base.logger = Logger.new('log/sql.log')
  ActiveRecord::Base.logger.formatter = proc do |severity, timestamp, progname, msg|
    {
      timestamp: timestamp.iso8601,
      LOG_LEVEL: severity,
      message: msg
    }.to_json << "\n"
  end

動作環境

rails 6.0.2
ruby 2.6.3

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

Lineのやり取りをSlackに通知する

この記事は GLOBIS Advent Calendar 2019 - Qiita の18日目です。

こんにちは、グロービスでエンジニアをしている @shifumin です。
皆さん、Lineを使っていますか? 僕は通知オフで使っています。理由は通知で集中力が切れるのが嫌だからです。
しかし、通知オフ設定だと送られたメッセージに長時間気づかずに怒られイベントがたまに発生しますよね? 僕はたびたび発生しています。
皆さん、Slackを使っていますか?使っていますよね。凝視していますよね。
Slackなら常に見ているから見逃さないんだけどなあ。

というわけで、多少矛盾を孕んでいますが、通知オフにしたLineで送られたメッセージをSlackに通知させるためのbotを作っていきたいと思います!

概要を3行で

  • 通知させたいLineグループにLine botをjoinさせてLine Messaging APIでメッセージを取得する
  • 上記で取得したメッセージをSlack botとしてSlackに通知する
  • botはRailsに乗せてHerokuで動かす

はい、やっていきましょう。

Line Messaging APIを利用する準備

Lineの送られてきたメッセージを取得するのに LINE Messaging API を利用します。

LINE Developersコンソールでチャンネルの作成とbotの設定を行います。
設定方法は公式のドキュメントが詳しいので、 Messaging APIを利用するにはボットを作成する を参考にするのが楽です。

botの作成まで終えた後に各種設定を変更します。

  • グループ・複数人チャットへの参加を許可する
    • オン
  • あいさつメッセージ(Greeting messages)
    • オフ
  • 応答メッセージ(Auto-reply messages)
    • オフ
  • Webhook
    • オン

それぞれの設定は下記画像左の Edit からいけます。
Image from Gyazo

Image from Gyazo
Image from Gyazo

Webhook設定はURLももちろん設定する必要があるのですが、これは後でHerokuでappを作成後に設定します。
設定後は下記の2つの値が必要になるため、メモります。
発行されていなければ、それぞれ Issue , Reissue から発行できると思います。

  • Channel secret (Basic settingsの中)
  • Channel access token (Messaging APi settingsの中) です。

Image from Gyazo
Image from Gyazo

botとフレンドになる

MessagingAPi > QR code にQRコードが表示されているのでbotをフレンド登録します。
また、複数人でのグループチャットを通知させたい場合はグループに招待します。

Slackの設定

続いて、SlackのApp設定とbot追加を行います。
https://api.slack.com/appsCreate New App からAppを作成します
まず、Appの名前と導入するWorkspaceを入力します。

Image from Gyazo

その後Appの設定をいくつか行う必要があります。

Image from Gyazo

Botの作成

  • Botsから Add a Bot User をクリックしてbotを作成する

Permissionsの設定

  • PermissionsをクリックしOAuth & Permissions画面へ移動する
  • OAuth Tokens & Redirect URLs > Install App to Workspace をクリックしてAppをインストールする
  • Scopesで下記のScopeを追加する
    • chat:write:bot
    • chat:write:user
    • files:write:user

Image from Gyazo

また、下記の値が必要になるため、メモります。

  • OAuth Access Token (OAuth & Permissions画面)

Image from Gyazo

Railsアプリ作成

ここからようやくBotの実装に進めていきます。

rails new

rails newまではよしなにやってください。
とりあえず最速でrails new。

# Railsをインストールまで
bundle init
vim Gemfile # Gemfileの `# gem "rails"` 行のコメントアウトを外す
bundle install

rails new は今回は画面描画系がいらないのでAPIモードかつ必要ないものを全てスキップしています。
参考: 小さく薄くrails newする(ViewやJSが必要ない場合) - Qiita

bundle exec rails new . --skip-yarn --skip-git --skip-action-mailer --skip-action-mailbox --skip-action-text --skip-active-record --skip-active-storage --skip-action-cable --skip-sprockets --skip-javascript --skip-turbolinks --skip-test --api --skip-bundle

SDKのインストール

Line、Slackそれぞれ公式が出しているSDKのgemを使います。
line-bot-apislack-ruby-client です。

この2つをGemfileに追加して bundle install します。

# Gemfile
# add
gem 'line-bot-api'
gem 'slack-ruby-client'
bundle install

コード実装

各SDKの設定

Slackは configure で設定できたので config/initializers/ 以下に設定を用意し、 Line::bot::Client はできなかったので仕方なくApplicationControllerでクライアントを作成する際に設定するようにしました。

# config/initializers/slack_ruby_client.rb
require 'slack'

Slack.configure do |config|
  config.token = ENV['SLACK_API_TOKEN']
end

Slack::Web::Client.configure do |config|
  config.user_agent = 'Slack Ruby Client/1.0'
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  private

  def line_client
    @line_client ||= Line::Bot::Client.new do |config|
      config.channel_secret = ENV["LINE_CHANNEL_SECRET"]
      config.channel_token = ENV["LINE_CHANNEL_TOKEN"]
    end
  end

  def slack_client
    @slack_client ||= Slack::Web::Client.new
  end
end

routes

Webhookはpostでリクエストが来るので、 /callback で受けて WebhookController#callbackに渡すようにしました。

# config/routes.rb 
Rails.application.routes.draw do
  post '/callback' => 'webhook#callback'
end

Blocked Hostの設定

Rails6から導入されたBlocked hostの設定は良しなにやってください。
今回はHerokuでdevelopmentで動かすかつ何も考えずに全開放ということで
下記のようにしています。
Rails6でrails newするのは今回が初めてでWebhookが届かないなあと記事を書くにあたりここでめっちゃ嵌った。嵌りポイント1

# config/environments/development.rb
Rails.application.configure do
  # 略
  config.hosts.clear
end

controller

line-bot-apislack-ruby-client のREADMEとサンプル実装を見ながらbotの本実装である WebhookController#callback を書いていきます。 今回は

  • メッセージが送られた場合はそのメッセージをそのままSlackに送る
  • 写真が送られてきた場合はその写真を保存して(Herokuなのでそのうち消えるけど)、Slackにアップロードする
  • ビデオ、スタンプの場合はそれぞれが送られてきた旨のメッセージを送る

ようにしました。

# app/controllers/webhook_controller.rb
class WebhookController < ApplicationController
  CHANNEL = '#{botに通知させるチャンネル名: 例: #hoge}'

  def callback
    body = request.body.read

    signature = request.env['HTTP_X_LINE_SIGNATURE']
    unless line_client.validate_signature(body, signature)
      error 400, 'Bad Request'
    end

    events = line_client.parse_events_from(body)
    events.each do |event|
      case event
      when Line::Bot::Event::Message
        case event.type
        when Line::Bot::Event::MessageType::Text
          post_message(event.message['text'])
        when Line::Bot::Event::MessageType::Image
          image_response = line_client.get_message_content(event.message['id'])
          file = File.open("/tmp/#{Time.current.strftime('%Y%m%d%H%M%S')}.jpg", 'w+b')
          file.write(image_response.body)

          slack_client.files_upload(channels: CHANNEL,
                                    file: Faraday::UploadIO.new(file.path, 'image/jpeg'),
                                    as_user: true,
                                    title: File.basename(file.path),
                                    filename: File.basename(file.path),
                                    initial_comment: '写真が送信されました')
        when Line::Bot::Event::MessageType::Video
          post_message('ビデオが送信されました。')
        when Line::Bot::Event::MessageType::Sticker
          post_message('スタンプが送信されました。')
        end
      end
    end

    head :ok
  end

  private

  def post_message(text)
    slack_client.chat_postMessage(channel: CHANNEL, text: text)
  end
end

Herokuのデプロイと設定

あとはHerokuでアプリを作成してデプロイして設定したら完了です。
アプリを作成してデプロイする方法はそこら辺に転がっているので割愛します。

環境変数の設定

上の方でメモっておいた下記の環境変数を設定します。

  • LINE_CHANNEL_SECRET
    • Lineの Channel secret
  • LINE_CHANNEL_TOKEN
    • Lineの Channel access token
  • SLACK_API_TOKEN
    • SlackのOAuth Access Token

Image from Gyazo

Dynoの起動

Dynoも忘れずに起動させます。
Herokuを触るのが久しぶりで起動を忘れていて、動いていないHerokuに対してWebhookを大量に空振りさせていた。嵌りポイント2

Image from Gyazo

Webhook URLの設定

最後にWebhook URLを設定したら完了です。
Settngs > Domains のURLを

Image from Gyazo

Line DevelopersコンソールのMessaging API > Webhook settingsのURLに設定します。
エンドポイントは /callback としているので忘れないようにします。

Image from Gyazo

実際の動作

グループにbotを招待してLineからメッセージを送ると下記のようにSlackに通知されます。
(左: Line, 右: Slack)

Image from Gyazo

また、Lineの通知をオフにしていても下記のような重要なメッセージを見逃すことが減ります
(なぜならSlackは見ているので)

Image from Gyazo

終わりに

今回はLineのやり取りをSlackに通知するbotを作ってみました。
公式で用意されているIntegration等でSlackとLineを直接繋ぐことはできないですが、それぞれのアプリがAPIとそのSDKを用意してくれているので、少しコードを書けばこの記事のようにそれぞれのアプリを連携させることができます。
個人開発といえばアプリ作成をまず思い浮かべるかと思いますが、APIを利用したbot開発や便利スクリプト作成も手間の割に日々の生活が便利になったりするのでオススメです!!

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

[Rails6]ActionTextの入力フォームが伸びて困る

はじめに

就活のポートフォリオサイトとしてAsobiというWebサイトを作成しました。(QiitaにAsobiに関しての記事を書いています。就活用ポートフォリオとしてWebサービス「Asobi」を作りました。)
このサイトの中でRails6から新しく導入されたActionTextを使っているのですが、入力フォームが行数に応じて伸びてしまいます。

今回はそんなActionTextの入力フォームをいい感じにするためにやったことをご紹介します。

実行環境

  • Ruby 2.6.5
  • Rails 6.0.2

ActionTextとは

Rails6から実装されたリッチテキストコンテンツと編集機能を導入する機能です。

ActionTextのインストールと導入したいモデルとカラムの用意を行い、少しコードを書くだけでブログのようなリッチテキストエディタが導入できます。

actiontext_test.gif

Ajaxを利用した画像のアップロードも実装されており、アップロードされた画像はActiveStorageを利用して保存されます。

actiontext_pic.gif

今回ActionTextの導入に関しては割愛させていただきます。
導入に関しては下記の記事が参考になるかと思います。

Rails6新機能 ActionText使用方法
Rails 6 と Action text を使ってみる - もふもふ技術部

伸びて困る

ActionTextで生成されるリッチテキストエディタの入力フォームは、行数に応じて高さが伸びるようになっています。

デフォルトの挙動

actiontext_default.gif

これをQiitaやはてなブログのような、はみ出た部分をスクロールバーで表示するように実装します。

こんな風な挙動にしたい

actiontext_custom.gif

実装

Rails側でこれを実装する方法を見つけられなかったため、CSSで実装します。

Trixについて

実装の前に、ActionTextに含まれるTrixについて説明します。
Trixとはリッチテキストエディタを実装するJavascriptのライブラリです。(ちなみに開発元はRuby on Railsと同じBasecampです)
Trixのドキュメントを見てみると、

Place an empty <trix-editor></trix-editor> tag on the page. Trix will automatically insert a separate <trix-toolbar> before the editor.
Like an HTML <textarea>, <trix-editor> accepts autofocus and placeholder attributes. Unlike a <textarea>, <trix-editor> automatically expands vertically to fit its contents.

とのことで、ページのTrixのエディタを入れたい場所にtrix-editorタグを書き込むことで使えるようになります。

ActionTextの場合、erbファイルに

<%= form.rich_text_area :body %>

と記述することでtrix-editorタグが生成されます。
そしてリッチテキストエリアへの入力はtrix-editorタグの子要素にDOMとなって反映されていきます。

actiontext_dom.gif

なので、入力フォームのスタイルは親要素であるtrix-editorタグのCSSを記述することで調整できそうです。

CSSの記述

入力フォームの高さを固定するためにmin-heightmax-heightを、そして入力フォームからはみ出した部分はoverflow-y: auto;を設定することでスクロールバーで表示できるようにします。

以下のCSSを記述します。(actiontext.scss/app/assets/app/javascriptに生成されていると思うので、そこに追記することをオススメします)

trix-editor {
  min-height: 20em;
  max-height: 20em;
  overflow-y: auto;
}

以上で目指す挙動の入力フォームになっていると思います。

まとめ

ActionTextは新しい機能のため中々情報が出てきません。もしかしたらActionTextの設定ファイルか何かでうまいことやれるかもしれないです。

何か直すべき点、間違った記述があればコメント等で指摘していただければと思います。

参考文献

Trix
https://railsguides.jp/action_text_overview.html

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

【Rails】使用するレイアウトファイルを切り替える

デフォルトでは全てのviewファイルがapplication.html.erbをレイアウトとして利用するようになっています。
コントローラーやアクションごとに、使用するレイアウトを指定する方法を調べました。

レイアウトファイル側から指定

レイアウトファイルの名前をviews/layouts/hoges.html.erbにすることで、hoges_controller内の全てのアクションに適用されます。

コントローラー側から指定

コントローラーの内上部にlayout '<layoutファイル名の冒頭>'と書き込むことで、そのコントローラー内のアクションで利用するレイアウトファイルを指定することができます。

hoges_controller.rb
class HogesController < ApplicationController
  layout 'another'

  def index
  end
.
.

(レイアウトファイル名がanother.html.erbの場合)

アクションごとに指定

アクション内にrender layout: '<'layoutファイル名の冒頭'>と書き込むことで、そのアクションで利用するレイアウトファイルを指定することができます。

hoges_controller.rb
class HogesController < ApplicationController
  layout 'another'

  def index
    render layout: 'another'
  end
.
.

(レイアウトファイル名がanother.html.erbの場合)

参考

コントローラやアクション毎に使用するレイアウトを切り替える

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

未経験が受託企業に入って半年経ったので、学んだスキル全部書いてみる

はじめに 

今年2019年6月にエンジニアとして晴れてキャリアをスタートさせました。
それから約半年間で本当にいろいろなことを学ばせて頂いたので、私ごとですが簡単に学んだことをまとめます。

もちろんマスターなんてしておらず、全てが勉強中なうえ、なんならちょっとかじっただけのものまで書いてますので悪しからず。

一概に未経験といっても、自社開発/受託企業/SES企業、大手/メガベンチャー/スタートアップ、元IT業界/全くの素人、普段からパソコン触ってた/触ってない等いろいろなキャリアの始め方があると思うので、平均的な成長度合いなのかどうかは不明ですが、ほんの一例としてご参考ください。

■簡単な経歴

・良くも悪くもない普通の大卒
 パソコンスキルはitunesで音楽入れる程度
・某アパレルチェーン企業で3年ほど勤務
 内2年管理職
 パソコンスキルはWordで毎週報告書を書く程度
・2018/12〜  Mac購入
       某オンラインスクールで勉強開始
・2019/3〜 独学開始、都内もくもく会に週1参加、Menta契約、アプリ作成等
・2019/6〜 就職
・2019/12 現在

0ヶ月〜半年で学んだこと

1)プログラミング技術

Ruby/Rails

ほぼ毎日触っていました。

基本的には既存のコードや他PJのコードをマネて書くことが多いですが、必ず意味を理解しながら次へ繋げています。
ただやはりまだまだ分からないことが多く、日々痛感しています。

ここでいろいろ書蹴たらいいのですがキリがなさそうなので、また別の記事で書きます。

HTML/CSS

Railsに合わせてHTMLはslimで書いていました。

特にCSSの方は奥深すぎてほんとナメてました。めちゃくちゃ難しいです。
新システム開発時にいかに大勢のユーザーに"初めから"良い印象を持ってもらえるかと考えた時に、画面作成でかなり手こずりました。

SQL

本番データをよく触らせて貰える案件だったので、抽出しなければならないことが多くありました。

入社前は全く勉強できてなかったのですが、基礎的なところからサブクエリの書き方、inner join left outer join concatあたりに触れ、
加えてlimitをつけずクエリ重すぎて本番が落ちる等も経験できたので、とても良い経験()になりました。

DB

上記と同じく、よく触らせて貰えました。
案件JOIN当初、railsdbから大事なレコードをそのまま物理削除してしまい、その直後5分後くらいに先方から怒りの電話がかかってきたのは良い思い出()です。

mysqlコマンドやdump、トランザクションの仕組みなど知ることができました。

Git

入社前からもちろん触っていましたが、git mergegit clone等は正直あまりよく分かっていませんでした。

現在でもまだそこまで幅広く扱えてないですが、
それでもgit add前にgit statusgit diffで変更内容をちゃんと確認する習慣付けや、コンフリクト発生時の解消方法、git fetch git stash git reset --hard HEAD^あたりを日常的に使えるようになりました。

IDE操作

cloud9で開発していました(これ言うとよく社外の方に珍しいと言われます)。

https://qiita.com/shin1kt/items/03eed49c12104002a2c7
こちらにあるような様々なショートカットを教えて頂き、いち早く見たいファイルを引っ張り出したり、直前の操作を取り消す/戻すなど、格段にスピードが速くなりました(当たり前のことかも知れませんが)。
これを通してMacのショートカットキーを覚えたりすることも多かったです。
いずれはVScodeとか使って開発したいなという思いも密かにあります。

テストの書き方

Rubyでminitestを書きました。

入社前はRSpecとともに中途半端な勉強で終わっていたので、実務を通して、調べながらであればなんとか書けるようになったかなと思います。
ただ担当PJのテスト管理が少し甘そうなので、しっかりテストを書いたら実際はどうなるのかな、と気になってはいます。

セキュリティ対策

新システム開発の中でセキュリティ対策をしました。

Railsガイド等を参考に、
・総当たり攻撃・辞書攻撃対策
・CSRF対策
・セッションハイジャック対策
・個人情報保護(クライアントやログ)
・SSL化
・エラー文表記修正(「入力されたユーザー名は登録されていません」のような具体的なメッセージにしない等)
・パスワード強化
・リダイレクトとファイル対策
あたりを一通りやったかなと思います(まだまだあると思いますが)。

今後はAWS側でのセキュリティ対策もしたいです。

Vim

いわゆるVimmerとは程遠いのですが、
本番サーバー内でdumpファイルを探したり、落ちた原因を探るためコードを直接いじってみたりして、黒い画面に抵抗がなくなる程度にはなりました。

cdで(地道に)いろんなディレクトリに寄る旅をしたりしたので、普段全く触れないようなディレクトリやファイルがまだまだたくさんあるんだなーというのも実感できました。

これで半ば強制的にLinuxの簡単な勉強にも繋がっていきました。

AWS

社内で勉強会を開催し、基礎システムの概要から掴んでみたり、PJごとにシステム構成を見ていったりしました。
ちなみに自分の案件はEC2を使わずLightsailというパッケージを使ってサーバーが構築されていたため少し特殊だったのですが、それも含め良い知見になりました。

早いうちにこちらの資格取得まで結びつけたいです。。。
AWSソリューションズアーキテクトアソシエイト

ステージング/本番環境へのデプロイ

今でもそうですが毎週のようにリリース作業を行なっているので、抵抗がないのが怖いくらいの感覚になりました(おそらく異常なんですよね、これ)。

上記で述べたようにいろんな場面で何度もやらかして先輩方に迷惑をかけまくっているので、
今では ローカルでの確認/ステージング(テスト)環境での確認・打鍵/コマンドのテンプレ化・誤字脱字チェック 等をしっかり行なう習慣が(嫌でも)つきました。

新システム作成〜システム構成〜リリース

AWSを触るということも含め、とても良い経験になりました。

DBは既存システムのものを共用したので当初「スキーマファイルはどこ??」「カラムはどうやって追加するの??」「ユーザーのパスワードが入ったかどうかってどこで確認できるの??」などパニックになったり、
まず初めにサーバーを立てようと既存サーバーのスナップショットを取ろうとしたらEC2すら使っておらず出鼻をくじかれたり、
ドメインとサブドメインの違いが分からずしばらくIPアドレスをそのまま打ち込んで画面を開いたり、
今となっては馬鹿らしいですが当時は本気で取り組んでいました。

2)顧客対応

タスク/スケジュール管理

タスク量は多くマルチタスクになりがちなので、優先順位を決めたり、話が出たら漏れのないようにすぐメモしたり、不明点はすぐ聞くようにしました。

頻繁なチャットのやりとりも含め、1つのことのなかなか集中できない難しさを痛感しています。
うまくやるコツをぜひ教えて頂きたいです。

工数見積もり

このタスクならだいたいこれくらいかかるだろうな、という予想を自分なりにするようにしていました。
それでもまだまだ多く工数を見積もってしまっているらしく、上司と答えあわせをするといつも大体半分くらいに修正されます泣

とても難しいです。

見積書/請求書の書き方、渡し方

受託はお客様に納品して初めてお金を頂くので、書類もしっかりしたものを書く必要がありました。
渡す時期やお会いした時の渡すタイミング等にも気をつけたり、PDFでもデータ送信も行なったりしました(どこまでやるかはお客様にもよると思います)。

肝心の月単位での工数計算に関しては、、、、まだまだなので、これから勉強です。

お客様を運用に乗せる気遣い/気配り

担当していた案件のお客様が、非IT/多くの部署や担当者がいる/レガシーな問題を抱えるお客様、ということも影響し、そもそも導入を嫌がっていたり、依頼したことを予定通りやってくれなかったりしました。
そのため、システムを入れるメリットや現状との具体的な比較、システムの使い方を実際に見せるなどしてお客様の抵抗を少しでも軽減できるように努めたり、食事をご一緒して少しでも親近感を持って頂けるようにしました。

協力的なお客様であれば問題なかったと思うのですが、これはお会いするたびになかなか骨の折れる業務でした。

大人数に向けたシステム説明

結構大きなリリースを控えると、60名ほどのお客様の前で自分がシステムの説明をしました。

人前で説明するのはあまり緊張しない方なので助かりましたが、丁寧に説明できなかったりうまく伝えられなかった点があり、誰でも理解できる目線での話し方と内容にしないといけないと反省しました。

ただこれが、逆にベテランエンジニアだらけだった場合にどうなってたかと考えると、、、それも恐ろしいものです。

3)その他

基本的なPC操作

そもそも自分はPCに疎かったので、現役エンジニアに囲まれながら操作することでいろいろと技を盗めました。
ファイル形式の違いやデータ管理、ショートカットあたりでしょうか。

タイピングスピードに関してはまだなかなかにひどいので、今後ブラインドタッチができるように頑張ります。

スプレッドシートの使い方

お客様と共有で使うものを中心に、スプレッドシートの使い方を覚えました。

ワードこそ前職で触っていましたが、エクセルは触ったことがなく今新たに使い方を覚えるのもちょっと億劫だったので、このタイミングでスプレッドシートだけでも使えるようになれて良かったです。

基本何にでも使えるので、やはり便利です。

チーム開発の流れ

Gitを中心に、ブランチをどのように管理するか、チーム全体でのコード管理はどうするか、プルリクをレビュー頂いたらどう修正して再レビュー頂くか、OKだったらどうマージするか、本番にはどのタイミングで反映させるか、ローカルの最新化はどうやるか、など一通り掴めました。

独学では単純に「コミット→プッシュ! コミット→プッシュ!」しかやってなかったので、現場で働かないとこれはなかなか掴めないですね。

分からない点の質問の仕方

初めは詰まったらすぐ質問してしまってましたが、まずは自分でひたすらググりまくって、それでもダメなら一体何がわからないのか質問内容をしっかり文章にまとめてから質問するようにしました。

質問内容をまとめているうちに、問題を俯瞰して見ることができたりググり方を変えたりすることに繋がったり、ググり方のコツを覚えるとだんだんとググり力も上がっていったりしたので、結構効果はあったと思います。
また周辺知識の定着度合いに比例して、「調べればわかりそう」と思うことも増えていきました。

半年〜1年でやりたいこと

ここからはほんのメモ書き程度に、自分が今後の半年で学んでいきたいスキルをバーっと書いていきます。
先にこれ勉強すればいいのに、とかありましたら、ご指摘頂けると嬉しいです。

フロント書きたい

ベタにvue.jsかreactあたり触れたいです。
、、、、の前にJSの基礎からしっかりやりたいですね。

インフラを深く知る

AWSは基礎システムしかまだ触っていないので、よく聞くけどいまいち分かってないものから知見を深めていきたいです。
とりあえずセキュリティあたりが見れるようになると嬉しい。

PLでチーム管理

責任を持って開発したり、スケジュール組んだりして、案件全体の流れを把握する経験がしたいです。
振られたタスクを必死でやってく、だけでは見えないところも多々あると思うので。

デザイン

新システムをリリースした時に、まざまざとデザイン力のなさを感じました。
Web制作みたいなところの勉強をしたいです。
有名どころのデザインの本から数冊探して勉強してみます。

IT基礎

いわゆるコンピューターリテラシーというものを全く知らないのはエンジニアとしてマズいと思うので、基礎の基礎であるITパスポートから基本情報の午前の出題範囲くらいは頭に入れておきたいです。

ググり力

マイナーな開発を調べている時に見る、英語の記事やドキュメントにまだまだ抵抗があります。
Qiita様や先人が書かれたブログ記事には日々お世話になっていますが、そろそろ幅広く読めるようになりたいです。

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

rails 部分テンプレート健忘禄

rails ~render 健忘禄~

共通部分のレイアウトをまとめて一つのファイルにしてそれを使い回す際に用いられる

code
index.html.erb
<render "パス" >

↑で記述したところに"_ファイル名.html.erb"のレイアウトが表示される。

  • パスの部分は部分テンプレートのファイル名がインスタンス変数の単数形
  • 部分テンプレートのファイル内で使う変数名が同一であること この条件を満たしていれば

code
index.html.erb
<render "@インスタンス変数名" >

と記述することができる。

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

Railsチュートリアル 第12章 パスワードの再設定 - 本番環境での動作に関する演習

1.production環境でユーザー登録を試してみましょう。ユーザー登録時に入力したメールアドレスにメールは届きましたか?

スクリーンショット 2019-12-17 12.38.59.png

まずは「Sign up」画面から、ユーザー登録に必要な情報を入力し、「Create my account」ボタンをクリックします。

スクリーンショット 2019-12-17 12.39.10.png

上記の画面が出て、入力したメールアドレスが有効ならば、実際にメールが到着しているはずです。確認してみましょう。

スクリーンショット 2019-12-17 12.42.29.png

確かにメールが到着していました。

2. メールを受信できたら、実際にメールをクリックしてアカウントを有効化してみましょう。また、Heroku上のログを調べてみて、有効化に関するログがどうなっているのか調べてみてください。

ヒント: ターミナルからheroku logsコマンドを実行してみましょう。

スクリーンショット 2019-12-17 12.47.29.png

まずは、受信したメールの「Activate」リンクをクリックします。

スクリーンショット 2019-12-17 12.48.23.png

無事ユーザーが有効化されました。

ユーザー有効化処理時のサーバーログ

まずはUsersコントローラーのnewアクションに関するログです。新規ユーザー登録情報の入力画面が表示されるところまでです。

2019-12-17T03:38:26.701082+00:00 heroku[router]: at=info method=GET path="/signup" host=warm-woodland-62915.herokuapp.com request_id=9ef43fd8-9976-4b6c-9793-fde8257190ab fwd="103.5.140.188" dyno=web.1 connect=1ms service=28ms status=200 bytes=3428 protocol=https
2019-12-17T03:38:26.675341+00:00 app[web.1]: I, [2019-12-17T03:38:26.675221 #4]  INFO -- : [9ef43fd8-9976-4b6c-9793-fde8257190ab] Started GET "/signup" for 103.5.140.188 at 2019-12-17 03:38:26 +0000
2019-12-17T03:38:26.676239+00:00 app[web.1]: I, [2019-12-17T03:38:26.676157 #4]  INFO -- : [9ef43fd8-9976-4b6c-9793-fde8257190ab] Processing by UsersController#new as HTML
2019-12-17T03:38:26.677651+00:00 app[web.1]: I, [2019-12-17T03:38:26.677545 #4]  INFO -- : [9ef43fd8-9976-4b6c-9793-fde8257190ab]   Rendering users/new.html.erb within layouts/application
2019-12-17T03:38:26.678404+00:00 app[web.1]: I, [2019-12-17T03:38:26.678315 #4]  INFO -- : [9ef43fd8-9976-4b6c-9793-fde8257190ab]   Rendered shared/_error_messages.html.erb (0.1ms)
2019-12-17T03:38:26.686611+00:00 app[web.1]: I, [2019-12-17T03:38:26.686532 #4]  INFO -- : [9ef43fd8-9976-4b6c-9793-fde8257190ab]   Rendered users/_form.html.erb (8.5ms)
2019-12-17T03:38:26.686868+00:00 app[web.1]: I, [2019-12-17T03:38:26.686762 #4]  INFO -- : [9ef43fd8-9976-4b6c-9793-fde8257190ab]   Rendered users/new.html.erb within layouts/application (9.1ms)
2019-12-17T03:38:26.688192+00:00 app[web.1]: I, [2019-12-17T03:38:26.688122 #4]  INFO -- : [9ef43fd8-9976-4b6c-9793-fde8257190ab]   Rendered layouts/_rails_default.erb (0.9ms)
2019-12-17T03:38:26.688627+00:00 app[web.1]: I, [2019-12-17T03:38:26.688523 #4]  INFO -- : [9ef43fd8-9976-4b6c-9793-fde8257190ab]   Rendered layouts/_shim.html.erb (0.1ms)
2019-12-17T03:38:26.689527+00:00 app[web.1]: I, [2019-12-17T03:38:26.689418 #4]  INFO -- : [9ef43fd8-9976-4b6c-9793-fde8257190ab]   Rendered layouts/_header.html.erb (0.6ms)
2019-12-17T03:38:26.690255+00:00 app[web.1]: I, [2019-12-17T03:38:26.690186 #4]  INFO -- : [9ef43fd8-9976-4b6c-9793-fde8257190ab]   Rendered layouts/_footer.html.erb (0.3ms)
2019-12-17T03:38:26.690776+00:00 app[web.1]: I, [2019-12-17T03:38:26.690656 #4]  INFO -- : [9ef43fd8-9976-4b6c-9793-fde8257190ab] Completed 200 OK in 14ms (Views: 13.3ms | ActiveRecord: 0.0ms)

続いて、Usersコントローラーのcreateアクションが開始されました。ユーザーの操作としては、「新規ユーザー登録情報の入力画面で、「Submit」ボタンがクリックされたところ」です。

2019-12-17T03:39:00.628200+00:00 app[web.1]: I, [2019-12-17T03:39:00.628068 #4]  INFO -- : [b0361e09-bbea-4688-bdcd-1f9d317e14fa] Started POST "/signup" for 103.5.140.188 at 2019-12-17 03:39:00 +0000
2019-12-17T03:39:00.629006+00:00 app[web.1]: I, [2019-12-17T03:39:00.628922 #4]  INFO -- : [b0361e09-bbea-4688-bdcd-1f9d317e14fa] Processing by UsersController#create as HTML
2019-12-17T03:39:00.629110+00:00 app[web.1]: I, [2019-12-17T03:39:00.629041 #4]  INFO -- : [b0361e09-bbea-4688-bdcd-1f9d317e14fa]   Parameters: {"utf8"=>"✓", "authenticity_token"=>"kPI+VonM8nuUu11tRyJNCgkw++6CAdlBZgdH7pgF3GwQjj4/Ah6f7x8yXoXNG4AwTYpDgE+jwtNJ13XXCZMh8w==", "user"=>{"name"=>"Hoge Hoge", "email"=>"[有効なメールアドレス]", "password"=>"[FILTERED]", "password_confirmation"=>"[FILTERED]"}, "commit"=>"Create my account"}
2019-12-17T03:39:00.714970+00:00 app[web.1]: D, [2019-12-17T03:39:00.714832 #4] DEBUG -- : [b0361e09-bbea-4688-bdcd-1f9d317e14fa]    (1.7ms)  BEGIN
2019-12-17T03:39:00.718390+00:00 app[web.1]: D, [2019-12-17T03:39:00.718293 #4] DEBUG -- : [b0361e09-bbea-4688-bdcd-1f9d317e14fa]   User Exists (2.1ms)  SELECT  1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER($1) LIMIT $2  [["email", "[有効なメールアドレス]"], ["LIMIT", 1]]
2019-12-17T03:39:00.807361+00:00 app[web.1]: D, [2019-12-17T03:39:00.807224 #4] DEBUG -- : [b0361e09-bbea-4688-bdcd-1f9d317e14fa]   SQL (10.7ms)  INSERT INTO "users" ("name", "email", "created_at", "updated_at", "password_digest", "activation_digest") VALUES ($1, $2, $3, $4, $5, $6) RETURNING "id"  [["name", "Hoge Hoge"], ["email", "[有効なメールアドレス]"], ["created_at", "2019-12-17 03:39:00.718914"], ["updated_at", "2019-12-17 03:39:00.718914"], ["password_digest", "$2a$10$tLq83mKKaJN4XGBafxp0BOMpmTDw6iKYj729bNu9dE6jrGYu7w/o6"], ["activation_digest", "$2a$10$CKnDgcIkfShDxkQLli6OU.6vszmQ3rrXdSMa7khjybONYxxNWxNf2"]]
2019-12-17T03:39:00.811145+00:00 app[web.1]: D, [2019-12-17T03:39:00.811013 #4] DEBUG -- : [b0361e09-bbea-4688-bdcd-1f9d317e14fa]    (3.0ms)  COMMIT

有効化トークンに対応するダイジェストは以下の文字列ですね。

$2a$10$CKnDgcIkfShDxkQLli6OU.6vszmQ3rrXdSMa7khjybONYxxNWxNf2

続いて、メイラーに関するログが返されています。

2019-12-17T03:39:00.817822+00:00 app[web.1]: I, [2019-12-17T03:39:00.817728 #4]  INFO -- : [b0361e09-bbea-4688-bdcd-1f9d317e14fa]   Rendering user_mailer/account_activation.html.erb within layouts/mailer
2019-12-17T03:39:00.818892+00:00 app[web.1]: I, [2019-12-17T03:39:00.818826 #4]  INFO -- : [b0361e09-bbea-4688-bdcd-1f9d317e14fa]   Rendered user_mailer/account_activation.html.erb within layouts/mailer (0.9ms)
2019-12-17T03:39:00.819846+00:00 app[web.1]: I, [2019-12-17T03:39:00.819782 #4]  INFO -- : [b0361e09-bbea-4688-bdcd-1f9d317e14fa]   Rendering user_mailer/account_activation.text.erb within layouts/mailer
2019-12-17T03:39:00.820407+00:00 app[web.1]: I, [2019-12-17T03:39:00.820338 #4]  INFO -- : [b0361e09-bbea-4688-bdcd-1f9d317e14fa]   Rendered user_mailer/account_activation.text.erb within layouts/mailer (0.4ms)
2019-12-17T03:39:01.140739+00:00 app[web.1]: D, [2019-12-17T03:39:01.140608 #4] DEBUG -- : [b0361e09-bbea-4688-bdcd-1f9d317e14fa] UserMailer#account_activation: processed outbound mail in 328.5ms
2019-12-17T03:39:01.383306+00:00 app[web.1]: I, [2019-12-17T03:39:01.383191 #4]  INFO -- : [b0361e09-bbea-4688-bdcd-1f9d317e14fa] Sent mail to [有効なメールアドレス] (242.3ms)
2019-12-17T03:39:01.383340+00:00 app[web.1]: D, [2019-12-17T03:39:01.383285 #4] DEBUG -- : [b0361e09-bbea-4688-bdcd-1f9d317e14fa] Date: Tue, 17 Dec 2019 03:39:01 +0000

続いて、ユーザー有効化URL通知用メールのヘッダーが返されます。

2019-12-17T03:39:01.383343+00:00 app[web.1]: From: noreply@example.com
2019-12-17T03:39:01.383345+00:00 app[web.1]: To: [有効なメールアドレス]
2019-12-17T03:39:01.383347+00:00 app[web.1]: Message-ID: <5df84dd5239a7_42b04df0df05c5129@b042e83a-3492-4249-911e-6b41b5e09cd5.mail>
2019-12-17T03:39:01.383349+00:00 app[web.1]: Subject: Account activation
2019-12-17T03:39:01.383351+00:00 app[web.1]: Mime-Version: 1.0
2019-12-17T03:39:01.383353+00:00 app[web.1]: Content-Type: multipart/alternative;
2019-12-17T03:39:01.383355+00:00 app[web.1]: boundary="--==_mimepart_5df84dd521d3b_42b04df0df05c50d3";
2019-12-17T03:39:01.383358+00:00 app[web.1]: charset=UTF-8
2019-12-17T03:39:01.383360+00:00 app[web.1]: Content-Transfer-Encoding: 7bit
2019-12-17T03:39:01.383362+00:00 app[web.1]: 
2019-12-17T03:39:01.383364+00:00 app[web.1]: 
2019-12-17T03:39:01.383366+00:00 app[web.1]: ----==_mimepart_5df84dd521d3b_42b04df0df05c50d3

以下のログには、テキストメールの本文が返されています。

2019-12-17T03:39:01.383368+00:00 app[web.1]: Content-Type: text/plain;
2019-12-17T03:39:01.383370+00:00 app[web.1]: charset=UTF-8
2019-12-17T03:39:01.383372+00:00 app[web.1]: Content-Transfer-Encoding: 7bit
2019-12-17T03:39:01.383374+00:00 app[web.1]: 
2019-12-17T03:39:01.383376+00:00 app[web.1]: Hi Hoge Hoge,
2019-12-17T03:39:01.383377+00:00 app[web.1]: 
2019-12-17T03:39:01.383380+00:00 app[web.1]: Welcome to the Sample App! Click on the link below to activate your account:
2019-12-17T03:39:01.383382+00:00 app[web.1]: 
2019-12-17T03:39:01.383384+00:00 app[web.1]: https://warm-woodland-62915.herokuapp.com/account_activations/iNFYhM1X7rDUmVORFrGMbA/edit?email=[有効なメールアドレス]
2019-12-17T03:39:01.383386+00:00 app[web.1]: 
2019-12-17T03:39:01.383387+00:00 app[web.1]: 
2019-12-17T03:39:01.383390+00:00 app[web.1]: ----==_mimepart_5df84dd521d3b_42b04df0df05c50d3

以下のログには、HTMLメールの本文が返されています。

2019-12-17T03:39:01.383392+00:00 app[web.1]: Content-Type: text/html;
2019-12-17T03:39:01.383393+00:00 app[web.1]: charset=UTF-8
2019-12-17T03:39:01.383395+00:00 app[web.1]: Content-Transfer-Encoding: 7bit
2019-12-17T03:39:01.383397+00:00 app[web.1]: 
2019-12-17T03:39:01.383402+00:00 app[web.1]: <!DOCTYPE html>
2019-12-17T03:39:01.383404+00:00 app[web.1]: <html>
2019-12-17T03:39:01.383406+00:00 app[web.1]: <head>
2019-12-17T03:39:01.383409+00:00 app[web.1]: <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
2019-12-17T03:39:01.383410+00:00 app[web.1]: <style>
2019-12-17T03:39:01.383412+00:00 app[web.1]: /* Email styles need to be inline */
2019-12-17T03:39:01.383414+00:00 app[web.1]: </style>
2019-12-17T03:39:01.383416+00:00 app[web.1]: </head>
2019-12-17T03:39:01.383418+00:00 app[web.1]: 
2019-12-17T03:39:01.383420+00:00 app[web.1]: <body>
2019-12-17T03:39:01.383422+00:00 app[web.1]: <h1>Sample App</h1>
2019-12-17T03:39:01.383424+00:00 app[web.1]: 
2019-12-17T03:39:01.383426+00:00 app[web.1]: <p>Hi Hoge Hoge,</p>
2019-12-17T03:39:01.383428+00:00 app[web.1]: 
2019-12-17T03:39:01.383430+00:00 app[web.1]: <p>
2019-12-17T03:39:01.383432+00:00 app[web.1]: Welcome to the Sample App! Click on the link below to activate your account:
2019-12-17T03:39:01.383434+00:00 app[web.1]: </p>
2019-12-17T03:39:01.383435+00:00 app[web.1]: 
2019-12-17T03:39:01.383438+00:00 app[web.1]: <a href="https://warm-woodland-62915.herokuapp.com/account_activations/iNFYhM1X7rDUmVORFrGMbA/edit?email=[有効なメールアドレス]">Activate</a>
2019-12-17T03:39:01.383440+00:00 app[web.1]: 
2019-12-17T03:39:01.383441+00:00 app[web.1]: </body>
2019-12-17T03:39:01.383443+00:00 app[web.1]: </html>
2019-12-17T03:39:01.383445+00:00 app[web.1]: 
2019-12-17T03:39:01.383447+00:00 app[web.1]: ----==_mimepart_5df84dd521d3b_42b04df0df05c50d3--

メール本文から、有効化トークンは以下の文字列であることがわかります。

iNFYhM1X7rDUmVORFrGMbA

続いてのログにより、Usersコントローラーのcreateアクションの最後は / へのリダイレクトで完了したことがわかります。

2019-12-17T03:39:01.383918+00:00 app[web.1]: I, [2019-12-17T03:39:01.383828 #4]  INFO -- : [b0361e09-bbea-4688-bdcd-1f9d317e14fa] Redirected to https://warm-woodland-62915.herokuapp.com/
2019-12-17T03:39:01.384190+00:00 app[web.1]: I, [2019-12-17T03:39:01.384101 #4]  INFO -- : [b0361e09-bbea-4688-bdcd-1f9d317e14fa] Completed 302 Found in 752ms (ActiveRecord: 17.6ms)

今度は、AccountActivationsコントローラーに対するeditが開始されたところからのログです。「メール本文中に記載されたURLにアクセスがあった」というタイミングですね。

2019-12-17T03:48:03.998174+00:00 app[web.1]: I, [2019-12-17T03:48:03.998081 #4]  INFO -- : [f2ca36e3-b2d1-4746-ba15-c67b852fbee5] Started GET "/account_activations/iNFYhM1X7rDUmVORFrGMbA/edit?email=[有効なメールアドレス]" for 103.5.140.188 at 2019-12-17 03:48:03 +0000
2019-12-17T03:48:03.999604+00:00 app[web.1]: I, [2019-12-17T03:48:03.999504 #4]  INFO -- : [f2ca36e3-b2d1-4746-ba15-c67b852fbee5] Processing by AccountActivationsController#edit as HTML
2019-12-17T03:48:03.999666+00:00 app[web.1]: I, [2019-12-17T03:48:03.999597 #4]  INFO -- : [f2ca36e3-b2d1-4746-ba15-c67b852fbee5]   Parameters: {"email"=>"[有効なメールアドレス]", "id"=>"iNFYhM1X7rDUmVORFrGMbA"}
2019-12-17T03:48:04.013441+00:00 app[web.1]: D, [2019-12-17T03:48:04.013350 #4] DEBUG -- : [f2ca36e3-b2d1-4746-ba15-c67b852fbee5]   User Load (4.0ms)  SELECT  "users".* FROM "users" WHERE "users"."email" = $1 LIMIT $2  [["email", "[有効なメールアドレス]"], ["LIMIT", 1]]
2019-12-17T03:48:04.101912+00:00 app[web.1]: D, [2019-12-17T03:48:04.101770 #4] DEBUG -- : [f2ca36e3-b2d1-4746-ba15-c67b852fbee5]   SQL (12.9ms)  UPDATE "users" SET "activated" = 't', "activated_at" = '2019-12-17 03:48:04.087544' WHERE "users"."id" = $1  [["id", 104]]
2019-12-17T03:48:04.102898+00:00 app[web.1]: I, [2019-12-17T03:48:04.102788 #4]  INFO -- : [f2ca36e3-b2d1-4746-ba15-c67b852fbee5] Redirected to https://warm-woodland-62915.herokuapp.com/users/104
2019-12-17T03:48:04.103242+00:00 app[web.1]: I, [2019-12-17T03:48:04.103156 #4]  INFO -- : [f2ca36e3-b2d1-4746-ba15-c67b852fbee5] Completed 302 Found in 103ms (ActiveRecord: 18.7ms)

(再掲)以下のログのSQL文を見るに、ユーザーの有効化処理は正常に完了したようです。

2019-12-17T03:48:04.101912+00:00 app[web.1]: D, [2019-12-17T03:48:04.101770 #4] DEBUG -- : [f2ca36e3-b2d1-4746-ba15-c67b852fbee5]   SQL (12.9ms)  UPDATE "users" SET "activated" = 't', "activated_at" = '2019-12-17 03:48:04.087544' WHERE "users"."id" = $1  [["id", 104]]

3. アカウントを有効化できたら、今度はパスワードの再設定を試してみましょう。正しくパスワードの再設定ができたでしょうか?

まず、ログインフォームから「forgot password」のリンクをクリックします。

スクリーンショット 2019-12-17 18.38.02.png

パスワード再設定の対象となるユーザーのメールアドレスを入力します。

スクリーンショット 2019-12-17 18.28.09.png

メールが送られてきます。ハイライトした部分は再設定用トークンです。

リンクをクリックして、パスワード再設定フォームへ進みます。

スクリーンショット 2019-12-17 18.28.37.png

新しいパスワードを入力します。

スクリーンショット 2019-12-17 18.29.10.png

パスワードの再設定が完了し、パスワードを再設定したユーザーのプロフィールページが表示されます。

スクリーンショット 2019-12-17 18.29.19.png

パスワード再設定に関するサーバーログ

まずはPasswordResetsコントローラーのnewアクションに関するログです。

2019-12-17T09:27:37.630786+00:00 app[web.1]: I, [2019-12-17T09:27:37.630687 #4]  INFO -- : [4904fcd9-d965-4f6a-93d7-0fa95587d243] Started GET "/password_resets/new" for 175.177.6.7 at 2019-12-17 09:27:37 +0000
2019-12-17T09:27:37.632157+00:00 app[web.1]: I, [2019-12-17T09:27:37.632064 #4]  INFO -- : [4904fcd9-d965-4f6a-93d7-0fa95587d243] Processing by PasswordResetsController#new as HTML
2019-12-17T09:27:37.634071+00:00 app[web.1]: I, [2019-12-17T09:27:37.633999 #4]  INFO -- : [4904fcd9-d965-4f6a-93d7-0fa95587d243]   Rendering password_resets/new.html.erb within layouts/application
2019-12-17T09:27:37.635383+00:00 app[web.1]: I, [2019-12-17T09:27:37.635314 #4]  INFO -- : [4904fcd9-d965-4f6a-93d7-0fa95587d243]   Rendered password_resets/new.html.erb within layouts/application (1.2ms)
2019-12-17T09:27:37.635971+00:00 app[web.1]: I, [2019-12-17T09:27:37.635907 #4]  INFO -- : [4904fcd9-d965-4f6a-93d7-0fa95587d243]   Rendered layouts/_rails_default.erb (0.4ms)
2019-12-17T09:27:37.636126+00:00 app[web.1]: I, [2019-12-17T09:27:37.636073 #4]  INFO -- : [4904fcd9-d965-4f6a-93d7-0fa95587d243]   Rendered layouts/_shim.html.erb (0.0ms)
2019-12-17T09:27:37.636548+00:00 app[web.1]: I, [2019-12-17T09:27:37.636470 #4]  INFO -- : [4904fcd9-d965-4f6a-93d7-0fa95587d243]   Rendered layouts/_header.html.erb (0.2ms)
2019-12-17T09:27:37.636862+00:00 app[web.1]: I, [2019-12-17T09:27:37.636799 #4]  INFO -- : [4904fcd9-d965-4f6a-93d7-0fa95587d243]   Rendered layouts/_footer.html.erb (0.1ms)
2019-12-17T09:27:37.637075+00:00 app[web.1]: I, [2019-12-17T09:27:37.637018 #4]  INFO -- : [4904fcd9-d965-4f6a-93d7-0fa95587d243] Completed 200 OK in 5ms (Views: 3.7ms | ActiveRecord: 0.0ms)

ここまでのログは、「forgot password」リンクのクリックから、パスワード再設定対象のメールアドレスの入力画面がWebブラウザに表示されるところまでに対応しています。

続いて、PasswordResetsコントローラーのcreateアクションが開始されました。ユーザーの操作としては、「パスワード再設定対象のメールアドレスの入力画面で、「Submit」ボタンがクリックされたところ」です。

2019-12-17T09:28:10.677268+00:00 app[web.1]: I, [2019-12-17T09:28:10.677144 #4]  INFO -- : [3885b893-52a2-4eed-bc9d-d14c153f57e1] Started POST "/password_resets" for 175.177.6.7 at 2019-12-17 09:28:10 +0000
2019-12-17T09:28:10.678356+00:00 app[web.1]: I, [2019-12-17T09:28:10.678278 #4]  INFO -- : [3885b893-52a2-4eed-bc9d-d14c153f57e1] Processing by PasswordResetsController#create as HTML
2019-12-17T09:28:10.678449+00:00 app[web.1]: I, [2019-12-17T09:28:10.678378 #4]  INFO -- : [3885b893-52a2-4eed-bc9d-d14c153f57e1]   Parameters: {"utf8"=>"✓", "authenticity_token"=>"s6/JcwQzuM6JAiREt9u1PDuPaKc5we/TAWuJbaIJyxcyV3rusF2/H0ksby8hky03XDCj8W0j/jxFcmxVT61haQ==", "password_reset"=>"[FILTERED]", "commit"=>"Submit"}
2019-12-17T09:28:10.686514+00:00 app[web.1]: D, [2019-12-17T09:28:10.686418 #4] DEBUG -- : [3885b893-52a2-4eed-bc9d-d14c153f57e1]   User Load (2.8ms)  SELECT  "users".* FROM "users" WHERE "users"."email" = $1 LIMIT $2  [["email", "[有効なメールアドレス]"], ["LIMIT", 1]]
2019-12-17T09:28:10.766666+00:00 app[web.1]: D, [2019-12-17T09:28:10.766571 #4] DEBUG -- : [3885b893-52a2-4eed-bc9d-d14c153f57e1]   SQL (3.7ms)  UPDATE "users" SET "reset_digest" = '$2a$10$uOXG8lKmc/1ytMzVC5QHAOiY0S/GjmMYHYMb7lVd.dNK8vIsQFYtG', "reset_sent_at" = '2019-12-17 09:28:10.762063' WHERE "users"."id" = $1  [["id", 104]]

(再掲)パスワード再設定トークンに対応するダイジェストをRDBに保存する処理には、以下のログが対応しています。

2019-12-17T09:28:10.766666+00:00 app[web.1]: D, [2019-12-17T09:28:10.766571 #4] DEBUG -- : [3885b893-52a2-4eed-bc9d-d14c153f57e1]   SQL (3.7ms)  UPDATE "users" SET "reset_digest" = '$2a$10$uOXG8lKmc/1ytMzVC5QHAOiY0S/GjmMYHYMb7lVd.dNK8vIsQFYtG', "reset_sent_at" = '2019-12-17 09:28:10.762063' WHERE "users"."id" = $1  [["id", 104]]

ダイジェストは以下の文字列ですね。

$2a$10$uOXG8lKmc/1ytMzVC5QHAOiY0S/GjmMYHYMb7lVd.dNK8vIsQFYtG

続いて、メイラーに関するログが返されています。

2019-12-17T09:28:10.771901+00:00 app[web.1]: I, [2019-12-17T09:28:10.771829 #4]  INFO -- : [3885b893-52a2-4eed-bc9d-d14c153f57e1]   Rendering user_mailer/password_reset.html.erb within layouts/mailer
2019-12-17T09:28:10.772669+00:00 app[web.1]: I, [2019-12-17T09:28:10.772606 #4]  INFO -- : [3885b893-52a2-4eed-bc9d-d14c153f57e1]   Rendered user_mailer/password_reset.html.erb within layouts/mailer (0.7ms)
2019-12-17T09:28:10.773513+00:00 app[web.1]: I, [2019-12-17T09:28:10.773447 #4]  INFO -- : [3885b893-52a2-4eed-bc9d-d14c153f57e1]   Rendering user_mailer/password_reset.text.erb within layouts/mailer
2019-12-17T09:28:10.773958+00:00 app[web.1]: I, [2019-12-17T09:28:10.773897 #4]  INFO -- : [3885b893-52a2-4eed-bc9d-d14c153f57e1]   Rendered user_mailer/password_reset.text.erb within layouts/mailer (0.4ms)
2019-12-17T09:28:10.987227+00:00 app[web.1]: D, [2019-12-17T09:28:10.987125 #4] DEBUG -- : [3885b893-52a2-4eed-bc9d-d14c153f57e1] UserMailer#password_reset: processed outbound mail in 219.8ms
2019-12-17T09:28:11.369313+00:00 heroku[router]: at=info method=POST path="/password_resets" host=warm-woodland-62915.herokuapp.com request_id=3885b893-52a2-4eed-bc9d-d14c153f57e1 fwd="175.177.6.7" dyno=web.1 connect=1ms service=692ms status=302 bytes=1049 protocol=https
2019-12-17T09:28:11.363917+00:00 app[web.1]: I, [2019-12-17T09:28:11.363795 #4]  INFO -- : [3885b893-52a2-4eed-bc9d-d14c153f57e1] Sent mail to [有効なメールアドレス] (376.4ms)
2019-12-17T09:28:11.364042+00:00 app[web.1]: D, [2019-12-17T09:28:11.363974 #4] DEBUG -- : [3885b893-52a2-4eed-bc9d-d14c153f57e1] Date: Tue, 17 Dec 2019 09:28:10 +0000

続いて、パスワード再設定URL通知用メールのヘッダーが返されます。

2019-12-17T09:28:11.364046+00:00 app[web.1]: From: noreply@example.com
2019-12-17T09:28:11.364048+00:00 app[web.1]: To: [有効なメールアドレス]
2019-12-17T09:28:11.364051+00:00 app[web.1]: Message-ID: <5df89faaf1d68_42b033a8e4c6c36478@0a287fc0-3c85-4948-82ec-158df909485c.mail>
2019-12-17T09:28:11.364053+00:00 app[web.1]: Subject: Password reset
2019-12-17T09:28:11.364055+00:00 app[web.1]: Mime-Version: 1.0
2019-12-17T09:28:11.364057+00:00 app[web.1]: Content-Type: multipart/alternative;
2019-12-17T09:28:11.364059+00:00 app[web.1]: boundary="--==_mimepart_5df89faaf0b60_42b033a8e4c6c36371";
2019-12-17T09:28:11.364062+00:00 app[web.1]: charset=UTF-8
2019-12-17T09:28:11.364064+00:00 app[web.1]: Content-Transfer-Encoding: 7bit

以下のログには、テキストメールの本文が返されています。

2019-12-17T09:28:11.364070+00:00 app[web.1]: ----==_mimepart_5df89faaf0b60_42b033a8e4c6c36371
2019-12-17T09:28:11.364072+00:00 app[web.1]: Content-Type: text/plain;
2019-12-17T09:28:11.364074+00:00 app[web.1]: charset=UTF-8
2019-12-17T09:28:11.364077+00:00 app[web.1]: Content-Transfer-Encoding: 7bit
2019-12-17T09:28:11.364078+00:00 app[web.1]: 
2019-12-17T09:28:11.364081+00:00 app[web.1]: To reset your password click the link below:
2019-12-17T09:28:11.364082+00:00 app[web.1]: 
2019-12-17T09:28:11.364085+00:00 app[web.1]: https://warm-woodland-62915.herokuapp.com/password_resets/itlJPL32OY-XMHORblVHpQ/edit?email=[有効なメールアドレス]
2019-12-17T09:28:11.364087+00:00 app[web.1]: 
2019-12-17T09:28:11.364089+00:00 app[web.1]: This will expire in two hours.
2019-12-17T09:28:11.364091+00:00 app[web.1]: 
2019-12-17T09:28:11.364093+00:00 app[web.1]: If you did not request your password to be reset, please ignore this email and
2019-12-17T09:28:11.364095+00:00 app[web.1]: your password stay as it is.
2019-12-17T09:28:11.364097+00:00 app[web.1]: 
2019-12-17T09:28:11.364099+00:00 app[web.1]: 
2019-12-17T09:28:11.364101+00:00 app[web.1]: ----==_mimepart_5df89faaf0b60_42b033a8e4c6c36371

以下のログには、HTMLメールの本文が返されています。

2019-12-17T09:28:11.364103+00:00 app[web.1]: Content-Type: text/html;
2019-12-17T09:28:11.364105+00:00 app[web.1]: charset=UTF-8
2019-12-17T09:28:11.364107+00:00 app[web.1]: Content-Transfer-Encoding: 7bit
2019-12-17T09:28:11.364109+00:00 app[web.1]: 
2019-12-17T09:28:11.364110+00:00 app[web.1]: <!DOCTYPE html>
2019-12-17T09:28:11.364112+00:00 app[web.1]: <html>
2019-12-17T09:28:11.364115+00:00 app[web.1]: <head>
2019-12-17T09:28:11.364117+00:00 app[web.1]: <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
2019-12-17T09:28:11.364119+00:00 app[web.1]: <style>
2019-12-17T09:28:11.364121+00:00 app[web.1]: /* Email styles need to be inline */
2019-12-17T09:28:11.364123+00:00 app[web.1]: </style>
2019-12-17T09:28:11.364125+00:00 app[web.1]: </head>
2019-12-17T09:28:11.364128+00:00 app[web.1]: 
2019-12-17T09:28:11.364130+00:00 app[web.1]: <body>
2019-12-17T09:28:11.364132+00:00 app[web.1]: <h1>Password reset</h1>
2019-12-17T09:28:11.364133+00:00 app[web.1]: 
2019-12-17T09:28:11.364135+00:00 app[web.1]: <p>To reset your password click the link below:</p>
2019-12-17T09:28:11.364137+00:00 app[web.1]: 
2019-12-17T09:28:11.364140+00:00 app[web.1]: https://warm-woodland-62915.herokuapp.com/password_resets/itlJPL32OY-XMHORblVHpQ/edit?email=[有効なメールアドレス]
2019-12-17T09:28:11.364141+00:00 app[web.1]: 
2019-12-17T09:28:11.364143+00:00 app[web.1]: <p>This will expire in two hours.</p>
2019-12-17T09:28:11.364145+00:00 app[web.1]: 
2019-12-17T09:28:11.364147+00:00 app[web.1]: <p>
2019-12-17T09:28:11.364150+00:00 app[web.1]: If you did not request your password to be reset, please ignore this email and
2019-12-17T09:28:11.364152+00:00 app[web.1]: your password stay as it is.
2019-12-17T09:28:11.364153+00:00 app[web.1]: </p>
2019-12-17T09:28:11.364155+00:00 app[web.1]: 
2019-12-17T09:28:11.364157+00:00 app[web.1]: </body>
2019-12-17T09:28:11.364159+00:00 app[web.1]: </html>
2019-12-17T09:28:11.364161+00:00 app[web.1]: 
2019-12-17T09:28:11.364164+00:00 app[web.1]: ----==_mimepart_5df89faaf0b60_42b033a8e4c6c36371--

メール本文から、パスワード再設定トークンは以下の文字列であることがわかります。

itlJPL32OY-XMHORblVHpQ

続いてのログにより、PasswordResetsコントローラーのcreateアクションの最後は / へのリダイレクトで完了したことがわかります。

2019-12-17T09:28:11.364629+00:00 app[web.1]: I, [2019-12-17T09:28:11.364565 #4]  INFO -- : [3885b893-52a2-4eed-bc9d-d14c153f57e1] Redirected to https://warm-woodland-62915.herokuapp.com/
2019-12-17T09:28:11.364841+00:00 app[web.1]: I, [2019-12-17T09:28:11.364781 #4]  INFO -- : [3885b893-52a2-4eed-bc9d-d14c153f57e1] Completed 302 Found in 686ms (ActiveRecord: 6.5ms)

今度は、PasswordResetsコントローラーに対するeditが開始されたところからのログです。「メール本文中に記載されたURLにアクセスがあった」というタイミングですね。

2019-12-17T09:28:40.413354+00:00 heroku[router]: at=info method=GET path="/password_resets/itlJPL32OY-XMHORblVHpQ/edit?email=[有効なメールアドレス]" host=warm-woodland-62915.herokuapp.com request_id=ed1e4c85-a8ad-4e30-90a9-2d581a582d69 fwd="175.177.6.7" dyno=web.1 connect=1ms service=98ms status=200 bytes=3413 protocol=https
2019-12-17T09:28:40.314045+00:00 app[web.1]: I, [2019-12-17T09:28:40.313937 #4]  INFO -- : [ed1e4c85-a8ad-4e30-90a9-2d581a582d69] Started GET "/password_resets/itlJPL32OY-XMHORblVHpQ/edit?email=[有効なメールアドレス]" for 175.177.6.7 at 2019-12-17 09:28:40 +0000
2019-12-17T09:28:40.314979+00:00 app[web.1]: I, [2019-12-17T09:28:40.314889 #4]  INFO -- : [ed1e4c85-a8ad-4e30-90a9-2d581a582d69] Processing by PasswordResetsController#edit as HTML
2019-12-17T09:28:40.315025+00:00 app[web.1]: I, [2019-12-17T09:28:40.314972 #4]  INFO -- : [ed1e4c85-a8ad-4e30-90a9-2d581a582d69]   Parameters: {"email"=>"[有効なメールアドレス]", "id"=>"itlJPL32OY-XMHORblVHpQ"}
2019-12-17T09:28:40.319097+00:00 app[web.1]: D, [2019-12-17T09:28:40.319022 #4] DEBUG -- : [ed1e4c85-a8ad-4e30-90a9-2d581a582d69]   User Load (1.7ms)  SELECT  "users".* FROM "users" WHERE "users"."email" = $1 LIMIT $2  [["email", "[有効なメールアドレス]"], ["LIMIT", 1]]
2019-12-17T09:28:40.398272+00:00 app[web.1]: I, [2019-12-17T09:28:40.398149 #4]  INFO -- : [ed1e4c85-a8ad-4e30-90a9-2d581a582d69]   Rendering password_resets/edit.html.erb within layouts/application
2019-12-17T09:28:40.403019+00:00 app[web.1]: I, [2019-12-17T09:28:40.402925 #4]  INFO -- : [ed1e4c85-a8ad-4e30-90a9-2d581a582d69]   Rendered shared/_error_messages.html.erb (0.7ms)
2019-12-17T09:28:40.405414+00:00 app[web.1]: I, [2019-12-17T09:28:40.405331 #4]  INFO -- : [ed1e4c85-a8ad-4e30-90a9-2d581a582d69]   Rendered password_resets/edit.html.erb within layouts/application (7.0ms)
2019-12-17T09:28:40.406458+00:00 app[web.1]: I, [2019-12-17T09:28:40.406378 #4]  INFO -- : [ed1e4c85-a8ad-4e30-90a9-2d581a582d69]   Rendered layouts/_rails_default.erb (0.7ms)
2019-12-17T09:28:40.406920+00:00 app[web.1]: I, [2019-12-17T09:28:40.406841 #4]  INFO -- : [ed1e4c85-a8ad-4e30-90a9-2d581a582d69]   Rendered layouts/_shim.html.erb (0.0ms)
2019-12-17T09:28:40.407613+00:00 app[web.1]: I, [2019-12-17T09:28:40.407535 #4]  INFO -- : [ed1e4c85-a8ad-4e30-90a9-2d581a582d69]   Rendered layouts/_header.html.erb (0.4ms)
2019-12-17T09:28:40.408136+00:00 app[web.1]: I, [2019-12-17T09:28:40.408058 #4]  INFO -- : [ed1e4c85-a8ad-4e30-90a9-2d581a582d69]   Rendered layouts/_footer.html.erb (0.2ms)
2019-12-17T09:28:40.408613+00:00 app[web.1]: I, [2019-12-17T09:28:40.408534 #4]  INFO -- : [ed1e4c85-a8ad-4e30-90a9-2d581a582d69] Completed 200 OK in 93ms (Views: 10.7ms | ActiveRecord: 1.7ms)

ここまでのログは、パスワード再設定画面がWebブラウザに表示されるところまでに対応しています。

続いて、PasswordResetsコントローラーのupdateアクションが開始されました。ユーザーの操作としては、「パスワード再設定画面で、「Submit」ボタンがクリックされたところ」です。

2019-12-17T09:29:12.595676+00:00 app[web.1]: I, [2019-12-17T09:29:12.595538 #4]  INFO -- : [8848c122-5082-4846-86ce-2ceb8b852cbe] Started PATCH "/password_resets/itlJPL32OY-XMHORblVHpQ" for 175.177.6.7 at 2019-12-17 09:29:12 +0000
2019-12-17T09:29:12.596922+00:00 app[web.1]: I, [2019-12-17T09:29:12.596829 #4]  INFO -- : [8848c122-5082-4846-86ce-2ceb8b852cbe] Processing by PasswordResetsController#update as HTML
2019-12-17T09:29:12.597042+00:00 app[web.1]: I, [2019-12-17T09:29:12.596951 #4]  INFO -- : [8848c122-5082-4846-86ce-2ceb8b852cbe]   Parameters: {"utf8"=>"✓", "authenticity_token"=>"dg4e+oRJwi5xsE/Jz4Pmq+2UoA2OiDh5+ocmuTpFoVb2ch6TD5uvuvo5TCFFuiuRqS4YY0MqI+vVVxSAq9NcyQ==", "email"=>"[有効なメールアドレス]]", "user"=>{"password"=>"[FILTERED]", "password_confirmation"=>"[FILTERED]"}, "commit"=>"Update password", "id"=>"itlJPL32OY-XMHORblVHpQ"}
2019-12-17T09:29:12.601036+00:00 app[web.1]: D, [2019-12-17T09:29:12.600952 #4] DEBUG -- : [8848c122-5082-4846-86ce-2ceb8b852cbe]   User Load (1.6ms)  SELECT  "users".* FROM "users" WHERE "users"."email" = $1 LIMIT $2  [["email", "[有効なメールアドレス]"], ["LIMIT", 1]]
2019-12-17T09:29:12.677061+00:00 app[web.1]: D, [2019-12-17T09:29:12.676951 #4] DEBUG -- : [8848c122-5082-4846-86ce-2ceb8b852cbe]    (1.4ms)  BEGIN
2019-12-17T09:29:12.756980+00:00 app[web.1]: D, [2019-12-17T09:29:12.756856 #4] DEBUG -- : [8848c122-5082-4846-86ce-2ceb8b852cbe]   User Exists (1.6ms)  SELECT  1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER($1) AND ("users"."id" != $2) LIMIT $3  [["email", "[有効なメールアドレス]"], ["id", 104], ["LIMIT", 1]]
2019-12-17T09:29:12.759699+00:00 app[web.1]: D, [2019-12-17T09:29:12.759627 #4] DEBUG -- : [8848c122-5082-4846-86ce-2ceb8b852cbe]   SQL (1.4ms)  UPDATE "users" SET "password_digest" = $1, "updated_at" = $2 WHERE "users"."id" = $3  [["password_digest", "$2a$10$H0x3ghcOyu0l4DHat9NqLOsJ0.xRYZ.DDKTXvoaO18Ot7E5p/omzq"], ["updated_at", "2019-12-17 09:29:12.757425"], ["id", 104]]
2019-12-17T09:29:12.762463+00:00 app[web.1]: D, [2019-12-17T09:29:12.762374 #4] DEBUG -- : [8848c122-5082-4846-86ce-2ceb8b852cbe]    (2.4ms)  COMMIT
2019-12-17T09:29:12.763828+00:00 app[web.1]: D, [2019-12-17T09:29:12.763760 #4] DEBUG -- : [8848c122-5082-4846-86ce-2ceb8b852cbe]    (1.1ms)  BEGIN
2019-12-17T09:29:12.765871+00:00 app[web.1]: D, [2019-12-17T09:29:12.765779 #4] DEBUG -- : [8848c122-5082-4846-86ce-2ceb8b852cbe]   SQL (1.3ms)  UPDATE "users" SET "reset_digest" = $1, "updated_at" = $2 WHERE "users"."id" = $3  [["reset_digest", nil], ["updated_at", "2019-12-17 09:29:12.763908"], ["id", 104]]
2019-12-17T09:29:12.768224+00:00 app[web.1]: D, [2019-12-17T09:29:12.768124 #4] DEBUG -- : [8848c122-5082-4846-86ce-2ceb8b852cbe]    (2.1ms)  COMMIT
2019-12-17T09:29:12.768664+00:00 app[web.1]: I, [2019-12-17T09:29:12.768603 #4]  INFO -- : [8848c122-5082-4846-86ce-2ceb8b852cbe] Redirected to https://warm-woodland-62915.herokuapp.com/users/104
2019-12-17T09:29:12.768800+00:00 app[web.1]: I, [2019-12-17T09:29:12.768749 #4]  INFO -- : [8848c122-5082-4846-86ce-2ceb8b852cbe] Completed 302 Found in 172ms (ActiveRecord: 15.9ms)

パスワードの再設定が正常に完了していますね。最後は当該ユーザーのプロフィールページにリダイレクトされています。

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

slim の書き方が合ってるかどうか、すぐ試したい

問題(やりたいこと)

slim のレビューをしていて、こんな書き方ができたような…ということを言いたいが自信がない。
とりあえず一行だけ slim を書いて、その書き方が合っているか確かめるにはどうするか。

解決策

ターミナルで下記のコマンドを実行する。これで標準入力の待ち状態に入るので適当な slim を書き込み Control+d を2回押す。(なぜ2回なのかはわからない)

bundle exec slimrb

たとえば

bundle exec slimrb
div hoge

のあと Control+d を2回押すと

<div>hoge</div>

が出力されて、正しい slim だったということがわかる。

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

slim ちょっと試したいときには slimrb を使う

問題(やりたいこと)

slim のレビューをしていて、こんな書き方ができたような…ということを言いたいが自信がない。
とりあえず一行だけ slim を書いて、その書き方が合っているか確かめるにはどうするか。

解決策

ターミナルで下記のコマンドを実行する。これで標準入力の待ち状態に入るので適当な slim を書き込み Control+d を2回押す。(なぜ2回なのかはわからない)

bundle exec slimrb

たとえば

bundle exec slimrb
div hoge

のあと Control+d を2回押すと

<div>hoge</div>

が出力されて、正しい slim だったということがわかる。

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

xray-railsはRails6だと動かないという話

TL;DR

xray-railsgemを使っている方がRails6にアップグレードするときは要注意。

事の流れ

発端

プロジェクトをRails6に上げたところ、なぜかimage_tagが動作しなくなりました。具体的には、app/assets/images/exist.svgみたいなファイルがあるのに、image_tag 'exist.svg'がエラーを吐くようになりました。

調査

試しに新規にRails6アプリケーションを作成し、同じファイルをコピーしてimage_tagを使用したところ問題なく動作しました。この時点で該当アプリケーションと新規アプリケーションの差分が原因(ファイルや記法の問題ではない)ということはわかったものの、設定が問題だと思いこんでいたためどこが真の原因かを突き止めることができませんでした。

ruby-jp

https://ruby-jp.github.io/

自力での調査を続けてもよかったのですが、正直疲れていた私はオンラインで質問することにしました。
幸い、ruby-jpというSlackコミュニティがあり、そこにはrailsチャンネルがあるので私はそちらに質問を投下しました。

undefined method 'pathname' for #<#Class:0x00007f840557dad8:0x00007f84088a6018>というエラーが出て困っています。
既存のRails 5.2のアプリをRails 6に上げたら出るようになりました。
image_tagの箇所で出ており、 app/assets/images/hoge.svgが存在するときに image_tag 'hoge.svgを呼ぶと上記エラーとなります。
アセットパイプラインがおかしいとは思うのですが、正常動作しているRails 6のアプリと比べても違いがわからずに困っています。
同じような経験のある方はいらっしゃいますでしょうか…

するとなんと8分後に回答が!

xray-rails入ってます?

はっとなりました。Gemfilexray-railsのエントリがあることは知っていたため「あります」と回答するとURLが。

https://github.com/brentd/xray-rails/pull/103

なるほど…

解決

xray-railsGemfileから除外するとエラーは出なくなりました、めでたしめでたし。
masterブランチを使うという手もありましたが、もう使っていないということだったので除外する方法を採りました)

まとめ

というわけで、xray-railsが原因であることがruby-jpのおかげでわかりました、という話でした。回答してくださったbluerabbitさんをはじめとした方々、ありがとうございました!

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

【Rails】ITインフラ全体像をまとめてみた(DNSサーバーへの問合せ、リクエストからレスポンスの流れ)

目的

RailsチュートリアルではMVCといったRailsアプリケーション特有の全体像については書かれていますが、それ以前の一般的なシステムのインフラについてはほぼ載っていないのが現状です。今回はそうした一般的なシステム全体像を含めて解説して、Railsの全体像について理解を深めていきたいと思います。

流れ

①WebブラウザでURLをアクセスする時DNSサーバーを使ってドメインIPアドレスの取得作業を行う。

②DNSサーバーはIP アドレスを返す。

③ブラウザはサーバのIPアドレスにHTTPリクエストを行う。

④Webサーバーは静的コンテンツを見せて、動的コンテンツについてはAPサーバーに要求する。

⑤APサーバーがRailsで作成されたアプリケーションを実行して、ルーター(Route.rb)がリクエストをコントローラに振り分ける。

⑥コントローラーが動的なコンテンツについてはModelに行きデータベースとの通信を行い、必要な情報をデータベースから取り出し、コントローラーに返す。コントローラーがその情報を保存し、ビューに通信を行いHTMLを生成する。

⑦コントローラは、ビューで生成されたHTMLを受け取り、ブラウザに返す。(HTTPレスポンス)
ITインフラ.png

まとめ

RailsについてRailsチュートリアル等でMVCの流れなどはよく載っていますが、それ以前の流れについては詳しく載っているものは少ないと思います。他のRailsエンジニアの方の意見を聞きながらまとめたものの、自信がないので間違っている部分などございましたら、お知らせください。

参照
https://thinkit.co.jp/article/11526?fbclid=IwAR1xhqnZ4CYQllY6dTS3htOVump-I14LJFh2HAWPac-OnZSSmNmu9N0Iiks

https://diveintocode.jp/tips/process_flow?fbclid=IwAR0em77uubUNxTUhsLEQWbE8nh1fcoDMfnZY0xRm6CqI3_idvz7KKPEHeZ4

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

ActiveRecordのメソッドとSQLの関係を基礎から理解しよう!

目標

  • RailsのModelの中核を担うActiveRecordのメソッドと発行されるSQLの関係を理解すること
  • includesやpluckといったメソッドを理解することで中級者レベルにステップアップすること

目次

  • 用語
  • 基本編
    • 基本編解説
  • 応用編
    • 応用編解説
  • まとめ
  • さらに発展的な内容

用語

RDBMS

Relational Data Base Management Systemの略。その名の通りリレーショナルデータベースを管理するソフトウェアの総称で、データをカラム(列)と、レコード(行)の中にまとめ、それらを二次元のテーブル(表)の中に並べて表現するものです。データの取得や作成といった手続きにSQL言語が用いられます。代表的なものにはPostgresqlやMysqlなどがあります。

ORM

ORMとはオブジェクトリレーショナルマッピングの略で、Rubyなどのオブジェクト指向言語では直接扱うことのできないRDBMS上のテーブルやデータを、クラスなどにマッピングすることで、扱えるようにするプログラミングの技法のこと。

ActiveRecord

Railsで用いられているActiveRecordとは、MVCのMつまりModelの中核を担う階層で、主にデータベースとのやり取りを行います。これは、ORMシステムに記述されている「Active Recordパターン」を実装したライブラリであり、このパターンと同じ名前が付けられています。 Railsのモデルとして作成されたクラスにはApplicationRecordクラスがもれなく継承されていますが、これはActiveRecord::Baseを継承したものなので、Railsで生成される全てのクラスでこのActiveRecordのメソッドを使用することができるというわけです。

準備

以下に用いる例では、UserモデルとArticleモデルが出てきます。それぞれ以下のようなカラムを持っているものとします。

Userモデル

カラム
id integer
name string
age integer

Articleモデル

カラム
id integer
user_id integer
title string
body string

コンソールやRDBMSににアクセスして直接実行して確認したい方は、以下のリンクで解説と同じ環境を用意する手順を示しているので参考にしてみてください。

https://qiita.com/ttexan/private/63c9f07cd1a33af3d18d

1.ActiveRecordのメソッドと発行されるSQL文(基礎編)

ActiveRecordのメソッドとそれに対応してデータ取得のために発行されるSQLの様子は以下のようになります。(SQL文はrails console上に出てくるものと少し異なりますが、実際のRDBMSで実行できる形で示しています。)

# ①
User.all
# SELECT * FROM users;

# ②
User.find(1)
# SELECT * FROM users WHERE id = 1 LIMIT 1;

# ③
User.find_by(name: "太郎")
# SELECT * FROM users WHERE name = '太郎' LIMIT 1;

# ④
User.create(name: 'John', age: 20)
# INSERT INTO users (name, age) values ('John', 20);

# ⑤
User.first.update(name: "Mathias")
# SELECT * FROM users ORDER BY id ASC LIMIT 1;
# UPDATE users SET name = 'Mathias' updated_at = NOW() WHERE id = 1;

基礎編解説

①all

SELECT * FROM users;
コントローラーのindexでよく実行されるメソッドです。ご存知の通り、allは、全てのユーザーを取得するというActiveRecordのメソッドです。*はワイルドーカードで、全てという意味です。つまり、このSQLは「usersテーブルからuserの全てのカラムを取得する」という意味になります。取得されたデータはUserクラス(の配列)にマッピングされ、繰り返し処理や、カラムの値を表示する際には、Rubyのメソッドとして実行できるようになるという仕組みが提供されます。

②find

SELECT * FROM users WHERE id = 1 LIMIT 1;
findメソッドを実行すると途中まではallと一緒で最後に WHERE id = 1 LIMIT 1というのがつきます。このSQLは「usersテーブルからidが1のuserの一つ目のデータの全てのカラムを取得する」という意味です。allに条件がついただけです。

③find_by

SELECT * FROM users WHERE name = '太郎' LIMIT 1;
idで条件指定をしてユーザーを取得する際には、単純に1を指定すればよしなにやってくれましたが、それ以外のカラムで条件指定をしたい場合にはfind_byメソッドを用いてカラムと条件の指定を同時にします。
しかし、発行されたSQLを見るとWHERE id = 1の部分がWHERE name = '太郎'に変わっただけですね。ActiveRecordに限らず、このようにid検索の場合には簡略化されたメソッドを提供しているORMシステムは多く存在します。

④create

INSERT INTO users (name, age) values ('John', 20);
カラムと挿入したい値を指定し、このように書きます。sqlではデータを列に挿入するという感覚なので、insertという句が用いられているのだ思います。また、''""の使い分けは厳密で、文字列を使用する場合には''でくくる必要があります。

⑤update

SELECT * FROM users ORDER BY id ASC LIMIT 1;
UPDATE users SET name = 'Mathias' updated_at = NOW() WHERE id = 1;
updateとではまず、更新すべきデータの列を取得して、それを書き換えるという動作を行うため、二つのSQL文が発行されます。ここで用いられているfirstメソッドは、データベース上でidがもっとも小さいもののデータを取得するメソッドですが、SQLではその条件を「データをidの昇順に並べてその最初のデータを取ってくる」という指定の仕方をします。また、udpateのやり方は見ての通りですが、insertとは少しカラムと値の指定の仕方が異なるのです。

2.ActiveRecordのメソッドと発行されるSQL(応用編)

# ①
Article.pluck(:title)
# SELECT title FROM articles;

# ②
User.find(1).articles
# SELECT * FROM users WHERE id = 1 LIMIT 1;
# SELECT * FROM articles WHERE user_id = 1;

# ③
Article.all.includes(:user)
# SELECT * FROM articles;
# SELECT * FROM users WHERE id in (1,2,3...);

応用編解説

①pluck

今までは、*で指定した列の全てのデータを取得していましたが、pluckメソッドを使うことによってカラムを指定してデータを取得することが可能になります。取得するデータの規模が大きくなるほど実行速度は遅くなるので、適宜このようなメソッドを使用することがアプリケーションの性能改善に役立ちます。

②子モデルのAssociation

なんのことはない、ユーザーを見つけてそのユーザーのidを持ったarticleを取得しているだけです。しかし、それをrubyで簡単にかけるようなインターフェースを実装してくれているので、我々は非常に便利に使うことができるというわけです。

③includes

記事を全て取得してその記事のuser_idが含まれるユーザーを取得しているだけで、アソシエーションによる関連モデルの取得とどう違うのかと思われるかもしれません。しかし、この場合はあくまで@articlesにはarticleモデルの配列が入っています。これは例えばhtmlで

<% @articles.each do |a| %>
  <%= a.title %>
  <%= a.user.name %>
<% end %>

のように表示をしたい場合に必要となります。このテンプレートを表示するには、コントローラで@articles = Article.allと指定すれば問題なく表示されるのですが、実際には3行目のa.user.nameが呼ばれるたびに、裏でSELECT * FROM users WHERE id = (a.user_id)というSQLが毎回発行されてしまい動作が非常に重たくなってしまうのです。

これは俗にN+1問題と言われ、articlesを取得(1回)+articlesの数(=N回)分userを取得するSQLが発行されるため、そのように呼ばれています。それを解決するメソッドがincludesで、あらかじめ必要となるテーブルのデータを取得しておき、SQLの発行を2回で済ませます。

まとめ

ActiveRecordやデータベース、そしてその関係について説明しました。Railsでの開発はそもそもアプリケーションとデータベースが別れているということすら意識せずに使用できるため便利な反面、ちょっとしたエラーが起きるととたんに理解の浅さが浮き彫りになってしまうことがあります。これを機会にぜひ勉強してみてください。

発展的な内容

  • join, preload, eage_loadなどのさらに発展的な内容も理解したい方向け

https://qiita.com/k0kubun/items/80c5a5494f53bb88dc58

  • データベースや設計を学びたい方向け

達人に学ぶDB設計徹底指南書

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

一人で新規Webサービスを作ろうとしたら、Amplifyにたどり着いた

こんにちは。
フリーランスエンジニアのtelumoです。

はじめに

この記事では、

  • Amplifyを使うまでの経緯
  • 実際に使ってみた所感

について書きたいと思います。
具体的なAmplifyの使い方は他の記事や公式に任せたいと思います。
この記事では、私の目線でAmplifyを語っていますので、この記事を読めば体系的にAmplifyが理解できるとかは期待しないでください。。。(すみません)

Amplifyがバックエンドの選択肢の一つになる、そんなきっかけになれば幸いです。

Amplifyを使うまでの経緯

前提

今年、友人の紹介である建設会社でWebサービスを開発する依頼を受けました。
そのWebサービスは求人サイトで、サービスの特徴としては、地図から求人が探せるというものです。
大島てるの求人サイト版のようなイメージです。

得意なフレームワークでWebアプリを構築

はじめ私はDjangoでアプリを構築していました。
(一人プロジェクトだったので技術は自由に選べました)

Djangoは、

  • モデルを記述するだけでDBマイグレーションをやってくれる
  • ユーザーのクラスを継承するだけで複数ロールのユーザーを作成して、それぞれに認可を与えられる
  • テンプレートエンジンを利用してフロントが比較的楽にかける

といった特徴があります。
これらは、Djangoの特権でもなんでもなく、他のフルスタックフレームワークにも存在する便利機能です。

私はPythonが得意で、DjangoでのWebアプリ構築経験があったので、
「まずはDjangoで」
的なノリでアプリを構築しました。

プロトタイプが完成

地図を使ったアプリは作ったことがなかったので、最初の段階では実際にアプリを作りながら技術検証をしていました。
インフラ(AWSを利用)はpulumiでさくっと実装しました。

余談ですが、pulumiのawsxを利用すると、AWSのベストプラクティスに則ったインフラが数行のコードで書けるのでおすすめです。

そして、最初のプロトタイプが完成しました。

DjangoでWebアプリ自体は比較的に簡単に作れたのですが、なんか遅いんです。
地図をドラッグしたあとのアイドル状態時にデータベースからデータを取得して地図に表示する仕組みなのですが、
なんか遅い。

調べてみると、

  1. WebサーバーがDBサーバーからデータを取得するのに時間がかかっている
  2. ブラウザが取得するデータの通信量が大きい

というダブルパンチで遅くなっていることがわかりました。

まず1に関しては、WebサーバーとDBの最適化が必要だなと感じました。
プロトタイプなので、どちらも小さなスペックのものを利用していたので遅いのは当然なのですが、新規サービスでどれだけのスペックのものを用意すればいいのかが測りづらい、、、
当然ですが、サービスが利用されなくてもサーバー台はかかる。。。そもそも、一人プロジェクトでインフラに時間をかけたくないな。。。
みたいなことを考えていました。
スペックの問題以外にも、クエリが最適化されているかという点も自信がありませんでした。

次に2に関してですが、APIのレスポンスの実装がおかしいことがわかりました。
テストデータが数百件のうちは、いいんですが、数万件になった時に、取得するデータがたくさんあると、(当たり前ですが)表示も遅くなります。
だからAPIのレスポンスのパラメーターを適切に調節してあげればいいだけです。
しかし、それと同時に
もしかして、APIの修正が入ったらいちいちAPI側とブラウザ側書き直さなきゃいけない!?
みたいなことを考えていました。

WebサーバーをLambdaにする

これを解決するために、色々と試行錯誤しました。
後から考えると、この試行錯誤している時の私の失敗は、
Djangoメインで考えていたことです。
そもそも、Webアプリケーションを作るとなったらWebサーバー + RDBという構成は一般的で、
Djangoでなくても、そういう構成で利用できるフレームワークをまず考えてしまう人は多いと(勝手に)予想しています。

試行錯誤のうちの一つは、WebサーバーをLambdaに置き換えるというものです。
「Webサーバーがサーバーだからスペックやコストを気にする必要があるんだ。だったらサーバーレスにすればいいじゃない。」
という単純な思考です。

LambdaにDjangoを乗せるには、zappaを使えば簡単ですね。
ただ、ここで有名な問題にぶち当たります。
それは、「Lambda + RDS相性良くない問題」です。

ただ、Aurora ServerlessのData APIを利用すれば、その問題を解決できると思ってました。
確かにそれでLambdaからRDSは問題なく利用できます。
しかし、それだとDjangoの良さを殺すことになります。何せ、Data APIを呼び出すためのコードを別に書く必要があるのですから。

それに、「Webサーバー + RDB」構成において、RDB(RDS)が占めるコストは少なくありません。
新規サービスでリクエスト数もわからないのに常時稼働のRDBを保持するのは賢明ではないと思いました。

他にも、コンテナサービスであるFargateやk8sを考えましたが、学習コストが高いのと、RDBの問題で二の足を踏んでました。

Amplifyを思い出す

そんなこんなしている時にふとAmplifyを思い出しました。
AWS Dev Day 2018の「Dev Day Challenge」に参加した際に、チャットアプリを作りAmplifyを使ったのです。

Dav Dayのときは、開発時間もあっという間だったので詳しく理解することはできませんでしたが、
「そういえば、AWSソリューションアーキテクトの人がAmplifyをめちゃくちゃ推してたな」
という印象が強く残っていたので、調べてみることにしました。

すると、以下の点で今回の案件にぴったりなバックエンドだと感じました。

  • サーバーレスでリクエスト課金なので、リクエスト数に最適なインフラを構築する必要がない(そもそもBaaSなのでインフラを意識する必要ない)
  • GraphQLがデフォルトで使え、必要なデータをフロントが選択できる(APIを実装する必要がない)
  • ユーザーの種類に応じて権限を細かく設定できる(Cognitoとの連携がやりやすい)

ということで、色々ありましたがAmplifyを使ってみることにしました。
Amplifyを使うということは、Djangoはもう必要がないということです。

フロントはNuxt.jsでやりました。
地図の実装が重要なアプリケーションなので、HTMLを返Djangoのようなフレームワークは合っていなかったのかなと思います。

Amplifyを使ってみた所感

Amplifyたどり着くまでの話が長くなりましたが、ここからは実際に使ってみた感じたことを書きます。

結論を先に言うと、Amplify最高でした!
これからWebアプリケーションを作る際に、まず間違いなく有力な選択肢の一つになると思います。

そういえば、Amplify自体の説明をしていませんね。
Amplifyは、AWSのBaaSです。フロントからバックエンドを簡単に構築することができます。
詳しいことは、公式や記事等を参考にしてください。

では、所感を書いていきます。

scheme.graphqlが、データモデルのドキュメントになる

Amplifyでは、バックエンドとしてGraphQLが簡単に利用できます。
データストアはDynamoDBなのですが、GraphQLを挟むことで普通のCRUDや、チャットのようなサブスクリプションが必要なアプリが楽に構築することができます。

GraphQLを利用するために、scheme.graphqlというファイルを書きます。
逆に言うと、データストア周りで書く必要があるのはscheme.graphqlだけです。

これが意味するのは、scheme.graphql自体が実質的にデータモデルのドキュメントになるということです。
これは非常に楽です。このscheme.graphqlファイルを見るだけで、

  • どんなテーブルが存在するのか
  • それぞれのテーブルにどんなカラムが存在するのか
  • 各カラムの型
  • テーブルへのアクセス権限まわり

が一目瞭然なのです。

これをみてください。

type Salary @model @auth(rules: [{allow: groups, groups: ["Admin"]}]) {
  id: ID!
  wage: Int
  currency: String
}

上記は、Amplifyの公式に書かれた例です。
GraphQLを触ったことがない人でも少なくとも以下のことが読み取れるのではないかと思います。

  • Salaryモデルは、ID型のidInt型のwageString型のcurrencyという3つのフィールドから成り立っていること。
  • Adminグループにのみ何かしらの権限が与えられていること。

@modelディレクティブで実際にDynamoDBにテーブルを作ってくれるとか、
Adminグループに与えられる権限はCRUD全ての権限であるとか、
ID型のidはAWSが自動生成してくれるとか、、、
そう言ったことはドキュメントを読んで一つ一つ理解していく必要がありますが、このようなモデルをScheme.graphqlに記述していくだけで、テーブルが簡単に作れます。しかも、(Cognitoと連携して)どんなユーザーにどんな権限が許可されているのかもすぐにわかります。

さらにAmplifyはこのScheme.graphqlを読み取って自動でquery、mutation、subscriptionのJavaScript(or TypeScript)のスクリプトを自動生成してくれます!
素晴らしい。

Djangoやその他のフレームワークでも同じようなことができるとは思います。
Djangoの場合は、モデルのクラスを実装すればいいんです。
しかしアプリごとに別々のファイルに書く必要がありますし、所詮スクリプトなので定義的ではありません。

余談ですが、宣言的なコードを見ればドキュメントを書く必要がない(少なくとも見る必要がない)と言うのはエンジニアの理想です。
関数型言語が流行ったのもそういった背景があるからだと思います。

AmplifyはRDBユーザーでも利用しやすい

AmplifyのデータストアであるDynamoDBはいわゆる「NoSQL」です。そのため、RDBを主に利用する人にとっては理解しにくいところです。

しかし、AmplifyはRDBを利用する人が理解しやすいと感じます。
RDBでは、「1対多」や「多対多」の関係を利用してテーブルを作成していきますが、同様のことがScheme.graphqlにも記述できます。
(そもそも、AWSのGraphQLサービスであるAppSyncはバックエンドにRDSも利用できるので当たり前かもしれません。)

type Post @model {
  id: ID!
  title: String!
  comments: [Comment] @connection(keyName: "byPost", fields: ["id"])
}

type Comment @model
  @key(name: "byPost", fields: ["postID", "content"]) {
  id: ID!
  postID: ID!
  content: String!
  post: Post @connection(fields: ["postID"])
}

上記は、@connectionディレクティブを利用した1対多の関係を表したテーブルのサンプルコードです。
Postテーブルが複数のComment([Comment]のように配列で表している)を持っていることが一目瞭然です。
Post側にもComment側にも@connectionを記述することでどちらからでももう一方を取得することができます。

そして取得する時には、以下のようなGraphQLのクエリを発行すればいいのです。


query GetCommentWithPostAndComments {
  getComment( id: "a-comment-id-1" ) {
    id
    content
    post {
      id
      title
      comments {
        items {
          id
          content
        }
      }
    }
  }
}

GraphQLはフロントで取得したいカラムを選択できます。
さらにAmplifyはこのquery自体も自動で生成してくれます!
(もちろん自分でカスタマイズしたクエリを記述して利用することも可能です。)

多対多のモデルは、間にテーブルを一つ挟めば実現できます。(RDBと同じですね。)

type Post @model {
  id: ID!
  title: String!
  editors: [PostEditor] @connection(keyName: "byPost", fields: ["id"])
}

type PostEditor
  @model(queries: null)
  @key(name: "byPost", fields: ["postID", "editorID"])
  @key(name: "byEditor", fields: ["editorID", "postID"]) {
  id: ID!
  postID: ID!
  editorID: ID!
  post: Post! @connection(fields: ["postID"])
  editor: User! @connection(fields: ["editorID"])
}

type User @model {
  id: ID!
  username: String!
  posts: [PostEditor] @connection(keyName: "byEditor", fields: ["id"])
}

CI/CDもAmplify1つで。

複数環境をAmplify CLIで構築、デプロイできることは当たり前なのですが、Amplifyコンソールを利用すれば、GitHubなどのGitレポジトリと連携して環境毎のバックエンドを自動で選択してくれます。

環境 Gitレポジトリのブランチ バックエンド(Amplify)
本番環境 master prod
開発環境 dev dev

上記のように、本番環境と開発環境があって、Gitレポジトリのブランチがそれぞれmaster、devと分かれているとします。
そして、本番環境はバックエンドのprod環境を、開発環境はdev環境を利用したい。
よくありますよね。

それ、Amplifyコンソールで環境変数を設定しておけば、Amplifyが自動でやってくれます。
つまり、masterブランチに変更(プルリクからのマージ等が行われるなど)があった場合は、prodのバックエンドが利用され、
devブランチに変更があった場合は、devバックエンドが利用されます!
(もちろんバックエンドの環境は、本番、開発だけでなく自由に追加することができます。)

これは便利ですよね。
フロント側はバックエンドの指定等一切することがなく、Amplifyがやってくれるのです。

また、gitのブランチの変更を感知してデプロイされるタイミングでテストも自動でやってくれます。
現在はE2EのテストフレームワークであるCypressのみ利用できます。

おわりに

ざっくりとAmplifyを利用するに至った経緯と所感を私の視点ではありますが、書いてみました。

書き忘れましたが、最初の問題であった「レスポンスの遅さ」はAmplifyを利用することで解決していました。
もちろんサーバーを使っても、解決していたと思います。
しかし、課金コストの問題や一人でインフラを管理する時間的コストを考えると、Amplifyにして正解だと思います。

余談ですが、BaaSといえばFirebaseも有名ですが、

  • GraphQLが楽に使える
  • テーブルへのアクセス権限が同じファイルに楽にかけて管理しやすい
  • 環境を自由に分けられる

そんなAmplifyの方が私は好きです。

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

マイグレーションファイルのdef upとdef self.upの違いについて調べてみた

背景

社内のマイグレーションファイルにて、def updef self.upが混在していたのでどちらかに統一すべく(そもそも統一すべきかどうかも含めて)、違いを調べてみた。
(正確には、調べてみたけどまだ途中です)

環境

ruby 2.6.3
rails 5.2.3

やり方

適当なマイグレーションファイル内にbinding.pryを埋め込んで挙動を確認する。

def up

class CreateUsers < ActiveRecord::Migration[5.2]
  def up
binding.pry    # ここに埋め込む
    create_table :users do |t|
      t.string :name

      t.timestamps
    end
  end
end

実行

$ rails db:migrate
== 20191020160206 CreateUsers: migrating ======================================

    2:   def self.up
    3: binding.pry
 => 4:     create_table :users do |t|
    5:       t.string :name
    6: 
    7:       t.timestamps
    8:     end
    9:   end

pry(CreateUsers)> step  # nextだとcreate_tableの実行後に移動してしまうのでstepでcreate_tableの中に入る

    603: def method_missing(name, *args, &block) # :nodoc:
 => 604:   nearest_delegate.send(name, *args, &block)
    605: end

pry(CreateUsers)> self
=> CreateUser

pry(CreateUsers)> nearest_delegate   # CreateUsersクラスのインスタンスが格納されている
=> <##<CreateUsers:0x00007fbc300761f8
 @connection= #<ActiveRecord::ConnectionAdapters::SQLite3Adapter:0x00007f9f74064350 .....>
 @name="CreateUsers"
 @version=2019102016020

pry(CreateUsers)> name
=> :create_table

pry(CreateUsers)> args
=> [:users]

pry(CreateUsers)> block
=> #<Proc:0x00007fbc30176c10@/home/ec2-user/environment/sample/db/migrate/20191020160206_create_users.rb:4>

CreateUsersクラスのインスタンス(nearest_delegate)がcreate_tableを実行。

pry(CreateUsers)> step

    858: def method_missing(method, *arguments, &block)
 => 859:   arg_list = arguments.map(&:inspect) * ", "
    860: 
    861:   say_with_time "#{method}(#{arg_list})" do
    862:     unless connection.respond_to? :revert
    863:       unless arguments.empty? || [:execute, :enable_extension, :disable_extension].include?(method)
    864:         arguments[0] = proper_table_name(arguments.first, table_name_options)
    865:         if [:rename_table, :add_foreign_key].include?(method) ||
    866:           (method == :remove_foreign_key && !arguments.second.is_a?(Hash))
    867:           arguments[1] = proper_table_name(arguments.second, table_name_options)
    868:         end
    869:       end
    870:     end
    871:     return super unless connection.respond_to?(method)
    872:     connection.send(method, *arguments, &block)
    873:   end
    874: end

pry(#<CreateUsers>)> self   # nearest_delegateと同じ
=> #<CreateUsers:0x00007fbc300761f8
 @connection= #<ActiveRecord::ConnectionAdapters::SQLite3Adapter:0x00007f9f74064350 .....>
 @name="CreateUsers"
 @version=2019102016020

pry(#<CreateUsers>)> method
=> :create_table

pry(#<CreateUsers>)> arguments
=> [:users]

create_tableのレシーバはCreateUsersであり、CreateUsersの親クラス(ActiveRecord::Migrationなど)や読み込んでいるモジュールのクラスメソッドにもないっぽいので、クラスメソッドのmethod_missingが呼ばれる。

def up

とりあえず実行

$ rails db:migrate
== 20191020160206 CreateUsers: migrating ======================================

From: /home/ec2-user/environment/sample/db/migrate/20191020160206_create_users.rb @ line 4 CreateUsers#up:

    2:   def up
    3: binding.pry
 => 4:     create_table :users do |t|
    5:       t.string :name
    6: 
    7:       t.timestamps
    8:     end
    9:   end

pry(#<CreateUsers>)> self
=> #<CreateUsers:0x00007f8f04070c70
 @connection= #<ActiveRecord::ConnectionAdapters::SQLite3Adapter:0x00007f9f74064350 .....>
 @name="CreateUsers",
 @version=20191020160206>

pry(#<CreateUsers>)> step

From: /home/ec2-user/environment/rails/activerecord/lib/active_record/migration.rb @ line 859 ActiveRecord::Migration#method_missing:

    858: def method_missing(method, *arguments, &block)
 => 859:   arg_list = arguments.map(&:inspect) * ", "
    860: 
    861:   say_with_time "#{method}(#{arg_list})" do
    862:     unless connection.respond_to? :revert
    863:       unless arguments.empty? || [:execute, :enable_extension, :disable_extension].include?(method)
    864:         arguments[0] = proper_table_name(arguments.first, table_name_options)
    865:         if [:rename_table, :add_foreign_key].include?(method) ||
    866:           (method == :remove_foreign_key && !arguments.second.is_a?(Hash))
    867:           arguments[1] = proper_table_name(arguments.second, table_name_options)
    868:         end
    869:       end
    870:     end
    871:     return super unless connection.respond_to?(method)
    872:     connection.send(method, *arguments, &block)
    873:   end
    874: end

create_tableのレシーバがインスタンスになったので、インスタンスメソッドのmethod_missingが呼ばれる。

これ以降はupself.upも同じ(だと思う)。

結論

  • self.upの場合はクラスメソッドのmethod_missingが呼ばれ、その後にレシーバにインスタンスを指定した後、再度インスタンスメソッドのmethod_missingが呼ばれる。
  • upの場合はインスタンスメソッドのmethod_missingだけが呼ばれる。


いやいや


nearest_delegateってなんなの

nearest_delegate

module ActiveRecord



 class Migration



   class << self
     attr_accessor :delegate # :nodoc:
     attr_accessor :disable_ddl_transaction # :nodoc:

->  def nearest_delegate # :nodoc:
       delegate || superclass.nearest_delegate
     end

accessor :delegateがある。あと下の方に、

   def initialize(name = self.class.name, version = nil)
      @name       = name
      @version    = version
      @connection = nil
    end

    # instantiate the delegate object after initialize is defined
    self.delegate = new

とかあったので、今回の場合、

CreateUsers.delegate = CreateUsers.new(name : "CreateUsers", version: 2019102016020, connection: #<ActiveRecord::ConnectionAdapters::SQLite3Adapter:0x00007f9f74064350 .....>)

ということ?

結び

引き続き調査します。

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

自分なりのテストの書き方 by RSpec

くふうカンパニーアドベントカレンダー20日目の記事です。

今年の新卒研修の中で自動テストの研修を担当してRSpecを使ったテストの書き方を説明しました。
しかしながら新卒エンジニア達からRSpecの使い方はわかったけれど、テストってどうやって書いていけばいいのと質問を受けたので私が普段どのようなステップを踏んでテストを書いているか振り返ってみることにしました。まだまだ不十分な研修で申し訳ない気持ちです?
使用するテストフレームワークは実務で利用しているRSpecです。

新卒たちの質問

  • そもそもテストって何をテストするの
  • どこまでテストを書けばいいの
  • どこから書き始めてばいいの

そもそもテストって何をテストするの

作っている機能が動作することを確認(テスト)します。
例えば名前とメールアドレスを登録できる機能があったとします。ユーザ目線ではフォームから名前とメールアドレス入力して確定すると、登録されたというメッセージと登録情報が画面に表示される機能となります。そして内部構造に注目するとデータの有効性を検証しデータベースに保存されて登録完了画面にリダイレクトする機能になります。
これらの機能が仕様通り振る舞いが正しく動作しているかテストで確認するようにします。
ユーザ目線で仕様通り動作するか確認するテストはE2EテストとしてSystem specで、開発者目線で動作しているメソッドの振る舞いを確認するテストはユニットテストとしてModel specやController specで書いています。
今回はとりあえずModel specを例に書きました。

どこまでテスト書けばいいの

どの程度の品質基準を目指すのかはソフトウェアテストの基準を参考にしています。

例としてUserクラスに以下のようなメソッドがあったとします。
ユニットテストに関してはホワイトボックステストの条件網羅(C2)を目指して書いています。下のようにシンプルな処理の時は分岐網羅(C1)で留めているものもあったりしますが。。

def able_to_send_mail?
if name && email
  true
else
  false
end

条件網羅の時は、nameとemailの両方がtrue、emailのみfalse、nameのみfalseの3つのテストケースのテストを書くようにします。nameがfalseであればemailの真偽は判定に影響しないのでnameとemailがfalseのケースは省略しています。条件を書くか省くか迷った場合は書いておけば漏れはないので問題はありません。

name email name.present? email.present? if文の判定
ケース1 'bob' 'bob @mail.com' true true true
ケース2 'bob' nil true false false
ケース3 nil 'bob @mail.com' false true false

分岐網羅の時はこのようなコードがあった場合はif文がtureになるテストケースとfalseになるテストケースの2種類のテストを書くようにします。

name email name.present? email.present? if文の判定
ケース1 'bob' 'bob @mail.com' true true true
ケース2 nil 'bob @mail.com' false true false

どこから書き始めればいいの

テストで網羅するテストケースはわかったので次は実際テストを順序立てて組み立てていきます。
テスト用のファイルが自動生成されていればそれを使って、なければ新しく作成します。

テスト対象を書く

require 'rails_helper'

RSpec.describe User, type: :model do
  describe '#able_to_send_mail?' do
  end

  # 他のメソッドがある場合は同じインデントで並べて書きます
  describe '他のメソッド' do
  end
end

describeでテスト対象のメソッド名を書きます。
他にもテスト対象のメソッドが追加された場合は同じインデントでテストを追加していきます。

あり得るテストケースを書き出す

require 'rails_helper'

RSpec.describe User, type: :model do
  describe '#able_to_send_mail?' do
    context 'nameとemailが存在する場合' do
    end
    context 'emailがnilの場合' do
    end
    context 'nameがnilの場合' do
    end
  end
end

contextで先ほど網羅したメソッドの実行する時にあり得るテストケースを書き出します。コンテキストをネストして書くこともできますが、ネストが3重くらいになってくるような場合はメソッドの設計を見直すことを考えます。
日本語で書くか英語で書くかは全体で決まっていればどちらでもいいと思います。

期待する結果を書き出す

require 'rails_helper'

RSpec.describe User, type: :model do
  describe '#able_to_send_mail?' do
    context 'nameとemailが存在する場合' do
      it 'trueを返すこと' do
      end
    end
    context 'emailがnilの場合' do
      it 'falseを返すこと' do
      do
    end
    context 'nameがnilの場合' do
      it 'falseを返すこと' do
      end
      # 他にも期待する結果がある場合は書きます
      it '他の期待値' 
      end
    end
  end
end

itを使って期待する結果を書き出します。
ここまで書けると後からテストを見た時にメソッドがどのような振る舞いをするかわかるようになってきました。

テストコードを実装する

require 'rails_helper'

RSpec.describe User, type: :model do
  describe '#able_to_send_mail?' do
    context 'nameとemailが存在する場合' do
      it 'trueを返すこと' do
        user = User.create(name: 'bob', email: 'bob@mail.com')
        expect(user.able_to_send_mail?).to eq true
      end
    end
    context 'emailがnilの場合' do
      it 'falseを返すこと' do
        user = User.create(name: 'bob', email: nil)
        expect(user.able_to_send_mail?).to eq false
      do
    end
    context 'nameがnilの場合' do
      it 'falseを返すこと' do
        user = User.create(name: nil, email: 'bob@mail.com')
        expect(user.able_to_send_mail?).to eq false
      end
    end
  end
end

期待する結果を検証するためのコードをexpectを使って書いていきます。
ここまでできれば一旦テストとしては完成です。

テストのリファクタリング

require 'rails_helper'

RSpec.describe User, type: :model do
  describe '#able_to_send_mail?' do
    subject { user.able_to_send_mail? }
    context 'nameとemailが存在する場合' do
      let(:user) { User.create(name: 'bob', email: 'bob@mail.com') }
      it { is_expected.to eq true }
      end
    end
    context 'emailがnilの場合' do
      let(:user) { User.create(name: 'bob', email: nil) }
      it { is_expected.to eq false }
    end
    context 'nameがnilの場合' do
      let(:user) { User.create(name: nil, email: 'bob@mail.com') }
      it { is_expected.to eq false }
    end
  end
end

重複を省いたり変数をletに書き出したりリファクタリングしました。
expect(user.able_to_send_mail?)はsubjectでまとめて、ローカル変数だったuserはletを使うようにしました。期待する値も真偽値のみなので自然言語で書かれた期待値は削除しました。
もっとも過度にリファクタリングしてコードの重複を省いた結果、コードを追うことが難しくなるようであれば後からメソッドの振る舞いを知るのが難しくなってしまうので、ある程度の重複は許容してもいいと考えています。

まとめ

今回は私がどのように考えながらテストコードを書いていっているか振り返ってみました。
今年のフィードバックを糧に今後の新卒たちの研修を改善していきたいと思います。

合わせて読みたい

第6回 Webアプリケーションのテスト
ホワイトボックステストにおけるカバレッジ(C0/C1/C2/MCC)について
【初心者向け】テストコードの方針を考える(何をテストすべきか?どんなテストを書くべきか?)
RSpecえかきうた

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

Rails6 のちょい足しな新機能を試す112(database.yml warning編)

はじめに

Rails 6 に追加された新機能を試す第112段。 今回は、database.yml warning 編です。
Rails 6 では、複数データベースに対応していますが、 database.yml ファイルで複雑なERBが使われていると警告が出るようになっています。

Ruby 2.6.5, Rails 6.0.1 で確認しました。 (Rails 6.0.0 でこの修正が入ったようです。)

$ rails --version
Rails 6.0.1

今回は、警告を出すために、 環境変数 DB によって、 MySQLデータベースかPostgreSQLデータベースにアクセスを切り変えるということで試してみたいと思います。
複数データベースの機能は使いません。

Rails プロジェクトを作成する

$ rails new rails_sandbox
$ cd rails_sandbox

Gemfile に mysql2 と pg を追加する。

MySQL と PostgreSQL どちらでも接続できるように、 Gemfile に mysql2 と pg を追加します。

Gemfile
gem 'pg', '>= 0.18', '< 2.0'
gem 'mysql2'

bundle install を実行します。

$ bundle install

database.yml ファイルを編集する

database.yml を変更します。

MySQL 用の接続設定と PostgreSQL 用の接続設定を追加します。

環境変数によって、接続を切り変えられるようにします。

ここで、ポイントは、 <<: *<%= ENV.fetch("DB") { 'postgresql' } %> となっている部分です。
この記述によって警告が出るようになります。

config/database.yml
mysql: &mysql
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password:
  host: db_mysql

postgresql: &postgresql
  adapter: postgresql
  encoding: unicode
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  host: db_postgresql
  user: <%= ENV.fetch("POSTGRES_USER") %>
  password: <%= ENV.fetch("POSTGRES_PASSWORD") %>

development:
  <<: *<%= ENV.fetch("DB") { 'postgresql' } %>
  database: app_development
  ...

test:
  <<: *<%= ENV.fetch("DB") { 'postgresql' } %>
  database: app_test
  ...

production:
  <<: *<%= ENV.fetch("DB") { 'postgresql' } %>
  database: app_production
  ...

スクリプトを作成する

確認のためだけなのですが、 database.yml を ERB でパースして結果を出力するスクリプトを書いておきます。

scripts/parse_database_yml.rb
yml = ERB.new(File.read(Rails.root.join('config/database.yml')))
print yml.result

スクリプトを実行する

スクリプトを実行します。特にエラーもなくパースできています。

development: の次の行が、 <<: *postgresql と処理できていることに注意してください。

$ bin/rails runner scripts/parse_database_yml.rb
Running via Spring preloader in process 104
mysql: &mysql
  adapter: mysql2
  ...

postgresql: &postgresql
  adapter: postgresql
  ...

development:
  <<: *postgresql
  database: app_development
  ...

db:create を実行する

db:create を実行します。
Rails couldn't infer whether you are using multiple databases from your database.yml ... please simplify your ERB. と警告が出ます

$ bin/rails db:create
Rails couldn't infer whether you are using multiple databases from your database.yml and can't generate the tasks for the non-primary databases. If you'd like to use this feature, please simplify your ERB.
Created database 'app_development'
Created database 'app_test'

なぜ警告が出るのか

複数データベースに対応するために、 Rails は、 boot 完了する前に、 database.yml を読んで、task を生成しているそうです。task 生成する時点では、 ERB を使っておらず DummyCompiler を使って解析しており、この DummyCompiler では処理できない場合に警告を出すようにしたということらしいです。

どうすれば良いのか

結論としては、ERB の部分を警告が出ないように書き変えれば良いということになります。
では、一般的にどう書き変えれば良いのかは、筆者にもわかっていません。

ワーニングを出ないように修正する

今回の場合は、以下のように書き変えることで警告が出ないようにすることができました。
default を追加して if 文使って切り変えるようにして、 development などの環境では、 << *default を使って ERB を使わないように修正しました。

config/database.yml
mysql: &mysql
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password:
  host: db_mysql

postgresql: &postgresql
  adapter: postgresql
  encoding: unicode
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  host: db_postgresql
  user: <%= ENV.fetch("POSTGRES_USER") %>
  password: <%= ENV.fetch("POSTGRES_PASSWORD") %>

default: &default
  <% database = ENV.fetch("DB"){ 'postgresql' } %>
  <% if database == 'postgresql' %>
  <<: *postgresql
  <% else %>
  <<: *mysql
  <% end %>

development:
  <<: *default
  database: app_development
  ...

test:
  <<: *default
  database: app_test
  ...

production:
  <<: *default
  database: app_production
  ...

試したソース

試したソースは以下にあります。
https://github.com/suketa/rails_sandbox/tree/try112_warn_database_yml - 警告が出る方
https://github.com/suketa/rails_sandbox/tree/try112_warn_database_yml_fix - 警告が出ないように修正した方

参考情報

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

.present?はnilかそうじゃないか、という場合は不要

キャンペーンコードから適用されるキャンペーンとプランに紐づくキャンペーンがあるんだけど、キャンペーンコードからのキャンペーンを配列の一番最初に表示させたかった時の実装。

辞書型(hash型)なのが注意。
keyでキャンペーンコードからのものを探して一旦削除してunshiftで先頭にpushしてるんだけどいまいち納得いってない。。もっといい方法ないのかな?

修正前

order_base_decorator.rb
  def order_campaigns
    c = options['campaigns'].to_a
    cc = c.find{|x| x["key"] == "campaign-code"}
    c.delete(cc) if cc.present?
    c.unshift(cc)  if cc.present?
    if c.present?
      unless c.first == {} || cc
        return c.unshift({}).to_a
      end
      return c
    end
    return c
  end

修正後

order_base_decorator.rb
  def order_campaigns
    c = options['campaigns'].to_a
    cc = c.find{|x| x["key"] == "campaign-code"}
    # cのなかで見つからなかったらccはnil 
    if cc
      c.delete(cc)
      c.unshift(cc)
    end
    if c.present?
      unless c.first == {} || cc
        return c.unshift({}).to_a
      end
      return c
    end
    return c
  end
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails: exifにタイムゾーンがないからどんな感じで保存しようかなと思った話

Classi Advent Calendar 2019 の18日目、Railsアプリケーションエンジニアをやっている@HokotaMisakiです。

今回書くお話はClassiではなく、自宅で作っている野鳥観察記録サービスで体験したものなのですが、マニアックでピンポイントなケースだと思ったので紹介します(テーマ的にRails絡んでるからセーフだと思ってる)。

主に、exifにタイムゾーンの有り無しを確認することになった経緯についての話になります。

※ なおexifとは、画像の中に埋め込まれているメタデータのことです。位置情報や時刻、撮影カメラの設定情報などが保存されています。

まずは結論から

この2つのあわせ技で行くことにしました。

  1. ユーザに自身のタイムゾーンを登録してもらう
  2. アプリケーションはユーザ毎のタイムゾーンで動き、時刻をそのままアプリケーションのタイムゾーンで保存する

「えっ、exifらしさもないし、なんか普通・・・」

って感じなんですが、ではなぜこれに行き着いたのか、なぜこれをしなければならなかったのか、を以下に記載します。

前提

自宅で作っている野鳥観察記録サービス「ZooPicker」は、世界中で撮った野鳥の写真とフィールドノート(観察場所や時間、観察種類などを記録するもの)を投稿していくサービスです。
投稿したものは公開されるので、野鳥情報の共有サービスとも言えます。
ゆくゆくは海外展開もしていきたいので、I18n系の対応も徐々に進めています。

ことの発端

バーダー(野鳥を追いかけている人たちのこと)にとって、野鳥が「どこで」「いつ」出たのか、は大事な情報です。
なので素敵な野鳥写真が投稿されると、「これ!!!!どうやったら見れるの???」となります。

撮った写真の場所を登録する機能はあったので(exifの位置情報は捨ててるので手入力なんですが)、
撮った写真の時刻(⇒ 「撮影現地時刻」)も登録できるようにしよう!と思いました。

なお、写真の投稿はだいたい帰宅後に行うので、投稿日時は使えません。

やりたいこと: 世界の誰が見ても、撮った現地の時刻で表示させたい

ご存知のとおり、時刻およびデータベースの時刻系カラムにはタイムゾーンがあり、
かつ、ActiveRecordはそれをアプリケーションのタイムゾーンに応じて出してくれるという便利機能を持っています。

加えて、写真にはexif情報があるので、
このexifにきっと時刻情報あるだろうから、それ保存すればいいじゃーんと思いました。

下の絵のように、「撮影現地時刻」がアプリケーションのタイムゾーンに自動変換されちゃう事態は避けたかったわけです。
スクリーンショット 2019-12-18 11.25.54.png

つまづいた点

exifに時刻はあるけどタイムゾーンは無い

はい。タイトルのとおりなのですが。おおよそ無いようです。
Wikipediaより:
Exchangeable image file format

撮影日時の情報は、UTCとタイムゾーンを組み合わせたものではなく、機種依存のローカルタイム(現地時刻)のみで記録され、タイムゾーン情報が記録されていないので、海外旅行や出張などタイムゾーンをまたいで移動、生活する際に問題となることもある。なお、タイムゾーン情報が記録できるカメラなどもあるが機種依存の機能であって、Exif共通の仕様ではタイムゾーン情報の付加には対応していない。

言われてみればそうかーという感じです。カメラがWebに繋がんないとですもんね・・・

なお、時刻情報はOriginalTimeという項目で存在します。

「投稿日時」は時差計算されてもいいけど、「撮影時刻」は現在地の時刻を出したい

ActiveRecordは、アプリケーションのタイムゾーンに応じて時刻を表示してくれますね。
ところが、「撮影現地時刻」だけは自動変換されてほしくありません。
じゃあどこのタイムゾーンで表示するの!カメラに現地時刻入ってないのに!となりました。

カメラのOriginalTimeがどこのタイムゾーンかの判定方法がわからない

わかれば、場所と照らし合わせて撮影時刻算出できるのになぁ。

結果

とりあえず ユーザのタイムゾーン≒カメラのタイムゾーン ってことにしよう

今回のケースを実現するロジックは、

1. 何らかでタイムゾーンを保存(exifの位置情報でタイムゾーンを算出 or ユーザにタイムゾーンを設定してもらう)
2. カメラのOriginalTimeから時差計算して「撮影現地時刻」を算出
3. 保存
4. 表示するときに「保存したタイムゾーンで出す」をやる

と思っています。

exifからの算出は、もっとタイムゾーン付きカメラが普及してからにしよう・・・と思いました。
それまでは、多少そぐわないケースもあるかもしれないけど、だいたい一致する「ユーザの希望タイムゾーン」で。

現在は、時刻が取れたらそのまま自動入力、修正がある場合は手入力可、としています。

ちなみに1度「文字列で時刻保存する・・・?」と思ったです。

できあがったコード

datetime =
  if v[:exif][:dateTimeOriginal]
    # exifにはタイムゾーン情報が基本ない
    # とりあえずtimezone設定を使う
    timezone = Time.current.strftime('%z')
    DateTime.strptime(
      v[:exif][:dateTimeOriginal].gsub('-', ':') + " #{timezone}",
      '%Y:%m:%d %H:%M:%S' + " %z"
    )
  end

何も解決してない

実は「ユーザ自身のタイムゾーンを登録する機能」は未実装なので、サービス的にはまだ何も解決してません!

参考: システム的なこと

  • Rails5.1(当時)
  • 画像投稿するときは、フロントでバイナリからexifを取り出してサーバにリクエストしている
    • exifは保存してない
    • exif-jsを使ってる

おわりに

おおよそのケースではRailsはタイムゾーンのことをほとんど意識せずに済むような仕組みが整っていると思います。
今までの業務でも海外展開しているサービスは触ったことがなかったので、今回改めて勉強になったなぁと思いました。

明日は @hakshu さんです!

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

5kg太ったのでイケメンがダイエットを応援してくれるLine bot作ろうとしてた話

クソアプリ Advent Calendar 2019がバズってるのを見て、約2年前に作ろうとしたクソ設計☆クソアプリを思い出した。。。

どんなアプリ

イケメン(仮)に「目標体重」と「今日の体重」を教えて、その差分から罵倒されたり褒められたりするLineBot。


(実際の体重ではございません)

2年前の私

ワイ 「おもちもおせちも美味しい。みかんも食べ放題。幸せだ...」
(久々に体重計乗る)
ワイ 「うわ!5kg太った!!!!!!(泣)」
ワイ 「痩せなきゃ。どうすればいいんだ。イケメンに叱られたら痩せるのに。」
ワイ 「....」
ワイ 「そうか、罵倒してくれるイケメンおらんかったら作ればいいのか(脳死)」

使ってた技術

  • Messaging API (LINE)
  • ruby on rails 5.2.3
  • Heroku

おそらく参考にしてた記事

今の私

とりあえず、Line Bot自体はみつからなかったから、コードだけよんでみよう...
ん?なんだこれは.....
クソアプリ以前にクソ設計じゃん!!!!(大声)

【クソポイント 1】 私の目標体重はいずこへ...

とりあえず、データの構造を見てみよう...
ん...? app/modelsの中に何も書いてない。もちろんdbの中にも何もない...

私の目標体重はいずこへ...

クラス変数に格納されてた。

このアプリでは、linebot_controller.rbにしかほとんんどコードが書いておらず、そこに全ての処理が書いてあった。

linebot_controller.rb
class LinebotController < ApplicationController
  require "line/bot"  # gem "line-bot-api"

  # callbackアクションのCSRFトークン認証を無効
  protect_from_forgery :except => [:callback]

  def client
    @client ||= Line::Bot::Client.new { |config|
      config.channel_secret = ENV["LINE_CHANNEL_SECRET"]
      config.channel_token = ENV["LINE_CHANNEL_ACCESS_TOKEN"]
    }
  end

  def callback
    body = request.body.read
    signature = request.env["HTTP_X_LINE_SIGNATURE"]
    unless client.validate_signature(body, signature)
      error 400 do "Bad Request" end
    end

    events = client.parse_events_from(body)

    ・・・

    events.each { |event|
      case event
      when Line::Bot::Event::Message
        case event.type
        when Line::Bot::Event::MessageType::Text

          if event.message["text"].match(/設定/)
            if t = event.message["text"].match(/([1-9][0-9]{0,2}|0)(\.[0-9])?/)
              @@targetWeight =  t[0].to_f
              ・・・
            end
          elsif ・・・
           ・・・
          end
          client.reply_message(event["replyToken"], message)
        end
      end
    }
  end
end

うん、つっこみどころ満載。

Messaging APIのリクエストボディでは、destination(ボットのユーザーID)とevents(ユーザーが発火)が格納されており、eventsの中は下記のようなjsonオブジェクトになってます。

{
  "replyToken": "",
  "type": "message",
  "mode": "active",
  "timestamp": 1462629479859,
  "source": {
    "type": "user",
    "userId": "..."
  },
  "message": {
    "id": "325708",
    "type": "text",
    "text": "目標体重を50kgに設定して"
  }
}

ユーザーを識別することは非常に簡単そう。

それなのにユーザーごとのデータを管理せずにクラス変数で目標体重を管理している。
つまり、この目標体重は全ユーザー共通で書き換え自由な状態になっていたのだ。
自分の目標体重、丸わかり。。。。

あれかな。みんなのイケメン(仮)になるのが嫌だったのかな。

【クソポイント 2】 性癖.yml

DBがないけどイケメンはセリフを返す。
しかも、目標体重に近づけば優しくなる。
これはどうゆう仕組みなんだろ...と探っていったら...

性癖.yml
※ これ以上はお見せできません

イケメン(仮)に言われたいセリフがみっちり書いてある性癖.ymlが発掘されました。
全部自分で羅列してったのか...痛いぞ、自分。

また、yamlも絶妙に読みづらい。
入れ子になった配列がずらーーーっとならんでおり、voice["message"][差分][乱数]で取ってくるって感じ。
配列にする必要なかった部分まで配列...

linebot_controller.rb
# ymlファイル読み込み
voice = YAML.load_file("#{Rails.root}/config/project.yml")

# diffは目標体重と現在の体重の差分
message_length = voice["message"][diff].length - 1
diet_message = voice["message"][diff][rand(message_length)]

message = {
  type: "text",
  text: "今日の体重は#{weight}kgで、目標体重の#{@@target}まであと#{sprintf("%.1f", diff)}kg減か!#{diet_message}"
}

【クソポイント 3】
ワイ『今日は23歳の誕生日!50kgだったよ!』
イケメン(仮)『今日の体重は23kg』

messageに数字があったら、必ず最初に出てくる数字を体重だと扱う処理がかいてあるため、イケメン(仮)と円滑な会話が全くできない仕様。
書き方もつっこみどころ満載...

if myWeight = event.message["text"].match(/([1-9][0-9]{0,2}|0)(\.[0-9])?/)
  myWeight = myWeight[0].to_f #やばい
 # 果てしなく痛い処理の連続
end

実際のところ

Q. このbotをつかいましたか?
A. 使ってないです。性癖.ymlを書いてる時に目が覚めました。

Q. 痩せました?
A. 全くもって痩せなかったです。

さいごに

しょうもない記事を最後まで読んでくださり、ありがとうございます。
いつもは結構真面目に記事を書いてるので、よかったら読んでください。

ちなみに、ダイエットに一番効力あったのは、好きな人に言われた「肉団子」のひとこと。
痩せたい。

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

Rails のバグ報告テンプレートがすごい!

この記事は

ruby-jp の #rails チャンネルで知った Rails のバグ報告テンプレートが目からウロコだった話をします。bug_report_templates/active_record_gem.rb の存在を知らない方向けです。

概要

気軽に ActiveRecord の動作を確かめたい

ちょっと ActiveRecord の動作を確かめたい。でも手元にちょうといい Rails プロジェクトがない。rails new するのは面倒だ……。マイグレーションも書かなきゃ……。

そんなとき、Rails のリポジトリに含まれている次の Ruby スクリプトが役に立ちます。Rails のバグ報告テンプレートだそうです。

https://github.com/rails/rails/blob/v6.0.2/guides/bug_report_templates/active_record_gem.rb

active_record_gem.rb
# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  # Activate the gem you are reporting the issue against.
  gem "activerecord", "6.0.0"
  gem "sqlite3"
end

require "active_record"
require "minitest/autorun"
require "logger"

# This connection will do for database-independent bug reports.
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = Logger.new(STDOUT)

ActiveRecord::Schema.define do
  create_table :posts, force: true do |t|
  end

  create_table :comments, force: true do |t|
    t.integer :post_id
  end
end

class Post < ActiveRecord::Base
  has_many :comments
end

class Comment < ActiveRecord::Base
  belongs_to :post
end

class BugTest < Minitest::Test
  def test_association_stuff
    post = Post.create!
    post.comments << Comment.create!

    assert_equal 1, post.comments.count
    assert_equal 1, Comment.count
    assert_equal post.id, Comment.first.post.id
  end
end

解説

このスクリプトは次のことを行っています。

  • bundler/inline を使って Ruby スクリプト上で bundle install を行っている。
    • gemfile メソッドの第 1 引数に true を渡すことで、まだインストールしていない Gem をインストールする。
    • (Bundler のバージョンが 2.1.0 以上の場合) gemfile メソッドの第 2 引数に quiet: true を渡すと bundle install 時のログ出力を抑制できる。
    • BUNDLE_PATH は無視する。つまり Gem は必ずグローバルにインストールされる。
  • ActiveRecord::Base.establish_connection の database オプションに :memory: を渡すことで、SQLite のデータベースをメモリ上に作成している。
  • ActiveRecord::Schema.define を使うことで、スクリプト内でテーブルを定義、作成している。
  • スクリプト内でモデルを定義している。
  • Minitest::Test で単体テストを実行している。

いろんなテクニックがシンプルに詰まっていて、いいスクリプトだなと思いました :relaxed:

参考

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

Rails6 Bootstrap link_toを使ってmethodとclassを両方とも指定しよう

目的

  • ログアウトボタンを作る時にlink_toを使用してmethodclassを指定する際に若干詰まった内容をまとめる

結論

  • methodとclassを個々に中括弧でくくるのではなく一緒に定義する。

    <!-- NG -->
    <li class="navbar-item"><%= link_to("ログアウト", "/logout", {method: "delete"}, {class: "nav-link"}) %></li>
    
    <!-- OK -->
    <li class="navbar-item"><%= link_to("ログアウト", "/logout", {method: "delete", class: "nav-link"}) %></li>
    

    情報

  • header内をBootstrapを使用して書いている。

  • 今までmethodを指定していなかったのでそのままaタグを使用してheaderメニューを書いてた。

  • aタグをlink_toに置き換えたい。

困ったこと

  • link_toを使用して個々の中括弧でmethodclassを指定すると構文エラーが出た。
  • 下記にエラーが出た時のコードを記載する。

    <li class="navbar-item"><%= link_to("ログアウト", "/logout", {method: "delete"}, {class: "nav-link"}) %></li>
    

解決法

  • 同じ中括弧内でmethodclassを指定すれば良い。
  • 下記に正常に表示された時のコードを記載する。

    <li class="navbar-item"><%= link_to("ログアウト", "/logout", {method: "delete", class: "nav-link"}) %></li>
    
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む