Конфигурируем Ruby модуль

в 7:01, , рубрики: configuration, gem, module, ruby

Я думаю вы знакомы с методом configure, который многие гемы предоставляют для конфигурации. Например конфигурация carrierwave:

CarrierWave.configure do |config|
  config.storage = :file
  config.enable_processing = false
end

Как реализовать это в своем модуле?

Быстро и грязно

Начнем с падающих тестов.

# configure.rb
require 'minitest/autorun'

class ConfigurationTest < MiniTest::Test
  def test_configure_block
    MyModule.configure do |config|
      config.name = "TestName"
      config.per_page = 25
    end

    assert_equal "TestName", MyModule.config.name
    assert_equal 25, MyModule.config.per_page

    assert_equal "TestName", MyModule.config[:name]
    assert_equal 25, MyModule.config[:per_page]
  end
end

➜  Projects  ruby configure.rb
Run options: --seed 25758

# Running:

E

Finished in 0.001166s, 857.6329 runs/s, 0.0000 assertions/s.

  1) Error:
ConfigurationTest#test_configure_block:
NameError: uninitialized constant ConfigurationTest::MyModule
    configure.rb:5:in `test_configure_block'

1 runs, 0 assertions, 0 failures, 1 errors, 0 skips

Теперь, когда у нас есть падающие тесты приступим к реализации функциональности. Прежде всего объявим модуль, содержащий метод configure.

module MyModule
  def self.configure

  end
end

Нам нужно место для хранения нашей конфигурации. Я думаю переменная модуля хорошо подойдет для этого.

module MyModule
  def self.configure
    self.config ||= {}
  end

  def self.config
    @config
  end

  private

  def self.config=(value)
    @config = value
  end
end

Здесь есть проблема. Мы не сможем хранить конфигурацию в хеше. Пока я заменю хеш на OpenStruct, который соответствует функциональности, которую мы собираемся получить в конечном счете. После этого я уже могу вызвать блок внутри метода и передать ему хранилище в качестве аргумента.

require 'minitest/autorun'
require 'ostruct'

module MyModule
  def self.configure
    self.config ||= OpenStruct.new
    yield(self.config)
  end

  def self.config
    @config
  end

  private

  def self.config=(value)
    @config = value
  end
end

Нужная функциональность готова. Тесты проходят.

➜  Projects  ruby configure.rb
Run options: --seed 8967

# Running:

.

Finished in 0.001607s, 622.2775 runs/s, 2489.1101 assertions/s.

1 runs, 4 assertions, 0 failures, 0 errors, 0 skips

Рефакторинг

Пришло время провести рефакторинг этого решения. Сходу видны две проблемы:

  • Мы можем хранить что угодно внутри нашей конфигурации. Набор методов которым мы можем передать значение ничем не ограничен. Это не круто для конфигурации, потому что это прячет ошибки от пользователя. Если пользователь совершит ошибку в названии конфигурационного метода, мы должны немедленно дать ему знать об этом, выбросив исключение.
  • OpenStruct не очень хорошая идея для продакшн-кода. Он намного медленнее чем обычный Struct или класс и использует намного больше памяти.

Добавим тесты, чтобы быть увереными, что при вызове несуществующего конфигурационного методы мы получим исключение.

def test_set_not_exists_attribute
  assert_raises NoMethodError do
    MyModule.configure do |config|
      config.unknown_attribute = "TestName"
    end
  end
end

def test_get_not_exists_attribute
  assert_raises NoMethodError do
    MyModule.config.unknown_attribute
  end
end

У нас есть два способа исправить это. Первый — использовать Struct с белым списком доступных конфигурационных методов.

module MyModule
  CONFIG_ATTRIBUTES = %i(name per_page)

  def self.configure
    self.config ||= Struct.new(*CONFIG_ATTRIBUTES).new
    yield(self.config)
  end

  def self.config
    @config
  end

  private

  def self.config=(value)
    @config = value
  end
end

Все выглядит отлично. Тесты проходят, код простой и читаемый. Но я забыл одну важную деталь. Конфигурационные значения по-умолчанию. Для них нужно добавить еще один тест.

def test_default_values
  MyModule.configure do |config|
    config.name = "TestName"
  end

  assert_equal 10, MyModule.config.per_page
end

Чтобы избежать перезаписывания конфигурационных значений в разных тестах нужно добавить сброс предыдущей конфигурации перед запуском каждого теста. Я добавлю метод сброса прямо в тестовом классе, потому что он нужен только для тестовых нужд и нет необходимости делать его частью публичного API.

module ::MyModule
  def self.reset
    self.config = nil
  end
end

def setup
  MyModule.reset
end

Вернемся к решению проблемы со значениями по-умолчанию. Простейшее решение будет выглядеть так:

self.config ||= begin
  config = Struct.new(*CONFIG_ATTRIBUTES).new
  config.per_page = 10
  config
end

Хм, код начинает попахивать. Значения по-умолчанию могут быть намного сложнее. Такой код будет сложно поддерживать. Я думаю мы можем сделать лучше. Давайте заменим Struct на класс. В классе мы можем устанавливать значения по-умолчанию прямо в инициализаторе. Такой код будет легко читать и расширять.

module MyModule
  class Configuration
    attr_accessor :name, :per_page

    def initialize
      @per_page = 10
    end

    def [](value)
      self.public_send(value)
    end
  end

  def self.configure
    self.config ||= Configuration.new
    yield(self.config)
  end

  def self.config
    @config
  end

  private

  def self.config=(value)
    @config = value
  end
end

Мне нравится это решение. Оно все еще очень простое и читаемое. Оно также достаточно гибкое. Мы можем устанавливать сложные значения по-умолчанию и при необходимости выносить их в отдельные методы. Мы также имеем два способа получать конфигурационные значения: с помощью метода и через subscript.

Это все, чем я хотел поделиться сегодня. Исходники доступны здесь: goo.gl/feCwCC

Автор: BloodyHistory

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js