Teil 2 – Servlerless für alle

Serverless glücklich: Die erste anspruchsvolle Implementierung mit Fn Project

Sven Ruppert

Der Begriff Serverless ist in aller Munde und meist verbunden mit einem der großen Cloud-Anbieter wie Amazon, IBM, Microsoft oder Google. Aber was kann man damit eigentlich machen? Wie funktioniert das ganze und wie kann man sich dem Thema nähern? Und noch wichtiger: Kann man Serverless auch ohne öffentliches Rechenzentrum verwenden? In unserer Artikelserie „Serverless glücklich“ geht Sven Ruppert, Developer Advocate bei Vaadin, auf diese und weitere Fragen ein und bietet einen Überblick über Oracles Fn Project.

Im ersten Teil der Einführung in Oracles Fn Project haben wir uns angesehen, wie ein HelloWorld als Funktion implementiert werden kann. Die grundlegende Struktur wurde lokal mittels Docker und dem Kommandozeilen-Werkzeug fn vorbereitet. Nun kommen wir zu unserer ersten etwas anspruchsvolleren Implementierung.

Was wir erreichen wollen

Ziel ist es, eine minimale Anwendung zu schreiben, die eine Funktion konsumiert. Was wird hierfür benötigt und wie sieht die Entwicklung aus? Als Beispiel werden wir einen einfachen Login-Mechanismus implementieren, der von einer Vaadin-Anwendung verwendet wird.

Benötigte Infrastruktur

Bisher wurde der FN Server via Kommandozeilenwerkzeug gestartet. Allerdings handelt es sich hier auch lediglich um einen recht einfachen Docker-Aufruf, da der Fn Server selbst als Docker Image vorhanden ist. Da wir sowohl den Fn Server als auch das dazugehörige User Interface starten möchten, werden alle benötigten Elemente in einer Datei docker-compose.yml gespeichert. Der nachfolgende Befehl docker-compose up wird bei Bedarf die benötigten Images aus dem Internet herunterladen und die entsprechenden Conatiner erzeugen sowie starten.

version: '3'
services:

  fnserver:
    image: fnproject/fnserver
    hostname: fnserver
    container_name: fnserver
    restart: always
    ports:
      - "8080:8080"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

  fnserver-ui:
    image: fnproject/ui
    hostname: fnserver-ui
    container_name: fnserver-ui
    restart: always
    ports:
      - "4000:4000"
    depends_on:
          - fnserver
    links:
      - "fnserver"
    environment:
      - FN_API_URL=http://fnserver:8080

Der Fn Server ist unter dem Port 8080 und die grafische Oberfläche unter dem Port 4000 erreichbar. Ein erster Test zeigt einem dann das nachfolgende Bild auf dem Bildschirm.

Die Modul-Struktur

Sehen wir uns zu Beginn an, wie das Projekt strukturiert werden soll. Wie wir später sehen werden, ist es notwendig, die einzelnen Elemente in diesem Projekt in einzelne Maven-Projekte auszulagern.

Um das Beispiel möglichst überschaubar zu halten, werden wir das Projekt lediglich in drei Teile aufteilen. Der erste Teil ist das API das auf beiden Seiten, der grafischen Oberfläche und auf der Fn-Project-Seite, eingesetzt wird. Ich gehe in diesem Beispiel diesen Weg, da auf beiden Seiten Java die verwendete Sprache sein wird. Aus diesem Grund beitet es sich an, die Schnittstelle ebenfalls in Java zu definieren. Würden unterschiedliche Sprachen zum Einsatz kommen, wäre das JSON-Modell die einzige Schnittstellendefinition.

Wie gerade schon angedeutet, gibt es dann noch zwei weitere Teile. Zum Einen die grafische Oberfläche, in der auch der Client-Teil zu den Fn-Funktionen abgebildet wird. Der dritte und letzte Teil ist dann die Fn-Funktionen-Seite. Hierbei handelt es sich um die Schnittstellenimplementierung mittels Fn Project.

Alles zusammen haben wir also nun folgende Projekte:

  • API-Projekt zur Definition der Schnittstelle
  • Fn-Project-Module
  • UI als grafische Schnittstelle zum Benutzer

Für ein Hello World ist das schon einiges.

DevOpsCon Whitepaper 2018

Free: 40+ pages of DevOps expert knowledge

Learn about Containers,Continuous Delivery, DevOps Culture, Cloud Platforms & Security with articles by experts like Kai Tödter (Siemens), Nicki Watt (OpenCredo), Tobias Gesellchen (Europace AG) and many more.

„fnproject-helloworld-modules-api-login“

Das Obermodul-API wird für die einzelnen Aufgaben in Module zerteilt. Eines davon ist derzeit das Modul, welches die Definition für den Login-Service bereitstellt. Die Unterteilung wird hier aus dem Grunde vorgenommen, da die Teilfunktionen in dazu korrespondierenden unabhängige Fn-Project-Funktionen abgebildet werden. Die Anwendung, also das UI selbst, erhält alle Abhängigkeiten, um daraus das Backend der Anwendung zu konsumieren.

„fnproject-helloworld-modules-ui“

In diesem Modul werden die UI-Elemente der Anwendung untergebracht. Die Vaadin-Anwendung wird den mittels Fn Project den angebotenen Service konsumieren und damit den Log-in-Prozess abbilden.

„fnproject-helloworld-modules-services-login“

Korrespondierend zu dem Modul fnproject-helloworld-modules-api-login handelt es sich bei diesem Modul um die Implementierung selbst. Die Implementierung enthält den Server-Teil. Dieser stellt die Fn-Project-Funktion dar, die dann von dem Client konsumiert werden wird.

Das API „LoginService“

Das API, das hier zum Einsatz kommen soll, ist sehr überschaubar, handelt es sich doch um lediglich eine zu implementierende Funktionalität. Was hier auffällt, ist die Verwendung der Jackson-Annotationen: Diese sind notwendig, um das Mapping zwischen Pojo und JSON zu definieren.

Eine Frage, die sich immer wieder stellen wird, ist die Frage nach dem richtigen Ort, an dem API-Definitionen und Modelle mit technischen Annotationen durchsetzt werden. Hier habe ich mich der Einfachheit halber dafür endschieden, dieses direkt in dem Modell und damit auch in dem API zu verwenden. Damit ist das API natürlich technisch nicht mehr neutral.

public class Login extends Pair<String, String> {

  @JsonCreator
  public Login(
      @JsonProperty("login") String login,
      @JsonProperty("password") String password) {
    super(login, password);
  }

  public static CheckedFunction<Login, String> toJson() {
    return (login) -> {
      final ObjectMapper objectMapper = new ObjectMapper();
      return objectMapper.writerFor(Login.class).writeValueAsString(login);
    };
  }

  public static CheckedFunction<String, Login> fromJson() {
    return (json) -> new ObjectMapper()
        .readerFor(Login.class)
        .readValue(json);
  }

  public String getLogin() {
    return getT1();
  }

  public String getPassword() {
    return getT2();
  }
}

public interface LoginService {
  boolean checkLogin(Login login);

Die Aufgabe ist schnell erklärt. Basierend auf den Parametern mit den Namen login und passwd wird ein Boolean zurückgeliefert. TRUE bedeutet, dass es sich um eine valide Kombination handelt.

Services – Log-in – FN

Kommen wir nun zu der Implementierung der Log-in-Funktion. Die Logik selbst kann in Core Java geschrieben werden. Das bedeutet auch, dass die Logik selbst mittels JUnit getestet werden kann und dabei kein weiterer Technologie-Stack notwendig ist. In diesem Beispiel wird absichtlich auf eine Nutzerdatenbank oder Frameworks wie zum Beispiel Keycloack oder Shiro verzichtet.

public class LoginServiceFN implements LoginService {

  @Override
  public boolean checkLogin(Login login) {
    return match(
        matchCase(() -> success(FALSE)),
        matchCase(() -> login == null,
                  () -> failure("login should not be null")
        ),
        matchCase(() -> login.getLogin() == null ||
                        login.getPassword() == null,
                  () -> failure("login or passwd is null")
        ),
        matchCase(() -> login.getLogin().isEmpty() ||
                        login.getPassword().isEmpty(),
                  () -> failure("login or passwd is empty")
        ),
        matchCase(() -> login.getLogin().equals("admin") &&
                        login.getPassword().equals("admin"),
                  () -> success(TRUE)
        )
    )
        .ifAbsent(System.out::println) //logger to use
        .getOrElse(() -> FALSE);
  }
}

Diese Implementierung erlaubt lediglich die Authentifizierung mit dem Usernamen admin und dem Passwort admin. Via JUnit kann sie nun getestet werden:

  @Test
  void checkLogin000() {
    final Login login = new Login("admin", "admin");
    boolean checkLogin = new LoginServiceFN().checkLogin(login);
    Assertions.assertTrue(checkLogin);
  }

  @Test
  void checkLogin001() {
    final Login login = new Login("admin", "");
    boolean checkLogin = new LoginServiceFN().checkLogin(login);
    Assertions.assertFalse(checkLogin);
  }

  @Test
  void checkLogin002() {
    final Login login = new Login("admin", null);
    boolean checkLogin = new LoginServiceFN().checkLogin(login);
    Assertions.assertFalse(checkLogin);
  }

Oracles Fn Project bietet einem noch (derzeit ausschließlich auf Basis von jUnit4) die Möglichkeit, die Funktionen lokal zu testen. Realisiert worden ist diese Funktionalität durch den Einsatz einer Rule mit dem Klassenanemn FnTestingRule.

  @Rule //junit4 !!
  public final FnTestingRule testing = FnTestingRule.createDefault();

  public static CheckedFunction<Login, String> toJson() {
    return (login) -> {
      final ObjectMapper objectMapper = new ObjectMapper();
      return objectMapper.writerFor(Login.class).writeValueAsString(login);
    };
  }

  private Consumer<Triple<String, String, String>> test() {
    return (triple) -> toJson()
        .apply(new Login(triple.getT1(), triple.getT2()))
        .ifAbsent(() -> {
          throw new RuntimeException("JSON Encoding failed for " + triple.getT1() + " - " + triple.getT2());
        })
        .ifPresent(success -> {
                     testing.givenEvent().withBody(success).enqueue();
                     testing.thenRun(LoginServiceFN.class, "checkLogin");
                     assertEquals(triple.getT3(), testing.getOnlyResult().getBodyAsString());
                   }
        );
  }

  @Test //junit4 !!
  public void checkLogin000() {
    test().accept(next("admin", "admin", "true"));
  }

  @Test //junit4 !!
  public void checkLogin001() {
    test().accept(next("admin", null, "false"));
  }

  @Test //junit4 !!
  public void checkLogin002() {
    test().accept(next("admin", "asas", "false"));
  }

Nun wurde die Funktion, die abgebildet worden ist, auf zwei verschiedene Arten getestet. In diesem Beispiel müssen die Tests noch unabhängig voneinander gepflegt werden. Das ist sicherlich bei größeren Projekten nicht ratsam. In unserem Beispiel gibt es einem aber erst einmal die Möglichkeit, beide Arten der Tests auszuprobieren und miteinander zu vergleichen.

Kommen wir nun zu der Herausforderung, die Fn-Funktion in einem Docker-Container anzubieten und dort zu testen. Das ist ja schließlich der finale Zustand, der funktionieren muss. Hierzu wird das Image gebaut, um dann nachfolgend im Docker Host verwendet werden zu können. Auf der Kommandozeile kann mit dem Befehl fn deploy --all --local der Bereitstellungsprozess gestartet werden. Als erstes wird die Versionsnummer inkrementiert, danach das Image neu gebaut. Zu guter Letzt wird ein Container gestartet und die Route innerhalb von FN gesetzt.

Wurde der Befehl erfolgreich abgeschlossen, kann man mit dem Aufruf fn routes list vaadin-fnproject-002 überprüfen, wie die URL auf die Funktion gesetzt worden ist. In diesem Fall ergibt sich bei mir die folgende Ausgabe.

$ fn routes list vaadin-fnproject-002
path            image                                           endpoint
/service-login  publications/vaadin-fnproject-002-login:0.0.15  localhost:8080/r/vaadin-fnproject-002/service-login

Da die Funktion nun innerhalb des Docker Host bereitgestellt worden ist, kann der Zugriff per curl überprüft werden. Hierzu nehmen wir die URL, die uns gerade auf der Komandozeile ausgegeben worden ist, und verwenden diese:

curl -X POST -d '{}' http://localhost:8080/r/vaadin-fnproject-002/service-login

Bei diesem Aufruf übergeben wir keine Daten, demnach liefert die Funktion als Ergebnis ein false. Soweit stimmt alles. Wenn der Aufruf mit Daten versehen wird, in unserem Fall mit admin und admin, bekommen wir true zurück. Die Funktion erfüllt also die für sie vorgesehene Aufgabe.

Fullstack-Test der Funktionen

Wir haben bisher die Möglichkeiten angesehen, wie man die Logik losgelöst von der umhüllenden Technologie testen kann. Der Test auf dem lokalen System mittels der im FDK gelieferten Rule bringt einen schon näher an die realen Eigenschaften des Systems heran. Hier wird man zum Beispiel damit konfrontiert, wie das JSON Encoding funktioniert. Aber wie kann man nun das Gesamtsystem testen?

Notwendig hierfür ist eine in Docker lauffähige Funktionsinstanz. Der JUnit-Test kann mittels HttpOK einen Request gleich dem Aufruf mittels curl erzeugen und absetzen. Auch hier werden wieder Request-/Response-Paare definiert. Aber wenn man sich das ein wenig genauer überlegt, so hat man all diese Dinge ja schon einmal implememntiert.

Anbindung an die Vaadin-App

Kommen wir zu der Anbindung an die Vaadin-Anwendung. Hier werden wir nun basierend auf den Eingabewerten die Funktion aufrufen und dann entscheiden, ob man einen erfolgreichen Log-in hatte oder nicht. Der Aufbau der Vaadin-Anwendung selbst ist sehr einfach gehalten, da es sich um eine Demonstration handelt. Wer mehr darüber lesen möchte, der sollte sich die entsprechende Dokumentation ansehen.

Die erste Komponente, die zum Einsatz kommt, ist in diesem Fall die LoginComponent. Es werden lediglich ein Textfeld, ein Passwortfeld und zwei Buttons angezeigt. Der eine Button, um den Log-in-Prozess zu starten, der andere, um den Prozess vollständig abzubrechen. Der dafür notwendige Quelltext kann recht einfach gehalten werden. Wir gehen hier nicht von einer Anwendung aus, die in Produktion gehen soll.

public class LoginComponent extends Composite implements HasLogger {

  private final TextField     loginField    = new TextField();
  private final PasswordField passwordField = new PasswordField();
  private final Button        okButton      = new Button();
  private final Button        cancelButton  = new Button();
  private final FormLayout    formLayout    = new FormLayout(loginField,
                                                             passwordField,
                                                             new HorizontalLayout(
                                                                 okButton, 
                                                                 cancelButton));

  public LoginComponent() {

    loginField.setCaption("Username");
    passwordField.setCaption("Password");

    okButton.setCaption("OK");
    cancelButton.setCaption("Cancel");

    setCompositionRoot(formLayout);
  }

  public Component postConstruct() {

    okButton.addClickListener((Button.ClickListener) clickEvent -> {
      LoginServiceClient client = new LoginServiceClient();
      Login              login  = new Login(
                                        loginField.getValue(),
                                        passwordField.getValue());
      if (client.checkLogin(login)) {
        LoginComponent
            .this
            .getUI()
            .setContent(new DashboardComponent().postConstruct());
      } else {
        cancelButton.click();
      }
    });

    cancelButton.addClickListener((Button.ClickListener) clickEvent -> {
      loginField.setValue("");
      passwordField.setValue("");
    });
    return this;
  }
}

Das Verhalten ist schnell erklärt: Immer dann, wenn der Button OK betätigt wird, werden die Werte, die in den beiden Eingabefeldern sind, abgeholt und in eine Instanz der Klasse Login verpackt. Diese Instanz wird dann der Instanz der Klasse LoginServiceClient() übergeben. Liefert der Aufruf true zurück, so ist der Log-in-Prozess erfolgreich verlaufen. Als Reaktion darauf wird die angezeigte Komponente gegen eine Instanz der Klasse DashboardComponent ausgetauscht.

Von Bedeutung ist demnach die Implementierung der Klasse LoginServiceClient. Hier handelt es sich um die Implementierung, die eine Anfrage an die Funktion stellen soll, die wir vorher im Docker Host aktiviert und mittels curl getestet haben.

Die Client-Implementierung

Die Client-Implementierung ist in unserem Beispiel via HttpOK umgesetzt worden. Der Client implementiert, genau wie die Funktions-Implementierung, das Interface LoginServer. Hierbei handelt es sich um eine Besonderheit, da wir beide Seiten mittels Java implementieren. Zudem ist in dieser Implementierung die URL zum Service direkt angegeben worden, was für eine Implementierung im produktiven Einsatz selbstverständlich nicht zu empfehlen ist.

public class LoginServiceClient implements LoginService {

