Как стать автором
Обновить

Как выглядит zip-архив и что мы с этим можем сделать. Часть 3 — Практическое применение

Время на прочтение 8 мин
Количество просмотров 2.8K
Продолжение статьи Как выглядит zip-архив и что мы с этим можем сделать. Часть 2 — Data Descriptor и сжатие.

Дорогие читатели, я снова приветствую вас на передаче Нетрадиционное программирование на PHP. Для понимания происходящего рекомендую ознакомиться с предыдущими двумя статьями о zip-архивах: Как выглядит zip-архив и что мы с этим можем сделать и Как выглядит zip-архив и что мы с этим можем сделать. Часть 2 — Data Descriptor и сжатие

Ранее я рассказывал как создавать архивы используя только лишь код на PHP и не применяя никакие библиотеки и расширения (в том числе и стандартное zip), а так же упоминал некоторые сценарии использования. Сегодня я постараюсь привести пример одного из таких сценариев.

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

Приготовления


В одном из моих пет-проектов есть простенький скрипт, который я применяю для скачивания пачки картинок и сохранения их в архив. Скрипт принимает на вход в STDIN список ссылок в json, в STDOUT отдает сам архив, а в STDERR json со структурой массива (конечно, это немного оверкилл и не обязательно создавать архив средствами native PHP, можно использовать более применимые для этого средства, а потом просто прочесть структуру — я постараюсь осветить этот момент в дальнейшем, но на данном этапе, как мне кажется, это будет более наглядно).

Этот скрипт, с некоторыми модификациями, я привожу ниже:

zip.php
<?php

$buffer = '';
while (!feof(STDIN)) {
    $buffer .= fread(STDIN, 4096);
}

$photos = json_decode($buffer, true, 512, JSON_THROW_ON_ERROR);

$skeleton = ['files' => []];
$written = 0;
    
[$written, $skeleton] = writeZip($written, $skeleton, $photos);

$CDFH_Offset = $written;

foreach ($skeleton['files'] as $index => $info) {
    $written += fwrite(STDOUT, $info['CDFH']);
    $skeleton['files'][$index]['CDFH'] = base64_encode($info['CDFH']);
}

$skeleton['EOCD'] = pack('LSSSSLLS', 0x06054b50, 0, 0, $records = count($skeleton['files']), $records, $written - $CDFH_Offset, $CDFH_Offset, 0); 
$written += fwrite(STDOUT, $skeleton['EOCD']);
$skeleton['EOCD'] = base64_encode($skeleton['EOCD']);

fwrite(STDERR, json_encode($skeleton));

function writeZip($written, $skeleton, $files)
{    
    $c = curl_init();
    
    curl_setopt_array($c, [
        CURLOPT_RETURNTRANSFER => 1,
        CURLOPT_TIMEOUT        => 50,
        CURLOPT_FOLLOWLOCATION => true,
    ]);

    foreach ($files as $index => $url) {
        $fileName = $index . '.jpg';
        
        for ($i = 0; $i < 1; $i++ ) {
            try {            
                curl_setopt($c, CURLOPT_URL, $url);
                
                [$content, $code, $contentLength] = [
                    curl_exec($c), 
                    (int) curl_getinfo($c, CURLINFO_HTTP_CODE), 
                    (int) curl_getinfo($c, CURLINFO_CONTENT_LENGTH_DOWNLOAD)
                ];
                
                if ($code !== 200) {
                    throw new \Exception("[{$index}] " . 'Photo download error (' . $code . '): ' . curl_error($c));
                }
                    
                if (strlen($content) !== $contentLength) {
                    var_dump(strlen($content), $contentLength);
                    throw new \Exception("[{$index}] " . 'Different content-length');
                }
                
                if ((false === $imageSize = @getimagesizefromstring($content)) || $imageSize[0] < 1 || $imageSize[1] < 1) {
                    throw new \Exception("[{$index}] " . 'Broken image');
                }            
                [$width, $height] = $imageSize;
                $t = null;
                break;
            } catch (\Throwable $t) {}
        }
        
        if ($t !== null) {
            throw new \Exception('Error: ' . $index . ' > ' . $url, 0, $t);
        }
        
        $fileInfo = [
            'versionToExtract'      => 10,
            'generalPurposeBitFlag' => 0,
            'compressionMethod'     => 0,
            'modificationTime'      => 28021,
            'modificationDate'      => 20072,
            'crc32'                 => hexdec(hash('crc32b', $content)),
            'compressedSize'        => $size = strlen($content),
            'uncompressedSize'      => $size,
            'filenameLength'        => strlen($fileName),
            'extraFieldLength'      => 0,
        ];
        
        $LFH_Offset = $written;
        
        $skeleton['files'][$index] = [
            'LFH'  => pack('LSSSSSLLLSSa*', 0x04034b50, ...array_values($fileInfo + ['filename' => $fileName])),
            'CDFH' => pack('LSSSSSSLLLSSSSSLLa*', 0x02014b50, 798, ...array_values($fileInfo + [
                'fileCommentLength' => 0,
                'diskNumber' => 0,
                'internalFileAttributes' => 0,
                'externalFileAttributes' => 2176057344,
                'localFileHeaderOffset' => $LFH_Offset,
                'filename' => $fileName,
            ])),
            'width' => $width,
            'height' => $height,
        ];
        
        $written += fwrite(STDOUT, $skeleton['files'][$index]['LFH']);
        $written += fwrite(STDOUT, $content);
        $skeleton['files'][$index]['LFH'] = base64_encode($skeleton['files'][$index]['LFH']);
    }
    
    curl_close($c);
    
    return [$written, $skeleton];
}

Давайте его куда-нибудь сохраним, назовем zip.php и попробуем запустить.

У меня уже есть готовый список ссылок, я рекомендую использовать его, так как там картинок примерно на ~18мб, а нам необходимо, чтоб общий размер архива не превышал 20мб (чуть позже я расскажу зачем) — https://gist.githubusercontent.com/userqq/d0ca3aba6b6762c9ce5bc3ace92a9f9e/raw/70f446eb98f1ba6838ad3c19c3346cba371fd263/photos.json

Запускать будем так:

$ curl -s \
    https://gist.githubusercontent.com/userqq/d0ca3aba6b6762c9ce5bc3ace92a9f9e/raw/70f446eb98f1ba6838ad3c19c3346cba371fd263/photos.json \
    | php zip.php \
    2> structure.json \
    1> photos.zip

Когда скрипт закончит свою работу, на выходе мы должны получить два файла — photos.zip, рекомендую его проверить командой:

$ unzip -tq photos.zip

и structure.json, в котором мы храним json c base64 всего, что есть в нашем архиве, кроме самих данных — все структуры LFH и CDFH, а так же EOCD. Практического применения EOCD отдельно от архива я пока не вижу, а вот в CDFH указана позиция смещения LFH относительно начала файла и длина данных. А значит, зная длину LFH мы можем наверняка знать с какой позиции начнутся данные и где они закончатся.

Теперь нам нужно залить наш файл на какой-нибудь удаленный сервер.

Для примера я буду использовать телеграм-бота — это проще всего, именно поэтому так важно было вписаться в лимит 20мб.

Регистрируем бота у @BotFather, если у вас еще такового нет, пишем своему боту что-нибудь приветственное, ищем свое сообщение в https://api.telegram.org/bot{{TOKEN}}/getUpdates, откуда вычленяем свойство message.from.id = это id вашего с ботом чата.

Заливаем наш с вами архив, полученный в предыдущем шаге:

$ curl -F document=@"photos.zip" "https://api.telegram.org/bot{{TOKEN}}/sendDocument?chat_id={{CHAT ID}}" > stored.json

