Pull to refresh
1308.2
OTUS
Цифровые навыки от ведущих экспертов

Накапливайте ошибки в Scala с помощью typelevel cats

Reading time8 min
Views786
Original author: Julien Truffaut

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

Все примеры кода вы можете найти в данном репозитории github.

Проблема

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

case class CreateOrderRequest(
  ticker  : String,
  quantity: Long,
  expiry  : Option[LocalDate],
)

Тикер (ticker) — это идентификатор финансового инструмента. Количество (quantity) представляет собой желаемое число инструментов. Наконец, окончание срока (expiry) — это необязательное поле, которое сообщает нам, когда запрос считается валидным.

Мы хотим проверить следующие три ограничения:

  1. Тикер не должен быть пустым

  2. Количество должно быть положительным

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

Давайте реализуем эти правила:

def validateTicker(ticker: String): Either[String, String] =
  if(ticker.isEmpty)
    Left("Ticker cannot be empty")
  else
    Right(ticker)
​
def validateQuantity(quantity: Long): Either[String, Long] =
  if(quantity <= 0)
    Left("Quantity must be positive")
  else
    Right(quantity)

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

enum Expiry {
  case Never
  case ValidUntil(date: LocalDate)
} 
​
def validateExpiry(optExpiry: Option[LocalDate], today: LocalDate): Either[String, Expiry] =
  optExpiry match {
    case None         => Right(Expiry.Never)
    case Some(expiry) =>
      val min = today
      val max = today.plusMonths(1)
      if (expiry.isBefore(min) || expiry.isAfter(max))
        Left(s"Expiry must be between $min and $max")
      else
        Right(Expiry.ValidUntil(expiry))
  }

После того, как мы имплементировали эти правила валидации, можно объединить их с помощью for-comprehension (синтаксис for-выражения):

def validateOrder(request: CreateOrderRequest, today: LocalDate): Either[String, Order] =
    for {
      ticker   <- validateTicker(request.ticker)
      quantity <- validateQuantity(request.quantity)
      expiry   <- validateExpiry(request.expiry, today)
    } yield Order(ticker, quantity, expiry) 

Теперь давайте попробуем:

validateOrder(
  CreateOrderRequest(
    ticker   = "AAPL",
    quantity = 10,
    expiry   = None,
  ),
  LocalDate.of(2023,4,24),
)
// res = Right(Order("AAPL", 10, Expiry.Never)))
​
​
validateOrder(
  CreateOrderRequest(
    ticker   = "AAPL",
    quantity = -2,
    expiry   = None,
  ),
  LocalDate.of(2023,4,24),
)
// res = Left("Quantity must be positive"))

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

validateOrder(
  CreateOrderRequest(
    ticker   = "",
    quantity = -2,
    expiry   = Some(LocalDate.of(2022, 1, 1)),
  ),
  LocalDate.of(2023,4,24),
)
// res = Left("Ticker cannot be empty"))

Мы видим только первую ошибку, потому что for-comprehension по своей природе является последовательным, то есть он выполняет следующую строку только в том случае, если предыдущая строка была заполнена некоторыми данными. Поэтому, если мы хотим накапливать ошибки, то не можем использовать for-comprehension или </span><span style="font-size:10pt;font-family:'Roboto Mono',monospace;color:#000000;background-color:transparent;font-weight:700;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">flatMap, нужно использовать другой метод. Но какой?

Решение 1: parMapN

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

Весь код для этого раздела можно найти здесь

Мы собираемся заменить for-comprehension на parMapN, который будет мерджить все ошибки вместе, если они есть.

import cats.implicits.*
​
def validateOrder(request: CreateOrderRequest, today: LocalDate): Either[String, Order] =
  (
    validateTicker(request.ticker),
    validateQuantity(request.quantity),
    validateExpiry(request.expiry, today),
    ).parMapN(
    (ticker, quantity, expiry) => Order(request.id, ticker, quantity, expiry)
  )

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

validateOrder(
  CreateOrderRequest(
    ticker   = "",
    quantity = -2,
    expiry   = Some(LocalDate.of(2022, 1, 1)),
  ),
  LocalDate.of(2023,4,24),
)
// res = Left("Ticker cannot be emptyQuantity must be positiveExpiry must be between 2023-04-24 and 2023-05-24")

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

