Оглавление

В заключение рассмотрим другие возможности зипперов, которые могут быть полезны.

HTML

И прошлых примеров видно, что зипперы подходят для работы с форматом XML. В том числе их можно применить и для HTML. Строго говоря, синтаксис HTML отличается от XML: некоторые элементы вроде <br> или <img> не имеют закрывающих тегов. Проблему можно решить с помощью парсеров, которые учитывают эти особенности. На выходе получим XML-дерево, которое поддается обходу как в примерах выше.

Библиотека Hickory предлагает парсер разметки HTML. Разбор основан на Java-библиотеке JSoup, которая строит дерево элементов. Hickory содержит модуль hickory.zip, чтобы перестроить исходное дерево в Clojure-подобное (с элементами :tag, :attrs и :content). Добавьте в проект зависимость:

[hickory "0.7.1"]

и выполните пример:

(ns zipper-manual.core
  (:require
   [hickory.core :as h]
   [hickory.zip :as hz]
   [clojure.zip :as zip]))

(def html (-> "https://grishaev.me/"
              java.net.URL.
              slurp))

(def doc-src (h/parse html))
(def doc-clj (h/as-hiccup doc-src))
(def doc-zip (hz/hiccup-zip doc-clj))

Объясним эти преобразованиия. В переменную html загружается верстка сайта в виде строки. В переменной doc-src оказалось дерево, полученное из HTML. Это объект класса Document из пакета org.jsoup.nodes. С точки зрения Clojure это черный ящик: чтобы работать с ним, нужно читать документацию к методам Document.

Функция as-hiccup переводит документ в набор вложенных векторов вида:

[:tag {:attr "value"} & [...]],

где на первом месте тег, затем словарь атрибутов, а за ним – любое число таких же векторов или строк. Это стандартное представление HTML в Clojure, и многие библиотеки следуют ему.

Функция hiccup-zip возвращает зиппер для этой структуры. С ним можно сделать все то, в чем мы упражнялись раньше, например:

  • удалить нежелательные теги вроде <script>, <iframe>;
  • оставить эти теги, но исправить их атрибуты;
  • оставить опасные теги, только если их источник указывает на доверенные сайты;
  • найти наиболее вложенные элементы (оценить длину результата zip/path).

Вот как найти все картинки страницы:

(defn loc-img? [loc]
  (some-> loc zip/node first (= :img)))

(defn loc->src [loc]
  (some-> loc zip/node second :src))

(->> doc-zip
     iter-zip
     (filter loc-img?)
     (map loc->src))

("/assets/static/photo-round-small.png" ...)

Первая функция проверяет, что локация указывает на узел с тегом <img>, вторая извлекает из него атрибут src. Последняя форма вернет список ссылок на изображения.

На базе этих действий можно построить фильтрацию HTML, что особенно важно, если разметка приходит от пользователя. Другой сценарий — найти в HTML подходящее изображение для обложки в соцсети. Для этого нужно выбрать все изображения, оценить их ширину и высоту и выбрать наибольшее по площади (если заполнены атрибуты width и height).

Hickory учитывает типовые случаи и предлагает селекторы для поиска по тегу и атрибуту. Для этого даже не обязательно приводить дерево JSoup к зипперу. Однако в редких случаях нужно найти теги со сложной взаимосвязью как в примере с товаром и набором (только в наборе или строго не в нем). Эти задачи изящно ложатся на зипперы.

Данные и сериализация

Другой плюс зипперов в том, что они представлены данными — комбинацией списков и словарей. Это значит, текущий зиппер можно записать в EDN или JSON. При чтении мы получим прежнюю структуру данных и продолжим обход с места, где остановились. Это отличает Clojure от объектных языков, где в общем случае нельзя записать объект в файл без определений методов (de)serialize.

При восстановлении зиппера помните о его метаданных. Функции branch?, children и make-node, которые мы передали в конструктор, хранятся в метаданных зиппера. Это сделано для того, чтобы отделить данные от действий над ними. Проверим метаданные:

(meta doc-zip)

#:zip{:branch? #function[clojure.core/sequential?],
      :children #function[hickory.zip/children],
      :make-node #function[hickory.zip/make]}

Напишем функции для сброса и чтения EDN:

(defn edn-save [data path]
  (spit path (pr-str data)))

(defn edn-load [path]
  (-> path slurp edn/read-string))

Предположим, мы дошли со середины зиппера, и теперь сохраним его в файл:

(-> doc-zip
    zip/next
    zip/next
    zip/next
    (edn-save "zipper.edn"))

Чтобы считанный из файла зиппер заработал, добавьте ему метаданные. Их можно либо вынести в переменную заранее, либо объявить вручную.

(def zip-meta (meta doc-zip))

;; or

(def zip-meta
  #:zip{:branch? sequential?
        :children #'hickory.zip/children
        :make-node #'hickory.zip/make})

Во втором случае нам прошлось указать ссылки на функции children и make-node, потому что они приватные. Прочитанный зиппер окажется в том же состоянии, что и в момент сохранения.

(def doc-zip-new
  (-> "zipper.edn"
      edn-load
      (with-meta zip-meta)))

(-> doc-zip-new zip/node first)
:head

Хранение зиппера в долговременной памяти дарит новые возможности. Например, обход каких-то данных занимает время, и программа может делать задачу порциями, сохраняя промежуточных результат. Так работают сложные бизнес-сценарии. Если клиент отказывается от услуг фирмы, вы должны удалить его записи в базе, файлы, ссылки на него в документах и много другое. Этот процесс можно представить как набор шагов, некоторые из которых выполняются последовательно, а другие — параллельно. В Exoscale подобные сценарии работают на базе зипперов. На каждом шаге код читает из базы зиппер в виде EDN, сдвигает его на один zip/next, выполняет задачу и обновляет запись в базе с новой версией зиппера.

Другое

Пример с разменом валют показывает, как найти решение задачи перебором. Если нужно найти оптимальную цепочку шагов, максимальную цену, маршрут обхода — возможно, вам помогут зипперы. Легко проверить, ложится ли на них ваша задача. Зиппер подразумевает, что у вас есть текущее значение и несколько других на его базе. Если условие работает, вы в шаге от того, чтобы построить дерево и обойти его.

Скажем, согласно таблице обмена доллар (текущее значение) можно разменять на евро и рубль (дочерние значения). Из точки A (текущее) можно проехать в пункты B и C (дочерние). В HTML один тег может включать в себя другие. Все три случая подходят к зипперу, нужно только описать функции branch? — может элемент иметь потомков, и children — как конкретно их найти.

Сторонние библиотеки

Модуль clojure.zip предлагает достаточно функций; все же по ходу главы нам пришлось дописывать свои. В библиотеке data.zip собраны различные дополнения, например предикаты attr= и tag= для поиска по атрибуту и тегу. Изучите библиотеку, если много работаете с зипперами.

Заключение

Зипперы — это механизм навигации по структуре данных. Зиппер предлагает движение по четырем сторонам: вниз, вверх, влево, вправо. Элемент в центре называется текущим.

Зиппер может перемещаться по самым разным структурам. Ему нужно знать только две вещи: является ли текущий элемент веткой дерева и если да, то как найти потомков. Для этого зиппер принимает функции branch? и children, которые позже хранит в метаданных.

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

Текущий элемент зиппера называют локацией. Он хранит не только очередное значение, но и данные для перехода во все стороны, а также путь. Это выгодно отличает зиппер от tree-seq и аналогов, которые раскладывают дерево в цепь без учета пути к элементу. Некоторые задачи состоят именно в поиске нужного пути.

Зиппер предлагает функции для правки и удаления текущего узла. Правка может отталкиваться от текущего значения (zip/edit) или нового (zip/replace).

По умолчанию обход зиппера происходит в глубину (depth first). При переходе в конец локация получит отметку о том, что выполнен полный цикл прохода. Используйте функцию zip/end?, чтобы прекратить итерацию. В наших примерах мы написали функцию zip-iter, которая совершает строго один обход.

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

Зипперы полезны в работе с XML, поиском решений, фильтрации HTML. Разберитесь с ними, чтобы быть готовыми к самым разным задачам производства.

Оглавление