Messen statt vermuten: 18 Abfragen auf 8, Ladebudget, Blaettern ueberall

Arbeitspaket M sagte "Abfragen je Seite zaehlen. Messen, dann zusammenfassen."
Messen braucht ein Messgeraet, also gibt es jetzt eines: ein Zaehler als
Doctrine-Middleware, dessen Zahl ausserhalb der Produktion im Kopf
X-Kant-Queries steht. Keine Profiler-Maschinerie, eine Zahl.

Die erste Messung: die Startseite brauchte 18 Abfragen. Nicht wegen einer
komplizierten Seite, sondern weil SiteSettings::load() und Theme::load() je
Aufrufer dieselbe Zeile erneut holten - Renderer, strukturierte Daten,
Einwilligungsschalter, Vorlagen. Beide merken sich ihren Stand jetzt fuer die
Dauer einer Anfrage: 18 auf 8.

Dabei ist mir die klassische Falle des Merkens selbst passiert, und ein
bestehender Test hat sie sofort gezeigt: ich leerte den Merker am Anfang des
Speicherns. Das Speichern liest den Stand aber noch einmal und fuellte ihn
damit erneut - der Rest der Anfrage bekam den alten Stand. Geleert wird jetzt
nach dem Schreiben.

Das Ladebudget wird gemessen statt behauptet: die Browserpruefung liest je
Seite HTML-Groesse, CSS-Groesse, Anzahl Anfragen, Anzahl Skripte und
Abfragezahl und prueft Obergrenzen. Startseite: 7,4 KB HTML, 22,7 KB CSS, 2
Anfragen, 8 Abfragen, 0 Skripte. Die Null ist die wichtigste Zahl - sie haelt
als Test fest, was bisher nur Absicht war.

Das erste Bild einer Seite ist nicht mehr lazy. Es ist meistens das groesste
Ding ueber der Falz, und es zu verzoegern verzoegert genau das Pixel, auf das
jemand wartet. Der Renderer bestimmt es in Renderreihenfolge, Kopfbereich
zuerst.

Eine feste Obergrenze ist kein Blaettern: der Fassungsverlauf hoerte bei
dreissig auf, die Einsendungen bei fuenfzig, die Weiterleitungen zeigten bis
zu zweitausend auf einmal. Das Erste sieht aus wie "es gibt dreissig" und ist
das Einzige, was eine Historie nicht tun darf. Alle drei blaettern jetzt, mit
derselben Rechnung und demselben Pager wie Beitragsarchiv und Mediathek.

347 PHP-Tests / 1422 Assertions unter PHP 8.2.12 und 8.4.22.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 21:56:09 +02:00
1 parent 8c49f6a67a
commit 269fb223af
27 files changed
+480 -54

No files matched your search

