Эта статья не про какую-то готовую библиотеку (я накидал реализацию с тестами за пару дней, и, думаю, читателям не составит труда сделать то же самое), а, скорее, попытка презентовать новую идею. Которая с моей точки зрения выглядит достаточно интересной.

Одна из проблем при разработке сколь-либо большого проекта — это проблема зависимостей. Состоящая из
— где взять нужный экземпляр объекта, если он был создан «наверху», а нужен глубоко внутри иерархии вызовов методов?
— как управлять получением этого экземпляра, чтобы можно было подставлять другие реализации? Прежде всего актуально для тестов
— как это сделать таким образом, чтобы любой кусок кода можно было запустить без долгой пляски с бубном над настройками фреймворка, реализующего п.1 и п.2?



Чтобы избежать обвинений в велосипедостроении и прочих смертных грехах, давайте я изложу своё представление о хорошем коде и хороших библиотеках. Если оно не совпадает с вашим, пожалуйста, воздержитесь от комментирования. Вкратце: Библиотеки должны или решать одну сложную, но конретную задачу, или быть достаточно малы по объему, чтобы изучение исходников было проще, чем чтение документации. Фреймворки предназначены для людей, которые ищут какую-то магию, чтобы оно «само работало». При наличии опыта, практичнее написать свою, заточенную под конкретную задачу, реализацю, скомпоновав реализацию из узкозаточенных, качественных библиотек, чем изучать многомегабайтные исходники фреймворка, написанного «для всех».

Spring я даже рассматривать не буду, времена, когда он считался легковесной альтернативой J2EE, давно прошли. Стандартный скаловский подход со статическими переменными, завернутыми в `object`-ы, я не понимаю: нетестируемо и сложно конфигурируемо. Cake pattern страдает теми же недостатками. Остается Guice и аналогичные библиотеки.

Что насчет Guice? Я обожаю Guice. Практически неинтрузивен, конфигурация модульна и отделена от рабочего кода, возможность подменять отдельные объекты при инициализации инжектора. НО:
— по дефолту, новые объекты создаются одноразовыми, а не синглтонами. Кто это придумал? Кому еще не надоело каждый раз явно указывать скоуп?
— рефлекшн. Негативно сказывается на времени старта приложения, что очень, очень нехорошо во времена sbt-revolver.
— композиция и переопределение для модулей сделаны не так чтобы удобно.
— Not Invented Here (шутка)

Итак идея: хранить готовый Injector в ThreadLocal переменной и использовать статический метод для получения экземпляра. Примерно так:

Много кода
/**
  * Marker interface for custom injector keys, useful to encode object type into the key.
  *
  * @tparam T type of object returned by this key
  */
trait InjectorKey[T]

/**
  * The injector - main interface used to bind code to this injector and access stored objects directly
  */
trait Injector {

  /**
    * Bind this injector to current thread.
    * @param func code that will be bound to this injector
    * @tparam T return type
    * @return value, returned by func
    */
  def let[T](func: => T): T

  def getInstance[T](classTag: ClassTag[T]): Option[T]

  def getInstance[T](key: Any, classTag: ClassTag[T]): Option[T]

  /**
    * Initialize all known dependencies eagerly. Good for production mode and validation of dependencies.
    */
  def eagerInit(): Unit
}

object Injector {
  private[depend] val context = new ThreadLocal[Injector]()

  /**
    * Get object instance by type. Fails if this type cannot be resolved to single instance
    *
    * @param classTag class tag
    * @tparam T type of value to return
    * @return value
    */
  def inject[T](implicit classTag: ClassTag[T]): T =
    injector.getInstance(classTag).getOrElse {
      throw new InjectorException("No instance registered for " + classTag)
    }

  /**
    * Get object instance by type and some type-assisted key. Useful if you have multiple instances of the same type.
    *
    * @param key key, used to identify object
    * @param classTag class tag
    * @tparam T type of value to return
    * @return value
    */
  def inject[T](key: InjectorKey[T])(implicit classTag: ClassTag[T]): T =
    injector.getInstance(key, classTag).getOrElse {
      throw new InjectorException("No instance registered for " + classTag + ", key=" + key)
    }

  /**
    * Get object instance by type and some  key. Useful if you have multiple instances of the same type.
    *
    * @param key key, used to identify object
    * @param classTag class tag
    * @tparam T type of value to return
    * @return value
    */
  def inject[T](key: Any)(implicit classTag: ClassTag[T]): T =
    injector.getInstance(key, classTag).getOrElse {
      throw new InjectorException("No instance registered for " + classTag + ", key=" + key)
    }

  /**
    * Return current injector
    * @return current injector or exception
    */
  def injector: Injector = {
    val r = context.get()
    if (r == null) {
      throw new IllegalStateException("There is no injector in current context. Forgot to run Injector.let for this thread?")
    }
    r
  }

}



Реализация инжектора представляет из себя простой ассоциативный массив [тип объекта] -> [лямбда, возвращающая экземпляр] плюс кеш для синглтонов.

