Server- und Clientseite

JavaFX und Dependency Injections: Entkopplung leicht gemacht
Kommentare

Aus dem Bereich der (Java-EE-) Webanwendungen kennt und schätzt man die Möglichkeiten, die einem von Applikationsservern zur Verfügung gestellt werden. Eine davon ist Dependency Injection (DI). DI wird eingesetzt, um die einzelnen Komponenten zu entkoppeln. Was also liegt näher, als diesen Ansatz auch in einer Clientanwendung zu verwenden?

Contexts and Dependency Injection (CDI) ist hier der Standard und bietet den Vorteil, auf der Server- und Clientseite eingesetzt zu werden. Im Idealfall sollten somit die reinen CDI-basierenden Komponenten in beiden Umgebungen lauffähig sein. Natürlich vorausgesetzt, dass keine weiteren Features der EE-Welt verwendet worden sind.

CDI als auch JavaFX sind mächtige und zukunftsweisende Technologien. Die Verbindung beider bietet dem Entwickler den Zugriff auf eine sehr vielfältige Palette von Möglichkeiten. Wie aber kann diese Symbiose möglichst effizient erreicht werden? Wenn man sich ein wenig mit beiden Technologien auseinandersetzt, stellt man recht schnell fest, dass sie einen eigenen Lebenszyklus besitzen. Diese Zyklen passen nur leider nicht immer zueinander. Die initiale Frage, die sich immer stellt, ist: Welche der Technologien wird als führend in der Initialisierung angenommen? Aber dazu später mehr. Beginnen wir mit den beiden Lebenszyklen.

Das Leben von JavaFX

Der Lebenszyklus von JavaFX ist recht unkompliziert. Hier verweise ich auf das API-Doc. Die Schritte sind:

  • Erzeugen einer Instanz der Applikationsklasse
  • Aufruf der init()-Methode
  • Aufruf der start(javafx.stage.Stage)-Methode
  • Warten bis die Applikation beendet werden soll, indem auf eines der beiden Ereignisse gewartet wird: Aufruf von exit() oder implicitExit, wenn das letzte Fenster der Anwendung geschlossen worden ist.
  • Aufruf der Methode stop()

Die Geburt einer CDI Bean

Bei CDI ist der Prozess über Events gesteuert und kann demnach auch mittels dieser Events beschrieben werden:

  • BeforeBeanDiscovery
  • ProcessModule
  • ProcessAnnotatedType, ProcessSyntheticAnnotatedType
  • ProcessInjectionTarget, ProcessProducer
  • ProcessInjectionPoint
  • ProcessBeanAttributes
  • ProcessBean, ProcessManagedBean, ProcessSessionBean, ProcessProducerMethod, ProcessProducerField
  • ProcessObserverMethod
  • AfterBeanDiscovery
  • AfterDeploymentValidation

CDI in SE-Umgebungen

Um CDI in einer SE-Umgebung zu verwenden, muss lediglich der CDI-Container initialisiert werden. Bei der Verwendung von Weld ist dazu ein Schritt notwendig, wie er in Listing 1 dargestellt ist.

Listing 1

WeldContainer weldContainer = new Weld().initialize();

// Fuer die Verwendung in SE-Umgebungen
public class CDIContainerSingleton {
  private final static CDIContainerSingleton ourInstance 
    = new CDIContainerSingleton();
  private final WeldContainer weldContainer;
  public static CDIContainerSingleton getInstance() { return ourInstance; }
  private CDIContainerSingleton() { weldContainer = new Weld().initialize(); }
  public <T> T getManagedInstance(final Class<T> clazz) { 
    final Instance<T> ref = getInstanceReference(clazz);
                            return ref.get();
  }
  public <T> T getManagedInstance(final AnnotationLiteral literal, 
  final Class<T> clazz) {
    final Instance<T> ref = getInstanceReference(literal, clazz);
    return ref.get();
  }
  public void fireEvent(final Object o) { weldContainer.event().fire(o); }
  public Event<Object> event() { return weldContainer.event(); }
  public BeanManager getBeanManager(){return weldContainer.getBeanManager();}
// SNIPP..
}

Danach kann man von dieser Container-Instanz die jeweils gewünschte, von CDI verwaltete Klasseninstanz bekommen. Qualifier werden hier als Instanzen der Klasse AnnotationsLiteral vom Typ T (der Qualifier) zusammen mit der zu instanziierenden Klasse übergeben. Listing 2 zeigt, wie eine Instanz eines Loggers vom Weld-Container programmatisch geholt werden kann. Hierbei wird ersichtlich, dass dieses Vorgehen selbst noch nicht den gewünschten Komfort bietet. Der zu schreibende Quelltext ist noch wesentlich länger als notwendig. Angenehmer für den Entwickler ist die Verwendung der Annotation @Inject. Um dieses zu erreichen, sind noch einige zusätzliche Schritte notwendig.

Listing 2

Logger logger = weldContainer.instance().select(Logger.class).get();
// Verwendung des Singleton
private final CDIContainerSingleton cdi =  CDIContainerSingleton.getInstance();
private final Logger logger = cdi.getManagedInstance(Logger.class);

JavaFX mit CDI verbinden

Um beides zusammenzubringen, bietet sich die start-Methode von JavaFX als initialer Startpunkt an: public void start(Stage primaryStage) throws Exception. Hier erhält man die Haupt-Stage-Instanz der Anwendung und kann mit der Initialisierung der CDI-Umgebung beginnen. Diese Instanz der Klasse Stage ist selbst noch nicht im CDI-Kontext, was allerdings nicht weiter von Bedeutung ist. Prinzipiell sind folgende Schritte notwendig, um mit der Integration von CDI zu beginnen:

  • Hole die Instanz des CDI-Containers
  • Hole die Parameter der Main-Methode
  • Übergebe die Instanz der Klasse Stage an eine CDI-Umgebung

Um an eine Instanz des Weld-Containers zu gelangen, wird hier das Singleton-Pattern verwendet. Das ist natürlich nicht zwingend und kann auf beliebigem alternativem Wege passieren. In der Clientanwendung allerdings hat sich dieser Weg oft als ausreichend und robust erwiesen. Ebenfalls wünschenswert wäre es, wenn die Applikationsparameter an beliebigen Stellen innerhalb der Clientanwendung per @Inject zur Verfügung gestellt werden könnten. Der Grundgedanke, um das zu erreichen, ist simpel. Die Klasse Application bietet für diesen Zweck die Methode getParameters(). Nun gilt es noch, dieses so aufzubereiten, damit es per Injektion an anderen Stellen zur Verfügung steht. Hierfür eigenen sich Producer. Auf den ersten Blick mag es ein wenig umständlich erscheinen, dass man einer vom CDI-Container verwalteten Instanz der Klasse ApplicationParametersProvider (Listing 3) die Parameter sich selbst übergibt (siehe Listing 4: applicationParametersProvider.setParameters(getParameters());). Allerdings hat man damit genau das erreicht, was gewollt war: Diese Klasse selbst ist ein @Singleton und damit in der Clientanwendung einmalig vorhanden. In jeder weiteren Klasse kann so per @Inject ApplicationParametersProvider auf die Parameter zugegriffen werden, ohne auf die Instanz der Klasse Application (statischen) Zugriff haben zu müssen.

Listing 3

@Singleton @CDIJavaFXBaseApp
public class ApplicationParametersProvider {
  private Application.Parameters parameters;
  void setParameters(Application.Parameters p) { this.parameters = p; }
  public @Produces @CDIJavaFXBaseApp
  Application.Parameters getParameters() { return this.parameters; }
} 

Listing 4

public abstract class CDIJavaFXBaseApplication extends Application {
  // SNIPP..
  @Override
  public void start(Stage primaryStage) throws Exception {
    // Hole die Instanz des Weld-Containers
    final CDIContainerSingleton cdi = CDIContainerSingleton.getInstance();
    // Hole die Parameter der Main-Methode
    final AnnotationLiteral&ltCDIJavaFXBaseApp&gt annotationLiteral 
      = new AnnotationLiteral&ltCDIJavaFXBaseApp&gt() { };
    final ApplicationParametersProvider applicationParametersProvider
      = cdi.getManagedInstance(annotationLiteral,
      ApplicationParametersProvider.class);
    applicationParametersProvider.setParameters(getParameters());
    // SNIPP..
    // Uebergebe die Instanz der Klasse Stage an eine CDI-Umgebung
    cdi.event().select(Stage.class, new AnnotationLiteral&ltCDIStartupScene&gt() 
    { }).fire(primaryStage);
  }
  // SNIPP..
}

Die Übergabe erfolgt mittels Event über den CDI-Event-Mechanismus. Der Aufruf ist synchron, was allerdings hier nicht weiter interessant ist. Ausgenutzt wird der Umstand, dass dem Event eine Instanz der Klasse Stage mitgegeben werden kann. Bei der Gegenstelle, die für die Verarbeitung des Events verantwortlich ist, handelt es sich um eine vom CDI-Container verwaltete Instanz. Ab diesem Zeitpunkt kann also per @Inject gearbeitet werden. Aber leider noch nicht überall in der JavaFX-Anwendung. Sehen wir uns zuerst die Gegenstelle an, die das Ereignis verarbeitet. Hierbei handelt es sich um einen CDI-Observer. Dieser fängt den Event auf und damit die Instanz der Klasse Stage (Listing 5).

Listing 5

public class MainStarter {
  @Inject MainWindow mainWindow;
  public void launchJavaFXApplication(
    @Observes @CDIStartupScene Stage stage) {
    stage.setScene(new Scene(mainWindow));
    stage.show();
  }
}

Die Instanz der Klasse MainStarter wird vom CDI-Container verwaltet, arbeiten per @Inject ist somit möglich. Hier wird eine Instanz der Klasse MainWindow injiziert und der Instanz stage übergeben. Die Klasse MainWindow ist also schon selbst eine vom CDI-Container verwaltete Instanz. Wie aber sieht es mit den Controllern aus? Ein Ziel ist es, die JavaFX-eigenen Mittel soweit es geht zu unterstützen. Das bedeutet in diesem Fall, dass die Definitionen des GUI in einer FXML-Datei vorgenommen werden und die Controller dynamisch zur Laufzeit des GUI zugewiesen werden. Dementsprechend sollen Controller auch nicht explizit in der FXML-Datei angegeben werden.

Damit innerhalb eines Controllers die Möglichkeiten von CDI ausgeschöpft werden können, muss man den Weg über den CDI-Container gehen. Als Einstiegspunkt hat sich der FXMLLoader bewährt. Der FXMLLoader gibt einem die Möglichkeit, eine ControllerFactory per Callback anzugeben. Das hat den Vorteil, dass der Instanziierungsprozess über den CDI-Container umgeleitet werden kann. Im Beispiel (Listing 6) wir eine neue Instanz mittels instance.select(p).get(); als return-Statement verwendet. Zu beachten ist, dass hier eine Namenskonvention implizit vorausgesetzt wird. Der Name des Controllers ist in diesem Beispiel immer NameDerGUIKlasse + Controller und liegt im selben Package. Mittels ContextResolver kann man das natürlich auch zur Laufzeit entscheiden, um dann die jeweils gewünschte Implementierung zu wählen.

Listing 6

@Singleton
public class FXMLLoaderSingleton {
  private @Inject @CDILogger Logger logger;
  private @Inject Instance<CDIJavaFxBaseController> instance;
  //..SNIPP..
  public FXMLLoader getFXMLLoader(Class clazz) {
    final Map<Class, FXMLLoader> loaderMap = class2LoaderMap;
    final String name = clazz.getName();
    if (loaderMap.containsKey(clazz)) {
      // alles schon geladen...
    } else {
      final String fxmlFileName = clazz.getSimpleName() + ".fxml"; 
      final URL resource = clazz.getResource(fxmlFileName);
      final FXMLLoader loader = new FXMLLoader(resource);
      loader.setControllerFactory(new Callback<Class&lt?>, Object>() {
          @Override
          public Object call(Class<?> param) {
            final Class<CDIJavaFxBaseController> p =
              (Class<CDIJavaFxBaseController>) param;
            return instance.select(p).get();
          }
      });
      try {
        final Class<?> aClass 
          = Class.forName(clazz.getName() + "Controller");
        final Callback<Class>?>, Object> controllerFactory = 
          loader.getControllerFactory();
        final CDIJavaFxBaseController call = 
          (CDIJavaFxBaseController) 
        controllerFactory.call(aClass);
        loader.setController(call);
      } catch (ClassNotFoundException e) {
        logger.error(e);
      }
      loaderMap.put(clazz, loader);
    }
    return loaderMap.get(clazz);
  }
  private FXMLLoaderSingleton() {
  }
}

