UUUM攻殻機動隊(エンジニアブログ)

UUUMのエンジニアによる技術ブログです

Railsの便利gem紹介【ridgepole】

こんにちは。日が経つのは早いものでもう会社に入って6ヶ月になります、UUUMシステムユニットの赤根谷です。

はじめに

弊社ではRailsを利用したプロジェクトが多いのですが、一部でマイングレーションツールとしてridgepoleというrubyのライブラリ(gem)を使っております。

この度私がこの「ridgepole」について社内向けに勉強会を行なったので、ブログにもまとめたいと思います。

環境

なお、弊社ではrubyを利用したプロジェクトの場合の技術スタックとしてRails + MySQLの採用率が高いため、例は全てその環境が前提となります。ただ試していませんがDBはMySQLの代わりにPostgreSQLでも動きそうです。 またridgepoleのバージョンは現在(2018/11/11)で最新の0.7.4、Railsは5.2.1、MySQLは5.7.24です。

Ridgepoleとは

Cookpad開発者ブログに書いてありますが、Cookpadの菅原さんという方が作成したライブラリです。

対応関係としては、ridgepoleはRailsのデフォルトのMigrationシステムの代わりになりうるライブラリです。

従来のMigrationシステム

Railsのデフォルトのマイグレーションシステムを利用する場合、DBスキーマの変更を行う際にはその都度新たなMigrationファイルを作って、そこに現在の状態からの差分を記述するという使い方をすると思います。

Mirationファイルの例

class AddDisplayNameToUsers < ActiveRecord::Migration[5.2]
  def change
    # カラムを足すという差分
    add_column :users, :display_name, :string
  end
end

この手法は広く使われていますが、差分を記述する度に新たにひとつファイルを作るのは大げさで面倒だと感じる人もいるでしょう。

ridgepoleを利用すると、Migrationファイルをいちいち作らずとも単一のSchemafileというファイルを編集するだけでスキーマの変更が可能になります。

使い方

準備

前置きはこのくらいにして、ridgepoleの使い方です。 まずGemfileにridgepoleを追加し、bundle installします。通常の通り、MySQLを立ち上げ、config/database.ymlにMySQLの設定を記述し、rails db:create RAILS_ENV=developmentにて空のDBを作ります。

Schemafile

ここからSchemafileと呼ばれるファイルを書いていきます。 ridgepoleはデフォルトでSchemafileがプロジェクトのルートパスにあるものとして動作するので、特に理由がなければプロジェクトのルートパスに置くと良いでしょう。

Schemafileは名前の通りスキーマが書かれたファイルで、そこにスキーマの完成形を書きます。

ここを自分の好きなように書き換えてコマンドで反映させれば、その通りにDBのスキーマを変更できます。

例えるなら、Railsのdb/schema.rbを編集すればそれが直接DBに反映される、という感じです。

実践

さっそく具体例を見ていきましょう。

なおDBの設定は正しくconfig/database.ymlに記述され、またDBが立ち上がっていることを確認してください。

テーブルを作る

次のようなridgepoleコマンドで、SchemafileをDBに反映したとします。

コマンド

bundle exec ridgepole --config ./config/database.yml --file ./Schemafile --apply

/Schemafile

create_table "users", id: :bigint, unsigned: true, force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", comment: "ユーザー" do |t|
  t.string "name", limit: 191, default: "", null: false
  t.string "email", limit: 191, default: "", null: false
  t.datetime "updated_at", precision: 6, null: false
  t.datetime "created_at", precision: 6, null: false
end

すると、SQLとしては次のようなものが発行されます。

CREATE TABLE `users` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT PRIMARY KEY,
`name` varchar(191) DEFAULT '' NOT NULL,
`email` varchar(191) DEFAULT '' NOT NULL,
`encrypted_password` varchar(191) COLLATE utf8mb4_bin DEFAULT '' NOT NULL)
ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT 'ユーザ'