В коде, которому нужны зависимости, используем inject[Type], чтобы получить объект. Разумеется, лучше всего использовать его в качестве дефолтных значений параметров конструктора.

Что это дает?
— если нужен не синглтон, а объект, то его можно создать просто через new MyClass().
— поддержка скоупов не нужна — текущий инжектор из ThreadLocal можно завернуть в делегат, который сначала запрашивает объекты из инжектора уровня сессии
— никакого рефлекшна
— инжектор можно использовать в доменных объектах! Я думаю, это отличная новость для всех любителей зашивать логику в доменную модель.
— В любом месте кода (и особенно тестов!) можно легко заменить инжектор, получая нужное поведение

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

Тесты, они же примеры использования
class InjectorTest extends FunSuite {
  import Injector.inject

  test("basic query by type") {

    val i = Injector.newModule()
      .bind[String]("hello")
      .injector()

    i.let {
      assert(inject[String] == "hello")

      //no object of type Date
      intercept[InjectorException] {
        inject[Date]
      }

      //we have no keys
      intercept[InjectorException] {
        inject[String]("hello")
      }
    }
  }

  test("objects are singletons") {
    class A

    val i = Injector.newModule()
      .bind[Date](new Date())
      .bind[A]("key")(new A)
      .injector()

    i.let {
      assert(inject[Date] eq inject[Date])
      assert(inject[A]("key") eq inject[A]("key"))
    }
  }

  test("query by key works") {
    val i = Injector.newModule()
      .bind[Date]("key1")(new Date(1))
      .bind[Date]("key2")(new Date(2))
      .injector()

    i.let {
      assert(inject[Date]("key1") == new Date(1))
      assert(inject[Date]("key2") == new Date(2))

      intercept[InjectorException] {
        inject[String]("key1")
      }

      intercept[InjectorException] {
        inject[Date]("key3")
      }
    }
  }

  test("object cannot be bound twice") {
    intercept[InjectorException] {
      Injector.newModule()
      .bind[Date](new Date(0))
      .bind[Date](new Date(1))
    }
  }

  test("Binding with dependencies works") {
    class A
    class B
    case class C(a: A, b: B)

    val i = Injector.newModule()
      .bind[C](C(inject[A], inject[B]))
      .bind[A](new A)
      .bind[B](new B)
      .injector()

    i.let {
      assert(inject[C].a eq inject[A])
      assert(inject[C].b eq inject[B])
    }

  }

  test("Binding with cyclic dependencies does not work") {
    case class A(c: C)
    class B
    case class C(a: A, b: B)

    val i = Injector.newModule()
      .bind[C]{
      C(inject[A], inject[B])
    }
      .bind[A](A(inject[C]))
      .bind[B](new B)
      .injector()

    i.let {
      intercept[InjectorException] {
        inject[C]
      }
    }
  }

  test("modularization") {
    class A
    class B
    case class C(a: A, b: B)

    val m1 = Injector.newModule().bind[C](C(inject[A], inject[B]))

    val m2 = Injector.newModule()
      .bind[A](new A)
      .bind[B](new B)

    m1.injector().let {
      intercept[InjectorException] {
        inject[C]
      }
    }

    (m1 + m2).injector().let {
      inject[C]
    }
  }

  test("It is possible to inject primitive type") {
    val i = Injector.newModule()
      .bind[Int](42)
      .injector()

    i.let {
      assert(inject[Int] == 42)
    }
  }
}




