この記事は、UUUM Advent Calendar 2018 9日目です。
UUUMシステムユニットに入社して6ヶ月、赤根谷です。
今回は弊社で使っているridgepoleというgemの紹介をしたいと思います。
(先月に弊社のはてなブログで公開した内容とほぼ一致してしまうのですが、読んだという方はあまり多くないと思うので再利用してしまいたいと思います。)
ブログとの大きな差分だけを読みたい方は 「弊社ブログ記事に追記」の箇所だけお読みください。バグをコミットして修正したよ、という話です。
それ以外の方は上から順番にどうぞ。
はじめに
弊社ではRailsを利用したプロジェクトが多いです。そして一部でマイングレーションツールとしてridgepoleというrubyのライブラリ(gem)を使っております。弊社で開発しているわけではありませんが、今回はそのgemの紹介です。
環境
なお弊社ではrubyを利用したプロジェクトの場合の技術スタックとしてRails + MySQLの採用率が高いため、例は全てその環境が前提となります。ただ試していませんがDBはMySQLの代わりにPostgreSQLでも動きそうです。
またridgepoleのバージョンはv0.7.4(最新版はv0.7.5)、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
# usersテーブルにdisplay_nameカラムを足すという差分
add_column :users, :display_name, :string
end
end
この手法は広く使われていますが、差分を記述する度に新たにひとつファイルを作るのは大げさで面倒だと感じる人もいるでしょう。
今回紹介するridgepoleを利用すると、Migrationファイルをいちいち作らずとも単一のSchemafileというファイルを編集するだけでスキーマの変更が可能になります。
使い方
準備
前置きはこのくらいにして、ridgepoleの使い方です。
まずGemfileにridgepoleを追加し、 bundle install
します。通常の通り、MySQLを立ち上げ、config/database.ymlにMySQLの設定を記述し、bundle exec 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
# encoding: utf-8
# 上のコメント行をつけないと、下の定義の中で日本語が使えません。
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,
`updated_at` datetime(6) NOT NULL,
`created_at` datetime(6) 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の仕組みについてです。
大まかに以下のような手順で動作します。
- Schemafileを解読してスキーマを表すHashオブジェクトを作成する。
- -c/--configで指定したDBの設定ファイルにしたがってDBからスキーマのダンプを取得。同様にスキーマを表すHashオブジェクトを作る。
- その2つのHashオブジェクトを比較し、差分を表すHashオブジェクトを計算する。
- その差分を表すHashオブジェクトからActiveRecordが理解できる
add_column
などに改めて置き換え、ActiveRecordに渡す。 - 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する
弊社ブログ記事に追記
上で述べた次の部分に関して、弊社ブログに元記事を公開した時からちょっとした変更があったので追記します。
引用
--mysql-use-alter利用時
ALTER TABLE `posts` ADD INDEX `index_posts_on_user_id` (
`user_id`)
この部分に関して触れていませんでしたが、実はINDEXを落とす際にこの機能は正常に動いていませんでした。
--dry-runオプションを指定すると下のように表示されますが、本来 ALTER TABLE
を使って欲しいところです。
ridgepole v0.7.4
remove_index("users", {:user_id=>"index_users_on_user_id"})
# DROP INDEX `index_users_on_user_id` ON `users`
そこで、こちらに対して修正のPRを送ることにしました。
変更としては1行消して2行足すという本当に小さなものでしたが、これのおかげで最新版のv0.7.5では、--mysql-use-alterを利用した場合のINDEXを落とす操作が正常に行えるようになりました。
ridgepole v0.7.5
remove_index("users", {:name=>"index_users_on_user_id"})
# ALTER TABLE `users` DROP INDEX `index_users_on_user_id`
PRを送ったのは日曜日の深夜にも関わらず、テストが通ったら菅原さんにすぐにマージしてもらえました。
また、その後すぐにこの変更に関するテストもその夜の間に書いていただけました。
さらにその後もv0.7.5をリリースされたりととても精力的にメンテナンスされているのですが、見たところ現状はどうしても菅原さん1人のみで回っている状態のようです。
もし利用する際はバグ直しやドキュメント作成などして差し上げると喜ばれるかもしれません。
まとめ
長くなりましたが、ここでまとめたいと思います。
ridgepoleは思想はすごく格好良いし共感するのですが、バグ?と感じる部分があったり、認知度が低かったり、ドキュメントが少なかったりあたりがネックかなと思います。
またあまりないと思いますが、誤ってテーブルを消した状態のSchemafileを適用させてしまったりすると、そのテーブルを誤って消してしまうことになります。
Migrationファイルを利用する場合、テーブルを消すのには明示的に指定しないといけなかったりすることを考えるとこれはridgepoleならではの怖さです。
こういったあたりをうまく回避できれば使い勝手はかなり良いと思いますので、特に小さなプロジェクトの際は検討してみてはいかがでしょうか。
ここまで読んでいただき、ありがとうございました!
まとめのまとめ
- --dry-runオプションは優秀。
- --verboseオプションも優秀。
- 痒いところに手が届かないときもあるけど、ソースコードを読みにいく根性があれば便利
会社らしいことを
弊社のウェブアプリケーション開発では、今回紹介したような便利gemを利用していたりしますし、そもそもOSSに対する貢献に価値を置くような文化があります。気になった方は是非一度遊びに来てください!(下記のリンクをクリックするとwantedlyのサイトに飛びます)
https://www.wantedly.com/projects/9783
https://www.wantedly.com/projects/25995