Server Side Testing: Welche Architektur verwendet Kameleoon?
In diesem Artikel möchten wir unsere Überlegungen und Schlussfolgerungen zusammenfassen, die Ende 2017 zur Definition unserer SDK-Architektur geführt haben. Das Thema Performance stand natürlich bei unseren Überlegungen im Vordergrund, wie schon über die letzten sechs Jahre bei der Weiterentwicklung unserer Client Side-Lösung für A/B-Testing und Personalisierung. Von Anfang an haben wir eine solide Basis geschaffen (z.B. mit einem einmaligen Download des Skriptes, Skriptgrößenoptimierung und Minimierung des nötigen Codes für die komplette Plattform).
Regelmäßig wurde die Plattform um weitere Innovationen erweitert (wie z.B. die Eliminierung des Flicker-Effekts oder erst kürzlich die Nutzung des Brotli-Algorithmus statt der GZIP Kompression in der Übertragung des Skriptes, wodurch eine Größenoptimierung von 30% ermöglicht wurde). Die folgenden Erklärungen dürften gerade Technik-Teams interessieren, die mehr über die Backend-Seite der Test-Implementierung wissen möchten.
1 „Bucketing“ von Besuchern: Umsetzung und die Herausforderung mit synchronen Anfragen
Die Implementierung von A/B-Tests benötigt eine Abfolge von Aktionen, die in 4 Schritte eingeteilt werden können:
Targeting und „Triggering“ des Tests: In diesem Schritt wird festgelegt, welche Besucher zu welchem Zeitpunkt am Test teilnehmen.
Zuordnung einer Variante zu einem Besucher (oder „Bucketing“ auf Englisch): Welche Variante sieht der Besucher und – sehr wichtig – wie wird sichergestellt, dass er in Zukunft immer dieselbe Variante sieht?
Sichtbarkeit der Variante und die zugrundeliegende Implementierung: Ist die Variante für den Besucher ermittelt, muss das entsprechende HTML erstellt werden (Sichtbarkeit im weiteren Sinne: Es kann sich auch um eine veränderte Business-Logik wie z.B. Lieferbedingungen handeln).
Tracking des Besuchers: Für eine Übersicht der Testergebnisse müssen verschiedene Daten abgespeichert werden: Die Information „Besucher-Variante“ (3 Daten: Besucher-ID, Test-ID, Varianten-ID) und die Aktionen des Besuchers (vor allem seine eventuelle Conversion oder ein Kauf im Laufe des Besuchs).
Beim serverseitigen A/B-Testing werden die Schritte 1 und 3 direkt vom technischen Team des Kunden übernommen. Das Team kennt den Quell-Code und kann sofort die Stelle identifizieren, an welcher der Test ausgelöst werden soll, und die gewünschte Logik einfügen. Technik-Teams beherrschen außerdem die Generierung des HTML der verschiedenen Varianten (normalerweise über ein Templating-Framework), das übrigens kaum von der ursprünglichen Version (ohne A/B-Test) abweicht. Dafür reicht es, einen Switch Case hinzuzufügen (oder if/else, im gängigen Fall von zwei Varianten), der auf der Varianten-ID basiert, und den entsprechenden Zweig zu erstellen.
Ein A/B-Testing-Framework bietet bei diesen Schritten wenig Mehrwert, da zahlreiche unterschiedliche Technologien genutzt werden können und so keine allgemeingültige Methode möglich ist. So ist beim Test-Triggering die Logik hinter einer MVC-Methode mit Controllern wie Spring oder Symfony eine ganz andere als bei einer hauseigenen Methode, z.B. mit URL-Rewriting in Apache. Für die HTML-Generierung gibt es Dutzende, wenn nicht Hunderte unterschiedliche Templating-Frameworks (Smarty auf PHP, Jinja auf Python, Velocity auf Java, um nur einige zu nennen). Deshalb sollte sich ein SDK für einen Editor wie Kameleoon auf die Schritte 2 und 4 beschränken und hier die Arbeit des Entwicker-Teams effizient unterstützen.
Zuerst geht es um das Bucketing (Schritt 2). Targeting (Schritt 4) ist weniger problematisch. Auf den ersten Blick scheint die Implementierung einfach. Eigentlich müsste es ausreichen, eine Zahl nach Zufallsprinzip zu ermitteln und damit zu bestimmen, ob ein Besucher der Gruppe A oder B angehört. Man darf aber nicht vergessen, dass der Besucher bei seinem nächsten Besuch die gleiche Variante sehen muss. Ohne diese Grundlage des A/B-Testing sind alle Ergebnisse verfälscht. Man kann also nicht einfach einen Code ausführen, der den Test beim Laden der URL auslöst. Die Information muss irgendwo gespeichert und gegebenenfalls reaktiviert werden. Jedoch können traditionelle IT-Systeme, gerade von E-Commerce-Webseiten, zwar die Daten identifizierter, d.h. eingeloggter User, erheben, haben aber mit der Erfassung von Daten anonymer Besucher Probleme.
Zum Glück ist genau das Kameleoons Stärke. Die logische (aber auch naivste) Implementierungsart wäre ein Aufruf eines Kameleoon-Servers (per REST Web Service). Der Call würde auf unsere Datenbanken zugreifen (die Terabytes von Daten speichern und Tausende Calls pro Sekunde beantworten können), um zu erfahren, ob der durch eine spezifische ID identifizierte Besucher bereits einer Variante zugeordnet ist. Wenn ja, würde dieser Wert genutzt, ansonsten würde eine Variante nach Zufallsfaktor zugeordnet und die entsprechende Information gespeichert. Das hört sich schlüssig an, besonders, weil gleichzeitig das Tracking des Besuchers gewährleistet ist (Schritt 4).
Diese Methode hat jedoch einen großen Nachteil. Solange der Call nicht beendet ist, kann das IT-Team keine Besucher-ID erhalten, und der dem Call entsprechende Code, der Schritt 3 beeinflusst (normalerweise ein einfaches if (variationId = 1) { // implement variation A} else { // implement variation B}) nicht durchgeführt werden. Dies führt zu einem blockierenden Call. Der darauffolgende Code wird erst durchgeführt, wenn der entfernte Server geantwortet hat. Dies wäre eine Situation, die jeder Informatiker lieber vermeidet, weil das Funktionieren der Webseite de facto von einer externen Lösung abhängt.
Nehmen wir das Beispiel eines A/B-Tests auf der Homepage einer E-Commerce-Webseite. Alle Aufrufe zum Laden dieser Seite generieren einen Call an Kameleoons Server. Sollte unser Server nicht erreichbar sein, ist die E-Commerce-Webseite ebenfalls nicht nutzbar, weil die Webseite nicht vom Hosting-Server generiert werden kann. Man könnte Sicherheitsmaßnahmen hinzufügen und z.B. die A/B-Tests im Falle eines Timeouts deaktivieren (d.h. wenn der Server nicht innerhalb von 2 Sekunden antwortet). Aber auch wenn alles perfekt funktioniert, wird die Generierung der Seite durch den Aufruf des entfernten Servers um mindestens 100 Millisekunden verlangsamt. Wenn man weiß, dass einer der großen Vorteile des serverseitigen A/B-Tests die maximierte Performance sein sollte, ist die Verlangsamung des Seitenaufbaus nicht akzeptabel! Deshalb haben wir Lösungen für dieses Problem gesucht.
Wie kann das Bucketing ohne blockierende Server-Aufrufe durchgeführt werden? Eine schwierige Frage, die uns viel Zeit und Aufwand gekostet hat. Zwar hat der Ansatz mit REST Web Service den Vorteil, allgemein zu funktionieren und wäre die einfachere Lösung gewesen als die Entwicklung eines SDK pro genutzte Technik. Aber wir wollten keine Kompromisse eingehen und haben schließlich eine zufriedenstellende Lösung gefunden. Wir geben nicht alle unsere Geheimnisse preis, aber so viel sei gesagt: Das Bucketing wird direkt und sofort innerhalb der SDK-Algorithmen durchgeführt. Und die SDK-Methode, mit welcher der Besucher einer Variante zugeordnet wird, reagiert in Echtzeit. Dieser Ansatz ist komplett unabhängig von externen Servern oder internen, eingebetteten Datenbanken.
Die einzige (sehr kleine) Einschränkung: Es ist mit dieser Methode nicht möglich, die Aufteilung der Umleitung auf unterschiedliche Varianten zu verändern, ohne ein Repooling auszulösen (ein interessantes Thema, dem wir bald einen separaten Artikel widmen). Die komplette Entkoppelung vom Hosting-Server des Kunden (mit SDK) und unseren Servern ist somit gewährleistet. Nun bleibt die Herausforderung des Trackings (Schritt 4). Hier muss zwingend unser Server aufgerufen werden, aber dieser Call kann ohne weiteres asynchron durchgeführt werden. Für das Tracking müssen Informationen an Kameleoons Server übermittelt werden, eine Antwort des Servers ist allerdings nicht nötig. Diese Aufrufe, die allein der Übermittlung von Tracking-Informationen dienen, werden auch Beacon-Calls genannt. Unsere Methode der Varianten-Zuordnung integriert also das Tracking im Aufruf des Kameleoon-Servers. Weil der Call über einen parallelen Thread ausgeführt wird, reagiert diese Methode sofort und bietet so optimierte Performance.
2 Einen Schritt weiter und Caching: Wo soll der Bucketing-Code ausgeführt werden?
Die meisten (wenn nicht alle) Webseiten mit hohem Traffic nutzen Caching, das auf mehreren Ebenen stattfinden kann. Die Implementierung von Caching kann neue Probleme bei der Erstellung von A/B-Tests auf der Server-Seite aufwerfen. Es geht hier nicht darum, zu erklären, wie Kameleoon diese Probleme angeht – es gibt ebenso viele Situationen wie Kunden, und keine Plattform kann eine allgemeingültige Lösung bieten.
Vielmehr möchten wir unsere zahlreichen Erfahrungen teilen, die wir diesbezüglich gesammelt haben und entsprechende Empfehlungen geben. Nehmen wir das Beispiel einer E-Commerce-Webseite mit hohem Traffic. Das CMS, das für E-Commerce-Aspekte verantwortlich ist (egal ob Java, PHP oder andere Technologie-Stacks) muss dynamische HTML-Seiten generieren. Auf der Homepage sollen z.B. aktuelle Sonderangebote erscheinen und bestimmte Produkte hervorgehoben werden. All diese Informationen kommen aus Backend-Datenbanken. Der entsprechende CMS-Code wird in der Regel bei jeder HTTP-Abfrage der Homepage ausgeführt. Er nutzt die Informationen der Datenbanken und zeigt dem Besucher die entsprechende Seite. Man kann davon ausgehen, dass die Homepage nicht häufiger als einmal pro Tag verändert wird.
In diesem Fall ist es sinnvoll, die Seite abzuspeichern und jedem Besucher die identische Version bereitzustellen, anstatt bei jedem (normalerweise mit einer teuren SQL-Basisabfrage gekoppelten) HTTP-Abruf erneut den Java- oder PHP-Code auszuführen. Das ist natürlich eine stark vereinfache Beschreibung, stellt aber die Idee des Caching gut dar: Dynamische Antworten werden durch statische (gleichbleibende) ersetzt, die mit weniger Aufwand bereitgestellt werden können. Cache-Speicherung kann auf verschiedene Arten implementiert werden. Hier nur zwei gängige Methoden: Beim Caching via CDN (Content Delivery Network) greift der Browser auf einen Server des CDN zu, der eine Kopie enthält. Bei der Cache-Speicherung via HTTP Cache Server (wie Varnish oder Squid) stellt ein Frontend-Server (nginx, lighttpd) die gespeicherte Seite aus dem Cache bereit, anstatt sie über Apache oder Tomcat zu erhalten. Will man einen Test implementieren, tritt wieder das Problem des Bucketing auf.
Für das Bucketing muss ein dynamischer Code ausgeführt werden, es reicht nicht, einfach eine gespeicherte Seite aus dem Cache bereitzustellen. Interessanterweise ist die HTML-Generierung dagegen einfach und ohne Störung des Caching möglich. Mit Varnish könnte man z.B. einen Header „Vary“ auf nginx Frontend-Server-Niveau erstellen. Die Position des Headers hinge vom Wert eines Cookies ab, welcher der Varianten-ID des Tests entspricht. Es ist also möglich, verschiedene Versionen / Varianten eines A/B-Tests im Cache zu speichern.
Allerdings bleibt das Bucketing problematisch. Für jeden neuen Besucher, der am Test teilnimmt, ist es notwendig, den Bucketing-Code auszuführen, er kann nicht im Cache gespeichert werden. Und das ist für manche Webseiten ein unüberwindbares Hindernis. Dieses Problem kann auf mehrere Weisen gelöst werden. Eine Mischung aus Server Side und Client Side kann durchaus relevant sein. Der Bucketing-Code könnte im Skript des Browsers angesiedelt sein, nach den Cookies der Varianten. In diesem Fall könnten mehrere Varianten im Cache gespeichert werden, zum Beispiel wie oben erwähnt via einen Header „Vary“. Mit einer solchen Methode müsste das Tracking allerdings auch auf Client Side durchgeführt werden. Ist das nicht der Fall, entsteht wieder das Problem der unmöglichen Code-Ausführung mit Cache-Speicherung. Die Mischung ist leider nicht immer möglich. Soll der Test z.B. auf der ersten aufgerufenen Seite der Webseite starten (Homepage oder eine tiefere Seite, auf der der Besucher direkt über Google landet), brauchen wir die Information der ihm zugeordneten Variante schon bevor die erste Seite (und mit ihr das Bucketing-Skript) geladen ist.
Für diese schwierigen Fälle, bei denen maximale Performance gefordert und eine Server Side-Implementierung gewünscht ist, empfehlen wir, den Bucketing-Code so weit vorne wie möglich auf Server-Seite anzusiedeln (z.B. eher auf Frontend-Ebene als in unseren Server-SDKs). Nebenbei bemerkt sollte man in einigen Fällen die gesamte Server Side-Methode hinterfragen, ein Full Client Side-Ansatz kann sich manchmal als relevanter erweisen. Zurück zu unserer Empfehlung. Ein speziell erstelltes nginx-Modul könnte das Bucketing und die entsprechende „Abzweigung“ vornehmen, während die HTML-Generierung vom Backend (Apache, Tomcat) übernommen würde.
Ein nginx-Modul ist auf jeden Fall leistungsstärker als ein Aufruf unserer SDKs, unabhängig von einer gewählten Programmiersprache, und wird besser den Anforderungen einer maximalen Performance gerecht. Kameleoon denkt übrigens über die offizielle Implementierung eines solchen Moduls nach.
3 Zum Schluss
Abschließend sei bemerkt, dass die Positionierung des Bucketing auf CDN-Niveau (in einem Schema der HTTP-Anfrage gleichwertig mit einem nginx-Server, allerdings noch weiter vorne angesiedelt) eine interessante Lösung sein kann. Cloudflare, eine innovative CDN-Lösung hat z.B. vor kurzem die Bereitstellung von JavaScript-Workern angekündigt, ein Konzept, das eine Code-Durchführung „on the edge“ (auf CDN-Niveau, wenn die HTTP-Abfrage des Besuchers nach der DNS-Weichenstellung auf dem Cloudflare-Server landet) ermöglicht.
So ist es ein Leichtes, den Bucketing-Code in NodeJS zu implementieren. Und hinter NodeJS steckt Cloudflares enorme IT-Infrastruktur mit tausenden Servern, die auch den höchsten Anforderungen an Skalierung und Performance gerecht werden.