Оглавление

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

HTML

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

Библиотека Hickory предлагает парсер разметки HTML. Разбор основан на Java-библиотеке JSoup, которая строит дерево элементов. Hickory содержит функцию, чтобы перестроить Java-дерево в Clojure-подобное и получить зиппер. Добавьте в проект зависимость:

[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>;
  • оставить эти теги, но обезопасить их атрибуты;
  • оставить, только если источник указывает на доверенные сайты;
  • искать интересующие нас элементы.

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

(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 от объектных языков, где в общем случае нельзя записать объект в файл без определенных усилий.

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

(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"))

Если считать EDN и передать результат в zip/next, получим ошибку. Функция вызовет branch? и children из метаданных, которые не сохранились, что приведёт к исключению. Чтобы зиппер из файла заработал, добавьте ему метаданные. Скопируйте их из зиппера или объявите вручную:

(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, потому что они приватные. После вызова with-meta локация из файла окажется в том же состоянии, что и при сохранении.

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

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

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

Другое

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

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

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

Модуль clojure.zip предлагает достаточно функций для навигации, однако по ходу главы мы создали немало своих инструментов. Автор собрал их в библиотеке Zippo. Похожий проект data.zip содержит различные дополнения к зипперами. Возможно, эти две библиотеки окажутся вам полезны.

Заключение

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

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

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

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

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

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

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

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

Оглавление