LoginSignup
26
26

More than 1 year has passed since last update.

[Rails]継続的にプロダクト開発していくために必要な設定たち

Last updated at Posted at 2021-04-14

Railsを使えば簡単にWebアプリケーションを立ち上げることができます。
例えば、Railsガイドに書かれている手順通りに実施するだけで1時間もかからずに画面を表示することができると思います。
静的なページだけではなく、scaffoldを使うことで簡単なデータ操作を一通りできる画面もサクッと作ることができます。

以前私が書いた『Rails newからproductionモードで動くようになるまで』では、1歩踏み込んでproductionモードで動くまでの手順を書きました。
ただ、上記はproductionモードで動いているものの、プロダクト開発で使うためには他にも様々な設定が必要です。

プロダクトで使えるようにするためには、同期的な処理だけでは足りず、ほとんどの場合に非同期処理を行う仕組み(Active job)やメール配信の仕組み(Action Mailer)などが必要になります。
また、外部接続情報など環境ごとに設定値を持つようにしたり、継続的に開発できるようにCIを設定したり、誰でも同じ環境で開発できるようにどのマシンでも開発環境を再現できるようにしたり、様々なことをする必要があります。

この記事ではrails newで作ったRailsアプリケーションに上記に書いたプロダクト開発するために必要になるであろう設定を入れる手順をまとめました。
※私個人が最近はRailsをAPIアプリケーションとしか扱っていないため、この記事ではAPIモードで構築します。

前提

  • 開発環境: Docker
  • ソース管理: GitHub
  • CI: GitHub Actions
  • 各種バージョンは下記の通り
    • Ruby: 3.0
    • Rails: 6.1.1→6.1.3.1
      • APIモード
      • 記事を書いている途中でライセンス問題が発生して動かなくなったのでバージョンアップ
    • MySQL: 8.0.23
    • Redis: 6.0.9

Githubにリポジトリを作る

今の時代、ソース管理は必須ですよね。GitHubに新しいリポジトリを追加します。
READMEや.gitignoreは rails newした時に生成されるので不要です。
スクリーンショット 2021-02-09 23.22.40.png

リポジトリができたらローカルにCloneしましょう。

% git clone git@github.com:ham0215/rails_api_base.git
Cloning into 'rails_api_base'...
remote: Enumerating objects: 3, done.
remote: Counting objects: 100% (3/3), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
Receiving objects: 100% (3/3), done.
% cd rails_api_base
rails_api_base % ls
LICENSE

Dockerの準備

開発環境はどのマシンでも同じ環境を再現しやすくするためにDockerで構築します。

Dockerfile

書き方は様々あると思いますが、下記のような感じにしています。
localeやvimは必須ではないのですが、コンテナ上でvimを使ったり日本語入力できるようにするために入れています。

Dockerfile
FROM ruby:3.0.0

RUN apt-get update && apt-get install -y \
    build-essential \
    vim \
    locales \
    locales-all \
    default-mysql-client \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

ENV LANG ja_JP.UTF-8

RUN mkdir /app
WORKDIR /app

COPY Gemfile Gemfile
COPY Gemfile.lock Gemfile.lock

RUN bundle install

COPY . .

CMD ["rails", "server", "-b", "0.0.0.0"]

Dockerfile内でGemfileとGemfile.lockは明記しており、ファイルがないとエラーになってしまうのでファイルを作っておきます。
Gemfileはrailsだけ記述しておき、Gemfile.lockは空でOKです。

Gemfile
source 'https://rubygems.org'

gem 'rails', '6.1.1'

ここまでできたら一度ビルドしてみます。
DOCKER_BUILDKITを指定すると少し高速化され、コンソールの表示が見やすくなるのでオススメです。

 % COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose build
db uses an image, skipping
WARNING: Native build is an experimental feature and could change at any time
Building api
...
Successfully built c20632ba2529ddbf7702f9df80ded5d28955d0272290c57ebbdb01b65f55b5ed

docker-compose.yml

MySQLもローカルから接続できるようにportsを指定しています。
portはデフォルトのままだと他のアプリケーションと被ることが多いので少しずらすと良いです。

docker-compose.yml
version: '3.8'
services:
  db:
    image: mysql:8.0.23
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
    ports:
      - '3308:3306'
    volumes:
      - ./mysqlcnf.d:/etc/mysql/conf.d
      - ./tmp/mysql:/var/lib/mysql
  api:
    tty: true
    stdin_open: true
    build: .
    command: rails s -b 0.0.0.0
    volumes:
      - .:/app
    environment:
      DB_HOST: db
    ports:
      - "3001:3000"
    depends_on:
      - db

dbのvolumesに指定している- ./mysqlconf.d:/etc/mysql/conf.dはMySQLの認証方法を変更するために指定しています。

mysqlcnf.d/custom.cnf
[mysqld]
default_authentication_plugin=mysql_native_password

詳細は下記の記事をご覧ください。
下記の記事はGitHub Actionsの話ですが、同様のことを行っています。

rails new

次にRailsアプリケーションを作成します。
APIモードで作成するので--apiをつけています。その他不要なものはskipしています。

docker-compose run api bundle exec rails new . --database=mysql --skip-action-mailbox --skip-action-text --skip-spring --skip-turbolinks --skip-bootsnap --skip-action-cable --skip-javascript --skip-jbuilder --skip-system-test --api --skip-test --force

rails newが正常終了して必要なファイルが生成されたら、必要最低限の設定を行ってサーバーを立ち上げてみます。

まずDB接続情報の修正が必要です。
docker-composeでつけた名前がhostとして使えるので修正します。

config/database.yml
-  host: localhost
+  host: db

接続情報を直したらDBを作成して、空のschema.rbを作っておきましょう。

% docker-compose run api rails db:create
% docker-compose run api rails db:migrate

一通り設定ができたのでDockerを立ち上げてサーバーにアクセスしてみます。
下記コマンドでコンテナをバックグラウンドで立ち上げます。

"COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose up --build -d"

立ち上がったらブラウザで画面を表示してみましょう。
http://localhost:3001/

お馴染みの下記画面が出たらここまでの手順は成功です!
スクリーンショット 2021-03-11 15.36.57.png

database setting

最初にデータベースのcharacter_setを確認しておきます。
データベースの設定は途中で変える場合、面倒なことになることが多いので必ず最初に確認しましょう。

MySQLに接続します。
db:createとdb:migrateを行っているのでDBとテーブルが作成されています。

% mysql -h 127.0.0.1 -P3308 -uroot

mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| app_development    |
| app_test           |
| information_schema |
| mysql              |
| performance_schema |
| sys                |
+--------------------+
6 rows in set (0.01 sec)

mysql> use app_development
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Database changed
mysql> show tables;
+---------------------------+
| Tables_in_app_development |
+---------------------------+
| ar_internal_metadata      |
| schema_migrations         |
+---------------------------+
2 rows in set (0.01 sec)

character_set

character_setを確認します。

mysql> show variables like '%char%';
+--------------------------+--------------------------------+
| Variable_name            | Value                          |
+--------------------------+--------------------------------+
| character_set_client     | utf8mb4                        |
| character_set_connection | utf8mb4                        |
| character_set_database   | utf8mb4                        |
| character_set_filesystem | binary                         |
| character_set_results    | utf8mb4                        |
| character_set_server     | utf8mb4                        |
| character_set_system     | utf8                           |
| character_sets_dir       | /usr/share/mysql-8.0/charsets/ |
+--------------------------+--------------------------------+
8 rows in set (0.01 sec)

database.ymlに下記設定があるため、ほとんどの項目はutf8mb4が設定されています。

config/database.yml
encoding: utf8mb4

特にこだわりがなければutf8mb4を使えばよいと思います。
character_set_systemだけutf8ですが、こちらはutf8mb4は設定できないので問題ありません。

utf8mb4utf8を拡張したものです。詳細は下記をご覧ください。

照合順序

続いて照合順序を確認します。

mysql> SELECT @@collation_database;
+----------------------+
| @@collation_database |
+----------------------+
| utf8mb4_0900_ai_ci   |
+----------------------+
1 row in set (0.00 sec)

MySQL8系からデフォルトの照合順序がutf8mb4_0900_ai_ciになったようです。
下記のブログがとても分かりやすかったので参考にさせていただきました。

日本語を使う環境であれば、utf8mb4_general_ciutf8mb4_binを選ぶのが良いと思います。
この表だけで考えると英語の大文字/小文字が区別できるutf8mb4_binが良さそうに見えますが、文字列型でよくユニーク制約を付けるメールアドレスの比較は大文字/小文字を区別しない方が嬉しいのでutf8mb4_general_ciが便利だったりします。

今回はcollation: utf8mb4_general_ciに変更します。
database.ymlにcollation: utf8mb4_general_ciを設定してDBを作り直します。
作り直すときはvolumsで指定しているtmp/mysql/配下を消し忘れないようにご注意ください。
Dockerを起動し直して、改めてdb:createを行い変更されていることを確認します。

mysql> use app_development
Database changed
mysql> SELECT @@collation_database;
+----------------------+
| @@collation_database |
+----------------------+
| utf8mb4_general_ci   |
+----------------------+
1 row in set (0.00 sec)

ログをjsonにする

Railsではデフォルトで下記のようなログが出力されます。

