В нашем блоге на Хабре мы не только рассказываем о развитии своего продукта — биллинга для операторов связи «Гидра», но и публикуем материалы о работе с инфраструктурой и использовании технологий из опыта других компаний. Программист и один из руководителей австралийской студии разработки Icelab Тим Райли написал в корпоративном блоге статью о внедрении зависимостей Ruby — мы представляем вашему вниманию адаптированную версию этого материала.

В предыдущей части Райли описывает подход, в котором внедрение зависимостей используется для создания небольших переиспользуемых функциональных объектов, реализующих шаблон «Команда». Реализация оказалась относительно простой, без громоздких кусков кода — всего три работающих вместе объекта. С помощью этого примера объясняется использование не нескольких сотен, а одной или двух зависимостей.

Для того, чтобы внедрение зависимостей работало даже при больших масштабах инфраструктуры, необходимо наличие единственной вещи — контейнера с инверсией управления.

В этом месте Райли приводит код команды CreateArticle, в которой используется внедрение зависимостей:

class CreateArticle
  attr_reader :validate_article, :persist_article

  def initialize(validate_article, persist_article)
    @validate_article = validate_article
    @persist_article = persist_article
  end

  def call(params)
    result = validate_article.call(params)

    if result.success?
      persist_article.call(params)
    end
  end
end

В этой команде используется внедрение зависимости в конструктор для работы с объектами validate_article и persist_article. Здесь объясняется, как можно использовать dry-container (простой потокобезопасный контейнер, предназначенный для использования в качестве половины реализации контейнера с инверсией управления) для того, чтобы зависимости были доступны при необходимости:

require "dry-container"

# Создаем контейнер
class MyContainer
  extend Dry::Container::Mixin
end

# Регистрируем наши объекты
MyContainer.register "validate_article" do
  ValidateArticle.new
end

MyContainer.register "persist_article" do
  PersistArticle.new
end

MyContainer.register "create_article" do
  CreateArticle.new(
    MyContainer["validate_article"],
    MyContainer["persist_article"],
  )
end

# Теперь объект `CreateArticle` доступен к использованию 
MyContainer["create_article"].("title" => "Hello world")

Тим объясняет инверсию управления с помощью аналогии — представьте один большой ассоциативный массив, который управляет доступом к объектам в приложении. В представленном ранее фрагменте кода были зарегистрированы 3 объекта с помощью блоков для их последующего создания при обращении. Отложенное вычисление блоков означает так же, что сохраняется возможность их использования для доступа к другим объектам в контейнере. Таким образом передаются зависимости при создании create_article.

Можно вызвать MyApp::Container["create_article"], и объект будет полностью сконфигурирован и готов к использованию. Имея контейнер, можно зарегистрировать объекты один раз и многократно использовать их в дальнейшем.

dry-container поддерживает объявление объектов без использования пространства имен для того, чтобы облегчить работу с большим количеством объектов. В реальных приложениях чаще всего используется пространство имен вида «articles.validate_article» и «persistence.commands.persist_article» вместо простых идентификаторов, которые можно встретить в описываемом примере.

Все хорошо, однако, в больших приложениях хотелось бы избежать большого количества шаблонного кода. Решить эту задачу можно в два этапа. Первый из них заключается в использовании системы автоматического внедрения зависимостей в объекты. Вот, как это выглядит при использовании dry-auto_inject (механизм, обеспечивающий разрешение зависимостей по требованию):

require "dry-container"
require "dry-auto_inject"

# Создаем контейнер
class MyContainer
  extend Dry::Container::Mixin
end

# В этот раз регистрируем объекты без передачи зависимостей
MyContainer.register "validate_article", -> { ValidateArticle.new }
MyContainer.register "persist_article", -> { PersistArticle.new }
MyContainer.register "create_article", -> { CreateArticle.new }

# Создаем модуль AutoInject для использования контейнера
AutoInject = Dry::AutoInject(MyContainer)

# Внедряем зависимости в CreateArticle
class CreateArticle
  include AutoInject["validate_article", "persist_article"]

  # AutoInject делает доступными объекты `validate_article` and `persist_article` 
  def call(params)
    result = validate_article.call(params)

    if result.success?
      persist_article.call(params)
    end
  end
end

Использование механизма автоматического внедрения позволяет уменьшить объем шаблонного кода при объявлении объектов с контейнером. Исчезает необходимость в разработке списка зависимостей для их передачи методу CreateArticle.new при его объявлении. Вместо этого можно определить зависимости непосредственно в классе. Модуль, подключаемый с помощью AutoInject[*dependencies] определяет методы .new, #initialize и attr_readers, которые «вытягивают» из контейнера зависимости, и позволяют их использовать.

Объявление зависимостей в том месте, где они будут использоваться — это очень мощная рокировка, позволяющая сделать понятными совместно используемые объекты без необходимости определения конструктора. Кроме того появляется возможность простого обновления списка зависимостей — это полезно, поскольку задачи, выполняемые объектом, со временем меняются.

