Это перевод статьи Jonas Bonér Real-World Scala: Dependency Injection (DI)

Во втором посте серии Real-World Scala я собираюсь рассказать как реализовывать внедрение зависимостей в Scala (Dependency Injection, DI). Scala очень богатый и глубокий язык, который дает несколько способов задать DI исключительно на основе языковых конструкций, но ничто не мешает использовать существующие фреймворки DI из Java, если это предпочтительнее.

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

Шаблон Cake Pattern впервые объяснил Martin Odersky в статье “Scalable Component Abstractions”[pdf] (замечательная статья, которую я рекомендую) как пример того, как Мартин и его команда структурировали компилятор Scala. Вместо того, чтобы объяснять сам шаблон и как его использовать для реализации DI, лучше взглянем на небольшой пример.

Сначала зададим пользовательский репозитарий UserRepository (DAO)

// a dummy service that is not persisting anything
// solely prints out info
class UserRepository {
  def authenticate(user: User): User = {
    println("authenticating user: " + user)
    user
  }
  def create(user: User) = println("creating user: " + user)
  def delete(user: User) = println("deleting user: " + user)
}

Можно было бы разделить реализацию на trait интерфейс и его определение, но для упрощения в этом нет необходимости.

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

class UserService {
  def authenticate(username: String, password: String): User =
    userRepository.authenticate(username, password)
  def create(username: String, password: String) =
    userRepository.create(new User(username, password))
  def delete(user: User) = All is statically typed.
    userRepository.delete(user)
}

Здесь можно увидеть, что мы используем экземпляр UserRepository. Это та зависимость, которую мы хотели бы внедрить.

Отлично. Начинается самое интересное. Давайте сначала обернём UserRepository в trait и создадим здесь пользовательский репозитарий.

trait UserRepositoryComponent {
  val userRepository = new UserRepository
  class UserRepository {
    def authenticate(user: User): User = {
      println("authenticating user: " + user)
      user
    }
    def create(user: User) = println("creating user: " + user)
    def delete(user: User) = println("deleting user: " + user)
  }
}

Это просто создание компонентного пространства имён для нашего репозитария. Далее я покажу зачем это нужно.

Теперь давайте взглянем на UserService, репозитарий пользователей. Чтобы внедрить экземпляр userRepository в UserService, мы сначала сделаем тоже, что и с репозитарием выше; обернём его в trait и используем так называемую self-type annotation для объявления зависимости в сервисе UserRepository.

Звучит запутаннее, чем есть на самом деле. Взглянем на код.

// using self-type annotation declaring the dependencies this
// component requires, in our case the UserRepositoryComponent
trait UserServiceComponent { this: UserRepositoryComponent =>
  val userService = new UserService
  class UserService {
    def authenticate(username: String, password: String): User =
      userRepository.authenticate(username, password)
    def create(username: String, password: String) =
      userRepository.create(new User(username, password))
    def delete(user: User) = userRepository.delete(user)
  }
}

Self-type annotation о которой мы говорим здесь это

this: UserRepositoryComponent =>

Если надо объявить более одной зависимости, то можно композировать несколько аннотаций

this: Foo with Bar with Baz =>

Отлично. Мы объявили зависимость UserRepository. Осталось только связывание.

Чтобы сделать это, единственная вещь которую мы должны сделать - совместить разные пространства имён в один модуль. Это достигается путём создания модульного объекта, состоящего из всех наших компонентов. Все связывание происходит автоматически.

object ComponentRegistry extends
  UserServiceComponent with
  UserRepositoryComponent

Одна из красивых сторон этого решения в том, что связывание статически типизировано. К примеру, если мы забываем объявить зависимость, или ошибаемся в написании или что-то еще сломано, мы получим ошибку компиляции.

Красивость этого решения также в том, что всё неизменяемо (все зависимости объявлены как val).

Чтобы использовать этот шаблон, надо использовать компонент ComponentRegistry, все зависимости будут связаны для нас (также работают Guice/Spring).

val userService = ComponentRegistry.userService
...
val user = userService.authenticate(..)

Всё ли в порядке?

Нет. Это плохое решение.

У нас есть строгая связь между реализацией сервиса и его созданием, связывающая конфигурация используется везде в коде. Это крайне негибко.

Вместо инстанцирования сервисов в trait-ах, поменяем инстанцируемые объекты на абстрактные поля.

trait UserRepositoryComponent {
  val userRepository: UserRepository
  class UserRepository {
    ...
  }
}
trait UserServiceComponent {
  this: UserRepositoryComponent =>
  val userService: UserService
  class UserService {
    ...
  }
}

Теперь мы можем переместить объявление(и конфигурацию) сервисов в модуль ComponentRegistry