log/development.log
Started GET "/" for 172.24.0.1 at 2021-02-24 14:49:00 +0000
   (0.7ms)  SELECT `schema_migrations`.`version` FROM `schema_migrations` ORDER BY `schema_migrations`.`version` ASC
Processing by Rails::WelcomeController#index as HTML
  Rendering /usr/local/bundle/gems/railties-6.1.1/lib/rails/templates/rails/welcome/index.html.erb
  Rendered /usr/local/bundle/gems/railties-6.1.1/lib/rails/templates/rails/welcome/index.html.erb (Duration: 11.5ms | Allocations: 406)
Completed 200 OK in 52ms (Views: 33.8ms | ActiveRecord: 0.0ms | Allocations: 2208)

開発中は上記のログで特に問題ないのですが、本番のログなどは監視ツール等に取り込みたいなどありjson形式にしたいことがあります。

そんなときに役立つgemがlogrageです。

Gemfileに追加して最低限の設定を入れてみました。
元のログも残したいので別ファイルに出力するようにしています。
設定方法の詳細はREADMEをご覧ください。

config/initializers/lograge.rb
Rails.application.configure do
  return if Rails.env.test?

  config.lograge.enabled = true
  config.lograge.keep_original_rails_log = true
  config.lograge.logger = ActiveSupport::Logger.new "#{Rails.root}/log/lograge_#{Rails.env}.log"
  config.lograge.formatter = Lograge::Formatters::Json.new
end

下記のようにjson形式のログも出力されるようになりました。

log/lograge_development.log
{"method":"GET","path":"/","format":"html","controller":"Rails::WelcomeController","action":"index","status":200,"duration":44.07,"view":28.32,"db":0.0}

RSpec, factory-bot, faker, simplecov

継続的に開発するアプリケーションにはテストコードが必須です。
テストを行うgemは様々あると思いますが、メジャーなRSpecを使うと良いと思います。

RSpec関連のgemを入れます。私がよく使うgemは下記の通り。

  • rspec-rails
    • RailsでRSpecを使えるようにするgem
  • factory_bot_rails
    • テストデータを生成するためのgem
  • faker
    • ダミーデータ(適当な名前やメールアドレスなど)をいい感じに生成してくれるgem
  • simplecov
    • テストカバレッジを出力してくれるgem
  • simplecov-json
    • simplecovのテストカバレッジをjsonにしてくれるgem

では早速インストールします。
各gemのREADMEに記載されている通り、Gemfileに追加してinstallするだけです。
本番環境では必要ないのでdevelopmentやtestのgroupに追加しましょう。

ここからは各Gemについてもう少し詳細に書いていきます。

rspec-rails

インストールするためのコマンドがあるので実行します。

% docker-compose exec api rails g rspec:install
      create  .rspec
      create  spec
      create  spec/spec_helper.rb
      create  spec/rails_helper.rb

RSpecで必要なファイルが生成されます。
Ruby2.7.2からdeprecated warningがデフォルトで出力されなくなったのですが、test時には出力されたほうが嬉しいので出力されるように下記の記述を追加しました。

spec/spec_helper.rb
Warning[:deprecated] = true

factory_bot_rails

設定不要で使えますが、下記の記述を書いておくとFactoryBot.createなどのFactoryBotを省略してcreateだけで使えるようになるので便利です。

spec/rails_helper.rb
config.include FactoryBot::Syntax::Methods

faker

特に初期設定は不要です。

simplecov, simplecov-json

READMEを参考に設定を行います。
設定はconfig/initializers配下に置きました。
出力は人が見やすいHTMLとプログラムで扱いやすいJsonの2種類にしています。

config/initializers/simplecov.rb
return unless Rails.env.test?

require 'simplecov'
require 'simplecov-json'

SimpleCov.formatters = [
  SimpleCov::Formatter::HTMLFormatter,
  SimpleCov::Formatter::JSONFormatter,
]

.simplecovにadd_filterでカバレッジを取得する必要がないフォルダーを指定します。
設定ファイルやマイグレーションファイル、テストファイルを除いています。

.simplecov
SimpleCov.start('rails') do
  add_filter 'config'
  add_filter 'spec'
  add_filter 'db'
end

RSpec実行

設定したのでテストを1つ書いてみます。
と言っても、まだ1つもアクションがないのでテストと一緒に作成してみます。

簡単にscaffoldを使ってUserリソース(カラムはnameのみ)を作ってみます。
RSpecやFactoryBotを導入していた後なので、controller / model / migration だけではなく、RSpecやFactoryBotのファイルも自動生成されました。

% docker-compose exec api rails g scaffold User name:string
      invoke  active_record
      create    db/migrate/20210311150016_create_users.rb
      create    app/models/user.rb
      invoke    rspec
      create      spec/models/user_spec.rb
      invoke      factory_bot
      create        spec/factories/users.rb
      invoke  resource_route
       route    resources :users
      invoke  scaffold_controller
      create    app/controllers/users_controller.rb
      invoke    resource_route
      invoke    rspec
      create      spec/requests/users_spec.rb
      create      spec/routing/users_routing_spec.rb

簡単に動作させるなら何も編集しなくても良いレベルのソースが書かれているのでそのまま利用します。

まずはマイグレーションを実行してテストDBにも反映します。

% docker-compose exec api rails db:migrate
== 20210311150016 CreateUsers: migrating ======================================
-- create_table(:users)
   -> 0.0302s
== 20210311150016 CreateUsers: migrated (0.0303s) =============================

% docker-compose exec api rails db:test:prepare
% docker-compose exec api rspec
***********......

Pending: (Failures listed here are expected and do not affect your suite's status)

  1) User add some examples to (or delete) /app/spec/models/user_spec.rb
     # Not yet implemented
     # ./spec/models/user_spec.rb:4

  2) /users GET /index renders a successful response
     # Add a hash of attributes valid for your model
     # ./spec/requests/users_spec.rb:36

  3) /users GET /show renders a successful response
     # Add a hash of attributes valid for your model
     # ./spec/requests/users_spec.rb:44

  4) /users POST /create with valid parameters creates a new User
     # Add a hash of attributes valid for your model
     # ./spec/requests/users_spec.rb:53

  5) /users POST /create with valid parameters renders a JSON response with the new user
     # Add a hash of attributes valid for your model
     # ./spec/requests/users_spec.rb:60

  6) /users POST /create with invalid parameters does not create a new User
     # Add a hash of attributes invalid for your model
     # ./spec/requests/users_spec.rb:69

  7) /users POST /create with invalid parameters renders a JSON response with errors for the new user
     # Add a hash of attributes invalid for your model
     # ./spec/requests/users_spec.rb:76

  8) /users PATCH /update with valid parameters updates the requested user
     # Add a hash of attributes valid for your model
     # ./spec/requests/users_spec.rb:91

  9) /users PATCH /update with valid parameters renders a JSON response with the user
     # Add a hash of attributes valid for your model
     # ./spec/requests/users_spec.rb:99

  10) /users PATCH /update with invalid parameters renders a JSON response with errors for the user
     # Add a hash of attributes valid for your model
     # ./spec/requests/users_spec.rb:109

  11) /users DELETE /destroy destroys the requested user
     # Add a hash of attributes valid for your model
     # ./spec/requests/users_spec.rb:120


Finished in 0.13536 seconds (files took 5.92 seconds to load)
17 examples, 0 failures, 11 pending

Coverage report generated for RSpec to /app/coverage. 14 / 29 LOC (48.28%) covered.
Coverage report generated for RSpec to /app/coverage/coverage.json. 14 / 29 LOC (48.28%) covered.

user_spec.rbのテストはすべてpendingになっているので実行できるように修正します。

テストを直す前にUserモデルのバリデーション失敗をテストするために、nameに必須制約を入れておきます。

app/models/user.rb
class User < ApplicationRecord
  validates :name, presence: true
end

users_specはskipのところを修正しました。
nameの生成にはFakerを使っています。

spec/requests/users_spec.rb
   let(:valid_attributes) {
-    skip("Add a hash of attributes valid for your model")
+    { name: Faker::Name.name }
   }

   let(:invalid_attributes) {
-    skip("Add a hash of attributes invalid for your model")
+    { name: '' }
   }

   # This should return the minimal set of values that should be in the headers
@@ -85,7 +85,7 @@ RSpec.describe "/users", type: :request do
   describe "PATCH /update" do
     context "with valid parameters" do
       let(:new_attributes) {
-        skip("Add a hash of attributes valid for your model")
+        { name: Faker::Name.name }
       }

       it "updates the requested user" do
@@ -93,7 +93,7 @@ RSpec.describe "/users", type: :request do
         patch user_url(user),
               params: { user: new_attributes }, headers: valid_headers, as: :json
         user.reload
-        skip("Add assertions for updated state")
+        expect(user.name).to eq new_attributes[:name]
       end

再実行してみましたが、headerのチェックでこけてしましました。

% docker-compose exec api rspec
*.....F..F.......

Failures:

  1) /users POST /create with invalid parameters renders a JSON response with errors for the new user
     Failure/Error: expect(response.content_type).to eq("application/json")

       expected: "application/json"
            got: "application/json; charset=utf-8"

       (compared using ==)
     # ./spec/requests/users_spec.rb:80:in `block (4 levels) in <top (required)>'

  2) /users PATCH /update with invalid parameters renders a JSON response with errors for the user
     Failure/Error: expect(response.content_type).to eq("application/json")

       expected: "application/json"
            got: "application/json; charset=utf-8"

       (compared using ==)
     # ./spec/requests/users_spec.rb:114:in `block (4 levels) in <top (required)>'

Finished in 1.52 seconds (files took 5.43 seconds to load)
17 examples, 2 failures, 1 pending

調べてみるとRials6.1でresponse.content_typeで返却される値が変わったようです。

ということなので、エラーになっているテストのexpected valeueを変更します。
よく見たらcontent_typeの部分一致で比較している箇所もあったので合わせてみました。これはRSpecのテンプレートの修正漏れなのかな?
→ rspec-railsに該当箇所のプルリクを送ったらマージされたので次のバージョンからはこの事象は発生しなくなると思います。

spec/requests/users_spec.rb
-        expect(response.content_type).to eq("application/json")
+        expect(response.content_type).to match(a_string_including("application/json"))

再実行したら全て成功しました。
モデルスペックがpendingのままですが今回は省略します。直す気がないpendingは残しておいても邪魔なので削除しておくと良いです。

% docker-compose exec api rspec
*................

Pending: (Failures listed here are expected and do not affect your suite's status)

  1) User add some examples to (or delete) /app/spec/models/user_spec.rb
     # Not yet implemented
     # ./spec/models/user_spec.rb:4


Finished in 1.44 seconds (files took 6.11 seconds to load)
17 examples, 0 failures, 1 pending

Coverage report generated for RSpec to /app/coverage. 28 / 30 LOC (93.33%) covered.
Coverage report generated for RSpec to /app/coverage/coverage.json. 28 / 30 LOC (93.33%) covered.

最後にRSpec実行時の最後に記載されているカバレッジをみてみましょう。
coverage/index.htmlをブラウザで開いてみると下記のようにファイルごとのカバレッジが見れます。
今回追加したuser系のファイルは100%になっているのでテストは網羅されてそうです!
coverage配下にjson形式のファイルも入っています。

スクリーンショット 2021-03-12 1.09.35.png

simplecovが出力するカバレッジファイルはコミット不要なので.gitignoreにcoverageディレクトリを追加しておきましょう。

.gitignore
+ coverage

RuboCop

RuboCopはコードを静的解析してくれるGemです。
これを入れておくことでコードの記述揺れやインデントやスペースの入れ方などをルールに基づいて機械的にチェックすることができます。

例えば下記のようなチェックを行ってくれます。