Что думаете?

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


  1. nehaev
    03.03.2016 14:35

    > Стандартный скаловский подход со статическими переменными, завернутыми в `object`-ы, я не понимаю: нетестируемо и сложно конфигурируемо. Cake pattern страдает теми же недостатками.

    Нельзя ли как-нибудь развить эту мысль? Насколько я понимаю, стандартный скаловский подход — это как раз cake pattern. И главный его плюс по сравнению с guice-подобными фреймворками в том, что корректность конфигурации проверяется в момент компиляции, а не в рантайме.


  1. bromzh
    03.03.2016 14:39
    +1

    А где тут DI? Что внедряется?
    Самый большой косяк — ручное инстанциирование внедряемых объектов. DI на то и DI, чтобы сам всё внедрял. Далее, в каждый модуль, желающий получить зависимость нужно явно импортировать инстанс инжектора. В итоге, это скорее просто ServiceLocator.
    И как внедрять зависимости в конструкторы/сеттеры?

    Ну и ещё вопросы:
    1) Ничто же не мешает сделать .bind[A](new B), т.е. нет проверки типов. Или есть?
    2) ClassTag лежит в пакете scala.reflect, так что я не уверен, что рефлекшена нет.


    1. Scf
      03.03.2016 15:26

      нет, не ручное инстанциирование:

      trait ServiceA
      trait LogFactory
      class ServiceB(serviceA: ServiceA = inject[ServiceA]) {
      private[this] val log = inject[LogFactory].getLogger(getClass())
      }
      


      Импортируется не инстанс, импортируется статическая функция inject. Инстанс берется из ThreadLocal.


      1. bromzh
        03.03.2016 16:29

        Нет, я не про "внедрение", а про это: .bind[Date](new Date(0)). Это совсем не DI. Тут приходится вручную создавать и настраивать внедряемое. Далее, чтобы получить инстанс нужно вызвать метод. И при этом, нужно указать тип как у поля/аргумента, так и у ключа в методе inject. Это уже ServiceLocator, который антипаттерн. Ну и даже то, что импортируется статическая функция, не меняет в принципе ничего. Потребителям всё равно нужно самим импортировать "инжектор", самим же его вызвать и самим сохранить в поле, например. Это не внедрение.
        В случае DI достаточно для интерфейса сервиса указать (каким-то образом) только сам класс реализации. Создавать и настраивать экземпляры должен DI-контейнер. Т.е. должно быть как-то так: .bind[A](AImpl). А конфигурация делается через провайдеры или в коллбеках жизненного цикла внедряемого (типа как делается через @PostConstruct в CDI). Ну и конечно само внедрение: DI (обычно) берёт код, в который надо что-то внедрить, находит точки инъекции и сам внедряет нужный инстанс требуемой зависимости. Со стороны потребителя ничего быть не должно. Потребитель просто должен как-то указать, что требуется зависимость (например, через аннотации или через аргументы конструктора).

        Далее, ничего не ясно с ко/контр-вариантностью типов.
        Например, есть такая иерархия:

        trait ServiceA
        trait ServiceB extends ServiceA
        class ServiceAImpl extends ServiceA
        class ServiceBImpl extends ServiceB

        Можно ли:
        1) Сделать так: .bind[ServiceA](new ServiceAImpl)
        2) Сделать так: .bind[ServiceA](new ServiceBImpl)
        3) Сделать так: val service: ServiceA = inject[ServiceB]
        И не понятно, как разруливаются генерики.


        1. Scf
          03.03.2016 17:23

          параметр bind — это не значение, а функция. т.е. guice-like .bind[A](AImpl) эквивалентен .bind[A](new AImpl()) что эквивалентно .bind[A](() => new AImpl). bind — это ленивая инициализация, хотя она так и не выглядит. Зависимости создаваемого класса резолвятся тоже в рантайме. В Guice нужно указывать аннотацией, где брать объекты для полей конструктора, в моем случае вместо аннотации работает метод inject[,,,]

          на все три вопроса — да. генерики разруливаются просто — через implicit ClassTag. Эта фича скалы позволяет знать тип дженерика в момент компиляции. bind выглядит вот так:
          def bind[T](factory: => T)(implicit classTag: ClassTag[T]): Module

          можно даже так:

          .bind[ServiceA] {
            val a0 = inject[ServiceA0]
            new ServiceAImpl(a0)
          }

          объявление биндинга для ServiceA0 может быть совсем в другом месте, т.к. лямбда вызывается только при необходимости создания объекта с типом ServiceA


    1. Scf
      03.03.2016 15:32

      По вопросам:
      1) bind[] разумеется типизирован, это же Scala
      2) в скале есть compile-time рефлекшн, это именно он.


  1. bromzh
    03.03.2016 15:11

    если нужен не синглтон, а объект, то его можно создать просто через new MyClass().

    Вот есть интерфейс/трейт и 2 его реализации:

    trait FooService {
        def foo(): String
    }
    
    class FooImpl extends FooService { ... }
    class FakeFooImpl extends FooService { ... }

    Если мне нужен не синглтон-сервис, то как сделать так, как вы предлагаете? new FooService()? Т.е. в этом случае нужно вручную импортировать реализацию. Да, так тоже можно делать, только причём тогда DI? Синглтоны ведь тоже можно ручками создавать, зачем тогда "инжектор"? Ваш "инжектор" всё равно нужно явно импортировать и получать инстанс зависимости тоже надо явно. Зачем всё это, если можно тупо импортировать синглтон и получить его инстанс?
    Смысл DI же совсем не в этом.


    1. Scf
      03.03.2016 15:29

      Согласен, если нужен не-синглтон объект, спрятанный за интерфейсом, то придется биндить фабрику для этого объекта. Типа

      val fooFactory = inject[FooService]
      val foo = fooFactory()

      Но у меня почти никогда не было необходимости инжектить не-синглтон через DI. Ради такого случая можно и фабрику сделать.

      Про импорт ответил выше — импортируется не инстанс


  1. LMnet
    04.03.2016 07:23

    Если у вас нет рефлексии, то можно попробовать адаптировать библиотеку для использования в scala.js. Не думали над этим?


    1. Scf
      04.03.2016 20:06

      Для scala.js всё еще проще — одна глобальная переменная, в которую записывается инжектор + метод let, который в рамках лямбды заменяет глобальный инжектор на другой. Но я не верю в scala.js — пока будущее за ES6/TypeScript. Если яваскрипт — это ассемблер, то scala.js — это всего лишь кандидат на роль языка С.