— Artikel — № 046

046 —PHP

14 PHP-includes per pagina: dependencies in kaart brengen

Veertien require-statements per pagina, een sidebar die er nog drie binnenhaalt en niemand weet meer waarom. Breng de graph in kaart voor je iets aanraakt.

Handgetekend PHP-include-diagram op ruitjespapier met inktknopen, telstaat, manilla tabs, messing plaatje, rood lakzegel.
Hero · gestileerd stilleven№ 046

De pagina die tien minuten nodig had om te renderen

Een ops engineer bij een middelgrote uitgever stuurde ons op een dinsdag om 23:41 een bericht. Hun staging server liep vast op één artikelpagina. De oorzaak bleek een verlopen require_once die een nieuwsbrief-widget uit 2009 binnentrok, welke probeerde te verbinden met een Mailman-server die jaren geleden al was uitgezet. Zes andere includes stonden erachter in de wachtrij, elk wachtend tot de vorige klaar was voordat hun eigen DNS-lookup kon falen.

Het bestand bovenaan de keten zag er onschuldig uit. index.php, 38 regels. Maar elke regel die begon met require opende een deur naar een andere wereld, en de meeste van die werelden openden er weer meer. Tegen de tijd dat de pagina op een werkdag klaar was met laden, waren er 14 verschillende PHP-bestanden in de request getrokken, plus een config-laag die er zelf ook nog zeven extra binnenhaalde.

Dit artikel is het playbook dat wij doorlopen wanneer we op een custom PHP-site terechtkomen met dat soort include-topologie. Het doel is niet om in één keer te refactoren. Het doel is weten wat wat aanroept, zodat de refactor die later komt niet het hele gebouw laat instorten. We hebben dit gedraaid op Magento 1-shops, WordPress-installaties met 200 must-use plugins en maatwerk-CMS'en van bureaus die niet meer bestaan. De vorm van het werk is altijd dezelfde.

Maak een snapshot van de inclusion-graph tijdens runtime

Statische analyse liegt tegen je bij legacy PHP. Een bestand kan include $template . '.php' zeggen, waar $template drie stack-niveaus hoger berekend wordt op basis van een query string. Een grep vangt dat niet. Een linter kan het niet resolven. Runtime tracing wel.

Zet dit in een bestand dat je via auto_prepend_file aan elke request kunt vasthaken:

<?php
// /var/www/_trace/includes.php
register_shutdown_function(function () {
    $entry = [
        'ts'    => microtime(true),
        'uri'   => $_SERVER['REQUEST_URI'] ?? 'cli',
        'files' => get_included_files(),
        'mem'   => memory_get_peak_usage(true),
    ];
    file_put_contents(
        '/var/log/php-includes.ndjson',
        json_encode($entry) . "\n",
        FILE_APPEND | LOCK_EX
    );
});

En in de .htaccess in de document root, of in een per-vhost config:

php_value auto_prepend_file "/var/www/_trace/includes.php"

Laat het draaien gedurende een representatieve periode. Een week echte traffic op een contentsite, drie dagen op een interne tool, één volledige factuurcyclus bij alles wat financieel is. Je wilt elke cron raken, elk admin-pad, elke vreemde URL die alleen een ingelogde redacteur twee keer per kwartaal aantikt. De get_included_files()-aanroep staat gedocumenteerd op php.net en is goedkoop genoeg om in productie aan te laten staan voor een afgebakende periode.

Verzoen de trace met de source

Zodra je een paar duizend NDJSON-regels hebt, bouw je de daadwerkelijke graph. Een kort script volstaat, geen library nodig:

<?php
// build-graph.php
$edges = [];
foreach (file('/var/log/php-includes.ndjson') as $line) {
    $row = json_decode($line, true);
    $files = $row['files'];
    for ($i = 1, $n = count($files); $i < $n; $i++) {
        $key = $files[$i - 1] . ' -> ' . $files[$i];
        $edges[$key] = ($edges[$key] ?? 0) + 1;
    }
}
arsort($edges);
foreach (array_slice($edges, 0, 50, true) as $edge => $hits) {
    echo str_pad((string)$hits, 8) . $edge . PHP_EOL;
}

Pipe de output door graphviz als je een plaatje wilt, of lees gewoon de bovenkant van de lijst. De eerste verrassing is meestal hoeveel edges naar één bestand wijzen. Op de uitgeverssite werd lib/legacy/strings.php per request 11 keer binnengetrokken vanuit vier verschillende parents, waarvan er twee maar één helper-functie uit het bestand gebruikten.

Kruisverwijs nu met de statische kant. nikic/PHP-Parser geeft je een schone AST, maar voor een eerste pass is zelfs dit genoeg:

grep -rEn "(include|require)(_once)?\s*[\(\"']" \
  --include="*.php" /var/www/htdocs \
  > static-includes.txt

Waar je naar zoekt, is het verschil. Bestanden die de grep vindt maar die de runtime nooit aanraakt, zijn ofwel dood ofwel alleen actief op een code path dat je nog niet hebt geraakt. Bestanden die de runtime wel aanraakt maar die de grep niet kan resolven (omdat het pad berekend wordt) zijn je echte koppeling, en die verdienen de eerste ronde aandacht. Markeer die bestandsnamen rood en plak ze op de muur, letterlijk of figuurlijk.

Wat de graph meestal laat zien

Drie patronen komen telkens terug bij dit soort opdrachten. Een platte ster, waarin één centrale functions.php de parent is van alles en niets anders enige structuur heeft. Een diamant, waarbij twee parents allebei dezelfde shared library binnentrekken en die shared library via $GLOBALS weer in beide terugreikt. En een daisy chain, waarbij header.php nav.php include die menu-helpers.php include die cache.php include die config.php include, en het verwijderen van welke schakel dan ook midden in de keten de pagina breekt op een manier waaraan je een uur kwijt bent om te diagnosticeren.

Een daisy chain breken is van de drie het pijnlijkst, omdat elke schakel op zichzelf dragend lijkt. Werk van onderaf. Inline de bijdrage van het leaf-bestand in de laag erboven, laat de trace nog 24 uur draaien, bevestig dat niets anders het leaf-bestand nodig had en verwijder het dan pas. Herhaal één laag per keer. Het is traag. Het is ook de enige manier om er weer uit te klimmen zonder een stapel halfverwijderde includes achter te laten waarover de volgende onderhouder mag piekeren.

Vind de globals voordat je iets verplaatst

Bij sites uit dit tijdperk gaat de include-volgorde zelden over code-organisatie. Het gaat over variabelen in scope. header.php zet $current_user, sidebar.php leest hem. Verplaats er één en je breekt iets drie bestanden verderop.

Je hebt een lijst nodig van globals die over de include-grens heen lopen. Er is geen schone manier om die eruit te halen, maar een brute-force scan haalt het grootste deel:

# variables declared with the `global` keyword
grep -rEn '^\s*global\s+\
 --include="*.php" /var/www/htdocs

# direct $GLOBALS array access
grep -rEn '\$GLOBALS\[' --include="*.php" /var/www/htdocs

Dan het lastigere deel. PHP lekt elke top-level $var = ...-toewijzing naar de scope van het includende bestand. Een variabele die in config.php wordt gedeclareerd, is dus stilletjes zichtbaar in alles waarin config.php wordt binnengetrokken. Dit gedrag staat gedocumenteerd in de manual page van include en is het herlezen waard vóór de refactor, vooral de zin dat de variable scope die van de aanroepende regel is.

Lijst elke top-level toewijzing in de geïncludeerde bestanden:

for f in $(cut -d: -f1 static-includes.txt | sort -u); do
  awk '/^[[:space:]]*\$[A-Za-z_]+[[:space:]]*=/ {
    print FILENAME ":" NR ": " $0
  }' "$f"
done