# `{`の後のスペースがいらない
app/models/user.rb:13:19: C: [Correctable] Layout/SpaceInsideBlockBraces: Space between { and | detected.
    (1..10).each { |n| p n }
                  ^

# hogeが定義されているけど使われていない
app/models/user.rb:25:5: W: Lint/UselessAssignment: Useless assignment to variable - hoga.
    hoge = 'hoge'
    ^^^^

# 最終行はreturnを書かなくても良い
app/models/user.rb:26:5: C: [Correctable] Style/RedundantReturn: Redundant return detected.
    return true
    ^^^^^^

上記のような機械的にできるチェックをレビューで人がやるのは時間の無駄になるだけでなく、体裁ばかりに目がいってしまい本来に抽出したい複雑な不具合などに目がいかなくなってしまいます。
このような事象を回避するために静的コードチェックツールは必須と言えるでしょう。

READMEに記載されている通り、Gemfileに追加してinstallしましょう。
本番環境では必要ないものなのでdevelopmentやtestのgroupに追加しましょう。

インストールできたら早速実行してみます。

 % docker-compose exec api rubocop
Inspecting 38 files
CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC

Offenses:

.simplecov:1:1: C: [Correctable] Style/FrozenStringLiteralComment: Missing frozen string literal comment.
SimpleCov.start('rails') do

...(略)

38 files inspected, 196 offenses detected, 183 offenses auto-correctable

196個も指摘されました・・・
様々な原因がありますが、現時点ではまだほとんど実装していないので指摘されている箇所はRailsが自動生成したファイルが多いです。

私はざっくり下記の方針で直しています。

  • Railsが自動生成したファイル
    • 今後触るファイル: RuboCopのルールに合わせて修正
    • 今後ほぼ触らないファイル: RuboCopの対象外にする
  • migrationファイルやconfigは対象外

まずはこの方針に合わせて設定ファイル(.rubocop.yml)を設定しました。
NewCops: enableを書いておくことで、バージョンアップで追加されるチェック項目が自動的に有効になります。

.rubocop.yml
AllCops:
  Exclude:
    - 'bin/**/*'
    - 'config/**/*'
    - 'config.ru'
    - 'db/**/*'
    - 'Gemfile'
    - 'spec/**/*'
    - 'vendor/**/*'
  NewCops: enable

この設定を入れて再実行すると21個まで減りました。
8 files inspected, 21 offenses detected, 19 offenses auto-correctable

残りは[修正する / チェックを無効(またはゆるく)にする]を考えながら見ていきます。
最終的に下記のチェックだけ無効にして他はコードを修正しました。

  • Style/Documentationを無効
    • classの先頭にドキュメントがないとNGになる。コメントは必要な時だけ追加すれば良いと思っているので無効化。

まだコード量が少ないのでほとんど引っ掛かりませんでしたが、今後コードを追加していくと新しくNGになることがあると思います。
特にMetrics/AbcSizeMetrics/MethodLengthは曲者です。
無理やりメソッドを分割することでNGを回避することができるのですが、可読性を考えて分割しない方がよいという判断もありえます。
RuboCopのチェックを全て正とするのではなく[修正する / チェックを無効(またはゆるく)にする]を吟味して開発速度が最大化される方向にチェック仕様をアップデートしていきましょう。

Brakeman

Brakemanはコードを静的解析して脆弱性を検知してくれるgemです。
これを入れておくことで典型的な脆弱性となり得るコードを機械的に検知することができます。
あくまで典型的なものを防げるだけですが、入れておいて損はないでしょう。

READMEに記載されている通り、Gemfileに追加してinstallするだけです。
本番環境では必要ないのでdevelopmentやtestのgroupに追加しましょう。

早速実行します。
オプションはこのページに細かく記載されています。

# Rails6系なので -6 を指定
# 全てのチェック項目を実施したいので -A を指定
# 全ての警告を検知したいので -w 1 を指定
% docker-compose exec api brakeman -6 -A -w 1
...
== Overview ==

Controllers: 2
Models: 2
Templates: 0
Errors: 0
Security Warnings: 1

== Warning Types ==

Missing Encryption: 1

== Warnings ==

Confidence: High
Category: Missing Encryption
Check: ForceSSL
Message: The application does not force use of HTTPS: `config.force_ssl` is not enabled
File: config/environments/production.rb
Line: 1

1つ警告が検知されました。
productionではconfig.force_sslを有効にしろとのことです。
こちら本番環境の構成によって設定すべきかどうか違うと思いますが、今回はとりあえずtrueにしておきます。
対応不要な場合はbrakeman.ignoreというファイルを生成することでチェック対象外にできます。

config/environments/production.rb
-  # config.force_ssl = true
+  config.force_ssl = true

これで警告はなくなりました。

% docker-compose exec api brakeman -6 -A -w 1
...
== Overview ==

Controllers: 2
Models: 2
Templates: 0
Errors: 0
Security Warnings: 0

== Warning Types ==


No warnings found

tbls

tblsはデータベースのスキーマ情報からテーブル定義のドキュメントを生成してくれるツールです。
テーブル定義に限りませんが、手動でメンテしているドキュメントは本番環境と乖離してしまうので、tblsのようにコードからリバースエンジニアリングできるツールは重要です。

こちらはDockerが提供されているのでそれを使います。
docker-composeに追記しました。
設定値の詳細はREADMEをご覧ください。
下記のように設定することでdocs/tables配下にテーブル定義が出力されます。

docker-compose.yml
+  tbls:
+    image: k1low/tbls:latest
+    volumes:
+      - .:/work
+    environment:
+      TBLS_DSN: mysql://root:@db:3306/app_development
+      TBLS_DOC_PATH: docs/tables
+    depends_on:
+      - db

早速実行してみます。 --forceをつけることでファイルがあっても上書きするようにしています。

 % docker-compose run --rm tbls doc --force
Creating rails_api_base_tbls_run ... done
docs/tables/schema.svg
docs/tables/ar_internal_metadata.svg
docs/tables/schema_migrations.svg
docs/tables/users.svg
docs/tables/README.md
docs/tables/ar_internal_metadata.md
docs/tables/schema_migrations.md
docs/tables/users.md

下記のMarkdownが生成されました。

スクリーンショット 2021-03-15 23.30.53.png

スクリーンショット 2021-03-15 23.31.25.png

シードデータ

Dockerで環境構築していると環境を簡単にリセットすることができますが、そのたびにデータまで初期化されてしまっては面倒です。
そこで開発時にあったら便利なデータはシードデータを作っておき、いつでもロードできるようにしておくと良いです。
また、マスターデータなどを入れる際にもシードデータを作っておくと便利です。

シードデータの作成はseed-fuというgemが便利です。
Gemfileに追加してインストールしましょう。
2018年から更新されていないのでドキュメントなどが古いですが、Rails6でも問題なく使えます。

Gemfile
+gem 'seed-fu'

development環境用のシードデータを作ってみます。

db/fixtures/development/users.rb
User.seed(:id,
  { id: 1, name: 'hoge' },
  { id: 2, name: 'fuga' },
)

実行します。

% docker-compose exec api rails db:seed_fu

== Seed from /app/db/fixtures/development/users.rb
 - User {:id=>1, :name=>"hoge"}
 - User {:id=>2, :name=>"fuga"}

データベースに登録されました。

mysql> select * from users;
+----+------+----------------------------+----------------------------+
| id | name | created_at                 | updated_at                 |
+----+------+----------------------------+----------------------------+
|  1 | hoge | 2021-03-15 15:03:33.725453 | 2021-03-15 15:03:33.725453 |
|  2 | fuga | 2021-03-15 15:03:33.734686 | 2021-03-15 15:03:33.734686 |
+----+------+----------------------------+----------------------------+
2 rows in set (0.00 sec)

seed-fuのいいところは指定したキーが重複している場合はUpdateになるところです。
上記の場合はidをキーとしてデータが存在している時はUpdateとして動作します。
そのため重複実行してもエラーになったり、実行ごとにデータが増加していったりすることはありません。

Security Alert / dependabot

Gemfileでインストールしているgemのバージョンを定期的にチェックし、脆弱性があるバージョンを使っていたり、新しいバージョンがある場合に通知してくれるサービスがあります。
継続的に開発していく場合、ライブラリのアップデートは必要不可欠なので検知できるように設定しておきましょう。

Security Alert

GitHubのリポジトリページのSettingsタブのSecurity & analysisから設定できます。
このページでDependabot alertsとDependabot security updatesを有効にするだけです。
スクリーンショット 2021-03-16 0.34.48.png

有効にしておくとセキュリティーアラートがある場合にリポジトリページに下記のように表示されます。
スクリーンショット 2021-03-16 0.37.04.png

See Dependabot alertsをクリックすると詳細ページに飛べます。

スクリーンショット 2021-03-16 0.37.12.png

詳細ページからは脆弱性を解消するためのプルリクを生成することができ、とても便利です。
(当然ですが、脆弱性を解消するライブラリのバージョンが存在していない場合はプルリクは生成できません)

スクリーンショット 2021-03-16 0.41.04.png

下記のようなプルリクが作られるので、内容を確認して問題なければマージしてバージョンアップ完了です
スクリーンショット 2021-03-17 11.03.08.png

dependabot

dependabotは脆弱性有無にかかわらず、ライブラリのバージョンアップを検知してプルリクを作ってくれるサービスです。
こちらも入れておきましょう。

marketplaceからインストールします。

インストールするとSettings>Applicationsに追加されます。
設定画面から対象のリポジトリを追加しましょう。
スクリーンショット 2021-03-16 0.54.39.png

これで新しいバージョンのライブラリを検知した場合、プルリクを勝手に作ってくれます。

CI(GitHub Actions)

ここまででRSpecやRuboCopなどをインストールして自動テストや静的解析が整ってきましたが、コードを修正するたびに手動で各種チェックツールを実行するのは現実的ではなく、実行し忘れのリスクがあります。
そこで、Pushしたときに自動実行されるようにします。
CIをするためのツールは様々ありますが、GitHubを使っているのでGitHub Actionsを使います。

Github Actionsの構文は公式ドキュメントをご覧ください。

GitHub ActionsでRailsのCIを行う記事は以前に書いたのでこちらもみていただけるとありがたいです。

今回は自動実行したいツールごとにYAMLを作りました。

Brakeman

Brakemanを実行します。
Brakemanは静的解析なのでBrakemanのGemだけインストールしています。
また、DBも必要ないのでDBセッティングもなし。
pathsやpaths-ignoreを設定設定してBrakemanに影響があるファイルが更新された時だけ実行するようにしておくと良いです。

.github/workflows/brakeman.yml
name: Brakeman

on:
  pull_request:
    branches:
      - 'feature/*'
      - main
    paths-ignore:
      - README.md
      - Dockerfile
      - docker-compose.yml
      - 'spec/**'
      - 'docs/**'

jobs:
  brakeman:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2.3.4
      - name: Set up Ruby 3.0.0
        uses: ruby/setup-ruby@v1.66.0
        with:
          ruby-version: 3.0.0
          bundler-cache: true
      - name: run brakeman
        run: |
          gem install brakeman
          brakeman -6 -A -w 1

RSpec

RSpecを実行します。
GitHub ActionsでRailsからMySQL8系に接続するにも一手間必要なのですが、詳細は別記事に書いています。

.github/workflows/rspec.yml
name: RSpec

on:
  pull_request:
    branches:
      - 'feature/*'
      - main
    paths-ignore:
      - README.md
      - Dockerfile
      - docker-compose.yml
jobs:
  rspec:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    env:
      RAILS_ENV: test
      DB_HOST: 127.0.0.1
      DB_PORT: 33060
    services:
      db:
        image: mysql:8.0.23
        volumes:
          - mysqlconf.d:/etc/mysql/conf.d
        ports:
          - 33060:3306
        env:
          MYSQL_ALLOW_EMPTY_PASSWORD: yes
          BIND-ADDRESS: 0.0.0.0
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
    steps:
      - uses: actions/checkout@v2.3.4
      - name: Set up Ruby
        uses: ruby/setup-ruby@v1.64.1
        with:
          ruby-version: 3.0.0
          bundler-cache: true
      - name: bundle install
        run: |
          gem install bundler
          bundle install --jobs 4 --retry 3 --path vendor/bundle
      - name: migration
        run: |
          bundle exec rails db:create
          bundle exec rails db:test:prepare
      - name: run rspec
        run: bundle exec rspec

RuboCop

RuboCopを実行します。
RuboCopは静的解析なのでRuboCopのGemだけインストールしています。
また、DBも必要ないのでDBセッティングもなし。
paths-ignoreを.rubocopのExcludeに合わせておくと良いと思います。

.github/workflows/rubocop.yml
name: RuboCop

on:
  pull_request:
    branches:
      - 'feature/*'
      - main
    paths-ignore:
      - README.md
      - Dockerfile
      - docker-compose.yml
      - 'spec/**'
      - 'docs/**'
      - 'db/**'

jobs:
  rubocop:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2.3.4
      - name: Set up Ruby
        uses: ruby/setup-ruby@v1.66.0
        with:
          ruby-version: 3.0.0
          bundler-cache: true
      - name: run rubocop
        run: |
          gem install rubocop
          rubocop

Seed

seedを実行します。
RuboCopやRSpecのようにコード自体をチェックするものではないのですが、コード修正した時にseedを修正し忘れて壊れてしまうことが多々あるので、CIで正常に実行できるか確認するようにしています。

seedに限らず、コード修正時によく修正漏れしてしまうものがある場合はこのように機械的に検知できるようにしておくと良いです。

.github/workflows/seed.yml
name: seed

on:
  pull_request:
    branches:
      - 'feature/*'
      - main
    paths-ignore:
      - README.md
      - Dockerfile
      - docker-compose.yml

jobs:
  seed:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    env:
      RAILS_ENV: development
      DB_HOST: 127.0.0.1
      DB_PORT: 33060
    services:
      db:
        image: mysql:8.0.23
        volumes:
          - mysqlconf.d:/etc/mysql/conf.d
        ports:
          - 33060:3306
        env:
          MYSQL_ALLOW_EMPTY_PASSWORD: yes
          BIND-ADDRESS: 0.0.0.0
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
    steps:
      - uses: actions/checkout@v2.3.4
      - name: Set up Ruby
        uses: ruby/setup-ruby@v1.66.0
        with:
          ruby-version: 3.0.0
          bundler-cache: true
      - name: bundle install
        run: |
          gem install bundler
          bundle install --jobs 4 --retry 3 --path vendor/bundle
      - name: migration
        run: |
          bundle exec rails db:create
          bundle exec rails db:schema:load
      - name: run seed_fu
        run: bundle exec rails db:seed_fu

Tbls

tblsを実行します。
tblsはREAMEに下記のように書かれている通り、CIと相性が良いです。

tbls is a CI-Friendly tool for document a database, written in Go.

ドキュメントに記載されている通りdiffというパラメーターを指定するとDBスキーマとドキュメントの差分を検出してくれます。
これを利用してドキュメントの更新漏れを検知できるようにしています。

.github/workflows/tbls.yml
name: Tbls

on:
  pull_request:
    branches:
      - 'feature/*'
    paths:
      - 'docs/tables/*'
      - 'db/**'

jobs:
  tbls:
    runs-on: ubuntu-latest
    env:
      RAILS_ENV: development
      DB_HOST: 127.0.0.1
      DB_PORT: 33060
    services:
      db:
        image: mysql:8.0.23
        volumes:
          - mysqlconf.d:/etc/mysql/conf.d
        ports:
          - 33060:3306
        env:
          MYSQL_ALLOW_EMPTY_PASSWORD: yes
          BIND-ADDRESS: 0.0.0.0
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
    steps:
      - uses: actions/checkout@v2.3.4
      - name: Set up Ruby
        uses: ruby/setup-ruby@v1.66.0
        with:
          ruby-version: 3.0.0
          bundler-cache: true
      - name: apt-get
        run: |
          sudo apt-get update
          sudo apt-get install libmysqlclient-dev jq
      - name: bundle install
        run: |
          gem install bundler
          bundle install --jobs 4 --retry 3 --path vendor/bundle
      - name: migration
        run: |
          bundle exec rails db:create
          bundle exec rails db:migrate
      - name: tbls diff
        uses: docker://k1low/tbls:latest
        env:
          TBLS_DSN: mysql://root:@db:3306/app_development
          TBLS_DOC_PATH: docs/tables
        with:
          args: diff

Github Actionsのdependabot

Github Actionsのyamlで指定しているライブラリもガンガンバージョンアップしていくので、dependabotで検知できるようしておくと良いです。
dependabotの設定ファイル(yaml)はGitHubのリポジトリーページから生成することができます。
DependenciesタブのDependabotで"Create config file"をクリックしてください。
(執筆時点でBetaと記載されているので今後変わるかもしれません)

スクリーンショット 2021-03-17 10.52.37.png

yamlが生成され、ブラウザ上のエディターでyamlが生成されるので、修正してコミットします。
package-ecosystemのところだけ"github-actions"に修正しました。
特に変更しませんでしたが、チェックする頻度なども指定できます。
指定できるものはドキュメントをご覧ください。

スクリーンショット 2021-03-17 10.57.25.png

i18n

i18nは多言語化対応するための仕組みです。
Railsガイドにも記載されている通りRailsに最初から入っています。

特に多言語化対応しないとしても、エラーメッセージなどの文言はi18nの仕組みを使っておくと一元管理されて便利です。

今回はすでに作っているuserモデルのエラーメッセージを英語/日本語で出し分けられるようにしてみます。
Nameの必須チェックしか実装されておらず面白みがないのでi18nの機能をわかりやすく試すためにカスタムバリデーションも追加しました。

app/models/user.rb
class User < ApplicationRecord
  validates :name, presence: true
  validate :ng_name

  private

  def ng_name
    errors.add(:name, 'にNGNAMEは使えません') if name == 'NGNAME'
  end
end

このまま実行すると下記のようなエラーメッセージが出力されます。
ブランクのメッセージは英語になっており、カスタムエラーのメッセージはハードコーディングした通りに出ていますが項目名がNameになっています。

irb(main):001:0> User.create!(name: '')
Traceback (most recent call last):
        1: from (irb):1:in `<main>'
ActiveRecord::RecordInvalid (Validation failed: Name can't be blank)
irb(main):002:0> User.create!(name: 'NGNAME')
Traceback (most recent call last):
        2: from (irb):1:in `<main>'
        1: from (irb):2:in `rescue in <main>'
ActiveRecord::RecordInvalid (Validation failed: Name にNGNAMEは使えません)

まずは設定を変更して英語と日本語を使えるようにします。
今回は言語を分岐させたい箇所で明示的に指定しようと思っているので、デフォルトはenのままにしています。

config/initializers/locale.rb
I18n.config.available_locales = %i[ja en]
I18n.default_locale = :en

続いて言語ファイル(ja.ymlやen.yml)を作りますが、最初から作るのは大変なのでrails-i18nにある言語ファイルのyamlをがconfig/localesにコピーしておきます。
コピーしてきたyamlに今回必要な項目を追加し、モデルのバリデーションを使うようにします。

--- a/config/locales/en.yml
+++ b/config/locales/en.yml
+        ng_name: cannot be NGNAME

--- a/config/locales/ja.yml
+++ b/config/locales/ja.yml
 ja:
   activerecord:
+    attributes:
+      user:
+        name: 名前
     errors:
       messages:
         record_invalid: 'バリデーションに失敗しました: %{errors}'
         restrict_dependent_destroy:
           has_one: "%{record}が存在しているので削除できません"
           has_many: "%{record}が存在しているので削除できません"
+        ng_name: に'NGNAME'は使えません

--- a/app/models/user.rb
+++ b/app/models/user.rb
   def ng_name
-    errors.add(:name, 'にNGNAMEは使えません') if name == 'NGNAME'
+    errors.add(:name, :ng_name) if name == 'NGNAME'
   end
 end

それでは英語、日本語を出し分けてみます。
I18n.with_localeで囲むことでその中で使う言語を変更することができます。

irb(main):001:1* I18n.with_locale(:en) do
irb(main):002:1*   User.create!(name: '')
irb(main):003:0> end
Traceback (most recent call last):
        2: from (irb):1:in `<main>'
        1: from (irb):2:in `block in <main>'
ActiveRecord::RecordInvalid (Validation failed: Name can't be blank)
irb(main):004:1* I18n.with_locale(:ja) do
irb(main):005:1*   User.create!(name: '')
irb(main):006:0> end
Traceback (most recent call last):
        3: from (irb):3:in `<main>'
        2: from (irb):4:in `rescue in <main>'
        1: from (irb):5:in `block in <main>'
ActiveRecord::RecordInvalid (バリデーションに失敗しました: 名前を入力してください)
irb(main):007:1* I18n.with_locale(:en) do
irb(main):008:1*   User.create!(name: 'NGNAME')
irb(main):009:0> end
Traceback (most recent call last):
        3: from (irb):6:in `<main>'
        2: from (irb):7:in `rescue in <main>'
        1: from (irb):8:in `block in <main>'
ActiveRecord::RecordInvalid (Validation failed: Name cannot be NGNAME)
irb(main):010:1* I18n.with_locale(:ja) do
irb(main):011:1*   User.create!(name: 'NGNAME')
irb(main):012:0> end
Traceback (most recent call last):
        3: from (irb):9:in `<main>'
        2: from (irb):10:in `rescue in <main>'
        1: from (irb):11:in `block in <main>'
ActiveRecord::RecordInvalid (バリデーションに失敗しました: 名前に'NGNAME'は使えません)

config

開発を行っていると、外部環境への接続情報など環境ごとに値を出し分けたいことが多々あります。
そういうときにはconfigというGemを使うと便利です。
これを使うことで環境ごとに切り替えたい設定値をyamlに定義することができるようになります。

Gemfileに追加してインストールします。
インストールコマンドを実行すると、下記のように環境ごとの設定ファイルが生成されます。

config直下のsettingsは環境共通の設定値で、config/settings配下に環境ごとに設定値を設定します。

% docker-compose exec api rails g config:install
      create  config/initializers/config.rb
      create  config/settings.yml
      create  config/settings.local.yml
      create  config/settings
      create  config/settings/development.yml
      create  config/settings/production.yml
      create  config/settings/test.yml
      append  .gitignore

また、gitignoreにlocalとついているyamlが除外されるようになっています。
localがついているファイルはgitに保存しないような機密情報を設定するために用意されています。
ただ、特に暗号化されるわけではなくgitignoreに追加されるだけなので管理は自分たちで行う必要があります。
機密情報は次に紹介するcredentialsが使えるのでそちらもご確認ください。

.gitignore
+config/settings.local.yml
+config/settings/*.local.yml
+config/environments/*.local.yml

早速動作確認をしてみます。
yamlに下記を追加しました。

# config/settings/development.yml
app:
  env: devleopment

# config/settings/production.yml
app:
  env: production

下記のように環境ごとに出し分けることができました。

# rails c
Loading development environment (Rails 6.1.1)
irb(main):001:0> Settings.app.env
=> "devleopment"

# RAILS_ENV=production rails c
Loading production environment (Rails 6.1.1)
irb(main):001:0> Settings.app.env
=> "production"

Credentials

1つ前に紹介したconfigと同様に環境ごとの設定値を格納できるのですが、こちらは暗号化して保存されます。
暗号化されており値の確認や編集に一手間かかるので機密情報かどうかでconfigと使い分けると良いと思います。
こちらは最初からRailsに入っている仕組みなのでGemの追加は不要です。

早速環境ごとに作ってみます。
下記のようなエラーが発生した場合はEDITORという環境変数に編集に使うエディターを指定して実行ください。

% docker-compose exec api rails credentials:edit --environment development
No $EDITOR to open file in. Assign one like this:

EDITOR="mate --wait" bin/rails credentials:edit

For editors that fork and exit immediately, it's important to pass a wait flag,
otherwise the credentials will be saved immediately with no chance to edit.

私はvimを使うのでdocker-compose.ymlの環境変数のところに下記のように追加しました。

docker-compose.yml
     environment:
       DB_HOST: db
+      EDITOR: vim

改めてeditコマンドを実行するとファイルが生成されるので下記の項目を記載しました。

config/credentials/development.yml
secret:
  key: secret!!

--environment developmentを指定したのでdevelopment環境用の設定ファイル(暗号化済み)と復号するためのkeyが生成されます。

  • config/credentials/development.key
  • config/credentials/development.yml.enc

またこのときに.gitignoreに/config/credentials/development.keyが追加されます。
keyが流出してしまうと誰でも復号できるようになってしまうので、GitHubには上げずに厳重に管理しましょう。

設定値は下記のように利用することができます。
こちらもSettingsと同様に環境ごとに出し分けることができます。environmentを指定して必要な環境分用意しておきましょう。

irb(main):005:0> Rails.application.credentials.secret[:key]
=> "secret!!!"

Active Job

開発していると実行に時間がかかる処理やリアルタイムで行う必要がない処理などがでてきます。
そのような処理を非同期で実行する仕組みがActive Jobです。
詳細はRailsガイドを参照してください。

Active Jobを使うにはキューとキューに接続するためのフレームワークを使います。
様々なものがありますが、今回は幅広く使われているSidekiqを使いたいと思います。
またキューを保持するためにRedisを使います。

Redis

Redisをdocker-composeに追加します。
下記を追加してビルドし直します。PORTは他とかぶらないように少しずらしました。

docker-compose.yml
+  redis:
+    image: redis:6.0.9
+    ports:
+      - '6380:6379'

起動できたらコンテナに入って動作確認しておきましょう。

% docker-compose exec redis bash
root@86983cd92065:/data# redis-cli -h redis -p 6379
redis:6379> INFO
# Server
redis_version:6.0.9
...

Sidekiq

Sidekiqをインストールします。
ドキュメントが充実しているので参考にします。

まず、Gemfileに追加してbundle installしてRedisの設定をします。
詳細はドキュメントを参照してください。

今回はinitializerを使います。
内容はドキュメント通りですが、テストのときは使わないのでreturnを入れています。

config/initializers/sidekiq.rb
return if Rails.env.test?

Sidekiq.configure_server do |config|
  config.redis = { url: 'redis://redis:6379/0' }
end

Sidekiq.configure_client do |config|
  config.redis = { url: 'redis://redis:6379/0' }
end

続いてActive Jobで使えるようにします。
詳細はドキュメントを参照してください。

テスト以外の環境ではsidekiqを使うのでapplication.rbに追加して、テスト環境だけ:testを設定しました。

config/application.rb
config.active_job.queue_adapter = :sidekiq
config/environments/test.rb
config.active_job.queue_adapter = :test

テストアダプターを利用するとジョブを同期実行してくれるようになります。
テストのときに非同期実行されると結果の確認が困難なのでtestの場合はテストアダプターを設定しておくと便利です。

テストジョブ実行

一通り設定は終わったので動作確認用にテストジョブを作って実行します。
動作確認したいだけなのでログを出力するだけのジョブにします。

app/jobs/example_job.rb
class ExampleJob < ActiveJob::Base
  # Set the Queue as Default
  queue_as :default

  def perform(*args)
    Rails.logger.debug 'start job!!!!'
  end
end

続いてsidekiqを起動します。

% docker-compose exec api sidekiq -q default
WARN: Unresolved or ambiguous specs during Gem::Specification.reset:
      minitest (>= 5.1)
      Available/installed versions of this gem:
      - 5.14.4
      - 5.14.2
      racc (~> 1.4)
      Available/installed versions of this gem:
      - 1.5.2
      - 1.5.1
WARN: Clearing out unresolved specs. Try 'gem cleanup <gem>'
Please report a bug if this causes problems.
2021-04-01T08:43:01.144Z pid=38 tid=6hq INFO: Booting Sidekiq 6.2.0 with redis options {:url=>"redis://redis:6379/0"}


               m,
               `$b
          .ss,  $$:         .,d$
          `$$P,d$P'    .,md$P"'
           ,$$$$$b/md$$$P^'
         .d$$$$$$/$$$P'
         $$^' `"/$$$'       ____  _     _      _    _
         $:     ,$$:       / ___|(_) __| | ___| | _(_) __ _
         `b     :$$        \___ \| |/ _` |/ _ \ |/ / |/ _` |
                $$:         ___) | | (_| |  __/   <| | (_| |
                $$         |____/|_|\__,_|\___|_|\_\_|\__, |
              .d$$                                       |_|


2021-04-01T08:43:02.269Z pid=38 tid=6hq INFO: Booted Rails 6.1.3.1 application in development environment
2021-04-01T08:43:02.269Z pid=38 tid=6hq INFO: Running in ruby 3.0.0p0 (2020-12-25 revision 95aff21468) [x86_64-linux]
2021-04-01T08:43:02.270Z pid=38 tid=6hq INFO: See LICENSE and the LGPL-3.0 for licensing details.
2021-04-01T08:43:02.270Z pid=38 tid=6hq INFO: Upgrade to Sidekiq Pro for more features and support: https://sidekiq.org
2021-04-01T08:43:02.273Z pid=38 tid=6hq INFO: Starting processing, hit Ctrl-C to stop

起動したらコンソールからジョブを実行してみます。

irb(main):001:0> ExampleJob.perform_later
Enqueued ExampleJob (Job ID: c578b673-7b66-43e4-91ac-d48f34537ccb) to Sidekiq(default)
=> #<ExampleJob:0x000056020c20d690 @arguments=[], @job_id="c578b673-7b66-43e4-91ac-d48f34537ccb", @queue_name="default", @priority=nil, @executions=0, @exception_executions={}, @timezone="UTC", @provider_job_id="24ee1022e0a460169097b15a">

結果はログで確認します。
Sidekiq経由でジョブが実行され、きちんとログが出力されています。

[ActiveJob] Enqueued ExampleJob (Job ID: c578b673-7b66-43e4-91ac-d48f34537ccb) to Sidekiq(default)
[ActiveJob] [ExampleJob] [c578b673-7b66-43e4-91ac-d48f34537ccb] Performing ExampleJob (Job ID: c578b673-7b66-43e4-91ac-d48f34537ccb) from Sidekiq(default) enqueued at 2021-04-01T08:44:50Z
[ActiveJob] [ExampleJob] [c578b673-7b66-43e4-91ac-d48f34537ccb] start job!!!!
[ActiveJob] [ExampleJob] [c578b673-7b66-43e4-91ac-d48f34537ccb] Performed ExampleJob (Job ID: c578b673-7b66-43e4-91ac-d48f34537ccb) from Sidekiq(default) in 0.67ms

Action Mailer

アプリケーションからメールを送信したいことがあると思います。
Railsにはメールを送信する仕組みも入っているので設定しておきます。
詳細はRailsガイドを参照してください。

SendGrid

Action Mailerを設定する前にメール送信するためには配信サービスを使う必要があります。
一昔前であれば自分でメールサーバーを立てるなどかなり手間だったのですが、現状はSaasサービスを使ってサクッと配信できるのでそれを利用します。

今回は執筆時点(2021/04)で月に12,000通まで無料でメール配信できるSendGridを利用します。

SendGridは値段がお手頃なだけではなく、Ruby用のGemが用意されているのでRubyとの相性も良いです。

Sidekiq

Sidekiqを使ってメール送信を非同期で行うことができます。
ドキュメントに記載されている通りデフォルトではmailersというキューに格納されるのでSidekiqを起動するときにmailersというキューも立ち上げるようにしましょう。

docker-dompose exec api sidekiq -q default -q mailers

ちなみにconfig/application.rbなどでconfig.action_mailer.deliver_later_queue_name = 'hoge'のように設定することでキューをmailersから変更することも可能です。
また、nilを設定するとdefaultのキューが使われるようになるようです。

Action Mailerの設定

Action Mailerを設定します。
ActionMailerは配信メソッドをdelivery_methodで指定することができます。
指定できるものはドキュメントを御覧ください。

SendGridをSMTPとして使うだけであれば、delivery_methodにSMTPを設定するだけでできます。
SendGridのドキュメントにもSMTPで設定する方法が記載されています。

ただ、この方法ではSendGridの機能を活かすことができないので、SendGridの機能を活かせるようにdelivery_methodに指定できるカスタムメソッドを作成したいと思います。

Railsではadd_delivery_methodという配信メソッドを追加する仕組みが用意されているため、SendGridを使う配送メソッドを独自に実装してdelivery_methodで使えるようにします。

SendGridの配送メソッド作成

それではSendGridの配送メソッドを作成します。
ゼロから作るのは難しいので、SMTPのソースを参考に作成します。

  • SMTPのソースをadd_delivery_methodしている箇所

ソースの一部を抜粋しました。
smtpが指定された場合に使うクラスMail::SMTPとパラメーター(address〜enable_starttls_auto)を指定しています。

add_delivery_method :smtp, Mail::SMTP,
  address:              "localhost",
  port:                 25,
  domain:               "localhost.localdomain",
  user_name:            nil,
  password:             nil,
  authentication:       nil,
  enable_starttls_auto: true
  • SMTPのソース

ソースのうち、publicメソッドを抜粋しました。
下記の2つのメソッドが定義されています。
initializeでは、add_delivery_methodで指定した引数を受け取っています。
deliver!では、mailオブジェクトが渡されるので配送処理を実装します。

    def initialize(values)
      self.settings = DEFAULTS.merge(values)
    end

    def deliver!(mail)
      response = start_smtp_session do |smtp|
        Mail::SMTPConnection.new(:connection => smtp, :return_response => true).deliver!(mail)
      end

      settings[:return_response] ? response : self
    end

これらを参考に配送メソッドを作成しました。app/serviseに置きましたが場所はどこでもOKです。
配送処理はsendgrid-rubyのREADMEを参考にしています。

contentを複数指定したかったので、細かいところはmailクラスをのぞいてみて実装しています。
https://github.com/sendgrid/sendgrid-ruby/blob/main/lib/sendgrid/helpers/mail/mail.rb

app/services/send_grid_service.rb
class SendGridService
  attr_reader :api_key, :mail

  def initialize(settings)
    @api_key = settings[:api_key]
  end

  def deliver!(mail)
    @mail = mail
    sg = SendGrid::API.new(api_key: api_key)
    response = sg.client.mail._('send').post(request_body: request_body)
    raise response.inspect if response.status_code.to_i >= 300

    response.body
  end

  private

  def request_body
    sg_mail = SendGrid::Mail.new
    sg_mail.from = SendGrid::Email.new(email: mail.from.first)
    personalization = SendGrid::Personalization.new
    mail.to.each { personalization.add_to(SendGrid::Email.new(email: _1)) }
    sg_mail.add_personalization(personalization)
    sg_mail.subject = mail.subject

    mail.body.parts.each do
      content_type = "#{_1.main_type}/#{_1.sub_type}"
      sg_mail.add_content(SendGrid::Content.new(type: content_type, value: _1.body.raw_source))
    end

    sg_mail.to_json
  end
end

上記の配送クラスをadd_delivery_methodを使って追加します。
api_keyはSendgridで発行したものを使います。環境ごとに違う&機密情報なのでcredentialsを使いました。
ちなみにto_prepareを使うとproductionでは初回のみしか読み込まれませんが、developmentでは都度読み込まれるので開発時に便利です。

config/initializers/sendgrid.rb
Rails.configuration.to_prepare {
  ActionMailer::Base.add_delivery_method(:sendgrid,
                                         SendGridService,
                                         api_key: Rails.application.credentials.sendgrid_api_key)
}

追加できたのでdelivery_methodで指定します。
ついでにconfig.action_mailer.raise_delivery_errors = trueを設定しておくとメール配信時にエラーが発生したときにエラー検知できるようになるので設定しておきましょう。

config/application.rb
config.action_mailer.delivery_method = :sendgrid
config.action_mailer.raise_delivery_errors = true
config/environments/test.rb
config.action_mailer.delivery_method = :test

テストの場合はメールを送りたくないので:testを指定しておきましょう。
testを指定するとメール配信される代わりにActionMailer::Base.deliveriesに格納されるためテストのときに便利です。

メール配信

設定が終わったのでメールクラスを実装します。

rails g mailerで必要なファイルを生成します。
今回はUserMailerにしました。

% docker-compose exec api rails g mailer UserMailer hello
      create  app/mailers/user_mailer.rb
      create  app/mailers/application_mailer.rb
      invoke  erb
      create    app/views/user_mailer
      create    app/views/layouts/mailer.text.erb
      create    app/views/layouts/mailer.html.erb
      create    app/views/user_mailer/hello.text.erb
      create    app/views/user_mailer/hello.html.erb
      invoke  rspec
      create    spec/mailers/user_mailer_spec.rb
      create    spec/fixtures/user_mailer/hello
      create    spec/mailers/previews/user_mailer_preview.rb

テストメールを送りたいだけなのでサクッとmailerクラスとviewを作ります。
viewはRailsガイドに書いてあるとおりhtmlとtext形式を用意しました。
自動生成されたviewファイルに.enをつけていますが、ここを言語の数だけ用意するだけでlocaleの設定によってメールを出し分けてくれます。
今回は動作確認をしたいだけなのでdefault_localeにしているenだけ準備しました。

app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
  def hello(user_id)
    @user = User.find user_id
    # usersテーブルにemailカラム追加
    mail(to: user.email, subject: 'Hello!! ham!!' )
  end
end
app/views/user_mailer/hello.en.html.erb
hello!! <%= @user.name %>.<br>
first mail!!!<br>
app/views/user_mailer/hello.en.text.erb
hello!! <%= @user.name %>.
first mail!!!<br>

準備ができたのでRails consoleを使って動作確認してみます。

irb(main):002:0> UserMailer.hello(1).deliver_now
  User Load (0.9ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
  Rendering layout layouts/mailer.html.erb
  Rendering user_mailer/hello.en.html.erb within layouts/mailer
  Rendered user_mailer/hello.en.html.erb within layouts/mailer (Duration: 0.1ms | Allocations: 6)
  Rendered layout layouts/mailer.html.erb (Duration: 2.6ms | Allocations: 82)
  Rendering layout layouts/mailer.text.erb
  Rendering user_mailer/hello.en.text.erb within layouts/mailer
  Rendered user_mailer/hello.en.text.erb within layouts/mailer (Duration: 0.1ms | Allocations: 4)
  Rendered layout layouts/mailer.text.erb (Duration: 3.7ms | Allocations: 80)
UserMailer#hello: processed outbound mail in 18.5ms
Delivered mail 60730ef18f54d_14c21fc2499b@77f71c6e18a0.mail (623.8ms)

...(中略)

<60730ef18f54d_14c21fc2499b@77f71c6e18a0.mail>>, <Subject: Hello!! ham!!>, <Mime-Version: 1.0>, <Content-Type: multipart/alternative; boundary="--==_mimepart_60730ef18e808_14c21fc2489e"; charset=UTF-8>, <Content-Transfer-Encoding: 7bit>>

メールが届くことを確認しました。

hello!! ham.
first mail!!!

また、Sidekiqを起動して非同期で遅れることも確認します。

% docker-compose exec api sidekiq -q default -q mailers

非同期送信

irb(main):003:0> UserMailer.hello(1).deliver_later
Enqueued ActionMailer::MailDeliveryJob (Job ID: af499814-b119-4a69-b6db-d37935b5e650) to Sidekiq(mailers) with arguments: "UserMailer", "hello", "deliver_now", {:args=>[1]}
=> #<ActionMailer::MailDeliveryJob:0x00005651c0d9bbc0 @arguments=["UserMailer", "hello", "deliver_now", {:args=>[1]}], @job_id="af499814-b119-4a69-b6db-d37935b5e650", @queue_name="mailers", @priority=nil, @executions=0, @exception_executions={}, @timezone="UTC", @provider_job_id="88ff5493a88556145308df57">

Sidekiqのコンソール

2021-04-11T15:02:46.229Z pid=395 tid=883 class=ActionMailer::MailDeliveryJob jid=88ff5493a88556145308df57 INFO: start
2021-04-11T15:02:47.251Z pid=395 tid=883 class=ActionMailer::MailDeliveryJob jid=88ff5493a88556145308df57 elapsed=1.054 INFO: done

Sidekiqを経由してもメール送信できることを確認できました。

Active Storage

アプリケーションからファイルをアップロードしたいことがあると思います。
Railsにはファイルアップロードの仕組みも入っているので設定しておきます。
詳細はRailsガイドを参照してください。

セットアップ

Railsガイドに記載されている通り、インストールコマンドを実行します。

% docker-compose api rails active_storage:install

実行すると、Active Storageで使うテーブルを作成するマイグレーションファイルが生成されるので実行しておきます。

config/storage.ymlを参照するとAWSやGCPを保存先にするサンプルがコメントされています。
ここの設定値を環境に合わせて変更することで外部サービスのストレージと簡単に連携することができます。

また、アップロードするファイルに対するバリデーションを行うためのGemもあるためインストールしておきます。

今回は開発環境を構築しているので外部ストレージを使わずローカル保存で動作確認します。

ファイルアップロード実装

Userモデルに画像を添付できるようにしてみます。
active_storage_validationsも入れたので、画像以外のファイルやサイズが4MB以上のファイルはアップロードできないようにバリデーションを追加してみました。

app/models/user.rb
has_one_attached :avatar
validates :avatar, content_type: %i[png jpg jpeg], size: { less_than: 4.megabytes }

次にアップロードするエンドポイントを追加します。

config/routes.rb
-  resources :users
+  resources :users do
+    post :avatar
+  end

routesを確認してみます。
今回追加したファイルアップロード用のエンドポイントuser_avatar POST /users/:user_id/avatar(.:format)が追加されています。
また、/rails/active_storageで始まるActive Storage用のエンドポイントがいくつか追加されています。

 % docker-compose exec api rails routes
                         Prefix Verb   URI Pattern                                                                                       Controller#Action
                    user_avatar POST   /users/:user_id/avatar(.:format)                                                                  users#avatar
                          users GET    /users(.:format)                                                                                  users#index
                                POST   /users(.:format)                                                                                  users#create
                           user GET    /users/:id(.:format)                                                                              users#show
                                PATCH  /users/:id(.:format)                                                                              users#update
                                PUT    /users/:id(.:format)                                                                              users#update
                                DELETE /users/:id(.:format)                                                                              users#destroy
             rails_service_blob GET    /rails/active_storage/blobs/redirect/:signed_id/*filename(.:format)                               active_storage/blobs/redirect#show
       rails_service_blob_proxy GET    /rails/active_storage/blobs/proxy/:signed_id/*filename(.:format)                                  active_storage/blobs/proxy#show
                                GET    /rails/active_storage/blobs/:signed_id/*filename(.:format)                                        active_storage/blobs/redirect#show
      rails_blob_representation GET    /rails/active_storage/representations/redirect/:signed_blob_id/:variation_key/*filename(.:format) active_storage/representations/redirect#show
rails_blob_representation_proxy GET    /rails/active_storage/representations/proxy/:signed_blob_id/:variation_key/*filename(.:format)    active_storage/representations/proxy#show
                                GET    /rails/active_storage/representations/:signed_blob_id/:variation_key/*filename(.:format)          active_storage/representations/redirect#show
             rails_disk_service GET    /rails/active_storage/disk/:encoded_key/*filename(.:format)                                       active_storage/disk#show
      update_rails_disk_service PUT    /rails/active_storage/disk/:encoded_token(.:format)                                               active_storage/disk#update
           rails_direct_uploads POST   /rails/active_storage/direct_uploads(.:format)                                                    active_storage/direct_uploads#create

アップロード処理を実装します。
受け取ったファイルデータをattachするだけです。とても簡単ですね。

app/controllers/users_controller.rb
def avatar
  user = User.find params[:user_id]
  user.avatar.attach params[:avatar]
  raise 'バリデーションエラー' if user.invalid?

  head :ok
end

動作確認

動作確認します。
APIにリクエストを送れるツールなら何でも良いのですが、今回はChromeの拡張機能にあるTalend API Testerを使いました。

下記のようにリクエストして200 OKが返却されました。
スクリーンショット 2021-04-13 11.17.12.png

rails consoleでファイル添付されていることをattached?で確認して参照用のURLを発行します。

irb(main):001:0> user = User.first
irb(main):002:0> user.avatar.attached?
=> true
irb(main):003:0> Rails.application.routes.url_helpers.rails_storage_proxy_path(user.avatar, only_path: true)
=> "/attachments/blobs/proxy/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--0efc3d198976ed0b619154371865ef5d0ccf5bfe/avatar.jpg"

早速発行したURLにブラウザでアクセスしてみると、下記のようなエラーが発生しました。

NoMethodError (undefined method `flash' for #<ActionDispatch::Request GET "http://localhost:3001/attachments/blobs/proxy/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--0efc3d198976ed0b619154371865ef5d0ccf5bfe/avatar.jpg" for 172.23.0.1>):

APIモードだとflashが使えないのでflashが見つからずエラーになっているようです。
下記を追加して使えるようにします。

config/application.rb
config.middleware.use ActionDispatch::Flash

再度アクセスすることでファイルを表示することができました。
スクリーンショット 2021-04-13 11.32.54.png

おまけ

参照URLの/rails/active_storageを変える

Active Storageで自動追加されるURLのprefixは/rails/active_storageになります。
システム名が丸見えでこのまま使いたいと思う人は少ないと思います。
変更するための仕組みが用意されているので変更しておきましょう。
config.active_storage.routes_prefix = '/attachments'のように設定することで/rails/active_storageを変更することができます。
上記のように設定すると下記のように変わります。

 % docker-compose exec api rails routes
             rails_service_blob GET    /attachments/blobs/redirect/:signed_id/*filename(.:format)                               active_storage/blobs/redirect#show
       rails_service_blob_proxy GET    /attachments/blobs/proxy/:signed_id/*filename(.:format)                                  active_storage/blobs/proxy#show
                                GET    /attachments/blobs/:signed_id/*filename(.:format)                                        active_storage/blobs/redirect#show
      rails_blob_representation GET    /attachments/representations/redirect/:signed_blob_id/:variation_key/*filename(.:format) active_storage/representations/redirect#show
rails_blob_representation_proxy GET    /attachments/representations/proxy/:signed_blob_id/:variation_key/*filename(.:format)    active_storage/representations/proxy#show
                                GET    /attachments/representations/:signed_blob_id/:variation_key/*filename(.:format)          active_storage/representations/redirect#show
             rails_disk_service GET    /attachments/disk/:encoded_key/*filename(.:format)                                       active_storage/disk#show
      update_rails_disk_service PUT    /attachments/disk/:encoded_token(.:format)                                               active_storage/disk#update
           rails_direct_uploads POST   /attachments/direct_uploads(.:format)

使わないエンドポイントを消す

Active Storageのエンドポイントがいくつか定義されますが、アプリケーションで全てを使うことはないと思います。
使わないエンドポイントは閉じておきたいので、エンドポイントを消す手順を調べてみました。

config.active_storage.draw_routes = falseを設定することまるごと削除することができるのですが、一部だけ消す方法は見当たりませんでした。
そこで、railsのソースから必要な箇所だけ持ってくることにしました。

まずはconfig.active_storage.draw_routes = falseを設定してデフォルトで追加されるルートを削除します。

config/application.rb
config.active_storage.draw_routes = false

次にconfig/routes.rbにRailsのソースから必要な箇所を転記します。
参照で使うエンドポイントだけ残まして他は削除しました。
以下差分です。長くなるのでscope以下は省略しています。

config/routes.rb
  #
  # 下記から必要な箇所を転記
  # https://github.com/rails/rails/blob/v6.1.3.1/activestorage/config/routes.rb
  #
  scope ActiveStorage.routes_prefix do
    get "/blobs/redirect/:signed_id/*filename" => "active_storage/blobs/redirect#show", as: :rails_service_blob
    get "/blobs/proxy/:signed_id/*filename" => "active_storage/blobs/proxy#show", as: :rails_service_blob_proxy
    get "/disk/:encoded_key/*filename" => "active_storage/disk#show", as: :rails_disk_service
  end

...(長いので省略)

routesを確認すると減っていることが確認できます。

% docker-compose exec api rails routes
...(前略)
      rails_service_blob GET    /attachments/blobs/redirect/:signed_id/*filename(.:format) active_storage/blobs/redirect#show
rails_service_blob_proxy GET    /attachments/blobs/proxy/:signed_id/*filename(.:format)    active_storage/blobs/proxy#show
      rails_disk_service GET    /attachments/disk/:encoded_key/*filename(.:format)         active_storage/disk#show

今回はRailsのソースコードをコピペして対応しました。
このように対応するとRailsをバージョンアップしたときなどオリジナルのソースが更新されてしまったときにコピペした部分にも反映が必要となります。
コピペ対応はバージョンアップ時のバグの温床になりやすいので可能な限りやらないほうが良いです。
どうしてもやる場合は、下記のようにコピペした箇所にバージョンが変わった場合にエラーになる処理を追加しておきましょう。

config/routes.rb
raise 'バージョンアップしたので下記コピペコードも確認' unless ActiveStorage.version.to_s == '6.1.3.1'

これを入れておくとバージョンが変わるとエラーが発生するようになり、バージョンアップ時に反映漏れを防げるようになります。

/app/config/routes.rb:10:in `block in <top (required)>': バージョンアップしたので下記コピペコードも確認 (RuntimeError)

その他のおすすめ機能

ここまで私がほぼ外せないと思っている機能を設定してきました。
この章では必須ではないが、便利だと思っている機能を紹介します。

devise

deviseはRailsに認証を追加するために便利なgemです。
最近では認証・認可はIDaaSを利用することも多いですが、そこまでお金をかけられない場合にサクッと認証を導入するときに便利です。

deviseにはメール送信する処理もあるのですが、同期処理で送信しています。
deviseのメールを非同期にするgem(devise-sync)もあるので合わせて使うと便利です。

さらに私は使ったことはないですが、deviseに2要素認証を入れるgem(devise-two-factorもあるようです。
最近では多要素認証が主流になってきているのでチェックしておきたいです。

JWT

JWTは認証用トークンなどで幅広く使われているトークンです。
Amazon CognitoやAzure ADなどでも使われています。
JWT自体の仕組みについてはこの記事には書きませんが、JWTはトークン自体に様々な情報をもたせることができるのでとても便利です。

Rubyで使うためのGem(ruby-jwt)もあるため簡単に導入することができます。
認証を実装するときは候補の1つとしておくと良いと思います。

graphql-ruby

RailsでGraphQLを使うためのGemです。

GraphQLはRESTに変わるAPIのインターフェースで、クエリーを指定することで呼び出し側からレスポンスをカスタマイズすることができます。
詳細は公式ドキュメントを御覧ください。

Rails単体で見ると入出力のバリデーションを自動で行ってくれたり、スキーマファイルが自動生成されスキーマファーストの開発がやりやすいなどメリットがありますが、GraphQLの最大のメリットは様々なプログラミング言語に広がっている点だと思います。
マイクロサービス化が主流となっている昨今ではサービス間でプログラミング言語が異なることがよくあります。
そのため、様々な言語でGraphQLのフレームワークが提供されていることはシステム連携する上で大きなメリットだと思います。

初見ではとっつきにくいとこもありますが、新たにAPIを作成する場合は一度検討してみてください。

開発環境完成

かなり長くなりましたが、ここまでの手順でローカルで動作する開発環境がやっと完成しました。
開発時に便利(というか複数人で開発するときは必須)であるCIの設定や、非同期処理やメール配信、環境ごとの設定変更などプロダクトとして利用するアプリケーションを開発するときにはほぼ100%必要となる機能は一通り入れることができたと思います。

この他にも最後に紹介したdeviseやgraphqlなど開発するプロダクトに合わせて必要なものを追加していきましょう。

今回はプロダクション環境での実行については触れませんでしたが、プロダクションで動かすにはインフラやトラフィックに合わせたチューニングなどがさらに必要になります。
ここまでも長かったですが、まだまだここがプロダクト開発のスタート位置です!
作ったプロダクトを継続的にどんどん育てていきましょう!!

26
26
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
26
26