+11 -4
View File
@@ -253,10 +253,15 @@ final class FormRepository
$this->db->executeStatement('UPDATE form_submission SET delivered = ? WHERE id = ?', [true, $submissionId]);
}
/** @return list<array{id: string, form: string, data: array, createdAt: string, consentAt: ?string, delivered: bool}> */
public function submissions(int $limit = 100): array
/**
* One page of the submissions, newest first.
*
* @return array{items: list<array>, page: int, pages: int, total: int}
*/
public function submissions(int $number = 1, int $perPage = 25): array
{
return array_map(static fn (array $row): array => [
$slice = Pagination::of($number, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'), $perPage);
$items = array_map(static fn (array $row): array => [
'id' => $row['id'],
'form' => $row['name'] ?? $row['form_id'],
'data' => json_decode($row['data'], true, 512, JSON_THROW_ON_ERROR),
@@ -264,7 +269,9 @@ final class FormRepository
'consentAt' => $row['consent_at'],
'delivered' => (bool) $row['delivered'],
], $this->db->fetchAllAssociative(
'SELECT s.*, f.name FROM form_submission s LEFT JOIN form f ON f.id = s.form_id ORDER BY s.created_at DESC, s.id DESC LIMIT '.max(1, min($limit, 500)),
'SELECT s.*, f.name FROM form_submission s LEFT JOIN form f ON f.id = s.form_id ORDER BY s.created_at DESC, s.id DESC LIMIT '.$slice['perPage'].' OFFSET '.$slice['offset'],
));
return ['items' => $items, 'page' => $slice['page'], 'pages' => $slice['pages'], 'total' => $slice['total']];
}
}
+14 -3
View File
@@ -208,11 +208,20 @@ final class PageRepository
}
/** @return list<array{id: string, createdAt: string, title: string, published: bool, draft: bool}> */
public function revisions(string $pageId, int $limit = 30): array
/**
* One page of the history, newest first.
*
* It used to stop at thirty without saying so - which looks like "there are thirty" and is
* the one thing a history must not do.
*
* @return array{items: list<array>, page: int, pages: int, total: int}
*/
public function revisions(string $pageId, int $number = 1, int $perPage = 25): array
{
$slice = Pagination::of($number, (int) $this->db->fetchOne('SELECT COUNT(*) FROM revision WHERE page_id = ?', [$pageId]), $perPage);
$page = $this->db->fetchAssociative('SELECT published_revision_id, draft_revision_id FROM page WHERE id = ?', [$pageId]);
return array_map(static function (array $row) use ($page): array {
$items = array_map(static function (array $row) use ($page): array {
$document = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
return [
@@ -223,9 +232,11 @@ final class PageRepository
'draft' => $row['id'] === ($page['draft_revision_id'] ?? null),
];
}, $this->db->fetchAllAssociative(
'SELECT id, created_at, document FROM revision WHERE page_id = ? ORDER BY seq DESC LIMIT '.max(1, min($limit, 100)),
'SELECT id, created_at, document FROM revision WHERE page_id = ? ORDER BY seq DESC LIMIT '.$slice['perPage'].' OFFSET '.$slice['offset'],
[$pageId],
));
return ['items' => $items, 'page' => $slice['page'], 'pages' => $slice['pages'], 'total' => $slice['total']];
}
/**
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Content;
/**
* The arithmetic every paged list needs, in one place.
*
* A fixed upper limit is not pagination: it shows the first thirty entries and says nothing about
* the rest. This makes "there are more, here is how to reach them" cheap enough that no list has
* to settle for a cap.
*/
final class Pagination
{
/**
* @return array{page: int, pages: int, offset: int, perPage: int, total: int}
*/
public static function of(int $page, int $total, int $perPage): array
{
$perPage = max(1, min(200, $perPage));
$pages = max(1, (int) ceil($total / $perPage));
// A page beyond the end shows the last one rather than an empty screen.
$page = max(1, min($page, $pages));
return [
'page' => $page,
'pages' => $pages,
'offset' => ($page - 1) * $perPage,
'perPage' => $perPage,
'total' => $total,
];
}
}
+15 -4
View File
@@ -17,12 +17,23 @@ final class RedirectRepository
{
}
/** @return list<array{id: string, from: string, to: ?string, status: int}> */
public function all(): array
/**
* One page of the redirects. There can be up to MAX of them, and a list of two thousand rows
* is not a list anybody reads.
*
* @return array{items: list<array{id: string, from: string, to: ?string, status: int}>, page: int, pages: int, total: int}
*/
public function all(int $number = 1, int $perPage = 50): array
{
return array_map(static fn (array $row): array => [
$slice = Pagination::of($number, (int) $this->db->fetchOne('SELECT COUNT(*) FROM redirect WHERE website_id = ?', ['default']), $perPage);
$items = array_map(static fn (array $row): array => [
'id' => $row['id'], 'from' => $row['from_path'], 'to' => $row['to_path'], 'status' => (int) $row['status'],
], $this->db->fetchAllAssociative('SELECT * FROM redirect WHERE website_id = ? ORDER BY from_path', ['default']));
], $this->db->fetchAllAssociative(
'SELECT * FROM redirect WHERE website_id = ? ORDER BY from_path LIMIT '.$slice['perPage'].' OFFSET '.$slice['offset'],
['default'],
));
return ['items' => $items, 'page' => $slice['page'], 'pages' => $slice['pages'], 'total' => $slice['total']];
}
/** @return array{to: ?string, status: int}|null */
+14 -1
View File
@@ -40,12 +40,24 @@ final class SiteSettings
{
}
/**
* Remembered for the length of one request.
*
* These settings are asked for several times per page - by the renderer, by the structured
* data, by the consent gate, by the templates - and each time it was the same row again. The
* value is dropped on every save, so nothing stale survives a change.
*/
private ?array $loaded = null;
public function load(): array
{
if (null !== $this->loaded) {
return $this->loaded;
}
$raw = $this->db->fetchOne('SELECT settings FROM website WHERE id = ?', ['default']);
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
return [...self::DEFAULTS, ...(is_array($stored) ? $stored : [])];
return $this->loaded = [...self::DEFAULTS, ...(is_array($stored) ? $stored : [])];
}
public function save(array $input): void
@@ -132,6 +144,7 @@ final class SiteSettings
}
}
$this->loaded = null;
$this->db->executeStatement('UPDATE website SET settings = ? WHERE id = ?', [
json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'default',
+14 -2
View File
@@ -56,15 +56,21 @@ final class Theme
{
}
/** Remembered for one request, like the settings; dropped on every write. */
private ?array $loaded = null;
public function load(): array
{
if (null !== $this->loaded) {
return $this->loaded;
}
$raw = $this->db->fetchOne('SELECT theme FROM website WHERE id = ?', ['default']);
$stored = is_string($raw) ? json_decode($raw, true, 512, JSON_THROW_ON_ERROR) : [];
if (!is_array($stored) || !isset($stored['colors'], $stored['fonts'], $stored['base'])) {
return [...self::DEFAULTS, 'presets' => []];
return $this->loaded = [...self::DEFAULTS, 'presets' => []];
}
return [
return $this->loaded = [
'colors' => $stored['colors'],
'fonts' => $stored['fonts'],
'base' => [...self::DEFAULTS['base'], ...$stored['base']],
@@ -97,6 +103,9 @@ final class Theme
json_encode($theme, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'default',
]);
// After the write, never before: saving reads the theme again, and an early reset
// would only refill the memo with the state that is about to be replaced.
$this->loaded = null;
}
private function colors(array $input): array
@@ -240,6 +249,9 @@ final class Theme
json_encode([...$theme, 'presets' => $presets], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'default',
]);
// After the write, never before: saving reads the theme again, and an early reset
// would only refill the memo with the state that is about to be replaced.
$this->loaded = null;
}
/** The stylesheet every page carries: palette variables, font variables and base typography. */
+2 -2
View File
@@ -249,14 +249,14 @@ final class EditorController extends AbstractController
}
#[Route('/pages/{id}/revisions', name: 'admin_revisions', methods: ['GET'])]
public function revisions(string $id): Response
public function revisions(Request $request, string $id): Response
{
$page = $this->page($id);
return $this->render('admin/revisions.html.twig', [
'page' => $page,
'title' => $page['document']['title'] ?? $page['path'],
'revisions' => $this->pages->revisions($id),
'revisions' => $this->pages->revisions($id, $request->query->getInt('seite', 1)),
]);
}
+2 -2
View File
@@ -17,12 +17,12 @@ final class FormAdminController extends AbstractController
}
#[Route('/admin/forms', name: 'admin_forms', methods: ['GET'])]
public function index(): Response
public function index(Request $request): Response
{
return $this->render('admin/forms.html.twig', [
'forms' => $this->forms->all(),
'types' => FormRepository::TYPES,
'submissions' => $this->forms->submissions(50),
'submissions' => $this->forms->submissions($request->query->getInt('seite', 1)),
]);
}
+35
View File
@@ -76,6 +76,8 @@ final class PageController extends AbstractController
'structuredData' => $this->structuredData($page),
'areas' => $areas,
'sections' => $sections,
// Rendered before the page content, so the header decides first.
'eagerImage' => $this->firstImage($areas['header'] ?? [], $page['document']),
'pageStyles' => $styles,
'googleFonts' => $googleFonts,
]);
@@ -340,6 +342,39 @@ final class PageController extends AbstractController
return $response;
}
/**
* The first image of the page, in the order it is rendered.
*
* That one must not be lazy: it is usually the largest thing above the fold, and deferring it
* delays exactly the pixel a visitor waits for. Everything below it stays lazy.
*/
private function firstImage(array ...$documents): ?string
{
foreach ($documents as $document) {
$found = $this->findImage($document['blocks'] ?? []);
if (null !== $found) {
return $found;
}
}
return null;
}
private function findImage(array $blocks): ?string
{
foreach ($blocks as $block) {
if ('image' === ($block['type'] ?? null)) {
return $block['id'];
}
$inside = $this->findImage($block['children'] ?? []);
if (null !== $inside) {
return $inside;
}
}
return null;
}
/**
* The hosts of embeds that may load on this request.
*
+2 -2
View File
@@ -20,7 +20,7 @@ final class SettingsController extends AbstractController
}
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET'])]
public function index(): Response
public function index(Request $request): Response
{
$choosable = [];
foreach ($this->pages->listForEditor() as $page) {
@@ -33,7 +33,7 @@ final class SettingsController extends AbstractController
return $this->render('admin/settings.html.twig', [
'settings' => $this->settings->load(),
'redirects' => $this->redirects->all(),
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
'choosableHome' => $choosable,
'consentGroups' => Consent::GROUPS,
'missingLegal' => $this->settings->missingLegalPages(),
@@ -0,0 +1,46 @@
<?php
namespace App\EventSubscriber;
use App\Infrastructure\QueryCounter;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Puts the number of database queries of a request into a header.
*
* Only outside production: it is an instrument for the browser checks and for looking at a page
* while building it, not something a visitor should receive. The checks assert a ceiling with it,
* so a page that starts asking the database ten times per section fails a test instead of only
* getting slower.
*/
final class QueryHeaderSubscriber implements EventSubscriberInterface
{
public function __construct(private QueryCounter $counter, private string $environment)
{
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onRequest', 4096],
KernelEvents::RESPONSE => ['onResponse', -512],
];
}
public function onRequest(RequestEvent $event): void
{
if ($event->isMainRequest()) {
$this->counter->reset();
}
}
public function onResponse(ResponseEvent $event): void
{
if ('prod' !== $this->environment && $event->isMainRequest()) {
$event->getResponse()->headers->set('X-Kant-Queries', (string) $this->counter->count());
}
}
}
+10 -2
View File
@@ -2,16 +2,24 @@
namespace App\Infrastructure;
use Doctrine\DBAL\Configuration;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Tools\DsnParser;
final class ConnectionFactory
{
public static function create(string $url): Connection
public static function create(string $url, ?QueryCounter $counter = null): Connection
{
$params = (new DsnParser(['sqlite' => 'pdo_sqlite', 'mysql' => 'pdo_mysql']))->parse($url);
$connection = DriverManager::getConnection($params);
$configuration = null;
if (null !== $counter) {
// Counting queries is a measuring instrument, not a feature: it is wired up outside
// the production environment, where the browser checks read the number.
$configuration = new Configuration();
$configuration->setMiddlewares([$counter]);
}
$connection = DriverManager::getConnection($params, $configuration);
if ('pdo_sqlite' === $params['driver']) {
$connection->executeStatement('PRAGMA foreign_keys = ON');
$connection->executeStatement('PRAGMA busy_timeout = 5000');
+94
View File
@@ -0,0 +1,94 @@
<?php
namespace App\Infrastructure;
use Doctrine\DBAL\Driver;
use Doctrine\DBAL\Driver\Connection as DriverConnection;
use Doctrine\DBAL\Driver\Middleware;
use Doctrine\DBAL\Driver\Middleware\AbstractConnectionMiddleware;
use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
use Doctrine\DBAL\Driver\Middleware\AbstractStatementMiddleware;
use Doctrine\DBAL\Driver\Result;
use Doctrine\DBAL\Driver\Statement as DriverStatement;
/**
* Counts the database queries of one request.
*
* Not a profiler and not a log: one number, so "how many queries does this page need" can be
* measured instead of guessed. It is the difference between a page with ten linked sections that
* asks once and one that asks ten times - and without counting, nobody notices the second kind
* until the site is slow on a real host.
*
* The number is attached as a header outside the production environment (see
* QueryHeaderSubscriber) and read by the browser checks.
*/
final class QueryCounter implements Middleware
{
private int $count = 0;
public function count(): int
{
return $this->count;
}
public function reset(): void
{
$this->count = 0;
}
public function record(): void
{
++$this->count;
}
public function wrap(Driver $driver): Driver
{
return new class($driver, $this) extends AbstractDriverMiddleware {
public function __construct(Driver $driver, private QueryCounter $counter)
{
parent::__construct($driver);
}
public function connect(array $params): DriverConnection
{
return new class(parent::connect($params), $this->counter) extends AbstractConnectionMiddleware {
public function __construct(DriverConnection $connection, private QueryCounter $counter)
{
parent::__construct($connection);
}
public function prepare(string $sql): DriverStatement
{
return new class(parent::prepare($sql), $this->counter) extends AbstractStatementMiddleware {
public function __construct(DriverStatement $statement, private QueryCounter $counter)
{
parent::__construct($statement);
}
public function execute(): Result
{
$this->counter->record();
return parent::execute();
}
};
}
public function query(string $sql): Result
{
$this->counter->record();
return parent::query($sql);
}
public function exec(string $sql): int|string
{
$this->counter->record();
return parent::exec($sql);
}
};
}
};
}
}