Обработка xml-файлов в Java

На днях я решил зарефакторить свой курсовой проект из универа, где можно было решать японские кроссворды. Кроссворды хранились в xml-файлах в виде списков чисел для каждой строки и столбца. Поэтому встал вопрос, как лучше читать и записывать такие файлы. О работе с xml-файлами на Java и пойдёт речь в этой статье. Рассмотрю DOM, SAX, и StAX.

Структура файлов следующая:

<crossword name="cat">
  <rows>
    <row>1</row>
    ...
  </rows>
  <columns>
    <column>1 1</column>
    ...
  </columns>
</crossword>

Теперь перейдём к каждому варианту и их реализациям в моём коде.

DOM (объектная модель)

XML-файл полностью находится в памяти в виде древовидной структуры объектов (узлы, элементы, текст, атрибуты).

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

Из плюсов простота навигации, но из минусов большие расходы памяти при работе с объёмными xml-файлами.

Ключевые классы DOM

DocumentBuilderFactory – фабрика для создания парсера. Настраивает параметры парсинга (поддержка namespace, валидация по XSD/DTD, защита от XXE и т. п.). Возвращает объект DocumentBuilder.

DocumentBuilder – сам парсер XML. Используется для загрузки XML в память и получения объекта Document. Может создать пустой Document (для записи).

Document – объект верхнего уровня, представляющий весь xml-документ. Через него создаём новые элементы/атрибуты, если пишем XML.

Element – тег документа. Корневой получаем из объекта Document через getDocumentElement()).

Node – всё дерево состоит из таких узлов. Это базовый интерфейс, от которого наследуются все объекты DOM (и Document, и Element, и Text, и Attr и т.д.). У каждого узла есть тип (short nodeType). Основные константы:

  • Node.ELEMENT_NODE → элемент (…)
  • Node.ATTRIBUTE_NODE → атрибут (name=»cat»)
  • Node.TEXT_NODE → текстовый узел («hello world»)
  • Node.DOCUMENT_NODE → корневой объект Document
  • Node.COMMENT_NODE → комментарий ()
  • Node.DOCUMENT_TYPE_NODE → <!DOCTYPE ...>

Ключевые методы DOM

getDocumentElement() у Document возвращает корневой элемент XML-документа (первый тег, который оборачивает всё дерево).

Element root = doc.getDocumentElement();

getElementsByTagName(String name) у Element возвращает список (NodeList) всех элементов с заданным именем в поддереве заданного элемента. Позволяет быстро найти узлы определённого типа.

getAttribute(String name) у Element получает значение атрибута по имени. Если атрибута нет, то вернёт пустую строку. Для записи есть аналог setAttribute:

String oldRootName = root.getAttribute("name");
root.setAttribute("name", "dog");

getTextContent() возвращает текстовое содержимое узла (и всех его потомков).

NodeList rows = root.getElementsByTagName("row");
for (int i = 0; i < rows.getLength(); i++) { 
    Element row = (Element) rows.item(i);      
    System.out.println(row.getTextContent()); 
}

Если внутри элемента есть вложенные элементы, метод соберёт текст из всех потомков. Например вернёт Hello World для этого кода:

<row>
   Hello <b>World</b>
</row>

Если нужен текст только одного элемента, то придётся проходиться через Node:

    StringBuilder sb = new StringBuilder();
    NodeList children = element.getChildNodes();
    for (int i = 0; i < children.getLength(); i++) {
        Node node = children.item(i);
        if (node.getNodeType() == Node.TEXT_NODE) {
            sb.append(node.getNodeValue());
        }
    }
    return sb.toString().trim();

Если мы решаем ходить не по Element, а по Node, нужно так же знать основные методы для этого случая:

Получение информации и проход по узлам:
getNodeName() – имя узла (например, «row», «column», текст для текстового узла).
getNodeValue() – для узла типа Text возвращает текст, для Element возвращает null (текст берём через getTextContent()).
getNodeType() – возвращает тип узла (см. выше). Можно проверять if (node.getNodeType() == Node.TEXT_NODE).
getParentNode() – возвращает ссылку на родителя.
getChildNodes() – возвращает список детей (NodeList).
getFirstChild() / getLastChild() – быстрый доступ к первому/последнему ребёнку.
getNextSibling() / getPreviousSibling() – соседние узлы на одном уровне.

Редактирование узлов:
appendChild(Node newChild) – добавить дочерний узел.
insertBefore(Node newChild, Node refChild) – вставить новый узел перед указанным.
removeChild(Node oldChild) – удалить дочерний узел.
replaceChild(Node newChild, Node oldChild) – заменить дочерний узел.
cloneNode(boolean deep) – возвращает копию узла (если deep = true, то копирует со всеми потомками).

Пример чтения с DOM

Рассмотрим пример чтения моего xml-файла с кроссвордами (см. структуру выше):

    public Crossword read(InputStream in) {         
            DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();

            DocumentBuilder db = dbf.newDocumentBuilder();
            Document doc = db.parse(in);
            doc.getDocumentElement().normalize();

            Element root = doc.getDocumentElement();
            String crosswordName = root.getAttribute("name");       

            Element rowsEl = firstChildElementByName(root, "rows");
            Element colsEl = firstChildElementByName(root, "columns");

            List<List<Integer>> rows = readLines(rowsEl, "row");
            List<List<Integer>> cols = readLines(colsEl, "column");

            return new Crossword(rows, cols, crosswordName);
}

    private static List<List<Integer>> readLines(Element section, String itemName) {
        NodeList nl = section.getElementsByTagName(itemName);
        List<List<Integer>> out = new ArrayList<>(nl.getLength());
        for (int i = 0; i < nl.getLength(); i++) {
            Node n = nl.item(i);
            if (n.getNodeType() != Node.ELEMENT_NODE) continue;
            String text = n.getTextContent();
            out.add(parseClueLine(text));
        }
        return out;
    }

    private static Element firstChildElementByName(Element parent, String name) {
        NodeList nl = parent.getElementsByTagName(name);
        for (int i = 0; i < nl.getLength(); i++) {
            Node n = nl.item(i);
            if (n.getParentNode() == parent && n.getNodeType() == Node.ELEMENT_NODE) {
                return (Element) n;
            }
        }
        return null;
    }

В этом примере мы находим корень, потом элементы, в которых хранятся все строки и столбцы (rows, columns). По умолчанию такие элементы должны быть по одному. Поэтому используем firstChildElementByName — реализацию метода прилагаю.

Потом для каждого элемента row или column достаём содержимое изнутри (текст с подсказками кроссворда, например «1 2 4») и уже обрабатываем этот текст. Метод обработки не показываю, так как он тут не важен и уже работает со строкой.

Пример записи с DOM

Теперь запись моего объекта Crossword.

    public void write(Crossword cw, OutputStream out) {
            Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument();
            Element root = doc.createElement("crossword");
            root.setAttribute("name", "cat");
            doc.appendChild(root);

            Element rowsEl = doc.createElement("rows");
            root.appendChild(rowsEl);
            for (List<Integer> row : cw.getRows()) {
                Element rowEl = doc.createElement("row");
                if (!row.isEmpty()) {
                    rowEl.setTextContent(joinInts(row));
                }
                rowsEl.appendChild(rowEl);
            }

            Element colsEl = doc.createElement("columns");
            root.appendChild(colsEl);
            for (List<Integer> col : cw.getColumns()) {
                Element colEl = doc.createElement("column");
                if (!col.isEmpty()) {
                    colEl.setTextContent(joinInts(col));
                }
                colsEl.appendChild(colEl);
            }
 
            TransformerFactory tf = TransformerFactory.newInstance();
            Transformer t = tf.newTransformer();
            t.setOutputProperty(OutputKeys.INDENT, "yes");
            t.transform(new DOMSource(doc), new StreamResult(out));
}

Тут так же, как в чтении, создаём документ и уже наполняем его элементами на основе содержимого объекта. Используем setAttribute для атрибутов, appendChild для добавления потомков и setTextContent для добавления текста.

Здесь особенно нас интересуют TransformerFactory и Transformer. Трансформер умеет преобразовывать XML-дерево (DOMSource) в поток.

А чтобы при выводе весь файл не был в одну строчку, то добавляет Property: OutputKeys.INDENT, «yes».

А потом уже отправляем наш документ doc в поток out.

SAX (событийная потоковая модель)

Документ читается потоком, парсер последовательно генерирует события: начало элемента, текст, конец элемента и т. д. Мы реализуем обработчик (handler), который реагирует на эти события.

Из плюсов минимальные затраты памяти, даже на больших файлах. Из минусов: нельзя «свободно гулять» по дереву, так как его нет в памяти, приходится хранить состояние (флаги/стек) в обработчике.

Для записи этот вариант не очень подходит, но я всё равно рассмотрю его на всякий случай. Нам потребуется для этого TransformerHandler.

Ключевые классы SAX

SAXParserFactory – фабрика парсера SAX.

SAXParser – сам парсер; запускает разбор и вызывает DefaultHandler.

DefaultHandler – базовый обработчик событий, который мы переопределяем.

Attributes – атрибуты элемента

TransformerHandler – для записи: принимает SAX-события и превращает их в XML-текст

Transformer – контролирует, как именно XML будет записан (кодировка, отступы, заголовок и т.п.)

Ключевые методы SAX

startDocument() / endDocument() – начало/конец документа.

startElement(uri, localName, qName, Attributes atts) – встретился открывающий тег.

characters(char[] ch, int start, int length) – обработка текстовой порции (может приходить частями).

endElement(uri, localName, qName) – закрывающий тег.

getValue(name) / getValue(uri, localName) – доступ к атрибутам текущего элемента. Возвращает null, если атрибута нет.

Пример чтения с SAX

Предлагаю побыстрее перейти к примеру чтения кроссворда.

public Crossword read(InputStream in) {
            SAXParserFactory f = SAXParserFactory.newInstance();
            f.setNamespaceAware(false);
            f.setValidating(false);

            SAXParser parser = f.newSAXParser();
            CrosswordHandler handler = new CrosswordHandler();
            parser.parse(in, handler);

            if (!handler.seenRoot) {
                throw new Exception("Root <crossword> not found");
            }

            return new Crossword(handler.rows, handler.cols, handler.crosswordName);
    }

    private static final class CrosswordHandler extends DefaultHandler {
        boolean inRows = false;
        boolean inCols = false;
        boolean inRowItem = false;
        boolean inColItem = false;
        boolean seenRoot = false;
        
        String crosswordName;
        final List<List<Integer>> rows = new ArrayList<>();
        final List<List<Integer>> cols = new ArrayList<>();

        StringBuilder text = new StringBuilder();

        @Override
        public void startElement(String uri, String localName, String qName, Attributes attributes) {
            switch (qName) {
                case "crossword" -> {
                     seenRoot = true;
                     crosswordName = attributes.getValue("name"); 
                 }
                 case "rows"    -> inRows = true;
                 case "columns" -> inCols = true;
                 case "row" -> {
                    if (inRows) {
                        inRowItem = true;
                    }
                 }
                 case "column" -> {
                     if (inCols) {
                         inColItem = true;
                     }
                 }
                 default -> { /* ignore */ }
            }
        }

        @Override
        public void characters(char[] ch, int start, int length) {
            if (inRowItem || inColItem) {
                text.append(ch, start, length);
            }
        }

        @Override
        public void endElement(String uri, String localName, String qName) throws SAXException {
            switch (qName) {
                case "rows"    -> inRows = false;
                case "columns" -> inCols = false;

                case "row" -> {
                    if (inRowItem) {
                        rows.add(parseLine(text.toString().trim())); 
                        inRowItem = false;
                        text.setLength(0);
                    }
                }
                case "column" -> {
                    if (inColItem) {
                        cols.add(parseLine(text.toString().trim())); 
                        inColItem = false;
                        text.setLength(0);
                    }
                }
                default -> { /* ignore */ }
            }
        }
}

