💎

Rails API を App Engine + Cloud SQL にデプロイする作業過程で得たことの復習

2021/08/12に公開

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 の橋渡しをするためのデザインパターンとのこと。

https://techracho.bpsinc.jp/hachi8833/2021_01_07/14738#form-object

上記サイトではライブラリを使ってるけど、シンプルに 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 アプリケーションをとりまく環境コンテキストとして、以下のようなものがあることをまず把握します。

  1. 環境変数(コンテナやクラウドプラットフォームでセットされるもの)
  2. Rails Environment Credentials
  3. database.yml などの個別設定

まず1で RAILS_ENV および秘匿情報でないもの、アプリケーションではなくプラットフォームに依存する変数を指定します。次に2で、RAILS_ENV に従いアプリケーションの変数が読み込まれます。最後にDBに接続する場合など3で設定ファイルが読み込まれます。

2について。参加したプロジェクトがもともと Rails Environment Credentials を使っていたので便乗しました。

https://zenn.dev/banrih/articles/f22f0a70bbead2a02110

ただ、最初はこの仕組がわからず、うんうん言いながら触っていました。要するに環境変数ファイルを 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 を使ってローカルサーバーにアクセスしてもらい、実際に触ってもらうようにしました。

https://ngrok.com/

Google App Engine へのデプロイ(フレキシブル環境)

GCPプロジェクトは開発用です。ステージや本番にはすでにデプロイされています。今回せっかくなのでデプロイしてDBと繋ぐ部分もやってみました。苦労したポイント3。特に PostgresのCloud SQL とつなぐ部分です。

https://cloud.google.com/ruby/rails/using-cloudsql-postgres?hl=ja

…と思いましたが、いまこうして整理しながら振り返ると大したことはないですね。たぶん、上記の環境変数について理解しないまま進めていたので、ちゃんと設定したつもりでも 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_doubleclass_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を呼び出していて、これをモックしています。

各種ダブルの使い方について整理しておきます。

圧倒的参考:
https://qiita.com/noraworld/items/3c6d13519ecde6b68fdf

  • double: テスト中に呼び出されるすべてのメソッド定義を与えてやる必要があります。上の例でいうと、allow(dummy_cloud_tasks).to receive(:create) です。Rubocop に怒られます。
  • spy: double と違い、明示的にメソッド定義を与えなくてもエラーになりません。楽ですが、意図せずテストが成功してしまったりするので、あまり使わないほうがいいかもしれません。やはり Rubocop に怒られます。
  • instance_double: doubleの制約に加えて、実装側で定義されていないインスタンスメソッドのスタブを与えようとするとエラーになります。意図しないテストの成功を防げます。Rubocopくんもニッコリ。
  • class_double: doubleの制約に加えて、実装側で定義されていないクラスメソッドのスタブを与えようとするとエラーになります。意図しないテストの成功を防げます。Rubocopくんもニッコリ。

このことから、instance_doubleclass_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に通じるものがありますが、意図的にアプリケーション側で毎度保存時に与えているデフォルト値があれば、それはデータベースのデフォルト値にできないか?を疑うとよさそうです。

schema.rb
  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

データーベース側でできる仕事をたくさん任せられれば、その分アプリケーション側が楽になります。

読んでる本やサイト

https://i.loveruby.net/ja/rhg/book/minimum.html

https://amzn.to/3CJPJPW

https://amzn.to/3AGwVQ5

Discussion