IDCT Bartosz Pachołek

Uwaga: Ciasteczka!

Ta witryna korzysta z cookies: Informujemy, że ta witryna korzysta z własnych, technicznych oraz należących do podmiotów zewnętrznych ciasteczek ("cookies") celem śledzenia aktywności oraz zapewnienia pełnej funkcjonalności. Kontynuując akceptujesz użycie cookies. Aby korzystać z pewnych części witryny konieczna jest akceptacja.

Przetwarzanie wielkich plików XML korzystając z PHP

Opublikowano 2020-01-20

Przez dłuższy czas pracowałem w firmie, w której jednym z moich zajęć było przetwarzanie dość sporych plików z danymi: najczęściej w formacie XML, ale też o nietypowych formach. W tym momencie skupimy się głównie na XMLach. Środowiskiem, w którym pracowaliśmy, było ukochane i znienawidzone przez wielu PHP. Znam przynajmniej kilka osób, które powiedzą, że prawdopodobnie nie jest to najlepsze narzędzie do tego zadania, ale koniec końców spełniało wszystkie swoje funkcje. Jak już wspomniałem, pliki były często bardzo duże, ponad 4GiB, a my staraliśmy się trzymać w reżimie górnego limitu 1,5GiB pamięci, aby maksymalizować ilość uruchomionych procesów na jednej fizycznej maszynie. Wymagało to od nas znalezienia określenia najbardziej wydajnych sposobów, aby dobrze wykorzystać pamięć i jednocześnie przetworzyć dane w najkrótszym możliwym czasie.

Uwaga

Komentarze w kodzie nie będą przetłumaczone: zakładam, że jednak czytający ten artykuł programista, nawet junior, potrafi posługiwać się prostym angielskim.

Metody oferowane przez PHP

PHP udostępnia nam przynajmniej kilka sposobów na obchodzenie się z plikami XML: SimpleXML, XMLReader, mieszane: XMLReader + Simple XML, SAX (Expat), DOM, XMLReader + DOM (i wiele więcej, jak np. SDO, ale ten interfejs akurat pominiemy, gdyż jego zadanie jest dość konkretne i inne od zamierzonego przez nas): każde z podejść ma swoje wady i zalety. W tym artykule postaram się przedstawić zyski oraz problemy wynikające z korzystania z każdego z nich oraz przedstawię komentarz na temat sposobu w moim przypadku najbardziej wydajnego.

Dane wejściowe

W mojej pracy miałem styczność z przede wszystkim dwoma typami plików: danymi obiektów noclegowych (atrybuty jak identyfikator, nazwa, opis, rozmiar, itp.) oraz oddzielnymi, które zawierały dane na temat dostępności danego dnia lub cen. Na potrzeby pierwszego testu wygenerujmy plik, który zawiera informacje o dostępności obiektów dla danej daty:

<?php
//filename: generate.php

//generates a random XML with vacancies info on particular date for hotel rooms
$rooms = 100000;
$datesPerRoom = 1000;

$pregeneratedDates = [];
$date = new DateTime();
for ($i = 0; $i < $datesPerRoom ; $i++) {
    $date->modify('+1 day');
    $pregeneratedDates[] = $date->format('Y-m-d');
}

echo "<hotel>";
echo "\t<rooms>";
for ($i = 0; $i < $rooms; $i++){
    echo "\t\t<room id=\"$i\">";
        foreach($pregeneratedDates as $date) {
            echo "\t\t\t<vacancy date=\"$date\">" . mt_rand(0,1) . "</vacancy>\n";
        }
    echo "\t\t</room>";
}
echo "\t</rooms>";
echo "</hotel>";

Po wywołaniu

php generate.php > random.xml

powinniście otrzymać plik zajmujący około 4GiB, którego użyjemy do pierwszych testów.

Pomiar

Mierzyć będziemy: wykorzystanie pamięci oraz czas wykonania. Kod powinien oferować "moment", w którym mamy dostępne wszystkie dane, które moglibyśmy przekazać do funkcji zapisującej: id obiektu $objectid, datę $date i wartość $value, testować więc będziemy jedynie odczyt.

