Single Sign On für Web und Desktop Applikationen leicht gemacht

Schlagwörter

,

Nachdem in den letzten Monaten die spannendsten Themen um mich herum mit Windeln und Ausscheidungen jeglicher Art von Babys zu tun hatten, habe ich diese Woche wieder ein spannendes IT Problem auf dem Schreibtisch gehabt.

In meinem aktuellen Projekt soll ein Single Sign On (SSO) eingeführt werden. Das spannende dabei ist, das Web-Applikationen und eine Java-Spring Applikation mit SSO versehen werden sollen. Was daran spannend ist, wird deutlich, wenn man sich skizenhaft anschaut wie gängige SSO Provider arbeiten.


Es soll eine Applikation abgesichert werden, die unter myService.someDomain.de erreichbar ist, dabei passiert folgendes:

  1. User öffnet myService.someDomain.de. GET myService.someDomain.de
  2. Der Server erkennt, dass der User nicht eingeloggt ist und leitet diesen zum SSO Provider um. Dabei gibt er ihm einen Backlink auf sich mit: 302 sso.anydomain.de/login?backlink=https://myService.someDomain.de
  3. Der SSO Provider prüft anhand der Cookies in seiner Domäne, die Identität des Users. Falls der User noch nicht eingeloggt ist, bekommt er ein User-Login angezeigt
  4. Nachdem der User erfolgreich angemeldet wurde, wird der Browser auf den Backlink umgeleitet und eine TicketId mitgegeben: 302 myService.someDomain.de?ticket=123456789
  5. Die Applikation nimmt die TicketId und fragt anhand dieser die Identiät des Users über eine API an.

Solange man sich im Webumfeld befindet, alles gut. Aber eine Desktop Applikation wird nun Probleme haben an die Identität des Users zu kommen, da diese in den Cookies des Browsers steht.

Man könnte sich auch einen Speicherort außerhalb des Cookies überlegen, an den die Desktop Applikation ohne weiteres kommt (z.B. das lokale Filesystem), da wird aber der Browser Probleme haben dran zu kommen, da der in einer Sandbox läuft und an keine lokalen Ressourcen des Rechners dran kommt.

Wie mans nun dreht und wendet, man muss sich einen Hack überlegen, wie man

  • es der Desktop Applikation erlaubt an die Identität in den Cookies zu kommen

oder

  • es dem Browser erlaubt an die lokal gespeicherte Identität zu kommen.

Da die meisten SSO Provider die Webschine standardmäßig unterstützten, fährt man deutlich standardkonformer und damit risikoärmer, wenn man die Desktop Applikation dahingehend anpasst, dass diese die Cookies ausliest.

Java bietet die Desktop-API, über die man u.a. eine Url im Standardbrowser des Systems öffnen kann.

Desktop.getDesktop().browser(new URI("https://sso.anydomain.de/login"));

Damit geht ein Browser-Fenster auf, das den User authentifizert, entweder über ein bereits vorhandenes Login, oder über eine Username/Passwort Eingabe.

Nun haben wir zwar schon einen Browser, der den User kennt, geöffnet, stehen aber weiterhin vor dem Problem, dass die Applikation ohne tiefere Kenntnis der Browserinterna nicht an die Information rankommt, wer eingeloggt ist. Der Browser darf leider auch nicht viel, um die Login-Information an die Applikation weiterzugeben. Aber eins kann ein Browser sehr gut: HTTP.

Wenn wir für den Login in der Desktop Applikation einen HTTP Server öffnen, dann kann der Browser seine Informationen dorthin weitergeben. Dafür kann sogar standard SSO-Vorgehen genutzt werden. Da der geöffnete Browser auf der selben Maschine läuft wie die startende Desktop Applikation, kann er die TicketId einfach an localhost weiterleiten.

sso.anydomain.de/login?backlink=http://localhost

Der Webserver muss nichts anderes tun können, als einen GET-Request entgegen zu nehmen und zu beantworten, daher muss es kein ausgewachsener Tomcat oder Jetty sein, es tut auch ein JLHTTP.

Der Java-Code dazu kann wie folgt aussehen:

Semaphore ticketIdSemaphore = new Semaphore();
HttpServer server = new HttpServer();
server.onRequest(req -> {
ticketIdSemaphore.set(req.getParameter("ticketId"));
});
server.start();
Desktop.getDesktop().browser(new URI("https://sso.anydomain.de/login?backlink=http://localhost"));
String ticketId = semaphore.get();
User user = new MySSoProviderApi().validate(ticketId);

Ein Nachteil dieser Methode ist natürlich, dass es für das Login einen Medienbruch gibt und der User die native Applikation verlässt, um sich anzumelden. Ob dies akzeptabel ist, muss fachlich geprüft werden. Die Vorteile auf technischer Seite sind:

  • Die Lösung ist OS- und Browser-unabhängig, da nur Standard-Java und HTTP verwendet wird.
  • Man kann SSO wie es vom Provider kommt out of the box benutzen und auf diese Weise Implementierungsaufwände sparen und geht keine Security Risiken bei selbstgestrickten Lösungen ein.
Werbung

Softwareentwicklung@Cloud

Schlagwörter

, , , , ,

Eine vernünftige Buildumgebung gehört in den Werkzeugkoffer eines guten Softwareentwicklers. Ohne diese ist man mit jeder Menge manuellem Kleinkram beschäftigt, der einem die Zeit für die Entwicklung nimmt und kann keine Software in vernünftiger Qualität ausliefern.

So sehr ich das in meinen professionellen Projekten beherzige, so deletantisch habe ich mich anfangs bei meinen privaten Projekten angestellt. Da wurden Sourcen per Mail zwischen Rechnern ausgetauscht, manuell erstellte Jars auf Server deployed und andere Sünden begangen. Doch dann kam jener glorreiche Tag, an dem im Java Magazin ein Artikel mein Interesse für Amazon Webservices weckte. Schon ziemlich bald setzte ich mir einen Server auf, auf dem ich ein SVN hostete. Nach und nach baute ich den Server dann weiter aus.

Bevor jemand fragt: Ja ich lebe damit, dass Amazon Zugriff auf meinen Sourcecode hat. Ja, ich habe einen Teil meiner privaten Daten und auch Schlüssel auf einem Server im Nicht-Europäischen Ausland liegen. Und nein, ich glaube nicht, dass es Angestellte bei Amazon gibt, die nichts besseres zu tun haben, als sich durch die etlichen Petabyte an Daten in ihren Rechenzentren zu wühlen, um meine Daten zu klauen.

Folgende Dienste bietet mein Server an:

  • Versionsverwaltung: Angefangen hat alles mit einem SVN. Inzwischen fühle ich mich ziemlich Oldschool und werde wohl auch noch ein Git installieren 😉
  • CI-Server: Um Binaries zu erzeugen, automatisierte Tests durchzuführen und meine Deployment-Pipeline durchzulaufen, habe ich einen Jenkins installiert.
  • Binary-Repository: Brauche ich weniger, um Binaries mit anderen Team-Mitgleidern auszutauschen, sondern als zentralen Bestandteil meiner Deployment Pipeline, die fürs Staging Binaries aus dem Repository lädt. Hierfür verwende ich Nexus.
  • Konfiguration-Management: Um Kosten für die Testumgebung zu sparen, ziehe ich meine Testinstanzen on demand hoch. Für die Konfiguration der Instanzen verwende ich Puppet, der Buildserver dient auch als Puppet Master.

Zugegeben ziemlich viele Diente auf der einen Kiste. Da es sich aber um kleine Projekte handelt, mache ich mir keine Sorgen, dass das nicht skaliert. Tatsächlich kam ich bis vor kurzem mit einer Micro-Instanz aus, die gerade mal 615 MB Speicher hat.

Doch erst mal der Reihe nach. Welche Dienste von AWS brauchen wir überhaupt? Wir brauchen einen Server, auf dem wir die ganze Software installieren können und wir brauchen eine Sicherheit, dass die Daten (insbesondere die Sourcen), die wir ablegen nicht plötzlich durch Datenfehler weg sind. 3 Dienste sind für uns interessant:

  • EC2 (Elastic Compute Cloud): Dieser Dienst stellt uns den eigentlichen Server mit CPU, Speicher und Betriebssystem zur Verfügung. Dieser ist die Ausgangsbasis meines Buildservers. Zu beachten bei EC2 ist, dass Amazon keinerlei Gewährleistung für die Verfügbarkeit von Server oder auch nur einen konsistenten Zustand des unterliegegenden Filesystems übernimmt.
  • EBS (Elastic Block Storage): Dieses ist einer der vielen Persistenz-Dienste von AWS. Im Wesentlichen kann man EBS mit einem Storage aus einem SAN vergleichen. Es kann ins Betriebssystem gemountet werden und erlaubt schnelle Zugriffe. EBS Volumes wird 99,5% Bestängigkeit pro Jahr zugesichert => Mit einer Wahrscheinlichkeit von 0,5% verliert man in einem Jahr alle seine Daten auf dem Drive
  • S3 (Simple Storage Service): Das ist Amazons Persistenz-Dienst fürs Grobe. Man kann TB-weise Daten ablegen, hat diese bei Bedarf direkt per http im Zugriff und eine zugesicherte Beständigkeit von 99,999999999%. Der Preis dafür ist, dass es sich um einen Webservice handelt, der nicht direkt gemountet werden kann und entpsrechend langsam ist.

Das ganze wird wie folgt zusammengestöpselt:

ScreenHunter_01 Apr. 21 22.23

Der Buildserver selber ist eine Ec2-Instanz, auf der alle Tools installiert sind. Diese werden einmalig manuell installiert, anschließend wird aus der Installation ein Image erstellt, aus dem man den Server jederzeit wieder herstellen kann. Da sich an der Serverkonfiguration selber nicht so viel ändert, ist das recht schwergewichtige Erstellen eines Images als Backup ein gangbarer Weg.

Anders sieht es bei den Daten aus, die durch die Tools erzeugt werden. Diese ändern sich ständig (mit jedem Commit) und haben einen hohen Wert in Form von Arbeit, die drin steckt. Diese brauchen eine höhere Zusage an die Datensicherheit als 0. Daher wird ein EBS Volume erzeugt und dem Buildserver zugewiesen. Das Home für die genutzten Tools wird auf diesem Volume angelegt.

Da 99,95% Beständigkeit nicht die Welt sind und Daten auch mal durch Layer-8 Fehler verloren gehen können, erzeuge ich regelmäßig ein Snapshot des Daten-Volums, das dann in S3 abgelegt wird.

Der Jenkins selber hat Jobs, die andere EC2-Instanzen starten und stoppen, um Test-Instanzen und Last-Generatoren zur Verfügung zu haben.

So haben alle Dienste, die ich zum Glücklichsein bauche, ein Zuhause gefunden und werden optimal gesichert. Das Sichern des Daten-Volumes und das Erzeugen des Images sind Jenkins Jobs, die manuell oder zeitgesteuert werden können. Das hat was für sich: Der Jenkins ha Jobs, um sich selbst zu sichern.

Kosten

Die Aufschlüsselung der Kosten ist bei AWS sehr feingranular nach Laufzeit von Instanzen, belegten GB, I/O-Zugriffen, …

Um ein Gefühl für die Zahlen zu bekommen: Mein Daten-Volume 10 GB groß. Für die gesamte Persistierung inkl. Backups gebe ich ~€2 / Monat aus. Spannender weil teurer sind die EC2 Instanzen. Als Buildserver habe ich bis vor kurzem die Micro-Instanz verwendet, diese kostet $0,02 pro Stunde. Das macht pro Monat ~$15,36 oder ~€11. Verbessert wird die Bilanz durch einen kostenlosen Schnupperkurs in AWS, bei dem man 1 Jahr lang einige Dienste von Amazon in geringem Umfang kostenlos testen kann. Die Verwendung einer Microinstanz ist da drin. Meine Testserver sind m1.small-Instanzen, diese kosten $0,044 pro Stunde. Mit einer Stunde komme ich für meine automatisierten Tests aus, damit kostet mich jedes Commit noch mal zusätzliche 3 c€nt.

Leider komme ich seit kurzem nicht mehr mit der Microinstanz als Buildserver aus, da der physikalische Speicher für einen Buildjob nicht mehr ausreicht. Mit der m1.small wäre ich jetzt bei €25 pro Monat. Da hat bei mir jetzt der Geiz zugeschlagen, und ich habe mich auf die Vorteile von Cloud besonnen: „Nimm nur was du brauchst, zahle was du nimmst“. Ich entwickele keine 24h am Tag und arbeite nicht mal annähernd jeden Tag an meinen privaten Projekten. Warum soll ich dann 24/7 einen Buildserver bezahlen?

Also habe ich mich noch mal hingesetzt und die Start-Skripte des Servers geradegezogen, so dass dieser ohne manuelles Eingreifen nach einem Neustart alle seine Dienste bereitstellt. Dann habe ich mir eine dynamischen Dns-Adresse bei Freedns geholt (Dyndns ist ja bald nicht mehr kostenlos) und mit den EC2 Api Tools Start/Stop Skripte erstellt und auf meinem Desktop abgelegt. Nun ruht mein Buildserver, wenn ich nicht gerade am Werkeln bin und kostet mich kein Futter. Wie sich das auf meine Rechnung auswirkt, kann ich noch nicht sagen, ich gehe aber davon aus, dass ich nun trotz mehr Power bei deutlich unter €10 pro Monat liege.

Was uns Socken über Software-Architekturen lehren

Zuletzt habe ich mit einem Nicht-IT-Problem gekämpft, das mir mal wieder eine wichtige Regel für Software-Architekturen vor Augen geführt hat:

Bei meinem täglichen Prozess „Anziehen“ gibt es einen kleinen Teilschritt „Socken bereitstellen“, der überpropotional viel Performance zog. Dieser Prozess muss im Dunkeln ablaufen, damit ich meine Frau nicht wecke. Er ist schon teilweise optimiert, z.B: habe ich nur dunkle Socken einer Dicke in meinem Socken-Pool, so dass ein Random-Zugriff ausreicht, um ein passendes Paar herauszuziehen.

Dieser Prozess wird aber dadurch gestört, dass sich im Socken-Pool über die Zeit immer mehr Einzelsocken sammeln, und ich (gerade wenn sich die Anzahl gematchter Sockenpaare kurz vorm nächsten Waschgang senkt) mehrere Zugriffe brauche bis ich ein Paar erwische.

Es gab zwar regelmäßige Housekeeping-Jobs, in denen ich zusammengehörende Einzelsocken wieder gematcht habe, dieser lief aber sehr unregelmäßig und wurde erschwert, da die Socken unterschiedlich schnell verblasten und so das Matching erschwerten. Er führte nie dazu, dass der Pool frei von Einzelsocken war. Einen Prozess zum endgültigen Entfernen von Einzelsocken gab es nicht, da ich keine Statistiken darüber führen konnte wie lange eine bestimmte Einzelsocke schon im Pool lag.

Diesen unbefriedigenden Zustand versuchte ich an der Wurzel zu packen, indem ich den Matching-Prozess diekte nach dem Waschgang verbessern wollte. Lösungsversuche sahen hierzu wie folgt aus:

  • Kauf von unterschiedlich gemusterten Socken, um das Matching zu erleichtern
  • Socken im Wäschesack waschen
  • Verwenden von Snap-Socks (diese kann man vor der Wäsche mit einem Durckknopf verbinden)

