Показаны сообщения с ярлыком validation. Показать все сообщения
Показаны сообщения с ярлыком validation. Показать все сообщения

среда, 26 сентября 2012 г.

Validation и исключения: совместная жизнь

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

Это значит,что будет происходить много чего о чём автор кода мог даже не догадываться. А о таких вещах JVM извещает код бросая исключения. Да да, возьмёт и бросит, и плевать ей на чистоту.

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

def readFields(rec: DBObject, ...): GzVal[DataRecord] = {
  val deletedBy =
    for {
      userId <- get[ObjectId](rec, "deleted_by");
      user <- getUserData(userId).toSuccess(MissedEntity(userId.toString, "user"))    } yield user
  for {
    id <- get[String](rec, "_id")
    content <- get[String](rec, "content")
    updated <- asValidOption(get[DateTime](rec, "upd"))
    //twelve more
    user <- getUserById(userId, currentUserId map (new ObjectId(_)))
        .toSuccess(MissedEntity(userId.toString, "user"): ValidationError)
  } yield DataRecord(/*you not gonna like it*/)
}

В общем-то ничего сложного - берём запись из БД, разбираем проверяем значения, создаём объект. При этом что-то может упасть, но что и как упало на этом уровне никого не волнует. Разбор записи надо прекращать сразу независимот от того нарушилась ли консистентность или умерло соединение с БД.

Мои основные мысли на тему Exceptions vs. Validations можно выразить таким вот набором тезисов:
  • Валидации позволяют очень выразительно и кратко обрабатывать ошибки в чистом коде.
  • При этом используя и смешивая разные стили обработки ошибок.
  • Они требуют небольшой синтаксической избыточности на каждую операцию.
  • В то же время исключения часто являются вполне достойной абстракцией для обработки ошибок.
  • Они создают значительную синтаксическую избыточность, зато сразу на группу операций.
Суммируя эти наблюдения с тем фактом, что неожиданные исключения всё-равно будут пролетать, я принял на вооружение следующие принципы:
  • Высокоуровневые абстракции в приложении стоит строить используя валидации, однако страховать их на случай совсем внезапной ошибки.
  • Тот код который легче написать с использованием исключений нужно писать с использованием исключений.
  • Исключения из такого кода надо ловить и преобразовывать в валидации.
Далее я попробую описать основные детали того, как я пытался преобразовать эти принципы в работающую архитектуру. Для начала общая картина, срисованная с моего доклада в Минске.



Слева располагается достаточно грязный код, связанный с внешними службами вроде хранилища данных. Он бросает исключения как часть логики. Для него нужно выделить некоторый набор интерфейсных методов, которые перехватывают исключения и преобразовывают их в ошибки (символическая зелёная рамка). Преобразование это является по сути простой функцией, которую (не в случае бибилиотечного кода) вполне можно статически задать просто одну на систему ('Exception converter' на диаграмме).

Те, кто находятся правее написаны в более чистом стиле, игнорируют исключения (в смысле прозрачного пробрасывания наверх, а не в смысле подавления). Об их существовании вновь вспоминает лишь тонкая прослойка, между нашим кодом и внешним миром (в моём случае это был Lift, в других может быть сетевая библиотека или GUI).
Давайте рассмотрим несколько фрагментов кода, для более точного представления этих идей. Скажем для нашего HTTP сервиса из предыдущих примеров мы отказались от строки как от типа ошибки, определив такой класс:
abstract class Error(
    val httpCode: Int,
    val code: String)

И каких-то его потомков, например так:

case class MissedEntity(
  id: String, type: String)
extends Error(404, "MISSED_ENTITY")

case class InternalError(
  @transient Throwable cause)
extends Error(cause, "INTERNAL")

Тогда можно легко задать функцию преобразования:
val dispatchException: Function[Throwable, Error] = {
  case UserNotFound(name) =>
    MissedEntity(name, "user")
  ...
  // Don't remove it!!!
  case t => InternalError(t)
}

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

def safeVal[T]: Catch[ReqVal[T]] =
  handling(classOf[Throwable]) by { e =>
    dispatchException(e).fail
  }

