PHP: Как разобрать сложный XML-файл и не утонуть в собственном коде

в 13:34, , рубрики: php, XML, xmlreader

Доброе время суток!

Сфера применения XML-формата достаточно обширна. Наряду с CSV, JSON и другими, XML — один из самых распространенных способов представить данные для обмена между различными сервисами, программами и сайтами. В качестве примера можно привести формат CommerceML для обмена товарами и заказами между 1С «Управление торговлей» и интернет-магазином.

Поэтому практически всем, кто занимается созданием веб-сервисов, время от времени приходится сталкиваться с необходимостью разбора XML-документов. В своем посте я предлагаю один из методов, как это сделать по возможности наглядно и прозрачно, используя XMLReader.

PHP предлагает несколько способов работы с форматом XML. Не вдаваясь в подробности, скажу, что принципиально их можно разделить на две группы:

  1. Загрузка всего XML-документа в память в виде объекта и работа с этим объектом
  2. Пошаговое чтение XML-строки на уровне тегов, атрибутов и текстового содержимого

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

Второй способ — это более низкоуровневый подход, что дает нам ряд преимуществ, и вместе с тем несколько омрачает жизнь. Остановимся на нем поподробней. Плюсы:

  • Скорость парсинга. Более подробно можете прочитать здесь.
  • Потребление меньшего объема оперативной памяти. Мы не храним все данные в виде объекта, весьма затратного по памяти.

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

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

Основная идея в следующем: и схему нашего XML, и то, как с ней работать, мы будем хранить в одном-единственном массиве, повторяющем иерархию только необходимых нам тегов. Также для любого из тегов в этом же массиве мы сможем прописать нужные нам функции-обработчики открытия тега, его закрытия, чтения атрибутов или чтения текста, либо все вместе. Таким образом, мы храним структуру нашего XML и обработчики в одном месте. Одного взгляда на нашу структуру обработки будет достаточно для того, чтобы понять, что мы делаем с нашим XML-файлом. Оговорюсь, что на простых задачах (как в примерах ниже) преимущество в читаемости невелико, однако оно будет очевидно при работе с файлами относительно сложной структуры — например, форматом обмена с 1С.

Теперь конкретика. Вот наш класс:

Класс XMLReaderStruct - кликните, чтобы раскрыть

class XMLReaderStruct extends XMLReader {

  public function xmlStruct($xml, $structure, $encoding = null, $options = 0, $debug = false) {
    $this->xml($xml, $encoding, $options);
    $stack = array();
    $node = &$structure;
    $skipToDepth = false;
    while ($this->read()) {
      switch ($this->nodeType) {
        case self::ELEMENT:
          if ($skipToDepth === false) {
            // Если текущая ветка не входит в структуру, то просто игнорируем открытие тегов, иначе смотрим: если текущий узел структуры содержит
            // текущий тег, то открываем его, предварительно запоминая в стеке текущую позицию, чтобы при закрытии можно было вернуться. Если
            // не содержит, то открываем режим пропуска, пока не встретим закрывающий тег с текущей глубиной.
            if (isset($node[$this->name])) {
              if ($debug) echo "[ Открытие ]: ",$this->name," - найден в структуре. Спуск по структуре.rn";
              $stack[$this->depth] = &$node;
              $node = &$node[$this->name];
              if (isset($node["__open"])) {
                if ($debug) echo "              Найден обработчик открытия ",$this->name," - выполняю.rn";
                if (false === $node["__open"]()) return false;
              }
              if (isset($node["__attrs"])) {
                if ($debug) echo "              Найден обработчик атрибутов ",$this->name," - выполняю.rn";
                $attrs = array();
                if ($this->hasAttributes)
                  while ($this->moveToNextAttribute())
                    $attrs[$this->name] = $this->value;
                if (false === $node["__attrs"]($attrs)) return false;
              }
              if ($this->isEmptyElement) {
                if ($debug) echo "              Элемент ",$this->name," пустой. Возврат по структуре.rn";
                if (isset($node["__close"])) {
                  if ($debug) echo "              Найден обработчик закрытия ",$this->name," - выполняю.rn";
                  if (false === $node["__close"]()) return false;
                }
                $node = &$stack[$this->depth];
              }
            } else {
              $skipToDepth = $this->depth;
              if ($debug) echo "[ Открытие ]: ",$this->name," - не найден в структуре. Запуск режима пропуска тегов до достижения вложенности ",$skipToDepth,".rn";
            }
          } else {
            if ($debug) echo "( Открытие ): ",$this->name," - в режиме пропуска тегов.rn";
          }
          break;
        case self::TEXT:
          if ($skipToDepth === false) {
            if ($debug) echo "[ Текст    ]: ",$this->value," - в структуре.rn";
            if (isset($node["__text"])) {
              if ($debug) echo "              Найден обработчик текста - выполняю.rn";
              if (false === $node["__text"]($this->value)) return false;
            }
          } else {
            if ($debug) echo "( Текст    ): ",$this->value," - в режиме пропуска тегов.rn";
          }
          break;
        case self::END_ELEMENT:
          if ($skipToDepth === false) {
            // Если $skipToDepth не установлен, то это значит, что предшествующее ему открытие тега было внутри структуры,
            // и поэтому текущий узел структуры надо откатить.
            if ($debug) echo "[ Закрытие ]: ",$this->name," - мы в структуре. Подьем по структуре.rn";
            if (isset($node["__close"])) {
              if ($debug) echo "              Найден обработчик закрытия ",$this->name," - выполняю.rn";
              if (false === $node["__close"]()) return false;
            }
            $node = &$stack[$this->depth];
          } elseif ($this->depth === $skipToDepth) {
            // Если $skipToDepth установлен, то игнорируем все, что имеет бОльшую глубину, пока не дойдем до закрытие игнора с текущей глубиной.
            if ($debug) echo "[ Закрытие ]: ",$this->name," - достигнута вложенность ",$skipToDepth,". Отмена режима пропуска тегов.rn";
            $skipToDepth = false;
          } else {
            if ($debug) echo "( Закрытие ): ",$this->name," - в режиме пропуска тегов.rn";
          }
          break;
      }
    }
    return true;
  }

}

