mitmario.dev

Bau dir was

PHP Sandbox 5 Min Lesezeit 3 BeispieleLektion 7 von 8

Nach einer Woche mit fünfzig Lesezeichen fehlt der Anwendung einiges, das keine der fünf Aufgaben vorgesehen hat, eine Sortierung nach Titel, Seiten statt einer endlosen Liste oder ein Satz zu jedem Eintrag. Nichts davon braucht etwas Neues. Ob so eine Erweiterung tut, was du willst, weiß aber vorher keine Prüfliste, und deshalb hat diese Lektion keine Aufgabe.

Die fertige Anwendung

Die fertige Anwendung
<?php

declare(strict_types=1);

// Der Front Controller aus Lektion 16.7, und die einzige Datei im
// oeffentlichen Ordner. Jede Anfrage kommt hier an, und was eine
// Seitenfunktion zurueckgibt, steckt die letzte Zeile ins Layout.

use App\LesezeichenSpeicher;

session_start();

$konfig = require __DIR__ . "/../konfiguration.php";

require __DIR__ . "/../src/funktionen.php";
require __DIR__ . "/../src/LesezeichenSpeicher.php";
require __DIR__ . "/../src/seiten.php";

$routen = require __DIR__ . "/../src/routen.php";

$speicher = new LesezeichenSpeicher($konfig["datenbank"]);

$methode = $_SERVER["REQUEST_METHOD"];
$pfad = parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH) ?: "";

$inhalt = null;

foreach ($routen as $route) {
    if ($route[0] !== $methode) {
        continue;
    }

    $werte = passtRoute($route[1], $pfad);

    if ($werte !== null) {
        $inhalt = $route[2]($speicher, $werte[0] ?? null);
        break;
    }
}

if ($inhalt === null) {
    http_response_code(404);
    $inhalt = seiteNichtGefunden($pfad);
}

echo rendere(__DIR__ . "/../ansichten/seite.php", [
    "titel" => $konfig["titel"],
    "inhalt" => $inhalt,
    "meldung" => holeMeldung(),
]);

Das erste Beispiel ist die Anwendung nach der Suche, zehn Dateien mit allen Lücken so gefüllt, wie der Kurs sie löst. Im Reiter Browser melden sich mia mit gurkenbeet-im-mai und tom mit zaun-streichen-heute an, wie in den Aufgaben.

Eine Suche nach php geht so durch die Dateien. oeffentlich/index.php startet die Sitzung, liest konfiguration.php, holt die drei Dateien aus src/ herein und baut die Speicherklasse, die ihre Tabellen anlegt, falls sie fehlen. Die Schleife über src/routen.php findet die Zeile für GET /suche und ruft seiteSuche() aus src/seiten.php. Die fragt zuerst verlangeAnmeldung() und gibt den Begriff dann an suchen() in der Speicherklasse weiter, der einzigen Datei mit SQL. Mit den Treffern füllt rendere() die Vorlage ansichten/liste.php, und zuletzt legt index.php das Layout aus ansichten/seite.php darum und gibt alles einmal aus.

Bis zu dieser letzten Zeile kommt nicht jede Anfrage. weiterZu() und beendeMit() enden mit exit, und was sie schicken, eine Weiterleitung oder eine kurze Fehlerseite, bekommt nie ein Layout. In der Befehlsleiste bringt die Suche <h1>Lesezeichen</h1> aus dem Layout mit, ein POST ohne Token nur <h1>Kein gültiges Token.</h1> aus beendeMit().

Was davon von dir ist

Ohne Leerzeilen und Kommentare hat die Anwendung rund 420 Zeilen. Knapp hundert davon sind die Lücken aus deinen fünf Aufgaben, also die Tabellen und vier Methoden der Speicherklasse, die Wand vor der Liste mit den zwei Wegen hinein, die Allowlist für Adressen samt dem Anlegen, Besitzerprüfung und Tokenprüfung und zuletzt die Suche. Front Controller, Routentabelle, Vorlagen und Helfer standen jedes Mal fertig da. Jedes davon hatte seine Lektion in Abschnitt 16 oder davor, nur für diese Anwendung hast du keines geschrieben, auch keine Zeile HTML.

Die Tests der Speicherklasse

Die Testsuite des Projekts
<?php

declare(strict_types=1);

use App\LesezeichenSpeicher;
use PHPUnit\Framework\TestCase;

final class LesezeichenSpeicherTest extends TestCase
{
    private LesezeichenSpeicher $speicher;

    // Vor jedem Test eine frische Datenbank, und zwar im Arbeitsspeicher.
    // Deshalb nimmt der Konstruktor den Pfad entgegen, statt ihn zu
    // kennen: Was hereingegeben wird, kann ein Test bestimmen. Das ist die
    // Regel aus Lektion 17.4, hier an der eigenen Klasse.
    protected function setUp(): void
    {
        $this->speicher = new LesezeichenSpeicher(":memory:");
        $this->speicher->nutzerAnlegen("mia", "egal");
        $this->speicher->nutzerAnlegen("tom", "egal");
    }