Сначала мы создаём фабрику и конкретный парсер. А потом уже запускаем парсинг:

parser.parse(in, handler);

Для этого нам нужна конкретная реализация DefaultHandler, как она будет реагировать на начало элемента, текст и конец элемента.

Парсер идёт по потоку in и вызывает методы handler в порядке появления элементов в xml-файле.

Во время парсинга нам надо понимать контекст, где мы находимся. Мне нужно знать, что мы сканируем: строки или столбцы, поэтому нужны inRows / inCols и inRowItem / inColItem. Вы можете создавать свои флаги для считывания своих элементов.

Эти флаги – простая «машина состояний». Они помогают накапливать текст только там, где нужен (у меня между тэгами col или row)

startElement() вызывается, когда парсер встречает открывающий тег и меняет наши флаги.

endElement() берёт накопленный текст и потом парсит их в нужный мне формат. После этого важно сбросить флаг и показать, что мы действительно вышли из тега.

Когда парсер дошёл до конца входного потока он берёт все данные из хэндлера и создаёт нужный мне объект. По желанию можно валидировать эту модель перед созданием. У меня в случае, если мы не нашли корень, выкидывается исключение, что документ не валидный.

Пример записи с SAX

Рассмотрим мои пример записи кроссворда в файл:

@Override
    public void write(Crossword cw, OutputStream out) {
        try {
            SAXTransformerFactory tf = (SAXTransformerFactory) SAXTransformerFactory.newInstance();
            TransformerHandler th = tf.newTransformerHandler();
            Transformer tr = th.getTransformer();

            tr.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
            tr.setOutputProperty(OutputKeys.INDENT, "yes");
            tr.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2");

            Result result = new StreamResult(out);
            th.setResult(result);

            AttributesImpl attrs = new AttributesImpl();

            th.startDocument();
            th.startElement("", "", "crossword", attrs);

            th.startElement("", "", "rows", attrs);
            for (List<Integer> row : cw.getRows()) {
                th.startElement("", "", "row", attrs);
                if (!row.isEmpty()) {
                    char[] data = joinInts(row).toCharArray();
                    th.characters(data, 0, data.length);
                }
                th.endElement("", "", "row");
            }
            th.endElement("", "", "rows");

            th.startElement("", "", "columns", attrs);
            for (List<Integer> col : cw.getColumns()) {
                th.startElement("", "", "column", attrs);
                if (!col.isEmpty()) {
                    char[] data = joinInts(col).toCharArray();
                    th.characters(data, 0, data.length);
                }
                th.endElement("", "", "column");
            }
            th.endElement("", "", "columns");

            th.endElement("", "", "crossword");
            th.endDocument();

            out.flush();
        } catch (Exception e) {
            throw new RuntimeException("SAX writer error: " + e.getMessage(), e);
        }
    }

В этом примере первым делом я создаю TransformerHandler, получаю Tranformer и настраиваю вывод.

И так как доступа к дереву элементов у нас нет, то каждый тег (открывающий и закрывающий) и его содержимое нужно записывать отдельно.

Для начала начинаем документ с startDocument(). Далее каждый элемент открываем с startElement(), наполняем его другими элементами или текстом с characters(). Главное не забыть также все элементы закрыть с endElement() и под конец закрыть документ.

joinInts() в моём коде это вспомогательный метод, который все числа кроссворда собирает в строку, которую потом записываем в элементы.

Я не использую namespace, поэтому первые два аргумента для методов startElement и endElement пустые. Иначе там были бы URI пространства имён (namespace) и локальное имя.

Атрибутов у тегов в этом примере нет. Но их можно было бы создавать следующим образом:

AttributesImpl attrs = new AttributesImpl();
attrs.addAttribute("", "", "name", "CDATA", crosswordName);
th.startElement("", "", "crossword", attrs);

Порядок аргументов: uri, localName, qName, type, value. Так как пространства имён у меня всё ещё нет, то первые два аргумента остаются пустыми.

StAX (потоковый «курсорный» парсер)

Это тоже потоковая модель, как SAX, но с «курсорным» API: мы сами двигаемся по событиям с помощью стрима XMLStreamReader/XMLStreamWriter.

Память почти не расходуется, как и в SAX, но код получается чуть короче и понятнее, так как не нужен DefaultHandler и машина состояний. Вместо этого всё в одном цикле while. То есть система очень похожа на обычное чтение файла потоком.

Ключевые классы StAX

XMLInputFactory – создаёт XMLStreamReader на основе InputStream для чтения.

XMLStreamReader – обеспечивает доступ к событиям (начало элемента START_ELEMENT, текстовое содержимое CHARACTERS, конец элемента END_ELEMENT и др.) и даёт методы доступа к текущему элементу и его атрибутам.

XMLOutputFactory – создаёт XMLStreamWriter на основе OutputStream для записи.

XMLStreamWriter – отвечает за пошаговую запись.

Ключевые методы StAX

next() – продвигает курсор к следующему событию (тегу, тексту, комментариям). Возвращает константу из XMLStreamConstants.

hasNext() – проверяет, есть ли ещё события (аналог итератора).

getEventType() – возвращает тип текущего события.

getLocalName() – возвращает имя текущего элемента. Работает только при START_ELEMENT и END_ELEMENT.

getText() – возвращает текст содержимого текущего события CHARACTERS или CDATA.

Может возвращать часть текста, если коалесцирование (IS_COALESCING) выключено. Например, если текст идёт вперемешку с CDATA, или он длинный и с переносами. Тогда getText() возьмёт только первую секцию или первую строчку, и вы рискуете потерять часть данных. Как его включить?

XMLInputFactory f = XMLInputFactory.newInstance();
f.setProperty(XMLInputFactory.IS_COALESCING, true)

r.getAttributeCount() – возвращает количество атрибутов у текущего элемента.

getAttributeLocalName(int i) – возвращает имя атрибута с индексом i.

getAttributeValue(int i) / r.getAttributeValue(String ns, String name) – возвращает значение атрибута (по индексу или по имени).

writeStartDocument(«UTF-8», «1.0») – начало XML-документа при записи, добавляет заголовок.

writeStartElement(«rows») – открывает новый элемент.

writeAttribute(«name», «cat») – добавляет атрибут текущему открытому элементу.

writeCharacters(«1 2 3») – пишет текстовое содержимое между тегами. Автоматически экранирует спецсимволы (<, >, &, «).

writeEndElement() – закрывает текущий элемент. Нужно вызывать для каждого открытого.

writeEndDocument() – завершает документ (после последнего writeEndElement()).

flush() – сбрасывает буфер при записи

close() – освобождает ресурсы, связанные с потоком (как при чтении, так и при записи)

Константы из XMLStreamConstants

START_ELEMENT (числовое значение 1) – открывающий тег

END_ELEMENT (числовое значение 2) – закрывающий тег

CHARACTERS (числовое значение 4) – текст между тегами

COMMENT (числовое значение 5) – комментарий

SPACE (числовое значение 6) – пробельные символы вне текста

START_DOCUMENT (числовое значение 7) – начало документа

END_DOCUMENT (числовое значение 8) – конец документа

CDATA (числовое значение 12) – CDATA-секция

Пример чтения со StAX

