Open150

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によってプロセスが削除されるかもしれないし。そのため、一度にたくさんのメモリを使うのは避けましょう。

https://tech.smarthr.jp/entry/2021/11/11/151444

ハガユウキハガユウキ

シバン

シバンとは、#!のこと。
シバンとシバンの後に続く命令を書くことで、スクリプトファイルを任意のプログラムで実行しろと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
ハガユウキハガユウキ
ハガユウキハガユウキ

envを使う理由は、システム上のある位置にrubyインタプリタが必ずしも存在するわけではないからである。

ハガユウキハガユウキ

rbenvとbundler

rbenvとは、1つのホストマシン上で複数のバージョンのRubyを扱うためのソフトウェアである。rbenvがあることによって、1つのホストマシンが持つプロジェクトごとに異なるRubyで開発することができる。rbenvがない場合、プロジェクトを切り替えるごとにホストマシンにRubyを都度インストールし直すか、仮想マシンを使うかなどの必要性が出てくる。
Bundlerとは、gem同士の依存関係やどのバージョンのgemがインストールされたかを管理するためのgemである。Bundler自体は、gem installでインストールする。
bundle exec コマンド名を実行するとBundlerでインストールされているgemを利用してコマンドを実行することができる。

https://yukihaga.hatenablog.com/entry/2023/02/05/205557#rbenvとは何か

ハガユウキハガユウキ

server.pidを削除する理由

server.pidはサーバー起動時に生成され、server.pidが存在すると、サーバーは起動状態であると認識される。
サーバー終了後、server.pidは残ったままの可能性があり、その場合、サーバー再起動時に「サーバーは再起動しています」というエラーが吐かれてしまうそう。そのため、サーバーを立ち上げるタイミングまたはサーバーを終了させるタイミングでserver.pidを削除させるようなプログラムを書く。そうすれば、サーバーを問題なく起動することができる

https://qiita.com/zakino123/items/dbdea7bc6a490e863bd2

ハガユウキハガユウキ

シェルスクリプトの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

https://qiita.com/yaaabu51/items/8758447fa672288f4757#----例題-----4

ハガユウキハガユウキ

docker-composeのhealthcheckについて

一般的なヘルスチェックとは、サーバー上のプログラムが正常に動作を実行できるかを確認することである。
docker-compose.ymlにdepneds_onだけを書くと、どの順番でコンテナを起動するかを指定するだけである。そのため、mysqlコンテナが起動したけどクエリを受けつけてない状態でappコンテナを立ち上げてしまう。この時、mysqlコンテナがクエリを受けつけられる状態になっていないのにappコンテナが立ち上がっているので、appコンテナを起動する際にクエリを発行する場合、エラーが起きたりする。appコンテナからのアクセスを受け付けられるようにしてから、appコンテナを起動するようにしたいなら、mysqlコンテナのヘルスチェックが完了したらappコンテナを起動するようにすれば良い。
以下のコードのヘルスチェックでは、指定したインターバルで複数回testに指定したヘルスチェックを実行する。コンテナを起動した直後は、コンテナはstarting状態になっている。もし1回でもヘルスチェックが成功したら、コンテナはhealthy状態になる。retriesの回数失敗すれば、コンテナはunhealthy状態になる。

  1. appコンテナのサービスの部分のdepends_onにmysqlコンテナを指定する。その際に、condtion: service_healtyを指定する。そうすることで、mysqlコンテナのヘルスチェックが正常な場合に、appコンテナを起動するようにできる。
    depends_on:
      mysql:
        # 依存サービスのヘルスチェックがパスするまで待つ
        # 「サービスが開始する」ことと「他のサービスからのアクセスを受け付けられる」ことは異なるということです。
        # 例えば、MySQL や PostgreSQL などはプロセス開始直後からすぐにクエリを実行できるわけではありません。
        # プロセス開始後に起動処理が実行されクエリを受け付けることができる状態になるまでに少なくとも数秒かかると思います。
        # service_started はサービスが開始されるのを待つだけなので、「他のサービスからのアクセスを受け付けられる」状態になっているかどうかは保証しません。
        # service_healthy は適切なヘルスチェックを行うことにより「他のサービスからのアクセスを受け付けられる」状態になるまでそれに依存しているサービスは起動を待ちます。
        condition: service_healthy
  1. 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