object ComponentRegistry extends
UserServiceComponent with
UserRepositoryComponent
{
val userRepository = new UserRepository
val userService = new UserService
}

Используя такое определение мы абстрагируемся от конкретной реализации компонента и связываем все в один конфигурационный объект.

Изящность подхода в том, что мы можем переключаться здесь между различными реализациями служб (если бы мы определили интерфейсный trait и несколько его реализаций). Но еще более интересно то, что мы можем создать несколько “миров” или “окружений” просто совмещая trait-ы в различных комбинациях.

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

В этом случае, вместо создания экземпляра фактических сервисов, мы создадим фиктивную mock-реализацию каждому из них. Мы также изменим окружение на trait (я покажу через секунду почему).

trait TestEnvironment extends
    UserServiceComponent with
    UserRepositoryComponent with
    org.specs.mock.JMocker
{
  val userRepository = mock(classOf[UserRepository])
  val userService = mock(classOf[UserService])
}

Здесь мы не просто создаем mock-и, но также связывем их в виде объявленных зависимостей, где бы они не были определены.

Хорошо, теперь начинается самое интересное. Давайте создадим модульный тест, в котором мы свяжемся с TestEnvironment, содержащим все mock-и.

class UserServiceSuite extends TestNGSuite with TestEnvironment {
  @Test { val groups=Array("unit") }
  def authenticateUser = {
    // create a fresh and clean (non-mock) UserService
    // (who's userRepository is still a mock)
    val userService = new UserService
    // record the mock invocation
    expect {
      val user = new User("test", "test")
      one(userRepository).authenticate(user) willReturn user
    }
    ... // test the authentication method
  }
  ...
}

Это только один пример того, как вы можете композировать свои компоненты.

Другие альтернативы

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

Я опирался во всех этих примерах на одну и ту же маленьку dummy-программу, чтобы было проще понимать и сравнивать различные подхода (программа взята из обсуждения найденного в пользовательской рассылке Scala User mailing list).Во всех этих примерах вы можете просто скопировать и запустить код в интерпретаторе Scala, если вы хотите поиграться с ним.

Использование структурной типизации

Этот приём с использованием структурной типизации был размещена Jamie Webb в рассылке Scala User mailing list некоторое время назад. Мне нравится этот подход; элегантный, неизменяемый(immutable), типобезопасный(type-safe).

// =======================
// интерфейсы сервисов
// service interfaces
trait OnOffDevice {
  def on: Unit
  def off: Unit
}
trait SensorDevice {
  def isCoffeePresent: Boolean
}
// =======================
// реализации сервисов
// service implementations
class Heater extends OnOffDevice {
  def on = println("heater.on")
  def off = println("heater.off")
}
class PotSensor extends SensorDevice {
  def isCoffeePresent = true
}
// =======================
// сервис определяющий две внедряемые зависимости
// использующий структурное типизирование для объявления его зависимостей
// service declaring two dependencies that it wants injected,
// is using structural typing to declare its dependencies
class Warmer(env: {
  val potSensor: SensorDevice
  val heater: OnOffDevice
}) {
  def trigger = {
    if (env.potSensor.isCoffeePresent) env.heater.on
    else env.heater.off
  }
}
class Client(env : { val warmer: Warmer }) {
  env.warmer.trigger
}
// =======================
// instantiate the services in a configuration module
// создание сервисов в конфигурационном модуль
object Config {
  lazy val potSensor = new PotSensor
  lazy val heater = new Heater
  lazy val warmer = new Warmer(this) // здесь происходит внедрение /this is where injection happens
}
new Client(Config)

Использования неявных объявлений (implicit declarations)

Это простой и прямолинейный подход. Но мне он не очень нравится, потому что фактическое связывание (импорт неявных деклараций) рассеяно и запутано с кодом приложения.

// =======================
// service interfaces
// интерфейсы сервисов
trait OnOffDevice {
  def on: Unit
  def off: Unit
}
trait SensorDevice {
  def isCoffeePresent: Boolean
}
// =======================
// service implementations
// реализация сервисов
class Heater extends OnOffDevice {
  def on = println("heater.on")
  def off = println("heater.off")
}
class PotSensor extends SensorDevice {
  def isCoffeePresent = true
}
// =======================
// сервис, объявляющий две внедряемые зависимости
// service declaring two dependencies that it wants injected
class Warmer(
implicit val sensor: SensorDevice,
implicit val onOff: OnOffDevice) {
  def trigger = {
    if (sensor.isCoffeePresent) onOff.on
    else onOff.off
  }
}
// =======================
// создание сервисов в модуле
// instantiate the services in a module
object Services {
  implicit val potSensor = new PotSensor
  implicit val heater = new Heater
}
// =======================
// Импортирование сервисов в текущую область
// связывание происходит автоматически с использование implicit-ов
// import the services into the current scope and the wiring
// is done automatically using the implicits
import Services._
val warmer = new Warmer
warmer.trigger

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

Scala прекрасно работает с отдельными DI фреймворками. Вы можете использовать Guice по-разному, но здесь мы будем обсуждать slick-технику, основанную на ServiceInjector-е, которую показал мне Jan Kriesten.

// =======================
// интерфейсы сервисов
// service interfaces
trait OnOffDevice {
  def on: Unit
  def off: Unit
}
trait SensorDevice {
  def isCoffeePresent: Boolean
}
trait IWarmer {
  def trigger
}
trait Client
// =======================
// реализации сервисов
// service implementations
class Heater extends OnOffDevice {
  def on = println("heater.on")
  def off = println("heater.off")
}
class PotSensor extends SensorDevice {
  def isCoffeePresent = true
}
class @Inject Warmer(
  val potSensor: SensorDevice,
  val heater: OnOffDevice)
  extends IWarmer {
  def trigger = {
    if (potSensor.isCoffeePresent) heater.on
    else heater.off
  }
}
// =======================
// клиент
// client
class @Inject Client(val warmer: Warmer) extends Client {
  warmer.trigger
}
// =======================
// Конфигурационный класс Guice, определяющий связи
// интерфейс-реализации
// Guice's configuration class that is defining the
// interface-implementation bindings
class DependencyModule extends Module {
  def configure(binder: Binder) = {
    binder.bind(classOf[OnOffDevice]).to(classOf[Heater])
    binder.bind(classOf[SensorDevice]).to(classOf[PotSensor])
    binder.bind(classOf[IWarmer]).to(classOf[Warmer])
    binder.bind(classOf[Client]).to(classOf[MyClient])
  }
}
// =======================
// Использование: val bean = new Bean with ServiceInjector
// Usage: val bean = new Bean with ServiceInjector
trait ServiceInjector {
  ServiceInjector.inject(this)
}
// помогающий объект-компаньон
// helper companion object
object ServiceInjector {
  private val injector = Guice.createInjector(
    Array[Module](new DependencyModule))
  def inject(obj: AnyRef) = injector.injectMembers(obj)
}
// =======================
// примесь trait-а ServiceInjector для реализации внедрения
// в момент инстанцирования
// mix-in the ServiceInjector trait to perform injection
// upon instantiation
val client = new MyClient with ServiceInjector
println(client)

Это пример подводит итог того, что я запланировал в статье. Я надеюсь, что вы получили некоторое представление о том, как можно достичь внедрения зависимостей DI в Scala - либо с помощью абстракций языка либо отдельным DI фреймворком. Выбирайте то, что подходит лучше для вашего случая, требований и вкуса.

**Добавление

Ниже я добавил Cake Pattern версию последнего примера для облегчения сравнения различных стратегий DI. Просто к сведению, если вы сравните различные стратегии используя этот простой пример, то Cake Pattern может выглядеть немного чрезмерно сложным с его вложенными trait-ами, но такой подход имеет место, когда у вас есть менее тривиальный пример со многими компонентами с более или менее сложными зависимостями для управления.

// =======================
// интерфейсы сервисов
// service interfaces
trait OnOffDeviceComponent {
  val onOff: OnOffDevice
  trait OnOffDevice {
    def on: Unit
    def off: Unit
  }
}
trait SensorDeviceComponent {
  val sensor: SensorDevice
  trait SensorDevice {
    def isCoffeePresent: Boolean
  }
}
// =======================
// реализации сервисов
// service implementations
trait OnOffDeviceComponentImpl extends OnOffDeviceComponent {
  class Heater extends OnOffDevice {
    def on = println("heater.on")
    def off = println("heater.off")
  }
}
trait SensorDeviceComponentImpl extends SensorDeviceComponent {
  class PotSensor extends SensorDevice {
    def isCoffeePresent = true
  }
}
// =======================
// сервис, объявляющий две внедряемые зависимости
// service declaring two dependencies that it wants injected
trait WarmerComponentImpl {
  this: SensorDeviceComponent with OnOffDeviceComponent =>
  class Warmer {
    def trigger = {
      if (sensor.isCoffeePresent) onOff.on
      else onOff.off
    }
  }
}
// =======================
// создание сервисов в модуле
// instantiate the services in a module
object ComponentRegistry extends
  OnOffDeviceComponentImpl with
  SensorDeviceComponentImpl with
  WarmerComponentImpl {
  val onOff = new Heater
  val sensor = new PotSensor
  val warmer = new Warmer
}
// =======================
val warmer = ComponentRegistry.warmer
warmer.trigger

Jonas Bonér