[Из песочницы] 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 для имен компонентов поскольку это визуально сразу отличает идентификаторы от обычных строковых констант в коде. Плюс, в придачу мы получаем принудительное интернирование, что может быть в этом случае полезно.
Более сухое, но подробное и насыщенное примерами описание, а также исходный код здесь