id は自動で追加されます。

なおデフォルトで現在のディレクトリのSchemafileという名前のファイルを参照するので、 --file ./Schemafile の場合このオプションは省略可能です。

また-a/--applyは、Schemafileと実際のDBを比較するという意味です。詳しくは「オプション」の項目で説明します。

テーブルにカラムを足す

さて、この状態でlast_sign_in_ipをというstring型のカラムを追加したければ、下のようなSchemafileに書き換えて改めて上と同じコマンドを打てば良いです。

コマンド

bundle exec ridgepole --config ./config/database.yml --file ./Schemafile --apply

/Schemafile

create_table "users", id: :bigint, unsigned: true, force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", comment: "ユーザー" do |t|
  t.string "name", limit: 191, default: "", null: false
  t.string "email", limit: 191, default: "", null: false
  t.string "last_sign_in_ip", limit: 191
  t.datetime "updated_at", precision: 6, null: false
  t.datetime "created_at", precision: 6, null: false
end

発行されるSQL

ALTER TABLE `users` ADD `last_sign_in_ip` varchar(191) AFTER `email`

ridgepoleは冪等性を目指しており、同じSchemafileを1回実行しても2回以上実行しても結果が変わらないことが期待されています。 実際、上の2つのSchemafileどちらについても2回目を実行しようとすると、

Apply `Schemafile`
No change

と出て、何も実行されません。

仕組み

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

また知ってる方もいると思いますが、Railsのdb/schema.rbファイルはmigrateを行うたびに今のスキーマをダンプして保存しておいてくれます。 ridgepoleコマンドも従来の通りDBサーバからダンプでschema.rbファイルを更新するので、これもバージョン管理に含めると良いです。

(その性質上、db/schema.rbとSchemafileはとてもよく似たものになると思いますが、それでもやはりSchemafileとdb/schema.rbは異なりますし、テストの際に利用されるのは現状db/schema.rbの方のはずなので、db/schema.rbもバージョン管理に入れた方が良いと思います)

ちょっと複雑な使い方

さてridgepoleはこれだけでも使えるのですが、ちょっと複雑なことをしようとするとMigrationなら当然できた操作もはじめは逐一詰まることになります。 早速みていきましょう。

カラムのリネーム

emailをemail_addressにrenameする場合は、以下のようにSchemafileを書き換えて同じridgepoleコマンドで反映させます。

create_table "users", id: :bigint, unsigned: true, force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", comment: "ユーザー" do |t|
  t.string "name", limit: 191, default: "", null: false
  t.string "email_address", limit: 191, default: "", null: false, renamed_from: "email"
  t.string "last_sign_in_ip"
  t.datetime "updated_at", precision: 6, null: false
  t.datetime "created_at", precision: 6, null: false
end

発行されるSQL

ALTER TABLE `users` CHANGE `email` `email_address` varchar(191) DEFAULT '' NOT NULL

インデックス

まず以下のSchemafileを既に適用済みとします。

/Schemafile

create_table "users", id: :bigint, unsigned: true, force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", comment: "ユーザー" do |t|
  t.string "name", limit: 191, default: "", null: false
  t.string "email_address", limit: 191, default: "", null: false, renamed_from: "email"
  t.string "last_sign_in_ip"
  t.datetime "updated_at", precision: 6, null: false
  t.datetime "created_at", precision: 6, null: false
end

create_table "posts", id: :bigint, unsigned: true, force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", comment: "ポスト" do |t|
  t.string "title", limit: 191, default: "", null: false
  t.string "subtitle", limit: 191, default: "", null: false
  t.text "content", null: false
  t.bigint "user_id", unsigned: true
end

このときpostsのuser_idにindexを貼るには、

/Schemafile

create_table "users", id: :bigint, unsigned: true, force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", comment: "ユーザー" do |t|
  t.string "name", limit: 191, default: "", null: false
  t.string "email_address", limit: 191, default: "", null: false, renamed_from: "email"
  t.string "last_sign_in_ip"
  t.datetime "updated_at", precision: 6, null: false
  t.datetime "created_at", precision: 6, null: false
