Preis: 9,80 €
Erhältlich ab: Juni 2019
Umfang: 100
Gerade ist mit der JAX 2019 eine rauschende Konferenzwoche zu Ende gegangen. Welche Eindrücke sind geblieben?
Nach wie vor steht auf der JAX die Java-Plattform im Zentrum. Doch ist der Kontext, in dem sich Java heute befindet, ein ganz anderer als noch vor zehn Jahren. Themen wie die Cloud, Microservices, Continuous Delivery und Container drängen in den Vordergrund und sind dabei, die gesamte IT zu verändern. Das Dilemma: Während neue Technologien speziell für diesen Kontext entwickelt werden – Stichwort „Cloud-native“ –, müssen sich traditionelle Technologien anpassen. So auch Java.
Nun galten im Java-Universum jahrelang Werte wie Langlebigkeit, Abwärtskompatibilität, Standardisierung, Plattformunabhängigkeit („Write once, run anywhere“) als wichtig , daraufhin wurden zahlreiche Frameworks, Tools und Sprachkomponenten optimiert. Im Cloudparadigma sind nun plötzlich andere Dinge gefragt: schnelle Start-up-Zeiten, geringer Speicherverbrauch, verteilte und resiliente Strukturen. Deshalb ist Sebastian Meyens Frage im Eröffnungspanel der JAX durchaus berechtigt: „Ist Java angesichts der neuen Herausforderungen überhaupt zukunftsfähig?“
Eberhard Wolff beantwortete diese Frage mit einer charakteristischen Eigenschaft der Java-Community: Ihrer ungebrochenen Innovationskraft. Java wird momentan aktiv an das Cloud-native-Modell herangeführt. So verabschiedet man sich derzeit etwa mit der GraalVM von der Bytecode-JIT-Kompilierung und ermöglicht Native Binaries, die ohne installiertes JRE auskommen. Damit wird der Laufzeitoverhead umgangen, der Java beispielsweise gegenüber Sprachen wie Go ins Hintertreffen geraten lässt.
Lars Röwekamp führte neue (Micro-)Frameworks wie Quarkus oder Micronaut (siehe Seite 34 dieser Ausgabe) ins Feld, die speziell auf die Anforderungen von Microservices eingehen. Röwekamps Fazit lautete hier: „Wir müssen unser Wissen, das wir in den letzten Jahren aufgebaut haben, nicht wegschmeißen.“ Wir können also auch mit Java Cloud-Natives sein!
Neben diesen dramatischen Auftritten hatte die JAX aber natürlich auch ganz bodenständige Java-Kost zu bieten. Dazu gehörten das Suchprojekt Apache Lucene und dessen Abkömmlinge Solr und Elasticsearch. Seit 1999 verrichtet Lucene als Volltextsuchbibliothek die Arbeit hinter zahlreichen Suchprojekten, etwa bei Apple, Adobe, Disney, Netflix und whitehouse.gov. Was technologisch dahinter steckt, und wohin die Reise geht, erzählt uns Uwe Schindler im Titelthema dieser Ausgabe des Java Magazins.
Viel Spaß beim Schmökern
Für Reactive Programming gibt es in Java mittlerweile viele Frameworks. Aber wie greife ich dann auf eine relationale Datenbank zu? Der Java-Standard zum Zugriff auf relationale Datenbanken, JDBC, unterstützt bisher nur blockierende Aufrufe. Welche Möglichkeiten gibt es aber dann, wenn man ganz auf Reactive Programming setzt?
In vergangenen Kolumnen habe ich bereits mehrfach über Reactive Programming geschrieben; teilweise war ich dabei durchaus kritisch (siehe z. B. [1]). Reactive Programming erhöht den Entwicklungs- und Wartungsaufwand, weil Logging, Testing und Debugging deutlich aufwendiger werden. Die Entscheidung, eine Anwendung reactive zu realisieren, macht also nur dann Sinn, wenn die Performance- und/oder Speicheranforderungen so hoch sind, dass normale imperative oder objektorientierte Programmierung nicht ausreicht. Lediglich dann ist der höhere Entwicklungs- und Wartungsaufwand zu rechtfertigen.
Solche Anforderungen kommen häufig zustande, wenn es darum geht, große Mengen an Daten bereitzustellen oder zu verarbeiten. Bei klassischen Entwicklungsansätzen würden diese Daten zunächst komplett in den Speicher geladen und dann entweder zum Client zurückgegeben oder verarbeitet werden.
Werden die Datenmengen zu groß, um sie komplett in den Speicher zu laden, würde man klassischerweise damit beginnen, die Daten häppchenweise (z. B. über Pagination) zu verarbeiten. Dieser Ansatz hat allerdings einen Nachteil: Um die Menge der Daten im Speicher gering zu halten, gibt es viele Round-Trips zur Datenbank. Bei einer relationalen Datenbank und der Verwendung von JDBC (sei es direkt oder über JPA) ist allerdings jeder dieser Round-Trips mit einem blockierenden Warten auf das Ergebnis verbunden. Und jeder dieser blockierenden Aufrufe führt zu einem schwergewichtigen Kontextwechsel im Betriebssystem. Dadurch wird die gesamte Verarbeitung inperformant. Um die Anzahl der Kontextwechsel zu minimieren, müsste die Anzahl der Round-Trips minimiert werden, folglich müsste man größere Chunks verwenden. Das wiederum führt allerdings zu mehr Speicherbedarf. Man befände sich in einem Teufelskreis der Optimierung von Datenzugriffen.
Hier kommt Reactive Programming mit Reactive Streams ins Spiel. Anstatt die Daten Chunk-weise zu laden, können sie (nicht-blockierend) gestreamt werden. Die Datenbank informiert die Anwendung, sobald ein neuer Datensatz geladen wurde, woraufhin die Anwendung diesen verarbeiten kann. Damit lassen sich beide Probleme gleichzeitig lösen: Es befindet sich immer nur ein Datensatz im Speicher und es muss nicht blockierend auf die Datenbank gewartet werden. Mit NoSQL-Datenbanken wie z. B. MongoDB ist ein solches Streamen schon länger möglich, wie das folgende Beispiel zeigt:
MongoCollection<Document> customers
= database.getCollection("customers");
Publisher<Document> customerStream = customers.find();
customerStream.subscribe(customerProcessor);
Aber wie sieht es bei relationalen Datenbanken aus? Das Streamen von Daten ist auch hier möglich. Im JDBC-Standard gibt es dafür das ScrollableResultSet. Dieses ermöglicht es, Daten einzeln in den Speicher zu laden. Was allerdings fehlt, ist die Asynchronität. Auf jeden einzelnen Datensatz muss hier blockierend gewartet werden, was wieder zu den inperformanten Kontextwechseln führt.
Ist eine asynchrone Verarbeitung mit relationalen Datenbanken also nicht möglich? Das ist zum Glück nicht so. Die meisten relationalen Datenbanken unterstützen das asnychrone Speichern und Laden von Daten. Was fehlt, ist die standardisierte Anbindung an Java. JDBC kennt hier nur blockierende Aufrufe.
Da der JDBC-Standard keine Möglichkeit der asynchronen Kommunikation bietet, lohnt sich ein Blick auf die Zusatz-APIs, die die Hersteller mit ihren JDBC-Treibern ausliefern.
In MySQL z. B. gibt es das X DevAPI [2]. Dieses bietet erweiterte Möglichkeiten für den Zugriff auf MySQL-Datenbanken. So kann unter anderem eine Datenbankabfrage asynchron abgesetzt werden (Listing 1).
Listing 1
Table customers = database.getTable("customers");
CompletableFuture<RowResult> customerResult
= customers.select("firstName", "lastName").executeAsync();
SubmissionPublisher<Row> customerStream = new SubmissionPublisher<>();
customerResult.thenAccept(rowResult -> {
while (rowResult.hasNext()) {
customerStream.submit(rowResult.fetchOne());
}
});
customerStream.subscribe(customerProcessor);
Was mit diesem API leider (noch) nicht möglich ist, ist das asynchrone Streamen großer Ergebnismengen. Zwar können die Datenbankzeilen einzeln geladen werden (mit fetchOne()), der Aufruf von fetchOne() ist allerdings wieder synchron und damit blockierend.
Einige Hersteller wie z. B. PostgreSQL haben die Protokolle, über die mit der Datenbank kommuniziert werden kann, sehr gut dokumentiert [3]. Im Fall von PostgreSQL ist über das Protokoll auch asynchrone Kommunikation möglich, sodass auf dieser Basis ein reaktiver Treiber implementiert werden kann. Tatsächlich gibt es auch verschiedene Open-Source-Projekte, die genau das tun (z. B. [4]). Mit diesen Treibern kann somit eine asynchrone Datenbankabfrage abgesetzt und das Ergebnis gestreamt werden (Listing 2).
Listing 2
Observable<Row> customerStream = Client
.rxBegin() // cursors require a transaction
.flatMapObservable(tx -> tx
.rxPrepare("SELECT * FROM customers")
.flatMapObservable(query -> query
.createStream(1, Tuple.tuple())
.toObservable())
.doAfterTerminate(tx::commit));
customerStream.subscribe(customerProcessor);
Geht man den beschriebenen Weg von herstellerspezifischen APIs oder Open-Source-Projekten, verlässt man natürlich das sichere Pflaster der Standardisierung und damit der garantierten Abwärtskompatibilität. Da ein Wechsel des Datenbankherstellers in der Praxis eher selten vorkommt, ist das aber ein überschaubares Risiko, das durchaus eingegangen werden kann.
Dennoch wäre es schön, wenn es einen Java-Standard für asynchrone Datenbankkommunikation gäbe. Ein solcher Standard ist mit ADBA bereits in Arbeit, wie Sven Kölpin in seiner Kolumne [5] berichtete. Die Kolumne ist mittlerweile allerdings fast ein Jahr alt und ein Release von ADBA leider noch immer nicht in Sicht.
Immerhin gibt es mittlerweile das Projekt AoJ, also „ADBA over JDBC“ [6], das die Möglichkeit bietet, den aktuellen Stand der ADBA-Spezifikation zu verproben und im Hintergrund einen JDBC-Treiber zu verwenden. Dennoch ist dieses Projekt nicht für den Produktivbetrieb vorgesehen. Die tatsächlichen Vorteile von asynchroner Datenbankkommunikation kann es auch nicht bieten, da im Hintergrund weiterhin JDBC verwendet wird. Damit existiert wiederum blockierender Code; dieser wird nur in einen separaten Thread ausgelagert.
Allerdings ermöglicht das Projekt, das neue ADBA API einmal auszuprobieren und dabei mit einer echten Datenbank zu sprechen.
Das Spring Framework steht seit jeher für kurze Innovationszyklen und ist nicht dafür bekannt, auf die Fertigstellung eines Java-Standards zu warten. In der Regel gibt es stattdessen eine eigene Lösung, die dann ggf. später den Standard adaptiert.
Und so ist es auch beim reaktiven Zugriff auf relationale Datenbanken. Da die Spezifikation des ADBA-Standards noch andauert, wurde das Projekt R2DBC ins Leben gerufen [7].
R2DBC bietet ein API zum reaktiven Zugriff auf relationale Datenbanken. Letztendlich handelt es sich dabei also um einen alternativen Treiber, wie er bereits oben für PostgreSQL beschrieben wurde. Im Vergleich zu diesem hat man aber zwei Vorteile: Erstens erhält man eine sehr gute Integration in das Spring Framework, genauer gesagt in Spring Data und Project Reactor. Die Erstellung von Reactive Repositories passiert damit automatisch. Wer Spring sowieso nutzt, sollte R2DBC ggf. allein deshalb schon den beschriebenen anderen Lösungen vorziehen.
Der zweite Vorteil von R2DBC ergibt sich aus der Tatsache, dass das Projekt wie ein Standard aufgezogen wurde, d. h., es gibt ein Public API, das von verschiedenen Treibern implementiert werden kann und wird. Zusätzlich gibt es eine Test-Suite, die eine Überprüfung ermöglicht, ob eine Implementierung dem API entspricht. Das erleichtert die Anbindung unterschiedlicher Datenbanken und sei es nur, um eine In-Memory-Datenbank wie H2 in den Tests zu verwenden und z. B. PostgreSQL in Produktion.
Mittlerweile existieren R2DBC-Implementierungen für PostgreSQL, MS SQL Server und H2. Zudem gibt es ein Projekt, das ADBA wrappt. Dadurch kann R2DBC direkt weiterverwendet werden, wenn der ADBA-Standard veröffentlicht worden ist. Außerdem könnte mit dem oben erwähnten AoJ-Treiber bereits jetzt jede beliebige Datenbank mit JDBC-Treibern angebunden werden, dann allerdings doppelt gewrappt (zunächst mit AoJ und dann mit R2DBC). Zusätzlich wäre ein solches Set-up, wie bereits erwähnt, dank JDBC auch nicht reaktiv – eine Variante, die also sicherlich auch nur zum Ausprobieren geeignet ist, nicht aber für den Produktivbetrieb.
Reactive Programming macht vor allem dann Sinn, wenn man große Datenmengen mit hoher Performance verarbeiten will. NoSQL-Datenbanken wie z. B. MongoDB bieten schon seit Längerem reaktive Schnittstellen; ist man aber an eine relationale Datenbank gebunden, sieht es mit der Unterstützung schon schlechter aus. Bisher gibt es kein standardisiertes API, um nicht-blockierend (und damit reactive) auf eine relationale Datenbank zuzugreifen.
Oracle plant, das in Zukunft zu ändern, und mit ADBA entsteht gerade ein Java-Standard, der genau dafür entwickelt wird. Wann dieser aber fertig ist und tatsächlich in Java einfließt, ist aktuell unklar. Bis dahin muss man auf anbieterspezifische Treiber zurückgreifen oder eine der verfügbaren Open-Source-Lösungen verwenden.
Wenn man sowieso mit dem Spring Framework unterwegs ist, bietet es sich dabei an, einen Blick auf R2DBC zu werfen. Diese Spring-Bibliothek bietet eine Integration von reaktivem Datenbankzugriff in die Spring Data Repositories und abstrahiert von verschiedenen Datenbankanbietern. So erhält man z. B. asynchronen Zugriff auf PostgreSQL und MS SQL über das Reactive API von Spring, dem Project Reactor. Zudem existiert bereits eine Anbindung an den aktuellen Stand von ADBA. R2DBC kann also als Aufsatz auf ADBA verwendet werden, sobald ADBA in Java eingeflossen ist. Bis dahin muss allerdings jedes Projekt für sich entscheiden, ob es den nativen asynchronen Treiber der jeweiligen Datenbank nimmt oder auf die Spring-Abstraktion R2DBC setzt.
In diesem Sinne: Stay tuned.
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 im Android-Umfeld seit der ersten Stunde aktiv.
[1] https://jaxenter.de/reactive-programming-82051
[2] https://dev.mysql.com/doc/x-devapi-userguide/en/
[3] https://www.postgresql.org/docs/current/protocol.html
[4] https://github.com/reactiverse/reactive-pg-client
[5] https://jaxenter.de/jdbc-asynchron-72905
[6] https://github.com/oracle/oracle-db-examples/tree/master/java/AoJ
Im gleichen Maß, in dem das Schreiben von Tests immer mehr in den Aufgabenbereich von Entwicklern fällt, müssen wir uns vermehrt die Frage stellen, wie wir die Qualität unserer Testsuite messen und bewerten können. Mutation Testing ist in diesem Kontext ein interessantes Tool, das wir zur Entwicklungszeit einsetzen können, um unsere Tests zu verbessern, während wir sie schreiben.
Für viele von uns sind Tests ein ständiger Begleiter im Entwickleralltag und für manche womöglich ein zuverlässiger Partner, der es uns erlaubt, die Kontrolle über unseren Produktionscode zu behalten. So ermöglicht es uns eine gute Testsuite, unseren Code kontinuierlich zu erweitern und einem Refactoring zu unterziehen, ohne dass wir uns Sorgen machen müssen, bestehende Funktionalitäten dabei zunichte zu machen.
Aber wie kann ich beim Refactoring meines Testcodes Sicherheit erhalten? Woher weiß ich, dass ich einer Testsuite trauen kann, wenn ich in ein bestehendes Projekt komme? Wie kann ich sicherstellen, dass ich effektive Tests schreibe? Und woher weiß ich, dass mein Team effektive Tests schreibt?
Zusammenfassend stellt sich uns also die Frage: Wie kann ich die Qualität meiner Testsuite beurteilen? Hier wird als Antwort gerne auf Code Coverage verwiesen (vielleicht auch, weil es sich als Metrik so managerfreundlich in Tools wie SonarQube visualisieren lässt). Es gibt verschiedene Code-Coverage-Kriterien, üblich sind zum Beispiel Line-, Branch- oder Statement-Coverage. Doch diese Coverage sagt letztlich nur aus, welche Teile unseres Codes ausgeführt wurden, aber nicht, welche Teile getestet werden. Code ausführen ist nicht das gleiche wie Code testen. Entsprechend kann Code Coverage uns nur sagen, welcher Code nicht getestet wurde.
Bereits 1971 veröffentlichte der Student Richard Lipton, bekannt durch seine Pionierarbeit im Bereich des DNA-Computing, das Paper „Fault Diagnosis of Computer Programs“, in dem er erstmalig die Idee äußert, bewusst Bugs in eine zu testende Codebasis einzuführen, um die existierende Testsuite daraufhin zu bewerten, ob sie diesen Bug findet. Da Lipton das Paper während seines Studiums lediglich im Rahmen einer Seminararbeit veröffentlicht hat, ist es sehr schwer, diese Quelle ausfindig zu machen, wenngleich das Paper in vielen anderen akademischen Papers referenziert wird.
Glücklicherweise veröffentlichte Lipton zusammen mit DeMillo und Sayward im IEEE Computer Journal 1978 das Paper „Hints on Test Data Selection: Help for the Practicing Programmer“. Unter Berücksichtigung der Coupling-Effect-Hypothese präsentieren Lipton et al. in diesem eine Methode, um festzustellen, ob die verwendeten Testdaten ein Programm hinreichend getestet haben (wir können in diesem Fall Testdaten mit Unit-Tests gleichsetzen). Diese Methode wird im Paper als Program Mutation bezeichnet. Der Coupling Effect besagt, dass Testdaten (Tests), die alle Programme abweichend von einem korrekten Programm nur durch einfache Fehler unterscheiden können, so empfindlich sind, dass sie auch komplexere Fehler implizit unterscheiden. Das Vorgehen ist bei der im Paper vorgestellten Methode wie folgt:
Ein Programm P wird mit einer Menge Testdaten T, deren Eignung festgestellt werden soll, von einem Mutation System ausgeführt.
Wenn hierbei Fehler auftreten, ist P fehlerhaft -> Abbruch
Wenn hierbei keine Fehler auftreten, könnte P ebenfalls fehlerhaft sein, falls der Umfang von T ungenügend ist.
Das Mutation System erstellt eine Reihe von Mutationen von P, die sich von P durch einzelne, einfache Fehler unterscheiden. Diese bezeichnen wir als P1, P2, …, Pk. So wird z. B. der Ausdruck a == b zu a >= b mutiert. Wenn wir nun die mutierten Programme mit den Testdaten ausführen, können zwei verschiedene Fälle eintreten:
Pi verhält sich anders als P, erzeugt also einen Fehler basierend auf den Testdaten.
Pi verhält sich wie P auf Basis der Testdaten.
Für jeden Mutant, für den der Fall (a) eingetreten ist, sagen wir, der Mutant wurde „getötet“, wir hatten also Testdaten, die diesen Fehler feststellen konnten. Im Fall (b) sagen wir, der Mutant „lebt“. Das kann zwei verschiedene Gründe haben:
Unsere Testdaten sind ungenügend, um den durch Pi eingeführten Defekt zu identifizieren (das ist eine erstrebenswerte Erkenntnis).
P und Pi sind äquivalente Programme. Wir bezeichnen Pi in diesem Fall als einen Equivalent Mutant, und der kann unmöglich durch Testdaten getötet werden, weil sich das Programm trotz der Mutation verhält wie gewünscht. Diese Mutants sind händisch zu identifizieren und können ignoriert werden.
Bereits in Liptons Paper wird erwähnt, dass dieser Prozess vor allem während des Entwicklungsprozesses dabei helfen soll, gute Testdaten zu erzeugen und einfach Fehler zu finden. Es geht also beim Mutation Testing nicht in erster Linie darum, Metriken in einem nachgelagerten QS-Prozess zu erzeugen.
Obgleich die theoretischen Grundlagen für Mutation Testing nicht neu sind, dauerte es viele Jahre, bis Mutation Testing schließlich in Teilen Einzug in den Programmieralltag hielt. Einer der prominenteren Open-Source-Vertreter im Java-Umfeld ist vermutlich das im Jahr 2000 veröffentlichte Projekt Jester [1]. Zu Jester finden wir ein interessantes Zitat von Kent Beck: „Why just think your tests are good when you can know for sure? Sometimes Jester tells me my tests are airtight, but sometimes the changes it finds come as a bolt out of the blue. Highly recommended.“
Man könnte nun also meinen, dass ein Open-Source-Tool, das sogar von Kent Beck empfohlen wird, auf eine breite Akzeptanz in Entwicklerkreisen trifft. Das war allerdings augenscheinlich nicht der Fall, und auch wenn es schwer ist, konkrete Gründe zu benennen, so kann man zumindest einen großen Nachteil von Jester (und anderen Mutation-Testing-Tools der entsprechenden Epoche) identifizieren: Mutation Testing war extrem langsam und ressourcenhungrig. Klassische Mutation-Testing-Systeme kompilieren den Code für jeden Mutant und führen die gesamte Testsuite für jeden Mutant aus. Das sorgt dafür, dass Mutation Testing, vor allem in Hinsicht auf schnelles Feedback innerhalb des Entwicklungsprozesses, unpraktisch wird. Mit dieser Erkenntnis könnten wir den Artikel an dieser Stelle enden lassen und Mutation Testing wieder in die Academia-Schublade verbannen, gäbe es nicht mittlerweile Mutation-Testing-Systeme, die eine Lösung für diese Problematik gefunden haben. Ein prominenter Vertreter in der Java-Welt ist PIT [2].
PIT wurde 2010 von Henri Coles [3] unter der Apache License veröffentlicht und stand ursprünglich für „Parallel Isolated Test“. Der initiale Anwendungsfall war die parallelisierte Ausführung von JUnit-Tests in separaten Class Loadern. Das diente letztlich aber als Grundlage, um aus PIT ein Mutation-Testing-System zu machen. PIT kann als Industriestandard für Mutation Testing im Java-Kontext angesehen werden und findet Verwendung in solch prominenten Projekten wie dem Large Hadron Collider am Europäischen Kernforschungszentrum CERN. Aber was macht PIT anders als Jester, um praktikabler zu werden? Dazu implementiert PIT eine Reihe sinnvoller Optimierungen gegenüber naiven Mutation-Testing-Ansätzen:
Tests werden parallelisiert ausgeführt. Da Unit-Tests in der Regel CPU-bound sind, erlaubt uns aktuelle Multi-Core-Prozessorarchitektur ein hohes Maß an Parallelität.
Mutants werden nicht kompiliert, sondern durch Bytecodemanipulation erzeugt.
Tests werden anhand der Laufzeit priorisiert, sodass billige Tests zuerst laufen. Sobald ein Test fehlschlägt, kann der aktuelle Lauf abgebrochen werden, da der Mutant in diesem Fall getötet wurde.
Mithilfe von Code Coverage wird eine Zuordnung zwischen Tests und Codezeilen erzeugt. Somit müssen nur die Tests gegen einen Mutant ausgeführten werden, die eine mutierte Codezeile ausführen.
All diese Faktoren zusammengenommen erlauben es, PIT als schnelles Feedback während der Entwicklung einzusetzen, z. B. im Kontext eines TDD-Zyklus. Die Integration in ein bestehendes Projekt ist denkbar einfach, und es gibt Plug-ins für alle gängigen Java-Build-Systeme wie Maven, Ant oder Gradle (darüber hinaus existieren außerdem Plug-ins für gängige IDEs wie z. B. Eclipse oder IntelliJ). In Listing 1 ist die beispielhafte Konfiguration als Maven-Build-Plug-in zu sehen.
Listing 1: PIT – Konfiguration als Maven-Build-Plug-in
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.4.3</version>
</plugin>
Durch dieses Plug-in wird das Maven Goal mutationCoverage hinzugefügt, das sich folgendermaßen ausführen lässt:
mvn org.pitest:pitest-maven:mutationCoverage
Dieses Goal führt die Mutation-Tests aus, gibt eine Zusammenfassung über erzeugte, getötete und überlebende Mutants aus und erzeugt anschließend einen HTML-Report. Das Maven Goal ist umfangreich parametrisierbar, und es kann für einen schnelleren Feedbackzyklus sinnvoll sein, Mutation-Tests nur für einzelne Klassen auszuführen:
mvn org.pitest:pitest-maven:mutationCoverage -dtargetTests=my.package.Foo*Test -dtargetClasses=my.package.Foo*
PIT funktioniert out of the box mit JUnit-4-Tests. Verwendet man JUnit 5, muss ein zusätzliches Plug-in konfiguriert werden (Listing 2).
Listing 2: Maven-Plug-in mit JUnit-5-Support
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.4.3</version>
<dependencies>
<dependency>
<groupId>org.pitest</groupId>
<artifactId>pitest-junit5-plugin</artifactId>
<version>0.7</version>
</dependency>
</dependencies>
</plugin>
Es ist relativ schwer, mit künstlichen Beispielen ein sinnvolles Verständnis für Mutation Testing zu vermitteln, und reale Open-Source-Projekte würden als Beispiel den Rahmen und die Verständlichkeit dieses Artikels sprengen. Wir werden uns daher trotz allem ein relativ abstraktes Beispiel anschauen (das mit freundlicher Genehmigung aus Henry Coles JavaZone-Talk übernommen wurde [4]. Es ist aber zu empfehlen, PIT einfach einmal in einem eigenen Projekt einzusetzen, um ein echtes Gefühl für Mutation Testing zu bekommen.
Angenommen, wir haben den in Listing 3 dargestellten, korrekten Produktionscode. Würden wir nun Code Coverage als unsere einzige Metrik nehmen, anhand derer wir die Qualität unserer Testsuite bewerten, könnten wir die Testsuite aus Listing 4 nehmen und 100 Prozent Code Coverage erreichen.
Listing 3: Produktionscode
public class Counter {
private int count;
public void count(int i) {
if (i >= 10) {
count++;
}
}
public int getCount() {
return count;
}
}
Listing 4: Code-Coverage-Test
class CounterCodeCoverageTest {
@Test
void bossSaysMustHaveCodeCoverage() {
Counter c = new Counter();
c.count(0);
c.count(9);
c.count(11);
c.getCount();
}
}
Hier wird sehr schön deutlich, dass Code Coverage keinerlei Rückschlüsse auf getestete Codezeilen zulässt. Nun wollen wir allerdings annehmen, dass Entwickler in der Regel versuchen, sinnvolle Tests zu schreiben. Diese Tests könnten für unser Beispiel aussehen wie in Listing 5 dargestellt.
Listing 5: Fachlogiktest
class CounterGoodFaithTest {
private Counter c;
@BeforeEach
void setUp() {
c = new Counter();
}
@Test
void startsWithEmptyCount() {
assertEquals(0, c.getCount());
}
@Test
void countsIntegersAboveTen() {
c.count(11);
assertEquals(1, c.getCount());
}
@Test
void doesNotCountIntegersBelowTen() {
c.count(9);
assertEquals(0, c.getCount());
}
}
Die Testsuite ist nun auch ein guter Kandidat für Mutation Testing. Wenn wir PIT ausführen, stellen wir fest, dass (in der Defaultkonfiguration) vier Mutants erzeugt wurden und davon drei mit unserer Testsuite getötet werden konnten (Abb. 1).
Der überlebende Mutant ist changed conditional boundary, also die Mutation von >= zu >. Und tatsächlich fehlt uns ein Test für unsere Wertgrenze (Listing 6). Wenn wir ihn nachliefern, können alle Mutants getötet werden.
Listing 6: Fehlender Test
@Test
void countsTen() {
c.count(10);
assertEquals(1, c.getCount());
}
Was wir in diesem Beispiel nicht gesehen haben, ist ein Equivalent Mutant. Bewusst Code zu schreiben, der anfällig für Equivalent Mutants ist, ist gar nicht so einfach, doch wir wollen uns einmal das Beispiel in Listing 7 anschauen.
Listing 7: Equivalent Mutants
public boolean isFoobar() {
boolean foobar;
if (1 > 0) {
foobar = true;
}
return foobar;
}
@Test
void isFoobar() {
assertTrue(c.isFoobar());
}
Hier erzeugt PIT den Mutant replaced boolean return with true, der return foobar durch return true ersetzt. Diesen können wir natürlich mit keinem vorstellbaren Test töten, da der aktuelle Code ja bereits in jedem Fall true als Wert zurückliefern wird. An dieser Stelle (und in vielen anderen Fällen) kann der Equivalent Mutant allerdings ein Hinweis auf potenziell zu vereinfachenden Code sein.
Wir haben gesehen, dass Mutation Testing eine mächtige Technik ist, die uns verschiedene Vorteile bietet. Wir können fehlende Test-Cases oder fehlerhafte Tests identifizieren und wir haben ein Sicherheitsnetz, um unsere Tests einem Refactoring zu unterziehen. Außerdem können durch Equivalent Mutants Hinweise auf Code Smells und redundanten Code gegeben werden. Zusammenfassend noch einmal der Hinweis, dass Mutation Testing in erster Linie ein Tool ist, das beim Entwickeln unterstützen soll und sich nur schlecht als Metrik für managementgetriebene Quality Gates eignet. Außerdem möchte ich hier die Empfehlung an jeden aussprechen, der sich nach diesem Artikel von Mutation Testing angesprochen fühlt: Probiert PIT am besten einfach einmal in einem bestehenden Projekt aus.
Kevin Wittek ist Java-Entwickler und JVM-Fan, seit er vor dreizehn Jahren der Sprache begegnete. Inzwischen fungiert Kevin als Software-Engineer für die codecentric AG und beschäftigt sich dort mit den Themen Software Craftsmanship, Infrastructure as Code und Continous Integration Pipelines. In seiner Freizeit ist er Musiker, aktives Mitglied der Open-Source-Community und arbeitet als Co-Maintainer am Testcontainers-Projekt.
[1] http://jester.sourceforge.net
[4] https://2018.javazone.no/program/4a55ab6a-49c6-4671-822d-5dff26356bcc
Android-Apps programmiert man am besten in Java und Android Studio. Und das Backend, wie entwickelt man das? Im Idealfall gar nicht! Firebase ist der von Google betriebene Clouddienst, der umfassende Services primär für Android-Apps zur Verfügung stellt. In vielen Fällen kann man auf diesen Backend-Dienst setzen und kommt damit zügig zum Ziel.
Apps für mobile Geräte beziehen Daten und Logik zu einem Großteil aus der Cloud. Sieht man von wenigen komplett offlinefähigen Applikationen ab, nutzen die meisten Apps umfassende Dienste. Diese Art von Cloudservice wird als Backend as a Service (BaaS) bezeichnet. Eine Reihe von Anbietern konkurriert mit im Detail unterschiedlichen, aber in der Funktionsweise ähnlichen Konzepten um die Gunst der Entwickler. In dieser dreiteiligen Artikelserie wird es primär um Firebase gehen. Firebase ist der Clouddienst aus dem Hause Google und damit insbesondere für die Arbeit mit Android-Apps geeignet. Andererseits ist man weder als Android-Programmierer an Firebase gebunden noch beschränkt sich die Unterstützung von Firebase auf dieses Betriebssystem. Es ist sinnvoll, sich mit der Funktionsweise und der Verwendung eines BaaS-Dienstes intensiv zu beschäftigen. Hat man grundlegende Kenntnisse, wie man hier vorgehen sollte, geht die Programmierung der nächsten App mit Sicherheit viel schneller voran. Auch bekommt man ein Gefühl dafür, welcher BaaS-Dienst für die eine oder andere Aufgabe besser geeignet ist.
Wir beginnen mit einem allgemeinen Überblick zu den Aufgaben eines BaaS-Dienstes und sehen uns dann die inzwischen so umfangreichen Services von Firebase im Überblick an. Die Teile zwei und drei sind dann der Praxis gewidmet. Themen sind die Nutzung der Services von Firebase von der Authentifizierung bis zur Datenspeicherung.
In diesem Abschnitt geht es um die Nutzung von Clouddiensten für mobile Apps – vornehmlich im unternehmerischen Umfeld (Mobile Enterprise Computing). In etwas reduzierter Form lassen sich die Funktionen auch auf die Nutzung von Apps für das private Umfeld übertragen. Mobile Enterprise Computing umfasst gewissermaßen drei Infrastrukturbereiche (Abb. 1).
Beginnen wir in der Abbildung ganz links: Die Clients sind die mobilen Endgeräte, auf denen die Apps laufen, die Sie für die betriebliche Nutzung programmiert haben. Dabei treffen wir auf eine umfassende Systemvielfalt in Hard- und Software. Blicken wir nun nach ganz rechts. Die IT-Infrastruktur ist meist schon vorhanden. Gemeint sind die Anwendungs- und Datenbankserver, auf denen die Businessapplikationen installiert sind. Dieser Teil der Technik ist oft schon seit Jahren in Betrieb, hat sich bewährt und wurde in der Regel erstellt, als an Mobile Computing in der vorliegenden Form nicht zu denken war. Das Ziel ist es, einen Teil der Services und die Daten für die mobile Nutzung zur Verfügung zu stellen. Im Zentrum unserer Betrachtungen steht die so genannte Middleware.
Hier ist es wie beim Fußball. Für das Schießen der Tore sind die Stürmer verantwortlich. Dazu bewegen Sie sich vorrangig in der gegnerischen Hälfte. Der Spielaufbau beginnt dagegen am eigenen Tor. Die Abwehr hat nach erfolgreicher Verteidigung den Ball erobert. Nun muss dieser zu den Spielern des eignen Angriffs katapultiert werden. Klar könnten Sie den Ball direkt dorthin schießen, doch die Erfolgsquote ist in der Regel nicht berauschend. Viel besser gelingt der Spielaufbau über das Mittelfeld, das die Bälle aus der Abwehr übernimmt und die Angreifer in die Lage versetzt, sich in Szene zu setzen. Unsere Middleware hat eine ähnliche Funktion. Sie sorgt dafür, dass die mobilen Clients einen Zugriff auf die Unternehmens-IT bekommen. Dazu verfügt sie über Schnittstellen auf Client- und auf Serverseite. Zusätzlich bieten sich spezifische, auf das Mobile Computing abgestimmte Systemfunktionen an. Diese Art von Middleware trägt dann den eindrucksvollen Namen Backend as a Service. Dazu gehören u. a. eine Benutzerverwaltung, ein (temporärer) Datenspeicher und gegebenenfalls die Anbindung sozialer Netzwerke für erweiterte Szenarien der Benutzerauthentifizierung.
Typisch ist es, eine solche Middleware in die Cloud auszulagern. Damit ist es eine besondere Form des Cloud-Computings. Beim Cloud-Computing werden bekanntermaßen Hard- und Software als Service über das Internet angeboten. Die Kernelemente des Cloud-Computings werden in drei große Bereiche unterteilt: Software as a Service (SaaS), Platform as a Service (PaaS) und Infrastructure as a Service (IaaS). Unter SaaS versteht man die Nutzung von Anwendungen und Programmen. Das Angebot reicht von einzelnen Applikationen bis hin zur vollständigen Arbeitsumgebung. Bei PaaS kann der Kunde ein komplettes, vorkonfiguriertes System nutzen. Dazu gehören beispielsweise einfache Testsysteme bis hin zur Bereitstellung ganzer Rechencluster, um komplexe Aufgaben zu bearbeiten. Bei IaaS geht es um das Bereitstellen von virtualisierten Betriebsmitteln wie CPUs, Hauptspeicher, Massenspeicher, Netzwerkkomponenten und Ähnlichem. BaaS ist eine Sonderform des Cloud-Computings, die auf die Anforderungen von Apps und mobilen Webanwendungen ausgerichtet ist und oft auch als Zwischenschicht dient.
Das Rad stetig neu zu erfinden, ist wenig effektiv und ziemlich fehlerbehaftet. Wir als Entwickler freuen uns, wenn wir auf bereits implementierte und erprobte Funktionen zurückgreifen können. So sparen wir Arbeit, Zeit, Geld und vor allem Nerven. Wir sind schneller fertig, und es besteht die Chance auf einen pünktlichen Feierabend. BaaS- bzw. Middlewarelösungen sollten dabei die folgenden Anforderungen erfüllen bzw. Funktionen bereitstellen, denn die Aufgaben gleichen sich zwischen den Systemen.
Clientanbindung: Das Ziel besteht darin, auf Clientseite möglichst keine Einschränkungen vorzunehmen. Idealerweise kann die gesamte Breite der bestehenden Technologien angebunden werden. Dabei muss man berücksichtigen, dass die heutige Systemvielfalt enorm ist. Die Programmierschnittstelle (API) muss weitgehend unabhängig von Technologie, Framework und Sprache anzusprechen sein.
Usermanagement: Eine umfassende Benutzerverwaltung ist Bestandteil der meisten Applikationen. Diese komplett selbst zu implementieren, ist sehr aufwendig. Neben einer eigenen Benutzerverwaltung sollte idealerweise auch die Anmeldung über Social Media (wie Google oder Facebook) bereitgestellt werden.
Integrierter Datenspeicher: Ein integrierter Datenspeicher sorgt für eine Synchronisierung der Daten zwischen den Clients und den Servern. Für eine temporäre Datenspeicherung bzw. bei einem geringeren Datenvolumen ist diese Form bereits ausreichend. Idealerweise können die Daten in strukturierter Form oder alternativ als Dokumente abgelegt werden.
Push-Benachrichtigungen: Apps für mobile Endgeräte setzen zunehmend auf eine aktive Ereignisbenachrichtigung zwischen den Clients. Dabei wird der Client aktiv vom Server mit Nachrichten getriggert.
Statistiken: Das Backend sollte Möglichkeiten bieten, die Aktivitäten der Benutzer und der von ihnen genutzten Services einfach auszuwerten. Damit bekommen die Entwickler einen Überblick über die Akzeptanz der einzelnen Features.
Konnektivität zur Unternehmens-IT: Ähnlich wie die Konnektivität auf der Clientseite muss die Middleware bzw. die Backend-Lösung eine breite Anbindung an die bestehende IT-Landschaft, zum Beispiel an die unterschiedlichsten Datenbanksysteme ermöglichen. Das geschieht in der Regel mithilfe sogenannter Konnektoren, die eine Brücke zwischen Middleware und bestehender IT-Infrastruktur bilden. Der Vorteil besteht darin, dass an den bestehenden IT-Systemen so gut wie keine Anpassungen notwendig sind, da der Zugriff über standardisierte Schnittstellen erfolgt. Ebenso wird auf diese Weise der Zugriff auf den notwendigen Datenausschnitt begrenzt, denn spezialisierte Clientlösungen benötigen in der Regel keinen Zugriff auf die gesamte Datenlandschaft eines Unternehmens. Das ist ein wichtiger Aspekt aus der Sichtweise des Datenschutzes und der Datensicherheit. Der Einsatz von Standardschnittstellen reduziert den Implementierungsaufwand in großem Umfang.
Neben den technischen Prämissen gibt es auch aus einige weitere Aspekte bei der Auswahl eines Anbieters zu beachten. Dazu zählen unter anderem die folgenden:
Skalierbarkeit: Die gewählte Technologie sollte in technischer Hinsicht ohne zusätzlichen Aufwand in der Lage sein, auch ein starkes Wachstum des Datenverkehrs und der Anzahl der Service-Anforderungen zu managen. Die Middlewareplattform darf nicht zu einem Nadelöhr werden. Sie soll vielmehr ein Enabler des Business sein. Es wäre doch traurig, wenn Sie bei der Konzeption und der Programmierung alles richtig gemacht haben und die Nutzer sich auf Ihre Software stürzen. Auf einmal gerät das Backend an seine Grenzen und kann die Zahl der Anfragen nicht bedienen. Also bedenken Sie bereits am Anfang eine mögliche Erweiterung.
Kosten: Die Kosten müssen transparent und angemessen sein. Niedrige Kosten bei der anfänglichen Nutzung aufgrund eines geringen Volumens dürfen nicht darüber hinwegtäuschen, dass bei Skalierung und produktivem Einsatz ein Vielfaches an Entgelten anfallen kann. Unter Berücksichtigung weiterer Aspekte, wie zum Beispiel technischer Möglichkeiten, Datenschutz usw., ist eine Vergleichsrechnung zwischen Self- und Cloudhosting durchzuführen. Eine pauschale Einschätzung ist nicht möglich. Die letztendliche Entscheidung kann immer nur individuell getroffen werden.
Datenschutz und Datensicherheit: Dieses Thema kann nerven, doch seine Relevanz ist nicht wegzudiskutieren. Sie können kein Backend für unternehmerische Zwecke aufsetzen, ohne darüber intensiv nachgedacht zu haben. Sehr lange Zeit wurden die Services für Businessapplikationen nahezu ausschließlich durch ein Hosting im eigenen Rechenzentrum erbracht. Mit dem Trend zur Cloud bekommen datenschutzrechtliche Fragen neues Gewicht. Gegen ein Hosting in der Cloud können Argumente des Datenschutzes sprechen. Beispielsweise dürfen personenbezogene Daten den Verantwortungsbereich eines Unternehmens nur unter strengsten Auflagen, wie Verschlüsselung usw., verlassen. Nicht berechtigte Zugriffe sind unbedingt zu vermeiden. Ob unter Berücksichtigung der strengen Datenschutzvorschriften in Deutschland ein Hosting auf dem Server eines externen Dienstleisters möglich ist, muss individuell beurteilt werden. Eine konservative Einschätzung ist vor dem Hintergrund der gegebenen Rechtsvorschriften notwendig. Letztendlich kann das Selfhosting daher alternativlos sein (Textkasten: „Alles selbst machen: Selfhosting“). Die Backend- beziehungsweise die Middlewarelösung sollte in diesem Fall für die Installation auf einem eigenen Server zur Verfügung stehen.
Jetzt sind wir in der Lage, uns die Angebote von Firebase näher anzusehen. Starten wir auch hier mit einem generellen Überblick.
Wenn Sie auf die Webseite [1] von Firebase blicken (Abb. 2), sehen Sie sofort, dass die Funktionsvielfalt von Firebase bereits so umfassend ist, dass es über ein reines Daten-Backend weit hinausgeht.
Cloud Firestore: Dient dem Speichern und Synchronisieren von Daten zwischen Benutzern und Geräten mit Hilfe einer cloudgehosteten NoSQL-Datenbank. Cloud Firestore bietet Livesynchronisation und Offlinesupport sowie effiziente Datenabfragen. Durch die Integration mit anderen Firebase-Produkten können Sie sogenannte serverlose Apps erstellen. Hinweis: Die Funktion befindet sich noch in der Testphase, das heißt, sie ist im Moment noch mit Beta ausgezeichnet.
Authentication: Verwalten Sie Ihre Benutzer einfach und sicher. Firebase bietet mehrere Methoden zur Authentifizierung an, darunter E-Mail und Passwort, Drittanbieter wie Google oder Facebook und die direkte Verwendung Ihres vorhandenen Kontosystems. Erstellen Sie Ihr eigenes User Interface oder nutzen Sie eine vollständig anpassbare Open-Source-Benutzeroberfläche.
Cloud Storage: Dieser dient dazu, benutzergenerierte Inhalte wie Bilder, Audio und Video in einem einfachen Objektspeicher abzulegen. Die Uploads und Downloads werden über das Firebase-SDK aus den Apps für die mobilen Endgeräte erledigt.
Realtime Database: Speichern und synchronisieren Sie mit dieser Funktion Daten zwischen Benutzern und Geräten in Echtzeit mithilfe einer NoSQL-Datenbank, die in der Cloud gehostet wird. Aktualisierte Daten werden in Bruchteilen von Sekunden über verbundene Geräte synchronisiert. Die Daten bleiben verfügbar, wenn Ihre App offline geschaltet wird. Damit kann man offlinefähige Anwendungen umsetzen.
ML Kit: In immer mehr Apps werden Funktionen auf der Basis von Machine Learning (ML) integriert. Firebase bietet Funktionen für ML. Die Funktion ist noch im Betastatus. Es können zum Beispiel eigene Modelle für ML importiert werden. Ebenso stehen fertige APIs zur Nutzung zur Verfügung.
Cloud Functions: Gemeint ist damit, dass man funktionalen Code vom Client in die Cloud auslagert. Dabei muss man keinen eigenen Server verwalten und skalieren. Funktionen können durch Ereignisse ausgelöst werden, die von Firebase-Produkten, Google-Cloud-Diensten oder Dritten mit Hilfe von WebHooks gesendet werden.
Crashlytics: Analyse des Absturzverhaltens der betreffenden App. Alle Informationen zu den App-Crashs werden im Crashlytics-Dashboard angezeigt. Ebenso gibt es die Möglichkeit von Echtzeitbenachrichtigungen. Crashlytics ist damit der primäre Crash-Reporter für Firebase.
Performance Monitoring: Mit Hilfe dieses Tools kann man die Ausführungsgeschwindigkeit der Apps auf den Endgeräten der Benutzer überprüfen und gegebenenfalls Performanceprobleme diagnostizieren. Dazu können Sie Ablaufverfolgungen einsetzen, um die Leistung bestimmter Teile der App zu überwachen und eine zusammenfassende Ansicht in der Firebase-Konsole anzuzeigen. Ein entscheidendes Kriterium für die Nutzerakzeptanz einer App ist die Startzeit. Diese können Sie ebenfalls, genauso wie HTTP-Anforderungen, überwachen.
In-App Messaging: Das Versenden von Nachrichten von der App an die Nutzer ist eine sehr häufig genutzte Funktion. Die Benutzer werden mit zielgerichteten und kontextbezogenen Nachrichten dazu angehalten, sinnvolle Aktionen in der App durchzuführen. Es können Nachrichten basierend auf dem Benutzerverhalten und den Interessen ausgelöst werden. Die Funktion ist konfigurierbar, und eine Vielzahl von Anwendungsfällen und Formaten stehen zur Auswahl.
Test Lab: Zur Verfügung stehen virtuelle und physische Geräte, die von Google gehostet werden. Auf diesen Geräten können Sie während der Entwicklung, nach Änderungen oder vor einem finalen Deployment umfassende Tests durchführen. Das Ziel: durch umfassende Tests den Grad der Geräteabdeckung zu erhöhen und damit eine fehlerfreiere App zu entwickeln.
A/B Testing: Diese Tests dienen dazu, die Wirkung/Nutzerakzeptanz von neuen Funktionen zu erproben. Mit Gruppen von Nutzern (A und B) werden unterschiedliche Funktionen beziehungsweise Designs der App erprobt (Textkasten: „A/B Testing“). Firebase stellt die Infrastruktur für derartige Produkt- und Marketingexperimente direkt über Firebase zur Verfügung. Damit kann man geplante Updates einer App vor der endgültigen Auslieferung auf ihre Wirksamkeit und Funktionsweise prüfen und dann gegebenenfalls noch anpassen.
Cloud Messaging: Senden Sie Nachrichten und Benachrichtigungen an Benutzer über Plattformgrenzen hinweg (Android, iOS). Die Nachrichten können an einzelne Geräte, Gerätegruppen oder auch bestimmte Benutzersegmente gesendet werden. Firebase Cloud Messaging (FCM) lässt sich umfassend skalieren und ist laut Dokumentation in der Lage, täglich sehr viele Nachrichten zu versenden.
Google Analytics: Diese Funktion liefert Erkenntnisse über die Nutzung der App durch die Anwender. Auf der Basis des Nutzerverhaltens können Entscheidungen über die weitere App-Entwicklung (Produktroadmap) getroffen werden. Neben Echtzeitberichten und Auswertungen im Dashboard kann man die Daten für eine weitere Analyse exportieren.
Hosting: Viele der vorgestellten Funktionen richten sich an die direkte Nutzung in nativen bzw. hybriden Apps für die mobilen Geräte. Das Hosting stellt spezielle Services für das Bereitstellen von Web-Apps bereit, unter anderem die Nutzung von kostenlosen SSL-Zertifikaten.
Predictions: Die Möglichkeiten des maschinellen Lernens (ML) sollen genutzt werden, um Nutzer zu klassifizieren und beispielsweise potenzielle App-Wechsler anhand ihres Nutzerverhaltens zu identifizieren und dementsprechend entgegenwirken zu können.
Remote Config: Via Remote können Sie die App für den Benutzer individuell anpassen, zum Beispiel im Rahmen von A/B-Tests. Auf diese Weise können Funktionen via Update bereitgestellt und getestet werden, ohne eine komplette neue Version zu deployen.
Dynamic Links: Verwenden Sie dynamische Links, um eine angepasste Benutzererfahrung für iOS, Android und das Web bereitzustellen. Mit Dynamic Links erhalten die Nutzer je nach aktiver Plattform angepasste Optionen zur Auswahl. Öffnet ein Nutzer einen Dynamic Link in einer iOS- oder Android-App, können diese direkt zu den Inhalten in der nativen App zeigen. Öffnet ein Nutzer den gleichen Dynamic Link in einem Desktopbrowser, kann der Link zu den entsprechenden Inhalten auf der Webseite zeigen. Wenn ein Benutzer einen Dynamic Link unter iOS oder Android aktiviert und die betreffende App nicht installiert ist, wird der Benutzer aufgefordert, sie zu installieren. Das Ziel ist eine höhere Installationsrate der App.
Dieser Überblick hat Ihnen die Funktionsvielfalt von Firebase vorgestellt. Dabei wird es Ihnen wahrscheinlich wie den Autoren gehen, die sich zunächst auf die Kernfeatures konzentriert haben. Je nach App und Funktion der App dürften das die Bereiche Datenspeicherung, Authentifizierung und Messaging sein. Die anderen Funktionen und Features sind wahrscheinlich bei der Auswahl des Backends nicht entscheidend, können jedoch später im Entwicklungsprozess eingebunden werden.
Firebase kommt aus dem Hause Google und ist damit natürlich das bevorzugte Backend für Android-Apps. Andere Systeme werden dennoch unterstützt, denn die meisten Apps werden heute sowohl für Android als auch für iOS programmiert. Ein Blick in die Dokumentation zeigt, dass Firebase direkt Programmierschnittstellen unter anderem für die folgenden technischen Systeme anbietet: Java (Android), Swift und Objective-C (iOS), JavaScript, Node.js (Client), C++ und Unity. Damit dürften aus technischer Sicht auf Clientseite alle maßgeblichen Systeme dabei sein. Auf der Webseite von Firebase gibt es neben der jeweiligen API-Referenz dokumentierte Beispiele für die am meisten verwendeten Systeme und die notwendigen Libraries (SDKs) zum Download. Für unsere kommenden Experimente werden wir insbesondere die sogenannten Guides nutzen, die uns die ersten Schritte zur Verwendung der Basisfunktionen ebnen (Abb. 3). Positiv: Mit Hilfe kurzer Videosequenzen bekommt man die wichtigsten Schritte direkt gezeigt.
Ein wichtiges Merkmal zur Auswahl des passenden Backends für eine App sind die anfallenden Kosten. Es stehen unterschiedliche Pläne zur Auswahl. Eine Übersicht finden Sie in Abbildung 4.
Sehr erfreulich: Der kostenfreie Plan ist nicht zeitlich begrenzt, sondern steht auch für die dauerhafte Nutzung zur Verfügung. Wenn man sich die einzelnen Leistungsparameter ansieht, dann dürften die gebotenen Leistungen im sogenannten Spark Plan (kostenfrei) auch für umfassendere App-Projekte genügen. Für mehr Speicher, Anfragen, Datenbanknutzung und API Calls stehen erweiterte Pläne und individuelle Preispakete zur Auswahl. Für den Einstieg bedeutet das, dass man mit Firebase anfangen und im Rahmen eines Testprojekts prüfen kann, ob es das passende Backend ist. Kommen Sie zu dem Ergebnis, dass es aus technischer Sicht passt, dann können Sie im Produktivprojekt mit dem kostenfreien Plan (Spark Plan) starten. Erst wenn die Nutzung (Anzahl der Anwender, Häufigkeit der Anfragen) entsprechend skaliert, müssen Sie auf einen kostenpflichtigen Plan wechseln. Es steht ein Kalkulator zur Verfügung [2], der Ihnen hilft, die künftigen Kosten der Nutzung des Firebase Backends abzuschätzen. Damit kann man die einzelnen Funktionen und zu nutzenden Features – jeweils beginnend beim freien Kontingent – konfigurieren.
Starten wir, indem wir einen ersten Streifzug durch das Online-Dashboard von Firebase vornehmen. Sie benötigen auf jeden Fall einen Google-Account, mit dem Sie sich unter [1] anmelden. In Firebase rufen Sie über den Menüpunkt Console das Dashboard auf. Die Arbeit mit Firebase wird in Projekten organisiert. Für einen ersten Eindruck legen wir ein Projekt (Name: Test) an. Dem Projekt wird automatisch der Spark Plan (kostenfrei) zugeordnet. Das Dashboard startet mit einer Projektübersicht (Abb. 5).
Sie erhalten direkte Links zu den oben beschriebenen Basisfunktionen, wie beispielsweise Authentication, Database, Storage, Hosting usw. und ebenso einen Link mit dem Verweis, Firebase zu Ihrer App hinzuzufügen. Dieser Vorgang läuft in folgenden Schritten ab: App im Dashboard registrieren (anhand einer eindeutigen Kennung), die Konfigurationsdatei herunterladen und im App-Projekt einbinden und das Firebase SDK zum App-Projekt ergänzen, das als Basis für die Kommunikation aus dem Quellcode dient. Diese Schritte werden wir bei den weiteren Betrachtungen in den kommenden beiden Teilen dieser Artikelserie detailliert unter die Lupe nehmen. Sie werden der Einstieg für die Nutzung von Firebase als Backend für eine App sein. Kommen wir jetzt noch kurz zu möglichen Alternativen zu Firebase.
Auch wenn es in dieser Serie um Firebase als Backend und Clouddienst für Apps geht, hier noch ein paar Hinweise auf Alternativen. Im Funktionsumfang ist sicherlich Microsoft Azure [3] direkt vergleichbar. Auch hier gehen die Funktionen weit über die Kernfeatures der Datenspeicherung und der Authentifizierung hinaus. Einige Funktionen sind auch bei Azure bis zu einem bestimmten Umfang nach der Testphase kostenfrei.
Direkt für mobile Apps möchten wir noch die folgenden Anbieter erwähnen: Parse Server [4], Kinvey [5] und backendless [6]. Auswahlkriterien für den richtigen Backend-Anbieter wurden zu Beginn dieses Artikels bereits beschrieben.
Eine beeindruckende Vielfalt an Funktionen bieten die Backend-as-a-Service- beziehungsweise Cloudanbieter für die Entwicklung von Apps. Interessant ist, dass sich die Funktionen nicht nur auf Datenspeicherung und Benutzerverwaltung beziehen, sondern auch eine Reihe an Aufgaben rund um die App-Entwicklung und -Pflege abdecken. In der Praxis sind neben den angebotenen Funktionen und den anfallenden Kosten die Art und Weise der Einbindung in die eigene App von Interesse. Wie erfolgt die Kommunikation? Gibt es ein eigenes SDK? Lassen sich die Bibliotheken direkt in die integrierten Entwicklungsumgebungen, insbesondere Android Studio für Android-Apps und Xcode für iOS-Apps einbinden? Welche Konfigurationsarbeiten sind im Dashboard von Firebase notwendig? All das werden wir konkret testen, zum Beispiel anhand der typischen Aufgaben der Benutzerverwaltung für Apps der mobilen Geräte. Wie man mit Firebase nun konkret arbeitet, was gut funktioniert und wo es vielleicht beim Einstieg etwas hakelt, lesen Sie in der nächsten Ausgabe des Java Magazins.
Dr. Veikko Krypczyk ist Entwickler und Fachautor.
Olena Bochkor beschäftigt sich mit dem Design von Webseiten und Apps für mobile Geräte.
Weitere Informationen zu diesen und anderen Themen der IT finden Sie unter http://larinet.com.
[1] Firebase: https://firebase.google.com
[2] Kalkulator: https://firebase.google.com/pricing/
[3] Microsoft Azure: https://azure.microsoft.com/de-de/
[4] Parse Server: https://parseplatform.org
[5] Kinvey: https://www.progress.com/kinvey
[6] backendless: https://backendless.com
Bisher kennt Java zwei verschiedene Datentypen: primitive Datentypen und Objekte. In den kommenden Releases wird es einen neuen Typ geben, den ValueType. Für die tägliche Programmierung sollte es keinen Unterschied zwischen Objekten und Werttypen geben, unter der Haube sind sie jedoch ziemlich unterschiedlich. ValueTypes sind wie Objekte, aber funktionieren wie primitive Datentypen. Das heißt, sie sind effektiv.
Andere Sprachen nutzen ebenfalls diese Art der Datenverarbeitung, obwohl sie sie normalerweise anders bezeichnen. In diesem Artikel beschreibe ich die Problematik von Objekten, die es notwendig macht, ValueTypes (oder Werttypen) in Java aufzunehmen. Anschließend erkläre ich, was ValueTypes sind, und gehe schließlich auf ihre Problematik ein. Ja, auch Werttypen unterliegen einer Problematik, und der Grund dafür ist ein sehr elementarer: Das Leben ist kein Ponyhof, und man kann nicht alles haben. Es gibt bei der Verwendung von ValueTypes gegenüber Objekten Vorteile, aber ebenso existieren Anwendungen beziehungsweise Programmierkonstrukte, bei denen Objekte besser passen. Im letzten Teil des Artikels erkläre ich außerdem, warum es Einschränkungen bei Werttypen gibt, wie z. B. keine Vererbung, Mangel an generischen Elementen und Unveränderlichkeit.
Objekte in Java sind kleine Speicherteile, die in der Regel in einem Segment des Speichers namens Heap gespeichert werden. Der Speicher wird beim Erstellen des Objekts zugewiesen und freigegeben, wenn das Objekt nicht mehr verwendet und „weggeräumt“ wird (Garbage Collection). Während der Lebensdauer des Objekts kann der Speicher, der das Objekt repräsentiert, im Verlauf des Garbage-Collection-Prozesses von einem Ort zum anderen verschoben werden. Auf diese Weise verwaltet Java den Speicher und stellt sicher, dass unabhängig davon, wie Objekte erstellt und zerstört werden, der Speicher nicht segmentiert wird. Andere Sprachen mit Garbage Collector, die den „Objektmüll“ zwar wegräumen, aber nicht komprimieren, laufen Gefahr, dass lange laufende Prozesse den Speicher segmentieren. Aber: Komprimieren ist ein CPU-intensiver Prozess, deshalb konkurriert Java GC kaum mit der Geschwindigkeit der Garbage Collection (GC) der Programmiersprache Go. Vor allem aber macht die Tatsache, dass sich die Objekte im Speicher bewegen, Zeiger nutzlos. Es wird zwar irgendwohin verwiesen, die Daten können sich jedoch nach einer Komprimierungsphase von GC bereits irgendwo anders befinden. Das ist der Grund, warum Java keine Zeiger verwendet – Java verwendet Referenzen.
Man könnte sich fragen, worin der Unterschied zwischen Zeiger und Referenz besteht. Kurz gesagt: Bei Referenzen handelt es sich um verwaltete Zeiger. Wenn GC ein Objekt im Speicher verschiebt, müssen alle Referenzen aktualisiert werden, damit sie jederzeit auf das richtige Objekt verweisen. Die Integration von nativem Code ist ebenfalls etwas umständlich. Java GC kann nicht wissen, wohin im durch den nativen Code verwalteten Speicher der Zeiger kopiert wurde. Außerdem gibt es in Java keine Zeigerarithmetik. Es existieren zwar Arrays, aber ansonsten kann man die schöne Zeigerarithmetik vergessen, an die man sich bei der Programmierung in C oder C++ vielleicht bereits gewöhnt hat. Der Vorteil ist, dass auch durch fehlerhafte Zeigerwertberechnungen verursachte Speicherfehler ausgeschlossen werden können.
Dennoch befinden sich die Objekte im Speicher, und wenn der Prozessor damit rechnen muss, muss das Objekt vom Hauptspeicher zum Prozessor gelangen. In den guten alten Zeiten, in denen die CPUs mit 4 MHz liefen, war das kein Problem. Die Geschwindigkeit des Speicherzugriffs war vergleichbar mit der Geschwindigkeit des Prozessors. Heute laufen Prozessoren mit 4 GHz, und der Speicherzugriff ist kaum schneller als früher. Die Technologie ist nicht schlecht, es handelt sich hierbei einfach um Physik. Man muss nur die Zeit berechnen, die benötigt wird, um von der CPU zum Speicher und mit Lichtgeschwindigkeit wieder zurück zu gelangen, das ist alles. Es gibt nur einen Weg, die Geschwindigkeit zu erhöhen, nämlich den Speicher näher an die Verarbeitungseinheit zu bringen. Und genau das ist es, was moderne CPUs auszeichnet: Sie verfügen über Speichercaches auf der CPU selbst. Leider ist nicht nur die CPU-Geschwindigkeit, sondern auch der Speicherbedarf gestiegen. Früher hatten wir 640 kB auf einem Computer, was für alles reichen musste. Heute hat mein Mac 16 GB. Und hier kommt wieder die Physik ins Spiel: Man kann nicht 16 GB oder mehr auf die CPU legen, da dort kein Platz ist und es auch keine effektive Methode gibt, das System entsprechend zu kühlen. Und wir wollen die CPU schließlich zum Rechnen nutzen, nicht zum Kochen.
Wenn die CPU Speicherplatz braucht, wird im Cache gespeichert. Wenn ein Programm etwas von einem Speicherort abruft, ist es wahrscheinlich, dass es bald etwas vom anderen Speicherort abruft und so weiter. Deshalb liest die CPU ganze Speicherseiten in den Cache ein. Im Cache können sich viele Seiten aus verschiedenen Speicherbereichen befinden. Wenn wir auf ein Element in einem Array zugreifen wollen, kann das lange dauern (aus CPU-Sicht bedeutet „lange dauern“ ein paar Dutzend Nanosekunden), wenn wir jedoch ein zweites Element aus dem Array brauchen, befindet es sich bereits im Cache. Das ist sehr effektiv, es sei denn, es handelt sich um ein Array von Objekten. In diesem Fall ist das Array selbst ein zusammenhängender Bereich von Referenzen. Die CPU, die auf das zweite Element im Array zugreift, hat die Referenz im Cache, das Objekt selbst kann sich jedoch auf einer völlig anderen Seite befinden als auf der, auf der sich das erste Objekt befand. Es existieren einige Techniken zur Optimierung des Speicherlayouts, die dieses Problem teilweise lösen, aber der Knüller wäre es, wenn die Objekte im Speicher wie die Hühner auf der Stange aufgereiht gespeichert würden. Nur geht das leider nicht, denn selbst wenn sie nacheinander aufgereiht wären, wären sie durch den sogenannten Objektheader getrennt.
Der Objektheader befindet sich einige Bytes vor dem Objektspeicher, der das Objekt beschreibt. Er beinhaltet die Sperre, die in synchronisierten Anweisungen verwendet wird, und auch den Typ des Objekts. Wenn wir eine Referenz in einer Variable haben, die z. B. vom Typ Serializable ist, kennen wir den tatsächlichen Typ des Objekts nicht von der Variable selbst. Wir müssen uns Zugriff auf das Objekt verschaffen, damit die JRE den tatsächlichen Typ des Objekts lesen kann. In Java hilft dieser Objektheader bei Vererbung und Polymorphie. Auch betragen die wenigen Bytes in den 32-Bit-Implementierungen 12 Bytes und 16 Bytes auf der 64-Bit-Architektur. Das bedeutet, dass ein Integer einen 32-Bit-int-Wert und zusätzlich 128 Bit an administrativen Bits speichert – ein Verhältnis von 4:1.
Werttypen versuchen, dieses Problem anzugehen. Ein ValueType ist insofern etwas wie eine Klasse, als er Felder und Methoden haben kann. ValueTypes werden im Rahmen des Valhalla-Projekts unter JEP 169 für Java entwickelt. Derzeit ist eine Early-Access-Version verfügbar. Diese Version ist ein Abkömmling der Version Java 11 und hat noch Einschränkungen. Die Syntax ist noch vorläufig (mit einigen Schlüsselwörtern, die mit einem doppelten Unterstrich beginnen und nicht in der endgültigen Version enthalten sein können), und auch einige Funktionen sind nicht implementiert. Dennoch gibt es die Möglichkeit, das Ganze auszuprobieren.
Der ValueType unterscheidet sich von einem Objekt dadurch, dass er keinen Objektheader und keine Identität hat, es keine Referenzen auf ihn gibt, Werttypen unveränderlich sind und es keine Vererbung zwischen Werttypen und somit auch keine Polymorphie gibt. Manches davon – wie das Fehlen eines Objektheaders – sind Implementierungsdetails, anderes wiederum Designentscheidungen. Kommen wir zu einigen Merkmalen von ValueTypes.
ValueTypes haben keine Identität. Wenn wir es mit Objekten zu tun haben, können zwei davon identisch sein. Im Grunde genommen sprechen wir nur zweimal über das gleiche Objekt und Objekte können gleich sein. Im letztgenannten Fall haben wir zwei verschiedene Objekte – es handelt sich jedoch um Instanzen der gleichen Klasse, und die Methode equals() gibt true zurück, wenn wir sie vergleichen. Die Identität wird in Java mit dem Operator == überprüft, die Gleichheit, wie bereits erwähnt, mit der Methode equals().
Primitive Datentypen wie Byte, char, short, int, long, float, double oder boolean haben ebenfalls keine Identität. In diesem Fall ist das ziemlich offensichtlich. Es ist Unsinn, zu sagen, dass zwei Boolesche Werte beide true, aber dennoch unterschiedliche Instanzen sind. Als logische Werte haben auch Zahlen wie Null, Eins oder Pi keine Instanzen. Sie sind Werte. Wir können sie mit dem Operator == auf Gleichheit und nicht auf Identität prüfen. Die Idee der Werttypen ist es, den Satz dieser acht primitiven Werte um programmdefinierte Typen zu erweitern, die ebenfalls Werte darstellen.
Die Werte werden in den Variablen und nicht im Heap gespeichert, und wenn die Bitdarstellung des Werts sich in einer Variable befindet, wird der Compiler den Typ erkennen. Ebenso erkennt er, dass die Bits in einer Variablen als 32-Bit-Ganzzahl mit Vorzeichen behandelt werden sollten, wenn die Variable vom Typ int ist. Es ist auch wichtig, zu beachten, dass, wenn ein ValueType als Argument an eine Methode übergeben wird, die Methode die „Kopie“ des ursprünglichen ValueTypes erhält. Das liegt daran, dass Java alle Argumente nach Wert und nie nach Referenz übergibt, was sich mit der Einführung von Werttypen auch nicht ändert. Wenn ein ValueType als Argument an einen Methodenaufruf übergeben wird, werden alle Bits des ValueTypes in die lokale beziehungsweise in die Argumentvariable der Methode kopiert.
Da Werttypen Werte sind, die in den Variablen und nicht im Heap gespeichert werden, benötigen sie keinen Header. Der Compiler weiß einfach, was für ein Typ eine Variable ist und wie das Programm mit den Bits in dieser Variable umgehen soll. Das ist von entscheidender Bedeutung, wenn die ValueType-Arrays ins Spiel kommen. Genau wie bei primitiven Datentypen werden beim Erstellen eines Arrays mit einem Werttyp die Werte im Speicher nacheinander gepackt. Das bedeutet, dass wir bei ValueTypes nicht das Problem haben werden, das bei Objektarrays auftritt. Es gibt keine Referenzen auf die einzelnen Elemente des Arrays, und sie können nicht im Speicher verteilt werden. Wenn die CPU das erste Element lädt, lädt sie alle Elemente, die sich auf der gleichen Speicherseite befinden. Der Zugriff auf nachfolgende Elemente nutzt den Vorteil des Prozessorcache.
Es könnte eine Vererbung zwischen Werttypen geben, aber es wäre äußerst schwierig, sie durch den Compiler zu verwalten. Außerdem würde das Ganze nicht viele Vorteile bringen. Ich wage zu behaupten, dass das Zulassen von Vererbung nicht nur Probleme für den Compiler verursachen, sondern auch unerfahrene Programmierer dazu anregen würde, Konstrukte zu erstellen, die mehr Schaden als Nutzen brächten. In der kommenden Java-Version, die ValueTypes unterstützt, wird es keine Vererbung zwischen ValueTypes und auch nicht zwischen Klassen und ValueTypes geben. Dabei handelt es sich um eine Designentscheidung. Statt uns in vielen Erklärungen zu verlieren, betrachten wir einfach einige Beispiele.
Enthält eine Klasse C ein Feld vom Typ einer anderen Klasse P, enthält sie ebenfalls eine Referenz auf diese andere Klasse. Es kann auch sein, dass C der Klasse P untergeordnet ist. Das ist kein Problem. Beispielsweise gibt es eine verknüpfte Liste mit P-Instanzen. Es existiert ein Feld namens Next, das entweder „Null“ ist oder den Verweis auf das nächste P in der Liste enthält. Wenn die Liste auch Instanzen von C enthalten kann (zur Erinnerung: C erweitert P), dann hat C auch eine Referenz auf das nächste P. Die Liste kann C-Instanzen enthalten, da, wie die Vererbung impliziert, ein C auch ein P ist.
Wie sieht es aus, wenn P ein ValueType ist? Wir können die Elemente nicht miteinander verbinden. Es gibt keine Referenz auf das nächste P, da es keine Referenz auf einen ValueType gibt. Klassen können über Feldwerte gegenseitig aufeinander referenzieren. Werttypen implementieren nur Containment. Wenn ein ValueType ein Feld hat, das einem anderen ValueType entspricht, enthält er alle Bits dieses anderen ValueTypes. Ein Werttyp kann daher niemals ein Feld enthalten, das der Typ selbst ist. Denn das würde bedeuten, dass sich der Werttyp selbst enthält, was eine unendliche Rekursion in der Definition des Typs ist. Ein solcher Typ wäre unendlich groß, daher ist er in der Spezifikation ausdrücklich verboten. Wenn ValueTypes voneinander erben könnten, wäre die Einschränkung komplexer. In diesem Fall könnten wir nicht einfach sagen, dass sich ein ValueType nicht selbst enthalten darf. Es hätte jeder andere Werttyp verboten werden müssen, der vom aktuellen Werttyp abstammt.
Man muss versuchen, sich eine Variable vorzustellen, die vom Typ V ist. Eine solche Variable sollte groß genug sein, um alle Bits von V zu beinhalten, aber auch groß genug, um alle Bits von K aufzunehmen, wenn es möglich wäre, Wertobjekte zu erweitern. K würde dann V hypothetisch erweitern. In diesem Fall enthält K alle Bits von V und seine eigenen. Wie viele Bits sollte eine Variable vom Typ V haben? Die Anzahl der Bits von V? Dann könnten wir keinen K-Wert darin speichern, K würde nicht passen. Alle Variablen vom Typ V sollten also groß genug sein, um auch K zu enthalten. Aber wir sollten nicht bei K aufhören, da es mehr ValueTypes geben könnte, die K erweitern – unter der Annahme, dass eine Vererbung stattgefunden hat, was aber nicht der Fall ist. In diesem Fall sollte eine Variable vom Typ V so viele Bits haben, wie das größte untergeordnete Element von V haben könnte, was zum Zeitpunkt der Kompilierung unbekannt ist. Bekannt wird es nur und erst, wenn alle ValueTypes geladen sind.
Da es keine Vererbung gibt, kann es keine Werttyppolymorphie geben. Es existieren jedoch noch weitere Gründe, die darauf hindeuten, dass es nicht sinnvoll ist, eine Polymorphie für Werttypen zu implementieren. Betrachten wir das obige Beispiel und stellen wir uns die Variable vor, die alle Bits des größten untergeordneten Elements von V enthalten kann. Riefen wir eine Methode für diese Variable auf, welche sollte es (während der Laufzeit) sein? Die Variable enthält keine Informationen über den Typ des tatsächlichen ValueTypes. Ist es V, ist es K oder ein anderes untergeordnetes Element? Der Compiler muss wissen, welche Methode er aufrufen soll, da es keine Headerinformationen gibt, die den Typ signalisieren würden, der zu diesem Zeitpunkt in der Variable ist.
Unveränderlichkeit ist eine Designentscheidung, aber eine nachvollziehbare, sozusagen natürliche. Werttypen in Java sind unveränderlich. Unveränderlichkeit ist in der Regel eine gute Sache. Unveränderliche Objekte helfen dabei, auf saubere und threadsichere Weise Code zu schreiben. Unveränderlichkeit löst nicht alle Probleme, aber sie ist oft praktisch. Auch wenn man int als Zahl sieht, ist es ziemlich offensichtlich, dass man ihren Wert nicht ändern kann. Wenn eine Variable den ganzzahligen Wert 2 enthält, kann man den in der Variable gespeicherten Wert ändern, aber nicht den Wert selbst auf 3. Könnte man, würde im ganzen Universum plötzlich 2 mal 2 mal 2 gleich 9 ergeben. Ähnlich sieht es bei Werttypen aus. Man kann den Inhalt der Variable ändern, die den ValueType enthält, nicht jedoch den ValueType selbst. Wenn man ein einzelnes Bit ändert, hat man diesem Ansatz zufolge einen neuen Werttyp angelegt und den neuen Wert anstelle des alten gespeichert. Schauen wir uns das einfache Beispiel in Listing 1 an.
Listing 1
package javax0.valuetype;
public __ByValue class Point {
public int x;
public int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public Point pushedRight(int d) {
return __WithField(this.x, x+d);
}
public String toString() {
return "[" + x + "," + y + "]";
}
}
Dies ist ein einfacher ValueType, der eine vorläufige Syntax verwendet, die mit der Version build 11-lworldea+0-2018-07-30-1734349.david.simms.valhalla der Early-Access-Version von Java kompiliert wurde. Damit wird das „Schieben“ eines Punkts entlang der X-Achse möglich. Das Hauptprogramm, das den Werttyp verwendet, macht das, was in Listing 2 zu sehen ist.
Listing 2
package javax0.valuetype;
public class Main {
public static void main(String[] args) {
Point a = new Point(3,4);
a = a.pushedRight(1);
System.out.println(a);
}
}
Wenn wir pushedRight aufrufen, hat die Variable a einen neuen Wert. Der Punkt (3,4) hat sich jedoch nicht bewegt, der zweidimensionale Raum wurde nicht verzerrt. Dieser Punkt bleibt dort für immer, nur der Variablenwert wird geändert. Wenn wir jetzt versuchen, die Zeile a = a.pushedRight(1); auf a.x = 4; zu ändern, erhalten wir einen Kompilierungsfehler, der besagt:
/.../src/javax0/valuetype/Main.java:7: error: cannot assign a value to final variable x
Dabei ist zu beachten, dass das Feld x nicht als endgültig deklariert wurde, sondern automatisch als endgültig gilt, da es sich in einem Werttyp befindet.
Die Unveränderlichkeit als Merkmal steht in starkem Zusammenhang mit der Tatsache, dass es keine Referenzen auf einen ValueType geben kann. Java könnte uns theoretisch erlauben, ein Feld eines ValueTypes zu modifizieren. Das Ergebnis wäre aber im Wesentlichen das gleiche: Wir erhalten einen anderen Wert. Auf diese Weise ist die Unveränderlichkeit bei Werttypen keine Einschränkung. Es geht nur darum, wie wir unser Programm schreiben und wie wir ValueTypes sehen. Wenn man sie als Werte (wie Zahlen) betrachtet, die von Natur aus unveränderlich sind, wäre das sozusagen eine gesunde Denkweise.
Sie können den Early-Access-Release ausprobieren, indem Sie die JEP-Homepage [1] besuchen und außerdem die EA-Version [2] herunterladen. Die Builds sind für Linux, macOS und Windows für die x64-Plattform verfügbar. Die Installation ist zwar nicht so einfach wie bei den Produkten für den Handel, aber für erfahrene Java-Entwickler sollten einige Umgebungsvariableneinstellungen und das manuelle Extrahieren und Verschieben von Dateien an den richtigen Ort kein Problem sein. Sie können versuchen, Eclipse, IntelliJ oder Notepad zu verwenden, um den Quellcode zu bearbeiten, aber zumindest konnte ich mit der IntelliJ-2019.EA-Edition den Code nicht erstellen. Obwohl es sich bei dem Code um einen Fork aus dem Java-11-Quellcode handelt, erkennt IntelliJ ihn als Java 13, was übrigens ein kleiner Hinweis darauf ist, wo wir in der freigegebenen Version Werttypen erwarten können. Die Kompilierung des Codes kann manuell durch Ausführen des Befehls javac über die Befehlszeile und dann des Befehls java zum Starten der JVM erfolgen. Ich bin mit dem IntelliJ-extrahierten Ant-Skript klargekommen, obwohl ich mit Maven besser vertraut bin als mit Ant. Beim Herumspielen habe ich einige unerklärliche Kompilierungsfehler festgestellt, wobei der Code letztendlich fehlerhaft war. Nach Änderung des Codes funktionierte mit der Kompilierung alles gut. Andere Male, als der Fehler definitiv nicht bei mir lag, waren die Fehlermeldungen gut beschrieben und leicht verständlich.
Java entwickelt sich rasant. In den vergangenen zwei Jahren hat sich viel getan, und im Moment sind viele neue Entwicklungen im Gange, die uns in der Zukunft zur Verfügung stehen. Dazu gehört das Merkmal ValueType. Dieser Artikel beschreibt die wichtigsten Aspekte dieses Merkmals und gibt einen kurzen Ausblick auf diese neue Technologie. Java ist zudem eine der wichtigsten Programmiersprachen, vielleicht die zweithäufigste Programmiersprache im professionellen Umfeld nach COBOL. Als Java-Entwickler ist einem der Arbeitsplatz so gut wie sicher. Ständiges Lernen ist jedoch unabdingbar, wenn man professionell und up to date sein möchte. Glücklicherweise handelt es sich bei Java um Open Source und es existieren eine frei verfügbare Mailingliste, Dokumentationen, Quellcode und Testversionen von zukünftigen Versionen, lange bevor sie veröffentlicht werden. Ein leidenschaftlicher Entwickler sollte sich diese ansehen, die EA-Versionen zum Experimentieren verwenden und bereits zum Zeitpunkt des GA-Releases für den Handel die neuen Möglichkeiten kennen. Also los! Schnappen Sie sich die Valhalla-EA-Version von Java und probieren Sie sie aus.
Peter Verhas ist Senior Software Architect bei EPAM Schweiz. Er hat mehr als zehn Jahre Erfahrung in der Java-Entwicklung und mehr als zwanzig Jahre Erfahrung mit C und anderen Programmiersprachen. Zudem ist er Autor der Bücher „Java Projects“, „Mastering Java 9“ und „Java 9 Programming By Example“. Er bloggt außerdem regelmäßig in englischer Sprache (bei DZONE, Java Code Geeks und seinem eigenen Blog Javax0.wordpress.com). Peter hat einen Master in Elektrotechnik und studierte an der TU Budapest, der TU Wien und der TU Delft. Er arbeitete für Unternehmen wie Digital Equipment Corporation, T-Mobile und unterstützte die Telekommunikations- und Finanzbranche. Er war kurzzeitig Lehrer an der TU Budapest. Peter veröffentlicht auch Open-Source-Programme auf GitHub und ist Autor des ScriptBasic-Interpreters.