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

в 10:05, , рубрики: dependency injection, dsl, 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 для имен компонентов поскольку это визуально сразу отличает идентификаторы от обычных строковых констант в коде. Плюс, в придачу мы получаем принудительное интернирование, что может быть в этом случае полезно.

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

Автор: xkorpsex

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js