Описанный метод кажется довольно изящным и эффективным, однако стоит подробнее остановиться на способе объявления контейнеров, который использовался в начале последнего примера кода. Такое объявление можно использовать с dry-component, системой, имеющей все необходимые функции управления зависимостями и основанной на dry-container и dry-auto_inject. Эта система сама управляет тем, что необходимо для использования инверсии управления между всеми частями приложения.

В своем материале Райли отдельно фокусируется на одном аспекте этой системы — автоматическом объявлении зависимостей.

Предположим, что три наших объекта определены в файлах lib/validate_article.rb, lib/persist_article.rb и lib/create_article.rb. Все их можно включить в контейнер автоматически, используя специальную настройку в файле верхнего уровня my_app.rb:

require "dry-component"
require "dry/component/container"

class MyApp < Dry::Component::Container
  configure do |config|
    config.root = Pathname(__FILE__).realpath.dirname
    config.auto_register = "lib"
  end

  # Добавляем "lib/" в $LOAD_PATH
  load_paths! "lib"
end

# Запускаем автоматическую регистрацию
MyApp.finalize!

# И теперь все готово к использованию
MyApp["validate_article"].("title" => "Hello world")

Теперь в программе больше не содержится однотипных строк кода, при этом приложение по-прежнему работает. Автоматическая регистрация использует простое преобразование файла и имени класса. Директории преобразуются в пространства имен, таким образом класс Articles::ValidateArticle в файле lib/articles/validate_article.rb будет доступен для разработчика в контейнере articles.validate_article без необходимости каких-либо дополнительных действий. Таким образом обеспечивается удобное преобразование, похожее на преобразование в Ruby on Rails, без возникновения каких-либо проблем с автоматической загрузкой классов.

dry-container, dry-auto_inject, и dry-component — это все, что необходимо для работы с небольшими отдельными компонентами, легко соединяющимися вместе с помощью внедрения зависимостей. Применение этих инструментов упрощает создание приложений и, что даже более важно, облегчает их поддержку, расширение и перепроектирование.

Другие технические статьи от «Латеры»:


Поделиться с друзьями
-->

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


  1. A1MaZ
    22.05.2016 00:13
    +2

    Я для похожих задач начал использовать гем interactor. Доволен как слон!


    1. getElementById
      22.05.2016 12:25

      Аналог interactor — dry-transaction, это следующая ступень.


  1. bulka_by
    22.05.2016 03:24
    +1

    Рекомендую, также, обратить внимание на гем trailblazer.


    1. Kane
      22.05.2016 10:10

      К сожалению, trailblazer хорош только до тех пора, пока не возникает необходимость смотреть в его код.


      1. Tonkonozhenko
        22.05.2016 17:20

        Я хотел попробовать trailblazer. Можете рассказать за ваш опыт его использования?


        1. Kane
          22.05.2016 18:00
          +1

          Я трогал его более полугода назад. Тогда он мне показался очень сырым, АПИ постоянно менялся. При попытке разобраться с проблемами, приходится продираться через довольно небрежные исходники, написанные в очень специфичном стиле. Я такой код могу понимать только в дебагере, да и то с трудом. Судя по отсутствию других core-контрибьютеров, такие сложности не только у меня.


          Вообще, так можно охарактеризовать весь код этого автора (apotonick) — reform, cells. Эти гемы предполагается использовать вместе с trailblazer.


          Еще один момент. Мне показалось, что trailblazer больше ориентирован на работу с формами, вьюхами и т.д.,


          В итоге, trailblazer, я не использую, но использую reform вместе с ActiveAdmin. Я от этого не в восторге, но хорошей альтернативы нет.


          1. Dreyk
            23.05.2016 12:14

            плюс trailblazer в том, что его не обязательно использовать целиком. Я, например, часто использую Cells, и больше ничего


            1. Kane
              23.05.2016 14:11

              Если кому-то trailblazer подходит, очень хорошо. Но в случае, если gui нет, половина его становится не нужна, и обозначенные проблемы начинают иметь больший вес.


              1. Dreyk
                23.05.2016 14:12

                Естественно, если гуи нет — то там только Операции разве что можно использовать


                1. Tab10id
                  24.05.2016 22:59

                  Вообще, операции это основная суть trailblazer'а… Эта та штука которая объединяет уровни авторизацию, форм-обжекты и репрезентеры (классы отвечающие как за сериализацию данных в различные форматы такие как xml, json и тп), так и десериализацию из тех же форматов). Все это вместе позволяет в кратчайшие сроки реализовать красивый API приложения со всеми необходимыми вещами. К примеру, из коробки можно отдавать данные по стандарту JSON-API.
                  Да, можно использовать trailblazer по частям (я так и начинал: cells -> reform -> representable -> disposable и тд), к тому же это позволяет довольно быстро понять как все это должно работать.
                  Потом наступает период когда ты смотришь на свой разросшийся контроллер из-за таких вещей как авторизация и препопуляция данных и понимаешь что в нем этого быть не должно… Вот тут то и понимаешь всю прелесть trailblazer-операций=)
                  Что особенно приятно, операции не делают за тебя никакой магии, к примеру, для авторизации можно использовать собственные policy-классы не имеющие ничего общего с trailblazer'ом.

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


                  1. Kane
                    25.05.2016 00:26

                    Я делаю API в формате JSON, специфика в основном такова, что данные отдаются и редко пишутся. Trailblazer мне никакой пользы не приносит, только проблемы так как приходится ковыряться в этом disposable. Вместо representable использую AMS. Никакой лапши в контроллерах нет. Типичный тест на контроллер выглядит так — проверка кода ответа, проверка что ответ не пустой и проверка что ответ соответствует JSON Schema.


                    Я хочу сказать, что Trailblazer — это очередная штука которая помогает организовать код тем, что его не в состоянии организовать сам.


  1. MpaK999
    22.05.2016 06:46

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


    1. Kane
      22.05.2016 10:11

      Не всегда это возможно, Например, в rails action нельзя передать зависимость явно.


      1. MpaK999
        22.05.2016 12:22

        Но зачем нужна зависимость в Rails Action? Хотя для этих случаев есть before_action callbacks.


        1. Kane
          22.05.2016 13:02
          +1

          Например, в контроллере используется CreateArticle, у которого есть зависимость persist_article, которая делает что-то тяжелое (например, внешний вызов).


          Тестируя контроллер, я хочу тестировать только его зону отвественности и избежать внешнего вызова. Внедрение зависимости здесь может помочь мне внедрить noop имплементацию persist_article.


          1. Dreyk
            23.05.2016 12:16

            что мешает в тесте написать


            MyContainer.register "persist_article" do
              NoOpPersistence.new
            end

            тем не менее, согласен, явное понятней неявного, но это как обычно палка о двух концах


            1. Kane
              23.05.2016 14:09

              Я о том и говорю, что предложенный подход позволяет решать такого рода проблемы.


    1. getElementById
      22.05.2016 12:21

      Похоже тут дело вкуса. В любом случае имена явно записываются в начале класса. Если в дело не вступает наследование, и если файл не слишком большой, то вроде бы не должно быть проблем.


      1. MpaK999
        22.05.2016 12:22

        Похоже, что слишком много «если» :)


        1. getElementById
          22.05.2016 12:30

          Надо пробовать. Я думаю, что зависимости стоит использовать в том файле, где они подключаются, иначе действительно непонятно откуда они взялись. Но в любом случае здесь не больше проблем, чем в обычном руби, тем более, что оба гема (dry-container и dry-auto_inject) очень небольшие. Вот dry-component пока сыроват как мне кажется.


  1. tmn4jq
    23.05.2016 10:12
    +1

    В прицнипе интересно, но очень не нравится конструкция вида:

    include AutoInject["validate_article", "persist_article"]
    


    include подразумевает добавление определенной функциональности к классу/модулю, а тут она не только добавляется, но и сразу совершаются определенные операции. Как по мне, лаконичней потратить несколько строк, но сделать это более наглядным:
    include AutoInject
    
    inject :validate_article
    inject :persist_article
    


    Например, в Java тот же Spring-framework делает нечто похожее:
    @ Inject
    private ArticleValidator validator;
    
    @ Inject
    private ArticlePersisting persisting
    


    (имена переменных не лучшие в этом примере, а пробел между собакой и Inject поставил, чтобы никого не упомянуть))
    Но все это ИМХО, конечно.


    1. Dreyk
      23.05.2016 12:19

      ИМХО, это дело вкуса. Для меня запись


      include AutoInject["validate_article", "persist_article"]

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


      Более того, если все это у вас используется по всему проекту, то легче один раз объяснить новому человеку, что происходит, чем каждый раз писать вместо одной строки 3


    1. getElementById
      23.05.2016 17:09

      AutoInject.[] на самом деле генерирует анонимный модуль, который добавляется к классу. Но я написал такой враппер:

      module Imprint
        Import = Dry::AutoInject(Container)
        KwargsImport = Import.kwargs
      
        class << self
          def inject(target)
            -> *values { target.send(:include, KwargsImport[*values]) }
          end
        end
      
        module Mixin
          def inject
            Imprint.inject(self)
          end
        end
      end
      


      И потом использую так
      
      module Operations
        class Operation
          extend Imprint::Mixin
        end
      
        class AcceptPayment < Operation
      
          inject['repo.account_repo',
                 'repo.bill_repo']
      
          def call(...)
            # ...
          end
        end
      end
      


  1. ufadiz
    28.05.2016 16:20

    Внедрение зависимостей это хорошо, лишь бы это не превратилось в «горе от ума», паттерн ради паттерна.


    1. getElementById
      29.05.2016 02:17

      К счастью, здесь этим не пахнет. У этих библиотек нету и не будет таких целей, хотя некоторые опасаются, начинают пугать джавой, в глаза ее не видев, и так далее. Ключ-значение + небольшая обертка, вот и все. dry-component только чуть посложнее с логической точки зрения, потому что берет на себя логику по загрузке приложения.