Webserver-Applikation Teil 2 mit dem Vert.x-Framework – Währungsumrechner

Lesen Sie den zweiten Teil der Währungsrechner-Applikation mit dem Vert.x-Web-Framework: Währungen importieren, Klassen initialisieren & testen.

Autor Mirko Eberlein
Datum 05.07.2017
Lesezeit 17 Minuten

Im ersten Teil unserer kleinen Währungsumrechnungs-Applikation habe ich euch gezeigt, wie man ein Vert.x-Maven-Projekt aufsetzt und wir haben unseren ersten kleinen Service geschrieben. Der Webservice hat eine statische Ressource geliefert und wir konnten unseren kleinen Webserver testen. Wir wollen ja einen Betrag von einer Währung in eine andere umrechnen. Überlegen wir zuerst, was wir dafür benötigen. Na, habt ihr es schon? Richtig, wir benötigen erst einmal alle benötigten Währungen. Anschliessend einen Service, der unseren Dienstnutzern sagt, welche Währungen verfügbar sind. Zum Abschluss einen Service, der bei einer Anfrage eine Berechnung durchführt. Fangen wir an.

Aktuelle Währungen abgreifen und einbinden

Zuerst überlege ich mir, woher ich die Währungen bekommen kann. Ich habe mich für einen Service der Europäischen Zentralbank entschieden. Unter www.ecb.europa.eu werden täglich die aktuellen Währungskurse als XML angeboten.

Vert.x Währungsrechner

Wenn wir uns das File anschauen, so sehen wir in dem XML jeweils ein Währungskürzel und den aktuellen Stand dieses Kurses zum Euro. Zuerst werden wir also eine Klasse erstellen, die den Import vornimmt. Da unsere Applikation schnell laufen soll und wir ausser der Berechnung nichts auf dem Server ausführen wollen, habe ich mir überlegt, die Liste mit den Währungen in einem Singleton zu halten. Da ich auch nur die Währungskürzel im XML zur Verfügung habe, werde ich ein zweites Singleton mit den vollen Währungsnamen vorhalten.

Die Singletons erstellen wir als Enum-Objekte – ganz wie es Joshua Bloch in seinem Buch «Effective Java» empfiehlt.

public enum CurrencyNames {
INSTANZCE;
private Map<String,String> nameMap = new HashMap<String, String>();
private CurrencyNames(){
   nameMap.put("EUR", "Euro");
   nameMap.put("USD", "US Dollar");
   nameMap.put("JPY", "Japanischer Yen");
   nameMap.put("BGN", "Bulgarische Lew");
   nameMap.put("CZK", "Tschechische Krone");
   nameMap.put("DKK", "Dänische Krone");
   nameMap.put("GBP", "Britisches Pfund");
   nameMap.put("HUF", "Ungarischer Forint");
   nameMap.put("PLN", "Polnischer Zloty");
   nameMap.put("RON", "Rumänischer Leu");
   nameMap.put("SEK", "Schwedische Krone");
   nameMap.put("CHF", "Schweizer Franken");
   nameMap.put("NOK", "Norwegische Kronen");
   nameMap.put("HRK", "Kroatische Kuna");
   nameMap.put("USD", "US Dollar");
   nameMap.put("RUB", "Russischer Rubel");
   nameMap.put("TRY", "Türkische Lira (neu)");
   nameMap.put("AUD", "Australischer Dollar");
   nameMap.put("BRL", "Brasilianischer Real");
   nameMap.put("CAD", "Kanadischer Dollar");
   nameMap.put("CNY", "Chinesischer Renminbi / Yuan");
   nameMap.put("HKD", "Hongkong-Dollar");
   nameMap.put("IDR", "Indonesische Rupiah");
   nameMap.put("ILS", "Schekel (Israel)");
   nameMap.put("INR", "Indische Rupie");
   nameMap.put("KRW", "Südkoreanischer Won");
   nameMap.put("MXN", "Mexikanischer Peso");
   nameMap.put("MYR", "Malaysischer Ringgit");
   nameMap.put("NZD", "Neuseeländischer Dollar");
   nameMap.put("PHP", "Phillipinischer Peso");
   nameMap.put("SGD", "Singapur-Dollar");
   nameMap.put("THB", "Thailändischer Baht");
   nameMap.put("ZAR", "Südafrikanischer Rand");
}
public Map<String,String> getNameMap(){
   return nameMap;
}
public String getName(String key){
   return nameMap.get(key);
}
}

Dies ist ein einfaches Singleton, in dem ich die Ländernamen in einer HashMap hinterlegt habe.

INSTANCE ist der einzige Wert in meinem Singleton und so kann ich sicher sein, dass die Map in der Applikation nur einmal erstellt wird. Da sie auch vom Umfang nicht so gross ist, wird der Speicher nicht extrem belastet. Das zweite Singleton ist ziemlich ähnlich.

public enum CurrencyData {
INSTANZCE;
private Map<String,Currency> currencyMap = null;
private CurrencyData(){}
private Date date;
private SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd");
private boolean chkDate() {
   if(this.date != null){
      Date now = new Date();
      if(!sdf.format(now).equals(sdf.format(date))){
         return true;
      }
   }
   return false;
}
private void fillMap(){
   CurrencyImportService.importXML();
}
public Map<String, Currency> getCurrencyMap() {
   if(currencyMap == null || chkDate()){
      fillMap();
   }
   return currencyMap;
}
public void setCurrencyMap(Map<String, Currency> currencyMap) {
   this.currencyMap = currencyMap;
}
public Date getDate() {
   return date;
}
public void setDate(Date date) {
   this.date = date;
}
}

Auch hier gibt es eine INSTANCE. Ausserdem gib es eine Map, die als Key das Währungskürzel verwendet und als Objekt je eine Currency enthält. Es gibt ausserdem ein Datumsobjekt (date), um das letzte Update zu kennzeichnen.

Der Ablauf ist wie folgt: Wird versucht, auf die Liste zuzugreifen, so wird geprüft, ob die Liste existiert. Existiert die Liste, wird geprüft, ob das Datum «Heute» entspricht. Dafür wird ein SimpleDateFormat in der Form yyyyMMdd erstellt, das dann zwei Strings erstellt, die miteinander verglichen werden können. Die Map ist nur beim Erstellen des Servers «null». Sobald sie einmal initialisiert wird, liegt sie im Speicher und muss nicht erneut initialisiert werden. Falls das Datum abgelaufen ist, wird sie ebenfalls neu initialisiert.

Klassen initalisieren

Schauen wir uns nun die Klasse zum Initialisieren an. Ich erstelle da das Projekt. Nicht so gross ist auch die Klasse CurrencyImportService im Package me.digi.

public class CurrencyImportService {
private static final String ezbPath = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml";
private static Map<String,Currency> currencyMap;

public static void importXML(){
   URL url;
   try {
     url = new URL(ezbPath);
     URLConnection connection = url.openConnection();
     Document doc = parseXML(connection.getInputStream());
     NodeList cubeNodes = doc.getElementsByTagName("Cube");
     currencyMap = new HashMap<>();
     Currency euro = new Currency();
     euro.setCourse(new BigDecimal(1));
     final String euroCode = "EUR";
     euro.setCode(euroCode);
     euro.setName(CurrencyNames.INSTANZCE.getName(euro.getCode()));
     currencyMap.put(euroCode, euro);
     for (int i = 0; i < cubeNodes.getLength(); i++) {
        handleData(cubeNodes.item(i));
     }
     CurrencyData.INSTANZCE.setCurrencyMap(currencyMap);
  } catch (Exception e) {
     System.out.println("Error Message " + e);
  }
}
private static void handleData(Node item){
  if (item.hasAttributes()) {
      Node currencyNode = item.getAttributes().getNamedItem("currency");
     if (currencyNode != null) {
       Node rateNote = item.getAttributes().getNamedItem("rate");
       String rateString = rateNote.getNodeValue();
       String currencyString = currencyNode.getNodeValue();
       Currency currency = new Currency();
       currency.setCourse(new BigDecimal(rateString));
       currency.setCode(currencyString);
       currency.setName(CurrencyNames.INSTANZCE.getName(currencyString));
       currencyMap.put(currencyString, currency);
     }
  }
}
private static Document parseXML(InputStream inputStream) {
  DocumentBuilderFactory objDocumentBuilderFactory = null;
  DocumentBuilder objDocumentBuilder = null;
  Document doc = null;
  try {
     objDocumentBuilderFactory = DocumentBuilderFactory.newInstance();
     objDocumentBuilder = objDocumentBuilderFactory.newDocumentBuilder();
     doc = objDocumentBuilder.parse(inputStream);
  } catch (Exception exception) {
     System.out.println("error here " + exception);
  }
  return doc;
}
}

Ich habe bewusst auf Java Doc und Error Handling verzichtet, um euch das Grundprinzip einfach zu vermitteln. Die statische Funktion importXML ist der eigentliche Aufruf der Klasse. Den Pfad zur EZB habe ich statisch angelegt. Zuerst baue ich die URLConnection über das URLObjekt auf. Ich erstelle ein Dokument aus dem InputStream. Anschliessend hole ich mir eine NodeList aus dem soeben erstellten Document der URL. Die Objekte zum Verarbeiten des XML nehme ich aus dem Package org.w3c.dom. Ich lese also in meine NodeList alle Elemente aus dem XML-Element «Cube» ein. Wenn wir uns die Import-Seite anschauen, sehen wir, dass jede Währung in einem Cube-Element eingetragen ist. Zuerst füge ich das Euro-Objekt zu meiner Liste hinzu. Anschliessend iteriere ich über die erstellte NodeList. Dabei rufe ich für jede Node die Funktion handleData auf. Diese liest den Kurs und das Währungskürzel aus und erstellt ein neues Currency Object.

public class Currency {
private String code;
private BigDecimal course;
private String name;
public Currency(){}
public String getCode() {
   return code;
}
public void setCode(String code) {
   this.code = code;
}
public BigDecimal getCourse() {
   return course;
}
public void setCourse(BigDecimal course) {
   this.course = course;
}
public String getName() {
   return name;
}
public void setName(String name) {
   this.name = name;
}
}

Das Currency-Objekt habe ich im Package business angelegt. Es ist eine einfache Datenklasse mit Gettern und Settern. Die Grundlogik haben wir erstellt. Nun müssen wir noch die Klasse zum Berechnen der Wechselkurse erstellen. Da es beim Java Type Double zu Rundungsfehlern kommen kann, verwende ich bei den Berechnungen BigDecimal. Dies ist ein allgemeiner Standard bei mathematischen Berechnungen.

Ich werde also noch zwei Klassen erstellen; eine, die die Berechnungen erledigt und eine zweite, der ich den Namen ServiceClass gebe. Diese dient nur dazu, dass meine ApplicationVerticle-Klasse nicht zu voll wird. Schauen wir uns zuerst die Berechnungsklasse an. Ich zeige hier einmal die Testklasse, die ich unter src/main/test anlege und als zweites die Berechnungsklasse. Im Idealfall sollte man, wenn man Test Driven entwickeln möchte, zuerst den Test schreiben und anschliessend die Klasse definieren. Dabei führt man den Test solange aus, bis er funktioniert.

Wenn Sie eine Funktion oder Klasse aufrufen, die noch nicht existiert, so bietet Eclipse, wenn Sie mit der Maus über den Syntaxfehler gehen, gleich die Möglichkeit, die Klasse direkt zu erstellen. Das vereinfacht den Aufbau einer Applikation ungemein.

public class CalculateTest {
  @Test
  public void calculateTest(){
     BigDecimal from = new BigDecimal(1);
     BigDecimal to = new BigDecimal(1.2);
     BigDecimal testOne = round(NumberCalculation.change(from, to, new BigDecimal(1)),1);
     assertTrue(round(new BigDecimal(1.2),1).equals(testOne));
     from = new BigDecimal(0.8);
     to = new BigDecimal(7.1);
     BigDecimal testTwo = round(NumberCalculation.change(from, to, new BigDecimal(1)),3);
     assertEquals(round(new BigDecimal(8.875),3),testTwo);
     from = new BigDecimal(0.001);
     to = new BigDecimal(1000);
     BigDecimal testThree = NumberCalculation.change(from, to, new BigDecimal(1)).setScale(0,RoundingMode.HALF_UP);
     assertEquals(round(new BigDecimal(1000000),0), testThree);
  }
  private BigDecimal round(BigDecimal n,int numbers){
     BigDecimal ret = n.setScale(numbers, RoundingMode.HALF_UP);
     return ret;
  }
}

Testklasse CalculateTest

public class NumberCalculation {
  public static BigDecimal change(BigDecimal from,BigDecimal to,BigDecimal value){
     return round(new BigDecimal(1).divide(from,6,RoundingMode.HALF_UP).multiply(to).multiply(value),2);
  }
  public static BigDecimal getRelation(BigDecimal from,BigDecimal to){
     return round(new BigDecimal(1).divide(from).multiply(to),2);
  }
  private static BigDecimal round(BigDecimal n,int numbers){
     BigDecimal ret = n.setScale(numbers, RoundingMode.HALF_UP);
     return ret;
}
}

Berechnungsklasse

In der Testklasse erstelle ich ausgesuchte Testfälle, von denen ich das Ergebnis kenne. Dafür lege ich immer die Zahlen als BigDecimal an. Beim Berechnen in der NumberCalculation-Klasse, begrenze ich die Nachkommastellen auf 6. Ich habe allgemein nur zwei Funktionen. Eine zum Berechnen eines Ergebnisses mit einer angegebenen Summe. Zum Beispiel CHF 100 in Dollar. Die andere Funktion ist nur dazu da, den Kurs zwischen zwei Währungen zu ermitteln. Diese Klassen kann man auch gänzlich ohne die anderen Klassen in dem Projekt verwenden. Rein theoretisch könnte man sie sogar verwenden, um Kilogramm in Gramm umzurechnen. In einem grossen Projekt könnte man dies sogar auslagern.

Kommen wir nun zur Serviceklasse.

public class ServiceClass {
  public static JsonArray getAllCurrencys() {
     JsonArray json = new JsonArray();
     for(Entry<String, Currency> entry : CurrencyData.INSTANZCE.getCurrencyMap().entrySet()){
        JsonObject cur = new JsonObject();
        cur.put("code", entry.getValue().getCode());
        cur.put("name", entry.getValue().getName());
        cur.put("course", String.valueOf(entry.getValue().getCourse()));
        json.add(cur);
     }
     return json;
  }
 public static JsonObject calculateCourse(String from,String to,String value){
     JsonObject json = new JsonObject();
     System.out.println("from " + from + " " + to + " " + value);
     BigDecimal fromNumber = CurrencyData.INSTANZCE.getCurrencyMap().get(from).getCourse();
     BigDecimal toNumber = CurrencyData.INSTANZCE.getCurrencyMap().get(to).getCourse();
     BigDecimal valueNumber = new BigDecimal(value);
     BigDecimal result = NumberCalculation.change(fromNumber, toNumber, valueNumber);
     BigDecimal relation = NumberCalculation.getRelation(fromNumber, toNumber);
     json.put("result",result.toString());
     json.put("relation", relation);
     return json;
  }
}

Diese enthält zwei Funktionen. Die getAllCurrencys wandelt unsere Map mit den Währungen in ein JsonArray um und gibt es zurück. Die zweite Funktion calculateCourse ist zum Berechnen da. Sie gibt ein JsonObject mit Resultat der Berechnung und dem aktuellen Kurs zurück. Die Umwandlung machen wir, damit wir in unserem Restservice ein JSON-Object zur Verfügung haben.

Passen wir nun noch unsere Vert.x-Klasse an. Ich füge in meine Klasse zwei neue Funktionen auf das Routing-Objekt hinzu. Beide mit Pfadangaben. Zusätzlich werde ich, nachdem die beiden Absätze eingefügt sind, diese wieder mit eigenen Testmethoden prüfen.

public class ApplikationVerticle extends AbstractVerticle { 
  HttpServer http;
  @Override
  public void start(Future<Void> future) throws Exception {
     Router router = Router.router(vertx);
     router.route().handler(BodyHandler.create());
     router.get("/getCurrencys/").handler(rc -> {
        vertx.executeBlocking( block -> {
           block.complete(ServiceClass.getAllCurrencys());
        }, res -> {
           rc.response()
           .putHeader("content-type", "application/json")
           .end( res.result().toString());
        });
     });
    router.get("/calculate/").handler(rc -> {
        vertx.executeBlocking( block -> {
           block.complete(
              ServiceClass.calculateCourse(
                rc.request().getParam("from"),
                rc.request().getParam("to"),
                rc.request().getParam("value")
              )
           );
        }, res -> {
           rc.response().
           putHeader("content-type", "application/json")
           .end( res.result().toString());
        });
     });
     router.route().handler(StaticHandler.create());
     http = vertx.createHttpServer()
     .requestHandler(router::accept)
     .listen(config().getInteger("http.port", 8081),
     result -> {
         if (result.succeeded()) {
             future.complete();
         } else {
             future.fail(result.cause());
         }
     });
}

@Override
  public void stop(Future<Void> stopFuture) throws Exception {
     http.close(closed -> {
         if (closed.succeeded()) {
             stopFuture.complete();
         } else {
             stopFuture.fail(closed.cause());
         }
     });
  }
}

Wir sehen die beiden Blöcke mit den Pfaden «/calculate/» und «/getCurrencys/».

Bei getCurrencys hole ich mir einfach mit meiner Serviceklasse das aktuelle JSON-Array zurück und gebe es als Response zurück. Zusätzlich stelle ich ein, dass der ContentType Application JSON ist. Wichtig ist, dass wenn diese Funktion aufgerufen wird und die Liste noch nicht initialisiert ist, sie in genau diesem Moment erstellt wird. Die andere Funktion ruft in der Serviceklasse die Funktion calculateCourse auf. Dabei liest es aus dem request die Query-Parameter from,to und amount. Diese sendet er mit an die Serviceklasse.

Applikation testen

Bleibt uns noch der Test.

In unserer Vert.x-Testklasse fügen wir folgende Funktionen hinzu:

@Test
  public void testGetCurrencys(TestContext context) {
     final Async async = context.async();
     List<String> responseList = new ArrayList<String>();
     vertx.createHttpClient().getNow(8081, "localhost", "/getCurrencys/", response -> {
         response.handler(body -> {
             String responseString = body.toString();
             responseList.add(responseString);
             async.complete();
         });
         response.endHandler(body -> {
             String responseString = String.join("", responseList);
             JsonArray testConn = new JsonArray(responseString);
             context.assertTrue(testConn.size() == 32);
         });
     });
  }

@Test
  public void calculate(TestContext context) {
     final Async async = context.async();
     vertx.createHttpClient().getNow(8081, "localhost", "/calculate/?from=EUR&to=CHF&value=1", response -> {
         response.handler(body -> {
             String responseString = body.toString();
             JsonObject json = new JsonObject(responseString);
             context.assertTrue(json.getDouble("result")>1);
             async.complete();
         });
     });
  }

Ich teste die getCurrencys-Funktion. Dabei starte ich die Anfrage auf den Pfad getCurrencys und prüfe, ob die zurückgegebene JSON-Liste eine Länge von 32 hat. 31 werden von der EZB im Export angeboten. Eine Währung, den Euro, habe ich in unserer Importfunktion hinzugefügt.

Im zweiten Test rechne ich einen Euro in Franken um. Da ich hoffe, dass der Kurs ein wenig stabil bleibt, gehe ich davon aus, dass ich mehr als CHF 1 zurückbekommen muss. Für eine genaue Berechnung müsste ich hier immer den aktuellen Tageskurs eintragen.

Das war es für dieses Mal. Wenn ihr mit der im ersten Blog beschriebenen Run Configuration den Server startet, könnt ihr die beiden Pfade natürlich direkt im Browser anschauen und das erstellte JSON bewundern.

JavaScript-Kurse bei Digicomp

In meinem nächsten Blog werde ich dann eine Client-Oberfläche mit HTML5 und Angular JS erstellen, die auf meine REST-Services zugreift.


Über den Autor

Mirko Eberlein

Mirko Eberlein ist Senior Developer bei der Webgate Consulting AG und Trainer im Bereich Webentwicklung bei Digicomp. Er ist seit 17 Jahren im IT-Umfeld tätig und sich über die Jahre Wissen über verschiedene Programmiersprachen angeeignet. Er versucht sich stets neue Techniken und Trends anzusehen.