Dependency Injection с проверкой корректности на Scala средствами языка

Хочу рассказать про свою небольшую библиотеку Dependency Injection на Scala. Проблема которую хотелось решить: возможность протестировать граф зависимостей до их реального конструирования и падать как можно раньше если что-то пошло не так, а также видеть в чем именно ошибка. Это именно то, чего не хватает в замечательной DI-библиотеке Scaldi. При этом хотелось сохранить внешнюю прозрачность синтаксиса и максимально обойтись средствами языка, а не усложнять и влезать в макросы.


Также хочу сразу обратить внимание что я концентрируюсь на DI через конструктор, как на самом простом и идиоматичном способе, не требующем изменений в реализацию классов.


Передать конструктор как функцию в Scala можно при помощи частичного вызова, например:


class A(p0: Int, p1: Int)
Module().bind(new A(_: Int, _: Int))

Запись довольно громоздкая, возможно лучше будет использовать заранее определенные функции, вызывающие конструктор, которые можно передавать явно:


class A(p0: Int, p1: Int)
object A {
  def getInstance(p0: Int, p1: Int) = new A(p0, p1)
}
Module().bind(A.getInstance)

Читаемость такого стиля заметно лучше, так что в примерах ниже постараюсь использовать именно его.


Пример использования


build.sbt:


libraryDependencies += "io.ics" %% "disciple" % "1.2.1"

Imports:


import io.ics.disciple._

Допустим у нас есть некий набор классов, экземпляры которых требуется внедрить друг в друга:


Доменная сущность "Пользователь"


case class User(name: String)

Сервис, принимающий в качестве параметра экземпляр пользователя-админа, имеющий один метод с примитивной реализацией


class UserService(val admin: User) {
  def getUser(name: String) = User(name)
}

Мы хотим иметь экземпляр этого сервиса в виде синглтона — для того чтобы убедиться в том что он создается именно один раз, заведем статический счетчик экземпляров этого класса (для наглядности забудем сейчас о многопоточном выполнении).


object UserService {
  var isCreated: Boolean = false
  def getInstance(admin: User) = {
    isCreated = true
    new UserService(admin)
  }
}

А также условный контроллер, зависимый от этого сервиса


class UserController(service: UserService) {
  def renderUser(name: String): String = {
    val user = service.getUser(name)
    s"User is $user"
  }
}

object UserController {
  def getInstance(service: UserService) = new UserController(service)
}

Типы биндингов


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


Мы также можем добавить к объявлению метод singleton — в этом случае компонент будет создан строго один раз. Метод nonLazy помечает биндинг как неленивый, что значит что этот компонент будет создан при вызове метода build() у модуля. Неленивыми могут быть только компоненты-синглтоны.


Посмотрим как будет выглядеть создание графа зависимостей с помощью библиотеки DIsciple (обратите внимание что порядок объявления биндингов не важен):


