Рефакторинг за допомогою композиції Клейсли

протягом досить тривалого часу ми підтримували додаток, який обробляє дані у форматах 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.Монада[Option]
. Так що ми можемо переписати
getByPathGeneric
наступним чином:

import scalaz._, Scalaz._

def getByPathGeneric[M[_]: Монада, 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
, використовуючи параметризированные типи (узагальнення) для підтримки XML так і JSON, а такожscalaz.\/ (диз'юнкцію) для абстрагування від
Option
та видачі повідомлень про помилки.

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

Оригінал статті: блог інженерів компанії Wix.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.