Da die GUI-Komponente (in unserem Beispiel die Klasse MainWindow) per @Inject instanziiert wird, wird nach dem Konstruktoraufruf und der Injektion der dort definierten Instanzen die Methode mit der Annotation @Postconstruct aufgerufen (falls vorhanden). Gehen wir davon aus, dass eine Instanz der Klasse FXMLLoaderSingleton per @Inject zur Verfügung gestellt worden ist (Listing 7). Somit kann in der @Postconstruct-Methode die Verbindung zwischen FXML-Datei, Controller und dem Root-Element hergestellt werden.

Listing 7

//..SNIPP..
public @Inject FXMLLoaderSingleton fxmlLoaderSingleton;
public C controller;

@PostConstruct
public void init() {
  final FXMLLoader fxmlLoader = 
    fxmlLoaderSingleton.getFXMLLoader(getPaneClass());
  fxmlLoader.setRoot(this);
  try {
    fxmlLoader.load();
    controller = fxmlLoader.getController();
  } catch (IOException exception) {
    throw new RuntimeException(exception);
  }
}
public C getController() { return controller; }
public void setController(C controller) { this.controller = controller; }

Für den Entwickler ist der Controller von nun an eine CDI Bean. Mit allen Vor- und Nachteilen. Bestehender Code muss oft in der Form umgebaut werden, dass ausschließlich der Default-Konstruktor verwendet wird. Bis hierher haben wir nun die GUI-Komponente, den Controller als auch die Main-Anwendungsklasse im CDI-Kontext. Soweit die weiteren eigenen GUI-Komponenten per @Inject in die Instanz der MainGUI-Komponente injiziert werden, sind diese auch im CDI-Kontext. Nur leider funktioniert das schon alleine bei der TableView-Komponente nicht mit allen Elementen. Die TableView selbst kann noch per @Inject erzeugt werden, da diese Klasse einen default-Konstruktor besitzt. Nur leider ist man damit keinen Schritt weiter, wenn man z. B. eigene Implementierungen der CellFactory verwenden möchte. Sicherlich kann man diese programmatisch innerhalb der @Postconstruct-Methode setzen, das allerdings ist nicht der saubere Weg aus meiner Sicht und führt zu vermehrt repetitiven Codeanteilen, die sich ausschließlich mit der Initialisierung der Komponenten beschäftigen. Ziel ist hier ganz klar eine Definition der Elemente per FXML. In Listing 8 ist beispielhaft eine TableView mit einer eigenen Implementierung der CellFactory definiert (EditingAutoCompleteStringCellFactory). Gehen wir davon aus, dass die Klasse EditingAutoCompleteStringCellFactory mit der dort benötigten fachlichen Logik per @Inject versehen worden ist.

Listing 8

//..SNIPP..
<TableView fx:id="tableView" editable="true">
  <columns>
    <FilterableStringTableColumn fx:id="vorname">
      <cellFactory>
        <EditingAutoCompleteStringCellFactory/>
      </cellFactory>
      <cellValueFactory>
        <PropertyValueFactory property="vorname"/>
      </cellValueFactory>
    </FilterableStringTableColumn>
    //..SNIPP..     
  </columns>
</TableView>

JavaFX selbst wird bei der Erzeugung der TableView-Instanz den default-Konstruktor verwenden. Um hier eine CDI Bean zu erhalten, muss also in den Initialisierungsprozess eingegriffen werden. Der Vorgang selbst ist recht trivial, da man dank der unterschiedlichen Lifecycle die Möglichkeit erhält, die jeweiligen Initialisierungen in der richtigen Reihenfolge durchführen zu können. Hier wird der @Postconstruct-Ansatz gewählt. Die Implementierung eines default-Konstruktors ist die Stelle, an der die CDI-Initialisierung der Komponente selbst vorgenommen werden kann.

Listing 9

public <T> T activateCDI(T t) {
  final BeanManager beanManager = getBeanManager();
  final Class<?> aClass = t.getClass();
  final AnnotatedType annotationType = beanManager.createAnnotatedType(aClass);
  final InjectionTarget injectionTarget =  
    beanManager.createInjectionTarget(annotationType);
  final CreationalContext creationalContext = 
    beanManager.createCreationalContext(null);
  injectionTarget.inject(t, creationalContext);
  injectionTarget.postConstruct(t);
  return t;
}
//..SNIPP..

