Работа с XML в Haskell и фреймворке Yesod

24 сентября 2012

Будет преувеличением сказать, что работы над переводом книги о веб-фреймворке Yesod близятся к завершению, однако большая часть пути уже определенно пройдена. В настоящее время не переведено несколько глав, а те, что переведены, еще предстоит перепроверить. Сегодня я хотел бы представить на ваш суд черновой вариант перевода 27-ой главы «xml-conduit», перевод которой не без пинков со стороны Darkus’а я закончил на днях.

Пакет xml-conduit

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

Как я уже отмечал, система типов языка Haskell позволяет нам с легкостью свести любую проблему к ее самой базовой форме. Пакет xml-types аккуратно предобразует модель данных XML (поддерживается работа как с целыми документами, так и с потоком данных) в простые абстрактные типы данных. Стандартные неизменяемые структуры данных языка Haskell упрощают преобразование документов, а простой набор функций делает их парсинг и рендеринг легкими и непринужденными.

Рассмотрим пакет xml-conduit. Под его капотом используется множество подходов, которые Yesod обычно применяет для высокой производительности: пакеты blaze-builder, text, conduit и attoparsec. С точки зрения пользователя он предоставляет все, начиная с простейших API (readFile/writeFile) и заканчивая полным контролем над потоками событий XML.

В дополнение к xml-conduit, в игру вступает еще несколько связанных пакетов, например, xml-hamlet и xml2html. Мы рассмотрим, как и при каких условиях должны использоваться эти пакеты.

Типы

Воспользуемся восходящим подходом для анализа типов. Помимо прочего, этот раздел будет служить введением в саму модель данных XML, так что не беспокойтесь, если вы не вполне знакомы с ней.

Я думаю, первое место, где Haskell по-настоящему показывает свою силу, это тип данных Name. Многие языки программирования (например, Java) сопротявляются введению выразительного типа данных, представляющего имена в XML. Проблема заключается в том, что на самом деле эти имена состоят из трех частей: локального имени, пространства имен (опционально), а также префикса (также опционально). Для наглядности рассмотрим следующий кусок XML-документа:

<no-namespace/>
<no-prefix xmlns="first-namespace" first-attr="val1"/>
<foo:with-prefix xmlns:foo="second-namespace" foo:second-attr="val2"/>

Первый тэг имеет локальное имя no-namespace, не имеет префикса и не принадлежит какому-либо пространству имен. Второй тэг (с локальным именем no-prefix) также не имеет префикса, но он принадлежит пространству имен first-namespace. Однако атрибут first-attr не наследует это пространство имен: пространства имен атрибутов всегда должны точно задаваться с помощью префикса.

Примечание: Пространства имен почти всегда представляют собой своего рода URI, хотя ничего подобного не требуется ни в одной спецификации.

Трегий тэг имеет локальное имя with-prefix, префикс foo и принадлежит пространству имен second-namespace. Его атрибут имеет локальное имя second-attr, имеет тот же префикс и принадлежит тому же пространству имен. Атрибуты xmlns и xmlns:foo являются частью спецификации пространства имен и не рассматриваются в качестве атрибутов соответствующих элементов.

Еще раз, из чего состоит имя? Из локального имени, а также опциональных префикса и пространства имен. Похоже на подходящий случай для применения записей:

data Name = Name
    { nameLocalName :: Text
    , nameNamespace :: Maybe Text
    , namePrefix :: Maybe Text
    }

Согласно стандарту пространств имен в XML, два имени считаются эквивалентными, если они имеют одинаковое локальное имя и принадлежат одному пространству имен. Другими словами, префикс неважен. Потому в пакете xml-types определены экземпляры классов Eq и Ord, игнорирующие префиксы.

Последний экземпляр класса, который следует упомянуть, это IsString. Было бы очень утомительно печатать:

Name "p" Nothing Nothing

…каждый раз, когда нам нужен новый параграф. Если вы включите расширение OverloadedStrings, "p" будет преобразовываться во все это хозяйство самостоятельно! Кроме того, экземлпяр IsString распознает нечто, называемое нотацией Кларка, что позволяет использовать в именах префикс с пространством имен в фигурных скобочках. Другими словами:

"{namespace}element" == Name "element" (Just "namespace") Nothing
"element" == Name "element" Nothing Nothing

Четыре типа узлов

XML-документы представляют собой дерево вложенных узлов. На самом деле, существует четыре различных типа узлов — элементы, содержание (то есть, текст), комментарии, а также инструкции по обработке (processing instructions).

Вероятно, вы не знакомы с последними, поскольку они используются довольно редко. Инструкции по обработке обозначаются следующим образом:

<?target data?>

Есть два удивительных факта об инструкциях по обработке:

  1. Инструкции по обработке не имеют атрибутов. Несмотря на то, что вам могут попастся инструкции, имеющие атрибуты, на самом деле не существует никаких правил относительно данных в инструкциях.
  2. <?xml …?> не является инструкцией по обработке. Это просто начало документа (также известное, как объявление XML), и так получилось, что оно выглядит поразительно похожим на инструкции по обработке. Разница заключается в том, что строка <?xml …?> не появится в разобранных данных (parsed content).

Учитывая, что каждая инструкция имеет два куска текста, связанных с ней (target и data), получается очень простой тип данных:

data Instruction = Instruction
    { instructionTarget :: Text
    , instructionData :: Text
    }

Комментариям не соответствует специальный тип, потому что они представляют собой обычный текст. Зато содержание (content) куда интереснее — оно может состоять из простого текста и неразрешенных сущностей (например, &bebebe;). Пакет xml-types оставляет эти сущности неразрешенными во всех типах данных, чтобы полностью соответствовать спецификации. Однако на практике может быть очень трудно работать с такими типами данных. И в большинстве случаев неразрешенная сущность в конечном итоге приведет к возникновению ошибки.

По этой причине модуль Text.XML определяет собственный набор типов данных для узлов, элементов и документов, в которых удаляются все неразрешенные сущности. Если вам нужно работать с неразрешенными сущностями, используйте модуль Text.XML.Unresolved. Начиная с этого момента мы сосредоточимся на типах данных модуля Text.XML, поскольку они почти идентичны версиям из пакета xml-types.

В связи с вышесказанным, содержание (content) также представляет собой обычный текст и потому у него также нет специального типа данных. Последним типом узлов является элемент, который состоит из имени, списка атрибутов и списка дочерних узлов. Каждый атрибут состоит из двух частей — имени и значения. (В пакете xml-types значение атрибута также может содержать неразрешенные сущности.) Итак, определим Element следующим образом:

data Element = Element
    { elementName :: Name
    , elementAttributes :: [(Name, Text)]
    , elementNodes :: [Node]
    }

Возникает закономерный вопрос — а как должен выглядеть тип данных Node? Вот где Haskell по-настоящему рулит:

data Node
    = NodeElement Element
    | NodeInstruction Instruction
    | NodeContent Text
    | NodeComment Text

Документы

Итак, у нас есть элементы и узлы, но как на счет целых документов? Рассмотрим следующие типы данных:

data Document = Document
    { documentPrologue :: Prologue
    , documentRoot :: Element
    , documentEpilogue :: [Miscellaneous]
    }

data Prologue = Prologue
    { prologueBefore :: [Miscellaneous]
    , prologueDoctype :: Maybe Doctype
    , prologueAfter :: [Miscellaneous]
    }

data Miscellaneous
    = MiscInstruction Instruction
    | MiscComment Text

data Doctype = Doctype
    { doctypeName :: Text
    , doctypeID :: Maybe ExternalID
    }

data ExternalID
    = SystemID Text
    | PublicID Text Text

В спецификации XML сказано, что документ может иметь только один корневой элемент (documentRoot). Он также может содержать опциональное объявление типа документа (doctype). Перед и после как типа документа, так и корневого элемента, разрешается иметь комментарии и инструкции по обработке. (Также разрешены пробелы, но они и так игнорируются во время парсинга.)

Так что там на счет типа документа? Он определяет корневой элемент документа, а затем, опционально, публичный (public) и системый (system) идентификаторы. Эти идентификаторы используются для ссылок на DTD-файлы, которые предоставляют больше информации о документе (например, правила валидации, атрибуты по умолчанию, разрешения сущностей). Вот несколько примеров:

<!DOCTYPE root> <!-- no external identifier -->
<!DOCTYPE root SYSTEM "root.dtd"> <!-- a system identifier -->
<!DOCTYPE root PUBLIC "My Root Public Identifier" "root.dtd">

Это, друзья мои, и есть вся модель данных XML. На практике, в большинстве случаев вы можете просто игнорировать тип данных Document и переходить сразу к documentRoot.

События

В дополнение к API для работы с документами пакет xml-types также определяет тип данных Event. Он может быть использован для конструирования поточных инструментов, которые могут потреблять намного меньше оперативной памяти при решении множества задач (например, добавления нового атрибута всем элементам). Сейчас мы не будем рассматривать соответствующий API, однако если вы посмотрите на него после прочтения этой главы, он наверняка покажется вам очень знакомым.

Примечание: Вы можете найти пример использования «поточного» API в главе 21, посвященой работе со Sphinx (перевод которой ранее публиковался в этом бложике).

Модуль Text.XML

Рекомендуемой точкой входа в пакет xml-conduit является модуль Text.XML. Этот модуль экспортирует все типы данных, которые могут вам потредоваться для манипулирования XML в DOM-стиле (Document Object Model, объектная модель документа), а также предоставляет несколько походов к парсингу и рендеренгу XML-документов. Начнем с простого:

readFile :: ParseSettings -> FilePath -> IO Document
writeFile :: RenderSettings -> FilePath -> Document -> IO ()

Здесь вы видите типы данных ParseSettings и RenderSettings. Вы можете использовать их для изменения поведения парсера или рендерера, например, добавления сущностей или включения оформленного вывода (то есть, с отступами). Оба типа являются экземплярами класса типов Default, так что вы можете просто использовать функцию def в тех случаях, когда требуется предоставить значение одного из них. Собственно, так мы и собираемся поступать до конца этой главы. Дополнительную информацию вы можете найти в документации на Hackage.

Следует также отметить, что в дополнение к API для работы с файлами также имеются API для работы с текстом и байтовыми строками (bytestring). В функциях для работы с байтовыми строками реализовано интеллектуальное определение кодировки. Поддерживаются кодировки UTF-8, UTF-16 и UTF-32, с прямым (little endian) и обратным (big endian) порядком байт, как с BOM (Byte-Order Marker), так и без него. Весь вывод генерируется в UTF-8.

Для комплексного поиска данных в XML-документах мы рекомендуем использовать API более высокого уровня для работы с курсорами. Стандартный API модуля Text.XML не только формирует базис для этого более высокого уровня. Он также предоставляет отличный API для простого преобразования и простой генерации XML. Пример его использования вы можете найти в разделе «Краткое содержание» этой главы (в переводе раздел опущен).

Замечание относительно путей к файлам

В приведенных выше сигнатурах функций вы видели тип FilePath. Однако это не Prelude.FilePath! В модуле Prelude определяется синоним:

type FilePath = [Char]

К сожалению, данный подход имеет множество ограничений, включая неопределенность кодировки файла, а также возможность использования различных символов в качестве разделителей в пути к файлу.

Вместо этого в xml-conduit используется пакет system-filepath, в котором определяется абстрактный тип FilePath. Я лично нахожу такой подход более удобным. Пакет очень прост в использовании, так что здесь я не буду останавливаться на деталях. Вместо этого я приведу лишь краткое пояснение относительно его использования.

  • Поскольку FilePath является экземпляром класса IsString, вы можете вводить обычные строки и они будут интерпретированы правильно, если активировано расширение OverloadedStrings. (Я настоятельно рекомендую использовать его в любом случае, поскольку это делает работу со значениями типа Text намного приятнее.)
  • Если вам требуется преобразование в или из FilePath модуля Prelude, вы должны использовать функцию encodeString или decodeString соответсвенно. Таким образом в рассчет будет принята кодировка пути к файлу.
  • Вместо того, чтобы вручную соединять имена директорий, имена файлов и их расширения, используйте операторы из модуля Filesystem.Path.CurrentOS, например myfolder </> filename <.> extension.

Курсоры

Допустим, вы хотите получить заголовок (title) из XHTML-документа. Вы можете сделать это с помощью интерфейса модуля Text.XML, который мы только что изучили, используя стандартное сопоставление с образцом потомков элементов. Но работа над программой с использованием этого подхода очень быстро станет утомительной. Вероятно, золотым стандартом для такого типа поиска является XPath, который позволяет вам обращаться к элементам, используя пути типа /html/head/title. Именно XPath вдохновил дизайн комбинаторов из модуля Text.XML.Cursor.

Кусрор представляет собой узел, который помнит свое положение в дереве; он может перемещаться вверх, в сторону или вниз. (Это реализовано с помощью приема «завязывание узлов».) Есть две функции, позволяющие преобразовывать типы модуля Text.XML в курсоры — fromDocument и fromNode.

Также имеется концепция оси (axis), реализованная как:

type Axis = Cursor -> [Cursor]

Проще всего понять эту концепцию на примере. Функция child возвращает список из нуля или более курсоров, которые являются потомками текущего курсора. Функция parent возвращает одиночный родительский курсор входного курсора или пустой список для корневого элемента. И так далее.

Некоторые оси используют предикаты. Так функция element обычно используется для фильтрации элементов по имени. Например, element "title" вернет входной элемент только в том случае, если он имеет имя «title», иначе будет возвращен пустой список.

Еще одна функция, которая не вполне является осью, это:

content :: Cursor -> [Text]

Для всех узлов с неким содержимым она возвращает содержимый текст, иначе возвращается пустой список.

Благодаря тому, что списки являются монадами, не составляет труда объединить все вышесказанное воедино. Например, следующая программа предназначена для поиска заголовка XHTML-документа:

{-# LANGUAGE OverloadedStrings #-}
import Prelude hiding (readFile)
import Text.XML
import Text.XML.Cursor
import qualified Data.Text as T

main :: IO ()
main = do
  doc <- readFile def "test.xml"
  let cursor = fromDocument doc
  print $ T.concat $
    child cursor >>= element "head" >>= child
                 >>= element "title" >>= descendant
                 >>= content

В переводе на русский это значит:

  1. Найти все дочерние узлы корневого элемента.
  2. Отфильтровать элементы, оставив лишь элементы с именем «head».
  3. Найти всех потомков (child) элементов, полученных на предыдущем шаге.
  4. Отфильтровать элементы, оставив лишь элементы с именем «title».
  5. Найти всех наследников (descendant) полученных элементов. (Наследник — это потомок или наследник потомка. Да, это рекурсивное определение.)
  6. Оставить только текстовые узлы.

Итак, для входного документа:

<html>
    <head>
        <title>My <b>Title</b></title>
    </head>
    <body>
        <p>Foo bar baz</p>
    </body>
</html>

Мы получим вывод «My Title». Это все, конечно, здорово и замечательно, но вообще-то тут намного больше кода, чем в случае использования XPath. Для борьбы с этой многословностью Aristid Breitkreuz добавил в модуль Cursor набор операторов для обработки большинства случаев. С их помощью мы можем переписать наш пример следующим образом:

{-# LANGUAGE OverloadedStrings #-}
import Prelude hiding (readFile)
import Text.XML
import Text.XML.Cursor
import qualified Data.Text as T

main :: IO ()
main = do
    doc <- readFile def "test.xml"
    let cursor = fromDocument doc
    print $ T.concat $
        cursor $/ element "head" &/ element "title" &// content

Оператор $/ говорит применить ось справа к потомкам (children) курсора слева. Оператор &/ практически идентичен, только используется он для комбинирования двух осей. Общее правило в модуле Text.XML.Cursor таково: операторы, начинающиеся со знака $, напрямую применяют ось, а начинающиеся со знака & объединяют две оси. Оператор &// используется для применения оси ко всем наследникам (descendants).

Рассмотрим более сложный (или более надуманный?) пример. Имеется следующий документ:

<html>
    <head>
        <title>Headings</title>
    </head>
    <body>
        <hgroup>
            <h1>Heading 1 foo</h1>
            <h2 class="foo">Heading 2 foo</h2>
        </hgroup>
        <hgroup>
            <h1>Heading 1 bar</h1>
            <h2 class="bar">Heading 2 bar</h2>
        </hgroup>
    </body>
</html>

Мы хотим получить содержимое всех тэгов h1, которые предшествуют тэгу h2 с атрибутом class, имеющим значение «bar». Для выполнения этого запутанного поиска мы можем написать:

{-# LANGUAGE OverloadedStrings #-}
import Prelude hiding (readFile)
import Text.XML
import Text.XML.Cursor
import qualified Data.Text as T

main :: IO ()
main = do
    doc <- readFile def "test2.xml"
    let cursor = fromDocument doc
    print $ T.concat $
        cursor $// element "h2"
               >=> attributeIs "class" "bar"
               >=> precedingSibling
               >=> element "h1"
               &// content

Давайте попробуем разобраться, что здесь происходит. Сначала мы получаем все элементы h2 в документе. (Оператор $// получает всех наследников корневого элемента.) Из них мы оставляем только те, что имеют атрибут class со значением «bar». Оператор >=> на самом деле является стандартным оператором из модула Control.Monad; вот еще одно преимущество того, что списки являются монадами. Функция precedingSibling находит все узлы, что идут перед заданным и имеют с ним общего родителя. (Имеется также предшествующая ось, с помощью которой мы ранее получили все элементы в дереве.) Затем мы просто берем все элементы h1 и получаем их содержимое.

Эквивалентный XPath, для сравнения, будет следующим:

//h2[@class ='bar']/preceding-sibling::h1//text()

Хоть API курсоров и уступает XPath в краткости, зато мы обращаемся к нему, используя чистый Haskell и обеспечивая безопасность типов.

Пакет xml-hamlet

Благодаря простоте системы типов языка Haskell, создание XML-документов с помощью API модуля Text.XML является крайне простым, хотя и несколько многословным. Следующий код:

{-# LANGUAGE OverloadedStrings #-}
import Text.XML
import Prelude hiding (writeFile)

main :: IO ()
main =
  writeFile def "test3.xml" $
    Document (Prologue [] Nothing []) root []
  where
    root = Element "html" []
        [ NodeElement $ Element "head" []
            [ NodeElement $ Element "title" []
                [ NodeContent "My "
                , NodeElement $ Element "b" []
                    [ NodeContent "Title"
                    ]
                ]
            ]
        , NodeElement $ Element "body" []
            [ NodeElement $ Element "p" []
                [ NodeContent "foo bar baz"
                ]
            ]
        ]

… генерирует:

<?xml version="1.0" encoding="UTF-8"?>
<html>
  <head><title>My <b>Title</b></title></head>
  <body><p>foo bar baz</p></body>
</html>

Это во много раз проще, чем использовать императивный API с изменяемыми состояниями (как в Java), но все же далко от идеала и к тому же затемняет наши настоящие намерения. Для исправления ситуации имеется пакет xml-hamlet, который использует расширение QuasiQuotes для того, чтобы позволить вводить XML, используя естественный синтаксис. Например, предыдущий пример может быть переписан так:

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
import Text.XML
import Text.Hamlet.XML
import Prelude hiding (writeFile)

main :: IO ()
main =
  writeFile def "test3.xml" $
    Document (Prologue [] Nothing []) root []
  where
    root = Element "html" [] [xml|
<head>
    <title>
        My #
        <b>Title
<body>
    <p>foo bar baz
|]

Тут нужно обратить внимание на следующее:

  • Синтаксис практически идентичен Hamlet, если не считать отсутствия интерполяции URL (@{…}). Таким образом:
    • Не нужно никаких закрывающих тэгов.
    • Имеет место чувствительность к пробелам.
    • Если вам нужны пробелы в конце строки, используйте на конце решетку. В начале строки используйте обратный слэш.
  • XML-интерполяция возвращает список узлов. В связи с этим вы все еще должны оборачивать результат в обычную конструкцию из Document и корневого Element.
  • Нет поддержки специальной формы атрибутов .class и #id.

Как и в нормальном Hamlet, вы можете использовать интерполяцию переменных и управляющие структуры. Рассмотрим это на чуть более сложном примере:

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
import Text.XML
import Text.Hamlet.XML
import Prelude hiding (writeFile)
import Data.Text (Text, pack)

data Person = Person
    { personName :: Text
    , personAge :: Int
    }

people :: [Person]
people =
    [ Person "Michael" 26
    , Person "Miriam" 25
    , Person "Eliezer" 3
    , Person "Gavriella" 1
    ]

main :: IO ()
main =
  writeFile def "people.xml" $
    Document (Prologue [] Nothing []) root []
  where
    root = Element "html" [] [xml|
<head>
    <title>Some People
<body>
    <h1>Some People
    $if null people
        <p>There are no people.
    $else
        <dl>
            $forall person <- people
                ^{personNodes person}
|]

personNodes :: Person -> [Node]
personNodes person = [xml|
<dt>#{personName person}
<dd>#{pack $ show $ personAge person}
|]

Еще пара моментов:

  • Caret-интерполяция (^{…}) принимает список узлов и следовательно может с легкостью вставлять другие xml-цитаты;
  • В отличие от Hamlet hash-интерполяция (#{…}) не является полиморфной и может принимать только значения типа Text;

Пакет xml2html

До сих пор в этой главе наши примеры вращались вокруг XHTML. Я сделал это по той простой причине, что XHTML скорее всего окажется наиболее знакомой формой XML для большинства читателей. Но в этом есть и отрицательный момент, который следует признать: не всякий XHTML является корректным HTML. Существуют следующие расхождения:

  • Некоторые «пустые» HTML-тэги (например, img, br) не обязаны иметь парные закрывающие тэги, и вообще-то не имеют права их иметь;
  • HTML не понимает самозакрывающиеся тэги, то есть <script></script> и <script/> означают разные вещи;
  • Объединяем два предыдущих пункта: «пустые» тэги могут быть самозакрывающимися, однако это ничего не значит для браузера;
  • Чтобы избежать недоразумений, HTML-документы должны начинаться с DOCTYPE-выражения;
  • XML-объявление <?xml …?> не нужно в HTML-страницах;
  • В HTML нет никаких пространств имен, в то время, как XHTML ими полон;
  • Содержимое тэгов <style> и <script> не должно экранироваться;

К счастью, пакет xml-conduit представляет экземпляры класса ToHtml для типов Node, Document и Element, которые уважают эти расхождения. Итак, просто используя функцию toHtml, мы можем получить корректный вывод. Следующая программа:

{-# LANGUAGE OverloadedStrings, QuasiQuotes #-}
import Text.Blaze.Html (toHtml)
import Text.Blaze.Html.Renderer.String (renderHtml)
import Text.XML
import Text.Hamlet.XML
import Text.XML.Xml2Html ()

main :: IO ()
main =
  putStr $ renderHtml $ toHtml $
    Document (Prologue [] Nothing []) root []

root :: Element
root = Element "html" [] [xml|
<head>
    <title>Test
    <script>if (5 < 6 || 8 > 9) alert("Hello!");
    <style>body > h1 { color: red }
<body>
    <h1>Hello World!
|]

… выводит (пробелы добавлены вручную):

<!DOCTYPE HTML>
<html>
    <head>
        <title>Test</title>
        <script>if (5 < 6 || 8 > 9) alert("Hello!");</script>
        <style>body > h1 { color: red }</style>
    </head>
    <body>
        <h1>Hello World!</h1>
    </body>
</html>

Если у вас имеются вопросы относительно написанного или вы видите ошибку в переводе, пожалуйста, сообщите об этом.

Дополнение: См также перевод главы про модуль Persistent.

Метки: , , , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.