カラーミーショップ database PHP Ruby

データベースのスキーマ変更と開発環境のデータ管理をいい感じにする

カラーミーショップ database PHP Ruby

こんにちは。EC事業部カラーミーショップグループの小野です。今回はカラーミーショップにおける「データベーススキーマの変更方法」と「開発環境データベースの管理方法」について紹介します。

アプリケーションの構成

カラーミーショップは複数のアプリケーションが1つのデータベースを共有して動作しています。使用言語はPHPとRubyです。PHPで書かれたアプリケーションはサービス開始当初から動き続けており特にWebアプリケーションフレームワークは使用していません。Rubyで書かれたアプリケーションはそれに比べると最近作られたものでRuby on Railsを使用しています。

アプリケーションの構成

この記事の問題領域

データベースのスキーマ情報はアプリケーションの進化とともに変わっていきます。アプリケーションのコードがgitなどのバージョン管理システムで管理されているように、データベースのスキーマ変更や最新のスキーマ情報もバージョン管理すべきでしょう。そのメリットは以下のとおりです。

  • githubのプルリクエストベースで開発ができる
  • 最新のスキーマ状態が常にコミットされている安心感

またアプリケーションを正常に動かすためにはデータベースのスキーマ情報の他に初期データ、マスターデータなどが必要になることも多いかと思います。これらのデータは主に開発環境を構築する際に頻繁に使用することになりますが、データがおかしくなっていると以下のような事が起きて無駄な時間が浪費されてしまいます。

  • 開発環境を作ったけど必要なデータが足りなくてちゃんと動かない
  • マスターデータを更新したけど他の開発メンバーの開発環境には反映されていなかった

Webアプリケーションフレームワークを利用している場合は、この辺の仕組みも機能として備わっているかもしれません。しかしながら何らかの事情でそのような仕組みを利用できていない場合もあるかと思います。この記事ではカラーミーショップではどのようにこれらの問題に対処しているのか紹介していきます。

データベーススキーマの変更方法

さて、データベースのスキーマ管理機能を持たないWebアプリケーションフレームワークを使っていたり、そもそもフレームワークを使っていないアプリケーションを運用している皆さんは、データベースのスキーマをどのように管理しているでしょうか?

  1. 特に管理していない。スキーマの変更が必要な時に単にSQLを実行する
  2. スキーマの変更内容をソースコードと同じようにバージョン管理している
  3. 最新のスキーマをダンプしたファイルを生成してバージョン管理している
  4. スキーマを変更したり巻き戻すための統一的な仕組みがある

ActiveRecord::Tasks::DatabaseTasks を使う

カラーミーショップではそれぞれのアプリケーションとは別にリポジトリを作成し、そこでRuby on RailsのActive Recordマイグレーションの仕組みを使用しています。この仕組みはRailsを利用していなくても単独で使うことができます。 Active Recordマイグレーションの機能は ActiveRecord::Tasks::DatabaseTasksクラスに実装されているので、これを使っていきます。

サンプルコードがあったほうが理解しやすいと思うので、この記事では例としてmy_db_tasksというgemを作成して実装していきながら見ていきましょう。

$ bundle gem my_db_tasks
$ cd my_db_tasks

まずは依存ライブラリにactiverecordとmysql2を追加してから、Rakefileにこんな感じのコードを書いておきます。

my_db_tasks.gemspec:

spec.add_dependency "activerecord", "~> 5.0"
spec.add_dependency “mysql2"

Rakefile:

env = ENV['ENV'] || 'development'
db_config = YAML.load(ERB.new(File.read('config/database.yml')).result)

task :environment do
  ActiveRecord::Base.logger = Logger.new(STDOUT)
  ActiveRecord::Base.schema_format = :sql
  ActiveRecord::Base.establish_connection(db_config[env])
end

namespace :db do
  task :load_config do
    ActiveRecord::Tasks::DatabaseTasks.env = env
    ActiveRecord::Tasks::DatabaseTasks.db_dir = 'db'
    ActiveRecord::Tasks::DatabaseTasks.database_configuration = db_config
    ActiveRecord::Tasks::DatabaseTasks.migrations_paths = ['db/migrate']
    ActiveRecord::Tasks::DatabaseTasks.root = Pathname.new(Dir.pwd)

    if env != 'development'
      ActiveRecord::Base.dump_schema_after_migration = false
    end
  end
end

load "active_record/railties/databases.rake"

config/database.yml:

defaults: &defaults
  adapter: mysql2
  port: 3306

development:
  <<: *defaults
  host: localhost
  username: root
  password:
  database: my_db_tasks_development

これでデータベースタスクが使えるようになりました。rake db:createを叩くとデータベースが作成されます。かんたんですね。

$ bundle exec rake db:create
Created database 'my_db_tasks_development'

マイグレーションファイルの生成

タスクの準備ができたので、次は実際にスキーマを変更するためにマイグレーションファイルを作成していきます。Ruby on Railsにはマイグレーションジェネレータが用意されているためbin/rails generate migrationというコメンドで作成できますが、今回はActiveRecordしか利用していないので使えません。しかし毎回手でファイルを作るのも面倒です。カラーミーショップでは固定のテンプレートを出力するだけの簡単なジェネレータをbin/generate-migrationという名前で用意して運用しています。