Теперь посмотрим, как я реализовал чтение кроссворда из файла:

    public Crossword read(InputStream in) {
        try {
            XMLInputFactory f = XMLInputFactory.newInstance();

            f.setProperty(XMLInputFactory.IS_NAMESPACE_AWARE, false);
            f.setProperty("javax.xml.stream.isSupportingExternalEntities", false);
            f.setProperty("javax.xml.stream.supportDTD", false);

            XMLStreamReader r = f.createXMLStreamReader(in, "UTF-8");
            StringBuilder text = new StringBuilder();

            List<List<Integer>> rows = new ArrayList<>();
            List<List<Integer>> cols = new ArrayList<>();

            while (r.hasNext()) {
                int ev = r.next();
                switch (ev) {
                    case XMLStreamConstants.START_ELEMENT -> {
                        String name = r.getLocalName();
                        switch (name) {
                            case "crossword" -> seenRoot = true;
                            case "row" -> text.setLength(0);
                            case "column" -> text.setLength(0);
                            default -> {}
                        }
                    }
                    case XMLStreamConstants.CHARACTERS, XMLStreamConstants.CDATA -> {
                        text.append(r.getText());
                    }
                    case XMLStreamConstants.END_ELEMENT -> {
                        String name = r.getLocalName();
                        switch (name) {
                            case "row" -> {
                                rows.add(parseLine(text.toString()));
                                text.setLength(0);
                            }
                            case "column" -> {
                                cols.add(parseLine(text.toString()));
                                text.setLength(0);
                            }
                            default -> {}
                        }
                    }
                    default -> {}
                }
            }
            r.close();

            return new Crossword(rows, cols);
        } catch (Exception e) {
        }
    }

Этот пример я сделал покороче, чем прошлый с SAX, убрал из него логику проверок данных (в прошлый раз я проверял, в какой секции нахожусь: читаю ли строки или столбцы кроссворда, чтобы исключить чтение файла, сохранённого не по правилам). На этот раз сфокусировался на главном. Вы, конечно, можете расширить логику под свою задачу.

Создаю ридер с помощью XMLInputFactory и XMLStreamReader. И позже в цикле пробегаю по всем событиям, попутно наполняя строки и столбцы кроссворда. Текстовые значения тегов я обрабатываю с помощью вспомогательного метода parseLine(), который строку разбивает на числа и собирает их в список.

Пример записи со StAX

Теперь запись


    public void write(Crossword cw, OutputStream out) {
        try {
            XMLOutputFactory f = XMLOutputFactory.newInstance();
            XMLStreamWriter w = f.createXMLStreamWriter(out, "UTF-8");

            w.writeStartDocument("UTF-8", "1.0");
            w.writeStartElement("crossword");
            w.writeAttribute("name", cw.getName());

            w.writeStartElement("rows");
            for (List<Integer> row : cw.getRows()) {
                w.writeStartElement("row");
                if (!row.isEmpty()) {
                    w.writeCharacters(joinInts(row));
                }
                w.writeEndElement();
            }
            w.writeEndElement(); 

            w.writeStartElement("columns");
            for (List<Integer> col : cw.getColumns()) {
                w.writeStartElement("column");
                if (!col.isEmpty()) {
                    w.writeCharacters(joinInts(col));
                }
                w.writeEndElement();
            }
            w.writeEndElement(); 

            w.writeEndElement(); 
            w.writeEndDocument();

            w.flush();
            w.close();
        } catch (Exception e) {
            throw new RuntimeException("StAX writer error: " + e.getMessage(), e);
        }
    }

Здесь всё тоже предельно просто: получаю XMLStreamWriter с помощью XMLOutputFactory, и потом по очереди записываю все строки и потом столбцы японского кроссворда. Числа собираю в строки с помощью вспомогательного метода joinInts(). Самое важное, чтобы количество writeStartElement и writeEndElement совпадало.


В этой статье я постарался найти баланс между реальной задачей (считывание и запись кроссвордов для моего приложения) и простым кодом. Постарался не сильно вдаваться в подробности в логикой, и некоторые из них опустил, но надеюсь, мне удалось показать самое главное – основную тактику работы с xml файлами, основные методы и классы.

Оставьте комментарий