Оглавление

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

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

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

Заключение

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

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

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

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

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

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

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

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

Оглавление