Rails で信頼性の高い Migration を書くには


エムスリーでは Tech Talk という技術勉強会をほぼ隔週で行っているのですが、今日は記念すべき 50 回目ということで飲み会がありました。少しだけ飲み過ぎて遅くなってしまったのですが、エムスリーアドベントカレンダー 12/21（月）の記事がこちらになります。

私は日頃趣味では JavaScript 関連を触っていることが多いのですが、業務ではフロントエンドに加えて、Rails を使ってデータの整合性が何よりも大事な業務系のシステムを書いています。このようなシステムを開発する中で、Rails の Migration、特にスキーマ変更に付随するデータ変更について学んだことをいくつか書いてみようと思います。

## バックアップ

何はなくとも、本番環境で Migration を実行する前にバックアップを取りましょう。当たり前のことですが、大事なことなので二度言います。バックアップを取りましょう。

## Migration の仕組みを知る

PostgreSQL のように DDL を rollback できるデータベースでは、Migration 一つ一つが transaction に包まれています。Migration の中で例外を投げれば、その Migration については rollback させることができますが、その前に成功してしまった Migration は元には戻りません。

私が PostgreSQL を使っている関係上、この記事は DDL を rollback できるものとして書かれています。

## Migration 毎に専用の Model を用意する

「Migration は後でいじらない」というのは常識かと思いますが、Migration の中で Migration 外のコードを利用していると「開発時と本番適用時で挙動が変わっていた」ということになりかねません。Migration はそのファイル内だけで完結させるのが理想です。

特に Model を使ってデータの移行を行う場合は注意が必要です。`create`, `update`, `where` など一部のメソッドしか使わないつもりでついついそのまま使ってしまいがちですが、hook や `default_scope`、validation などの変化によって知らぬうちに挙動が変わってしまいます。Migration 毎に専用の Model を作りましょう。

実は意外に簡単です。ポイントは `table_name` を指定するところ。また、名前空間を汚染しないように Migration class の中に書きましょう。

```rb
class DoSomething < ActiveRecord::Migration
  class MigrationFoo < ActiveRecord::Base
    self.table_name = :foos
    has_many :bars, class_name: 'MigrationBar'
  end

  class MigrationBar < ActiveRecord::Base
    self.table_name = :bars
    belongs_to :foo, class_name: 'MigrationFoo'
  end

  def change
    add_column :foo, :some_column

    reversible do |dir|
      dir.up do
        MigrationFoo.reset_column_information
        MigrationFoo.find_each do |foo|
          # Do something with foo.
        end
      end
    end
  end
end
```

## Model を使う前には必ず `reset_column_information` を呼ぶ

ActiveRecord はテーブルのカラム情報をキャッシュしています。このキャッシュは Model class 毎ではなくテーブル毎のようなので、Migration 毎に専用の Model を作成していても削除する必要があります。

```rb
def change
  # ...

  reversible do |dir|
    dir.up do
      MigrationFoo.reset_column_information
      # Do something with MigrationFoo
    end
  end
  
  # ...
end
```


## find_each ならレコード数が多くても安心

これは Migration だけに限らない話題ですが書いておきます。レコード数の多いテーブルに対して全件 `SELECT` することは、アプリの普通のコードではあまりありませんが、データ移行の場面ではたまに出てくると思います。

普通に `Model.all.each` のように書くと、全レコードを一回で `SELECT` して全部 Model のインスタンスにするという、メモリを一気に使用する処理が走ってしまいます。それをバッチで処理してメモリを使いきらないようにするのが `find_each` です。デフォルトでは 1000 件ずつ `SELECT`するようになっています。

```rb
# レコード数が多いとメモリを使いきって大変！
MigrationFoo.all.each do |foo|
  # Do something with foo.
end

# レコードが数が多くても安心
MigrationFoo.find_each do |foo|
  # Do something with foo.
end
```

## 失敗したらエラーを投げる

ActiveRecord のメソッドには `save`/`save!`, `create`/`create!`, `update`/`update!` など、失敗した時にエラーを投げるものと投げないものがありますが、必ずエラーを投げる方を使うと良いです。失敗に気づかないこと程怖いものはありません。じゃんじゃんエラーを投げましょう。

```rb
# 黙って失敗するので NG
MigrationFoo.find_each do |foo|
  foo.update(some_attrs)
end

# 保存できない時はエラーにしてロールバックするので OK
MigrationFoo.find_each do |foo|
  foo.update!(some_attrs)
end
```

これは結構うっかりしがちなので、コードレビューでも要注意ポイントです。

## データ変更の妥当性を担保する

データ変更の妥当性を担保するには、主に二種類の方法があります。

1. Migration の中に assertion を書く
2. Migration Spec を書く

理由は後述しますが 1 の assertion を書く方法を推奨します。2 はコストが高いのでおすすめしません。止むに止まれぬ事情と余裕がある場合は両方やると良いでしょう。

### Migration の assertion

Migration の最後に、期待通りの結果になったかを検証するコードを書くというものです。期待通りになっていなかった場合はエラーを投げて Migration を中断（rollback）します。

最大のメリットは、本番環境を含めた全ての環境でデータ変更の妥当性を担保できることです。デメリットは Migration の実行が少し遅くなるくらいですが、実質ほとんどありません。

```rb
def change
  # ...

  reversible do |dir|
    dir.up do
      MigrationFoo.reset_column_information
      # Do something with MigrationFoo
    end
  end
  
  # ...
  
  reversible do |dir|
    dir.up do
      MigrationFoo.reset_column_information
      MigrationFoo.find_each do |foo|
        fail 'Something is wrong' unless foo.going_well?
      end
    end
  end
end
```

### Migration の Spec は書き捨て

テストコードの中でマイグレーションをしてみて、期待通りの結果が得られるかを確認する方法です。Spec を書く方法は [Start Testing Your Migrations. (Right Now)](Start Testing Your Migrations. (Right Now)) という記事で紹介されていたのを同僚が見つけてきて知りました。テストコードの中で以下の手順を実行します。

1. テスト対象の Migration の実行前の状態まで rollback
2. テスト用のデータを用意（最新の Model とずれがある可能性があるので FactoryGirl が使えなくて辛い・・・）
3. テスト対象の Migration を実行
4. 期待通りの結果になったか検証
5. 最新の状態まで migrate

一見良い方法に見えますが、それなりにデメリットがあります。

まず当然ですが、用意したデータでは成功しても本番環境で成功することを担保できません。ですので、仮に Migration Spec を採用する場合でも assertion と併用すると良いでしょう。

そして、テストケースの数だけ rollback, migrate を実行するので、非常に時間がかかります。特に Migration を追加する毎にどんどん遅くなっていきます。やってみたところ CI でテストが全然終わらなくて辛い思いをしました。対象の Migration が本番環境で成功したら、削除すると良いでしょう。「旅の恥はかき捨て」といいますが「Migration の Spec は書き捨て」と考えるといいと思います。

また 2. テストデータの用意のところで FactoryGirl を使うと、その後テーブルや Model を変更した時にテストが壊れます。複雑な association がある場合などは辛いですが、FactoryGirl などは使わない方が良いです。

本番環境に入っているデータを予測できない場合や、ダウンタイムが許容できない場合には採用すると良いかもしれません。

## 終わりに

いかがでしたでしょうか？改めて見ると当たり前のことばかりですが、実践的な Migration 方法がまとまっているページが見当たらなかったため書いてみました。中でも Migration Spec よりも assertion を書いたほうがいいというのは、しばらく Migraton Spec を書いて苦しんだ後辿り着いた手法です。かなりコスパが高いのでおすすめです。

もっといい方法がある、ですとか、こんな方法をやっていて便利、などあれば是非コメント等で教えていただけると助かります。

ではでは楽しい Migration ライフを！