Wantedlyでは、Railsによる開発が行われており、社内で有志のコードリーディングを行っています。
WHY
何故やるのか:コードレベルまで一度追うことで、問題が起こった時の解決までの時間が短縮されます。
また、本質的になにをやっているか理解するとRailsの魔法感を低減できます。
WHAT
なにをするのか:今回はmigrate周りのコードリーディングです。コマンドからSQL文発行までのソースを追います。
HOW
どうやってやるか?実際のコードを読みます。
rake db:migrateを実行するとなにが起こるか
コマンドからSQL文発行までのソースを追いたい。
source file
今回呼んだソースはRails 4.0.13のものです。自分の環境では以下に入っていました。
~/.rbenv/versions/2.1.5/lib/ruby/gems/2.1.0/gems/activerecord-4.0.13
task :migrate (railties/database.rake)
rakeタスクはこのファイルで定義されています。
ここでは、Migrator.migrate
を呼び出すのが主なシゴト。
desc "Migrate the database (options: VERSION=x, VERBOSE=false, SCOPE=blog)."
task :migrate => [:environment, :load_config] do
ActiveRecord::Migration.verbose = ENV["VERBOSE"] ? ENV["VERBOSE"] == "true" : true
ActiveRecord::Migrator.migrate(ActiveRecord::Migrator.migrations_paths, ENV["VERSION"] ? ENV["VERSION"].to_i : nil) do |migration|
ENV["SCOPE"].blank? || (ENV["SCOPE"] == migration.scope)
end
db_namespace['_dump'].invoke
end
Migratorは何なのかと思いながら、Migrator.migrate
と引数のMigrator.migrations_paths
をみる。
Migrator#migrate (activerecord-4.0.13/lib/active_record/migration.rb)
現在のバージョンによって、バージョンを進めるのか、戻すのか、何も変えないのかを決める。
(このファイル内にdef migrate
が3つある)
def migrate(migrations_paths, target_version = nil, &block)
target_versionとcurrent_versionの状態によって、行うアクションを決める。
単純にrake db:migrate
とした場合は、普通1番上のup
を呼ぶ。
現在よりも古いバージョンを指定した場合はdown
を呼ぶ。
when target_version.nil?
up(migrations_paths, target_version, &block)
when current_version == 0 && target_version == 0
[]
when current_version > target_version
down(migrations_paths, target_version, &block)
else
up(migrations_paths, target_version, &block)
end
target_versionとcurrent_versionはどうやって決まるか?
target_versionは指定しなければnil
current_versionは、以下のメソッドにより、繋がっているDBのSchemaMigration.table_nameのversion数値の最大値が用いられる。schme.rbのバージョン値は関係ないんですね。
def current_version
sm_table = schema_migrations_table_name
if Base.connection.table_exists?(sm_table)
get_all_versions.max || 0
else
0
end
end
Migrator.migration_paths
デフォルトでは、['db/migrate']
が帰る。Arrayにto_a
してもArrayのまま変化しない。
Wantedlyでは複数のDBをRailsに繋いでいます。
デフォルトではなく、rake mail_db:migrate
とした場合は以下。
rakeファイルのload_configでmigration_pathsに"mail_db"が入る。
ActiveRecord::Migrator.migrations_paths = ActiveRecord::Tasks::DatabaseTasks.migrations_paths
Migrator.up
現在のmigrationを持ってきて、Migratorオブジェクトを作ってmigrateを呼ぶ。
migrations.select! { |m| yield m } if block_given?
migration.scopeとENV['SCOPE']が違うものを実行しないようにしている。
def up(migrations_paths, target_version = nil)
migrations = migrations(migrations_paths)
migrations.select! { |m| yield m } if block_given?
self.new(:up, migrations, target_version).migrate
end
そもそもmigrationsとは?
Migrator.migrations
migrateディレクトリをみて、マッチするファイルを持ってくる。
- Dir
ワイルドカードの展開を行い、 パターンにマッチするファイル名を文字列の配列として返します。
なので、fileには、指定したmigrateディレクトリ以下のマッチしたファイルが文字列で全部入る。
["db/migrate/20110627144734_create_jobs.rb",
"db/migrate/20110628003714_devise_create_users.rb",
"db/migrate/20110629071604_remove_applicant_from_jobs.rb",
"db/migrate/20110629071620_remove_connector_from_jobs.rb",
...]みたいな感じ。
ここから更に、バージョン数_名前_.rb
に一致するものだけを見て、MigrationProxyなるものを作成する。
これは実際のMigrationを呼び出すもの。
MigrationProxy is used to defer loading of the actual migration classes until they are needed
delegate :migrate, :announce, :write, :disable_ddl_transaction, to: :migration
これがmigrationsになる。
def migrations(paths)
paths = Array(paths)
files = Dir[*paths.map { |p| "#{p}/**/[0-9]*_*.rb" }]
migrations = files.map do |file|
version, name, scope = file.scan(/([0-9]+)_([_a-z0-9]*)\.?([_a-z0-9]*)?\.rb\z/).first
raise IllegalMigrationNameError.new(file) unless version
version = version.to_i
name = name.camelize
MigrationProxy.new(name, version, file, scope)
end
migrations.sort_by(&:version)
end
Migrator#initialize
Migrationを呼び出す人。
migrationsに文字列が一つでもあったらwarnを出してmigrationsを取得し直す。
その後、validateを呼び出してから、Base.connection.initialize_schema_migrations_table
def initialize(direction, migrations, target_version = nil)
raise StandardError.new("This database does not yet support migrations") unless Base.connection.supports_migrations?
@direction = direction
@target_version = target_version
@migrated_versions = nil
if Array(migrations).grep(String).empty?
@migrations = migrations
else
ActiveSupport::Deprecation.warn "instantiate this class with a list of migrations"
@migrations = self.class.migrations(migrations)
end
validate(@migrations)
Base.connection.initialize_schema_migrations_table
end
Base.connection.initialize_schema_migrations_table
ActiveRecord::SchemaMigration.create_table
をやると思っている
これは、テーブルが存在しなければ以下のテーブルを作る。
connection.create_table(table_name, id: false) do |t|
t.column :version, :string, version_options
end
Migrator#migrate
各々のmigrationに対して、execute_migration_in_transaction(migration, @direction)
を呼び出す。
def migrate
if !target && @target_version && @target_version > 0
raise UnknownMigrationVersionError.new(@target_version)
end
running = runnable
if block_given?
message = "block argument to migrate is deprecated, please filter migrations before constructing the migrator"
ActiveSupport::Deprecation.warn message
running.select! { |m| yield m }
end
running.each do |migration|
Base.logger.info "Migrating to #{migration.name} (#{migration.version})" if Base.logger
begin
execute_migration_in_transaction(migration, @direction)
rescue => e
canceled_msg = use_transaction?(migration) ? "this and " : ""
raise StandardError, "An error has occurred, #{canceled_msg}all later migrations canceled:\n\n#{e}", e.backtrace
end
end
end
execute_migration_in_transaction
migration.migrate(direction)を呼び出したあとに、それを記録する。
ddl_transaction(migration)はTransaction処理が可能(Base.connection.supports_ddl_transactions?)ならTransaction処理の中で行う。
def execute_migration_in_transaction(migration, direction)
ddl_transaction(migration) do
migration.migrate(direction)
record_version_state_after_migrating(migration.version)
end
end
Migration#migrate(direction)
ここでは既に、どの変更をどの方向に切り替える、という情報が揃っている。
exec_migration(conn, direction)
を実行。
up
であれば"migrating"として、終了後に実行時の経過時間を表示。
"migrated (2.4854)"みたいに表示する。
# Execute this migration in the named direction
def migrate(direction)
return unless respond_to?(direction)
case direction
when :up then announce "migrating"
when :down then announce "reverting"
end
time = nil
ActiveRecord::Base.connection_pool.with_connection do |conn|
time = Benchmark.measure do
exec_migration(conn, direction)
end
end
case direction
when :up then announce "migrated (%.4fs)" % time.real; write
when :down then announce "reverted (%.4fs)" % time.real; write
end
end
Migration.exec_migration(connection, direction)
if respond_to?(:change)
で changeが定義されていれば、
changeかrevertを実行する。
up, downとかしかなければ、upかdownを実行する。
def exec_migration(conn, direction)
@connection = conn
if respond_to?(:change)
if direction == :down
revert { change }
else
change
end
else
send(direction)
end
ensure
@connection = nil
end
Migration.change
Migrationファイルのchangeメソッドが呼ばれる。我々が書いているあのファイルだ。
ここでは、add_columnが呼ばれることにする。
add_columnは定義されていない。これはmethod_missingによって実行される。
method='add_column'のときは、ActiveRecord::Base.connectionにadd_column
メソッドがあるかを確かめて、
connection.send(method, *arguments, &block)
により実行。
実行されたら、say_with_time
というフランクな名前のメソッドにより、実行時間と共に出力される。
def method_missing(method, *arguments, &block)
arg_list = arguments.map{ |a| a.inspect } * ', '
say_with_time "#{method}(#{arg_list})" do
unless @connection.respond_to? :revert
unless arguments.empty? || [:execute, :enable_extension, :disable_extension].include?(method)
arguments[0] = Migrator.proper_table_name(arguments.first)
arguments[1] = Migrator.proper_table_name(arguments.second) if method == :rename_table
end
end
return super unless connection.respond_to?(method)
connection.send(method, *arguments, &block)
end
end
最後:add_columnはなにをやっているか
ここから先は結構、PostgresSQLとかSQLiteとか固有のものと共通のものがあってごちゃごちゃしている印象。
SchemaStatements.add_column (postgresql/schema_statements.rb)
def add_column(table_name, column_name, type, options = {})
clear_cache!
super
end
SchemaStatements.add_column (abstract/schema_statements.rb)
# Adds a new column to the named table.
# See TableDefinition#column for details of the options you can use.
def add_column(table_name, column_name, type, options = {})
at = create_alter_table table_name
at.add_column(column_name, type, options)
execute schema_creation.accept at
end
alter_tableというものを作っているっぽい。
AlterTable.new create_table_definition(name, false, {})
add_column(name, type, options)だけの情報を持つクラス
def schema_creation
SchemaCreation.new self
end
class SchemaCreation
def accept(o)
m = @cache[o.class] ||= "visit_#{o.class.name.split('::').last}"
send m, o
end
...
end
visit_AlterTableが呼ばれる。
def visit_AlterTable(o)
sql = "ALTER TABLE #{quote_table_name(o.name)} "
sql << o.adds.map { |col| visit_AddColumn col }.join(' ')
end
def visit_AddColumn(o)
sql_type = type_to_sql(o.type.to_sym, o.limit, o.precision, o.scale)
sql = "ADD #{quote_column_name(o.name)} #{sql_type}"
add_column_options!(sql, column_options(o))
end
execute の中で@connection.async_exec(sql)
が呼ばれてSQLが実行される。
おわり
実際にコードを読んでみることで、日常的に打っているコマンドが内部で何をやっているかコードレベルで理解しやすくなりました。
特にバージョンの話とか、migrateファイルのadd_columnみたいなメソッドが、実際はmethod_missingで拾われていて、databaseのコネクションにメソッドが追加されたら自動的に使えるので面白い仕組みだなとか。
Wantedlyの場合は、WantedlyのコードベースでRailsを触るのが初めてというメンバーも多く(自分もJavaとPythonの人でした)、こういった読み会は勉強になります。
他のメンバーの読み方とか、考え方とかを共有できるという意味でもお勧めです。