четверг, 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 контейнеров.