Commandパターンとは
Commandは「命令」という意味で、「命令」をオブジェクトに表したデザインパターンです。命令オブジェクトごとにステータスを保持されることで、複数のコマンドの履歴管理や、複数の命令をまとめて実行・ロールバックすることが容易になります。
Rails の ActiveRecord での Commandパターン
Rails
の ActiveRecord
のマイグレーション機能ではテーブルを追加したり、カラムの変更を行うことができます。本来はSQLを直接実行することでDBに変更を加えますが、マイグレーションは専用のスクリプトファイルを用意し、それを実行します。
マイグレーションの基本動作
マイグレーションファイルの作成はコマンドで実行することができます
rails generate model Cat name:string age:integer
class CreateCats < ActiveRecord::Migration[7.1]
def change
create_table :cats do |t|
t.string :name
t.integer :age
t.timestamps
end
end
end
さらにテーブルを追加したい時も同様にマイグレーションファイルを作成します。
rails generate model Dog name:string age:integer
class CreateDogs < ActiveRecord::Migration[7.1]
def change
create_table :dogs do |t|
t.string :name
t.integer :age
t.timestamps
end
end
end
DBに反映するためにはマイグレーションコマンドを実行します。
rails db:migrate
== 20240316133121 CreateDogs: migrating =======================================
-- create_table(:cats)
-> 0.0157s
== 20240316133121 CreateDogs: migrated (0.0165s) ==============================
== 20240316133130 CreateChicks: migrating =====================================
-- create_table(:dogs)
-> 0.0109s
== 20240316133130 CreateChicks: migrated (0.0110s) ============================
マイグレーション実行されていないファイルをまとめて実行することができます。
また、 実行した内容を一つロールバックできます。
rails db:rollback
== 20240316133146 CreateOwls: reverting =======================================
-- drop_table(:dogs)
-> 0.0112s
== 20240316133146 CreateOwls: reverted (0.0316s) ==============================
仕組み自体はシンプルで、DBに変更を追加したい時、
db/migration/
配下にマイグレーションファイルを作成し、change
メソッドに応答できるActiveRecord::Migration
を継承したクラスを用意するだけ。
これだけで、rails db:migrate
コマンドで、未実行なマイグレーションファイルを全て実行してくれるのです。よくできた仕組みだと思います。
この仕組みについて深ぼってみます。
どのような仕組みで実現されているのかをコードリーディングしてみようと思います。
コードリーディング時の環境
Rails 7.0.2.3
マイグレーションの仕組み超絶ざっくり理解(飛ばしてOK)
rails db:migrate
によってRakeタスクが実行されます。
databases.rake
task migrate: :load_config do
db_configs = ActiveRecord::Base.configurations.configs_for(env_name: ActiveRecord::Tasks::DatabaseTasks.env)
if db_configs.size == 1
ActiveRecord::Tasks::DatabaseTasks.migrate
else
original_db_config = ActiveRecord::Base.connection_db_config
mapped_versions = ActiveRecord::Tasks::DatabaseTasks.db_configs_with_versions(db_configs)
mapped_versions.sort.each do |version, db_configs|
db_configs.each do |db_config|
ActiveRecord::Base.establish_connection(db_config)
ActiveRecord::Tasks::DatabaseTasks.migrate(version)
end
end
end
db_namespace["_dump"].invoke
ensure
ActiveRecord::Base.establish_connection(original_db_config) if original_db_config
end
注目したいのは以下の二行で
- データベース接続する
- 指定されたマイグレーションのバージョンを実行する
ActiveRecord::Base.establish_connection(db_config)
ActiveRecord::Tasks::DatabaseTasks.migrate(version)
DatabaseTasks.migrateについて深掘りしてみる
DatabaseTasks.migrate
def migrate(version = nil)
check_target_version
scope = ENV["SCOPE"]
verbose_was, Migration.verbose = Migration.verbose, verbose?
Base.connection.migration_context.migrate(target_version) do |migration|
if version.blank?
scope.blank? || scope == migration.scope
else
migration.version == version
end
end.tap do |migrations_ran|
Migration.write("No migrations ran. (using #{scope} scope)") if scope.present? && migrations_ran.empty?
end
ActiveRecord::Base.clear_cache!
ensure
Migration.verbose = verbose_was
end
migration_context
は MigrationContext
インスタンスを返却するだけなので
MigrationContext#migrate
が主な仕事
def migration_context # :nodoc:
MigrationContext.new(migrations_paths, schema_migration)
end
MigrationContext#migrate
についてみてみると、バージョン指定していないとup
が実行され、対象バージョンが今より小さいとdown
が実行される。つまり、現状のバージョンからバーションを戻すか進めるかを判定する。
def migrate(target_version = nil, &block)
case
when target_version.nil?
up(target_version, &block)
when current_version == 0 && target_version == 0
[]
when current_version > target_version
down(target_version, &block)
else
up(target_version, &block)
end
end
up
ではmigrations
メソッドによってdb/migration
フォルダ配下にあるマイグレーションファイル(20040316_create_xxx.rb のようなファイル)を全て取得し、migrate
させる。
def up(target_version = nil, &block) # :nodoc:
selected_migrations = if block_given?
migrations.select(&block)
else
migrations
end
Migrator.new(:up, selected_migrations, schema_migration, target_version).migrate
end
migrate
では、runnable
なファイル(未実行などマイグレーション可能なファイル)にフィルタし、マイグレーションを実行する。
def migrate_without_lock
if invalid_target?
raise UnknownMigrationVersionError.new(@target_version)
end
result = runnable.each(&method(:execute_migration_in_transaction))
record_environment
result
end
その後、execute_migration_in_transaction
の内部でmigrate(@direction)
によってexec_migration
が実行される。
ようやくここでchange
が実行される。(長い...😅)
これは、自分で追加したマイグレーションファイルのchange
メソッドのことである。
def exec_migration(conn, direction)
@connection = conn
if respond_to?(:change)
if direction == :down
revert { change }
else
change
end
else
public_send(direction)
end
ensure
@connection = nil
end
しかし、 migrtion.rb
にはchange
メソッド定義したcreate_table
は定義されていない。そのため、同ファイル内のmethod_missing
が実行される。
def method_missing(method, *arguments, &block)
arg_list = arguments.map(&: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] = proper_table_name(arguments.first, table_name_options)
if method == :rename_table ||
(method == :remove_foreign_key && !arguments.second.is_a?(Hash))
arguments[1] = proper_table_name(arguments.second, table_name_options)
end
end
end
return super unless connection.respond_to?(method)
connection.send(method, *arguments, &block)
end
end
connection.send(method, *arguments, &block)
により実行され、say_with_time
によって実行時間が表示される。
マイグレーション要約
- 実際にDBを変更する処理を
db/migration
に分離して追加できる -
rails db:migrate
実行時に未実行のマイグレーションファイルを取得して実行する - 実行結果を履歴ファイルとして残すことで、未実行・実行済ファイルの区別ができる
以下ではup
メソッドとdown
メソッドを用意することでデータベーススキーマを先に進める(up)ことができ、戻す(down)こともできる。
class AddColumnToCat < ActiveRecord::Migration[7.1]
def up
add_column :cat, :owner_id, :integer
end
def down
remove_column :cat, :owner_id
end
end
DBに変更を加えるコマンド(命令)をオブジェクトから分離しているおかげで、DBの変更をしたい時は新しいスクリプトファイル(マイグレーションファイル)を追加するだけで済みます。
既存ファイルを意識しなくていいのです!
こういう便利な機能のおかげで、railsに魔法感を感じてしまいます..😥
コマンドパターンで何が嬉しいのか
拡張性が高い
各コマンドが個別のクラスとして実装されるため、新しいコマンドを追加したり既存のコマンドを変更が容易。
実行タイミングを柔軟に制御できる
コマンドオブジェクトでステータスを管理することで、実行状態から実行タイミングをコントロールしやすい。
履歴の管理
実行されたコマンドの履歴を管理することで、取り消しややり直しやの機能を実装しやすい。
コマンドパターンを簡単なプログラムで表現してみる
猫を管理するプログラムを考える。
猫を追加・削除するコマンドのみを用意し、一気に実行させるとする。
DBクラス
猫の種類を配列で管理するだけのクラス
class Database
attr_reader :data
def initialize
@data = ['雑種', 'サイベリアン']
end
def insert(data)
@data << data
end
def delete(data)
@data.delete(data)
end
end
DBにデータをinsert
, delete
するためのコマンドクラスを用意する
コマンドを実行する時は、execute
と、元に戻すにはrollback
などの一般的なメソッド名で用意する。
# 抽象クラス
class Command
def execute
raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'"
end
def rollback
raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'"
end
end
# データベースへの挿入
class InsertCommand < Command
def initialize(database, data)
@database = database
@data = data
end
def execute
@database.insert(@data)
end
def rollback
@database.delete(@data)
end
end
# データベースから削除
class DeleteCommand < Command
def initialize(database, data)
@database = database
@data = data
end
def execute
@database.delete(@data)
end
def rollback
@database.insert(@data)
end
end
コマンドの管理と、一斉実行とロールバックなどの制御を行うクラスを用意する。
class TransactionManager
def initialize
@commands = []
end
def add_command(command)
@commands << command
end
def execute_commands
@commands.each(&:execute)
end
def rollback_commands
@commands.reverse_each(&:rollback)
end
end
用意ができたので実行してみる ᕦ(ò_óˇ)ᕤ
database = Database.new
# データの追加と削除をコマンドとして定義
insert_command_1 = InsertCommand.new(database, "マンチカン")
insert_command_2 = InsertCommand.new(database, "ラグドール")
delete_command_1 = DeleteCommand.new(database, "サイベリアン")
# トランザクションにコマンドを追加
transaction_manager = TransactionManager.new
transaction_manager.add_command(insert_command_1)
transaction_manager.add_command(insert_command_2)
transaction_manager.add_command(delete_command_1)
# 実行
transaction_manager.execute_commands
puts database.data
# => 雑種 マンチカン ラグドール
# ロールバック
transaction_manager.rollback_commands
puts database.data
# => 雑種 サイベリアン
上記のようにコマンドをクラスとして切り離すことで、コマンドの処理を閉じ込めることができました。
次に、コマンドが追加される場合(機能追加)を想定してみる。
例えば、猫の名前を変更したい要求が出てきました。
update
専用のコマンドを用意してみましょう。
class UpdateCommand < Command
def initialize(database, old_data, new_data)
@database = database
@old_data = old_data
@new_data = new_data
end
def execute
@database.update(@old_data, @new_data)
end
def rollback
@database.update(@new_data, @old_data)
end
end
これを呼び出せるようにデータベースクラスにもロジックを追加する
# データベースの操作を行うクラス
class Database
attr_reader :data
def initialize
@data = ['雑種', 'サイベリアン']
end
def insert(data)
@data << data
end
def delete(data)
@data.delete(data)
end
# 以下を追加
def update(old_data, new_data)
index = @data.index(old_data)
@data[index] = new_data if index
end
end
実行してみる
# データベースへの操作を定義
database = Database.new
# データの更新をコマンドとして定義
update_command_1 = UpdateCommand.new(database, "雑種", "雑種_更新")
# トランザクションにコマンドを追加
transaction_manager = TransactionManager.new
transaction_manager.add_command(update_command_1)
transaction_manager.execute_commands
puts database.data
# => 雑種_更新, サイベリアン
# ロールバック
transaction_manager.rollback_commands
puts database.data
# => 雑種, サイベリアン
コマンドをオブジェクトとして切り出していることで、処理(コマンド)を追加する際に 既存のロジックを変更するのではなく、新しいクラスを追加したり、メソッドを追加する だけでよくなっています。
また、コマンドごとにクラスを分けているので、既存のロジックに変更が生じてもその場所が一目瞭然です。
どんな時にこのパターンが採用しやすいか
複数のタスクを一括で実行したい時
バッチ処理の実装:
大量のジョブを処理する場合や、バッチ処理を途中で中断して再開する場合に、各処理をコマンドとして扱うことで管理がしやすそう。
ダウンロード機能
複数のコンテンツをDLする時にダウンロードの開始、停止、取り消し、やり直しなどの操作を柔軟に管理できそう。
etc...
懸念
コマンドパターン超便利だからどんどん使っていこう! となるわけではなく...
これはデザパタ全般に共通するのですが、「ハンマーを持つ人にはすべてが釘に見える」 という諺がある通り、デザパタを適用することが目的になると本末転倒なわけで、容量用法を守って使うことが必要です。
コマンドパターンで耐える想定を予め明確にしておきたいですね。
最後に
ここまで見ていただきありがとうございます。
Rails
のマイグレーションを潜ることで、Rails
の魔法感も多少拭えました。
それに、その設計にコマンドパターンが使用されていることを学ぶことができました。