RailsでN + 1の環境を再現する Part 1
クエリは遅延実行である。
クエリのインターフェースを書いて変数に代入してるコードがあったとします。その変数が実際に何かで利用された際に、初めてクエリが実行されます。
rails console環境だとUser.where(status: 'active')でもクエリを発行します。これはIRB(pry)では戻り値をレシーバとしてinspectメソッドを実行し、得られた文字列を表示するという仕様があるからです。ActiveRecord::Relationオブジェクトはinspectメソッドを実行するとクエリを発行します。
ActiveRecordのオブジェクトは重い
ActiveRecordについて考える際には、クエリの内容は発行回数もそうだが、ActiveRecordのオブジェクトを生成しすぎていないかについても考える必要がある。
Active Recordのオブジェクトはそれなりに大きいので、たくさん作るとメモリを大量に消費するからである。
たくさんのオブジェクトを作ったとしても、それを使わなくなったら、GCされるので問題ないのでは?と思うかもですが、RubyのプロセスはOSにほとんどメモリを返さないです。そのため、実際にRubyプロセスが使っているメモリ容量よりもはるかに大きな容量を確保した状態になってしまう。プロセスがメモリ使いまくって、システムの性能を落としているとOCMKillerによってプロセスが削除されるかもしれないし。そのため、一度にたくさんのメモリを使うのは避けましょう。
後で読む
なんでentrypoint.shを使うんやろうか
シバン
シバンとは、#!のこと。
シバンとシバンの後に続く命令を書くことで、スクリプトファイルを任意のプログラムで実行しろとOSに命令できる。
例えば、hoge.rbファイルに以下のプログラムを書くとする
puts "hogehoge"
このプログラムをシェルから実行するには、シェルでruby hoge.rb
を実行すればよい。
hoge.rbに以下のシバン行を書くと、./hoge.rb
で実行できるようになる(rubyをわざわざかかなくて済む)。
#!/usr/bin/env ruby
puts "hogehoge"
/usr/bin/env rubyの意味は、ざっくりいうと環境変数PATHを使ってサーチしたrubyプログラムで、hoge.rbを実行しろという意味。
ファイル名はhoge.rbじゃなくてもhogeでもOK
上の画像のrailsとかrakeを実行する際に、シェルでruby railsとか書かなくても実行できる理由は、これらのファイルにシバンが書かれているからである。
注)
hoge.rbファイルはデフォルトで実行権限が付与されていないので、chmodで実行権限を付与しないと、上の挙動にならない。シェル上で実行できない(シバンを採用するなら、ファイルに実行権限を付与しないといけないので、そこがちょいめんどくさい)
↓ デフォルト
-rw-r--r-- 1 yuuki_haga staff 36 12 9 00:03 hoge.rb
↓ シバンを追加しても、シェルからhoge.rbを実行できない
./hoge.rb
zsh: permission denied: ./hoge.rb
↓ chmodでファイルの権限を変更する
chmod 755 hoge.rb
↓ hoge.rbに実行権限が付与された。
-rwxr-xr-x 1 yuuki_haga staff 36 12 9 00:03 hoge.rb
↓ シェルからhoge.rbを実行できる
./hoge.rb
hogehoge
rbenvとbundler
rbenvとは、1つのホストマシン上で複数のバージョンのRubyを扱うためのソフトウェアである。rbenvがあることによって、1つのホストマシンが持つプロジェクトごとに異なるRubyで開発することができる。rbenvがない場合、プロジェクトを切り替えるごとにホストマシンにRubyを都度インストールし直すか、仮想マシンを使うかなどの必要性が出てくる。
Bundlerとは、gem同士の依存関係やどのバージョンのgemがインストールされたかを管理するためのgemである。Bundler自体は、gem installでインストールする。
bundle exec コマンド名を実行するとBundlerでインストールされているgemを利用してコマンドを実行することができる。
とりあえず、db:migrateからデータベースを作るところまでできた。
server.pidを削除する理由
server.pidはサーバー起動時に生成され、server.pidが存在すると、サーバーは起動状態であると認識される。
サーバー終了後、server.pidは残ったままの可能性があり、その場合、サーバー再起動時に「サーバーは再起動しています」というエラーが吐かれてしまうそう。そのため、サーバーを立ち上げるタイミングまたはサーバーを終了させるタイミングでserver.pidを削除させるようなプログラムを書く。そうすれば、サーバーを問題なく起動することができる
ENV命令
ENV命令で、Dockefileが解釈する環境変数を宣言できる
環境変数はDockerfileでは
ENV APP_HOME /var/app
DockefileのENVは、シェルプロセスの環境変数として設定される。
地味に参考になる
Linuxの/varディレクトリ
Linuxの/varディレクトリは、システム運用中に生成されて削除されるデータを 一時的に 保存するためのディレクトリ。
dockerコンテナの作業ディレクトリを/var配下に生成した
シェルスクリプトの実行方法
- 「source」コマンドを使う
- bashシェルプログラムの引数として指定して、実行する
- シバンを書いて、かつ、スクリプトファイルに実行権を付与する
シェルスクリプトのif文
もし、特定のファイルが存在するかどうかチェックしたいなら、-eを指定すれば良い。
#!/bin/sh
# set -eを追加することで、シェルスクリプト内でエラーが発生した時に、
# プログラムが終了する
# uオプションは、未定義の変数が使用された場合にエラーを出すように設定します。つまり、未定義の変数を使おうとするとスクリプトが中断されます。
set -eu
cd $APP_HOME
bundle i
if [ -e ./tmp/pids/server.pid ]; then
rm ./tmp/pids/server.pid
fi
# bundle exec rails s -b 0.0.0.0
bundle exec rails s
RailsをProductionモードで起動する経験があんまないから、いつかやってみても良いかも
.envファイルを使って、コンテナに環境変数を流し込むメリットとしては、仮にコンテナじゃなくて、ローカルで試したいなってなった時に、簡単に切り替えることができること
docker-compose.ymlのbuildのcontextについて
docker-compose.ymlのbuildのcontextは、dockerfileが含まれるディレクトリへのパスを表している。
docker-composeのhealthcheckについて
一般的なヘルスチェックとは、サーバー上のプログラムが正常に動作を実行できるかを確認することである。
docker-compose.ymlにdepneds_onだけを書くと、どの順番でコンテナを起動するかを指定するだけである。そのため、mysqlコンテナが起動したけどクエリを受けつけてない状態でappコンテナを立ち上げてしまう。この時、mysqlコンテナがクエリを受けつけられる状態になっていないのにappコンテナが立ち上がっているので、appコンテナを起動する際にクエリを発行する場合、エラーが起きたりする。appコンテナからのアクセスを受け付けられるようにしてから、appコンテナを起動するようにしたいなら、mysqlコンテナのヘルスチェックが完了したらappコンテナを起動するようにすれば良い。
以下のコードのヘルスチェックでは、指定したインターバルで複数回testに指定したヘルスチェックを実行する。コンテナを起動した直後は、コンテナはstarting状態になっている。もし1回でもヘルスチェックが成功したら、コンテナはhealthy状態になる。retriesの回数失敗すれば、コンテナはunhealthy状態になる。
- appコンテナのサービスの部分のdepends_onにmysqlコンテナを指定する。その際に、condtion: service_healtyを指定する。そうすることで、mysqlコンテナのヘルスチェックが正常な場合に、appコンテナを起動するようにできる。
depends_on:
mysql:
# 依存サービスのヘルスチェックがパスするまで待つ
# 「サービスが開始する」ことと「他のサービスからのアクセスを受け付けられる」ことは異なるということです。
# 例えば、MySQL や PostgreSQL などはプロセス開始直後からすぐにクエリを実行できるわけではありません。
# プロセス開始後に起動処理が実行されクエリを受け付けることができる状態になるまでに少なくとも数秒かかると思います。
# service_started はサービスが開始されるのを待つだけなので、「他のサービスからのアクセスを受け付けられる」状態になっているかどうかは保証しません。
# service_healthy は適切なヘルスチェックを行うことにより「他のサービスからのアクセスを受け付けられる」状態になるまでそれに依存しているサービスは起動を待ちます。
condition: service_healthy
- mysqlコンテナにヘルスチェックを書く
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ""
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
healthcheck:
# -h localhostはMySQLサーバーがローカルホスト上にあることを指定している。
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
start_period: 5s
ports:
- 3306:3306
volumes:
- mysql-data:/var/lib/mysql:delegated
全体像
version: "3.8"
services:
app:
build:
# dockerfileが含まれるディレクトリへのパス
context: ./backend
env_file: .env.development
healthcheck:
# ヘルスチェック方法
# railsが起動しているかを確認している
# 前者のコマンドが失敗した時だけ、後ろのコマンドが実行される
# exit 1はプログラムが異常終了したことを表すコマンド
test: "curl -f http://localhost:3000/robots.txt || exit 1"
# ヘルスチェックの間隔
interval: 5s
# リトライ回数
# コンテナを起動した直後は、コンテナはstarting状態になっている
# 指定した間隔でヘルスチェックを実行して、1回でもチェックに成功すれば、
# コンテナはhealthy状態になる。retriesの回数失敗すれば、コンテナはunhealthy状態になる
retries: 10
ports:
- 3000:3000
volumes:
- ./backend:/var/app:cached
# tmpディレクトリには、キャッシュやpidなどの一時ファイルが置かれます。
- rails-tmp-data:/var/app/tmp:delegated
# デフォルトでは、/usr/local/bundleにgemがインストールされる
- bundle-data:/usr/local/bundle:delegated
depends_on:
mysql:
# 依存サービスのヘルスチェックがパスするまで待つ
# 「サービスが開始する」ことと「他のサービスからのアクセスを受け付けられる」ことは異なるということです。
# 例えば、MySQL や PostgreSQL などはプロセス開始直後からすぐにクエリを実行できるわけではありません。
# プロセス開始後に起動処理が実行されクエリを受け付けることができる状態になるまでに少なくとも数秒かかると思います。
# service_started はサービスが開始されるのを待つだけなので、「他のサービスからのアクセスを受け付けられる」状態になっているかどうかは保証しません。
# service_healthy は適切なヘルスチェックを行うことにより「他のサービスからのアクセスを受け付けられる」状態になるまでそれに依存しているサービスは起動を待ちます。
condition: service_healthy
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ""
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
healthcheck:
# -h localhostはMySQLサーバーがローカルホスト上にあることを指定している。
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
start_period: 5s
ports:
- 3306:3306
volumes:
- mysql-data:/var/lib/mysql:delegated
volumes:
rails-tmp-data:
bundle-data:
mysql-data:
exitコマンドは、シェルやプログラムを終了させるコマンドである。
exit 1はプログラムが異常終了したことを表す。
シェルスクリプトのOR演算について
ヘルスチェックについて
curlの-fオプション
-fオプションは、curlがサーバーから受け取ったHTTPステータスコードが失敗の場合(404 Not Foundなど)、エラーとして扱うことを指示しています。
OR条件で別のシェルスクリプトを実行させたいから-fを使うのか
http://localhost:3000/hoge.txtでアクセスできる。
Railsのpublicディレクトリにあるhoge.txtは、Webサーバー昔作った時に、同じような挙動を実装してたなそういえば。
docker-compose.ymlのdelegatedとcachedについて
ホストとコンテナでデータの一貫性を担保するのは大事だが、データの一貫性をそこまで担保しなくて良い場面でも担保してしまい、パフォーマンスを落とすことがあるそのため、delegated or cachedを使う。
delegatedは最も弱い保証を提供する。コンテナが実行した書き込みがホストファイルシステムに即座に反映されないことがある。delegatedでは、コンテナのファイルシステム上の表示が信頼できるものとなる。
cachedはdelegatedの性質とコンテナが実行した書き込みの可視性に関する保証を提供する。cached としてマウントしたディレクトリは、ホスト側ファイルシステムが信頼できます。つまり、コンテナでの書き込み処理は即時ホスト側でも見えるようになりますが、ホスト上での書き込み処理がコンテナ内で見えるようになるには遅延が発生しうるでしょう。
わかりそうでわからん。
とりあえず、ホストのやつを連携するなら、cachedのが良くて、ホストは特に重要じゃないなら、delegatedにするって感じか
Railsのtmpディレクトリ
tmpディレクトリには、キャッシュやpidなどの一時ファイルが置かれる。
RubyのDockerイメージのGEM_HOMEについて
GEM_HOMEは、どのディレクトリにGemをインストールするかを指定する環境変数。
Ruby-alpineのdockerイメージがどのように作られているかを実際のDockerfileを見ると、GEM_HOMEにどのような値が設定されているかを理解できる。
database.ymlの書き方
アンカーとエイリアス久しぶりに聞いた
Spring
Springは、railsコマンドやrakeコマンドが早くなるやつ。
前職では入ってなかった。けど、コマンドを高速化したいなら入れても良いかも
rspec-parameterized gem
goのゴールデンテストに似ている。
1つのテストケースを複数のデータのパターンで試したい時に使える。
モデルやサービスの単体テストで、一つのテストケースで複数のデータのパターンを試したい時に使えそう
Listen gem
Listen gemは、ファイルの変更を検知して、オリジナルの動作を設定したりできる。
こいつがなんでRailsのデフォルトのgemなのかが不明やな
イベントベースのファイル検知をしたいなら、入りそう。
それ以外だと別に良いかなという印象。
annotate gem
annotate gemは、各モデルのスキーマ情報をファイルの先頭もしくは末尾にコメントとして書き出してくれるGemである。
どんなカラムがあったっけ?ってなった時にいちいちdb/schema.rbを見に行く手間を省くことができる。
bootsnap gem
bootsnap gemは、railsの起動時間を短縮してくれるgem
railsの立ち上げが遅い時に使うかも。現状そこまで遅いと感じたことはない。
すでに使われているからかな。bootsnap自体はデフォルトで入ってた
# bundlerはrequireでbootsnapを読み込まない
# これは、requireするライブラリの名前がgemの名前と異なる場合によく使われる。
gem 'bootsnap', require: false
Active Flag gem
Active Flag gemは、
- ON/OFFできるユーザ設定をたくさん持たせたい
- 選択肢を複数選択できる選択項目を持たせたい
場面で使える。このgemはBitArrayなカラムを扱いやすくしてくれる。
フォームで一つの項目に対して、複数の値を選択する場面は全然ありそうだから、いつか使う場面が来そう。
Global gem
よく使うconfig gemは、開発、本番というジャンルごとにファイルを分けているが、
Global gemは、取り扱う値ごとにファイルがあって、そのファイルの中で開発環境の時の値や本番環境の時の値を書く
Groupdate gem
このgemを使うことで、あるテーブルの
- day
- week
- hour of the day
- and more
ごとにグループ化して、そのグループごとの値をカウントしたりできる。
https://github.com/ankane/groupdate
phonlib gem
phonlib gemとは、電話番号の検証や電話番号に関する情報を取得できるライブラリ
gccとg++の動作の違い
gccは,.cファイルをC言語として,.cppファイルをC++としてコンパイル
g++は,.cファイルも.cppもC++としてコンパイル
g++は,リンクの時にstd C++ライブラリをリンク(gccはしない)
こいついつか読む
TZInfo
TZInfoはRubyのライブラリで、タイムゾーン・データにアクセスし、タイムゾーン・ルールを使って時刻を変換することができる。
TZInfo にはタイムゾーン・データのソースが必要です。オプションは2つある:
タイムゾーン定義ファイルを含むzoneinfoディレクトリ。これらのファイルは、zicユーティリティを使用してIANAタイムゾーン・データベースから生成される。ほとんどのUnix系システムにはzoneinfoディレクトリが含まれている。
TZInfo::Dataライブラリ(tzinfo-data gem)。TZInfo::Dataには、IANAタイムゾーンデータベースから生成されたRubyモジュールが含まれています。
デフォルトでは、TZInfoはTZInfo::Dataを使用しようとします。TZInfo::Dataが利用できない場合、TZInfoは代わりにzoneinfoディレクトリを検索します。
apkのrm -rf /var/cache/apk/*は、--no-cacheで良い
Alpine Linux は 3.3 から apk で --no-cache というオプションが使えます。
従来は --update add でインストールした後に rm -rf /var/cache/apk/* で
不要なゴミファイルを削除していたようですが、いまや --no-cache で OK です。
なるほど、以下のようにしてできた。
RUN apk add --update --no-cache \
bash \
curl \
g++ \
git \
make \
mysql-dev \
openssl \
tzdata
レプリカ
Railsでレプリカを使う際に参考になるかも
docker composeで初めてアプリを立ち上げただけだと、dbがcreateされていない。なので、自分でdb createとマイグレーションをする必要がある。
database.ymlでencodingスタイルを指定すると、4バイト文字とかも扱えるぽい?
絵文字が扱えれたら良いかも
default: &default
adapter: mysql2
encoding: utf8mb4
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
username: <%= ENV.fetch("MYSQL_USER") { "root" } %>
password: <%= ENV.fetch("MYSQL_PASSWORD") { "" } %>
host: <%= ENV.fetch("MYSQL_HOST") { "127.0.0.1" } %>
timeout: 5000
サーバーのタイムゾーンを変更する
Railsサーバーが日本で動いていることを前提とすると、サーバーのデフォルトの時刻(UTC+0)は日本時刻(UTC+9)と大幅にズレている。そのため、環境変数を設定して、サーバーの稼働時刻をずらす。
サーバーのタイムゾーンを変更するには、環境変数TZを設定する。
docker compose exec app bash
bash-5.1# env
CHARSET=UTF-8
HOSTNAME=4c6daf2eb422
REDIS_HOST=redis
RUBY_DOWNLOAD_SHA256=ca10d017f8a1b6d247556622c841fc56b90c03b1803f87198da1e4fd3ec3bf2a
API_ORIGIN=http://app:3000
RUBY_VERSION=3.1.2
PWD=/var/app
BUNDLE_APP_CONFIG=/usr/local/bundle
RUBY_MAJOR=3.1
APP_HOME=/var/app
HOME=/root
LANG=C.UTF-8
BUNDLE_SILENCE_ROOT_WARNING=1
SCHEME=http
MYSQL_HOST=mysql
GEM_HOME=/usr/local/bundle
TERM=xterm
SHLVL=1
LC_COLLATE=C
PATH=/usr/local/bundle/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
SMTP_HOST=mail-catcher
_=/usr/bin/env
bash-5.1# uptime
17:17:08 up 8 days, 3:32, 0 users, load average: 0.34, 0.16, 0.17
bash-5.1# man uptime
bash: man: command not found
bash-5.1# date
Sat Dec 9 17:19:37 UTC 2023
bash-5.1#
上のコードでは、TZを指定していないので、現在2:17分だが、9時間遅れていることがわかる(dateコマンドとuptimeコマンドがずれていることがわかる)。
DockerfileにTZを追加する。
ENV TZ Asia/Tokyo
ちゃんとサーバーのタイムゾーンを(UTC + 9)にできた
docker compose exec app bash
bash-5.1# env
CHARSET=UTF-8
HOSTNAME=499bb790041d
REDIS_HOST=redis
RUBY_DOWNLOAD_SHA256=ca10d017f8a1b6d247556622c841fc56b90c03b1803f87198da1e4fd3ec3bf2a
API_ORIGIN=http://app:3000
RUBY_VERSION=3.1.2
PWD=/var/app
BUNDLE_APP_CONFIG=/usr/local/bundle
RUBY_MAJOR=3.1
TZ=Asia/Tokyo
APP_HOME=/var/app
HOME=/root
LANG=C.UTF-8
BUNDLE_SILENCE_ROOT_WARNING=1
SCHEME=http
MYSQL_HOST=mysql
GEM_HOME=/usr/local/bundle
TERM=xterm
SHLVL=1
LC_COLLATE=C
PATH=/usr/local/bundle/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
SMTP_HOST=mail-catcher
_=/usr/bin/env
bash-5.1# date
Sun Dec 10 02:28:50 JST 2023
bash-5.1#
DockefileのENVは、シェルプロセスの環境変数として設定されるのか。なるほど
dateコマンド
dateコマンドは、現在の時刻を取得したり、設定したりするコマンドである。
Railsのタイムゾーン周りの設定について
3.8.9 config.active_record.default_timezone
データベースから日付・時刻を取り出した際のタイムゾーンをTime.local(:localを指定した場合)とTime.utc(:utcを指定した場合)のどちらにするかを指定します。デフォルト値は:utcです。
アプリケーションのタイムゾーンを設定する前
docker compose exec app bash
bash-5.1# rails c
Calling `DidYouMean::SPELL_CHECKERS.merge!(error_name => spell_checker)' has been deprecated. Please call `DidYouMean.correct_error(error_name, spell_checker)' instead.
Loading development environment (Rails 7.0.8)
irb(main):001> Time.current
=> Sat, 09 Dec 2023 17:56:36.614589634 UTC +00:00
irb(main):002> ActiveRecord::Base.default_timezone
DEPRECATION WARNING: ActiveRecord::Base.default_timezone is deprecated and will be removed in Rails 7.1.
Use `ActiveRecord.default_timezone` instead.
(called from <main> at bin/rails:9)
=> :utc
irb(main):003> ActiveRecord.default_timezone
=> :utc
irb(main):004> exit
bash-5.1#
アプリケーションのタイムゾーンを設定する
config/application.rb
module NPlusOne
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.0
# Settings in config/environments/* take precedence over those specified here.
# Application configuration can go into files in config/initializers
# -- all .rb files in that directory are automatically loaded after loading
# the framework and any gems in your application.
# Only loads a smaller set of middleware suitable for API only apps.
# Middleware like session, flash, cookies can be added back manually.
# Skip views, helpers and assets when generating a new resource.
config.api_only = true
# Railsのアプリケーション上で表示したい時間の基準となるタイムゾーンを指定する
# このタイムゾーンはOSとは独立している。
# しかし基本的にはアプリケーションのタイムゾーンはOSのタイムゾーンと合わせておくのが安全。。
config.time_zone = "Tokyo"
# config.active_record.default_timezoneはデフォルトでutc
# config.active_record.default_timezoneは、DBのタイムゾーンと一致させておけばOK
end
end
Time.currentがUTC + 9の時刻になった。
bash-5.1# rails c
Calling `DidYouMean::SPELL_CHECKERS.merge!(error_name => spell_checker)' has been deprecated. Please call `DidYouMean.correct_error(error_name, spell_checker)' instead.
Loading development environment (Rails 7.0.8)
irb(main):001> Time.current
=> Sun, 10 Dec 2023 03:42:22.720175159 JST +09:00
データベースは特にタイムゾーンとか設定していないから、UTCのまま。
docker compose exec mysql bash
bash-4.4# mysql -u root -@
mysql: [ERROR] mysql: unknown option '-@'.
bash-4.4# mysql -u root -p
Enter password:
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 89
Server version: 8.0.33 MySQL Community Server - GPL
Copyright (c) 2000, 2023, Oracle and/or its affiliates.
Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.
Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
mysql> select current_timestamp;
+---------------------+
| current_timestamp |
+---------------------+
| 2023-12-09 18:48:20 |
+---------------------+
1 row in set (0.00 sec)
mysql>
Railsアプリケーションで考慮すべきタイムゾーン
おそらく、以下のような感じなのかなと思った。
- Rubyプロセスを動かしているホストのタイムゾーン
- Railsアプリケーション自体のタイムゾーン
- ActiveRecordでデータベースと通信する際の読み書きの時刻をどうするかを考慮するためのタイムゾーン
- データベースのタイムゾーン
現時点だと、以下のような設定にしている。
- Rubyプロセスを動かしているホストのタイムゾーン: Asia/Tokyo
- Railsアプリケーション自体のタイムゾーン: Tokyo
- ActiveRecordでデータベースと通信する際の読み書きの時刻をどう変換するかを設定するためのタイムゾーン: utc
- データの読み書き時にここら辺本当によしなにやってくれるのかは調査した方が良い。
- データベースのタイムゾーン: utc
- データベースでどのようにデータが表示されるかは調査した方が良い。
- データーベースサーバーのタイムゾーンがUTCなのかJSTなのか知っておいた方が良い。
MySQLには2つのタイムゾーンに関する変数がある。system_time_zoneとtime_zoneである。
system_time_zoneは、ホストマシーンのタイムゾーンを表す。time_zoneは、MySQLサーバーが現在動作しているタイムゾーンを表す。time_zone の初期値は'SYSTEM'で、サーバーのタイムゾーンがシステムのタイムゾーンと同じであることを示す。つまり、以下の場合、データーベースソフトウェアのタイムゾーンはUTCである。
mysql> show variables like "%time_zone%";
+------------------+--------+
| Variable_name | Value |
+------------------+--------+
| system_time_zone | UTC |
| time_zone | SYSTEM |
+------------------+--------+
2 rows in set (0.02 sec)
Railsでは、config.time_zoneとconfig.active_record.default_timezoneを設定する。
config.time_zoneは、Railsのアプリケーション上で表示したい時間の基準となるタイムゾーンを指定する。このタイムゾーンはOSとは独立している。しかし基本的にはアプリケーションのタイムゾーンはOSのタイムゾーンと合わせておくのが安全。一致していないと、アプリケーションの挙動が把握しづらい。
config.active_record.default_timezoneはデフォルトでutcである。config.active_record.default_timezoneは、DBのタイムゾーンと一致させておけばOK
一致していないと、時間がずれてdbに記録されたりするので、バグの温床になる。
例えば、utcに変換したものを、jstのデーターベースサーバーに記録したりすると、jstで記録しないといけないのに、utcでインサートしてしまう。(おそらく。ここは検証が必要)
RailsではTimeWithZone使っといた方が良いな。
ループバックアドレス(127.0.0.1)と0.0.0.0について
コンテナに入って、直接アプリケーションに対してリクエストを出すとレスポンスが返されることが確認できた。
bash-5.1# curl -i localhost:3000/robots.txt
HTTP/1.1 200 OK
Last-Modified: Fri, 08 Dec 2023 14:10:48 GMT
Content-Type: text/plain
Content-Length: 99
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
bash-5.1#
コンテナを動かしているホストで同じカールをやると、以下のエラーが出る。
curl -i localhost:3000/robots.txt
curl: (52) Empty reply from server
ループバックアドレスが何たらかんたらとか言ってたからおそらくそこに原因がある。
development.shでは、以下のようなコードを書いている。
おそらく、最後のbundle exec rails sでバインドするホストを書いていないのが原因かも
#!/bin/sh
# set -eを追加することで、シェルスクリプト内でエラーが発生した時に、
# プログラムが終了する
# uオプションは、未定義の変数が使用された場合にエラーを出すように設定します。つまり、未定義の変数を使おうとするとスクリプトが中断されます。
set -eu
cd $APP_HOME
bundle i
if [ -e ./tmp/pids/server.pid ]; then
rm ./tmp/pids/server.pid
fi
# bundle exec rails s -b 0.0.0.0
bundle exec rails s
できた
curl -i localhost:3000/robots.txt
HTTP/1.1 200 OK
Last-Modified: Fri, 08 Dec 2023 14:10:48 GMT
Content-Type: text/plain
Content-Length: 99
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
127.0.0.1は自分自身を表すipアドレス。
コンテナの中で127.0.0.1のport3000でリッスンすると、コンテナの中ではアクセスできるけど、コンテ外からアクセスできない。ポートマッピングしても失敗する。おそらく0.0.0.0:3000とlocalhost:3000をマッピングしているみたいな意味になっちゃうからダメなのかな。localhost:3000は結局ホスト自身の3000にアクセスしちゃうからダメみたいな。マッピング側のコンテナも0.0.0.0:3000にすれば、通せる的な感じかな。
0.0.0.0
0.0.0.0は、すべてのネットワーク・インターフェースを表す。
Railsのdatabse.yml
dbのコネクションの設定とか、dbmsに何を使うかとか、本番ステージング開発でどんなdbコネクションをするとか、全部こいつに書けばいい感じにやってくれる。goでやってためんどくさい作業から解放される
書いただけだとダメで、最後にコマンドを実行する
t.timestamps
Dir.glob, File.expand_path, dir, require,
↓ Schemafile(リッジポールで使うファイル。このファイルの中にテーブル定義をまとめて書くと、テーブル数が増えたときに可読性が一気に悪くなるので、分割している)
# Dir.glob(pattern, flags = 0, base: nil, sort: true) -> [String]
# Dir.globは、ワイルドカードの展開を行い、パターンにマッチするファイル名を文字列の配列として返します。
# File.expand_path(path, default_dir = '.') -> String
# File.expand_pathは、pathを絶対パスに展開した文字列を返します。 path が相対パスであれば default_dir を基準にします。
# __dir__は、現在のソースファイル(__FILE__)のあるディレクトリ名を正規化された絶対パスで返す。
# require(feature) -> bool
# requireは、Ruby ライブラリ feature をロードします。
# feature が絶対パスのときは feature からロードします。 feature が相対パスのときは組み込み変数 $: に示されるパスを順番に探し、最初に見付かったファイルをロードします。
# Ruby ライブラリとは Ruby スクリプト (*.rb) か拡張ライブラリ (*.so,*.o,*.dll など) であり、
# feature の拡張子が省略された場合はその両方から探します( *.rb が優先されます)。省略されなかった場合は指定された種別のみを探します。
Dir.glob(File.expand_path("schemas/*.rb", __dir__)).each do |file|
# fileは絶対パスだから、fileからロードされる
require file
end
確かに、絶対パスになっていた。
irb(main):001:0> __dir__
=> "."
irb(main):002:0> File.expand_path("schemas/*.rb", __dir__)
=> "/Users/yuuki_haga/repos/learning/rails/rails-n-plus-1/src/n_plus_one/backend/db/schemas/*.rb"
irb(main):003:0> file_path = File.expand_path("schemas/*.rb", __dir__)
irb(main):004:0> file_path
=> "/Users/yuuki_haga/repos/learning/rails/rails-n-plus-1/src/n_plus_one/backend/db/schemas/*.rb"
irb(main):005:0> Dir.glob(file_path)
=> ["/Users/yuuki_haga/repos/learning/rails/rails-n-plus-1/src/n_plus_one/backend/db/schemas/customers.rb", "/Users/yuuki_haga/repos/learning/rails/rails-n-plus-1/src/n_plus_one/backend/db/schemas/authors.rb", "/Users/yuuki_haga/repos/learning/rails/rails-n-plus-1/src/n_plus_one/backend/db/schemas/books.rb", "/Users/yuuki_haga/repos/learning/rails/rails-n-plus-1/src/n_plus_one/backend/db/schemas/reviews.rb"]
irb(main):006:0> exit
MySQLのDate型
Date型なんてあったんか。年月日を格納する
DATE
日付
'1000-01-01' から '9999-12-31'
フォーマット : 'YYYY-MM-DD'
Rakeタスク
Rakeとは、Rubyで実装されたMake(UNIX系のOSで使用できるコマンド)のようなビルド作業を自動化するgemである。Ruby Makeを略してRakeと言っている。
Rakeを使うことで、Rubyで書かれたコードをタスク(Rakeタスク)として作成しておき、必要に応じて呼び出し実行する事が出来る。
RakeタスクとMakefileに定義するのは同じようだけど、Rakeタスクの場合、Rubyのいろんな操作をコラボレーションすることができる。
RakefileってMakefile的なポジションか
Rakeの設定をいじりたいなら、Rakefileをいじる
実行可能なRakeタスクの一覧を確認する
以下のコマンドで、実行可能なRakeタスクの一覧を確認できる
rails -T
rails db:migrateなどいつも使用しているコマンドの実態は、Railsに組み込まれているRakeタスクだということがわかります。
なるほど、railsに組み込まれているRakeタスクってことか。
rakeタスクを実行するために、railsコマンドを使うか、rakeコマンドを使うか2つの選択肢があるけど、Rails 5以降を使っているならrailsコマンドに統一して問題ないそう。
namespaceは名前空間を指定します。命名規則などは特にありませんが、ブロック内の各タスクを包括する名詞がいいでしょう。
featureレベルでnamespaceは書いた方が良いね。
RakeタスクでActiveRecordを扱う場合、taskに:environmentオプションをつける必要があるそう。
Rakeタスクを試しに作ってみる。
まずジェネレーターでrakeタスクのファイルを作成
docker-compose exec app rails g task greet
Calling `DidYouMean::SPELL_CHECKERS.merge!(error_name => spell_checker)' has been deprecated. Please call `DidYouMean.correct_error(error_name, spell_checker)' instead.
create lib/tasks/greet.rake
# サンプルで作ったRakeタスク
namespace :greet do
# descはタスクの説明
desc "Say hello"
# taskの後ろにはタスク名を書く
task :say_hello do
puts "Hello hoge"
end
end
実行できた。
docker-compose exec app rails greet:say_hello
Calling `DidYouMean::SPELL_CHECKERS.merge!(error_name => spell_checker)' has been deprecated. Please call `DidYouMean.correct_error(error_name, spell_checker)' instead.
Hello hoge
Rails.env
Rails.envでRailsの環境を確認できる
Rails.root.to_s
Rails.root.to_sでプロジェクトルートパスを取得できる。
puts Rails.root # => /var/app
puts Rails.root.to_s # => /var/app
joinをすれば、絶対パスを作れたりもする。
conf_file = Rails.root.join("config/database.yml").to_s # => /var/app/config/database.yml
Rakeタスクについてはこの記事が参考になる
shで実行したら、確かに、コマンドになっていた。
sh("ridgepole", *options)
[ridgepole --apply --config /var/app/config/database.yml --file /var/app/db/Schemafile --env development]
リッジポールはマイグレートした際に、データベースの現在の状態をもとに、schema.rbに反映してくれない
generatorコマンドでマイグレーションファイルを生成しないようにする方法。
application.rbに書く。ridgepole gem入れただけだと、ここを対応したりはできない
リッジポールの動作
さて、ridgepoleの仕組みについてです。
大まかに以下のような手順で動作します。
- Schemafileを解読してスキーマを表すHashオブジェクトを作成する。
- -c/--configで指定したDBの設定ファイルにしたがってDBからスキーマのダンプを取得。同様にスキーマを表すHashオブジェクトを作る。
- その2つのHashオブジェクトを比較し、差分を表すHashオブジェクトを計算する。
- その差分を表すHashオブジェクトからActiveRecordが理解できる add_column などに改めて置き換え、ActiveRecordに渡す。
- ActiveRecordのMySQLのアダプタがMySQL用のSQLに置き換え、実行する。
↑
Schemafileにベタがきしなくても、分割しようと思えばできる
railsの外部キーのreferences型について
# t.referencesのメリット
# 1. userではなくuser_idというカラム名を作成してくれる
# 2. インデックスを自動で張ってくれる
# しかし、外部キー制約(foreign_key: true)は自分でつける必要がある
# reviewsテーブルのcustomer_idは同じ値が入ってもよい。入っちゃダメなら。ユニークインデックスにする
t.references :customer, unsigned: true, foreign_key: { on_delete: :cascade }
railsのt.referencesは、外部キーのカラムを作りたい時に使う。
t.referecesを使うと以下のようなメリットがある。
- 〇〇_idというカラム名を作成してくれる(以下の場合、customer_id)
- 〇〇_idカラムにインデックスを自動で張ってくれる
t.referecesを使うだけだと、外部キー制約(外部のテーブルの特定のカラムに含まれている値しか指定できないようにする制約のこと)を設定できないので注意する。t.referencesを使う際にforeing_keyを追加すれば、外部キー制約をつけれる。外部キー制約をつける際に、on_deleteなどのオプションをつけることもできる。
t.references :customer, unsigned: true, foreign_key: { on_delete: :cascade }
上のコードを適用することで、customer_idカラムは、外部のテーブルの特定のカラムに含まれている値しか入れられなくなる(データの整合性が担保される)。そして、on_delete: cascadeを設定したので、参照先のレコードが消えると、このレコードも消えるようになる。
外部キー制約のカラムにインデックスをつける理由は、親テーブルのレコードが削除された際に、子テーブルをチェックして外部キー制約を満たすかを高速に確認するためである。データの整合性が担保されているかをチェックするのに、子テーブルをフルテーブルスキャンしてたら時間がかかる。そのチェックを高速に処理するためにインデックスが使われるそう。
libディレクトリ
railsのRAILS_ROOT/libディレクトリには、プロジェクトで使用するライブラリを置いておきます。
例えば、既存クラスに機能を追加したい場合、まず追加機能を書いたファイルを用意します。
libディレクトリには、プロジェクトで使用するライブラリを置いておくそう。オープンクラスのファイルとか置いたりするそう。
ENVとENV.fetchの違い
ENV['hoge'] // 'hoge'という環境変数が存在しないと、nilが返ってくる
ENV['hoge'] || 'default' // 存在しない場合のデフォルト値を設定できる
ENV.fetch('hoge', 'default') // fetchを使うと、引数でデフォルト値を設定できる
defined?
defined?は引数として入れた変数やメソッドが定義済みであれば式の種別を表す文字列を返す。未定義であればnilを返す。
defined?は、変数が定義されているかを確認するために使う
ジェネレータで生成されるファイル
application.rbで初期化時の設定を変更する際に参考にありそう。
Rakefileの中身を深ぼる
# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
require_relative 'config/application'
# この一行でRailsとlib/tasks配下のRakeタスクが追加される
Rails.application.load_tasks
# db:migrate を実行するたびに自動的に注釈をつけるためには、
# rails g annotate:install を実行するか、Rakefile に Annotate.load_tasks を追加してください。
# この一行を追加すると、以下のRakeタスクが登録される(rails -Tで見れる)
# rake annotate_models # Add schema information (as comments) to model and fixture files
# rake annotate_routes # Adds the route map to routes.rb
# rake remove_annotation # Remove schema information from model and fixture files
Annotate.load_tasks if defined?(Annotate)
Rails.application.load_tasksを試しにコメントアウトしたら、rails -Tしたら、annotate系のrakeタスク以外全て消えた。
db:migrate を実行するたびに自動的に注釈をつけるためには、
rails g annotate:install を実行するか、Rakefile に Annotate.load_tasks を追加してください。
railsにおける一般的なdbマイグレーションの手順
- まず、データベースを作る。database.ymlをもとにデータベースの設定を書いて、db:createを実行。環境ごとのデータベースが作れる。
- マイグレーションファイルを作る
- db:migrateでマイグレーションを実行。もしマイグレーションが成功したなら、db:schema:dumpを実行して、現在のデータベースの内容をもとに、schema.rbを更新する。
マイグレーションファイルとschema.rbは直接的に関係しているわけではなくて、データーベースを経由して関係しているので、注意する。schema.rbは現在のデータベースの情報を表しているので、情報の信頼度は高い
ダンプ
ダンプとは、既存のデーターベースの内容を再現するようなcreate文やinsert文を表すファイルのこと。ダンプをもとに、データベースの内容を復元したりできる。
リッジポール用のRakeタスク
namespace :ridgepole do
desc "ridgepole apply"
task :apply do
environment = ENV["RAILS_ENV"] || "development"
# Rails.root.to_sでプロジェクトルートパスを取得できる
# puts Rails.root # => /var/app
# puts Rails.root.to_s # => /var/app
conf_file = Rails.root.join("config/database.yml").to_s # => /var/app/config/database.yml
schema_file = Rails.root.join("db/Schemafile").to_s
options = [
"--apply",
["--config", conf_file],
["--file", schema_file],
["--env", environment],
].flatten
# p options
# => ["--apply", "--config", "/var/app/config/database.yml", "--file", "/var/app/db/Schemafile", "--env", "development"]
# コマンドを実行する
sh("ridgepole", *options)
# Rails.envでRailsの環境を確認できる
# あるRakeタスクから別のRakeタスクを実行する必要がある場合、以下のような書き方をする
# db:schema:dumpを実行することで、データベースの最新の状態をもとにschema.rbを更新する。
Rake::Task["db:schema:dump"].invoke if Rails.env.development?
# defined?は引数として入れた変数やメソッドが定義済みであれば式の種別を表す文字列を返す。未定義であればnilを返す。
# defined?は、変数が定義されているかを確認するために使う
Rake::Task["annotate_models"].invoke if defined?(Annotate) && Rails.env.development?
end
end
Railsにおけるdbマイグレーションの手順
1. まず、データベースを作る。database.ymlをもとにデータベースの設定を書いて、db:createを実行。環境ごとのデータベースが作れる。
2. マイグレーションファイルを作る
3. db:migrateでマイグレーションを実行。もしマイグレーションが成功したなら、db:schema:dumpを自動で実行して、現在のデータベースの内容をもとに、schema.rbを更新する。
マイグレーションファイルとschema.rbは直接的に関係しているわけではなくて、データーベースを経由して関係している。schema.rbは現在のデータベースの情報を表しているので、情報の信頼度は高い。
Ridgepoleにおけるdbマイグレーションの手順
1. Schemafileを解読してスキーマを表すHashオブジェクトを作成する。
2. -c/--configで指定したDBの設定ファイル(database.yml)にしたがってDBからスキーマのダンプを取得。同様にスキーマを表すHashオブジェクトを作る。
3. その2つのHashオブジェクトを比較し、差分を表すHashオブジェクトを計算する。
4. その差分を表すHashオブジェクトからActiveRecordが理解できる add_column などに改めて置き換え、ActiveRecordに渡す。
5. ActiveRecordのMySQLのアダプタがMySQL用のSQLに置き換え、実行する。
ridgepoleはマイグレーションファイルを使わないので、マイグレーションファイルがめちゃくちゃ多いみたいな状況を防ぐことができる。このマイグレーションファイルが原因で、このマイグレーションができないみたいなのも防げる。あとマイグレーションファイルを生成しなくてもSchemafileをいじればカラムを追加したり、変更できるので便利。
ridgepoleのdbマイグレーションの手順を見る限り、db:migrationコマンドが実行されるわけではないので、db:schema:dumpが自動で実行されない。そのため、schema.rbに最新のデータベースの情報を反映させられない。仮に反映するとしても手動でdb:schema:dumpを実行する必要がある。
ridgepoleを使う場合、
1.ridgepoleのdbマイグレーションを実行した後に、
2. db:schema:dumpを実行
これらの一連の流れをタスクとして実行したい。そのような場合、Rakeタスクで実装する。
↓ ridgepole.rake
namespace :ridgepole do
desc "ridgepole apply"
task :apply do
environment = ENV["RAILS_ENV"] || "development"
# Rails.root.to_sでプロジェクトルートパスを取得できる
conf_file = Rails.root.join("config/database.yml").to_s # => /var/app/config/database.yml
schema_file = Rails.root.join("db/Schemafile").to_s
options = [
"--apply",
["--config", conf_file],
["--file", schema_file],
["--env", environment],
].flatten
# コマンドを実行する
sh("ridgepole", *options)
# あるRakeタスクから別のRakeタスクを実行する必要がある場合、以下のような書き方をする
# db:schema:dumpを実行することで、データベースの最新の状態をもとにschema.rbを更新する。
Rake::Task["db:schema:dump"].invoke if Rails.env.development?
Rake::Task["annotate_models"].invoke if defined?(Annotate) && Rails.env.development?
end
end
↓ Makefile
migrate:
docker-compose exec app rails ridgepole:apply
これで、make migrateを実行すると、ridgepoleのdbマイグレーションを実行しつつ、db:schema:dumpを実行して、schema.rbに最新のデータベースの状態を反映してくれる
今までRakeタスクっていつ使うのかなって思ってたけど、Rubyの一連のタスクを実行したい時に使うと良さそう
次はOpenAPIの設定をやって、テスト基盤を作ろう
N + 1にいつになったら辿り着けるのやら
なんでそれを使うのか?
どういうモチベーションでそれを使うのか?
メリットデメリットは何か?
できるだけここら辺の解像度は高くありたい
これ前も見たけど、参考になる
OpenAPIの設定とテスト基盤やるか
OpenAPI
OpenAPIとは、Web API のインタフェースを定義するための記法のことである。OpenAPIの記法で、APIドキュメントを定義することができる。OpneAPIの仕様に沿ったAPIドキュメントを定義することで、そのAPIドキュメントから、APIクライアントを作成したり、そのAPIドキュメントを使って自動テスト(リクエストスペック)をかけたりする。
使い方
API定義ファイルを用意するだけ。
形式は JSON を YAML の両方がある。
基本フォーマット
openapi: 3.0.3 # [必須] Open API バージョンを指定します
info: # [必須] API 定義の基本情報を記載します
...
servers: # API サーバの情報を記載します
...
paths: # [必須] エンドポイントのリクエストやレスポンスを記載する。メインとなる部分
...
components: # 共通部分をここにまとめておきます
...
seed_fu導入するか
seed_fuのメリット
- seedデータを変更したとき、その変更だけを反映してくれる。その変更を新しいデータとして追加ない。
- デフォルトのseed.rbだと、新しいデータとして追加してしまうので、テーブルのデータを一回削除する必要があった
- 環境ごとにseedデータを分けやすい。
seed_fuのサンプル
author_ids = Author.pluck(:id)
# N + 1が起こるから本来はあまりよろしくない
author_ids.each do |author_id|
3.times do
# データの同一性はデフォルトでは、idをもとに判定している
# :author_idを書くと、同じauthor_idを持つレコードが存在した場合、レコードを追加しない。更新部分だけ更新する
Book.seed(
# :author_id,
{
title: Faker::Book.title,
published_date: "2023-12-11",
price: 1000,
submitted_at: Time.current,
status: :published,
page_count: 100,
author_id:,
}
)
end
end
Ruby3.1でハッシュとバリューが一致した時に省略できるようになった
enum
enumについてはこの記事が一番シンプルで分かりやすい
Time.current
Rspecの.rspec、rails_helper.rb, spec_helper.rbの特徴について深ぼる
rspecコマンド
rspec-core gemをインストールすると、rspec実行ファイルがインストールされます。rspecコマンドには多くの便利なオプションが用意されています。rspec --helpを実行すると、その一覧が表示されます。
コマンドラインオプションの保存 .rspec
プロジェクトのルートディレクトリにある .rspec ファイルにコマンドラインオプションを保存することができます。rspecコマンドはコマンドラインで入力したのと同じようにそれらを読み込む。
.rspecファイルには、要はrspecコマンドに渡すことができるオプションを書くことができるのか。.rspecに書いた。rspecコマンドはその.rspecファイルを見て実行するそう。 --requireはファイルを読み込むrspecコマンドのオプション。この.rspecファイルのおかげで、全てのスペックファイルでrequire spec_helperとかrequire rails helperとか書かずに済む
rspec-rails のREADMEを読むと、これからは spec/rails_helper.rb に Rails 特有の設定を書き、spec/spec_helper.rbには RSpec の全体的な設定を書く、というようにお作法が変わるそうです。これによって、Railsを必要としないテストを書きやすくなるんだとか。
なるほど、Rspecをrailsで使う際のrails特有の設定はrails_helper.rbに書き、spec_helperはRspec全体の設定を書くのか。RspecってsinatraとかRubyでも使えるもんね。
# Checks for pending migrations and applies them before tests are run.
# If you are not using ActiveRecord, you can remove these lines.
begin
ActiveRecord::Migration.maintain_test_schema!
rescue ActiveRecord::PendingMigrationError => e
abort e.to_s.strip
end
railsのdbマイグレーション使わないから、rails_helper.rbの上の行消してた。
1つのitメソッドのブロックに複数のexpectを書く際のtips
itメソッド中に複数expectがあるときにどこかでexpectが失敗した場合、後続するexpect群はテスト実行されない。後続も実行したいときはaggregate_failuresオプションをrails_helperに追加する。これを設定しないと、最初のものでパスしないと2つめも結果は期待どおりなのか、どうなのかわからない。
↓ spec_helper.rb
RSpec.configure do |config|
config.expect_with :rspec do |expectations|
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
end
config.mock_with :rspec do |mocks|
mocks.verify_partial_doubles = true
end
config.shared_context_metadata_behavior = :apply_to_host_groups
config.filter_run_when_matching :focus
# itメソッド中に複数expectがあるときにどこかでexpectが失敗した場合、後続するexpect群はテスト実行されない。
# 後続も実行したいときはaggregate_failuresオプションを追加します
# これを設定しないと、最初のものでパスしないと2つめも結果は期待どおりなのか、どうなのかわからない。
config.define_derived_metadata do |meta|
meta[:aggregate_failures] = true
end
end
rspec-rails gem
rspec-railsは、Ruby on RailsのデフォルトのテストフレームワークであるMinitestのドロップイン代替品として、RSpecテストフレームワークをRuby on Railsにもたらします。
RSpecでは、テストは単にアプリケーションコードを検証するスクリプトではありません。テストは仕様書(略してスペック)でもあります。アプリケーションがどのように動作することになっているかを、平易な英語で詳細に説明するものです。
rspec-rails gemは、RspecをRuby on Railsで使えるようにしたもの?であると思われる。
RSpecにおいて、テストはアプリケーションコードを検証するスクリプトではなく、アプリケーションがどのように動作するかを説明する仕様書である。
Rack
この記事普通に分かりやすかった。
↓ requireとrequire_relativeは拡張子を省略できる
Rackとは、アプリケーションサーバとWebアプリケーション間のインターフェースのこと。
このインターフェースが存在することで、新しいWebアプリケーションフレークワークが出た際に、アプリケーションサーバー側で対応する必要がなくなった。
アプリケーションをRackの規約に則ったアプリケーションにするためには、callメソッドを定義する(この時のアプリケーションをRackアプリケーションと言ったりもする)。callメソッドを定義することで、アプリケーションの入出力のインターフェースがすごくシンプルになる。
Rackを動かすには、rack gemをインストールする必要がある。
rackをインストールすると、rackupコマンドが利用できるようになる。
config.ruは、Rackが利用するエントリーポイント用のファイルである。
Rackミドルウェア
Rackミドルウェアとは、アプリケーションサーバとRackアプリケーションの間に処理を追加するミドルウェアである。Rackに用意されている機能である。
Rackミドルウェアを利用するには、config.ruで、useメソッドを使う。
↓ Rackミドルウェア
# Rackミドルウェアは以下のインタフェースを満たす必要がある
# 1. initializeメソッドに引数を1つ取る
# 2. Rackアプリケーションと同じインターフェースのcallメソッドを用意する
class SimpleMiddleware
attr_reader :app
def initialize(app)
puts "*" * 50
puts "* #{self.class} initialize(app = #{app.class})"
puts "*" * 50
@app = app
end
# ミドルウェアはinitializeで後続として処理していくアプリケーションまたはミドルウェアのオブジェクトを受け取り
# 自身のcallメソッドが呼ばれた時に、initializeで受け取ったオブジェクトのcallメソッドを呼ぶことで
# 後続の処理を行っている
# Rackアプリケーションの後に特定の処理をしたいなら、app.callが呼ばれた後に実行すれば良いってことか
# やっぱ玉ねぎみたいな感じか。callが呼ばれた時に、その層での処理が一旦ストップするからか。
# リクエストを受け取ると、Rackミドルウェア・Rackアプリケーションのcallメソッドを呼び出しながら、
# リクエストデータに基づく処理を行う。中心に位置するRackアプリケーションまで到達すると、
# callメソッドの戻り値を使ってレスポンスデータを返しながら、各層でレスポンスデータに対する処理を行い
# クライアントに返すレスポンスデータを決定する
def call(env)
status, headers, body = app.call(env)
puts "*" * 50
puts "* #{self.class} call(body = #{body})"
puts "*" * 50
[status, headers, body]
end
end
↓ cofig.ru
# Rackライブラリを読み込むことで、runメソッドが使用できるようになる
# runメソッドには、Rackアプリケーションのインスタンスをわたす
require "rack"
require_relative "src/sample_app"
require_relative "src/lib/middlewares/simple_middleware"
use Rack::Runtime
use SimpleMiddleware
run SampleApp.new
module function Kernel.#abort
abortは、Ruby プログラムをエラーメッセージ付きで終了する。終了ステータスは 1 固定である。
Railsとrackupコマンドの関係
rackupコマンドを実行すると、Rackアプリケーションが起動して、サーバーが立ち上がる。
rackupコマンドを実行すると、Rack::Server.startを実行する。
Railsでrails sする際には、Rack::Serverクラスを継承したRails::Serverクラスを生成して、Rack::Server#startを実行している。なので、rails sを実行した際に実はrackupを実行している。
rails middlewareコマンド
rails middlewareコマンドで、Railsで利用しているミドルウェアの一覧を表示することができる
docker compose exec app rails middleware
Calling `DidYouMean::SPELL_CHECKERS.merge!(error_name => spell_checker)' has been deprecated. Please call `DidYouMean.correct_error(error_name, spell_checker)' instead.
use ActionDispatch::HostAuthorization
use Rack::Sendfile
use ActionDispatch::Static
use ActionDispatch::Executor
use ActiveSupport::Cache::Strategy::LocalCache::Middleware
use Rack::Runtime
use ActionDispatch::RequestId
use ActionDispatch::RemoteIp
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use WebConsole::Middleware
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
use ActionDispatch::Reloader
use ActionDispatch::Callbacks
use ActiveRecord::Migration::CheckPending
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
run NPlusOne::Application.routes
Railsプロジェクトにもconfig.ruがあって、このファイルがエントリーポイントになって、Rack::Server.startが実行されているのか。
rails_helper.rbのuse_transactional_fixtures, infer_spec_type_from_file_location, filter_rails_from_backtrace!の解説。
# rails_helper.rb内で、spec_helper.rbの内容を読み込んでいるな。
require 'spec_helper'
ENV['RAILS_ENV'] ||= 'test'
require_relative '../config/environment'
# abortは、Rubyプログラムをエラーメッセージ付きで終了する。終了ステータスは 1 固定である。
# つまり、本番環境なら、強制終了する
abort("The Rails environment is running in production mode!") if Rails.env.production?
require 'rspec/rails'
Rails.root.glob('spec/support/**/*.rb').sort.each { |f| require f }
RSpec.configure do |config|
config.include FactoryBot::Syntax::Methods
# テストデータで使うそう
# 正直FactoryBotを使うから、なくても良さそう
config.fixture_path = Rails.root.join('spec/fixtures')
# use_transactional_fixtures という設定項目が true になっていると、exampleのたびにデータが削除されるようになる。
# trueにすると、1つのexampleがデータベースのトランザクションの中で実行されるようになり、exampleの終わりにロールバックされ、データが消えるという仕組みになっている。
# テストを実行したときの log/test.log を見るとその様子がよくわかる。
config.use_transactional_fixtures = true
# 以下のオプションを書くことで、specファイルが配置しているディレクトリから、自動的にspecのタイプ(model, controller, feature, requestなど)を判別してくれる
# specファイルでそれぞれのspecタイプを明示する必要がなくなった
config.infer_spec_type_from_file_location!
# Filter lines from Rails gems in backtraces.
# テスト時のgemによるバックトレースをフィルタリングする
# テスト失敗時のノイズを減らすための設定であるそう
config.filter_rails_from_backtrace!
end
before(:all)とbefore(:suite)
RSpec.configureのブロックの中に以下を書く
# before(:suite)はRspec実行時に一度だけ実行される
config.before(:suite) do
# この一行でRailsとlib/tasks配下のRakeタスクが追加される
Rails.application.load_tasks
# おそらくridgepole:applyのrakeタスクを呼び出している
Rake.application["ridgepole:apply"].invoke
# fakerのロケールを設定する
Faker::Config.locale = :en
end
# before(:all)はcontext/describeブロック実行時に呼び出される
config.before(:all) do
# Fakerはユニーク値が尽きると例外が起こるので、
# Faker::UniqueGenerator.clearでユニーク値をカウントをリセットできる
Faker::UniqueGenerator.clear
end
# テスト環境用にアダプターを変更しておく
ActiveJob::Base.queue_adapter = :test
end
ActiveSupport::Testing::TimeHelpersとRSpec::JsonMatcher
RSpec.configureのブロックの中に以下を書く
# ActiveSupport::Testing::TimeHelpersは、時間の経過をテストするのに役立つヘルパーが含まれているモジュール
# travel_toとか入っている
config.include ActiveSupport::Testing::TimeHelpers
# RSpec::JsonMatcherは、rspec-json_matcher gemが提供するマッチャを利用するためのモジュール
config.include RSpec::JsonMatcher
type, subject, create_list
type
spec/models以外の場所にspecファイルがある際に、rspec-rails gemの用意するヘルパーをexampleの中で使えるようにするため記法だそう。
subject
グループ・スコープの subject を使用して、example スコープの subject メソッドが返す値を明示的に定義する。
subjectを使うことで、exampleスコープのsubjectメソッドが返す値を明示的に定義することができる。
named subjectという機能があって、名前付きでsubjectを定義することができる。
create_list
create_listは、ファクトリーから複数のインスタンスを生成する時に使う
create_listの場合、ファクトリーから作成したデータがDBにインサートされている
have_attributes
have_attributesは、実際の値の属性値が、期待される属性ハッシュと一致するかをチェックするマッチャである。
# spec/requests配下のテストではないから、requestを書いている
# /api/authorsについてのテストを書くよ~
RSpec.describe "/api/authors", type: [:request] do
# GET /api/authorsについてのテストを書くよ〜
describe "GET /api/authors" do
# グループ・スコープの subject を使用して、example スコープの subject メソッドが返す値を明示的に定義する。
subject { api_get "/api/authors" }
before do
# create_listは、ファクトリーから複数のインスタンスを生成する時に使う
# create_listの場合、ファクトリーから作成したデータがDBにインサートされている
create_list(:author, 3)
end
it "returns ok" do
subject
expect(response).to have_http_status(:ok)
expect(response.body).to be_json_including(data: {
# これってauthorsオブジェクトのsizeプロパティの値を検証しているってことか
authors: have_attributes(size: 3),
})
end
end
end
committee committee-rails
commitee-railsは、commiteeをRailsに導入しやすくするためのgemである。
commiteeは、JSON Schema、OpenAPI 2、OpenAPI 3のスキーマに基づいて、HTTPリクエストメッセージ& HTTPレスポンスメッセージを検証するミドルウェアを提供するgemである。
committee-rails gemの/lib/committee/rails/test/methods.rbの中で、committee gemの/lib/committee/test/methods.rbをincludeしている。そして、commitee-rails gemのmethods.rb側でcommittee_optionsやrequest_object、response_dataなどの既存メソッドをオーバーライドしている。
(モジュールAの中でモジュールBをinlcludeして、モジュールBと同名のメソッドをモジュールAに定義すると、モジュールでもメソッドのオーバーライドができる。)
レスポンススキーマを検証するassert_response_schema_confirm
メソッドをRspecで使うのがよくあるcommitee-railsの使い方だが、assert_response_schema_confirm
メソッド自体は、committee-rails gemに定義されていない。commitee gemに定義されている。
↓ committee-rails gemの/lib/committee/rails/test/methods.rb
require 'committee'
require 'committee/rails/request_object'
module Committee::Rails
module Test
module Methods
include Committee::Test::Methods
def committee_options
if defined?(RSpec) && (options = RSpec.try(:configuration).try(:committee_options))
options
else
{ schema_path: default_schema, query_hash_key: 'rack.request.query_hash', parse_response_by_content_type: false }
end
end
def default_schema
@default_schema ||= Committee::Drivers.load_from_file(Rails.root.join('docs', 'schema', 'schema.json').to_s)
end
def request_object
@request_object ||= Committee::Rails::RequestObject.new(integration_session.request)
end
def response_data
[integration_session.response.status, integration_session.response.headers, integration_session.response.body]
end
end
end
end
↓ committee gemのlib/committee/test/methods.rb
# frozen_string_literal: true
module Committee
module Test
module Methods
def assert_schema_conform(expected_status = nil)
assert_request_schema_confirm unless old_behavior
assert_response_schema_confirm(expected_status)
end
def assert_request_schema_confirm
unless schema_validator.link_exist?
request = "`#{request_object.request_method} #{request_object.path_info}` undefined in schema (prefix: #{committee_options[:prefix].inspect})."
raise Committee::InvalidRequest.new(request)
end
schema_validator.request_validate(request_object)
end
def assert_response_schema_confirm(expected_status = nil)
unless schema_validator.link_exist?
response = "`#{request_object.request_method} #{request_object.path_info}` undefined in schema (prefix: #{committee_options[:prefix].inspect})."
raise Committee::InvalidResponse.new(response)
end
status, headers, body = response_data
if expected_status.nil?
Committee.need_good_option('Pass expected response status code to check it against the corresponding schema explicitly.')
elsif expected_status != status
response = "Expected `#{expected_status}` status code, but it was `#{status}`."
raise Committee::InvalidResponse.new(response)
end
if schema_coverage
operation_object = router.operation_object(request_object)
schema_coverage&.update_response_coverage!(operation_object.original_path, operation_object.http_method, status)
end
schema_validator.response_validate(status, headers, [body], true) if validate_response?(status)
end
def committee_options
raise "please set options"
end
def request_object
raise "please set object like 'last_request'"
end
def response_data
raise "please set response data like 'last_response.status, last_response.headers, last_response.body'"
end
def validate_response?(status)
Committee::Middleware::ResponseValidation.validate?(status, committee_options.fetch(:validate_success_only, false))
end
def schema
@schema ||= Committee::Middleware::Base.get_schema(committee_options)
end
def router
@router ||= schema.build_router(committee_options)
end
def schema_validator
@schema_validator ||= router.build_schema_validator(request_object)
end
def schema_coverage
return nil unless schema.is_a?(Committee::Drivers::OpenAPI3::Schema)
coverage = committee_options.fetch(:schema_coverage, nil)
coverage.is_a?(SchemaCoverage) ? coverage : nil
end
def old_behavior
committee_options.fetch(:old_assert_behavior, false)
end
end
end
end
describe
describeにはこれからテストする項目を書く。
describeは、説明するみたいな意味。Rspec.describe "/api/authors"と書いた場合、"/api/authors"について説明するよと言う意味になる。
it
itはdescribeに書かれた説明を指している
factory_bot gemとfactory_bot_rails gem
factory_bot_rails gemはfactory_bot gemをrailsに特化させたもの
rspecでdebuggerとかFactoryBotが使えない問題が発生した。
.rspecにはいかのようなコードが書いてある
--require spec_helper
--format documentation
gemがインストールされているかを確認するコマンド
gemがインストールされていないから、gem特有のコマンドが使えないってパターンがワンチャンあるから、gemがインストールされているかを確認するのは大事。
gem list
rspecコマンドのオプションを調べるコマンド
rspec --helpを実行すると、rspecコマンドのオプションを調べることができる。
rspecコマンドを実行した際のざっくりとした実行順序
おそらくこんな感じ。
- rspecコマンドを実行する
- .rspecファイルが読み込まれる
- .rspecファイルに書いたrails_helper.rbが読み込まれる
- rails_helper.rb内で
require spec_helper
を実行して、spec_helper.rbが読み込まれる - spec_helper.rbの読み込みが完了したら、rails_helper.rbが引き続き読み込まれる。
- rails_helper.rbの読み込みが完了したら、テストケースが実行される。
.rspecファイルってなんだ?って思ってたけど、rspec-coreというgemのREADMEに詳細が書いてあった。
コマンドラインオプションの保存 .rspec
プロジェクトのルートディレクトリにある .rspec ファイルにコマンドラインオプションを保存することができます。rspecコマンドはコマンドラインで入力したのと同じようにそれらを読み込む。
このコマンドラインオプションというのは、rspecコマンドのオプションのこと。つまり、.rspecにはrspecコマンドのオプションが書かれている。以下の--requireとか--formatは全部rspecコマンドのオプション。rspecコマンドのオプションは、rspec --help
で確認できる。
↓ .rspec
--require rails_helper
--format documentation
もし、.rspecを以下のように書くと、rails_helper.rbが読み込まれない。
--require spec_helper
--format documentation
rails_helper.rbが読み込まれないってことは、rails_helper.rbに書いたrequire_relative '../config/environment'
が実行されないってこと。これが実行されないと、rails特有の設定がrspec実行時に効かないことを意味する。あとプロジェクトで使っているgemをrspec実行時に使えなかったりする。
↓ specファイルでrequire rails_helperを毎回書いてた。
.rspecファイルを使うメリット
.rspecファイルを使うメリットは、
- rspecコマンドを実行する際に毎回使うオプションを、わざわざ指定しなくても良くなる
- テストファイルにrequire rails_helperを書かなくて良くなる。なので、テストファイルを作成するたびに毎回毎回require rails_hleperと書く必要がなくなる。
Rubyのrequireについて
Rubyのrequireって、複数のファイルを一つのファイルにまとめている感じがするね。
TSのモジュールインポートとは若干違う気もする。
- comitteeについてざっくり調べる
- ヘルパーの中身について深ぼる
- apiを実装してテストを通す(シリアライザーも導入する)
- rubodopやる。todoも深ぼる
- bullet導入する。n + 1もやってみる。n + 1が発生するapiも実装する。
- n + 1の解消方法の違い(include, preload, eager_load)をまとめる。どうなるかを見てみる。
- comitterをrackに導入したらどうなるかを調べる。
- エラーハンドリングの機構を作る
- トランザクションを使うapi何か実装する