Как лучше всего в PHP читать последние строки из файла?

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

Здесь есть вопросы о том, как получить единственную последнюю строку из файла (но мне нужны N строк), и были даны различные решения. Я не уверен, какой из них лучший, и который работает лучше.

Обзор методов

Поиск в Интернете я столкнулся с различными решениями. Я могу сгруппировать их по трем подходам:

  • наивные , которые используют функцию file() PHP;
  • обманывающие , которые управляют командой tail в системе;
  • могущественных , которые счастливо перескакивают по открытому файлу с помощью fseek() .

Я закончил тем, что выбрал (или написал) пять решений, наивный , мошеннический и три могущественных .

  1. Наиболее сжатое наивное решение , использующее встроенные функции массива.
  2. Единственное возможное решение, основанное на команде tail , которое имеет небольшую большую проблему: оно не запускается, если tail недоступен, например, в не-Unix (Windows) или в ограниченных средах, которые не позволяют выполнять системные функции.
  3. Решение, в котором отдельные байты считываются с конца поиска файла (и подсчета) символов новой строки, найдены здесь .
  4. Многобайтовое буферизированное решение оптимизировано для больших файлов, найденных здесь .
  5. Немного измененная версия решения №4, в которой длина буфера динамическая, определяется в соответствии с количеством строк для извлечения.

Все решения работают . В том смысле, что они возвращают ожидаемый результат из любого файла и для любого количества строк, которые мы запрашиваем (за исключением решения # 1, которое может нарушать пределы памяти PHP в случае больших файлов, не возвращая ничего). Но какой из них лучше?

Тесты производительности

Чтобы ответить на вопрос, я запускаю тесты. Вот как это делается, не так ли?

Я подготовил образец файла размером 100 КБ, объединяющий разные файлы, найденные в каталоге /var/log . Затем я написал скрипт PHP, который использует каждое из пяти решений для извлечения 1, 2, .., 10, 20, … 100, 200, …, 1000 строк из конца файла. Каждый отдельный тест повторяется десять раз (это примерно 5 × 28 × 10 = 1400 тестов), измеряя среднее истекшее время в микросекундах.

Я запускаю сценарий на своей локальной машине разработки (Xubuntu 12.04, PHP 5.3.10, 2.20 ГГц двухъядерный процессор, 2 ГБ ОЗУ) с использованием интерпретатора командной строки PHP. Вот результаты:

Время выполнения на образце файла журнала 100 КБ

Решение №1 и №2 выглядит хуже. Решение № 3 хорошо, только когда нам нужно прочитать несколько строк. Решения №4 и №5 кажутся лучшими. Обратите внимание, как динамический размер буфера может оптимизировать алгоритм: время выполнения немного меньше для нескольких строк из-за уменьшенного буфера.

Попробуем с большим файлом. Что делать, если мы должны прочитать файл журнала 10 МБ ?

Время выполнения в файле журнала 10 МБ

Теперь решение №1, безусловно, хуже: фактически, загрузка всего 10 МБ-файла в память – это не отличная идея. Я запускаю тесты также на 1MB и 100MB файл, и это практически такая же ситуация.

А для крошечных файлов журналов? Это график для файла размером 10 КБ :

Время выполнения на образце файла журнала 10 КБ

Решение №1 является лучшим сейчас! Загрузка 10 КБ в память не является большой проблемой для PHP. Также # 4 и # 5 работают хорошо. Однако это краевой случай: журнал 10 KB означает что-то вроде строк 150/200 …

Вы можете скачать все мои тестовые файлы, источники и результаты здесь .

Последние мысли

Решение №5 сильно рекомендуется для общего использования: отлично работает с каждым размером файла и особенно хорошо работает при чтении нескольких строк.

Избегайте решения №1, если вы должны читать файлы размером более 10 КБ.

Решение №2 и №3 не являются лучшими для каждого теста, который я запускаю: # 2 никогда не работает менее чем за 2 мс, а # 3 сильно зависит от количества строк, которые вы задаете (работает неплохо только с 1 или 2 линиями ).

Это модифицированная версия, которая также может пропускать последние строки:

 /** * Modified version of http://www.geekality.net/2011/05/28/php-tail-tackling-large-files/ and of https://gist.github.com/lorenzos/1711e81a9162320fde20 * @author Kinga the Witch (Trans-dating.com), Torleif Berger, Lorenzo Stanco * @link http://stackoverflow.com/a/15025877/995958 * @license http://creativecommons.org/licenses/by/3.0/ */ function tailWithSkip($filepath, $lines = 1, $skip = 0, $adaptive = true) { // Open file $f = @fopen($filepath, "rb"); if (@flock($f, LOCK_SH) === false) return false; if ($f === false) return false; // Sets buffer size, according to the number of lines to retrieve. // This gives a performance boost when reading a few lines from the file. $max=max($lines, $skip); if (!$adaptive) $buffer = 4096; else $buffer = ($max < 2 ? 64 : ($max < 10 ? 512 : 4096)); // Jump to last character fseek($f, -1, SEEK_END); // Read it and adjust line number if necessary // (Otherwise the result would be wrong if file doesn't end with a blank line) if (fread($f, 1) == "\n") { if ($skip > 0) { $skip++; $lines--; } } else { $lines--; } // Start reading $output = ''; $chunk = ''; // While we would like more while (ftell($f) > 0 && $lines >= 0) { // Figure out how far back we should jump $seek = min(ftell($f), $buffer); // Do the jump (backwards, relative to where we are) fseek($f, -$seek, SEEK_CUR); // Read a chunk $chunk = fread($f, $seek); // Calculate chunk parameters $count = substr_count($chunk, "\n"); $strlen = mb_strlen($chunk, '8bit'); // Move the file pointer fseek($f, -$strlen, SEEK_CUR); if ($skip > 0) { // There are some lines to skip if ($skip > $count) { $skip -= $count; $chunk=''; } // Chunk contains less new line symbols than else { $pos = 0; while ($skip > 0) { if ($pos > 0) $offset = $pos - $strlen - 1; // Calculate the offset - NEGATIVE position of last new line symbol else $offset=0; // First search (without offset) $pos = strrpos($chunk, "\n", $offset); // Search for last (including offset) new line symbol if ($pos !== false) $skip--; // Found new line symbol - skip the line else break; // "else break;" - Protection against infinite loop (just in case) } $chunk=substr($chunk, 0, $pos); // Truncated chunk $count=substr_count($chunk, "\n"); // Count new line symbols in truncated chunk } } if (strlen($chunk) > 0) { // Add chunk to the output $output = $chunk . $output; // Decrease our line counter $lines -= $count; } } // While we have too many lines // (Because of buffer size we might have read too many) while ($lines++ < 0) { // Find first newline and remove all text before that $output = substr($output, strpos($output, "\n") + 1); } // Close file and return @flock($f, LOCK_UN); fclose($f); return trim($output); } 

Это также будет работать:

 $file = new SplFileObject("/path/to/file"); $file->seek(PHP_INT_MAX); // cheap trick to seek to EoF $total_lines = $file->key(); // last line number // output the last twenty lines $reader = new LimitIterator($file, $total_lines - 20); foreach ($reader as $line) { echo $line; // includes newlines } 

Или без LimitIterator :

 $file = new SplFileObject($filepath); $file->seek(PHP_INT_MAX); $total_lines = $file->key(); $file->seek($total_lines - 20); while (!$file->eof()) { echo $file->current(); $file->next(); } 

К сожалению, ваш тестовый файл segfaults на моей машине, поэтому я не могу сказать, как это работает.