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

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

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

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

Делая это, я заметил пару вещей…

Код стал уродливее

В Scala последнее значение, вычисленное в функции, является возвращаемым значением.
Это приводит к лаконичному коду, например:

val calculatePrice = (price: Price)                      =>
                     (maybeShippingPrice: Option[Price]) =>
  maybeShippingPrice.map(p => p + price)

Однако с ведением журнала он становится намного громоздким:

val calculatePrice = (price: Price)                      =>
                     (maybeShippingPrice: Option[Price]) => {
  val maybeTotal = maybeShippingPrice.map(p => p + price)
  if (maybeTotal.isEmpty) {
    logger.info("No shipping price available")
  }
  maybeTotal
}

В журнале был шаблон

Я продолжал сталкиваться с той же закономерностью в логировании, что и выше, — в целом:

def exampleFunction[T](arg: T): String = {
  val result = calculateStringFromT(arg)
  if (someTestOn(result)) {
    logger.error(s"The test on result was true - arg was [$arg]")
  }
  result
}

Это требовало обобщения.

Кроме того, было бы здорово, если бы мы могли сделать его менее уродливым¹.

Участвующие типы

Разбивая наш exampleFunction, мы имеем:

  • Результат
  • Условие для регистрации на основе результата
  • Средство регистрации строки
  • Строка для журнала

Результатом может быть что угодно, поэтому обозначим это переменной типа T.

Условие является предикатом:

type LoggingPredicate[T] = T => Boolean

Средство ведения журнала — это процедура², которая принимает строку:

type Logger = String => Unit

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

type MessageGen[T] = T => String

Первый удар по функции ведения журнала

Учитывая эти типы, мы можем начать создавать функцию ведения журнала:

def log[T]( t         : T,
            shouldLog : LoggingPredicate[T],
            logger    : Logger,
            msgGen    : MessageGen[T] ): T = {
  if (shouldLog(t)) logger(msgGen(t))
  t
}

Улучшение вещей с частичными функциями

Мы можем объединить функциональность предиката ведения журнала и генератора сообщений, сделав генератор сообщений частичной функцией:

type MessageGen[T] = PartialFunction[T, String]

Частичная функция определяется только для подмножества ее возможных входных данных. Мы используем это для регистрации String, которое он возвращает, когда он определен, и пропускаем регистрацию, когда он не определен:

def log[T]( t     : T,
            msgGen: MessageGen[T],
            logger: Logger ): T = {
  msgGen.lift(t).foreach(msg => logger(msg))
  t
}

Мы не хотим получить исключение, если частичная функция не определена, поэтому мы вызываем для нее lift(t). Это преобразует частичную функцию в простую функцию, которая возвращает None, если переданный параметр не находится в домене частичной функции, и Some(msg), если это так. Только в случае последнего вызывается регистратор.

Использование функции ведения журнала

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

Во-первых, давайте настроим Loggers³

val info    : Logger = s => logger.info(s)
val warning : Logger = s => logger.warn(s)
val error   : Logger = s => logger.error(s)

Теперь мы можем обновить пример функции, чтобы использовать нашу новую функцию log:

def exampleFunction[T](arg: T): String = {
  val result = calculateStringFromT(arg)
  val msgGen: MessageGen[String] = {
    case "" => s"The result was empty - arg was [$arg]"
  }
  log(result, msgGen, info)
}

Обратите внимание, что MessageGen теперь является частичной функцией, что обеспечивает элегантную реализацию с использованием синтаксиса сопоставления с образцом.

Если мы также переместим его за пределы функции, мы сможем еще больше прояснить ситуацию:

private def msgGen[T](t: T): MessageGen[String] = {
  case "" => s"The result was empty - arg was [$t]"
}

def exampleFunction[T](arg: T): String = {
  val result = calculateStringFromT(arg)
  log(result, msgGen(arg), logger.info)
}

Теперь он начинает выглядеть лучше! Однако функция журнала плохо передает свои намерения читателю-человеку. То, что мы пытаемся сказать по-английски, это:

Log this item using this message generator at level info, then return it.

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

Немного синтаксического сахара для улучшения читабельности

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

Во-первых, давайте каррируем функцию log:

def log[T](t: T): MessageGen[T] => Logger => T = {
                  msgGen        => logger =>
  msgGen.lift(t).foreach(msg => logger(msg))
  t
}

Каррирование функции log превратило ее из функции, которая принимает три параметра и возвращает значение T, в функцию, которая принимает один параметр и возвращает другую функцию MessageGen[T] => Logger => T.

Итак, теперь вместо вызова:

log(result, msgGen(arg), logger.info)

Мы называем:

log(result)

и отложить предоставление остальных параметров на потом.

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

implicit class MessageGenOps[T](f: MessageGen[T] => Logger => T) {
  def using(msgGen: MessageGen[T]): Logger => T = f(msgGen)
}

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

log(item).using(msgGen)

Предоставив параметр MessageGen типа возвращаемого значения метода log, у нас осталось Logger => T.

Итак, мы делаем еще один неявный класс:

implicit class LoggerOps[T](f: Logger => T) {
  def atLevel(logger: Logger): T = f(logger)
}

Теперь мы можем написать log(item).using(msgGen).atLevel(info), что почти готово, нам просто нужен еще один неявный класс:

implicit class SelfHelper[T](t: T) {
  def andReturnIt: T = t
}

Это может показаться странным классом — у него нет никакой функциональности! Однако это позволяет нам изящно завершить нашу беглую цепочку вызовов:

log(item).using(msgGen).atLevel(info).andReturnIt

Сравните это с нашим английским предложением:

Log this item using this message generator at level info, then return it.

Довольно близко, а?

Регистрация параметризованных типов

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

val msgGen: MessageGen[Option[Price]] = { 
  case Some(price) if price> 50 => "Expensive shipping"
}
val calculatePrice = (price: Price)                      =>
                     (maybeShippingPrice: Option[Price]) => {
  val maybeTotal = maybeShippingPrice.map(p => p + price)
  log(maybeTotal).using(msgGen).atLevel(info).andReturnIt
}

Здесь мы регистрируем значение типа Option[T]. Это параметризованный тип, часто называемый универсальным⁴.

Обратите внимание, что предикат price > 50 действительно находится во внутреннем значении Price, но поскольку log введено:

def log[T](t: T): MessageGen[T] => Logger => T

это связывает T с Option[Price], поэтому нам нужно определить наш генератор сообщений как MessageGen[Option[Price]].

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

Однако есть важный тип, для которого этот подход вообще не работает.

Будущее

Future представляет собой значение, которое все еще находится в процессе расчета. Мы не можем создать MessageGen[Future[T]], потому что он будет заблокирован до тех пор, пока не будет заполнено будущее значение.

К счастью, оказывается, что Future имеет функцию, которая примет процедуру в качестве параметра и выполнит ее, как только значение будет сгенерировано:

def foreach[U](f: (T) => U)
              (implicit executor: ExecutionContext): Unit

Это говорит о том, что мы должны создать функцию регистрации для Future, используя foreach:

def logF[T](ft: Future[T])(implicit ec: ExecutionContext):
  MessageGen[T] => Logger => Future[T] = {
  msgGen        => logger => {
    ft.foreach(t => msgGen.lift(t).foreach(msg => logger(msg)))
    ft
  }
}

Как только Future разрешается, она вызывает функцию, переданную foreach, и регистрирует если будущее значение находится в домене msgGen.

Регистратор для параметризованных типов

Мы можем попытаться обобщить этот будущий регистратор на все типы отдельных параметров:

def logEachBad[T, F[_]](ft: F[T])
                       : MessageGen[T] => Logger => F[T] = {
                         msgGen        => logger =>
    ft.foreach(t => log(t)(msgGen)(logger))
    ft
  }

но это не скомпилируется, потому что нет доказательств того, что F[T] имеет функцию foreach.

Обобщение ForEach с использованием классов типов

Это интересно, потому что Option, List и т. д. действительно имеют foreach функций. Проблема в том, что нет trait, общего между этими типами и Future, который мы могли бы использовать в качестве типа, привязанного к F[_], чтобы доказать это.

Но мы можем сделать один:

trait ForEach[T, F[_]] {
  def forEach(ft: F[T]): (T => Unit) => Unit
}

ForEach⁵ — это класс типов, который мы будем использовать для предоставления F[_] функциональности forEach⁶ для List, Future, Option и т. д.

Реализации тривиальны и используют преобразование SAM⁷, что позволяет нам реализовать единственный абстрактный метод трейта ForEach с лямбдой:

implicit def optionForEach[T]: ForEach[T, Option] =
  (t: Option[T]) => t.foreach

Реализация для List, Set, Option и др. на самом деле все они будут идентичными, потому что они имеют общую черту — IterableOnce — которая содержит их метод foreach. Таким образом, мы можем заменить вышеуказанное на:

implicit def itrForEach[T, F[A] <: IterableOnce[A]]: ForEach[T, F] =
  (ft: F[T]) => ft.iterator.foreach

Что позволяет нам охватить List, Set, Option и все остальные подклассы IterableOnce.

Это, в сочетании с реализацией для Future:

implicit def futureForEach[T](implicit ec: ExecutionContext)
                             : ForEach[T, Future] =
  (ft: Future[T]) => ft.foreach

должен охватывать большинство случаев.

Каждая реализация помечена как implicit. Мы можем понять почему, когда посмотрим на обновленное определение logEach:

def logEach[T, F[_]](ft: F[T])
                    (implicit fe: ForEach[T, F])
                    : MessageGen[T] => Logger => F[T] =
                      msgGen        => logFn  => {
  fe.forEach(ft)(t => log(t)(msgGen)(logFn))
  ft
}

Когда клиент хочет зарегистрировать Future, значение implicitForEach должно быть доступно в неявной области. Затем компилятор предоставит его как параметр ForEach[T, F].

Это специальный полиморфизм, и это круто!

Мы можем убедиться, что все экземпляры находятся в неявной области видимости, поместив их в компаньон-объект ForEach⁸:

object ForEach {
  implicit def futureForEach ...
}

Затем, просто импортировав сам ForEach, мы поместим все неявные экземпляры в область видимости:

import util.ForEach

val intF: Future[Int] = calculateFancyInt()
val msgGenInt: MessageGen[Int] = { case 0 => "Zero returned!" }
logEach(intF).using(msgGenInt).atLevel(info).andReturn

Обратите внимание, что в logEach тип MessageGenerator — T и тип возвращаемого значения logEachF[T] отличаются, в отличие от log, в котором они одинаковы.

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

implicit class MessageGenOps2[T, R]
  (f: MessageGen[T] => Logger => R) {
    def using(msgGen: MessageGen[T]): Logger => R = f(msgGen)
}

Еще одно - Либо

Как следует из названия, значение Either[A, B] может быть либо Left(a: A), либо Right(b: B).

У него есть метод foreach, который немного похож на Option в том смысле, что если Either является Right(b), он предоставит это значение предоставленной процедуре, но если это Left, он вообще не будет вызывать процедуру.

Проблема в том, что его конструктор типа принимает два параметра типа, поэтому он не соответствует шаблонуForEach[T, F[_]], который ожидает только один.

Мы можем решить эту проблему, установив параметр A⁹, например, на Error:

type ErrorOrT[T] = Either[Error, T]

Затем:

implicit def errorOrTForEach[T]: ForEach[T, ErrorOrT] =
  (ft: ErrorOrT[T]) => ft.foreach

Мы можем использовать аналогичный трюк, чтобы регистрировать вложенный тип¹⁰, используя внутреннее значение, например:

type FutureOption[T] = Future[Option[T]]
implicit def futureOptionForEach[T](implicit ec: ExecutionContext)
                                   : ForEach[T, FutureOption] =
  (ft:   FutureOption[T]) => 
  (proc: T => Unit)       => ft.foreach(opt => opt.foreach(proc))

Это позволит нам зарегистрировать Future[Option[T]], используя MessageGen[T]¹¹

Выводы

Логирование важно. Ваша служба поддержки по вызову поблагодарит вас за это, если все будет сделано хорошо (и проклянет вас, если это не так).

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

Так что никаких оправданий!

¹ Эстетика кода, конечно, чисто субъективна.

² Мы определяем процедуру как любую функцию, которая возвращает Unit.

³ Здесь мы используем play.api.Logger, который скрыто использует slf4j.

⁴ Это параметрический полиморфизм.

ForEach немного более ограничен, чем foreach методы Future, Option и т. д. Они ожидают функцию T => U там, где нам требуется T => Unit. На практике причина того, что переменная типа называется U, заключается в том, что она будет отброшена, даже если она не Unit, поэтому вы можете передать функцию, скажем, T => String в foreach, но вы никогда не знаешь, что такое значение String.

⁶ Я никогда не понимал, почему foreach — это все один случай в библиотеках Scala.

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

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

⁹ Или мы могли бы использовать добрый проектор.

¹⁰ Future[Option[T]] не является типом более высокого порядка, поскольку Future определяется как Future[T]не Future[F[_]], просто в этом случае T привязан к универсальному типу.

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