Когда дело доходит до обработки ошибок, основной стратегией является прекращение всех вычислений после обнаружения первой погрешности. Обычно это достигается за счет использования исключений. Хотя этот подход работает в большинстве случаев, бывают случаи, когда он не идеален. Например, при получении запроса от пользователя предпочтительнее вернуть все ошибки сразу и позволить исправить их одним махом. В этой статье блога я рассмотрю такой сценарий и изучу конкретный пример с использованием Scala 3 и библиотеки Cats.
Все примеры кода вы можете найти в данном репозитории github.
Проблема
Допустим, мы работаем в финансовой организации, которая получает заказы на покупку или продажу финансовых продуктов. Вот простой запрос:
case class CreateOrderRequest(
ticker : String,
quantity: Long,
expiry : Option[LocalDate],
)
Тикер (ticker) — это идентификатор финансового инструмента. Количество (quantity) представляет собой желаемое число инструментов. Наконец, окончание срока (expiry) — это необязательное поле, которое сообщает нам, когда запрос считается валидным.
Мы хотим проверить следующие три ограничения:
Тикер не должен быть пустым
Количество должно быть положительным
Срок действия должен быть либо пустым, либо не позже, чем за один месяц до истечения.
Давайте реализуем эти правила:
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-разработчик».