  public static final MediaType JSON
      = MediaType.parse("application/json; charset=utf-8");

  // not production ready ,-)
  public static final String URL = "http://127.0.0.1:8080/r/vaadin-fnproject-002/service-login";

  //performs best if client will be shared
  private static final OkHttpClient client = new OkHttpClient();

  public CheckedBiFunction<String, String, String> request() {
    return (url, json) -> {
      final Request request = new Request.Builder()
          .url(url)
          .post(RequestBody.create(JSON, json))
          .build();
      return client
          .newCall(request)
          .execute()
          .body()
          .string();
    };
  }

  @Override
  public boolean checkLogin(Login login) {
    if (login == null) return false;
    return toJson()
        .apply(new Login(login.getLogin(), login.getPassword() ))
        .flatMap(loginAsJson ->
                     request()
                         .apply(URL, loginAsJson)
                         .or(() -> Result.success("false"))
        )
        .map(Boolean::parseBoolean)
        .getOrElse(() -> Boolean.FALSE);
  }
}

Fazit – Der Workflow

Wenn man nun soweit gekommen ist und einige Versuche unternommen hat, Änderungen, die im API vorgenommen worden sind, bis in die Funktion hinein zu bekommen, so fällt einem einiges auf. Der praktische Ablauf bei der Entwicklung ist durch einige Eigenarten geprägt. Wenn man zum Beispiel die Signatur der Methode im Interface verändert, so muss man mvn clean deploy durchführen. Das ist notwendig, damit der SNAPSHOT in dem genutzten Repository verfügbar ist.

Ein install reicht nicht aus. Das liegt darin begründet, das der Build-Prozess, der später innerhalb von Docker gestartet wird, diesen SNAPSHOT benötigt. Das führt dazu, dass ohne weitere Vorkehrungen der Zugriff auf das lokale .m2-Verzeichnis nicht gegeben ist. Was ebenfalls auffällt: der Caching-Vorgang beim Bauen der Images führt dazu, dass manchmal Änderungen nicht aktiviert werden. Das passiert zum Beispiel dann, wenn die vorherige Stufe alle Dependencys abgeholt hat und nur der nachfolgende Übersetzungsprozess fehlgeschlagen ist. In diesem Fall muss man die Images, die temporär zum Einsatz kommen, manuell löschen. Sind diese Images gelöscht, so beginnt der Prozess von Beginn an. Der nächste Punkt, der dann auffällt, ist der Zeitbedarf, um wieder alle Dependencys aus dem Internet zu holen. Was bei einer guten Internetverbindung kaum auffällt, nervt gewaltig, wenn man eben nur recht schmalbandig an das Internet angebunden ist.

All diese Eigenarten zusammengefasst, ergeben sich folgende Punkte, die einer Optimierung bedürfen:

  • Ein lokal verfügbares Maven Repository
  • Eine Build Pipeline, die alle Module nacheinander automatisch baut und bereitstellt
  • Ein auf Wunsch deaktiviertes Caching der einzelnen Build-Stufen (Docker Images)
  • Ein Update der auf JUnit4 basierenden Testunterstützung auf JUnit5

Wir werden in den nächsten Teilen auf genau diese Dinge eingehen.

Happy Coding!

Geschrieben von
Sven Ruppert
Sven Ruppert
Sven Ruppert arbeitet seit 1996 mit Java und ist Developer Advocate bei Vaadin. In seiner Freizeit spricht er auf internationalen und nationalen Konferenzen, schreibt für IT-Magazine und für Tech-Portale. Twitter: @SvenRuppert
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu: