Qiita株式会社 アドベントカレンダー15日目は、プロダクト開発グループの @WakameSun が担当します!
アプリケーション開発を行なっていると、厳密には用途が違うデータ群が同じテーブルに入ったりすることがあり、それらのデータをそれぞれ別のテーブルへ分離させたくなることがあります。
テーブルの分割を行う場合、サービス停止メンテナンスを行いアクセスを止めてからテーブルを分割することもありますが可能なら止めずにユーザーにわからないまま行いたいですよね。
今回はRailsアプリケーションを題材として、サービスを稼働させたままテーブルを分割する方法を考えます。
題材
今回は説明を簡単にするために文房具と本のみを扱う雑貨屋を例にします。
しかし、modelは文房具も本も同じitemsテーブルに格納されているものとしてます。
今回は、下記の状態から本を新しく作るbooksテーブルに隔離することを目標にします。
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
class Item < ApplicationRecord
enum kind: { stationary: 0, book: 1, }, _prefix: true
validates :kind, presence: true
validates :price, presence: true
validates :name, presence: true
end
分割する手順
大きく5つに分けて説明します。
今回は、全手順Railsで完結する方法を取るようにしました。
- 1.テーブルを作成する
- 2.コールバックで同期を取れるようにする
- 3.ワンタイムスクリプトで同期の抜け漏れをなくす
- 4.参照を変えて、同期を止める
- 5.レコードやカラムを整理する
完全にサービスを止める場合手順は大きく変わってくる(いくつか楽ができる)し、他のフレームワークなどではまた違うかと思われますが、Railsかつサービスを稼働させたままやろうとすると基本的にこの手順になるかなと思います。
1. テーブルを作成する
ひとまずbooksテーブルとBookモデルを作成しましょう。
ここで、booksテーブルにitem_idカラムを付与し、itemテーブルと関連付けます。
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
class Book< ApplicationRecord
validates :price, presence: true
validates :name, presence: true
validates :item_id, presence: true
belongs_to :item
end
2.コールバックで同期を取れるようにする
今回、まだbooksテーブルにはまだ何もデータが入っていませんし参照もされていません。
そこで、まずはitemsテーブルにレコードが作成・更新・削除された時にbooksテーブルも作成・更新・削除するようにします。
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
で実行できるスクリプトを記述しましょう。
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 が担当します!お楽しみに!