Рефакторинг при помощи композиции Клейсли

В течение довольно длительного времени мы поддерживали приложение, которое обрабатывает данные в форматах XML и JSON. Обычно поддержка заключается в исправлении дефектов и незначительном расширении функциональности, но иногда она также требует рефакторинга старого кода.
ef401db8eddf41139377429bdbdebefc.jpg

Рассмотрим, например, функцию getByPath, которая извлекает элемент из XML дерева по его полному пути.

import scala.xml.{Node => XmlNode}

def getByPath(path: List[String], root: XmlNode): Option[XmlNode] =
  path match {
    case name::names =>
      for {
        node1 <- root.child.find(_.label == name)
        node2 <- getByPath(names, node1)
      } yield node2
    case _ => Some(root)
  }

Эта функция отлично работала, но требования поменялись и теперь нам нужно:

  • Извлекать данные из JSON и, возможно, других древоподобных структур, а не только из XML;
  • Возвращать сообщение об ошибке, если данные не найдены.

В этой статье мы расскажем, как осуществить рефакторинг функции getByPath, чтобы она соответствовала новым требованиям.

Композиция Клейсли


Давайте выделим тот фрагмент кода, который извлекает дочерний элемент по имени. Мы можем назвать ее createFunctionToExtractChildNodeByName, но давайте назовем ее для краткости просто child.
val child: String => XmlNode => Option[XmlNode] = name => node =>
  node.child.find(_.label == name)

Теперь мы можем сделать ключевое наблюдение: наша функция getByPath является последовательной композицией функций, извлекающих дочерние элементы. Приведенная ниже функция compose реализует такую композицию двух функций: getChildA and getChildB.

type ExtractXmlNode = XmlNode => Option[XmlNode]

def compose(getChildA: ExtractXmlNode, 
            getChildB: ExtractXmlNode): ExtractXmlNode = 
  node => for {a  <- getChildA(node); ab <- getChildB(a)} yield ab

К счастью, библиотека Scalaz предоставляет более общий, абстрактный способ реализовать композицию функций вида A => M[A], где M является монадой. Библиотека определяет Kleisli[M, A, B], обертку для A => M[B], у которой есть метод >=> для реализации последовательной композиции этих Kleisli, подобно композиции обычных функций при помощи andThen. Эту композицию мы будем называть композицией Клейсли. Приведенный ниже код демонстрирует пример такой композиции:

val getChildA: ExtractXmlNode = child("a”)
val getChildB: ExtractXmlNode = child("b”)

import scalaz._, Scalaz._

val getChildAB: Kleisli[Option, XmlNode, XmlNode] = 
  Kleisli(getChildA) >=> Kleisli(getChildB)

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

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

import scalaz._, Scalaz._

def getByPath(path: List[String], root: XmlNode): Option[XmlNode] =
  path.map(name => Kleisli(child(name)))
    .fold(Kleisli.ask[Option, XmlNode]) {_ >=> _}
    .run(root)

Обратите внимание на использование Kleisli.ask[Option, XmlNode] в качестве нейтрального элемента метода fold. Этот нейтральный элемент нужен нам для обработки специального случая, когда path пуст. Kleisli.ask[Option, XmlNode] — это просто другое обозначение функции из любого node в Some(node).

Абстрагируемся от XmlNode


Давайте обобщим наше решение и абстрагируем его от XmlNode. Мы можем переписать его в виде следующей обобщенной функции
getByPathGeneric:
def getByPathGeneric[A](child: String => A => Option[A])
                       (path: List[String], root: A): Option[A] = 
  path.map(name => Kleisli(child(name)))
    .fold(Kleisli.ask[Option, A]) {_ >=> _}
    .run(root)

Теперь мы можем повторно использовать getByPathGeneric для извлечения элемента из JSON (мы используем здесь json4s):
import org.json4s._

def getByPath(path: List[String], root: JValue): Option[JValue] = {
  val child: String => JValue => Option[JValue] = name => json =>
    json match {
      case JObject(obj) => obj collectFirst {case (k, v) if k == name => v}
      case _ => None
    }
  getByPathGeneric(child)(path, root)
}

Мы написали новую функцию, child: JValue => Option[JValue], чтобы работать с JSON вместо XML, но функция getByPathGeneric осталась неизменной и работает как с XML, так и с JSON.

Абстрагируемся от Option


Мы можем обобщить getByPathGeneric еще больше и абстрагировать её от Option при помощи библиотели Scalaz, которая предоставляет экземпляр (instance) монады для Option -- scalaz.Monad[Option]. Так что мы можем переписать getByPathGeneric следующим образом:
import scalaz._, Scalaz._

def getByPathGeneric[M[_]: Monad, A](child: String => A => M[A])
                                    (path: List[String], root: A): M[A]=
  path.map(name => Kleisli(child(name)))
    .fold(Kleisli.ask[M, A]) {_ >=> _}
    .run(root)

Теперь мы можем реализовать нашу исходную функцию getByPath при помощи функции getByPathGeneric:

def getByPath(path: List[String], root: XmlNode): Option[XmlNode] = {
  val child: String => XmlNode => Option[XmlNode] = name => node =>
    node.child.find(_.label == name)
  getByPathGeneric(child)(path, root) 
}

Таким образом, мы можем повторно использовать getByPathGeneric, чтобы возвращать сообщение об ошибке, если элемент не найден. Для этого мы используем scalaz.\/ (т.н. «дизъюнкцию») которая является правосторонней версией scala.Either.

В дополнение, Scalaz предоставляет «неявный» (implicit) класс OptionOps с методом toRightDisjunction[B](b: B), который преобразует Option[A] в scalaz.B\/A, так, что Some(a) становится Right(a) и None становится Left(b).

Так, мы можем написать функцию, которая повторно использует getByPathGeneric, чтобы вернуть сообщение об ошибке вместо None, если искомый элемент не найден.

type Result[A] = String\/A

def getResultByPath(path: List[String], root: XmlNode): Result[XmlNode] = {
  val child: String => XmlNode => Result[XmlNode] = name => node =>
    node.child.find(_.label == name).toRightDisjunction(s"$name not found")
  getByPathGeneric(child)(path, root)
}

Исходная функция getByPath обрабатывала только данные в формате XML и возвращала None, если искомый элемент не найден. Нам понадобилось, чтобы она также работала с форматом JSON и возвращала сообщение об ошибке вместо None.

Мы видели, как использование композиции Клейсли, которую предоставляет библиотека Scalaz, позволяет написать обобщенную функцию getByPathGeneric, используя параметризированные типы (generics) для поддержки как XML так и JSON, а также scalaz.\/ (дизъюнкцию) для абстрагирования от Option и выдачи сообщений об ошибках.

Разработчик конструктора сайтов Wix,
Михаил Дагаев

Оригинал статьи: блог инженеров компании Wix.

Комментарии (1)

  • 4 июля 2016 в 19:45

    0

    Это у вас вышло хорошо. Спасибо.

© Habrahabr.ru