Pull to refresh

Работаем с XML как с массивом, версия 2

Reading time9 min
Views5.4K

Всем привет.

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

В двух словах для быстрого парсинга файлов надо пользоваться XMLReader в связке с yield.

О моей реализации этой связки читайте ниже.

XMLReader

XMLReader это единственный класс в PHP, который умеет читать файл по частям. В PHP есть SimpleXML, есть DOMDocument, но они работают с файлом только полностью считывая его с диска, только полностью загружая в оперативную память.

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

yield

Следующее что нам поможет это выражение yield.

Прочитали один элемент из файла, распарсили, вернули его с помощью yield, сформировали SQL команду insert, выполнили команду, читаем следующий элемент из файла, и так далее, до победного конца. Ни кто не простаивает, всё примерно одинаково загружено.

Теперь сложим всё вместе и получим FastXmlToArray.

FastXmlToArray

FastXmlToArray это класс со статическим методом prettyPrint(), на вход можно подать или ссылку на XML файл (путь к файлу) или собственно XML строку. На выходе будет PHP массив со всеми атрибутами и значениями корневого элемента и всех вложенных элементов.

$xml =<<<XML
<outer any_attrib="attribute value">
    <inner>element value</inner>
    <nested nested-attrib="nested attribute value">nested element value</nested>
</outer>
XML;
$result =
    \SbWereWolf\XmlNavigator\FastXmlToArray::prettyPrint($xml);
echo json_encode($result, JSON_PRETTY_PRINT);

Вывод в консоль

{
  "outer": {
    "@attributes": {
      "any_attrib": "attribute value"
    },
    "inner": "element value",
    "nested": {
      "@value": "nested element value",
      "@attributes": {
        "nested-attrib": "nested attribute value"
      }
    }
  }
}

Killer feature этого класса это статический метод extractElements(), который принимает XMLReader, а выдаёт всё тоже самое: массив со всеми атрибутами и значениями этого элемента и всех вложенных элементов.

    /**
     * @param XMLReader $reader
     * @param string $valueIndex index for element value
     * @param string $attributesIndex index for element attributes collection
     * @return array
     */
    public static function extractElements(
        XMLReader $reader,
        string $valueIndex = IFastXmlToArray::VALUE,
        string $attributesIndex = IFastXmlToArray::ATTRIBUTES,
    ): array;

Существенная разница, в том что prettyPrint() обрабатывает сразу корневой элемент XML документа (по сути весь файл), а extractElements() работает с произвольным элементом (часть файла), из XML документа можно передать любой элемент.

То есть не надо весь файл загружать в парсер, что бы этот файл распарсить, можно дочитать до нужного элемента, и уже только его парсить, обработать результат парсинга, и вычитать ещё часть файла, ещё один элемент, и снова обработать.

Такая потоковая обработка даёт более плавную загрузку сервиса парсинга и сервиса СУБД.

Использование extractElements() снимает зависимость от размера файла. Проверим на практике.

Тестирование с файлами разного размера

Нагенерим файлы разного размера:

function generateFile(string $filename, int $limit, string $xml): void
{
    $file = fopen($filename, 'a');
    fwrite($file, '<Collection>');

    for ($i = 0; $i < $limit; $i++) {
        $content = "$xml$xml$xml$xml$xml$xml$xml$xml$xml$xml";
        fwrite($file, $content);
    }

    fwrite($file, '</Collection>');
    fclose($file);

    $size = round(filesize($filename) / 1024, 2);
    echo "$filename size is $size Kb" . PHP_EOL;
}

$xml = '<SomeElement key="123">value</SomeElement>' . PHP_EOL;
$generation['temp-465b.xml'] = 1;
$generation['temp-429Kb.xml'] = 1_000;
$generation['temp-429Mb.xml'] = 1_000_000;

foreach ($generation as $filename => $size) {
    generateFile($filename, $size, $xml);
}

Размеры файлов:

temp-465b.xml size is 0.45 Kb
temp-429Kb.xml size is 429.71 Kb
temp-429Mb.xml size is 429687.52 Kb