Er is nog één patroon dat geautomatiseerde scans ruïneert. Als een include extract($_REQUEST) of extract($row) aanroept, wordt elke key in die array een top-level variabele in de aanroepende scope, en je hebt geen manier om te weten welke totdat runtime. Grep case-insensitief op extract( door de hele boom. Behandel elke hit als een waarschuwing, documenteer de vorm van de array met een kort commentaar voordat je iets verandert, en beschouw het bestand als effectief onbegrensd in wat het injecteert.

Twee uur van deze oefening bespaart je twee weken refactor-pijn later. Bij de uitgeversopdracht vonden we 47 top-level toewijzingen verspreid over 22 bestanden, waarvan er zes werden gelezen door code in volstrekt niet-gerelateerde delen van de boom. Die zes bepaalden alles aan de volgorde van de refactor.

Knip de refactor op naar blast radius

Nu kun je gaan plannen. De slices, in de volgorde die wij meestal aanhouden:

  1. Verwijder de dode includes. Bestanden die in de statische grep verschijnen maar nooit in de runtime trace, na een representatieve periode, kunnen weg. Commit elk bestand apart, met de route-dekking die het dood verklaarde als bewijs. Als je trace-venster een week was en het bestand nooit afging, heb je nog altijd git revert.
  2. Klap de dubbele pulls in. Als strings.php 11 keer wordt gerequired, vind dan de hoogste gemeenschappelijke voorouder in de call graph en require het daar één keer. De andere tien require_once-aanroepen worden no-ops die je in een vervolg-commit kunt verwijderen, los, zodat de diff reviewbaar blijft.
  3. Promoveer globals naar expliciete parameters. Kies één global, vind elke read, wrap elke read in een functie die de waarde als argument accepteert. $current_user wordt render_sidebar($currentUser). Dit is mechanisch en saai en de enige uitweg. Gebruik de runtime trace uit stap één als regressiecheck: de bestandslijst per URL moet voor en na identiek zijn.
  4. Vervang dynamische includes door een router. De include $template . '.php'-patronen zijn de plek waar productiebugs zich verstoppen. Verplaats ze naar een switch-statement of een kleine dispatch-array. Nu houdt je statische analyse op met liegen en kan je IDE eindelijk de call sites autocompleten.

Op zichzelf zijn dit geen grote wijzigingen. De reden dat ze meestal worden overgeslagen, is dat stap drie een week aan oninteressante edits kost en de diff lastig te reviewen is. De truc is om elke global als een eigen pull request te committen, met de runtime trace van voor en na als bewijs dat het gedrag niet veranderd is.

Als de site een Composer-autoloader heeft die bovenop de require-keten is geschroefd, behandel die dan als nog een parent in de graph en niet als een ontsnappingsroute. De class map zal namespaced calls netjes resolven, maar de legacy includes vuren nog steeds parallel af en dezelfde global leaks gaan nog steeds over de grens. Een veelgemaakte fout is aannemen dat het toevoegen van Composer betekent dat de bestand-voor-bestand refactor klaar is. In de praktijk heb je meestal twee inclusion-systemen die jarenlang naast elkaar draaien, en de trace is de enige eerlijke map van de unie.

Het database-beeld parallel

Eén kanttekening die bij elke opdracht naar boven komt. Dezelfde sites die 14 includes per pagina hebben, hebben vaak een db.php die bovenaan een connection opent, en daarna een queries.php die op require-niveau twintig mysql_query-aanroepen op module scope afvuurt. De output van stap één hierboven is waar je dat beeld ook vindt. Zodra je weet welke bestanden op welke requests draaien, wordt de SQL-audit een stuk korter, en het query log dat je een paar uur lang verzamelt met general_log = 'ON' legt zich netjes over de include-graph heen.

Valideer elke slice tegen de trace

Na elke commit in bovenstaande reeks heb je bewijs nodig dat het include-gedrag niet is verschoven. De goedkoopste fingerprint is een stabiele hash van de included-file-lijst per URL. Voeg één regel toe aan de prepend-handler zodat elke regel een identifier draagt waarop je kunt groeperen:

$entry['hash'] = substr(md5(implode('|', get_included_files())), 0, 12);

Haal elke hash voor een gegeven URL van vóór de wijziging op, draai de refactor en haal daarna de hashes erna op. Als de set verschilt, heeft iets een bestand binnengetrokken dat er eerder niet was, of er juist één weggelaten die er nog had moeten staan. Bij de uitgeversopdracht vingen we op deze manier twee refactor-regressies, die anders stilletjes naar productie waren gegaan. De shutdown-handler is het dichtst bij een regressietest dat een site zonder tests ooit zal hebben.

De vergelijking zelf is een one-liner:

jq -r 'select(.uri == "/article/foo") | .hash' \
  /var/log/php-includes.ndjson \
  | sort -u

Meer dan één afwijkende hash voor een URL tijdens een rustig uur is een aanwijzing, ofwel voor een echte wijziging, ofwel voor een code path dat je hebt gemist. Bekijk het voordat de volgende commit landt. De discipline verdient zichzelf terug op het moment dat ze voor het eerst een refactor vangt die stilletjes stopte met het includen van de cache layer op één request-type.

Waar dit voor ons uitkomt

Toen we Pier bouwden voor het bewerken van precies dit soort legacy sites, was het include-probleem een van de eerste muren waar we tegenaan liepen. Wat we uiteindelijk deden, was per request een trace bijhouden van aangeraakte bestanden op de server, de graph in de editor renderen en je elke edge laten aanklikken om direct naar de require-regel te springen die hem binnentrok. De version history verzorgt het deel waarin je terugloopt vanaf een slice die niet werkte, en de MySQL editor staat naast de bestandsboom zodat de schema-audit naast de code-audit kan draaien. Dat is het werk. De tool komt daar pas achteraan.

Eén ding om vandaag te doen

Open de grootste index.php of front-controller.php op een site die je onderhoudt en voeg de auto_prepend_file-trace van hierboven toe. Laat hem 48 uur staan. De lijst met bestanden die je terugkrijgt, is korter dan je verwacht, en het verschil tussen die lijst en wat de codebase werkelijk bevat, is de omvang van je uiteindelijke refactor.

— Vragen —

Kan ik de auto_prepend_file-trace in productie draaien?

Ja, voor een afgebakende periode. De shutdown-handler voegt microseconden per request toe. Roteer het NDJSON-bestand en schakel de prepend uit zodra je een representatieve sample hebt. Laat hem niet onbeperkt aanstaan.

Wat als de site PHP-FPM gebruikt in plaats van mod_php?

Dezelfde prepend, maar recycle workers tussen samples in, zodat get_included_files() geen cumulatieve resultaten teruggeeft uit een langlopend proces. Zet pm.max_requests op een laag getal tijdens het trace-venster.

Hoe trace ik includes op CLI cron jobs, niet alleen op web requests?

Zet auto_prepend_file in een php.ini die gescoped is op de CLI SAPI, of geef -d auto_prepend_file=/path/to/trace.php mee in het cron-commando. De shutdown-handler vuurt op dezelfde manier af.