Preis: 9,80 €
Erhältlich ab: April 2015
Umfang: 100
Dieser Artikel zeigt, wie durch REST mit JSON praktisch ohne festen Kontrakt mit Services kommuniziert werden kann. Möglich wird dies durch generisches Mapping von benötigten und erhaltenen Feldern mittels rekursiver Tiefensuche im JSON-Objektgraphen. Dadurch wird der Unterschied in der Kopplung zwischen RPC-Verfahren wie SOAP mit Apriori-Schnittstellenkontrakt (wsdl, xsd), generierten Clientbibliotheken, Versionen etc. und dieser geradezu kontaktfreien dynamischen Kommunikation auf die Spitze getrieben.
Normalerweise denke ich mir eine Schnittstelle als eindeutig definierte Methode eines Objekts mit einer Anzahl von typisierten Transferobjekten für Parameter und Rückgabe. Mit anderen Worten: Wenn ich eine Schnittstelle nutzen will, muss ich das Objekt (bzw. den Aufruf) kennen und die benötigten Transferobjekte zur Verfügung haben. Das kennen wir von SOAP Web Services: Die WSDL beschreibt den nötigen Aufruf als Abstraktion eines Prozeduraufrufs (Remote Procedure Call, RPC) und das XSL Schema die benötigten Transferobjekte. Damit habe ich mich von der Schnittstelle und ihren Versionen abhängig gemacht. Mehr noch: Ich habe mir ein fremdes (und vielleicht unpassendes) Objektmodell in den Code geholt – eine generierte SOAP-Clientbibliothek mit evtl. hässlichem Namen und fremder Komposition. Ich kann versuchen, die Verbreitung dieser SOAP-Clientbibliothek in meinem Code zu begrenzen, aber dazu muss ich die Transferobjekte in einer eigenen Komponente isolieren und deren Daten in eigene Objekte umfüllen. Das ist aufwändig und überflüssig.
Bei Benutzung von REST bestehen die zu übertragenden Daten aus JSON. Es müsste doch möglich sein, sich dieses JSON von einer remote Schnittstelle zu holen, ohne einen solchen Aufwand betreiben und ein fremdes Objektmodell importieren zu müssen. Und das geht tatsächlich: Der Schlüssel liegt in der JSON-Deserialisierung, die aus JSON Objekte erzeugt und befüllt. Normalerweise müssen die übergebenen Objekte der JSON-Struktur entsprechen, um erfolgreich gefüllt zu werden, und zwar sowohl syntaktisch als auch strukturell. Hätte man aber einen flexibleren JSON Object Mapper zur Verfügung, könnte man ihm ganz andere Objekte übergeben, und er könnte in JSON nach den zu den Feldern passenden Daten suchen, egal wo. Diese Flexibilität ist natürlich nicht komplett generisch zu erreichen: Es muss Kriterien geben, nach denen der JSON Object Mapper entscheiden kann, ob ein gegebenes Datum das richtige für ein bestimmtes Feld eines Objekts ist. Die einfachsten Kriterien sind Name und Typ des Felds. Dies ist gleichzeitig die Grenze der Abhängigkeit von der remote Schnittstelle, denn nur die Feldnamen müssen bekannt und gleichlautend sein. Vom Umfang der Daten und ihrer Struktur ist man so aber völlig unabhängig.
Diese Funktionalität wird im Haspa-Serviceframework [1] von der Klasse GenericServiceClient bereitgestellt. Der GenericServiceClient übernimmt sowohl das REST-Kommunikationsprotokoll (Service-Look-up, HATEOAS, Serialisierung, Deserialisierung etc. [2]) als auch die Befüllung der Zielobjekte. Letztlich reicht dem Aufrufer der folgende Zweizeiler:
Auftraggeber auftraggeber = new GenericServiceClient(MITARBEITER_SERVICE).getTransformed(personalnummer, Auftraggeber.class, false);
Was hier geschieht, ist die Transformation von JSON für ein Objekt Mitarbeiter vom Mitarbeiterservice in ein Objekt Auftraggeber eines Druckauftrags im Druckservice. Es bedeutet, dass der GenericServiceClient sich vom Mitarbeiterservice JSON für einen „Mitarbeiter“ besorgt (für den der Druckservice gar keine Klasse hat), das empfangene JSON aber auf die übergebene Klasse Auftraggeber des Druckservice anwendet (die dem Mitarbeiterservice unbekannt ist). Abbildung 1 zeigt deutlich, dass die Klasse Auftraggeber aus Feldern der Klasse Mitarbeiter und den Unterklassen Betriebsstelle und Anmeldung besteht. Es ist also nicht wichtig, wo sich die Felder befinden. Wichtig ist, dass die Namen (und Typen) der Felder übereinstimmen.
Der Aufrufer (Service-Consumer) wird durch den GenericServiceClient weitgehend von der Kommunikation mit dem benötigten Service und dem Mapping der Daten entlastet. Er muss nur den Namen des Service und ein passendes Objekt für die Daten haben. Nach der Instanziierung des GenericServiceClient beginnt dieser das REST-Kommunikationsprotokoll mit dem Service-Look-up. Der Registryservice wird mit einem HTTP GET und dem Servicenamen im URI aufgerufen und liefert daraufhin den URL einer aktiven Instanz des Mitarbeiterservice. Diesen URL benutzt der GenericServiceClient, um die Einstiegslinks vom Mitarbeiterservice zu holen. Damit ist die Servicekommunikation vorbereitet (Listing 1).
Listing 1: REST-Kommunikationsprotokoll
> GET /service/registry/de.haspa.gp.mitarbeiter HTTP/1.1
>
< HTTP/1.1 200 OK
http://server/service/mitarbeiter
> GET /service/mitarbeiter HTTP/1.1
>
< HTTP/1.1 200 OK
< Link: <http://server/service/mitarbeiter>; rel="info";
type="text/html"; title="Einstiegslinks"; verb="GET,OPTIONS",
<http://server/service/mitarbeiter/mitarbeiter>; rel="all";
type="application/json"; title="Angemeldete Mitarbeiter"; verb="GET",
<http://server/service/mitarbeiter/mitarbeiter?name=>; rel="suche";
type="application/json"; title="Mitarbeitersuche"; verb="GET",
<http://server/service/mitarbeiter/anmeldung>; rel="new";
type="application/json"; title=" Mitarbeiterlogin"; verb="POST",
< location: http://server/service/mitarbeiter
Nun kann der Service-Consumer die Methode getTransformed mit den Argumenten Personalnummer und Zielobjekt aufrufen. Der GenericServiceClient ergänzt den all-Link mit der Personalnummer zu einem URL für den gewünschten Mitarbeiter und erhält dessen JSON als Antwort auf ein GET. Schließlich übergibt er das erhaltene JSON und das Auftraggeberobjekt dem JsonMapper, der sich um das Mapping kümmert (Abb. 2 und Listing 2).
Listing 2: GenericServiceClient holt JSON vom Mitarbeiterservice
public <E> E getTransformed(Object id, Class<E> clazz, boolean override, boolean lenient) {
Link link = this.getLinks().get(LinkHeaderType.ALL);
String url = this.concatenatePath(link.getHref(), id.toString());
ClientResponse<String> response = new ClientRequest(url).get(String.class);
this.setLinks(response.getLinkHeader().getLinksByRelationship());
return JsonMapper.getAvailableFromJson(response.getEntity(), clazz.newInstance(), override, lenient);
}
Die Suche nach passenden Feldern orientiert sich an Jackson [3] und Java Reflection: Aus dem JSON wird eine com.fasterxml.jackson.databind.node.ObjectNode generiert und dann über die Felder des zu füllenden Objekts iteriert (Listing 3).
Listing 3: JSON-Deserialisierung und Mapping im JsonMapper
public static <E> E getAvailableFromJson(String json, E object, boolean override, boolean lenient) {
ObjectNode objectNode = objectMapper.readValue(json, ObjectNode.class);
for(Field field : object.getClass().getDeclaredFields()) {
field.setAccessible(true);
Types.getType(field.getType())
.setValue(object, field, objectNode, override, lenient);
}
}
Die bisher gesammelten Informationen werden einer Enumeration Types übergeben, die mögliche Feldtypen (String, Integer, Long, Double, Object, Collection etc.) repräsentiert. Zunächst wird in der übergebenen ObjectNode nach einem Objekt mit dem Feldnamen gesucht. Dieses Objekt ist eine JsonNode oder eine ihrer Subklassen. In setValue(...) setzt jeder Feldtyp den Wert des Felds auf seine spezifische Weise (das erspart if-else-Kaskaden im JsonMapper, Listing 4).
Listing 4: Types-Enum mit spezifischen Methodenimplementierungen
private static enum Types {
STRING {
public void setValue(Object object, Field field, ObjectNode objectNode, boolean override, boolean lenient) {
JsonNode value = objectNode.findValue(field.getName());
if (lenient || (value != null && value.isTextual())) {
if (override || field.get(object) == null) {
field.set(object, value.asText());
}
}
}
},
...
OBJECT {
public void setValue(Object object, Field field, ObjectNode objectNode, boolean override, boolean lenient) {
JsonNode value = objectNode.findValue(field.getName());
if (lenient || (value != null && value.isObject())) {
if (override || field.get(object) == null) {
Object objct = field.getType().newInstance();
JsonMapper.getAvailableFromJson(objectNode.toString(), objct, override, lenient);
field.set(object, objct);
}
}
}
}
public static Types getType(Class<?> clazz) {
String name = null;
if (clazz.isPrimitive() ||
clazz.getPackage().getName().startsWith("java.")) {
name = clazz.getSimpleName().toUpperCase();
if ("INT".equals(name)) {
name = "INTEGER";
}
} else {
name = "OBJECT";
}
return Types.valueOf(name);
}
public abstract void setValue(Object object, Field field, ObjectNode node, boolean override, boolean lenient);
}
Wie in Listing 4 zu sehen, wird die Befüllung der Felder durch zwei boolesche Flags konfiguriert: Das Flag override bestimmt, ob gefüllte Felder eines bestehenden Objekts mit den Werten aus JSON überschrieben werden sollen. Das Flag lenient modifiziert die erforderliche Typgenauigkeit: lenient = false bedeutet, dass der Datentyp des JSON-Werts genau dem des Felds entsprechen muss, lenient = true ermöglicht es dem Enum, nur nach den Feldnamen zu gehen und eine Datenkonversion (z. B. Long -> Integer, Boolean -> String oder Integer etc.) zu versuchen. Auf diese Weise ist man nicht so abhängig von den Datentypen der Quellklasse und bekommt die Werte trotzdem. Es ist also gar nicht nötig, in der Zielklasse exakt die gleichen Datentypen wie in der Quellklasse zu verwenden. Listing 5 und Abbildung 3 zeigen beispielhaft, dass die Werte der Klasse A über ihr JSON problemlos in die Klasse B transferiert werden können (und zurück), obwohl die Felder der beiden Klassen ganz unterschiedliche Typen haben (deshalb muss hier das Flag lenient = true sein).
Listing 5: Hin- und Rücktransformation zweier unterschiedlicher Klassen
A a1 = new A();
a1.a = false;
a1.b = Boolean.TRUE;
a1.c = 9824598;
a1.d = 984L;
a1.e = Arrays.asList(true, false, true);
String json = JsonMapper.getObjectMapper().writeValueAsString(a1);
B b1 = JsonMapper.getAvailableFromJson(json, B.class, false, true);
json = JsonMapper.getObjectMapper().writeValueAsString(b1);
A a2 = JsonMapper.getAvailableFromJson(json, A.class, false, true);
Dieser Artikel hat gezeigt, wie man REST und JSON dazu nutzen kann, eine Kommunikation zwischen Komponenten mit minimaler Kopplung zu erreichen. Im Gegensatz zu schwergewichtigen Verfahren, die Abhängigkeiten durch Servicekontrakte und Clientbibliotheken erfordern, kommunizieren hier Services ohne Softwareabhängigkeiten nur auf der Basis von JSON-Strings. Auf diesem Weg macht man sich zwar unabhängig von zu importierenden Artefakten und ihren Versionen, es ist dann aber besonders wichtig, die empfangenden Services so zu programmieren, dass sie sich melden, wenn erwartete Daten plötzlich nicht mehr kommen (z. B. weil ein sendender Service sein JSON geändert hat). Im hier vorliegenden Fall handelt es sich allerdings ausschließlich um hausinterne Services, was dieses Risiko unwahrscheinlich macht.
Stefan Ullrich hat Informatik studiert und arbeitet als Softwarearchitekt bei der Hamburger Sparkasse. Er ist Sun Certified Enterprise Architect mit fünfzehn Jahren Erfahrung im Java-Umfeld und hat für die Haspa eine RESTful SOA implementiert (siehe auch „Weckruf der Moderne“, Java Magazin 5.2014, „Fieldings Vermächtnis“, Java Magazin 7.2014 und „Nachladen, bitte“, Java Magazin 3.2015).
[1] Ullrich, Stefan: „Weckruf der Moderne“, in Java Magazin 5.2014
[2] Ullrich, Stefan: „Fieldings Vermächtnis“, in Java Magazin 7.2014
Mit seinem rein Java-basierten, serverseitigen Ansatz für die Webentwicklung gewann das Vaadin-Framework in den letzten Jahren immer mehr an Zuspruch. Seit der siebten Version des Frameworks wurde die Erweiterbarkeit wesentlich verbessert und die Entwicklung von eigenen UI-Komponenten vereinfacht. Dies erleichtert die Integration von JavaScript-Bibliotheken in den eigenen Widgets und bereichert die Möglichkeiten der Webentwicklung mit Vaadin.
Der größte Teil der Entwicklung mit Vaadin geschieht auf der Serverseite, bei der die Entwickler Webapplikationen mit Vaadins eventbasiertem Ansatz in einem Java-Swing-ähnlichen Stil entwerfen und steuern können [1]. Auf der Clientseite baut Vaadin auf dem Google Web Toolkit (GWT) auf und abstrahiert damit von clientseitigen Technologien und Browserinkompatibilitätsproblemen. Schließlich fügt Vaadin, transparent für den Entwickler, eine Kommunikationsebene zwischen der Server- und Clientseite hinzu. Dementsprechend erfordert die Implementierung eigene Vaadin-UI-Komponenten: erstens die Erstellung des clientseitigen GWT-basierten Teils, zweitens die serverseitige Steuerkomponente und drittens die Definition und Implementierung der Kommunikationsschnittstellen zwischen den beiden Seiten.
Um diesen Prozess zu veranschaulichen, wird in diesem Artikel das herkömmliche Vaadin-Textfeld mit dem jQuery-Plug-in jquery.inputmask erweitert, mit dem Ziel, die Funktionalitäten des Plug-ins serverseitig steuerbar zu machen, sowie vom Plug-in gefeuerte Events an den Server zurückzuübertragen.
jquery.inputmask [2] ist ein jQuery-Plug-in für die Erstellung von Eingabemasken, durch das Entwickler die möglichen Eingabezeichen eines Felds beliebig einschränken können. Zusätzlich können mit diesem Plug-in Trennzeichen und Platzhalter festgelegt werden. Das Plug-in bietet per Default nur drei Maskendefinitionen:
9: numerisch
a: alphabetisch
*: alphanumerisch
Allerdings können diese Definitionen durch mehrere bereitgestellte Extensions erweitert werden, beispielsweise für die Definition eines Datums oder einer Telefonnummer. Außerdem können eigene Maskendefinitionen leicht konfiguriert werden.
Im folgenden Beispiel wird die Eingabemöglichkeit im Feld test auf drei Ziffern, ein Strich als Trennzeichen, gefolgt von drei Buchstaben, beschränkt. Die eckigen Klammern geben lediglich an, dass ein Teil der Maske optional ist:
$('#test').inputmask('999[-aaa]');
Versucht ein Benutzer einen Buchstaben bei den ersten drei Zeichen einzutippen oder eine Zahl bei den letzten drei Zeichen, wird seine Eingabe ignoriert.
Zusätzliche Parameter, wie z. B. Platzhalter oder Callback-Methoden zum Event Handling, können in einem Objekt an die Methode inputmask() übergeben werden (Listing 1).
Listing 1
$('#test').inputmask('999',
{"placeholder": "###",
"oncomplete": function(){ alert('inputmask complete');
});
Die zu implementierende UI-Komponente dagegen sieht ein weniger fehleranfälliges Java-API vor, mit separaten Methoden zum Setzen der Maske und dem Platzhalter sowie zur Registrierung von Event-Listenern. Das Beispiel aus Listing 1 würde dann mit dem neuen Java-API wie in Listing 2 aussehen.
Listing 2
testField.setMask('999');
testField.setPlaceholder('###');
testField.addOnCompleteEventListener(
() -> Notifications.show('inputmask complete') );
Vaadin liefert eine ausführliche Menge an UI-Komponenten inklusive Slidern, interaktiven Tabellen und sogar einem Farbauswahlelement. Die Sammlung aller UI-Komponenten, die ein Entwickler in einer Vaadin-Applikation verwenden kann, wird als Widget-Set bezeichnet. Der Begriff Widget an sich stammt jedoch aus der Google-Web-Toolkit-Welt und beschreibt ein einzelnes UI-Element, das zu JavaScript und HTML kompiliert und im Browser gerendert wird.
Standardgemäß verwendet eine Vaadin-Applikation das so genannte Default-Widget-Set, das wiederum durch eine Vielzahl an Vaadin-Add-ons aus dem Vaadin Add-ons Directory [3] erweitert werden kann. Dennoch gibt es Fälle, bei denen diese Auswahl nicht ausreichend ist. Für diese Fälle bietet es sich an, eigene Vaadin-Widgets zu implementieren.
Solch ein Vorhaben ist zwar verbunden mit einem erhöhten Aufwand in der Anfangsphase der Entwicklung, macht sich jedoch durch die gewonnene Flexibilität und den vereinfachten Zugriff auf clientseitige Ressourcen bezahlt.
Um Webentwicklern den Ärger um Browserkompatibilität zu ersparen, setzt GWT auf ein Feature namens Deferred Binding. Durch dieses Feature ist das Framework in der Lage, mehrere Varianten einer Klasse während des Kompilierens zu erstellen und anschließend, während der Laufzeit, die richtige an den Benutzer zu liefern. Die Generierung der einzelnen Varianten ist meistens browserabhängig. Weitere Umgebungsparameter, wie z. B. die Sprache, können ebenfalls einbezogen werden. In diesem Fall wird eine Variante pro Browser und Sprache bereitgestellt [4].
An vielen Stellen im Toolkit wird Deferred Binding implizit verwendet. Bei der Entwicklung eigener Widgets wird jedoch bei manchen Klassen die explizite Verwendung notwendig. Dabei wird zur Instanziierung dieser Klassen anstatt des new-Operators die Methode GWT.create(Class) verwendet. Dies erlaubt GWT an einem späteren Zeitpunkt zu entscheiden, welche Version der Klasse zurückgegeben wird.
Abbildung 1 zeigt die einzelnen Komponenten eines Vaadin-UI-Elements auf. MyWidget ist eine eigenentwickelte clientseitige Komponente, die für die Darstellung im Browser bestimmt ist. Ihr serverseitiges Gegenstück ist MyComponent, in dem das API implementiert wird, das es Entwicklern erlaubt, das Widget serverseitig zu steuern. Die beiden Hälften teilen ein gemeinsames Zustandsobjekt, MyComponentState, und kommunizieren über den MyConnector miteinander, der die Aufgabe hat, die Synchronisation des Zustandsobjekts sowie die Events vom und zum Server zu verwalten [5].
Der einfachste Weg, mit der Entwicklung von Vaadin-Widgets loszulegen, ist die Verwendung des Vaadin-Plug-ins für Ihre IDE. Das Plug-in ist mittlerweile für Eclipse, NetBeans und neuerdings auch für IntelliJ IDEA verfügbar. Dadurch lassen sich mithilfe des grafischen Wizards Templates für Vaadin-Projekte, Themes oder Widgets anlegen.
Um nicht an eine IDE gebunden zu sein und deshalb, weil in der Praxis Projekte selten ohne die Verwendung eines Build-Tools entwickelt werden, wird in diesem Beitrag das Widget als ein Maven-Projekt implementiert, unter Verwendung des Vaadin-Archetype-Widgets (Tabelle 1).
Parameter |
Wert |
---|---|
archetypeGroupId |
com.vaadin |
archetypeArtifactId |
vaadin-archetype-widget |
archetypeVersion |
LATEST |
Tabelle 1: Das Maven-Archetype-Vaadin-Archetype-Widget
Die Generierung, basierend auf diesem Archetype, wird mit dem Maven-Befehl mvn archetype:generate gestartet, und es werden folgende Parameterwerte übergeben:
groupId: de.adesso.jm
artifactId: maskedinputfield
version: 1.0-SNAPSHOT
package: de.adesso.jm.maskedinput
ComponentClassName: MaskedInputField
Damit wird ein Maven-Projekt mit zwei Modulen generiert: maskedinputfield und maskedinputfield-demo. Im Demomodul befindet sich eine beispielhafte Vaadin-Applikation, in der das neue Widget getestet werden kann. Zusätzlich enthält die POM-Datei dieses Moduls die Deklaration des Vaadin-Maven-Plug-ins. Dieses Plug-in übernimmt die Kompilierung des Widgets während des Maven-Build-Prozesses (Listing 3).
Listing 3
<plugin>
<groupId>com.vaadin</groupId>
<artifactId>vaadin-maven-plugin</artifactId>
<version>${vaadin.plugin.version}</version>
<configuration>
<webappDirectory>
${basedir}/src/main/webapp/VAADIN/widgetsets
</webappDirectory>
<hostedWebapp>
${basedir}/src/main/webapp/VAADIN/widgetsets
</hostedWebapp>
...
</configuration>
<executions>
<execution>
...
<goals>
<goal>resources</goal>
<goal>update-widgetset</goal>
<goal>compile</goal>
</goals>
</execution>
</executions>
</plugin>
Das Goal update-widgetset ist für die Neukompilierung des Widget-Sets verantwortlich und kann einzeln aus der Kommandozeile mit dem Befehl mvn vaadin:update-widgetset ausgeführt werden.
Im Modul maskedinputfield dagegen sind die Package-Struktur sowie alle notwendigen Klassen generiert worden, die für die Implementierung der in Abbildung 1 dargestellten Komponenten des Widgets notwendig sind (Abb. 2). Zusätzlich wird im Ressourcenverzeichnis ein GWT-Modul-Descriptor (die Datei WidgetSet.gwt.xml) angelegt, der den Eingangspunkt der clientseitigen Engine darstellt [6].
Abschließend müssen die Quelldateien für jquery.inputmask [2] und jQuery [7] heruntergeladen und im Ressourcenverzeichnis unter dem Pfad de/adesso/jm/maskedinputfield/client abgelegt werden.
Im Package de.adesso.jm.maskedinputfield.client des Moduls MaskedInputField sind sämtliche Klassen, die für die Implementierung des Widgets sowie deren Integration mit der Serverseite relevant sind, generiert worden. Dieser Abschnitt bietet einen detaillierteren Einblick in die einzelnen Klassen sowie die durchzuführenden Anpassungen, angefangen mit der Klasse MaskedInputFieldWidget.
Wie bereits erwähnt, deutet das Suffix Widget im Namen der Klasse darauf hin, dass diese Klasse die (GWT-basierte) Clientseite repräsentiert. Da im Grunde das Ziel ist, das Eingabefeld nur zu erweitern, bietet es sich an, diese Implementierung von der Klasse des GWT-Widgets com.google.gwt.user.client.ui.TextBox erben zu lassen. Zusätzlich implementiert diese Klasse das Interface com.vaadin.client.ui.Field, das als ein so genanntes Markierungsinterface keine Methoden vorschreibt, sondern lediglich dazu dient, Vaadin serverseitig mitzuteilen, dass es sich bei diesem Widget um ein Eingabefeld handelt.
Die Hauptaufgabe der Klasse MaskedInputFieldWidget ist es, die Werte der Platzhalter und Maske von der serverseitigen Hälfte an dem JavaScript-Plug-in clientseitig weiterzuleiten. Dafür werden die beiden Methoden setPlaceholder() und setMask() benötigt. Außerdem wird zum Event Handling ein einfaches Observer-Pattern implementiert. Dies besteht aus einem internen Handler-Interface, MaskEventHandler, sowie den dazugehörigen Methoden, um neue Handler zu registrieren und auf Events zu reagieren sowie schließlich zwei Handler-Listen. Listing 4 zeigt nun den dafür notwendigen Code und die vorerst leeren setMask()- und setPlaceholder()-Methoden.
Als ein Vaadin „Best-Practice“ wird zusätzlich ein CSS-Klassenname für das Widget definiert und im Konstruktor an die Methode addStyleName() übergeben. Dies kann später die Arbeit der Designer vereinfachen.
Listing 4
public class MaskedInputFieldWidget extends TextBox implements Field {
public static final String CLASSNAME = "ad-masked-input-field";
private String mask;
private String placeholder;
private List<MaskEventHandler> maskCompleteHandlers;
private List<MaskEventHandler> maskIncompleteHandlers;
public MaskedInputFieldWidget() {
setStyleName(CLASSNAME);
maskCompleteHandlers = new ArrayList<>();
maskIncompleteHandlers = new ArrayList<>();
...
}
public void setInputMask(String mask){
...
}
public void setPlaceholder(String placeholder){
...
}
public void addOnMaskCompleteHandler(MaskEventHandler handler) {
maskCompleteHandlers.add(handler);
}
public void addOnMaskIncompleteHandler(MaskEventHandler handler) {
maskIncompleteHandlers.add(handler);
}
private void onMaskComplete() {
for (MaskEventHandler handler : maskCompleteHandlers){
handler.handleMaskEvent();
}
}
private void onMaskIncomplete() {
for (MaskEventHandler handler : maskIncompleteHandlers) {
handler.handleMaskEvent();
}
}
protected interface MaskEventHandler {
void handleMaskEvent();
}
}
Wie in Abbildung 1 dargestellt, werden Zustandsinformationen einer UI-Komponente über ein Shared-State-Objekt vom Server zum Client geteilt. Wird zum Beispiel die Beschriftung eines Felds serverseitig geändert, entnimmt die Clienthälfte den neuen Wert aus dem Zustandsobjekt und aktualisiert ihn anschließend im Browser.
Dementsprechend muss die Zustandsklasse des neuen Widgets (MaskedInputFieldState) um die zwei neuen Zustandswerte mask und placeholder erweitert werden. Da ein Zustandsobjekt keine Logik enthalten darf, reicht es, die beiden neuen Parameter als public zu deklarieren. Die Definition von Bean-Parametern mit Getter und Setter ist jedoch ebenfalls möglich.
Alle anderen für ein Textfeld relevanten Zustandswerte, wie z. B. die maximale Länge oder gar der Textinhalt des Felds, werden dadurch verfügbar gemacht, dass die Klasse MaskedInputFieldState von com.vaadin.shared.ui.textfield.AbstractTextFieldState erbt (Listing 5), die wiederum von der Oberklasse aller Zustandsobjekte com.vaadin.shared.communication.SharedState erbt.
Listing 5
public class MaskedInputFieldState
extends AbstractTextFieldState {
public String mask;
public String placeholder;
}
Für den Informationsaustausch zwischen Server und Client ist der Connector zuständig. Ein Connector ist eine clientseitige Klasse, die die Aufgabe hat, serverseitige Änderungen im Zustandsobjekt an das Widget zu kommunizieren sowie Userinteraktionen an die Serverseite weiterzureichen [8].
Um die Widget-Klasse mit ihrem serverseitigen Gegenstück zu verbinden, wird die Klasse MaskedInputFieldConnector folgendermaßen erweitert: Als Erstes werden die Methoden getWidget() und getState() der Oberklasse AbstractComponentConnector überschrieben, um einen Cast zum richtigen Klassentyp durchführen zu können. Dies ermöglicht dem Connector den Zugriff auf die neuen Setter-Methoden des Widgets sowie auf die neuen Zustandsparameter des Shared States. Um das Widget mit dem richtigen Klassentyp erstellen zu können, muss zusätzlich die Methode createWidget() überschrieben werden. Dabei ist zu beachten, dass die Instanziierung des Widgets nicht mit dem new-Operator durchgeführt werden darf, sondern durch „Deferred Binding“ mit der Methode GWT.create(). Anschließend wird über die Annotation @Connect die serverseitige Klasse der Komponente festgesetzt. Zuletzt wird die Methode onStateChanged() überschrieben, um bei Zustandsänderungen die Werte aus dem Zustandsobjekt zu lesen und das Widget zu aktualisieren (Listing 6).
Listing 6
@Connect(MaskedInputField.class)
public class MaskedInputFieldConnector
extends AbstractComponentConnector {
@Override
protected Widget createWidget() {
return GWT.create(MaskedInputFieldWidget.class);
}
@Override
public MaskedInputFieldWidget getWidget() {
return (MaskedInputFieldWidget) super.getWidget();
}
@Override
public MaskedInputFieldState getState() {
return (MaskedInputFieldState) super.getState();
}
@Override
public void onStateChanged(StateChangeEvent stateChangeEvent) {
super.onStateChanged(stateChangeEvent);
String mask = getState().mask;
String placehoder = getState().placeholder;
getWidget().setInputMask(mask);
getWidget().setPlaceholder(placehoder);
}
}
Um Synchronisationsprobleme beim Shared State auszuschließen, ist die Änderung der Zustandsparameter nur in einer Richtung möglich, nämlich die vom Server zum Client. Clientseitige Änderungsversuche werden ignoriert und beim nächsten serverseitigen Zugriff einfach überschrieben.
Um jedoch dem clientseitigen Widget die Änderung der Zustandswerte zu erlauben, ohne die Konsistenz des Shared States zu gefährden, wird auf das Pattern Remote Procedure Call (RPC) zurückgegriffen. Die beiden Interfaces MaskedInputServerRpc und MaskedInputClientRpc definieren die Methoden, die über das RPC-Pattern aufrufbar sind und erlauben damit server- bzw. clientseitigen Komponenten, Methoden auf der jeweils anderen Seite auszuführen.
Für das neue Widget werden im ServerRpc-Interface Methoden definiert, um serverseitig Events zu feuern, die die Vollständigkeit bzw. Unvollständigkeit des Felds melden (Listing 7). Das ClientRPC-Interface wird für dieses Beispiel nicht gebraucht. Letztlich werden die RPC-Methoden im Konstruktor der Connector-Klasse aufgerufen (Listing 8). Die Methode getRpcProxy() liefert ein RPC-Proxy-Objekt zurück, das das Aufrufen der serverseitigen Methoden ermöglicht.
Listing 7
public interface MaskedInputFieldServerRpc extends ServerRpc {
void fireMaskCompleteEvent();
void fireMaskIncompleteEvent();
}
Listing 8
public class MaskedInputFieldConnector extends AbstractComponentConnector {
public MaskedInputFieldConnector() {
getWidget().addOnMaskIncompleteHandler(new MaskEventHandler() {
@Override
public void handleMask() {
getRpcProxy(MaskedInputFieldServerRpc.class).fireMaskIncompleteEvent();
}
});
getWidget().addOnMaskCompleteHandler(new MaskEventHandler() {
@Override
public void handleMask() {
getRpcProxy(MaskedInputFieldServerRpc.class).fireMaskCompleteEvent();
}
}); }
...
}
Serverseitig besteht die UI-Komponente aus der Klasse MaskedInputField, die analog zu ihrem clientseitigen Gegenstück von der Serverkomponente des Textfelds die Klasse com.vaadin.ui.TextField erbt.
Um nun die Steuerung des Widgets serverseitig zu ermöglichen, muss die Klasse MaskedInputField auf folgende Weise angepasst werden: Zunächst muss die Methode getState() überschrieben werden, um erneut einen Cast zum richtigen Klassentyp durchzuführen. Dadurch ist es möglich, in den zwei neuen Methoden setMask() und setPlaceholder() auf die Zustandsparameter zuzugreifen und ihnen neue Werte zuweisen zu können (Listing 9). Clientseitig reagiert der Connector auf die Änderungen im Shared State und leitet anschließend die neuen Werte an das Widget weiter. Das Setzen einer ID im Konstruktor ermöglicht die eindeutige Identifikation des Textfelds im Browser.
Listing 9
public class MaskedInputField extends TextField {
public MaskedInputField() {
setId(UUID.randomUUID().toString());
...
}
...
@Override
protected MaskedInputFieldState getState() {
return (MaskedInputFieldState) super.getState();
}
public void setMask(String mask){
getState().mask = mask;
}
public void setPlaceholder(String placeholder) {
getState().placeholder = placeholder;
}
}
Somit ist es nun möglich, das Widget serverseitig zu steuern. Um allerdings auf die vom jQuery-Plug-in gefeuerten Events serverseitig zu reagieren, fehlt der Serverkomponente eine Implementierung des Server-RPC-Interface. Der Einfachheit halber wird das Interface als eine anonyme Klasse im Konstruktor der Serverkomponente implementiert und anschließend über die Methode registerRpc() registriert. Durch die Registrierung werden die vom Client stammenden Methodenaufrufe mit den richtigen serverseitigen Methodenimplementierungen verbunden.
Analog zum clientseitigen Widget wird für das Management von Events und Event-Listeners wieder auf das Observer-Pattern zurückgegriffen. Dazu gehören die zwei internen Listener-Interfaces, MaskCompleteListener und MaskIncompleteListener, Methoden zur Registrierung neuer Listener sowie zum Event-Feuern und schließlich zwei Listen, um die Listener zu verwalten (Listing 10).
Listing 10
public class MaskedInputField extends TextField {
private List<MaskCompleteListener> maskCompleteListeners;
private List<MaskIncompleteListener> maskIncompleteListeners;
public MaskedInputField() {
setId(UUID.randomUUID().toString());
maskCompleteListeners = new ArrayList<>();
maskIncompleteListeners = new ArrayList<>();
registerRpc(new MaskedInputFieldServerRpc() {
@Override
public void updateValue(String newValue) {
setValue(newValue);
}
@Override
public void fireMaskCompleteEvent() {
MaskedInputField.this.fireMaskCompleteEvent();
}
@Override
public void fireMaskIncompleteEvent() {
MaskedInputField.this.fireMaskIncompleteEvent();
}
});
}
private void fireMaskCompleteEvent() {
for (MaskCompleteListener l : maskCompleteListeners) {
l.onMaskComplete();
}
}
private void fireMaskIncompleteEvent() {
for (MaskIncompleteListener l : maskIncompleteListeners) {
l.onMaskIncomplete();
}
}
public interface MaskCompleteListener {
void onMaskComplete();
}
public interface MaskIncompleteListener {
void onMaskIncomplete();
}
...
}
Das letzte Stück des Puzzles ist die Integration der JavaScript-Bibliothek jquery.inputmask. Bis zu diesem Stand werden die Werte der Maske und Platzhalter von der Serverseite über den Connector an das Widget weitergeleitet. Um diese nun an die passenden JavaScript-Funktionen zu übergeben, wird auf ein GWT-Feature namens JavaScript Native Interface (JSNI) zurückgegriffen.
JSNI greift Konzepte aus dem Java-API Java Native Interface (JNI), das es Entwicklern erlaubt, Methoden aus plattformabhängigen Applikationen und Bibliotheken aufzurufen. Diese so genannten „nativen“ Applikationen können in einer beliebigen anderen Sprache geschrieben werden, wie z. B. C oder Assembly [9]. Für die JSNI-Variante ist die Plattform der Browser und der Code ist in JavaScript geschrieben.
Im Java-Code müssen Methoden die „nativen“ Codes aufrufen. Diese sollen mit dem Keyword native gekennzeichnet werden. Weiterhin erlaubt JSNI dem JavaScript-Code, statt in externe Ressourcen direkt im Methodenrumpf zu schreiben. Dazu müssen die Rümpfe in einen speziellen JSNI-Comment-Block gesetzt werden. Der Block fängt mit /*-{ an und endet mit }-*/ (Listing 11).
Listing 11
public static native void alert(String msg) /*-{
$wnd.alert(msg);
}-*/;
Bevor die nativen Methoden definiert werden können, müssen die nötigen JavaScript-Ressourcen geladen werden. In GWT wird das durch die Definition eines ClientBundle-Interface ermöglicht. Dementsprechend wird das Interface MaskedInputFieldResources, das von com.google.gwt.resources.client.ClientBundle erbt, erstellt. Dieses Interface definiert die zwei Methoden jquery() und inputmask(), die JavaScript-Skripte als Objekte der Klasse TextResource zurückliefern. Das Binding zwischen den jeweiligen Methoden und den eigentlichen Dateien, die sich im Ressourcenverzeichnis des Moduls befinden, wird über die Annotation @Source erreicht. Schließlich wird die Instanziierung eines Objekts dieses Interface GWT durch die Methode create() überlassen und als statische Konstante namens INSTANCE abgelegt (Listing 12).
Listing 12
@Connect(MaskedInputField.class)
public class MaskedInputFieldConnector
extends AbstractComponentConnector {
public MaskedInputFieldConnector() {
getWidget().addOnMaskIncompleteHandler(new MaskEventHandler() {
@Override
public void handleMaskEvent() {
getRpcProxy(MaskedInputFieldServerRpc.class).fireMaskIncompleteEvent();
}
});
getWidget().addOnMaskCompleteHandler(new MaskEventHandler() {
@Override
public void handleMaskEvent() {
getRpcProxy(MaskedInputFieldServerRpc.class).fireMaskCompleteEvent();
}
});
}
...
}
Zusätzlich wird die Hilfsklasse MaskedInputFieldScriptLoader erstellt, die die Aufgabe hat, die JavaScript-Ressourcen in der Webapplikationen zu injizieren. Dies geschieht in der nativen Methode inject(), indem die JavaScript-eval()-Methode aufgerufen wird (Listing 13). Wichtig ist dabei zu beachten, dass das Objekt window nicht direkt referenziert werden kann, da das kompilierte Skript schließlich in einem Frame auf der „Hostseite“ läuft. Stattdessen kann die Browservariable window sowie auch document nur über $wnd bzw. $doc referenziert werden. Diese Parameter werden von GWT so initialisiert, um die window- und document-Objekte der Hostseite zurückzuliefern und nicht die des Frames [10].
Der restliche Code dient lediglich dazu, die Ressourcen aus der statischen Instanz des ClientBundle-Interface zu holen und sie bei Bedarf über die inject()-Methode zu laden. Auch hier wird die Klasse MaskedInputFieldScriptLoader über GWTs Deferred-Binding-Prinzip initialisiert und in einer eigenen INSTANCE-Konstante abgelegt (Listing 13).
Listing 13
public class MaskedInputFieldScriptLoader {
public static final MaskedInputFieldScriptLoader INSTANCE = GWT
.create(MaskedInputFieldScriptLoader.class);
private static boolean injected = false;
public static void ensureInjectedResources() {
if (!injected) {
INSTANCE.injectResources();
injected = true;
}
}
protected void injectResources() {
if (!hasJQuery()) {
inject(MaskedInputFieldResources.INSTANCE.jquery().getText());
}
inject(MaskedInputFieldResources.INSTANCE.inputmask().getText());
}
protected static native boolean hasJQuery()
/*-{
if($wnd.jQuery){
return true;
}
return false;
}-*/;
protected static native void inject(String script)
/*-{
$wnd.eval(script);
}-*/;
}
Dieser Ansatz, in dem das Client-Bundle in Kombination mit einer Script-Loader-Klasse verwendet wird, ist vom Code des vom Vaadin-Team entwickelten Charts-Add-on [11] inspiriert worden.
Nachdem nun die JavaScript-Ressourcen eingebunden worden sind, kann die Widget-Klasse MaskedInputWidget vervollständigt werden. Zuerst wird im Konstruktor die statische Methode ensureResourcesInjection() aus dem Script Loader aufgerufen. Anschließend werden die beiden Methoden setMask() und setPlaceholder() erweitert, um die neuen Werte an die native Methode setFieldProperties() zu überreichen.
Das jquery.inputmask-Plug-in erlaubt es, über ein Parameterobjekt JavaScript-Methoden zu definieren, die auf bestimmte Events reagieren. Um aber eine Java-Methode benutzen zu können, wird auf ein weiteres Feature von JSNI zurückgegriffen. Der Methodenaufruf sieht folgendermaßen aus:
[instance-expr.]@class-name::method-name(param-signature)(arguments)
In der Methode setFieldProperties() wird ein Objekt props erstellt, das die beiden Callback-Methoden sowie gegebenenfalls den Wert des Platzhalters festlegt und schließlich an die JavaScript-Methode inputmask() übergeben wird. Die finalen Erweiterungen der Klasse MaskedInputWidget sind in Listing 14 dargestellt.
Listing 14
public class MaskedInputFieldWidget extends TextBox implements Field {
...
public void setInputMask(String mask) {
if (mask != null && !mask.isEmpty()
&& !mask.equals(this.mask)){
this.mask = mask;
setFieldProperties(this.getElement().getId(),
this.mask, this.placeholder);
}
}
public void setPlaceholder(String placeholder) {
if (placeholder != null && !placeholder.isEmpty()
&& !placeholder.equals(this.placeholder)) {
this.placeholder = placeholder;
setFieldProperties(this.getElement().getId(),
this.mask, this.placeholder);
}
}
...
private native void setFieldProperties(String elementId,
String mask, String placeholder)
/*-{
var thisWidget = this;
var props = {
oncomplete : function(){
thisWidget.@de.adesso.jm.maskedinputfield.client
.MaskedInputFieldWidget::onMaskComplete()()},
onincomplete : function(){
thisWidget.@de.adesso.jm.maskedinputfield.client
.MaskedInputFieldWidget::onMaskIncomplete()()}
};
if (placeholder !== ""){
props.placeholder = placeholder;
}
$wnd.jQuery('#' + elementId).inputmask(mask, props);
}-*/;
}
Somit ist die Implementierung des neuen Widgets abgeschlossen, und es ist nun möglich, eine Demo durchzuführen.
Im generierten Demomodul muss in der UI-Klasse DemoUI die Methode init(), die den Eingangspunkt einer Vaadin-Applikation darstellt, bearbeitet werden.
In der Demoapplikation wird eine neue MaskedInputField-Komponente abgelegt, mit der Maske „999-aaa“ und dem Platzhalter „###-???“. Zusätzlich werden zwei einfache Event-Listener (mittels Lambda-Ausdrücken) definiert, die lediglich darüber benachrichtigen, ob die Maske vollständig ist oder nicht (Listing 15).
Listing 15
protected void init(VaadinRequest request) {
final MaskedInputField component = new MaskedInputField();
component.setMask("999[-aaa]");
component.setPlaceholder("###-???");
component.addMaskCompleteListener(() -> Notification.show("complete"));
component.addMaskIncompleteListener(() -> Notification.show("incomplete"));
final VerticalLayout layout = new VerticalLayout();
layout.setStyleName("demoContentLayout");
layout.setSizeFull();
layout.addComponent(component);
layout.setComponentAlignment(component, Alignment.TOP_CENTER);
setContent(layout);
}
Die Applikation kann nun mit Maven gebaut und in einen beliebigen Servlet-Container deployt und gestartet werden. Das Ergebnis ist in den Abbildungen 3 und 4 dargestellt.
Der Ansatz des Vaadin-Frameworks verfolgt eine serverseitige, rein Java-basierte Webentwicklung ohne direkte Verwendung von HTML und JavaScript-Code, schließt diese jedoch nicht aus. Durch die verbesserte Erweiterbarkeit des Frameworks können selbstentwickelte Add-ons auf verschiedenen Abstraktionsebenen entwickelt werden – wie zum Beispiel rein serverseitig, clientseitig mit HTML und JavaScript oder, wie das Beispiel aus diesem Artikel, clientseitig auf GWT-Ebene.
Weiterhin zeigt dieser Artikel auf, wie solche Add-ons die Vorteile des Programmierens mit einer typisierten Sprache wie Java mit der Flexibilität und Vielfalt anderer Webtechnologien kombinieren können.
Wajdi Almir Ahmad ist ein Softwareentwickler bei der adesso AG. Sein Fokus liegt auf der Entwicklung von Webapplikationen und Diensten auf Basis von Java-Technologien und -Frameworks.
[1] Vaadin: Endlich 7: http://jaxenter.de/artikel/Vaadin-Endlich-7-2
[2] http://robinherbots.github.io/jquery.inputmask
[3] https://vaadin.com/directory
[4] http://www.gwtproject.org/doc/latest/DevGuideCodingBasicsDeferred.html
[5] Book of Vaadin, Chapter 16: https://vaadin.com/book/-/page/gwt.html
[6] Book of Vaadin, Section 11.2: https://vaadin.com/book/vaadin6/-/page/gwt.widgetset.html
[7] http://jquery.com/download
[8] Book of Vaadin, Section 16.4: https://vaadin.com/book/vaadin7/-/page/gwt.connector.html
[9] http://docs.oracle.com/javase/8/docs/technotes/guides/jni
[10] http://www.gwtproject.org/doc/latest/DevGuideCodingBasicsJSNI.html
Es gibt wohl kaum ein Thema in der Softwareentwicklung, das derzeit so heiß diskutiert wird wie die Wunderwelt der Microservices. Von „einfach genial“ bis hin zu „alter Wein in neuen Schläuchen“ trifft man auf die unterschiedlichsten Meinungen. Nicht selten begründen sich dabei die verschiedenen Einstellungen der Diskutanten schon in der Auffassung, was sich denn genau hinter dem Begriff „Microservices“ verbirgt. Grund genug für EnterpriseTales, einen Blick hinter die Kulissen zu wagen.
Um es gleich vorwegzunehmen: Wer in diesem Artikel eine eindeutige Definition des Begriffs Microservices sowie klare Architekturvorgaben in Form von Patterns und Tipps für ein entsprechend definiertes, universell einsetzbares Toolset/Framework erwartet, der sollte gar nicht erst weiterlesen. Zu unterschiedlich sind derzeit die Auffassungen und Erfahrungen der Experten aus ihren eigenen, stark individuellen Projekten, die sie bereitwillig in Blogs, Artikeln oder auf Konferenzen teilen. Angefangen bei kleinsten Einheiten, die je Request einen eigenen Prozess starten (und diesen dann auch sofort wieder beenden) bis hin zu SOA-ähnlichen Dimensionen eines Service sind für die Definition des Begriffs Microservices nahezu alle Facetten vertreten.
Einig sind sich allerdings nahezu alle Verfechter Microservices-basierter Architekturen darin, dass ein derartiger Ansatz nur dann sinnvoll ist, wenn die einzelnen Services so autark wie möglich sind und so eine lose Kopplung des Gesamtsystems erreicht werden kann. Dies gilt nicht nur für die Entwicklung der Services selbst, sondern vor allem auch für Datenhaltung, Deployment und Monitoring – DevOps lässt grüßen. Denn was nutzt es, losgelöste Services zu implementieren, die am Ende doch alle auf dieselbe Datenbank zugreifen und somit bei kleinsten Änderungen an der Struktur dank der damit verbundenen Abhängigkeiten alle gleichzeitig neu deployt werden müssen? Und wie flexibel ist ein System mit autarken Entwicklungsteams, deren Softwarefragmente am Ende doch wieder in einem einheitlichen Deployment-Rhythmus künstlich synchronisiert werden?
Im Rahmen der vor Kurzem in Berlin stattgefundenen Konferenz microxchg 2015 (http://microxchg.io) haben sich einige Vordenker des Microservices-Ansatzes an einer Definition versucht: Adrian Cockcroft, ehemaliger Cloud Architect bei Netflix und somit einer der Microservices-Pioniere, definierte den Microservices-Ansatz als „Loosely coupled service oriented architecture with bounded contexts“. Oliver Wegner, Leiter Architektur und Qualitätssicherung E-Commerce bei OTTO, bezeichnet dagegen einen Microservice als „Einheit, die von einem kleinen Team komplett – sowohl fachlich als auch technologisch – beherrscht werden kann“. Stefan Tilkov, Gründer von innoQ, wiederum wählte einen Ansatz zur „Definition“ von Microservices, den auch viele andere Speaker auf der Konferenz verfolgten, und beschrieb Microservices über eine Reihe von Charakteristika. Angefangen bei einer in sich abgeschlossenen, fachlichen Logik und einer damit einhergehenden, bei Bedarf redundanten Persistenz, über eine losgelöste Entwicklung und Evolution des Service sowie stark limitierter Interaktionen mit anderen Services, bis hin zu einem autonomen Deployment und Monitoring reichte sein Lackmustest.
Eine wesentliche Herausforderung – und auch hier scheinen sich nahezu alle Microservices-Verfechter einig zu sein – stellt das richtige Zuschneiden der Services dar. Als Basis für sinnvolle Servicegrenzen wird in diesem Kontext gerne das mittlerweile mehr als zehn Jahre alte Buch „Domain-Driven Design“ von Eric Evans [1] und die dort vorgestellten Bounded Contexts heranzogen.
Weniger Konsens herrscht bei den Experten dagegen darüber, ob auch das User Interface Bestandteil der einzelnen Services sein oder es besser ein einheitliches User Interface geben sollte, das über ein Client-API-Gateway gekapselt auf die Services zugreift. Neben den beiden Extremen findet man in einschlägigen Blogs und vor allem in realen Projekten auch häufig Lösungen, bei denen zwar jeder Service sein eigenes UI mit sich bringt, dieses aber auf gemeinsamen Styles und Richtlinien aufsetzt und so nach außen ein einheitliches Ganzes darstellt.
Dass Projekte und Architekturen nicht selten an den zugrunde liegenden Organisationsstrukturen scheitern, wissen wir spätestens seit Conway’s Law:
„Any organization that designs a system (defined broadly) will produce a design whose structure is a copy of the organization’s communication structure.“ (Conway’s Law, ([2])
Hier bilden leider auch Microservices keine Ausnahme. Entsprechend muss die Einführung eines Microservices-basierten Ansatzes passende Team- und Kommunikationsstrukturen mit sich bringen, um Aussichten auf Erfolg zu haben. Dies stellt Organisationen vor die Aufgabe, gegebene Teamstrukturen zu hinterfragen und bestehende Teams gegebenenfalls neu zu strukturieren. Nur so lässt sich den zusätzlichen Aufgaben und Verantwortungen eines autark arbeitenden Entwicklerteams gerecht werden. Im Gegenzug dazu erhält das Team deutlich mehr Freiheiten, z. B. bei der Wahl der Programmiersprache, den einzusetzenden Frameworks, Tools und Libraries oder der zu verwendenden Datenbank bzw. Persistenz.
Wichtig ist, dass ein Team auch organisatorisch in die Lage versetzt werden muss, seine Services eigenständig zu implementieren, zu deployen und zu monitoren, um sie so permanent verbessern zu können. Nur so kann das Risiko der vielen vielen losgelösten Releases einzelner Services minimiert werden. Das ist laut Sam Newman von ThoughtWorks, Autor des Buchs „Building Microservices“ (O’Reilly, 2015) wiederum nur dann möglich, wenn sich teamübergreifend eine „Kultur der Automatisierung“ etabliert, die maßgeblich auf automatisierten Tests und Continuous Delivery aufsetzt.
Auch wenn Microservices für den einen oder anderen noch sehr abstrakt zu sein scheinen, feiern die ersten großen Referenzprojekte Erfolge und bringen klare Vorteile zum Vorschein. Die Entwicklungsteams genießen ihre neu gewonnenen Freiheiten und stellen sich im Gegenzug der Verantwortung für „ihre“ Services – von der Entwicklung bis zum Deployment und darüber hinaus.
Kleine, flexible Teams sind in der Lage, schnell auf Anforderungsänderungen oder Laufzeitprobleme zu reagieren, ohne dabei ein zehnfach abgesichertes Qualitätsgateway durchlaufen zu müssen. Fehlerhafte Services, die nicht in den Griff zu bekommen sind, werden im Zweifelsfall einfach neu implementiert, bei Bedarf auch in einer anderen Technologie. Der Aufwand hierfür ist in der Regel sehr überschaubar und somit schnell gerechtfertigt.
Natürlich bringt der „neue“ Ansatz auch neue Herausforderungen mit sich. Dies gilt insbesondere für den organisatorischen Teil eines Projekts. Diese Herausforderungen scheinen aber mittlerweile einigermaßen gut verstanden zu sein – erste „Lessons learned“ wurden durchlebt.
Fairerweise soll an dieser Stelle erwähnt werden, dass es sich bei den bisherigen Pionieren fast ausschließlich um große bis sehr große Unternehmen mit entsprechendem „Kampfkapital“ zur Entwicklung eigener Infrastruktur, z. B. für Continuous Integration und Monitoring, handelt. Erste Lösungen wurden aber bereits z. B. von Netflix als Open-Source-Projekte der Allgemeinheit zur Verfügung gestellt.
Etwas überraschend scheint vielleicht, dass kaum ein Microservices-Verfechter das Thema „Kostenreduzierung in der Softwareentwicklung“ als Argument pro Microservices in den Raum wirft. Laut eigener Aussage geht es den meisten vielmehr darum, die Reaktionszeiten – Stichwort „Time to Market“ – sowie die Qualität der Softwaresysteme deutlich zu verbessern. Der Aspekt Kosten spielt dabei eine eher untergeordnete Rolle.
Oder anders formuliert: Microservices stellen eine immense Chance dar, dem Chaos der monolithischen Systeme wieder Herr zu werden und so endlich die Kontrolle über die eigene Software zurückzugewinnen. Ob dies gelingt, bleibt spannend. In diesem Sinne: Stay tuned.
Lars Röwekamp ist Geschäftsführer der open knowledge GmbH und berät seit mehr als zehn Jahren Kunden in internationalen Projekten rund um das Thema Enterprise Computing.
Arne Limburg ist Softwarearchitekt bei der open knowledge GmbH in Oldenburg. Er verfügt über langjährige Erfahrung als Entwickler, Architekt und Consultant im Java-Umfeld und ist auch seit der ersten Stunde im Android-Umfeld aktiv.
[1] Evans, Eric: Domain-Driven Design, 2003: http://dddcommunity.org/book/evans_2003
[2] Conway’s Law: http://www.melconway.com/Home/Conways_Law.html
Java lädt und initialisiert Klassen über einen ausgefeilten Mechanismus. Damit kommt man zwar im Programmieralltag nicht oft in Berührung, es hilft aber z. B. dabei, das Verhalten von Application Servern zu verstehen.
Moderne IDEs und Build-Werkzeuge wie Maven machen es einfach, Bibliotheken in den Classpath aufzunehmen. Man kann deren Klassen dann einfach verwenden – das ist ja Sinn der Sache.
Man kann aber auch Klassen laden und verwenden, die nicht im Classpath liegen. Als Beispiel dient die Klasse NodeCachingLinkedList aus der commons-collections-Bibliothek von Apache [1], eine besondere Implementierung von java.util.List; ihre Besonderheiten sind für unser Beispiel egal. Die Bibliothek liegt zum Download unter [2] bereit.
Um eine Klasse zu laden, brauchen wir einen ClassLoader (Listing 1) – der Code soll ja funktionieren, ohne dass commons-collections im Classpath liegt. Dazu erzeugen wir einen URLClassLoader, der im Konstruktor den Pfad zur JAR-Datei erhält.
Listing 1
final URLClassLoader cl = new URLClassLoader (new URL[] {new URL("file:///home/arno/tmp/commons-collections4-4.0.jar")});
final Class cls = cl.loadClass ("org.apache.commons.collections4.list.NodeCachingLinkedList");
final List<String> coll = (List<String>) cls.newInstance ();
coll.add ("a");
coll.add ("b");
for (String s: coll) {
System.out.println (s);
}
System.out.println (coll.getClass ());
System.out.println (coll.getClass().getSuperclass ());
assert (coll instanceof java.util.List);
Zum Laden einer Klasse ruft der Code dann die Methode loadClass() des ClassLoaders auf und übergibt dabei den voll qualifizierten Namen der Klasse. Dieser Aufruf liefert eine Class zurück. NodeCachingLinkedList hat einen No-Args-Konstruktor, also können wir durch Aufruf von cls.newInstance() eine neue Instanz erzeugen.
Ab diesem Punkt ist die neu erzeugte Liste ein Objekt wie jedes andere – man kann Elemente hinzufügen oder löschen, und man kann über die Elemente iterieren. Auch Reflection funktioniert auf der dazugehörigen Klasse ohne Einschränkung. Und wenn man sich mit getClass().getSuperclass() die Basisklasse holt – AbstractLinkedList, ebenfalls aus commons-collections – dann sieht man, dass der ClassLoader diese Klasse automatisch mitgeladen hat, ohne dass man sich darum kümmern musste.
Der Code in Listing 1 funktioniert übrigens auch, wenn man in der ersten Zeile den Download-URL aus [2] einträgt – dann lädt der URLClassLoader die Klassen direkt über das Netzwerk, ohne dass die JAR-Datei lokal im Dateisystem liegen müsste. Das ist natürlich erheblich langsamer, man kann Probleme mit Proxies haben, und die Anwendung lässt sich nur starten, wenn ein Third-Party-Server verfügbar ist. Aber es zeigt, dass nicht alle Klassen von einem lokalen Classpath kommen müssen.
Wenn man auf demselben URLClassLoader mehrmals loadClass() für dieselbe Klasse aufruft, erhält man immer dasselbe Objekt zurück. Wenn man dieselbe Klasse aber über verschiedene ClassLoader lädt, dann sind das für die JVM komplett verschiedene Klassen (Listing 2).
Listing 2
final URLClassLoader loader1 = new URLClassLoader (new URL[] {new URL("file:///home/arno/tmp/commons-collections4-4.0.jar")});
final URLClassLoader loader2 = new URLClassLoader (new URL[] {new URL("file:///home/arno/tmp/commons-collections4-4.0.jar")});
final Class cls = loader1.loadClass ("org.apache.commons.collections4.list.NodeCachingLinkedList");
final Class cls1 = loader1.loadClass ("org.apache.commons.collections4.list.NodeCachingLinkedList");
final Class cls2 = loader2.loadClass ("org.apache.commons.collections4.list.NodeCachingLinkedList");
assert (cls == cls1);
assert (cls != cls2);
final List l = (List) cls.newInstance ();
assert (cls.isInstance (l));
assert (!cls2.isInstance (l));
Der zweite ClassLoader erzeugt ein komplett separates Class-Objekt, das alle statischen Variablen ein zweites Mal enthält und ggf. initialisiert. Und wenn man auf Basis des ersten ClassLoaders eine Instanz erzeugt, ist sie keine Instanz der Klasse, die der zweite ClassLoader geladen hat.
Diese vollständige Isolation zwischen ClassLoadern machen sich Application Server zunutze, um mehrere Webanwendungen im selben Container zu deployen, ohne dass diese sich gegenseitig sehen oder in die Quere kommen könnten.
Jede Anwendung (inklusive ihrer Bibliotheken) wird mit einem eigenen ClassLoader geladen, und selbst wenn mehrere Anwendungen dieselben Klassen verwenden, existieren sie friedlich nebeneinander. Das gilt sogar dann, wenn sie statische Variablen verwenden oder verschiedene Versionen derselben Bibliothek benutzen.
Wenn man einen String mit einem Klassennamen hat und die dazugehörige Klasse laden will, braucht man einen ClassLoader. Und wenn man in einer Anwendung mit mehreren ClassLoadern den falschen verwendet, führt das zur Laufzeit zu Fehlern, z. B. weil er die Klasse nicht findet.
Ein naiver Ansatz besteht darin, mit getClass().getClassLoader() den ClassLoader der aktuellen Klasse zu verwenden. Die Methode Class.forName(...) tut dasselbe, wenn auch mit weniger syntaktischem Overhead. Beides funktioniert aber in nicht trivialen Anwendungen nicht zuverlässig.
Das ist besonders bei Frameworks wie Hibernate oder Spring der Fall, bei denen ein Framework per Reflection auf Anwendungsklassen zugreift. Wenn das Framework als Teil des Application Servers installiert ist, „sieht“ der ClassLoader des Frameworks die Anwendungsklassen nicht.
Deshalb gibt es den so genannten Context Class Loader: Wenn man Thread.currentThread().getContextClassLoader() aufruft, erhält man den ClassLoader, der zum Laden von Anwendungsklassen geeignet ist. Wenn man nicht sehr sicher ist, dass man weiß, was man tut, sollte man zum Laden von Klassen immer diesen ClassLoader verwenden.
Dieser Context Class Loader ist unter der Oberfläche einfach eine Variable je Thread, die man mit setContextClassLoader(...) auch setzen könnte. Das übernehmen aber Anwendungsframeworks und Application Server, und man kann und sollte davon ausgehen, dass die Variable sinnvoll gefüllt ist.
Per Default ist sie mit dem AppClassLoader initialisiert. Man kann sie also auch in kleinen, einfachen Anwendungen ohne Frameworks guten Gewissens verwenden.
Jeder ClassLoader hält intern eine Liste aller von ihm geladenen Klassen und schützt diese dadurch vor der Garbage Collection. Erst wenn der ClassLoader selbst freigegeben wird, können auch seine Klassen abgeräumt werden. Das ist wichtig, weil dadurch die Inhalte von statischen Variablen erhalten bleiben, auch wenn es vorübergehend keine Instanzen einer Klasse gibt.
Umgekehrt hat jede Klasse eine Referenz auf „ihren“ ClassLoader. Der kann also nicht von der GC abgeräumt werden, solange es auch nur eine einzige Referenz „von außen“ auf eine einzige Instanz einer seiner Klassen gibt. Das kann zum Beispiel eine nicht aufgeräumte ThreadLocal-Variable sein oder ein Hintergrundthread.
Solche Referenzen, die eine GC eines ClassLoaders verhindern, verhindern das saubere Undeployment von Anwendungen in Application Servern und machen sich dort als Memory Leak bemerkbar (siehe z. B. [3]).
ClassLoader bilden eine Hierarchie: Jeder ClassLoader hat einen (optionalen) Parent ClassLoader, und wenn er versucht, eine Klasse zu laden, fragt er diesen typischerweise zuerst, ob der die Klasse kennt. Dadurch wird z. B. die Java-Standardbibliothek immer vom selben ClassLoader geladen, und java.lang.String ist immer und überall dieselbe Klasse.
Wie die ClassLoader-Hierarchie einer neu gestarteten Anwendung aussieht, kann jede JVM selbst frei entscheiden. Listing 3 gibt diese Hierarchie aus.
Listing 3
ClassLoader loader = getClass().getClassLoader();
while (loader != null) {
System.out.println (loader.getClass().getName());
loader = loader.getParent();
}
System.out.println (String.class.getClassLoader());
Der Code beginnt mit dem ClassLoader, der die Anwendungsklasse geladen hat. Den gibt er aus, holt sich den Parent ClassLoader und wiederholt das Ganze, solange dieser nicht null ist. Schließlich gibt der Code noch den ClassLoader aus, mit dem Klassen aus der Standardbibliothek geladen werden.
Auf der Oracle-JVM ist das eine dreistufige Hierarchie. Anwendungsklassen werden von einer Instanz von AppClassLoader geladen, die den Classpath der Anwendung kennt und bedient. Darunter liegt eine Instanz von ExtClassLoader, der für Bibliotheken in den Extension Directories der JVM zuständig ist: Bibliotheken, die z. B. im Verzeichnis lib/ext der JRE liegen, stehen auf diesem Weg allen Java-Anwendungen zur Verfügung [4].
Schließlich gibt es noch den Bootstrap ClassLoader, der für die Standardbibliotheken zuständig ist. Der ist aber selbst kein Java-Objekt, sonst müsste er sich ja selbst laden. Deshalb liefert der Aufruf von getClassLoader() für eine Klasse aus der Standardbibliothek null zurück. Dieser Bootstrap ClassLoader ist übrigens der einzige, der Klassen aus dem Wurzelpackage java liefern darf. Die Klasse ClassLoader überprüft das hart in der Methode preDefineClass().
Es ist aufschlussreich, den Code aus Listing 3 einmal im Application Server auszuführen. Dann sieht man, was für eine Hierarchie von ClassLoadern dieser benutzt, um Webanwendungen voneinander zu isolieren.
Java lädt alle Klassen durch ClassLoader, die hierarchisch angeordnet sind. Code aus verschiedenen ClassLoadern ist vollständig voneinander isoliert, und besonders Application Server nutzen das aus, um mehrere Anwendungen in derselben JVM deployen zu können.
Auch wenn man mit diesen Mechanismen beim alltäglichen Programmieren selten direkt in Berührung kommt, ist es hilfreich, sie zu kennen – spätestens wenn der Application Server sich überraschend verhält und man sein Verhalten verstehen will, oder einfach, weil man neugierig ist und gerne versteht, wie Dinge funktionieren.
Arno Haase ist freiberuflicher Softwareentwickler. Er programmiert Java aus Leidenschaft, arbeitet aber auch als Architekt, Coach und Berater. Seine Schwerpunkte sind modellgetriebene Softwareentwicklung, Persistenzlösungen mit oder ohne relationaler Datenbank und nebenläufige und verteilte Systeme. Arno spricht regelmäßig auf Konferenzen und ist Autor von Fachartikeln und Büchern. Er lebt mit seiner Frau und seinen drei Kindern in Braunschweig.
In den Anfängen des Internets machten Firmen wie Sun Microsystems, IBM, aber auch Yahoo! als Taktgeber technischer Innovation und als Orte „wahrhaftiger Ingenieurskultur“ auf sich aufmerksam.
Nach dem Platzen der New-Economy-Blase folgte dann das Google-Jahrzehnt, in dem technische Exzellenz, modernste Geek-Kultur (vom kostenlosen Bioessen bis hin zur berühmten „20 Percent Time“) und höchste Sympathiewerte in der Tech-Szene den Internetgiganten aus Mountain View auszeichneten.
Möglicherweise befinden wir uns heute mitten in einem „Netflix-Zeitalter“, zumindest wenn man über das Thema Softwarearchitektur in extrem skalierbaren Systemen nachdenkt. So ist der Videostreamer, dem man nachsagt, für ein Drittel des gesamten Downstream-Internettraffics in Nordamerika verantwortlich zu sein, Urheber von einer Fülle zukunftsweisender Konzepte für den Bau von Software – womit wir bei unserem Titelthema wären.
In Zeiten digitaler Geschäftsmodelle ist die Verfügbarkeit eines Systems von essenzieller Bedeutung für den Erfolg von Unternehmen. Da es aber deren Natur mit sich bringt, dass die entsprechenden Systeme hoch skalierbar und extrem verteilt mit einer Vielzahl externer Abhängigkeiten funktionieren, müssen wir uns eingestehen, dass Fehler unvermeidlich werden.
Wie können wir dann noch für die gebotene Verlässlichkeit sorgen? Bei klassischen Ansätzen der Qualitätssicherung und -optimierung würde man versuchen, die Anlässe für solche Fehler systematisch zu reduzieren oder gar zu eliminieren.
Das Konzept „Resilience“ schlägt da einen anderen Gedanken vor: Es ist etwas fundamental anderes, Fehler als nicht vorgesehene Ausnahme zu betrachten oder aber davon auszugehen, dass Fehler immer passieren können und passieren werden – und von dieser Warte aus nach Wegen zu suchen, die Verfügbarkeit von Systemen zu maximieren. Davon handelt unser Schwerpunkt ab Seite 33.
Wenn man bedenkt, dass das erfolgreichste Netzwerk, das in den vergangenen Jahrzehnten aufgebaut wurde und im Prinzip seit über zwei Jahrzehnten unverändert seinen Dienst tut, das Internet ist, dann ahnt man die Bedeutung des Resilience-Konzepts: Das Netz, vom amerikanischen Militär von Anfang an für den Fall des Ausfalls konzipiert, scheint das stabilste und dauerhafteste zu sein, das die IT je hervor gebracht hat. Es ist resilient.
Jede Applikation benötigt Daten. Diese werden mit dem (Java) Collections Framework verwaltet, das deshalb zu den wichtigsten Teilen des JDK gehört. Es definiert Interfaces und stellt Klassen mit Standardimplementierungen bereit. Allerdings kommt es häufig vor, dass man keine Klasse findet, die genau den Anforderungen entspricht. Entweder akzeptiert man diese Lücke, schließt sie mit Programmieraufwand oder zieht Collections aus zusätzlichen Bibliotheken hinzu. Viel besser wäre es doch, wenn man die benötigten Collections selbst wie aus einem Baukasten zusammenstellen könnte. Genau diese Funktionalität stellt die Brownies Collections Library mit den Key Collections zur Verfügung.
Der Artikel „High-Performance Lists für Java“ in der letzten Ausgabe des Java Magazins hat mit GapList und BigList Alternativen zu den List-Implementierungen des JDK vorgestellt, die in allen Fällen schnell sind und gut skalieren. In diesem Artikel stellen wir mit den Key Collections den zweiten Hauptteil der Brownies Collections Library [1] vor. Sie erlauben es, Collections mit konfigurierbarer Funktionalität zur Laufzeit zu erzeugen.
Bereits im ersten Artikel haben wir das spartanische API des List-Interface bemängelt. Es gibt aber noch weitere Kritikpunkte am Java-Collections-Framework: So decken die zur Verfügung gestellten Interfaces und Klassen nur die einfachsten Anwendungsfälle ab. Neben dem List-Interface gibt es nur noch Set und Queue/Deque, die ebenfalls von Collection erben, und getrennt von dieser Hierarchie noch Map – komplexere Datenstrukturen bleiben außen vor. Wenn man solche verwenden will, muss man sie entweder selbst aus den bestehenden zusammenbauen oder zusätzliche Libraries wie z. B. Googles Guava [2] einsetzen. Guava fügt dann beispielsweise die Interfaces Multiset, Multimap und BiMap mit entsprechenden Implementierungen hinzu, die weitere häufige Anwendungsfälle abdecken.
Allerdings wird man so mit einer Vielzahl von Interfaces und Klassen konfrontiert, die neu, unterschiedlich und zu erlernen sind, bevor die volle Funktionalität genutzt werden kann. Und da sich die Klassen typischerweise nicht konfigurieren lassen, findet man dann vielleicht doch wieder bloß eine Klasse, die den gewünschten Anwendungsfall nur fast, aber eben nicht ganz abdeckt. Viel besser wäre es doch, wenn man die benötigten Collections selbst wie aus einem Baukasten zusammenstellen könnte. Genau diese Funktionalität stellt die Brownies Collections Library mit den Key Collections zur Verfügung. Die Vielfältigkeit wird durch die Integration der Konzepte von Keys und Constraints ermöglicht, die durch ihre Mächtigkeit die Produktivität beim Entwickeln um ein Vielfaches erhöhen kann.
Am einfachsten wird die Nützlichkeit der Funktionalität mit einem Beispiel erklärt: Wir wollen in unserem Programm die Metainformationen zu den Spalten einer Datenbanktabelle repräsentieren, ähnlich wie sie beispielsweise java.sql.ResultSetMetaData bereitstellt, aber es sollen ebenfalls Änderungen unterstützt werden. Damit ergeben sich folgende Anforderungen:
Spalten haben eine explizite Ordnung, wie sie z. B. für eine Abfrage mit select * verwendet wird.
Die Spaltennamen einer Tabelle müssen einzigartig sein, doppelte Namen müssen also verhindert werden.
Effizienter Zugriff auf die Spalteninformationen soll sowohl über die Position der Spalte in der Tabelle als auch über den Spaltennamen möglich sein, analog wie java.sql.ResultSet den Zugriff auf Daten mit getObject(int) und getObject(String) erlaubt.
Wenn wir überlegen, welche Collection wir für diese Aufgabe wählen sollen, stellen wir fest, dass uns das JDK keine optimale Lösung bietet. Eine List erlaubt nur effizienten Zugriff über die Position, nicht aber über den Namen, während es bei einer Map gerade umgekehrt ist. Nur wenn man davon ausgehen kann, dass die Liste immer wenig Elemente enthält, können wir den Zugriff über den Namen durch Iteration realisieren. Eine Tabelle wird zwar nie eine wirklich riesige Menge von Spalten haben, aber das Iterieren wird bereits bei tausend Einträgen nicht mehr wirklich effizient sein. Um eine wirklich skalierbare Lösung zu erhalten, müssen wir die List deshalb mit einer Map synchronisieren. Dies ist keine unmögliche Aufgabe, aber doch jede Menge Arbeit für eine eigentlich alltägliche Anforderung. Mithilfe der Key Collections hingegen können wir eine Datenstruktur mit den gewünschten Eigenschaften mit einem einzigen Aufruf erzeugen (Listing 1).
Listing 1
// Definition of type Column
class Column {
String name;
String type;
// Add constructor / getters / setters
}
// Create list with columns
Key1List<Column,String> cols = new Key1List.Builder<Column,String>.withKey1Map(Column::getName).withKey1Null(false).withKey1Duplicates(false).build();
// Populate and query list
cols.add(new Column("name", "varchar"));
Column col = cols1.getByKey1("name");
Um die gewünschte Funktionalität zu erreichen, nutzt das Beispiel sowohl Keys als auch Constraints. Diese beiden Konzepte wollen wir nun vorstellen.
Ein Constraint auf einer Collection definiert, welche Bedingungen Elemente erfüllen müssen, damit sie in der Collection enthalten sein können. Beispiele für Constraints sind also, dass Elemente nicht null sein oder dass gespeicherte Strings nur Großbuchstaben enthalten dürfen. Zwar haben auch gewisse JDK-Klassen implizite Constraints, z. B., dass ein Set keine Duplikate enthalten kann oder dass ArrayDeque keine Nullwerte erlaubt, aber diese Einschränkungen sind fix und können nicht nach Bedarf geändert werden.
Will man konfigurierbare Constraints mit den JDK-Klassen realisieren, stellt man schnell fest, dass die Klassen nicht für solche Erweiterbarkeit entwickelt wurden: Um die erlaubten Elemente von ArrayDeque zu beschränken, müsste man z. B. alle Methoden, die das Hinzufügen von Elementen erlauben (add/addLast/addFirst, offer/offerFirst/offerLast, addAll), einzeln überschreiben, und kann dies nicht an einem zentralen Ort tun.
Weshalb sind aber Constraints so wichtig? Constraints sind nicht nur zur Datenhaltung selbst wichtig, sondern vor allem ein zentraler Baustein, um ein mächtiges API zur Verfügung stellen zu können. Gehen wir wieder zurück zu unserem Beispiel mit den Datenbankspalten und betrachten die Beispielklasse Table:
class Table { List<Column> columns; }
Welches API stellen wir den Nutzern der Klasse zur Verfügung, um die Spaltendefinition anlegen oder ändern zu können? Klar ist, dass die einfache Methode
List<Column> getColumns { return columns; }
gefährlich ist, da wir damit nicht garantieren können, dass die auf der zurückgegebenen Liste durchgeführten Änderungen die definierten Constraints erfüllen. Mit einer leicht angepassten Methode können wir zumindest den Lesezugriff realisieren:
List<Column> getColumns { return Collection.unmodifiableList(columns); }
Das API für Änderungen fehlt uns aber noch immer. Dazu bleiben zwei Möglichkeiten: eine ineffiziente für den faulen Programmierer, die nur eine einzige Methode zur Verfügung stellt:
void setColumns(List<Column> columns) {
check(columns); this.columns = new ArrayList(columns);
}
Das Problem mit diesem Ansatz ist, dass wir bei jedem Aufruf die Constraints für alle Elemente wieder prüfen müssen, auch wenn vielleicht nur eine einzige Änderung gemacht wurde. Dieser Ansatz skaliert damit nicht wirklich gut.
Damit bleibt nur noch die andere, aufwändigere Möglichkeit: Wir stellen für jede Art von Änderung, die durchgeführt werden können muss, eine separate Methode zur Verfügung, wie z. B.:
void addColumn(Column column) {
check(column); columns.add(columns);
}
Für ein vollständiges API brauchen wir dann aber auch noch die Methoden setColumn, removeColumn, vielleicht noch addColumn an einer angegebenen Position, und auch das Löschen von allen Spalteninformationen sollte möglich sein. Wie man sieht, artet das einfache API schnell in Fleißarbeit aus – oder aber das API bleibt unvollständig und ist damit unhandlich zu bedienen. Aber auch wenn diese Arbeit machbar ist, sollte man sich doch wieder die Frage stellen, ob es dafür keine einfachere Lösung gibt.
Tatsächlich können wir alle Anforderungen durch Einsatz der Key Collections mit der einfachstmöglichen Implementierung realisieren, da dann die Collection selbst sicherstellt, dass alle gespeicherten Elemente die Constraints jederzeit erfüllen:
class Table {
Key1List<Column,String> columns = ...;
List<Column> getColumns { return columns; }
}
Ein Key ist ein Wert, der von einer Funktion aus einem in einer Collection gespeicherten Element bestimmt wird. Wie man in Listing 1 sehen kann, speichern die Key Collections deshalb immer Elemente – dies als Gegensatz zum JDK-Map-Interface, das Elemente mit externen Keys assoziiert. Der Nachteil der JDK-Entscheidung ist, dass Map deshalb komplett getrennt von allen anderen Interfaces ist und nicht einmal das Basisinterface Collection erweitert. Schaut man sich dann aber Code an, der mit Maps arbeitet, stammt der Key in der Mehrzahl der Fälle aus dem Element selbst, d. h., die Map wird mit Aufrufen wie
map.put(elem.getName(), elem);
befüllt. So gesehen kann man sagen, dass sich die Key Collections auf den Normalfall konzentrieren, wenn sie grundsätzlich nur die Elemente selbst speichern und die Keys bei Bedarf mit Funktionen aus den gespeicherten Elementen bestimmen. Diese Entscheidung stellt auch keine wirkliche Einschränkung dar, denn sollte der Key einmal nicht in der Element-Klasse selbst vorhanden sein, kann dies durch die Definition einer Hilfsklasse einfach aufgefangen werden:
class Entry { String key; Column column; }
Wir bezeichnen die von der Funktion definierten Werte als Key Map. Wie auch Einträge in einer Map oder einem Set sollten die als Keys genutzten Werte grundsätzlich immutable sein, d. h., nach dem Hinzufügen des Elements zur Collection dürfen sich diese nicht mehr ändern.
Während Listing 1 eine Collection mit einem definierten Key zeigt, kann es pro Collection auch keine oder zwei Key Maps geben. Auch das Element selbst kann als Key genutzt werden; in diesem Fall sprechen wir von einem Element Set.
Das Beispiel zeigt auch, dass die Keys einer Collection nicht nur für den effizienten Zugriff auf die Elemente, sondern auch wieder für die Definition von Constraints verwendet werden können. Für jede Key Map oder das Element Set können folgende Eigenschaften festgelegt werden:
Nullwerte: Erlaubt oder verboten.
Duplikate: Erlaubt oder verboten. Ebenfalls kann spezifiziert werden, dass zwar Nullwerte mehrmals vorkommen können, andere Werte aber einzigartig sein müssen.
Sortierung: Sortiert oder nicht. Beim Sortieren kann entweder die natürliche Ordnung der Klasse verwendet oder explizit ein eigener Comparator angegeben werden. Wenn eine Key Map sortiert ist, kann ebenfalls festgelegt werden, dass diese Reihenfolge auch für die Elemente selbst verwendet werden soll, wodurch wir eine sortierte Collection erhalten.
Mit Kenntnissen relationaler Datenbanken werden einem diese Konzepte bekannt vorkommen. So haben wir in unserem Beispiel eigentlich den Spaltennamen als Primary Key der Collection definiert. Und analog zum Primary Key in einer Datenbanktabelle garantiert der definierte Constraint, dass die zu speichernden Elemente korrekt sind und erlaubt einen effizienten Zugriff über den Constraint Key. Auch andere Konzepte aus der Datenbankwelt, wie Check Constraints oder Triggers, die für das manuelle Prüfen von Elementen nützlich sein können, haben ihre Entsprechung in den Key Collections.
Doch nun genug der Theorie. Anhand von Beispielen wollen wir nun die Möglichkeiten aufzeigen, die sich aus den besprochenen Konzepten ergeben.
Die Key Collections implementieren das Collection- oder das List-Interface. Durch die Kombination mit Anzahl der genutzten Keys erhalten wir sechs Klassen, die Tabelle 1 im Überblick zeigt.
Klasse |
Beschreibung |
---|---|
KeyCollection<E>Key1Collection<E,K1> Key2Collection<E,K1,K2> |
Die Klassen KeyCollection, Key1Collection und Key2Collection implementieren das Collection-Interface. Die Reihenfolge der Elemente ist per Default nicht definiert, sie kann aber von einer der Key Maps bestimmt werden. |
KeyList<E>Key1List<E,K1> Key2List<E,K1,K2> |
Die Klassen KeyList, Key1List und Key2List implementieren das List-Interface, d. h., die Reihenfolge der Elemente wird immer durch die Liste bestimmt. Es kann aber festgelegt werden, dass die Reihenfolge der Liste der Reihenfolge einer Key Map entspricht, wodurch eine sortierte Liste entsteht. |
Tabelle 1: Übersicht über die Key-Collections-Klassen
Um die Vielzahl der Optionen vernünftig unterstützen zu können, geschieht das Anlegen von Key Collections immer über ein Builder-Pattern. Das zeigt unser erstes Beispiel, das eine einfache Integer-Liste ohne Constraints und Keys erzeugt:
KeyList<Integer> list = new KeyList.Builder<Integer>().build()
Die erzeugte KeyList verhält sich im Wesentlichen wie eine GapList und bietet über das IList-Interface auch alle bekannten Methoden an. Im Gegensatz zu einer GapList kann die KeyList nun aber beim Erstellen nach Belieben konfiguriert werden, indem wir z. B. die erlaubten Elemente mit einem Constraint einschränken:
Eine Integer-Liste, die keine Nullwerte zulässt:
new KeyList.Builder<Integer>().withElemNull(false).build()
Eine Integer-Liste, die keine Nullwerte und nur positive Zahlen zulässt:
new KeyList.Builder<Integer>().withElemNull(false).withConstraint(i -> i>=0).build()
Wenn nun versucht wird, ein Element hinzuzufügen, das die definierten Bedingungen nicht erfüllt, wird eine Exception geworfen. Neben den Elementen selbst kann auch die Anzahl der erlaubten Elemente eingeschränkt werden:
Eine Liste mit einer fixen maximalen Anzahl von Elementen, d. h. das Hinzufügen eines Elements wird fehlschlagen, wenn die Liste bereits voll ist:
new KeyList.Builder<Integer>().withMaxSize(10).build()
Eine Liste mit einer maximalen Anzahl von Elementen, die als rollendes Fenster organisiert sind, d. h. wenn die Liste voll ist, wird das Hinzufügen eines neuen Elements automatisch das erste Element aus der Liste entfernen, damit die festgelegte Größe nicht überschritten wird:
new KeyList.Builder<Integer>().withWindowSize(10).build()
Wenn komplexere Bedingungen geprüft werden müssen, die nicht nur vom Element selbst oder von den sich bereits in der Collection befindlichen Elementen abhängt, kann dies analog der Datenbankwelt mit Triggern gelöst werden.
Eine Liste, die vor dem Einfügen eines Elements eine Trigger-Funktion aufruft. Diese Funktion wird als Consumer-Interface definiert und so dem Trigger übergeben:
new KeyList.Builder<Integer>().withBeforeInsert(...).build()
Die Funktionen withBeforeInsert und withBeforeDelete erlauben das explizite Prüfen von Elementen, bevor eine Änderung durchgeführt wird. Wird in der angegebenen Funktion eine Exception geworfen, wird die Operation abgebrochen. Die Funktionen withAfterInsert und withAfterDelete werden entsprechend nach dem Durchführen der Operation aufgerufen.
Ebenfalls kann die zur Speicherung der Elemente verwendete Datenstruktur angepasst werden, sodass Speicherverbrauch und Performance für jeden Fall optimiert werden können:
Integer-Werte werden in einer GapList gespeichert:
new KeyList.Builder<Integer>().build()
Integer-Werte werden in einer BigList gespeichert:
new KeyList.Builder<Integer>().withElemBig(true).build()
Integer-Werte werden in einer IntObjGapList gespeichert:
new KeyList.Builder<Integer>().withElemClass(int.class).build()
Integer-Werte werden in einer IntObjBigList gespeichert:
new KeyList.Builder<Integer>().withElemBig(true).withElemClass(int.class).build()
Alle offerierte Funktionalität ist bisher alleine auf der zugrunde liegenden Listenstruktur aufgebaut. So kann zwar die Standardfunktion contains verwendet werden, die Implementierung ist aber langsam, da zur Ausführung über alle Elemente der Liste iteriert werden muss. Diese Operation kann beschleunigt werden, indem die KeyList angewiesen wird, neben der eigentlichen Liste auch noch ein Element Set zu führen – eine Integer-Liste mit einem Element Set für schnelle Zugriffe:
new KeyList.Builder<Integer>().withElemSet().build()
Damit sind nun auch Operationen wie contains oder remove sehr schnell, da diese über eine intern angelegte Map ausgeführt werden. Als einzige Einschränkung ist das Entfernen von Elementen über den Key bei Listen langsam, da das Element in der Liste durch Iterieren gesucht werden muss. In größeren Listen sollten deshalb Elemente immer über den Index gelöscht werden – oder man verwendet sortierte Listen. Die Map erlaubt auch eine effiziente Prüfung auf Duplikate – eine Integer-Liste, die keine Duplikate zulässt:
new KeyList.Builder<Integer>().withElemDuplicates(false).build()
Während die Key Maps normalerweise nicht sortiert sind, kann auch dies angepasst werden – eine Integer-Liste mit einer sortierten Key Map:
new KeyList.Builder<Integer>().withElemSort(true).build()
Durch das Sortieren des Element Set wird die Reihenfolge der Liste als solche noch nicht beeinflusst. Man kann die Collection aber so konfigurieren, dass eine sortierte Liste entsteht – eine sortierte Integer-Liste:
new KeyList.Builder<Integer>().withElemSort(true).withElemOrderBy(true).build()
Zu sortierten Lists gibt es zahlreiche Diskussionen [3], weshalb Java diese nicht kennt. Es gibt zwei Hauptargumente, die dagegen sprechen: Das erste, puristische Argument ist, dass die add-Funktion den Contract des List-Interface verletzt, da das Element nicht am Ende der Liste hinzugefügt wird. Das zweite, praktische Argument ist, dass es nicht möglich ist, eine sortierte Liste zu implementieren, in der Elemente in zufälliger Reihenfolge effizient hinzugefügt werden können (da in diesem Fall immer Elemente verschoben werden müssen, wodurch die Performance leidet). Während beide Argumente eine gewisse Berechtigung haben, kann eine sortierte Liste dennoch nützlich und – wenn sie richtig genutzt wird – auch effizient sein.
Nun erweitern wir das Beispiel um die Definition eines Keys, wie wir ihn bereits in Listing 1 gesehen haben – eine Liste mit Spaltenelementen und Zugriff über den Spaltennamen:
new Key1List.Builder<Column,String>.withKey1Map(Column::getName).build()
Für die definierten Keys stehen im Wesentlichen dieselben Möglichkeiten zur Verfügung wie für das Element Set. So definiert z. B. die Methode withKey1Null einen Constraint auf dem definierten Key, wie withElemNull einen Constraint auf dem Element selbst definiert hat – eine Liste mit Spaltenelementen mit einzigartigen Namen und Zugriff über den Spaltennamen:
new Key1List.Builder<Column,String>.withKey1Map(Column::getName).withKey1Null(false).withKey1Duplicates(false).build()
Da das Definieren eines solchen Primary Keys eine häufige Anforderung ist, gibt es als Shortcut die Methoden withPrimaryKey1 und withUniqueKey1, die Nullwerte und Duplikate in einem Aufruf definieren. Damit vereinfacht sich auch die Definition der Liste – eine Liste mit Spaltenelementen mit einzigartigen Namen und Zugriff über den Spaltennamen:
new Key1List.Builder<Column,String>().withKey1Map(Column::getName).withPrimaryKey1().build()
Während wir bisher nur die Funktionen zum Definieren der Key Collections untersucht haben, stellen wir nun noch die Funktionen zum Arbeiten mit den erstellten Datenstrukturen vor.
Für den Zugriff auf das Element Set können neben der Standardfunktion contains die neuen Methoden getAll, getCount, getDistinct und removeAll genutzt werden.
Für das Arbeiten mit Key Maps gibt es die Methoden containsKey, getByKey, getAllByKey, getCountByKey, getDistinctKeys, removeByKey, removeAllByKey und indexOfKey (nur für Listen).
Ebenfalls kann mithilfe der Methoden asSet und asMap über die JDK-Standardinterfaces auf Element Set und Key Maps zugegriffen werden.
Dadurch, dass sich die aufgezeigten Möglichkeiten beliebig kombinieren lassen, kann man mit den Key Collections maßgeschneiderte Collections für alle Anwendungsfälle erzeugen. Listing 2 enthält weitere Beispiele. Unter anderem wird gezeigt, wie man mit wenigen Codezeilen eine BiMap erstellen kann, also eine Map, wo man sowohl effizient über den Key auf den Wert als auch umgekehrt über den Wert auf den Key zugreifen kann. Weitere Beispiele finden sich auch in der Dokumentation der Library [4].
Es gibt einen Spezialfall, der nicht orthogonal auf alle Key Collections angewendet werden kann, sondern nur von KeyCollection unterstützt wird. Es handelt sich dabei um die Möglichkeit, ein Multiset zu realisieren, in dem identische Elemente nur einmal zusammen mit einem Zähler für ihr Vorkommen gespeichert werden – ein Multiset von Strings, in dem für jeden String die Häufigkeit gespeichert wird:
new KeyCollection.Builder<String>.withElemCount().build()
Listing 2
// Eine nach Namen sortierte File-List mit Namen als // Primary Key
Key1List<File,String> coll1 =
new Key1List.Builder<File,String>().
withKey1Map(File::getName).
withPrimaryKey1().withKey1OrderBy(true).build();
// Eine Collection mit 2 Keys, wobei 1 Key optional // ist
Key2Collection<Ticket,String,String> coll2 =
new Key2Collection.Builder<Ticket,String,String>().
withKey1Map(Ticket::getId).withPrimaryKey1().
withKey2Map(Ticket::getExtId).withUniqueKey2().build();
// Eine BiMap, welche Zip-Codes verwaltet, wobei // alle Key Maps sortiert sind
class Zip {
int code;
String city;
Zip(int code, String city) {
this.code = code;
this.city = city;
}
int getCode() {
return code;
}
String getCity() {
return city;
}
}
void useBiMap() {
Key2Collection<Zip, Integer, String> zips =
new Key2Collection.Builder<Zip, Integer, String>().
withKey1Map(Zip::getCode).withKey1Sort(true).
withKey2Map(Zip::getCity).withKey2Sort(true).build();
zips.add(new Zip(1000, “city1000”));
String city = zips.getByKey1(1000).getCity();
int code = zips.getByKey2(“city1000”).getCode();
}
Wir wollen auch noch einen Blick hinter die Kulissen werfen und sehen, wie die Key Collections die vorgestellte Funktionalität implementieren. Vielleicht erinnern wir uns noch daran, wie wir bei der Vorstellung des ersten Beispiels erwähnt haben, dass man für eine skalierende Lösung eine List mit einer Map synchronisieren muss – genau das tun die Key Collections bei Bedarf für uns.
Abbildung 1 zeigt, wie eine Key1List je nach Anforderung, ob die Liste sortiert sein soll oder nicht, aus unterschiedlichen Collection-Klassen zusammengestellt wird, die die gewünschte Funktionalität effizient implementieren. Key Maps und Element Set werden typischerweise mit HashMap/TreeMap realisiert, bei sortierten Listen wird auch die Key Map, die die Sortierreihenfolge vorgibt, als List gespeichert. Die Elemente der Liste selbst werden per Default in einer GapList gespeichert, bei Bedarf kann aber, wie gesehen, auch eine BigList oder eine primitive Wrapper-Klasse wie IntObjGapList oder IntObjBigList verwendet werden.
Die Key Collections erweitern die Möglichkeiten der Java Collections auf eine faszinierende Art und Weise. Die einfache Definition von mächtigen Datenstrukturen, denen die gewünschte Funktionalität deklarativ zur Laufzeit zugewiesen werden kann, erlaubt ein präzises Abbilden des Datenmodells.
Die Brownies Collections Library erhöht damit die Effizienz beim Entwickeln und ermöglicht dem Entwickler mit geringem Programmieraufwand, Applikationen zu erstellen, die in jeder Situation effizient bezüglich Performance und Speicherplatzverbrauch sind und dadurch gut skalieren.
Thomas Mauch arbeitet als Softwarearchitekt bei Swisslog in Buchs, Schweiz. Seine Schwerpunkte liegen im Bereich Java und Datenbanken. Er interessiert sich seit den Zeiten des C64 für alle Aspekte der Softwareentwicklung.
Die automatisierte Ausführung von Testläufen ist ein wichtiger Schritt im Softwareentwicklungsprozess, um zu gewährleisten, dass Probleme und Auffälligkeiten bei Codeänderungen schnell erkannt werden.
Nachdem wir im ersten Teil des Artikels hauptsächlich auf die Aufzeichnungen von Tests mit Selenium Builder eingegangen sind, legen wir in diesem Teil den Schwerpunkt auf das Ausführen der Tests. Die bisher gezeigte Möglichkeit des Abspielens von Tests über das Selenium-Builder-Add-on im Firefox ist auf einen Browser beschränkt und muss aktiv durch den Anwender erfolgen. Für eine automatisierte Testdurchführung ist Selenium Builder deshalb eher ungeeignet. Des Weiteren sorgen Browserinkompatibilitäten immer wieder für Probleme, die nur durch Testläufe mit verschiedenen Browsern gefunden werden können.
Die Firma Sauce Labs [1], die wesentlich Selenium Builder entwickelt, bietet beispielsweise über ihre Cloud-Lösung eine entsprechende Infrastruktur an, die die Anforderungen der Automatisierung und des Cross-Browser-Testings erfüllt. Diese Lösung eignet sich aus rechtlichen Gründen für viele Projekte jedoch nicht, da die zu testenden Ressourcen über das Internet erreichbar sein müssen.
Mit dem Selenium Interpreter [2] steht eine freie Java-Bibliothek zur Verfügung, mit deren Hilfe aufgezeichnete Selenium-Builder-Skripte abgespielt werden können. Das Ausführen der Testsuite für die im ersten Teil des Artikels vorgestellten Mitteilungen kann mit dem folgenden Befehl auf der Kommandozeile erfolgen:
java -jar SeInterpreter.jar createNewsSuite.json
Es wird standardmäßig nur ausgegeben, ob die Ausführung erfolgreich verlief. Für detailliertere Informationen zu den abgearbeiteten Schritten muss der Loglevel auf DEBUG umgestellt werden.
Die Bibliothek bietet aktuell die Möglichkeit zur Nutzung von Firefox sowie des Remote-Drivers zum Abspielen der Tests. Mit Webdriver als Basis lassen sich jedoch mit ein wenig Eigenarbeit einfach weitere Browser unterstützen. Hierfür muss die Klasse WebDriverFactory implementiert werden. Zur Verwendung von Chrome ist die Umsetzung wie in Listing 1 gezeigt denkbar einfach. Über Webdriver lässt sich ebenfalls PhantomJS [3] einbinden. PhantomJS ist ein Browser ohne Oberfläche, der auf WebKit basiert und sich damit hervorragend zur Ausführung von Tests auf Serverumgebungen eignet. Die Implementierung der WebDriverFactory erfolgt analog zu dem vorgestellten Beispiel mit Chrome.
Listing 1
package com.sebuilder.interpreter.webdriverfactory;
import java.util.HashMap;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.remote.DesiredCapabilities;
import org.openqa.selenium.remote.RemoteWebDriver;
public class Chrome implements WebDriverFactory {
/**
* @param config Key/value pairs treated as required capabilities.
* @return A ChromeDriver.
*/
@Override
public RemoteWebDriver make(HashMap<String, String> config) {
return new ChromeDriver(DesiredCapabilities.chrome());
}
}
Mit dem Selenium-Interpreter-Parameter driver wird definiert, mit welchem Browser die Ausführung des Tests erfolgt. Listing 2 zeigt den Ausschnitt eines Gradle-Skripts, das Testausführungen mit mehreren Browsern ermöglicht, die nacheinander ablaufen. Hierfür werden zunächst die Abhängigkeiten zu den verschiedenen WebDriver-Implementierungen definiert. Für die beiden genannten Browserimplementierungen Chrome und PhantomJS gibt es jeweils eine eigene Gradle-Task, die mit Umgebungsvariablen den Pfad zu den Treibern festlegt. Anschließend erfolgt die Testdurchführung durch Starten des Selenium Interpreters. Die Gradle-Task runWithAllDrivers sorgt für den aufeinanderfolgenden Aufruf aller Browserimplementierungen.
Listing 2
...
dependencies {
compile('com.saucelabs:sebuilder-interpreter:1.0.6')
compile "org.seleniumhq.selenium:selenium-firefox-driver:$seleniumVersion"
compile "org.seleniumhq.selenium:selenium-chrome-driver:$seleniumVersion"
compile("com.codeborne:phantomjsdriver:1.2.1") {
transitive = false
}
}
task runWithAllDrivers {
// The drivers we want to use
def drivers = ["Firefox", "Chrome", "PhantomJs"]
dependsOn drivers.collect { driver -> "runSeInterpreterWith${driver}" }
}
task(runSeInterpreterWithPhantomJs, dependsOn: ['unzipPhantomJs', 'classes'], type: JavaExec) {
def phantomJsFilename = Os.isFamily(Os.FAMILY_WINDOWS) ? "phantomjs.exe" : "bin/phantomjs"
systemProperty "phantomjs.binary.path",
new File(unzipPhantomJs.outputs.files.singleFile, phantomJsFilename).absolutePath
main = 'com.sebuilder.interpreter.SeInterpreter'
classpath = sourceSets.main.runtimeClasspath
args "src/main/resources/createNewsSuite.json"
args "--driver=PhantomJs"
}
task(runSeInterpreterWithChrome, dependsOn: ['unzipChromeDriver', 'classes'], type: JavaExec) {
def chromedriverFilename = Os.isFamily(Os.FAMILY_WINDOWS) ? "chromedriver.exe" : "chromedriver"
systemProperty "webdriver.chrome.driver",
new File(unzipChromeDriver.outputs.files.singleFile, chromedriverFilename).absolutePath
main = 'com.sebuilder.interpreter.SeInterpreter'
classpath = sourceSets.main.runtimeClasspath
args "src/main/resources/createNewsSuite.json"
args "--driver=Chrome"
}
...
Mit wenigen Erweiterungen kann damit für den Selenium Interpreter eine Cross-Browser-Umgebung zum automatisierten Abspielen von aufgezeichneten Selenium-Builder-Tests geschafft werden. Das vollständige Gradle-Projekt für das gezeigte Beispiel ist auf GitHub [4] verfügbar.
Mit Selenium-Grid lässt sich ein dynamischer Zusammenschluss aus Computerknoten einrichten, die Selenium-Testfälle von einem Hub entgegennehmen und abarbeiten. Es lassen sich Testläufe auf unterschiedlichen Maschinen mit verschiedenen Browsern parallel ausführen. Für den Einsatz von Selenium-Grid [5] spricht zum einen die Zeitersparnis durch parallele Abarbeitung der Testläufe. Zum anderen können Testfälle mit mehreren Browsern auf verschiedenen Betriebssystemen ausgeführt werden.
Die Anwendung steht in der Standalone-Variante als JAR-Datei zum Download bereit [6]. Im Selenium-Grid gibt es die Rollen Hub und Node, die beim Kommandoaufruf vergeben werden. Ein Hub steuert jeweils ein Grid mit beliebig vielen Nodes. Der Hub hat dabei folgende Aufgaben:
Verwaltung der angemeldeten Nodes sowie deren verfügbare Fähigkeiten, auch Capabilities genannt (Browsertyp, Version, Anzahl Instanzen, Protokoll)
Entgegennehmen und Verteilen von Testläufen anhand eines CapabilityMatchers und der am Grid angemeldeten Nodes
Verwaltung der lokal eingerichteten Browser, wobei das bereits vorgestellte PhantomJS oder auch HtmlUnit eingesetzt werden kann
Nodes können sich dynamisch am Grid anmelden. Sie übermitteln bei der Anmeldung an einen Hub die Fähigkeiten, die sie zur Verfügung stellen. Ein Node muss mindestens eine und kann beliebig viele Capabilities bereitstellen. Sie bestehen aus folgenden Attributen (siehe auch [6] und [7]):
browserName: Pflichtattribut, das den Namen des Browsers angibt, der im Parameter binary referenziert wird. Der angegebene Name kann später beim Aufruf des Selenium Interpreters oder im Selenium Builder verwendet werden und muss zum eingesetzten CapabilityMatcher passen. Folgende Namen können vom Standard-CapabilityMatcher interpretiert werden:
android
chrome
firefox
htmlunit
iexplorer
iphone
opera
Version: Optionales Attribut für die Version des Browsers. Bei Angabe sollte die evtl. vorhandene Autoupdatefunktion des Browsers deaktiviert werden. Die angegebene Versionsnummer ist unabhängig von der tatsächlich in binary referenzierten ausführbaren Datei.
platform: Dieses optionale Attribut beschreibt, welche Betriebssystemarchitektur der Node verwendet. Zur Auswahl stehen Windows, Linux und Mac.
maxInstances: Definiert die maximale parallele Anzahl an Instanzen für den angegebenen Browser. Bei Verwendung älterer Versionen des Internet Explorers ist der Wert 1 empfehlenswert, um Probleme beim synchronen Zugriff zu vermeiden. Um die maximale Anzahl gleichzeitig laufender Sessions auf dem Node einzuschränken, steht das globale Attribut maxSession zur Verfügung.
seleniumProtocol: Gibt das Protokoll an, das zwischen Node und Hub für die Übermittlung und Bearbeitung des Testfalls verwendet wird. Folgende Protokolle können verwendet werden:
Selenium (bei Verwendung von Selenium-1-Skripten)
WebDriver (Standard; bei Verwendung von Selenium-2-Skripten)
binary (teilweise auch firefox_binary bzw. chrome_binary): Definiert den Pfad zur ausführbaren Datei des Browsers.
Ein vom Selenium-Grid entgegengenommener Auftrag besteht aus dem Skript mit dem Testfall, also beispielsweise einem JSON-Skript mit Selenium-Code oder einer Testsuite. Außerdem wird der URL und der Port des Hubs benötigt, der den Auftrag entgegennehmen soll. Die Angaben bezüglich der Eigenschaften des Zielhosts, also Browsername, Browserversion und Architektur, sind dagegen freiwillig.
Die Einrichtung des Selenium-Grids setzt neben der Java-Laufzeitumgebung die Installation des Selenium-Standalone-Servers [6] voraus. Das JAR-Archiv enthält bereits alle benötigten Komponenten zum sofortigen Start. Ein funktionsfähiges Grid benötigt immer einen Hub. Entweder es wird zusätzlich mindestens ein Node definiert oder auf dem Hub muss wenigstens ein lokaler Browser konfiguriert sein. Hub und Node können auf einem einzigen Host gestartet werden, in der Regel laufen Hub und Node jedoch auf unterschiedlichen Hosts. Die Kommunikation innerhalb des Grids, die dann über das Netzwerk stattfindet, kann über die Parameter port und hubPort des Selenium-Servers beeinflusst werden. Standardmäßig lauscht der Hub an Port 4444. Nodes öffnen standardmäßig lokal Port 5555 für die Verbindung zum Hub.
Hub und Nodes lassen sich entweder in der Kommandozeile über Parameter definieren oder mittels Konfigurationsdatei im JSON-Format. Listing 3 zeigt die Datei hubconfig.json, die einen Hub definiert. Der zugehörige Kommandozeilenaufruf lautet:
java -jar selenium-server-standalone.jar -role hub -hubConfig hubconfig.json
In Listing 4 wird die Konfigurationsdatei nodeconfig.json gezeigt, die einen Node definiert, der maximal zwei Instanzen von Firefox Version 35 und maximal eine Instanz von Internet Explorer 11 anbietet. Außerdem arbeitet der Node unter einer Windows-Architektur, erlaubt maximal zwei gleichzeitige Sessions und nimmt nur Testfälle mit dem Selenium-2-Protokoll entgegen. Der zugehörige Kommandozeilenaufruf lautet:
java -jar selenium-server-standalone.jar -role node -nodeConfig nodeconfig.json
Listing 3: hubconfig.json
{
"host": null,
"port": 4444,
"newSessionWaitTimeout": -1,
"servlets" : [],
"prioritizer": null,
"capabilityMatcher":"org.openqa.grid.internal.utils.DefaultCapabilityMatcher",
"throwOnCapabilityNotPresent": true,
"nodePolling": 5000,
"cleanUpCycle": 5000,
"timeout": 300000,
"browserTimeout": 0,
"maxSession": 5,
"jettyMaxThreads":-1
}
Listing 4: nodeconfig.json
{
"capabilities":
[
{
"browserName": "firefox",
"version": "35",
"platform": "WINDOWS",
"maxInstances": 2,
"seleniumProtocol": "WebDriver",
"binary": "c:\Program Files (x86)\Mozilla Firefox\firefox.exe"
} ,
{
"browserName": "iexplorer",
"version": "11",
"platform": "WINDOWS",
"maxInstances": 1,
"seleniumProtocol": "WebDriver"
}
],
"configuration":
{
"nodeTimeout":240,
"nodePolling":2000,
"maxSession": 2,
"timeout":30000,
"port": 5555,
"host": mynode,
"register": true,
"registerCycle": 5000,
"cleanUpCycle":2000,
"hubPort": 4444,
"hubHost": myhub
}
}
Wurden Hub und Node gestartet, steht das Grid zur Verfügung und ist bereit, Testläufe entgegenzunehmen. Zur manuellen Überprüfung der Funktionsfähigkeit des Grids oder um zu überprüfen, ob sich Testfälle auf anderen Browsern ausführen lassen, bietet sich der Selenium Builder an. Dazu wählt man im Menü Run entweder den Eintrag Run on Selenium Server (für ein einzelnes JSON-Skript) oder Run suite on Selenium Server (für eine Suite von JSON-Skripten). Im nachfolgenden Dialog gibt man die Parameter wie oben definiert ein (Abb. 1).
Für die automatisierte Ausführung bietet sich die schon vorgestellte Bibliothek Selenium Interpreter an. Die Kommandozeile für das Ausführen der Testsuite aus Teil 1 des Artikels sieht damit wie folgt aus:
java -jar SeInterpreter.jar --driver=Remote --driver.browserName='iexplorer' --driver.version=11 --driver.url=http://myhub:4444/wd/hub/ createNewsSuite.json
Wird der Continuous-Integration-Server Jenkins [8] eingesetzt, bietet sich das Selenium-Plug-in [9] an, das den CI-Server auch zu einem Hub eines Selenium-Grids macht. Bei entsprechender Konfiguration verwandeln sich die Jenkins-Slaves nach dem Deployment des Plug-ins umgehend in Selenium-Knoten. Die Nodes lassen sich ebenfalls, wie oben beschrieben, manuell starten und dynamisch zu diesem Selenium Grid hinzufügen. In unserem Szenario verwenden wir als Nodes vordefinierte virtuelle Maschinen, z. B. von modern.IE [10], die per Autostart obige Kommandozeile ausführen und sich auf diese Weise beim Hub registrieren. Abbildung 2 zeigt das Hub Management des Selenium-Grids im Jenkins-Server. Am Grid hat sich ein Node registriert, der die Konfiguration aus Listing 4 nutzt.
Die Möglichkeiten, in Jenkins einen Selenium-Testfall automatisch zu starten, sind sehr umfangreich. Mit dem Build-Flow-Plug-in [11] kann beispielsweise ein Projekt definiert werden, bei dem die Applikation zuerst gebaut und gestartet wird und anschließend die Testfälle ausgeführt werden. In unserem Szenario gehen wir davon aus, dass die zu testende Applikation bereits gestartet wurde. Im Jenkins-Projekt müssen deshalb nur Selenium-Testskripte ausgeführt werden. Die Steuerung der Tests, also welches Selenium-Skript verwendet wird und mit welchem Browser es getestet wird, muss dabei über Parameter definiert werden. Dazu setzt man beim Anlegen des neuen Projekts mit dem Namen SeleniumTest vom Typ Free Style in Jenkins die Option Dieser Build ist parametrisiert. Als Parameter definiert man dort z. B. die vier Textparameter browser, version, hub und script, ggf. mit Vorgabewerten. In Abbildung 3 ist die Konfiguration in Jenkins für den Parameter browser mit dem Vorgabewert firefox zu sehen.
Als Build-Verfahren wählt man Shell ausführen und hinterlegt die obige Kommandozeile in angepasster Form:
java -jar seInterpreter/SeInterpreter.jar --driver=Remote --driver.url=$hub --driver.browserName=$browser --driver.version=$version $script
Mit dieser Konfiguration lassen sich, dynamisch anpassbar an die erforderliche Umgebung, unterschiedliche Selenium-Testskripte automatisch von Jenkins starten. Dazu muss in Jenkins ein Projekt vom Typ Build Flow angelegt werden. Der Flow in der Domain-specific Language (DSL) des schon angesprochenen Build-Flow-Plug-ins kann Listing 5 entnommen werden. Er startet das zuvor erstellte Projekt SeleniumTest drei Mal parallel, jeweils mit den angegebenen Parametern der Selenium Nodes.
Listing 5: Jenkins Build Flow
parallel (
{ build("SeleniumTest", browser: "iexplorer", version: "8", hub: "http://myhub:4444/wd/hub", script: "createNewsSuite.json") },
{ build("SeleniumTest", browser: "iexplorer", version: "9", hub: "http://myhub:4444/wd/hub", script: "createNewsSuite.json") },
{ build("SeleniumTest", browser: "iexplorer", version: "10", hub: "http://myhub:4444/wd/hub", script: "createNewsSuite.json") }
)
Detaillierte Informationen über den Verlauf der Tests in Jenkins sind wichtig. Ein gutes Reporting benötigt aber etwas Vorarbeit. So sollten relevante Schritte des Testfalls im JSON-Skript von einem print-Kommando begleitet werden, das den Vorgang kommentiert. Die Ausgaben dieses Kommandos finden sich anschließend im Build-Log von Jenkins. Mit dem Kommando saveScreenshot erstellte Bildschirmabzüge kann man von Jenkins automatisch als Artefakte ablegen lassen. Das Image Gallery Plug-in [12] erstellt daraus eine Galerie. Um Fehler schnell erkennen zu können, bietet sich der Einsatz des Build-Failure-Analyzer-Plug-ins [13] an, dessen Fehlerdatenbank allerdings konsequent gepflegt werden muss. In Abbildung 4 sind zwei Builds dargestellt. Beim erfolgreichen Build ist die Galerie zu sehen, beim anderen wird die Ausgabe eines Fehlers angezeigt.
Mit dem vorgestellten Szenario lassen sich funktionale Tests im JSON-Format von Selenium automatisiert über einen CI-Server anstoßen und auf unterschiedlichen Browsern ausführen. Dabei wird sowohl von der schnellen und einfachen Aufzeichnung der Testfälle mit Selenium Builder als auch von den umfangreichen Funktionen von Jenkins profitiert. Durch den geschickten Aufbau der Projekte im Build-Server und die verwendeten Plug-ins erhält man eine Umgebung, mit der effektiv funktionale Tests parallel in unterschiedlichen Browsern ausgeführt werden können. Trotz der Komplexität des Szenarios können Änderungen an den Selenium-Tests einfach über den Builder vorgenommen werden.
Für ein gutes Reporting, vor allem im Fehlerfall, ist allerdings etwas Handarbeit erforderlich. Eine Lösung könnte die Erweiterung der Logging-Funktion des Selenium Interpreters sein.
Roland Rickborn ist IT-Berater bei der exensio GmbH in Karlsruhe. Als Consultant arbeitet er in Kundenprojekten im Bereich fachliche Konzeption, Projektleitung und automatisierte Tests. Aktuell betreut er die Weiterentwicklung und Optimierung von Intranetportalen bei einem großen Kunden aus der Pharmabranche.
Tobias Kraft beschäftigt sich bei der exensio GmbH mit der Architektur und Umsetzung von Enterprise-Portalen, Webapplikationen und Suchtechnologien basierend auf dem Java-Stack sowie dem Grails-Framework. Des Weiteren ist er Mitorganisator des Search Meetup Karlsruhe.
[1] Sauce Labs: https://saucelabs.com
[2] Selenium Interpreter: https://github.com/sebuilder/se-builder/wiki/Se-Interpreter
[3] PhantomJS: http://phantomjs.org
[4] Selenium-Interpreter-Erweiterung: https://github.com/tobiaskraft/SeInterpreterGradle
[5] Selenium Grid: http://docs.seleniumhq.org/docs/07_selenium_grid.jsp
[6] Selenium Grid: https://code.google.com/p/selenium/wiki/Grid2
[7] Selenium Grid Properties: https://code.google.com/p/selenium/source/browse/java/server/src/org/openqa/grid/common/defaults/GridParameters.properties
[8] CI-Server Jenkins: http://jenkins-ci.org
[9] Selenium-Plug-in: https://wiki.jenkins-ci.org/display/JENKINS/Selenium+Plugin
[10] modern.IE: https://www.modern.ie
[11] Build-Flow-Plug-in: https://wiki.jenkins-ci.org/display/JENKINS/Build+Flow+Plugin
[12] Image-Gallery-Plug-in: https://wiki.jenkins-ci.org/display/JENKINS/Image+Gallery+Plugin
[13] Build-Failure-Analyzer-Plug-in: https://wiki.jenkins-ci.org/display/JENKINS/Build+Failure+Analyzer
Not only SQL, kurz NoSQL, ist eine noch relativ junge Disziplin der Informatik, die sich mit Datenspeicherungstechnologien beschäftigt. NoSQL befindet sich in beständigem Wandel, was es schwer macht, eine strenge Definition zu geben. In dieser dreiteiligen Artikelserie wollen wir versuchen, die bisherige Entwicklung in groben Zügen nachzuvollziehen. Wir beginnen mit einem Rückblick auf die relationalen Datenbanksysteme.
NoSQL ist momentan in aller Munde und erfährt eine Namensgebung und -prägung, die es sehr schwer macht, eine strenge Definition dafür zu geben, was sich genau hinter diesem Akronym verbirgt. Das ursprünglich erklärte Ziel von NoSQL war die Entwicklung von so genannten Web-Scale-Datenbanken [1], jedoch gibt es viele verschiedene Auffassungen und Meinungen über das, was unter NoSQL zu verstehen ist.
Wir wollen die Entstehung von NoSQL näher beleuchten und beginnen unsere dreiteilige Serie über NoSQL mit einem für das Verständnis notwendigen Rückblick auf die relationalen Datenbanken und die Probleme, die das relationale Datenmodell zu lösen versuchte. Im nächsten Artikel werden wir uns dann intensiver den NoSQL-Datenbanken selbst widmen, um im dritten Teil neue Trends zu prognostizieren und zu diskutieren.
Die Evolution der Speicherungstechnologien seit den 1970er-Jahren machen wir an drei Arbeiten fest, die uns durch unsere Serie begleiten werden.
Der Ausgangspunkt ist Codds Arbeit zu relationalen Datenbanksystemen [2] aus dem Jahr 1970, in der die Grundsteine des relationalen Datenbankmodells gelegt werden. Den Konsequenzen der zunehmenden Verteilung von Daten trägt der Beweis der Brewer’schen Vermutung Rechnung, der 2002 durch Gilbert und Lynch präsentiert wurde. Er ist als CAP-Theorem bekannt und stellt, streng interpretiert, ein „Wähle zwei aus drei“-Ergebnis dar, mit gewichtigen Implikationen für die verteilte Datenhaltung. Die dritte Arbeit ist die 2005 verfasste Absage an relationale Datenbanken als Universallösungen durch Stonebraker und Çetintemel [3]. Sie versetzte dem Vorgehen, für jedes Problem eine relationale Datenbank zu verwenden, den etablierten Todesstoß.
Die in den 1960er-Jahren vorherrschenden Datenspeichertechnologien waren Systeme wie IBMs IMS oder IDS von General Electric, in denen Daten hierarchisch oder in Netzwerken organisiert sind. Codd formulierte drei wesentliche Kritikpunkte [2]:
Eine Applikation hängt direkt davon ab, wie die Daten auf dem Persistenzspeicher angeordnet sind (Ordering Dependence).
Eine Anfrage an ein Datenbanksystem hängt direkt davon ab, welche Indexstrukturen definiert sind (Indexing Dependence).
Eine Applikation hängt direkt davon ab, über welche Zugriffspfade die auf dem Speichersystem persistierten Daten untereinander vernetzt sind (Access Path Dependence).
Diese Abhängigkeiten erlegen dem Anwendungsentwickler Bürden auf. Er muss wissen, wie die Daten auf dem Speichersystem abgelegt sind, ob Indizes existieren, wie diese benannt sind und wie von einem Datensatz zu einem anderen zu navigieren ist, um die für seine Anwendung relevanten Informationen zu finden. Jede Änderung an der Anordnung der Daten, den Indexstrukturen oder den Zugriffspfaden kann dazu führen, dass eine Applikation nicht mehr wie erwartet funktioniert oder gar vollständig auseinanderbricht.
Zur Verdeutlichung der Abhängigkeiten nehmen wir an, wir hätten eine Anwendung für einen Möbelhandel zu schreiben, der seine Möbelstücke in Einzelteilen zum eigenständigen Zusammenbauen ausliefert. Dazu benötigt er ein Verwaltungssystem, in dem festgehalten ist, wie viele Teile welcher Art vorhanden sowie bestellt sind und wie viele Teile für den Zusammenbau eines bestimmten Möbelstücks benötigt werden.
Die vielleicht naheliegendste Modellierung ist, die Möbelstücke den Teilen strukturell unterzuordnen und die Beziehung hierarchisch in einer Datei abzulegen (Abb. 1).
Eine zweite Möglichkeit geht umgekehrt an den Sachverhalt heran. Die Teile werden den Möbelstücken untergeordnet, für die sie gebraucht werden. Diese hierarchische Beziehung wird wieder in einer einzigen Datei abgelegt (Abb. 2).
Eine dritte Möglichkeit betrachtet Möbelstücke und Teile einander gleichgestellt und speichert sie auch in verschiedenen Dateien. Die Beziehungen zwischen den Möbelstücken und den Teilen wird über eine zusätzliche Struktur modelliert, die Möbelstücke und Teile verknüpft (Abb. 3).
Weitere Möglichkeiten der Datenhaltung sind denkbar, eine ausführlichere Diskussion kann bei Codd [2] nachgelesen werden. Im Allgemeinen ist es für eine Anwendung nicht realistisch zu prüfen, welche der möglichen Darstellungen gewählt worden ist. Der Anwendungsentwickler muss die gewählte Speicherart wissen, um Bestandsabfragen und -pflege implementieren zu können. Im Falle einer Änderung müssen alle Anwendungsprogramme, die sich auf die alten Strukturen verlassen, nachgepflegt und neu ausgerollt werden.
Das relationale Datenmodell wie es Codd vorschlägt [4], bietet einen möglichen Ausweg aus dieser Bredouille. Es schlägt die Separation von Datenmodell und Datenspeicherung vor, die Trennung in ein logisches und ein physikalisches Datenmodell.
Basierend auf der mathematischen Mengentheorie werden Daten als Mengen geordneter Tupel, so genannte Relationen, beschrieben. Eine wichtige Bedingung ist, dass die zugrunde liegenden Mengen nur atomare Wertebereiche haben dürfen, die Tupel sind also flach. Die Struktur der Tupel ist ein Datenbankschema, eine Menge von Relationen ist eine Datenbankinstanz. Häufig wird von Tabellen statt von Relationen gesprochen. Die Tupel sind deren Zeilen, die Namen der Spalten heißen Attribute.
Die gespeicherten Daten werden mittels Anfragesprachen, die gegen das Datenbankschema formuliert werden, wieder aus der Datenbank herausgeholt. Codd schlug für das relationale Datenmodell die relationale Algebra als Anfragesprache vor, deren Operatoren Relationen als Eingabe erhalten und neue Relationen als Ergebnis erzeugen.
Die Structured Query Language, SQL, ist heute die am weitesten verbreitete Anfragesprache für relationale Datenbanksysteme. Als ein Nachfolger der Sprache SEQUEL [6] beruhen die Implementierungen von SQL auf der relationalen Algebra.
Allerdings hat fast jede relationale Datenbank einen eigenen SQL-Dialekt umgesetzt, was die Austauschbarkeit des Datenbanksystems erschwert oder sogar verhindert. Auch die Unterstützung von Erweiterungen oder NF2-Eigenschaften ist weder überall vorhanden noch standardisiert. Die Datenbank PostgreSQL [7] beispielsweise erlaubt das direkte Auswerten von in Tupeln gespeicherten JSON-Dokumenten.
Der innere Zusammenhang zwischen den gespeicherten Daten wird durch referenzielle Integrität beschrieben. Hierzu werden Primärschlüssel und Fremdschlüsselbeziehungen definiert, die Eindeutigkeit von Werten in Attributen oder die Gültigkeit anderer funktionaler Abhängigkeiten gefordert.
Transaktionen sind Gruppierungen von Operationen in der Datenbank, die dafür sorgen, dass die referenzielle Integrität im Betrieb erhalten bleibt. Kernkonzept ist das ACID-Paradigma: Eine Transaktion
wird entweder vollständig oder gar nicht ausgeführt (Atomicity)
überführt die Datenbank von einem konsistenten Zustand in einen weiteren konsistenten Zustand (Consistency)
sieht die Datenbank so, als wäre sie die einzige momentan laufende Transaktion (Isolation)
hat nach ihrem Beenden alle ihre Änderungen dauerhaft gespeichert (Durability)
Die Operationen einer Transaktionen werden also
vollständig ausgeführt (Commit),
oder gar nicht (Rollback).
Inkonsistente Zwischenzustände sind vorübergehend erlaubt, diese sehen andere Transaktionen aber nicht. Um die ACID-Eigenschaften zu gewährleisten, werden in der Regel sperrende Verfahren (Locks) und blockierende Protokolle wie ein Zwei-Phasen-Commit-Protokoll verwendet. Die Granularität der Sperren kann variieren. In vielen relationalen Datenbanksystemen ist der Konsistenzlevel in gewissem Rahmen veränderbar.
Anfragen werden vom Datenbanksystem transparent in Zugriffspfade auf dem Persistenzspeicher übersetzt. Indexstrukturen werden neben den eigentlichen Daten gepflegt, ihre Existenz hat keinen Einfluss auf den Informationsgehalt der gespeicherten Daten. Auch muss der Anwendungsentwickler von ihnen keine Kenntnis haben, um Anfragen formulieren zu können, da in Anfragen keine Indizes auftauchen.
Dem Datenbanksystem obliegt es, die Indizes auch tatsächlich zu nutzen. Ein Index kann die Performance einer Anfrage stark verbessern, ohne dass diese selbst angepasst werden muss. Das macht Anfragen robust gegen Änderungen der Indexstrukturen. Anfragen und Indizes können unabhängig voneinander erstellt und gepflegt werden.
In der Theorie liefern die oben diskutierten Konzepte die Basis für ein universell-aussagekräftiges Datenmodellierungs- und Datenspeicherwerkzeug mit standardisierter Anfragesprache. So wird es möglich, Daten dauerhaft zu speichern und wieder auszulesen und sich gleichzeitig über den Zustand der Daten jederzeit sicher sein zu können. Abbildung 4 zeigt ein normalisiertes relationales Schema zum eingangs diskutierten Möbelhandelbeispiel.
Die Praxis sieht, wie so oft, anders aus. Die Erfahrung hat hier gezeigt, dass Schemata im Nachhinein häufig denormalisiert werden müssen, um den Anforderungen an das Anfrageverhalten zu genügen. Auf die Normalisierung wird von daher, oft bewusst, von vorne herein verzichtet, um Performanceproblemen vorzubeugen. Durch Redundanz hervorgerufene Anomalien werden dabei wissentlich in Kauf genommen und deren Behandlung in die Anwendung verlegt.
Performanceeinbußen können daher rühren, dass das Datenbanksystem die Zugriffspfade der Anfragen zwar vor dem Entwickler versteckt, die durch die Normalisierung hervorgerufene Verteilung der Daten aber dennoch durchscheinen und sich negativ bemerkbar machen kann. (Das Phänomen, dass Eigenschaften durch alle Abstraktionsschichten durchscheinen können, hat Spolsky bereits vor mehr als zehn Jahren treffend beschrieben [9].)
Schauen wir wieder auf unser Beispiel von oben. Es ist wahrscheinlich, dass typische Anfragen zu Möbelstücken auch Informationen zu den Teilen, aus denen sie zusammengebaut werden, beinhalten. Dies legt es nahe, einen Teil der Daten redundant vorzuhalten, um das Joinen der Daten zu vermeiden. In der Anwendung zur Pflege der Daten muss jetzt allerdings darauf geachtet werden, Änderungen der Eigenschaften eines Teils in unterschiedlichen Tabellen zu pflegen, um eine konsistente Repräsentation der Daten zu gewährleisten (Abb. 5).
Die relationalen Datenbanken haben seit den 1970er-Jahren einen fulminanten Siegeszug als Universaldatenspeicher angetreten. Die Verwendung eines Datenspeichers ist oft eine fast nicht funktionale Anforderung: In vielen Unternehmen stand (und steht) ein, gegebenenfalls teuer betriebenes, relationales Datenbanksystem als universeller Speicher für die Daten einer Vielzahl von Anwendungen. Dies birgt nicht nur die Gefahr, sich an einen Hersteller zu binden (Vendor Lock-in) und abhängig von dessen Betriebserfahrung und Supportleistungen zu werden, es hängen damit auch viele Applikationen von den Wartungszyklen der Datenbank ab. Verfügbarkeit, Ausfallsicherheit und Datensicherheit des zentralen Mollochs müssen kostspielig gewährleistet werden.
Die Hersteller relationaler Datenbanksysteme haben sich diese Situation zunutze gemacht und versuchen jedes Problem relational erscheinen zu lassen. Oft wurden (und werden) bei den Anstrengungen, jedes Problem in eine relationale Struktur zu pressen, große Geldsummen in Unternehmen verschwendet.
ACID bietet dem Entwickler eine sichere Ausgangsbasis für das Schreiben zuverlässiger Anwendungen. Transaktionen bilden solide Grundbausteine für Abstraktionen, aus denen eine Anwendung zusammengesetzt werden kann. Eine Anwendung muss sich nicht um inkonsistente Zwischenzustände aufgrund fehlgeschlagener Transaktionen kümmern.
Zahlreiche Hilfsmittel unterstützen den Anwendungsentwickler bei seiner täglichen Arbeit mit relationalen Datenbanksystemen. Das Java Persistence API (JPA) [10] bietet ein Programmierinterface zur Persistierung von Daten in relationalen Datenbanksystemen.
Object-relational Mapper (ORM) übernehmen dann die Verwaltung der Korrespondenz zwischen den Objekten der Anwendungsschicht und ihrer Repräsentation und Persistierung im relationalen Datenbanksystem. Diese Korrespondenz ist für den Entwickler transparent und abstrahiert von der Verteilung der Daten auf unterschiedliche Tabellen. Mit Hibernate ORM [11] steht eine freie Implementierung von JPA zur Verfügung. Allerdings bietet Hibernate ORM auch eigene Funktionalitäten an, die über den für JPA definierten Standard hinausgehen. Hier ist Vorsicht bei der Verwendung geboten: Greift die Anwendung auf diese Funktionen zurück, so ist die Austauschbarkeit des ORM nicht mehr gewährleistet.
Technologien wie z. B. Spring Data JPA [12] oder das Spring-JDBC-Template [13] stellen Mechanismen zur Verfügung, die den Entwickler weiter unterstützen und das Entwickeln robuster Anwendungen, die auf relationale Datenbanken als Speichertechnologie setzen, sichern.
Mit PreparedStatements [14] steht ein Mittel zur Verfügung, um das Datenbanksystem bei der optimierten Ausführung zu unterstützen und Böswilligkeiten wie SQL Injection vorzubeugen.
Auf Basis der Möglichkeiten, die Frameworks wie Spring Data bieten, können einige der im nachfolgenden Abschnitt besprochenen Kritikpunkte abgeschwächt werden. Beispielsweise kann die Komplexität von SQL-Anfragen hinter geschickten Mappings versteckt werden. Das macht aber die Konfiguration von Transaktionen und kaskadierenden Interaktionen kompliziert. Hier gilt wie so oft, dass sich Komplexität zwar verschieben, nicht aber eliminieren lässt. Allerdings bringen diese Techniken deutlich sichtbare Vorteile für den Alltag des Anwendungsentwicklers. Trotz allem darf die Komplexität bei der Fehlersuche, welche mit jeder weiteren Abstraktion steigt, aber nicht unterschätzt werden.
Aufgrund der Normalisierung sind komplexe Objekte der Geschäftsdomäne zumeist auf mehrere Relationen verteilt. Diese als Impedance Mismatch [15] bekannte Tatsache macht SQL-Anfragen schnell kompliziert und unübersichtlich, was zu Lasten der Wartbarkeit der Anfragen geht.
Das Zusammensuchen der auf dem Speichersystem verteilten Daten zum Befüllen der Objekte kostet Zeit und Rechenleistung. Hier bieten Systeme ausgefeilte Caching- und Optimierungsmechanismen. Bei Anfragen mit vielen Join-Operationen sind die I/O-Zeiten aber deutlich spürbar. Eine weitere Maßnahme zur Performancesteigerung ist die vertikale Skalierung, der Einbau von mehr RAM, CPUs oder schnelleren I/O-Subsystemen, gemäß der Maxime „viel hilft viel“. Denormalisierung des Schemas kann die Performance steigern, bringt aber wieder mehr Verantwortung in die Anwendung zurück.
Die Performance einer Anfrage ist weiterhin abhängig von der Abarbeitung durch das relationale Datenbanksystem. Äquivalente Umformulierungen der Anfragen durch den Entwickler können zu einer signifikanten Verbesserung führen, wenn dieser die Daten kennt. Eine Daumenregel ist, Anfragen so zu formulieren, dass die Relationen möglichst früh möglichst wenige Tupel enthalten. Weiter kann der Anfrageoptimierer gezwungen werden, gewisse Zugriffpfade und Indizes zu verwenden. Dies bringt aber eine Abhängigkeit vom Zustand des Systems mit sich und weicht die Trennung von Anfrage- und Datenspeicherungsebene auf. Die Arbeitsweise des Optimierers ist darüber hinaus von der Version des Datenbanksystems abhängig, eine performante Anfrage kann nach einem Update hoffnungslos ineffizient sein.
Weitere Schwächen offenbaren relationale Datenbanken bei der Skalierung im Umfeld der fortschreitenden Verteilung der Datenwelt. Der vertikalen Skalierung sind physikalische und monetäre Grenzen gesetzt. Sperren und blockierende Verfahren, die die Transaktionalität des Systems sichern, skalieren horizontal nur schlecht. Eine Verteilung der relationalen Datenbank kommt mit zum Teil sehr deutlichen Performanceeinbußen daher, da Netzwerklatenzen sich dann bis in die Sperrverfahren durchschlagen. In der Konsequenz laufen Transaktionen länger und die Antwortzeiten der Applikation erhöhen sich, der Durchsatz sinkt.
Verfahren für den Umgang mit Hochverfügbarkeitsanforderungen (High-Availability) und Lastverteilung beruhen meist darauf, die relationale Datenbank weiterhin als ein System erscheinen zu lassen. Verteilung und Replikation der Daten werden transparent gehandhabt, das Gewährleisten der strikten Konsistenzanforderungen kostet wertvolle Antwortzeit.
In einigen Bereichen haben sich relationale Datenbanken als nicht einsetzbar erwiesen. Ein Beispiel kann ein Webshop mit Anforderungen und SLAs sein, die ein Antwortverhalten fordern, das mit dem Overhead eines relationalen Systems schlicht nicht erreichbar ist. Eine gute Beschreibung der Defizite relationaler Systeme in diesem Bereich findet sich in [16].
Die genannten Schwächen, insbesondere im Bereich der horizontalen Skalierung und dem Antwortverhalten, sind in den letzten Jahren mit zunehmender Verteilung der Daten und geänderten Anforderungen an die Datenkonsistenz schwerwiegender geworden. Im Zuge der Diskussionen um Big Data und Web Scale ist eine Vielzahl neuer Systeme entstanden, die versuchen, das relational geprägte Denken zu revolutionieren und den Fokus bei der Wahl der Speichertechnologie wieder verstärkt auf die Anforderungen der Anwendungsdomäne zu lenken. In den Vordergrund rücken die Wahl des richtigen Werkzeugs für das vorliegende Problem und der richtigen Datenbank für das gewählte Datenmodell. Die Universalität der relationalen Datenbanken ist zur Marketingillusion erklärt worden [3]. Im Beispiel des Webshops kann auf neue Technologien zurückgegriffen werden. Abbildung 6 zeigt eine mögliche Architektur, die von verschiedenen Speichertechnologien Gebrauch macht, um speziellen Anforderungen gerecht zu werden (vgl. auch [16]). Auf die genannten Datenbankarten gehen wir im nächsten Teil der Serie gezielter ein.
Nicht mehr alles in ein relationales Schema pressen zu müssen, wird als Befreiung empfunden. Domänenmodelle können natürlicher erstellt werden, wenn die Transformation in ein relationales Modell wegfällt. Manchmal ist die Domäne ein Graph oder eine schlichte Menge von Schlüssel-Werte-Paaren. Wenn die gewählte Art der Modellierung direkt von der Datenbank unterstützt wird, so fühlt sich das nicht nur besser an, es gibt auch mehr Sicherheit und Vertrauen in das Modell. Dies beschleunigt die Entwicklung und macht die Evolution des Modells weniger fehleranfällig.
Geminderte Konsistenz (sofern die Anwendungsdomäne es erlaubt) ist nur eine Technik, Performance und Datendurchsatz zu steigern. Allerdings kehrt auch wieder mehr Verantwortung zum Anwendungsentwickler zurück. Die Regeln der verteilten Datenhaltung sind strikt, die Implikationen des CAP-Theorems [3] spielen eine zentrale Rolle. Die NoSQL-Bewegung befindet sich hier teilweise in einer Situation, die mit der in den 1970er vergleichbar ist. Und vielleicht kann aus den Entwicklungen in der Vergangenheit gelernt werden.
NoSQL-Datenbanken und Analogien zwischen der aktuellen Entwicklung im Bereich NoSQL und der Situation der Anwendungsentwickler zu Codds Zeiten werden wir im nächsten Teil dieser Serie ausführlicher diskutieren.
Christian Mennerich hat Diplom-Informatik studiert, einer seiner Schwerpunkte lag auf Theorie und Implementation von Datenbanksystemen. Er arbeitet als Entwickler bei der synyx GmbH & Co. KG in Karlsruhe, wo er sich unter anderem mit NoSQL beschäftigt.
Joachim Arrasz ist als Software- und Systemarchitekt in Karlsruhe bei der synyx GmbH & Co. KG als Leiter der CodeClinic tätig. Darüber hinaus twittert (@arrasz) und bloggt er gerne (http://blog.synyx.de/).
[1] Edlich, S. et al.: „NoSQL: Einstieg in die Welt nichtrelationaler Web 2.0 Datenbanken“, Carl Hanser Verlag, 2011
[2] Codd, E. F.: „A Relational Model of Data for Large Shared Data Banks“, Communications of the ACM, Vol. 13:6, 1970
[3] Stonebraker, M.; Çetintemel, U.: „One Size Fits All: An Idea Whose Time Has Come and Gone“, Proceedings of the 21st International Conference on Data Engineering, 2005
[4] Gilbert, S.; Lynch, N.: „Brewer’s conjecture and the feasibility of consistent, available, partition-tolerant web services“, ACM SIGACT News, Volume 33 Issue 2 (2002)
[5] Kemper, A.; Eickler, A.: „Datenbanksysteme – Eine Einführung“, 8. Auflage, Oldenbourg Wissenschaftsverlag, 2011
[6] Chamberlin, D. C.; Boyce, R. F.: „SEQUEL: A structured English query language“, in „Proceedings of the 1974 ACM SIGFIDET workshop on Data description, access and control“, 1974
[7] http://www.postgresql.org/docs/9.4/static/functions-json.html
[8] Kandzia, P.; Klein, H.-J.: „Theoretische Grundlagen relationaler Datenbanksysteme“, BI Wissenschaftsverlag, 1993
[9] Spolsky, J.: „The Law of Leaky Abstractions“, 2002: http://www.joelonsoftware.com/articles/LeakyAbstractions.html
[10] http://www.oracle.com/technetwork/java/javaee/tech/persistence-jsp-140049.html
[11] http://hibernate.org/orm/
[12] http://projects.spring.io/spring-data-jpa/
[13] http://docs.spring.io/spring/docs/current/spring-framework-reference/html/jdbc.html
[14] http://de.wikipedia.org/wiki/Prepared_Statement
[15] http://de.wikipedia.org/wiki/Object-relational_impedance_mismatch
[16] DeCandia, G. et al.: „Dynamo: Amazon’s Highly Available Key-value Store“, in: „SOSP ’07 Proceedings of twenty-first ACM SIGOPS symposium on Operating systems principles“, ACM, 2007