Получились файлы с размерами полкилобайта, полмегабайта и полгигабайта.

Замерим время парсинга первого элемента:

function parseFirstElement(string $filename): void
{
    $start = hrtime(true);

    /** @var XMLReader $reader */
    $reader = XMLReader::open($filename);
    $mayRead = true;
    while ($mayRead && $reader->name !== 'SomeElement') {
        $mayRead = $reader->read();
    }

    $elementsCollection =
        SbWereWolf\XmlNavigator\FastXmlToArray::extractElements(
            $reader,
            SbWereWolf\XmlNavigator\FastXmlToArray::VAL,
            SbWereWolf\XmlNavigator\FastXmlToArray::ATTR,
        );
    $result =
        SbWereWolf\XmlNavigator\FastXmlToArray
            ::composePrettyPrintByXmlElements(
                $elementsCollection,
            );

    $finish = hrtime(true);
    $duration = $finish - $start;
    $duration = number_format($duration,);
    echo 'First element parsing duration of' .
        " $filename is $duration ns" .
        PHP_EOL;
    echo json_encode($result, JSON_PRETTY_PRINT) . PHP_EOL;

    $reader->close();
}

$files = [
    'temp-465b.xml',
    'temp-429Kb.xml',
    'temp-429Mb.xml',
];

echo 'Warm up OPcache' . PHP_EOL;
parseFirstElement(current($files));

echo 'Benchmark is starting' . PHP_EOL;
foreach ($files as $filename) {
    parseFirstElement($filename);
}
echo 'Benchmark was finished' . PHP_EOL;

Результаты:

Warm up OPcache
First element parsing duration of temp-465b.xml is 1,291,100 ns
Benchmark is starting
First element parsing duration of temp-465b.xml is 156,600 ns
First element parsing duration of temp-429Kb.xml is 133,700 ns
First element parsing duration of temp-429Mb.xml is 122,100 ns
Benchmark was finished

Время доступа к первому элементу составило:

  • 156,600 нс,

  • 133,700 нс,

  • 122,100 нс.

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

Теперь разберёмся с тем как писать код с использованием FastXmlToArray.

Пример использования

Будем парсить что-то такое:

<?xml version="1.0" encoding="utf-8"?>
<CARPLACES>
    <CARPLACE
            ID="11361653"
            OBJECTID="20326793"
    />
    <CARPLACE
            ID="94824"
            OBJECTID="101032823"
    />
</CARPLACES>

Допустим нас во всём документе интересуют только элементы CARPLACE.

Переведём XMLReader на первый элемент CARPLACE

$reader = XMLReader::XML($xml);
$mayRead = true;
while ($mayRead && $reader->name !== 'CARPLACE') {
    $mayRead = $reader->read();
}

Пройдёмся по всем элементам CARPLACE, пока не перейдём к элементу с другим именем или пока документ не кончиться

while ($mayRead && $reader->name === 'CARPLACE') {
    $elementsCollection = FastXmlToArray::extractElements(
        $reader,
    );
    $result = FastXmlToArray::createTheHierarchyOfElements(
        $elementsCollection,
    );
    echo json_encode([$result], JSON_PRETTY_PRINT);

    while (
        $mayRead &&
        $reader->nodeType !== XMLReader::ELEMENT
    ) {
        $mayRead = $reader->read();
    }
}

Что мы тут делаем ?

С помощью FastXmlToArray::extractElements() получаем список элементов со всеми значениями и атрибутами(в этом примере список всего из одной позиции)

$elementsCollection = FastXmlToArray::extractElements(
    $reader,
);

С помощью FastXmlToArray::createTheHierarchyOfElements() формируем иерархию элементов как они есть в исходном документе, иерархию будет в формате массива (в этом примере из одного элемента и его атрибутов).

С помощью FastXmlToArray::composePrettyPrintByXmlElements() можно сформировать более компактное представление, но работать с ним не так удобно.

$result = FastXmlToArray::createTheHierarchyOfElements(
    $elementsCollection,
);

Выводим в консоль получившийся массив, или можем выполнить любую обработку над сформированным массивом.

echo json_encode([$result], JSON_PRETTY_PRINT);

Вместо echo можно, делать yield и отдавать массив на обработку наружу, я в своём парсере ФИАС ГАР так и делаю. Возвращаю через yield массив, и делаю insert в базу.

Проматываем "файл" до следующего элемента или до конца "файла"

while (
    $mayRead &&
    $reader->nodeType !== XMLReader::ELEMENT
) {
    $mayRead = $reader->read();
}

В консоли будет что-то такое:

[
    {
        "n": "CARPLACE",
        "a": {
            "ID": "11361653",
            "OBJECTID": "20326793"
        }
    }
][
    {
        "n": "CARPLACE",
        "a": {
            "ID": "94824",
            "OBJECTID": "101032823"
        }
    }
]

Другой пример

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<QueryResult
        xmlns="urn://x-artefacts-smev-gov-ru/services/service-adapter/types">
    <smevMetadata
            b="2">
        <MessageId
                c="re">c0f7b4bf-7453-11ed-8f6b-005056ac53b6
        </MessageId>
        <Sender>CUST01</Sender>
        <Recipient>RPRN01</Recipient>
    </smevMetadata>
    <Message
            xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
            xsi:type="RequestMessageType">
        <RequestMetadata>
            <clientId>a0efcf22-b199-4e1c-984a-63fd59ed9345</clientId>
            <linkedGroupIdentity>
                <refClientId>a0efcf22-b199-4e1c-984a-63fd59ed9345</refClientId>
            </linkedGroupIdentity>
            <testMessage>false</testMessage>
        </RequestMetadata>
        <RequestContent>
            <content>
                <MessagePrimaryContent>
                    <ns:Query
                            xmlns:ns="urn://rpn.gov.ru/services/smev/cites/1.0.0"
                            xmlns="urn://x-artefacts-smev-gov-ru/services/message-exchange/types/basic/1.2"
                    >
                        <ns:Search>
                            <ns:SearchNumber
                                    Number="22RU006228DV"/>
                        </ns:Search>
                    </ns:Query>
                </MessagePrimaryContent>
            </content>
        </RequestContent>
    </Message>
</QueryResult>

Пример запроса сведений, пришедший по СМЭВу, нас тут интересует только элемент ns:Query

Код будет аналогичным:

$mayRead = true;
$reader = XMLReader::XML($xml);
while ($mayRead && $reader->name !== 'ns:Query') {
    $mayRead = $reader->read();
}

while ($reader->name === 'ns:Query') {
    $elementsCollection = FastXmlToArray::extractElements(
        $reader,
    );
    $result = FastXmlToArray::createTheHierarchyOfElements(
        $elementsCollection,
    );

    echo json_encode([$result], JSON_PRETTY_PRINT);

    while (
        $mayRead &&
        $reader->nodeType !== XMLReader::ELEMENT
    ) {
        $mayRead = $reader->read();
    }
}
$reader->close();

Вывод в консоль:

[
    {
        "n": "ns:Query",
        "a": {
            "xmlns:ns": "urn:\/\/rpn.gov.ru\/services\/smev\/cites\/1.0.0",
            "xmlns": "urn:\/\/x-artefacts-smev-gov-ru\/services\/message-exchange\/types\/basic\/1.2"
        },
        "s": [
            {
                "n": "ns:Search",
                "s": [
                    {
                        "n": "ns:SearchNumber",
                        "a": {
                            "Number": "22RU006228DV"
                        }
                    }
                ]
            }
        ]
    }
]

На самом деле, конечно, нас интересует только <ns:SearchNumber Number="22RU006228DV"/>, и в коде продакшена было бы while ($reader->name === 'ns:SearchNumber'), ради наглядности я привёл получение кусочка побольше.

Замеры производительности

Прежде чем переписать свою библиотеку для работы с XML, я посмотрел что нам может предложить Open Source.

