067 —Architecture
Custom PHP-framework of spaghetti: een test in vijf vragen
Je opent de repo, ziet een map lib en vindt een functions.php van 4.200 regels. Voordat je een rewrite offreert, loop je deze vijf vragen langs.
Het is dinsdagochtend. Een Nederlands bureau waarmee we werken heeft net een website overgenomen van een interne ontwikkelaar die in 2022 met pensioen ging. De repo is 1,4 GB. De mappenstructuur heeft een /lib, een /modules, een /classes, en in de root één bestand genaamd config.php met 37 define()-aanroepen. Je opent /lib/functions.php. Het bestand is 4.217 regels lang. Er is geen readme.
De klant wil voor vrijdag twee dingen weten. Of je het kunt onderhouden. Of je het kunt uitbreiden.
Wat ze eigenlijk willen weten, ook al spreken ze het niet uit, is of de vorige ontwikkelaar een custom PHP-framework heeft gebouwd of acht jaar lang bestanden op elkaar heeft gestapeld. Van buitenaf zien die twee situaties er identiek uit. Het zijn niet dezelfde projecten om over te nemen. Deze vijf vragen, in minder dan een uur uitgevoerd, vertellen je welke van de twee je hebt gekocht.
Vraag één. De front controller
Open de .htaccess in de document root. Zie je zoiets, dan heeft iemand nagedacht over architectuur:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php?route=$1 [QSA,L]
Dat blok herschrijft elke request naar één entry point. Of index.php die request vervolgens netjes of slordig afhandelt is een aparte vraag, maar de vorige ontwikkelaar koos ervoor om requests via één bestand te routeren. Dat is het front-controller-patroon. Zo werken Laravel, Symfony, de eigen index.php van WordPress, en elk serieus PHP-framework sinds 2008. De Apache mod_rewrite-referentie legt uit wat die flags doen, mocht je nog nooit op een vrijdag een oneindige redirect-loop hebben gedebugd.
Kijk nu naar het alternatief. Bevat /admin/ 38 .php-bestanden met namen als edit-user.php, delete-user.php, edit-order.php, edit-order-shipping.php, edit-order-shipping-address.php, dan voegde de vorige ontwikkelaar simpelweg een bestand toe elke keer dat er een feature werd gevraagd. Dat is spaghetti, hoeveel gedeelde code de bestanden ook includen.
Tel het mee. Front controller aanwezig, +1. Eén .php-bestand per pagina, 0.
Vraag twee. Waar de credentials staan
Draai dit in de project root:
grep -rEn "mysqli_connect|new PDO|mysql_connect" --include="*.php" | wc -l
De -E is belangrijk. GNU grep accepteert \| als alternation in basic mode, BSD grep op macOS niet, en de stille nul die je terugkrijgt liegt tegen je. Met ERE aan gedragen ze zich beide hetzelfde.
Krijg je 1 terug, dan heeft de ontwikkelaar de database-verbinding gecentraliseerd. Krijg je 47 terug, dan niet. 47 losse verbindingen betekent ook 47 kopieën van het wachtwoord, wat betekent dat het roteren van het wachtwoord vereist dat je 47 bestanden bewerkt, wat betekent dat niemand het wachtwoord sinds 2019 heeft geroteerd.
Een echt custom framework, ook een rommelig framework, heeft een Database-class of in elk geval een db.php die één keer wordt geïncludeerd. Spaghetti herhaalt de verbinding in elk bestand omdat de ontwikkelaar bij het maken van een nieuw bestand uit het vorige plakte. Vaak vind je de credentials op diezelfde regel hard gecodeerd, en dat is een gesprek op zich met de klant.
Vraag drie. De routing-laag
In vraag één vond je een front controller. Mooi. Zoek nu de routing-tabel. Waar staat de lijst met URL's waarop de app reageert.
In een framework staat dit op één plek. Het ziet eruit als:
$routes = [
'GET /orders' => 'OrderController@index',
'POST /orders' => 'OrderController@create',
'GET /orders/{id}' => 'OrderController@show',
];
Of een config-array. Of een routes.php. Of annotaties op controller-classes als de ontwikkelaar ambitieus was. Het punt is: er is één plek die je van boven naar beneden kunt lezen om het URL-oppervlak van de applicatie in beeld te krijgen.
In spaghetti is er geen routing-laag, want de routing is het filesystem. De URL /admin/edit-user.php mapt naar het bestand /admin/edit-user.php omdat Apache zo PHP serveerde in 2003 en de ontwikkelaar dat nooit heeft veranderd. Om het URL-oppervlak te kennen doe je find . -name "*.php". Dat is geen framework.
Vraag vier. Naamgeving en conventies
Kies willekeurig twintig functienamen uit de codebase. Schrijf ze op.
Een custom framework, ook één waarvan de conventies geen PSR-12 zijn, gebruikt één conventie. Misschien is alles app_get_user, app_save_order, app_send_email. Misschien is het App\Repo\UserRepo::find. Misschien lelijke Hongaarse notatie als fn_get_user_arr. Het punt is: er is een patroon. De ontwikkelaar koos er één en hield zich eraan.
Spaghetti heeft zes conventies omdat zes verschillende copy-paste-bronnen de codebase hebben bezaaid. Je ziet, allemaal in hetzelfde project, allemaal hetzelfde doend, zonder dat ze elkaar aanroepen:
get_user_by_id($id);
getUserData($userId);
db_user_get($id);
fetch_user($id);
user_load($id);
function GetUserInfo($Id) { /* ... */ }
De PHP-FIG-standaarden documenteren hoe consistentie er in de praktijk uitziet. Hun aanwezigheid in een codebase bewijst op zichzelf niets over kwaliteit, maar hun afwezigheid bewijst dat er nooit één ontwikkelaar met een plan is geweest.
Vraag vijf. De verwijdertest
Kies een bestand dat ongebruikt lijkt. Bijvoorbeeld /lib/helpers-old.php. Verwijder het. Herlaad de homepage.
Werkt de site nog, dan was het bestand niet in gebruik en was de vorige ontwikkelaar zeker genoeg van zijn dependencies om het niet "voor de zekerheid" vanuit de bootstrap te includen. Dat is een klein datapunt richting framework.
Geeft de site een fatal omdat helpers-old.php werd geïncludeerd vanuit /lib/init.php, dat weer werd geïncludeerd vanuit /index.php, dan heb je geleerd dat de include-graph een netwerk is en geen boom, en dat de ontwikkelaar zelf ook niet wist welke bestanden actief waren. Sterk signaal voor spaghetti.
Een echt framework heeft een autoloader. Volgens de php.net-autoload-documentatie resolvet de autoloader classnames naar bestandspaden op het moment dat ze nodig zijn. Zie je spl_autoload_register of een Composer-autoload.php bovenaan de bootstrap, dan is verwijderen veilig. Zie je 92 require_once-aanroepen in init.php, dan is verwijderen pure gok.
Het resultaat lezen
Vijf punten mogelijk. In de praktijk is de uitslag binair, niet geleidelijk.
Vier of vijf van de vijf betekent dat je een onder-gedocumenteerd maar echt custom PHP-framework hebt geërfd. Behandel het als een interne Symfony-fork. Maak de conventies eigen, schrijf een README van één pagina, en offreer onderhoud tegen onderhoudstarief. Het werk is onspectaculair maar afgebakend.
Twee of minder betekent dat de vorige ontwikkelaar geen framework heeft gebouwd. De ontwikkelaar bouwde een website uit bestanden, één feature per keer, acht jaar lang. Onderhouden kan nog steeds. Je moet alleen anders offreren. Elke wijziging heeft onbekende impact tot het tegendeel bewezen is. Een bugfix is minimaal een halve dag, want de helft daarvan is uitzoeken welke van de zes get-user-functies de kapotte pagina aanroept. Prijs daarnaar, of loop weg.
Drie is het gevaarlijke middenstuk. De ontwikkelaar begon met een plan en liet het na jaar twee varen. De eerste helft van de code ziet eruit als een framework. De tweede helft ziet eruit als spaghetti die erop is geënt. Die projecten rotten op de slechtste manier weg: het team gaat ervan uit dat de framework-regels gelden, en struikelt vervolgens over het deel waar dat niet zo is.
De tool die we voor de volgende duizend hiervan bouwden
Toen we Pier bouwden liepen we hier op vrijwel elke legacy site waarmee we aanlegden tegenaan, want het 3-uit-5-geval is de mediaan en niet de uitzondering. We pakten het uiteindelijk zo aan: een project-brede grep over het live FTP-filesystem naast onze MySQL editor, zodat de antwoorden op vraag twee tot en met vijf minuten kosten in plaats van een middag, en elke wijziging die je tijdens het verkennen maakt belandt automatisch in de version history, zonder dat je erover hoeft na te denken.
De kleinste zet vandaag
Heb je nu een van deze projecten openstaan, draai dan de grep uit vraag twee en tel de database-verbindingen. Dat getal alleen, voordat je ook maar één andere regel code leest, vertelt je het meeste van wat je moet weten over welk van de twee projecten je daadwerkelijk hebt gekocht.
— Vragen —
Wat als de code wel een front controller heeft maar geen routing-tabel?
Dat is het begin van een framework dat de ontwikkelaar nooit heeft afgemaakt. Behandel alles wat na jaar twee is geschreven als onbetrouwbaar en grep naar de URL-mapping in headers, switch-statements of inline if-blokken.
Telt WordPress als custom framework?
WordPress is het framework. Wat in wp-content/themes/ of wp-content/plugins/ staat, is wat je scoort. De vijf vragen gelden voor die code, niet voor WP core.
Kan ik deze test draaien op een Magento 1-site?
Ja. Magento 1 heeft een echte front controller en routing, dus vraag één en drie scoren automatisch punten. De credential-, naamgevings- en verwijdertest onthullen nog steeds veel over de lokale modules.