$ bin/generate-migration CreateUsers
create  db/migrate/20170407170908_create_users.rb

これでマイグレーションファイルの雛形ができました。試しに以下のようなテーブルを作ることにします。

class CreateUsers < ActiveRecord::Migration
  def up
    execute <<-SQL
      CREATE TABLE users(
        id int not null auto_increment,
        name varchar(255) not null,
        primary key (id)
      ) ENGINE=InnoDB
    SQL
  end

  def down
    execute <<-SQL
      DROP TABLE users
    SQL
  end
end

rake db:migrateを実行して変更を適用しましょう。

$ bundle exec rake db:migrate

これで変更が適用されました。ステータスを見てみましょう。

$ bundle exec rake db:migrate:status
D, [2017-04-07T17:16:56.533945 #76786] DEBUG -- :    (0.2ms)  SELECT `schema_migrations`.`version` FROM `schema_migrations`

database: my_db_tasks_development

Status   Migration ID    Migration Name
--------------------------------------------------
   up     20170407170908  Create users

できています。development環境ではActiveRecord::Base.dump_schema_after_migrationをtrueにしているので、migrateを実行するとdb/structure.sqlにスキーマダンプファイルが生成されます。あとはこれらのファイルをバージョン管理システムにコミットすれば終わりです。

ここまででスキーマの変更と最新のスキーマ情報をバージョン管理できるようになりました。

AUTO_INCREMENTオプションの削除

ActiveRecord::Base.schema_format:sqlにしていると、Rubyのコードではなくそれぞれのデータベース独自の方式でスキーマ情報がダンプされます。MySQLの場合はmysqldumpが使われるわけですが、一点困ったことがあります。テーブルにレコードを幾つか追加したあとに再度ダンプするとテーブルオプションにAUTO_INCREMENT値が含まれてしまうのです。

いくつかレコードを追加して、、

mysql> select * from users ;
+----+--------+
| id | name   |
+----+--------+
|  1 | ichiro |
|  2 | jiro   |
+----+--------+
2 rows in set (0.00 sec)

再度ダンプすると、

$ bundle exec rake db:structure:dump
D, [2017-04-07T17:28:14.434991 #77672] DEBUG -- :    (0.3ms)  SELECT `schema_migrations`.`version` FROM `schema_migrations` ORDER BY version

こういう差分が発生します。

diff --git a/db/structure.sql b/db/structure.sql
index d306da4..d721e6b 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -55,7 +55,7 @@ CREATE TABLE `users` (
   `id` int(11) NOT NULL AUTO_INCREMENT,
   `name` varchar(255) NOT NULL,
   PRIMARY KEY (`id`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;

--
@@ -71,7 +71,7 @@ CREATE TABLE `users` (
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;

--- Dump completed on 2017-04-07 17:15:25
+-- Dump completed on 2017-04-07 17:28:14
INSERT INTO `schema_migrations` (version) VALUES
('20170407170908’);

これでは複数人で開発している場合に、それぞれのローカルにあるデータベースの値が反映されてしまうため、意図しない差分が発生してしまいます。この問題への対処としてAUTO_INCREMENT値を削除するためのタスクを追加することにします。

Rakefile:

namespace :db do
  namespace :structure do
    desc "Remove AUTO_INCREMENT table option from the db/structure.sql file"
    task :remove_auto_increment_table_option do
      ActiveRecord::Base.logger.debug "Remove AUTO_INCREMENT table option from the db/structure.sql file"
      structure = File.read('./db/structure.sql').gsub(/\sAUTO_INCREMENT=\d+/, '')
      File.open('./db/structure.sql', 'w') do |f|
        f.write(structure)
      end
    end
  end
end

そしてそのタスクをdb:structure:dumpの後に呼んでおきます。

Rake::Task['db:structure:dump'].enhance do
  Rake::Task['db:structure:remove_auto_increment_table_option'].invoke
end

これで余計な差分が入り込むのを防ぐことができました。

手順

最終的に以下のような手順でスキーマの変更を行えるようになりました。

  1. ジェネレータを使ってマイグレーションファイルの雛形を生成する
  2. そこにスキーマの変更内容を書く
  3. 開発環境に適用する
  4. 生成したマイグレーションファイル、更新されたスキーマダンプファイルをコミットしてプルリクエストを作成する
  5. レビューに通してLGTMをもらう
  6. インテグレーション環境に適用する
  7. 本番環境に適用する

開発環境データベースの管理方法

カラーミーショップではエンジニアとデザイナーがそれぞれ自分のローカル作業マシン上に開発環境を構築しています。データベースも同様です。ここではその管理方法について紹介します。

データの分類

まず、スキーマ情報以外に管理すべきデータを以下のように定義しました。

  • 初期データ
    • データベース構築時に一度だけ設定するデータ。構築後は人手による更新は行わない。
  • マスターデータ
    • データベース構築時に設定するデータだが開発・運用していく中で更新されるデータ。

初期データの管理方法

これもActiveRecordのマイグレーション機能にシードデータを管理するためのタスクrake db:seedが定義されているのでこれを使っていきます。方法は簡単でload_seedという名前のメソッドを持ったクラスを定義してActiveRecord::Taks::DatabaseTasks.seed_loaderにインスタンスを渡すだけです。

lib/my_db_tasks/seed_loader.rb:

module MyDbTasks
  class SeedLoader
    def initialize(db_config)
      @db_config = db_config
      @logger = Logger.new(STDOUT)
      @logger.info "database: #{@db_config}"
    end

    def load_seed
      @logger.info 'Load seed data start.'
      # ここでシードデータをロードする
      @logger.info 'Load seed data end.'
    end
  end
end

Rakefile:

ActiveRecord::Tasks::DatabaseTasks.seed_loader = MyDbTasks::SeedLoader.new(db_config[env])

これでrake db:seedを実行できるようになりました。

$ bundle exec rake db:seed
I, [2017-04-07T17:44:45.782157 #78647]  INFO -- : database: {"adapter"=>"mysql2", "host"=>"localhost", "username"=>"root", "password"=>nil, "database"=>"my_db_tasks_development", "port"=>3306}
D, [2017-04-07T17:44:45.848587 #78647] DEBUG -- :   ActiveRecord::SchemaMigration Load (0.3ms)  SELECT `schema_migrations`.* FROM `schema_migrations`
I, [2017-04-07T17:44:45.858685 #78647]  INFO -- : Load seed data start.
I, [2017-04-07T17:44:45.858727 #78647]  INFO -- : Load seed data end.

初期データとしてロードするデータはdb/seeds/sqlディレクトリにテーブルごとのファイルを作ってそれを読み込んでいます。また、一部のデータは開発者間でidが重複すると不都合が生じるためテーブルのAUTO_INCREMENT値をランダムに設定するなどの工夫をしています。

マスターデータの管理方法

マスターデータは運用中に更新が発生するデータなので、繰り返し作り直しが発生します。それを支える仕組みとして独自のタスク群を用意することにしました。

$ bundle exec rake -T -A | grep master_data
rake colorme:master_data:create                       # Creates colorme master data
rake colorme:master_data:drop                         # Drops colorme master data
rake colorme:master_data:dump                         # Create master data sql files in db/master_data/sql directory
rake colorme:master_data:load_config                  #
rake colorme:master_data:reset                        #

これらのタスクを使って以下のように運用しています。

  • マスターデータをロードしたい
    • rake colorme:master_data:createを実行する
  • マスターデータを更新したい
    • 自分の開発環境でデータを変更する
    • rake colorme:master_data:dumpを実行する
    • プルリクエストを作ってレビューする
    • masterブランチにマージする
  • マスターデータの更新を自分の環境に取り込みたい
    • rake colorme:master_data:resetを実行する

この運用によってリポジトリにコミットされているデータを「正」として開発者間の同期が取れるようになっています。実装としてはマスターデータとするテーブル群をmysqldumpしてからテーブルごとにファイルを分割してコミットしておき、ロードするときはそれを読み込むだけというシンプルなものにしています。ファイルを分割するのはサイズを小さくして更新時のレビューをしやすくするためです。

(ちなみに本番環境ではこの方法は使用していません。)

セットアップ方法

さて、これらの初期データ、マスターデータの管理方法が出来上がると開発環境のデータベースをセットアップするのはとても簡単になります。

以下の2つのコマンドを実行するだけで完了します。

$ bundle exec rake db:setup
$ bundle exec rake colorme:master_data:create

これはActiveRecordのタスクに則ることができた恩恵です。ちなみにrake db:setupは実際には以下のことを行うコマンドです。

  • データベースの作成(rake db:create)
  • スキーマの読み込み(rake db:structure:load)
  • 初期データの作成(rake db:seed)

今後の展望

データベースのスキーマ変更について現状の課題は、実行に長い時間かかるような変更です。MySQLのバージョンは5.6なのでスキーマ変更中に書き込みをブロックせずに実行できるケースが多いのですが、マスター、スレーブ構成を取っているためスレーブ側でレプリケーション遅延が発生するという問題があります。今後はこのあたりの仕組みも整えていく必要があると考えています。また開発環境データベースの管理方法ももっといい方法があれば改善を続けていくつもりです。

まとめ

この記事ではカラーミーショップの「データベーススキーマの変更方法」と「開発環境データベースの管理方法」について紹介しました。

複数のPHPアプリケーションとRubyアプリケーションが1つのデータベースを共有する構成において、データベース周りの管理を別リポジトリとして切り出してRuby on RailsのActive Recordに実装されているデータベースタスクだけ流用することで、いい感じにタスクのレールに乗った運用ができているのではないかと思います。同じような境遇の方々にとって少しでも参考になれば幸いです。

最後にこの記事の中で作成したサンプルコードを以下に公開しています。実装の詳細が気になる方は参考にしてみてください。

https://github.com/takatoshiono/my_db_tasks