Зипперы в Clojure (часть 8). Заключение
Оглавление
- Зипперы (часть 1). Азы навигации
- Зипперы (часть 2). Автонавигация
- Зипперы (часть 3). XML-зипперы
- Зипперы (часть 4). Поиск в XML
- Зипперы (часть 5). Редактирование
- Зипперы (часть 6). Виртуальные деревья. Обмен валют
- Зипперы (часть 7). Обход в ширину. Улучшенный обмен валют
- Зипперы (часть 8). Заключение
В заключение рассмотрим другие возможности зипперов, которые могут быть вам полезны.
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. Потратьте на них время, чтобы в будущем решать такие задачи коротко и изящно.
Оглавление
- Зипперы (часть 1). Азы навигации
- Зипперы (часть 2). Автонавигация
- Зипперы (часть 3). XML-зипперы
- Зипперы (часть 4). Поиск в XML
- Зипперы (часть 5). Редактирование
- Зипперы (часть 6). Виртуальные деревья. Обмен валют
- Зипперы (часть 7). Обход в ширину. Улучшенный обмен валют
- Зипперы (часть 8). Заключение
Нашли ошибку? Выделите мышкой и нажмите Ctrl/⌘+Enter