end

create_table "posts", id: :bigint, unsigned: true, force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", comment: "ポスト" do |t|
  t.string "title", limit: 191, default: "", null: false
  t.string "subtitle", limit: 191, default: "", null: false
  t.text "content", null: false
  t.bigint "user_id", unsigned: true
  t.index "user_id" # これを追加
end

のようにすれば良いです。 今回は不必要なのですが、ridgepoleにはMySQL専用のオプションがいくつかあり、頭にいれておくと良いでしょう。 上の例だと--mysql-use-alterオプションが使えて、以下のように変化します。

デフォルト

CREATE  INDEX `index_posts_on_user_id`  ON `posts` (
`user_id`)

--mysql-use-alter利用時

ALTER TABLE `posts` ADD  INDEX `index_posts_on_user_id`  (
`user_id`)

オプション

ここでオプションについて触れたいと思います。

ridgepoleではオプションがたくさんありますが、ドキュメントはほとんどありません。

作者のブログに日本語のドキュメントが少しありますが、Githubの方にはありません。

なのでオプションを利用しようと思った場合はソースコード自体を読んでいく必要があります。

今回の発表にあたり全体を追ったのですが、幸いなことに非常に読みやすい構成となっておりました。

まず基本的なオプションについて説明した後、コードの構成について説明します。

モードを指定するオプション

  • -a/--apply
  • -e/--export
  • -d/--diff DSL1 DSL2

の3つがあり、どれか1つのみ選ばなければなりません。

--applyは、Schemafileと現在のDBのスキーマを比較し、反映します。ただし--dry-runをつけると発行されるSQLを見ることだけができ、反映はされません。

--exportは、現在のDBのスキーマをダンプして、Schemafileの形式で書き出してくれるようです。残念ながら使ったことはありません。

--diffは、2つのSchemafileを比較して、差分のSQLを見せてくれるようです。残念ながら使ったことはありません。

反映の仕方を指定するオプション

  • -m/--merge 差分のうち、カラムやテーブルを足す操作のみ行う。

  • -t/--table 差分のうち、指定したテーブルの操作のみ行う。

  • --mysql-系 MySQL特有の操作の変更を行う。

ダンプのとり方を指定するオプション

  • --dump-with-default-fk-name

外部キーに名前をつけない場合、railsのデフォルトだとfk_rails_** のような名前になりますが、これは単純にDBからダンプを取るだけでは名前としては得られません。もしそういったRailsがつけた外部キー名前が欲しい場合は利用します。

Githubを見るとその他にも色々なオプションがあることがわかりますが、どのように使えば良いか分からない際にはコードを読み解いていきましょう。

その作業が大変のはいうまでもないですが、以下にそのソースコードを読んだ記録を残したので少しでも助けになれば幸いです。

コードを読んだメモ

(モードが-a/--applyの場合にて重点的に読んでいます)

まず鍵となるのは、client.rbです。

Ridgepole::Clientは他の主要なridgepoleのクラスのインスタンスをもっており、外部とのインターフェースを提供しています。 重要なのは、bin/ridgepoleの次の部分です。

dsl = File.read(file)
delta = client.diff(dsl, path: file)

fileはSchemafileなので、 dsl = Schemafileの中身(String) です。ついで、DBとの差分を計算し、deltaオブジェクトでラップしています。 ということは、dsl(ただのString)をパースしたり、DBのダンプをとったり、また両者を比較する操作はclient.diffの中で行われているはずです。 それが行われているのが、lib/ridgepole/client.rbの次の部分です。

def diff
  expected_definition, expected_execute = @parser.parse(dsl, opts)
  ...
  current_definition, _current_execute = @parser.parse(@dumper.dump, opts)
  ...
  @diff.diff(current_definition, expected_definition, execute: expected_execute)
