Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

Railsアプリケーションでstructure.sqlを使うメリット・デメリット徹底解説

本記事では、Ruby on Railsアプリケーションにおけるstructure.sqlと、デフォルトのschema.rbという2つのスキーマ形式の重要な違いと、それぞれの利点について詳しく解説します。データ駆動型の現代において、データベースが持つ豊富な機能をどれだけ活用できるかは、プロジェクトの成否を左右する大きな要素になります。

まず両者の主な違いを確認したうえで、structure.sqlへの切り替え手順を紹介し、さらにデータ整合性の確保や、通常では維持できない高度なデータベース機能の活用にどう役立つのかを実例とともに見ていきます。

記事内の例では、PostgreSQLデータベースと組み合わせてstructure.sqlを使用するRailsアプリケーションを取り上げますが、ここで説明する概念は他のデータベースにもそのまま応用できます。信頼性の高いデータベースなしに、本格的なWebアプリケーションは成立しないからです。

それでは早速、始めましょう!

schema.rbとstructure.sqlの違い

Ruby on Railsプロジェクトを開始して最初に行うことのひとつが、データベースマイグレーションの実行です。たとえばUserモデルを生成すると、Railsは必ずマイグレーションの実行を求めてきます。これによりschema.rbファイルが自動的に作成されます。

rails g model User first_name:string last_name:string

このコマンドで生成されるマイグレーションファイルは次のとおりです。

class CreateUsers < ActiveRecord::Migration[6.0]
  def change
    create_table :users do |t|
      t.string :first_name
      t.string :last_name
 
      t.timestamps
    end
  end
end

マイグレーションを実行すると、Railsがschema.rbファイルを生成していることが確認できます。

ActiveRecord::Schema.define(version: 2019_12_14_074018) do
 
  # These are extensions that must be enabled in order to support this database
  enable_extension "plpgsql"
 
  create_table "users", force: :cascade do |t|
    t.string "first_name"
    t.string "last_name"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end
 
end

このschema.rbファイルは、比較的シンプルなアプリケーションやユースケースには非常に便利です。

ここで注目すべきポイントは2つあります。

  1. これはデータベース構造のRuby表現である。schema.rbは実際のデータベースを検査し、その構造をRubyのコードとして表現したもの。
  2. データベースに依存しない(データベースアグノスティック)。SQLite、PostgreSQL、MySQLなど、Railsがサポートするどのデータベースを使っても、構文や構造はほぼ同じになる。

しかし、アプリケーションの成長に伴い、この方式では物足りなくなる瞬間が訪れることがあります。

たとえば、マイグレーションファイルが数百、数千に達したケースを考えてみてください。

新しい本番環境を素早く立ち上げる必要がある場合、すべてのマイグレーションを順番に実行するのに時間がかかりすぎる事態に陥るかもしれません。また、古いバージョンのデータベース向けに書かれたコードが含まれるマイグレーションが、現在のバージョンでは実行できなくなることもあります。さらに、当時のデータ状態を前提に書かれたマイグレーションが、今のデータでは成立せず失敗してしまうケースもあるでしょう。

こうした状況では、単純なrails db:create db:migrateコマンド一発で、新しいアプリケーションインスタンス(本番環境でも新メンバーの開発環境でも)を効率的にセットアップすることができません。もし本当にそうなったら、正しいデータベーススキーマをどうやって用意すればよいのでしょうか?

もちろん、ひとつの方法としては、壊れたマイグレーションをすべて遡って修正することです。それは決して悪い考えではありません!

しかし、大量のマイグレーション修正のコストが大きすぎる場合は、rails db:setupタスクを実行するという手もあります。このタスクはschema.rbファイルからデータベーススキーマを生成してくれます。ただし、データベースに複雑なロジックが含まれていて、それがschema.rbの表現に反映されていない場合はどうでしょうか?

幸い、Railsにはもうひとつの選択肢、structure.sqlが用意されています。

