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