Rails API を App Engine + Cloud SQL にデプロイする作業過程で得たことの復習
Rails を学ぶ機会があったので、開発サイクルベースで復習します。
前提
- docker-compose で PostgreSQL と Rails のコンテナを立ち上げて開発する
- ローカルマシンにRubyはインストールしてなくて、すべてDockerコンテナで実行する
-
gcloud
はインストールしてあるので、デプロイ作業などは手元から
bundle install から
困ったらまずこれを実行します。やっているうちにコマンドなどを思い出してきます。
docker-compose run --rm web bundle install
Rails のモデル追加
新機能を開発するにあたり、ここから始めます。まず、商品モデルを作りたいとして、最初に以下のようなコマンドを打ちます。
docker-compose run --rm web rails generate model Product value:integer track_ids:text
track_ids
は追跡ID的なやつを想定しています。本来は別テーブルを用意して :has_many
アソシエーションを設定するのが筋ですが、メンテナンス用途の機能だったこともあり、配列をもつことにしました。配列に変更する点については、マイグレーション前にファイルを修正します。
このようなファイル群が作成されます。
invoke active_record
create db/migrate/20210806064327_create_products.rb
create app/models/product.rb
invoke rspec
create spec/models/product_spec.rb
invoke factory_bot
create spec/factories/products.rb
まず、spec系についてはテスト時に見直すのでスルー。マイグレーションファイルを確認します。
class CreateProducts < ActiveRecord::Migration[6.1]
def change
create_table :products do |t|
t.integer :value
+ t.text :track_ids array: true, default: []
t.timestamps
end
end
end
generate コマンドで全部カバーしようとせずに、作成した後に柔軟に書き換える方針でよさそうです。
マイグレーション
適用するときは以下のように実行します。
docker-compose exec web rails db:migrate:status
docker-compose exec web rails db:migrate
戻す時。
ocker-compose exec web rails db:rollback STEP=1
手元で繰り返す分には、わりとカジュアルにできるので、最初の generate
コマンドの前にうんうん考えすぎないようにします。
Routing + View + Controller の追加
苦労したポイント1。画面でやる業務は、以下のような流れでした。
- Rails Admin で商品を登録する。このとき拠点IDもカンマ区切りで入力される
- 登録すると、DBに商品が保存され、さらに拠点IDごとに存在チェック、存在する場合は CloudTasks に拠点IDごとにでタスクが送信される
- 商品一覧を画面に表示する
後続の環境変数周りも考える必要ありですが、まずは Routing + View + Controller から進めます。Controllerの責務として、
- ユーザーの入力を受け取る
- Productモデルを作成して保存する
- CloudTasks にタスクを送信する
という感じになります。最初、CloudTasksへのタスク送信はModelで実行していたのですが、Modelは永続化に集中したほうがいいということでControllerに寄せました。もっというと、この場合は Form Ojbect というものを検討するといいらしいです。ユーザーの入力に対して、副次的な処理をやりたいときなどに、Controller と Model の橋渡しをするためのデザインパターンとのこと。
上記サイトではライブラリを使ってるけど、シンプルに include ActiveModel::Model
でもいいかも、とのことでした。このあたりの発展的なデザインパターンは、まだ完全に把握しきれていません。
scaffold で Controller のファイルを作成
モデルは個別で作ったので、残りのファイルをいい感じに追加したいです。以下のコマンドを使いました。
docker-compose exec web rails generate scaffold_controller admin/product
Running via Spring preloader in process 1632
create app/controllers/admin/products_controller.rb
invoke resource_route
route namespace :admin do
resources :products
end
invoke rspec
create spec/requests/admin/products_spec.rb
create spec/routing/admin/products_routing_spec.rb
あとは個別に編集していきます。
Rails の環境変数周りの話
苦労したポイント2。Rails アプリケーションをとりまく環境コンテキストとして、以下のようなものがあることをまず把握します。
- 環境変数(コンテナやクラウドプラットフォームでセットされるもの)
- Rails Environment Credentials
- database.yml などの個別設定
まず1で RAILS_ENV
および秘匿情報でないもの、アプリケーションではなくプラットフォームに依存する変数を指定します。次に2で、RAILS_ENV
に従いアプリケーションの変数が読み込まれます。最後にDBに接続する場合など3で設定ファイルが読み込まれます。
2について。参加したプロジェクトがもともと Rails Environment Credentials を使っていたので便乗しました。
ただ、最初はこの仕組がわからず、うんうん言いながら触っていました。要するに環境変数ファイルを Rails の仕組みで 暗号化・復号できる仕組みです。なので、「編集にはbin/railsコマンドを使うもの」だと覚えておけばそんなに怖がることはありません。以下のようにして編集します。
docker exec -it web /bin/sh
# シェルに入った後
EDITOR="vim" bin/rails credentials:edit --environment development
Rails の環境変数周りについては、3種類の変数セットが設定できること、Rails Environment Credentials は rails コマンドを使って編集すること、を把握しておけば納得しやすいです。
ngrok でサーバーをたてて触ってもらう
このご時世なのでチームは拠点がバラバラです。最初なのでいろいろ画面を見てもらいながら進めたいところですが、 Rails Admin の使用感などを都度動画で撮影するのは大変です。ngrok を使ってローカルサーバーにアクセスしてもらい、実際に触ってもらうようにしました。
Google App Engine へのデプロイ(フレキシブル環境)
GCPプロジェクトは開発用です。ステージや本番にはすでにデプロイされています。今回せっかくなのでデプロイしてDBと繋ぐ部分もやってみました。苦労したポイント3。特に PostgresのCloud SQL とつなぐ部分です。
…と思いましたが、いまこうして整理しながら振り返ると大したことはないですね。たぶん、上記の環境変数について理解しないまま進めていたので、ちゃんと設定したつもりでも RAILS_ENV=development
のときは常にローカルDBへつなぐようになっていたから動かない、といったことでハマってた記憶があります。なおドキュメントにもあるように PostgreSQL の接続設定は socket
ではなく host
でつなぐので注意が必要です。
production:
adapter: postgresql
encoding: unicode
pool: 5
timeout: 5000
username: "[YOUR_POSTGRES_USERNAME]"
password: "[YOUR_POSTGRES_PASSWORD]"
database: "cat_list_production"
+ host: "/cloudsql/[YOUR_INSTANCE_CONNECTION_NAME]"
Cloud SQL へ db:seed したい
無事に開発用プロジェクトにデプロイできました。動作確認もOKでした。今回はさらに、大量の商品に対する拠点データ更新の負荷的な確認も行いたい状況がありました。これですこし困りました。データ自体は seeds.rb
を調整して投入すればいいのですが、誤爆防止のため、seeds.rb
では RAILS_ENV
が development のときのみ発動するロジックになっています。これはこれで正しいと思います。一方デプロイ済みのアプリケーションは RAILS_ENV=production
です。よってデプロイしたアプリケーションにSSHして db:seed
することはできません。
結局、ローカル環境から CloudSQL Proxy 経由で Cloud SQL に接続し、一時的に database.yml の設定を変えて RAILS_ENV=development
でも CloudSQL へ接続できるようにしました。その上で、ローカル環境から db:seed
を実行しました。
RSpecによるテスト
いろいろなテスト技法が用意されていて、やっていて楽しかったです。
ダミーデータをつくりたいとき
FactoryBot 経由でかんたんに生成できます。さらに let
を使ってデータを生成しておくことで、コンテキスト内で使い回せるデータを定義できます。RSpecテストの見通しが良くなる良い仕組みだと思いました。
context "経路情報がすでにあるとき" do
let(:track1) { create(:track, available: true, route: "sumida") }
let(:track2) { create(:track, available: false, route: "oomiya") }
it "IDを指定して経路を探し当てる。対象外のIDは除外される。" do
product = create(:product, track_ids: [track1.id, track2.id, 0].join(","))
expect(product.planned_route_count).to eq 2
end
end
テストダブルを作りたいとき
苦労したポイント4。 今回の場合でいうと Cloud Tasks に送信する部分はダブルが必要です。これを作成する方法が種々あり、違いを理解するのに苦労しました。結論的には、instance_double
と class_double
で頑張ってダブルを作るのが良さそうです。最初は spy
を使っていましたが Rubocop に怒られました。Cloud Tasks のダブルは以下のように書きました。
let!(:dummy_cloud_tasks) do
# CloudTaskへの追加はモックする
dummy_cloud_tasks = instance_double(CloudTasks)
allow(dummy_cloud_tasks).to receive(:create)
allow(CloudTasks).to receive(:new).and_return(dummy_cloud_tasks)
dummy_cloud_tasks
end
CloudTasks
クラスは Google SDK のライブラリではなくて、アプリケーション側で用意したクラスです。この中でSDKを呼び出していて、これをモックしています。
各種ダブルの使い方について整理しておきます。
圧倒的参考:
-
double
: テスト中に呼び出されるすべてのメソッド定義を与えてやる必要があります。上の例でいうと、allow(dummy_cloud_tasks).to receive(:create)
です。Rubocop に怒られます。 -
spy
: double と違い、明示的にメソッド定義を与えなくてもエラーになりません。楽ですが、意図せずテストが成功してしまったりするので、あまり使わないほうがいいかもしれません。やはり Rubocop に怒られます。 -
instance_double
:double
の制約に加えて、実装側で定義されていないインスタンスメソッドのスタブを与えようとするとエラーになります。意図しないテストの成功を防げます。Rubocopくんもニッコリ。 -
class_double
:double
の制約に加えて、実装側で定義されていないクラスメソッドのスタブを与えようとするとエラーになります。意図しないテストの成功を防げます。Rubocopくんもニッコリ。
このことから、instance_double
と class_double
でダブルを作る方針がよさそうです。
テスト中、コールバックを無効にしたいとき
バリデーションなどのコールバックは便利ですが、テスト時意図的に dirty なデータを作りたいときなど、無効化したいシーンがあります。基本的には、無効化したいタイミングで Klass.skip_callback(:save, :before, :auto_update_route)
、有効化したいタイミングで Klass.set_callback(:save, :before, :auto_update_route)
のように指定すればOKです。コールバックを無効にするということは普段と違う振る舞いをさせるということなので、必要最小限のタイミングにおさえることが大事ですね。
レビューで指摘をもらったポイント/学んだこと
書いているよりもたくさんのレビューをもらっています。チームメンバーに感謝します。今日まで社会人を頑張ってきたご褒美として、ありがたくメンバーの時間をもらいました。
もっと Rails の仕組みを活用できる
具体的にはコールバックやデフォルトメソッド(index,create,...)の活用です。レールに従うことで記述が削減でき、見る側としても理解しやすいものになります。レビューに従っていくと、ゴリゴリと記述が削れていって「これがDRYってやつか…」と思うなどしました。
Model は永続化に集中する
なんとなく、ビジネスロジックは Model に詰め込んで Fat Model にしていくのかしらと思っていたのですが、ビジネスとして何をするべきかはエンドポイントつまりControllerが知っているはずなので、ビジネスロジックはControllerに分散させようという指摘をもらいました。もちろん Fat Controller も好ましくないので、あまり肥大化するようであれば Service の導入を検討するように、という具合です。
DBのデフォルト値を活用する
DRYに通じるものがありますが、意図的にアプリケーション側で毎度保存時に与えているデフォルト値があれば、それはデータベースのデフォルト値にできないか?を疑うとよさそうです。
create_table "products", force: :cascade do |t|
t.integer "planned_route_count", default: 0, null: false
t.decimal "value", null: false
t.string "track_ids" default: [], array: true
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
end
データーベース側でできる仕事をたくさん任せられれば、その分アプリケーション側が楽になります。
読んでる本やサイト
Discussion