В течение довольно длительного времени мы поддерживали приложение, которое обрабатывает данные в форматах XML и JSON. Обычно поддержка заключается в исправлении дефектов и незначительном расширении функциональности, но иногда она также требует рефакторинга старого кода.
Рассмотрим, например, функцию 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.
Автор: Wix.com