MidТеория11 min

SPL Работа с файлами

SplFileInfo, SplFileObject, DirectoryIterator и практические примеры обработки файлов

SPL предоставляет объектно-ориентированный интерфейс для работы с файлами и каталогами. Вместо процедурных функций (fopen, fread, fgets) можно использовать классы SplFileInfo и SplFileObject, которые инкапсулируют все операции с файлами.

SplFileInfo -- информация о файле

SplFileInfo -- это объектная обёртка над информацией о файле или каталоге. Она не открывает файл для чтения/записи, а только предоставляет метаданные.

<?php
declare(strict_types=1);

$info = new SplFileInfo('/var/www/app/src/Controller/UserController.php');

// Basic information
echo $info->getFilename();    // UserController.php
echo $info->getBasename();    // UserController.php
echo $info->getBasename('.php'); // UserController (without extension)
echo $info->getExtension();   // php

// Path information
echo $info->getPath();        // /var/www/app/src/Controller
echo $info->getPathname();    // /var/www/app/src/Controller/UserController.php
echo $info->getRealPath();    // /var/www/app/src/Controller/UserController.php (resolved symlinks)

// Type checks
var_dump($info->isFile());      // true
var_dump($info->isDir());       // false
var_dump($info->isLink());      // false (not a symlink)
var_dump($info->isReadable());  // true
var_dump($info->isWritable());  // true
var_dump($info->isExecutable()); // false

// Size and time
echo $info->getSize();         // 2048 (bytes)
echo $info->getMTime();        // 1708531200 (Unix timestamp of last modification)
echo $info->getATime();        // 1708531500 (last access time)
echo $info->getCTime();        // 1708531200 (inode change time on Unix)

// Type and permissions
echo $info->getType();         // "file" or "dir" or "link"
echo $info->getPerms();        // 33188 (octal 0100644)
echo decoct($info->getPerms()); // 100644

// Owner
echo $info->getOwner();        // 1000 (UID)
echo $info->getGroup();        // 1000 (GID)

SplFileInfo при работе с несуществующими файлами

<?php
declare(strict_types=1);

$info = new SplFileInfo('/path/to/nonexistent.txt');

// These work without the file existing:
echo $info->getFilename();   // nonexistent.txt
echo $info->getExtension();  // txt
echo $info->getPath();       // /path/to

// These will fail or return false:
var_dump($info->isFile());    // false
var_dump($info->isDir());     // false
var_dump($info->getRealPath()); // false (file doesn't exist)
// $info->getSize();  // RuntimeException: SplFileInfo::getSize(): stat failed

Важно: SplFileInfo не проверяет существование файла при создании. Методы, требующие доступа к файловой системе (getSize(), getMTime()), бросят RuntimeException для несуществующих файлов.

SplFileObject -- чтение и запись файлов

SplFileObject расширяет SplFileInfo и предоставляет полноценный интерфейс для работы с содержимым файлов. Он реализует Iterator, что позволяет использовать файл в foreach.

Построчное чтение

<?php
declare(strict_types=1);

$file = new SplFileObject('/var/log/app.log');

// Read line by line with foreach
foreach ($file as $lineNumber => $line) {
    echo sprintf('[%d] %s', $lineNumber + 1, $line);
}

// Read single line
$file->rewind();
$firstLine = $file->current();
echo $firstLine;

// Move to specific line
$file->seek(9); // Go to line 10 (0-indexed)
echo $file->current();

Флаги SplFileObject

Флаги управляют поведением при чтении:

<?php
declare(strict_types=1);

$file = new SplFileObject('/path/to/data.txt');

// DROP_NEW_LINE — strip newline characters from end of line
$file->setFlags(SplFileObject::DROP_NEW_LINE);

// SKIP_EMPTY — skip empty lines during iteration
$file->setFlags(SplFileObject::SKIP_EMPTY);

// READ_AHEAD — read on rewind/next to make hasChildren() work
$file->setFlags(SplFileObject::READ_AHEAD);

// Combine flags with bitwise OR
$file->setFlags(
    SplFileObject::DROP_NEW_LINE
    | SplFileObject::SKIP_EMPTY
    | SplFileObject::READ_AHEAD,
);

// Now iteration skips empty lines and strips newlines
foreach ($file as $line) {
    echo $line . PHP_EOL; // No trailing \n, no empty lines
}

Чтение CSV-файлов

<?php
declare(strict_types=1);

