Статьи

Как читать большие файлы с помощью PHP (не убивая ваш сервер)

Мы, разработчики PHP, не часто беспокоимся об управлении памятью. Движок PHP делает звездную работу по очистке после нас, а модель веб-сервера с кратковременным контекстом выполнения означает, что даже самый небрежный код не имеет долговременных эффектов.

В редких случаях нам может потребоваться выйти за пределы этой удобной границы — например, когда мы пытаемся запустить Composer для большого проекта на минимальном VPS, который мы можем создать, или когда нам нужно прочитать большие файлы на столь же маленьком сервере ,

Фрагментированная местность

Это последняя проблема, которую мы рассмотрим в этом уроке.

Код для этого урока можно найти на GitHub .

Измерение успеха

Единственный способ убедиться, что мы делаем какие-либо улучшения в нашем коде, — это измерить плохую ситуацию, а затем сравнить это измерение с другим после того, как мы применили наше исправление. Другими словами, если мы не знаем, насколько «решение» помогает нам (если вообще), мы не можем знать, действительно ли это решение или нет.

Есть два показателя, о которых мы можем заботиться. Первый — использование процессора. Насколько быстрым или медленным является процесс, над которым мы хотим работать? Второе — использование памяти. Сколько памяти занимает выполнение сценария? Они часто обратно пропорциональны — это означает, что мы можем разгрузить использование памяти за счет использования ЦП и наоборот.

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

Непрактично измерять загрузку процессора внутри PHP. Если вы хотите сосредоточиться на этой области, подумайте об использовании чего-то вроде top Для Windows рассмотрите возможность использования подсистемы Linux, чтобы вы могли использовать top

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

Методы, которые мы будем использовать, чтобы увидеть, сколько памяти используется:

 // formatBytes is taken from the php.net documentation

memory_get_peak_usage();

function formatBytes($bytes, $precision = 2) {
    $units = array("b", "kb", "mb", "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];
}

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

Какие у нас варианты?

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

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

Для второго сценария давайте представим, что мы хотим сжать содержимое особенно большого ответа API. Нам все равно, что там написано, но нам нужно убедиться, что оно заархивировано в сжатом виде.

В обоих случаях нам нужно читать большие файлы. Во-первых, нам нужно знать, что это за данные. Во-вторых, нам все равно, что это за данные. Давайте рассмотрим эти варианты …

Чтение файлов, строка за строкой

Есть много функций для работы с файлами. Давайте объединим несколько в наивный читатель файла:

 // from memory.php

function formatBytes($bytes, $precision = 2) {
    $units = array("b", "kb", "mb", "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";

Есть предположения, сколько памяти мы используем сейчас? Удивительно ли, что вы знаете, что, хотя мы разбиваем текстовый документ на 1216 блоков, мы все равно используем только 459 КБ памяти? Учитывая природу генераторов, больше всего памяти мы будем использовать для хранения самого большого фрагмента текста в итерации. В этом случае самый большой кусок составляет 101 985 символов.

Я уже писал о повышении производительности при использовании генераторов и библиотеки Итератора Никиты Попова , так что проверьте это, если хотите увидеть больше!

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

Трубопровод между файлами

В ситуациях, когда нам не нужно работать с данными, мы можем передавать данные файла из одного файла в другой. Это обычно называется трубопроводом (вероятно, потому что мы не видим, что находится внутри трубы, кроме как на каждом конце… до тех пор, пока она непрозрачна, конечно!). Мы можем достичь этого с помощью потоковых методов. Давайте сначала напишем скрипт для передачи из одного файла в другой, чтобы мы могли измерить использование памяти:

 // 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_contentsGuzzle ), но под капотом оно практически не отличается .

Использование памяти (для этого изображения) составляет около 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
  • php://stderr
  • php://input
  • php://output
  • php://memoryphp://temp Разница в том, что php://tempphp://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 ». Если вам нужна другая точка зрения, проверьте это!

Настройка потоков

fopenfile_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 безопасна, но нам все еще нужно использовать свойство контекста httphttphttps Мы устанавливаем несколько заголовков и открываем дескриптор файла для 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-namesfiltername Также можно использовать пользовательские фильтры в строке php://filter/highligh-names/resource=story.txt Гораздо проще определить фильтры, чем определить протоколы. Одна из причин этого заключается в том, что протоколы должны обрабатывать операции с каталогами, тогда как фильтры должны обрабатывать только каждый кусок данных.

Если у вас есть предположение, я настоятельно рекомендую вам поэкспериментировать с созданием пользовательских протоколов и фильтров. Если вы можете применить фильтры к операциям stream_copy_to_stream Представьте себе, что вы пишете фильтр resize-imageencrypt-for-application

Резюме

Хотя это не та проблема, от которой мы часто страдаем, легко работать с большими файлами. В асинхронных приложениях так же просто отключить весь сервер, если мы не заботимся об использовании памяти.

Мы надеемся, что это руководство познакомит вас с несколькими новыми идеями (или освежит вашу память о них), чтобы вы могли больше думать о том, как эффективно читать и писать большие файлы. Когда мы начинаем знакомиться с потоками и генераторами и прекращаем использовать такие функции, как file_get_contents Это кажется хорошей вещью, к которой нужно стремиться!