На работе попросили провести исследование какими средствами лучше разбирать объёмный XML файл (более 100Mb). Предлагаю сообществу ознакомиться с результатами.
Рассмотрим основные методы работы с XML:
1. Simple XML (documentation)
2. DOM (documentation)
3. xml_parser (SAX) (documentation)
4. XMLReader (documentation)
Simple XML
Минусы: работает очень медленно, собирает весь файл в память, дерево составляется в отдельных массив.
Плюсы: простота работы, работа «из коробки» (требует библиотеки libxml которая включена практически на всех серверах)
$xml = simplexml_load_file("price.xml");
echo "<table border='1'>\n";
foreach ($xml->xpath('/DocumentElement/price') as $producs) { ?>
<tr>
<td><?php echo $producs->name; ?></td>
<td><?php echo $producs->company; ?></td>
<td><?php echo $producs->city; ?></td>
<td><?php echo $producs->amount ?></td>
</tr>
<?
}
echo "</table>\n";
DOM
Минусы: работает очень медленно, как и все предыдущие примеры собирает весь файл в память.
Плюсы: На выходе привычный DOM с которым очень легко работать.
$doc = new DOMDocument();
$doc->load( 'books.xml' );
$books = $doc->getElementsByTagName( "book" );
foreach( $books as $book )
{
$authors = $book->getElementsByTagName( "author" );
$author = $authors->item(0)->nodeValue;
$publishers = $book->getElementsByTagName( "publisher" );
$publisher = $publishers->item(0)->nodeValue;
$titles = $book->getElementsByTagName( "title" );
$title = $titles->item(0)->nodeValue;
echo "$title - $author - $publisher\n";
xml_parser и XMLReader.
Предыдущие 2 нам не подходят из-за работы с целым файлом, т.к. файлы у нас бывают по 20-30 Mb, и во время работы с ними некоторые блоки образуют цепочку (массив) в 100> Mb
Оба способа работают чтением файла построчно что подходит идеально для поставленной задачи.
Разница между xml_parser и XMLReader в том что, в первом случае вам нужно будет писать собственные функции которые будут реагировать на начало и конец тэга.
Проще говоря, xml_parser работает через 2 триггера – тэг открыт, тэг закрыт. Его не волнует что там идёт дальше, какие данные используются и т.д. Для работы вы задаёте 2 триггера указывающие на функции обработки.
class Simple_Parser
{
var $parser;
var $error_code;
var $error_string;
var $current_line;
var $current_column;
var $data = array();
var $datas = array();
function parse($data)
{
$this->parser = xml_parser_create('UTF-8');
xml_set_object($this->parser, $this);
xml_parser_set_option($this->parser, XML_OPTION_SKIP_WHITE, 1);
xml_set_element_handler($this->parser, 'tag_open', 'tag_close');
xml_set_character_data_handler($this->parser, 'cdata');
if (!xml_parse($this->parser, $data))
{
$this->data = array();
$this->error_code = xml_get_error_code($this->parser);
$this->error_string = xml_error_string($this->error_code);
$this->current_line = xml_get_current_line_number($this->parser);
$this->current_column = xml_get_current_column_number($this->parser);
}
else
{
$this->data = $this->data['child'];
}
xml_parser_free($this->parser);
}
function tag_open($parser, $tag, $attribs)
{
$this->data['child'][$tag][] = array('data' => '', 'attribs' => $attribs, 'child' => array());
$this->datas[] =& $this->data;
$this->data =& $this->data['child'][$tag][count($this->data['child'][$tag])-1];
}
function cdata($parser, $cdata)
{
$this->data['data'] .= $cdata;
}
function tag_close($parser, $tag)
{
$this->data =& $this->datas[count($this->datas)-1];
array_pop($this->datas);
}
}
$xml_parser = new Simple_Parser;
$xml_parser->parse('<foo><bar>test</bar></foo>');
В XMLReader всё проще. Во первых, это класс. Все триггеры уже заданы константами (их всего 17), чтение осуществляется функцией read() которая читает первое вхождение подходящее под заданные триггеры. Далее мы получаем объект в который заносится тип данных (аля триггер), название тэга, его значение. Также XMLReader отлично работает с аттрибутами тэгов.
<?php
<?php
Class StoreXMLReader
{
private $reader;
private $tag;
// if $ignoreDepth == 1 then will parse just first level, else parse 2th level too
private function parseBlock($name, $ignoreDepth = 1) {
if ($this->reader->name == $name && $this->reader->nodeType == XMLReader::ELEMENT) {
$result = array();
while (!($this->reader->name == $name && $this->reader->nodeType == XMLReader::END_ELEMENT)) {
//echo $this->reader->name. ' - '.$this->reader->nodeType." - ".$this->reader->depth."\n";
switch ($this->reader->nodeType) {
case 1:
if ($this->reader->depth > 3 && !$ignoreDepth) {
$result[$nodeName] = (isset($result[$nodeName]) ? $result[$nodeName] : array());
while (!($this->reader->name == $nodeName && $this->reader->nodeType == XMLReader::END_ELEMENT)) {
$resultSubBlock = $this->parseBlock($this->reader->name, 1);
if (!empty($resultSubBlock))
$result[$nodeName][] = $resultSubBlock;
unset($resultSubBlock);
$this->reader->read();
}
}
$nodeName = $this->reader->name;
if ($this->reader->hasAttributes) {
$attributeCount = $this->reader->attributeCount;
for ($i = 0; $i < $attributeCount; $i++) {
$this->reader->moveToAttributeNo($i);
$result['attr'][$this->reader->name] = $this->reader->value;
}
$this->reader->moveToElement();
}
break;
case 3:
case 4:
$result[$nodeName] = $this->reader->value;
$this->reader->read();
break;
}
$this->reader->read();
}
return $result;
}
}
public function parse($filename) {
if (!$filename) return array();
$this->reader = new XMLReader();
$this->reader->open($filename);
// begin read XML
while ($this->reader->read()) {
if ($this->reader->name == 'store_categories') {
// while not found end tag read blocks
while (!($this->reader->name == 'store_categories' && $this->reader->nodeType == XMLReader::END_ELEMENT)) {
$store_category = $this->parseBlock('store_category');
/*
Do some code
*/
$this->reader->read();
}
$this->reader->read();
}
} // while
} // func
}
$xmlr = new StoreXMLReader();
$r = $xmlr->parse('example.xml');
Тест производительности
<?php
$xmlWriter = new XMLWriter();
$xmlWriter->openMemory();
$xmlWriter->startDocument('1.0', 'UTF-8');
$xmlWriter->startElement('shop');
for ($i=0; $i<=1000000; ++$i) {
$productId = uniqid();
$xmlWriter->startElement('product');
$xmlWriter->writeElement('id', $productId);
$xmlWriter->writeElement('name', 'Some product name. ID:' . $productId);
$xmlWriter->endElement();
// Flush XML in memory to file every 1000 iterations
if (0 == $i%1000) {
file_put_contents('example.xml', $xmlWriter->flush(true), FILE_APPEND);
}
}
$xmlWriter->endElement();
// Final flush to make sure we haven't missed anything
file_put_contents('example.xml', $xmlWriter->flush(true), FILE_APPEND);
Результаты тестирования (чтение без разбора данных)
PHP 7.0.15
Intel® Core(TM) i5-3550 CPU @ 3.30GHz, 16 Gb RAM, 256 SSD
Метод | Время выполнения (19 Mb) | Время выполнения (190 Mb) |
---|---|---|
Simple XML | 0.46 сек | 4.56 сек |
DOM | 0.52 сек | 4.09 сек |
xml_parse | 0.22 сек | 2.25 сек |
XML Reader | 0.26 сек | 2.18 сек |
P.S. Советы и комментарии с удовольствием выслушаю. Прошу сильно не пинать
Комментарии (53)
frees2
05.06.2017 14:56-10какими средствами лучше разбирать объёмный XML
Во первых надо отказаться от формата и переходить на json в любом случае.
Во вторых получать ленту со стороны сервера уже обработанную, по параметрам.
?part=snippet,contentDetails&maxResults=15&order=dateSect0R
05.06.2017 14:57+2К сожалению наши поставщики прайс-листов не хотят переходить на параметры и отдают нам XML от 500 до 1500 Mb.
frees2
05.06.2017 16:02-1В принципе даже simplexml_load_file можете разбить на блоки, как вот товарищ пишет, он через curl/
Но вот как обрабатывает ошибки json.
Есть же какие то программы, которые конвертируют файл на стороне клиента, только конвертация уменьшит его размер на треть.
$json = curl_exec($ch); if ($json !== false) { //решаем проблему ошибок $json = preg_replace("#(/\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*+/)|([\s\t]//.*)|(^//.*)#", '', $json); setlocale(LC_ALL, 'ru_RU.utf8'); Header("Content-Type: text/html;charset=UTF-8"); curl_close($ch); $json = json_decode($json, true) ;
Sect0R
05.06.2017 16:09Не так давно пришлось изменять целый модуль после старого программиста, именно из-за его идеи разбивать файл на кучу новых по 30 Mb и разбирать всё это дело через simplexml. Такой утечки памяти добился он что сервер не мог выдержать 100 человек онлайн.
aprusov
05.06.2017 16:37+1Можно парсить на сущности (теги), используя XMLReader, а затем эту сущность разбирать уже через DomDocument. Таким образом, памяти будет кушать не много, а DomDocument куда более приятен и расширяем, хотя, конечно, по скорости будет чуть медленнее.
А вообще, такие парсеры лучше писать на более шустрых комилируемых языках, к примеру go. Там это в разы будет быстрее работать и можно использовать конкурентные вычисления, писать порциями напрямую в базу. Вы удивитесь насколько это будет шустрее работать и памяти жрать будет в разы меньше
frees2
07.06.2017 22:27-1Ну так я и пишу, в принципе, если очень надо. XMLлом собаку уже съел, искренне не понимаю, кто наминусовал за «Во первых надо отказаться от формата и переходить на json в любом случае.».
Какие то странные кадры пошли в хабрахабре.
curl тут прописал, ибо раз 5 спрашивали в тостере как сделать чтобы кодировка была русская и ошибок не было. Им удобно смотреть и разбирать.
Forum3
06.06.2017 01:17+1А как можно обработать большой json (к примеру > 100 мб)? Если вот нужно, и никак иначе? Буду признателен за ответ
Scf
06.06.2017 09:07использовать streaming json parser
Например: https://www.mkyong.com/java/jackson-streaming-api-to-read-and-write-json/
michael_vostrikov
07.06.2017 10:15Если JSON вида
[{}, {}, {}, ...]
или похожий, где структура несложная, но есть большой кусок, который надо обрабатывать потоком, можно сделать так. Читаем посимвольно с учетом вложенности скобок, то есть определяем ситуации «начало массива», «конец массива», «начало объекта», «конец объекта». Задаем уровень вложенности или ключ, которые определяют одну единицу данных. Читаем текст для этой единицы в буфер, буфер передаем в json_decode(). Делаем обработку результата, читаем дальше.baldr
07.06.2017 16:02И снова гуглинг по словам «JSON stream php» может навести на мысль что велосипедить — это тратить время.
Dimash2
05.06.2017 15:42+2У меня тоже есть поставщики фидов по 2-4 gb на xml
Мне пришлось написать безумные вещь для этой задачи.
— Открываю файл и читаю его построчно, пропуская открытие и закрытие, беру около 300 000 строк
— При помощи CURL рассылаю строки на эту же машину + на удаленные машины (24 запроса = 300 000/24)
— Все 24 запроса конвертируют XML в key/value массив
— Система собирает с каждого curl один массив
И так по кругу пока не кончится файл
Из сложного — надо правильно открывать и закрывать xml, чтобы он был валидный.
PS.
24 — это не сервера, это запросы, сервернов 4 по 6 (6 ядрен * на 4 офисные машины)
Проверьте htop, ваш php для этой задачи берет скорее всего только 1 ядро, заставить брать все ядра можно мультизапросами.Dimash2
05.06.2017 15:48Уточняю
— Все 24 запрсоа не только конвертируют XML -> В массив, естественно они сначала конвертируют строки в XML объект. Но так как по XML объект ходить сложно (особенно, когда структура постоянно разная), то я написал функцию конвертации всех параметрво и аттрибутов в key/value массив, который возращается как json
— 24, 300 000 — все эти цифры условные, постоянно перенастраиваются и тдSect0R
05.06.2017 16:11Почитайте мой верхний комментарий.
Из-за таких способов происходит дикая утечка памяти, т.к. вы грузите по 300 000 строк, в то время когда воспользовавшись XMLReader или xml_parser вы будете тратить максимум 1 мегабайт памяти.
P.S. на разбор файла в 1.5 Gb уходит 7 Mb памяти и около 1.5 часа. Это учитывая что кроме разбора файла идёт множество mysql запросовDimash2
05.06.2017 16:16+4За что минусуют не могу понять, у меня на разбор уходет от 5 до 15 минут на 7gb xml данных, а у вас 1.5 часа на 1.5
Дикая утечка памяти? Авто же жаловался на скорость, зачем мне экономить память, и это не утечка, а использование. Моя задача скорость и я разбил файл на 300 000 кусков и разослал на обработку 6 серверам всем их ядрам под 100%.
Зачем это делать на сервере с онлайн пользователями — я не понимаю )
XMLReader или xml_parser — я жду их выполнения, что мне толку от их потребления памяти, памяти в избытке, а 300 000 строк по кругу не позволяют ей выйти из того объема, который меня устраивает. Если их же вызвать 24 раза одновременно — будет в 24 раза быстрее, а 300 000/24 — парсер не каждую строчку конвертирует, а все строчки как 1 файл на ядре.mayorovp
05.06.2017 16:19А завтра поменяют разбиение XML-файла на строки и все у вас упадет.
Dimash2
05.06.2017 16:26Нет, я не конвертирую строки, я на 1 ядре объединяю их и делаю из них валидный xml, потому не важно как именно разбит файл, идея считывать группу строк, а не весь файл целиком.
Все упадет если файл будет 1 строковый. Ну под это тоже можно написать альтернативу, но по задаче не требуется.mayorovp
05.06.2017 16:35А как вы будете разбивать файл вот такого формата?
<root><item> <name>Item 1</name> </item><item> <name>Item 2</name> </item><item> <name>Item 3</name> </item><item> <name>Item 4</name> </item><item> <name>Item 5</name> </item></root>
Dimash2
05.06.2017 23:32По строкам, а потом допишу в разрыве xml, чтобы превратить в валидный, чтобы не потерять данные, следующий цикл начнется на пару строк выше разрыва, есть вероятность повтороного попадания данных (дубли), но они пропускаются при импорте
Нужно просто очистить хвост, чтобы в конце остался только закрывающийся тегmayorovp
06.06.2017 05:36А завтра поменяют разбиение XML-файла на строки и все у вас упадет.
Dimash2
06.06.2017 05:40+1Вы это уже говорили, я не знаю как бы вам внятно объяснить. Есть открывающие и закрывающие теги, какая разница что там со строками и где что лежит, после получения данных для одного ядра строки соединяются и проверяются на валидность, все обрывистые куски отсеиваются и закрываются те теги, которые нужны, затем передается в xml reader
mayorovp
06.06.2017 08:39Как вы собираетесь отслеживать закрывающие тэги
</item>
второго уровня в файле произвольного формата?
Да вы сами признали, что костыль, изготовленный для конкретного формата файла, упадет если в файле вся информация окажется в одну строку.
Sect0R
05.06.2017 16:19+1Это время и память на работу на 1 сервере.
По факту вы делаете кучу лишней работы которая не нужна.
Возможно конкретно в вашем случае это помогло решить какие-то другие задачи, но в целом даже на обработке 300 000 строк я бы использовал xml_reader, а не simplexmlDimash2
05.06.2017 16:21Ну это моя проблема выбора, конвертора, я лишь описал как я ускорил, если xml_reader лучше, то в данном случае он тоже отработает хорошо.
frees2
08.06.2017 08:58-1Да, заметил, набежит группа и минусует одновременно. Выбивают в других комментариях, а не по теме срача, кто хоть что то знает, а остаются полит. психи…
Скоро будет жежечка вторая.
Может нервничают от старого железа или перехода на PHP7, где SimpleXML extension были проблемы с установкой.
99% будет ошибка где то в XML, особо при таких гигантских файлах, не пойму как они работают.
Assargin
05.06.2017 16:35-1Долго. Хотя, если такое время выполнения никому неудобств не доставляет — ну ладно.
Но, в любом случае, насилие БД… Вы при разборе каждого узла делаете запросы? У вас при разборе файла какие запросы превалируют: INSERT или UPDATE? Что, если собирать данные и отправлять их в БД пачками (bulk insert), т.е., вместо 100/200/500 запросов сделать 1? Или возможно, если вы используете innodb и у вас autocommit=1 (по умолчанию), а вы и не знали?Sect0R
05.06.2017 16:47Оптимизация базы проведена.
Используются транзакции.
Большие запросы вынесены в отдельный файл который работает с очередью RabbitMQ
Ogoun
05.06.2017 16:41+1Была задача распарсить многогиговые XML файлы, делал очень просто, зная структуру XML файла читал построчно, последовательно заполняя и обрабатывая объектную модель. При незнакомом формате, можно в два прохода, на первом строит схему XML, на втором уже зная ее обрабатываем последовательно блоки.
JSmitty
06.06.2017 07:59-6А что, регулярками уже никто не парсит? :)
Кстати, вполне вариант для файла вида
<root><item> <name>Item 1</name> </item> ... </root>
при размере от сотен мегабайт вполне годится первоначальная нарезка на item-ы по регулярке, и нормальный парсинг каждого item-а уже чем-то более адекватным.
Еще придумался такой способ (не для всех типов данных) — конвертировать снаружи XML в нечто табличное (CSV / TSV), через XSLT, а затем вычитывать построчно (или тот же MySQL может такое съесть через LOAD DATA INFILE). Да, колонок будет овер много в общем случае. Конвертировать можно например не в файл, а в пайп, чтоб место поберечь на диске.igurylev
06.06.2017 11:17Ну почему же никто :)
Тоже была задача в своё время распарсить xml на сотню-другую мегабайт. При этом структура была заранее известна и размер item-а был вменяемый, не больше сотен килобайт.
Комбинация регулярок с построчным чтением файла вполне спасала от перерасхода памяти.
daemonhk
06.06.2017 11:17Уф… XML в несколько гигов это жесть)) Мне пока максимум доводилось работать с 30-35Мб, использую Simple XML и пару раз DOM за карьеру. Про другие библиотеки не знал, спасибо.
baldr
06.06.2017 11:32+1По запросу «php stream xml» вроде бы в первых же результатах вылезают ссылки на XMLReader, SAX и другие полезные слова?
(это коммент не к статье, а к товарищам, которые велосипедят)
raidhon
06.06.2017 12:45-2Вот всегда так.
После прочтения статьи по машинному обучению, ты попадаешь на статью где разбирают xml в 2017 году. =_=
Наверное ещё и под php 5.2 — 5.4.
Ребят существуют же бинарные форматы msgpack, protobuf, avro и тд, они же в разы компактнее и в 50 раз быстрее.
Даже если нет возможности использовать современный формат есть же потоки.
Вы вообще в packagist.org бываете.
https://packagist.org/packages/hobnob/xml-stream-reader.
Вашим поставщикам как минимум это нужно — https://packagist.org/packages/prewk/xml-string-streamer
А в идеале http://msgpack.org.raidhon
06.06.2017 16:37Пропустил что использовали седьмую версию во вкладке <<Характеристики тестовой среды>>.
Фразу <<Наверное ещё и под php 5.2 — 5.4.>> прошу пропустить.
К сожалению это не меняет того что моя критика обоснована.
FieryCat
06.06.2017 15:06Как насчет использования eXist? Он как раз и создавался для удобства в оперировании большими XML файлами.
AHDPEu
10.06.2017 16:30Используйте SAX парсер, но не используйте его главную фишку — парсить в потоке?
У вас поэтому такая смешная разница между DOM и SAX.
Этот парсер легко переваривает многогиговые эксельки (любой zip архив с xml), которые офис не в состоянии открыть.
frees2
12.06.2017 11:15-1https://www.sitepoint.com/functional-programming-phunkie-building-php-json-parser/
— A JSON Parser.
По опыту, большее количество кода это обнаружение и устранение ошибок, вот как работает по новому. См. выше.
CrazyNiger
Ваш тест производительности опровергает ваши же утверждения о медлительности DOM и SimpleXML по сравнению с XMLReader. И еще бы не плохо показать объем используемой памяти.
Sect0R
да, последнюю цифру не правильно скопировал.
ок, добавлю данные по используемой памяти
vasiljok
тогда было бы неплохо и на CPU глянуть.