https://tech.actindi.net/2022/02/21/083000

ハガユウキハガユウキ

全体像

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:

ハガユウキハガユウキ

curlの-fオプション

-fオプションは、curlがサーバーから受け取ったHTTPステータスコードが失敗の場合(404 Not Foundなど)、エラーとして扱うことを指示しています。

ハガユウキハガユウキ

docker-compose.ymlのdelegatedとcachedについて

ホストとコンテナでデータの一貫性を担保するのは大事だが、データの一貫性をそこまで担保しなくて良い場面でも担保してしまい、パフォーマンスを落とすことがあるそのため、delegated or cachedを使う。
delegatedは最も弱い保証を提供する。コンテナが実行した書き込みがホストファイルシステムに即座に反映されないことがある。delegatedでは、コンテナのファイルシステム上の表示が信頼できるものとなる。
cachedはdelegatedの性質とコンテナが実行した書き込みの可視性に関する保証を提供する。cached としてマウントしたディレクトリは、ホスト側ファイルシステムが信頼できます。つまり、コンテナでの書き込み処理は即時ホスト側でも見えるようになりますが、ホスト上での書き込み処理がコンテナ内で見えるようになるには遅延が発生しうるでしょう。
https://docs.docker.jp/docker-for-mac/osxfs-caching.html#delegated

ハガユウキハガユウキ

わかりそうでわからん。
とりあえず、ホストのやつを連携するなら、cachedのが良くて、ホストは特に重要じゃないなら、delegatedにするって感じか

ハガユウキハガユウキ

RubyのDockerイメージのGEM_HOMEについて

GEM_HOMEは、どのディレクトリにGemをインストールするかを指定する環境変数。

https://r7kamura.com/articles/2022-04-01-ruby-dockerfile-env

ハガユウキハガユウキ

rspec-parameterized gem

goのゴールデンテストに似ている。
1つのテストケースを複数のデータのパターンで試したい時に使える。
モデルやサービスの単体テストで、一つのテストケースで複数のデータのパターンを試したい時に使えそう

ハガユウキハガユウキ

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なカラムを扱いやすくしてくれる。
フォームで一つの項目に対して、複数の値を選択する場面は全然ありそうだから、いつか使う場面が来そう。
https://tech.actindi.net/2019/05/13/091143

ハガユウキハガユウキ

Global gem

よく使うconfig gemは、開発、本番というジャンルごとにファイルを分けているが、
Global gemは、取り扱う値ごとにファイルがあって、そのファイルの中で開発環境の時の値や本番環境の時の値を書く

https://spirits.appirits.com/doruby/9975/

ハガユウキハガユウキ

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ディレクトリを検索します。

ハガユウキハガユウキ

tzinfo-dataはTZInfoが参照するタイムゾーン情報を提供するgem。
TZInfoはRubyからタイムゾーン情報を参照し、その情報に基づいて時間をコンバートするためのライブラリ。

なるほど、TZInfoがタイムゾーン情報を元に時間を変換するためのRubyライブラリ。
tzinfo-dataはTZInfoが参照するタイムゾーン情報を提供するgemか。
https://qiita.com/tatama/items/3f0f5e42cb5f75b53817

ハガユウキハガユウキ

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
ハガユウキハガユウキ

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コマンドがずれていることがわかる)。

ハガユウキハガユウキ

