Как создать временный файл на PHP, когда функция tmpfile() не подходит

    Когда PHP-программисту необходимо создать временный файл, он в мануале находит функцию tmpfile() и после изучения примеров начинает думать, как её лучше применить. Так было и со мной, когда мне потребовалось выгрузить данные сразу во временный файл, а не работать с ними через переменную. Но с файлом, созданным таким образом, в дальнейшем неудобно работать в силу того, что tmpfile() возвращает дескриптор, а не ссылку на локальный файл. Давайте немного углубимся в анатомию временного файла и рассмотрим подводные камни, с которыми мне пришлось столкнуться.


    Временные файлы в PHP нужны, например, для загрузки большого количества данных из сети или выгрузки данных из БД. Сохранять мега- или гигабайты дынных в переменной не самая лучшая идея, поскольку потребление памяти интерпретатором и сервером ограничено. Лучшим вариантом является сохранение данных на диске и удаление их по результату обработки. Функция tmpfile() именно так и работает, но в PHP существуют и другие нативные способы, с помощью которых можно организовать работу с временными данными:


    +------------------------+------------------------------------------------------+
    | Способ                 | Описание                                             |
    +------------------------+------------------------------------------------------+
    | php://memory           | Записывает и всегда хранит данные в ОЗУ. С помощью   |
    |                        | fopen() можно работать как с обычным файлом.         |
    |                        | Сохранить данные можно копированием в другой поток   |
    |                        | stream_copy_to_stream() или отправки контента        |
    |                        | stream_get_contents() в другой обработчик.           |
    +------------------------+------------------------------------------------------+
    | php://temp             | Создаёт файл во временной папке, когда размер данных |
    |                        | превышает 2 Мбайт. До этого все записанные данные    |
    |                        | хранятся в ОЗУ. Временная папка определяется         |
    |                        | по результату функции sys_get_temp_dir(), значение   |
    |                        | которой можно переопределить в php.ini, если указать |
    |                        | абсолютный путь в директории sys_temp_dir = "/tmp".  |
    +------------------------+------------------------------------------------------+
    | php://temp/maxmemory:0 | Обнуляет максимальный размер данных в байтах         |
    |                        | для хранения в памяти и с первым байтом создаёт файл |
    |                        | во временной папке. Можно установить свой лимит      |
    |                        | стартового хранения данных в ОЗУ путем добавления    |
    |                        | /maxmemory:NN, где NN — это максимальный размер      |
    |                        | данных в байтах для хранения в памяти перед          |
    |                        | использованием временного файла.                     |
    +------------------------+------------------------------------------------------+
    | tmpfile()              | Создаёт временный файл с уникальным именем в режиме  |
    |                        | чтения и записи r+, а затем возвращает файловый      |
    |                        | указатель. Не отличается от php://temp/maxmemory:0,  |
    |                        | кроме способа вызова. URI временного файла           |
    |                        | можно получить с помощью stream_get_meta_data(), а   |
    |                        | сохранить данные через копирование файла             |
    |                        | функцией copy().                                     |
    +------------------------+------------------------------------------------------+

    Временные данные, созданные через fopen() или tmpfile(), будут уничтожены самим PHP после завершения скрипта или когда вы принудительно закроете ресурс с помощью функции fclose(). Если будет брошено исключение, PHP всё равно уничтожит временные данные, но если вылетит фатальная ошибка или вы просто вызовете exit(), нет гарантий, что временные данные будут удалены.


    Получаем URI временного файла


    У вас может появиться необходимость «придержать» временные данные и поработать с ними на уровне физических файлов. Этого можно достичь, если открыть поток php://temp/maxmemory:0 или вызвать tmpfile(), а затем, до закрытия ресурса, воспользоваться stream_get_meta_data(), чтобы извлечь метаданные и узнать абсолютный путь к файлу для дальнейших манипуляций:


    <?php
    
    $tmpFile = tmpfile();
    
    $metadata = stream_get_meta_data($tmpFile);
    
    print_r($metadata);

    Какие значения возвращает stream_get_meta_data() хорошо описано в документации, но нас больше интересует абсолютный путь к файлу, связанный с потоком. Его можно извлечь по ключу uri из полученного массива:


    Array
    (
        [timed_out]    => false
        [blocked]      => true
        [eof]          => false
        [wrapper_type] => plainfile
        [stream_type]  => STDIO
        [mode]         => r+b
        [unread_bytes] => 0
        [seekable]     => true
        [uri]          => /tmp/phpDC08
    )

    В случае с php://temp/maxmemory:0 мы не сможем получить URI файла из метаданных потока, но фактически файл будет создан во временной папке. Функция stream_get_meta_data() по ключу uri будет возвращать название потока. Поэтому для получения URI временного файла на диске, нужно использовать функцию tmpfile(). Другого способа узнать, где физически хранится временный файл, не существует.


    Существует класс SplTempFileObject, который является всего лишь ООП-обёрткой на потоком php://temp и не более. Данный класс наследуется от SplFileObject, а тот в свою очередь от SplFileInfo. Это значит, что у SplTempFileObject должны быть доступны такие методы, как getFilename(), getPathInfo(), getSize(), но они не отработают так, как ожидается. Начиная с версии 5.3 закрался баг, который возвращает false для вышеперечисленных методов. В версии 7.4 ничего не изменилось.


    Получив абсолютный путь к временному файлу вы можете пользоваться привычными интерфейсами для работы с данными через файл: symfony/filesystem, symfony/http-foundation, thephpleague/flysystem или нативными PHP-функциями.


    Проблемы функции tmpfile()


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


    <?php
    
    $tmpFile = tmpfile();
    
    $metadata = stream_get_meta_data($tmpFile);
    
    rename($metadata['uri'], '/path/to/meow.txt');

    К сожалению, сохранить временный файл через rename() не получится, т. к. tmpfile() для Windows накладывает блокирующий режим и файл будет всё ещё занят другим процессом. В UNIX-системах такой проблемы нет, здесь временный файл можно удалять ещё до закрытия ресурса. Другими словами, для Windows вызов rename() до fclose() приведёт к ошибке:


    Warning: rename(/tmp/phpDC08,/path/to/meow.txt): The process cannot access the file because it is being used by another process. (code: 32) in /usr/test.php on line 9

    Использовать rename() после fclose() не целесообразно, т. к. после закрытия ресурса PHP сразу же удалит временный файл. Тем не менее, мы можем сохранить временный файл через копирование, воспользовавшись функцией copy(). Этот способ рабочий, но при копировании временного файла на сервере будет храниться две копии файла до завершения скрипта.


    Создание временного файла


    Для альтернативного решения создадим свой временный файл, который будет базироваться на функции tempnam(). Функция только создаёт файл с уникальным именем на диске, но автоматическое удаление файла не предусмотрено. Поэтому для его уничтожения нам понадобится написать свой обработчик. Жизненный цикл временного файла будет следующий: создание файла во временной папке -> манипуляции с файлом -> автоматическое удаление.


    <?php
    
    $tmpFile = tempnam(sys_get_temp_dir(), 'php');

    Первым аргументом указывается расположение временной папки через sys_get_temp_dir(), а вторым — префикс в имени файла. Такой файл доступен для чтения и записи только владельцу, т. к. создаётся с правами 0600 (rw-). Для реализации автоматического удаления файла можно перенести дальнейшую логику в класс, где с помощью __destruct() попробуем удалить файл:


    <?php
    
    class TmpFile
    {
        private $filename;
    
        public function __construct()
        {
            $this->filename = tempnam(sys_get_temp_dir(), 'php');
    
            if (!$this->filename) {
                throw new \RuntimeException("tempnam() couldn't create a temp file");
            }
        }
    
        public function __destruct()
        {
            if (file_exists($this->filename)) {
                unlink($this->filename);
            }
        }
    
        public function __toString()
        {
            return $this->filename;
        }
    }

    Объект вернёт ссылку на файл, который создала функция tempnam(), т. к. в классе прописан __toString(). Таким образом, мы избавились от работы с ресурсом. Временный файл будет удалён при освобождении всех ссылок на объект или по завершению скрипта, но до того случая, пока не будет вызвана фатальная ошибка, брошено исключение или вызвана функция exit().


    Полагаться на __destruct() плохая идея, т. к. в языках с автоматическим управлением памятью, нельзя быть уверенным на 100% в том, когда он выполнится. Деструктор не должен оставлять объект в нестабильном состоянии, поэтому в PHP обработчики уничтожения и освобождения объекта отделены друг от друга. Обработчик освобождения вызывается, когда движок полностью уверен, что объект больше нигде не применяется.


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


    <?php
    
    class TmpFile
    {
        private $filename;
    
        public function __construct()
        {
            $this->filename = tempnam(sys_get_temp_dir(), 'php');
    
            if (!$this->filename) {
                throw new \RuntimeException("tempnam() couldn't create a temp file");
            }
    
            register_shutdown_function(function () {
                $this->delete();
            });
        }
    
        private function delete(): void
        {
            if (file_exists($this->filename)) {
                unlink($this->filename);
            }
        }
    
        public function __destruct()
        {
            $this->delete();
        }
    
        public function __toString()
        {
            return $this->filename;
        }
    }

    Класс TmpFile избегает ситуации, когда на него по умолчанию открыт дескриптор. Теперь можно использовать rename() вместо copy() и не бояться, когда при сохранении временных данных в другой файл на диске хранится две копии до завершения скрипта. Главное не держать открытый дескриптор, чтобы rename() наверняка отработала в Windows.


    Также мы получили возможность типизировать временный файл и декларировать его тип через конструктор или методы классов, чтобы быть увереным, что приходит временный файл, а не строка, т. к. в PHP нет валидации на тип resource в сигнатурах.


    Пример с загрузкой файлов


    Ниже реальный юзкейс, в котором функция tmpfile() не подходит в силу уничтожения временного файла после закрытия ресурса или невозможности использовать rename() для перемещения файла. В этом случае нужно было вернуть временный файл в объекте File из пакета symfony/http-foundation в код, у которого строгая зависимость от этого класса File. Код ниже загружает файл как временный, валидирует его и сохраняет на диске:


    <?php
    
    use Symfony\Component\HttpFoundation\File\File;
    
    $uploader = new Uploader();
    
    /** @var File $file */
    $file = $uploader->uploadUrl('https://i.imgur.com/JRorA8V.gif');
    
    if ('image/gif' !== $file->getMimeType()) {
        throw new \Exception("You can't upload image not image/gif MIME-type");
    }
    
    $file->move('/path/to/file.gif');

    Бизнес-логика предполагала валидацию файла на другом уровне и здесь важно было позаботиться об удалении файла в самом начале его пути, если проверка будет провалена. С помощью функции register_shutdown_function() мы можем гарантировать, что временный файл будет удалён, когда скрипт завершится. В сниппете ниже приведён пример того, как был использован класс TmpFile вместо tmpfile():


    <?php
    
    use Symfony\Component\HttpFoundation\File\File;
    
    class Uploader
    {
        public function uploadUrl(string $url): File
        {
            $tmpFile = new TmpFile();
    
            $fh = fopen($tmpFile, 'r+');
            $ch = curl_init($url);
    
            curl_setopt_array($ch, [
                CURLOPT_FILE => $fh,
                CURLOPT_FAILONERROR => true,
                CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
            ]);
    
            curl_exec($ch);
    
            curl_close($ch);
            fclose($fh);
    
            return new File($tmpFile);
        }
    }

    В коде создаётся объект временного файла, открывается на него дескриптор и через cURL выкачивается файл по ссылке. Обработка HTTP-статусов и другие параметры cURL в этом сниппете не указаны. В итоге мы закрываем все дескрипторы и отправляем временный файл в нужной обёртке. Решить этот юзкейс через функцию tmpfile() было бы невозможно.


    Временный файл в CLI и try-finally


    В вебе запросы пользователей живут относительно недолго, но в CLI скрипты могут выполняться бесконечно и гарантировать выполнение функции register_shutdown_function() мы не можем. Скрипт может быть убит на системном уровне или выполнятся так долго, что все временные файлы останутся лежать без их финальной обработки. В консоле лучшим способом удаления временных файлов является использование конструкции try-finally:


    <?php
    
    function createTmpFileContext(\Closure $callback): void
    {
        $filename = tempnam(sys_get_temp_dir(), 'php');
    
        try {
            $callback($filename);
        } finally {
            if (file_exists($filename)) {
                unlink($filename);
            }
        }
    }
    
    createTmpFileContext(function (string $filename) {
        $file = new \SplFileInfo($filename);
    
        file_put_contents($file, 'Meow!');
    
        rename($file, '/path/to/meow.txt');
    });

    Здесь процесс удаления временного файла мы помещаем в блок finally, который выполнится сразу после выхода из коллбека. В долгоживущих консольных приложениях это самый оптимальный способ обработать удаление временного файла, но при фатальных ошибках код до finally не дойдёт, а использование register_shutdown_function() не желательно, поэтому не остаётся ничего другого, как писать валидный код в секции try.


    Чем класс TmpFile отличается от tmpfile()


    Ниже привожу сравнительную таблицу между new TmpFile() и tmpfile(). Основные отличия заключаются в удалении временного файла: TmpFile удаляет файл по завершению скрипта, а tmpfile() — сразу после закрытия ресурса.


    +---------------------------------------+---------------------------------------+
    | new TmpFile()                         | tmpfile()                             |
    +---------------------------------------+---------------------------------------+
    | Создаёт временный файл с уникальным   | Открывает ресурс на временный         |
    | именем и возвращает его URI           | файл, где URI можно получить через    |
    | на диске.                             | stream_get_meta_data().               |
    +---------------------------------------+---------------------------------------+
    | Можно работать как с обычным файлом:  | Возвращает дескриптор файла           |
    | file_exists(), filesize(), unlink()   | аналогичный функции fopen().          |
    | и др.                                 |                                       |
    +---------------------------------------+---------------------------------------+
    | Валидация на тип TmpFile в аргументах | Для типизации нужно написать свою     |
    | методов или конструкторе классов.     | обёртку или использовать сторонний    |
    |                                       | StreamInterface.                      |
    +---------------------------------------+---------------------------------------+
    | Можно сохранить данные путём          | Скопировать можно через copy() по     |
    | перемещения временного файла с        | URI или выгрузить в другой поток      |
    | помощью rename().                     | с помощью stream_copy_to_stream().    |
    +---------------------------------------+---------------------------------------+
    | Поддержка CRUD-операций через         | Чтение и запись возможна через        |
    | библиотеки для работы с файлами или   | функции: stream_get_contents(),       |
    | через нативные функции.               | fwrite(), rewind() и др.              |
    +---------------------------------------+---------------------------------------+

    На основе идей из этой статьи, я написал менеджер для управления временными файлами, который доступен в репозитории image denisyukphp/tmpfile-manager. Менеджер умеет много полезного: 1) настраивать путь к папке с временными файлами; 2) задавать префикс временным файлам; 3) закрывать отрытые ресурсы на временные файлы; 4) автоматически или вручную очищать временные файлы; 5) запускать свой сборщик мусора.


    Посмотреть примеры
    <?php
    
    use TmpFile\TmpFile;
    use TmpFileManager\TmpFileManager;
    use TmpFileManager\ConfigBuilder;
    
    $config = (new ConfigBuilder())
        ->setTmpFileDirectory(sys_get_temp_dir())
        ->setTmpFilePrefix('php')
        ->setUnclosedResourcesCheck(true)
        ->build()
    ;
    
    $tmpFileManager = new TmpFileManager($config);
    
    for ($i = 0; $i < 10; $i++) {
        /** @var TmpFile $tmpFile */
        $tmpFile = $tmpFileManager->createTmpFile();
    
        $fh = fopen($tmpFile, 'r+');
    
        fwrite($fh, random_bytes(1024));
    
        // ...
    }
    
    $tmpFileManager->createTmpFileContext(function (TmpFile $tmpFile) {
        // ...
    });
    
    $tmpFileManager->purge();

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

    Средняя зарплата в IT

    110 000 ₽/мес.
    Средняя зарплата по всем IT-специализациям на основании 8 431 анкеты, за 2-ое пол. 2020 года Узнать свою зарплату
    Реклама
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее

    Комментарии 16

      +11
      Вообще-то, в *nix-подобных системах вы спокойно можете удалить файл, сразу после получения его дескриптора от функции fopen(). И продолжать работать с его дескриптором — по окончании работы вашей программы _любым_ способом — временного файла ни диске не останется.
        0
        я так и не понял, зачем вам понадобился путь до временного файла?

        сама идея, что среда предоставляет вам дескриптор некого файла, размещенного фиг знает где, исключительно для задач временного хранения, может через 5 лет будет норма для временных файлов в системе делать специальное хранилище (пониженная надежность например) а ваше приложение будет ожидать путь до файла, а оно даже монтироваться в файловое дерево не будет.
          +1
          Мне нужно было передать имя временного файла вместе с классом File из Symfony HttpFoundation в объект, в котором чётко прописана зависимость от класса File в конструкторе. Переписать этот объект я не мог. С моей стороны, бизнес-логика предполагала создание временного файла с определёнными данными, а с другой — валидацию и перемещение, где не предусмотрено удаление файла в случае отрицательной проверки. Два слоя приложения разрабатывались разными программистами. Временный файл наполнялся пользовательскими данными.
            +1
            вот это уже реальный кейс.
            а на практике этот путь хоть где то использовался в дальнейшем, или можно было пустую строку туда отправить?
              0

              Да, использовался. Временный файл перемещался из временной папки куда-то на диск для хранения с помощью метода move() или удалялся, если валидацию не проходил. Пустую строку никак нельзя было отправить.

              0

              Не ковырял особо глубоко, но чем вас не устроил UploadedFile из той же Symfony HttpFoundation? Его можно создать и вручную, передав ему путь на временный файл… По идее должно сработать

                0

                Несмотря на то, что UploadedFile наследует File, задача была другая. UploadedFile предполагает загрузку файлов из $_FILES всё таки.

                  0

                  но его можно конструировать и вручную передав путь на файл созданный чем угодно, разве не так?

                    0

                    UploadedFile в любом случае не подойдёт, так как реализация метода move() отличается от такого же из класса File. UploadedFile строго для $_FILES, посмотрите исходники. Статья не об этом, временный файл можно передать куда угодно и с чем угодно, тут всё зависит от конкретной задачи. Я использовал File, т. к. была явная зависимость в дочернем классе.

            –2
            Вы неправильно готовите временные файлы.
              +2
              Опишите правильный рецепт!
              +2

              Если нужен был ооп почему не пользовали SplTempFileObject, у которого есть помимо всего еще и getPathname, который и возвращает путь к файлу?

                0

                SplTempFileObject является обёрткой для php://temp и php://memory. Метод getPathname() не вернёт URI, а укажет на тот же поток (php://temp). Это всё тот же fopen(), только через объект. SplTempFileObject имеет баги от класса SplFileInfo, который наследует через SplFileObject, т. к. getRealPath() показывает не тот результат, который ожидается.


                Здесь даже не ООП был нужен, а механизм, который может создать c URI временный файл и передать в какой-то класс имя файла. В классе File из Symfony HttpFoundation есть метод move(), который не сможет переместить временный файл, созданный tmpfile(), т. к. тот заблокирован на время работы с потоком. По большому счёту это всё из-за трансферинга временного файла между объектами, которые не работают с потоками, а только с именами файлов.

                  0
                  Благодарю за разъяснения.
                  действительно есть такой баг и уже висит 3 года. проголосовал за него, он всё еще есть в 7.0.х версии.

                  Не часто работаю непосредственно с файлами, потому полезно было узнать.
              • НЛО прилетело и опубликовало эту надпись здесь
                  0

                  Функция register_shutdown_function() регистрирует функцию, которая будет выполнена по завершению скрипта. Свой класс я покрыт тестами. Всё отработает как задумано.

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое