使用PHP从文件中读取最后几行(即“尾巴”)的最佳方法是什么?


75

在我的PHP应用程序中,我需要从许多文件(主要是日志)的末尾开始读取多行。有时我只需要最后一个,有时我需要数十或数百。基本上,我想像Unixtail 命令一样灵活。

这里有一些关于如何从文件中获取最后一行的问题(但是我需要N行),并且给出了不同的解决方案。我不确定哪一个最好,哪个表现更好。


$file = file('filename.txt'); echo $file[count($file) - 1];
温斯顿2013年

@Winston基本上,这是我的答案中的解决方案#1。检查出来,对于大文件,绝对要避免!
lorenzo-s 2013年

1
PHP尾巴库使此操作变得非常简单:packagist.org/packages/icyapril/tail
mjsa

Answers:


269

方法概述

在互联网上搜索时,我遇到了不同的解决方案。我可以将它们分为三种方法:

  • 天真的使用file()PHP函数;
  • 作弊tail在系统上运行命令;
  • 强大的使用fseek()

我最终选择(或编写)了五个解决方案,一个幼稚的解决方案,一个作弊的解决方案和三个强大的解决方案。

  1. 最简洁的天真解决方案,使用内置数组函数。
  2. 基于tailcommand唯一可能的解决方案有一个大问题:如果tail不可用,它将无法运行,即在非Unix(Windows)或不允许系统功能的受限环境中运行。
  3. 从文件末尾读取单个字节以查找(并计数)换行符的解决方案,请参见此处
  4. 针对大文件进行了优化的多字节缓冲解决方案,请参见 此处
  5. 解决方案4的略微修改版本,其中缓冲区长度是动态的,取决于要检索的行数。

所有解决方案均有效。从某种意义上说,它们可以从任何文件返回任意数量的预期结果(解决方案#1除外,在大型文件的情况下,这可能会破坏PHP的内存限制,什么也不返回)。但是哪个更好?

性能测试

为了回答这个问题,我进行了测试。这些事情就是这样完成的,不是吗?

我准备了一个样本100 KB文件,将/var/log目录中的不同文件合并在一起。然后,我编写了一个PHP脚本,该脚本使用这五个解决方案中的每一个从文件末尾检索1、2,..,10、20,... 100、200,...,1000行。每个测试重复十次(大约5×28×10 = 1400次测试),以毫秒为单位测量平均经过时间

我使用PHP命令行解释器在本地开发计算机(Xubuntu 12.04,PHP 5.3.10、2.70 GHz双核CPU,2 GB RAM)上运行脚本。结果如下:

样本100 KB日志文件的执行时间

解决方案#1和#2似乎更糟。仅当我们需要阅读几行内容时,解决方案3才是好的。解决方案4和5似乎是最好的。 注意动态缓冲区的大小如何优化算法:由于减少了缓冲区,执行时间在几行中要短一些。

让我们尝试更大的文件。如果我们必须读取10 MB的日志文件怎么办?

样本10 MB日志文件的执行时间

现在,解决方案#1更为糟糕:实际上,将整个10 MB文件加载到内存中并不是一个好主意。我也在1MB和100MB文件上运行测试,实际上是相同的情况。

还有小的日志文件?这是一个10 KB文件的图形:

样本10 KB日志文件的执行时间

解决方案#1是现在最好的解决方案!对于PHP来说,将10 KB的内存加载到内存中并不是什么大问题。#4和#5的表现也不错。但这是一个极端的情况:10 KB日志意味着大约150/200行...

您可以在此处下载我的所有测试文件,源和结果 。

最后的想法

强烈建议在一般用例中使用解决方案#5:适用于每种文件大小,并且在读取几行内容时表现特别好。

如果您应该读取大于10 KB的文件,请避免使用解决方案#1

解决方案#2#3 并不是我进行的每个测试的最佳选择:#2永远不会在2毫秒内运行,并且#3受到您要求的行数的严重影响(仅对1或2行有效)。


顺便说一句,如何将代码放在BitBucket或类似的文件中而不是令人讨厌的zip文件中?:p
Svish 2013年

另外...不确定您的优化是否确实必要,呵呵。没有太大的区别。
2013年

6
@Svish代码在GitHub Gist上。如果您正在谈论整个测试文件,我认为没有必要将它们放入存储库中。关于优化:我真的很想专注于性能,因为我不得不非常频繁地使用该代码进行几行读取(少于10)。因此,对我来说似乎不需要很大的缓冲。请注意,轴是对数的:减少几行缓冲就意味着执行时间减少了一半!
lorenzo-s

它将所有行合并为一。我们可以保留换行符吗?
FractalSpace 2015年

12
可能是我见过的最好的SO答案之一。选项,多项测试,结论。你需要一枚奖章。
大卫

5

这是修改后的版本,也可以跳过最后几行:

/**
 * 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;

  if (!$adaptive) $buffer = 4096;
  else {
    // 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);
    $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);
}

4

这也将起作用:

$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();
}

不幸的是,您的测试用例在我的计算机上存在段故障,因此我无法确定其性能。


1
我不知道这SplFileObject堂课,谢谢。不知道为什么测试sigfault会在您的计算机上运行,​​无论如何,我会与10MB文件的更好方法(#5)一起运行它,并且性能不是很好,它可以与shell方法(#2)相提并论。看这里
lorenzo-s

请注意,如果文件中的行少于20行,则使用的第一个解决方案LimitIterator将抛出。基本上,第二个原因将是相同的。OutOfRangeExceptionParameter offset must be >= 0LogicException
乔治·伊万诺夫

1

我喜欢以下方法,但不适用于最大2GB的文件。

<?php
    function lastLines($file, $lines) {
        $size = filesize($file);
        $fd=fopen($file, 'r+');
        $pos = $size;
        $n=0;
        while ( $n < $lines+1 && $pos > 0) {
            fseek($fd, $pos);
            $a = fread($fd, 1);
            if ($a === "\n") {
                ++$n;
            };
            $pos--;
        }
        $ret = array();
        for ($i=0; $i<$lines; $i++) {
            array_push($ret, fgets($fd));
        }
        return $ret;
    }
    print_r(lastLines('hola.php', 4));
?>

1

在阅读完所有这些内容后,我的小复制粘贴解决方案。tail()不会关闭$ fp,因为无论如何您必须使用Ctrl-C将其杀死。usleep可节省您的CPU时间,目前仅在Windows上进行过测试。您需要将此代码放入类中!

/**
 * @param $pathname
 */
private function tail($pathname)
{
    $realpath = realpath($pathname);
    $fp = fopen($realpath, 'r', FALSE);
    $lastline = '';
    fseek($fp, $this->tailonce($pathname, 1, false), SEEK_END);
    do {
        $line = fread($fp, 1000);
        if ($line == $lastline) {
            usleep(50);
        } else {
            $lastline = $line;
            echo $lastline;
        }
    } while ($fp);
}

/**
 * @param $pathname
 * @param $lines
 * @param bool $echo
 * @return int
 */
private function tailonce($pathname, $lines, $echo = true)
{
    $realpath = realpath($pathname);
    $fp = fopen($realpath, 'r', FALSE);
    $flines = 0;
    $a = -1;
    while ($flines <= $lines) {
        fseek($fp, $a--, SEEK_END);
        $char = fread($fp, 1);
        if ($char == "\n") $flines++;
    }
    $out = fread($fp, 1000000);
    fclose($fp);
    if ($echo) echo $out;
    return $a+2;
}

0

另一个功能是,您可以使用正则表达式来分隔项目。用法

$last_rows_array = file_get_tail('logfile.log', 100, array(
  'regex'     => true,          // use regex
  'separator' => '#\n{2,}#',   //  separator: at least two newlines
  'typical_item_size' => 200, //   line length
));

功能:

// public domain
function file_get_tail( $file, $requested_num = 100, $args = array() ){
  // default arg values
  $regex         = true;
  $separator     = null;
  $typical_item_size = 100; // estimated size
  $more_size_mul = 1.01; // +1%
  $max_more_size = 4000;
  extract( $args );
  if( $separator === null )  $separator = $regex ? '#\n+#' : "\n";

  if( is_string( $file ))  $f = fopen( $file, 'rb');
  else if( is_resource( $file ) && in_array( get_resource_type( $file ), array('file', 'stream'), true ))
    $f = $file;
  else throw new \Exception( __METHOD__.': file must be either filename or a file or stream resource');

  // get file size
  fseek( $f, 0, SEEK_END );
  $fsize = ftell( $f );
  $fpos = $fsize;
  $bytes_read = 0;

  $all_items = array(); // array of array
  $all_item_num = 0;
  $remaining_num = $requested_num;
  $last_junk = '';

  while( true ){
    // calc size and position of next chunk to read
    $size = $remaining_num * $typical_item_size - strlen( $last_junk );
    // reading a bit more can't hurt
    $size += (int)min( $size * $more_size_mul, $max_more_size );
    if( $size < 1 )  $size = 1;

    // set and fix read position
    $fpos = $fpos - $size;
    if( $fpos < 0 ){
      $size -= -$fpos;
      $fpos = 0;
    }

    // read chunk + add junk from prev iteration
    fseek( $f, $fpos, SEEK_SET );
    $chunk = fread( $f, $size );
    if( strlen( $chunk ) !== $size )  throw new \Exception( __METHOD__.": read error?");
    $bytes_read += strlen( $chunk );
    $chunk .= $last_junk;

    // chunk -> items, with at least one element
    $items = $regex ? preg_split( $separator, $chunk ) : explode( $separator, $chunk );

    // first item is probably cut in half, use it in next iteration ("junk") instead
    // also skip very first '' item
    if( $fpos > 0 || $items[0] === ''){
      $last_junk = $items[0];
      unset( $items[0] );
    } // … else noop, because this is the last iteration

    // ignore last empty item. end( empty [] ) === false
    if( end( $items ) === '')  array_pop( $items );

    // if we got items, push them
    $num = count( $items );
    if( $num > 0 ){
      $remaining_num -= $num;
      // if we read too much, use only needed items
      if( $remaining_num < 0 )  $items = array_slice( $items, - $remaining_num );
      // don't fix $remaining_num, we will exit anyway

      $all_items[] = array_reverse( $items );
      $all_item_num += $num;
    }

    // are we ready?
    if( $fpos === 0 || $remaining_num <= 0 )  break;

    // calculate a better estimate
    if( $all_item_num > 0 )  $typical_item_size = (int)max( 1, round( $bytes_read / $all_item_num ));
  }

  fclose( $f ); 

  //tr( $all_items );
  return call_user_func_array('array_merge', $all_items );
}

0

对于常规的小型文本文件,仅需一根衬纸,无需担心:

echo join(array_slice(file("path/to/file"), -5));

要定义新行,根据上下文,通常这样更容易:

echo join("\n",array_slice(explode("\n",file_get_contents("path/to/file")), -5));

echo join("<br>",array_slice(explode(PHP_EOL,file_get_contents("path/to/file")), -5));

echo join(PHP_EOL,array_slice(explode("\n",file_get_contents("path/to/file")), -5));
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.