1. Введение
Если вы хоть раз видели код, который «парсит XML» с помощью substringBefore("</tag>"), то вы знаете два факта: такой код иногда даже работает… и именно поэтому он особенно опасен. DOM‑парсинг — это путь, когда XML превращается в дерево объектов в памяти. Мы перестаём ловить углы строками и начинаем «ходить по структуре», как по папкам в проводнике.
DOM удобен, когда документ не гигантский, а нам нужно свободно извлекать разные куски данных. Да, DOM загружает XML целиком в память (это цена удобства), зато дальше мы можем достать корневой элемент, посмотреть атрибуты, найти все <expense>, прочитать текст внутри <amount> и так далее.
И приятный бонус: Kotlin на JVM спокойно вызывает Java‑API, а DOM‑парсер — это классический Java‑инструмент. То есть мы будем пользоваться готовыми, проверенными временем библиотеками, не изобретая велосипед из split() и надежды. Kotlin прямо позиционируется как язык, который интероперабелен с Java, так что «вызвать Java DOM‑API из Kotlin» — нормальный рабочий сценарий.
Мини‑пайплайн DOM‑парсинга
Прежде чем мы углубимся в детали, давайте соберём общую картинку процесса. В DOM‑подходе обычно есть два этапа: сначала мы парсим XML в Document, а потом извлекаем данные из этого дерева.
Вот простая блок‑схема (да, программисты рисуют стрелочки, чтобы потом меньше плакать при отладке):
flowchart TD
A[XML как String / файл] --> B["DocumentBuilder.parse(...)"]
B --> C[Document]
C --> D[documentElement: Element]
D --> E[Поиск элементов / чтение атрибутов / textContent]
E --> F[Ваши Kotlin-объекты / строки / числа]
Сейчас в этой лекции нас интересует всё до блока E включительно: как корректно получить Document, как безопасно достать Element, как читать атрибуты и текст.
2. DOM‑модель: Document, Element, NodeList
Перед тем как писать код, полезно навести порядок в терминах. DOM — это «Document Object Model», модель документа как дерева. Вершина дерева — сам документ, ниже — элементы (теги), внутри элементов — другие элементы и текст.
Чтобы мозг не пытался запомнить всё сразу, держите простую соответствующую картинку: XML → дерево → узлы.
Ниже маленькая таблица соответствий, чтобы вы не путались, где «XML‑понятие», а где «DOM‑класс»:
| В XML | В DOM (JVM) | Что это означает в коде |
|---|---|---|
| Документ целиком | |
«Коробка», внутри которой всё дерево |
| Тег <user>...</user> | |
Узел‑элемент, у него есть имя тега, атрибуты и содержимое |
| Атрибут id="42" | |
Достаём строковое значение атрибута |
| Набор однотипных тегов <item>...</item> | |
Коллекция DOM‑мира: длина + доступ по индексу |
| Текст внутри тега | |
Весь текст внутри элемента (часто с пробелами/переносами) |
А теперь важный «подводный камень» для новичков: NodeList — это не List. У него нет map, forEach, indices и прочих радостей Kotlin‑коллекций. В DOM‑мире часто придётся жить «по‑старому»: 0 until nodes.length и nodes.item(i).
3. Получаем Document из XML
XML‑строка → Document
Чтобы получить Document, нам нужен DOM‑парсер. В Java/JVM он создаётся через фабрику DocumentBuilderFactory, которая выдаёт DocumentBuilder. А DocumentBuilder уже умеет parse(...).
Начнём с минимального примера: XML лежит прямо в строке Kotlin.
import javax.xml.parsers.DocumentBuilderFactory
fun main() {
val xml = """<user id="42"><name>Ann</name></user>"""
val doc = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(xml.byteInputStream())
println(doc.documentElement.tagName) // user
}
Обратите внимание на byteInputStream(). Парсер обычно работает с потоками байтов (InputStream), а строка — это удобство для нас. Мы превращаем строку в поток байтов и кормим парсер.
Здесь есть важная житейская мысль: XML — это текст, но парсер читает байты. В реальных проектах вы ещё будете много думать про кодировки, но в рамках этой лекции нам достаточно понимать простое правило: «парсер получает InputStream, а не “просто строку”».
Корневой элемент: documentElement
Когда Document уже получен, хочется понять: «а что внутри?» Стартовая точка почти всегда одна: корневой элемент.
doc.documentElement возвращает Element. Это и есть корень дерева (тот самый «верхний тег», который оборачивает всё остальное).
import javax.xml.parsers.DocumentBuilderFactory
fun main() {
val xml = """<user id="42"><name>Ann</name></user>"""
val doc = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(xml.byteInputStream())
val root = doc.documentElement
println(root.tagName) // user
}
Почему это важно? Потому что дальше вы часто будете читать «контекстно»: сначала убедились, что корень — это <expenses>, и только потом ищете внутри него <expense>. Такой подход делает код более предсказуемым (и чуть меньше магии, которую вы сами же себе подсовываете).
XML из файла: parse(File)
Когда XML приходит не строкой, а файлом (классический кейс «экспорт отчёта»), хочется читать напрямую. DOM‑парсер умеет парсить файл без промежуточного readText(), то есть вы не обязаны сначала читать файл в строку.
Мини‑пример «файл → Document»:
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
fun parseXmlFile(path: String) =
DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(File(path))
Использование:
import org.w3c.dom.Element
fun main() {
val doc = parseXmlFile("expenses.xml")
val root = doc.documentElement
println(root.tagName) // expenses
val first = root.getElementsByTagName("expense").item(0) as? Element
println(first?.getAttribute("id")) // например: e1
}
Почему это может быть удобнее? Потому что parse(File) сам откроет поток и прочитает данные. А если вы делаете File(path).readText() и потом parse(...), вы вначале создаёте большую строку в памяти, а потом снова превращаете её в байты. Для маленьких файлов это не трагедия, но привычка «лишний раз не копировать большие данные» обычно хорошая.
Ошибки парсинга и try/catch
DOM‑парсер довольно строгий: если XML не well‑formed, он бросит исключение. И это нормально: он не обязан угадывать, что именно вы имели в виду, когда забыли закрыть тег. (Хотя было бы круто, если бы он ещё приносил чай и сочувственно кивал.)
Оборачивать парсинг в try/catch — хорошая практика, особенно в CLI‑приложениях, где вы хотите продолжить работу, а не упасть.
import javax.xml.parsers.DocumentBuilderFactory
fun main() {
val brokenXml = "<user><name>Ann</user>" // name не закрыт
try {
DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(brokenXml.byteInputStream())
println("Parsed OK")
} catch (e: Exception) {
println("XML parse failed: ${e.message}")
}
}
Здесь мы ловим Exception максимально широко (для учебного примера это нормально). В боевом коде часто ловят более конкретные типы, но пока нам важнее понять идею: «парсинг — потенциально падающая операция».
4. Читаем данные из DOM: атрибуты, элементы, текст
Атрибуты: getAttribute(...)
Атрибуты в XML выглядят как id="42". В DOM они читаются так:
import javax.xml.parsers.DocumentBuilderFactory
fun main() {
val xml = """<user id="42"></user>"""
val doc = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(xml.byteInputStream())
val id = doc.documentElement.getAttribute("id")
println(id) // 42
}
И вот здесь начинаются «весёлые» моменты: если атрибута нет, многие ожидают null. Но getAttribute(...) обычно возвращает пустую строку.
import javax.xml.parsers.DocumentBuilderFactory
fun main() {
val xml = """<user></user>"""
val doc = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(xml.byteInputStream())
val id = doc.documentElement.getAttribute("id")
println("id='$id'") // id=''
}
Это значит, что «атрибут есть, но пустой» и «атрибута нет» для вас выглядят одинаково — обе ситуации дают "". Поэтому в реальном коде вы почти всегда делаете дополнительную проверку:
val id = root.getAttribute("id").trim()
val idOrNull = id.takeIf { it.isNotEmpty() }
Мы используем trim(), потому что иногда атрибут может содержать пробелы (и вы не хотите радостно принять " " за осмысленное значение).
Поиск элементов: getElementsByTagName(...)
Когда у нас есть корень, следующий типичный шаг — найти вложенные элементы. Самый простой способ: getElementsByTagName("name"). Он возвращает NodeList.
Сделаем мини‑пример:
import javax.xml.parsers.DocumentBuilderFactory
import org.w3c.dom.Element
fun main() {
val xml = """<user><name>Ann</name></user>"""
val doc = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(xml.byteInputStream())
val nodes = doc.getElementsByTagName("name")
println(nodes.length) // 1
val nameEl = nodes.item(0) as Element
println(nameEl.tagName) // name
}
Здесь сразу несколько нюансов:
Во‑первых, nodes.length — это число элементов. Если length == 0, то item(0) вернёт null, и приведение as Element уронит программу. Поэтому правило простое: сначала проверка, потом доступ.
Во‑вторых, getElementsByTagName ищет по всему поддереву, а не только среди прямых детей. То есть если у вас <user><profile><name>...</name></profile></user>, то поиск user.getElementsByTagName("name") найдёт <name> и внутри <profile>. Иногда это удобно, иногда неожиданно. На этом шаге достаточно помнить: поиск «глубокий».
Чтобы не падать на пустом результате, можно использовать безопасное приведение as? и проверку на null:
import javax.xml.parsers.DocumentBuilderFactory
import org.w3c.dom.Element
fun main() {
val xml = """<user></user>"""
val doc = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(xml.byteInputStream())
val nameEl = doc.getElementsByTagName("name").item(0) as? Element
println(nameEl?.tagName) // null
}
Текст внутри тега: textContent
Теперь добираемся до самого «вкусного»: как достать значение внутри <name>Ann</name>.
В DOM это делается через textContent:
import javax.xml.parsers.DocumentBuilderFactory
import org.w3c.dom.Element
fun main() {
val xml = """<user><name>Ann</name></user>"""
val doc = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(xml.byteInputStream())
val nameEl = doc.getElementsByTagName("name").item(0) as Element
println(nameEl.textContent) // Ann
}
А теперь жизненный пример: XML часто красиво форматируют переносами строк и отступами. Для человека это прекрасно, для парсера — это тоже текст.
import javax.xml.parsers.DocumentBuilderFactory
import org.w3c.dom.Element
fun main() {
val xml = """
<user>
<name>
Ann
</name>
</user>
""".trimIndent()
val doc = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(xml.byteInputStream())
val nameEl = doc.getElementsByTagName("name").item(0) as Element
println(nameEl.textContent) // "\n Ann\n "
println(nameEl.textContent.trim()) // Ann
}
Вот почему trim() рядом с textContent — это не «ну так, на всякий случай», а почти стандартная гигиена.
Ещё один нюанс: textContent возвращает весь текст внутри элемента, включая текст вложенных элементов. Например:
import javax.xml.parsers.DocumentBuilderFactory
import org.w3c.dom.Element
fun main() {
val xml = """<msg>Hello <b>World</b>!</msg>"""
val doc = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(xml.byteInputStream())
val msgEl = doc.documentElement
println(msgEl.textContent.trim()) // Hello World!
}
Это нормально, просто важно понимать: textContent — это «всё текстовое, что внутри», а не «только прямой текстовый ребёнок».
Небольшая DOM‑гигиена, чтобы код был спокойнее
Когда вы начинаете писать DOM‑код, появляется типичный соблазн: везде делать item(0) as Element и верить, что мир добрый. Мир не добрый. Он просто ещё не успел показать вам XML, где <amount> забыли или написали <Amount> (другая регистрозависимая вселенная).
Поэтому хороший стиль — выделять маленькие функции‑помощники, которые скрывают проверки, trim() и безопасные приведения. Мы уже сделаем это в следующем разделе — там появится firstTagText, и основной код парсинга станет заметно читаемее, а проверки соберутся в одном месте.
Ещё полезная техника — нормализовать строки сразу при чтении: trim(), иногда lowercase() для категорий (если ваш формат это допускает), проверка на пустоту. DOM‑парсинг почти всегда идёт в паре с «подготовкой данных», иначе вы получите category = " food " и потом будете удивляться, почему группировки не работают.
5. Пример: импорт расходов из XML в список
Чтобы примеры не жили отдельной жизнью «в вакууме», давайте встроим DOM‑чтение в знакомую нам логику: у нас есть консольное приложение учёта расходов, и внезапно бухгалтерия (или банк) отдаёт экспорт… в XML. Потому что «так исторически сложилось». Не спрашивайте, это как «почему в офисе принтер работает только если его попросить ласково».
Пусть XML имеет такой вид:
<expenses>
<expense id="e1">
<amount>120.50</amount>
<category>food</category>
<comment>Lunch</comment>
</expense>
</expenses>
Для простоты заведём модель расхода (если в вашем проекте она уже есть — используйте её, здесь важна не модель, а DOM‑извлечение):
data class Expense(
val id: String,
val amount: Double,
val category: String,
val comment: String
)
Теперь сделаем маленький helper: «достань текст первого тега tag внутри parent». Обратите внимание: мы возвращаем String?, потому что элемент может отсутствовать.
import org.w3c.dom.Element
fun firstTagText(parent: Element, tag: String): String? {
val node = parent.getElementsByTagName(tag).item(0) as? Element ?: return null
return node.textContent.trim()
}
А теперь основная функция: распарсить XML и получить список Expense.
import javax.xml.parsers.DocumentBuilderFactory
import org.w3c.dom.Element
fun parseExpenses(xml: String): List<Expense> {
val doc = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(xml.byteInputStream())
val nodes = doc.getElementsByTagName("expense")
val result = mutableListOf<Expense>()
for (i in 0 until nodes.length) {
val expenseEl = nodes.item(i) as? Element ?: continue
val id = expenseEl.getAttribute("id").trim()
val amount = firstTagText(expenseEl, "amount")?.toDoubleOrNull()
val category = firstTagText(expenseEl, "category")
val comment = firstTagText(expenseEl, "comment") ?: ""
if (id.isNotEmpty() && amount != null && category != null) {
result.add(Expense(id, amount, category, comment))
}
}
return result
}
Здесь специально использованы максимально «земные» техники: toDoubleOrNull(), проверки на null, trim(). Мы пока не делаем красивую систему ошибок — нам важно научиться аккуратно читать DOM без падений, потому что входные данные почти всегда «иногда нормальные, иногда… ну, вы поняли».
Проверим на мини‑входе:
fun main() {
val xml = """
<expenses>
<expense id="e1">
<amount>120.50</amount>
<category>food</category>
<comment>Lunch</comment>
</expense>
<expense id="e2">
<amount>999</amount>
<category>tech</category>
<comment>Keyboard</comment>
</expense>
</expenses>
""".trimIndent()
val items = parseExpenses(xml)
println(items.size) // 2
println(items.first().id) // e1
}
6. Типичные ошибки при DOM‑парсинге
Ошибка №1: брать item(0) без проверки length.
Это самая частая причина внезапных падений. В XML элемент может отсутствовать: файл старой версии формата, другой поставщик, просто человеческий фактор. Если вы делаете nodes.item(0) as Element, то при пустом NodeList item(0) вернёт null, а дальше либо упадёт приведение, либо вы словите NPE чуть позже. Лечится привычкой: «сначала проверка, потом доступ» или использованием as? и раннего return.
Ошибка №2: ожидать null от getAttribute(...).
Новички часто пишут val id = el.getAttribute("id") ?: ..., а потом удивляются, почему ?: не срабатывает. getAttribute обычно возвращает пустую строку, и это отдельная ветка логики. В результате без trim() и isNotEmpty() вы можете “принять” отсутствие атрибута как «ну вроде значение есть».
Ошибка №3: забыть про trim() у textContent и получить «невидимые» пробелы.
Форматирование XML (отступы, переносы строк) — это тоже текст. Поэтому textContent может содержать "\n 120.50\n" вместо "120.50". Если потом сделать toDouble() (или сравнение строк), вы получите ошибки там, где вроде «всё красиво». В учебных примерах это выглядит мелочью, в реальном проекте превращается в часы отладки “почему не парсится число, оно же число”.
Ошибка №4: думать, что getElementsByTagName ищет только среди прямых детей.
Этот метод ищет по всему поддереву, и иногда это приводит к неожиданным результатам: вы хотели найти <amount> именно внутри конкретного <expense>, а нашли вообще все <amount> из вложенных блоков (или из другого места, если структура сложнее). На небольших документах это не бросается в глаза, а потом внезапно “первый <amount>” оказывается не тем самым. На этом этапе спасает дисциплина: искать от нужного элемента (контекста) и держать структуру XML максимально понятной.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