Я думаю вы знакомы с методом 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