Aus verschiedenen Gründen sind alle diese Versuche gescheitert. Zuletzt habe ich die Strategie gewechselt und einfach akzeptiert, dass Einzelsocken aus der Waschmaschine kommen können. Dann habe ich einen Prozess etabliert, der besser mit dieser Situation umgehen kann: In einem dedizierten Filter-Schritt werden alle Einzelsocken herausgefiltert bevor sie in den Socken-Pool gehen und in einem eigenen Einzelsocken-Pool abgelegt.

Der Einzelsocken-Pool ist eine selbstentwickelte Custom-Lösung. Dabei handelt es sich um eine familien-übergreifende Instanz. Hier eine Abbildung der Komponente:

socken-tool

Mit diesem System gehören die Matching-Probleme nun endlich der Vergangenheit an.

Fazit
Nachdem ich sehr viel Geld und Aufwand investiert habe, um das Auftreten von Match-Fails nach einem Waschgang zu vermeiden, habe ich das Problem letztlich gelöst, indem ich akzeptiert habe, dass diese einfach auftreten und mit diesem Umstand umgehe.

Die Analogie in die IT ist vermutlich augenfällig: Kein System ist 100% verfügbar; Keine Schnittstelle bekommt 100% richtige oder auch nur syntaktisch korrekte Daten; Nicht alle User verhalten sich unseren Erwartungen konform. Der Fehler ist die Regel. Design for the failure!

Eigentlich keine neue Erkenntnis, aber man muss diese immer mal wieder auffrischen. Gerade in Zeiten von Cloud-Diensten und Microservices wird es immer wichtiger, dass eine Applikation nicht komplett ausfällt, nur weil ein kleiner Peripher-Dienst nicht verfügbar ist.

P.S. Nachdem das System nun schon 2 Monate in Produktion ist, glaube ich ein uraltes Rätsel der Menschheit gelöst zu haben: Die Waschmaschine lässt keine Einzelsocken verschwinden: Früher oder später finden sich die Paare im Einzelsocken-Pool wieder.

Continuous Delivery mit Maven und Jenkins

Schlagwörter

, , , ,

Continuous Delivery (CD) tritt an uns die Angst vor Releases zu nehmen. Die von CD gewählte Therapieform ist die Konfrontationstherapie: Wir release so oft bis es uns keine Angst mehr macht. Der Angsthemmer, den uns CD dabei an die Hand gibt heißt „Automatisierung“:

  • Automatisiertes Bauen
  • Automatisierte Deployments
  • Automatisierte Tests
  • Automatisiertes Konfigurationsmanagement

In diesem Post will ich mich den ersten beiden Punkten widmen.

Automatisiertes Bauen

Das automatisierte Bauen ist eine der am besten verstandenen Disziplinen, da Continuous Integration (CI) inzwischen weit verbreitet ist und viel Vorarbeit geleistet hat. Eine Besonderheit ergibt sich allerdings bei CD in Kombination mit Maven:

Wenn wir uns die Kernaussage von Continuous Delivery „Jedes Build ist ein potentielles Release“ anschauen, ergibt sich daraus, dass jedes Build ein Release sein sollte, und eben kein SNAPSHOT. Das ist schon mal die erste Herausforderung in Maven, da dieses Tool darauf ausgelegt ist so lange Snapshots rauszuhauen, bis sich jemand entschließt manuell (und seis per Release-Plugin) die Versionsnummer hochzuzählen.

Es gibt aber einen Trick (kurz vor Hack), mit dem wir Maven helfen seine Versionsnummern dynamisch zu vergeben: Wir müssen die Versoinsnummer einfach als Property definieren und dann beim Build überschreiben.

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
  <modelVersion>4.0.0</modelVersion>
  <groupId>org.mibutec</groupId>
  <artifactId>CiApp</artifactId>
  <version>${ciVersion}</version>

  <properties>
    <ciVersion>0.1-SNAPSHOT</ciVersion>
  </properties>
</project>

Der Aufruf erfolgt dann über

mvn deploy -DciVersion=42

Durch diese POM in Kombination mit dem gezeigten Aufruf wird in unserem Release-Repository ein neues org.mibutec.CiApp Artefact in der Version 42 abgelegt. Dass die Versionsnummer nun für kein anderes Build mehr verwendet werden kann und wir einen Automatismus brauchen, der uns eine Versionsnummer generiert, ist klar, aber letztlich auch gewollt. Schließlich müssen wir in folgenden Prozessschritten auf diese Versionsnummer zugreifen können.

Das Setzen der richtigen Versionsnummer ist Aufgabe der CI-Umgebung. Im einfachsten Fall nimmt man als Versionsnummer die aktuelle Buildnummer.

ScreenHunter_05 Mar. 14 09.14

Automatisierte Deployments

Nachdem die Applikation gebaut wurde, muss diese auch noch durch die verschiedenen Testinstanzen gestagt werden. Welche und wie viele es sind hängt von den individuellen Testprozessen des Projekte ab. In der Minimalform sollten die folgenden aber nicht fehlen:

  • Integration: Auf dieser Instanz wird aus den vielen Einzelmodulen ein großes Ganzes gebaut. Auf dieser Instanz findet eine Reihe von Integrationstests auf Basis von Testdaten statt.
  • QA: Auf dieser Instanz finden die automatisierten Akzeptanztests statt, die auf echten Daten beruhen. Auch wenn es manuelle Tests theoretisch bei CI nicht mehr gibt, würden diese hier stattfinden.
  • Predprod: Unterschiedliche Stages sind in der Regel unterschiedlich konfiguriert, so werden an den vorher genannten Testsystemen externe Systeme auch nur als Testsystem angebunden. Die Preprod hat eine Live-Konfiguration. Auf dieser finden nur noch lesende Smoketests statt, die die Richtigkeit der Live-Konfiguration testen sollen.

Die Release Artefakte selbst können aus dem Maven Repository geladen werden, dort sind sie zentral per http abrufbar. Eine Deployment-Strategie, die sich in meinen Projekten bewährt hat, ist es die Systeme selbst wissen zu lassen wie die Software auf diese deployed wird. Hierzu haben die Systeme ein Shell-Skript als Schnittstelle. Dieses Skript liegt in allen Systemen an der gleichen Stelle, hat den gleichen Namen und bekommt als Parameter mindestens die Versionsnummer des zu deployenden Artefakts übergeben. Diese Skript lädt die Software aus dem Repository, entpackt sie, legt an die richtige Stelle, startet den Server durch, …

#!/bin/bash
version=${1}

curl --user nexusUser --location "http://nexus.mibutec.org/service/local/artifact/maven/redirect?r=public&g=com.testo&a=TestoApp&v=${version}&p=war" > mibutec.war
if [ $? != 0 ]; then
  echo "curl failed for to load artifact, exiting with status code 2"
  exit 2
fi

# stop Tomcat
sudo /etc/init.d/tomcat6 stop
if [ $? != 0 ]; then
  echo "Stopping Tomcat failed, exiting with status code 2"
  exit 2
fi

mv ${TOMCAT_HOME}/webapps/mibutec* archive/`date`/
mv mibutec.war ${TOMCAT_HOME}/webapps

# start tomcat
sudo /etc/init.d/tomcat6 start
waitForStartup

if [ `<code>curl -I http://localhost:8080/index.jsp`</code> != 200 ]; then
  echo "Sanitycheck failed"
  exit 2
fi

Die generierten Fehlercodes sind wichtig, da diese die aufrufende CI-Umgebung informieren, wenn beim Deployment etwas schief gegangen ist. Wichtig sind auch die Zeilen 17 und 24. Diese implementieren (wenn in diesem Beispiel auch in einer sehr primitiven Form):

  • in Zeile 17 eine Rollback-Möglichkeit: Wenn sich rausstellt, dass das Deployment nicht gekalppt hat, kann die alte Verson der Applikation aus dem Archiv geladen und deployed werden.
  • in Zeile 24 einen ersten Sanity-Check, der prüft, ob die Applikation überhaupt richtig deployed wurde und starten kann (prüfe auf returncode 200 beim Abruf der index.jsp)

Für den Aufruf dieses Skripts wird ein Job im Jenkins erstellt, der als Parameter die zu deployende Version und ggf. das Zielsystem übergeben bekommt. Diese nutzt er dann, um per ssh das Skript auf dem Zielsystem aufzurufen.

ScreenHunter_05 Mar. 14 09.26 ScreenHunter_05 Mar. 14 09.27

Release

Funktional gesehen ist das Release auch nur ein Staging-Schritt (wenn auch der wichtigste), und wird wie die oben beschriebenen Schritte gehandhabt. Spätestens an dieser Stelle reicht das einfache Skript von oben allein nicht mehr aus, da nun noch Erweiterungen rein müssen, die ein Deployment erlauben ohne dass es für den Kunden spürbar ist. Diese hängen aber ganz individuell von der Deployment-Stretegie der Applikation ab.

Fazit

Dies ist schon einer meiner längeren Blog-Einträge, und ich habe es trotzdem nicht geschafft alles zu erwähnen, was man beim Einsatz von CD beachten muss. Wie führe ich meine automatierten Abnahmetests automatisch aus? Wie richte ich den Server ein, damit er meine Software überhaupt verwenden kann? Wie mache ich aus meinen vielen Jenkins Einzeljobs ein Großes und Ganzes? Wie gehe ich mit Datenbank-Änderungen um? Was wird aus meinen nightly builds? …

Aber das schöne an CD ist, ist dass es keine Alles-oder-Nichts Technik ist. Man muss nicht komplett alles umsetzen, um Vorteile zu haben. Da es sich bei den meisten Aspekten nur um Automatisierungen handelt, hilft jeder einzelne Schritt schon für sich manuelle Arbeit und damit verbundene Fehler einzusparen.

Wenn das Bauen der Software aufwändig ist und Schmerzen bereitet, dann baue eine Automatisierung dafür.

Wenn das Kopieren der Binaries und Aufsetzen der Applikation auf einer Instanz aufwändig ist und Schmerzen bereitet, dann baue eine Automatisierung dafür.

Wenn das manuelle Testen der Applikation aufwändig ist und Schmerzen bereitet, dann erstelle automatisierte Abnahmetests.

Wenn das regelmäßige Ausführen und Auswerten der Tests aufwändig ist und Schmerzen bereitet, dann baue eine Automatisierung dafür.

Wenn das Updaten der Konfiguration der Server aufwändig ist und Schmerzen bereitet, dann baue eine Automatisierung dafür.

Ihr könnt euch sicher vorstellen, wie das jetzt weiter geht.

Der Begriff Continuous Delivery ist übrigens nicht geschützt. Jeder kann selber entscheiden ab welchem Grad der Autoamtisierung er CD erreicht hat 😉

Fachliche Komplexität transformieren und so greifbar machen

Schlagwörter

,

Diese Woche hatte ich einen interessanten Workshop, in dem es darum ging dem Kunden das Ausmaß seiner fachlichen Anforderungen vor Augen zu führen.

Bei dem Projekt geht es um das Aufsetzen eines PIM-Systems, darin gibt es unter anderem auch die Anforderung, dass man Produkt-Verknüpfungen anlegen können soll. Eine Produktverknüpfung hat einen Typ (z.B. ist Zubehör, ist ein passender Aufsatz, …). Soweit so einfach.

relation

Von diesen potentiell vielen Verknüpfungen soll aber immer nur ein bestimmter Ausschnitt in unterschiedlichen Kontexten angezeigt werden. Die unterschiedlichen Kontexte sind

  • das Land, in dem ein Produkt angezeigt wird
  • das Medium / der Kanal, über den das Produkt ausgegeben wird
  • und die Zielgruppe, für die das Produkt angezeigt wird.

Modell

Die fachliche Bedeutung spielt für die weitere Betrachtung keine große Rolle, interessant ist der Komplexitätsraum, der aufgemacht wird: Es geht um das Kreuzprodukt der Produkte untereinander, multipliziert mit der Anzahl Länder, in denen unser Kunde tätig ist, multipliziert um die Anzahl Kanäle, über die er vertreibt, multipliziert mit der Anzahl Zielgruppen.

Die Formulierung dieser Anforderungen erfolgte über Regeln, in denen sowas drin steht wie eine Hierarchie, in der das Land der oberste Knoten ist, darunter der Kanal und darunter die Zielgruppe folgend. Dabei werden die Konfigurationen von Parent zu Child vererbt, können aber am Child überschrieben werden, indem einzelne Relationen dann exkludiert, andere inkludiert werden. Für die Zielgruppen wird auf oberster Ebene (parallel zum Land) ein Default konfiguert, der dann aber auf unterster Ebene doch wieder überschrieben werden kann. Der Kanal „Print“ ist dabei aber ein Sonderfall, und muss in der Hierarchie eigentlich eher so dargestellt werden, dass der Kanal der Toplevel ist und die Länder darunter hängen…

Im Großen und Ganzen haben diese Regeln nichts vereinfacht, da nach wie vor jedes Produkt mit jedem anderen in jedem beliebigen Kontext abbildbar war. Der Hintergrund dieser Regeln lag eher darin, dass der Kunde sich tief im Inneren der Komplexität bewusst war und den Pflegeaufwand fürchtete.

Dies habe ich versucht mir zu Nutze zu machen und die Komplexität in Form von Pflegeschritten greifbar zu machen.

Die Grundaussage ist: Fachliche Komplexität kann man durch die verwendete Technik nicht reduzieren! Insofern ist es legal die Komplexität in verschiedenen Formen darzustellen, z.B. in From von Einzelrelationen (Ausmultiplizieren der möglichen Kombinationen):

  • In Form der Einzelrelationen drückt sich die Komplexität darin aus, dass man plötzlich viel Pflegeaufwand hat
  • In Form der Regeln braucht man Expertenwissen, um diese Regeln sinnvoll festzulegen, muss für jede Regel, die man anlegt länger nachdenken und Fehler sind teuer in der Analyse.

Für den Workshop habe ich die Anforderungen von der Regel-Ebene auf die Einzelrelation-Ebene transformiert, da der Kunde dies eher greifen konnte. Dafür habe ich für ein angenommenes durchschnittliches Produkt die Anzahl der Relationen, die man pflegen müsste als Kennzahl der Komplexität angenommen.

Für die Ausgangsituation war das folgendes Bild:

Vollausbau

5000 Pflegeschritte (wenn auch nur als Kennzahl) für ein einziges Produkt hat seine Wirkung nicht verfehlt und die Teilnehmer für eine Diskussion zur Vereinfachung sensibilisiert.

Dann haben wir mögliche Vereinfachungen abgeleitet, mit denen wir in die Diskussion gegangen sind, diese sahen etwa wie folgt aus:

Annahme: Die Kunden, die sich über ein spezielles Produkt informieren, verhalten sich kanalübergreifend gleich, und man kann ihnen kanalübergreifend die gleichen Produktverknüpfungen anzeigen.

keinKanal

Vorteil:

  • Reduktion der Komplexität um einen Faktor 3

Nachteil:

  • Folgendes Szenario ist nicht abbildbar: Im Kanal Print sollen für ein Meßgerät einige Zubehörteile angezeigt werden, im Kanal Online sollen andere Zubehörprodukte angezeigt werden.

Fazit

In der Disukussion hat es uns geholfen die Komplexität auf eine für den Kunden erfassbare Kennzahl zu bringen. Im Verlauf des Workshops konnten wir über mögliche Reduzierungen sprechen, was diese an fachlichen Abschlägen bedeuten und wie stark sich die Komplexität durch diese Reduzierung vereinfacht.