structure.sqlschema.rbと異なる点は以下のとおりです。

  • データベース構造の完全なコピーを作成できる。チームで開発する場合や、rails db:setupタスクで本番環境の新しいデータベースを迅速に生成する必要がある場合に重要。
  • 高度なデータベース機能の情報を保持できる。たとえばPostgreSQLを使用している場合、ビュー、マテリアライズドビュー、関数、制約などを活用できるようになる。

アプリケーションが一定の成熟度に達すると、効率の向上、データの正確性の維持、高速なパフォーマンスの実現のために、あらゆる手段を駆使する必要があります。structure.sqlでRailsのデータベース挙動を管理すれば、それが可能になります。

schema.rbからstructure.sqlへの切り替え方法

schema.rbからstructure.sqlへの変更は、比較的簡単な作業です。config/application.rbに次の1行を追加するだけです。

module YourApp
  class Application < Rails::Application
    config.load_defaults 6.0
 
    # 次の行を追加:
    config.active_record.schema_format = :sql
  end
end

その後、rails db:migrateを実行すれば、db/structure.sqlにファイルが生成されているはずです。はい、完成!Railsは使用中のデータベース専用のツールを使ってデータベース構造をダンプします(PostgreSQLの場合はpg_dump、MySQLやMariaDBの場合は各テーブルに対するSHOW CREATE TABLEの出力など)。このファイルはバージョン管理下に置き、チーム全員が同じデータベース構造を共有できるようにしておくことをおすすめします。

初めてこのファイルを見ると、その分量に圧倒されるかもしれません。schema.rbはわずか25行だったのに、structure.sqlはなんと109行もあります!こんなに大きなファイルに、どんなメリットがあるのでしょうか?

データベースレベルの制約を追加する

ActiveRecordは、私がRailsを使う上で最も好きな部分のひとつです。まるで自然言語のように、直感的な形でデータベースへクエリを発行できます。たとえば、ある会社の「Dan」という名前のユーザーをすべて検索したい場合、ActiveRecordなら次のようなクエリを書くだけで済みます。

company = Company.find(name: 'Some Company')
 
# 自然言語のように読める!
company.users.where(first_name: 'Dan')

しかし、ActiveRecordが対応しきれないケースも存在します。たとえば、Userモデルに次のようなバリデーションを設定したとしましょう。

class User < ApplicationRecord
  validate :name_cannot_start_with_d
 
  private
 
  def name_cannot_start_with_d
    if first_name.present? && first_name[0].downcase == 'd'
      errors.add(:first_name, "cannot start with the letter 'D'")
    end
  end
end

「Dan」という名前のユーザーを作成しようとすると、バリデーション実行時にエラーが発生します。

User.create!(first_name: 'Dan')
Traceback (most recent call last):
ActiveRecord::RecordInvalid (Validation failed: First name cannot start with the letter 'D')

これは問題ありません。しかし、あなた自身やチームメンバーが、ActiveRecordのバリデーションをバイパスしてデータを変更したらどうなるでしょう?

u = User.create(first_name: 'Pan')
 
# update_attributeメソッドはActiveRecordのバリデーションをバイパスする
u.update_attribute :first_name, 'Dan'
u.first_name
=> "Dan"

ご覧のとおり、バリデーションは非常に簡単に回避できてしまいます。

これはアプリケーションにとって深刻な結果をもたらしかねません。ActiveRecordは諸刃の剣です。クリーンで自然なDSLのおかげで扱うのが楽しい一方、モデルレベルのバリデーションの強制に関しては意外と寛容すぎることが多いのです。解決策は、ご存じの方も多いでしょう、データベースレベルの制約を追加することです。

rails g migration AddFirstNameConstraintToUser

生成されたファイルを編集し、「D」で始まる名前を禁止するロジックを記述します。

