Herokuアプリのデータベースのカラム名を変えたくなったら

Webアプリが成長していくと、あー、このカラムの名前やっぱり別のにしておけばよかった。Railsのマイグレーションファイルで書くと`rename_column(:articles, :text, :body)`しなくちゃ、と思うこと、ありますよね。あ、そういえばカラム名が変わるとコードから参照してるところも編集しなくちゃ。`article.text`だったところを`article.body`に。

さて、カラム名は`db:migrate`すれば変わるんだけど、コードの方は？そうそう、デプロイしてdynoが再起動したら変わる。その間にアプリにアクセスがあった場合はどうなるんだろう？

試してみました。例えば、こうなります:

```
ActionView::Template::Error (undefined method `body' for #<Article>)
```

コードは`body`カラムを参照してるんだけどデータベースには`text`カラムしかない。

この記事では、カラムの名前は変えなきゃいけないとして、このエラーをどうやれば避けられるか考えます。

この記事は、[Heroku Advent Calendar 2018](https://qiita.com/advent-calendar/2018/heroku)の12月6日の記事としてお送りします。昨日は [tsukakei さんが TypeScriptをHerokuにデプロイした時にハマって](https://tsukakei1012.hatenadiary.jp/entry/2018/12/05/heroku-advent-calendar)いました。そうそう。アプリのビルドはコードのpush時に。トップディレクトリに`package.json`があれば`git push heroku master`されたコードをみてプラットフォームがよしなにビルドしてくれるはず!

## Dynoのコードの更新とデータベースのマイグレーションは同期してくれない

Herokuでは、デプロイの時のコードの更新とマイグレーションは別の時に起こります。その間にもリクエストは届き、コードはデータベースへのアクセスを続けます。アプリケーションをメンテナンスモードにしてもいいんだけど、できれば避けたいですよね。

デプロイをしてからOne-off dynoでマイグレーションを走らせる場合には、しばらくの間、新しいバージョンのコードが古いスキーマのデータベースとやりとりをします。コードがpushされ、ビルドが終わるとdynoが再起動され新しいコードが走り始めます。One-off dynoからデータベースのマイグレーションが起きるのはそのあとです。

いっぽう、[Release Phase](https://devcenter.heroku.com/articles/release-phase)コマンドを利用する場合は、逆に、しばらくの間古いバージョンのコードが新しいスキーマのデータベースとやりとりをします。コードがpushされ、ビルドが終わると、Release Phaseコマンドが走りデータベースのマイグレーションが起き、そのあとdynoが再起動され新しいコードが走り始めます。

## デプロイ前後のコードとデータベースは互換に保つ

このように、コードのバージョンとデータベースのスキーマの更新は同期せず前後します。このような状況でも、データベースのスキーマを、変更前後どちらのコードとも互換なようにして進めることで、アプリケーションを稼働させつつデプロイを済ませることができます。

例えば`articles`テーブルの`text`カラムを`body`カラムに変更する場合は、下記のように、まず`body`カラムを追加し、その後コードが`body`カラムを参照するようにしてから、最後に`text`カラムを消去します。3段階のデプロイをすることになります。

### (1) 新しいカラムをつくり既存のカラムと同期する
例えば下記のようなマイグレーションを作成してコードの変更なしにHerokuにデプロイし、マイグレーションのみ走らせます。`body`カラムを追加し、`text`カラムの内容をコピーします。`text`カラムを更新する旧版のコードはまだ走っている可能性があるので、`text`カラムに更新があった場合にはそれを`body`カラムにも反映するようにトリガを設定します。

このマイグレーションを追加しデプロイし、マイグレーションを走らせると、`text`カラムを参照しているコードからも、`body`カラムを参照しているコードからも、このデータベースとやりとりをできるようになります。

```ruby
class AddBodyToArticles < ActiveRecord::Migration[5.2]
  def up
    add_column :articles, :body, :text
    execute <<_ADD_TRIGGER
UPDATE articles SET body = text;
CREATE OR REPLACE FUNCTION sync_to_body()
  RETURNS TRIGGER AS $$
  BEGIN
    IF ( TG_OP = 'UPDATE' AND NEW.text != OLD.text OR NEW.body IS NULL ) THEN
      NEW.body := NEW.text;
    END IF;
    RETURN NEW;
  END;
  $$ LANGUAGE plpgsql;

CREATE TRIGGER sync_to_body_trigger
  BEFORE INSERT OR UPDATE OF text ON articles
  FOR EACH ROW EXECUTE PROCEDURE sync_to_body();
_ADD_TRIGGER
  end

  def down
    execute <<_REMOVE_TRIGGER
DROP TRIGGER sync_to_body_trigger ON articles;
DROP FUNCTION sync_to_body();
UPDATE articles SET text = body;
_REMOVE_TRIGGER
    remove_column :articles, :body
  end
end
```

### (2) コードが新しいカラムを参照するようにする

コード中で`text`カラムを参照している部分を編集して、`body`カラムを参照するようにします。この変更をデプロイしdynoが再起動すると、`text`カラムへの参照はなくなります。

### (3) 既存のカラムを削除する

下記のようなマイグレーションを作成してコードの変更なしにHerokuにデプロイし、マイグレーションのみ走らせます。これで`text`カラムが削除されます。念のためrollbackもできるようにしてあります。

```ruby
class DropTextFromArticles < ActiveRecord::Migration[5.2]
  def up
    execute <<_REMOVE_TRIGGER
DROP TRIGGER sync_to_body_trigger ON articles;
DROP FUNCTION sync_to_body();
_REMOVE_TRIGGER
    remove_column :articles, :text
  end

  def down
    add_column :articles, :text, :text
    execute <<_ADD_TRIGGER
CREATE OR REPLACE FUNCTION sync_to_body()
  RETURNS TRIGGER AS $$
  BEGIN
    IF ( TG_OP = 'UPDATE' AND NEW.text != OLD.text OR NEW.body IS NULL ) THEN
      NEW.body := NEW.text;
    END IF;
    RETURN NEW;
  END;
  $$ LANGUAGE plpgsql;

CREATE TRIGGER sync_to_body_trigger
  BEFORE INSERT OR UPDATE OF text ON articles
  FOR EACH ROW EXECUTE PROCEDURE sync_to_body();
_ADD_TRIGGER
  end
end
```

これでダウンタイムなしでカラム名を変更できました。

## もっと詳しく！
Thoughts on Javaの記事「[Update your Database Schema Without Downtime](https://www.thoughts-on-java.org/update-database-schema-without-downtime/)」などに、カラム名の変更以外の場合についても、詳しい解説があります。

この記事の内容の確認は[zunda/rails-dbmigrate-test](https://github.com/zunda/rails-dbmigrate-test/)でおこないました。

## 落ち穂拾い
- 筆者はRuby on RailsやPostgreSQLについては詳しくないです。ごめんなさい。上記のマイグレーションのコードなど、より良い書き方があればお知らせいただけるとうれしいです。
- Gitでの多段リリースの表現方法を考えてたい。上記のようにコミットが複数になる場合、Tagを書きつつブランチでマイグレーションのテストを終えて、プロダクション用のブランチにtagをmergeしていくのが良いだろうか？
- Private Spacesではコードの変更時に複数のweb dynoが新旧両方のバージョンで稼働する可能性があり、新しいバージョンのweb dynoからレスポンスをもらったクライアントが古いバージョンのweb dynoにアセットをもらいに行くと、404 Not Foundが返ってしまいます。新しいバージョンのアセットを先にリリースしておけば回避できるのですが、いい方法ないかな。