DIY DI в Ruby
На Хабре уже была статья, посвящённая Dependency Injection в Ruby, но упор в ней был больше на использование паттерна IoC-container с помощью гемов dry-container и dry-auto_inject. А ведь для использования преимуществ внедрения зависимостей совершенно необязательно городить контейнеры или подключать библиотеки. Сегодня расскажу о том, как по-быстрому реализовать DI своими руками.
Описание подхода
Для чего люди используют DI? Обычно для того, чтобы во время тестов менять поведение кода, избегая вызовов ко внешним сервисам или просто для тестирования объекта в изоляции от окружения. Конечно, DHH говорит, что мы можем застабить Time.now
, и наслаждаться зелёными точками тестов без лишних телодвижений, но не стоит слепо верить всему, что говорит DHH. Лично мне больше нравится точка зрения Piotr Solnica, изложенная в этом посте. Он приводит такой пример:
class Hacker
def self.build(layout = 'us')
new(Keyboard.new(layout: layout))
end
def initialize(keyboard)
@keyboard = keyboard
end
# stuff
end
Параметр keyboard
в конструкторе — и есть внедрение зависимости. Подобный подход позволяет тестировать класс Hacker
, передавая вместо реального инстанса Keyboard
моки. Изоляция, все дела:
describe Hacker do
let(:keyboard) { mock('keyboard') }
it 'writes awesome ruby code' do
hacker = Hacker.new(keyboard)
# some expectations
end
end
Но что мне в нравится примере выше, так это изящный трюк с методом .build
, в котором происходит инициализация keyboard. В обсуждениях DI я видел немало советов, в которых предлагалось инициализацию зависимостей выносить в вызывающий код, например, в контроллеры. Ага, и потом искать по всему проекту вхождения Hacker, чтобы посмотреть, какой конкретно класс используется для клавиатуры, ну-ну. То ли дело .build
: дефолтный usecase на видном месте, ничего не нужно искать.
Тестирование вызывающего кода
Рассмотрим следующий пример:
class ExternalService
def self.build
options = Config.connector_options
new(ExternalServiceConnector.new(options))
end
def initialize(connector)
@connector = connector
end
def accounts
@connector.do_some_api_call
end
end
class SomeController
def index
authorize!
ExternalService.build.accounts
end
end
Видно, что контроллер создаёт ExternalService, используя реальные объекты (хоть это и скрыто в методе ExternalService.build
), чего мы стараемся избежать, внедряя DI. Как справиться с этой ситуацией?
- Не тестировать вызывающий код вообще. Так себе вариант, решил записать его для полноты картины.
Подменять
ExternalService.build
. Фактически то, о чём говорил DHH, но есть один важный момент: заменяя.build
, мы не меняем поведение инстансов класса, только обёртку. Пример на RSpec:connector = instance_double(ExternalServiceConnector, do_some_api_call: []) allow(ExternalService).to receive(:build) { ExternalService.new(connector) }
- Тестировать контроллеры с помощью интеграционных тестов на CI-сервере. Плюсы: тестируется production-код, повышая вероятность того, что потенциальный баг отловят тесты, а не пользователи. Минусы: сложнее тестировать исключительные ситуации («сторонний сервис упал») и не всегда у сторонних сервисов есть аккаунт-песочница, на котором можно без опаски гонять тесты.
- Использовать-таки IoC-контейнеры.
Мне кажется, что наиболее эффективным является сочетание второго и третьего подходов: с помощью второго тестируем исключительные ситуации, с помощью третьего убеждаемся в том, что нет ошибок в коде, который инстанцирует объекты.
Выводы
Несмотря на написанное выше, я не против применения IoC-контейнеров в целом; просто полезно помнить, что существуют альтернативы.
Ссылки, используемые в посте:
- Dependency injection is not a virtue
- The World Needs Another Post About Dependency Injection in Ruby
- Эффективное внедрение зависимостей при масштабировании Ruby-приложений
- dry-container
- dry-auto_inject