Scala Best Practices от Alexandru Nedelcu

Перевод статьи Alexandru Nedelcu Scala Best Practices.

Ниже приведен список лучших практик, которые Alexandru Nedelcu составил для своих коллег.

0. Предисловие

Советы возникли из болезненного опыта, естественно, полученного при работе с кодом других людей :-)

Определить список лучших практик всегда сложно. Мне нравится думать, что мы определяем протокол связи, потому что это так. Поэтому в этом документе используются ключевые слова, определенные в RFC 2119, чтобы различать правила, которые никогда не следует нарушать, и те, которые можно нарушать, если вы знаете, что делаете.

Этот список также далеко не полный.

0.1. НЕЛЬЗЯ слепо следовать советам

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

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

1. Гигиенические правила

Это гигиенические правила общего назначения, выходящие за рамки правил языка или платформы. Язык программирования — это форма общения, ориентированная не только на компьютерные системы, но и на ваших коллег и на вас самих в будущем, поэтому соблюдать эти правила — это все равно, что мыть руки после посещения туалета.

1.1. СЛЕДУЕТ обеспечить разумную длину строки

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

В типографике оптимальной длиной строки считается длина где-то между 50 и 70 символами.

В программировании используются отступы, поэтому невозможно установить длину строк в 60 символов. Обычно ограничение в 80 символов приемлемо, но не в Scala, потому что в Scala мы используем много замыканий. И если вам нужны длинные и описательные имена для функций и классов, то 80 символов — это слишком мало.

С другой стороны, 120 символов, на которые IntelliJ IDEA настроен по умолчанию, могут быть слишком широкими. Да, я знаю, что у нас есть мониторы с соотношением сторон 16:9, но это не улучшает читаемость, и с более короткими строками мы можем эффективно использовать эти широкие мониторы при параллельном сравнении. А в случае длинных строк требуется приложить усилия, чтобы заметить важные детали, которые происходят в конце этих строк.

Итак, баланс:

С другой стороны, все, что выходит за рамки 120 символов, является мерзостью.

1.2. НЕЛЬЗЯ полагаться на плагин SBT или IDE, который выполнит форматирование за вас.

Плагины IDE и SBT могут оказаться очень полезными, однако, если вы подумываете об использовании их для автоматического форматирования кода, будьте осторожны.

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

Таким образом, автоматизированные средства - это хорошо, НО БУДЬТЕ ОСТОРОЖНЫ, чтобы не испортить тщательно отформатированный код других людей, иначе I'll slap you in prose.

Давайте подумаем о том, что я сказал: если строка слишком длинная, как плагин может ее разделить? Давайте поговорим об этой строке (реальный код):

val dp = new DispatchPlan(new Set(filteredAssets), start = startDate, end = endDate, product, scheduleMap, availabilityMap, Set(activationIntervals.get), contractRepository, priceRepository)

В большинстве случаев плагин просто выполняет усечение, и я видел много таких случаев на практике:

val dp = new DispatchPlan(Set(filteredAssets), start =
  startDate, end = endDate, product, scheduleMap, availabilityMap,
  Set(activationIntervals), contractRepository, priceRepository)

Теперь код не читается, не так ли? Я имею в виду, серьезно, это выглядит как блевотина. И именно такие результаты я вижу от людей, которые полагаются на работу плагинов. У нас также может быть такая версия:

val dp = new DispatchPlan(
  Set(filteredAssets),
  startDate,
  endDate,
  product,
  scheduleMap,
  availabilityMap,
  Set(activationIntervals),
  contractRepository,
  priceRepository
)

Выглядит намного лучше. Но правда в том, что в других случаях это не так хорошо. Допустим, у нас есть строка, которую мы хотим разделить:

val result = service.something(param1, param2, param3, param4).map(transform)

Теперь размещать эти параметры в отдельной строке ужасно, как бы вы с этим ни справлялись:

// ужасно, потому что вызов map не виден
val result = service.something(
  param1,
  param2,
  param3,
  param4).map(transform)

// Это ужасно, потому что нарушает логический поток
val result = service.something(
  param1,
  param2,
  param3,
  param4
).map(transform)

Так было бы намного лучше:

val result = service
  .something(param1, param2, param3, param4)
  .map(transform)

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

val result = {
  val instance =
    object.something(
      myAwesomeParam1,
      otherParam2,
      someSeriousParam3,
      anEvenMoreSoParam4,
      lonelyParam5,
      catchSomeFn6,
      startDate7
    )

  for (x <- instance) yield
    transform(x)
}

Конечно, иногда, код так сильно "воняет", что вам нужно заняться рефакторингом - например, слишком много параметров слишком много для одной функции ;-)

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

1.3. СЛЕДУЕТ разбивать длинные функции

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

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

1.4. НЕЛЬЗЯ допускать орфографических ошибок в именах и комментариях

Орфографические ошибки ужасно раздражают, прерывая поток чтения. Используйте программу проверки орфографии. Интеллектуальные IDE имеют встроенные средства проверки правописания. Обратите внимание на подчеркнутые предупреждения об орфографии и исправьте их.

1.5. Имена ДОЛЖНЫ быть осмысленными

"В информатике есть только две сложные вещи: аннулирование кэша и присвоение имен вещам." -- Phil Karlton

Здесь у нас есть три рекомендации:

  1. давайте описательные имена, но не переусердствуйте, четыре слова — это уже слишком много
  2. вы можете быть краткими в именовании, если тип/цель можно легко вывести из непосредственного контекста или если уже существует установленное соглашение
  3. если вы идете описательным путем, не употребляйте бессмысленных слов

Например, это приемлемо:

for (p <- people) yield
  transformed(p)

Мы видим, что p — это человек (person) из непосредственного контекста, поэтому можно использовать короткое имя из одной буквы.

Следующее также приемлемо, поскольку i является общепринятым соглашением об использовании в качестве индекса:

for (i <- 0 until limit) yield ???

В целом следующее неприемлемо, потому что обычно в случае кортежей название коллекции плохо отражает то, что содержится (если вы не дали этим элементам имя, то, как следствие, сама коллекция будет иметь плохое имя):

someCollection.map(_._2)

С другой стороны, короткие имена подходят для неявных параметров, поскольку они передаются неявно, мы не заботимся о неявных параметрах, если только они не отсутствуют:

def query(id: Long)(implicit ec: ExecutionContext, c: WSClient): Future[Response]

Следующее неприемлемо, потому что имя совершенно бессмысленно, даже если есть явная попытка быть описательным:

def processItems(people: Seq[Person]) = ???

Это неприемлемо, поскольку название этой функции указывает на побочный эффект (процесс (process) — это глагол, обозначающий команду), но оно не описывает, что мы делаем с этими людьми (people). Суффикс Items не имеет смысла, потому что мы могли бы сказать processThingy, processRows, processStuff, и это все равно будет говорить то же самое — абсолютно ничего. Это также увеличивает визуальный беспорядок, поскольку чем больше слов, тем больше текста нужно прочитать, а бессмысленные слова — это просто шум.

Правильно подобранные описательные названия – хорошо. Бредовые имена – плохо.

2. Языковые правила

2.1. НЕЛЬЗЯ использовать "return"

Оператор return из Java сигнализирует о побочном эффекте — раскручивает стек и передает заданное значение вызывающему объекту. В языке, в котором упор делается на программирование, полное побочных эффектов, это имеет смысл. Однако Scala — это язык, ориентированный на выражения, в котором упор делается на контроль/ограничение побочных эффектов, а return не является идиоматическим.

Что еще хуже, return, вероятно, ведет себя не так, как вы думаете. Например, попробуйте сделать следующее в контроллере Play:

def action = Action { request =>
  if (someInvalidationOf(request))
    return BadRequest("bad")

  Ok("all ok")
}