ちゃんとサーバーのタイムゾーンを(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#
ハガユウキハガユウキ

Railsのタイムゾーン周りの設定について

3.8.9 config.active_record.default_timezone
データベースから日付・時刻を取り出した際のタイムゾーンをTime.local(:localを指定した場合)とTime.utc(:utcを指定した場合)のどちらにするかを指定します。デフォルト値は:utcです。

https://railsguides.jp/configuring.html#初期化コードの置き場所

アプリケーションのタイムゾーンを設定する前

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使っといた方が良いな。
https://qiita.com/joker1007/items/2c277cca5bd50e4cce5e

ハガユウキハガユウキ

ループバックアドレス(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にすれば、通せる的な感じかな。

https://docs.docker.com/compose/compose-file/compose-file-v3/#ports
https://teratail.com/questions/211450

ハガユウキハガユウキ

Railsのdatabse.yml

dbのコネクションの設定とか、dbmsに何を使うかとか、本番ステージング開発でどんなdbコネクションをするとか、全部こいつに書けばいい感じにやってくれる。goでやってためんどくさい作業から解放される

ハガユウキハガユウキ

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

https://docs.ruby-lang.org/ja/latest/method/Kernel/m/__dir__.html
https://docs.ruby-lang.org/ja/latest/method/File/s/expand_path.html
https://docs.ruby-lang.org/ja/latest/method/Dir/s/=5b=5d.html
https://docs.ruby-lang.org/ja/latest/method/Kernel/m/require.html

ハガユウキハガユウキ

確かに、絶対パスになっていた。

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
ハガユウキハガユウキ

Rakeタスク

Rakeとは、Rubyで実装されたMake(UNIX系のOSで使用できるコマンド)のようなビルド作業を自動化するgemである。Ruby Makeを略してRakeと言っている。

Rakeを使うことで、Rubyで書かれたコードをタスク(Rakeタスク)として作成しておき、必要に応じて呼び出し実行する事が出来る。

https://qiita.com/mmaumtjgj/items/8384b6a26c97965bf047

ハガユウキハガユウキ

RakeタスクとMakefileに定義するのは同じようだけど、Rakeタスクの場合、Rubyのいろんな操作をコラボレーションすることができる。
RakefileってMakefile的なポジションか

ハガユウキハガユウキ

実行可能な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
ハガユウキハガユウキ

shで実行したら、確かに、コマンドになっていた。

    sh("ridgepole", *options)
 [ridgepole --apply --config /var/app/config/database.yml --file /var/app/db/Schemafile --env development]
ハガユウキハガユウキ

リッジポールはマイグレートした際に、データベースの現在の状態をもとに、schema.rbに反映してくれない

ハガユウキハガユウキ

リッジポールの動作

さて、ridgepoleの仕組みについてです。
大まかに以下のような手順で動作します。

  1. Schemafileを解読してスキーマを表すHashオブジェクトを作成する。
  2. -c/--configで指定したDBの設定ファイルにしたがってDBからスキーマのダンプを取得。同様にスキーマを表すHashオブジェクトを作る。
  3. その2つのHashオブジェクトを比較し、差分を表すHashオブジェクトを計算する。
  4. その差分を表すHashオブジェクトからActiveRecordが理解できる add_column などに改めて置き換え、ActiveRecordに渡す。
  5. ActiveRecordのMySQLのアダプタがMySQL用のSQLに置き換え、実行する。

https://qiita.com/tetz-akaneya/items/d10570aeb028fc603b86#テーブルにカラムを足す


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 }

https://qiita.com/ryouzi/items/2682e7e8a86fd2b1ae47

ハガユウキハガユウキ

railsのt.referencesは、外部キーのカラムを作りたい時に使う。
t.referecesを使うと以下のようなメリットがある。

  1. 〇〇_idというカラム名を作成してくれる(以下の場合、customer_id)
  2. 〇〇_idカラムにインデックスを自動で張ってくれる

t.referecesを使うだけだと、外部キー制約(外部のテーブルの特定のカラムに含まれている値しか指定できないようにする制約のこと)を設定できないので注意する。t.referencesを使う際にforeing_keyを追加すれば、外部キー制約をつけれる。外部キー制約をつける際に、on_deleteなどのオプションをつけることもできる。

  t.references :customer, unsigned: true, foreign_key: { on_delete: :cascade }

上のコードを適用することで、customer_idカラムは、外部のテーブルの特定のカラムに含まれている値しか入れられなくなる(データの整合性が担保される)。そして、on_delete: cascadeを設定したので、参照先のレコードが消えると、このレコードも消えるようになる。

外部キー制約のカラムにインデックスをつける理由は、親テーブルのレコードが削除された際に、子テーブルをチェックして外部キー制約を満たすかを高速に確認するためである。データの整合性が担保されているかをチェックするのに、子テーブルをフルテーブルスキャンしてたら時間がかかる。そのチェックを高速に処理するためにインデックスが使われるそう。
https://zenn.dev/awonosuke/articles/96b3d580860e4d
https://tech.layerx.co.jp/entry/2022/01/31/093141

ハガユウキハガユウキ

libディレクトリ

railsのRAILS_ROOT/libディレクトリには、プロジェクトで使用するライブラリを置いておきます。
例えば、既存クラスに機能を追加したい場合、まず追加機能を書いたファイルを用意します。

libディレクトリには、プロジェクトで使用するライブラリを置いておくそう。オープンクラスのファイルとか置いたりするそう。

https://lukesilvia.hatenablog.com/entry/20080511/p1

ハガユウキハガユウキ

defined?

defined?は引数として入れた変数やメソッドが定義済みであれば式の種別を表す文字列を返す。未定義であればnilを返す。
defined?は、変数が定義されているかを確認するために使う

https://v-crn.hatenablog.com/entry/2019/09/04/Rubyで変数が定義されているか確認する

ジェネレータで生成されるファイル

https://qiita.com/ko-suke24/items/993d0b8313e66fd800df

application.rbで初期化時の設定を変更する際に参考にありそう。

https://railsguides.jp/configuring.html

ハガユウキハガユウキ

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タスク以外全て消えた。

https://qiita.com/tbpgr/items/5c2f192da0ccf8fad5e1

db:migrate を実行するたびに自動的に注釈をつけるためには、
rails g annotate:install を実行するか、Rakefile に Annotate.load_tasks を追加してください。

https://qiita.com/ikamirin/items/326f49ae54fe46265db4#rails-における設定
https://github.com/ctran/annotate_models?tab=readme-ov-file#configuration-in-rails

ハガユウキハガユウキ

railsにおける一般的なdbマイグレーションの手順

  1. まず、データベースを作る。database.ymlをもとにデータベースの設定を書いて、db:createを実行。環境ごとのデータベースが作れる。
  2. マイグレーションファイルを作る
  3. db:migrateでマイグレーションを実行。もしマイグレーションが成功したなら、db:schema:dumpを実行して、現在のデータベースの内容をもとに、schema.rbを更新する。

マイグレーションファイルとschema.rbは直接的に関係しているわけではなくて、データーベースを経由して関係しているので、注意する。schema.rbは現在のデータベースの情報を表しているので、情報の信頼度は高い
https://qiita.com/hirohero/items/2f29334878b0cb525bda

ダンプ

ダンプとは、既存のデーターベースの内容を再現するようなcreate文やinsert文を表すファイルのこと。ダンプをもとに、データベースの内容を復元したりできる。
https://tex2e.github.io/blog/database/mysql-dump-restore
https://www.javadrive.jp/sqlite/sqlite_command/index9.html

https://railsguides.jp/v5.2/active_record_migrations.html

ハガユウキハガユウキ

リッジポール用の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は現在のデータベースの情報を表しているので、情報の信頼度は高い。

https://qiita.com/hirohero/items/2f29334878b0cb525bda

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に置き換え、実行する。

https://qiita.com/tetz-akaneya/items/d10570aeb028fc603b86#テーブルにカラムを足す

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

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データを変更したとき、その変更だけを反映してくれる。その変更を新しいデータとして追加ない。
    • デフォルトの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

https://github.com/mbleigh/seed-fu
https://github.com/faker-ruby/faker/blob/main/lib/faker/books/book.rb
https://railsdoc.com/page/limit
https://zenn.dev/yukihaga/articles/e0cf573f3c545e

Ruby3.1でハッシュとバリューが一致した時に省略できるようになった

https://qiita.com/jnchito/items/bcd9b7f59bf4b30ea5b3#ハッシュリテラルとキーワード引数で値を省略できるようになった
https://zenn.dev/1s22s1/articles/3b6548cb89c6ce

enum

enumについてはこの記事が一番シンプルで分かりやすい
https://qiita.com/shizuma/items/d133b18f8093df1e9b70

Time.current

https://qiita.com/kodai_0122/items/111457104f83f1fb2259

ハガユウキハガユウキ

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とか書かずに済む

https://qiita.com/na-777/items/7b19980be61bc59c9f4b
https://github.com/rspec/rspec-core

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でも使えるもんね。

https://zenn.dev/yukihaga/articles/816758ff6f0bdf

ハガユウキハガユウキ
# 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

https://blog.solunita.net/posts/easy-to-write-rspec-by-aggregate-failures-and-request-describer/
https://zenn.dev/igaiga/books/rails-practice-note/viewer/rack_middleware_and_rack
https://zenn.dev/igaiga/books/rails-practice-note/viewer/rails_rspec_workshop

rspec-rails gem

rspec-railsは、Ruby on RailsのデフォルトのテストフレームワークであるMinitestのドロップイン代替品として、RSpecテストフレームワークをRuby on Railsにもたらします。

RSpecでは、テストは単にアプリケーションコードを検証するスクリプトではありません。テストは仕様書(略してスペック)でもあります。アプリケーションがどのように動作することになっているかを、平易な英語で詳細に説明するものです。

rspec-rails gemは、RspecをRuby on Railsで使えるようにしたもの?であると思われる。
RSpecにおいて、テストはアプリケーションコードを検証するスクリプトではなく、アプリケーションがどのように動作するかを説明する仕様書である。

https://wild-outdoorlife.com/ruby-on-rails/rack/
https://github.com/rspec/rspec-rails

ハガユウキハガユウキ

Rack

この記事普通に分かりやすかった。

https://wild-outdoorlife.com/ruby-on-rails/rack/
↓ requireとrequire_relativeは拡張子を省略できる
https://zenn.dev/yukito0616/articles/e2f4b2ef94535d

Rackとは、アプリケーションサーバとWebアプリケーション間のインターフェースのこと。
このインターフェースが存在することで、新しいWebアプリケーションフレークワークが出た際に、アプリケーションサーバー側で対応する必要がなくなった。
アプリケーションをRackの規約に則ったアプリケーションにするためには、callメソッドを定義する(この時のアプリケーションをRackアプリケーションと言ったりもする)。callメソッドを定義することで、アプリケーションの入出力のインターフェースがすごくシンプルになる。
Rackを動かすには、rack gemをインストールする必要がある。
rackをインストールすると、rackupコマンドが利用できるようになる。

config.ruは、Rackが利用するエントリーポイント用のファイルである。

ハガユウキハガユウキ

Rackミドルウェア

Rackミドルウェアとは、アプリケーションサーバとRackアプリケーションの間に処理を追加するミドルウェアである。Rackに用意されている機能である。
Rackミドルウェアを利用するには、config.ruで、useメソッドを使う。

ハガユウキハガユウキ

↓ Rackミドルウェアを追加した結果
Image from Gyazo

↓ 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
ハガユウキハガユウキ

Railsとrackupコマンドの関係

rackupコマンドを実行すると、Rackアプリケーションが起動して、サーバーが立ち上がる。
rackupコマンドを実行すると、Rack::Server.startを実行する。
Railsでrails sする際には、Rack::Serverクラスを継承したRails::Serverクラスを生成して、Rack::Server#startを実行している。なので、rails sを実行した際に実はrackupを実行している。

https://wild-outdoorlife.com/ruby-on-rails/rack/
https://qiita.com/k0kubun/items/248395f68164b52aec4a
https://zenn.dev/igaiga/books/rails-practice-note/viewer/rack_middleware_and_rack

ハガユウキハガユウキ

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

https://qiita.com/aiandrox/items/7ff4d73416dc15f0cc0f#バックトレースをフィルタリングする設定

https://qiita.com/jnchito/items/f86b57d27b44e6e7d1e9#infer_spec_type_from_file_locationオプションを指定する

https://mogulla3.tech/articles/2019-02-11-01/
https://woshidan.hatenadiary.jp/entry/2021/01/03/195432
https://blog.ingage.jp/entry/2021/04/26/090000

ハガユウキハガユウキ

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

https://mogulla3.tech/articles/2019-02-11-01/
https://rspec.info/documentation/3.2/rspec-core/RSpec/Core/Configuration.html#before-instance_method
https://techtechmedia.com/before-suite-all-each-rspec/
https://qiita.com/YumaInaura/items/a130c0ba433d3c4d926f
https://qiita.com/kyanny/items/00ef3727c7738f2cc26c

ハガユウキハガユウキ

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の中で使えるようにするため記法だそう。
https://blog.kyanny.me/entry/2016/03/15/025321

subject

グループ・スコープの subject を使用して、example スコープの subject メソッドが返す値を明示的に定義する。

subjectを使うことで、exampleスコープのsubjectメソッドが返す値を明示的に定義することができる。
https://rspec.info/features/3-12/rspec-core/subject/explicit-subject/

named subjectという機能があって、名前付きでsubjectを定義することができる。
https://rspec.info/features/3-12/rspec-core/subject/explicit-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

https://magazine.rubyist.net/articles/0021/0021-Rspec.html
https://github.com/thoughtbot/factory_bot/blob/bd0a10c5ab9f93425beeba2ab17e503dceb368ab/docs/src/traits/using.md?plain=1#L25
https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md
https://github.com/rspec/rspec-expectations/blob/2e8e800a0d8b64e7168dd625efe8f7526b7f1262/lib/rspec/matchers.rb#L619

ハガユウキハガユウキ

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

https://github.com/willnet/committee-rails/blob/main/lib/committee/rails/test/methods.rb
https://github.com/interagent/committee/blob/f0aa55c5a5c3a857d0e4f0f9380a82df45531f87/lib/committee/test/methods.rb#L20
https://tech.itandi.co.jp/entry/2023/11/15/190000
https://tech.timee.co.jp/entry/2020/07/05/150312
https://qawsedrftgyhujiko.hatenablog.com/entry/2016/11/05/135008

ハガユウキハガユウキ

factory_bot gemとfactory_bot_rails gem

factory_bot_rails gemはfactory_bot gemをrailsに特化させたもの

ハガユウキハガユウキ

rspecでdebuggerとかFactoryBotが使えない問題が発生した。
.rspecにはいかのようなコードが書いてある

--require spec_helper
--format documentation
ハガユウキハガユウキ

rspecコマンドのオプションを調べるコマンド

rspec --helpを実行すると、rspecコマンドのオプションを調べることができる。

ハガユウキハガユウキ

rspecコマンドを実行した際のざっくりとした実行順序

おそらくこんな感じ。

  1. rspecコマンドを実行する
  2. .rspecファイルが読み込まれる
  3. .rspecファイルに書いたrails_helper.rbが読み込まれる
  4. rails_helper.rb内でrequire spec_helperを実行して、spec_helper.rbが読み込まれる
  5. spec_helper.rbの読み込みが完了したら、rails_helper.rbが引き続き読み込まれる。
  6. 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実行時に使えなかったりする。

https://github.com/rspec/rspec-core?tab=readme-ov-file#store-command-line-options-rspec
https://github.com/rspec/rspec-core?tab=readme-ov-file#store-command-line-options-rspec

↓ specファイルでrequire rails_helperを毎回書いてた。
https://zenn.dev/igaiga/books/rails-practice-note/viewer/rails_rspec_workshop

ハガユウキハガユウキ

.rspecファイルを使うメリット

.rspecファイルを使うメリットは、

  • rspecコマンドを実行する際に毎回使うオプションを、わざわざ指定しなくても良くなる
  • テストファイルにrequire rails_helperを書かなくて良くなる。なので、テストファイルを作成するたびに毎回毎回require rails_hleperと書く必要がなくなる。
ハガユウキハガユウキ

Rubyのrequireについて

Rubyのrequireって、複数のファイルを一つのファイルにまとめている感じがするね。
TSのモジュールインポートとは若干違う気もする。

ハガユウキハガユウキ
  1. comitteeについてざっくり調べる
  2. ヘルパーの中身について深ぼる
  3. apiを実装してテストを通す(シリアライザーも導入する)
  4. rubodopやる。todoも深ぼる
  5. bullet導入する。n + 1もやってみる。n + 1が発生するapiも実装する。
  6. n + 1の解消方法の違い(include, preload, eager_load)をまとめる。どうなるかを見てみる。
  7. comitterをrackに導入したらどうなるかを調べる。
  8. エラーハンドリングの機構を作る
  9. トランザクションを使うapi何か実装する