Туториали

Symfony: Automate Client Website Before/After Snapshots for Seamless Updates

Symfony: Автоматизирајте ги снимките пред/по ажурирање на веб-страницата на клиентот за беспрекорни надградби

Ажурирањето на веб-страница може да ги помине сите автоматизирани тестови, а сепак да испорача визуелно изненадување: недостасувачка главна слика, склопена навигациска лента или мобилна точка на прекин што веќе не се однесува правилно. Снимките од екранот пред и по промената им даваат на развивачите и клиентите траен визуелен запис за секое издание, без тимот да мора да одржува прелистувачи, драјвери и Chromium контејнери.

Ова упатство го гради тој работен тек како продукциска Symfony команда. Таа снима неколку јавни страници пред распоредувањето, го повторува снимањето потоа, го валидира секој PNG, ги евидентира релевантните заглавија за кеш и квота и го зачувува секој сет атомски под идентификатор на издание. Командата е соодветна за лаптоп на развивач, CI процес или хост за распоредување.

Добијте пристап до Screenshot API

Регистрирајте се преку страницата за регистрација, или користете ја страницата за најава ако веќе имате сметка.

  1. Отворете ја страницата на услугата Screenshot API.
  2. Изберете достапен Free, Plus или Pro план и завршете ја неговата активација.
  3. Отворете ја официјалната документација.
  4. Најдете го панелот Service token и копирајте го токенот ограничен на услугата.

Оваа услуга бара автентикација. Прифаќа Bearer токен, заглавие X-API-Token или параметар за барање token. Bearer токенот ги држи акредитивите надвор од URL-адресите, историјата на прелистувачот, дневниците на барања на прокси-серверите и повеќето метрики за барања, па затоа тука се користи тој метод.

Повторното генерирање на сервисниот токен го повлекува претходно активниот токен. Третирајте го повторното генерирање како ротација на акредитиви: ажурирајте ја секоја околина за распоредување пред да ја отстраните старата конфигурација од процесот за управување со тајни.

Потврдете ја крајната точка пред да пишувате апликациски код

Точното барање е GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture, со задолжителниот параметар за барање url. Успешниот одговор содржи тело image/png, заедно со заглавија на одговорот поврзани со кешот и квотата.

curl --fail-with-body --silent --show-error \
  --get 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture' \
  --header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
  --data-urlencode 'url=https://client.example/' \
  --output homepage.png

Отворете homepage.png и потврдете дека тоа е очекуваната страница пред да продолжите. Не ја предавајте таа тест-слика во репозиториумот ако содржи приватен материјал од клиентот.

За локален Symfony развој, поставете го токенот во .env.local, кој треба да остане непредаден во репозиториумот. Во продукција, внесете ја истата променлива преку хостинг-платформата или управувачот со тајни, наместо да ја вградите во сликата на контејнерот.

# .env.local
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'

    App\Screenshot\ScreenshotClient:
        arguments:
            $screenshotApiToken: '%env(string:SCREENSHOT_API_TOKEN)%'

Архитектура: мала граница со силни гаранции

Проектот намерно користи конзолна команда наместо HTTP контролер. Снимањата припаѓаат во контролиран процес на издание, а не зад јавна рута што би можела да троши квота или да претвори произволен кориснички влез во работа за далечинско прелистување.

Имплементацијата има три дела:

  • ScreenshotClient управува со автентикацијата, временските ограничувања, повторните обиди, валидацијата на одговорите и извлекувањето заглавија.
  • ScreenshotCapture го пресликува далечинскиот одговор во доменска вредност што содржи PNG бајти и оперативни метаподатоци.
  • CaptureSnapshotsCommand снима комплетен именуван сет во привремен директориум, а потоа го промовира атомски.

Инсталирајте ги компонентите од прва страна ако апликацијата веќе не ги содржи:

composer require symfony/http-client symfony/console symfony/filesystem
composer require --dev symfony/phpunit-bridge

Резултирачките датотеки се src/Screenshot/ScreenshotCapture.php, src/Screenshot/ScreenshotFailure.php, src/Screenshot/ScreenshotClient.php, src/Command/CaptureSnapshotsCommand.php и tests/Screenshot/ScreenshotClientTest.php.

Пресликајте и валидирајте го API одговорот

Далечинските бинарни податоци не треба да протекуваат низ целата апликација како неструктуриран објект на одговор. Следниве класи за вредност и исклучок го прават успехот и неуспехот експлицитни.

<?php
// src/Screenshot/ScreenshotCapture.php
namespace App\Screenshot;

final readonly class ScreenshotCapture
{
    public function __construct(
        public string $png,
        public int $status,
        public array $operationalHeaders,
    ) {}
}

// src/Screenshot/ScreenshotFailure.php
namespace App\Screenshot;

final class ScreenshotFailure extends \RuntimeException
{
    public function __construct(
        public readonly string $kind,
        public readonly int $status = 0,
        public readonly array $operationalHeaders = [],
        ?\Throwable $previous = null,
    ) {
        parent::__construct(
            sprintf('Screenshot capture failed: %s (HTTP %d)', $kind, $status),
            0,
            $previous,
        );
    }
}

Клиентот ги ограничува и времето за поврзување и вкупното време на одговор. Тој двапати повторува транспортни неуспеси и 5xx одговори од серверската страна со кратко експоненцијално повлекување. Не повторува слепо неправилно обликувани барања, одбиени акредитиви или HTTP 429 одговори: за нив се потребни одлуки за конфигурација, токен, квота или распоредување наместо уште едно непосредно барање.

<?php
// src/Screenshot/ScreenshotClient.php
namespace App\Screenshot;

use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class ScreenshotClient
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/screenshot-api/v1/capture';

    public function __construct(
        private readonly HttpClientInterface $http,
        private readonly string $screenshotApiToken,
        private readonly LoggerInterface $logger,
        private readonly ?\Closure $sleep = null,
    ) {}

    public function capture(string $url): ScreenshotCapture
    {
        if (!filter_var($url, FILTER_VALIDATE_URL)
            || !in_array(parse_url($url, PHP_URL_SCHEME), ['https', 'http'], true)
        ) {
            throw new \InvalidArgumentException('A valid HTTP(S) URL is required.');
        }

        for ($attempt = 1; $attempt <= 3; ++$attempt) {
            try {
                $response = $this->http->request('GET', self::ENDPOINT, [
                    'auth_bearer' => $this->screenshotApiToken,
                    'query' => ['url' => $url],
                    'timeout' => 5.0,
                    'max_duration' => 30.0,
                ]);

                $status = $response->getStatusCode();
                $headers = $this->operationalHeaders(
                    $response->getHeaders(false)
                );

                if ($status >= 500 && $attempt < 3) {
                    $this->backoff($attempt);
                    continue;
                }

                if ($status === 429) {
                    throw new ScreenshotFailure('quota_or_rate_limit', $status, $headers);
                }

                if ($status === 401 || $status === 403) {
                    throw new ScreenshotFailure('authentication', $status, $headers);
                }

                if ($status !== 200) {
                    throw new ScreenshotFailure('unexpected_status', $status, $headers);
                }

                $contentType = strtolower($response->getHeaders(false)['content-type'][0] ?? '');
                $png = $response->getContent(false);

                if (!str_starts_with($contentType, 'image/png')
                    || !str_starts_with($png, "\x89PNG\r\n\x1a\n")
                ) {
                    throw new ScreenshotFailure('invalid_png', $status, $headers);
                }

                $this->logger->info('Screenshot captured', [
                    'target_host' => parse_url($url, PHP_URL_HOST),
                    'target_hash' => hash('sha256', $url),
                    'bytes' => strlen($png),
                    'attempt' => $attempt,
                ]);

                return new ScreenshotCapture($png, $status, $headers);
            } catch (TransportExceptionInterface $exception) {
                if ($attempt === 3) {
                    throw new ScreenshotFailure(
                        'transport',
                        previous: $exception,
                    );
                }

                $this->backoff($attempt);
            }
        }

        throw new ScreenshotFailure('retry_exhausted');
    }

    private function backoff(int $attempt): void
    {
        $microseconds = 200_000 * (2 ** ($attempt - 1));
        ($this->sleep ?? static fn (int $delay) => usleep($delay))($microseconds);
    }

    private function operationalHeaders(array $headers): array
    {
        return array_filter(
            $headers,
            static fn (string $name): bool =>
                preg_match('/cache|quota|rate-limit|retry-after/i', $name) === 1,
            ARRAY_FILTER_USE_KEY,
        );
    }
}

Маперот на заглавија намерно не претпоставува недокументирани имиња на заглавија. Тој ги задржува вратените заглавија чии имиња укажуваат на информации за кеш, квота, ограничување на стапка или време за повторен обид. Ова зачувува корисни докази, а однесувањето на апликацијата останува независно од шпекулативна шема на одговор.

Снимете атомски сет пред или по промената

Командата прифаќа фаза, идентификатор на издание и една или повеќе URL-адреси. Таа прво запишува во привремен директориум. Ако некоја страница не успее, привремениот сет се отстранува; корисниците никогаш не мешаат нецелосно извршување со валидна споредба.

<?php
// src/Command/CaptureSnapshotsCommand.php
namespace App\Command;

use App\Screenshot\ScreenshotClient;
use App\Screenshot\ScreenshotFailure;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Filesystem\Filesystem;

#[AsCommand(
    name: 'app:snapshots:capture',
    description: 'Capture an atomic before-or-after website snapshot set.',
)]
final class CaptureSnapshotsCommand extends Command
{
    public function __construct(
        private readonly ScreenshotClient $client,
        private readonly Filesystem $filesystem,
        private readonly string $projectDir,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addArgument('phase', InputArgument::REQUIRED, 'before or after')
            ->addArgument('release', InputArgument::REQUIRED, 'Safe release identifier')
            ->addArgument(
                'urls',
                InputArgument::IS_ARRAY | InputArgument::REQUIRED,
                'Public URLs to capture',
            );
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $phase = (string) $input->getArgument('phase');
        $release = (string) $input->getArgument('release');
        $urls = $input->getArgument('urls');

        if (!in_array($phase, ['before', 'after'], true)) {
            $output->writeln('<error>Phase must be before or after.</error>');
            return Command::INVALID;
        }

        if (preg_match('/\A[a-zA-Z0-9._-]+\z/', $release) !== 1) {
            $output->writeln('<error>Release contains unsafe characters.</error>');
            return Command::INVALID;
        }

        $root = $this->projectDir.'/var/snapshots/'.$release;
        $target = $root.'/'.$phase;
        $staging = $root.'/'.sprintf('.%s-%s', $phase, bin2hex(random_bytes(6)));

        if (is_dir($target)) {
            $output->writeln('<error>This snapshot set already exists.</error>');
            return Command::FAILURE;
        }

        $this->filesystem->mkdir($staging);
        $manifest = [];

        try {
            foreach ($urls as $url) {
                $capture = $this->client->capture($url);
                $name = $this->filename($url);

                $this->filesystem->dumpFile($staging.'/'.$name.'.png', $capture->png);
                $manifest[] = [
                    'url' => $url,
                    'file' => $name.'.png',
                    'bytes' => strlen($capture->png),
                    'http_status' => $capture->status,
                    'operational_headers' => $capture->operationalHeaders,
                ];
            }

            $this->filesystem->dumpFile(
                $staging.'/manifest.json',
                json_encode(
                    $manifest,
                    JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES,
                )."\n",
            );
            $this->filesystem->rename($staging, $target);
        } catch (ScreenshotFailure | \Throwable $exception) {
            $this->filesystem->remove($staging);
            $output->writeln('<error>'.$exception->getMessage().'</error>');
            return Command::FAILURE;
        }

        $output->writeln(sprintf(
            '<info>Captured %d %s snapshots in %s</info>',
            count($manifest),
            $phase,
            $target,
        ));

        return Command::SUCCESS;
    }

    private function filename(string $url): string
    {
        $source = (parse_url($url, PHP_URL_HOST) ?: 'page')
            .'-'.(parse_url($url, PHP_URL_PATH) ?: 'home');
        $slug = trim((string) preg_replace('/[^a-z0-9]+/i', '-', $source), '-');

        return substr($slug, 0, 80).'-'.substr(hash('sha256', $url), 0, 10);
    }
}

Symfony може автоматски да го вметне $projectDir од неговиот стандарден параметар за проектен директориум кога е експлицитно поврзан:

# config/services.yaml
    App\Command\CaptureSnapshotsCommand:
        arguments:
            $projectDir: '%kernel.project_dir%'

Автоматизирајте го околу распоредувањето

Снимете го истиот подреден сет URL-адреси од двете страни на распоредувањето. Командата пред распоредувањето мора да се изврши додека старата верзија сè уште опслужува сообраќај; командата по распоредувањето треба да се изврши само откако апликацијата и нејзините јавни средства се исправни.

set -euo pipefail

RELEASE_ID="${CI_COMMIT_SHA:-manual-$(date -u +%Y%m%dT%H%M%SZ)}"
SNAPSHOT_URLS=(
  'https://client.example/'
  'https://client.example/services'
  'https://client.example/contact'
)

php bin/console app:snapshots:capture before "$RELEASE_ID" "${SNAPSHOT_URLS[@]}"

# Run the application's existing deployment and health-check steps here.

php bin/console app:snapshots:capture after "$RELEASE_ID" "${SNAPSHOT_URLS[@]}"

Архивирајте var/snapshots/$RELEASE_ID како приватен CI артефакт или копирајте го во складиште на објекти со контролиран пристап. Не поставувајте снимки под public/: дури и јавните страници може да откријат време на издание, персонализирана содржина, банери за преглед или информации за клиентите.

Тестирајте ја границата без мрежни повици

MockHttpClient му дава на тестот детерминистички транспорт. Првиот тест докажува пресликување на бинарни податоци и метаподатоци; вториот докажува дека неуспесите на автентикацијата се враќаат веднаш наместо да се повторат.

<?php
// tests/Screenshot/ScreenshotClientTest.php
namespace App\Tests\Screenshot;

use App\Screenshot\ScreenshotClient;
use App\Screenshot\ScreenshotFailure;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class ScreenshotClientTest extends TestCase
{
    public function testItMapsAValidatedPngResponse(): void
    {
        $png = "\x89PNG\r\n\x1a\nfixture";
        $http = new MockHttpClient(new MockResponse($png, [
            'http_code' => 200,
            'response_headers' => [
                'content-type: image/png',
                'cache-control: max-age=60',
                'x-test-quota: 9',
            ],
        ]));

        $client = new ScreenshotClient(
            $http,
            'test-token',
            new NullLogger(),
            static fn (int $microseconds) => null,
        );

        $capture = $client->capture('https://client.example/');

        self::assertSame($png, $capture->png);
        self::assertSame(200, $capture->status);
        self::assertArrayHasKey('cache-control', $capture->operationalHeaders);
        self::assertArrayHasKey('x-test-quota', $capture->operationalHeaders);
    }

    public function testItDoesNotRetryAuthenticationFailure(): void
    {
        $requests = 0;
        $http = new MockHttpClient(
            function () use (&$requests): MockResponse {
                ++$requests;
                return new MockResponse('rejected', ['http_code' => 401]);
            }
        );

        $client = new ScreenshotClient(
            $http,
            'test-token',
            new NullLogger(),
            static fn (int $microseconds) => null,
        );

        try {
            $client->capture('https://client.example/');
            self::fail('Expected ScreenshotFailure.');
        } catch (ScreenshotFailure $failure) {
            self::assertSame('authentication', $failure->kind);
            self::assertSame(1, $requests);
        }
    }
}
php bin/phpunit
php bin/console app:snapshots:capture before local-check \
  'https://client.example/' \
  'https://client.example/contact'

Безбедност, набљудливост и оперативни неуспеси

Ограничете ги целите за снимање на одобрена листа на имиња на хостови ако URL-адресите може да потекнуваат од каде било освен од доверлива конфигурација за распоредување. Ова ја штити квотата, спречува случајно снимање чувствителни одредишта и го прави сетот артефакти предвидлив. Чувајте ги акредитивите за преглед надвор од целните URL-адреси; низите за барање може да се појават во манифести и листи на процеси.

Дневниците треба да ги содржат целниот хост, хаш на URL-адресата, бројот на бајти, бројот на обидот, фазата и идентификаторот на изданието. Никогаш не ги евидентирајте токенот, заглавијата за авторизација, PNG телото или неограничена целна URL-адреса. Алармирајте одделно за неуспеси на автентикацијата, неуспеси поради стапка или квота, исцрпени повторни обиди, невалидни PNG одговори и нецелосни снимања по распоредувањето.

Резултат HTTP 429 треба да го запре сетот и да ги зачува неговите оперативни заглавија за дијагностика. Користете ги вратените информации за време на повторен обид при закажување подоцнежно извршување, но ограничете ги автоматизираните доцнења според прозорецот за распоредување. 401 или 403 обично укажува на недостасувачки, повлечен или неправилно распоредeн сервисен токен. Невалиден PNG често значи дека услугата вратила неочекуван одговор и покрај неговиот статус, па неговото отфрлање е побезбедно од зачувување оштетен артефакт.

За повеќе CI работници, направете го идентификаторот на изданието единствен и дозволете само една задача за снимки по издание. Намерно дефинирајте го задржувањето на артефактите: снимките се корисни за преглед на изданието, но неограниченото складирање ги зголемува трошоците и изложеноста на приватноста.

Конечна листа за верификација

  • Планот на услугата е активен и токенот потекнува од панелот Service token на страницата со документација.
  • SCREENSHOT_API_TOKEN се внесува при извршување и не е присутен во изворната контрола, дневниците, фикстурите и сликите.
  • Минималното барање враќа валиден PNG за одобрена јавна URL-адреса.
  • Командата создава целосни директориуми before и after со соодветни имиња на датотеки и манифести.
  • Транспортните и 5xx неуспесите добиваат само ограничени повторни обиди; неуспесите на автентикација, валидација и квота не влегуваат во слепа јамка.
  • Заглавијата на одговорот поврзани со кешот и квотата се задржуваат без претпоставка на недокументирани имиња.
  • Тестовите поминуваат со MockHttpClient и ниту еден тест не стигнува до услугата во живо.
  • Артефактите од снимките имаат контролиран пристап, се задржуваат определен период и се исклучени од јавниот веб-корен.

Највредните докази за издание се докази што луѓето навистина ќе ги создаваат. Со сведување на визуелното снимање на две предвидливи Symfony команди, секое ажурирање може да носи сопствен запис пред и по промената. Инфраструктурата за прелистувачот исчезнува од вашата листа за одржување, додека разговорот со клиентот станува конкретен: не „распоредувањето веројатно ги промени само овие страници“, туку „еве точно како изгледаше страницата од двете страни на изданието“.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.