Suche
Multilingual: Scala-Code in Java-Applikationen

Themen-Dossier Scala: Wie Sie Scala-Code effektiv in Java-Applikationen nutzen

Johannes Dienst

Diese Woche dreht sich auf JAXenter alles um die JVM-Sprache Scala. Zu Beginn gibt uns Johannes Dienst eine Einführung in die grundlegenden Konzepte der Programmiersprache und zeigt, wie diese sich im Zusammenspiel mit Java effektiv in eigenen Anwendungen nutzen lassen.

Multilingual: Scala-Code in Java-Applikationen

Scala ist eine Sprache, die sehr nahe an Java ist – jedenfalls wenn es um den erzeugten Bytecode geht. Manche lassen sich sogar dazu hinreißen, Scala nur als eine weitere externe Bibliothek zu bezeichnen [1], so gut soll die Integration möglich sein. Ist das aber wirklich so? Und wenn nicht, welche Reibungspunkte gibt es?

Scala – Just another Library?

Scala wurde mit der Prämisse entwickelt, ein besseres Java zu sein. Dabei bedient es sich dem Multiparadigmenansatz, der objektorientierte und funktionale Programmierung in einer Sprache vereint. Gerade die funktionalen Aspekte und die pragmatische Mehrfachvererbung mit Traits sind echte Erleichterungen im Entwickleralltag.

In vielen Projekten ist es nicht wirtschaftlich und praktikabel, den kompletten Sourcecode direkt auf Scala umzustellen. Es bietet sich daher an, nur einzelne Schichten der Architektur umzuschreiben. Dabei stellt sich die Frage, wie das Zusammenspiel mit Java funktioniert. Bei der Recherche zu diesem Artikel bin ich auf allerlei falsche Interpretationen gestoßen, die bei Entwicklern entstehen können. Der wildeste Auswuchs davon dürfte die Annahme sein, dass es doch umständlich sei, ein Funktionsobjekt aus Scala in Java zu instanziieren und dann aufzurufen.

Die saubere Trennung der Schichten steht daher im Vordergrund. Es werden also einige grundlegende Fragen zu beantworten sein: Wie geht man zum Beispiel mit Scala Collections in Java-Code um? Oder wie fügen sich Traits in das Vererbungskonzept von Java ein?

scala-iconDossier Scala
Diese Woche dreht sich auf JAXenter alles um die JVM-Sprache Scala. Entwickelt ab 2001 von Martin Odersky im Labor für Programmiermethoden an der École polytechnique fédérale de Lausanne, wurde die Sprache schnell als eines der heißestes Eisen im Java-Ökosystem gehandelt, mit dem Potenzial, die Innovationsflaute in der Java-Programmiersprache zu beenden. Der Hype ist mittlerweile abgeflaut, und spätestens seit der Integration von Lambda-Ausdrücken in Java 8, mit denen funktionale Programmierelemente auch in Java selbst Einzug hielten, stellt sich die Frage nach der Position Scalas im Raum der JVM-Sprachen neu.
Wir versuchen, hier auf JAXenter im Themen-Dossier „Scala“ eine Antwort zu geben. Martin Odersky ordnet die Evolution Scalas für uns im Interview ein, wir beleuchten den reaktiven Technologie-Stack um Scala in all seinen Facetten, Scala-Entwickler berichten von ihren persönlichen Scala-Tops und Flops. Zu Beginn des Dossiers gibt uns Johannes Dienst im vorligenden Artikel eine Einführung in die grundlegenden Konzepte der Sprache und zeigt, wie diese sich im Zusammenspiel mit Java effektiv in eigenen Anwendungen nutzen lassen.

Wo geht die Reise hin?

Der Artikel betrachtet zuerst die Datentypen von Scala. Anschließend sind die Sprachfeatures an der Reihe, die in Java keinen vergleichbaren Gegenpart besitzen. Nachdem dieser unerfreuliche Punkt abgehandelt wurde, geht es Schritt für Schritt über Operatorüberladung, Tupel und Defaultparameter zu den mächtigen Collections, die nur noch mit leichten Tücken behaftet sind. Als Vorletztes kommt die Vererbung mit Traits auf den Prüfstand, die fast schmerzfrei mit Java interagiert. Den versöhnlichen Abschluss bilden dann Generics und statische Methoden in Companion-Objekten, die – wie nicht anders zu erwarten – sehr gut mit Java funktionieren.

Polyglottes_Programmieren_mit_Java_Core-220x293Lesetipp:
Wenn Sie wissen wollen, wie sich weitere JVM-Sprachen im Zusammenspiel mit Java verhalten, dann sei Ihnen der Shortcut: Polyglottes Programmieren in Java Core empfohlen.
Neben Scala nimmt sich Johannes Dienst hier die Sprachen Groovy, Clojure und Jython vor und zeigt, wie sich diese effektiv in Java-Applikationen nutzen lassen.

Primitive Datentypen – Wie sieht’s in Scala aus?

In Scala gibt es im Gegensatz zu Java keine primitiven Datentypen, stattdessen wird jeder Datentyp als Objekt dargestellt. Im Hintergrund ist dafür ein Mechanismus wie das Un-/Boxing in Java verantwortlich. In Abbildung 1 ist die Hierarchie zu sehen, auf der die zugehörigen Klassen abgebildet sind.

dienst_multilingual_scala_1

Abb. 1: Datentypen in Bezug zur Scala-Typhierarchie (zum Vergrößern klicken)

Wie aus dem Codebeispiel aus der Scala-Dokumentation [2] in Listing 1 hervorgeht, ist die Arbeit mit primitiven Datentypen dadurch um vieles einfacher.

object UnifiedTypes extends App {
  val set = new scala.collection.mutable.LinkedHashSet[Any]
  set += "This is a string"  // add a string
  set += 732                 // add a number
  set += 'c'                 // add a character
  set += true                // add a boolean value
  set += main _              // add the main function
  val iter: Iterator[Any] = set.iterator
  while (iter.hasNext) { println(iter.next.toString()) }
}

 

 

Im Zusammenspiel mit Java gibt es keine Überraschungen. Alle Typen erscheinen in der JVM als für Java kompatible Typen, wie an den folgenden beiden Beispielen ersichtlich ist (Listing 2).

// returns int for Java calls
def theMeaningOfLife(): Int = {
  42
}

// returns Object for Java calls
def thePurposeOfLife(): Any = {
  42
}

 

 

Inkompatible Features: Was geht, was geht nicht?

Zuerst sollen die Sprachkonstrukte aus Scala besprochen werden, die in keinem Fall in Java verwendet werden können. Hier ist das Pattern Matching mit Case Classes zu nennen. Dieses hat keine Entsprechung in Java und kann dort nicht abgebildet werden, da es außer dem primitiven Switch Statement keine vergleichbaren Möglichkeiten in Java gibt. Abhilfe bietet in Java eine andere Herangehensweise über das Besuchermuster an (Details siehe [3]).

Ein weiteres Feature von Scala sind so genannte Implicits [4], die vor allem im Zusammenspiel mit APIs als Hilfsmittel dienen. Sie sorgen unter anderem für die implizite Konvertierung von inkompatiblen Übergabetypen zu den entsprechenden Typen. Ein Beispiel macht die Funktionsweise deutlicher: In Listing 3 wird ein Implicit erzeugt, der einen String wie eine Instanz von RandomAccessSeq erscheinen lässt.

implicit def stringWrapper(s: String) =
  new RandomAccessSeq[Char] {
  def length = s.length
  def apply(i: Int) = s.charAt(i)
}

Dadurch kann ein String entweder explizit als RandomAccessSeq behandelt werden oder aber der Compiler erkennt, wie im Beispiel weiter unten, dass die Methode exists nicht von der Klasse String bereitgestellt wird. Anschließend sucht er eine passende Konvertierung, die er in stringWrapper findet, und konvertiert den String implizit zu RandomAccessSeq:

scala> stringWrapper("abc123") exists (_.isDigit)
  res0: Boolean = true
scala> "abc123" exists (_.isDigit)
  res1: Boolean = true

Da der Java-Compiler diese implizite Konvertierung nicht beherrscht, ist das Sprachfeature in Java nicht nutzbar.

In Scala kein Problem: Operatorüberladung

Ein heikles Feature wie Operatorüberladung wird in Java sobald wohl keinen Einzug finden. In Scala ist es aber möglich, genau das zu tun. Um dieses Kunststück zu vollbringen, bedient sich der Compiler eines Tricks. Er benutzt eine spezielle Schreibweise $operatorname für die Operatoren. So wird aus der überladenen +-Funktion im kompilierten Zustand $plus. Listing 4 zeigt, wie eine Operatorüberladung in Scala implementiert wird und wie die überladene Funktion in Java benutzt werden kann.

