Статьи

Использование черт Scala в качестве модулей или шаблона «Тонкий пирог»

Я хотел бы описать чисто Scala подход к модульности, который мы успешно используем в нескольких наших проектах Scala.

Но давайте начнем с того, как мы делаем Dependency Injection (см. Также мои  другие  блоги ). Каждый класс может иметь зависимости в форме параметров конструктора, например:

class WheatField
class Mill(wheatField: wheatField)
class CowPasture
class DiaryFarm(cowPasture: CowPasture)
class Bakery(mill: Mill, dairyFarm: DairyFarm)

На «конце света» есть главный класс, который запускает приложение и где создается весь граф объектов:

object BakeMeCake extends App {
     // creating the object graph
     lazy val wheatField = new WheatField()
     lazy val mill = new Mill(wheatField)     
     lazy val cowPasture = new CowPasture()
     lazy val diaryFarm = new DiaryFarm(cowPasture)
     lazy val bakery = new Bakery(mill, dairyFarm)
 
     // using the object graph
     val cake = bakery.bakeCake()
     me.eat(cake)
}

Подключение может быть выполнено вручную или, например, с помощью  MacWire .

Обратите внимание, что мы можем выполнить определение с использованием конструкций Scala: a  lazy val соответствует одноэлементному объекту (в построенном графе объектов), a  def — объекту с зависимой областью (новый экземпляр будет создаваться для каждого использования).

Тонкий торт

Что если граф объектов, и в то же время основной класс, станет большим? Ответ прост: мы должны разбить его на части, которые будут «модулями». Каждый модуль представляет собой Scala  traitи содержит некоторую часть графа объектов.

Например:

trait CropModule {
     lazy val wheatField = new WheatField()
     lazy val mill = new Mill(wheatField)     
} 
 
trait LivestockModule {
     lazy val cowPasture = new CowPasture()
     lazy val diaryFarm = new DiaryFarm(cowPasture)
}

Основным объектом становится композиция черт. Это именно то, что также происходит в Cake Pattern. Однако здесь мы используем только один его элемент, отсюда и название паттерна «Think Cake».

object BakeMeCake extends CropModule with LivestockModule {
     lazy val bakery = new Bakery(mill, dairyFarm)
 
     val cake = bakery.bakeCake()
     me.eat(cake) 
}

Если вы когда-либо использовали  Google Guice , вы можете увидеть сходство: trait-модули напрямую соответствуют модулям Guice. Однако здесь мы получаем дополнительную проверку безопасности типов и времени компиляции, что требования зависимостей для всех классов выполняются.

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

зависимости

Что если у наших модулей черт есть межмодульные зависимости? Есть два способа решения этой проблемы.

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

Второй способ — это композиция. Например, если мы хотим создать больший модуль из трех меньших модулей, мы можем просто расширить другие признаки модуля, и благодаря тому, как работает наследование, мы можем использовать все объекты, определенные там.

Соединяя два метода, мы получаем, например:

// composition: bakery depends on crop and livestock modules
trait BakeryModule extends CropModule with LivestockModule {
     lazy val bakery = new Bakery(mill, dairyFarm)
}   
 
// abstract member: we need a bakery
trait CafeModule {
     lazy val espressoMachine = new EspressoMachine()
     lazy val cafe = new Cafe(bakery, espressoMachine)
 
     def bakery: Bakery
}
 
// the abstract bakery member is implemented in another module
object CafeApp extends CafeModule with BakeryModule {
     cafe.orderCoffeeAndCroissant()
}

Несколько реализаций

Если продолжить эту идею, то в некоторых ситуациях у нас могут быть интерфейсы trait-module и несколько реализаций trait-module. Интерфейс будет содержать только абстрактные члены, а реализации будут связывать соответствующие классы. Если другие модули зависят только от trait-module-interface, когда мы делаем окончательную композицию, мы можем использовать любую реализацию.

Это не идеально, однако. Реализация должна быть известна статически, при написании кода — мы не можем динамически решить, какие реализации мы хотим использовать. Если мы хотим динамически выбирать реализацию только для одного trait-интерфейса, это не проблема — мы можем использовать простое «если». Но каждая дополнительная комбинация вызывает экспоненциальное увеличение случаев, которые мы должны охватить. Например:

trait MillModule {
     def mill: Mill
}
 
trait CornMillModule extends MillModule { 
     lazy val cornField = new CornField()
     lazy val mill = new CornMill(cornField)
}
 
trait WheatMillModule extends MillModule { 
     lazy val wheatField = new WheatField()
     lazy val mill = new WheatMill(wheatField)
}
 
val modules = if (config.cornPreferred) {
     new BakeryModule with CornMillModule
} else {
     new BakeryModule with WheatMillModule
}

Может ли быть лучше?

Конечно! Там всегда есть что улучшить :). Одна из проблем уже упоминалась — вы не можете выбрать, какой модуль trait-модуля использовать динамически (конфигурация во время выполнения).

Еще одна область, которая может быть улучшена — это связь между trait-модулями и пакетами. Хороший подход состоит в том, чтобы иметь один модуль trait для каждого пакета (или для каждого дерева пакетов). Таким образом, вы логически группируете код, который реализует некоторые функциональные возможности в одном пакете, и указываете,  как  классы, которые формируют реализации, должны использоваться в модуле trait. Но почему тогда вы должны определить и пакет, и trait-модуль? Может их как-то объединить? Повышение роли пакетов также является идеей, которую я  изучаю в  проекте Veripacks .

Также может быть полезно ограничить видимость некоторых из определенных объектов. Следуя правилу «один публичный класс на пакет», здесь мы можем иметь «один публичный объект на модуль-черту». Однако, если мы создаем большие trait-модули из меньших, больший модуль не может ограничить видимость объектов в модуле, из которого он состоит. Фактически, меньшие модули должны знать максимальную область видимости и использовать соответствующий закрытый модификатор [имя пакета] (предположим, что больший модуль находится в родительском пакете).

Подводя итоги

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

Приятного аппетита!