Pomiar czasu jest prosty: zwyczajnie porównamy timestampy pomiędzy końcem i starem, ale określenie wykorzystania pamięci jest nieco bardziej złożone. Nie możemy skorzystać z oferowanej przez PHP funkcji memory_get_peak_usage(true); gdyż nie wlicza ona zasobów Resource czyli przynajmniej dla SimpleXMl wynik byłby przekłamany. Zamiast tego skorzystamy z danych zwracany dla danego procesu przez system operacyjny:

//memcheck.php
/**
 * Gets peak memory usage of a process in KiB from /proc.../status.
 *
 * @return int|bool VmPeak, value in KiB. False if data could not be found.
 */
function processPeakMemUsage()
{
    $status = file_get_contents('/proc/' . getmypid() . '/status'); 
    $matches = array();
    preg_match_all('/^(VmPeak):\s*([0-9]+).*$/im', $status, $matches);  
    return !isset($matches[2][0]) ? false : intval($matches[2][0]);
}

Powyższy kod zadziała na każdym systemie opartym Debiana, np. Ubuntu. Niestety nie zadziała na Debianie zainstalowanym poprzez Windows Subsystem for Linux 1, miejmy nadzieję, że WSL 2 uwzględni to w swojej implementacji.

Pomiar wykonam na maszynie z 16GB pamięci ram (8GB zawsze wolne).

Przed rozpoczęciem zmierzyłem też ile pamięci zużywa samo uruchomienie PHP w mojej konfiguracji: 63MiB, będzie to nasza tara.

SimpleXML

SimpleXML znane jest z bycia bardzo przyjaznym i prostym w użyciu dla programistów, przedrostek Simple nie znalazł się tutaj bez przyczyny. Ma jednak swoje ograniczenia: chociażby takie, że zużywa sporo pamięci ponieważ konwertuje CAŁOŚĆ załadowanych danych do obiektów. Jest wysoce prawdopodobne, że nawet nie zdołam załadować całego pliku 4GB korzystając tylko z SimpleXML, ale spróbujmy:

<?php 
//run php.
include "memcheck.php";
$start = time();
$xmlObject = simplexml_load_file('random.xml');

Wykonanie:

php run.php

Niestety, po kilku sekundach, gdy zabrakło pamięci, otrzymałem komunikat Segmentation fault. Technicznie rzecz ujmując jest możliwe, że mając do dyspozycji więcej zasobów SimpleXML byłby najszybszym narzędziem, ale ponieważ nie tylko przekroczyło reżim 1,5GiB to jeszcze nie zmieściło się w 8GiB to na tym etapie zdyskwalifikujemy to rozwiązanie. Jednak nie traćcie nadziei związanych z tym interfejsem: w dalszym etapach tego artykułu okaże się bardziej niż przydatne!.

DOM

Sprawdźmy czy rozwiązane oparte o Document-Object Model w ogóle da radę załadować całość czy polegnie jak SimpleXML:

<?php
include "memcheck.php";
$start = time();

$doc = new DOMDocument();
$doc->load('random.xml');

Trwało to dłużej, balansowało w okolicach 8GiB przez chwilę ...i w końcu przegrało bitwę z zasobami, podobnie jak SimpleXML. O samym rozwiązaniu jednak wspomnimy jeszcze w dalszej części.

SAX Expat Parser

SAQ to dość antyczny parser, który nie bazuje na libxml2, ale posiadający przynajmniej kilka interesujących rozwiązań: jak np. przetwarzanie kawałków danych. Jest on raczej zapomniany przez młodszych programistów, jednak sam z siebie oferuje przynajmniej kilka rozwiązań, których nie znajdziemy korzystając z SimpleXML lub XMLReadera. Głównym problemem przy pracy z nim jest konieczność pamiętania stanów, operowania tak jak gdyby nasz kod był odwołującą się do swoich atrybutów liniową maszyną, która skacze w różne miejsca kodu - będzie to widoczne w przykładzie.

Jest to natomiast pierwszy parser z naszej listy, który faktycznie da radę załadować dane, więc potrzebujemy napisać wreszcie gotowy kod do przetworzenia pliku:

<?php
include "memcheck.php";
$start = time();

//we open a handle to the file
$stream = fopen('random.xml', 'r');

//create the actual parser
$parser = xml_parser_create();

/* like mentioned: we operate from top to bottom and consider 
each incoming data as a potential state changer, therefore we 
need some variables to store the actual states */

//will be set when we hit `room` starting tag from attributes
$lastObjectId = null; 

//will be set when SAX reads the contents between the nodes
$lastContents = null; 

//will be set when we hit the `vacancy` start tag
$lastDate = null; 

/*
 ! end tag of vacancy is the moment when we have all the required data for 
 each entry in such configuration
*/

/* like mentioned: SAX Parser works in quite an unusual way as its origin 
dates to PHP4. We need to pass methods (as in methods' names) which will 
handle occurences of: start tag, end tag and literal contents (texts 
between start and end tags) */
xml_set_element_handler($parser, "startTag", "endTag");
xml_set_character_data_handler($parser, "contents");

//now the nice part of sax parser: we load the chunks of data into the parser and it does the trick of joining and parsing whenever needed
while (($data = fread($stream, 16384))) {    
    xml_parse($parser, $data); // parse the current chunk
}
xml_parse($parser, '', true); // finalize parsing
//free any memory used by the parser
xml_parser_free($parser);
//close the stream
fclose($stream);

/**
 * function which handles the start tag and therefore 
 * can access the xml attributes
 * 
 * in our case we want object id and date from attributes
 */
function startTag($parser, $name, $attrs) {   
    /* in this prototype we use globals,
    but it is possible to use object context
    by informing SAX that it reside in an 
    entity with `xml_set_object` */
    global $lastDate, $lastObjectId; 
    switch($name) {
        case 'ROOM':        
            $lastObjectId = $attrs['ID'];
        break;    
        case 'VACANCY':
            $lastDate = $attrs['DATE'];
        break;
    }    
}

/** 
 * function which is executed when an end tag is hit
 * in our case the moment when we have all data of 
 * a particular vacancy (as required: object id, vacancy
 * date) is the moment when we read the contents of the
 * vacancy node, but for a bit of clarity in the code
 * we shall actually handle it when we hit the end tag 
 */
function endTag($parser, $name) {
    global $lastObjectId, $lastContents, $lastDate;
    switch ($name) {
        case 'ROOM':
            //here we would finalise room's data...
        break;
        case 'VACANCY':
            $objectId = $lastObjectId;
            $date = $lastDate;
            $value = $lastContents;
            //here we would process the data
        break;
    }    
}

/** 
 * function which handles the literal (string) contents of
 * a node
 */
function contents($parser, $data) {
    global $lastContents;
    $lastContents = $data;
}

var_dump("Mem in MiB: " . round((processPeakMemUsage() / 1024)));
var_dump("Time in seconds:  " . (time() - $start));

Wykonanie z buforem 16384:

string(14) "Mem in MiB: 63"
string(21) "Time in seconds:  140"

Wynik jest bardziej niż zadowalający: praktycznie nie zużył dodatkowej pamięci (pamiętajcie o naszej tarze, czyli 63MiB). Sprawdźmy z większym rozmiarem bufora:

Z 64MiB (67 108 864 bajtów):

string(15) "Mem in MiB: 257"
string(19) "Time in seconds:  4"

Jeszcze lepszy wynik...

Sprawdziłem też z innymi wartościami:

  • 256 MiB

    string(15) "Mem in MiB: 833"
    string(20) "Time in seconds:  11"
  • 512 MiB

    string(16) "Mem in MiB: 1601"
    string(20) "Time in seconds:  20"
  • 32 MiB

    string(15) "Mem in MiB: 113"
    string(19) "Time in seconds:  3"
  • Cały plik, korzystając z file_get_contents

    string(16) "Mem in MiB: 4073"
    string(19) "Time in seconds:  3"

Wynika z tego, że nie ma zysku z zwiększania rozmiaru bufora w nieskończoność, poza załadowaniem całego pliku w całości. Wyniki dla wysokiego bufora były często gorsze niż dla 32MiB. Dlaczego? Niestety nie mam jednoznacznej odpowiedzi, ale można się domyślać, że przez to, iż SAX nie może oczekiwać zakończenia wszystkich nodów w jednym kawałku to musi wielokrotnie przeszukiwać załączony bufor oraz łączyć go z poprzednimi danymi: wykonuje przez to bardzo dużo operacji na tekście, które wewnętrznie mogą wiązać się z kopiowaniem wielu wartości lub przetwarzaniem olbrzymich tablic. Podobnie, jeśli użyjemy zbyt małego bufora to może nastąpić konieczność zbyt częstego przeszukiwania małych tablic oraz zbyt dużej ilości łączenia wielkiej ilości małych ciągów. Dlatego warto poświęcić trochę czasu na znalezienie optymalnego rozmiaru bufora dla swojego środowiska i danych. Faktem wartym uwagi jest to, że SAX dał radę przetworzyć całość danych w fizycznym limicie pamięci, i to w bardzo dobrym czasie!

Jak widzicie, po przejściu przez nieco nieprzyjazne oprogramowanie logiki parsera potrafi być potężnym narzędziem. Mimo wszystko, SAX Parser jest tylko mądrzejszym analizatorem tekstu, który nie zwraca żadnych metod ściśle związanych z plikami XML poza umieszczeniem atrybutów w tablicy.

XMLReader

Diament w koronie, bożyszcze nastolatek, rozwiązanie wszystkich Twoich problemów... takie komentarze można często usłyszeć na temat XMLReadera (no może nieco przesadziłem), ale czy naprawdę jest takim doskonałym rozwiązaniem? Łączy ze sobą najlepsze cechy libxml2 oraz przetwarzania strumieni danych (podobnie jak SAX), ale w przynajmniej kilka jego aspektów przedstawia braki. Przejdźmy do szczegółów:

XMLReader pozwala na wczytanie danych na dwa sposoby:

  • Wskazując plik:

    $reader = XMLReader::open('/opt/files/myfile.xml');
    //...
    $reader = XMLReader::open('https://idct.pl/some_url_loader.php');

    You can even post data with the request by setting the stream context using libxml_set_streams_context.

  • Wprowadzająć cały XML w postaci tekstu:

    $reader = new XMLReader();
    $reader->XML(/* ...xml contents ... */);

Pierwsze podejście jest szczególnie dobre w przypadku olbrzymich plików, gdyż nie ładuje całości do pamięci, ale ma jeden poważny problem: nie możesz wprost określić rozmiaru bufora, gdyż pozwala jedynie na przekazanie ścieżki a nie już otwartego strumienia, przez to nie możemy skorzystać np. z stream_set_read_buffer.

XMLReader udostępnia nam kilka dodatkowych funkcji, ściśle związanych z XML: np. możliwość skoku do następnego elementu określonego przez ten sam poziom lub nazwę (funkcja next), jednak w przypadku prostych scenariuszy wdrożenie oparte o niego nie różni się bardzo od tego opartego o SAX:

<?php
include "memcheck.php";
$start = time();

$xml = XMLReader::open('random.xml');
$lastObjectId = null;

while($xml->read()) {
    if ($xml->nodeType === \XmlReader::ELEMENT) {
        switch($xml->depth) {
            case 2: //we are in `room`
                //get the object id 
                $lastObjectId = $xml->getAttribute('id');
            break;
            case 3: //we are in `vacancy
                $date = $xml->getAttribute('date');
                $objectId = $lastObjectId;                
                //now we need to jump one more time to the actual value...
                if (!$xml->isEmptyElement) {
                    $xml->read();
                }
                $value = $xml->value;
            break;
        }
    }
}

var_dump("Mem in MiB: " . round((processPeakMemUsage() / 1024)));
var_dump("Time in seconds:  " . (time() - $start));

Okay, przepraszam, myliłem się: wdrożenie w sytuacji, gdy operujemy na prostej strukturze, gdzie nie ma konieczności weryfikowania zależności rodzic/dziecko w dodatkowy sposób, może być - tak jak na przykładzie wyżej - o wiele bardziej czytelne i zarządzalne niż podobne korzystające z SAX.

Jakie są wyniki wydajności?

root@test:~# php xmlreader.php 
string(14) "Mem in MiB: 63"
string(21) "Time in seconds:  100"

Wygląda świetnie, lepiej niż SAX z buforem 16KiB. Również nie zużył żadnej dodatkowej pamięci (pamiętajmy o naszej tarze, 63MiB), ale hej: mam pamięć do wykorzystania! Mamy 1,5GiB, czy korzystając z XMLReadera możemy jakoś mądrze skorzystać z tych zasobów? Niestety nie, jedynym realnym sposobem na wykorzystanie większej ilości pamięci korzystając jedynie z XMLReadera byłoby utworzenie nowej instancji XMLReadera dla danych wcześniej przetworzonych przez XMLReadera (np. wewnętrznych gałęzi). Dlatego też przetestujemy mieszane rozwiązania, ale najpierw spróbujemy jeszcze przetworzyć całość, ładując wcześniej dane korzystając z file_get_contents:

Niestety nie powiodło się, gdyż funkcja nie przetworzyła pliku i zwróciła jedynie ostrzeżenie

PHP Warning:  XMLReader::XML(): Unable to load source data in /root/xmlreader2.php on line 8

libxml_get_errors nie informuje o żadnych problemach.

Jest to najprawdopodobniej problem z samym libxml2, gdyż próba uruchomienia:

root@test:~# xmllint random.xml           
Killed

też się nie udaje.

XMLReader + SimpleXML

Skorzystamy z XMLReadera aby szybko przeskakiwać między elementami , ale same dane na temat dostępności (<vacancies>) przetworzymy korzystając z SimpleXML. Takie podejście byłoby szczególnie dobre w sytuacji pracy z dodatkowymi atrybutami określonymi przez kolejne gałęzie: np. nazwa, adres, dane kontaktowe itp., oferowałoby nam dostęp do tych wartości w wygodny, obiektowy, sposób, ale o tym później.

Kod testowy:

<?php
include "memcheck.php";
$start = time();

$xml = XMLReader::open('random.xml');

while($xml->read() && $xml->name !== 'room') {} //jump to first `room` element;

do {
    $node = simplexml_load_string($xml->readOuterXml());
    $objectId = (string) $node['id'];
    foreach($node->vacancy as $vacancyNode) {
        $value = (string) $vacancyNode;
        $date = (string) $vacancyNode['date'];
    }
} while ($xml->next('room'));

var_dump("Mem in MiB: " . round((processPeakMemUsage() / 1024)));
var_dump("Time in seconds:  " . (time() - $start));

Wynik:

string(14) "Mem in MiB: 65"
string(21) "Time in seconds:  291"

Wykorzystanie pamięći mieści się w 2MiB, ale czas niestety drastycznie wzrósł. W sytuacjach, gdy mówimy o niskiej złożoności, taka sytuacja powinna być czynnikiem dyskwalifikującym, ale wrócimy jeszcze do wykorzystania SimpleXML w bardziej złożonych strukturach gdzie pokaże swoje możliwości, szczególnie w kontekście jakści i dostępności kodu.

Ponownie, nie możemy w zasadzie nic zrobić, aby użyć więcej pamięci celem zyskania na czasie: jedynym rozwiązaniem byłoby podzielenie danych i przetwarzanie kilku obiektów jednocześnie przez SimpleXML po połączeniu ich fikcyjną gałęzią nadrzędną.

XMLReader + DOM

XMLReader udostępnia metodę expand która zwraca aktualnie wskazywaną gałąź w postaci instancji DOMNode. Poza faktem, że dalsze operowanie na takim elemencie jest o wiele bardziej wygodne, obiektowe, to wspiera też operowanie na niepoprawnych plikach XML. Jest to też element wdrożenia standardu W3C DOM API w PHP. Zalety i problemy wynikające z korzystania z DOM w PHP względem np. SimpleXML sprowadzają się głównie do poziomu opinii, dla przykładu jedni są wrogami rozproszenia na wiele klas i wiele obiektów podczas przetwarzania a inni gromadzeniem całości pod jednym, tak jak to robi SimpleXML.

Wykonajmy test dla naszego prostego scenariusza:

<?php
include "memcheck.php";
$start = time();

$xml = XMLReader::open('random.xml');

while($xml->read() && $xml->name !== 'room') {} //jump to first `room` element;

do {
    $node = $xml->expand();
    $objectId = $node->getAttribute('id');
    foreach($node->childNodes as $child) {
        if( $child->nodeType === 1 ) { //Element nodes are of nodeType 1. Text 3. Comments 8. etc. yes yes I should have used the constants here...
            $value = $child->textContent;
            $date = $child->getAttribute('date');
        }        
    }
} while ($xml->next('room'));

var_dump("Mem in MiB: " . round((processPeakMemUsage() / 1024)));
var_dump("Time in seconds:  " . (time() - $start));

Results:

string(14) "Mem in MiB: 65"
string(21) "Time in seconds:  194"

Pierwsze wnioski

Okay, pozornie łatwo powiedzieć, że XMLReader lub SAX to zwycięzcy: niskie zużycie pamięci oraz szybkie czasy wykonywania. Jest to jednak tylko krótkowzroczne spostrzeżenie: przejdźmy na nieco wyższy poziom złożoności i sprawdźmy jak zachowają się podane interfejsy, a w szczególności zbadajmy jak wzrośnie złożoność kodu.

Drugi plik testowy

Poza przetwarzaniem danych o cenach czy dostępności najczęściej musieliśmy też testować dane samych obiektów noclegowych, a te często zawierały o wiele więcej atrybutów, nierzadko dynamicznie się pojawiających. Wygenerujmy przykładowy plik o strukturze:

<objects>
    <object>
        <id>123</id>
        <name>random object name 123123</name>
        <services>
            <service>
                <id>123</id>
                <name>service name</name>
            </service>            
            ...
        </services>
        <features>
            <feature>
                <id>123</id>
                <name>feature name</name>
            </feature>            
        </features>
    </object>
    ...
</objects>

kod do wygenerowania:

<?php
//filename: generate2.php

//generates a random XML with vacancies info on particular date for hotel rooms
$objects = 80000;

echo "<objects>\n";
for ($i = 0; $i < $objects; $i++){
    echo "\t<object>\n";
    echo "\t\t<id>$i</id>\n";
    echo "\t\t<name>random name ".mt_rand(0,100000)."</name>\n";
    $services = mt_rand(0,10);
    if ($services > 0) {
        echo "\t\t<services>\n";
        for($j = 0; $j < $services; $j++) {
            echo "\t\t\t<service>\n";
            echo "\t\t\t\t<id>$j".mt_rand(100,200)."</id>\n";
            echo "\t\t\t\t<name>name $j</name>\n";
            echo "\t\t\t</service>\n";
        }        
        echo "\t\t</services>\n";
    } else {
        echo "\t\t<services/>\n";
    }

    $features = mt_rand(0,10);
    if ($features > 0) {
        echo "\t\t<features>\n";
        for($j = 0; $j < $features; $j++) {
            echo "\t\t\t<feature>\n";
            echo "\t\t\t\t<id>$j".mt_rand(100,200)."</id>\n";
            echo "\t\t\t\t<name>name $j</name>\n";
            echo "\t\t\t</feature>\n";
        }        
        echo "\t\t</features>\n";
    } else {
        echo "\t\t<features/>\n";
    }
    echo "\t</object>\n";
}
echo "</objects>\n";

Dla 80 tysięcy obiektów wygenerowany plik zajmie okolo 60MiB i powinien zmieścić się w pamięci korzystając z dowolnego narzędzia. Sprawdźmy przykładowe wdrożenie korzystając najpierw z XMLReadera (W przypadku SAX wdrożenie najczęściej jest jeszcze bardziej złożone, więc przedstawienie za pomocą samego XMLReadera już daje ogląd na zgłębiany aspekt tematu). Ponownie, naszym celem jest mieć "moment" w kodzie, w którym mamy wszystkie atrybuty obiektu:

Przykładowe wdrożenie:

<?php
include "memcheck.php";
$start = time();

$xml = XMLReader::open('out.xml');

$lastParent = null;
$currentObject = null;

while($xml->read()) {
    switch($xml->depth) {
        case 1: //we are in `object`       
            if ($xml->nodeType === \XmlReader::ELEMENT) {
                $currentObject = [
                    'services' => [],
                    'features' => []
                ];
            } elseif ($xml->nodeType === \XmlReader::END_ELEMENT) {
                //end of <object> tag
                //here we have all the values in variable `$currentObject`
            }                
        break;
        case 2: //now we are inside the object element
            //now the switching starts: as there are multiple nodes inside we need to actually verify their names
            if ($xml->nodeType === \XmlReader::ELEMENT) {
                switch($xml->name) {
                    case 'id':                                        
                    case 'name':
                        $nodeName = $xml->name;
                        $xml->read(); //go to value
                        $currentObject[$nodeName] = $xml->value;
                    break;
                    case 'features':
                    case 'services':
                        $lastParent = $xml->name;
                    break;  
                }
            }
        break;
        case 3: //we are in the third level: in features or services
            //now we are in `service` or `feature` tag
        break;
        case 4:
            //here we re in <service> or <feature> tag
            $id = null;
            $name = null;

            while ($xml->depth === 4) {
                if ($xml->nodeType === \XmlReader::ELEMENT) {
                    $lastName = $xml->name;
                    $xml->read();
                    switch($lastName) {
                        case 'id':
                            $id = $xml->value;
                        break;
                        case 'name':
                            $name = $xml->value;
                        break;
                    }
                }

                $xml->read();
            }      

            if ($id !== null && $name !== null) {
                $currentObject[$lastParent][strval($id)] = $name;
            }
        break;
    }
}

var_dump("Mem in MiB: " . round((processPeakMemUsage() / 1024)));
var_dump("Time in seconds:  " . (time() - $start));

Oczywiście jest to tylko przykład i można to wykonać na wiele inny sposobów. Oparłem swoje wdrożenie o parametr zagłębienia parsowania oraz odniesienie do nazwy gałęzi nadrzędnej. Wykonanie jest prawie natychmiastowe i nie zużywa pamięci:

string(14) "Mem in MiB: 63"
string(19) "Time in seconds:  3"

ale musicie przyznać: kod przestaje być czytelny i wygodny do manipulacji. W dodatku łatwo zauważyć, że jego złożoność będzie rosła wraz z pojawieniem się kolejnych atrybutów, które wymagają przetwarzania, a w szczególności takich, które byłyby jeszcze głębiej.

Dlatego w takich sytuacjach, dla spokoju swojego oraz współpracowników, warto rozważyć nieco nowocześniejsze podejście, np. korzystające z SimpleXML. Plik o rozmiarach jak nasz testowy zmieści się bez problemu do pamięci w całości, więc może w ten sposób powinniśmy to zrobić? Sprawdźmy:

<?php
include "memcheck.php";
$start = time();

$xml = simplexml_load_file('random5.xml');

foreach($xml->object as $object) {
    $id = (string) $object->id;
    $name = (string) $object->name;
    $features = [];
    foreach($object->features->feature as $feature) {
        $features[(string)$feature->id] = (string) $feature->name;
    }

    $services = [];
    foreach($object->services->service as $service) {
        $services[(string)$service->id] = (string) $service->service;
    }

    //and here we have everything we wanted to acquire for an object...
}

var_dump("Mem in MiB: " . round((processPeakMemUsage() / 1024)));
var_dump("Time in seconds:  " . (time() - $start));

wykonanie:

string(16) "Mem in MiB: 1114"
string(19) "Time in seconds:  2"