Решение 2: parMapN с List

Давайте обновим наши три функции валидации так, чтобы они возвращали List (список ошибок).

def validateTicker(ticker: String): Either[List[String], String] =
  if(ticker.isEmpty)
    Left(List("Ticker cannot be empty"))
  else
    Right(ticker)

Мы также делаем аналогичное обновление для validateQuantity и validateExpiry. Весь код для этого раздела вы можете найти здесь.

Нам также нужно изменить тип ошибки в validateOrder на List, но в остальном тело функции остается прежним:

def validateOrder(request: CreateOrderRequest, today: LocalDate): Either[List[String], Order] =
  (
    validateTicker(request.ticker),
    validateQuantity(request.quantity),
    validateExpiry(request.expiry, today),
    ).parMapN(
    (ticker, quantity, expiry) => Order(request.id, ticker, quantity, expiry)
  )

И теперь все работает, как и ожидалось. Это можно увидеть ниже:

validateOrder(
  CreateOrderRequest(
    ticker   = "",
    quantity = -2,
    expiry   = Some(LocalDate.of(2022, 1, 1)),
  ),
  LocalDate.of(2023,4,24),
)
// res = Left(List(
//  "Ticker cannot be empty”, 
//  “Quantity must be positive”,
//  “Expiry must be between 2023-04-24 and 2023-05-24"
//))

Это позволит показать все сообщения об ошибках нашему пользователю:

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

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

Решение 3: parMapN с Map

Еще раз обновим наши функции валидации так, чтобы они паковали ошибки в Map.

type FieldId     = String
type OrderErrors = Map[FieldId, List[String]]
​
def validateTicker(ticker: String): Either[OrderErrors, String] =
  if(ticker.isEmpty)
    Left(Map(“ticker” -> "cannot be empty"))
  else
    Right(ticker)

Аналогично для validateQuantity, validateExpiry и validateOrder. Весь код для этого раздела вы можете найти здесь.

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

validateOrder(
  CreateOrderRequest(
    ticker   = "",
    quantity = -2,
    expiry   = Some(LocalDate.of(2022, 1, 1)),
  ),
  LocalDate.of(2023,4,24),
)
// res = Left(Map(
//  “ticker” -> List("cannot be empty”), 
//  “quantity” -> List(“must be positive”),
//  “expiry” -> List(“must be between 2023-04-24 and 2023-05-24")
//))

Меня впечатляет, что достаточно было изменить тип ошибки, а затем parMapN автоматически скомбинировал их вместе! Как это работает? Не перегружена ли parMapN для поддержки распространенных типов ошибок, таких как String, List и Map, или она более универсальна?

Взгляд за кулисы

parMapN — это общий метод, который работает со всеми типами ошибок до тех пор, пока мы можем выполнить сквош слияние (слияние со сжатием) его значений. На практике это означает, что тип ошибки должен иметь неявный экземпляр класса Semigroup. Это может показаться запутанным, но на самом деле определить его очень просто. Вот пример для String:

import cats.Semigroup
​
given Semigroup[String] = new Semigroup[String] {
  def combine(x: String, y: String): String =
      x + y
}

Нам не нужно было имплементировать данный метод для String, List или Map, потому что библиотека Cats уже сделала это для наиболее распространенных типов стандартной библиотеки. Простой способ проверить, существует ли экземпляр, — использовать метод sumon.

summon[Semigroup[String]].combine("Hello", "World")
// res = “HelloWorld”
​
summon[Semigroup[Map[String, List[String]]]].combine(
  Map("id1" -> List("aaa", "bbb"), "id2" -> List("ccc")),
  Map("id2" -> List("ddd")       , "id3" -> List("eee")),
)
// res = Map(
//   "id1" -> List("aaa", "bbb"),
//   "id2" -> List("ccc", "ddd"),
//   "id3" -> List("eee"),
// ))
​
summon[Semigroup[UUID]].combine(UUID.randomUUID(), UUID.randomUUID())
// error: No given instance of type cats.kernel.Semigroup[java.util.UUID] was found for parameter x of method summon in object Predef

Как видите, Cats имплементировала экземпляр Semigroup для String и Map, но не для UUID [universally unique identifier — универсальный уникальный идентификатор], поскольку не существует эффективных способов объединить UUID вместе.

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

Бонус: валидация коллекции

Мы видели, что parMapN отлично работает для объединения нескольких ошибок вместе, но что если количество валидаций неизвестно во время компиляции? Например, мы получаем батч CreateOrderRequest для обработки. Как можно проверить все запросы и вернуть все ошибки по каждому конечному пользователю?

def validateOrders(requests: List[CreateOrderRequest], today: LocalDate)

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

import cats.implicits.*
​
def validateOrders(requests: List[CreateOrderRequest], today: LocalDate): Either[OrderErrors, List[Order]] =
  requests
    .parTraverse(request => validateOrder(request, today))

Давайте попробуем это сделать с двумя неверными запросами:

validateOrder(
  CreateOrderRequest(
    ticker   = "AAPL",
    quantity = -2,
    expiry   = None,
  ),
  CreateOrderRequest(
    ticker   = "",
    quantity = -2,
    expiry   = Some(LocalDate.of(2022, 1, 1)),
  ),
  LocalDate.of(2023,4,24),
)
// res = Left(Map(
//  “ticker” -> List("cannot be empty”), 
//  “quantity” -> List(“must be positive”, “must be positive”),
//  “expiry” -> List(“must be between 2023-04-24 and 2023-05-24")
//))

Он действительно работает, но мы не знаем, какая ошибка соответствует какому заказу. Например, мы видим, что тикер не должен быть пустым, но не знаем, относится ли это к первому или второму запросу. Есть несколько способов решить эту проблему. Здесь я предлагаю ввести уникальный идентификатор для заказов и присвоить OrderId каждому OrderErrors:

import cats.implicits.*
​
case class OrderId(value: String) // or UUID
​
type MultipleOrderErrors = Map[OrderId, OrderErrors]
​
def validateOrders(
  requests: List[CreateOrderRequest], 
  today   : LocalDate,
): Either[MultipleOrderErrors, List[Order]] =
  requests
    .parTraverse(request =>
      validateOrder(request, today)
        .leftMap(orderError => Map(request.id -> orderError))
    )

Давайте повторим тот же пример с двумя неверными запросами:

validateOrder(
  CreateOrderRequest(
    id       = OrderId("1111"),
    ticker   = "AAPL",
    quantity = -2,
    expiry   = None,
  ),
  CreateOrderRequest(
    id       = OrderId("2222"),
    ticker   = "",
    quantity = -2,
    expiry   = Some(LocalDate.of(2022, 1, 1)),
  ),
  LocalDate.of(2023,4,24),
)
// res = Left(Map(
//   OrderId("1111") -> Map(
//     FieldId.quantity -> List("must be positive"),
//   ),
//   OrderId("2222") -> Map(
//     FieldId.quantity -> List("must be positive"),
//     FieldId.expiry   -> List("must be between 2023-04-24 and 2023-05-24"),
//     FieldId.ticker   -> List("cannot be empty"),
//   ),
// ))

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

Подведем итог. Мы изучили ограничения for-comprehension, когда дело доходит до накопления ошибок, и узнали о двух мощных методах из библиотеки Cats: parMapN и parTraverse. Эти методы предлагают универсальное решение, которое работает с любым типом ошибок, оснащенным экземпляром Semigroup. Мы также видели, что Cats определяет такие экземпляры для распространенных типов, таких как String, List и Map, и что при использовании пользовательского типа ошибки очень легко создать свой собственный экземпляр.

Я надеюсь, что эта статья была информативной и полезной. Спасибо за прочтение!

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

Урок подойдет всем, кто уже знаком со Scala и хочет использовать функциональные подходы в решении задач. Записаться можно на странице курса «Scala-разработчик».

Tags:
Hubs:
Total votes 7: ↑5 and ↓2+4
Comments1

Articles

Information

Website
otus.ru
Registered
Founded
Employees
101–200 employees
Location
Россия
Representative
OTUS