Продолжение статьи Как выглядит zip-архив и что мы с этим можем сделать.
Предисловие
Доброго времени суток.
И снова в эфире у нас нетрадиционное программирование на PHP.
В прошлой статье уважаемых читателей заинтересовала тема сжатия в ZIP и сохранение потоков в zip. Попробуем сегодня хоть немного раскрыть эту тему.
Давайте взглянем на
<?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;
В чем его проблема? Ну, справедливости ради, стоит заметить, что единственным его достоинством является то, что он работает, а проблем там валом, но все же.
На мой взгляд основной проблемой является то, что сначала мы должны записать Local File Header (LFH) с crc32 и длиной файла, а потом уже само содержимое файла.
Чем это грозит? Либо мы загружаем весь файл в память, считаем для него crc32, записываем LFH, а потом содержимое файла — экономно с точки зрения I/O, но недопустимо с большими файлами. Либо мы читаем файл 2 раза — сначала чтоб посчитать хеш, а потом чтоб считать содержимое и записать в архив — экономно с точки зрения оперативной памяти, но, например, во-первых создает удвоенную нагрузку на накопитель, который не обязательно SSD.
А если файл лежит удаленно и объем его, например, 1.5гб? Это ж придется либо загружать все 1.5гб в память, либо ждать пока все эти 1.5гб скачаются и мы посчитаем хеш, а потом качать их еще раз чтоб отдать содержимое. В случае же, если мы хотим на лету отдать, например, дамп бд, который мы, к примеру, читаем из stdout, это вообще недопустимо — изменились данные в бд, изменятся данные дампа, будет совершенно другой хеш и мы получим невалидный архив. Дааа, дела, конечно, скверные.
Структура Data Descriptor для потоковых записей в архиве
Но не расстраивайтесь, спецификация ZIP позволяет нам сначала записать данные, а потом прилепить структуру Data Descriptor (DD) после данных, в которой уже и будет crc32, длина упакованных данных и длина данных без сжатия. Для этого нам нужно всего 3 раза в день натощак в LFH указать generalPurposeBitFlag равным 0x0008, а crc32, compressedSize и uncompressedSize указать 0. Тогда после данных мы записываем структуру DD, которая будет выглядеть как-то так:
pack('LLLL', ...array_values([
'signature' => 0x08074b50, // сигнатура Data Descriptor
'crc32' => $crc32, // значение crc32 для нашего файла
'compressedSize' => $compressedSize, // длина сжатых данных
'uncompressedSize' => $uncompressedSize, // длина данных без компрессии.
]));
А в Central Directory File Header (CDFH) поменяется только generalPurposeBitFlag, остальные данные должны быть настоящими. Но это не проблема, так как CDFH мы записываем после всех данных, и хеши с длинами данных у нас известны в любом случае.
Это всё, конечно, хорошо. Осталось только реализовать на PHP.
И стандартная библиотека Hash нам очень сильно в этом поможет. Мы можем создать контекст хеширования, в который достаточно будет запихивать чанки с данными, а в конце получить значение хеша. Конечно, это решение будет несколько более громоздким, чем hash('crc32b', $content), но сохранит нам просто невообразимую кучу ресурсов и времени.
Выглядит это примерно как-то так:
$hashCtx = hash_init('crc32b');
$handle = fopen($source, 'r');
while (!feof($handle)) {
$chunk = fread($handle, 8 * 1024);
hash_update($hashCtx, $chunk);
$chunk = null;
}
$hash = hash_final($hashCtx);
Если все сделано правильно, то значение нисколько не будет отличаться от hash_file('crc32b', $source) или hash('crc32b', file_get_content($source)).
Давайте попробуем это все как-то обернуть в одну функцию, чтоб мы могли удобным нам образом прочесть файл, а в конце получить его хеш и длину. А помогут нам в этом генераторы:
function read(string $path): \Generator
{
$length = 0;
$handle = fopen($path, 'r');
$hashCtx = hash_init('crc32b');
while (!feof($handle)) {
$chunk = fread($handle, 8 * 1024);
$length += strlen($chunk);
hash_update($hashCtx, $chunk);
yield $chunk;
$chunk = null;
}
fclose($handle);
return ['length' => $length, 'crc32' => hexdec(hash_final($hashCtx))];
}
и теперь мы можем просто
$reader = read('https://speed.hetzner.de/1GB.bin');
foreach ($reader as $chunk) {
// что-то делаем с чанком.
}
// получаем значения хеша и длины файла.
['length' => $length, 'crc32' => $crc32] = $reader->getReturn();
echo round(memory_get_peak_usage(true) / 1024 / 1024, 2) . 'MB - Memory Peak Usage' . PHP_EOL;
По-моему довольно просто и удобно. При файле 1Гб пиковое потребление памяти у меня получилось 2Мб.
Теперь попробуем модифицировать код из предыдущей статьи так, чтоб мы могли этой функцией воспользоваться.
<?php
function read(string $path): \Generator
{
$length = 0;
$handle = fopen($path, 'r');
$hashCtx = hash_init('crc32b');
while (!feof($handle)) {
$chunk = fread($handle, 8 * 1024);
$length += strlen($chunk);
hash_update($hashCtx, $chunk);
yield $chunk;
$chunk = null;
}
fclose($handle);
return ['length' => $length, 'crc32' => hexdec(hash_final($hashCtx))];
}
$entries = ['https://speed.hetzner.de/100MB.bin', __FILE__];
$destination = 'test.zip';
$handle = fopen($destination, 'w');
$written = 0;
$dictionary = [];
foreach ($entries as $entry) {
$filename = basename($entry);
$fileInfo = [
'versionToExtract' => 10,
// Если мы хотим писать архив с Data Descriptor, это значение должно быть 0х0008,
// а не 0х0000 как в прошлый раз.
'generalPurposeBitFlag' => 0x0008,
'compressionMethod' => 0,
'modificationTime' => 28021,
'modificationDate' => 20072,
'crc32' => 0,
'compressedSize' => 0,
'uncompressedSize' => 0,
'filenameLength' => strlen($filename),
'extraFieldLength' => 0,
];
$LFH = pack('LSSSSSLLLSSa*', ...array_values([
'signature' => 0x04034b50,
] + $fileInfo + ['filename' => $filename]));
$fileOffset = $written;
$written += fwrite($handle, $LFH);
// считываем файл нашим генератором
$reader = read($entry);
foreach ($reader as $chunk) {
// и записываем в наш архив
$written += fwrite($handle, $chunk);
$chunk = null;
}
// получаем длину и хеш нашего файла
['length' => $length, 'crc32' => $crc32] = $reader->getReturn();
// добавляем их в fileInfo, чтоб они попали в CDFH
$fileInfo['crc32'] = $crc32;
$fileInfo['compressedSize'] = $length;
$fileInfo['uncompressedSize'] = $length;
// Записываем Data Descriptor
$DD = pack('LLLL', ...array_values([
'signature' => 0x08074b50,
'crc32' => $fileInfo['crc32'],
'compressedSize' => $fileInfo['compressedSize'],
'uncompressedSize' => $fileInfo['uncompressedSize'],
]));
$written += fwrite($handle, $DD);
$dictionary[$filename] = [
'signature' => 0x02014b50,
'versionMadeBy' => 798,
] + $fileInfo + [
'fileCommentLength' => 0,
'diskNumber' => 0,
'internalFileAttributes' => 0,
'externalFileAttributes' => 2176057344,
'localFileHeaderOffset' => $fileOffset,
'filename' => $filename,
];
}
$EOCD = [
'signature' => 0x06054b50,
'diskNumber' => 0,
'startDiskNumber' => 0,
'numberCentralDirectoryRecord' => $records = count($dictionary),
'totalCentralDirectoryRecord' => $records,
'sizeOfCentralDirectory' => 0,
'centralDirectoryOffset' => $written,
'commentLength' => 0
];
foreach ($dictionary as $entryInfo) {
$CDFH = pack('LSSSSSSLLLSSSSSLLa*', ...array_values($entryInfo));
$written += fwrite($handle, $CDFH);
}
$EOCD['sizeOfCentralDirectory'] = $written - $EOCD['centralDirectoryOffset'];
$EOCD = pack('LSSSSLLS', ...array_values($EOCD));
$written += fwrite($handle, $EOCD);
fclose($handle);
echo 'Пиковое использование памяти: ' . memory_get_peak_usage(true) . ' байт' . PHP_EOL;
echo 'Размер архива составил: ' . $written . ' байт' . PHP_EOL;
echo 'Проверка целостности архива `unzip -tq ' . $destination . '`: ' . PHP_EOL;
echo '> ' . exec('unzip -tq ' . $destination) . PHP_EOL;
echo PHP_EOL;
На выходе мы должны получить Zip-архив с именем test.zip, в котором будет файл с указанным выше скриптом и 100MB.bin, размером около 100Мбайт.
Сжатие в Zip-архивах
Теперь у нас есть фактически всё, чтоб сжимать данные и делать это тоже «на лету».
Подобно тому, как мы получаем хеш, отдавая функции маленькие чанки, мы можем и сжимать данные благодаря чудесной библиотеке Zlib и её функциям deflate_init и deflate_add.
Выглядит это примерно как-то так:
$deflateCtx = deflate_init(ZLIB_ENCODING_RAW, ['level' => 6]);
$handle = fopen($source, 'r');
while (!feof($handle)) {
$chunk = fread($handle, 8 * 1024);
yield deflate_add($deflateCtx, $chunk, feof($handle) ? ZLIB_FINISH : ZLIB_SYNC_FLUSH);
$chunk = null;
}
Я встречал вариант вроде такого, что в сравнении с предыдущим допишет в конце несколько нолей.
while (!feof($handle)) {
yield deflate_add($deflateCtx, $chunk, ZLIB_SYNC_FLUSH);
}
yield deflate_add($deflateCtx, '', ZLIB_FINISH);
Поправим наш reader так, чтоб он сразу сжимал наши данные, а в конце возвращал нам хеш, длину данных без компрессии и длину данных с компрессией:
function read(string $path): \Generator
{
$uncompressedSize = 0;
$compressedSize = 0;
$hashCtx = hash_init('crc32b');
$deflateCtx = deflate_init(ZLIB_ENCODING_RAW, ['level' => 6]);
$handle = fopen($path, 'r');
while (!feof($handle)) {
$chunk = fread($handle, 8 * 1024);
hash_update($hashCtx, $chunk);
$compressedChunk = deflate_add($deflateCtx, $chunk, feof($handle) ? ZLIB_FINISH : ZLIB_SYNC_FLUSH);
$uncompressedSize += strlen($chunk);
$compressedSize += strlen($compressedChunk);
yield $compressedChunk;
$chunk = null;
$compressedChunk = null;
}
fclose($handle);
return [
'uncompressedSize' => $uncompressedSize,
'compressedSize' => $compressedSize,
'crc32' => hexdec(hash_final($hashCtx))
];
}
и попробуем на 100 мб файле:
$reader = read('https://speed.hetzner.de/100MB.bin');
foreach ($reader as $chunk) {
// что-то делаем с чанком.
}
['uncompressedSize' => $uncompressedSize, 'compressedSize' => $compressedSize, 'crc32' => $crc32] = $reader->getReturn();
echo 'Uncompressed size: ' . $uncompressedSize . PHP_EOL;
echo 'Compressed size: ' . $compressedSize . PHP_EOL;
echo round(memory_get_peak_usage(true) / 1024 / 1024, 2) . 'MB - Memory Peak Usage' . PHP_EOL;
Потребление памяти все так же показывает, что мы не загружали весь файл в память.
Давайте соберем все это и наконец-то получим действительно настоящий скрипт-архиватор.
В отличии от предыдущей версии у нас изменится generalPurposeBitFlag — теперь его значение 0x0018, а так же compressionMethod — 8 (что значит Deflate).
<?php
function read(string $path): \Generator
{
$uncompressedSize = 0;
$compressedSize = 0;
$hashCtx = hash_init('crc32b');
$deflateCtx = deflate_init(ZLIB_ENCODING_RAW, ['level' => 6]);
$handle = fopen($path, 'r');
while (!feof($handle)) {
$chunk = fread($handle, 8 * 1024);
hash_update($hashCtx, $chunk);
$compressedChunk = deflate_add($deflateCtx, $chunk, feof($handle) ? ZLIB_FINISH : ZLIB_SYNC_FLUSH);
$uncompressedSize += strlen($chunk);
$compressedSize += strlen($compressedChunk);
yield $compressedChunk;
$chunk = null;
$compressedChunk = null;
}
fclose($handle);
return [
'uncompressedSize' => $uncompressedSize,
'compressedSize' => $compressedSize,
'crc32' => hexdec(hash_final($hashCtx))
];
}
$entries = ['https://speed.hetzner.de/100MB.bin', __FILE__];
$destination = 'test.zip';
$handle = fopen($destination, 'w');
$written = 0;
$dictionary = [];
foreach ($entries as $entry) {
$filename = basename($entry);
$fileInfo = [
'versionToExtract' => 10,
// Мы добавили сжатие, поэтому теперь наш флаг будет иметь значение 0x0018 вместо 0x0008
'generalPurposeBitFlag' => 0x0018,
'compressionMethod' => 8,
// Ну и надо указать метод сжатия: 8 - Deflate
'modificationTime' => 28021,
'modificationDate' => 20072,
'crc32' => 0,
'compressedSize' => 0,
'uncompressedSize' => 0,
'filenameLength' => strlen($filename),
'extraFieldLength' => 0,
];
$LFH = pack('LSSSSSLLLSSa*', ...array_values([
'signature' => 0x04034b50,
] + $fileInfo + ['filename' => $filename]));
$fileOffset = $written;
$written += fwrite($handle, $LFH);
$reader = read($entry);
foreach ($reader as $chunk) {
$written += fwrite($handle, $chunk);
$chunk = null;
}
[
'uncompressedSize' => $uncompressedSize,
'compressedSize' => $compressedSize,
'crc32' => $crc32
] = $reader->getReturn();
$fileInfo['crc32'] = $crc32;
$fileInfo['compressedSize'] = $compressedSize;
$fileInfo['uncompressedSize'] = $uncompressedSize;
$DD = pack('LLLL', ...array_values([
'signature' => 0x08074b50,
'crc32' => $fileInfo['crc32'],
'compressedSize' => $fileInfo['compressedSize'],
'uncompressedSize' => $fileInfo['uncompressedSize'],
]));
$written += fwrite($handle, $DD);
$dictionary[$filename] = [
'signature' => 0x02014b50,
'versionMadeBy' => 798,
] + $fileInfo + [
'fileCommentLength' => 0,
'diskNumber' => 0,
'internalFileAttributes' => 0,
'externalFileAttributes' => 2176057344,
'localFileHeaderOffset' => $fileOffset,
'filename' => $filename,
];
}
$EOCD = [
'signature' => 0x06054b50,
'diskNumber' => 0,
'startDiskNumber' => 0,
'numberCentralDirectoryRecord' => $records = count($dictionary),
'totalCentralDirectoryRecord' => $records,
'sizeOfCentralDirectory' => 0,
'centralDirectoryOffset' => $written,
'commentLength' => 0
];
foreach ($dictionary as $entryInfo) {
$CDFH = pack('LSSSSSSLLLSSSSSLLa*', ...array_values($entryInfo));
$written += fwrite($handle, $CDFH);
}
$EOCD['sizeOfCentralDirectory'] = $written - $EOCD['centralDirectoryOffset'];
$EOCD = pack('LSSSSLLS', ...array_values($EOCD));
$written += fwrite($handle, $EOCD);
fclose($handle);
echo 'Пиковое использование памяти: ' . memory_get_peak_usage(true) . ' байт' . PHP_EOL;
echo 'Размер архива составил: ' . $written . ' байт' . PHP_EOL;
echo 'Проверка целостности архива `unzip -tq ' . $destination . '`: ' . PHP_EOL;
echo '> ' . exec('unzip -tq ' . $destination) . PHP_EOL;
echo PHP_EOL;
В итоге у меня получился архив, размером 360183 байт (очень хорошо сжался наш 100мб файл, в котором скорей всего просто набор одинаковых байт), а unzip показал, что ошибок в архиве не найдено.
Заключение
Если у меня хватит сил и времени на еще одну статью, то я постараюсь показать как и, главное, зачем это всё можно использовать.
Если вас интересует что-либо еще на данную тему — предлагайте в комментариях, я постараюсь дать ответ на ваш вопрос. Разбираться с шифрованием мы скорей всего не будем, потому что скрипт и так разросся, а в реальной жизни такие архивы, как мне кажется, используются не слишком часто.
Спасибо вам за внимание и за ваши комментарии.