// Read CSV with SplFileObject
$csv = new SplFileObject('/data/users.csv');
$csv->setFlags(
    SplFileObject::READ_CSV
    | SplFileObject::SKIP_EMPTY
    | SplFileObject::DROP_NEW_LINE,
);

// Default delimiter is comma, but can be changed:
$csv->setCsvControl(
    separator: ';',    // Delimiter
    enclosure: '"',    // Enclosure character
    escape: '\\',      // Escape character
);

// Read header
$csv->rewind();
$headers = $csv->current();
$csv->next();

// Read data rows
foreach ($csv as $row) {
    if ($row === [null]) {
        continue; // Skip malformed lines
    }

    // Map headers to values
    $record = array_combine($headers, $row);
    echo sprintf(
        '%s <%s>',
        $record['name'],
        $record['email'],
    ) . PHP_EOL;
}

Запись файлов

<?php
declare(strict_types=1);

// Write text file
$file = new SplFileObject('/tmp/output.txt', 'w');

$file->fwrite("First line\n");
$file->fwrite("Second line\n");
$file->fwrite("Third line\n");

// Write CSV
$csvOut = new SplFileObject('/tmp/export.csv', 'w');

// Write header
$csvOut->fputcsv(['id', 'name', 'email']);

// Write data
$users = [
    [1, 'Alice', '[email protected]'],
    [2, 'Bob', '[email protected]'],
    [3, 'Charlie', '[email protected]'],
];

foreach ($users as $user) {
    $csvOut->fputcsv($user);
}

Блокировка файлов (flock)

<?php
declare(strict_types=1);

$file = new SplFileObject('/tmp/counter.txt', 'c+');

// Acquire exclusive lock (blocking)
if ($file->flock(LOCK_EX)) {
    // Read current value
    $file->rewind();
    $counter = (int) $file->fgets();

    // Increment
    $counter++;

    // Write new value
    $file->rewind();
    $file->ftruncate(0);
    $file->fwrite((string) $counter);

    // Release lock
    $file->flock(LOCK_UN);

    echo "Counter: {$counter}" . PHP_EOL;
} else {
    echo 'Could not acquire lock' . PHP_EOL;
}

// Non-blocking lock attempt
$file2 = new SplFileObject('/tmp/resource.lock', 'c+');

if ($file2->flock(LOCK_EX | LOCK_NB)) {
    echo 'Lock acquired, processing...' . PHP_EOL;
    // ... work ...
    $file2->flock(LOCK_UN);
} else {
    echo 'Resource is busy, try later' . PHP_EOL;
}

Позиционирование в файле

<?php
declare(strict_types=1);

$file = new SplFileObject('/path/to/data.bin', 'r');

// Seek to byte position
$file->fseek(100);       // Go to byte 100
$file->fseek(50, SEEK_CUR); // Forward 50 bytes from current position
$file->fseek(-20, SEEK_END); // 20 bytes before end

// Read specific number of bytes
$data = $file->fread(1024); // Read 1024 bytes

// Get current byte position
$position = $file->ftell();

// Go to specific line (for text files)
$file->seek(5); // Go to line 6 (0-indexed)

SplTempFileObject -- временные файлы

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

<?php
declare(strict_types=1);

// Create temp file in memory (up to 5MB, then spills to disk)
$temp = new SplTempFileObject(5 * 1024 * 1024);

// Use like regular SplFileObject
$temp->fwrite("Temporary data line 1\n");
$temp->fwrite("Temporary data line 2\n");
$temp->fwrite("Temporary data line 3\n");

// Read back
$temp->rewind();
foreach ($temp as $line) {
    echo $line;
}

// When $temp goes out of scope, the file is deleted

Использование для промежуточных данных

<?php
declare(strict_types=1);

final class CsvTransformer
{
    /**
     * Transform CSV data and return as SplTempFileObject.
     * Keeps everything in memory for small datasets.
     */
    public function transform(SplFileObject $input, callable $rowMapper): SplTempFileObject
    {
        $output = new SplTempFileObject(10 * 1024 * 1024); // 10MB in-memory limit

        $input->setFlags(
            SplFileObject::READ_CSV
            | SplFileObject::SKIP_EMPTY
            | SplFileObject::DROP_NEW_LINE,
        );

        foreach ($input as $row) {
            if ($row === [null]) {
                continue;
            }

            $transformed = $rowMapper($row);
            if ($transformed !== null) {
                $output->fputcsv($transformed);
            }
        }

        $output->rewind();

        return $output;
    }
}

$transformer = new CsvTransformer();
$input = new SplFileObject('/data/raw.csv');

$result = $transformer->transform($input, function (array $row): ?array {
    // Skip rows with empty name
    if (empty($row[1])) {
        return null;
    }

    // Uppercase the name (column 1)
    $row[1] = strtoupper($row[1]);

    return $row;
});

// Read transformed data
foreach ($result as $row) {
    echo implode(', ', $row) . PHP_EOL;
}

DirectoryIterator -- перебор каталога

<?php
declare(strict_types=1);

$dir = new DirectoryIterator('/var/www/app/src');

foreach ($dir as $fileInfo) {
    /** @var DirectoryIterator $fileInfo */

    // Skip . and ..
    if ($fileInfo->isDot()) {
        continue;
    }

    $type = $fileInfo->isDir() ? 'DIR ' : 'FILE';
    $size = $fileInfo->isFile() ? $fileInfo->getSize() : 0;

    echo sprintf(
        '[%s] %-30s %s',
        $type,
        $fileInfo->getFilename(),
        $fileInfo->isFile() ? formatSize($size) : '',
    ) . PHP_EOL;
}

function formatSize(int $bytes): string
{
    $units = ['B', 'KB', 'MB', 'GB'];
    $power = $bytes > 0 ? (int) floor(log($bytes, 1024)) : 0;

    return sprintf('%.2f %s', $bytes / (1024 ** $power), $units[$power]);
}

Предупреждение: DirectoryIterator возвращает один и тот же объект при каждой итерации. Если вы собираете файлы в массив, используйте $fileInfo->getFileInfo() или clone $fileInfo:

<?php
declare(strict_types=1);

$dir = new DirectoryIterator('/tmp');

// WRONG — all entries will reference the last file
$wrong = [];
foreach ($dir as $item) {
    $wrong[] = $item; // Same object!
}

// CORRECT — clone each entry
$correct = [];
foreach ($dir as $item) {
    if (!$item->isDot()) {
        $correct[] = clone $item;
    }
}

RecursiveDirectoryIterator -- рекурсивный обход

<?php
declare(strict_types=1);

$dir = new RecursiveDirectoryIterator(
    '/var/www/app/src',
    RecursiveDirectoryIterator::SKIP_DOTS
    | RecursiveDirectoryIterator::UNIX_PATHS,
);

$iterator = new RecursiveIteratorIterator(
    $dir,
    RecursiveIteratorIterator::SELF_FIRST,
);

foreach ($iterator as $path => $fileInfo) {
    /** @var SplFileInfo $fileInfo */
    $depth = $iterator->getDepth();
    $indent = str_repeat('  ', $depth);

    if ($fileInfo->isDir()) {
        echo "{$indent}[{$fileInfo->getFilename()}]" . PHP_EOL;
    } else {
        echo "{$indent}{$fileInfo->getFilename()} ({$fileInfo->getSize()} bytes)" . PHP_EOL;
    }
}

Ограничение глубины рекурсии

<?php
declare(strict_types=1);

$dir = new RecursiveDirectoryIterator('/var/www/app', RecursiveDirectoryIterator::SKIP_DOTS);
$iterator = new RecursiveIteratorIterator($dir);

// Limit recursion depth to 2 levels
$iterator->setMaxDepth(2);

foreach ($iterator as $file) {
    echo $file->getPathname() . PHP_EOL;
}

Практические примеры

CSV-импортёр с валидацией

<?php
declare(strict_types=1);

final class CsvImporter
{
    /** @var list<string> */
    private array $errors = [];

    private int $imported = 0;

    /**
     * @param array<string, int> $columnMap Column name => CSV index
     * @param list<callable> $validators List of validators (row => bool)
     */
    public function __construct(
        private readonly string $filePath,
        private readonly array $columnMap,
        private readonly array $validators = [],
        private readonly bool $hasHeader = true,
    ) {}

    /**
     * Import CSV and yield valid rows as associative arrays.
     *
     * @return Generator<int, array<string, string>>
     */
    public function import(): Generator
    {
        $file = new SplFileObject($this->filePath);
        $file->setFlags(
            SplFileObject::READ_CSV
            | SplFileObject::SKIP_EMPTY
            | SplFileObject::DROP_NEW_LINE
            | SplFileObject::READ_AHEAD,
        );

        $lineNumber = 0;

        foreach ($file as $row) {
            $lineNumber++;

            // Skip header row
            if ($this->hasHeader && $lineNumber === 1) {
                continue;
            }

            // Skip malformed rows
            if ($row === [null] || count($row) < count($this->columnMap)) {
                $this->errors[] = "Line {$lineNumber}: insufficient columns";
                continue;
            }

            // Map columns to named keys
            $mapped = [];
            foreach ($this->columnMap as $name => $index) {
                $mapped[$name] = trim($row[$index] ?? '');
            }

            // Validate
            $valid = true;
            foreach ($this->validators as $validator) {
                if (!$validator($mapped, $lineNumber)) {
                    $valid = false;
                    break;
                }
            }

            if ($valid) {
                $this->imported++;
                yield $lineNumber => $mapped;
            }
        }
    }

    public function getImportedCount(): int
    {
        return $this->imported;
    }

    /** @return list<string> */
    public function getErrors(): array
    {
        return $this->errors;
    }
}

// Usage
$importer = new CsvImporter(
    filePath: '/data/users.csv',
    columnMap: ['name' => 0, 'email' => 1, 'age' => 2],
    validators: [
        function (array $row, int $line) use (&$importer): bool {
            if (empty($row['email']) || !filter_var($row['email'], FILTER_VALIDATE_EMAIL)) {
                return false;
            }
            return true;
        },
        function (array $row, int $line): bool {
            $age = (int) $row['age'];
            return $age >= 0 && $age <= 150;
        },
    ],
);

foreach ($importer->import() as $lineNumber => $user) {
    echo sprintf(
        'Line %d: %s <%s>, age %s',
        $lineNumber,
        $user['name'],
        $user['email'],
        $user['age'],
    ) . PHP_EOL;
}

echo sprintf(
    'Imported: %d, Errors: %d',
    $importer->getImportedCount(),
    count($importer->getErrors()),
) . PHP_EOL;

Парсер лог-файлов

<?php
declare(strict_types=1);

final readonly class LogEntry
{
    public function __construct(
        public \DateTimeImmutable $timestamp,
        public string $level,
        public string $channel,
        public string $message,
        public array $context = [],
    ) {}
}

final class LogFileParser
{
    private const string PATTERN = '/^\[(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}[^\]]*)\] (\w+)\.(\w+): (.+)$/';

    /**
     * Parse log file lazily.
     *
     * @return Generator<int, LogEntry>
     */
    public function parse(string $filePath): Generator
    {
        $file = new SplFileObject($filePath);
        $file->setFlags(
            SplFileObject::DROP_NEW_LINE
            | SplFileObject::SKIP_EMPTY
            | SplFileObject::READ_AHEAD,
        );

        $lineNumber = 0;

        foreach ($file as $line) {
            $lineNumber++;

            if (!preg_match(self::PATTERN, $line, $matches)) {
                continue;
            }

            try {
                $timestamp = new \DateTimeImmutable($matches[1]);
            } catch (\Exception) {
                continue;
            }

            yield $lineNumber => new LogEntry(
                timestamp: $timestamp,
                channel: $matches[2],
                level: $matches[3],
                message: $matches[4],
            );
        }
    }

    /**
     * Get only ERROR entries from the last N lines.
     *
     * @return Generator<int, LogEntry>
     */
    public function getRecentErrors(string $filePath, int $lastLines = 1000): Generator
    {
        $file = new SplFileObject($filePath);
        $file->setFlags(SplFileObject::DROP_NEW_LINE | SplFileObject::SKIP_EMPTY);

        // Seek to end to find total line count
        $file->seek(PHP_INT_MAX);
        $totalLines = $file->key();

        $offset = max(0, $totalLines - $lastLines);
        $limited = new LimitIterator($file, $offset);

        foreach ($limited as $line) {
            if (!preg_match(self::PATTERN, $line, $matches)) {
                continue;
            }

            if (strtoupper($matches[3]) !== 'ERROR') {
                continue;
            }

            try {
                yield new LogEntry(
                    timestamp: new \DateTimeImmutable($matches[1]),
                    channel: $matches[2],
                    level: $matches[3],
                    message: $matches[4],
                );
            } catch (\Exception) {
                continue;
            }
        }
    }
}

$parser = new LogFileParser();

// Parse entire file lazily
foreach ($parser->parse('/var/log/app/app.log') as $lineNum => $entry) {
    if ($entry->level === 'ERROR') {
        echo sprintf(
            '[%s] %s: %s',
            $entry->timestamp->format('Y-m-d H:i:s'),
            $entry->channel,
            $entry->message,
        ) . PHP_EOL;
    }
}

Утилита резервного копирования файлов

<?php
declare(strict_types=1);

final class FileBackup
{
    /** @var list<string> */
    private array $log = [];

    /**
     * @param list<string> $extensions Only backup files with these extensions
     * @param list<string> $excludeDirs Skip these directory names
     */
    public function __construct(
        private readonly string $sourceDir,
        private readonly string $backupDir,
        private readonly array $extensions = [],
        private readonly array $excludeDirs = ['.git', 'vendor', 'node_modules'],
    ) {}

    /**
     * @return array{copied: int, skipped: int, errors: int, totalSize: int}
     */
    public function backup(): array
    {
        $stats = ['copied' => 0, 'skipped' => 0, 'errors' => 0, 'totalSize' => 0];

        $source = new RecursiveDirectoryIterator(
            $this->sourceDir,
            RecursiveDirectoryIterator::SKIP_DOTS,
        );

        // Filter out excluded directories
        $filtered = new RecursiveCallbackFilterIterator(
            $source,
            function (SplFileInfo $file): bool {
                if ($file->isDir()) {
                    return !in_array($file->getFilename(), $this->excludeDirs, true);
                }

                if ($this->extensions !== []) {
                    return in_array(
                        strtolower($file->getExtension()),
                        $this->extensions,
                        true,
                    );
                }

                return true;
            },
        );

        $iterator = new RecursiveIteratorIterator(
            $filtered,
            RecursiveIteratorIterator::SELF_FIRST,
        );

        foreach ($iterator as $item) {
            /** @var SplFileInfo $item */
            $relativePath = substr($item->getPathname(), strlen($this->sourceDir));
            $targetPath = $this->backupDir . $relativePath;

            if ($item->isDir()) {
                if (!is_dir($targetPath)) {
                    mkdir($targetPath, 0755, true);
                }
                continue;
            }

            // Skip if backup is newer or same
            if (file_exists($targetPath) && filemtime($targetPath) >= $item->getMTime()) {
                $stats['skipped']++;
                continue;
            }

            // Ensure target directory exists
            $targetDir = dirname($targetPath);
            if (!is_dir($targetDir)) {
                mkdir($targetDir, 0755, true);
            }

            if (copy($item->getPathname(), $targetPath)) {
                $stats['copied']++;
                $stats['totalSize'] += $item->getSize();
                $this->log[] = "Copied: {$relativePath}";
            } else {
                $stats['errors']++;
                $this->log[] = "Error: {$relativePath}";
            }
        }

        return $stats;
    }

    /** @return list<string> */
    public function getLog(): array
    {
        return $this->log;
    }
}

$backup = new FileBackup(
    sourceDir: '/var/www/app/src',
    backupDir: '/backups/app-' . date('Y-m-d'),
    extensions: ['php', 'yaml', 'twig'],
);

$stats = $backup->backup();
echo sprintf(
    'Backup complete: %d copied, %d skipped, %d errors, %.2f MB',
    $stats['copied'],
    $stats['skipped'],
    $stats['errors'],
    $stats['totalSize'] / 1048576,
) . PHP_EOL;

Чтение больших файлов порциями

<?php
declare(strict_types=1);

/**
 * Read large file in chunks without loading it entirely into memory.
 *
 * @return Generator<int, string> Yields chunks of specified size
 */
function readChunks(string $filePath, int $chunkSize = 8192): Generator
{
    $file = new SplFileObject($filePath, 'r');

    while (!$file->eof()) {
        $chunk = $file->fread($chunkSize);
        if ($chunk !== false && $chunk !== '') {
            yield $chunk;
        }
    }
}

// Calculate file hash without loading entire file
$hashContext = hash_init('sha256');

foreach (readChunks('/data/large-file.dat', 65536) as $chunk) {
    hash_update($hashContext, $chunk);
}

$hash = hash_final($hashContext);
echo "SHA-256: {$hash}" . PHP_EOL;

Запомни: SplFileObject реализует интерфейс Iterator, что позволяет использовать файл в foreach, LimitIterator, RegexIterator и других SPL-итераторах. Флаги READ_CSV, SKIP_EMPTY, DROP_NEW_LINE и READ_AHEAD управляют поведением при чтении. Для построчного чтения больших файлов SplFileObject потребляет минимум памяти -- в отличие от file(), которая загружает весь файл.


Проверь себя

5 из 10

Какой флаг `RecursiveDirectoryIterator` пропускает `.` и `..`?

Как ограничить глубину рекурсии при использовании `RecursiveIteratorIterator`?

Как `SplFileObject::seek(5)` и `SplFileObject::fseek(5)` отличаются?

Почему нельзя просто собрать элементы `DirectoryIterator` в массив через `$arr[] = $item`?

Какой класс SPL используется для получения информации о файле без его открытия?