Open-Core mit Core Java und Vaadin — Teil 2

Sven Ruppert

Editionen, Laufzeit und Qualitätssicherung.

Sämtliche im Folgenden vorgestellten Quelltexte sind auf GitHub veröffentlicht und unter https://3g3.eu/naityh

erreichbar.

Rückblick auf den ersten Teil

Der erste Teil dieses Beitrags hat die abstrakte Mechanik der Open-Core-Anwendung entwickelt. Aus zwei eigenständigen Maven-Projekten mit strikt asymmetrischer Abhängigkeitsrichtung, einem bewusst schlanken Erweiterungs-API mit den Beitragstypen RouteContribution, MenuContribution, NavbarContribution und CounterEventListener, einer FeatureRegistry, die ihre Beiträge über den ServiceLoader der Java-Standardbibliothek auffindet, sowie einer Vaadin-Integration aus MainLayout und OpenCoreRouteInitializer steht damit ein Apparat bereit, der beliebige Beiträge in geordneter Form aufnehmen kann, ohne dass der Kern ihrer Identität bekannt sein muss.

Was nun folgt, ist die konkrete Anwendung dieser Mechanik in der Community-Edition und der Enterprise-Erweiterung, sodann die Laufzeit mit einem eingebetteten Jetty und schließlich die Tests, die die architektonischen Grenzen dauerhaft absichern. Die Kapitel behalten ihre fortlaufende Nummerierung aus dem ersten Teil bei, sodass sich Querverweise zwischen beiden Teilen ohne weitere Übersetzung lesen lassen.


Die Community-Edition als erste Beitragsinstanz

Mit allen drei zuvor entwickelten Bausteinen, dem Erweiterungs-API, der FeatureRegistry und der Vaadin-Integration, ist die Maschinerie für Beiträge vollständig vorhanden, jedoch noch nicht in Anspruch genommen. Eine Anwendung, die in dieser Form gestartet würde, wäre lauffähig, böte jedoch weder Routen noch Menüeinträge. Den ersten Beitrag liefert die Community-Edition selbst. Sie ist nicht etwa ein privilegierter Bestandteil des Kerns, der durch eine besondere Verdrahtung Sichtbarkeit erlangt, sondern eine ganz gewöhnliche Beitragsinstanz, die sich derselben Mechanik bedient, die später auch der Enterprise-Erweiterung zur Verfügung steht. Diese Symmetrie ist eine der zentralen Aussagen des gesamten Aufbaus.

Im Mittelpunkt steht die Klasse CoreFeatureContribution. Sie implementiert FeatureContribution, trägt den Identifikator community.core und meldet zwei Routen sowie zwei Menüeinträge an. Der erste Eintrag verweist auf die CounterView und ist mit dem leeren Pfad belegt, wodurch er zur Standardsicht der Anwendung wird. Der zweite verweist auf die AboutView unter dem Pfad „about“. Die Sortierreihenfolge der Menüeinträge ist so gewählt, dass die mit dem Wert 100 zuoberst erscheint und die mit dem Wert 900 das Ende der Liste einnimmt, um Raum für die mittleren Bereiche zu lassen, die im Enterprise-Fall mit Werten zwischen 300 und 500 dort einsortiert werden.

public final class CoreFeatureContribution
    implements FeatureContribution {

  public static final String FEATURE_ID = "community.core";

  @Override
  public String id() {
    return FEATURE_ID;
  }

  @Override
  public List<RouteContribution> routes() {
    return List.of(
        new RouteContribution("", CounterView.class),
        new RouteContribution("about", AboutView.class));
  }

  @Override
  public List<MenuContribution> menuItems() {
    return List.of(
        new MenuContribution("Counter", "", 100, "vaadin:plus"),
        new MenuContribution("About", "about", 900,
            "vaadin:info-circle"));
  }

  @Override
  public int order() {
    return 100;
  }
}