def safe[T](block: => T): ReqVal[T] =
  safeVal( block.success )

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

Пользоваться этими служебными функциями можно так:

def editData(e: Edit):
  ReqVal[Data] = safe {
    //all dangerous stuff here
  }

Экранируя подобным образом методы компонентов связаных с вводом-выводом (или ещё чем-то трудно обрабатываемым при помощи валидаций) мы можем создать на определённом уровне глобальный переход от одного способа работы с ошибками к другому. И стоящая выше логика может пользоваться всеми теми приятностями про которые рассказывал ранее. На картинке это уровни 'Services' и 'Controllers'.

Есть ещё один важнейший момент, который сподвиг меня на все эти эксперименты, но о котором я не говорил ранее. Дело в том, что конечной целью было построение REST сервиса, использующего протокол HTTP. Замечательной особенностью HTTP, дичайше игнорируемой всеми, является прямая поддержка сообщений об ошибках. В нём есть понятие кода ответа, что позволяет стандартным образом с детально описанной семантикой отвечать любому клиенту и быть понятым. Ещё раз подумайте, до любого клиента, хоть браузера, хоть curl можно донести что пошло не так и как с этим можно побороться... Вместо этого повсеместно практикуется '200 OK... {error: "Failed to..."}'. Зачем???!!!

Ну да, хватит о грустном. Поговорим о весёлом. А состоит оно в том что если мы представить себе, что у нас есть класс HttpResponse, позволяющий описать все ответы. То преобразование из результата R выполнения какой-то операции в HttpResponse концептуально абсолютно корректно задать как функцию Validation[R] => HttpResponse. Которая естественным образом распадается на незатейливый шаблон:

r match {
  case Sucess(r) => buildSuccessResponse(r)
  case Failure(f) => buildFailureResponse(f)
}

Где buildFailureResponse, как не сложно догадаться задаётся один раз на систему, а весь шаблон легко инкапсулируется в простую конструкцию. Предполагая, что у нас определены фунция-сериализатор decompose: Any=>JSONObject и преобразование из объекта ошибки в HTTP ответ errorResponse, можно написать такую функцию.

def valsToResponse[A](v: ListVal[A]): HttpResponse = v match {
  case Success(res) =>
    JsonResponse(decompose(rel), successCode)
  case Failure(err) => errorResponse(err)
}

Имея операцию op: A=>ListVal[B], где A и B уже специфичные для приложения классы представления запросов и ответов, можно легко строить (нетривиальные!) обработчики запросов в одну строчку.

def getUsers(req: Req, namePattern: String): LiftResponse = valsToResponse {
  getUsersInternal(namePattern) map { users =>
    val (count, list) = users
    Map("count" -> count, "items" -> list)
  }
}

Здесь для примера я преобразую пришедший список результатов в пару значений длина и собственно список (ну представим что кому-то из клиентов так удобнее ;) ). В таком примере getUsersInternal имел бы тип String => ListVal[List[User]].

Такого вида функции тривиально цепляются к REST модулю Lift - не буду тратить место на пример.

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

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

четверг, 15 декабря 2011 г.

Введение в Validation: часть 2


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

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

Именно в таком сценарии проявляется преимущество всех этих сложных штук, перед "проверенными временем" исключениями. У вас бывали в проекте классы "CompositeException" со списком других исключений в одном из полей? А милые кусочки кода, которые пробовали что-то делать, складывали ошибки в список и потом выбрасывали его в этой незатейливой оболочке? Тогда этот пост для Вас.

scalaz даёт нам операцию "собрать значения и если они успешны применить к ним функцию, а если нет - сагрегировать все возникшие ошибки". Реализуется она созданием временного объекта, который представляет собой выборку успешных данных. Эта выборка может быть преобразована применением к ней обычной функции (в смысле принимающей и возвращающей простые значения). Применение это похоже на обычный map, в том смысле, что при успешных входах на выходе появляется результат применения к ним функции. Но отличается тем, что оперирует сразу над набором значений и знает что делать с несколькими неудачами.

