Доброго времени суток, уважаемый Хабр!

За последние пол года кривая дорожка моих пет-проектов завела меня в такие дебри, откуда до сих пор выбраться не представляется возможным. И начиналось-то все безобидно — сайт с картинками, но чувство перфекционизма, погоня за халявой, а также некоторые особенности моего склада ума превратили эту, как изначально планировалось, маленькую прогулку, в настоящее длинное путешествие. Ну и ладно, как говорил один довольно картавый революционер: «Учиться, учиться и еще раз учиться», а мне, волей-неволей, приходится этому напутствию следовать.

Ой, что-то мы отвлеклись от основной темы. Не буду больше вас утомлять пространными речами, а перейду к делу.

Создаем Zip-архив


В принципе, переписывать спецификацию сюда я не буду. Да и в целом описывать структуру тоже смысла нет, потому что все это сделали до меня.

Для тех, кому лень переходить по ссылкам, просто вкратце обрисую, что любой zip-архив должен содержать в себе:

  • Запись о файле:
    • Local File Header
    • Полезные данные
    • Data descriptor (опционально, используется в случае, когда мы не знаем размер файла и его хэш до тех пор, пока до конца его не прочтем)
  • Central Directory File Header (для каждого файла. это как оглавление книги, где указан каждый раздел и страница, на которой его можно найти)
  • End Of Central Directory Record

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

<?php

// В архиве у нас будет два файла (1.txt и 2.txt) с соответствующим содержимым:
$entries = [
    '1.txt' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc id ante ultrices, fermentum nibh eleifend, ullamcorper nunc. Sed dignissim ut odio et imperdiet. Nunc id felis et ligula viverra blandit a sit amet magna. Vestibulum facilisis venenatis enim sed bibendum. Duis maximus felis in suscipit bibendum. Mauris suscipit turpis eleifend nibh commodo imperdiet. Donec tincidunt porta interdum. Aenean interdum condimentum ligula, vitae ornare lorem auctor in. Suspendisse metus ipsum, porttitor et sapien id, fringilla aliquam nibh. Curabitur sem lacus, ultrices quis felis sed, blandit commodo metus. Duis tincidunt vel mauris at accumsan. Integer et ipsum fermentum leo viverra blandit.',
    
    '2.txt' => 'Mauris in purus sit amet ante tempor finibus nec sed justo. Integer ac nibh tempus, mollis sem vel, consequat diam. Pellentesque ut condimentum ex. Praesent finibus volutpat gravida. Vivamus eleifend neque sit amet diam scelerisque lacinia. Nunc imperdiet augue in suscipit lacinia. Curabitur orci diam, iaculis non ligula vitae, porta pellentesque est. Duis dolor erat, placerat a lacus eu, scelerisque egestas massa. Aliquam molestie pulvinar faucibus. Quisque consequat, dolor mattis lacinia pretium, eros eros tempor neque, volutpat consectetur elit elit non diam. In faucibus nulla justo, non dignissim erat maximus consectetur. Sed porttitor turpis nisl, elementum aliquam dui tincidunt nec. Nunc eu enim at nibh molestie porta ut ac erat. Sed tortor sem, mollis eget sodales vel, faucibus in dolor.',
];

// А сохраним архив мы как Lorem.zip, он появится у нас в cwd (обычно в одной папке с запускаемым файлом)
$destination = 'Lorem.zip';
$handle = fopen($destination, 'w');

