PWA Jumpstart

How-to: Progressive Web Apps praktisch erklärt

How-to: Progressive Web Apps praktisch erklärt

PWA Jumpstart

How-to: Progressive Web Apps praktisch erklärt


Immer wieder gibt es Diskussionen darüber, welche Form der Anwendung besser geeignet ist: Sollte meine App eher eine Desktop- oder eine Web-Anwendung werden? Wenn es dann auch noch um Apps für den mobilen Sektor geht, wird es meist recht hitzig in der Diskussionsrunde. Eine bestimmte Variante kann hier eventuell für ein wenig Beruhigung sorgen. Die Rede ist von Progressive Web Apps.

Doch was sind PWAs eigentlich, welche Merkmale haben sie und wie kann ich sie überhaupt realisieren? Wir werden uns hier anhand eines kleinen Beispiels ansehen, was die Grundbausteine für eine PWA sind und welche Vorteile sich daraus ergeben.

Anwendungsgruppen

Zu Beginn möchte ich an dieser Stelle kurz skizzieren, welche Gruppen von Anwendungen miteinander diskutiert werden sollen. Diese Auflistung, oder besser gesagt Aufteilung, ist nicht vollständig und es gäbe noch einiges mehr, das man unterteilen könnte.

Für die nachfolgende Betrachtung verwende ich folgende Einteilung:

  • Native Desktop-Anwendungen (Windows/OS X/Linux)
  • Web-Apps für Desktops (Chrome/Firefox/Opera/…)
  • Web-Apps für mobile Endgeräte (Android/iOS/WindowsMobile/…)
  • Native mobile Anwendungen (Android/iOS/WindowsMobile/…)

Wenn man sich die Aufzählung genauer ansieht, so kann man erkennen, dass die Abstufung von einem Extrem zum anderen führt. Die beiden Versionen von Native-Apps bedeuten, dass die jeweiligen Entwicklungsumgebungen mit entsprechenden Ausprägungen vorhanden sein müssen. Zusätzlich wird das Spektrum an Technologien eingrenzt, die ein Entwickler verwenden kann. Allerdings sind solche Anwendungen in das jeweilige Trägersystem bestens integriert. Wenn das ein notwendiges Kriterium ist, muss der Weg bis in letzter Konsequenz gegangen werden.

Oftmals ist es jedoch nicht notwendig, solch eine hohe Integration zu erreichen. Viele Dinge können auch als Webapplikationen umgesetzt werden. Ich werde hier nicht tiefer auf die Unterschiede der Distribution einer Anwendung eingehen. Aber kann man sich vorstellen, dass einiges an eigener Infrastruktur und Prozessen reduziert werden kann, wenn man Anwendungen über einen Browser anbietet. Allerdings bringt dieser Weg auch einen gewissen Grad an Komplexität in anderen Bereichen mit sich. Nichts gibt es geschenkt.

Die Welt der Browser

Die Welt der Browser, mag sie auf den ersten Blick doch so einfach aussehen, hat einige Tücken. Nicht jeder Browser ist gleich, das Verhalten teilweise recht unterschiedlich, wenn es um die Interaktion mit dem Trägersystem geht. Hinzu kommt, dass im Consumer-Bereich eine recht freie Vorgehensweise bei der Wahl der Browser vorherrscht, in einem Konzern mit zentraler IT hingegen oft extrem reglementierte Prozesse zu finden sind. Aber wir wollen uns jetzt vor allem mit dem Unterschied bei der Entwicklung von Web-Anwendungen für mobile Endgeräte auf der einen Seite und den Desktop auf der anderen Seite beschäftigen.

Wie man sich unschwer vorstellen kann, sind zum Beispiel Bildschirmgröße und Art der Interaktion unterschiedlich. Hinzu kommt, dass mobile Endgeräte in einem stark wechselnden Bandbreitenbereich angebunden sind. Es ist ein Unterschied, ob man sich inmitten einer Stadt aufhält oder mit dem Zug über das Land fährt.

PWA – Progressive Web App

Um die Unterschiede in den Griff zu bekommen und beiden Umgebungen (Desktop und Mobile) gerecht zu werden, ohne dass mindestens zwei Versionen der Anwendung entwickelt werden müssen, hat man sich, getrieben von Google, auf drei Technologien verständigt:

  • HTML5
  • CSS3
  • JavaScript

Auf Grundlage dieser drei Technologien, die eine unabhängige Plattform darstellen, sollen sich Web-Anwendungen an Zielgeräte so weit anpassen, dass die Nutzung möglich ist. Anhand meiner Wortwahl kann man erkennen, dass es noch ein weiterer Weg ist bis alles wunderbar funktioniert. Der Anfang wurde aber gemacht und die Ergebnisse sind teils beachtlich.

HTTPS

Eine grundlegende Bedingung gibt es für die Verwendung von Web-Apps: Die Nutzung von HTTPS. Wenn eine Verbindung nicht per localhost erfolgt, ist HTTPS Voraussetzung. So kann lokal recht unkompliziert entwickelt werden, aber schon bei der Verwendung im eigenen Netzwerk – zum Beispiel wenn der CI Server die Anwendung testen soll – kommt das Thema HTTPS in Spiel. Zum Glück gibt es Dinge wie https://letsencrypt.org/.

PWA – Optionals

Mit Optionals wird definiert, was es für wünschenswerte aber nicht zwingend erforderlichen Kriterien gibt. Zweck dieser optionalen Elemente ist die höhere Integration in das Trägersystem, ohne damit zwanghafte Hürden aufzubauen. Zum Beispiel sollte eine PWA mit partiellem Verlust der Internetanbindung zurecht kommen (Offlinefähigkeit). Um die limitierte Bandbreite zu schonen, ist das Caching vorgesehen und die Kommunikation kann auf Wunsch auch vom Server aus initiiert werden. Damit ist ein push von Informationen vom Server an die Anwendung gemeint.

Diese Funktionen werden mittels ServiceWorker realisiert. Bei einem ServiceWorker handelt es sich um einen in JavaScript implementierten Proxy, der zwischen der Anwendung und dem Server geschaltet wird. Hiermit wird dann zum Beispiel die Offlinefähigkeit bereitgestellt. Ebenfalls wird die push-Funktion mittels ServiceWorker umgesetzt. Hier betreten wir nun ein Feld, das sich sehr unterschiedlich darstellt, je nachdem, welcher Browser in welcher Version auf welchem Betriebssystem zur Anwendung kommt. Die gute Nachricht ist, dass mittlerweile alle großen Browserhersteller zugesagt haben, diese Funktionen unterstützen zu wollen. Im vorliegenden Beispiel werden wir uns ausschließlich Google Chrome als Referenzplattform ansehen.

Ein Beispiel

Zu Beginn erstellen wir uns eine kleine Vaadin-Anwendung und sehen uns dann an, wie wir diese mit einigen wenigen Anpassungen zu einer Progressive Web App erweitern. Wir werden uns außerdem ansehen, wie man testen kann, welchen PWA-Kriterien eine vorhandene Web-App entspricht.

Beginnen wir mit unserer Anwendung.

Die Anwendung selbst ist recht einfach aufgebaut und besteht aus einer Tabelle mit einer Liste von Daten. Die Tabelle kann gefiltert und neue Datensätze können angelegt werden.Die Details zu einem Datensatz, genauso wie die Maske zur Eingabe neuer Datensätze, bestehen lediglich aus einigen Attributen und drei Buttons.

Wird ein Datensatz aus der Tabelle ausgewählt, so erscheint die Eingabemaske mit den Detailinformationen zum Datensatz auf der rechten Seite.

Das Projekt ist auf GitHub unter folgender Adresse zu finden: https://github.com/vaadin-developer/vaadin-dev-environment-demo-vaadin-testbench

Von der Web-App zur PWA

Um zu testen, in welchem Zustand sich die Web-Anwendung befindet, wenn man PWA-Kriterien anlegt, ist das Tool Lighthouse von Google recht hilfreich: https://developers.google.com/web/tools/lighthouse/.

Um die ersten Versuche mit dem Werkzeug zu unternehmen, sucht man in Chrome unter Weitere Tools | Entwicklertools nach Audit. Dort erscheint die Startseite des Werkzeugs Lighthouse und man kann nun für die aktuell im Browser angezeigte Seite ein Audit starten. Wie das aussehen kann, ist in der folgenden Abbildung (Aufnahme am 11.02.2018) zu sehen. Man sieht, dass die Webseite lediglich 55 von 100 Punkten erreicht, obwohl sie bei Wikipedia als Referenz für Progressive Web Apps genannt wird. Um festzustellen, welcher Aufwand betrieben werden muss, sollte man sich also zunächst kritisch mit den eigenen Erwartungen auseinandersetzen.

Nachdem das Werkzeug die Analyse abgeschlossen hat, bekommt man einen Report angezeigt. Darin sind unter anderem die Kriterien für Progressive Web Apps beschrieben, welche unsere Anwendung nicht erfüllt.

Vaadin App ohne PWA-Anpassungen

Kommen wir nun zu unserer Basisanwendung. Wenn wir die Vaadin-Anwendung ohne Modifikationen testen, erhalten wir das folgende Ergebnis. Der erste Test verwendet die IP 0.0.0.0, um auf die lokal laufende Web-App zuzugreifen. Hier erreichen wir 18 von 100 Punkten.

Der zweite Test verwendet dieselbe lokal laufende Instanz der Web-App, aber greift bei dem Test mittels der IP 127.0.0.1 auf die App zu. Hier erreichen wir 27 von 100 Punkten. Der Grund dafür ist, dass beim Zugriff über 127.0.0.1 HTTPS nicht zwingend vorausgesetzt wird. Man geht also indirekt davon aus, dass es sich um eine Entwicklungsumgebung handelt bzw. der Zugriff gesichert ist. Um die Vergleichbarkeit der Tests zu gewährleisten, ist es daher wichtig, dass diese stets in der gleichen Art erfolgen.

Anpassungen der Vaadin App

Kommen wir nun zu den Veränderungen, die an einer Vaadin-Anwendung vorgenommen werden müssen, damit sie als PWA gilt.

Auslieferung von statischen Dateien

Bei dem Umbau in eine Progressive Web App werden wir einige statische Dateien ausliefern müssen. Hierfür gibt es verschiedene Wege. In diesem Beispiel werden wir für die Auslieferung ein weiteres Servlet einsetzen. Dieses Servlet ist nicht von VaadinServlet abgeleitet, sondern direkt von HttpServlet. Die einzige Aufgabe des Servlets besteht darin, auf Anfrage eine bestimmte Menge an Dateien, inklusive des richtigen Mime-Type, zur Verfügung zu stellen.

Hierbei spiel es keine Rolle, ob es sich um einen GET– oder POST-Request handelt. Beide werden gleich behandelt.

  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
    serveDataFile(req, resp);
  }

  @Override
  protected void doPost(HttpServletRequest req, HttpServletResponse resp) {
    serveDataFile(req, resp);
  }

Die Implementierung der Methode serveDataFile entscheidet anhand der übergebenen Parameter, welche Datei ausgeliefert werden soll. Da die Menge der Dateien sehr überschaubar ist, werden wir einfach ein direktes Mapping aufbauen. Das Mapping besteht aus einem Paar von aktiver URL auf der gelauscht wird zu Datei.

@WebServlet(asyncSupported = true,
            urlPatterns = {
                SLASH + SERVICE_WORKER,
                SLASH + MANIFEST,
                SLASH + VAADIN + SLASH + APP_JS,
            }
)
public class PWAServlet extends HttpServlet implements HasLogger {


  public static final String SLASH          = "/";
  public static final String SERVICE_WORKER = "sw.js";
  public static final String APP_JS         = "app.js";
  public static final String MANIFEST       = "manifest.json";
  public static final String VAADIN         = "VAADIN";
Die Implementierung verwendet Elemente aus dem Open-Source-Projekt Functional Reactive, das sich auf GitHub befindet.
  private CheckedFunction<String, InputStream> asStream() {
    return PWAServlet.class::getResourceAsStream;
  }

  private CheckedBiFunction<InputStream, HttpServletResponse, Integer> write() {
    return (inputStream, httpServletResponse) 
           -> copy(inputStream, 
                   httpServletResponse.getOutputStream());
  }

  private void serveDataFile(HttpServletRequest request, 
                             HttpServletResponse response) {
    response.setCharacterEncoding("utf-8");
    final String url = request.getRequestURL().toString();
    logger().info("url for = " + url);
    match(
        matchCase(() -> failure("nothing matched " + url)),
        matchCase(() -> url.contains(SERVICE_WORKER), 
                  () -> success(SLASH + SERVICE_WORKER)),
        matchCase(() -> url.contains(APP_JS), 
                  () -> success(SLASH + VAADIN + SLASH + APP_JS)),
        matchCase(() -> url.contains(MANIFEST), 
                  () -> success(SLASH + MANIFEST))
    ).ifPresentOrElse(
        resourceToLoad -> {
          final Result<InputStream> ressourceStream 
                = asStream().apply(resourceToLoad);
          ressourceStream.ifAbsent(() -> logger().warning("resource was not available"));

          ressourceStream.ifPresent(inputStream -> {
            match(
                matchCase(() -> success("text/plain")),
                matchCase(() -> resourceToLoad.endsWith("js"), 
                          () -> success("application/javascript")),
                matchCase(() -> resourceToLoad.endsWith("json"), 
                          () -> success("application/json"))
            )
                .ifPresent(response::setContentType);
            write().apply(inputStream, response);

          });
        },
        failed -> logger().warning("failed .. " + failed)
    );
  }

manifest.json

Nun werden die angegebenen Anfragen von diesem Servlet beantwortet. Kommen wir zu den Dateien selbst.
Als erstes sehen wir uns die manifest.json an. Hierbei handelt es sich um eine Beschreibung, in der verschiedene Attribute der Anwendung definiert werden.

{
  "short_name": "Java PWA",
  "name": "Vaadin PWA",
  "display": "standalone",
  "lang":"en_US",
  "icons": [
    {
      "src": "./images/vaadinlogo-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "./images/splashscreen-512x384.jpg",
      "sizes": "512x512",
      "type": "image/jpg"
    }
  ],
  "start_url": "./microservice/",
  "theme_color": "#404549",
  "background_color": "#34373b"
}

Wichtig sind hier die Attribute

  • hort_name : Kurzname der Anwendung
  • name: Name der Anwendung
  • lang: Die standardmäßig verwendete Sprache
  • icons: Icons die Verwendet werden sollen, z.B auf dem HomeScreen
  • start_url: Die BasisURL der Anwendung (HTTPS)

Die Verbindung zu Vaadin stellen wir in der Klasse extends UI her, bzw. im konkreten Fall in der Klasse MyUI. Die Klasse wird mit der Annotation @Link(rel = "manifest", href = "/manifest.json") versehen.

app.js

Die Datei app.js ist der Punkt, an dem der ServiceWorker installiert wird.
Wie auch bei der Datei manifest.json, wird die Verbindung mittels Annotation in der Klasse MyUI hergestellt.

@JavaScript("vaadin://app.js")
if ('serviceWorker' in navigator) {
    navigator
        .serviceWorker
        .register('./sw.js', {scope: './'})
        .then((reg) => {
            if (reg.installing) {
                console.log('Service worker installing');
            } else if (reg.waiting) {
                console.log('Service worker installed');
            } else if (reg.active) {
                console.log('Service worker active');
            }
        })
        .catch((error) => {
        // Registration failed
            console.log('Registration failed with ' + error); 
        });

    // Communicate with the service worker using MessageChannel API.
    function sendMessage(message) {
        return new Promise((resolve, reject) => {
            const messageChannel = new MessageChannel();
            messageChannel.port1.onmessage = function (event) {
                resolve(`Direct message from SW: ${event.data}`);
            };
            navigator
                .serviceWorker
                .controller
                .postMessage(message, [messageChannel.port2])
        });
    }
}

sw.js – ServiceWorker

Kommen wir nun zu dem ServiceWorker selbst. Die Adresse dieser Datei ist indirekt in der Datei app.js Zeile 4 gegeben: .register('./sw.js', {scope: './'})
Der ServiceWorker ist die eigentliche Logik, mit der zum Beispiel die Offlinefunktionalität realisiert wird. Hier kann man sehr kreativ werden. In unserem Beispiel ist es nur eine absolute Minimalimplementierung. Ein Listing dieser Datei ist zu lang, als dass es hier passend sein würde, deswegen verweise ich hier auf die Quellen im Git-Repository selbst. Im Verzeichnis resources befindet sich die Datei sw.js.

„MyUI“ – dort, wo alles zusammenläuft

Die Klasse MyUI ist demnach der Dreh- und Angelpunkt, an dem alles in der Vaadin-Anwendung zusammenläuft, um die PWA-Funktionalitäten rudimentär zur Verfügung zu stellen.

@JavaScript("vaadin://app.js")
@Link(rel = "manifest", href = "./manifest.json")
@MetaTags({
              @Meta(name = "viewport", content = "width=device-width, initial-scale=1"),
              @Meta(name = "theme-color", content = "#404549"),
              @Meta(name = "description", content = "some content"),
          })
@Title("Vaadin PWA Jumpstart")
public class MyUI extends UI implements HasLogger {

//SNIPP 
}

Aber sehen wir uns einmal an, wie der Test nun verläuft nachdem diese Änderungen durchgeführt worden sind. Wir erreichen 91 von 100 Punkten, da wir kein HTTPS verwenden.

Lighthouse in Docker

Kommen wir nun zurück zum Tool Lighthouse. Der Report, den Lighthouse nach dem Test erzeugt, kann in Formaten wie .xml, .pdf, .html und weiteren exportiert werden. Es stellt sich demnach sofort die Frage, wie man diesen Test automatisiert ablaufen lassen kann, um danach das Ergebnis an geeigneter Stelle archivieren und ggf. auch auswerten zu können.

Der Weg dahin ist recht einfach. Man kann Chrome inklusive Lighthouse headless in einem Docker-Container laufen lassen. Damit kann dieser Vorgang sehr einfach ausgelagert werden und Teil einer CI/CD-Strecke werden.

Um die Problematik mit HTTPS zu umgehen, wird der Test lokal in der im Docker-Container laufenden Anwendung ausgeführt. Die Reports selbst werden in ein in den Container verlinktes Verzeichnis gespeichert. Selbstverständlich kann der Report auch mittels scp oder ähnlichem auf ein externes Ziel geladen werden. Ein Web-Server könnte dann etwa für Reviews immer die letzte Version im lokalen Netzwerk anbieten.

Das Docker Image ist recht klein und basiert auf einem Ubuntu. Es werden lediglich die benötigten Elemente installiert und ein Start der Vaadin-Anwendung hinzugefügt. Ein vorheriges mvn clean install wird außerhalb des Containers ausgeführt.
Genauso gut kann auch das Repository mit dem letzten SNAPSHOT als Quelle genommen werden.

FROM ubuntu:latest
MAINTAINER Sven Ruppert <sven.ruppert@gmail.com>

USER root
WORKDIR /app

RUN apt-get -y update && \
  apt-get -y install --no-install-recommends \ 
  -y curl chromium-browser software-properties-common && \
  rm -fr /var/lib/apt/lists/* /tmp/* /var/tmp/*

RUN curl -sL https://github.com/shyiko/jabba/raw/master/install.sh | \
    JABBA_COMMAND="install adopt@1.8.162-00 -o /jdk" bash

RUN curl -sL https://deb.nodesource.com/setup_8.x | bash -
RUN apt-get install --no-install-recommends -y nodejs && \
    rm -fr /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN npm install -g lighthouse

ENV JAVA_HOME /jdk
ENV PATH $JAVA_HOME/bin:$PATH
RUN useradd -ms /bin/bash  -g root lighthouse
USER lighthouse

RUN mkdir /home/lighthouse/report
WORKDIR /home/lighthouse/report

ENTRYPOINT nohup java -jar fat.jar & \
           lighthouse --chrome-flags="--headless --no-sandbox" ${lighthouse_url}

Aus Bequemlichkeit starte ich den Container mittels docker-compose, da bei mir auch eine spätere Verwendung mittels docker-compose erfolgt. Hier verweise ich auf die zu verwendende JAR-Datei und das Verzeichnis, in dem der Report nach dem Test zur Verfügung stehen soll.

version: '3.3'

networks:
  vaadin:

services:
  lighthouse:
    container_name: vaadin-lighthouse
    build: _lighthouse/
    ports:
          - 9222:9222
          - 8080:8080
    volumes:
          - ./_lighthouse/home/lighthouse/report:/home/lighthouse/report
          - ../target/helloworld-1.0.0-SNAPSHOT.jar:/home/lighthouse/report/fat.jar
    networks:
      - vaadin

    environment:
      - lighthouse_url=http://127.0.0.1:8080

Wenn der Test gelaufen ist, erhalten wir eine .html-Datei mit Zeitstempel. Im Verzeichnis ./_lighthouse/home/lighthouse/report

Fazit

Wir haben uns angesehen, mit welch wenigen Änderungen wir eine Vaadin-8-Anwendung mit PWA-Eigenschaften ausstatten können. Als Testwerkzeug wurde von das Open-Source-Projekt Lighthouse von Google verwendet. Da diese Tests auch in einen Docker-Container verpackt werden können, kann man auch eine CI/CD-Strecke damit anreichern und die Tests automatisch schon ab der ersten Minute im Projekt durchführen.

Das Projekt befindet sich auf GitHub unter der Adresse https://github.com/vaadin-developer/vaadin-dev-environment-demo-vaadin-testbench.

Bei Fragen oder Anmerkungen meldet Euch direkt per Twitter unter dem Handle @SvenRuppert oder
per Mail an mailsven.ruppert@gmail.com

Happy Coding

Sven Ruppert

Sven Ruppert arbeitet als Principal IT Consultant bei der codecentric AG am Standort München, spricht seit 1996 Java und arbeitet seitdem in nationalen und internationalen Projekten. In seiner Freizeit entwickelt er an seinem Projekt www.rapidpm.org und hält Vorträge bei JUGs.


Weitere Artikel zu diesem Thema