Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rubyでもっと活用されるべきValue Objectパターン(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

Rubyでもっと活用されるべきValue Objectパターン(翻訳)

RailsEventStore/rails_event_store - GitHub

最近私たちのRailsEventStoreユーザーが、issue #1650を投稿しました。PostGIS拡張を利用しているPostgreSQLデータベースでRailsEventStoreを使いたいが、イベントやストリームで使うテーブルをセットアップするマイグレーションがUnsupportedAdapterで失敗するとのことでした。

これまでRailsEventStoreでは、PostgreSQLアダプタとMySQL2アダプタとSQLiteアダプタをサポートしていました。しかしPostgreSQLでこのPostGIS拡張を使いたい場合は、activerecord-postgis-adapterを追加インストールしたうえでdatabase.ymlにadapter: postgisを設定する必要があります。私たちのコードは、以下で返される値に依存しています。

ActiveRecord::Base.connection.adapter_name.downcase
=> "postgis"

このときの私は「ちょろい修正で済む」と思っていました。PostGISは単なる拡張なので、マイグレーションを生成するときに内部でPostgreSQLと同じように扱う必要があり、同じデータ型を許可する必要があります。

🔗 実際には多くの修正が必要だった

簡単な修正と思いきや、実際には8ファイルもの変更が必要でした(実装4ファイル、テスト4ファイル)。どうも変です。それでは個別の修正を見てみましょう。

VerifyAdapterクラスのSUPPORTED_ADAPTERSリストにpostgisを追加する必要がありました。

  module RubyEventStore
    module ActiveRecord
      UnsupportedAdapter = Class.new(StandardError)

      class VerifyAdapter
-       SUPPORTED_ADAPTERS = %w[mysql2 postgresql sqlite].freeze
+       SUPPORTED_ADAPTERS = %w[mysql2 postgresql postgis sqlite].freeze

        def call(adapter)
          raise UnsupportedAdapter, "Unsupported adapter" unless supported?(adapter)
        end

        private

        private_constant :SUPPORTED_ADAPTERS

        def supported?(adapter)
          SUPPORTED_ADAPTERS.include?(adapter.downcase)
        end
      end
    end
  end

次に、ForeignKeyOnEventIdMigrationGenerator#each_migrationメソッドのcase文も拡張が必要でした。

  module RubyEventStore
    module ActiveRecord
      class ForeignKeyOnEventIdMigrationGenerator
        def call(database_adapter, migration_path)
          VerifyAdapter.new.call(database_adapter)
          each_migration(database_adapter) do |migration_name|
            path = build_path(migration_path, migration_name)
            write_to_file(path, migration_code(database_adapter, migration_name))
          end
        end

        private

        def each_migration(database_adapter, &block)
          case database_adapter
-         when "postgresql"
+         when "postgresql", "postgis"
            [
              'add_foreign_key_on_event_id_to_event_store_events_in_streams',
              'validate_add_foreign_key_on_event_id_to_event_store_events_in_streams'
            ]
          else
            ['add_foreign_key_on_event_id_to_event_store_events_in_streams']
          end.each(&block)
        end

        def absolute_path(path)
          File.expand_path(path, __dir__)
        end

        def migration_code(database_adapter, migration_name)
          migration_template(template_root(database_adapter), migration_name).result_with_hash(migration_version: migration_version)
        end

        def migration_template(template_root, name)
          ERB.new(File.read(File.join(template_root, "#{name}_template.erb")))
        end

        def template_root(database_adapter)
          absolute_path("./templates/#{template_directory(database_adapter)}")
        end

        def template_directory(database_adapter)
          TemplateDirectory.for_adapter(database_adapter)
        end

        def migration_version
          ::ActiveRecord::Migration.current_version
        end

        def timestamp
          Time.now.strftime("%Y%m%d%H%M%S")
        end

        def write_to_file(path, migration_code)
          File.write(path, migration_code)
        end

        def build_path(migration_path, migration_name)
          File.join("#{migration_path}", "#{timestamp}_#{migration_name}.rb")
        end
      end
    end
  end

Railsのマイグレーションジェネレータでも同じ変更が必要でした。

  begin
    require "rails/generators"
  rescue LoadError
  end

  if defined?(Rails::Generators::Base)
    module RubyEventStore
      module ActiveRecord
        class RailsForeignKeyOnEventIdMigrationGenerator < Rails::Generators::Base
          class Error < Thor::Error
          end

          namespace "rails_event_store_active_record:migration_for_foreign_key_on_event_id"

          source_root File.expand_path(File.join(File.dirname(__FILE__), "../generators/templates"))

          def initialize(*args)
            super

            VerifyAdapter.new.call(adapter)
          rescue UnsupportedAdapter => e
            raise Error, e.message
          end

          def create_migration
            case adapter
-           when "postgresql"
+           when "postgresql", "postgis"
              template "#{template_directory}add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
                       "db/migrate/#{timestamp}_add_foreign_key_on_event_id_to_event_store_events_in_streams.rb"
              template "#{template_directory}validate_add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
                       "db/migrate/#{timestamp}_validate_add_foreign_key_on_event_id_to_event_store_events_in_streams.rb"
            else
              template "#{template_directory}add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
                       "db/migrate/#{timestamp}_add_foreign_key_on_event_id_to_event_store_events_in_streams.rb"
            end
          end

          private

          def adapter
            ::ActiveRecord::Base.connection.adapter_name.downcase
          end

          def migration_version
            ::ActiveRecord::Migration.current_version
          end

          def timestamp
            Time.now.strftime("%Y%m%d%H%M%S")
          end

          def template_directory
            TemplateDirectory.for_adapter(adapter)
          end
        end
      end
    end
  end

ここで重要なのは、どちらのマイグレータもVerifyAdapterクラスを使っていたことです(次の2つのマイグレータもそうでした)。

TemplateDirectoryクラスもプリミティブな強迫観念に悩まされており、これもあらゆるマイグレータで使われていました。

  module RubyEventStore
    module ActiveRecord
      class TemplateDirectory
        def self.for_adapter(database_adapter)
          case database_adapter.downcase
-         when "postgresql"
+         when "postgresql", "postgis"
            "postgres/"
          when "mysql2"
            "mysql/"
          end
        end
      end
    end
  end

さらにもうひとつ、VerifyDataTypeForAdapterについてもそうでした。これはVerifyAdapterのコンポジションで、指定のデータベースエンジンで利用可能なデータ型の検証を追加していました。

しかしその後、文字列値のチェックがより具体的なコンテキストで繰り返されていました。

# frozen_string_literal: true

module RubyEventStore
  module ActiveRecord
    InvalidDataTypeForAdapter = Class.new(StandardError)

    class VerifyDataTypeForAdapter
      SUPPORTED_POSTGRES_DATA_TYPES = %w[binary json jsonb].freeze
      SUPPORTED_MYSQL_DATA_TYPES = %w[binary json].freeze
      SUPPORTED_SQLITE_DATA_TYPES = %w[binary].freeze

      def call(adapter, data_type)
        VerifyAdapter.new.call(adapter)
        raise InvalidDataTypeForAdapter, "MySQL2 doesn't support #{data_type}" if is_mysql2?(adapter) && !SUPPORTED_MYSQL_DATA_TYPES.include?(data_type)
        raise InvalidDataTypeForAdapter, "sqlite doesn't support #{data_type}" if is_sqlite?(adapter) && supported_by_sqlite?(data_type)
        raise InvalidDataTypeForAdapter, "PostgreSQL doesn't support #{data_type}" unless supported_by_postgres?(data_type)
      end

      private

      private_constant :SUPPORTED_POSTGRES_DATA_TYPES, :SUPPORTED_MYSQL_DATA_TYPES, :SUPPORTED_SQLITE_DATA_TYPES

      def is_sqlite?(adapter)
        adapter.downcase.eql?("sqlite")
      end

      def is_mysql2?(adapter)
        adapter.downcase.eql?("mysql2")
      end

      def supported_by_sqlite?(data_type)
        !SUPPORTED_SQLITE_DATA_TYPES.include?(data_type)
      end

      def supported_by_postgres?(data_type)
        SUPPORTED_POSTGRES_DATA_TYPES.include?(data_type)
      end
    end
  end
end

🔗 そしてパターンを見出した

  • 最初に、指定のアダプタが許可済みかどうかをチェックする必要がある(PostgreSQL、MySQL、SQLite)
  • 指定のアダプタが特定のデータ型についてデータベースエンジンに適合するかどうかをチェックする必要がある
  • 次に、特定のデータ型向けのマイグレーションを生成すべきかどうかを決定する必要がある
  • マイグレーションテンプレートが置かれているディレクトリはアダプタの種別によって異なる

🔗 やってみよう

これらの作業を専用のValue Objectにすべて任せることで、コード内での決定木を削減し、同じプリミティブを何度でもチェックできるようになります。

以下のような感じになります。

DatabaseAdapter.from_string("postgres")
=> DatabaseAdapter::PostgreSQL.new

DatabaseAdapter.from_string("bazinga")
=> UnsupportedAdapter: "bazinga" (RubyEventStore::ActiveRecord::UnsupportedAdapter)

DatabaseAdapter.from_string("sqlite", "jsonb")
=> SQLite doesn't support "jsonb". Supported types are: binary. (RubyEventStore::ActiveRecord::InvalidDataTypeForAdapter)

何度か試行錯誤した後、最終的に以下の実装が完成しました。

# frozen_string_literal: true

module RubyEventStore
  module ActiveRecord
    UnsupportedAdapter = Class.new(StandardError)
    InvalidDataTypeForAdapter = Class.new(StandardError)

    class DatabaseAdapter
      NONE = Object.new.freeze

      class PostgreSQL < self
        SUPPORTED_DATA_TYPES = %w[binary json jsonb].freeze

        def adapter_name
          "postgresql"
        end

        def template_directory
          "postgres/"
        end
      end

      class MySQL < self
        SUPPORTED_DATA_TYPES = %w[binary json].freeze

        def adapter_name
          "mysql2"
        end

        def template_directory
          "mysql/"
        end
      end

      class SQLite < self
        SUPPORTED_DATA_TYPES = %w[binary].freeze

        def adapter_name
          "sqlite"
        end
      end

      def initialize(adapter_name, data_type)
        raise UnsupportedAdapter if instance_of?(DatabaseAdapter)

        validate_data_type!(data_type)

        @data_type = data_type
      end

      attr_reader :data_type

      def supported_data_types
        self.class::SUPPORTED_DATA_TYPES
      end

      def eql?(other)
        other.is_a?(DatabaseAdapter) && adapter_name.eql?(other.adapter_name)
      end

      alias == eql?

      def hash
        DatabaseAdapter.hash ^ adapter_name.hash
      end

      def template_directory
      end

      def self.from_string(adapter_name, data_type = NONE)
        raise NoMethodError unless eql?(DatabaseAdapter)

        case adapter_name.to_s.downcase
        when "postgresql", "postgis"
          PostgreSQL.new(data_type)
        when "mysql2"
          MySQL.new(data_type)
        when "sqlite"
          SQLite.new(data_type)
        else
          raise UnsupportedAdapter, "Unsupported adapter: #{adapter_name.inspect}"
        end
      end

      private

      def validate_data_type!(data_type)
        if !data_type.eql?(NONE) && !supported_data_types.include?(data_type)
          raise InvalidDataTypeForAdapter,
                "#{class_name} doesn't support #{data_type.inspect}. Supported types are: #{supported_data_types.join(", ")}."
        end
      end

      def class_name
        self.class.name.split("::").last
      end
    end
  end
end

DatabaseAdadpterクラスは、あらゆる個別のアダプタの親クラスのように振る舞います。個別のアダプタにはsupported_data_typesリストが含まれていてクライアントクラスからアクセス可能になっており、選択したデータが指定のデータベースエンジンでサポートされていない場合は適切なエラーメッセージを返します。また、指定したアダプタでtemplate_directoryに設定されている名前も取得できます。

つまり、DatabaseAdapter.from_stringを含む単一のエントリを持つことになります。DatabaseAdapter.from_stringにはadapter_nameと、オプションのdata_typeを渡せます。これらはどちらも個別のアダプタのインスタンス生成時にバリデーションされます。

🔗 成果

以下のユーティリティクラスは削除できます。

  • VerifyAdapter
  • VerifyDataTypeForAdapter
  • TemplateDirectory

処理に必要な情報がValue Objectにすべて含まれているおかげで、4つのクラスと2つのrakeタスクがシンプルになりました。

  • ForeignKeyOnEventIdMigrationGenerator
  module RubyEventStore
    module ActiveRecord
      class ForeignKeyOnEventIdMigrationGenerator
-       def call(database_adapter, migration_path)
-         VerifyAdapter.new.call(database_adapter)
+       def call(database_adapter_name, migration_path)
+         database_adapter = DatabaseAdapter.from_string(database_adapter_name)
          each_migration(database_adapter) do |migration_name|
            path = build_path(migration_path, migration_name)
            write_to_file(path, migration_code(database_adapter, migration_name))

        def each_migration(database_adapter, &block)
          case database_adapter
-         when "postgresql", "postgis"
+         when DatabaseAdapter::PostgreSQL
            [
              'add_foreign_key_on_event_id_to_event_store_events_in_streams',
              'validate_add_foreign_key_on_event_id_to_event_store_events_in_streams'
  • RailsForeignKeyOnEventIdMigrationGenerator
        def initialize(*args)
          super

-         VerifyAdapter.new.call(adapter)
+         @database_adapter = DatabaseAdapter.from_string(adapter_name)
        rescue UnsupportedAdapter => e
          raise Error, e.message
        end

        def create_migration
-         case adapter
-         when "postgresql", "postgis"
-           template "#{template_directory}add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
+         case @database_adapter
+         when DatabaseAdapter::PostgreSQL
+           template "#{@database_adapter.template_directory}add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
                     "db/migrate/#{timestamp}_add_foreign_key_on_event_id_to_event_store_events_in_streams.rb"
-           template "#{template_directory}validate_add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
+           template "#{@database_adapter.template_directory}validate_add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
                     "db/migrate/#{timestamp}_validate_add_foreign_key_on_event_id_to_event_store_events_in_streams.rb"
          else
-           template "#{template_directory}add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
+           template "#{@database_adapter.template_directory}add_foreign_key_on_event_id_to_event_store_events_in_streams_template.erb",
                     "db/migrate/#{timestamp}_add_foreign_key_on_event_id_to_event_store_events_in_streams.rb"
          end
        end

        private

-       def adapter
-         ::ActiveRecord::Base.connection.adapter_name.downcase
+       def adapter_name
+         ::ActiveRecord::Base.connection.adapter_name
        end

-       def template_directory
-         TemplateDirectory.for_adapter(adapter)
-       end
  • MigrationGenerator
  module RubyEventStore
    module ActiveRecord
      class MigrationGenerator
-       DATA_TYPES = %w[binary json jsonb].freeze
-       def call(data_type, database_adapter, migration_path)
-         raise ArgumentError, "Invalid value for data type. Supported for options are: #{DATA_TYPES.join(", ")}." unless DATA_TYPES.include?(data_type)
-         VerifyDataTypeForAdapter.new.call(database_adapter, data_type)
-         migration_code = migration_code(data_type, database_adapter)
+       def call(database_adapter, migration_path)
+         migration_code = migration_code(database_adapter)
          path = build_path(migration_path)
          write_to_file(migration_code, path)
          path

-       def migration_code(data_type, database_adapter)
-         migration_template(template_root(database_adapter), "create_event_store_events").result_with_hash(migration_version: migration_version, data_type: data_type)
+       def migration_code(database_adapter)
+         migration_template(template_root(database_adapter), "create_event_store_events").result_with_hash(migration_version: migration_version, data_type: database_adapter.data_type)
        end

        def template_root(database_adapter)
-         absolute_path("./templates/#{template_directory(database_adapter)}")
-       end
-       def template_directory(database_adapter)
-         TemplateDirectory.for_adapter(database_adapter)
+         absolute_path("./templates/#{database_adapter.template_directory}")
        end
  • RailsMigrationGenerator
      class Error < Thor::Error
      end

-     DATA_TYPES = %w[binary json jsonb].freeze
      namespace "rails_event_store_active_record:migration"

      source_root File.expand_path(File.join(File.dirname(__FILE__), "../generators/templates"))

        type: :string,
        default: "binary",
        desc:
-         "Configure the data type for `data` and `meta data` fields in Postgres migration (options: #{DATA_TYPES.join("/")})"
+         "Configure the data type for `data` and `meta data` fields in migration (options: #{DatabaseAdapter::PostgreSQL.new.supported_data_types.join(", ")})"
      )

      def initialize(*args)
        super

-       if DATA_TYPES.exclude?(data_type)
-         raise Error, "Invalid value for --data-type option. Supported for options are: #{DATA_TYPES.join(", ")}."
-       end
-       VerifyDataTypeForAdapter.new.call(adapter, data_type)
-     rescue InvalidDataTypeForAdapter, UnsupportedAdapter => e
+       @database_adapter = DatabaseAdapter.from_string(adapter_name, data_type)
+     rescue UnsupportedAdapter => e
+       raise Error, e.message
+     rescue InvalidDataTypeForAdapter
+       raise Error,
+             "Invalid value for --data-type option. Supported for options are: #{DatabaseAdapter.from_string(adapter_name).supported_data_types.join(", ")}."
      end

      def create_migration
-       template "#{template_directory}create_event_store_events_template.erb", "db/migrate/#{timestamp}_create_event_store_events.rb"
+       template "#{@database_adapter.template_directory}create_event_store_events_template.erb",
                 "db/migrate/#{timestamp}_create_event_store_events.rb"
      end

      private

-     def template_directory
-       TemplateDirectory.for_adapter(adapter)
-     end

      def data_type
        options.fetch("data_type")
      end

-     def adapter
-       ::ActiveRecord::Base.connection.adapter_name.downcase
+     def adapter_name
+       ::ActiveRecord::Base.connection.adapter_name
      end
  • db:migrations:copyタスクおよびdb:migrations:add_foreign_key_on_event_idタスク
  task "db:migrations:copy" do
    data_type =
      ENV["DATA_TYPE"] || raise("Specify data type (binary, json, jsonb): rake db:migrations:copy DATA_TYPE=json")
    ::ActiveRecord::Base.establish_connection(ENV["DATABASE_URL"])
-   database_adapter = ::ActiveRecord::Base.connection.adapter_name
+   database_adapter =
+     RubyEventStore::ActiveRecord::DatabaseAdapter.from_string(::ActiveRecord::Base.connection.adapter_name, data_type)

    path =
-     RubyEventStore::ActiveRecord::MigrationGenerator.new.call(
-       data_type,
-       database_adapter,
-       ENV["MIGRATION_PATH"] || "db/migrate"
-     )
+     RubyEventStore::ActiveRecord::MigrationGenerator.new.call(database_adapter, ENV["MIGRATION_PATH"] || "db/migrate")

    puts "Migration file created #{path}"
  end
  @@ -30,7 +27,8 @@ desc "Generate migration for adding foreign key on event_store_events_in_streams
  task "db:migrations:add_foreign_key_on_event_id" do
    ::ActiveRecord::Base.establish_connection(ENV["DATABASE_URL"])

-   path = RubyEventStore::ActiveRecord::ForeignKeyOnEventIdMigrationGenerator.new.call(ENV["MIGRATION_PATH"] || "db/migrate")
+   path =
+     RubyEventStore::ActiveRecord::ForeignKeyOnEventIdMigrationGenerator.new.call(ENV["MIGRATION_PATH"] || "db/migrate")

    puts "Migration file created #{path}"
  end

このように、条件分岐を削減して多数のprivateメソッドを削除できました。

上述のクラスのテストも大幅にシンプルなものになり、どのデータ型がどのアダプタと互換性があるかを個別にチェックする代わりに、クラスの中核となる責務のテストに専念できるようになりました。

🔗 アダプタが追加されても大丈夫

間もなくRails 7.1でTrilogyと呼ばれる新しいMySQLアダプタが追加される予定です(訳注: その後7.1でTrilogyが追加されました)。この追加にも対応できたら素晴らしいですよね。

Trilogyへの対応は、コードを1行変更し、テストを1行追加するだけですべて完了しました。私たちが既に優れた抽象化を手にしていたからこそ可能だったのです。

  module RubyEventStore
    module ActiveRecord
      class DatabaseAdapter
        def self.from_string(adapter_name, data_type = NONE)
          raise NoMethodError unless eql?(DatabaseAdapter)
          case adapter_name.to_s.downcase
          when "postgresql", "postgis"
            PostgreSQL.new(data_type)
-         when "mysql2"
+         when "mysql2", "trilogy"
            MySQL.new(data_type)
          when "sqlite"
            SQLite.new(data_type)
          else
            raise UnsupportedAdapter, "Unsupported adapter: #{adapter_name.inspect}"
          end
        end
      end
    end
  end

+ expect(DatabaseAdapter.from_string("Trilogy")).to eql(DatabaseAdapter::MySQL.new)

TrilogyはMySQL向けのアダプタです。私たちにとって両者には何の違いもありませんし、そのように扱いたいと考えています。

🔗 まとめ

この作業全体に興味がおありの方は、RailsEventStoreのプルリク#1671DatabaseAdapterが導入された様子をご覧いただけます。mutant gemのおかげでミューテーションテストのカバレッジは100%になっています。

mbj/mutant - GitHub

Value ObjectはRubyエコシステムでまだまだ活用されていないと私は思っています。そういうわけで別記事で別の例を皆さんに提供したいと考えました。この例は、皆さんがMoneyあたりで目にしているようなValue Objectとは一味違います。

不要な分岐や繰り返されがちな分岐を削減して複雑なコードをシンプルにするための、優れたツールなのです。

関連記事

Rails: Active Recordのfindで怖い思いをした話(翻訳)

Rails: アプリケーションを静的解析で"防弾"する3つの便利ワザ(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。