Когда 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

Поделиться с друзьями
-->

Комментарии (15)


  1. Greendq
    24.01.2017 14:01
    +11

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


  1. rPman
    24.01.2017 14:22

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

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


    1. denisyukphp
      25.01.2017 20:18
      +1

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


      1. rPman
        25.01.2017 22:09
        +1

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


        1. denisyukphp
          25.01.2017 23:20

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


      1. oxidmod
        26.01.2017 00:59

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


        1. denisyukphp
          26.01.2017 12:15

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


          1. oxidmod
            26.01.2017 16:08

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


            1. denisyukphp
              26.01.2017 16:30

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


  1. Eldhenn
    24.01.2017 14:58
    -2

    Вы неправильно готовите временные файлы.


    1. LekaOleg
      24.01.2017 21:32
      +2

      Опишите правильный рецепт!


  1. Alxly
    25.01.2017 12:01
    +2

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


    1. denisyukphp
      25.01.2017 21:05

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


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


  1. serg_deep
    26.01.2017 12:06

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


    1. denisyukphp
      26.01.2017 12:10

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