Railsガイドにきちんと目を通して新しい知識を得る - Rails の自動読み込みと再読み込み編 -

ドキュメントを読み込むのは大事、ということでRailsガイドを頭から読んでいく取り組みをしています。 各章ごとに、(Railsガイドにちゃんと書いてあるのに)知らなかった機能を雑にまとめていきます。

今回は、Rails の自動読み込みと再読み込みの章です。

railsguides.jp

config.autoload_paths

リンクはこちら

app/下のディレクトリは自動読み込みされる。

例えばapp/samples/dummy.rbの中でDummyクラスを定義すると、rails consoleで起動した際などに読み込みができる。

一方で、例えばルートディレクトリ直下にsamples/dummy.rbというファイルを作った場合、samplesディレクトリは自動読み込みの対象外なのでDummyクラスは見つからない。

この場合に、config.autoload_pathsに登録することで自動読み込みの対象にできる。

# application.rb
config.autoload_paths << "#{root}/samples"

一度しか自動読み込みしないconfig.autoload_once_paths

リンクはこちら

アプリケーション起動時の一度だけ読み込まれるファイルを指定します。

config.enable_reloading = trueとの関係はどうなるんだろう?と思って開発環境で試してみました。

# development.rb
config.enable_reloading = true
config.autoload_once_paths << "#{root}/samples"

仮に上記のように設定した場合、samples配下のファイルを修正してリロードしても修正が反映されませんでした。

reload!

リンクはこちら

reloadをするとクラスが別物になります。アプリケーション内でreload!を入れることはあんまりなさそうですが、知っておくとハマりポイントを1つ回避できそうですね。

book_1 = Book.new
reload!
book_2 = Book.new

book_1.class.object_id
#=> 245440
book_2.class.object_id
#=> 462620

初期化時にリロード可能な定数を参照することは禁止

リンクはこちら

あまり使うケースがなく知らなかったのですが、config/initializers内でActiveRecordのクラスを読み込むなどしようとするとNameErrorで失敗します。

# config/initializers/sample.rb
p Book.new
rails s
# NameError

コードの変更(今回の例だとBook)があった場合ごとに読み込みたい場合はconfig.to_prepareを使えます。

# config/initializers/sample.rb
Rails.application.config.to_prepare { p Book.new }

もしくは、初期化時の1度だけ読み込めば良い時はafter_initializeを使えます。

# config/initializers/sample.rb
Rails.application.config.after_initialize { p Book.new }

Zeitwerkのcollapsing(折り畳み)機能

リンクはこちら

STI継承をするときなどに使えるメソッドとしてcollapsingが紹介されています。

これはディレクトリ構造をクラス名の名前空間に反映させたくなり場合などに使えます。

# app/models/samples/dummy.rb
class Dummy; end

上記だと、本来期待されるのはSamples::Dummyなので呼び出そうとしてもNameErrorが発生します。

これを例えばコンソールでcollapsingを試すと以下のようになります。

Dummy
#=> NameError)
Rails.autoloaders.main.collapse("#{Rails.root}/app/models/samples")

reload!

Dummy
#=> Dummy

push_dirでカスタム名前空間を作成する

リンクはこちら

collapseとは逆に、デフォルトの名前空間ディレクトリなしに作成したい時にはpush_dirが使えます。
Railsガイドに記載の通り、サービスクラスのクラスに対して、Services::名前空間を必要にしたい場合、ディレクトリ構成をapp/services/services/xxxとしなくても実現できます。

Railsガイドにきちんと目を通して新しい知識を得る - アセットパイプライン編 -

ドキュメントを読み込むのは大事、ということでRailsガイドを頭から読んでいく取り組みをしています。 各章ごとに、(Railsガイドにちゃんと書いてあるのに)知らなかった機能を雑にまとめていきます。

今回は、アセットパイプラインの章です。

railsguides.jp

Sprocketsの利用法

リンクはこちら

マニフェストファイルとディレクティブ

