Zum Inhalt springen

PHPArkitect: Architekturregeln als Code schreiben

Veröffentlicht am

Architektur steht meistens in einem Wiki: Controller rendern, Services rechnen, Altcode wird nicht mehr angefasst. Gelesen wird das selten. Und wenn mehrere Leute gleichzeitig Code erzeugen, hält sich am Ende jeder nur an das, was eine Maschine prüft.

PHPArkitect prüft solche Regeln statisch. Es liest den Code, ohne ihn auszuführen, und lässt die Pipeline scheitern, wenn eine Klasse an der falschen Stelle liegt, falsch heißt oder etwas benutzt, das sie nicht benutzen soll.

Alles hier stammt aus einem Projekt, in dem es läuft: PHPArkitect 1.3.1 auf PHP 8.4, ein Altbestand von gut 80 Klassen ohne Namespace und rund 260 Klassen in sauberem PSR-4.

Eine PHPArkitect-Regel: Klassen mit Namen *Exception müssen im Exception-Namespace liegen

Wie eine Regel aufgebaut ist

Nach ein paar Regeln fällt auf, dass sie alle nach demselben Muster gehen: Klassen, die X sind, sollen Y sein.

that() wählt die Klassen aus, should() stellt die Forderung. andThat() und andShould() hängen jeweils eine weitere Bedingung an, und because() liefert die Begründung für die Fehlermeldung:

PHP
Rule::allClasses()
    ->that(new MatchOneOfTheseNames(['*Exception']))
    ->should(new ResideInOneOfTheseNamespaces('App\*\Exception'))
    ->because('Exceptions liegen gesammelt im Exception-Ordner ihres Moduls');

Die Regel sagt: Jede Klasse, deren Name auf Exception endet, muss in einem Namespace wie App\Billing\Exception liegen. Liegt eine InvoiceFailedException stattdessen in App\Billing\Service, meldet PHPArkitect einen Verstoß.

Von Regel zu Regel ändert sich eigentlich nur die Eigenschaft. Die mitgelieferten Bausteine lassen sich grob so einteilen:

Worum es geht Beispiele
Ort ResideInOneOfTheseNamespaces, NotResideInTheseNamespaces
Name HaveNameMatching, MatchOneOfTheseNames
Abhängigkeiten NotDependsOnTheseNamespaces, NotHaveDependencyOutsideNamespace
Art der Klasse IsFinal, IsReadonly, IsEnum, IsInterface
Vererbung Extend, Implement, IsA

Daraus setzt sich fast alles zusammen. „Controller dürfen nur in Http\ liegen“ ist Name plus Ort, „Models dürfen keine Services kennen“ ist Ort plus Abhängigkeit.

Die Konfiguration

Die Regeln stehen in phparkitect.php im Projektroot. Die Datei gibt eine Funktion zurück, die jeder Menge von Klassen ihre Regeln zuordnet:

PHP
return static function (Config $config): void {
    $config->add(
        ClassSet::fromDir(__DIR__ . '/src'),
        $exceptionRule,
        $controllerRule,
    );
};

Aufgerufen wird es mit vendor/bin/phparkitect check. Findet es nichts, endet es mit 0. Sonst listet es jede Klasse mit ihrem Verstoß auf und endet mit 1, und damit kommt jede Pipeline zurecht.

Ab drei Regeln lohnt sich eine eigene Klasse je Regel. Die Konfiguration ist dann nur noch eine Liste, und im Review sieht man gleich, ob jemand eine bestehende Regel ändert oder eine neue dazunimmt.

Die Baseline: Altbestand einfrieren

Auf ein gewachsenes Projekt passt keine Regel, ohne dass sofort hundert Verstöße auftauchen. Dafür gibt es die Baseline:

Bash
vendor/bin/phparkitect generate-baseline   # schreibt phparkitect-baseline.json
vendor/bin/phparkitect check               # ignoriert alles, was dort steht
vendor/bin/phparkitect prune-baseline      # entfernt, was inzwischen behoben ist

Danach scheitert die Pipeline nur noch an neuen Verstößen. Wer eine alte Klasse aufräumt, lässt prune-baseline laufen, und die Datei wird kürzer.

Zwei Dinge sollte man wissen, bevor man sich darauf verlässt.

Abgeglichen wird über Klassenname und Meldungstext. Wer den Text einer Regel ändert, und dazu zählt schon das because(), macht jeden alten Eintrag ungültig und muss die Baseline neu erzeugen.

generate-baseline nimmt ungefragt alles auf. Ein Aufruf, und jeder neue Verstoß steht mit in der Datei. prune-baseline entfernt dagegen nur und fügt nie etwas hinzu. Die Zeilenzahl in der Pipeline zu prüfen hilft wenig, weil sich ein behobener und ein neuer Verstoß gegenseitig aufheben. Verlässlich ist erst der Vergleich Eintrag für Eintrag mit der Baseline des Zielbranches.

An der Größe der Baseline sieht man übrigens gut, ob eine Regel etwas taugt. Erzeugt sie 10 000 Einträge, beschreibt sie eher ein Wunschbild als das Projekt, und im Alltag wird sie dann eher umgangen als eingehalten.

Wenn die Bausteine nicht reichen: eine eigene Expression