val depGraph = Module().
  // Биндинг контроллера, объявляем его как singleton
  bind(UserController.getInstance _).singleton.
  // Биндинг сервиса, отмечаем что его зависимость будет привязана по не только по типу, но и по идентификатору,
  // помечаем биндинг как nonLazy, что значит что его экземпляр будет создан при вызове build().
  forNames('admin).bind(UserService.getInstance).singleton.nonLazy.
  // Компонент админской учетной записи, с идентификатором 'admin
  bind(User("Admin")).byName('admin).
  // Компонент учетной записи пользователя, доступной по идентификатору 'customer. Обратите внимание что
  // два последних компонента имеют один и тот же тип и не могли бы быть идентифицированы только по нему
  bind(User("Jack")).byName('customer).
  // Проверяем структуру и строим конечный граф зависимостей
  build()

Замечание 1: Вы наверное обратили внимание что в случае контроллера мы форсим передачу параметра как функции, а в случае сервиса — нет. Так происходит в связи с тем что существует перегрузка функции bind для by-name функции без аргументов, в связи с чем компилятор не может понять как трактовать функцию без параметров — как объект или как функцию. Буду рад если кто подскажет как исправить эту мелкую неконсистентность.


Использование:


assert(UserService.isCreated) // Проверяем что сервис был создан сразу после вызова build()

println(depGraph[User]('customer)) // Инжектим компонент типа User с именем customer
println(depGraph[UserService].admin) // Проверяем что в сервис была внедрена админская учетка
println(depGraph[UserController].renderUser("George")) // Проверяем что контроллер возвращает строку George

Замечание 2: если один аргумент нужно получить по имени, а другой по типу, то можно использовать оператор *:


case class A(label: String)
case class B(a: A, label: String)
case class C(a: A, b: B, label: String)

val depGraph = Module().
  forNames('labelA).bind { A }.
  forNames(*, 'labelB).bind { B }.
  forNames(*, *, 'labelC).bind { C }.
  bind("instanceA").byName('labelA).
  bind("instanceB").byName('labelB).
  bind("instanceC").byName('labelC).
  build()

Граничные условия


Неполный набор зависимостей


val depGraph = Module().
  bind {
    A("instanceA")
  }.
  bind {
    C(_: A, _: B, "instanceC")
  }.
  build()

В этом примере будет выброшено исключение: IllegalStateException: Not found binding for {Type[io.ics.disciple.B]}. (возможно будет лучше создать иерархию исключений, вместо использования везде IllegalStateException, но пока до этого не дошли руки)


Обнаружение циклической зависимости


case class Dep1(label: String, d: Dep2)
case class Dep2(d: DepCycle)
case class DepCycle(d: Dep1)

Module().
  bind(Dep1("test", _: Dep2)).
  bind(Dep2).
  bind(DepCycle).
  build()

Здесь будет выброшено исключение: IllegalStateException: Dependency graph contains cyclic dependency: ( {Type[io.ics.disciple.DepCycle]} -> {Type[io.ics.disciple.Dep1]} -> {Type[io.ics.disciple.Dep2]} -> {Type[io.ics.disciple.DepCycle]} )


Полиморфические биндинги


По умолчанию компоненты связываются по конечным типам результатов функций, передаваемых в bind(), но часто это не совсем то поведение, которого мы хотим, например если нам нужно забиндить компонент по трейту:


trait Service

class ServiceImpl extends Service

val depGraph =
  Module().
    bind(new ServiceImpl(): Service).
    build()

Под капотом


Постараюсь передать общую концепцию, не вдаваясь в детали реализации. Вызывая метод .bind() мы формируем список пар (DepId, List[Dep]), где DepId это либо описание типа результата, либо оно же + идентификатор:


sealed trait DepId

case class TTId(tpe: Type) extends DepId {
  override def toString: String = s"{Type[$tpe]}"
}

case class NamedId(name: Symbol, tpe: Type) extends DepId {
  override def toString: String = s"{Name[${name.name}], Type[$tpe]}"
}

a Dep представляет собой пару обернутую функцию-конструктор (Injector) для зависимости + список ID, компонент, от которых она зависит сама:


case class Dep[R](f: Injector[R], depIds: List[DepId])

Как часто приходится делать в Scala, для того чтобы сделать перегрузки метода для разного количества аргументов, приходится генерить бойлерплейты. Одно из таких мест — метод bind(). Но, к счастью, плагин sbt-boilerplate делает это занятие чуть менее грустным. Вы просто заключаете повторяющиеся части объявления между квадратными скобками и решетками, и плагин понимает что их нужно повторить, при этом заменяет вся единицы на n, двойки на n+1 и т. д. BindBoilerplate.scala.template. В итоге шаблон получается компактным и устраняется необходимость поддерживать эти огромные простыни вручную.


При вызове метода build() список зависимостей преобразуется в граф (то есть в мапу DepId -> Dep), проверяется на полноту и отсутствие циклических зависимостей при помощи алгоритма DFS, сложность которого оценивается в O(V + E), где V — количество компонент, E — количество зависимостей между ними. Eсли что-то идет не так, выбрасывается исключение, иначе возвращается объект класса DepGraph, который уже можно использовать для получения конечного компонента: depGraph[T] или depGraph[T]('Id) — если нам требуется получить именованный компонент.


Я использовал именно Symbol, а не String для имен компонентов поскольку это визуально сразу отличает идентификаторы от обычных строковых констант в коде. Плюс, в придачу мы получаем принудительное интернирование, что может быть в этом случае полезно.


Более сухое, но подробное и насыщенное примерами описание, а также исходный код здесь

  • +19
  • 4,1k
  • 6
Поделиться публикацией

Похожие публикации

Комментарии 6

    0
    Непонятна необходимость DI фреймворков в Scala. Трейты, имплиситы, ридер-монады чем не угодили? Play Framewor стал использовать Google Guice, обосновывая это тем, что мол в Scala столько встроенных возможностей для DI, что сообщество не может договориться, что лучше, поэтому мы не будем использовать ни одну из них и пойдем Java-way. Что со Scala не так?
      0
      Трейты — видимо речь о cake-паттерне?
      Cake-паттерн заставляет плодить слишком много лишних сущностей (трейтов) для каждого компонента, у меня был опыт его использования в одном средних размеров проекте, удовольствия он приносит мало.
      Имплиситы — сами по себе не делают DI, поскольку остается важным порядок их объявления.
      Reader-monad — это можно сказать родственная идея. Но там тоже нужно учитывать порядок инжекта (поправьте кстати меня, если я недопонял концепцию), есть проблемы с множественными зависимостями. Ну и в целом, скорее всего для них захочется притянуть scalaz, который далеко не всем по душе.

      А вообще конечно же можно и без них, особенно если у вас небольшие проекты.
      А в java-мире до сих пор средствами языка выкрутиться было нельзя (а теперь, с default-реализациями теперь тоже можно делать cake), по этому все просто берут библиотеку, использующую reflection — то есть guice или spring — и не парятся.
        0
        Cake-паттерн заставляет плодить слишком много лишних сущностей (трейтов) для каждого компонента

        Зачем для каждого? Можно для групп компонентов: Thin cake pattern. Или все зависимости объединить в один «контекст»

        Имплиситы — сами по себе не делают DI, поскольку остается важным порядок их объявления.

        Не понял мысль? Какой такой порядок? Проблема имплиситов такая же как и у внедрения через конструктор — если много, то неудобно. Выход как и в предыдущем случае: композиция зависимостей в более крупные объекты (опять получается что-то типа контекста)

        Единственное оправдание использования DI фреймворка пока вижу только для случаев «инжектим всё во всё на всякий случай, потом разберемся» и «мы в java так привыкли»
          0
          Не понял мысль? Какой такой порядок?

          Имплиситы это более удобный способ передачи параметров и не более. С помощью него можно сделать ручной DI, только чуть меньшим количеством знаков.

          Следовательно, вы можете сделать так:
          case class A(v: Int)
          
          case class B(implicit a: A)
          
          case class C(implicit b: B)
          
          
          implicit val a = A(10)
          
          implicit val b = B()
          
          val c = C()
          


          Но вот так уже не можете, так как получите ошибку компиляции:
          implicit val b = B()
          
          implicit val a = A(10)
          
          val c = C()
          

          поскольку в момент объявления b скоуп не содержит никакого имплиситного значения типа A.

          И этим отличается ручной DI от автоматизированного — когда у вас пара классов — создавать их явно очень здравая идея, но если вы создаете в коде кучу компонент, следить за порядком их объявления и создания может стать утомительно.
          Зачем для каждого? Можно для групп компонентов: Thin cake pattern. Или все зависимости объединить в один «контекст»

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

          Я в общем и не выступаю против ручного DI через конструктор. Считаю что в случае микросервисной архитектуры например, использовать какую-то тулу это явный оверинжиниринг.
      0
      Не пробовали https://github.com/adamw/macwire?
        0
        Смотрел на него, но видимо там тоже речь о макросах, да и про проблему циклических зависимостей ничего не написано. Мне было интересно просто попробовать другой взгляд.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое