Обсудим паттерн «конфигурирования модуля» а так же его характерные черты, приемы реализации и тест-кейсы (требования).

Для начала придумаем наш сферический модуль в ваккууме, какой делает что-нибудь полезное.

В учебном примере не важно, что модуль делает конкретно. Мы не будем писать его функционал, а применим прием (паттерн) конфигурирования модуля (или гема).

Он существует в несколько разных вариантах и реализациях, но это нормально для Ruby с его свободой самовыражения.

Назовем наш модуль MomentalPush, допустим он каким-то образом посылает сообщения пользователям в соцсети.

Конечно, хорошо бы иметь возможность его красиво настроить.

Допустим, у модуля есть настройки: в какие соцсети посылать сообщения, а так же какой email использовать для отправки сообщений по почте, помимо соцсетей.

Стандарт де-факто в конфигурировании гемов и отдельных модулей это применение блока.

Вот так:

MomentalPush.configure do |config|
config.social_networks = [:twitter, :vk, :facebook]
config.sender_email = 'iamservice@gmail.com'
end

Безусловно, количество таких настроек зависит от размера и задач Вашего модуля. Он может требовать две-три, но может и гораздо больше, например 10 настроечных параметров.

Итак, за дело. Я буду использовать тест-инструмент Rspec.

Выработаем самые простые требования к модулю:

Он должен:

  1. Существовать вообще
  2. Иметь метод configure
  3. При присвоении настроечных переменных хранить их и при обращении к конфигурации — возвращать
  4. Хорошо бы иметь настройки по умолчанию если пользователь ввел их не все, или пропустил обязательные
  5. Если программист ошибся при настройке и ввел параметр какой в нашем модуле не предусмотрен, нужно чтобы он немедленно получил леща исключение. Это очень важный момент! Многие упускают его и их решения не гибки — можно вводить в блоке конфигурации какую угодно белиберду и она «молча срабатывает» что потом вызывает трудности при отладке, ловле ошибок если вместо name ошиблись и ввели настройку с названием names например.

Создадим тест на базе этих требований:

require_relative '../lib/momental_push'

describe MomentalPush do
describe '#configure' do

let(:networks) { MomentalPush.config.social_networks }
let(:sender_email) { MomentalPush.config.sender_email }

shared_examples "a true params types" do
it { expect(networks).to be_a(Array) }
it { expect(sender_email).to be_a(String) }
end

context "when true set and get config vars" do
before :each do
MomentalPush.configure do |config|
config.social_networks = [:twitter, :vk, :facebook]
config.sender_email = 'iamservice@gmail.com'
end
end

it_behaves_like "a true params types"

it "must return preconfigured values" do
expect(networks).to eq(%i{twitter vk facebook})
expect(sender_email).to eq('iamservice@gmail.com')
end
end

context "when not configured module" do
it "must return default values" do
MomentalPush.reset
expect(networks).to eq(%i{telegram})
end
it_behaves_like "a true params types"
end

context "when user get unknown attr" do
it "must be exception" do
MomentalPush.reset
expect { MomentalPush.configure { |config| config.my_unknown_param = "yahoo!" } }.to raise_error(NoMethodError)
end
end
end
end

Тест конечно можно еще отрефакторить, нет предела совершенству. Но он достаточно неплох.

Обратите внимание — тестируются типы данных с помощью shared_examples, устранено повторение кода путем общих let-деклараций. Для удобства тестирования (это не обязательно!) введена возможность в решение ресета конфигурации MomentalPush.reset Так же, проверяется случай когда программист вводит какую-нибудь неизвестную модулю переменную.

А теперь код самого решения, собственно сам паттерн «конфигурация модуля»:

module MomentalPush
class << self
attr_accessor :config
end

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

def self.reset
self.config = Config.new
end

class Config
attr_accessor :social_networks, :sender_email

def initialize
@social_networks = [:telegram]
@sender_email = 'noreply@myservice.com'
end
end
end

Что тут нужно пояснить? Во-первых трюк с class << self просто сделан из-за того что модуль не может иметь наследников, но может быть «сам по себе» синглетом. То есть к модулю можно обращаться. Но если бы мы объявили аксесор без раскрытия модуля «на себя» как синглета, то аксесор имели бы классы и обьекты какие примешиваются к модулю, а не он сам статично. По этому используется трюк «раскрытие синглета на себя».

Далее — статик методы конфигурации лениво возвращают конфигурацию (экземпляр встроенного в модуль класса Config к какому с помощью трюка дан доступ извне для обращений) и вызывают yield выброс назад конфигурации в контекст блока конфигурирования.

Так как класс Config имеет четко известные поля и их значения по умолчанию, то модуль соответственно выдает как и нужно в тестовых случаях, NoMethodError исключение при неизвестном параметре так как неизвестный параметр «за кулисами» это обращение к несуществующему методу класса Config. Что нам и нужно.

Есть и альтернативные способы реализации, например без трюка на раскрытие:

module MyModule
DefaultConfig = Struct.new(:name, :per_page) do
def initialize
self.name = 'test'
self.per_page = 10
end
end

def self.configure
@config = DefaultConfig.new
yield(@config) if block_given?
@config
end

def self.config
@config || configure
end
end

Хоть код более читаем и понятен, а вместо аксесора и трюка используется статик переменная модуля, мне больше нравится первый вариант. Хотя это на усмотрение разработчика!

Ruby создан для самовыражения, по этому даже паттерн или прием могут реализоваться по разному.