Program wykonał się niemal natychmiast, ale wykorzystał ogromne ilości pamięci. Pozornie takie rozwiązanie jest akceptowalne w naszej sytuacji, gdyż mieścimy się w limicie. Kod jest o wiele bardziej czytelny, a jego złożoność nie będzie rosła wraz z pojawianiem się kolejnych atrybutów. W takim wypadku: Czy powinienem korzystać tylko z SimpleXML w takich sytuacjach? Wg mnie: nie. Dlaczego? Wyobraź sobie sytuację, że nagle Twój dostawca danych zwiększa ilość przesyłanych informacji, np. dostarcza o wiele więcej obiektów, w tych samych plikach. Łatwo możesz przekroczyć dostępne zasoby lub określone reżimy.

Jak rozwiązać taką sytuację? Używając podejścia mieszanego: kod stanie się tylko minimalnie bardziej złożony a wykorzystanie zasobów utrzymasz pod kontrolą nawet w przypadku wzrostu ilości danych.

W naszym przykładzie przejdziemy przez plik, pomiędzy elementami opisującymi dane obiektu, ale już sam obiekt przetworzymy korzystając z SimpleXML:

<?php
include "memcheck.php";
$start = time();

$xml = XMLReader::open('random5.xml');
//go to the first 'object' element
while ($xml->name !== 'object') { 
    $xml->read(); 
}

do {
    $object = simplexml_load_string($xml->readOuterXml());
    $id = (string) $object->id;
    $name = (string) $object->name;
    $features = [];
    foreach($object->features->feature as $feature) {
        $features[(string)$feature->id] = (string) $feature->name;
    }

    $services = [];
    foreach($object->services->service as $service) {
        $services[(string)$service->id] = (string) $service->service;
    }

    //here again we have all data of an object
} while ($xml->next('object'));

var_dump("Mem in MiB: " . round((processPeakMemUsage() / 1024)));
var_dump("Time in seconds:  " . (time() - $start));

Wykonanie:

string(14) "Mem in MiB: 63"
string(19) "Time in seconds:  6"

Wykorzystanie pamięci jest praktycznie zerowe (pamiętajcie o tarze) gdyż sam XML wewnętrzny, opisujący obiekt, jest bardzo prosty. Czas wykonania jest niski (dobrze) a złożoność kodu jest uczciwym kompromisem pomiędzy tym wykorzystującym jedynie XMLReader a tym tylko z SimplEXML: dodatkowo, w ramach jednej głównej gałęzi (tej która zawiera obiekty w naszym przykładzie) złożoność kodu wzrastać będzie jedynie liniowo wraz z pojawieniem się kolejnych atrybutów.

Wnioski

Przede wszystkim pamiętajcie, że wszystkie podane przykłady są jedynie przykładami: w swojej codziennej pracy powinniście zwracać uwagę na wszelkie możliwe błędy w dostarczanych danych, jak np. sprawdzać czy odpowiednie elementy istnieją lub są zgodne z oczekiwanymi formatami, wyłączać części wspólnego do oddzielnych funkcji, a także odpowiednio zamykać pliki i gdy to możliwe upraszczać kod.

Które rozwiązanie jest najlepsze? Jak widzicie nie ma jednego uniwersalnego. Dlatego tyle narzędzi jest wciąż rozwijanych lub utrzymywanych gdyż możliwe problemy różnią się od siebie i różne podejścia zdają się rozwiązywać je lepiej lub gorzej. To ciągłe balansowanie między wykorzystaniem zasobów, czasem wykonywania a złożonością kodu i w zależności od konkretnego projektu inne konkretne rozwiązanie, stawiające na inna aspekty może być lepsze. Przykładowo, jak było widać wyżej, dla plików opisujących wiele prostych wartości, które są w rzeczywistości prostymi kolumnami klucz => wartość prawdopodobnie warto unikać ładowania wszystkiego do pamięci w postaci obiektu, a XMLReader lub SAX zapewniają szybkie wykonanie przy akceptowalnej złożoności kodu. Dla plików, które opisują wiele (ale w względnie rozsądnej ilości) obiektów mających wiele atrybutów lepszym może być podejście przetworzenia wszystkiego za jednym razem w pamięci lub celem uniknięcia potencjalnego przekroczenia zasobów podejście mieszane, które korzysta z kilku omawianych narzędzi.