Dependency injection в Scala с помощью библиотеки SubCut

10 декабря 2015

Про DI и пользу от него сказано уже немало. Если простыми словами, то идея сводится к следующему. В ряде случаев вместо конкретной реализации того или иного компонента в коде используется только интерфейс. Конкретная же реализация передается явно через конструкторы классов, паттерн service locator, cake pattern, и так далее. Таким образом можно с легкостью мокать классы (вряд ли вы хотите слать настоящие e-mail при прогоне интеграционных тестов), или, например, при помощи файла конфигурации определять, в какой из трех поддерживаемых РСУБД хранить данные. В этой заметке мы рассмотрим, как добиться всего названного в Scala, воспользовавшись библиотекой SubCut. Также мы выясним, почему то же самое, по всей видимости, очень трудно проделать в некоторых других языках.

В одной из ранее опубликованных заметок мы писали простое in-memory key-value хранилище на Finagle. Но что, если в будущем мы захотим поддерживать хранение данных не только в памяти, но и на диске, при помощи какого-нибудь LevelDB? Или вообще использовать Couchbase? Опыт показывает, что подумать о таких вещах лучше заранее, иначе потом добавить поддержку альтернативных хранилищ будет очень непросто.

Итак, в соответствии с описанием, приведенным выше, вводим интерфейс и его конкретную реализацию:

import scala.collection.concurrent.TrieMap
import scala.concurrent._

trait KeyValueStorage {
  def get(key: String): Future[Option[String]]
  def update(key: String, value: String): Future[Unit]
  def remove(key: String): Future[Unit]
}

class KeyValueStorageImpl extends KeyValueStorage {
  private val kv = TrieMap.empty[String, String]

  def get(key: String): Future[Option[String]] = {
    val result = kv.get(key)
    Future.successful(result)
  }

  def update(key: String, value: String): Future[Unit] = {
    val result = kv.update(key, value)
    Future.successful(result)
  }

  def remove(key: String): Future[Unit] = {
    kv.remove(key)
    Future.successful({})
  }
}

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

Переписывание FinagleServiceExample в связи с внесенными изменениями тривиально, поэтому не будет подробно на нем останавливаться. Отмечу только, что понадобится преобразование скальных футур в твитеровский футуры. Это делается очень просто и готовый код легко гуглится.

Теперь самое интересное. В зависимостях проекта прописываем SubCut:

"com.escalatesoft.subcut" %% "subcut" % "2.1"

В объекте FinagleExample дописываем:

implicit val bindings = newBindingModule { module =>
  import module._

  bind[KeyValueStorage] toSingle new KeyValueStorageImpl
}

Переменная bindings имеет тип BindingModule и непосредственно отвечает за DI. Не пугайтесь строчки import module._. Она нужна всего лишь для того, чтобы можно было ниже писать bind вместо module.bind. Сиречь, это на самом деле никакой не импорт чего-то, переданного в качестве аргумента, а просто аналог конструкции with из мира Delphi.

Пачку байндингов нужно как-то передавать другим классам. Чтобы постоянно не делать это явно, воспользуемся имплиситами:

class FinagleServiceExample(implicit val bindingModule: BindingModule)
  extends /* ... */ Injectable

И за счет подмешивания трейта Injectable в нашем распоряжении оказывается метод inject. Пример его использования:

private lazy val kv = inject[KeyValueStorage]

Здесь переменная kv имеет тип KeyValueStorage. Вот таким простым образом классу подсовывается интерфейс, о конкретной реализации которого он ничегошеньки не знает!

На практике, однако, все немного интереснее:

  • Инжектируемые классы могут иметь зависимости друг от друга, и зависимости эти по мере развития проекта могут меняться. Поэтому значение, возвращаемое методом inject, рекомендуется всегда присваивать lazy-переменным;
  • Помимо inject есть еще injectOptional, про который все понятно из названия. Но на практике, скорее всего, вы не должны хотеть его использовать ни для чего;
  • Если вы обнаружили, что хотите несколько раз сделать bind для одного типа, то, скорее всего, вы делаете что-то не так;

Последняя ситуация часто возникает с акторами, так как все они имеют тип ActorRef. Правильное на мой взгляд решение заключается в том, чтобы вместо ActorRef делать bind класса, предоставляющего типизированный интерфейс к этому актору. В качестве примера такого класса можно привести в пример ProfileActor.AskExt из заметки Интересный пример с роутингом и кэшами в Akka Cluster. Вообще, хорошей практикой считается всегда (если, конечно, это возможно, но возможно это в 99% случаев) ходить в актор только через типизированный интерфейс. Даже в тестах следует работать именно с этим интерфейсом, а не конкретными сообщениями. Последние, вообще говоря, представляют собой деталь реализации, которая на практике часто меняется. Соответственно, если использовать в тестах сообщения, а не типизированный интерфейс, тесты придется постоянно переписывать. Оно вам надо? Следует также отметить, что, делая bind класса AskExt, вы экономите память, так как на каждый ActorRef у вас будет только один AskExt. Иначе вам придется создавать их везде, где используется ActorRef. Зачем плодить лишние копии объектов?

Тем не менее, если вам очень сильно понадобится делать bind для нескольких ActorRef, SubCut позволяет и это:

bind[ActorRef] idBy MigrationActor toSingle migrationActor
bind[ActorRef] idBy SessionsActor toSingle sessionsActor

… где в объекты, указанные аргументом idBy, должен быть подмешен BindingId:

object MigrationActor extends BindingId

Инжект же в этом случае делается так:

private lazy val migrationActor = inject[ActorRef](MigrationActor)

Вот, собственно, и все. Исходники к этой заметке вы найдете в этом коммите. Следует добавить, что фактически SubCut представляет собой немного измененный service locator. Для сравнения, классический service locator выглядит как-то так:

trait Bindings {
  def getEnv() : String
  // getSomething ...
  // getSomethingElse
  // etc
}

class ProdBindings extends Bindings {
 def getEnv() = "Prod!"
}

class MainClass(implicit val bindings: Bindings) {
  def run() {
    val otherClass = new SomeOtherClass("ololo")
    otherClass.run()
  }
}

class SomeOtherClass(text: String)(implicit val bindings: Bindings) {
  def run() {
    println(s"text = $text, env = ${bindings.getEnv()}")
  }
}

implicit val bindings = new ProdBindings()
val mainClass = new MainClass()
mainClass.run()

В заключение хотелось бы поделиться с вами фразой, которую я услышал на FPConf от Александра Гранина: «В Haskell нет интерфейсов». Меня эта фраза поначалу очень сильно удивила. Как же так, а классы типов? Но если подумать, оказывается, что в Haskell действительно нет интерфейсов. Во-первых, потому что в чисто функциональных языках функции отделены от данных. Во-вторых, потому что нельзя вернуть из функции просто Traversable. Можно вернуть только известный на этапе компиляции конкретный тип, для которого есть экземпляр класса типов Traversable. В случае с Rust, насколько мне известно, ситуация аналогичная за тем исключением, что возможно вручную написать vtable и вот это все.

Если вы знаете, как удобно и эффективно сделать DI в Rust и Haskell, прошу рассказать об этом в комментариях.

Метки: , .


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