Die Anmeldung dieser Klasse beim ServiceLoader erfolgt über dieselbe Datei, die auch der Enterprise-Erweiterung als Anlaufpunkt dient. Im Community-Projekt liegt sie unter src/main/resources/META-INF/services/com.svenruppert.opencore.counter.extension.FeatureContribution enthält eine einzige Zeile mit dem voll qualifizierten Namen der Beitragsklasse. Die Tatsache, dass sich das Community-Modul auf demselben Weg selbst bekannt gibt, der auch externen Erweiterungen offensteht, ist mehr als eine kosmetische Konsequenz. Sie ist die praktische Probe dafür, dass die API für Beiträge aus der eigenen Codebasis ebenso geeignet ist wie für solche aus fremden Modulen. Sollte sich der Mechanismus an dieser Stelle als unzureichend erweisen, wäre er auch für Erweiterungen unbrauchbar; da er für die Community-Edition vorgesehen ist, gilt dies für jeden weiteren Beitragenden.

Die beiden referenzierten Sichten sind bewusst schlicht gehalten. Die CounterView ruft über Application.context().counterService() den zentralen Zählerdienst auf und stellt dessen aktuellen Wert in einer Span-Komponente dar. Drei Schaltflächen lösen die bekannten Operationen aus und veranlassen anschließend die erneute Anzeige des Werts. Die Sicht trägt keine @Route-Annotation, da ihre Bindung an einen Pfad ausschließlich über die RouteContribution in der CoreFeatureContribution erfolgt. Jede der Schaltflächen erhält zudem einen festen Identifikator über setId, was die Sicht für die später vorgestellten browserlosen Tests adressierbar macht.

public class CounterView extends VerticalLayout {

  public static final String ID_VALUE_LABEL = "counter-value";
  public static final String ID_INCREMENT_BUTTON = "btn-increment";
  public static final String ID_DECREMENT_BUTTON = "btn-decrement";
  public static final String ID_RESET_BUTTON = "btn-reset";

  private final CounterService counterService;
  private final Span valueLabel;

  public CounterView() {
    this.counterService = Application.context().counterService();

    add(new H2("Counter"));

    valueLabel = new Span(String.valueOf(counterService.value()));
    valueLabel.setId(ID_VALUE_LABEL);
    add(valueLabel);

    Button increment = new Button("+1", e -> {
      counterService.increment();
      refresh();
    });
    increment.setId(ID_INCREMENT_BUTTON);

    Button decrement = new Button("-1", e -> {
      counterService.decrement();
      refresh();
    });
    decrement.setId(ID_DECREMENT_BUTTON);

    Button reset = new Button("Reset", e -> {
      counterService.reset();
      refresh();
    });
    reset.setId(ID_RESET_BUTTON);

    add(new HorizontalLayout(increment, decrement, reset));
  }

  private void refresh() {
    valueLabel.setText(String.valueOf(counterService.value()));
  }
}

Die AboutView ist noch zurückhaltender. Sie besteht aus einer Überschrift und drei Absätzen, die in Kurzform erläutern, dass es sich um die OSS-Edition handelt, dass weitere Funktionen erscheinen, sobald die Enterprise-JAR auf dem Klassenpfad liegt, und dass sämtliche Funktionen über den ServiceLoader geladen werden. Damit übernimmt die Sicht selbst eine kleine pädagogische Funktion gegenüber jenen, die die Anwendung zum ersten Mal aufrufen.

Mit der CoreFeatureContribution, ihrer Eintragung in der Service-Datei und den beiden Sichten ist die Community-Edition vollständig. Sie ist ohne weiteres Zutun lauffähig und liefert eine Anwendung mit zwei Sichten und zwei Menüeinträgen. Was bei der späteren Hinzunahme der Enterprise-Erweiterung geschieht, ist aus Sicht des Kerns nichts anderes. Eine weitere Implementierung von FeatureContribution wird gefunden, sie bringt eine weitere Liste von Routen, Menüeinträgen, Navigationsleisten-Ergänzungen und Ereignis-Zuhörern ein, und der bereits beschriebene Mechanismus integriert beides zu einer gemeinsamen Konfiguration. Aus Sicht des Kerns spielt es keine Rolle, ob der eine Beitrag aus dem eigenen Modul stammt und der andere aus einem anderen Modul.


Die Enterprise-Erweiterung