class AddFirstNameConstraintToUser < ActiveRecord::Migration[6.0]
  def up
    execute "ALTER TABLE users ADD CONSTRAINT name_cannot_start_with_d CHECK (first_name !~* '^d')"
  end
 
  def down
    execute "ALTER TABLE users DROP CONSTRAINT IF EXISTS name_cannot_start_with_d"
  end
end

ここで注意したいのは、マイグレーションを正しく元に戻すコードを必ず記述することの重要性です。上の例ではupdownの両ディレクティブを定義しています。upメソッドはマイグレーション実行時に呼ばれ、downメソッドはロールバック時に呼ばれます。データベース構造を適切に戻さないままにすると、後で手動でのクリーンアップ作業が必要になるかもしれません。将来の頭痛の種を避けるためにも、常にupdownの両方に対応したマイグレーションファイルを用意することをおすすめします。

それでは、マイグレーションを実行して、制約をバイパスできるかどうか確認してみましょう。

rails db:migrate
user = User.create first_name: 'Pan'
user.update_attribute :first_name, 'Dan'
 
ActiveRecord::StatementInvalid (PG::CheckViolation: ERROR:  new row for relation "users" violates check constraint "name_cannot_start_with_d")
DETAIL:  Failing row contains (2, Dan, null, 2019-12-14 09:40:11.809358, 2019-12-14 09:40:41.658974).

完璧です!制約は意図どおりに機能しています。何らかの理由でActiveRecordのバリデーションをバイパスしても、最後の砦であるデータベースがデータ整合性を守ってくれるのです。

では、これはstructure.sqlと何の関係があるのでしょうか?

structure.sqlを覗いてみると、次の記述が追加されていることがわかります。

CREATE TABLE public.users (
    id bigint NOT NULL,
    first_name character varying,
    last_name character varying,
    created_at timestamp(6) without time zone NOT NULL,
    updated_at timestamp(6) without time zone NOT NULL,
    CONSTRAINT name_cannot_start_with_d CHECK (((first_name)::text !~* '^d'::text)));

なんと、制約がスキーマそのものの中に含まれているのです!

schema.rbもデータベースレベルの制約をある程度サポートしていますが、トリガー、シーケンス、ストアドプロシージャ、CHECK制約など、データベースが提供するすべての機能を表現できるわけではない点は覚えておきましょう。たとえば、まったく同じマイグレーション(AddFirstNameConstraintToUser)を実行した場合、schema.rbだけを使っているとスキーマファイルは次のようになります。

ActiveRecord::Schema.define(version: 2019_12_14_074018) do
 
  # These are extensions that must be enabled in order to support this database
  enable_extension "plpgsql"
 
  create_table "users", force: :cascade do |t|
    t.string "first_name"
    t.string "last_name"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end
 
end

ファイルは一切変わっていません!制約は追加されていないのです。

この状態で新しい開発者をプロジェクトに迎えると、人によって異なるデータベースのルールのもとで作業してしまう可能性があります。

structure.sqlをバージョン管理にコミットしていれば、チーム全体が同じ認識を保てるようになります。structure.sqlファイルがあれば、rails db:setupを実行した際に、データベース構造に上記の制約が確実に含まれます。schema.rbにはそんな保証はありません。

本番環境についても同様です。真っさらなデータベースでアプリケーションの新しいインスタンスを素早く立ち上げる必要があり、すべてのマイグレーションを順次実行するのに時間がかかる場合、structure.sqlファイルからデータベースをセットアップするほうがはるかに高速です。そして、structure.sqlがあれば、他の環境とまったく同じ構造でデータベースが作成されることを安心して期待できます。

成長の痛み(運用上の課題)

簡潔なschema.rbファイルをチームで管理するほうが、冗長なstructure.sqlファイルを管理するよりも、はるかに容易です。

structure.sqlへ移行する際の最大の課題のひとつは、必要な変更だけをそのファイルにコミットすることを保証することです。これが意外と難しい場合があります。

たとえば、誰かのブランチをプルして、そのブランチ固有のマイグレーションを実行したとします。すると、あなたのstructure.sqlにはいくつかの変更が含まれることになります。その後、自分のブランチに戻って作業を続け、新しいマイグレーションを生成すると、structure.sqlには自分のブランチの変更と相手のブランチの変更が混在してしまうのです。これは少し厄介な問題で、こうしたコンフリクトの管理には間違いなく学習曲線が存在します。

このアプローチを採るということは、トレードオフを受け入れるということです。データベースの高度な機能を保持できる代わりに、最初に多少のコードの複雑さと向き合う必要があります。逆にschema.rbを選べば、よりシンプルなスキーマ表現を得られる一方、データベースの全機能を自由に使えるわけではなくなり、たとえばdb:setupタスクからの復元などが思うようにいかないこともあります。私の考えでは、本番システムで破損・不正なデータを修正する苦労や、データベースの高度な機能を活用できない不利益に耐えるよりも、多少のバージョン管理の手間を受け入れるほうが賢明です。

一般的に、私がstructure.sqlファイルに特定のブランチに関連する変更だけを含めるために使ってきた戦略は、次の2つです。

  • マイグレーションを含むブランチでの作業が完了したら、必ずrails db:rollback STEP=nを実行する(nはそのブランチのマイグレーション数)。これにより、データベース構造を元の状態に戻せる。
  • ブランチでの作業後にロールバックを忘れてしまうこともある。その場合は、新しいブランチで作業を始める際、新しいマイグレーションを作成する前に、masterからクリーンなstructure.sqlファイルをプルするようにする。

経験則として、structure.sqlファイルには、masterにマージされる前に、自分のブランチに関連する変更だけが含まれているべきです。

まとめ

一般論として、Railsアプリケーションが小規模である場合や、データベースの高度な機能を必要としない場合は、可読性が高く簡潔で管理しやすいschema.rbを使うのが安全です。

しかし、アプリケーションの規模と複雑さが増してくると、データベース構造を正確に反映させることが不可欠になります。そうすることで、チームは適切な制約、データベースモジュール、関数、演算子を維持できるようになり、それ以外の方法では実現できません。整備されたstructure.sqlファイルとともにRailsを使いこなせるようになれば、シンプルなschema.rbでは決して得られない優位性を手に入れられるでしょう。

P.S. Ruby Magicの最新記事をいち早くお読みになりたい方は、ぜひRuby Magicニュースレターをご購読ください。記事を見逃すことはありません!

  1. AWS RDSでSQL Serverネイティブデータベースをバックアップ・復元・監視する方法

    データベースのバックアップとは、データベースの稼働状態、構成、保存データ一式を退避することです。バックアップがあれば、プライマリデータベースがクラッシュ・破損・消失した場合でも、複製インスタンスやコピーを作成して迅速に復旧できます。 本記事では、Amazon Web Services(AWS)Relational Database Service(RDS)におけるSQL Serverネイティブデータベースのバックアップ、復元、監視の方法を解説します。 AWS RDSでのSQLネイティブフルバックアップ AWS RDSでは、SQL Serverネイティブデータベースに対して実行できるのはフルバッ

  2. Database-as-a-Service(DBaaS)とは?メリット・デメリットと導入判断のポイントを徹底解説

    本記事は、2017年12月7日にObjectRocket.com/blogで公開された記事をもとにしています。業務を社内でまかなうか、外部にアウトソーシングするか――多くの企業が直面するこの判断は、データベース管理において特に悩ましい問題です。本記事では、Database-as-a-Service(DBaaS)の長所と短所を詳しく解説し、貴社にとってDBaaSが適切な選択肢となるかを見極めるためのポイントをご紹介します。特にテック系企業など創業期の会社によくある疑問が、「特定の機能を外注すべきか、それとも社内で行うべきか」という点です。自前でチームを雇うにも、外部企業に依頼するにも費用がかかる