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.
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:
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:
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:
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:
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:
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.
