Парсинг XML с помощью XMLReader на PHP

XMLReader это потоковый XML парсер, способный обрабатывать большие объемы данных при низких затратах оперативной памяти.

XMLReader использует курсор для перемещения по структуре документа, без создания DOM дерева, как это делает, к примеру SimpleXML. Обрабатывать документы, конечно становится немного сложнее, но минимальные затраты драгоценной памяти и возможность обрабатывать огромные массивы данных, я думаю, перекрывают этот недостаток.

 

Самый простой пример использования XMLReader.

Предположим, у нас стоит задача импорта из XML в нашу базу данных.

Вот XML структура (файл example.xml) :

 

<?xml version="1.0" encoding="UTF-8"?>
<cards>
    <card number="9(999)-999-99-99">kenny</card>
    <card number="8(888)-888-88-88">finny</card>
</cards>

 

Для разбора этой, относительно несложной структуры, достаточно такого кода:

 

<?php
    $reader = new XMLReader();
    $reader->open('example.xml'); // указываем ридеру что будем парсить этот файл
    // циклическое чтение документа
    while($reader->read()) {
        if($reader->nodeType == XMLReader::ELEMENT) {
            // если находим элемент <card>
            if($reader->localName == 'card') {
                $data = array();
                // считываем аттрибут number
                $data['number'] = $reader->getAttribute('number');
                // читаем дальше для получения текстового элемента
                $reader->read();
                if($reader->nodeType == XMLReader::TEXT) {
                    $data['name'] = $reader->value;
                }
                // ну и запихиваем в бд, используя методы нашего адаптера к субд
                SomeDataBaseAdapter::insertContact($data);
            }
        }
    }

 

Таким образом, мы решили задачу об импорте из xml:

 

idcontactnumber
1kenny9(999)-999-99-99
2finny8(888)-888-88-88

 

Dive into XMLReader

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

Для себя я нашел простое, но достаточно практичное решение - я написал несколько оберток для работы с XMLReader, и то что получилось, меня пока что полностью удовлетворяет.

Итак, стояла задача обмена конфигурацией web-приложения с 1с. Обмен производится в виде XML документов с общими настройками приложения и ценообразования.

 

Вот пример конфигурационного xml файла, приходящего в приложение из 1с:

 

// инициализация ридера
$reader = new ConfigXMLReader('config.xml');
// чтобы не тратить память на хранение ненужных элементов, мы их просто выбрасываем на каждой итерации
$reader->onEvent('afterParseElement', function($name, $context) {
    $context->clearResult();
});
// мы хотим получать только настройки наценок
// эта анонимная функция(PHP5.3 и выше) будет вызвана сразу по завершению парсинга элементов <ratio>
$reader->onEvent('parseRatio', function($context) {
    // получаем наценку
    $ratio = $context->getResult()['ratio'][0];
    // сохраняем её в БД
    $ratioModel = new Ratio;
    $ratioModel->attributes = $ratio;
    $ratioModel->save();
});
// запускаем парсинг
$reader->parse();

 

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

 

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

 

Но довольно слов, приступим к коду:

 

Сперва нужно разработать общий базовый класс для оберток над этой библиотекой. Назовем наш абстрактный класс AbstractAdvertisementXMLReader.

 

Первое что нужно сделать - это объявить переменные-члены и создать конструктор:

 

<?php
    /*
        Родительский класс для XML обработчиков.
    */
    class AbstractAdvertisementXMLReader {
        protected $reader;
        protected $result = array();
        // события
        protected $_eventStack = array();
        /*
            Конструктор класса.
            Создает сущность XMLReader и загружает xml, либо бросает исключение
        */
        public function __construct($xml_path) {
            $this->reader = new XMLReader();
            if(is_file($xml_path))
                $this->reader->open($xml_path);
            else throw new Exception('XML file {'.$xml_path.'} not exists!');
        }
        /* ... */
    }

 

Как видим, имеется защищенный от агрессивной внешней среды экземпляр XMLReader'а, хэш-массив результатов извлечения, а также контейнер событий.

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

Тут все достаточно просто и наглядно.

 

Теперь давайте реализуем необходимые акцессоры:

 

// инициализация ридера
$reader = new ConfigXMLReader('config.xml');
// чтобы не тратить память на хранение ненужных элементов, мы их просто выбрасываем на каждой итерации
$reader->onEvent('afterParseElement', function($name, $context) {
    $context->clearResult();
});
// мы хотим получать только настройки наценок
// эта анонимная функция(PHP5.3 и выше) будет вызвана сразу по завершению парсинга элементов <ratio>
$reader->onEvent('parseRatio', function($context) {
    // получаем наценку
    $ratio = $context->getResult()['ratio'][0];
    // сохраняем её в БД
    $ratioModel = new Ratio;
    $ratioModel->attributes = $ratio;
    $ratioModel->save();
});
// запускаем парсинг
$reader->parse();

 

Реализация событийной модели:

 

        /*
            Вызывается при каждом распознавании
        */
        public function onEvent($event, $callback) {
            if(!isset($this->_eventStack[$event]))
                $this->_eventStack[$event] = array();
            $this->_eventStack[$event][] = $callback;
            return $this;
        }
        /*
            Выстреливает событие
        */
        public function fireEvent($event, $params = null, $once = false) {
            if($params == null)
                $params = array();
            $params['context'] = $this;
            if(!isset($this->_eventStack[$event]))
                return false;
            $count = count($this->_eventStack[$event]);
            if($count > 0) {
                for($i = 0; $i < $count; $i++) {
                    call_user_func_array($this->_eventStack[$event][$i], $params);
                    if($once == true) {
                        array_splice($this->_eventStack[$event], $i, 1);
                    }
                }
            }
        }

 

Соответственно, теперь у нас есть два метода - onEvent и fireEvent - являющиеся по сути реализацией паттерна Pub/Sub.

В качестве параметров, подписчики дополнительно получают контекст, т.е. ссылку на вызывающий объект AbstractAdvertisementXMLReader (либо класс его наследующий).

Осталось добавить в этот класс щепотку базовой логики:

 

        /*
            Потоково парсит xml и вызывает методы для определенных элементов
            напр.
                при обнаружении элемента <Rubric> попытается вызвать метод parseRubric
            все методы парсинга должны быть public или protected.
        */
        public function parse() {
            $this->reader->read();
            while($this->reader->read()) {
                if($this->reader->nodeType == XMLREADER::ELEMENT) {
                    $fnName = 'parse' . $this->reader->localName;
                    if(method_exists($this, $fnName)) {
                        $lcn = $this->reader->name;
                        // стреляем по началу парсинга блока
                        $this->fireEvent('beforeParseContainer', array('name' => $lcn));
                        // пробежка по детям
                        if($this->reader->name == $lcn && $this->reader->nodeType != XMLREADER::END_ELEMENT) {
                            // стреляем событие до парсинга элемента
                            $this->fireEvent('beforeParseElement', array('name' => $lcn));
                            // вызываем функцию парсинга
                            $this->{$fnName}();
                            // стреляем событием по названию элемента
                            $this->fireEvent($fnName);
                            // стреляем событием по окончанию парсинга элемента
                            $this->fireEvent('afterParseElement', array('name' => $lcn));
                        }
                        elseif($this->reader->nodeType == XMLREADER::END_ELEMENT) {
                            // стреляем по окончанию парсинга блока
                            $this->fireEvent('afterParseContainer', array('name' => $lcn));
                        }
                    }
                }
            }
        }

 

Вот и все, базовый класс полностью готов!

 

Далее я покажу как можно использовать все это безобразие.

К примеру, реализуем класс, обрабатывающий два типа элементов нашей xml'ки - ConfigXMLReader:

 

    /*
        Класс-обертка для парсинга конфигураций полученных из 1С.
    */
    class ConfigXMLReader extends AbstractAdvertisementXMLReader{
        /*
            Парсит наценки
        */
        protected function parseRatio() {
            if($this->reader->nodeType == XMLREADER::ELEMENT && $this->reader->localName == 'Ratio') {
                // объект для сохранения
                $ratio = array(
                    'group_id' => $this->reader->getAttribute('group_id'),
                    'id'       => $this->reader->getAttribute('id'),
                    'value'    => $this->reader->getAttribute('value')
                );
                // читаем глубже, для получения текстового
                $this->reader->read();
                if($this->reader->nodeType == XMLREADER::TEXT)
                    $ratio['name'] = $this->reader->value;
                $this->result['ratios'][] = $ratio;
            }
        }
        /*
            Парсит настройки несовместимых наценок
        */
        protected function parseRatioException() {
            if($this->reader->nodeType == XMLREADER::ELEMENT && $this->reader->localName == 'RatioException') {
                $ratioException = array(
                    'id_1' => $this->reader->getAttribute('id_1'),
                    'id_2' => $this->reader->getAttribute('id_2')
                );
                $this->result['ratioExceptions'][] = $ratioException;
            }
        }

 

Как видим, все просто и понятно.

Далее самое вкусное - вызывающий код:

 

// инициализация ридера
$reader = new ConfigXMLReader('config.xml');
// чтобы не тратить память на хранение ненужных элементов, мы их просто выбрасываем на каждой итерации
$reader->onEvent('afterParseElement', function($name, $context) {
    $context->clearResult();
});
// мы хотим получать только настройки наценок
// эта анонимная функция(PHP5.3 и выше) будет вызвана сразу по завершению парсинга элементов <ratio>
$reader->onEvent('parseRatio', function($context) {
    // получаем наценку
    $ratio = $context->getResult()['ratio'][0];
    // сохраняем её в БД
    $ratioModel = new Ratio;
    $ratioModel->attributes = $ratio;
    $ratioModel->save();
});
// запускаем парсинг
$reader->parse();

 

Вот таким нехитрым способом можно парсить большие и достаточно сложные XML файлы.

 


 

Описанные выше классы можно найти на GitHub Gist.