Symfony: Автоматско снимање состојба на веб-страниците на клиентите пред и по ажурирањето
Ажурирањето на веб-страница може да биде технички успешно, а сепак визуелно погрешно. Недостасувачки фонт, неочекуван банер за колачиња, расипана главна слика или CSS пакет послужен од стар кеш може да ги избегне здравствените проверки, а сепак да остане очигледен за клиентот.
Овој туторијал создава Symfony работен тек ориентиран кон продукција што снима PNG непосредно пред распоредувањето и уште еден веднаш потоа. Снимките се групирани по издание, придружени со метаподатоци за кеш и квота и се погодни за задржување како CI артефакти. Апликацијата го делегира прикажувањето во прелистувачот на Screenshot API, па не мора да инсталира, закрпува или управува со Chromium.
Добијте пристап пред да пишувате интеграциски код
- Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
- Отворете ја страницата на услугата Screenshot API.
- Изберете го достапниот Free, Plus или Pro план и завршете ја неговата активација.
- Отворете ја официјалната документација за Screenshot API.
- Пронајдете го панелот Service token и копирајте го токенот ограничен на услугата.
Услугата бара автентикација. Прифаќа Bearer токен, заглавие X-API-Token или параметар за пребарување token. Оваа имплементација користи Bearer токен бидејќи поверојатно е акредитивите во низите за пребарување да се појават во дневниците за пристап, историјата на прелистувачот и системите за надзор.
Повторното генерирање на сервисниот токен го поништува претходно активниот токен. Третирајте ја ротацијата како координирано распоредување: инсталирајте ја новата вредност насекаде каде што се извршува командата за снимање, потврдете ја и повлечете го секој процес што сè уште е конфигуриран со старата вредност.
Потврдете ја крајната точка со едно минимално барање
Точната операција е GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Нејзиниот задолжителен параметар за пребарување url ја одредува страницата што треба да се прикаже. Успешниот одговор содржи тело image/png плус заглавија на одговорот што го опишуваат однесувањето на кеширањето и квотата.
curl --fail-with-body --silent --show-error \
--get \
--header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
--data-urlencode 'url=https://client.example/' \
--dump-header /tmp/screenshot.headers \
--output /tmp/screenshot.png \
https://ai.mihajlo.mk/api/screenshot-api/v1/capture
file /tmp/screenshot.png
Задржете ги извезените заглавија за време на оваа проверка. Достапноста и именувањето на заглавијата треба да се прочитаат од вистинскиот одговор и официјалната документација, наместо да се нагаѓаат во кодот на апликацијата.
Структура на проектот и оперативни избори
Примерот е наменет за PHP 8.3 и тековна Symfony апликација со Console, HttpClient и PSR-компатибилен логер. Инсталирајте ги потребните компоненти од прва страна и поддршката за тестирање:
composer require symfony/http-client symfony/console symfony/monolog-bundle
composer require --dev symfony/test-pack
mkdir -p var/snapshots
Релевантните датотеки намерно се малку:
src/Screenshot/ScreenshotCapture.phpмапира успешен далечински одговор во доменот.src/Screenshot/ScreenshotException.phpизложува стабилни категории на грешки.src/Screenshot/ScreenshotClient.phpуправува со автентикацијата, временските ограничувања, повторните обиди, PNG валидацијата и мапирањето на заглавијата на одговорот.src/Command/CaptureSnapshotCommand.phpго валидира влезот за распоредување и атомски запишува артефакти за изданието.tests/Screenshot/ScreenshotClientTest.phpја испитува границата со детерминистички лажен транспорт.
Синхроната конзолна команда тука е попожелна од Messenger. Снимањето пред распоредувањето мора да заврши пред да почне распоредувањето, а снимањето потоа смее да се изврши само откако новата страница ќе ја помине проверката на подготвеност. Позадинскиот ред би го ослабнал тој редослед, а би додал мала вредност.
Конфигурирајте тајни и вбризгување зависности
Ставете ја локалната акредитива во .env.local, која треба да остане надвор од контрола на верзии:
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SNAPSHOT_ALLOWED_HOST=client.example
Продукцијата треба да ги вбризгува истите променливи преку механизмот за тајни на платформата за распоредување. Не ставајте вистински токен во .env, контрола на изворниот код, фикстури, аргументи на команди, дневници или зачувани метаподатоци за снимки.
Додајте ги скаларните поврзувања во config/services.yaml:
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
App\Screenshot\ScreenshotClient:
arguments:
$token: '%env(SCREENSHOT_API_TOKEN)%'
App\Command\CaptureSnapshotCommand:
arguments:
$projectDir: '%kernel.project_dir%'
$allowedHost: '%env(SNAPSHOT_ALLOWED_HOST)%'
Изградете дефанзивна граница за Screenshot API
Апликацијата не треба да пренесува необработени HTTP одговори низ остатокот од системот. Мапирајте ги успешните одговори во непроменлив објект, а неуспесите во категории што команда, тест или правило за надзор може да ги разбере.
<?php
// src/Screenshot/ScreenshotCapture.php
namespace App\Screenshot;
final readonly class ScreenshotCapture
{
/**
* @param array<string, list<string>> $serviceHeaders
*/
public function __construct(
public string $png,
public array $serviceHeaders,
) {}
}
// src/Screenshot/ScreenshotException.php
namespace App\Screenshot;
final class ScreenshotException extends \RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
Клиентот дозволува три обиди за привремени транспортни и серверски неуспеси. Неуспесите на автентикација и валидација никогаш не се повторуваат. Одговор 429 се повторува само кога обезбедува кратко нумеричко Retry-After; исцрпено или долго доцнење поради квота треба да се прикаже на системот за распоредување наместо задачата да спие неодредено време.
<?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';
/** @var \Closure(int): void */
private \Closure $sleep;
public function __construct(
private readonly HttpClientInterface $http,
private readonly LoggerInterface $logger,
private readonly string $token,
?\Closure $sleep = null,
) {
$this->sleep = $sleep ?? static fn (int $microseconds) =>
usleep($microseconds);
}
public function capture(string $url): ScreenshotCapture
{
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->http->request('GET', self::ENDPOINT, [
'auth_bearer' => $this->token,
'query' => ['url' => $url],
'timeout' => 10.0,
'max_duration' => 30.0,
]);
$status = $response->getStatusCode();
$headers = $response->getHeaders(false);
if ($status === 429) {
$retryAfter = $headers['retry-after'][0] ?? null;
if (
$attempt < 3
&& is_string($retryAfter)
&& ctype_digit($retryAfter)
&& (int) $retryAfter <= 2
) {
($this->sleep)((int) $retryAfter * 1_000_000);
continue;
}
throw new ScreenshotException(
'quota',
'Screenshot quota or rate limit prevented capture.'
);
}
if ($status >= 500 && $attempt < 3) {
($this->sleep)($attempt === 1 ? 250_000 : 750_000);
continue;
}
if ($status === 401 || $status === 403) {
throw new ScreenshotException(
'authentication',
'Screenshot API authentication was rejected.'
);
}
if ($status < 200 || $status >= 300) {
throw new ScreenshotException(
'request',
sprintf('Screenshot API returned HTTP %d.', $status)
);
}
$body = $response->getContent(false);
$contentType = strtolower(
explode(';', $headers['content-type'][0] ?? '')[0]
);
if (
$contentType !== 'image/png'
|| !str_starts_with($body, "\x89PNG\r\n\x1a\n")
) {
throw new ScreenshotException(
'invalid_response',
'Screenshot API did not return a valid PNG response.'
);
}
$metadata = array_filter(
$headers,
static fn (string $name): bool =>
str_contains($name, 'cache')
|| str_contains($name, 'quota')
|| str_contains($name, 'rate'),
ARRAY_FILTER_USE_KEY
);
return new ScreenshotCapture($body, $metadata);
} catch (TransportExceptionInterface $exception) {
if ($attempt === 3) {
throw new ScreenshotException(
'transport',
'Screenshot API could not be reached.',
$exception
);
}
$this->logger->warning('Screenshot transport retry.', [
'attempt' => $attempt,
'exception_class' => $exception::class,
]);
($this->sleep)($attempt === 1 ? 250_000 : 750_000);
}
}
throw new ScreenshotException('internal', 'Capture loop ended unexpectedly.');
}
}
Изборот на метаподатоци е намерно дефанзивен: ги задржува заглавијата чии имиња укажуваат на информации за кеш, квота или ограничување на стапката, без да измислува договор за заглавија специфичен за добавувачот. Токенот и телото на одговорот никогаш не се евидентираат.
Создадете команда за распоредување
Командата прифаќа идентификатор на издание, фаза и јавен HTTPS URL. Таа ги ограничува снимањата на еден конфигуриран клиентски хост, спречувајќи случајно изложена команда да троши квота на произволни цели. Идентификаторите на изданијата исто така се ограничени пред да станат имиња на директориуми.
<?php
// src/Command/CaptureSnapshotCommand.php
namespace App\Command;
use App\Screenshot\ScreenshotClient;
use App\Screenshot\ScreenshotException;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:snapshots:capture')]
final class CaptureSnapshotCommand extends Command
{
public function __construct(
private readonly ScreenshotClient $screenshots,
private readonly LoggerInterface $logger,
private readonly string $projectDir,
private readonly string $allowedHost,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('release', null, InputOption::VALUE_REQUIRED)
->addOption('phase', null, InputOption::VALUE_REQUIRED)
->addOption('url', null, InputOption::VALUE_REQUIRED);
}
protected function execute(
InputInterface $input,
OutputInterface $output
): int {
$release = (string) $input->getOption('release');
$phase = (string) $input->getOption('phase');
$url = (string) $input->getOption('url');
$host = parse_url($url, PHP_URL_HOST);
$scheme = parse_url($url, PHP_URL_SCHEME);
if (!preg_match('/\A[a-zA-Z0-9._-]{1,80}\z/', $release)) {
$output->writeln('<error>Invalid release identifier.</error>');
return Command::INVALID;
}
if (!in_array($phase, ['before', 'after'], true)) {
$output->writeln('<error>Phase must be before or after.</error>');
return Command::INVALID;
}
if ($scheme !== 'https' || $host !== $this->allowedHost) {
$output->writeln('<error>URL must use the allowed HTTPS host.</error>');
return Command::INVALID;
}
try {
$capture = $this->screenshots->capture($url);
$directory = $this->projectDir.'/var/snapshots/'.$release;
if (!is_dir($directory) && !mkdir($directory, 0770, true)) {
throw new \RuntimeException('Could not create snapshot directory.');
}
$this->atomicWrite($directory.'/'.$phase.'.png', $capture->png);
$this->atomicWrite(
$directory.'/'.$phase.'.json',
json_encode([
'release' => $release,
'phase' => $phase,
'captured_at' => gmdate(DATE_ATOM),
'service_headers' => $capture->serviceHeaders,
], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)
);
$this->logger->info('Website snapshot captured.', [
'release' => $release,
'phase' => $phase,
'host' => $host,
]);
$output->writeln('Snapshot saved for '.$release.' '.$phase.'.');
return Command::SUCCESS;
} catch (ScreenshotException $exception) {
$this->logger->error('Website snapshot failed.', [
'release' => $release,
'phase' => $phase,
'kind' => $exception->kind,
]);
$output->writeln('<error>Capture failed: '.$exception->kind.'</error>');
return Command::FAILURE;
}
}
private function atomicWrite(string $path, string $contents): void
{
$temporary = tempnam(dirname($path), 'snapshot-');
if ($temporary === false || file_put_contents($temporary, $contents) === false) {
throw new \RuntimeException('Could not write temporary snapshot.');
}
if (!rename($temporary, $path)) {
@unlink($temporary);
throw new \RuntimeException('Could not publish snapshot.');
}
}
}
Тестирајте без да ја повикувате вистинската услуга
MockHttpClient ги прави редоследот и содржината на одговорите детерминистички. Вбризгувањето на спијач без дејство ги одржува тестовите за повторни обиди брзи.
<?php
// tests/Screenshot/ScreenshotClientTest.php
namespace App\Tests\Screenshot;
use App\Screenshot\ScreenshotClient;
use App\Screenshot\ScreenshotException;
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 testMapsPngAndServiceHeaders(): void
{
$png = "\x89PNG\r\n\x1a\npayload";
$http = new MockHttpClient([
new MockResponse($png, [
'http_code' => 200,
'response_headers' => [
'content-type: image/png',
'x-cache: HIT',
'x-quota-remaining: 9',
],
]),
]);
$client = new ScreenshotClient(
$http,
new NullLogger(),
'test-token',
static fn (int $microseconds) => null
);
$result = $client->capture('https://client.example/');
self::assertSame($png, $result->png);
self::assertArrayHasKey('x-cache', $result->serviceHeaders);
self::assertArrayHasKey('x-quota-remaining', $result->serviceHeaders);
}
public function testAuthenticationFailureIsStructured(): void
{
$http = new MockHttpClient([
new MockResponse('Unauthorized', ['http_code' => 401]),
]);
$client = new ScreenshotClient(
$http,
new NullLogger(),
'invalid-token',
static fn (int $microseconds) => null
);
try {
$client->capture('https://client.example/');
self::fail('Expected authentication failure.');
} catch (ScreenshotException $exception) {
self::assertSame('authentication', $exception->kind);
self::assertSame(1, $http->getRequestsCount());
}
}
}
Илустративните имиња за кеш и квота погоре постојат само во лажниот одговор; продукцискиот код не зависи од нив. Додајте тестови за тело што не е PNG, транспортен исклучок, 500 што може повторно да се обиде и 429 со и без прифатливо Retry-After.
Поврзете го со распоредувањето
Извршете ја првата команда пред да го промените активното издание. По распоредувањето, почекајте да помине нормалната проверка на подготвеност на страницата, па извршете ја втората:
php bin/console app:snapshots:capture \
--release=2026-10-12.1 \
--phase=before \
--url='https://client.example/?visual_check=2026-10-12.1-before'
# Run the existing deployment and readiness check here.
php bin/console app:snapshots:capture \
--release=2026-10-12.1 \
--phase=after \
--url='https://client.example/?visual_check=2026-10-12.1-after'
Бидејќи услугата снима кеширани слики од екранот, различни безопасни целни URL-адреси помагаат да се избегне мешање на претходна снимка со новото издание. Потврдете дека клиентската апликација го игнорира параметарот visual_check и дека аналитиката го исклучува. Ако параметрите за пребарување влијаат на прикажувањето, наместо тоа користете стабилни верзионирани URL-адреси за преглед.
Неуспешното снимање пред распоредување нормално треба да го запре распоредувањето: нема валидна основа за споредба. Неуспех на снимањето потоа треба да ја означи задачата за издание како неуспешна и да ја зачува распоредената состојба за истрага; не треба автоматски да враќа здрава страница само затоа што не можеле да се соберат визуелни докази.
Безбедност, набљудливост и детали за распоредувањето
- Ограничете кој може да ја извршува командата и одржувајте ја конфигурацијата на дозволениот хост тесна.
- Никогаш не прифаќајте произволни URL-адреси за снимки од јавен контролер. Тоа повикува злоупотреба на квотата и може да изложи ненамерни страници на далечинската услуга за снимање.
- Евидентирајте издание, фаза, хост, вид на неуспех и обид за повторување, но не токени, целосни URL-адреси со чувствителни параметри или PNG тела.
- Испраќајте посебни предупредувања за повторени неуспеси
authentication,quota,transportиinvalid_response; тие бараат различни решенија. - Осигурете се дека
var/snapshotsе запишлив. Во ефемерни контејнери, објавете го директориумот на изданието како заштитен CI артефакт пред да заврши задачата. - Применете експлицитна политика за задржување бидејќи PNG-датотеките и нивните придружни датотеки се акумулираат со текот на времето.
Чести неуспеси и финална проверка
401 или 403 обично значи дека токенот недостига, е неправилно форматиран, е поништен или не е сервисниот токен што го очекува оваа интеграција. 429 значи дека снимањето треба да чека согласно разумна насока од серверот или јасно да не успее за преглед на квотата. Истекувањето на времето може да укажува на недостапен API или целна страница што се прикажува премногу бавно. Навидум непроменетата слика потоа може да биде ефект од кешот, нецелосно распоредување или целен URL чија низа за пребарување била нормализирана нагоре по текот.
Пред да го сметате работниот тек за завршен, проверете го сето следново:
- Минималното барање враќа PNG и забележливи заглавија за кеш и квота.
- Вистинскиот токен постои само во конфигурација на тајни поддржана од околината.
- Командата одбива URL-адреси што не се HTTPS, неодобрени хостови, невалидни фази и небезбедни имиња на изданија.
- Грешките при автентикација и валидација не се повторуваат.
- Привремените повторни обиди се ограничени со бројот на обиди, повратното одложување и вкупното времетраење на барањето.
- Двете PNG-датотеки се отвораат правилно, а нивните JSON придружни датотеки не содржат акредитиви.
- Распоредувањето чека снимање пред распоредувањето и подготвеноста на страницата пред да ја направи снимката потоа.
- Артефактите од снимките го преживуваат извршувачот на распоредувањето доволно долго за преглед.
Корисниот исход не се само две датотеки со слики. Тоа е трајна визуелна граница околу секое ажурирање за клиентот: еден доверлив запис за она што било присутно, еден запис за она што се променило и модел на неуспех што се однесува предвидливо кога мрежата, акредитивите, квотата или распоредувањето не функционираат.