image

На работе попросили провести исследование какими средствами лучше разбирать объёмный XML файл (более 100Mb). Предлагаю сообществу ознакомиться с результатами.

Рассмотрим основные методы работы с XML:

1. Simple XML (documentation)
2. DOM (documentation)
3. xml_parser (SAX) (documentation)
4. XMLReader (documentation)

Simple XML


Минусы: работает очень медленно, собирает весь файл в память, дерево составляется в отдельных массив.
Плюсы: простота работы, работа «из коробки» (требует библиотеки libxml которая включена практически на всех серверах)

Пример использования Simple XML
$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 с которым очень легко работать.

Пример использования 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 триггера указывающие на функции обработки.

Пример работы xml_parser
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 отлично работает с аттрибутами тэгов.

Пример использования 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');


Тест производительности


Код генератора 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);


Результаты тестирования (чтение без разбора данных)

Характеристики тестовой среды
Ubuntu 16.04.1 LTS
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)


  1. CrazyNiger
    05.06.2017 14:50
    +4

    Ваш тест производительности опровергает ваши же утверждения о медлительности DOM и SimpleXML по сравнению с XMLReader. И еще бы не плохо показать объем используемой памяти.


    1. Sect0R
      05.06.2017 14:54

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


      1. vasiljok
        07.06.2017 14:48

        тогда было бы неплохо и на CPU глянуть.


  1. frees2
    05.06.2017 14:56
    -10

    какими средствами лучше разбирать объёмный XML

    Во первых надо отказаться от формата и переходить на json в любом случае.
    Во вторых получать ленту со стороны сервера уже обработанную, по параметрам.
    ?part=snippet,contentDetails&maxResults=15&order=date


    1. Sect0R
      05.06.2017 14:57
      +2

      К сожалению наши поставщики прайс-листов не хотят переходить на параметры и отдают нам XML от 500 до 1500 Mb.


      1. 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) ; 
        


        1. Sect0R
          05.06.2017 16:09

          Не так давно пришлось изменять целый модуль после старого программиста, именно из-за его идеи разбивать файл на кучу новых по 30 Mb и разбирать всё это дело через simplexml. Такой утечки памяти добился он что сервер не мог выдержать 100 человек онлайн.


          1. aprusov
            05.06.2017 16:37
            +1

            Можно парсить на сущности (теги), используя XMLReader, а затем эту сущность разбирать уже через DomDocument. Таким образом, памяти будет кушать не много, а DomDocument куда более приятен и расширяем, хотя, конечно, по скорости будет чуть медленнее.

            А вообще, такие парсеры лучше писать на более шустрых комилируемых языках, к примеру go. Там это в разы будет быстрее работать и можно использовать конкурентные вычисления, писать порциями напрямую в базу. Вы удивитесь насколько это будет шустрее работать и памяти жрать будет в разы меньше


          1. frees2
            07.06.2017 22:27
            -1

            Ну так я и пишу, в принципе, если очень надо. XMLлом собаку уже съел, искренне не понимаю, кто наминусовал за «Во первых надо отказаться от формата и переходить на json в любом случае.».

            Какие то странные кадры пошли в хабрахабре.

            curl тут прописал, ибо раз 5 спрашивали в тостере как сделать чтобы кодировка была русская и ошибок не было. Им удобно смотреть и разбирать.


      1. Dmitry_4
        06.06.2017 16:06

        А чем плох прайс в CSV, например?


        1. Sect0R
          06.06.2017 16:51
          +1

          :) берём что дают


          1. Dmitry_4
            06.06.2017 17:59

            Конвертор можно потеговый написать?


        1. baldr
          06.06.2017 17:07

          Возможно тем, что поставщики его не предоставляют?


    1. Forum3
      06.06.2017 01:17
      +1

      А как можно обработать большой json (к примеру > 100 мб)? Если вот нужно, и никак иначе? Буду признателен за ответ


      1. akhmelev
        06.06.2017 08:58

        В java как-то помнится тупо использовал gson.


        1. baldr
          06.06.2017 11:25
          +1

          <старая шутка про java и память/>


      1. Scf
        06.06.2017 09:07

        использовать streaming json parser
        Например: https://www.mkyong.com/java/jackson-streaming-api-to-read-and-write-json/


      1. michael_vostrikov
        07.06.2017 10:15

        Если JSON вида [{}, {}, {}, ...] или похожий, где структура несложная, но есть большой кусок, который надо обрабатывать потоком, можно сделать так. Читаем посимвольно с учетом вложенности скобок, то есть определяем ситуации «начало массива», «конец массива», «начало объекта», «конец объекта». Задаем уровень вложенности или ключ, которые определяют одну единицу данных. Читаем текст для этой единицы в буфер, буфер передаем в json_decode(). Делаем обработку результата, читаем дальше.


        1. baldr
          07.06.2017 16:02

          И снова гуглинг по словам «JSON stream php» может навести на мысль что велосипедить — это тратить время.


  1. mayorovp
    05.06.2017 15:01
    +3

    Помимо времени выполнения следовало бы замерить использованную память.


  1. 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 ядро, заставить брать все ядра можно мультизапросами.


    1. Dimash2
      05.06.2017 15:48

      Уточняю

      — Все 24 запрсоа не только конвертируют XML -> В массив, естественно они сначала конвертируют строки в XML объект. Но так как по XML объект ходить сложно (особенно, когда структура постоянно разная), то я написал функцию конвертации всех параметрво и аттрибутов в key/value массив, который возращается как json

      — 24, 300 000 — все эти цифры условные, постоянно перенастраиваются и тд


      1. Sect0R
        05.06.2017 16:11

        Почитайте мой верхний комментарий.
        Из-за таких способов происходит дикая утечка памяти, т.к. вы грузите по 300 000 строк, в то время когда воспользовавшись XMLReader или xml_parser вы будете тратить максимум 1 мегабайт памяти.
        P.S. на разбор файла в 1.5 Gb уходит 7 Mb памяти и около 1.5 часа. Это учитывая что кроме разбора файла идёт множество mysql запросов


        1. 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 файл на ядре.


          1. mayorovp
            05.06.2017 16:19

            А завтра поменяют разбиение XML-файла на строки и все у вас упадет.


            1. Dimash2
              05.06.2017 16:26

              Нет, я не конвертирую строки, я на 1 ядре объединяю их и делаю из них валидный xml, потому не важно как именно разбит файл, идея считывать группу строк, а не весь файл целиком.

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


              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>


                1. Dimash2
                  05.06.2017 23:32

                  По строкам, а потом допишу в разрыве xml, чтобы превратить в валидный, чтобы не потерять данные, следующий цикл начнется на пару строк выше разрыва, есть вероятность повтороного попадания данных (дубли), но они пропускаются при импорте

                  Нужно просто очистить хвост, чтобы в конце остался только закрывающийся тег


                  1. mayorovp
                    06.06.2017 05:36

                    А завтра поменяют разбиение XML-файла на строки и все у вас упадет.


                    1. Dimash2
                      06.06.2017 05:40
                      +1

                      Вы это уже говорили, я не знаю как бы вам внятно объяснить. Есть открывающие и закрывающие теги, какая разница что там со строками и где что лежит, после получения данных для одного ядра строки соединяются и проверяются на валидность, все обрывистые куски отсеиваются и закрываются те теги, которые нужны, затем передается в xml reader


                      1. mayorovp
                        06.06.2017 08:39

                        Как вы собираетесь отслеживать закрывающие тэги </item> второго уровня в файле произвольного формата?


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


          1. Sect0R
            05.06.2017 16:19
            +1

            Это время и память на работу на 1 сервере.
            По факту вы делаете кучу лишней работы которая не нужна.
            Возможно конкретно в вашем случае это помогло решить какие-то другие задачи, но в целом даже на обработке 300 000 строк я бы использовал xml_reader, а не simplexml


            1. Dimash2
              05.06.2017 16:21

              Ну это моя проблема выбора, конвертора, я лишь описал как я ускорил, если xml_reader лучше, то в данном случае он тоже отработает хорошо.


          1. frees2
            08.06.2017 08:58
            -1

            Да, заметил, набежит группа и минусует одновременно. Выбивают в других комментариях, а не по теме срача, кто хоть что то знает, а остаются полит. психи…
            Скоро будет жежечка вторая.

            Может нервничают от старого железа или перехода на PHP7, где SimpleXML extension были проблемы с установкой.

            99% будет ошибка где то в XML, особо при таких гигантских файлах, не пойму как они работают.


        1. Assargin
          05.06.2017 16:35
          -1

          Долго. Хотя, если такое время выполнения никому неудобств не доставляет — ну ладно.
          Но, в любом случае, насилие БД… Вы при разборе каждого узла делаете запросы? У вас при разборе файла какие запросы превалируют: INSERT или UPDATE? Что, если собирать данные и отправлять их в БД пачками (bulk insert), т.е., вместо 100/200/500 запросов сделать 1? Или возможно, если вы используете innodb и у вас autocommit=1 (по умолчанию), а вы и не знали?


          1. Sect0R
            05.06.2017 16:47

            Оптимизация базы проведена.
            Используются транзакции.
            Большие запросы вынесены в отдельный файл который работает с очередью RabbitMQ


  1. Ogoun
    05.06.2017 16:41
    +1

    Была задача распарсить многогиговые XML файлы, делал очень просто, зная структуру XML файла читал построчно, последовательно заполняя и обрабатывая объектную модель. При незнакомом формате, можно в два прохода, на первом строит схему XML, на втором уже зная ее обрабатываем последовательно блоки.


  1. 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). Да, колонок будет овер много в общем случае. Конвертировать можно например не в файл, а в пайп, чтоб место поберечь на диске.


    1. igurylev
      06.06.2017 11:17

      Ну почему же никто :)
      Тоже была задача в своё время распарсить xml на сотню-другую мегабайт. При этом структура была заранее известна и размер item-а был вменяемый, не больше сотен килобайт.
      Комбинация регулярок с построчным чтением файла вполне спасала от перерасхода памяти.


  1. Scf
    06.06.2017 08:03
    +2

    Читаю и ужасаюсь — чего только не придумают люди, у которых нет StAX


  1. http2
    06.06.2017 10:29

    Хм, а у меня xml_parse был быстрее на порядок. :)


  1. daemonhk
    06.06.2017 11:17

    Уф… XML в несколько гигов это жесть)) Мне пока максимум доводилось работать с 30-35Мб, использую Simple XML и пару раз DOM за карьеру. Про другие библиотеки не знал, спасибо.


  1. mokhovcom
    06.06.2017 11:17

    Вы не пробовали использовать xslt преобразования? С их помощью из многомегабайтного XML документа можно вычленить только интересующую вас информацию, а уже её зачитывать в сущности.


    1. Sect0R
      06.06.2017 11:18

      У нас вся интересующая информация в xml )
      Именно весь xml и нужен был.


  1. baldr
    06.06.2017 11:32
    +1

    По запросу «php stream xml» вроде бы в первых же результатах вылезают ссылки на XMLReader, SAX и другие полезные слова?
    (это коммент не к статье, а к товарищам, которые велосипедят)


    1. Sect0R
      06.06.2017 11:34

      но почему-то до сих пор 80% <=middle разработчиков используют simplexml )


    1. daemonhk
      12.06.2017 05:45

      С каких пор использование simplexml — вилосипедописание?


      1. Sect0R
        13.06.2017 16:53

        велосипедописание если идет работа с файлом более 50 Mb


  1. 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.


    1. raidhon
      06.06.2017 16:37

      Пропустил что использовали седьмую версию во вкладке <<Характеристики тестовой среды>>.
      Фразу <<Наверное ещё и под php 5.2 — 5.4.>> прошу пропустить.


      К сожалению это не меняет того что моя критика обоснована.


  1. FieryCat
    06.06.2017 15:06

    Как насчет использования eXist? Он как раз и создавался для удобства в оперировании большими XML файлами.


  1. AHDPEu
    10.06.2017 16:30

    Используйте SAX парсер, но не используйте его главную фишку — парсить в потоке?
    У вас поэтому такая смешная разница между DOM и SAX.
    Этот парсер легко переваривает многогиговые эксельки (любой zip архив с xml), которые офис не в состоянии открыть.


  1. frees2
    12.06.2017 11:15
    -1

    https://www.sitepoint.com/functional-programming-phunkie-building-php-json-parser/
    — A JSON Parser.
    По опыту, большее количество кода это обнаружение и устранение ошибок, вот как работает по новому. См. выше.