Як зробити читання великих файлів PHP (і не угробити сервер)

326

Від автора: PHP розробники рідко піклуються про управління пам’яттю. PHP движок чудово виконує свою роботу і підчищає за нами. Серверна модель кратковыполняемого контексту означає, що навіть самий поганий код має довгостроковий ефект.

Нам мало коли необхідно виходити за ці комфортні рамки. Наприклад, коли ми намагаємося запустити Composer у великому проекту на мінімальному VPS, або коли необхідно зробити в PHP читання великого файлу на всі такому ж маленькому сервері.

У цьому уроці ми обговоримо останню проблему. Код до уроку можна знайти на GitHub.

Вимірюємо успіх

Єдиний спосіб зрозуміти, що ми поліпшили в коді, це виміряти поганий ділянку, після чого порівняти ці вимірювання після фікса. Іншими словами, якщо ми не знаємо, наскільки «рішення» допомогло нам (якщо взагалі допомогло), ми не можемо стверджувати, що це рішення взагалі.

Нас турбують два чинники. Перший – споживання CPU. З якою швидкістю працює процес, над яким ми будемо працювати? Другий фактор – споживання пам’яті. Скільки пам’яті виділяється на виконання скрипта? Часто ці два фактори обернено пропорційні – тобто ми можемо розвантажити пам’ять за рахунок CPU і навпаки.

Асинхронної моделі виконання (многопроцессовые або багатопотокові програми PHP) споживання CPU і пам’яті важливі фактори. У стандартній архітектурі PHP вони стають проблемою, коли один із факторів досягає обмежень сервера.

Всередині PHP вимірювати споживання CPU непрактично. Якщо ви хочете зосередитися на цій галузі, спробуйте використовувати щось типу top, Ubuntu або macOS. У Windows спробуйте використовувати Linux Subsystem, щоб використовувати top в Ubuntu.

В рамках уроку ми будемо вимірювати споживання пам’яті. Ми подивимося, скільки пам’яті використовується в «звичайних» скриптах. Проведемо пару стратегій оптимізації і виміряємо їх. В кінці я хочу, щоб ви могли робити утворений вибір.

Методи, які ми будемо використовувати для вимірювання споживання пам’яті:

// formatBytes is taken from the php.net documentation
memory_get_peak_usage();
function formatBytes($bytes, $precision = 2) {
$units = array(“b”, “kb”, “мб”, “gb”, “tb”);
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) – 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, $precision) . “” . $units[$pow];
}

Ці функції ми будемо викликати в кінці скриптів і дивитися, який скрипт використовує більше пам’яті.

Які у нас є варіанти?

Ефективно читати файли можна безліччю різних способів. Також є 2 ймовірні сценарії їх використання. Нам може знадобитися зчитувати і обробляти всі дані одночасно, виводити оброблені дані або виконувати інші дії на основі зчитаного. Також нам може знадобитися трансформувати потік даних без необхідності отримувати до нього доступ.

Уявімо, що для першого сценарію ми хочемо мати можливість читати файл і створювати окремі черги фонових завдань кожні 10 000 рядків. Нам знадобиться зберігати в пам’яті мінімум 10 000 рядків і передавати їх в менеджер черги фонової задачі (яку б форму вона не брала).

Для другого сценарію уявімо, що ми хочемо стиснути контент певного великого відповіді від API. Нам неважливо що у відповіді, але нам необхідно, щоб він був у стислій формі.

В обох сценаріях нам необхідно читати великі файли. У першому нам потрібно знати, що в даних. У другому нам неважливо, що в даних. Розберемо ці варіанти…

Читання файлів порядково

Для роботи з файлами існує безліч функцій. Давайте з’єднаємо парочку функцій в нативний файл рідер:

// from memory.php
function formatBytes($bytes, $precision = 2) {
$units = array(“b”, “kb”, “мб”, “gb”, “tb”);
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) – 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, $precision) . “” . $units[$pow];
}
print formatBytes(memory_get_peak_usage());
// from reading-files-line-by-line-1.php
function readTheFile($path) {
$lines = [];
$handle = fopen($path, “r”);
while(!feof($handle)) {
$lines[] = trim(fgets($handle));
}
fclose($handle);
return $lines;
}
readTheFile(“shakespeare.txt”);
require “memory.php”;

Ми читаємо текстовий файл з повним зібранням творів Шекспіра. Текстовий файл важить 5.5 Мб, пікове споживання пам’яті становить 12.8 Мб. Тепер давайте вважаємо всі рядки через генератор:

// from reading-files-line-by-line-2.php
function readTheFile($path) {
$handle = fopen($path, “r”);
while(!feof($handle)) {
yield trim(fgets($handle));
}
fclose($handle);
}
readTheFile(“shakespeare.txt”);
require “memory.php”;

Розмір текстового файлу той же, але пікове споживання пам’яті вже 393Кб. Але це нічого не значить, поки ми почнемо виконувати операції з ліченими даними. Можна розбити документ на шматки по двох порожніх рядків. Ось так:

// from reading-files-line-by-line-3.php
$iterator = readTheFile(“shakespeare.txt”);
$buffer = “”;
foreach ($iterator as $iteration) {
preg_match(“/\n{3}/”, $buffer, $matches);
if (count($matches)) {
print “.”;
$buffer = “”;
} else {
$buffer .= $iteration . PHP_EOL;
}
}
require “memory.php”;

Як думаєте, скільки тепер пам’яті використовується? Здивуєтеся, коли дізнаєтеся, що незважаючи на розбивка документа на 1 216 шматків, у нас все одно використовується 459Кб пам’яті? Природа генераторів така, що найбільше пам’яті використовується, коли зберігається найбільший шматок тексту в ітерації. У нас найбільший шматок становить 101 985 символів.

Я вже писав про прискорення продуктивності з допомогою генераторів і бібліотеці ітераторів від Nikita Popov. Можете почитати, якщо цікаво!

У генераторів є інше застосування, але наш приклад відмінно підходить для швидкого читання великих файлів. Для роботи з даними генератори, можливо, підходять найкраще.

Сполучення між файлами

В ситуації, коли нам не потрібно працювати з даними, ми можемо передавати дані файлу в інший файл. Процес називається piping (мабуть, тому що ми не знаємо, що всередині «труби», але знаємо що на її кінцях… поки труба непрозора, звичайно!). Для цього нам знадобляться потокові методи. Давайте спочатку напишемо скрипт передачі даних з одного файлу в інший, щоб виміряти споживання пам’яті:

// from piping-files-1.php
file_put_contents(
“piping-files-1.txt”, file_get_contents(“shakespeare.txt”)
);
require “memory.php”;

Не дивно, що цей скрипт використовує трохи більше пам’яті для запуску, ніж текстовий файл, який він копіює. Це відбувається тому, що метод повинен вважати (і зберігати) вміст файлу в пам’ять, поки дані не запишуться в новий файл. З маленькими файлами все буде добре. З великими файлами не дуже…

Давайте спробуємо передати дані з одного файлу в інший:

// from piping-files-2.php
$handle1 = fopen(“shakespeare.txt”, “r”);
$handle2 = fopen(“piping-files-2.txt”, “w”);
stream_copy_to_stream($handle1, $handle2);
fclose($handle1);
fclose($handle2);
require “memory.php”;

Код трохи дивний. Ми відкриваємо обробники в обох файлах, перший в режимі читання, другий в режимі запису. Далі дані копіюються з першого файлу у другій. Наприкінці обидва файли закриваються. Можливо, ви здивуєтеся, використовується 393Кб пам’яті.

Знайоме. Не стільки генератор використовував для зберігання при зчитуванні порядково? Обсяг пам’яті такої, тому що другий аргумент в fgets визначає кількість байт зчитування для кожного рядка (за замовчуванням -1 або поки не дійде до нового рядка).

Третій аргумент stream_copy_to_stream точно такий же параметр (з тими ж значеннями за замовчуванням). stream_copy_to_stream читає з одного потоку по рядках і пише в інший потік. Частини, де генератор дає значення, пропускаються, так як нам не потрібно працювати зі значенням.

Передавати текст нам незручно, тому давайте подумаємо про інших прикладах. Наприклад, ми хочемо вивести зображення з CDN, як свого роду перенаправленный рауса програми. Це можна проілюструвати схожим кодом:

// from piping-files-3.php
file_put_contents(
“piping-files-3.jpeg”, file_get_contents(
“https://github.com/assertchris/uploads/raw/master/rick.jpg”
)
);
// …or write this straight to stdout, if we don’t need the memory info
require “memory.php”;

Уявіть, що рауса додатки привів нас до цього коду. Але замість отримання файлу з локального сховища ми хочемо отримати його з CDN. Ми можемо замінити file_get_contents на щось більш елегантне (Guzzle), але всередині все те ж саме.

Пам’яті використовується для зображення) близько 581Кб. Так як же передавати зображення через потік?

// from piping-files-4.php
$handle1 = fopen(
“https://github.com/assertchris/uploads/raw/master/rick.jpg”, “r”
);
$handle2 = fopen(
“piping-files-4.jpeg”, “w”
);
// …or write this straight to stdout, if we don’t need the memory info
stream_copy_to_stream($handle1, $handle2);
fclose($handle1);
fclose($handle2);
require “memory.php”;

Споживання пам’яті трохи менше 400Кб, але результат той же. Якщо нам не потрібна інформація про пам’яті, можна було б виводити по-старому. Для цього в PHP є простий спосіб:

$handle1 = fopen(
“https://github.com/assertchris/uploads/raw/master/rick.jpg”, “r”
);
$handle2 = fopen(
“php://stdout”, “w”
);
stream_copy_to_stream($handle1, $handle2);
fclose($handle1);
fclose($handle2);
// require “memory.php”;

Інші потоки

Ми можемо поєднувати інші потік та/або писати в них і/або читати з них:

php://stdin (read-only)

php://stderr (write-only, як php://stdout)

php://input (read-only) дає доступ до тексту запиту

php://output (write-only) дозволяє писати в буфер висновку

php://memory і php://temp (read-write) місця тимчасового зберігання даних. Різниця в тому, що php://temp буде зберігати дані у файловій системі, поки вони не перестрибнуть певну відмітку, а php://memory зберігає дані, поки не скінчиться пам’ять.

Фільтри

З потоками можна використовувати інший трюк – фільтри. Це щось проміжне. Фільтри дає невеликий контроль над даними потік без необхідності їх відкриття. Уявіть, що нам необхідно стиснути shakespeare.txt. Ми можемо використовувати Zip розширення:

// from filters-1.php
$zip = new ZipArchive();
$filename = “filters-1.zip”;
$zip->open($filename, ZipArchive::CREATE);
$zip->addFromString(“shakespeare.txt”, file_get_contents(“shakespeare.txt”));
$zip->close();
require “memory.php”;

Акуратний код, але він працює зі швидкістю 10.75 Мб. З фільтрами можна краще:

// from filters-2.php
$handle1 = fopen(
“php://filter/zlib.deflate/resource=shakespeare.txt”, “r”
);
$handle2 = fopen(
“filters-2.deflated”, “w”
);
stream_copy_to_stream($handle1, $handle2);
fclose($handle1);
fclose($handle2);
require “memory.php”;

У коді використовується фільтр php://filter/zlib.deflate, який читає і стискає контент ресурсу. Далі стислі дані можна передати в інший файл. І операція займає всього 896Кб.

Знаю, формат іншої, і з створенням zip архівів є проблеми. Однак потрібно подумати: якщо ви можете вибрати інший формат і використовувати в 12 разів менше пам’яті, чи варто воно того?

Для розпакування даних можна запустити файл через інший zlib фільтр:

// from filters-2.php
file_get_contents(
“php://filter/zlib.inflate/resource=filters-2.deflated”
);

Потоки докладно розібрані «поняття потоків в PHP» і «Ефективне використання потоків в PHP». Хочете дізнатися щось нове, почитайте.

Налаштування потоків

У fopen і file_get_contents є свій набір опцій за замовчуванням, але їх можна настроювати. Для установки параметрів необхідно створити новий контекст потоку:

// from creating-contexts-1.php
$data = join(“&”, [
“twitter=assertchris”,
]);
$headers = join(“\r\n”, [
“Content-type: application/x-www-form-urlencoded”,
“Content-length:” . strlen($data),
]);
$options = [
“http” => [
“method” => “POST”,
“header”=> $headers,
“content” => $data,
],
];
$context = stream_content_create($options);
$handle = fopen(“https://example.com/register”, “r”, false, $context);
$response = stream_get_contents($handle);
fclose($handle);

У цьому прикладі ми намагаємося відправити POST запит до API. API захищений, але нам все одно потрібно використовувати контекстне властивість http (http і https). Ми задали кілька заголовків і відкрили обробник файлу API. Обробник можна відкрити тільки для читання, так як запис лягає на контекст.

Налаштувати можна масу параметрів, краще подивіться документацію.

Створення кастомних протоколів і фільтрів

Перш ніж ми завершимо, давайте поговоримо про створення кастомних протоколів. Якщо подивитися в документації, то можна знайти приклад класу:

Protocol {
public resource $context;
public __construct ( void )
public __destruct ( void )
public bool dir_closedir ( void )
public bool dir_opendir ( string $path , int $options )
public string dir_readdir ( void )
public bool dir_rewinddir ( void )
public bool mkdir ( string $path , int $mode , int $options )
public bool rename ( string $path_from , string $path_to )
public bool rmdir ( string $path , int $options )
public resource stream_cast ( int $cast_as )
public void stream_close ( void )
public bool stream_eof ( void )
public bool stream_flush ( void )
public bool stream_lock ( int $operation )
public bool stream_metadata ( string $path , int $option , mixed $value )
public bool stream_open ( string $path , string $mode , int $options ,
string &$opened_path )
public string stream_read ( int $count )
public bool stream_seek ( int $offset , int $whence = SEEK_SET )
public bool stream_set_option ( int $option , int $arg1 , int $arg2 )
public array stream_stat ( void )
public int stream_tell ( void )
public bool stream_truncate ( int $new_size )
public int stream_write ( string $data )
public bool unlink ( string $path )
public array url_stat ( string $path , int $flags )
}

Ми не будемо нічого реалізовувати, так як вважаю, що інтерфейс вартий окремого уроку. Багато чого потрібно зробити. Але після цього можна з легкістю зареєструвати наш потік:

if (in_array(“highlight-names”, stream_get_wrappers())) {
stream_wrapper_unregister(“highlight-names”);
}
stream_wrapper_register(“highlight-names”, “HighlightNamesProtocol”);
$highlighted = file_get_contents(“highlight-names://story.txt”);

Точно так само можна створити кастомні потокові фільтри. В документації є приклад класу фільтра:

Filter {
public $filtername;
public $params
public int filter ( resource $in , resource $out , int &$consumed ,
bool $closing )
public void onClose ( void )
public bool onCreate ( void )
}

Зареєструвати можна легко:

$handle = fopen(“story.txt”, “w+”);
stream_filter_append($handle, “highlight-names”, STREAM_FILTER_READ);

highlight-names повинен збігатися з властивістю filtername нового класу фільтра. Також у рядку php://filter/highligh-names/resource=story.txt можна використовувати кастомні фільтри. Набагато легше визначити фільтри, ніж протоколи. Одна з причин – протоколи обробляють операції над директоріями, а фільтри обробляють тільки шматки даних.

Якщо ви вловили суть, рекомендую вам поекспериментувати з кастомних протоколами і фільтрами. Якщо ви можете застосовувати фільтри до операцій stream_copy_to_stream, то ваш додаток не буде використовувати пам’ять, навіть при роботі з непристойно великими файлами. Уявіть, що ви пишете фільтр resize-image або encrypt-for-application.

Висновок

З цією проблемою ми стикаємося не так часто, однак її легко отримати при роботі з великими файлами. В асинхронних додатках дуже легко покласти весь сервер, якщо не думати про споживання пам’яті.

Сподіваюся, цей урок показав вам пару нових ідей (або освіжив їх у вашій пам’яті), щоб ви могли подумати, як ефективно читати й писати великі файли. Коли ми познайомимося з потоками і генераторами і перестанемо використовувати функції типу file_get_contents, програми зникне цілий ряд помилок. Хороша мета!