Strumieniowe parsowanie JSON — programistycznie o tym, jak radzić sobie z ogromnymi plikami
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.