Der Altbestand im Projekt liegt ohne Namespace in einem Ordner legacy/: Session, Db, Mailer. Neuer Code soll davon nichts mehr benutzen. Mit NotDependsOnTheseNamespaces lässt sich das nicht ausdrücken, denn ohne Namespace gibt es kein Muster, das genau diese Klassen trifft.

Eine eigene Expression ist eine Klasse mit zwei Methoden. describe() liefert den Text, evaluate() meldet die Verstöße:

PHP
final readonly class NotDependOnLegacyClasses implements Expression
{
    public function __construct(private string $legacyDir) {}

    public function describe(ClassDescription $theClass, string $because): Description
    {
        return new Description('darf keine Klassen aus legacy/ nutzen', $because);
    }

    public function evaluate(ClassDescription $theClass, Violations $violations, string $because): void
    {
        $seen = [];
        foreach ($theClass->getDependencies() as $dependency) {
            $fqcn = $dependency->getFQCN()->toString();
            if (in_array($fqcn, $seen, true)
                || !is_file($this->legacyDir . '/' . str_replace('\\', '/', $fqcn) . '.php')
            ) {
                continue;
            }
            $seen[] = $fqcn;
            $violations->add(Violation::create(
                $theClass->getFQCN(),
                ViolationMessage::selfExplanatory(new Description("darf {$fqcn} nicht nutzen", $because)),
                $theClass->getFilePath(),
            ));
        }
    }
}

Die eigentliche Arbeit macht is_file(). Die Expression schaut gar nicht auf den Namespace. Sie geht denselben Weg wie der Autoloader und prüft, ob zu der Klasse eine Datei in legacy/ liegt.

getDependencies() liefert jede Fundstelle einzeln. Ohne $seen stünde eine Klasse, die Db an zehn Stellen benutzt, zehnmal in der Baseline.

Dass der Name der benutzten Klasse in der Meldung steht, ist Absicht. In der Baseline steht dann „Klasse A benutzt Db“ statt nur „Klasse A verstößt“. Nimmt A später noch Mailer dazu, ist das ein neuer Eintrag, und die Pipeline wird rot.

Was sich nicht ersetzen lässt, kommt auf eine Liste

Die erste Fassung verbot alles aus legacy/. Das ergab 87 Einträge, und jede neue Klasse, die an die Session musste, wäre rot geworden. Für die Session gab es aber keinen Ersatz, also konnte sich niemand an die Regel halten.

Die Expression bekam deshalb eine Liste erlaubter Klassen. Darauf steht nur, wofür es noch keinen Ersatz gibt. Die Liste darf nur kürzer werden, und jede Änderung daran sieht man im Review. Die alte Datenbankklasse blieb gesperrt, weil es daneben schon einen Query Builder gab. Die Baseline schrumpfte auf 41 Einträge, und jeden davon kann man tatsächlich beheben.

Wo PHPArkitect einem ein Bein stellt

IsA lädt jede Klasse

Die naheliegende Auswahl „alle Klassen, die Throwable sind“ kann nur PHP selbst beantworten. PHPArkitect kennt aber nur den Composer-Autoloader. Sobald eine geprüfte Klasse von einer Altklasse erbt, bricht der ganze Check ab:

❌ Class "LegacyView" not found

Das meldet keinen Verstoß, der Check stürzt einfach ab. Abhilfe schafft die Auswahl über den Namen: MatchOneOfTheseNames(['*Exception']) braucht nur den Quelltext.

* geht über mehrere Namespace-Ebenen

Das Muster wird mit fnmatch() ohne Pfad-Modus verglichen. App\*\Exception trifft deshalb auch App\Billing\Provider\Stripe\Exception\…. Meistens passt das, man sollte es nur wissen. Groß- und Kleinschreibung zählt allerdings: App\*\exception trifft einen Ordner Exception nicht.

Was erst zur Laufzeit feststeht, sieht es nicht

Wer sich die Session über $_SESSION['current'] holt, hat für PHPArkitect keine Abhängigkeit auf Session. Dasselbe gilt für Klassennamen in Strings (new $class). Ein @var Session im Docblock zählt dagegen schon, ebenso @param, @return und @throws. Für alles Dynamische ist PHPStan zuständig.

Eine Expression sieht nicht, wo die Datei liegt

getFilePath() liefert den Pfad relativ zum Verzeichnis des ClassSet. Soll eine Regel nur für einen Ordner gelten, wählt man den Ordner über ClassSet::fromDir() aus und nicht in der Expression.

Gegenprobe

Ob eine Regel überhaupt greift, merkt man erst, wenn sie einmal rot wird. Deshalb bekommt jede neue Regel einmal eine Wegwerf-Klasse, die sie verletzt:

PHP
namespace App\Billing\Service;

final class InvoiceFailedException extends \RuntimeException {}

Die Klasse liegt neben den Services statt im Exception-Ordner. Wird der Check rot und ist die Meldung verständlich, stimmt die Regel. Danach kommt die Datei wieder weg.

Genannte Produkt-, Firmen- und Markennamen gehören ihren jeweiligen Inhabern und werden hier allein beschreibend verwendet. Eine Verbindung zu ihnen oder eine Empfehlung durch sie besteht nicht.