manifest.jslink_directory ../stylesheets .cssで、app/assets/stylesheetsディレクトリのcssファイルを読み込みます。
実際に、この行を削除するとcssが効かなくなりました。

Railsガイドには、(ただしサブディレクトリは含めません)と記載があったのですが、手元でapp/assets/stylesheets/books/index.csscssファイルを書いたらCSSが反映されます。
これはどうしてだろうと思ったら、app/assets/stylesheets/application.cssに以下の記載がありました。

 *= require_tree .

これによって、ディレクトリ配下のcssファイルを読み込んでいます。
manifast.jslink_directoryを使うことでstylesheet_link_tagapplication.cssが読み込めるようになり、
application.css*= require_tree . とすることでapplication.cssからディレクトリ内のcssが読み込まれるということだと理解しました。

なお、application.cssには以下も記載されていました。

 *= require_self

これは、application.css自身を読み込むタイミングを決めるものです。
例えば*= require_tree .より上に書いた場合、配下のディレクトリ内のcssと対象が重複した場合は配下のcssが適用されます。
逆に、*= require_tree .より下に書いた場合は、application.css`内のcssが適用されます。

Sprocketsにおけるindex.css

例えばapplication.css*= require booksと記述したとき、books/index.cssが存在しないとエラーを返します。

Sprockets::FileNotFound in Books#index

そして、そのindex.css内で *= require_tree .を記載すると、booksディレクトリ内の他のcssファイルを読み込めます。

.css.erb

.css.erbファイルを使うことで、asset_data_uriヘルパーを使ってデータURLスキームを使うこともできるようになります。

Railsガイドにきちんと目を通して新しい知識を得る - Rails アプリケーションの設定項目編 -

ドキュメントを読み込むのは大事、ということでRailsガイドを頭から読んでいく取り組みをしています。 各章ごとに、(Railsガイドにちゃんと書いてあるのに)知らなかった機能を雑にまとめていきます。

今回は、Rails アプリケーションの設定項目の章です。

railsguides.jp

rails実行前にコードを実行する

リンクはこちら

require 'rails/all' の上に書くことで、Railsが読み込まれる前にプログラムを実行できるそうです。
あまりユースケースが思いつかないですが、いざという時はありそうなので覚えておきたいです。

Rails全般の設定

リンクはこちら

週初めの設定をconfig.beginning_of_weekで設定する

config.beginning_of_weekで週の初めを何曜日とするかを設定できます。

デフォルト設定の場合、例えばDate.today.beginning_of_weekだと、今日が属する週の月曜日の日付インスタンスを返します。
(日曜かなと思っていたのですが、月曜でした。)

これを、config.beginning_of_weekを設定することで変更できます。

# appllication.rb

config.beginning_of_week = :wednesday
Date.today.beginning_of_week
#=> Wed, 14 Feb 2024

config.colorize_loggingでログの出力に色付けをする

これも設定になっていたとは知りませんでした。
falseにしたら確かにSQLクエリログなどに色がつかなくなりました。

config.disable_sandboxでsandboxの利用をさせない

sandbox環境の制限ができるのも知りませんでした。
データベースサーバーのメモリが枯渇するのを避けるうえで有用とのことです。
一方で、プログラムが問題なく動くか、や間違って更新しても安心なようにsandbox環境を使うことは多いと思うので、なかなか判断が難しいところですね。

これをtrueにしたところ、--sandboxオプションをつけてrailsコンソールを起動するとエラーが表示されるようになりました。

$ rails c --sandbox
Error: Unable to start console in sandbox mode as sandbox mode is disabled (config.disable_sandbox is true).

config.enable_reloadingでクラス等の変更時、リクエストごとに再読み込みするかどうかを決める

開発環境でコードの修正がリロードで反映されるのはconfig.enable_reloadingがtrueになっているからでした。
デフォルト設定では、production環境ではfalseというのも覚えておくべきだなと思いました。

config.sandbox_by_defaultでデフォルトをsandbox環境にする

Rails7.1から入った機能です。
config.sandbox_by_defaultをtrueにすると、railsコンソールがデフォルトでsandbox環境になります。
本番環境での事故防止に良さそうです。

手元の環境で試そうとしたら、trueに設定してもsandbox環境にならず、調べたところ、developmentとtestではこのオプションは無視されるとのことでした。

Note that this option is ignored when rails environment is development or test.

github.com

ActiveRecordを設定する

リンクはこちら

config.active_record.partial_inserts

trueにすると、createをしたときなどに、DBデフォルト値に対してはINSERTで値が送られなくなります。

設定をtrueにして、以下のbooksテーブルで試してみました。

カラム名 デフォルト値
title string 'デフォルトタイトル'
content string (設定なし)
Book.create
#=> INSERT INTO "books" ("created_at", "updated_at") VALUES ($1, $2) RETURNING "id"  [["created_at", "2024-02-18 ..."], ["updated_at", "2024-02-18 ..."]]

titleとcontentには確かにINSERTで値が指定されていません。
それぞれ'デフォルトタイトル'という文字列とnilが登録されていました。

こちらはRails7.0でデフォルトがfalseに変更されたそうです。
理由が気になって見てみたら、PRの方で以下の旨が挙げられていました。

  • ignored_columns機能が導入されたことにより、カラムを安全に削除する、より信頼性のある方法が生まれたこと
  • クエリサイズを減らすことの利点はそれほど大きなものではなくなった

github.com

active_record.before_committed_on_all_records

Rails7.1からのオプションです。

トランザクションに登録されているすべてのレコードに対して、before_committed!コールバックを有効にします。

上記の意味が最初よくわからなかったですが、試したらわかりました。

class Book < ApplicationRecord
  before_commit -> { p "title: #{title}" }
end

上記のように適当にbefore_commitを設定し、設定をfalseにします。

ActiveRecord::Base.transaction do
  book = Book.first
  book_2 = Book.first
  book.update(title: 'abc')
  book_2.update(title: 'def')
end

#=> "title: abc"

一方で、trueにした場合、pメソッドで出力される値は"title: def"になります。

これを見ると、falseにした場合、トランザクション内で同じレコードに対して複数回処理をした場合に、before_commitで見るのは最初に更新された方ということがわかりました。

ActionControllerを設定する

リンクはこちら

raise_on_open_redirectsでオープンリダイレクト脆弱性の対策を強化する

うっかりオープンリダイレクト脆弱性が紛れてしまった場合に、リダイレクト先が外部ホストの場合は例外を出してくれます。

例えば適当なコントローラで以下のように設定し、アクセスするとActionController::Redirecting::UnsafeRedirectErrorという例外が発生します。

allow_other_host: trueをつければ外部ホストへのリダイレクトが可能になる、というように回避策も用意されているので、基本はtrueで良さそうに思いました。

def index
  redirect_to 'http://example.com'
end

Railsガイドにきちんと目を通して新しい知識を得る - Rails アプリケーションのデバッグ編 -

ドキュメントを読み込むのは大事、ということでRailsガイドを頭から読んでいく取り組みをしています。 各章ごとに、(Railsガイドにちゃんと書いてあるのに)知らなかった機能を雑にまとめていきます。

今回は、Rails アプリケーションのデバッグの章です。

railsguides.jp

SQLクエリコメント

リンクはこちら

ログに出力されるSQLクエリログに、どのコントローラのどのアクション起因かというのが表示されます。
SQL実行に無駄がないか(結果をキャッシュできる箇所がないか)など調査するのに便利そうですね。

config.active_record.query_log_tags_enabled = true
SELECT "books".* FROM "books" /
*action='index',application='Myapp',controller='books'*/  ← この情報が追記される

debug gemによるブレークポイント

リンクはこちら

breakで行数や実行コードを指定して処理を止める

binding.bdebuggerで処理を止めた後、break 18b 18で行数で処理を止められます。
また、b Book.newのようにすると、呼ばれたnewメソッドで処理が止まるようになります。

gemのソースコードを読んだりするときに今まではdebuggerで処理を止めたところからジャンプ先にまたdebuggerを設定して、、とやっていましたが、これを上手に使えば楽になりそうです。

watchインスタンスの変化があった場合に処理を止める

watchを使うことで、オブジェクトに変化があった場合に処理を止めてくれます。
ちなみにActiveRecordインスタンスを指定した際には、インスタンスの属性が変わっただけでは処理は止まりませんでした。

def index
  debugger # ここで止まった時に、`watch @book`とする
  @book = Book.first # nil から インスタンスが入るので処理が止まる
  @book.title = '変更'  # 属性が1つ変わるだけでは止まらない
  @book = :book # シンボル:bookに変わるので止まる
end

Railsガイドにきちんと目を通して新しい知識を得る - Rails アプリケーションのエラー通知編 -

ドキュメントを読み込むのは大事、ということでRailsガイドを頭から読んでいく取り組みをしています。 各章ごとに、(Railsガイドにちゃんと書いてあるのに)知らなかった機能を雑にまとめていきます。

今回は、Rails アプリケーションのエラー通知の章です。

railsguides.jp

サブスクライバの設定

リンクはこちら

例えば以下のように記述をするだけで、エラーをキャッチしてログで「エラーを検知しました」と表示されるのを確認できます。
ポイントとしては、reportというメソッドを持ったクラスのインスタンスsubscribeで登録する、というところです。

# config/initializers/error_subscriber.rb

class ErrorSubscriber
  def report(error, handled:, severity:, context:, source: nil)
    p 'エラーを検知しました'
  end
end

Rails.error.subscribe(ErrorSubscriber.new)

エラーを通知しつつ、握りつぶす

リンクはこちら

例えば上記のサブスクライバを登録した状態で、適当にエラーを発生するコードを混ぜると、reportメソッドの中身は実行されますが、処理は継続されます。

def index
  @books = Book.all
  Rails.error.handle do
    no_exist_method
  end
end
Started GET "/books"
Processing by BooksController#index as HTML
"エラーを検知しました"
...
Completed 200 OK

処理を止めたくはないけど通知はして欲しい時に使えそうですね。

Railsガイドにきちんと目を通して新しい知識を得る - Rails セキュリティガイド編 -

ドキュメントを読み込むのは大事、ということでRailsガイドを頭から読んでいく取り組みをしています。 各章ごとに、(Railsガイドにちゃんと書いてあるのに)知らなかった機能を雑にまとめていきます。

今回は、Rails セキュリティガイドの章です。今回はセキュリティの話なので、知らなかったものと合わせて、改めて頭に入れておくべきと思った内容も含めてメモしました。

railsguides.jp

セッション固定攻撃の対策

リンクはこちら

最も効果的な対応策は、ログイン成功後に古いセッションを無効にし、新しいセッションIDを発行することです。

devisegemを見てみると、sign_in時にセッションを一度捨てる処理がされています。

# lib/devise/controllers/sign_in_out.rb
def sign_in(resource_or_scope, *args)
  # ...(略)

  expire_data_after_sign_in!

  # ...
end

private

def expire_data_after_sign_in!
  session.keys.grep(/^devise\./).each { |k| session.delete(k) }
end

ファイルのダウンロード機能の注意

リンクはこちら

ファイルダウンロードをする際に、ダウンロードパスにparamsの値を含める場合は../../xxxなどのparamsで悪さをされないように、構築されたダウンロードパスとアプリ側が想定しているパスが一致するかを検証する必要があります。

結構うっかりしてしまいそうな箇所にも思うので、知識としてしっかり頭に入れておかないといけないですね。

アカウント総当たり攻撃に対する対策

リンクはこちら

Webアプリケーションの設計でおろそかにされがちなのは、いわゆる「パスワードを忘れた場合」ページです

ついついユーザーの目先の便利さを求めて「指定されたメールアドレスは存在しません」といった内容にしてしまいそうですが、これは攻撃者にとってはヒントになるので危ないということですね。
ただ、ユーザーがtypoした場合などもあるので、そういった際に本当に困っているユーザーが自主的に気付けるようにする仕組みが必要そうです。

メールの変更にもパスワードを要求するべし

リンクはこちら

アカウントのメールアドレスを変更する攻撃を仕掛けることでパスワードをお忘れですか?からアカウントを乗っ取るということができてしまうので、メールアドレスを変更する際にもパスワードを要求するべしという内容です。

パスワード変更の際に旧パスワードを要求するのは当然必要として忘れないと思いますが、メールアドレスの方は漏れやすいようにも思いました。

正規表現

リンクはこちら

Rubyの^や$は、入力全体の冒頭と末尾ではなく「行の」冒頭と末尾にマッチしてしまうので、\A\zを使うべしという内容です。

これは知らないとすぐ踏んでしまうと思います。

str = "javascript:exploit_code();/*\nhttp://hi.com\n*/"

str.match?(/^https?:\/\/[^\n]+$/i)
#=> true

str.match?(/\Ahttps?:\/\/[^\n]+\z/i)
=> false

ヘッダーインジェクションの対策

リンクはこちら

ヘッダーの情報も比較的容易に操作できてしまうので、User-Agentを管理者画面でエスケープなしで表示するなどしてはいけないという内容です。
また、refererもそのまま使うと危ないので適切な対処が必要です。

DNSバインディングの対策

リンクはこちら

DNSバインディングをきちんと理解できていなかったので、これを機に調べました。

罠サイトはユーザーが一度アクセスした直後に別のIPアドレスを返すように変更し、「信頼された」状態となったブラウザを通じてユーザーの内部ネットワークなどにアクセスします。

Railsでは、Rails.application.config.hostsで許可するホストを制限することでDNSバインディング対策を実施します。

deep_mungeで安全でないクエリ生成を防止

リンクはこちら

意図しないクエリを発行しないようにしているのがdeep_mungeメソッドです。

mungeはdeeplだと翻訳が出ませんでしたが、weblioだと「コードを難読化する」という意味が表示されました。

  • config.action_dispatch.perform_deep_munge = falseを設定してJSON{ "person": null }のパラメータを送信した時
params  #=> {"person":[null], ...}
  • config.action_dispatch.perform_deep_munge = falseを設定せずにJSON{ "person": null }のパラメータを送信した時
params  #=> {"person":[]... }

CORS

リンクはこちら

Rack CORSミドルウェアによってCORSを有効にできます。
Railsガイドの例のように設定してから以下のようにcurlでレスポンスヘッダーを確認すると、access-control-allow-originなどが設定できていることを確認できました。

$ curl -I -X OPTIONS http://localhost:3000 -H 'Origin: http://example.com'
...
access-control-allow-origin: http://example.com
access-control-allow-methods: GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD
...

GitHub Actionsを使って子供が産まれてからの週数を毎日LINEで通知する

先日子供が産まれたのですが、予防接種の問診票等で何週目か?や何ヶ月何日か?といった情報が頻繁に必要になります。

毎回計算するのが面倒だったのでLINEで毎日通知させることにしました。
定期実行はGitHub Actionsにやってもらうことにしました。

その方法を記録していこうと思います。(ChatGPTにたくさん助けてもらいました。)

1. LINE BOTのアカウントを作成

LINEにデベロッパー登録して、BOTとなるチャンネルを作成します。
デベロッパーとしての登録などの方法は他のサイトにいくらでも書いてあるので割愛します。(自分はすでにアカウントを持っていたので忘れたというのもあります)

今回使ったのはLINEのMessaging APIです。 developers.line.biz

2. LINEのメッセージ送信の実装

難しいことは特になく、行ったこととしては、主に以下です。

  • line-bot-apigemを使う
    • チャネルシークレットとチャネルアクセストークン(長期)を利用
  • broadcastメソッドを使い、友達になった全員にメッセージを送信するようにする

Messeging APIでは、ユーザーIDを指定して送信する、や友達全員に対して送信する、など色々と方法があります。

developers.line.biz

line-bot-apiではそれぞれに対応したメソッドが用意されています。

3. 日付のロジックを計算する

生まれてから何日か、何週何日かは特に考えることもなかったですが、何ヶ月何日目かは色々考えることがあって面白かったです。

  • 月の月末までの日数の考慮
    • 例: 誕生日が3/20の場合、今日が4/1場合は0ヶ月12日で、5/1の場合では1ヶ月11日ととなるように、月末が何日あるかによってズレる
  • 誕生日の日にちを迎えているかどうかで月の計算が変わる
    • 例: 今日が2/12で誕生日が1/13の場合、0ヶ月30日になるが、誕生日が1/11なら1ヶ月1日になる
  • 誕生日を迎えているかどうかで年の計算が変わる
    • 例: 今日が2024/2/12で誕生日が2023/2/11の場合、1歳だが、2023/2/13が誕生日なら0歳

実装したコード(一部)

if TODAY_MONTH >= BIRTH_MONTH
  # 例: 今が5月で誕生月が3月なら5 - 3で2ヶ月
  passed_month = TODAY_MONTH - BIRTH_MONTH
else
  # 例: 今が3月で誕生月が5月なら12 - (5 -3)で10ヶ月
  passed_month = 12 - (BIRTH_MONTH - TODAY_MONTH)
end

if TODAY_DAT_NUM >= BIRTH_DAY_NUM
  # 例: 今日が20日で誕生日が5日なら単純な引き算で15日
  passed_day = TODAY_DAT_NUM - BIRTH_DAY_NUM
else
  # 例: 今日が10日で誕生日が20日なら前月の最終日を確認しながら計算する
  prev_month_date = TODAY.prev_month
  prev_max_date = Date.new(prev_month_date.year, prev_month_date.month, -1).day
  passed_day = (previous_max_date - BIRTH_DAY_NUM) + TODAY_DAT_NUM
  # このケースだと、たとえば今が5月で誕生月が4月の場合、1月も経っていないので-1する
  passed_month -= 1
end

passed_year = TODAY_YEAR - BIRTH_YEAR
# 今年誕生日を迎えているか?を確認し、迎えていない場合は-1する
passed_year -= 1 if TODAY < Date.new(TODAY_YEAR, BIRTH_MONTH, BIRTH_DAY_NUM)
"#{passed_year}#{passed_month}ヶ月#{passed_day}"

4. GitHub Actionsで実行するためのYAMLファイルを作成

.github/workfrows下にYAMLファイルを作成するだけで勝手にGitHub Actionsが把握して実行してくれます。
cronで毎日朝9:00にAPIを叩きに行くようにしました。

docs.github.com

なお、動作確認のためにGitHub上から手動で実行できるworkflow_dispatchが便利でした。

docs.github.com

実装したYAML一部

on:
  schedule:
    - cron: '0 0 * * *' # 日本時間でAM9:00
  workflow_dispatch:

環境変数(LINEのアクセストークン等)の設定は、リポジトリの設定から可能です。

ハマりかけたポイント

1. cronで設定しても実行されない?

動作確認でcronで2分後くらいに設定してpushしたのですがその時間になってもワークフローが実行されませんでした。

設定が悪いのかな?と思ったのですが、実際には実行開始が遅れているだけのようでした。
GItHubの公式ドキュメントにそれっぽいことが書いてありました。

Note: The schedule event can be delayed during periods of high loads of GitHub Actions workflow runs

実際に、朝9:00に実行するようにスケジュールしたんですが、実行されたのは9:33でした。

2. 環境変数が反映されない?

実装の途中で環境変数を1つ追加したのですが、GitHubSecrets and variablesに設定しているのに環境変数rubyスクリプトから取得できませんでした。

なんでだ?と思って色々見ていたら、単純にYAMLファイルでXXX_KEY: ${{ secrets.XXX_KEY }}を書くのを忘れていただけでした。。。

まとめ

特に難しいことはしていないですが、ちょっと面倒だったことを解決できると嬉しいですね。
LINE APIは以前使ったことがありましたが、GitHub Actionsのcronは初めて自分で設定して使ったので勉強になりました。

github.com