DIY DI в Ruby

cf08761502214ac6b1014d64026935af.png


На Хабре уже была статья, посвящённая 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. Как справиться с этой ситуацией?


  1. Не тестировать вызывающий код вообще. Так себе вариант, решил записать его для полноты картины.
  2. Подменять ExternalService.build. Фактически то, о чём говорил DHH, но есть один важный момент: заменяя .build, мы не меняем поведение инстансов класса, только обёртку. Пример на RSpec:


    connector = instance_double(ExternalServiceConnector, do_some_api_call: [])
    allow(ExternalService).to receive(:build) { ExternalService.new(connector) }

  3. Тестировать контроллеры с помощью интеграционных тестов на CI-сервере. Плюсы: тестируется production-код, повышая вероятность того, что потенциальный баг отловят тесты, а не пользователи. Минусы: сложнее тестировать исключительные ситуации («сторонний сервис упал») и не всегда у сторонних сервисов есть аккаунт-песочница, на котором можно без опаски гонять тесты.
  4. Использовать-таки IoC-контейнеры.

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


Выводы


Несмотря на написанное выше, я не против применения IoC-контейнеров в целом; просто полезно помнить, что существуют альтернативы.


Ссылки, используемые в посте:


  • Dependency injection is not a virtue
  • The World Needs Another Post About Dependency Injection in Ruby
  • Эффективное внедрение зависимостей при масштабировании Ruby-приложений
  • dry-container
  • dry-auto_inject

Комментарии (0)

© Habrahabr.ru