В scalaz для доступа к этому функционалу применяется без сомнения согревающий сердце истинного ценителя математики оператор |@|. Результатом его применения к паре завёрнутых в Validation значений является тот самый промежуточный объект в который мы можем или продолжить добавлять значения с помощью того же оператора или применить к нему функцию соответствующей арности.

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

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

case class SearchQuery(
  text: String,
  order: Option[Order],
  limit: Int)

def parseSearchQuery(req: Req): ReqVal[SearchQuery] =
  for {
    text  <- get(req, "text")
    order <- getOptOrder(req, "sort")
    limit <- getInt(req, "limit")
  } yield SearchQuery(text, order, limit)

def handleSearchPosts(req: Req): Response = 
  serialize(
    parseSearchQuery(req) flatMap searchPosts _
  )

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

type ReqVal[T] = ValidationNEL[String, T]

def get(req: Req, name: String): ReqVal[String] =
  req.param(name).toOption.toSuccess(
    ("Missed parameter "+name).failNel)

def parseInt(s: String): ReqVal[Int] =
  s.parseInt.fail.map { ne: NumberFormatException =>
      ("Value "+s+" is not an integer").failNel
    }.validation

def parseSearchQuery(req: Req): ReqVal[SearchQuery] =
  (   get(req, "text")
  |@| getOptOrder(req, "sort")
  |@| getInt(req, "limit"))
    apply SearchQuery _ 

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

Обратите внимание, мы не можем (и определённо не хотим) втянуть в мир "всего и сразу" операцию обращающуюся к БД. Но миры "шаг за шагом" и "всё сразу" совершенно интероперабельны. Как только что было показано, мы всегда можем выбрать удобный и даже потом поменять решение, чтоб так реализации SOAP жили.

Ну и для полноты картины можно привести пример с композицией. Давайте снова уроним на свои головы новый функционал. Пусть наши пользователи захотели смотреть не первые N результатов поиска, а все сколько их есть. С издавна на руси такую задачу решали пагинацией. И вот на смену отслужившему свое параметру limit приходит пара новых: slice_start и slice_end. И для обеспечения приемлемой производительности мы решили ограничить размер страницы 50 результатами.

Для начала как всегда подправим наши типы:

case class Slice(start: Int, end: Int)

case class SearchQuery(
  text: String,
  order: Option[Order],
  slice: Slice)

Имея необходимые типы в своём распоряжении, можно начать притворять функционал в жизнь. Очевидно, что потребуется новый строительный блок, функция вида Req => ReqVal[Slice]

def getSlice(req: Req): ReqVal[Slice] = {
  val start = getInt(req, "slice_start")
  val end   = getInt(req, "slice_end")
  if ((end | 0) - (start | 0) <= 50)
    (start |@| end) {Slice(_, _)}
  else
    "The page is too large".failNel
  }
}

Отлично, и теперь новая функция легко встаёт на своё место.

def parseSearchQuery(req: Req): ReqVal[SearchQuery] =
  (   get(req, "text")
  |@| getOptOrder(req, "sort")
  |@| getSlice(req))
    apply SearchQuery _ 

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

вторник, 8 ноября 2011 г.

Введение в Validation: часть 1

В этом посте я хочу подготовить почву для будущего рассказа о подходе к обработке ошибок, который как ни крути получился весьма скомканный без должной политической подготовки аудитории. Его темой будет класс Validation из библиотеки scalaz, некоторые (далеко не все) техники работы с ним и отдельные языковые конструкции, которые я нашёл наиболее полезными.

Этот класс по сути объявлен следующим образом:
sealed trait Validation[+E, +A] {
  def isSuccess : Boolean = this match {
    case Success(_) => true
    case Failure(_) => false
  }
  def isFailure : Boolean = !isSuccess
}
final case class Success[E, A](a: A) extends Validation[E, A]
final case class Failure[E, A](e: E) extends Validation[E, A]
В объявлении есть ещё ряд интересных методов, но давайте пока оставим их без внимания. Ничего не напоминает? Да это же брат-близнец Either. Опять велик? Спокойно, эта модель обладает рядом важных свойств.

Во-первых у него объявлены методы map и flatMap. В scala это означает, что мы можем использовать экземпляры этого класса (на самом деле trait, но как это блин по-русски сказать то?) в выражении for. Например так (это переписанный код метода login из моего давнего поста на эту же тему):
def login(login: Option[String], password: Option[String]): Validation[String, UserInfo] =
  for {
    login <- login.toSuccess( "You should provide login" )
    password <- password.toSuccess( "You should provide password" )
    user <- findUser(login)
    checkedUser <- checkUser(user)
    loggedUser <- doLogin(checkedUser, login, password)
  } yield loggedUser
Потратим немного времени и рассмотрим этот пример подробнее. Во-первых имеющиеся логин и пароль пользователя преобразуются из Option в Validation с помощью метода toSuccess, его реализация абсолютно очевидна. Об этой операции можно думать следующим образом: альтернатива из существующего или отсутствующего значения преобразуется в альтернативу из валидного значения или сообщения об ошибке. Во-вторых происходят вызовы методов findUser, checkUser, doLogin. Мне не хочется тратить время на детальное их описание, будем считать что они объявлены принимающими обычные значения (не Option или Validation, а строки и объекты) и возвращающими Validation[UserInfo, String]. То есть каждый из них принимает некоторые данные для проверки и возвращает либо информацию о прошедшем эту проверку пользователе либо сообщение о случившейся ошибке.

Теперь очередь магии внутри выражения for. <- в данном контексте работает как "Если у нас справа успешное вычисление связать имя слева с его результатом, если неудача - вернуть его как результат всего выражения." А yield в свою очередь работает как "Если все связывания были успешны вычислить выражение и вернуть его как успех". Обратите внимание, выбор всё ещё тут, но он спрятан в способе которым выражение for комбинирует свои элементы.

У Either методов map и flatMap нет, есть они только в его "проекциях" (правой и левой). На практике это значит, что использование Validation практически постоянно экономит нам 5 символов. Сравните код выше с кодом из моего старого поста.

Важно понимать, что этот подход позволяет унифицировано обрабатывать целый ряд проблемных ситуаций: (а) ошибку во входных данных (отсутствие пароля), (б) собственно отказ в обработке основанный на логике (пароль не верен) и (в) ошибку во внешней службе (БД легла в момент сверки пароля). При этом код верхнего уровня даже не упоминает никакие Failure, только использует <- вместо =.

Помимо приятного синтаксического сахара для сокрытия if'ов от нашего взгляда, такой подход к передаче ошибок обладает одним важным свойством - отдельные методы следующие паттерну "значения на вход - валидации на выход" могут легко объединяться вместе рядом других способов помимо for. В каком-то смысле они обладают свойством замыкания. Для примера я сейчас построю простой модуль для парсинга параметров http запросов из пары десятков строк (полнофункциональная, боевая её версия около 50, но её я естественно никому не покажу :) ).

Пусть у нас есть класс Req, несущий в себе параметры HTTP запроса в форме словаря <имя параметра> - <список значений>. Нам нужен набор функций, позволяющий типобезопасно извлекать из него параметры, при этом возможно применяя некие алгоритмы парсинга (скажем, стандартный toInt). Также желательно иметь возможность извлекать необязательный параметры и наборы параметров. Интерфейс такого модуля можно описать следующим образом:
trait ReqValidation {
  type ReqVal[T] = Validation[String, T]
  //возвращает значение параметра как есть или сообщение о его отсутствии
  def get(req: Req, name: String): ReqVal[String]
  //возвращает необязательное значение (его выполнение всегда успешно)
  def getOpt(req: Req, name: String): ReqVal[Option[String]]
  //возвращает целое значение или собщение об ошибке парсинга или сообщение об отсутствии параметра
  def getInt(req: Req, name: String): ReqVal[Int]
  //возвращает небязательное целое значение или собщение об ошибке парсинга
  def getOptInt(req: Req, name: String): ReqVal[Option[Int]]
}

