Strumieniowe parsowanie JSON — programistycznie o tym, jak radzić sobie z ogromnymi plikami

Transparent Data
Blog Transparent Data
5 min readMar 5, 2018

Zdarzył Ci się kiedyś problem z wczytaniem dużego pliku w formacie JSON?

Dużego, to znaczy naprawdę DUŻEGO? Takiego o rozmiarze kilkuset MB?

Jeżeli odpowiadasz twierdząco, to doskonale orientujesz się w istocie trudności — każdy proces PHP ma ustalony limit pamięci do wykorzystania (zazwyczaj pomiędzy 32–128 MB), a im mniej obciążamy pamięć, tym oczywiście lepiej dla wydajności procesu, bo przecież zawsze dążymy do tego, żeby obsłużyć jak największy ruch.

W przypadku naprawdę dużych plików JSON, wczytanie całej zawartości pliku za jednym zamachem (np. poprzez wykorzystanie funkcji json_decode() ) nie spełnia wspomnianych wyżej warunków wydajności.

Nie możemy też zastosować standardowych kodów do wczytywania do bazy plików tekstowych, takich jak na przykład ta:

$fileHandle = fopen(“file.txt”, “r”);

while (!feof($file_handle)) {

$line = fgets($fileHandle)

// rób coś z $line

}

fclose($file_handle);

bo w przypadku JSON’a są one całkowicie nieprzydatne. JSON bowiem jest takim formatem wymiany danych, który nie posiada podziału na linie.

Pozostaje nam zatem jedno rozwiązanie: wczytać plik JSON w częściach i procesować go na bieżąco.

Strumieniowe parsowanie dużych plików JSON — przydatne funkcje i przykładowe linijki kodu

Podstawową funkcją, jaka pomoże nam rozwiązać problem wczytywania dużych plików JSON w efektywnych wydajnie częściach jest funkcja stream_get_contents.

$chunk = stream_get_contents($handle, $length, $startFrom)

Mówiąc w skrócie, jej działanie polega na wyciągnięciu $length bajtów danych, rozpoczynając od bajtu $startFrom z pliku identyfikowanego uchwytem $handle.

Teraz, gdy już wiemy jak wczytać plik w częściach, staje przed nami kolejne zadanie: odnaleźć i przetworzyć obiekty JSON.

W naszym przypadku potrzebujemy wczytać tablicę obiektów, czyli:

[{…},{…},{…},…]

Każdy obiekt posiada swój zestaw kluczy, jednak nie ma w nim zagnieżdżeń innych obiektów. Znacznie upraszcza to cały proces, bo z miejsca widzimy, że naturalnym separatorem dla takiej struktury pliku jest zestaw znaków “},{: .

Żeby rozpocząć parsowanie obiektów JSON musimy jeszcze wprowadzić koncepcję bufora roboczego (BR), w którym będziemy składać kompletne obiekty JSON w formie łańcucha znaków. To właśnie w buforze roboczym (BR), w zaprogramowanej pętli, doczytywać będziemy dane z pliku o ustalonym rozmiarze, czyli z bufora źródłowego (BZ).

W trakcie doczytywania danych do BR musimy wziąć pod uwagę 3 przypadki:

1) odnaleziono separator },{

2) odnaleziono koniec pliku }]

3) nie odnaleziono separatorów, konieczne jest dalsze wczytanie danych do BR.

Dla przypadków 1) oraz 2) wyciągamy cały ciąg znaków składający się na pojedynczy obiekt JSON oraz przesuwamy wskaźnik w buforze BR. Przesunięcie wskaźnika jest naturalnie niezbędne dla nas, żeby wiedzieć, które obiekty zostały już przetworzone.

W pseudokodzie wygląda to tak:

while($BZ = doczytaj_z_pliku()) {

$BR .= $BZ;

$szukajObiektow = true;

// w $BZ może być część obiektu, cały lub więcej obiektów JSON

while ($szukajObiektow) {

if (znaleziono(‘},{‘) lub znaleziono(‘}]‘)) {

dodajDoTablicyJson();

ustawWskaźnikiWBuforze();

procesuj($json);

} else {

$szukajObiektow = false;

}

}

}

Ze względu na optymalizację pamięci, przetwarzamy wyłuskany obiekt JSON od razu — w funkcji procesuj($json). Nie budujemy żadnej wielkiej tablicy.

Implementacja przetwarzania strumieniowego plików JSON

Oto jak może wyglądać taki kod funkcji w PHP:

function parseStream(string $fileName, \Closure $closure) {

if (!$handle = @fopen($fileName, ‘r’)) {

throw new \Exception(‘File not found ‘ . $fileName);

}

$fileSize = filesize($fileName);

// rozmiar bufora BZ

$bufferSize = 2000;

// ile maksymalnie obiektów JSON przekazać do funkcji $closure jednocześnie

$jsonCountInChunk = 100;

// bufor BR

$tmpContent = ‘’;

// aktualna pozycja w pliku

$currentPos = 0;

// wskaźniki w buforze

$endSeparatorPos = $startSeparatorPos = null;

// tymczasowa tablica z rozpakowanymi obiektami JSON

$extractedJson = [];

// główna pętla

while ($chunk = stream_get_contents($handle, $bufferSize, $currentPos)) {

// dodaj BZ do BR

$tmpContent .= $chunk;

// obsłuż specjalny przypadek początkowy

if (is_null($startSeparatorPos)) {

$startSeparatorPos = 1;

} else {

$startSeparatorPos = 0;

}

$endSeparatorPos = 0;

// szukaj obiektów w BR

while (true) {

// najpierw szukamy separatora środkowego

$endSeparatorPos = strpos($tmpContent, ‘},{‘, $startSeparatorPos);

// jeżeli nie znaleziono środkowego to szukaj też końcowego

if ($endSeparatorPos !== false || ($endSeparatorPos = strpos($tmpContent, ‘}]’, $startSeparatorPos)) !== false) {

// ustal długość obiektu

$jsonLength = $endSeparatorPos — $startSeparatorPos + 1;

// wyłuskaj z bufora

$extracted = substr($tmpContent, $startSeparatorPos, $jsonLength);

// przeparsuj i dodaj to tablicy tymczasowej

$parsed = json_decode($extracted, true);

if ($parsed) {

$extractedJson[] = $parsed;

}

// ustaw wskaźnik początkowy skąd będziemy szukać następnych obiektów

$startSeparatorPos = $endSeparatorPos + 2;

// jeżeli wyciągnięto ustaloną liczbę obiektów, wywołaj funkcję z przekazaną tablicą oraz postępem przetwarzania (procent z pozycji w pliku)

if (count($extractedJson) >= $jsonCountInChunk) {

$closure($extractedJson, ceil(($currentPos / $fileSize) * 100));

unset($extractedJson);

$extractedJson = [];

}

} else {

// musimy wczytać więcej danych do bufora, nie znaleziono separatora

break;

}

}

// ustal nowy BR

$tmpContent = substr($tmpContent, $startSeparatorPos);

// zmień pozycję w pliku

$currentPos += strlen($chunk);

}

fclose($handle);

// przekaż pozostałe elementy z tablicy (w pętli mogliśmy nie osiągnąć limitu)

if (count($extractedJson) > 0) {

$closure($extractedJson, ceil(($currentPos / $fileSize) * 100));

unset($extractedJson);

}

}

Przykładowe wywołanie powyższej metody:

parseStream(‘/sciezka_do_pliku.json’, function($data, $percent) {

// $data jest tablicą obiektów

foreach ($data as $element) {

// przetwarzaj $element

}

});

Wydajność kodu

Zastanawiasz się teraz zapewne jak powyższy kod ma się do wydajności procesu.

Oto próbka z naszego testu, który przeprowadziliśmy na próbie 1,36 mln obiektów JSON (479 MB):

Rozmiar bufora BZ ustaliliśmy na 2000 bajtów, jako że powyżej tej wartości nie odnotowaliśmy zauważalnego wzrostu wydajności. Dodaliśmy również parametr liczby przekazywanych obiektów do funkcji $closure (1–100).

Rezultat: Zwiększenie liczby przekazywanych elementów nieznacznie zwiększa wydajność z uwagi na mniejszą ilość wywołań funkcji $closure. Obciążenie pamięci pozostaje na takim samym poziomie niezależnie od dobranych parametrów.

I tak oto możemy przetwarzać pliki o dowolnym rozmiarze przy stałym, niskim poziomie wykorzystania pamięci :)

Uwaga końcowa:

Powyższy przykład nie uwzględnia różnych przypadków struktury pliku. Może być koniecznie wcześniejsze przetworzenie pliku tak, aby pomiędzy ustalonymi separatorami nie było niepożądanych znaków np. tabulacji lub nowej linii.

O autorze artykułu:

Paweł Lange | Backend Developer w Transparent Data

Posiada bogate doświadczenie w tworzeniu i rozwijaniu systemów płatności internetowych. Zawodowo interesuje się technologiami internetowymi głównie od strony serwerowej. W wolnym czasie rozwija projekt służący do tworzenia dokumentów PDF przez API. Prywatnie jest zapalonym motocyklistą oraz miłośnikiem psów.

--

--