// Нам нужно следить сколько мы записали, чтоб потом указать смещение, с которого начинается каждый файл, в нашем "оглавлении" Central Directory File Header
$written = 0;
$dictionary = [];
foreach ($entries as $filename => $content) {
    // Для каждого файла нам нужно сначала записать структуру Local File Header, а потом его содержимое
    // В этой статье мы не будем рассматривать сжатие, поэтому данные будут храниться как есть.
    
    $fileInfo = [
        // минимальная версия для распаковки
        'versionToExtract'      => 10,                                      
        // должен быть 0, если мы сразу указываем длину файла и хэш-сумму
        'generalPurposeBitFlag' => 0,                                       
        // у нас хранятся данные без сжатия, так что тоже 0
        'compressionMethod'     => 0,                                       
        // по-хорошему тут нужно указать mtime файла, но кому какая разница, кто и когда трогал этот файл?
        'modificationTime'      => 28021,                                   
        // ну вы поняли, да?
        'modificationDate'      => 20072,
        // а вот тут уже халтурить нельзя. вообще можно указать любое значение, но мы же хотим получит валидный архив, не так ли?
        'crc32'                 => hexdec(hash('crc32b', $content)),
        // размер сжатых и несжатых данных. в нашем случае одно и то же число. 
        // тоже настоятельно рекомендую указывать реальные данные :)
        'compressedSize'        => $size = strlen($content),
        'uncompressedSize'      => $size,
        // Длина имени файла
        'filenameLength'        => strlen($filename),
        // Дополнительная информация. Мы её не пишем, так что 0.
        'extraFieldLength'      => 0,
    ];
    
    // Упакуем все это в нужный вид.
    $LFH = pack('LSSSSSLLLSSa*', ...array_values([
        'signature' => 0x04034b50, // Сигнатура Local File Header
    ] + $fileInfo + ['filename' => $filename]));
    
    // А информацию о файле сохраним на потом, ведь в конце нам еще писать Central Directory File Header
    $dictionary[$filename] = [
        'signature'     => 0x02014b50, // Сигнатура Central Directory File Header
        'versionMadeBy' => 798,        // Версия создания. Я стащил это значение, разбирая какой-то из архивов.
    ] + $fileInfo + [
        'fileCommentLength'      => 0,          // Длина комментария к файлу. No comments
        'diskNumber'             => 0,          // Мне обычно попадался везде 0, а в особенности я решил не вникать
        'internalFileAttributes' => 0,          // Внутренние атрибуты файла
        'externalFileAttributes' => 2176057344, // Внешние атрибуты файла
        'localFileHeaderOffset'  => $written,   // Смешение в файле до его Local File Header
        'filename'               => $filename,  // Имя файла.
    ];
    
    // А теперь запишем наш заголовок
    $written += fwrite($handle, $LFH);
    // И сами данные
    $written += fwrite($handle, $content);
}

// Теперь, когда мы записали все данные, можно приступать к оглавлению.
// Но давайте немного забежим вперед и начнем создавать структуру End of central directory record (EOCD)
$EOCD = [
    // Сигнатура EOCD
    'signature'                    => 0x06054b50, 
    // Номер диска. У нас этого нет, так что 0
    'diskNumber'                   => 0,          
    // И этого у нас нет - тоже 0
    'startDiskNumber'              => 0,          
    // Количество записей в архиве на текущем диске.
    'numberCentralDirectoryRecord' => $records = count($dictionary), 
    // Всего записей в архиве. У нас один архив, так что идентично предыдущему
    'totalCentralDirectoryRecord'  => $records, 
    // Размер записей Central Directory Record. 
    // Мы его пока еще не знаем, но нужно будет обязательно указать
    'sizeOfCentralDirectory'       => 0, 
    // Смешение, с которого начинаются Central Directory Records
    'centralDirectoryOffset'       => $written,
    // И снова без комментариев
    'commentLength'                => 0
];

// А вот теперь точно можно! Пишем оглавление
foreach ($dictionary as $entryInfo) {
    $CDFH = pack('LSSSSSSLLLSSSSSLLa*', ...array_values($entryInfo));
    $written += fwrite($handle, $CDFH);
}

// Все, разобрались со словарем. Давайте отметим, где он закачивается
$EOCD['sizeOfCentralDirectory'] = $written - $EOCD['centralDirectoryOffset'];
    
// А теперь можно записывать End of central directory record
$EOCD = pack('LSSSSLLS', ...array_values($EOCD));
$written += fwrite($handle, $EOCD);

// Архив готов. 
fclose($handle);

echo 'Размер архива составил: ' . $written . ' байт' . PHP_EOL;
echo 'Для проверки валидности архива запустите `unzip -tq ' . $destination . '`' . PHP_EOL;
echo PHP_EOL;

Попробуйте запустить этот примитивный код и на выходе вы получите файл Lorem.zip, который будет содержать 1.txt и 2.txt.

А зачем?


Конечно, любой адекватный человек скажет, что писать архиваторы на php это бесполезная затея, тем более что для такого формата, как zip, есть куча готовых реализаций на любой вкус и цвет. И в том же php есть готовые библиотеки. Я тоже так скажу :)

Но зачем же тогда вот вся эта статья, зачем я тратил время на её написание, а вы на прочтение?
А затем, что все не так просто и знание того, как работает zip, открывает нам некоторые дополнительные возможности.

Во-первых, я надеюсь, хоть немного, но поможет желающим понять структуру zip.
А во-вторых, создавая архив своими руками, мы имеем контроль, и, главное, доступ к его внутренним данным.

Мы можем предварительно расчитать Local File Header и Central Directory File Header, а потом on-demand генерировать zip-архив на лету с любым содержанием и порядком файлов, просто подставляя эти данные. И никаких накладных расходов, кроме как на ввод-вывод.

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

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


'diskNumber' => 0, // Мне обычно попадался везде 0, а в особенности я решил не вникать
Как подсказал berez — номер тома в многотомном архиве.

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


  1. trawl
    11.10.2019 05:51

    Было бы неплохо рассмотреть ещё сжатие данных (мы же zip'уем, а не tar'им) и защиту паролем.


    Ну и объединение массивов стоило сделать через array_merge, а не сложением. (не заметил хаб Ненормальное программирование)


    1. NiceDay Автор
      11.10.2019 06:09
      +1

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


    1. AlexanderS
      11.10.2019 16:07

      не заметил хаб Ненормальное программирование

      То есть, теги вас не смутили?)


      1. trawl
        11.10.2019 17:21

        Нисколько. Я посчитал, что это про написание уже написанного


    1. zapimir
      11.10.2019 17:11

      А что там рассматривать zip по сути просто контейнер. Сами данные можно сжимать разными алгоритмами, deflate (тот же gzip без дополнительных gzip заголовков) или bzip2, ну и более продвинутые lzma (но это уже не все смогут распаковать).

      Значительно интересней то, что zip позволяет сохранять потоки. Т.е. не обязательно точно знать размер и CRC файла для заголовка. Например, когда делаешь бэкап БД (на пару гигов) то не нужно сначала это всё во временный файл писать, а потом уже в zip. Можно сразу писать в zip, на лету считая CRC и размеры, и эти данные сохранять после файла (там специальные опции для этого) плюс в центральном каталоге. Вот это уже ни одна из многочисленных доступных реализаций на PHP (по сути они только необходимый минимум умеют) не поддерживает.


      1. NiceDay Автор
        11.10.2019 18:07

        А можно даже этот бекап прям сразу на лету и заливать куда-нибудь в облако, не сохраняя у себя вообще никаких временных данных.
        PHP позволяет считать хеши инрементально, так что это очень даже возможно и, вроде, не сложно.
        Я думаю, что следующая статья как раз про это и будет, постараюсь только набросать хоть как-нибудь интересный proof of concept, чтоб это можно было запустить и пощупать результат.


        1. zapimir
          11.10.2019 18:50
          -1

          Чисто технически нет проблем, я даже прикручивал сжатие и шифрование на лету. Можно покопать в сторону фильтров (именно на них делал), ну и будет не совсем банальное решение. Но в реальности вылазит такое ограничение, как скорость аплоада. Т.е. аплоад на облако может длиться в десятки раз дольше самого бэкапа. Если заливать на тот же Dropbox. То лучше всё же сохранять бэкап на локалке, чтобы сделать это как можно быстрее и разлочить базу. А потом уже потихоньку заливать. Причем заливать можно в несколько параллельных потоков, что сильно ускоряет процесс.

          Ну и в любом случае, решение проверить на больших файлах (чтобы не помещались в разнообразные буферы), в основном все «вкусности» там вылазят.


  1. SerafimArts
    11.10.2019 09:38

    Спасибо большое за статью!


    Всегда нравились с потрошением каких-нибудь популярных штук, общаться с которыми (со штуками) доводилось только через API.


  1. Cykooz
    11.10.2019 10:14

    С моей точки зрения, наиболее жизненное использование знаний приведённых в статье — это организация скачивания из веб-приложения нескольких файлов одним запросом. Подобное реализовано в популярных облачных хранилищах файлов.
    Причём реализовать это можно в виде «realtime стриминга» без предварительного создания zip-файла на диске сервера. И ещё будет возможность докачки после разрыва связи, т.к. за счёт отсутствия сжатия мы можем быстро рассчитать итоговый размер архива, и определять что должно находится по любому смещению от его начала.


    1. NiceDay Автор
      11.10.2019 18:15

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


      1. barbeer
        14.10.2019 20:21
        +1

        Приходилось делать подобную систему. Там файлы для «упаковки» собирались из достаточно больших блоков (до 3Гб макс, файл чуть меньше 4Гб макс — ограничение зипа), и часть блоков была динамическая. Пересчитывать crc для всего файла на лету при таких размерах, понятно, не вариант. :)
        Рассчитанные crc всех статических блоков хранились, для динамических считались в процессе генерации и подгонялись так, чтобы общий crc файла сходился.
        Отдавалось всё это через nginx с использованием SSI, в итоге пхп на выходе выдавал динамические блоки + SSI-разметку для nginx, а тот уже сам «собирал» итоговый файл из разных блоков с диска по мере необходимости.
        Точно так же потом собирался и рар-архив без сжатия.
        Ещё таймстэмпы, конечно, реальные были, и куча всего другого :)

        И докачка была, с вычислением смещения и отдачей только нужного куска.

        Зип со сжатием тоже делал, но для файлов поменьше. Теоретически, его тоже можно делать с предрассчитанными «блоками» (при аналогичной задаче «сборки» файла из блоков), надо поковыряться немного с кишками deflate для уточнения, но большие всё равно были уже жатые так что необходимости в этом так и не возникло.

        Всё собирался статью писать, но не думаю, что дойдут руки… Так что если вдруг интересны какие-то детали…


        1. NiceDay Автор
          14.10.2019 20:32

          Большое спасибо!
          Очень люблю почитать вот такие истории, особенно когда в них фигурирует php:)

          Насчет ssi думал когда нужно было склеивать несколько файлов и отдавать как один, но как-то дальше думать это не пошло и пока все это дело на lua.
          Вообще реально им собирать несколько удаленных файлов в один?
          То есть у меня есть location с proxy_pass, который отдает файл откуда-то.
          но файл может быть разбит на насколько (ну, например, зип-архив просто разбитый на куски по 200мб, не многотомник) и пользователю нужно, соответственно, отдать нормальный файл, а не 2-3куска. я могу отдать из php такой ssi, чтоб nginx последовательно подсунул пользователю несколько кусков как один файл?


          1. barbeer
            15.10.2019 00:11

            Ну да, конечно. Там получается в итоге просто последовательно несколько SSI-тегов со ссылками на соответствующие куски/локейшны. И между ними если надо можно вставлять всё, что душе угодно — nginx корректно всё обрабатывает. На другом проекте по тому же принципу менялась иконка в exe-файле — подготовленные блоки + рассчитанные куски секции ресурсов, заголовки, контрольные суммы, и т.п. PHP отдавал nginx-у мешанину из байтиков и ssi-тегов, а тот уже всё собирал по мере отдачи файла.
            Т.е. например если юзер прерывал загрузку в самом начале nginx уже не читал следующие блоки с диска.


  1. drWhy
    11.10.2019 10:27

    Конечно, любой адекватный человек скажет, что писать архиваторы на php это бесполезная затея
    Один знакомый писал рекурсивный упаковщик на Basic. Да, рекурсивный в смысле ненулевого прироста сжатия в каждой итерации. На вид был вполне нормальным. Чем закончился проект — засекречиванием или белой рубашкой с не по росту длинными рукавами — не знаю, потерялись.


    1. NiceDay Автор
      11.10.2019 18:18

      На самом деле я, конечно же, утрирую — мы же не на голом PHP хеш считаем, а больше там числодробительного ничего и нет. Практически только IO-операции.
      Возможно, с появлением jit в релизных версиях, подобные темы вообще станут чем-то обыденным для нашего сообщества.


  1. ladutsko
    11.10.2019 10:57

    Не забывайте про фактор кодировки для имени файла в архиве!

    По поводу применения — таким подходом я пользовался, чтобы дать пользователям возможность скачивать несколько файлов за раз…


  1. old_gamer
    11.10.2019 12:22

    Интересно.


  1. LuckyOok
    11.10.2019 17:57

    Весьма интересует распаковка файлов с переставленными байтами. А конкретно файлов ресурсов .idf
    Вроде и алгоритм запаковки есть, а ума написать распаковщик нету.


    1. NiceDay Автор
      11.10.2019 17:58

      Ну, я с zip тоже не за 5 минут разобрался)
      Главное время и упорство, а там все получится.


    1. Botanegg
      12.10.2019 21:43

      Попробуйте http://kaitai.io/ — очень полезная штука для ковыряния в кишках бинарников.


      1. LuckyOok
        12.10.2019 21:51

        Мерси! Попробую.


  1. SerVerOnLine
    12.10.2019 21:43

    Делал подобную дичь для использования в 1с, которая хранит сжатые данные в deflate: к ним пришлось прикручивать все рассчитанные секции zip-формата, чтобы потом можно было извлечь данные из полноценного zip-архива.