Mit der Community-Edition steht die Anwendung in einer ersten lauffähigen Version bereit. Was bislang aussteht, ist der eigentliche Open-Core-Mehrwert: eine zweite Beitragsinstanz, die zusätzliche Funktionen in dasselbe System einbringt, ohne dass der Kern eine einzige Zeile dafür anpassen müsste. Diese Aufgabe übernimmt die Enterprise-Erweiterung. Sie liefert drei zusätzliche Sichten, drei zusätzliche Menüeinträge, ein Element für die Navigationsleiste sowie zwei Ereignis-Zuhörer, die jede Änderung des Zählers in eigenen Speicher festschreiben. Bemerkenswert ist nicht der Umfang dieser Beiträge, sondern die Tatsache, dass sie ausschließlich über die im vierten Kapitel beschriebenen Schnittstellen angemeldet werden.

Die zentrale Klasse heißt EnterpriseFeatureContribution und unterscheidet sich von der CoreFeatureContribution zunächst nur in einer Hinsicht. Sie implementiert nicht FeatureContribution, sondern dessen Untervariante CounterEventFeature. Diese Wahl ist die einzige strukturelle Konsequenz aus dem Umstand, dass die Enterprise-Erweiterung an den Zählerereignissen interessiert ist und mithin zum Zuhören beizutragen hat. Alle übrigen Methoden überschreiben dieselben Verträge, die auch die Community-Variante bedient hat. Eine Sortierreihenfolge von 500 ordnet den Enterprise-Beitrag zwischen die Community-Edition mit der Reihenfolge 100 und den Standardwert 1000 ein.

public final class EnterpriseFeatureContribution
    implements CounterEventFeature {

  public static final String FEATURE_ID = "enterprise.counter";
  public static final String NAVBAR_BADGE_ID = "enterprise.edition.badge";

  @Override
  public String id() {
    return FEATURE_ID;
  }

  @Override
  public List<RouteContribution> routes() {
    return List.of(
        new RouteContribution("history", HistoryView.class),
        new RouteContribution("audit-log", AuditLogView.class),
        new RouteContribution("export", ExportView.class));
  }

  @Override
  public List<MenuContribution> menuItems() {
    return List.of(
        new MenuContribution("History", "history", 300, "vaadin:clock"),
        new MenuContribution("Audit Log", "audit-log", 400, "vaadin:list"),
        new MenuContribution("Export", "export", 500, "vaadin:download"));
  }

  @Override
  public List<CounterEventListener> counterEventListeners() {
    return List.of(
        new HistoryCounterEventListener(),
        new AuditLogCounterEventListener());
  }

  @Override
  public List<NavbarContribution> navbarItems() {
    return List.of(new NavbarContribution() {
      @Override
      public String id() {
        return NAVBAR_BADGE_ID;
      }

      @Override
      public Supplier<Component> componentFactory() {
        return EnterpriseEditionBadge::new;
      }

      @Override
      public int order() {
        return 100;
      }
    });
  }

  @Override
  public int order() {
    return 500;
  }
}

Die NavbarContribution wird im Quelltext als anonyme Klasse formuliert, was die Lokalisierung der Beitragsdeklaration unterstreicht. Ein einziger Beitrag wird unmittelbar an Ort und Stelle definiert, ohne eigene Datei und ohne separate Klasse. Die Fabrik liefert über die Methodenreferenz EnterpriseEditionBadge::new bei jedem Aufruf eine frische Komponenteninstanz, ganz wie es die im vierten Kapitel beschriebene Vertragsform vorsieht. Die Sortierreihenfolge von 100 stellt sicher, dass der Hinweis auf die Edition links neben anderen Ergänzungen der Navigationsleiste erscheint, falls in Zukunft weitere hinzukommen.

Die beiden Ereigniszuhörer folgen einem gemeinsamen Muster. Sie nehmen ein CounterChangedEvent entgegen, formen daraus einen eigenen Speichereintrag und legen ihn im modulinternen Speicher ab. Der HistoryCounterEventListener erzeugt einen HistoryEntry, der die Bestandteile des Ereignisses unverändert widerspiegelt. Beide Zuhörer akzeptieren in einem zweiten Konstruktor einen externen Speicher; dieser Konstruktor dient den Tests und erlaubt es, das singleton-artige Verhalten des Vorzugskonstruktors für Prüfzwecke zu umgehen.

public final class HistoryCounterEventListener
    implements CounterEventListener {

  private final HistoryStore store;

  public HistoryCounterEventListener() {
    this(HistoryStore.getInstance());
  }

  public HistoryCounterEventListener(HistoryStore store) {
    this.store = store;
  }

  @Override
  public void onCounterChanged(CounterChangedEvent event) {
    store.add(new HistoryEntry(
        event.timestamp(),
        event.oldValue(),
        event.newValue(),
        event.action()));
  }
}

Der AuditLogCounterEventListener ist strukturell identisch aufgebaut, übersetzt das Ereignis jedoch in einen textuellen Eintrag, der eine lesbare Beschreibung der Änderung enthält. Die Eintragsklasse AuditEntry trägt einen Zeitstempel, einen Ereignistyp und eine Nachricht, die im Falle einer Zählerveränderung den alten, den neuen Wert und die zugrunde liegende Aktion benennt.

public final class AuditLogCounterEventListener
    implements CounterEventListener {

  public static final String EVENT_TYPE = "COUNTER_CHANGED";

  private final AuditLogStore store;

  public AuditLogCounterEventListener() {
    this(AuditLogStore.getInstance());
  }

  public AuditLogCounterEventListener(AuditLogStore store) {
    this.store = store;
  }

  @Override
  public void onCounterChanged(CounterChangedEvent event) {
    String message = "Counter changed from "
        + event.oldValue() + " to " + event.newValue()
        + " by action " + event.action().name();
    store.add(new AuditEntry(event.timestamp(), EVENT_TYPE, message));
  }
}

Die beiden Speicher HistoryStore und AuditLogStore sind in ihrer Form ebenfalls einander gleich. Jeder besteht aus einer CopyOnWriteArrayList, die nebenläufige Zugriffe ohne explizite Synchronisation erlaubt, einem privaten statischen Singleton sowie wenigen Zugriffsmethoden zum Hinzufügen, Auslesen, Leeren und Zählen der Einträge. Die Wahl einer einfachen nebenläufigkeitssicheren Liste ist für die Trivialdomäne dieses Beitrags ausreichend. Eine produktionsreife Anwendung würde an dieser Stelle gegen eine ordentliche Persistenzschicht ausgetauscht.

public final class HistoryStore {

  private static final HistoryStore INSTANCE = new HistoryStore();

  private final List<HistoryEntry> entries = new CopyOnWriteArrayList<>();

  public static HistoryStore getInstance() {
    return INSTANCE;
  }

  public void add(HistoryEntry entry) {
    entries.add(entry);
  }

  public List<HistoryEntry> entries() {
    return List.copyOf(entries);
  }

  public void clear() {
    entries.clear();
  }

  public int size() {
    return entries.size();
  }
}

Die Speicher und die zugehörigen Eintragstypen, allesamt als record modelliert, liegen ausschließlich im Enterprise-Modul. Die Community kennt weder HistoryStore noch AuditLogStore, weder HistoryEntry noch AuditEntry, und sie muss diese Klassen auch nicht kennen. Die einzige Verbindung zwischen Kern und Erweiterung verläuft über die CounterEventListener-Schnittstelle, die im Community-Modul definiert ist und im Enterprise-Modul implementiert wird. Das Community-Modul publiziert Ereignisse an eine Liste von Zuhörern, deren konkrete Klassen ihm verborgen bleiben. Genau diese Verborgenheit ist es, die das gesamte Vorhaben architektonisch trägt.

Die drei Vaadin-Sichten der Enterprise-Erweiterung, HistoryView, AuditLogView und ExportView, folgen demselben Muster wie die Community-Sichten. Jede greift auf ihren jeweiligen Speicher zu, formt dessen Inhalt zu einer Darstellung und reagiert auf gewöhnliche Ereignisse von Vaadin-Komponenten. Da die Sichten keinen neuen architektonischen Erkenntnisgewinn beitragen, werden sie hier nicht im Detail behandelt; der vollständige Quelltext steht im Repositorium zur Einsicht bereit.


Diagramm 5: Ereignisweg vom Klick zu den Speichern (Enterprise-Modus)