    public function testJederSiehtNurSeineEigenen(): void
    {
        $this->speicher->anlegen(1, "Astro", "https://astro.example");
        $this->speicher->anlegen(2, "Toms Werkstatt", "https://example.org/tom");

        $this->assertCount(1, $this->speicher->alleVon(1));
        $this->assertSame("Astro", $this->speicher->alleVon(1)[0]["titel"]);
    }

    public function testDasNeuesteStehtOben(): void
    {
        $this->speicher->anlegen(1, "Zuerst", "https://example.org/1");
        $this->speicher->anlegen(1, "Danach", "https://example.org/2");

        $this->assertSame("Danach", $this->speicher->alleVon(1)[0]["titel"]);
    }

    public function testDieSucheFindetEinenTeilDesTitels(): void
    {
        $this->speicher->anlegen(1, "PHP-Handbuch", "https://php-handbuch.example/");

        $this->assertCount(1, $this->speicher->suchen(1, "handbuch"));
        $this->assertCount(0, $this->speicher->suchen(1, "javascript"));
    }

    public function testEinProzentzeichenIstEinZeichenUndKeinMuster(): void
    {
        $this->speicher->anlegen(1, "PHP-Handbuch", "https://php-handbuch.example/");

        $this->assertCount(0, $this->speicher->suchen(1, "%"));
    }

    public function testGeloeschtIstGeloescht(): void
    {
        $this->speicher->anlegen(1, "Weg damit", "https://example.org/weg");
        $id = $this->speicher->alleVon(1)[0]["id"];

        $this->speicher->loeschen((int) $id);

        $this->assertSame([], $this->speicher->alleVon(1));
        $this->assertFalse($this->speicher->holen((int) $id));
    }
}

Am meisten bringen Tests an der Speicherklasse. Sie gibt nichts aus, nimmt ihre Zutaten entgegen, und in ihr stehen die Regeln, bei denen ein Fehler still bliebe. Das zweite Beispiel hält fünf davon fest und läuft im Terminal wie in Abschnitt 17 mit vendor/bin/phpunit. Möglich ist das, weil der Konstruktor den Pfad zur Datenbank entgegennimmt, und setUp() gibt ihm :memory:. Vor einer Änderung an der Klasse läuft die Suite einmal und danach gleich noch einmal.

Die Seitenfunktionen fehlen in der Suite, denn sie lesen $_SESSION, schicken Kopfzeilen und enden mit exit.

Vier Aufträge an dieser Anwendung

Jeder geht mit Werkzeugen aus dem Kurs und berührt dieselben Dateien wie die fünf Aufgaben.

  • Nach Titel sortieren, über einen Link wie /liste?sortierung=titel. Für einen Spaltennamen gibt es keinen Platzhalter, hier hilft die Allowlist aus dem Abschnitt über Sicherheit, am einfachsten als match aus Abschnitt 4.
  • Seiten zu je zehn Einträgen. LIMIT kommt aus Abschnitt 14, OFFSET überspringt die Zeilen davor, und beide nehmen Platzhalter, solange ganze Zahlen hineingehen. Die Seitennummer kommt aus der Adresse und wird mit (int) eine Zahl.
  • Die eigenen Lesezeichen als JSON unter /export, mit json_encode() aus Abschnitt 8 und Content-Type: application/json aus Abschnitt 10. Der Front Controller legt allerdings um jede Rückgabe das Layout. Das JSON stünde dann mitten in einer HTML-Seite, und ein Titel mit Markup liefe ungeschützt mit, denn json_encode() maskiert kein HTML. Die Exportfunktion gibt deshalb selbst aus und endet mit exit.
  • Eine Notiz zu jedem Lesezeichen, als drittes Feld neben Titel und Adresse. Formular und Ausgabe gehen wie in Abschnitt 11, neu ist nur die Spalte.

Der erste Schritt zur Notiz

Eine Spalte, die nachkommt
<?php

declare(strict_types=1);

// Der erste Schritt zur Notiz je Lesezeichen: die Spalte. Die Speicherklasse
// legt ihre Tabellen bei jedem Start an, also laeuft auch alles, was hier
// dazukommt, bei jedem Start, und zwar auf einer Datenbank, die schon Zeilen hat.

function verbinde(): PDO
{
    $db = new PDO("sqlite::memory:", null, null, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]);

    // So steht die Tabelle heute da, mit einem Lesezeichen darin.
    $db->exec("CREATE TABLE lesezeichen (
        id INTEGER PRIMARY KEY,
        nutzer_id INTEGER NOT NULL REFERENCES nutzer(id),
        titel TEXT NOT NULL,
        url TEXT NOT NULL
    )");
    $db->exec("INSERT INTO lesezeichen (nutzer_id, titel, url) VALUES (1, 'Astro', 'https://astro.example')");

    return $db;
}

function spalten(PDO $db): array
{
    return array_column($db->query("PRAGMA table_info(lesezeichen)")->fetchAll(), "name");
}

function ergaenzeEinfach(PDO $db): void
{
    $db->exec("ALTER TABLE lesezeichen ADD COLUMN notiz TEXT NOT NULL DEFAULT ''");
}

function ergaenzeMitNachsehen(PDO $db): void
{
    if (!in_array("notiz", spalten($db), true)) {
        $db->exec("ALTER TABLE lesezeichen ADD COLUMN notiz TEXT NOT NULL DEFAULT ''");
    }
}

$db = verbinde();

foreach ([1, 2] as $start) {
    try {
        ergaenzeEinfach($db);
        echo "Start ", $start, " einfach:       ", implode(", ", spalten($db)), "\n";
    } catch (PDOException $fehler) {
        echo "Start ", $start, " einfach:       ", $fehler->getMessage(), "\n";
    }
}

$db = verbinde();

foreach ([1, 2] as $start) {
    ergaenzeMitNachsehen($db);
    echo "Start ", $start, " mit Nachsehen: ", implode(", ", spalten($db)), "\n";
}

$astro = $db->query("SELECT titel, notiz FROM lesezeichen WHERE id = 1")->fetch();
echo "\nDas alte Lesezeichen: ", $astro["titel"], ", Notiz: \"", $astro["notiz"], "\"\n";

Die Spalte kommt mit ALTER TABLE dazu, wie in der Lektion über die Datenbasis. Weil sich die Speicherklasse bei jedem Start einrichtet, liefe das ALTER TABLE aber auch bei jedem Start. Beim ersten Mal kommt notiz dazu, beim zweiten meldet SQLite duplicate column name: notiz, und in der Anwendung stünde ab der zweiten Anfrage dieser Fatal error statt einer Seite. Die zweite Fassung im Beispiel sieht mit PRAGMA table_info nach und legt die Spalte nur an, wenn sie fehlt. Ein Lesezeichen, das schon da war, bekommt den Standardwert, einen leeren Text.

Zu Hause weiterbauen

Auf dem eigenen Rechner bleibt die Datenbank von einem Tag zum nächsten liegen. Wie PHP und Composer dorthin kommen, beschreibt die Lektion „PHP und Composer auf deinem Rechner“ in Abschnitt 9. Die zehn Dateien aus dem ersten Beispiel kommen mit denselben Unterordnern in einen Ordner, und php -d display_errors=1 -S 127.0.0.1:3000 -t oeffentlich startet die Anwendung darin, im Browser unter http://127.0.0.1:3000.

Die Startdaten braucht zu Hause niemand. Ohne den Aufruf von startdaten() im Konstruktor fängt die Datenbank leer an, und das erste Konto entsteht über die Anmeldeseite. Erst dann bestehen auch die fünf Tests an dieser Klasse, mit dem Aufruf scheitern alle schon in setUp(). Dafür kommen phpunit.xml, tests/ und die composer.json aus dem zweiten Beispiel dazu, und composer install holt PHPUnit.

Was danach kommt

Wächst die Anwendung, mit mehr Seiten, mehr Regeln und mehr Leuten, lohnt sich ein Framework wie Laravel oder Symfony. Es bringt fertig mit, was hier in src/funktionen.php, src/routen.php und oeffentlich/index.php steht, und kennt tausend Fälle mehr.

Dazu kommt mehr SQL. Ein Index auf nutzer_id lässt SQLite gleich die eigenen Zeilen finden, statt die ganze Tabelle zu lesen, einem LIKE mit % vorn hilft ein Index auf titel dagegen nicht. Mit MySQL oder PostgreSQL ändert sich die Verbindung im Konstruktor, und ein paar Abfragen ziehen mit um, denn PostgreSQL unterscheidet bei LIKE Groß und Klein, und MySQL liest '\' als Text, der noch nicht zu Ende ist.

Wie die Anwendung samt Zugangsdaten, Sicherungen und HTTPS auf einen Server kommt, ist ein Thema für sich. Bewusst offen bleiben auch E-Mail, Dateiuploads über die Lektion zum Hochladen hinaus, mehrsprachige Oberflächen, Rechte über „gehört dir“ hinaus und alles mit mehreren Servern.

Zum Mitnehmen

Jede Anfrage geht denselben Weg, vom Front Controller über Routentabelle und Seitenfunktion bis zur Vorlage, und nur Weiterleitungen und die kurzen Fehlerseiten enden vorher. Von den rund 420 Zeilen der Anwendung stammen knapp hundert aus den fünf Aufgaben. Die vier Aufträge gehen mit dem, was der Kurs gezeigt hat, und weitergebaut wird am besten auf dem eigenen Rechner.

Jetzt du

Basis Konto, kostenlos

Im Editor änderst du die Beispiele dieser Lektion und lässt sie gleich laufen. So merkst du am schnellsten, ob es sitzt.

Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.

In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.

Was in dieser Lektion steckt

  • Artikel mit 3 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.