// Scala class with operator overloading
class Number(val value: Double) {
  def +(that: Number) =
    new Number(this.value + (2 * that.value))

  override def toString = "".concat(this.value.toString())
}

// Java
Number numberOne = new Number(1.5);
Number numberTwo = new Number(2.7);
// Prints: + is $plus: 6.9
System.out.println(" + is $plus: " + numberOne.$plus(numberTwo));

In Tabelle 1 (vollständig unter [5]) sind die wichtigsten Operatoren mit ihren generierten Methodennamen abgebildet.

Operatoren aus Scala Methodennamen in Java
= $eq
> $greater
< $less
+ $plus
$minus
* $times

Tabelle 1: Operatoren aus Scala mit ihren entsprechenden Java-Methodennamen

Tupel sind eine nette Sache

In Scala sind Tupel eine nette Sache, in die man Instanzen von verschiedenen Typen ablegen kann, ohne sich weiter darüber Gedanken machen zu müssen. Ein Tupel mit einem Int und einem String erzeugt man zum Beispiel so:

val tuple = ( 42, "Happy Tree Friends" )
println( tuple.getClass ) // Tuple2

 

Im kompilierten Code sind tatsächlich keine Typinformationen mehr vorhanden. So kann zum Beispiel eine Methode, die ein Tupel zurückgibt, in Java ohne Probleme aufgerufen werden. Es muss nur der Typ des Tupels bekannt sein (Tuple2 in obigen Fall). Sollte ein solcher Fall eintreten, ist kritisch zu prüfen, ob es sinnvoll ist, den Java-Code an eine Scala-Klasse zu binden. Dadurch wird es schwierig, diese Codestelle unabhängig weiterzuentwickeln.

Die Arbeit mit Tupeln selbst ist in Java nicht so angenehm wie in Scala. Aber sie ist möglich. In Listing 5 wird eine Tupelinstanz erzeugt und deren Werte ausgelesen.

import scala.Tuple2;

Tuple2<String, String> creator = new Tuple2<>("Martin", "Odersky");
System.out.println(creator._1 + " " + creator._2); // Martin Odersky

Defaultparameter: Was ist zu beachten?

In Java gibt es immer wieder die Situation, dass Funktionen mit einer reduzierten Parameterliste überladen werden. Das kann so aussehen:

public void doing() { doing("") }
public void doing(String thing) { System.out.println(thing) }

 

In Scala gibt es für diesen Fall Defaultparameter, die direkt in der Klassen- oder Methodendeklaration angegeben werden. Ein paar Besonderheiten bei der Interaktion mit Java sind dabei zu beachten. Listing 6 zeigt die Implementierung einer Klasse mit zwei Defaultparametern a und b.

class DefaultParam(a: Double = 2.0, b: Double) {

  def this() { this(b = 4.0) }
  
  def add(one: Double = 2.0, two: Double) =
    one + two

  override def toString = "a is: " + a + " b is: " + b
}

 

Auf den ersten Blick ist der sonderbare Defaultkonstruktor auffällig. Dieser ist unbedingt nötig, da sonst keine Instanziierung mit leerer Parameterliste aus Java heraus möglich ist (siehe dazu auch [6]). Dieser wird vom Scala-Compiler nicht automatisch generiert. Am einfachsten ist es, einen beliebigen Parameter, wie in diesem Beispiel b, im Defaultkonstruktor zu setzen. Eine neue Instanz nimmt dann die Defaultwerte an:

DefaultParam dParam = new DefaultParam();
dParam.toString(); // a is: 2.0 b is: 4.0

Ansonsten wird nur ein zusätzlicher Konstruktor erzeugt, der zwei Parameter erwartet:

// dParam = new DefaultParam(4.0) // Not defined!!
dParam = new DefaultParam(5.0, 7.0);
dParam.toString(); // a is: 5.0 b is: 7.0

 

Bei der Methode add sieht es nicht ganz so gut aus wie beim Konstruktor. Sie lässt sich nur mit zwei Parametern aufrufen:

dParam.add(3.0, 4.0); // 7.0

Es bleibt dann doch bloß der Umweg über eine oder mehrere überladene Methoden.

Collections mit Tücken

Wie in Java, gibt es auch in Scala ein mächtiges Collections-API. Dieses ist aber durch funktionale Methoden erweitert. Um eine Grundlage für die Diskussion zu haben, wird in Listing 7 eine Klasse Conversions mit der Methode javaList implementiert. Diese gibt eine java.util.ArrayList zurück.

class Conversions {
  def javaList = {
    var list = new java.util.ArrayList[String]()
    list.add("I")
    list.add("am a list")
    list
  }
}

 

Die Arbeit mit Java Collections in Scala funktioniert bis auf wenige Einschränkungen analog zu Scala Collections. Leider sind funktionale Methoden wie foreach in den Java-Implementierungen nicht vorhanden. Dafür stellt Scala das JavaConversions-API [7] bereit, mit dem Java Collections in Scala Collections umgewandelt werden können. Listing 8 zeigt sowohl die implizite Konvertierung von einer java.util.List in einen scala.collection.mutable.Buffer, als auch die explizite Umwandlung. Damit das funktioniert, ist der Import von asScalaBuffer aus dem JavaConversions-API notwendig.

import scala.collection.JavaConversions.asScalaBuffer

val conversions = new Conversions();
val list = conversions.javaList // java.util.ArrayList
list.foreach(println): // Compiles only with JavaConversions
val buffer = asScalaBuffer(list)
buffer.foreach(println)

 

Die Konvertierung funktioniert nicht nur mit einem Buffer, sondern mit sämtlichen Datenstrukturen wie Sets, (Concurrent)Maps, Dictionaries und den Interfaces Collection, Iterable, Iterator und Enumeration.

Die andere Richtung von Scala nach Java wird von JavaConversions ebenfalls abgedeckt. Dazu ist aber eine explizite Konvertierung notwendig. Listing 9 zeigt eine Methode sum, die eine java.util.List erwartet. Darunter werden nacheinander die Scala-Datenstrukturen Seq, ArrayBuffer und ListBuffer konvertiert und an diese Methode übergeben.

public class JConversions {
  public static int sum(List<Integer> list) {
    int sum = 0;
    for (int i: list) { sum = sum + i; }
    return sum;
  }
}

// Scala
import scala.collection.JavaConversions._
import scala.collection.mutable._

val sum1 = JConversions.sum(seqAsJavaList(Seq(1, 2, 3)))
val sum2 = JConversions.sum(bufferAsJavaList(ArrayBuffer(1,2,3)))
val sum3 = JConversions.sum(bufferAsJavaList(ListBuffer(1,2,3)))

 

Implizite Konvertierung kann unter Umständen zu Problemen führen [9]. Deswegen kann auch explizit konvertiert werden, mit dem JavaConverters-API [10]. Eine Auswahl von expliziten Konvertierungsmethoden findet sich in Listing 10. Mit diesen ist eine feinere Steuerung durch den Entwickler möglich.

val aList = Seq(1, 2, 3)
aList.asJavaCollection // Collection[Int]

val aSet = ArrayBuffer(1,2,3)
aSet.asJava // List[Int]

val aMap = Map(1 -> 2)
aMap.asJava // Map[Int, Int]
aMap.asJavaDictionary // Dictionary[Int, Int]

Traits mit kleinen Hindernissen

Die Vererbung mit Traits in Scala ist flexibler als die Vererbung mit Interfaces in Java. Durch sie wird eine pragmatische Mehrfachvererbung möglich. Im Hinblick auf das Java-Vererbungskonzept ergeben sich dadurch Fragen zur Kompatibilität von Traits. Am besten werden diese mit einigen Codebeispielen beantwortet. Zuerst wird in Listing 11 eine Klasse Person implementiert, die eine Methode greet enthält. Als Zweites steht die Erzeugung eines Traits an, der eine implementierte Methode reviewCode enthält. Hier zeigt sich schon der erste Unterschied zu Java-Interfaces. Diese enthalten erst ab Java 8 die Möglichkeit der Defaultmethoden. Dieser Artikel beruht auf Scala 2.11, das Java-6/-7-kompatiblen Bytecode erzeugt. Erst mit Version 2.12 wird es dafür eine elegantere Lösung geben.

class Person(name: String) {
  def greet() = println("Hello, my name is: " + name)
}

