Валидация данных — отнюдь не новое явление в разработке программного обеспечения. Она была, есть и будет оставаться актуальной задачей, которая не терпит халатности. Комплексная (comprehensive) валидация данных преследует две следующие цели:
-
Препятствовать внедрению недопустимых состояний в наши приложения
-
Аккумулировать максимально полный список причин отказа в валидации (Это не может быть достигнуто с помощью Either[E, A], так как это монадическая структура — поддерживает вычисление по сокращенной схеме (short circuiting) в вызовах flatMap и производит возврат, как только встречает первый Left, поэтому нам нужно что-то другое)
В этой статье мы с вами будем решать практическую задачу — реализуем комплексную валидацию для ситуации, когда пользователь пытается создать пароль для своего профиля.
Наша цель — аккумулировать ошибки валидации таким образом, чтобы пользователь увидел все допущенные им ошибки в пользовательском интерфейсе одновременно и мог изменить свой пароль в соответствии с нашими требования за одну попытку.
Но для начала нам необходимо убедиться в том, что предоставленный пароль соответствует стандартам безопасности, т.е. он должен быть проверен на соответствие четырем бизнес-правилам:
-
Он должен быть не пустым (кто бы мог подумать?!)
-
Он должен содержать хотя бы один символ в верхнем регистре
-
Чтобы считаться достаточно длинным, он должен содержать более 8 символов
-
Он должен содержать хотя бы один из этих специальных символов: !@#$%^&*()
С этим нам поможет знаменитая библиотека cats, а именно конструкции, которые можно найти в пакете cats.data, такие как: Validated (избавляет нас от ограничений Either[E, A]), NonEmptyList и специальный псевдоним типа, связанный с упомянутым выше.
Прежде всего, давайте познакомимся с Validated и NonEmptyList, а затем перейдем к реализации.
Validated — это структура данных, представляющая из себя тип-сумму из двух case-классов Valid или Invalid, которая оборачивает базовые данные, подлежащие проверке.
Простой пример, демонстрирующий иерархию Validated:
sealed trait Validated[+E, +A] { // куча абстрактных методов } object Validated { final case class Valid(data: A) extends Validated[Nothing, A] final case class Invalid(error: E) extends Validated[E, Nothing] }
Таким образом, очевидно, что левая часть +E предназначена для хранения ошибок валидации, а правая часть +A — для хранения переданных значений.
На первый взгляд это выглядит так же, как и Either[+E, +A] с его Left и Right частями, но на практике работает по-другому.
Простой пример создания Valid данных:
import cats.data.Validated val validPass: Validated[Nothing, String] = Validated.Valid("MyPassword!2#")
Простой пример создания Invalid данных:
import cats.data.Validated val invalidPass: Validated[String, Nothing] = Validated.Invalid("The password is invalid due to ...")
А для того, чтобы обернуть что-либо в Validated, мы можем использовать методы расширения:
import cats.syntax.all._ val validPass: Validated[Nothing, String] = "MyPassword!2#".valid val invalidPass: Validated[String, Nothing] = "The password is invalid due to ...".invalid
Достаточно просто, не так ли?
Теперь давайте разберемся с NonEmptyList. Как следует из названия, это, по сути, непустой список. Иногда NonEmptyList называют Nel (тут намеренный каламбур с Nil, который является пустым списком), и он основан на реализации односвязного списка.
Простая демонстрация использования NonEmptyList:
import cats.data.NonEmptyList val oneElementList: NonEmptyList[Int] = NonEmptyList.one(1) val maybeNonEmptyList1: Option[NonEmptyList[Int]] = NonEmptyList.fromList(Nil) // None, из-за параметра Nil val maybeNonEmptyList2: Option[NonEmptyList[Int]] = NonEmptyList.fromList(List(1, 2, 3)) // Some(...) из-за параметра List(1, 2, 3) NonEmptyList.fromListUnsafe(Nil) //выбрасывает исключение из-за параметра Nil
Еще проще, не правда ли?
А теперь посмотрим на тот самый псевдоним типа, который подходит для наших целей:
type ValidatedNel[+E, +A] = Validated[NonEmptyList[E], A]
Таким образом, после применения правил валидации мы получаем либо NonEmptyList ошибок — E, либо данные — A, которые удовлетворяют нашим правилам валидации.
Отлично! Теперь давайте создадим доменную модель для пароля и связанную с ней ADT иерархии ошибок:
final case class Password(value: String) extends AnyVal // value class object Password { // ADT ошибок валидации sealed trait ValidationError object ValidationError { case object IsEmpty extends ValidationError case object LacksUppercaseChar extends ValidationError case object IsShort extends ValidationError case object LacksSpecialSymbol extends ValidationError } }
Пока все выглядит достаточно просто. Теперь давайте разработаем протокол, который описывает проверку пароля на высоком уровне:
import Password.ValidationError trait PasswordValidations { def validate(password: String): ValidatedNel[ValidationError, Password] }
И, наконец, напишем реализацию в объекте-компаньоне PasswordValidations:
def create: PasswordValidations = password => { import Password.ValidationError import Password.ValidationError._ def validate( condition: Boolean, failureReason: ValidationError ): ValidatedNel[ValidationError, Unit] = if (!condition) failureReason.invalidNel else ().valid def isNonEmpty = validate(password.nonEmpty, IsEmpty) def containsUpperCaseCharacter = validate(password.exists(_.isUpper), LacksUppercaseChar) def isLong = validate(password.length > 8, IsShort) def hasSpecialSymbol = validate( password.exists(c => "!@#$%^&*()".contains(c.toString)), LacksSpecialSymbol ) // Здесь нам необходимо вызвать следующие методы: // 1. isNonEmpty // 2. containsUpperCaseCharacter // 3. isLong // 4. hasSpecialSymbol // и аккумулировать ошибки в канале Invalid, если таковые имеются // или вернуть проверенный пароль, обернутый конструктором Valid ??? } }
Мы почти у цели. Теперь нам нужен способ задействовать все четыре внутренние функции валидации и:
-
Если хотя бы один или несколько из них не пройдены — возвращается конкатенированный
Invalid(NonEmptyList[ValidationError](...)) -
Если все пройдены — возвращаем
Valid(Password(...))
Существует несколько способов сделать это, но здесь я покажу вам только свой любимый подход, заключающийся в использовании метода расширения .tupled, который определяется на кортежах произвольных значений, обернутых чем-то вроде Option, Either или, в нашем случае, Validated.
Под капотом .tupled использует typeclass Semigroupal и операцию произведения для достижения следующих целей:
import cats.syntax.all._ val maybe1 = Option(1) val maybe2 = Option(2) val result: Option[(Int, Int)] = (maybe1, maybe2).tupled // Some(1, 1)
В нашем случае кортеж будет содержать вызовы внутренних функций с типом возврата: ValidatedNel[ValidationError, Unit].
Таким образом, мы можем написать что-то вроде:
import cats.syntax.all._ // .tupled объединяет списки NonEmptyList[ValidationError], если они являются невалидными (Invalid) // и перебирает значения Unit, если они являются валидными (Valid) val result: ValidatedNel[ValidationError, (Unit, Unit, Unit, Unit)] = ( isNonEmpty, containsUpperCaseCharacter, isLong, hasSpecialSymbol ).tupled
Но… этого недостаточно, мы хотим иметь в конце такую сигнатуру: ValidatedNel[ValidationError, Password], чтобы мы могли легко вызвать map на результае как:
result.map(_ => Password(password)) // ValidatedNel[ValidationError, Password]
или использовать метод расширения .as, что делает его еще более лаконичным и читабельным:
result.as(Password(password)) // ValidatedNel[ValidationError, Password]
Итак, теперь давайте соберем все воедино:
object PasswordValidations { def create: PasswordValidations = password => { import Password.ValidationError import Password.ValidationError._ def validate( condition: Boolean, failureReason: ValidationError ): ValidatedNel[ValidationError, Unit] = if (!condition) failureReason.invalidNel else ().valid def isNonEmpty = validate(password.nonEmpty, IsEmpty) def containsUpperCaseCharacter = validate(password.exists(_.isUpper), LacksUppercaseChar) def isLong = validate(password.length > 8, IsShort) def hasSpecialSymbol = validate( password.exists(c => "!@#$%^&*()".contains(c.toString)), LacksSpecialSymbol ) ( isNonEmpty, containsUpperCaseCharacter, isLong, hasSpecialSymbol ).tupled.as(Password(password)) } }
Теперь давайте напишем тесты для нашего сервиса валидации:
Ожидается Invalid(NonEmptyList(IsEmpty, LacksUppercaseChar, IsShort, LacksSpecalSymbol)), поскольку все четыре правила не удовлетворены:
import Password.ValidationError._ val service = PasswordValidations.create val expected = NonEmptyList.fromListUnsafe { List(IsEmpty, LacksUppercaseChar, IsShort, LacksSpecialSymbol) }.invalid assert(service.validate("") == expected)
Ожидается Invalid(NonEmptyList(LacksUppercaseChar, IsShort)):
import Password.ValidationError._ val service = PasswordValidations.create val expected = NonEmptyList.fromListUnsafe { List(LacksUppercaseChar, IsShort) }.invalid assert(service.validate("pa$$") == expected)
Ожидается Valid(Password(...)), так как он соответствует всем четырем правилам:
import Password.ValidationError._ val service = PasswordValidations.create val pass = "Very$trongPassword123" val expected = Password(pass).valid assert(service.validate(pass) == expected)
В заключение приглашаем всех, у кого есть опыт работы со Scala, на открытый урок по теме «Type classes в Scala», который пройдет 14 декабря. В результате вебинара сформируется понимание того, что такое type class паттерн, из каких компонентов он состоит и как с его помощью решаются конкретные задачи. Записаться на урок можно на странице курса.
ссылка на оригинал статьи https://habr.com/ru/articles/778644/
Добавить комментарий