На Packagist под сотню пакетов, я посмотрел первые 30, посмотрел в исходники, везде примерно одно и тоже, но время работы отличалось в разы, как позже оказалось это была ошибка моего бенчмарка.

На самом деле время везде примерно одно и тоже. С той разницей, что функции работают быстрей статических методов, а статические методы работают быстрее методов экземпляра класса, а самые медленные это просто PHP скрипты.

За время разработки я перепробовал все 4 варианта, и один и тот же код работает в разных "форматах" с разницей в единицы микросекунд, если вам это важно, то пишите свой конвертор XML в процедурном стиле, выиграете пару микросекунд.

Мои замеры быстродействия:

91 mcs 200 ns \Mtownsend\XmlToArray\XmlToArray::convert()
82 mcs 0 ns xmlstr_to_array()
139 mcs 600 ns getNextElement
95 mcs 700 ns \SbWereWolf\XmlNavigator\Converter->prettyPrint
105 mcs 200 ns \SbWereWolf\XmlNavigator\FastXmlToArray::prettyPrint
107 mcs 0 ns \SbWereWolf\XmlNavigator\Converter->xmlStructure
91 mcs 900 ns \SbWereWolf\XmlNavigator\FastXmlToArray::convert

От запуска к запуску числа отличаются, но общая картина примерно такая.

Мой опыт использования

Мой use case заключался в том, что бы 280 гигабайт XML файлов превратить в базу данных Федеральной Информационной Адресной системы (БД ФИАС).

Надеюсь, теперь понятно почему меня волновало время парсинга XML файлов.

Что удивительно это то, что 280 гигабайт XML файлов превратить в 190 гигабайт базы данных, такое чувство что у СУБД нет ни какой оптимизации, если из XML выкинуть имена элементов и имена атрибутов и всю разметку, то что там останется ? На мой взгляд объём должен был сократиться в два раза, хотя бы, но нет. 190 гигабайт это без индексов, с индексами наверное все 220 будут.

В первых версиях парсер жрал по 4 гига оперативы, грузил БД на 25% процессора, сколько процесс PHP отъедал оперативки я уже не помню. Вставка 100 000 записей занимала от 2 минут, чтение файла могло затянуться на 10 минут.

Последняя версия парсера ровно каждые 9 секунд вставляет очередные 100 0000 записей, открытие файла занимает ноль секунд. PHP и БД каждый отъедают не больше 8 мегабайт оперативки и не больше 12% процессора. Но даже при этих условиях, добавление полной базы ГАР заняло 40 часов.

Rows was read is `1 571 374 861`
Import duration is 1 days, 15:56:02 855 ms

Полтора миллиарда записей за один день и 16 часов (24+16=40), 40 часов это очень долго, из оптимизаций в голову приходит только асинхронности добавить, что бы один поток парсил XML, а другой поток отправлял данные в СУБД.

Ваши предложения ?

Заключение

Если вам хочется попробовать этот парсер в деле, то вы можете установить пакет через Композер

composer require sbwerewolf/xml-navigator

Больше информации в репозитории в README.md

DISCLAIMER

Статья написана на 5 версию библиотеки, в 6 версии были добавлены классы и переименованы методы, суть осталась прежней, добавились приятные возможности, подробности в репозитории в README.md.

Add ability to use callable for filter XML text

Add XmlParser and FastXmlParser classes with ability to use callable
for filter XML document
Split FastXmlToArray to separate classes:
- HierarchyComposer,
- PrettyPrintComposer
- ElementExtractor

Only registered users can participate in poll. Log in, please.
Чем вы парсите XML?
18.18% XML, что это?6
36.36% XMLReader12
27.27% SimpleXMLElement9
12.12% DOMDocument4
3.03% Сторонней библиотекой (пожалуйста напишите название в комментариях)1
18.18% можно попробовать этот парсер6
33 users voted. 18 users abstained.
Tags:
Hubs:
Total votes 13: ↑12 and ↓1+11
Comments31

Articles