Теперь у нас есть аж два json файла — structure.json и stored.json.

И если все прошло хорошо, то файл stored.json будет содержать json с полями ok, равным true, а еще result.document.file_id, который нам то и нужен.

Прототип


Теперь, когда все наконец-то готово, займемся делом:

<?php

define('TOKEN', ''); // токен телеграм бота

$structure = json_decode(file_get_contents('structure.json'), true, 512, JSON_THROW_ON_ERROR);
$stored = json_decode(file_get_contents('stored.json'), true, 512, JSON_THROW_ON_ERROR);

$selectedFile = $structure['files'][array_rand($structure['files'])];
$LFH = base64_decode($selectedFile['LFH']);
$CDFH = base64_decode($selectedFile['CDFH']);

$fileLength = unpack('Llen', substr($CDFH, 24, 4))['len'];
$fileStart = unpack('Lpos', substr($CDFH, 42, 4))['pos'] + strlen($LFH);
$fileEnd = $fileStart + $fileLength;

$response = json_decode(file_get_contents('https://api.telegram.org/bot' . TOKEN  . '/getFile?' . http_build_query([
    'file_id' => $stored['result']['document']['file_id']
])), true, 512, JSON_THROW_ON_ERROR);

header('Content-Type: image/jpeg');
readfile('https://api.telegram.org/file/bot' . TOKEN . '/' . $response['result']['file_path'], false, stream_context_create([
    'http' => ['header'  => "Range: bytes={$fileStart}-{$fileEnd}"]
]));

В этом скрипте мы выбираем случайный файл из списка имеющихся у нас в архиве, находим позицию начала LFH, прибавляем к ней длину LFH и таким образом получаем позицию начала данных конкретного файла в архиве.

Прибавив к этому длину файла мы получим позицию конца данных.

Осталось получить адрес самого файла (это в конкретно взятом случае с телеграмом) и тогда достаточно просто сделать запрос с заголовком Range: bytes=<начало-диапазона>-<конец-диапазона>

(при условии, что сервер поддерживает Range запросы)

В итоге при обращении к указанному скрипту мы будем видеть содержимое случайной картинки из нашего архива.

Не, ну это не серьезно...


Само собой, что запрос к getFile следовало бы закешировать, ведь телеграм гарантирует нам, что ссылка проживет минимум час. И вообще этот пример не годится ни на что, кроме как показать, что это работает.

Давайте попробуем чуть сложнее — запустим это все на amphp. Ведь раздают же люди статику в production на node.js, а что нам мешает (ну кроме здравого смысла, само собой)? У нас тут тоже асинхронность, знаете ли.

Нам понадобится 3 пакета:

  • amphp/http-server — очевидно, сервер, которым мы будем раздавать файлы
  • amphp/artax — http-клиент, которым мы будем вытаскивать данные, а так же обновлять прямую ссылку на файл в кеше.
  • amphp/parallel — библиотека для многопоточности и всего такого. Но не бойтесь, нам из нее нужен только SharedMemoryParcel, который будет служить нам в качестве im-memory кеша.

Ставим нужные зависимости:

$ composer require amphp/http-server amphp/artax amphp/parallel

И пишем вот такой

Скрипт

<?php

define('TOKEN', ''); // токен телеграм бота

use Amp\Artax\DefaultClient;
use Amp\Artax\Request as ArtaxRequest;
use Amp\Http\Server\RequestHandler\CallableRequestHandler;
use Amp\Http\Server\Server as HttpServer;
use Amp\Http\Server\Request;
use Amp\Http\Server\Response;
use Amp\Http\Status;
use Amp\Parallel\Sync\SharedMemoryParcel;
use Amp\Socket;
use Psr\Log\NullLogger;

require 'vendor/autoload.php';

Amp\Loop::run(function () {
    $structure = json_decode(file_get_contents('structure.json'), true, 512, JSON_THROW_ON_ERROR);
    $stored = json_decode(file_get_contents('stored.json'), true, 512, JSON_THROW_ON_ERROR);
    
    $parcel = SharedMemoryParcel::create($id = bin2hex(random_bytes(10)), []);
    $client = new DefaultClient();
    
    $handler = function (Request $request) use ($parcel, $client, $structure, $stored) {
        $cached = yield $parcel->synchronized(function (array $value) use ($client, $stored) {
            if (!isset($value['file_path']) || $value['expires'] <= time()) {
                $response = yield $client->request('https://api.telegram.org/bot' . TOKEN  . '/getFile?' . http_build_query([
                    'file_id' => $stored['result']['document']['file_id']
                ]));
                $json = json_decode(yield $response->getBody(), true, 512, JSON_THROW_ON_ERROR);
                
                $value = ['file_path' => $json['result']['file_path'], 'expires' => time() + 55 * 60];
            }
            
            return $value;
        });
        
        $selectedFile = $structure['files'][array_rand($structure['files'])];
        $LFH = base64_decode($selectedFile['LFH']);
        $CDFH = base64_decode($selectedFile['CDFH']);

        $fileLength = unpack('Llen', substr($CDFH, 24, 4))['len'];
        $fileStart = unpack('Lpos', substr($CDFH, 42, 4))['pos'] + strlen($LFH);
        $fileEnd = $fileStart + $fileLength;
        
        $request = (new ArtaxRequest('https://api.telegram.org/file/bot' . TOKEN . '/' . $cached['file_path']))
            ->withHeader('Range', "bytes={$fileStart}-{$fileEnd}");
        $response = yield $client->request($request);
        
        if (!in_array($response->getStatus(), [200, 206])) {
            return new Response(Status::SERVICE_UNAVAILABLE, ['content-type' => 'text/plain'], "Service Unavailable.");
        }
        
        return new Response(Status::OK, ['content-type' => 'image/jpeg'], $response->getBody());
    };
    
    $server = new HttpServer([Socket\listen("0.0.0.0:10051")], new CallableRequestHandler($handler), new NullLogger);
    yield $server->start();

    Amp\Loop::onSignal(SIGINT, function (string $watcherId) use ($server) {
        Amp\Loop::cancel($watcherId);
        yield $server->stop();
    });
});

Что мы получаем в результате: у нас теперь есть кеш, где мы сохраняем ссылку на документ на 55 минут, который так же гарантирует что мы не будем запрашивать ссылку несколько раз подряд, если нам придет много запросов в момент истечения срока действия кеша. Ну и все это явно легче чем readfile с PHP-FPM (или, не дай бог, PHP-CGI).

Дальше можно поднять пул инстансов amphp — SharedMemoryParcel своим названием намекает, что наш кеш будет шариться между процессами/потоками. Ну или, если у вас все же есть здоровые опасения по поводу надежности этой конструкции, то proxy_pass и nginx.

Вообще идея далеко не нова и еще в бородатые годы DownloadMaster позволял скачать из Zip-архива конкретный файл, не скачивая весь архив, поэтому хранить структуру отдельно не обязательно, но экономит пару-тройку запросов. Что в случае, если мы, например, хотим проксировать файл пользователю на лету, довольно сильно влияет на скорость выполнения запроса.

Зачем это может пригодиться? В целях экономии инод, например, когда у вас десятки миллионов мелких файлов и не хочется хранить их непосредственно в БД — можно хранить их в архивах, зная смещение получать отдельно взятый файл.

Или, если вы храните файлы удаленно. В телеграме, по 20мб, конечно, не разгуляешься, но как знать, есть и другие варианты.
Теги:
Хабы:
+16
Комментарии 3
Комментарии Комментарии 3

Публикации

Истории

Работа

PHP программист
146 вакансий

Ближайшие события

PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн
Weekend Offer в AliExpress
Дата 20 – 21 апреля
Время 10:00 – 20:00
Место
Онлайн