LoginSignup
10
3

【Rails・デザパタ】マイグレーションで学ぶCommandパターン

Posted at

Commandパターンとは

Commandは「命令」という意味で、「命令」をオブジェクトに表したデザインパターンです。命令オブジェクトごとにステータスを保持されることで、複数のコマンドの履歴管理や、複数の命令をまとめて実行・ロールバックすることが容易になります。

Rails の ActiveRecord での Commandパターン

RailsActiveRecord のマイグレーション機能ではテーブルを追加したり、カラムの変更を行うことができます。本来はSQLを直接実行することでDBに変更を加えますが、マイグレーションは専用のスクリプトファイルを用意し、それを実行します。

マイグレーションの基本動作

マイグレーションファイルの作成はコマンドで実行することができます

rails generate model Cat name:string age:integer
db/migration/20240315_create_cats.rb
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
db/migration/20240315_create_dogs.rb
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

activerecord-7.0.2.3/lib/active_record/railties/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

activerecord-7.0.2.3/lib/active_record/tasks/database_tasks.rb
      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_contextMigrationContext インスタンスを返却するだけなので
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メソッドのことである。

migrtion.rb
    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が実行される。

migration.rb
    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の魔法感も多少拭えました。
それに、その設計にコマンドパターンが使用されていることを学ぶことができました。

10
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
3