Как видите, наш класс расширяет возможности стандартного класса XMLReader, к которому мы добавили один метод:

xmlStruct($xml, $structure, $encoding = null, $options = 0, $debug = false)

Параметры:

  • $xml, $encoding, $options: как в XMLReader::xml()
  • $structure: ассоциативный массив, полностью описывающий то, как мы должны работать с нашим файлом. Подразумевается, что его вид заранее известен, и мы точно знаем, с какими тегами и что мы должны делать.
  • $debug: делать ли вывод отладочной информации (по умолчанию — откл.). Я не стал убирать этот параметр, чтобы при желании можно было посмотреть весь цикл работы. Кто захочет — вырежет все, что с ним связано.

Аргумент $structure.

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

  • "__open" — функция при открытии тега — function()
  • "__attrs" — функция для обработки атрибутов тега (при наличии) — function($assocArray)
  • "__text" — функция при наличии текстового значения тега — function($text)
  • "__close" — функция при закрытии тега — function()

Если какой-либо из обработчиков возвратит false, то парсинг прервется, и функция xmlStruct() возвратит false. На приведенных ниже примерах видно, как конструировать аргумент $structure:

Пример 1, показывающий порядок вызова обработчиков

Пусть есть XML-файл:

<?xml version="1.0" encoding="UTF-8"?>
<root>
  <a attr_1="123" attr_2="456">Abc</a>
  <b>
    <x>This is node <x> inside <b></x>
  </b>
  <c></c>
  <d>
    <x>This is node <x> inside <d></x>
  </d>
  <e></e>
</root>


    $structure = array(
      'root' => array(
        'a' => array(
          "__attrs" => function($array) { echo "ATTR ARRAY IS ",json_encode($array),"rn"; },
          "__text" => function($text) use (&$a) { echo "TEXT a {$text}rn"; }
        ),
        'b' => array(
          "__open" => function() { echo "OPEN brn"; },
          "__close" => function() { echo "CLOSE brn"; },
          'x' => array(
            "__open" => function() { echo "OPEN xrn"; },
            "__text" => function($text) { echo "TEXT x {$text}rn"; },
            "__close" => function() { echo "CLOSE xrn"; }
          )
        )
      )
    );
    $xmlReaderStruct->xmlStruct($xml, $structure);

Будут вызваны обработчики (в хронологическом порядке):

атрибуты root->a
текстовое поле root->a
открытие root->b
открытие root->b->x
текст root->b->x
закрытие root->b->x
закрытие root->b

Остальные поля обработаны не будут (в т.ч. root->d->x будет проигнорирован, т.к. он вне структуры)

Пример 2, иллюстрирующий простую практическую задачу

Пусть есть XML-файл:

<?xml version="1.0" encoding="UTF-8"?>
<shop>
  <record>
    <id>0</id>
    <type>product</type>
    <name>Some product name. ID:0</name>
    <qty>0</qty>
    <price>0</price>
  </record>
  <record>
    <id>1</id>
    <type>service</type>
    <name>Some product name. ID:1</name>
    <qty>1</qty>
    <price>15</price>
  </record>
  <record>
    <id>2</id>
    <type>product</type>
    <name>Some product name. ID:2</name>
    <qty>2</qty>
    <price>30</price>
  </record>
  <record>
    <id>3</id>
    <type>service</type>
    <name>Some product name. ID:3</name>
    <qty>3</qty>
    <price>45</price>
  </record>
  <record>
    <id>4</id>
    <type>product</type>
    <name>Some product name. ID:4</name>
    <qty>4</qty>
    <price>60</price>
  </record>
  <record>
    <id>5</id>
    <type>service</type>
    <name>Some product name. ID:5</name>
    <qty>5</qty>
    <price>75</price>
  </record>
</shop>

Это некий кассовый чек с товарами и услугами.

Каждая запись чека содержит идентификатор записи, тип (товар «product» или услуга «service»), наименование, количество и цена.

Задача: посчитать сумму чека, но раздельно по товарам и услугам.


include_once "xmlreaderstruct.class.php";
$x = new XMLReaderStruct();
$productsSum = 0;
$servicesSum = 0;
$structure = array(
  'shop' => array(
    'record' => array(
      'type'    => array( "__text" => function($text) use (&$currentRecord) {
        $currentRecord['isService'] = $text === 'service';
      } ),
      'qty'     => array( "__text" => function($text) use (&$currentRecord) {
        $currentRecord['qty'] = (int)$text;
      } ),
      'price'   => array( "__text" => function($text) use (&$currentRecord) {
        $currentRecord['price'] = (int)$text;
      } ),

      '__open'  => function() use (&$currentRecord) {
        $currentRecord = array();
      },
      '__close' => function() use (&$currentRecord, &$productsSum, &$servicesSum) {
        $money = $currentRecord['qty'] * $currentRecord['price'];
        if ($currentRecord['isService']) $servicesSum += $money;
        else $productsSum += $money;
      }
    )
  )
);
$x->xmlStruct(file_get_contents('example.xml'), $structure);
echo 'Overal products price: ', $productsSum, ', Overal services price: ', $servicesSum;

Автор: abnorm

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js