end

@dumper.dumpを行うと、dslのようなStringが得られます。

Schemafileをパースした変更後のスキーマを表すオブジェクト(expected_definition)、DBをダンプしてパースして得られた変更前のスキーマを表すオブジェクト(current_definition)がそれぞれ対応しており、それぞれの詳細を見たければそれぞれを見にいけば良いことが分かります。

またダンプがうまくいかないときは@dumper.dumpを見にいけばいいし、比較がうまくいかないときは@diff.diffを見にいけば良いでしょう。

最後に、主要なファイルの意味付けをメモしたのでなんとなく参考にしてください。 (改めてですが、オプションのドキュメントはないのでコードを読むしかないのです)

bin
└─ ridgepole エントリーポイント
lib
├── ridgepole
│   ├── cli
│   │   └── config.rb オプションをParse、ラップする
│   ├── client.rb Ridgepoleの全ての処理を実質的に管理する。
│   ├── connection_adapters.rb
│   ├── default_limit.rb
│   ├── delta.rb 差分のハッシュから、add_columnなどの文字列を生成し、 `ActiveRecord::Schema.new.instance_eval` で実行する。@diff.diffの返り値。
│   ├── diff.rb 差分のハッシュを計算する
│   ├── dsl_parser
│   │   ├── context.rb create_table、add_index、add_foreign_keyを定義している。このクラスのオブジェクトの中で、Schemafileは実行される。
│   │   └── table_definition.rb context.rbのcreate_tableのdoの引数の `t` はこのクラスのインスタンス
│   ├── dsl_parser.rb SchmeafileやDumpの文字列をParseして、ハッシュに変換する
│   ├── dumper.rb 裏ではActiveRecord::SchemaDumper.dumpをしているだけだけど、そのラッパー。
│   ├── execute_expander.rb connectionのexecuteメソッドでを上書き。noopのときは実行せず、noopでないときはsuperを呼ぶ。
│   ├── ext 既存のRailsクラスを拡張している
│   │   ├── abstract_adapter
│   │   │   └── disable_table_options.rb
│   │   ├── abstract_mysql_adapter
│   │   │   ├── dump_auto_increment.rb
│   │   │   └── use_alter_index.rb
│   │   ├── pp_sort_hash.rb
│   │   └── schema_dumper.rb `ActiveRecord::SchemaDumper` を拡張して、 `with_default_fk_name` が指定された時はそんな風にダンプできるようにしている。
│   ├── external_sql_executer.rb
│   ├── logger.rb Railsのログのラッパー。infoとverbose_infoがある。
│   ├── migration_ext.rb
│   ├── schema_dumper_ext.rb
│   ├── schema_statements_ext.rb
│   └── version.rb バージョン定数
└── ridgepole.rb ほぼ全てのファイルをrequireする

まとめ

長くなりましたが、ここでまとめたいと思います。

ridgepoleは思想はすごく格好良いし共感するのですが、バグ?と感じる部分があったり、認知度が低かったり、ドキュメントが少なかったりあたりがネックかなと思います。

またあまりないと思いますが、誤ってテーブルを消した状態のSchemafileを適用させてしまったりすると、そのテーブルを誤って消してしまうことになります。

Migrationファイルを利用する場合、テーブルを消すのには明示的に指定しないといけなかったりすることを考えるとこれはridgepoleならではの怖さです。

こういったあたりをうまく回避できれば使い勝手はかなり良いと思いますが、そうでない場合デメリットがメリットを上回ってしまうのかなと思います。

ここまで読んでいただき、ありがとうございました!

まとめのまとめ

  • --dry-runオプションは優秀。
  • --verboseオプションも優秀。
  • 痒いところに手が届かないときもあるけど、ソースコードを読みにいく根性があれば便利

参考リンク

Githubのレポジトリ

作者の菅原さんのブログ

Cookpad開発者ブログ