Статьи

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

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

Рассмотрим, например, функцию, которая извлекает узел XML по пути:

01
02
03
04
05
06
07
08
09
10
11
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.

1
2
val child: String => XmlNode => Option[XmlNode] =
  name => node => node.child.find(_.label == name)

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

1
2
3
4
5
6
def compose(getChildA: XmlNode => Option[XmlNode],
            getChildB: XmlNode => Option[XmlNode]): XmlNode => Option[XmlNode] =
  node => for {
            a  <- getChildA(node)
            ab <- getChildB(a)
          } yield ab

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

1
2
3
4
5
6
7
val getChildA: XmlNode => Option[XmlNode] = child(“a”)
val getChildB: XmlNode => Option[XmlNode] = child(“b”)
  
import scalaz._, Scalaz._
  
val getChildAB: Kleisli[Option, XmlNode, XmlNode] =
  Kleisli(getChildA) >=> Kleisli(getChildB)

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

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

1
2
3
4
5
6
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] в качестве нейтрального элемента сгиба . Нам нужен этот нейтральный элемент для обработки особого случая, когда path равен Nil . Kleisli.ask [Option, XmlNode] — это просто псевдоним функции из любого узла в Some (узел) .

Абстрагирование над XmlNode

Давайте обобщим наше решение и абстрагируем его через XmlNode . Мы можем переписать его как следующую обобщенную функцию:

1
2
3
4
5
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)

Теперь мы можем повторно использовать эту универсальную функцию для извлечения узла из JSON (здесь мы используем json4s ):

01
02
03
04
05
06
07
08
09
10
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.

Абстрагирование над опцией

Мы можем обобщить getByPathGeneric еще дальше и абстрагировать его от Option с помощью Scalaz, который предоставляет экземпляр scalaz.Monad [Option] . Таким образом, мы можем переписать getByPathGeneric следующим образом:

1
2
3
4
5
6
7
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 :

1
2
3
4
5
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 предоставляет неявный класс OptionOps с методом toRightDisjunction [B] (b: B) , который преобразует Option [A] в scalaz.B \ / A, так что Some (a) становится Right (a), а None становится Left (б) Вы можете найти больше информации о \ / в других блогах .

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

1
2
3
4
5
6
7
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 .

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

Ссылка: Рефакторинг с Kleisli Composition от нашего партнера JCG Михаила Дагаева в блоге Wix IO .