Во-первых я ввожу новый тип для результата получения параметра. Этот тип параметризован типом успешного результата. Он является псевдонимом для Validation со строкой в качестве описания неудачи и его параметром в качестве типа успешного значения.

Давайте последовательно реализуем каждую из этих функций, начнём с get:
def get(req: Req, name: String): ReqVal[String] =
  req.param(name).toOption.toSuccess(
    Failure("Missed parameter "+name))

Она извлекает значение параметра по имени, преобразует его в стандартный Option. И уже его преобразует в Validation с соответствующим сообщением об ошибке.
Следующим шагом будет реализация метода getOpt. Его можно сделать уже совсем прямолинейным:
def getOpt(req: Req, name: String): ReqVal[Option[String]] =
  Success(req.param(name).toOption)

Реализацию второй пары методов, подразумевающих парсинг стоит начать с того, что собственно определить парсер:
def parseInt(s: String): ReqVal[Int] =
  s.parseInt.fail.map { ne: NumberFormatException =>
      Filure("Value "+s+" is not an integer")
    }.validation

Здесь используется стандартный метод parseInt, возвращающий Either. При помощи метода fail мы преобразуем его в FailProjection и преобразуем исключение из стандартной библиотеки Java в читаемое сообщение об ошибке. Кагда это сделано результат преобразуется назад в обычный Validation. FailProjection - это специальная форма Validation с инвертированной в каком-то смысле логикой: он сделан для явного манипулирования ошибкой и неявного игнорирования успешного результата.

А также вспомогательный метод для применения parseInt и ему подобных к необязательному значению:
def parseOption[T](parse: String => ReqVal[T])(opt: Option[String]): ReqVal[Option[T]] =
  opt match {
    case Some(s) => parse(s).lift[Option, T]
    case None => success(None)
  }

Метод lift берёт успешное значение и дополнительно заворачивает его в указанную "оболочку", в данном случае Option.

Теперь можно написать реализацию getInt:
def getInt(req: Req, name: String): ReqVal[Int] =
  get(req, name) flatMap parseInt _

Она предельно проста. Не особо задумываясь мы можем получить и getOptInt:
def getOptInt(req: Req, name: String): ReqVal[Option[Int]] =
  getOpt(req, name) flatMap parseOption(s, parseInt _)

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

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

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

Для того, чтобы получить более правдоподобную картину давайте придумаем тестовый пример для этого модуля... Скажем запрос на поиск поста в блоге по названию (параметр запроса "text"), с опцией сортировки по дате публикации (параметр "sort") и указанием ограничения на количество результатов в ответе (параметр "limit").

И пусть у нас уже есть метод которые делает что надо в БД и метод сериализующий список результатов для передачи по HTTP, как-то так:
case class Post(...)

sealed abstract class Order
case object Asc extends Order
case object Desc extends Order

def searchPosts(text: String, order: Option[Sort], limit: Int): ReqVal[List[Post]] = {...}

def serialize(ReqVal[Object]): Response = {...}

Давайте подумаем что нам нужно чтобы соединить вместе вышележащую библиотеку для работы с сетевым протоколом и нижележащую функцию работы с БД. Кажется почти всё есть, вот только надо научиться вытаскивать параметр сортировки... А чем порядок хуже числа?
def parseOrder(value: String): ReqVal[Order] =
  value match {
    case "asc"  => Success(Asc)
    case "desc" => Success(Desc)
    case _      => Filure("Illegal order value "+value)
  }

def getOptOrder(req: Req, name: String): ReqVal[Option[Order]] =
  getOpt(req, name) flatMap parseOption(s, parseOrder _)

А теперь можно и написать требуемый код склейки, извлекаюий параметры из запроса и передающий их в функцию работы с БД:
def handleSearchPosts(req: Req): Response = 
  serialize(
    for {
      text  <- get(req, "text")
      order <- getOptOrder(req, "sort")
      limit <- getInt(req, "limit")
      data  <- searchPosts(text, order, limit)
    } yield data
  )

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