Als Ergebnis im Workshop haben wir erreicht, dass diese Anforderungen zwar weiterhin annähernd vollständig bestehen blieben, aber immerhin mit dem nötigen Maß an Respekt dabei. Man kann halt nicht immer alles erreichen 😉

Ajax mit Taconite

Schlagwörter

, ,

Die gängigen Formate zum Austausch von Daten per Ajax sind JSON und XML. Ein gängiges daraus resultierendes Problem entsteht wenn man Seiten-Elemente zum einen dynamisch nachladen, aber auch schon beim initialen Ausliefern der Seite ausliefern möchte.

Das Formatieren der Daten beim initialen Ausliefern übernimmt die JSP, dort gibt es dann ein Konstrukt, das etwa wie folgt aussieht:

<table>
<tr><th>Kunde</th><th>Guthaben</th></tr>
<c:forEach items="table" var="row">
  <tr><td>${row.customer}</td><td>${row.money}</td></tr>
</c:forEach]
</table>

Um die Ajax-Request zu rendern, muss man in Javascript etwas analoges umsetzen

var table = "";
table += "<table>"
table += "<tr><th>Kunde</th><th>Guthaben</th></tr>"
for (var row : data) {
  table += "<tr><td>" + row.customer + "</td><td>" + row.money  +"</td></tr>"
}
table += "</table>"
addToDom(table)

An dieser Stelle ist eine Menge redundanter Code entstanden, der uns immer auf die Füße fallen wird, wenn irgendeine Design-Änderung an der Formatierung vorgenommen wird.

Die Alternative dazu ist, dass man für den initialen Seitenaufbau dieses Element gar nicht mitsendet, sondern im document.ready den Ajax-Call absetzt. So kann man sich das serverseitige Rendern sparen.

Der Datenaustausch über JSON hat damit folgende Nachteile:

  • Redundanter Code (wenn man direkt serverseitig in die Seite rendert)
  • Schlechtere SEO-Performance (wenn man beim document.ready nachlädt)
  • Schlechtere Seiten-Performance (wenn man beim document.ready nachlädt, da ein neuer Serverroundtrip benötigt wird, um die Seite vollständig zu rendern)
  • Javascript ist keine geeignete Sprache, um HTML-Formatierungen vorzunehmen (wie Listing 2 zeigt)
  • Bei multilingualen Seiten muss man sein Javascript lokalisieren (um z.B. die Begriffe „Kunde“ und „Guthaben“ aus den Headern zu übersetzen)

Dem gegenüber steht natürlich auch ein Vorteil, den ich nicht verschweigen möchte:

  • Nicht-Formatierte Daten benötigen weniger Bandbreite als formatierte

Zu der Datengröße habe ich ein paar nicht repräsentative Tests gemacht und auf unterschiedlichen Seiten die Datengröße eines übertragenen JSON-Strings mit dem entstandenen HTML vergleichen. Der Unterschied in der Datenmenge nachdem beides GZIP-komprimiert wurde lag bei unter 20%.

Wer nicht so high-sophisticated unterwegs ist, dass er mit diesen 20% leben kann, dem empfehele ich einen Blick auf Taconite zu werfen. Taconite ist eine Bibliothek, die es einem komfortabel erlaubt per Ajax serverseitig generierte HTML-Elemente in den DOM Tree zu hängen (auch mehrere pro Request).

Taconite gibt es als JQuery-Plugin, das im Wesentlichen dafür sorgt, dass bei Ajax-Requests per JQuery jedes Response-Dokument, das dem folgendem Aufbau entspricht, geparst wird und die entsprechenden Element-Definitionen im DOM landen

<taconite>
  <replaceContent select="someId"><![CDATA[
    some HTML
  ]]></replaceContent>
</taconite>

Es gibt noch einiges mehr an Befehlen als nur replaceContent, darüber gibt die Taconite-Doku am besten Aufschluss.

Und wie hilft uns das jetzt die oben beschriebenen Probleme zu lösen?

Zunächst brauchen wir für die Elemente, die man potentiell per Ajax aktualisieren kann eine eigene JSP, die unsere Daten formatiert:

<table>
<tr><th>Kunde</th><th>Guthaben</th></tr>
<c:forEach items="table" var="row">
  <tr><td>${row.customer}</td><td>${row.money}</td></tr>
</c:forEach]
</table>

Um das Table-Element nun serverseitig in eine Seite zu rendern, müssen wir diese JSP nur inkludieren:

<%-- do a lot of stuff --%>
<div id="tableId">
  <jsp:include page="tableView.jsp" />
</div>
<%-- do more stuff --%>

Für die Ajax-Calls brauchen wir noch etwas Infrastruktur, die es uns erlaubt in unserem Controller anzugeben welche Elemente wir aktualisieren wollen, und wie das gemacht wird

public class AjaxHandler {
  private Map<String, String> replaceContents = new HashMap<String, String>();

  public Map<String, String> getReplaceContents() {
    return replaceContents;
  }

  public void addReplaceContent(String id, String view) {
    this.replaceContents.put(id, view);
  }
}

 

<taconite>
  <c:forEach items="${ajaxHandler.replaceContents}" var="repl">
    <replaceContent select="#${repl.key}"><![CDATA[
      <jsp:include page="/WEB-INF/views/${repl.value}.jsp" />
    ]]></replaceContent>
  </c:forEach>
</taconite>

Damit haben wir unserem Controller nun die Möglichkeit gegeben bei einem Ajax-Aufruf beliebige Elemente zu aktualisisieren.

@Controller
@RequestMapping("mycontroller")
public class MyController {
@RequestMapping("main")
  public String getMainPage(Model model) {
    model.addAttribute("table", getMoneyTable());
    return "somePage";
  }

  @RequestMapping("updateTable")
  public String updateTableAjax(Model model) {
    model.addAttribute("table", getMoneyTable());
    AjaxHandler handler = new AjaxHandler();
    handler.addReplaceContent("tableId", "tableView");
    model.addAttribute("ajaxHandler", handler);
    return "taconite";
  }
}

Fazit
Indem wir das Denk-Paradigma „per Ajax darf man nur strukturierte Daten übertragen“ durchbrochen und eine ziemlich coole Bibliothek als Unterstützung geholt haben, konnten wir einige Probleme beim Umgang mit Ajax lösen. Auch wenn die obere Lösung nur eine Skizze darstellt und an einigen Stellen noch aufgebohrt werden muss, ist der Aufwand sehr überschaubar.
Insbesondere haben wir eine leichtgewichtige Lösung geschaffen, die uns außer einer sauberen Strukturierung unserer JSPs keine weiteren Auflagen macht.

Unittests von PageObjekten mit PopperFW

Schlagwörter

, ,

In einem meiner ersten Beiträgen habe ich mich zum Thema Automatisierte Tests von Pageobjekten ausgelassen. Dies habe ich nun mit meinem aktuellen Baby PopperFW (siehe auch Deklarative Beschreibung von PageObjekten) zusammengeführt.

Ausgangssituation bleibt, dass man bevor man seine Oberflächentests startet wissen möchte, ob die erstellten PageObjekte auch wirklich auf die realen Seiten mappen. Dafür muss man folgendes tun:

  • Die Seite, die man testen möchte, öffnen
  • Alle in diesem PageObjekt definierten Locatoren aufrufen und prüfen, ob diese auf eine Element innerhalb des HTMLs mappen

Ein typischer Test dafür würde wie folgt aussehen:

@Test
public void testSomePagePo {
  SomePage somePage = factory.createPage(SomePage.class);
  somePage.open();
  somePage.header(); // Ein Assert brauchen wir nicht => wenn das Element nicht existiert, gibt es eine Exception
  somePage.header().homeLink();
  somePage.header().naviLink1();
  // ...
  somePage.someElement();
  somePage.someElement().someInnerElement();
  // ...
  somePage.elementWithParameter("a", 42);
  somePage.elementWithParameter("a", 42).myLink();
  somePage.elementWithParameter("a", 42).someButton();
  // ...
}

Diesen Code zu schreiben ist lästig. Wie schön wäre es, wenn man dies automatisiert tun könnte… Man kann!

@Test
public void testSomePagePo {
  SomePage somePage = Pockito.pock(SomePage.class);
  Pockito
     .conf()
     .runTest(somePage);
}

Dieser Code tut folgendes:

  • Die Seite SomePage öffnen
  • Alle in diesem PageObjekt definierten Locatoren aufrufen und prüfen, ob diese auf eine Element innerhalb des HTMLs mappen

Hört sich doch schon sehr ähnlich zu dem an, was wir vorhaben, und der Tippaufwand ist überschaubar.

Aber wieder mal sind es die Ausnahmen, die uns das Leben schwer machen. Wenn ein Locator Parameter enthält, dann kann Pockito nicht wissen welche Parameter verwendet werden sollen, um ein Element auf der Testseite zu finden. Hier muss der Entwickler helfen.

@Test
public void testSomePagePo {
  SomePage somePage = Pockito.pock(SomePage.class);
  Pockito
     .conf()
     .parameter(somePage.elementWithParameter("a", 42))
     .runTest(somePage);
}

Neu ist die Zeile parameter(somePage.elementWithParameter(„a“, 42)). Diese sagt Pockito, dass um die Method elementWithParameter aufzurufen, die Parameter „a“ und 42 verwendet werden sollen. So kann Pockito auch diese Methode aufrufen und alle Kindelement wieder automatisiert testen. Die Konfigurations-Syntax und (zugegeben auch der Name der Klasse) sind dem Mockito-Framework entliehen.

Manchmal soll ein bestimmtes Element nicht getestet werden, weil man von vornherein weiß, dass es nicht da ist. Ein typisches Beispiel könnte eine Seite sein, die im Header entweder einen Login- oder Logout- Link enthält. Man wird niemals beide Links innerhalb einer Seite testen können.

@Test
public void testSomePagePo {
  SomePage somePage = Pockito.pock(SomePage.class);
  Pockito
     .conf()
     .ignore(somePage.header().logout())
     .runTest(somePage);
}

ignore(somePage.header().logout()) sagt Pockito, dass der Locator somePage.header().logout() und alle seine Kinder nicht getestet werden sollen.

Das Problem mit den Parametern ergibt sich auch für das Öffnen einer Seite. Pockito ist darauf angewiesen, dass ein PageObjekt einen parameterlosen PageAccessor mitbringt. Ist das nicht gegeben, muss man Pockito helfen.

@Test
public void testSomePagePo {
  SomePage somePage = Pockito.pock(SomePage.class);
  somePage.open("Username", "Password");
  Pockito
     .conf()
     .runTest(somePage);
}
@Test public void testSomePagePo {
  SomePage somePage = Pockito.pock(SomePage.class);
  Pockito.open(somePage, "/somePage.jsp?here=follow&some=parameters");
  Pockito
     .conf()
     .runTest(somePage);
}

Assertions

Pockito testet nur, dass es zu jeder PageObjekt-Methode auch ein Element auf der Webseite gibt, das bedeutet noch nicht, dass es auch das richtige Element ist. Wenn man das testen will, dann muss man noch einen Schirtt weiter gehen und Assertions hinzufügen.

@Test
public void testSomePagePo {
  SomePage somePage = Pockito.pock(SomePage.class);
  Pockito
     .conf()
     .runTest(somePage);
  Assert.assertEquals("42", somePage.someElement().text()); }

Dies sind letztlich ganz normale JUnit (oder TestNG) Assertions. Die Besonderheit an dieser Stelle ist, dass unser durch Pockito.pock(SomePage.class) erzeugtes Objekt zu Beginn im Konfigurationsmodus ist und die Methodenaufrufe nur Konfigurationen an Pockito übergeben. Nach dem Ausführen von Pockito.runTest verlässt das Objekt den Konfigurationsmodus und kann als normales PageObjekt verwendet werden.

Fazit

Mit Pockito ist wieder ein wichtiges Puzzlestück zum Popper Framework dazugekommen. Nachdem ich nun noch einiges an Testarbeit in PopperFW investiert habe und mit meinem Projekt diandan.de ein erstes Produktiv-Projekt auf PopperFW umgestellt habe, ist dieses nun aus der Alpha-Phase in die Beta-Phase übergegangen. Wer Interesse hat, kann sich die aktuelle Version unter https://sourceforge.net/p/popperfw/ herunterladen.

Define your own language

Schlagwörter

Wann immer ich in meinen Projekten an dem Punkt angekommen bin, dass es galt einen komplexen Sachverhalt ohne Programmierung zu beschreiben wie

  • Konfigurationsfiles
  • Regeln
  • Formatdefinitionen
  • u.ä.

habe ich es vermieden den Begriff „Parser“ auch nur zu denken. Viel zu gut hatte ich noch die Vorlesung „Algorithmen I“ im Hinterkopf, in der wir ganz trockenen und komplizierten Stoff mit Grammatiken, Lexern und LL(1)-Regeln durchgenommen haben. Statt dessen habe ich diese Problemstellungen mit Key-Value- oder XML-Syntaxen vergewaltigt, oder exzessiven Gebrauch von StringTokenizern und RegEx gemacht.

Nun bin ich dankbar auf ein Problem gestossen zu sein, dass sich nicht mehr mit diesen Mitteln lösen ließ und mich gezwungen hat, mich mit dem Definieren und Verarbeiten einer eigenen Sprache zu beschäftigen.

Es wäre jetzt falsch zu sagen, dass das Intepretieren von Texten trivial wäre, aber mit den richtigen Tools ist es eine Sache, in die man sich innerhalb ein paar Abende einarbeiten kann.

XText

Meine ersten Versuche habe ich mit XText gemacht. XText ist ein Eclipse-Projekt, das es erlaubt eigene Grammatiken zu verfassen und dann zu verarbeiten, bringt darüber hinaus dann auch Support mit, damit diese Syntax in Eclipse Syntax-Highlighting, Autovervollständigung und vieles mehr hat. Grundsätzlich hatte mich diese Suite angesprochen, aber wie es so ist: Viel Macht kommt mit viel Schwergewichtigkeit. So braucht man für die Arbeit mit XText eine spezielle Eclipse-Distribution, die Einstiegshürde seine erste Grammatik zum Laufen zu bekommen war recht hoch.

Da ich vieles von dem, was XText mitbringt nicht brauche, habe ich mich recht schnell nach einem anderen Tool umgesehen.

ANTLR
ANTLR (ANother Tool for Language Recognition) ist ein Parser, der es erlaubt eine eigene Grammatik zu definieren und daraus Basisklassen erzeugt, um auf die Elemente aus einer geparsten Datei zugreifen zu können. Der Zugriff auf eine geparste Datei erfolgt ähnlich dem Verarbeiten eines XML-Dokumentes: man kann entweder auf den Element-Baum zugreifen, oder man definiert einen Listener, der immer alarmiert wird, wenn ein bestimmtes Ereignis eintritt.

Anbei wollen wir uns eine einfache Grammatik anschauen, die aus einer Textdatei ermittelt, wer gegrüßt und wer verabschiedet wird. Die Datei ist zeilensepariert aufgebaut und kann folgende Inhalte haben
Hello <name>
Bye <name>
Daneben kann das ganze noch um Zeilenend- Kommentare und Inline-Kommentare erweitert werden

Hier ein Beispiel unserer zu verarbeitenden Datei:

# Hier stehen Grüße drin
Hello world
Hello Michael # am Anfang der Zeile steht ein Tab, am Ende ein Kommentar
Bye    /* hier steht ein Kommentar */    Terence
# Und zum Abschluß noch mehr Komemntare

Diese können wir mit folgender Grammatik verarbeiten:

// Define a grammar called Hello
grammar Hello;

// Parser-Regeln
greetings
            : greeting (ENDL greeting)* EOF
            ;

greeting
            : hello
            | bye
            |
            ;

hello
            : 'Hello' ID
            ;

bye
            : 'Bye' ID
            ;

// Lexer-Regeln
ID
            : [a-zA-Z]+
            ;

ENDL
            : '\r'? '\n'
            ;

// Whitespaces, Kommentare & Co
WS
           : [ \t]+ -> skip
           ;

LINE_COMMENT
           : '#' ~('\n'|'\r')* -> skip
           ;

COMMENT
           : '/*' .*? '*/' -> skip
           ;

Die Grammatik ist in Parser-Regeln und Lexer-Regeln unterteilt. Grob gesagt sagt eine Lexer Regel aus, wie ein einzelnes Wort erkannt wird, eine Parser-Regel sagt aus, wie aus Worten Sätze gebildet werden. Alle klein-geschriebenen Definitionen sind Parser-Definitionen, alles großgeschriebene Lexer-Definitionen.

In Zeile 5 steht die Hauptregel. Diese besagt, dass ein Dokument aus einem greeting besteht, gefolgt von 0 oder mehr weiterer greetings, die durch Zeilenumbrüche getrennt sind. Danach folgt das Ende der Datei

Ein greeting kann entweder eine hello-Regel, eine bye-Regel oder eine leere Regel (damit wird eine Leerzeile abgebildet) sein (Zeile 9).

hello besteht aus dem Schlüsselwort ‚Hello‘ und einem Namen (Zeile 15). bye aus dem Schlüsselwort ‚Bye‘ und einem Namen (Zeile 19). Ein Name darf aus den Zeichen [a-zA-Z]+ bestehen (Zeile 24).

Ab Zeile 33 folgen Patterns, die ignoriert werden sollen, also Whitespaces, Tabs und Kommentare. Das „-> skip“ sorgt dafür, dass das Auftreten dieser Token ignoriert wird.

Aus dieser Gramatik erzeugt ANTLR für uns einige Java-Klassen. Wie man das macht, will ich nicht beschreiben, das ist im ANTLR-Tutorial unter http://www.antlr.org/wiki/display/ANTLR4/Getting+Started+with+ANTLR+v4 schon viel besser erkärt. Es gibt aber noch etwas Java-Code, den wir schreiben müssen.

public class HelloListener extends HelloBaseListener {
  private List<String> hellos = new LinkedList<String>();
  private List<String> byes = new LinkedList<String>();

  @Override
  public void enterHello(HelloContext ctx) {
    hellos.add(ctx.ID().getText());
  }

  @Override
  public void enterBye(ByeContext ctx) {
    byes.add(ctx.ID().getText());
  }

  public List<String> getHellos() {
    return hellos;
  }

  public List<String> getByes() {
    return byes;
  }
}

Und um das Ganze auszuführen, noch etwas Boilerplate

public class Main {
  public static void main(String[] args) throws Exception {
    String filename = "/test.hello";
    ANTLRInputStream antIstream = new ANTLRInputStream(Main.class.getResourceAsStream(filename));
    HelloLexer lexer = new HelloLexer(antIstream);
    HelloParser parser = new HelloParser(new CommonTokenStream(lexer));
    ParseTree tree = parser.greetings();
    ParseTreeWalker walker = new ParseTreeWalker();
    HelloListener wtlListener = new DslHelloListener();
    walker.walk(wtlListener, tree);
    System.out.println("Hellos: " + wtlListener.getHellos());
    System.out.println("Byes: " + wtlListener.getByes());
  }
}

Die ganzen Klassen mit einem Hello im Namen (außer dem oben gezeigten HelloListener) sind durch ANTLR erzeugt worden.

Die Ausgabe sieht dann wie folgt aus:

Hellos: [world, Michael]
Byes: [Terence]

Fazit
Schon an diesem relativ einfachen Beispiel sieht man, dass man mit einfachen Java-Boardmitteln beim Parsen nicht weit kommen würde. ANTLR nimmt uns hier eine Masse Arbeit ab.
Natürlich wird man als Einsteiger in ein paar Fallen laufen und sich ein paar Patterns und Best Practises erarbeiten müssen (Allein nur die Grammatik so zu definieren, dass man die letzte Zeile nicht mit einem Zeilenumbruch beenden muss, hat mich einen Abend gekostet). Die Integration in eine Buildumgebung muss man sich auch erarbeiten, aber es gibt eine steile Lernkurve ohne sich mit akademischen Theoremen auseinandersetzen zu müssen. Und im Nachhinein ärgere ich mich an einigen Stellen zu schnell auf XML gesetzt zu haben, anstatt mir eine eigene Sprache auszudenken.

Hier noch zwei Buchtipps, für diejenigen, die sich tiefer mit der Materie auseinader setzen wollen. Beide sind von Terence Parr, dem Macher von ANTLR und angenehm zu lesen:

Language Implementations Patterns legt die Grundlagen für das Erstellen eigener Sprachen und stellt Patterns vor, die ANTLR Reference überträgt diese Patterns dann auf die konkrete Nutzung von ANTLR.

Deklarative (aber ein bisschen implementierte) PageObjekte

Schlagwörter

, , ,

In meinem letzten Post habe ich ein paar Ideen für ein Framwork zum Erstellen von vollständig deklarativen PageObjekten zusammengestellt. Dieses Framework erlaubt es über annotierte Interfaces zu beschreiben aus welchen Elementen eine Seite besteht und erstellt daraus ein fertig implementiertes PageObjekt. Seither hat mich eine Diskussion mit einem Kollegen umhergetrieben, der behauptet ein PageObjekt müsse mehr tun als nur Zugriffe auf Elemente bereitstellen, sondern höherwerige Funktionen zur Verfügung stellen, die den Seitenaufbau in fachliche Funktionen kapseln.

Auch wenn ich nicht 100% seiner Meinung bin, ich halte es für durchaus vertretbar in einem Pageobjekt ein


index.loginLink().click();

zu haben, anstatt einem


index.gotoLogin();

So stimmt es doch, dass es an manchen Stellen sinnvoll ist höherwertige Funktionen anzubieten. Diese höherwertigen Funktionen sind der Punkt, an dem es keinen Sinn mehr macht mit Deklaration weiterzuarbeiten und Implementierung ins Spiel kommen sollte. Das Framework kann sowas wie folgt abbilden

@Page(name="Login")
public abstract class Login {
  @PageAccessor(uri="login.html")
  public abstract void open();

  public void doLogin(String username, String password) {
    usernameTextbox().type(username);
    passwordTextbox().type(password);
    submitLoginButton().click();
  }

  @Locator(name="Username", xpath="//input[@name='username']")
  public abstract ITextBox usernameTextbox();

  @Locator(name="Password", xpath="//input[@name='password']")
  public abstract ITextBox passwordTextbox();

  @Locator(name="Login Submit", cssSelector=".submitLoginButton")
  public abstract IButton submitLoginButton();
}

Je nachdem wie streng man es mit dem Ansatz nimmt, dass PageObjekte nur höherwertige Funktionen nach außen geben dürfen, kann man die Locator-Methoden public oder package-private machen.

Daneben kann man Locator-Methoden von höherwertigen Methoden durch eine Vererbungshierarchie trennen, z.. zwei-stufige Vererbungen sind dabei denkbar:

@Page(name="Login")
public abstract class LoginPO implements LoginLocator {
  public void doLogin(String username, String password) {
    usernameTextbox().type(username);
    passwordTextbox().type(password);
    submitLoginButton().click();
  }
}

public interface LoginLocator {
  @PageAccessor(uri="login.html")
  void open();

  @Locator(name="Username", xpath="//input[@name='username']")
  ITextBox usernameTextbox();

  @Locator(name="Password", xpath="//input[@name='password']")
  ITextBox passwordTextbox();

  @Locator(name="Login Submit", cssSelector=".submitLoginButton")
  IButton submitLoginButton();
}

Die Pedanten (ja, ich meine dich, Nils ;)) können aus dem Locator-Interface auch eine abstrakte Klasse machen, damit die Locator-Methoden package-private werden so ja keine „minderwertigen“ Funktionen nach außen gegeben werden.

P.S.

Ich habe zu diesem Framework ein Opensource-Projekt gestartet. Die in diesem und letzten Post beschriebenen Funktionen sind dort bereits implementiert: https://sourceforge.net/projects/popperfw/

Deklarative Beschreibung von PageObjekten

Schlagwörter

, ,

Diese Woche war ich als Testautomatisierer bei einem Kunden und durfte wieder PageObjekte (POs) tippen. Obwohl wir schon ein recht gutes Framework haben, das uns die Erstellung von POs vereinfacht, gibt es immer noch jede Menge Boilerplate-Code, den man für jede einzelne Methode jedes einzelnen POs schreiben muss. Auf der Zugfahrt zurück nach Hause haben mein Kollege und ich das Thema noch mal diskutiert und sind zum Ergebnis gekommen, dass man da „was mit Annotations“ machen müsste. Nachdem das Ganze nun das Wochenende zum Gären hatte, sind ein paar ganz gute Ideen zusammengekommen wie ein solches Framework aussehen müsste.

Grundsätzlich unterscheidet das Framework zwischen zwei Arten von Komponenten:

  • Webelement: Dabei handelt es sich um konkrete Elemente auf einer Webseite, die man bedienen und mit denen man Interagieren kann (z.B. Buttons, Checkboxen, Links, Texte)
  • PageObjekte: PageObjekte sind eine logische (hierarchische) Gruppierung von Webelementen. Eine Spezialform eines PageObjekts ist die Page, diese stellt eine gesamte Seite dar und kann etwas mehr als ein „Seitenteil“.

Soviel zur Theorie, wie sieht das nun konkret aus?

public interface LoginSite {
  @Locator(name="Username")
  @LocateByXpath("//input[@name='username']")
  ITextBox usernameTextbox();

  @Locator(name="Password")
  @LocateByXpath("//input[@name='password']")
  ITextBox passwordTextbox();

  @Locator(name="Login Submit")
  @LocateByCssSelector(".submitLoginButton")
  IButton submitLoginButton();

  @Locator(name="Register")
  @LocateById("register")
  ILink registerLink();
}

ITextBox, IButton und ILink sind Webelemente zur Interaktion mit der Webseite. @Locator und @LocateBy… geben an, dass an dieser Stelle durch das Framework eine entsprechende Zugriffsmethode auf ein Webelement erzeugt werden soll. Der Name in der @Locator-Annotation wird für ein sauberes Logging und Fehler-Reporting benötigt.

Die obere Deklaration entspricht einem einfachen PageObjekt, wie gesagt gehört zu einer vollwertigen Page ein bisschen mehr:

@Page(name="Login Seite")
public interface LoginSite {
  @PageAccess(uri="Login.html")
  void open();
  //...

Im Gegensatz zu einem PageObjekt, das immer einen Parent hat, und daher seinen Namen über dessen @Locator-Annotation bekommt, kann eine Page „im leeren Raum“ Instanziiert werden und benötigt daher einen Namen. Dafür gibt es ein entsprechendes Attribute an der @Page-Annotation. Der PageAccessor ist dafür zuständig eine Seite aufzurufen.

Ein Testcase, der diese Deklaration verwendet, könnte wie folgt aussehen:

public class LoginTest {
  protected IPoFactory factory;

  @Before
  public void setup() {
    WebdriverContext context = new WebdriverContext();
    context.setBrowserName(WebdriverContext.HTML_UNIT_NAME);
    context.setBaseUrl("file://" + PoContainsPosTest.class.getResource("/").getFile());
    factory = context.getFactory();
  }

  @Test
  public void testLogin() throws Exception {
    LoginSite loginSite = factory.createPage(LoginSite.class);
    loginSite.open();
    loginSite.usernameTextbox().type("mibutec");
    loginSite.passwordTextbox().type("secret");
    loginSite.submitLoginButton.click();
    // Assert.assert...
  }
}

Alles, was man mit Webelementen machen kann, kann man auch mit PageObjekten machen

@Page(name="Testseite")
public interface Testsite {
  @PageAccessor(uri="Testseite.html")
  void open();

  @Locator(name="Header")
  @LocateById("header")
  Header header();

  @Locator(name="Footer")
  @LocateById("footer")
  Footer footer();
}

public interface Header {
  @Locator(name="Homepage")
  @LocateById("homepageLink")
  ILink homepageLink();

  @Locator(name="Logout")
  @LocateById("logoutLink")
  ILink logoutLink();
}

public interface Footer {
  @Locator(name="Company")
  @LocateById("companyLink")
  ILink companyLink();
}

Soweit das Grundkonzept. Es trägt aber noch nicht für produktive Tests. So muss man manchmal z.B. auch Locatoren mit Parametern erstellen können.

@Page(name="Produktdetailseite")
public interface PoWithParameters {
  @PageAccessor(uri="Produktdetailseite.html?produktnummer={0}")
  void open(@ParamName("Produktnummer") String produktnummer);

  @Locator(name="Produktattribute {0}")
  @LocateByXpath("//*[@id='attributes']//p[@class='attr_{0}']")<br />
  ILabel attributeText(@ParamName("Produktattribute Name") String attributename);
}

Um auch komplexe Locatoren abbilden zu können, muss es die Möglichkeit geben entsprechende @LocateBy-Annotations selbst implementieren zu können.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ImplementedBy(MyOwnLocatorImpl.class)
public @interface MyOwnLocator {
  String value();
}

Dann noch ein paar Convenience-Methoden zum Umgang mit Containern (z.B. Tabellen)

@Page(name="User Admin")
public interface UserAdmin {
  @Locator(name="Alle User")
  @LocateByXpath("//table[@id='userTable']//tr")
  List<UserRow> allUsers();

  @Locator(name="User")
  @LocateByXpath("//table[@id='userTable']//tr[@class='user_{0}']")
  UserRow userById(String userId);

  public static interface UserRow {
    @Locator(name="Surname")
    @LocateByClassname("surname")
    ILabel surnameLabel();

    @Locator(name="Lastname")
    @LocateByClassname("lastname")
    ILabel lastnameLabel();

    @Locator(name="Birthdate")
    @LocateByClassname("birthdate")
    ILabel birthdateLabel();
  }
}

Fazit
Eines der wenigen Fazits, die ich nicht mit den Worten „Mit geringem Aufwand ist es uns gelungen…“ einleiten kann. Das wird ein Haufen Arbeit werden, aber das Ziel ist lohnend. Und endlich werde ich mich mal im Bereich Reflections so richtig austoben können.

EDIT 04. November

Habe gerade von einem Kollegen eine Idee zur Verkürzung der Schreibweise bekommen. Für die Standard-Lokatoren muss man keine @LocatateBy-Annotation verwenden, sondern kann den Ausdruck direkt in @Locate angeben:

public interface LoginSite {
  @Locator(name="Username", xpath="//input[@name='username']")
  ITextBox usernameTextbox();

  @Locator(name="Password", xpath="//input[@name='password']")
  ITextBox passwordTextbox();

  @Locator(name="Login Submit", cssSelector=".submitLoginButton")
  IButton submitLoginButton();

  @Locator(name="Register", id="register")
  ILink registerLink();
}