JavaRush /Курсы /Kotlin SELF /DOM‑парсинг: Document, Element, атрибуты и textContent

DOM‑парсинг: Document, Element, атрибуты и textContent

Kotlin SELF
50 уровень , 1 лекция
Открыта

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) Что это означает в коде
Документ целиком
org.w3c.dom.Document
«Коробка», внутри которой всё дерево
Тег <user>...</user>
org.w3c.dom.Element
Узел‑элемент, у него есть имя тега, атрибуты и содержимое
Атрибут id="42"
Element.getAttribute("id")
Достаём строковое значение атрибута
Набор однотипных тегов <item>...</item>
org.w3c.dom.NodeList
Коллекция DOM‑мира: длина + доступ по индексу
Текст внутри тега
Element.textContent
Весь текст внутри элемента (часто с пробелами/переносами)

А теперь важный «подводный камень» для новичков: 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 максимально понятной.

1
Задача
Kotlin SELF, 50 уровень, 1 лекция
Недоступна
Паспорт пользователя
Паспорт пользователя
1
Задача
Kotlin SELF, 50 уровень, 1 лекция
Недоступна
Пропавший id
Пропавший id
1
Задача
Kotlin SELF, 50 уровень, 1 лекция
Недоступна
Заголовок книги
Заголовок книги
1
Задача
Kotlin SELF, 50 уровень, 1 лекция
Недоступна
Витрина товаров
Витрина товаров
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