Listing 9 zeigt eine Möglichkeit, wie man unter Verwendung des BeanManager die Initialisierung einer Instanz selbst vornehmen kann. Die Methode public <T> T activateCDI(T t) kann man z. B. in dem Holder des CDI-Containers (CDIContainerSingleton) unterbringen. Damit reduziert sich der Code zur Aktivierung von CDI in einer eigenen Komponente auf den Aufruf dessen innerhalb des Konstruktors:

public EditingAutoCompleteStringCellFactory () {
  CDIContainerSingleton.getInstance().activateCDI(this);
}

Hierbei ist zu beachten, dass sämtliche Initialisierungen der Komponente nicht wie sonst eventuell üblich, innerhalb des Konstruktors erfolgen. Stattdessen werden sie ausgelagert in die @Postconstruct-Methode und damit immer nach den Injektionen von JavaFX und des CDI-Containers ausgeführt.

Nun sind wir in der Lage, an fast allen Stellen innerhalb der JavaFX-Anwendung mit CDI in der gewohnten Art und Weise zu arbeiten.

Only one more Thing

Die Herausforderung lag bisher immer darin, dass die initialisierenden Methoden von den jeweiligen Frameworks teilweise in unterschiedlicher Reihenfolge aufgerufen wurden. Das lag immer daran, dass der treibende Prozess entweder CDI oder JavaFX gewesen ist. Das bedeutete auch, dass aus dem einen Initialisierungsprozess der jeweils andere getriggert wurde. Passiert das immer in derselben Reihenfolge, kommt es nicht zu Problemen, da man sich darauf einstellen kann. Aber es ist immer wichtig, die Reihenfolge bei der Implementierung zu beachten. Das wird sich jetzt ändern.

Die Initialisierung als Wiederholung

Zur Wiederholung der grundlegende Basisprozess kurz wiederholt: Eine Komponente wird instanziiert, und der dazugehörige Controller wird dann von dem FXMLLoader geladen. Dazu wird die ControllerFactory überschrieben. Das hat den Sinn, dass an dieser Stelle der CDI-Initialisierungsprozess getriggert werden kann. Natürlich nur für die Instanz des Controllers. Für den Artikel jetzt reicht dieser Ansatz vollständig aus, um die Wirkungsweise zu demonstrieren, denn hier passiert genau das, was vorher beschrieben wurde: Innerhalb des Initialisierungsprozesses (JavaFX) wird der Initialisierungsprozess von CDI getriggert. Der Controller selbst weiß allerdings nicht, in welcher Reihenfolge hier etwas von außen passieren wird. Es wird dem Controller lediglich mitgeteilt, dass der Initialisierungsprozess begonnen hat (controller.initInstance()).

Listing 10

@Inject Instance<CDIJavaFxBaseController> instance;
//...
loader.setControllerFactory(
  new Callback<Class<?>, Object>() {
    @Override
    public Object call(Class<?> param) {
      final Class<JavaFXBaseController> p 
        = (Class<JavaFXBaseController>) param;
      final JavaFXBaseController controller 
        = instance.select(p).get();
      controller.initInstance(); // trigger async call
      return controller;
    }
});
try {
  final Class<?> aClass 
    = Class.forName(clazz.getName() + "Controller");
  final CDIJavaFxBaseController call 
    = (CDIJavaFxBaseController) loader.getControllerFactory()
  .call(aClass);
  loader.setController(call);
} catch (ClassNotFoundException e) { logger.error(e); }

Die Implementierung

In Listing 10 ist die Instanziierung der Controllerinstanz zu sehen. Die Instanz selbst ist dann schon von CDI gemanagt. Auf dieser Instanz wird die Methode initInstance() aufgerufen. Dabei handelt es sich um den Trigger, um die Synchronisation der beiden Initialisierungsprozesse zu starten. Was also genau passiert nun?

Listing 11

private Boolean initCompleteCDI = false;
private Boolean initCompleteFX = false;
@Override
public final void initInstance(){
  final CachedThreadPoolSingleton instance = 
    CachedThreadPoolSingleton.getInstance();
  supplyAsync = CompletableFuture
  .supplyAsync(task, instance.cachedThreadPool);
  if (logger.isDebugEnabled())
  supplyAsync.thenAccept(logger::debug);  // logger
}
public final Supplier<String> task = ()-> {
  // Warten bis alle true
  while(! (initCompleteCDI && initCompleteFX) ){
    try {
      // evtl loggen
      if (logger.isDebugEnabled()) {
        logger.debug("initCompleteCDI 
          = " + initCompleteCDI);
        logger.debug("initCompleteFX 
          = " + initCompleteFX);
        logger.debug("ClassName = " 
          + getClass().getName());
      }
      TimeUnit.MILLISECONDS.sleep(1);
    } catch (InterruptedException e) {
      e.printStackTrace();
      return e.toString();
    }
  }
  final boolean fxApplicationThread 
    = Platform.isFxApplicationThread();
  if ( ! fxApplicationThread){
    Platform.runLater(this::initBusinessLogic);
  } else {
    initBusinessLogic();
  }
  return DONE;
};

In Listing 11 wird der Supplier einem ThreadPool zur Ausführung übergeben. Dieser Aufruf ist allerdings nicht blockierend. Erst wenn die beiden Attribute (initCompleteCDI, initCompleteFX ) true sind, wird die Methode initBusinessLogic() aufgerufen. Das jeweilige Attribut (CDI oder FX) wird in der dazugehörigen Init-Methode gesetzt (Listing 12). Fertig ist der Lifecycle.

Listing 12

@PostConstruct
public final void postconstruct(){
  cdiPostConstruct();
  initCompleteCDI = true;
  if (logger.isDebugEnabled()) {
    logger.debug("postconstruct ready " 
      + getClass().getName());
  }
}

public abstract void cdiPostConstruct();

@Override
public final void initialize(URL url, 
  ResourceBundle resourceBundle) {
  initializeFX(url, resourceBundle);
  initCompleteFX = true;
  if (logger.isDebugEnabled()) {
    logger.debug("initialize ready " 
      + getClass().getName());
  }
}

protected abstract void initializeFX(URL url, 
  ResourceBundle resourceBundle);
/**
* wird nach der init von CDI und JavaFX aufgerufen,
* egal in welcher Reihenfolge die init durchlaufen wird.
*
* ein blockierender method call
*
*/
public abstract void initBusinessLogic();

Die jeweils vom Framework vorgesehenen Methoden werden final deklariert und innerhalb der Implementierung zum einen die neu definierte abstrakte Methode aufgerufen, damit der Entwickler die Möglichkeit hat, gezielt dort weitere Initialisierungen vorzunehmen. Das sollte dann aber nur in seltenen Fällen passieren. Zusätzlich wird das korrespondierende Attribut auf true gesetzt, wenn der Methodenaufruf erfolgreich gewesen ist. Für den Entwickler, der dieses Konstrukt verwendet, sollte ausschließlich die Implementierung der Methode initBusinessLogic() von Interesse sein. Hier ist sichergestellt, dass alle Komponenten von CDI und JavaFX vorhanden und initialisiert sind.

Mit dem CompletableFuture kann man sehr schön und einfach asynchrone Abläufe erzeugen und an definierten Punkten wieder zusammenlaufen lassen. Im Vergleich zu einer Lösung mittels Threads ist die Komplexität erheblich verringert. Wir haben uns in diesem Artikel angesehen, wie dieses verwendet werden kann, um die beiden Lifecycle von CDI und JavaFX robust zu verbinden und dem Entwickler einen einfachen Einstiegspunkt für die Implementierung der Initialisierung einer Geschäftslogik zu geben. In diesem Zuge möchte ich auf das kleine Open-Source-Projekt RapidPM aufmerksam machen. Dort sind in den Modulen cdi-commons, cdi-commons-se und cdi-commons-fx einige der hier besprochenen Pattern abgelegt.

Fazit

Wir sind nun in der Lage, uns aus beiden Technologien die Dinge herauszupicken, die uns für die jeweilige Anwendung geeignet erscheinen. Annehmlichkeiten, die uns bisher aus der Java-EE-Welt bekannt waren, sind in der Desktopanwendung ebenfalls verfügbar. Aber CDI ist nicht das einzige, das verwendet werden kann. In einem weiteren Artikel werden wir uns zum Beispiel auch ansehen, wie der Bootstrap mittels Spring erfolgen kann. Es wird sich lohnen. Bei Anmerkungen oder Anregungen scheuen Sie sich nicht, mich direkt zu kontaktieren.

Aufmacherbild: Program code and computer keyboard von Shutterstock / Urheberrecht: isak55

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -