Railsアプリケーションでサービス停止メンテナンスをせず、テーブルのデータを別テーブルへ分離する方法

Qiita株式会社 アドベントカレンダー15日目は、プロダクト開発グループの @WakameSun が担当します！

https://qiita.com/advent-calendar/2021/qiita

アプリケーション開発を行なっていると、厳密には用途が違うデータ群が同じテーブルに入ったりすることがあり、それらのデータをそれぞれ別のテーブルへ分離させたくなることがあります。

テーブルの分割を行う場合、サービス停止メンテナンスを行いアクセスを止めてからテーブルを分割することもありますが可能なら止めずにユーザーにわからないまま行いたいですよね。
今回はRailsアプリケーションを題材として、サービスを稼働させたままテーブルを分割する方法を考えます。

## 題材
今回は説明を簡単にするために文房具と本のみを扱う雑貨屋を例にします。

しかし、modelは文房具も本も同じitemsテーブルに格納されているものとしてます。
今回は、下記の状態から本を新しく作るbooksテーブルに隔離することを目標にします。

```ruby:db/shcema.rb
ActiveRecord::Schema.define(version: 2021_12_06_075428) do
  create_table "items", id: :integer, charset: "utf8mb4", force: :cascade do |t|
    t.integer "kind", null: false
    t.string "name", null: false
    t.integer "price", null: false
    t.datetime "created_at"
    t.datetime "updated_at"
  end
end
```

```ruby:app/models/item.rb
class Item < ApplicationRecord
  enum kind: { stationary: 0, book: 1, }, _prefix: true
  validates :kind, presence: true
  validates :price, presence: true
  validates :name, presence: true
end
```

## 分割する手順

大きく５つに分けて説明します。
今回は、全手順Railsで完結する方法を取るようにしました。

- 1.テーブルを作成する
- 2.コールバックで同期を取れるようにする
- 3.ワンタイムスクリプトで同期の抜け漏れをなくす
- 4.参照を変えて、同期を止める
- 5.レコードやカラムを整理する

完全にサービスを止める場合手順は大きく変わってくる（いくつか楽ができる）し、他のフレームワークなどではまた違うかと思われますが、Railsかつサービスを稼働させたままやろうとすると基本的にこの手順になるかなと思います。

### 1. テーブルを作成する
ひとまずbooksテーブルとBookモデルを作成しましょう。
ここで、booksテーブルにitem_idカラムを付与し、itemテーブルと関連付けます。

```db/migrate/20211214105825_create_books.rb
class CreateBooks < ActiveRecord::Migration[6.1]
  def change
    create_table :books, id: :integer do |t|
      t.string :name: , null: false
      t.integer :price, null: false
      t.integer :item_id, null: false

      t.timestamps
    end
  end
end
```

```ruby:app/models/book.rb
class Book< ApplicationRecord
  validates :price, presence: true
  validates :name, presence: true
  validates :item_id, presence: true
  belongs_to :item
end
```

### 2.コールバックで同期を取れるようにする
今回、まだbooksテーブルにはまだ何もデータが入っていませんし参照もされていません。
そこで、まずはitemsテーブルにレコードが作成・更新・削除された時にbooksテーブルも作成・更新・削除するようにします。

```ruby:app/models/item.rb
class Item< ApplicationRecord
  enum kind: { stationary: 0, book: 1,  }, _prefix: true
  validates :kind, presence: true
  validates :price, presence: true
  validates :name, presence: true
  has_one :item, dependent: :destroy
  after_create :create_book, if: :kind_book?
  after_update :update_book
  
  private
  
  def create_book
    book.build_book(price: price, name: name).save!
  end

  def update_book
    if saved_changed_to_book
      book.build_book(price: price, name: name).save! if kind_book?
      book.destroy! if kind_stationary? && book # 同期を取る前はbooksテーブルにレコードが存在しないのでdestroyを呼ばないようにしている
    else
      book.update(price: price, name: name)
    end
  end
end
```

作成は`create_book`関数、更新は`update_book`関数、削除はリレーションの`dependent: destroy`がそれぞれ担当しています。
update_bookが複雑になっているのは文房具だけど本で登録しちゃった！みたいな場合のケアをしているためです。
作っているものによって、この手のケアは変わってくるのでそれは各アプリ、各テーブルで必要な考慮を考えてみてください。

### 3.ワンタイムスクリプトで同期の抜け漏れをなくす
先ほどのコールバックで新規に作成されたりしたbooksテーブルのデータは同期が取れるようになりましたが、過去のデータも同期を取る必要があります。今回は`rails runner`で実行できるスクリプトを記述しましょう。

```ruby:script/onetime/sync_books.rb
Item.where(kind: :kind_book).find_each do |item|
  item.build_book(price: item.price, name: item.name) unless item.book
end
```
itemsテーブルのうち、kindがbookのものを取り出して、まだbooksテーブルにデータがないもののみ作成します。
データ作成は`find_or_create_by`を使うのもいいでしょう。
データ数が多い場合、普通の`each`を使うことによりデータを多くロードしすぎないように注意してください。

### 4.参照を変えて、同期を止める
データが同期されてしまえば、あとはこっちのもの。
アプリケーション内で、itemsからbookを参照している部分を、booksテーブルから参照してあげるように書き換えましょう。
また、デプロイされれば同期は必要ではなくなるので、コールバックやリレーションも削除します。
参照の変更と同期を止めるのは同時でも良いですが不安な方は分けて行なっても構いません。
注意として、nullオプションをfalseにしているため、今回はこの時にbooksテーブルからitem_idを消しておく必要があります。

### 5.レコードやカラムを整理する
最後は残ったデータや不要になったカラムのお掃除です。
今回の場合だと順番に、

- itemsテーブルのbookデータの削除
- itemsテーブルからkindカラムの削除
- itemsテーブルをstationariesテーブルに改名 (これはしない場合もあるかも)

等があるでしょう。ワンタイムスクリプトやmigrateで簡単に行えると思います。

## まとめ
今回は可能な限り、Railsアプリケーションを動かしたままテーブルを分割する方法を考えてみました。
もしサービスを止めても良いのであれば、新規テーブルを作成して、ワンタイムスクリプトを流して、不要になったカラム・データ等を削除するだけで良いです。
しかし、そうでないアプリケーションでもRailsのコールバックを有効に活用することで、安全に倒してテーブルの分離を行うことができます。
もし誰かの参考になれば幸いです。


次の16日は @zumi0 が担当します！お楽しみに！