Mit der Eintragung der EnterpriseFeatureContribution in der Service-Datei META-INF/services/com.svenruppert.opencore.counter.extension.FeatureContribution des Enterprise-Moduls ist die Erweiterung an dieselbe Maschinerie angeschlossen, die zuvor bereits die Community-Edition verarbeitet hat. Bei jedem Start mit beiden Modulen auf dem Klassenpfad findet die FeatureRegistry zwei Beiträge, sortiert sie, sammelt ihre Routen, Menüeinträge, Navigationsleisten-Ergänzungen und Ereigniszuhörer ein und liefert die Gesamtkonfiguration an die Vaadin-Integration weiter. Der Kern selbst hat dabei nichts geändert, nichts geprüft und nichts gewusst. Die Erweiterung erfolgt rein additiv und vollständig außerhalb der eigenen Quellen des Kerns.


Laufzeit mit eingebettetem Jetty

Die Anwendung benötigt einen Servlet-Container, da Vaadin Flow zur Auslieferung seines Frontends auf den VaadinServlet angewiesen ist. Anstelle eines externen Anwendungsservers wird in diesem Aufbau Jetty als Bibliothek innerhalb der Anwendung verwendet. Die Anwendung selbst bleibt damit ein gewöhnliches Java-Programm mit main-Methode; der Servlet-Container ist lediglich eine ihrer Abhängigkeiten.

Die Laufzeit ist in zwei kleine Klassen unterteilt. Die erste, CounterServlet, ist eine schlichte Unterklasse von VaadinServlet ohne eigene Logik. Ihr Vorhandensein ist erforderlich, weil Vaadin den Servlet-Typ anhand seines Typs in der laufenden Web-Anwendung erkennt; ein eigener Typ erlaubt darüber hinaus, künftig anwendungsspezifische Anpassungen vorzunehmen, ohne das Vaadin-eigene Servlet zu modifizieren.

public class CounterServlet extends VaadinServlet {
}

Die zweite Klasse, CounterApplicationLauncher, enthält die main-Methode und ist für die programmatische Einrichtung von Jetty zuständig. Sie erzeugt einen Server auf dem gewählten Port, hängt einen WebAppContext als Wurzel ein und registriert in diesem den CounterServlet unter dem Pfadmuster /*. Zusätzlich wird Jettys AnnotationConfiguration aktiviert, damit Vaadins bytecode-basiertes Auffinden von Konfigurations- und Komponentenklassen so funktioniert, als ob die Anwendung in einem ausgewachsenen Servlet-Container lief. Die Konfiguration des Klassenpfad-Scans erfolgt über die MetaInfConfiguration-Attribute, die hier auf alle JAR-Dateien angewendet werden, um sowohl Community- als auch Enterprise-JARs einzubeziehen.

static Server startServer(int port) throws Exception {
  ensureProductionTokenFile();

  Server server = new Server(port);
  WebAppContext webapp = new WebAppContext();
  webapp.setContextPath("/");

  ResourceFactory rf = ResourceFactory.of(webapp);
  Resource base = rf.newClassLoaderResource("META-INF/resources", true);
  webapp.setBaseResource(base);

  webapp.setConfigurationDiscovered(true);
  webapp.addConfiguration(new AnnotationConfiguration());
  webapp.setAttribute(
      MetaInfConfiguration.CONTAINER_JAR_PATTERN, ".*\\.jar$");
  webapp.setAttribute(
      MetaInfConfiguration.WEBINF_JAR_PATTERN, ".*\\.jar$");
  webapp.setParentLoaderPriority(true);

  ServletHolder holder = new ServletHolder(new CounterServlet());
  holder.setInitOrder(1);
  holder.setAsyncSupported(true);
  holder.setInitParameter("productionMode", "true");
  webapp.addServlet(holder, "/*");

  server.setHandler(webapp);
  server.start();
  return server;
}

Die Methode ensureProductionTokenFile legt eine kleine Vaadin-spezifische Konfigurationsdatei an, sofern diese im Klassenpfad noch nicht vorliegt. Vaadin nutzt diese Datei, um den produktiven Betriebsmodus vom Entwicklungsmodus zu unterscheiden. Eine ausführlichere Behandlung dieses Vaadin-Details würde den Rahmen des architektonischen Themas dieses Beitrags sprengen; entscheidend ist allein, dass die Anwendung damit auch ohne das Vaadin-Maven-Plugin in einer typischen Entwicklungsumgebung lauffähig ist.

Der entscheidende Punkt aus architektonischer Sicht liegt nicht in der Konfiguration von Jetty, sondern in der Abgrenzung gegenüber einer Jakarta-EE-Plattform. Die Servlet-API wird hier zweifellos verwendet, jedoch ausschließlich als Bibliothek. Die Anwendung verwendet weder CDI noch JPA, weder EJB noch JAX-RS, und sie ist nicht auf das Vorhandensein eines Anwendungsservers angewiesen. Sie lässt sich über java -cp … CounterApplicationLauncher als gewöhnliche Java-Anwendung starten. Die einzige Eigentümlichkeit gegenüber einem rein konsolengebundenen Programm ist die Tatsache, dass die main-Methode ist nicht terminiert, sondern wartet server.join() zum Beenden des Servers.

Auch in der Enterprise-Edition kommt derselbe Launcher zum Einsatz. Das Enterprise-Modul bringt keinen eigenen Einstiegspunkt mit, sondern lässt sich über das Maven-Exec-Plugin starten. Da die Enterprise-JAR auf demselben Klassenpfad liegt, wird ihre META-INF/services-Datei vom ServiceLoader ebenfalls gefunden, und die Enterprise-Beiträge werden zusammen mit den Community-Beiträgen geladen. Welche Edition zur Laufzeit aktiv ist, entscheidet nicht der Launcher, sondern allein der Klassenpfad.


Architekturprüfung und browserlose Tests

Eine Architektur, die im Quellcode sichtbar ist, lässt sich durch den Quellcode auch prüfen. Das Open-Core-Modell stützt sich, wie in den vorangegangenen Kapiteln entwickelt, im Wesentlichen auf zwei Eigenschaften. Erstens darf das Community-Modul keinerlei Verweise auf das Enterprise-Modul enthalten, und zweitens muss die Anwendung in beiden Betriebsmodi, also mit und ohne die Enterprise-JAR im Klassenpfad, jeweils die zur Edition passenden Menüinhalte und Sichten anzeigen. Beide Eigenschaften lassen sich als Tests formulieren und werden bei jedem Bauvorgang erneut überprüft. Andere Tests, etwa zur Domäne, zum CounterService oder zu den Ereignis-Zuhörern, sind selbstverständlich ebenfalls vorhanden, treten in ihrer architektonischen Aussagekraft jedoch hinter die beiden genannten zurück.

Die quellcodebasierte Architekturprüfung trägt den Namen CommunityDoesNotReferenceEnterpriseTest und ist bewusst schlicht aufgebaut. Sie geht den Quellbaum unter src/main/java des Community-Moduls durch, liest jede Java-Datei als Zeichenkette ein und sucht in dieser nach einer kleinen Liste verbotener Begriffe. Findet sie einen Treffer, wird er gesammelt und am Ende als Verletzung gemeldet. Die Begriffe sind so gewählt, dass sie sämtliche denkbaren Pfade decken, über die Enterprise-Klassen im Community-Quellcode auftauchen könnten, also der Paketname .counter.enterprise ebenso wie die Klassennamen EnterpriseFeatureContribution, HistoryView, AuditLogView und ExportView. Die einfache Textsuche ist hier vollkommen ausreichend, da eine Importanweisung oder ein voll qualifizierter Klassenname in beiden Fällen genau diese Zeichenfolgen enthält.

static final List<String> FORBIDDEN_TOKENS = List.of(
    ".counter.enterprise",
    "EnterpriseFeatureContribution",
    "HistoryView",
    "AuditLogView",
    "ExportView");

@Test
@DisplayName("community sources contain no reference "
    + "to enterprise package or types")
void communitySourcesDoNotReferenceEnterprise() throws IOException {
  Path sourceRoot = Path.of("src", "main", "java");
  assertTrue(Files.isDirectory(sourceRoot),
      "Expected community source root at " + sourceRoot.toAbsolutePath());

  List<String> violations = new ArrayList<>();
  try (Stream<Path> files = Files.walk(sourceRoot)) {
    files
        .filter(p -> p.toString().endsWith(".java"))
        .forEach(p -> scanFile(p, violations));
  }

  assertTrue(violations.isEmpty(),
      "Community sources must not reference enterprise:\n  - "
          + String.join("\n  - ", violations));
}

Diese Form der Prüfung ist bewusst quellcodebasiert und nicht bytecodebasiert. Werkzeuge wie ArchUnit könnten dieselbe Aussage über die kompilierten Klassen treffen und zudem semantisch präziser urteilen. Für das vorliegende Vorhaben jedoch genügt eine Textsuche, da das Verhältnis von Aufwand zu Aussage hier besonders günstig ist. Wenn das Community-Modul den Buchstaben nicht in keinem Enterprise-Namen enthält, kann es ihn auch im Bytecode nicht enthalten haben. Der Test ist mithin eine sehr direkte Übersetzung der ursprünglich rein organisatorischen Aussage „die Community kennt die Enterprise nicht” in ausführbaren Code. Schlägt er fehl, schlägt der Bauvorgang fehl und die Verletzung der Open-Core-Grenze wird sofort sichtbar.

Die zweite Klasse von Tests betrifft die tatsächliche Sichtbarkeit der Erweiterungen in der Benutzeroberfläche. Da Vaadin sich gegen das Hochfahren ohne Servlet-Container sträubt, könnte man verleitet sein, hier auf einen vollständigen Integrationstest im Browser auszuweichen. Genau dies wird jedoch vermieden. Stattdessen kommt die Bibliothek browserless-test-junit6 von Vaadin zum Einsatz, die einen vollständigen Vaadin-Komponentenbaum im Speicher aufbaut, jedoch keinen Browser benötigt. Tests dieser Art dauern nur wenige Hundert Millisekunden und können daher als reguläre Einheitstests in jeder Bauphase durchgeführt werden.

Im Community-Modul prüft die Klasse MainLayoutBrowserlessTest, dass die Navigationsleiste genau die beiden Einträge Counter und About enthält und dass die Einträge History, Audit Log und Export ausdrücklich nicht erscheinen. Die Sicht wird über navigate(“”, CounterView.class) aufgerufen, das MainLayout aus der aktiven Routenkette extrahiert und dessen SideNav rekursiv aufgesucht. Anschließend werden die angezeigten Etiketten anhand der erwarteten Liste geprüft.

@Test
@DisplayName("community side-nav contains Counter and About "
    + "but no enterprise entries")
void sideNavContainsOnlyCommunityEntries() {
  navigate("", CounterView.class);

  MainLayout layout = UI.getCurrent().getInternals()
      .getActiveRouterTargetsChain().stream()
      .filter(c -> c instanceof MainLayout)
      .map(c -> (MainLayout) c)
      .findFirst()
      .orElseThrow();

  SideNav nav = findSideNav(layout);

  List<String> labels = nav.getItems().stream()
      .map(SideNavItem::getLabel).toList();

  assertTrue(labels.contains("Counter"));
  assertTrue(labels.contains("About"));
  assertFalse(labels.contains("History"));
  assertFalse(labels.contains("Audit Log"));
  assertFalse(labels.contains("Export"));
}

Die entsprechende Klasse EnterpriseMainLayoutBrowserlessTest im Enterprise-Modul liest dasselbe MainLayout aus, erwartet aber eine andere Konstellation, nämlich das vollständige Spektrum aus Counter, History, Audit Log, Export und About. Zusätzlich prüft eine zweite Testmethode, dass in der Navigationsleiste das vom Enterprise-Modul beigesteuerte EnterpriseEditionBadge erscheint und als kleines, abgerundetes Abzeichen gestaltet ist. Beide Tests stützen sich auf dieselbe Layoutklasse aus dem Community-Modul und führen lediglich denselben Aufruf in einer anderen Klassenpfad-Konstellation aus. Damit ist nicht nur belegt, dass die einzelnen Beiträge korrekt registriert werden, sondern auch, dass das Layout sich vollkommen passiv verhält und in beiden Editionen ohne eigene Sonderbehandlung das jeweils richtige Bild liefert.

Eine kleine technische Eigenheit verdient Erwähnung. Vaadins browserlose Tests begrenzen ihre Suchhilfe $view(...) standardmäßig auf den innersten Routenknoten, also etwa CounterView. Inhalte des umgebenden MainLayout, insbesondere alles im Drawer und in der Navigationsleiste, sind über diesen Pfad nicht erreichbar. Der gezeigte Test bedient sich daher des Umwegs über UI.getCurrent().getInternals().getActiveRouterTargetsChain(), um aus dieser Kette das MainLayout herauszufiltern, und durchläuft anschließend dessen Kindkomponenten von Hand. Dies ist kein Mangel der Bibliothek, sondern eine Konsequenz aus deren bewusster Abgrenzung gegenüber dem aktuell sichtbaren Bereich.

Neben diesen architektonisch besonders aussagekräftigen Tests gibt es weitere Prüfungen, die zwar nicht den Open-Core-Charakter des Vorhabens beleuchten, aber zur Vollständigkeit des Testbestandes beitragen. Dazu gehören Einheitstests für CounterState und CounterService, ein Test des korrekten Verhaltens der FeatureRegistry unter handgeschaffenen Konstellationen, ein Test des EnterpriseFeatureContribution anhand seiner Selbstauskünfte sowie Tests der beiden Ereigniszuhörer hinsichtlich ihrer Speichereigenschaften. Sie sind im Sinne der Architektur allesamt unauffällig und werden hier nicht im Detail behandelt.


Fazit

Was dieser Beitrag zeigen konnte, lässt sich in drei Aussagen zusammenfassen. Erstens lässt sich eine Open-Core-Vaadin-Anwendung vollständig mit den Mitteln des JDK aufbauen, ohne Spring, ohne die Jakarta-EE-Plattform und ohne ein eigenes Plugin-Framework. Zweitens lässt sich die Trennung zwischen Kern und Erweiterung architektonisch erzwingen, statt sie der Disziplin im Entwicklungsalltag zu überlassen. Drittens ist der ServiceLoader für diesen Zweck nicht ein Notbehelf, sondern in vielerlei Hinsicht eine bessere Wahl als die scheinbar bequemeren annotations- oder reflexionsbasierten Alternativen, weil er die Herkunft jedes Beitrags sichtbar in den jeweiligen JAR-Dateien dokumentiert.

Was dieser Beitrag bewusst nicht zeigen sollte, sei ebenso klar benannt. Das Beispiel ist keine Geschäftsanwendung, sondern eine architektonische Demonstration. Die Domäne ist trivial gehalten, die Speicherung erfolgt im Hauptspeicher, es gibt weder Benutzerverwaltung noch Lizenzprüfung, weder Mehrmandantenfähigkeit noch Sitzungsbindung der Zustände. Eine produktive Anwendung würde an jeder dieser Stellen zusätzliche Mechanismen vorsehen, die hier ausgespart bleiben, weil sie den Blick auf das eigentliche Thema verstellen würden. Auch der singleton-artige Anwendungskontext und die statischen Speicher der Enterprise-Erweiterung sind als didaktische Vereinfachung zu verstehen und nicht als Empfehlung für den Produktivbetrieb.

Was hingegen auch in größeren Vorhaben Bestand haben dürfte, sind die architektonischen Leitlinien, die dem Beispiel zugrunde liegen. Die ausschließlich additive Wirkung von Erweiterungen, die bauseits erzwungene Asymmetrie der Abhängigkeitsrichtungen, die kompromisslose Anbindung der Vaadin-Integration an eine zentrale Registry und die quellcodebasierte Architekturprüfung gegen Grenzverletzungen sind unabhängig von der Größe der Anwendung sinnvoll. Sie skalieren mit der Komplexität der Domäne, ohne dabei komplizierter werden zu müssen.

Open-Core ist letztlich weniger eine technische Frage als vielmehr eine Frage der Vertragsdisziplin. Die dafür benötigten Mechanismen sind, wie dieser Beitrag gezeigt hat, von beachtlicher Schlichtheit. Die eigentliche Herausforderung besteht nicht darin, sie zu implementieren, sondern darin, der Versuchung zu widerstehen, die Erweiterungs-API im Laufe der Zeit „nur ein wenig” zu öffnen, eine bequeme Sonderregelung einzufügen oder eine Grenze stillschweigend zu lockern. Eine kleine API, die streng gilt, ist auf lange Sicht jenem mächtigen API überlegen, das alles zulässt und damit nichts mehr verlässlich garantieren kann.

Total
0
Shares
Previous Post

Open-Core mit Core Java und Vaadin — Teil 1

Next Post

APIdia: Eine neue Plattform für API-Dokumentation

Related Posts