Про работу в php с архивами

December 26, 2007

Переезд дело муторное, хоть в мире реальном, хоть в мире виртуальном. Прошло уже почти три недели с того, как я сменил хостинг и перенес сайт, но до сих пор я нахожу ошибки. Причем там где, казалось бы, ошибки в принципе не могли возникнуть. Найдя очередной “баг” и исправив его, решил написать маленькую заметку (по теме) про php и zip-архивы.

Есть у меня на сайте такой сервис: “архив исходных текстов + подсветка ”. Я выкладываю не только статьи с небольшими примерами исходного кода, но и довольно большие фрагменты (десяток файлов разбитых на каталоги). Очевидно, что помещать их в, собственно, текст страницы (статьи) глупо. Можно было бы поместить их в архив zip|rar, снабдить парой комментариев и благополучно “забыть”. Но, как известно у настоящего программиста должны быть три благодетели:
 Лень, гордыня, нетерпеливость
Поэтому я решил поработать над собой и написать небольшой плагин для mediawiki, который бы умел показывать файловое дерево со списком файлов и подкаталогов. Я создал каталог sources, внутрь которого поместил подпапки для всех проектов с исходниками. Теперь для того чтобы в текст страницы был вставлено подобное файловое дерево, я пишу в тексте страницы нечто вроде (параметр base на картинке указывает относительный от корня хранилища путь к папке с исходниками):



Жму сохранить и вижу содержимое каталога php/wget.



А по нажатию на файл открывалась бы страница с исходным текстом этого файла (если это текстовой документ). Исходный код был бы подсвечен, например, с помощью geshi. Если жмут на файл картинки, то открывается сама картинка.
 Ага, и тут я решил изобрести велосипед!
Точно, файловых менеджеров вагон и маленькая тележка, взять хотя бы phpXplorer или … Стоп, но это отдельный продукт (как его встроить в mediawiki ни малейшего представления), и, скажем прямо, его функциональность избыточна, а если что-то избыточно, то и вредно. Одним словом, плагин я написал. Полюбовавшись на алеповатый интерфейс и походив по каталогам вверх-вниз, я догадался, что получилось то неплохо, но неудобно. Нужна возможность загрузить весь архив со связанными ресурсами за один клик. Давайте разберемся, как в php реализована работа с архивами.

Есть три возможных формата при работе с архивами: gz, zip, bz2. Вообще то можно добавить поддержку любого архиватора (даже без знания c|c++ и умения писать расширения для php) – это вызвать из php команду shell, передав архиватору нужные для его работы параметры. Вам поможет одна из следующих функций:
  1. $XXX_CMD = ‘конструируем строку, запускающую архиватор с некоторым набором параметров командной строки’;
  2. // “настоящие” программисты должны данные, из которых конструируется строка для 
  3. // исполнения shell предварительно проверить и экранировать опасные символы с помощью 
  4. // escapeshellcmd или escapeshellarg
  5. system($XXX_CMD);// можно и с помощью других функций
  6. exec($XXX_CMD);// использование внешнего приложения может быть наилучшим способом 
  7. // выйти из проблемы, если данные подлежащие архивации достаточно велики
  8. shell_exec($XXX_CMD);
Найдется еще пара способов запуска внешнего приложения, но разговор все же будет именно о встроенной поддержке архивации в php.

Давным-давно (во времена php версии 4.3) в состав php была встроена поддержка архивов gzip. Для работы с архивом нужно открыть его (можно открыть как для чтения так и для записи), при открытии следует указать в качестве параметра режим (чтение или запись), а также степень сжатия. В следующем примере (аккуратно выдранном из официальной справки) открывается для записи файл “somefile.gz”. Режим архивируемой информации равен 9-и, максимальный. Затем в файл пишется немного текста, и файл закрывается.
  1. <?php
  2. // сохраняем в файл некоторую строку текста
  3. $gz = gzopen('somefile.gz','w9');
  4. gzputs ($gz, 'I was added to somefile.gz');
  5. gzclose($gz);
  6. ?>
Для того, чтобы прочитать содержимое архива вы выполняете почти те же самые действия: открыть, прочитать и закрыть:
  1. <?php
  2. // получаем содержимое gz-файла в виде строки длиной до 10000 символов
  3. $filename = "/usr/local/something.txt.gz";
  4. $zd = gzopen($filename, "r");
  5. $contents = gzread($zd, 10000);
  6. gzclose($zd);
  7. ?>
Вторым параметром функции gzread следует указать то, сколько байт следует прочитать из файла (если файл кончится раньше, то ничего страшного).

Фактически если посмотреть на перечень доступных функций для работы с архивами gzip, то возникнет ощущение дежа-вю. Когда вы работали с обычными файлами, то использовали функции открытия, закрытия, чтения и записи содержимого в файл и их имена были…
Описание Обычные файлы Архивы gzip
Открыть файл для чтения или записи Fopen gzopen
Закрыть файл после завершения работы Fclose Gzclose
Прочитать произвольное количество байт из файла Fread Gzread
Проверить, что в файле остались еще не прочитанные данные Feof Gzeof
Записать в файл информацию Fwrite Gzwrite
И многие и многие другие
И для того чтобы окончательно вас запутать разработчики php ввели понятие wrapper-ов или handler-ов, которые могут играть роль посредников при работе с файлами. Например если вы откроете файл так:
  1. $h = fopen (‘boo.txt’, ‘r’);
То файл будет прочитан без каких-либо модификаций (архив это или не архив, да хоть картинка) вы получите те самые биты и байты, из которых файл и состоит. А вот если добавить перед именем файла указание протокола, то правила игры меняются.
  1. $h = fopen (‘http://abracadabra.ru/file.html’, ‘r’);
Теперь мы будем читать содержимое файла размещенного на другом сервере http. А так мы будем читать файл с сервера ftp
  1. $h = fopen (‘ftp://vasyano:secret@comp.server.ru’, ‘r’);
А вот мы прочитали содержимое файла gzip да еще и расположенного на ftp-сервере.
  1. $h = file_get_contents("compress.zlib://ftp://vasyano:secret@center/fex/backup/black-zorro-com_2007-12-08_00-57.sql.gz", "r");
  2. // функция file_get_contents так же как и fopen умеет работать с wrapper-ами
Одним словом, из wrapper-ов можно строить цепочки и результат работы одного из них подавать в качестве исходных данных для другого.

Можно создать собственный wrapper (если это вам интересно, то “копайте” в направлении функции stream_wrapper_register), так чтобы научиться прозрачно читать, скажем, архивы rar (правда, не понятно зачем, но это дело другое).

Супер, скажите вы. Значит я могу легко добавить к своему менеджеру исходников функцию архивации файлов и загрузки их клиентом. Точно и выглядит это примерно так:
  1. // предполагается, что в переменной $fullname находится имя файла,
  2. // которое нужно заархивировать и отправить клиенту
  3. header("Content-type: application/gzip");
  4. header('Content-Disposition: attachment; filename="'. basename($fullname) . '.zip' . '"');
  5. header("Expires: 0");
  6. header("Cache-Control: must-revalidate, post-check=0,pre-check=0");
  7. header("Pragma: public");
  8. die (gzencode (file_get_contents($fullname), 7));
Содержимое файла было прочитано в память с помощью функции file_get_contents, затем оно поступило на вход функции gzencode которая и выполнила сжатие. Цифра 7 указывает на степень сжатия, ее возможные значения от 0 до 9. 0 – сжатие не выполняется, а 9-ка, соответственно, задает максимальное сжатие. На практике разница между получаемыми после сжатия файлами в режимах 7,8,9 не велика, а вот процессорное время следует экономить.

Итак я научился архивировать файл, но … только один файл. Увы, особенность работы gz функций в том, что сжатию может быть подвергнут один и только один файл, а я ведь хочу иметь возможность поместить в архив содержимое каталога со всеми вложенными в него подкаталогами и файлами. Так что мне пришлось продолжить свой “квест”. Работа с множеством вложенных файлов доступна, например, в старом добром формате zip. Давайте посмотрим, что есть в php и нам может помочь.

На старом хостинге (jino-net.ru) версия php была 5.2.0, в ее состав входит поддержка работы с архивами zip с помощью класса ZipArchive. Далее идет пример кода из справки php, показывающий как можно создать архива и поместить в него несколько файлов.
  1. <?php
  2. // создаем объект Архив (внутри его находится множество функций для чтения и записи zip-файлов)
  3. $zip = new ZipArchive();
  4. $filename = "./test112.zip";
  5. // открываем (точнее создаем новый файл архива) 
  6. // указывая имя файла и режим его открытия (CREATE)
  7. if ($zip->open($filename, ZIPARCHIVE::CREATE)!==TRUE) {
  8.    exit("cannot open <$filename>\n");
  9. // открыть файл не удалось, так что завершим работу скрипта аварийно
  10. }
  11. // теперь начинаем наполнять архив содержимым
  12. $zip->addFromString("testfilephp.txt" . time(), "#1 This is a test string added as testfilephp.txt.\n");
  13. $zip->addFromString("testfilephp2.txt" . time(), "#2 This is a test string added as testfilephp2.txt.\n");
  14. $zip->addFile($thisdir . "/too.php","/testfromfile.php");
  15. echo "numfiles: " . $zip->numFiles . "\n";
  16. echo "status:" . $zip->status . "\n";
  17. $zip->close();
  18. ?>
Когда мы добавляем в архив файлы, то можем воспользоваться следующими двумя приемами: добавление существующего файла размещенного на диске, или же в архив помещается новый файл, но его содержимое следует указать явно в виде строки при вызове функции добавления.

В первом случае используется функция addFile, а во втором – addFromString.

Теперь я смог написать код формирующий архив с содержимым некоторого каталога. Предполагается, что в переменной $fullpath хранится путь к каталогу, который нужно заархивировать.
  1. $zip = new ZipArchive();
  2. // генерируем случайное имя файла размещенного в каталоге temp – именно внутрь этого файла будет помещен архив
  3. $tmpfname = tempnam(null, 'alldirshhhc');
  4. if ($zip->open($tmpfname, ZIPARCHIVE::OVERWRITE)!==TRUE) {
  5. 	return false;
  6. }
  7. $zip->addFromString("readme_zip.txt", "This file is autogenerated, and contains directory content");
  8. rec_scan_dir_to_zip ( $fullpath, $zip, ‘’ );
  9. $zip->close();
  10.  
  11. header("Content-type: application/zip");
  12. header('Content-Disposition: attachment; filename="'.  (basename($fullpath)) . '.zip' . '"');
  13. header("Expires: 0");
  14. header("Cache-Control: must-revalidate, post-check=0,pre-check=0");
  15. header("Pragma: public");
  16. $h = fopen ($tmpfname, 'rb');
  17. $fc = fread ($h , filesize ($tmpfname));
  18. fclose($h);
  19. die ($fc);
Непосредственно процесс формирования архива реализован внутри функции rec_scan_dir_to_zip. Она будет рекурсивно сканировать каталог (первый параметр функции) и помещать все найденные файлы в объект-архив (задан вторым параметром). Значение третьего параметра (он равен пустой строке) вы лучше поймете, когда проанализируете следующий фрагмент кода:
  1. // функция, выполняющая архивирование данного каталога и всех его подкаталогов в архив zip
  2. function rec_scan_dir_to_zip($dir, $zip, $fromname){
  3.   $dir = realpath ($dir . '/' ) . '/';
  4.   $h = opendir ($dir);
  5.   if (! $h) { 
  6.       /*print 'cannot open:' . $dir ;*/
  7.       return false;
  8.   }
  9.   while (($file = readdir ($h))!==false){
  10.     $fullname = $dir . $file;
  11.     if ($file == '..' || $file == '.') 
  12.         continue;
  13.     if (! is_readable ($fullname) ) continue;
  14.     if (is_dir ($fullname)){
  15.       rec_scan_dir_to_zip ($fullname , $zip, $fromname . $file ./);
  16.     }
  17.     else{
  18.        $locfilename = $fromname . $file;
  19.         $zip->addFile($fullname, $locfilename);
  20.     }// if file or dir
  21. }// while
  22. closedir ($h);
  23. }// if open dir
Обратите внимание только на строку, где я непосредственно добавляю в архив очередной файл. Я должен указать при вызове функции addFile два параметра – имя файла в файловой системе сервера (именно содержимое этого файла будет прочитано, заархивировано и помещено внутрь архива zip), а также “короткое имя” – имя под которым файл будет помещен внутрь архива. Очевидно, что короткое имя должно отсчитываться от корня самого первого каталога, который нужно заархивировать.