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

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


    Функция tmpfile() создаёт ресурс, так как это делает fopen(), и работает с потоками ввода-вывода STDIO. Это эквивалентно тому, если бы мы открыли поток php://temp для последующей работы с временным файлом. В обоих случаях файл появится во временной папке, которая прописана в php.ini, и будет автоматически удалён по завершению скрипта или досрочно с помощью fclose().


    При работе с php://temp файл будет создан во временной папке когда размер данных перевалит за 2 Мбайт. До этого все записанные данные будут храниться в php://memory. Это ограничение можно обойти, если сразу войти в поток php://temp/maxmemory:0. — PHP

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


    <?php
    
    // Создаём временный файл
    $tmpfile = tmpfile();
    
    // Извлекаем метаданные из потока
    $data = stream_get_meta_data($tmpfile);
    
    /* ... */

    Какие значения возвращает 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]          => home\user\temp\phpDC08.tmp
    )

    В случае с php://temp мы никак не сможем получить URI из метаданных, хотя файл по факту будет создан во временной папке, если его вес превысит 2 Мбайт. Другого способа узнать где физически хранится временный файл и под каким именем при работе с потоками не существует.


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


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


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


    <?php
    
    // Создаст файл во временной папке
    $tmpfile = tempnam(sys_get_temp_dir(), 'php');
    
    /* ... */

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


    <?php
    
    class tmpfile
    {
        public $filename;
    
        public function __construct()
        {
            $this->filename = tempnam(sys_get_temp_dir(), 'php');
        }
    
        public function __destruct()
        {
            @unlink($this->filename);
        }
    
        public function __toString()
        {
            return $this->filename;
        }
    }
    
    // Создаём временный файл
    $tmpfile = new tmpfile;
    
    // Работаем как с обычным файлом
    file_put_contents($tmpfile, 'Hello, world!');
    
    /* ... */

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


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

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


    <?php
    
    class tmpfile
    {
        public $filename;
    
        public function __construct()
        {
            $this->filename = tempnam(sys_get_temp_dir(), 'php');
    
            register_shutdown_function(function () {
                @unlink($this->filename);
            });
        }
    
        public function __toString()
        {
            return $this->filename;
        }
    }
    
    /* ... */

    Такой подход позволяет создать временный файл без использования tmpfile() или php://temp, что в ООП очень удобно. Стандартные способы предпочтительнее для решения локальных задач, где вся логика инкапсулирована в одном методе или классе.


    В итоге получился класс для работы с временным файлом. Исходники я выложил в репозитории на Гитхабе image denisyukphp/tmpfile и добавил в класс поддержку CRUD-операций. Методы для записи и чтения являются обёртками для file_put_contents() и file_get_contents(). Подключить в свой проект можно через Composer.


    Посмотреть примеры
    <?php
    
    require __DIR__ . '/vendor/autoload.php';
    
    // Создать временный файл
    $tmpfile = new tmpfile;
    
    // Записать в файл
    $tmpfile->write('Hello, world!');
    
    // Прочитать часть файла
    $tmpfile->read(7, 5);
    
    // Передать имя файла в объект
    new SplFileInfo($tmpfile);
    
    // Переместить в другую папку
    rename($tmpfile, __DIR__ . '/data.txt');
    
    // Досрочно удалить временный файл
    $tmpfile->delete();
    
    /* ... */

    Репозиторий на Github
    Проект на Packagist

    Поделиться публикацией
    Комментарии 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

                А вы проверяли? Файл не должен удалиться, вы же замыкание сделали в конструкторе.

                  0

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

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

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