Symfony: Automatizirajte snimke stanja klijentskih web-stranica prije i poslije za besprijekorna ažuriranja
Ažuriranje web-mjesta može proći svaki automatizirani test, a ipak donijeti vizualno iznenađenje: nedostajuću hero sliku, urušenu navigacijsku traku ili mobilnu prijelomnu točku koja se više ne ponaša ispravno. Snimke zaslona prije i poslije daju programerima i klijentima trajan vizualni zapis svakog izdanja bez potrebe da tim održava preglednike, upravljačke programe i Chromium spremnike.
Ovaj vodič izgrađuje taj tijek rada kao produkcijsku Symfony naredbu. Snima nekoliko javnih stranica prije implementacije, ponavlja snimanje nakon nje, provjerava svaki PNG, bilježi relevantna zaglavlja predmemorije i kvote te svaki skup atomski pohranjuje pod identifikatorom izdanja. Naredba je prikladna za prijenosno računalo programera, CI cjevovod ili poslužitelj za implementaciju.
Dobijte pristup Screenshot API-ju
Registrirajte se putem stranice za registraciju ili upotrijebite stranicu za prijavu ako već imate račun.
- Otvorite stranicu usluge Screenshot API.
- Odaberite dostupni paket Free, Plus ili Pro i dovršite njegovu aktivaciju.
- Otvorite službenu dokumentaciju.
- Pronađite ploču Service token i kopirajte token ograničen na uslugu.
Ova usluga zahtijeva autentikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Bearer token drži vjerodajnicu izvan URL-ova, povijesti preglednika, dnevnika upita proxyja i većine metrika zahtjeva, stoga se ovdje koristi ta metoda.
Ponovno generiranje tokena usluge opoziva prethodno aktivan token. Tretirajte ponovno generiranje kao rotaciju vjerodajnice: ažurirajte svako okruženje za implementaciju prije uklanjanja stare konfiguracije iz postupka upravljanja tajnama.
Potvrdite krajnju točku prije pisanja aplikacijskog koda
Točan zahtjev je GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture, s obaveznim parametrom upita url. Uspješan odgovor sadrži tijelo image/png te zaglavlja odgovora povezana s predmemorijom i kvotom.
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
Otvorite homepage.png i potvrdite da je to očekivana stranica prije nastavka. Ne predajte tu probnu sliku u repozitorij ako sadrži privatni materijal klijenta.
Za lokalni Symfony razvoj smjestite token u .env.local, koji bi trebao ostati nepredan u repozitorij. U produkciji umjesto ugrađivanja u sliku spremnika unesite istu varijablu putem hosting platforme ili upravitelja tajni.
# .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)%'
Arhitektura: mala granica s čvrstim jamstvima
Projekt namjerno koristi konzolnu naredbu umjesto HTTP kontrolera. Snimanja pripadaju kontroliranom procesu izdanja, a ne javnoj ruti koja bi mogla trošiti kvotu ili pretvoriti proizvoljan korisnički unos u udaljeno pregledavanje.
Implementacija ima tri dijela:
ScreenshotClientupravlja autentikacijom, vremenskim ograničenjima, ponovnim pokušajima, provjerom odgovora i izdvajanjem zaglavlja.ScreenshotCapturepreslikava udaljeni odgovor u domensku vrijednost koja sadrži PNG bajtove i operativne metapodatke.CaptureSnapshotsCommandsnima potpuni imenovani skup u pripremni direktorij, a zatim ga atomski promovira.
Instalirajte komponente prve strane ako ih aplikacija već ne sadrži:
composer require symfony/http-client symfony/console symfony/filesystem
composer require --dev symfony/phpunit-bridge
Rezultirajuće datoteke su src/Screenshot/ScreenshotCapture.php, src/Screenshot/ScreenshotFailure.php, src/Screenshot/ScreenshotClient.php, src/Command/CaptureSnapshotsCommand.php i tests/Screenshot/ScreenshotClientTest.php.
Preslikajte i provjerite API odgovor
Udaljeni binarni podaci ne bi trebali curiti kroz aplikaciju kao nestrukturirani objekt odgovora. Sljedeće klase vrijednosti i iznimke čine uspjeh i neuspjeh eksplicitnima.
<?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,
);
}
}
Klijent ograničava i vrijeme povezivanja i ukupno vrijeme odgovora. Dvaput ponovno pokušava transportne neuspjehe i odgovore poslužitelja 5xx s kratkim eksponencijalnim odgodama. Ne pokušava naslijepo ponovno neispravne zahtjeve, odbijene vjerodajnice ili HTTP 429 odgovore: oni zahtijevaju odluke o konfiguraciji, tokenu, kvoti ili raspoređivanju, a ne drugi neposredni zahtjev.
<?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,
);
}
}
Preslikavač zaglavlja namjerno ne pretpostavlja nedokumentirana imena zaglavlja. Zadržava vraćena zaglavlja čija imena označavaju informacije o predmemoriji, kvoti, ograničenju stope ili vremenu ponovnog pokušaja. Time se čuvaju korisni dokazi, uz zadržavanje ponašanja aplikacije neovisnim o nagađanoj shemi odgovora.
Snimite atomski skup prije ili poslije
Naredba prihvaća fazu, identifikator izdanja i jedan ili više URL-ova. Najprije zapisuje u privremeni direktorij. Ako bilo koja stranica ne uspije, privremeni se skup uklanja; korisnici nikada neće zamijeniti nepotpuno pokretanje za valjanu usporedbu.
<?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 može automatski povezati $projectDir iz svojeg standardnog parametra direktorija projekta kada je izričito vezan:
# config/services.yaml
App\Command\CaptureSnapshotsCommand:
arguments:
$projectDir: '%kernel.project_dir%'
Automatizirajte ga oko implementacije
Snimite isti uređeni skup URL-ova s obje strane implementacije. Naredba prije implementacije mora se pokrenuti dok stara verzija još uvijek poslužuje promet; naredba nakon implementacije trebala bi se pokrenuti tek nakon što su aplikacija i njezina javna sredstva ispravni.
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[@]}"
Arhivirajte var/snapshots/$RELEASE_ID kao privatni CI artefakt ili ga kopirajte u pohranu objekata s kontroliranim pristupom. Ne stavljajte snimke pod public/: čak i javne stranice mogu otkriti vrijeme izdanja, personalizirani sadržaj, bannere za pregled ili podatke o korisnicima.
Testirajte granicu bez mrežnih poziva
MockHttpClient testu pruža deterministički transport. Prvi test dokazuje preslikavanje binarnih podataka i metapodataka; drugi dokazuje da se neuspjesi autentikacije odmah vraćaju umjesto da se ponovno pokušavaju.
<?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'
Sigurnost, vidljivost i operativni neuspjesi
Ograničite ciljeve snimanja na popis odobrenih naziva hostova ako URL-ovi mogu potjecati odnekud osim iz pouzdane konfiguracije implementacije. To štiti kvotu, sprječava slučajno snimanje osjetljivih odredišta i čini skup artefakata predvidljivim. Držite vjerodajnice za pregled izvan ciljnih URL-ova; nizovi upita mogu se pojaviti u manifestima i popisima procesa.
Dnevnici bi trebali sadržavati ciljni host, sažetak URL-a, broj bajtova, broj pokušaja, fazu i identifikator izdanja. Nikada ne zapisujte token, autorizacijska zaglavlja, PNG tijelo ni neograničeni ciljni URL. Zasebno upozoravajte na neuspjehe autentikacije, neuspjehe stope ili kvote, iscrpljene ponovne pokušaje, neispravne PNG odgovore i nepotpuna snimanja nakon implementacije.
Rezultat HTTP 429 trebao bi zaustaviti skup i sačuvati njegova operativna zaglavlja za dijagnostiku. Upotrijebite vraćene informacije o vremenu ponovnog pokušaja pri raspoređivanju kasnijeg pokretanja, ali ograničite automatizirane odgode prema prozoru implementacije. 401 ili 403 obično označava nedostajući, opozvani ili nepravilno implementirani token usluge. Neispravan PNG često znači da je usluga vratila neočekivani odgovor unatoč svojem statusu, stoga je njegovo odbacivanje sigurnije od spremanja oštećenog artefakta.
Za više CI radnika učinite identifikator izdanja jedinstvenim i dopustite samo jedan posao snimanja po izdanju. Namjerno definirajte zadržavanje artefakata: snimke su korisne za pregled izdanja, ali neograničena pohrana povećava trošak i izloženost privatnosti.
Završni kontrolni popis za provjeru
- Paket usluge je aktivan, a token je preuzet s ploče Service token na stranici dokumentacije.
SCREENSHOT_API_TOKENumeće se tijekom izvođenja i ne nalazi se u kontroli izvornog koda, dnevnicima, fixture datotekama ni slikama.- Minimalni zahtjev vraća valjan PNG za odobreni javni URL.
- Naredba stvara potpune direktorije
beforeiafters odgovarajućim nazivima datoteka i manifestima. - Transportni i 5xx neuspjesi dobivaju samo ograničene ponovne pokušaje; neuspjesi autentikacije, provjere i kvote ne ponavljaju se naslijepo.
- Zaglavlja odgovora povezana s predmemorijom i kvotom zadržavaju se bez pretpostavljanja nedokumentiranih imena.
- Testovi prolaze s
MockHttpClient, a nijedan test ne doseže aktivnu uslugu. - Artefakti snimki imaju kontroliran pristup, čuvaju se određeno razdoblje i isključeni su iz javnog web korijena.
Najvrjedniji dokaz o izdanju jest dokaz koji će ljudi zaista stvoriti. Svođenjem vizualnog snimanja na dvije predvidljive Symfony naredbe, svako ažuriranje može nositi vlastiti zapis prije i poslije. Infrastruktura preglednika nestaje s vašeg popisa održavanja, dok razgovor s klijentom postaje konkretan: ne „implementacija je vjerojatno promijenila samo ove stranice”, nego „evo točno kako je web-mjesto izgledalo s obje strane izdanja.”