Auch wenn ich seit über 15 Jahren mit WordPress arbeite, lerne ich immer noch neues dazu. So z.B. in einem aktuellen Kundenprojekt. Leider ist die Konstante define('SHORTINIT', true); nicht von WordPress selber dokumentiert, aber was du damit machen kannst fasse ich hier einmal an einem Praxisbeispiel zusammen.
Das Problem
Komplexere WordPress Projekte werden schnell einmal zur Performance-Hölle, irgend ein Plugin, umfangreiche Gutenbergblöcke und WooCommerce führen schnell dazu, dass die Ladezeiten auch auf schnellen Servern mit Persistent Object Caching explodiert. Das ist sehr schade, denn im Grunde ist der Stack einfach und solide. Ich habe schon Stunden damit verbracht, das Bottleneck in einer bestehenden Installation zu finden, meist ohne grossen Erfolg. Es sind 100 kleine Teile, und wenn jedes davon 50 oder 100ms Ladezeit verursacht, hat man in Summe ein Desaster.
Also nun zum Problem: ein Kunde möchte gerne einen WooCommerce-Shop mit sehr vielen Artikeln (>5000) und Varianten (10-30 pro Artikel) und sehr vielen Produktattributen (Merkmale, Farbe, Grösse, Material etc. insgesamt 13 Dimensionen à jeweils 2-140 Werten). Jede Artikelvariante hat einen Lagerbestand.
Anforderung: nur Artikel die Verfügbar sind, sollen angezeigt werden. Filtern in allen Dimensionen und zusätzliche Freitextsuche sowie Sortierung etc. Zudem sollen nur Filter-Werte in der Auswahl angezeigt, werden für die es auch effektiv noch Artikel verfügbar hat. Normale Shop-Anforderungen also. In WooCommerce lässt sich das so einigermassen mit Boardmitteln abdecken, die Performance ist dann aber nicht akzeptabel.
Da der Import aus dem ERP und die Zahlungsanbindung bereits gebaut sind, bleiben wir also dennoch beim “Framework” Woocommerce.
WooCommerce legt zusätzliche Datenbanktabellen an “Lookup Tables” für Produkt-Attribute (wp_wc_product_attributes_lookup) und Produkt-Metas (wp_wc_product_meta_lookup), das hilft beim Suchen und Filtern im Shop.
Was nun?
Einen Headless-Shop der auf die Woocommerce Store API (obacht, nicht zu verwechseln mit der WooCommerce REST API) zugreift und dadurch schlank filtern und suchen kann. Gesagt getan, mit dem Frontend-Framework der Wahl werden alle Anforderungen abgedeckt und der Shop gebaut. Doch leider hat die Store API ein paar Einschränkungen. So muss ich z.B. selber irgendwie Buch führen, wenn ich nur noch Filter-Werte anzeigen will, bei denen es auch effektiv Produkte hat, also z.B. wenn ich Farbe: rot wähle, sollen im Grössen-Filter nur noch Werte angezeigt werden, bei denen auch effektiv Produkte verfügbar sind (lieferbar). Ich muss also für jede erdenkliche Kombination der Filter ausrechnen ob noch Produkte verfügbar sind oder nicht, bevor der Filter ausgewählt wurde (bei 13 Dimensionen à 2-140 Werten gibt das eine grosse Zahl von Möglichkeiten). Natürlich kann man auch mehrere Filter nacheinander auswählen…
Ich wollte mir nicht eingestehen, dass WooCommerce dafür nicht geeignet ist.
Die Lösung
Regel #1: don’t write your own… Ausser es ist wirklich nötig. Also habe ich kurzerhand eine eigene Query-API gebaut (stark verkürzt und vereinfacht, aber man sieht das Prinzip):
add_action( 'rest_api_init', function () {
register_rest_route( 'kdgs-shop', '/product-query', [
'methods' => 'GET',
'callback' => 'kdgs_product_api',
'permission_callback' => '__return_true', // allow public access
] );
} );
function kdgs_product_api( WP_REST_Request $request ) {
// handle query based on query parameters and order
// don't forget pagination and search...
// send result as json
return rest_ensure_response( $response );
}
Ein durchschnittlicher Request für den 1. Level (also ein Filter ist gesetzt) dauerte ca. 800-1000ms. Obwohl die Resultate aus dem Cache geliefert wurden. Irgendwie immer noch nicht berauschend. Also habe ich das mit einem kleineren Datenset probiert, gleiches Ergebnis. Und dann kam die Erleuchtung. Bei jedem API-Call wird der gesamte WP-Stack hochgefahren, also alle Plugins, alle Hooks, User-Session etc. aber das brauch ich alles gar nicht für eine öffentlich zugängliche Information.
SHORTINIT to the rescue!
Also habe ich in meinem Theme ein neues minimales File angelegt, welche komplett “ausserhalb” der WP Welt lebt: product-api.php
<?php
// this skips the entire WordPress stack being booted up and therefore
// has a huge performance impact. we can query the cache (get_transient)
define( 'SHORTINIT', true );
require_once( $_SERVER['DOCUMENT_ROOT'] . '/wp-load.php' );
$cache_key = 'kdgs_products_' . hash( 'sha256', json_encode( $_GET ) );
$cached = get_transient( $cache_key );
if ( $cached !== false ) {
header( 'Content-Type: application/json' );
header( 'X-Cache-Hit: ' . $cache_key );
echo json_encode( $cached );
exit;
} else {
// run the query and save it to the cache
// global $wpdb is available at this point, but not WP_Query or get_posts()
...
}
Dieses File kann nun vom Headless-Shop aus aufgerufen werden ohne, dass der ganze WordPress-Stack angeworfen wird. Das führt dazu, dass die Response Time auf <50ms (im Cache-Fall) reduziert wurde. Und noch obendrauf kann man Spezialfälle in der Query selber abwickeln, wenn also z.B. Sale-Artikel immer zuerst oder saisonale Artikel spezifisch gehandhabt werden sollen oder in der Freitextsuche auch nach Artikelnummer und Marke gesucht werden soll.
Der Nachteil? Man hat keinen Zugriff auf die ganze WP API, also alle Helper / Sanitizer usw. stehen einem nicht zur Verfügung. Das ist der Kompromiss den man eingeht. Ich finde aber, in begründeten Fällen, wie in meinem Beispiel, ist das gerechtfertigt und der Performance-Impact so gross, dass es vertretbar ist.