trait Reviewer {
  def reviewCode() = println("Reviewing Code...")
}

Aus dem Trait Reviewer wird tatsächlich ein Java-Interface generiert und eine dazugehörige Klasse Reviewer$class mit der implementierten Methode reviewCode. Diese Tatsache erlaubt es, das erzeugte Java-Interface Reviewer zu implementieren und trotzdem die Methode nicht neu schreiben zu müssen. Auf die erzeugte (und von Eclipse nicht erkannte) Klasse kann nämlich zugegriffen werden, wie Listing 12 zeigt.

public class HappyReviewer extends Person implements Reviewer {

  public HappyReviewer(String name) {
    super(name);
  }
  
  public void reviewCode() {
    Reviewer$class.reviewCode(this);
  }
}

 

Dieser Code funktioniert tadellos:

HappyReviewer happyReviewer = new HappyReviewer("Zoe");
happyReviewer.greet(); // Hello, my name is: Zoe
happyReviewer.reviewCode(); // Reviewing Code...

 

Eine andere Möglichkeit ist es, eine Art Delegationsklasse zwischenzuschalten. Die Scala-Klasse Tester in Listing 13 wird vom Compiler in eine funktionsfähige Java-Klasse umgewandelt. Diese kann als Ziel einer Erweiterung dienen, wie in der Klasse FriendlyTester.

class Tester(name: String) extends Person(name) with Reviewer {}

// Java
public class FriendlyTester extends Tester {
  public FriendlyTester(String name) {
    super(name);
  }
}

 

Mit diesem kleinen Workaround umgeht man die seltsam anmutende Konstruktion mit der erzeugten $class:

FriendlyTester friendlyTester = new FriendlyTester("Amelie");
friendlyTester.greet();
friendlyTester.reviewCode();

Was geht einfach?

Erfreulicherweise gibt es auch einige Dinge, die ohne Probleme im Zusammenspiel mit Java funktionieren. An erster Stelle sind Generics zu nennen. In Listing 14 wird eine Klasse Stack in Scala definiert. Der Compiler erzeugt daraus eine Klasse, die äquivalent zur zweiten Klassendefinition in Java ist. Damit lässt sich auch in Java der Stack mit Typparameter benutzen, wie man es von Generics gewohnt ist.

// Scala class with generics
class Stack[T] {
  def push(x: T) { elems = x :: elems }
  ...
}

// Java equivalent
public class Stack<T> {
  ...
}

// Call from Java
Stack<Integer> stack = new Stack<>();
stack.push(27);

Eine echte Erleichterung stellen in Scala die automatisch generierten Getter und Setter für Klassenvariablen dar. Dadurch erspart die Sprache dem Entwickler mechanische Arbeit, und er kann sich auf das Wesentliche konzentrieren. Im Hinblick auf die Interaktion mit Java-Frameworks gibt es aber einen Wermutstropfen. Die vom Scala-Compiler generierten Getter und Setter entsprechen nicht dem Bean-Standard. Dependency-Injection-Frameworks zum Beispiel beruhen aber auf diesem Standard.

Für dieses Problem gibt es die Annotation @BeanProperty, die Klassenvariablen dem Bean-Standard entsprechende Getter und Setter spendiert. Listing 15 zeigt zwei Klassen Kitty und Monkey, jeweils mit einer Klassenvariable name.

class Kitty {
  @BeanProperty
  var name = "Hello Kitty"
}

class Monkey {
  var name = "Abe"
}

In der Klasse Kitty ist name mit der Annotation @BeanProperty versehen. Es werden die entsprechenden Getter und Setter generiert:

Kitty kitty = new Kitty();
kitty.getName();
kitty.setName("Happy Kitty");

 

In der Klasse Monkey fehlen diese Methoden. Es sind nur die für Scala typischen Methoden vorhanden:

Monkey monk = new Monkey();
monk.name();
// setName() missing

 

Was ist mit statischen Methoden?

Im Hinblick auf statische Methoden geht Scala einen anderen Weg als Java. Es gibt so genannte Companion-Objekte, die idealerweise in derselben Datei wie die Klasse beheimatet sind. Methoden, die in diesem Objekt liegen, sind statische Methoden. Listing 16 zeigt eine statische Methode tasksLeft für die Klasse Tester. Der Scala-Compiler wandelt diese in eine statische Methode der Klasse Tester um. Der Aufruf gestaltet sich dann wie aus Java gewohnt:

// Prints: I have 21 tasks left
System.out.println("I have" + Tester.tasksLeft(42, 21) + " tasks");

 

Ebenfalls möglich ist der Aufruf über eine Instanz der Klasse, so wie es bei normalen statischen Java-Methoden der Fall ist.

object Tester {
  def tasksLeft(tasksTotal: Int, tasksFinished: Int) = {
    tasksTotal - tasksFinished
  }
}

 

Um den Rahmen des Artikels nicht zu sprengen, kann an dieser Stelle im Hinblick auf das in Kürze anstehende Release von Scala 2.12 (Roadmap unter [11]) nur angesprochen werden, was sich bezüglich dieses Artikels ändern wird.

Der wohl interessanteste Punkt ist die @interface-Annotation für Traits. Ein mit dieser Annotation versehener Trait mit Methodenimplementierungen wird zu einem Java-Interface mit den entsprechenden DefaultImplementierungen. Die Handhabung von Traits in Java vereinfacht sich dadurch erheblich.

Fazit

Es wurde gezeigt, dass Scala in weiten Teilen mit Java gut harmoniert. So funktionieren die von Scala bereitgestellten Datentypen ausnahmslos im Zusammenspiel mit Java. Da Scala auch mächtigere Konstrukte bereithält, gibt es auch Features, die in Java nicht integriert werden können. Darunter fallen das Pattern Matching und implizite Konvertierungen. Zahlreicher sind die Funktionen, die mit Einschränkungen zu nutzen sind. Operatorüberladung gibt es in Java nicht direkt. Der Scala-Compiler generiert dafür Methoden mit dem Schema $operatorname, die explizit aufgerufen werden können. Mit kleinen Einschränkungen sind Defaultparameter zu genießen. Hier ist das Weglassen von einzelnen Parametern nicht möglich, da Java immer eine vollständige Parameterliste erwartet.

Sowohl in Java als auch in Scala geht nichts ohne Collections. Scala stellt zur Konvertierung zwischen den einzelnen APIs zwei Bibliotheken bereit. JavaConversions, die – in Scala sogar implizit – zwischen den Collection-Implementierungen konvertieren kann. Da durch deren unbedarfte Verwendung Probleme mit der Typsicherheit entstehen können, wurden zusätzlich noch JavaConverters eingeführt. Dieses überlässt dem Entwickler die Aufgabe, die passende Konvertierung auszuwählen.

In Scala 2.11, das Java-6/-7-Bytecode als Ziel hat, sind Defaultmethoden nur über einen kleinen Workaround möglich. Erst mit Scala 2.12 und Java 8 als Ziel kann es an dieser Stelle eine saubere Lösung geben. Bis dahin muss bei Traits mit Methodenimplementierungen ein Umweg über eine vom Scala-Compiler erzeugte Klasse gegangen werden. Oder das Problem wird dadurch gelöst, dass eine Klasse den Trait erweitert und dadurch die Implementierung in diese Klasse wandert. Dieser Umstand ist aber zu verschmerzen, da das Vererbungskonzept mit Traits mächtiger ist als das Vererbungskonzept in Java.

Erfreulicherweise gibt es auch Bereiche, in denen die Interaktion mit Java problemlos verläuft. So sind Generics tatsächlich nur Generics. Getter und Setter, die für die Bean-Spezifikation und damit auch für viele Frameworks notwendig sind, können mit der Annotation @BeanProperty automatisch hinzugefügt werden. Zu guter Letzt werden statische Methoden aus Companion-Objekten wie normale statische Methoden behandelt.

Alles in allem wird Scala seinem Ruf gerecht, hervorragend im Zusammenspiel mit Java zu funktionieren. Die Einführung in den eigenen Technologiestack sollte mit dieser Sprache wohl am einfachsten gelingen.

Happy Coding!

Verwandte Themen:

Geschrieben von
Johannes Dienst
Johannes Dienst
Johannes Dienst ist Clean Coder aus Leidenschaft bei der MULTA MEDIO Informationssysteme AG. Seine Tätigkeitsschwerpunkte sind die Wartung und Gestaltung von serverseitigen Java- und JavaScript-basierten Applikationen. Twitter: @JohannesDienst
Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.