В Scala оператор return внутри вложенной анонимной функции реализуется путем выдачи и перехвата исключения NonLocalReturnException. Об этом говорится в Scala Language Specification, section 6.20.

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

2.2. СЛЕДУЕТ использовать неизменяемые структуры данных

Давайте проиллюстрируем:

trait Producer[T] {
 def fetchList: List[T]
}

// на стороне потребителя
someProducer.fetchList

Вопрос: если List, возвращенный выше, является изменяемым, что это говорит об интерфейсе Producer?

Вот некоторые проблемы:

  1. если этот список создается в другом потоке, чем потребитель, могут возникнуть проблемы как с видимостью, так и с атомарностью - вы не можете знать, произойдет ли это, если не посмотрите на реализацию Producer.
  2. даже если этот список фактически неизменяем (т.е. все еще изменяем, но больше не изменяется после того, как он был передан Consumer), вы не знаете, будет ли он передан другим Consumer-ам, которые могут изменить его самостоятельно, поэтому вы не можете рассуждать о том, что с этим можно сделать.
  3. даже если описано, что доступ к этому List должен быть синхронизирован, проблема в том - на каком lock вы собираетесь синхронизироваться? Вы уверены, что получите правильный порядок блокировки? Lock-и не являются составными.

Итак, вот что получаем: общедоступный API, раскрывающий изменяемую структуру данных, является натуральной мерзостью, приводящей к проблемам, которые могут быть хуже, чем те, которые возникают при ручном управлении памятью.

2.3. НЕ СЛЕДУЕТ обновлять var, используя циклы или условия

Это ошибка, которую совершает большинство Java-разработчиков, когда они переходят на Scala. Пример:

var sum = 0
for (elem <- elements) {
  sum += elem.value
}

Избегайте этого, вместо этого отдавайте предпочтение доступным операторам, например, foldLeft:

val sum = elements.foldLeft(0)((acc, e) => acc + e.value)

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

val sum = elements.map(_.value).sum

Точно так же не следует обновлять частичный результат условием. Пример:

def compute(x) = {
  var result = resultFrom(x)

  if(needToAddTwo) {
    result += 2
  }
  else {
    result += 1
  }

  result
}

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

def computeResult(x) = {
  val r = resultFrom(x)
  if (needToAddTwo)
    r + 2
  else
    r + 1
}

И знаете, как только ветки становятся слишком сложными, как было сказано в обсуждении return, это признак того, что код пахнет и нуждается в рефакторинге, и это хорошо.

2.4. НЕ СЛЕДУЕТ определять бесполезные trait-ы

Была такая Java Best Practice, в которой говорилось: "программировать для интерфейса, а не для реализации" ("program to an interface, not to an implementation"), лучшая практика, которая была культивирована до такой степени, что люди начали определять в своем коде совершенно бесполезные интерфейсы. Вообще говоря, это правило полезно, но оно относится к общей инженерной необходимости скрывать детали реализации, особенно детали изменения состояния (инкапсуляции), а не наносить удары по объявлениям интерфейса, которые в любом случае часто приводят к утечке деталей реализации.

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

trait PersonLike {
  def name: String
  def age: Int
}

case class Person(name: String, age: Int)
  extends PersonLike

Читатели этого кода могут прийти к выводу, что существуют случаи, когда переопределение PersonLike желательно. Это очень далеко от истины: Person прекрасно описывается своим кейс-классом как структура данных без поведения. Другими словами, он описывает форму ваших данных, и если вам нужно переопределить эту форму по какой-то неизвестной причине, то этот trait определен плохо, поскольку он навязывает форму ваших данных, и это единственное, что вы можете переопределить. Вы всегда можете придумать trait-ы позже, если вам понадобится полиморфизм, после того, как ваши потребности изменятся.

И если вы думаете, что вам, возможно, придется переопределить источник этого (например, чтобы получить имя человека из БД при первом доступе), Боже мой, не делайте этого!

Обратите внимание, что я не говорю об алгебраических структурах данных (т.е. о закрытых trait-ах, которые сигнализируют о закрытом наборе вариантов — например, Option).

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

trait DBService {
  def getAssets: Future[Seq[(AssetConfig, AssetPersistedState)]]

  def persistFlexValue(flex: FlexValue): Future[Unit]
}

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

В последнее время мой код выглядит примерно так:

final class AssetsObservable
    (f: => Future[Seq[(AssetConfig, AssetPersistedState)]])
  extends Observable[AssetConfigEvent] {

  // ...
}

object AssetsObservable {
  // constructor
  def apply(db: DBService) = new AssetsObservable(db.getAssets)
}

Видите ли, мне не нужно имитировать весь DBService, чтобы протестировать вышеизложенное.

2.5. НЕЛЬЗЯ использовать "var" внутри case class

Кейс-классы представляют собой синтаксический сахар для определения классов, в которых: все аргументы конструктора являются общедоступными и неизменяемыми и, следовательно, являются частью идентификатора значения, имеют структурное равенство, соответствующую реализацию hashCode и автоматически сгенерированные функции apply/unapply, предоставляемые компилятором.

Делая это:

case class Sample(str: String, var number: Int)

Вы только что нарушили операцию равенства и хэш-кода. Теперь попробуйте использовать его как ключ в ассоциативном массиве.

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

Приблизительно говоря словами Fogus в "The Joy of Clojure" или Baker в его статье 1993 года: если любые два изменяемых объекта считаются равными сейчас, то нет никакой гарантии, что тоже самое будет верно и через мгновение. А если два объекта не всегда равны, то технически они никогда не равны ;-)

2.6. НЕ СЛЕДУЕТ объявлять абстрактные члены "var"

Объявлять абстрактные переменные в абстрактных классах или трейтах — плохая практика. Не делай это:

trait Foo {
  var value: String
}

Вместо этого предпочтите объявлять абстрактные вещи как def:

trait Foo {
  def value: String
}

// затем может быть переопределено как угодно
class Bar(val value: String) extends Foo

Причина связана с наложенным ограничением - var можно переопределить только с помощью var. Способ предоставить свободу выбора наследования — использовать def для абстрактных членов. И зачем вам налагать ограничение на использование var для тех, которые наследуются от вашего интерфейса. def является общим, поэтому используйте его.

2.7. НЕЛЬЗЯ создавать исключения для проверки пользовательского ввода или управления потоком данных

Две причины:

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

Исключения полезны только для одного — сигнализировать о неожиданных ошибках (ошибках) вверх по стеку, чтобы супервизор мог отловить эти ошибки и принять решение о дальнейших действиях, таких как регистрация ошибок, отправка уведомлений, перезапуск виновного компонента и т.д.

В качестве апелляции к авторитету разумно сослаться на главу 4 "Functional Programming with Scala".

2.8. НЕЛЬЗЯ перехватывать Throwable при перехвате исключений

Никогда, никогда, никогда не делайте этого:

try {
 something()
} catch {
 case ex: Throwable =>
   blaBla()
}

Никогда не перехватывайте Throwable, потому что мы можем говорить о чрезвычайно фатальных исключениях, которые никогда не следует перехватывать и которые могут привести к сбою процесса. Например, если JVM выдает ошибку нехватки памяти, даже если вы повторно выдадите это исключение в этом предложении catch, может быть слишком поздно - учитывая, что процессу не хватает памяти, сборщик мусора, вероятно, взял на себя управление и все заморозил, при этом процесс заканчивается неизлечимым состоянием зомби. Это означает, что внешний супервизор (например, Upstart) не получит возможности его перезапустить.

Вместо этого сделайте следующее:

import scala.util.control.NonFatal

try {
 something()
} catch {
 case NonFatal(ex) =>
   blaBla()
}

2.9. НЕЛЬЗЯ использовать "null"

Вы должны избегать использования null. Вместо этого предпочитайте Option[T] Scala. Значения null подвержены ошибкам, поскольку компилятор не может вас защитить. Значения, допускающие null, которые встречаются в определениях функций, не документируются в этих определениях. Поэтому избегайте этого:

def hello(name: String) =
  if (name != null)
    println(s"Hello, $name")
  else
    println("Hello, anonymous")

В качестве первого шага вы можете сделать следующее:

def hello(name: Option[String]) = {
  val n = name.getOrElse("anonymous")
  println(s"Hello, $n")
}

Смысл использования Option[T] в том, что компилятор заставляет вас так или иначе с этим справляться:

  1. вам либо придется разобраться с этим сразу (например, предоставив значение по умолчанию, выдав исключение и т.д.)
  2. или вы можете распространить полученный Option вверх по стеку вызовов

Также помните, что Option — это набор из 0 или 1 элементов, поэтому вы можете использовать foreach, что совершенно идиоматично:

val name: Option[String] = ???

for (n <- name) {
  // выполняется только тогда, когда имя определено
  println(n)
}

Объединить несколько Option также легко:

val name: Option[String] = ???
val age: Option[Int] = ???

for (n <- name; a <- age)
  println(s"Name: $n, age: $a")

А поскольку Option тоже рассматривается как Iterable, вы можете использовать flatMap для коллекций, чтобы избавиться от значений None:

val list = Seq(1,2,3,4,5,6)

list.flatMap(x => Some(x).filter(_ % 2 == 0))
// => 2,4,6

2.10. НЕЛЬЗЯ использовать Option.get

У вас может возникнуть соблазн сделать это:

val someValue: Option[Double] = ???

// ....
val result = someValue.get + 1

Никогда не делайте этого, поскольку вы обмениваете исключение NullPointerException на исключение NoSuchElementException, а это в первую очередь противоречит цели использования Option.

Альтернативы:

  1. использование Option.getOrElse
  2. использование Option.fold
  3. использование сопоставления с образцом и явная работа с веткой None
  4. не вынимать значение из его необязательного контекста

В качестве примера для (4) отсутствие выдергивания значения из контекста означает следующее:

val result = someValue.map(_ + 1)

2.11. НЕЛЬЗЯ использовать Date или Calendar Java, вместо этого используйте java.time (JSR-310)

Классы Java Date и Calendar из стандартной библиотеки ужасны, потому что:

  1. результирующие объекты являются изменяемыми, что не имеет смысла для выражения даты, которая должна быть значением (как бы вы себя чувствовали, если бы вам приходилось работать со StringBuffer везде, где есть строки?)
  2. нумерация месяцев начинается с нуля
  3. Date, в частности, не сохраняет информацию о часовом поясе, поэтому значения даты совершенно бесполезны.
  4. нет разницы между GMT и UTC
  5. годы выражаются 2 цифрами вместо 4

Вместо этого всегда используйте API java.time, представленный в Java 8, или, если вы застряли в стране до Java 8, используйте Joda-Time, который является его духовным предком.

2.12. НЕ СЛЕДУЕТ использовать Any или AnyRef или isInstanceOf/asInstanceOf.

Избегайте использования Any или AnyRef или явного приведения типов, если только у вас нет для этого действительно веской причины. Scala — это язык, который извлекает пользу из своей выразительной системы типов, использование Any или приведения типов представляет собой дыру в этой выразительной системе типов, и компилятор не знает, как вам в этом помочь. В общем, вот так плохо:

val json: Any = ???

if (json.isInstanceOf[String])
  doSomethingWithString(json.asInstanceOf[String])
else if (json.isInstanceOf[Map])
  doSomethingWithMap(json.asInstanceOf[Map])
else
  ???

Часто мы используем Any при десериализации. Вместо того, чтобы работать с Any, подумайте о желаемом универсальном типе и наборе необходимых подтипов и придумайте алгебраический тип данных (ADT - Algebraic Data-Type):

sealed trait JsValue

case class JsNumber(v: Double) extends JsValue
case class JsBool(v: Boolean) extends JsValue
case class JsString(v: String) extends JsValue
case class JsObject(map: Map[String, JsValue]) extends JsValue
case class JsArray(list: Seq[JsValue]) extends JsValue
case object JsNull extends JsValue

Теперь вместо того, чтобы работать с Any, мы можем выполнять сопоставление с образцом для JsValue, и компилятор может помочь нам здесь с недостающими ветвями, поскольку выбор конечен. Следующее вызовет предупреждение об отсутствующих ветках:

val json: JsValue = ???
json match {
  case JsString(v) => doSomethingWithString(v)
  case JsNumber(v) => doSomethingWithNumber(v)
  // ...
}

2.13. НУЖНО сериализовать даты как timestamp Unix или как ISO 8601

Timestamp Unix, при условии, что речь идет о количестве секунд или миллисекунд с 1970-01-01 00:00:00 UTC (с акцентом на UTC), являются достойным кроссплатформенным форматом сериализации. У него есть тот недостаток, что он имеет ограничения в том, что он может выражать. ISO-8601 — достойный формат сериализации, поддерживаемый большинством библиотек.

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

2.14. НЕЛЬЗЯ использовать магические значения

Хотя в других языках нередко используются «магические» (специальные) значения, такие как -1, для обозначения определенных результатов, в Scala существует ряд типов, позволяющих прояснить намерение. Option, Either, Try — тому примеры. Кроме того, если вы хотите выразить нечто большее, чем просто логический успех или неудачу, вы всегда можете придумать алгебраический тип данных.

Не делайте этого:

val index = list.find(someTest).getOrElse(-1)

2.15. НЕ СЛЕДУЕТ использовать var в качестве общего состояния

Избегайте использования "var", по крайней мере, когда речь идет об общем изменяемом состоянии. Потому что, если у вас есть общее состояние, выраженное в виде переменных, вам лучше синхронизировать его, и оно быстро станет уродливым. Гораздо лучше избегать этого. Если вам действительно нужно изменяемое общее состояние, используйте атомарную ссылку и храните в ней неизменяемые вещи. Также ознакомьтесь с Scala-STM.

Итак, вместо чего-то вроде этого:

class Something {
  private var cache = Map.empty[String, String]
}

Если вы действительно не можете избежать переменной cache, лучше сделайте так:

import java.util.concurrent.atomic._

class Something {
  private val cache =
    new AtomicReference(Map.empty[String, String])
}

Да, это приводит к накладным расходам из-за необходимой синхронизации, что в случае атомарной ссылки означает циклы вращения (spin loops). Но это избавит вас от множества головных болей в дальнейшем. И лучше всего полностью избегать мутаций.

2.16. Публичные методы ДОЛЖНЫ явно указывать тип возвращаемого значения

Предпочитаю это:

def someFunction(param1: T1, param2: T2): Result = {
  ???
}

Чем это:

def someFunction(param1: T1, param2: T2) = {
  ???
}

Да, вывод типа по результату функции — это здорово и все такое, но для публичных методов:

  1. нельзя полагаться на IDE или проверять реализацию, чтобы увидеть возвращаемый тип.
  2. Scala в настоящее время выводит наиболее специализированный тип из возможных, потому что в Scala тип возвращаемого значения для функций является ковариантным, поэтому вы можете получить действительно уродливый тип.

Например, какой тип возвращает эта функция:

def sayHelloRunnable(name: String) = new Runnable {
  def sayIt() = println(s"Hello, $name")
  def run() = sayIt()
}

Как вы думаете, это Runnable? Неверно, это Runnable{ def sayIt(): Unit }.

В качестве побочного эффекта это также увеличивает время компиляции, поскольку всякий раз, когда sayHelloRunnable меняет реализацию, он также меняет сигнатуру, поэтому все, что от нее зависит, необходимо перекомпилировать.

2.17. НЕ СЛЕДУЕТ определять кейс-классы, вложенные в другие классы

Это заманчиво, но почти никогда не следует определять вложенные кейс-классы внутри другого объекта/класса, поскольку это мешает Java сериализации. Причина в том, что когда вы сериализуете кейс-класс, он закрывается по указателю "this" и сериализует весь объект, что, если вы помещаете в свой App объект, означает, что для каждого экземпляра кейс-класса вы сериализуете весь мир.

И что особенно важно в случае с классами:

  1. ожидается, что кейс-класс будет неизменяемым (значение, факт) и, следовательно,
  2. ожидается, что кейс-класс будет легко сериализоваться

Предпочитайте плоские иерархии.

2.18 НЕЛЬЗЯ включать классы, трейты и объекты внутри объектов пакета

Классы, включая кейс-классы, трейты и объекты, не принадлежат объектам пакета. Это ненужно, сбивает с толку компилятор и поэтому не рекомендуется. Например, воздержитесь от следующих действий:

package foo

package object bar {
  case object FooBar
}

Тот же эффект достигается, если все артефакты находятся внутри простого пакета:

package foo.bar

case object FooBar

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

Неявные классы значений могут быть определены в объекте пакета

В одном редком случае имеет смысл включить классы, определенные непосредственно в package object. Причина этого в том, что неявные классы должны быть вложены в другой объект/класс, и вы не можете определить верхнеуровневый implicit в Scala. Вложение неявных классов значений внутри package object также позволяет создать удобный интерфейс импорта для библиотеки, поскольку при одном импорте объекта пакета будут добавлены все необходимые неявные элементы.

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

package object dsl {
  implicit class DateTimeAugmenter(val date: Datetime) extends AnyVal {
    def yesterday: DateTime = date.plusDays(-1)
  }
}

2.19 СЛЕДУЕТ использовать head/tail и init/last декомпозицию только если они могут быть выполнены за константное время и память

Пример head/tail разложения:

def recursiveSumList(numbers: List[Int], accumulator: Int): Int =
  numbers match {
    case Nil =>
      accumulator

    case head :: tail =>
      recursiveSumList(tail, accumulator + head)
  }

В List есть специальный head/tail экстрактор ::, потому что списки всегда создаются путем добавления элемента в начало списка:

val numbers = 1 :: 2 :: 3 :: Nil

Это то же самое, что:

val numbers = Nil.::(3).::(2).::(1)

По этой причине и head, и tail списка требуют только постоянного времени и памяти! Эти операции имеют размер O(1). Существует еще один head/tail экстрактор под названием +:, который работает с любым Seq:

def recursiveSumSeq(numbers: Seq[Int], accumulator: Int): Int =
  numbers match {
    case Nil =>
      accumulator

    case head +: tail =>
      recursiveSumSeq(tail, accumulator + head)
  }

Вы можете найти реализацию +: здесь. Проблема в том, что другие коллекции, кроме List, не обязательно разлагаются по head/tail за постоянное время и память, например, Array:

val numbers = Array.range(0, 10000000)

recursiveSumSeq(numbers, 0)

Это крайне неэффективно: каждый tail на Array требует O(n) времени и памяти, поскольку каждый раз необходимо создавать новый массив! К сожалению, библиотека коллекций Scala допускает подобные неэффективные операции. Мы должны следить за ними.


Примером эффективной декомпозиции init/last является scala.collection.immutable.Queue. Он поддерживается двумя списками, а эффективность head, tail, init и last амортизируется постоянным временем и памятью, как объяснено в Scala collection performance characteristics.

Я не думаю, что разложение init/last является таким уж распространенным явлением. В целом это аналог head/tail разложения. init/last деконструктор для любой Seq — это :+.

2.20 Нельзя использовать Seq.head

У вас может возникнуть соблазн:

val userList: List[User] = ???

// ....
val firstName = userList.head.firstName

Никогда не делайте этого, так как при этом возникнет исключение NoSuchElementException, если последовательность пуста.

Альтернативы:

  1. использование Seq.headOption, возможно, в сочетании с getOrElse или сопоставлением с образцом

    Пример:

    val firstName = userList.headOption match {
        case Some(user) => user.firstName
        case _ => "Unknown"
      }
  2. использование сопоставления шаблонов с оператором cons ::, если вы имеете дело со List

    Пример:

    val firstName = userList match {
     case head :: _ => head.firstName
     case _ => "Unknown"
    }
  3. используя NonEmptyList, если требуется, чтобы список никогда не был пустым. (См. cats, scalaz, ...)

2.21 Кейс-классы ДОЛЖНЫ быть final

Расширение кейс-класса приведет к неожиданному поведению. Следите за следующим:

scala> case class Foo(v:Int)
defined class Foo

scala> class Bar(v: Int, val x: Int) extends Foo(v)
defined class Bar

scala> new Bar(1, 1) == new Bar(1, 1)
res25: Boolean = true

scala> new Bar(1, 1) == new Bar(1, 2)
res26: Boolean = true
// ????
scala> new Bar(1,1) == Foo(1)
res27: Boolean = true

scala> class Baz(v: Int) extends Foo(v)
defined class Baz

scala> new Baz(1) == new Bar(1,1)
res29: Boolean = true //???

scala> println (new Bar(1,1))
Foo(1) // ???

scala> new Bar(1,2).copy()
res49: Foo = Foo(1) // ???

Поверьте: почему кейс-классы должны быть final

Поэтому по умолчанию кейс-классы всегда должны определяться как final.

Пример:

final case class User(name: String, id: Long)

2.22 НЕ СЛЕДУЕТ использовать scala.App

scala.App часто используется для обозначения точки входа приложения:

object HelloWorldApp extends App {
  println("hello, world!")
}

DelayedInit, один из механизмов, используемых для реализации scala.App, устарел. Любые переменные, определенные в теле объекта, будут доступны как поля, если не будет применен модификатор private. Предпочитайте более простую альтернативу определения основного метода:

object HelloWorldApp {
  def main(args: Array[String]): Unit = println("hello, world!")
}

3. Архитектура приложения

3.1. НЕ СЛЕДУЕТ использовать Cake Pattern

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

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

Люди неправильно реализуют Cake, поскольку это трудно понимаемый шаблон проектирования. Я не видел реализаций Cake, в которых trait-ы спроектированы как абстрактные модули или в которых уделяется должное внимание проблемам жизненного цикла. На практике происходит неряшливость, в результате чего получается большой комок шерсти. Замечательно, что Scala позволяет делать такие вещи, как Cake Pattern, подчеркивая реальную мощь ООП, но то, что вы можете, не означает, что вы должны это делать, потому что, если целью является внедрение зависимостей и развязка между различными компонентами, то вы потерпите неудачу и возложите бремя обслуживания на своих коллег.

Например, в Cake это обычное явление:

trait SomeServiceComponent {
  type SomeService <: SomeServiceLike
  val someService: SomeService // abstract

  trait SomeServiceLike {
    def query: Rows
  }
}

trait SomeServiceComponentImpl extends SomeServiceComponent {
  self: DBServiceComponent =>

  val someService = new SomeService

  class SomeService extends SomeServiceLike {
    def query = dbService.query
  }
}

В приведенном выше примере someService фактически является singleton и подлинным, поскольку в нем, вероятно, отсутствует управление жизненным циклом. И если, прочитав этот код, ваши тревоги не были вызваны отсутствием единичного управления жизненным циклом, что ж, познакомьтесь с ужасным секретом большинства реализаций Cake. И те немногие сознательные, кто делает это правильно, попадают в ад инициализации JVM.

Но это не единственная проблема. Более серьезная проблема заключается в том, что разработчики ленивы, поэтому в итоге вы получаете огромные компоненты с десятками зависимостей и обязанностей, потому что Cake поощряет это. И после того, как первоначальные разработчики, которые нанесли этот ущерб, уходят из проекта, вы получаете другие, меньшие компоненты, которые дублируют функциональность исходных компонентов, просто потому, что оригинальные компоненты адски тестировать, потому что вам нужно имитировать или заглушить слишком много вещей (еще один запах кода). И у вас есть этот вечно повторяющийся цикл: разработчики в конечном итоге ненавидят базу кода, выполняют минимальный объем работы, необходимый для выполнения своих задач, и в конечном итоге получают другие большие, уродливые и фундаментально ошибочные компоненты. А из-за жесткой связи, которую естественным образом создает Cake, их будет нелегко реорганизовать.

Так зачем делать вышеизложенное, если что-то вроде этого гораздо более читабельно и имеет здравый смысл:

class SomeService(dbService: DBService) {
  def query = dbService.query
}

Или, если вам действительно нужны абстрактные вещи (но, пожалуйста, прочитайте правило 2.4 о том, как не определять бесполезные trait-ы):

trait SomeService {
  def query: Rows
}

object SomeService {
  /** Builder for [[SomeService]] */
  def apply(dbService: DBService): SomeService =
    new SomeServiceImpl(dbService)

  private final class SomeServiceImpl(dbService: DBService)
    extends SomeService {
    def query: Rows = dbService.query
  }
}

Ваши зависимости сходят с ума? Эти конструкторы начинают болеть? Это особенность. Это называется "развитие, основанное на боли" (сокращенно PDD :-)). Это признак того, что архитектура не в порядке, и различные библиотеки внедрения зависимостей или Cake pattern решают не проблему, а симптомы, пряча мусор под ковриком.

Поэтому отдайте предпочтение старым и надежным аргументам конструктора. А если вам действительно нужно использовать библиотеки внедрения зависимостей, делайте это по краям (как в контроллерах Play). Потому что, если компонент зависит от слишком многих вещей, этот код пахнет. Если компонент зависит от трудно инициализируемых аргументов, этот код пахнет. Если вам нужно имитировать или заглушать интерфейсы в ваших тестах только для проверки чистой бизнес-логики, вероятно, этот код пахнет ;-)

Не прячьте болезненные вещи под ковер, а исправьте их.

3.2. НЕЛЬЗЯ помещать вещи в Play's Global

Я вижу это снова и снова.

Ребята, глобальный объект Play (Play's Global) — это не ведро, в которое вы можете запихнуть потерянные фрагменты кода. Его цель — подключиться к конфигурации и жизненному циклу Play, не более того.

Придумайте собственное пространство имен для своих утилит.

3.3. НЕ СЛЕДУЕТ применять оптимизацию без профилирования

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

Это связано с тем, что наша интуиция о том, как ведет себя система, часто нас подводит, и применение оптимизаций без точных цифр может привести к множественным эффектам:

Доступно несколько стратегий, и вам желательно использовать их все:

В целом – измеряйте, а не гадайте.

3.4. СЛЕДУЕТ помнить о сборщике мусора

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

По словам Мартина Томсона, если вы нагружаете сборщик мусора, вы увеличиваете задержку при зависаниях системы и количество таких случаев, при этом сборщик мусора действует как GIL и, таким образом, ограничивает производительность и вертикальную масштабируемость.

Пример:

query.filter(_.someField.inSet(Set(name)))

Это пример, возникший в нашем проекте из-за проблемы с API Slick. Поэтому вместо теста === разработчик решил выполнить операцию inSet с последовательностью из 1 элемента. Такое выделение коллекции из 1 элемента происходит при каждом вызове метода. Это нехорошо, того, чего можно избежать, следует избегать.

Другой пример:

someCollection
  .filter(Set(a,b,c).contains)
  .map(_.name)

Прежде всего, это создает Set каждый раз для каждого элемента нашей коллекции. Во-вторых, filter и map можно сжать за одну операцию, иначе мы получим больше мусора и больше времени потратим на построение итоговой коллекции:

val isIDValid = Set(a,b,c)

someCollection.collect {
  case x if isIDValid(x) => x.name
}

Общий пример, который часто появляется и иллюстрирует бесполезные обходы и операторы, которые можно сжать:

collection
  .filter(bySomething)
  .map(toSomethingElse)
  .filter(again)
  .headOption

Кроме того, обратите внимание на свои требования и используйте структуру данных, подходящую для вашего случая использования. Вы хотите построить стек? Это List. Вы хотите проиндексировать список? Это Vector. Вы хотите добавить в конец списка? Это снова Vector. Вы хотите добавлять в начало и забирать с конца? Это Queue. У вас есть множество элементов и вы хотите проверить вхождение? Это Set. У вас есть множество элементов, которые вы хотите держать в заданном порядке? Это SortedSet. Это не ракетостроение, просто информатика 101.

Мы не говорим здесь о экстремальных микрооптимизациях, мы даже не говорим здесь о чем-то, специфичном для Scala, FP или JVM, но помните о том, что вы делаете, и старайтесь не создавать ненужных выделений, поскольку это намного сложнее позднее исправить.

Кстати, есть очевидное решение для сохранения выразительности при фильтрации и сопоставлении — ленивые коллекции, что в Scala означает LazyList, если вам нужна меморизация, или Iterable, если вам не нужна меморизация.

Также обязательно прочтите Правило 3.3 о профилировании.

3.5. НЕЛЬЗЯ использовать ConfigFactory.load() без параметров или напрямую обращаться к объекту Config

Может быть очень заманчиво вызвать метод ConfigFactory.load(), который очень доступен и не имеет параметров, всякий раз, когда вам нужно извлечь что-то из конфигурации, но это обернется против вас бумерангом, например, при написании тестов.

Если у вас есть ConfigFactory.load(), разбросанный по всем вашим классам, то они, в основном, загружают конфигурацию по умолчанию при запуске вашего кода, что чаще всего не то, чего вы действительно хотите во время тестирования, где вам нужно загружзить измененную конфигурацию (например, разные таймауты, разные реализации, разные IP-адреса и т.д.).

НИКОГДА не делайте этого:

class MyComponent {
  private val ip = ConfigFactory.load().getString("myComponent.ip")
}

Один из способов справиться с этим — передать сам экземпляр Config тому, кто в нем нуждается, или передать необходимые значения из него. Описанная здесь ситуация на самом деле является разновидностью предпочтительного внедрения зависимостей (DI) над Service Locator.

Вы можете вызвать ConfigFactory.load(), но из корня вашего приложения, скажем, в main() (или его эквиваленте), чтобы вам не приходилось жестко кодировать имя файла вашей конфигурации.

Еще одна хорошая практика — иметь классы конфигурации, специфичные для домена, которые анализируются из объектов Config общего назначения, похожих на ассоциативные массивы. Преимущество этого подхода заключается в том, что специализированные классы конфигурации точно отражают ваши конкретные потребности в конфигурации и после анализа позволяют вам работать со скомпилированными классами более типобезопасным способом (где "безопаснее" означает, что вы используете config.ip вместо config.getString("ip")).

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

Рассмотрим следующий пример:

/** Это класс конфигурации, специфичный для вашего домена, с заранее определенным набором 
  * свойств, которые вы смоделировали в соответствии с вашим доменом, 
  * а не мешок свойств в виде ассоциативного массива
  */
case class AppConfig(
  myComponent: MyComponentConfig,
  httpClient: HttpClientConfig
)

/** Configuration for [[MyComponent]] */
case class MyComponentConfig(ip: String)

/** Configuration for [[HttpClient]] */
case class HttpClientConfig(
  requestTimeout: FiniteDuration,
  maxConnectionsPerHost: Int
)

object AppConfig {
  /** Загрузка вашего конфига.
    * Используется из `main()` или его эквивалента.
    */
  def loadFromEnvironment(): AppConfig =
    load(ConfigUtil.loadFromEnvironment())

  /** Загрузка из заданного объекта Typesafe Config */
  def load(config: Config): AppConfig =
    AppConfig(
        myComponent = MyComponentConfig(
          ip = config.getString("myComponent.ip")
        ),
        httpClient = HttpClientConfig(
          requestTimeout = config.getDuration("httpClient.requestTimeout", TimeUnit.MILLISECONDS).millis,
          maxConnectionsPerHost = config.getInt("httpClient.maxConnectionsPerHost")
        )
    )
}

object ConfigUtil {
  /** Утилита для замены прямого использования ConfigFactory.load() */
  def loadFromEnvironment(): Config = {
    Option(System.getProperty("config.file"))
      .map(f => ConfigFactory.parseFile(f).resolve())
      .getOrElse(
        ConfigFactory.load(System.getProperty(
          "config.resource", "application.conf")))
  }
}

/** Один компонент */
class HttpClient(config: HttpClientConfig) {
  ???
}

/** Другой компонент, в зависимости от конфигурации вашего домена. 
  * Также обратите внимание на разумное внедрение зависимостей ;-)
  */
class MyComponent(config: MyComponentConfig, httpClient: HttpClient) {
  ???
}

Преимущества этого подхода:

ПРИМЕЧАНИЕ о стиле: эти кейс-классы конфигурации имеют тенденцию становиться большими и содержать примитивы (например, целые числа, строки и т.д.), поэтому использование именованных параметров делает код более устойчивым к изменениям и менее подверженным ошибкам по сравнению с использованием позиционирования. Выбранный здесь стиль отступов делает экземпляр похожим на Map или объект JSON, если хотите.

4. Конкурентность и параллелизм

4.1. СЛЕДУЕТ избегать параллелизма, как чумы

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

ВНИМАНИЕ: проблемы параллелизма возникают не только при использовании общей памяти и потоков, но и между процессами, когда возникает конкуренция за какой-либо ресурс (например, базу данных).

Пример: если задание запланировано на выполнение каждую минуту с использованием cron.d в Linux и это задание извлекает и обновляет элементы из очереди, сохраненной в MySQL, выполнение этого задания может занять больше 1 минуты, и, таким образом, вы можете получить 2 или 3 процесса, выполняющихся одновременно и конкурирующих в одной и той же таблице MySQL.

4.2. СЛЕДУЕТ использовать соответствующие абстракции только там, где это возможно — Future, Actors, Rx

Узнайте о доступных абстракциях и выбирайте между ними в зависимости от поставленной задачи. Не существует общего универсального решения. Чем выше уровень абстракции, тем меньше возможностей у нее при решении проблем. Но чем меньше возможностей и мощности, тем проще и компонуемее модель. Например, многие разработчики из сообщества Scala злоупотребляют Akka Actors — это здорово, но только не тогда, когда их неправильно применяют. Например, не используйте Akka Actor, когда можно использовать Future.

"Власть имеет тенденцию развращать, а абсолютная власть развращает абсолютно" — Лорд Эктон

Futures и Promises Scala хороши, потому что:

Futures и Promises плохи, потому что они сигнализируют только одно значение от производителя потребителю и все — если вам нужен поток или двунаправленная связь, Future может быть не лучшей абстракцией.

Akka Actor-ы хороши, потому что:

Акторы Акки плохи, потому что:

Потоковые абстракции, такие как Play's Iteratees / Akka Streams / RxJava / Reactive Streams / FS2 / Monix, хороши, потому что:

Потоки плохи, потому что:

Посмотрите презентацию Runar Bjarnason на эту тему, потому что она потрясающая: Ограничения освобождают, свободы ограничивают.

4.3. НЕ СЛЕДУЕТ заключать чистые операции, связанные с ЦП, в стандартные фьючерсы Scala

В целом это антипаттерн:

def add(x: Int, y: Int) = Future { x + y }

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

Кроме того, если вы хотите инициализировать Future[T] константой, всегда используйте Future.successful().

4.4. НЕОБХОДИМО использовать Scala BlockContext для блокировки ввода-вывода

Сюда входят все блокирующие операции ввода-вывода, включая SQL-запросы. Реальный образец:

Future {
  DB.withConnection { implicit connection =>
    val query = SQL("select * from bar")
    query()
  }
}

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

Вот упрощенный пример, демонстрирующий проблему в поучительных целях:

implicit val ec = ExecutionContext
  .fromExecutor(Executors.newFixedThreadPool(1))

def addOne(x: Int) = Future(x + 1)

def multiply(x: Int, y: Int) = Future {
  val a = addOne(x)
  val b = addOne(y)
  val result = for (r1 <- a; r2 <- b) yield r1 * r2

  // это зайдет в тупик 
  Await.result(result, Duration.Inf)
}

Этот пример упрощен, чтобы сделать эффект детерминированным, но это рано или поздно затронет все пулы потоков, настроенные с верхними границами.

Блокирующие вызовы должны быть помечены вызовом blocking, который сигнализирует BlockContext об операции блокировки. Это очень аккуратный механизм в Scala, который сообщает ExecutionContext о том, что происходит операция блокировки, так что ExecutionContext может решить, что с этим делать, например, добавить больше потоков в пул потоков (что и делает пул потоков ForkJoin в Scala).

Весь код необходимо просмотреть, и всякий раз, когда происходит блокирующий вызов, это исправление:

import scala.concurrent.blocking
// ...
blocking {
  someBlockingCallHere()
}
ПРИМЕЧАНИЕ. blocking вызов также служит документацией, даже если базовый пул потоков не поддерживает BlockContext, поскольку вещи, которые блокируют, совершенно неочевидны.

4.5. НЕ СЛЕДУЕТ блокировать

Иногда вам нужно заблокировать поток внизу — к сожалению, у JDBC нет неблокирующего API. Однако, если у вас есть выбор, никогда, никогда не блокируйте. Например, не делайте этого:

def fetchSomething: Future[String] = ???

// позднее ...
val result = Await.result(fetchSomething, 3.seconds)
result.toUpperCase

Предпочитаю полностью сохранять контекст этого Future:

def fetchSomething: Future[String] = ???

fetchSomething.map(_.toUpperCase)

Также проверьте Scala-Async, чтобы сделать это проще.

4.6. СЛЕДУЕТ использовать отдельный пул потоков для блокировки ввода-вывода

Что касается правила 4.4, если вы выполняете много блокирующих операций ввода-вывода (например, много вызовов JDBC), лучше создать второй пул потоков/контекст выполнения и выполнить все блокирующие вызовы на нем, оставив пул потоков приложения для работы с ресурсами, связанными с процессором.

Итак, вы можете инициализировать этот второй контекст выполнения, например:

import java.util.concurrent.Executors

// ...
private val ioThreadPool = Executors.newCachedThreadPool(
  new ThreadFactory {
    private val counter = new AtomicLong(0L)

    def newThread(r: Runnable) = {
      val th = new Thread(r)
      th.setName("eon-io-thread-" + counter.getAndIncrement.toString)
      th.setDaemon(true)
      th
    }
  })

Обратите внимание, что здесь я предпочитаю использовать неограниченный "кешированный пул потоков", поэтому у него нет ограничений. При блокировании ввода-вывода идея состоит в том, что у вас должно быть достаточно потоков, которые вы можете заблокировать. Но если неограниченность — это слишком много, в зависимости от варианта использования вы можете позже ее настроить. Идея этого примера состоит в том, что вы можете начать работу.

И тогда вы можете предоставить помощника, например:

def executeBlockingIO[T](cb: => T): Future[T] = {
  val p = Promise[T]()

  ioThreadPool.execute(new Runnable {
    def run() = try {
      p.success(blocking(cb))
    }
    catch {
      case NonFatal(ex) =>
        logger.error(s"Uncaught I/O exception", ex)
        p.failure(ex)
    }
  })

  p.future
}

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

4.7. Все общедоступные API ДОЛЖНЫ БЫТЬ поточно-ориентированными

Как общее правило разработки программного обеспечения поверх JVM: абсолютно все общедоступные API внутри вашего процесса в конечном итоге будут использоваться в контексте, в котором несколько потоков используют эти API в одно и то же время. Это лучшая практика для всех общедоступных API (все компоненты в вашем Cake, например) с самого начала быть спроектированным как потокобезопасное прохождение, потому что вы не хотите гоняться за ошибками параллелизма.

А если публичный API по какой-то причине не является потокобезопасным (как обычно компромиссы, сделанные при разработке программного обеспечения), затем укажите этот факт ЖИРНЫМИ ЗАГЛАВНЫМИ БУКВАМИ.

Причина проста: если API не является потокобезопасным, то и нет способа, которым пользователь этого API может узнать о способе его синхронизации.

Пример:

val list = mutable.List.empty[String]

Допустим, этот изменяемый список открыт. По какой блокировке пользователь должен синхронизироваться? В самом списке? Этого недостаточно: это довольно бесполезная блокировка в более широком контексте. Помните, что блокировки не являются составными и очень подвержены ошибкам. Никогда не перекладывайте ответственность за синхронизацию из-за конфликтов на своих пользователей.

4.8. СЛЕДУЕТ избегать конфликтов при совместном чтении

Знакомьтесь: Закон Амдала. Синхронизация с блокировками резко ограничивает возможное распараллеливание и, таким образом, вертикальную масштабируемость. Чтения досадно распараллеливаются, поэтому никогда не делайте этого:

def fetch = synchronized { someValue }

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

4.9. НЕОБХОДИМО предоставить четко определенный и документированный протокол для каждого компонента или субъекта, который взаимодействует через границы асинхронности

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

В качестве рекомендации не уклоняйтесь от написания комментариев и документирования:

4.10. СЛЕДУЕТ всегда отдавать предпочтение сценариям с одним производителем

Совместная запись не подлежит распараллеливанию, тогда как совместное чтение досадно параллелизуемо. Метафора: 100 000 человек смогут посмотреть тот же футбольный матч на том же стадионе в то же время (читает), но 100 000 человек не могут пользоваться одной и той же ванной (пишет). В многопоточном сценарии предпочтительно один производитель / много потребителей. Это позволяет избежать конфликтов и проблем с производительностью. Приложение не масштабируется вертикально, когда несколько производителей работают над одним и тем же ресурсом, из-за закона Амдала.

Проверьте LMAX Disruptor.

4.11. НЕЛЬЗЯ жестко запрограммировать пул потоков/контекст выполнения

Это общая проблема дизайна, связанная с проектом в целом, но не делайте этого:

import scala.concurrent.ExecutionContext.Implicits.global

def doSomething: Future[String] = ???

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

Просто передайте ExecutionContext как неявный параметр. Это идиоматично и приемлемо. Кроме того, неявные параметры следует передавать во второй группе параметров, чтобы не запутать неявное разрешение. При передаче в первой группе он позволяет вызывать такие методы, как doSomething(), которые не будут компилироваться, но большинство IDE покажут их действительными.

def doSomething()(implicit ec: ExecutionContext): Future[String] = ???

5. Akka акторы

5.1. СЛЕДУЕТ выделять состояние акторов только в ответ на сообщения, полученные извне

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

class SomeActor extends Actor {
  private var counter = 0
  private val scheduler = context.system.scheduler
    .schedule(3.seconds, 3.seconds, self, Tick)

  def receive = {
    case Tick =>
      counter += 1
  }
}

В приведенном выше примере актор назначает Tick каждые 3 секунды, который меняет его состояние. Это чрезвычайно дорогостоящая ошибка. Поведение актора становится совершенно недетерминированным, и его невозможно проверить правильно.

Если вам действительно нужно периодически что-то делать внутри актора, то этот планировщик не должен инициализироваться внутри актора. Выньте это.

5.2. СЛЕДУЕТ изменять состояние акторов только с помощью context.become

Скажем, у нас есть актор, который изменяет свое состояние (большинство акторов так делают), даже не имеет значения, какое это состояние:

class MyActor extends Actor {
  val isInSet = mutable.Set.empty[String]

  def receive = {
    case Add(key) =>
      isInSet += key
      
    case Contains(key) =>
      sender() ! isInSet(key)
  }
}

// Сообщения
case class Add(key: String)
case class Contains(key: String)

Поскольку мы используем Scala, мы хотим быть максимально чистыми, мы хотим иметь дело с неизменяемыми структурами данных и чистыми функциями, мы хотим перейти на FP, чтобы уменьшить область для случайной сложности, и позвольте мне сказать вам, в вышеизложенном нет ничего чистого, неизменного или ссылочно прозрачного ;-)

Посмотрите context.become:

import collection.immutable.Set

class MyActor extends Actor {
  def receive = active(Set.empty)

  def active(isInSet: Set[String]): Receive = {
    case Add(key) =>
      context become active(isInSet + key)
      
    case Contains(key) =>
      sender() ! isInSet(key)
  }
}

Если это вас не сразу насторожило, просто подождите, пока вам придется смоделировать конечный автомат с 10 состояниями и десятками возможных переходов и эффектов, связанных с ним, и тогда вы это получите.

5.3. НЕЛЬЗЯ раскрывать внутреннее состояние актора в асинхронных замыканиях

Опять же, используя изменяемое состояние, определите проблему:

class MyActor extends Actor {
  val isInSet = mutable.Set.empty[String]

  def receive = {
    case Add(key) =>
      for (shouldAdd <- validate(key)) {
        if (shouldAdd) isInSet += key
      }
        
    // ...
  }
  
  def validate(key: String): Future[Boolean] = ???
}

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

Прежде всего, ознакомьтесь с правилом использования context.become для изменения состояния, что уже является шагом в правильном направлении. И тогда вам нужно разобраться с этим, отправив нашему актору еще одно сообщение, когда наше будущее наступит:

import akka.pattern.pipeTo

class MyActor extends Actor {
  val isInSet = mutable.Set.empty[String]

  def receive = {
    case Add(key) =>
      val f = for (isValid <- validate(key))
        yield Validated(key, isValid)
        
      // отправка результата в виде сообщения обратно нашему актору
      f pipeTo self

    case Validated(key, isValid) =>
      if (isValid) isInSet += key
              
    // ...
  }
  
  def validate(key: String): Future[Boolean] = ???
}

// Сообщения
case class Add(key: String)
case class Validated(key: String, isValid: Boolean)

И, конечно же, мы могли бы моделировать конечный автомат, который не принимает больше запросов, пока не будет выполнен последний. Давайте также избавимся от этой изменяемой коллекции и введем back-pressure (т.е. нам нужно сообщить отправителю, когда он сможет отправить следующий элемент):

import akka.pattern.pipeTo

class MyActor extends Actor {
  def receive = idle(Set.empty)

  def idle(isInSet: Set[String]): Receive = {
    case Add(key) =>
      // отправка результата в виде сообщения обратно нашему актору
      validate(key).map(Validated(key, _)).pipeTo(self)
      
      // ожидание подтверждения
      context.become(waitForValidation(isInSet, sender()))
  }

  def waitForValidation(set: Set[String], source: ActorRef): Receive = {
    case Validated(key, isValid) =>
      val newSet = if (isValid) set + key else set
      // отправка подтверждения о завершении
      source ! Continue
      // вернуться в режим ожидания, принимая новые запросы
      context.become(idle(newSet))

    case Add(key) =>
      sender() ! Rejected
  }

  def validate(key: String): Future[Boolean] = ???
}

// Сообщения
case class Add(key: String)
case class Validated(key: String, isValid: Boolean)
case object Continue
case object Rejected

Да, проекты, основанные на акторах, могут оказаться непростыми.

5.4. СЛЕДУЕТ делать back-pressure

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

Проблемы:

  1. если очередь сообщений неограничена, то при медленных потребителях эта очередь может взорваться
  2. Распределение может быть неэффективным, так как у работника может оказаться несколько ожидающих выполнения элементов, в то время как другой работник может стоять на месте

Правильный и беспроблемный дизайн делает следующее:

Вот подробный образец с комментариями:

/**
 * Сообщение, означающее подтверждение того, 
 * что восходящий поток может отправить следующий элемент.
 */
case object Continue

/**
 * Сообщение, используемое производителем 
 * для непрерывного опроса источника данных в состоянии опроса.
 */
case object PollTick

/**
 * Машина состояний с 2 состояниями:
 *
 *  - Ожидание, что означает, что, вероятно, существует очередь элементов, 
 *    ожидающих отправки в нисходящий поток, но актор ожидает сигнала о требованию.
 *    
 *  - Опрос, что означает, что есть спрос со стороны нисходящего потока, 
 *    но актор ждет, пока что-то произойдет.
 *
 * ВАЖНО: в соответствии с протоколом этот актор не должен получать несколько событий Continue — 
 *        нижестоящий маршрутизатор должен дождаться доставки элемента, 
 *        прежде чем отправлять следующее событие Continue этому актору.
 */
class Producer(source: DataSource, router: ActorRef) extends Actor {
  import Producer.PollTick

  override def preStart(): Unit = {
    super.preStart()
    // это игнорирует еще одно правило, которое меня волнует
    // (акторы должны выделяться только в ответ на внешние сообщения), 
    // но мы оставим это в обучающих целях
    context.system.scheduler.schedule(1.second, 1.second, self, PollTick)
  }

  // актор начинает работу в режиме ожидания
  def receive = standby

  def standby: Receive = {
    case PollTick =>
      // игнорировать

    case Continue =>
      // сигнал о спросе, поэтому попробуйте отправить следующий элемент
      source.next() match {
        case None =>
          // нет доступных позиций, перейдите в режим опроса
          context.become(polling)
          
        case Some(item) =>
          // элемент доступен, отправьте его вниз по течению 
          // и оставайтесь в состоянии ожидания
          router ! item
      }
  }

  def polling: Receive = {
    case PollTick =>
      source.next() match {
        case None =>
          () // игнорировать - остается в опросе
        case Some(item) =>
          // элемент в наличии, спрос имеется
          router ! item
          // перейти в режим ожидания
          context.become(standby)
      }
  }
}

/**
 * Маршрутизатор является посредником между вышестоящим производителем и работниками, 
 * отслеживая спрос (чтобы упростить работу производителя).
 *
 * ПРИМЕЧАНИЕ. Необходимо соблюдать протокол производителя, 
 *       поэтому мы сигнализируем Continue вышестоящему производителю после 
 *       и только после того, как элемент был отправлен в нисходящий поток 
 *       для обработки работнику.
 */
class Router(producer: ActorRef) extends Actor {
  var upstreamQueue = Queue.empty[Item]
  var downstreamQueue = Queue.empty[ActorRef]

  override def preStart(): Unit = {
    super.preStart()
    // сигнализирует о первоначальном спросе на upstream
    producer ! Continue
  }

  def receive = {
    case Continue =>
      // сигнал о спросе поступает из нисходящего потока, 
      // если у нас есть элементы для отправки, то отправьте его, 
      // в противном случае поставьте в очередь нижестоящего потребителя
      if (upstreamQueue.isEmpty) {
        downstreamQueue = downstreamQueue.enqueue(sender)
      }
      else {
        // нет необходимости сигнализировать о спросе в восходящем направлении, 
        // поскольку у нас есть элементы в очереди, просто отправьте их в нисходящий поток
        val (item, newQueue) = upstreamQueue.dequeue
        upstreamQueue = newQueue
        sender ! item

        // сигнализировать о спросе на другой элемент
        producer ! Continue
      }

    case item: Item =>
      // элемент, сигнализируемый из восходящего потока, если у нас есть потребители в очереди, 
      // тогда сигнализируйте об этом в нисходящем направлении, 
      // в противном случае поставьте его в очередь
      if (downstreamQueue.isEmpty) {
        upstreamQueue = upstreamQueue.enqueue(item)
      }
      else {
        val (consumer, newQueue) = downstreamQueue.dequeue
        downstreamQueue = newQueue
        consumer ! item

        // сигнализировать о спросе на другой элемент
        producer ! Continue
      }
  }
}

class Worker(router: ActorRef) extends Actor {
  override def preStart(): Unit = {
    super.preStart()
    // сигнализирует о первоначальном спросе на upstream
    router ! Continue
  }
  
  def receive = {
    case item: Item =>
      process(item)
      router ! Continue
  }
}

5.5. НЕ СЛЕДУЕТ использовать Akka FSM

Akka представляет то, что многие считают крутым DSL для создания конечных автоматов, под названием Akka FSM.

Но он неадекватен, ограничивает и его использование приводит к плохим практикам. Текущие проекты должны попытаться заменить его, а новые проекты должны его избегать. Вместо этого предпочитайте моделировать конечные автоматы с помощью context.become.

Три основные причины, по которым вам следует избегать Akka FSM:

  1. с Akka FSM вы можете моделировать только детерминированные конечные автоматы (DFA)
  2. Akka FSM вызывает нечистую, побочную логику в вашем акторе
  3. Akka FSM связывает вашу бизнес-логику с Akka, что затрудняет тестирование

Итак, чтобы объяснить это рассуждение. С помощью Akka FSM вы можете моделировать только детерминированные конечные автоматы, и в какой-то момент это приведет к неприятностям. Небольшой образец:

when (Available) {
  case Event(Setpoint(value), data) =>
    goto(Ramping) using data.copy(setpoint=value)
}

when (Ramping) {
  case Event(RampIsOver, data) =>
    goto(Dispatched)
}

onTransition {
  case Available -> Ramping =>
    logger.info("Ramping ...")
  case Ramping -> Dispatched =>
    logger.info("Dispatched ...")
}

Это DFA. Но большая проблема, которая возникнет, заключается в том, что для любого достаточно сложного конечного автомата вам понадобится инициировать несколько переходов в ответ на одно сообщение. Другими словами, в конечном итоге вам захочется сделать что-то вроде этого:

firstGoto(Ramping).thenGoto(Dispatched)

Это выдуманный API. Akka FSM это не поддерживает. И на этом этапе вам нужно будет форкнуть Akka FSM, чтобы получить его. Это, конечно, выполнимо. Но если вы в конечном итоге используете Akka FSM для своего проекта, вы явно допустили ошибку, выбрав Akka FSM. И, конечно же, большинство людей никогда не подумают о форке Akka FSM, поэтому в конечном итоге им придется столкнуться с труднопроверяемыми и необоснованными хаками, такими как передача этому актору дополнительных внутренних сообщений.

Другая причина, по которой Akka FSM неадекватна, заключается в том, что он заставляет вас моделировать конечный автомат как вещь, которая изменяет свое состояние. По сути, в итоге вы получите следующее:

Позвольте мне объяснить следующее: результат будет хуже, чем худший, который вы когда-либо видели при использовании ООП. Поскольку это будет объект, состояние которого зависит от его истории (например, от идентификатора), и поскольку связь является асинхронной, введение nondeterminism в эту логику действительно очень просто.

Это подводит меня к тому, что Akka FSM связывает вашу бизнес-логику с Akka, которая по определению является комплементарной. Это плохо, потому что:

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

Решение состоит в том, чтобы ваша бизнес-логика описывалась вне какого-либо актора и чтобы акторы отвечали только за коммуникации, желательно развивать с помощью context.become, как упоминалось в пункте 5.2. В этот момент вам больше не нужно тестировать этих акторов, вам больше не нужно зависеть от akka-testkit. Но такая стратегия полностью исключит Akka FSM.


Ссылки: