Angular als “Multi-Page-App” in den Bloomreach brXM integrieren: Umsetzung

Teil 2/2 der Blogreihe zum Thema Website-Bau mit Bloomreach

Im ersten Teil der Blogreihe zum Thema How To: Websitebau mit Bloomreach wurden die Anforderungen, das Anwendungsszenario und die Architektur einer Integration von Angular in den Bloomreach Experience Manager (brXM) beschrieben. Dieser Ansatz ermöglicht eine flexible Kombination aus dynamischen und „klassischen“ Seiten, wenn aus verschiedenen Gründen die Umsetzung einer vollständig in Angular geschriebenen Webanwendung nicht realisierbar ist.

Angular als “Multi-Page-App” in den Bloomreach brXM integrieren: Umsetzung

Die Architektur zeigt, dass über separate App-Selektoren verschiedene Module in die Website eingebunden und geladen werden. In diesem Teil der Blogreihe werden wir zeigen, wie die Umsetzung funktioniert. Zudem werden wir darstellen, wie diese Module in separaten „Chunks" ausgeliefert werden, um die Seiten-Performance zu erhöhen.

Abbildung 1: Detailarchitektur der Angular-Integration

Im ersten Schritt muss die Angular-App an der geeigneten Stelle in ein Template des brXM integriert werden. Das geschieht, fast wie üblich, mit einem speziellen Tag und der JavaScript-Einbindung (s. Code-Listing 1). Allerdings benennen wir den <app-root>-Tag in „app-news-loader" um. „News" ist in diesem Fall der Name der Teil-App, die hier in Zukunft als Angular Modul geladen werden soll. Jedes App-Modul benötigt ein eigenes Tag zum Einbinden in den brXM, sonst ist es im Angular-Code später nicht möglich festzustellen, welches Modul eigentlich geladen werden soll. In einer zweiten FTL-Datei binden wir das zweite Modul „app-contact-loader" ein (s. Code-Listing 2). Damit ist die Integration auf Seiten des brXM bereits abgeschlossen.

Code-Listing 1

<#include "../include/imports.ftl">

<h1>This is the news app module!</h1>
<app-news-loader></app-news-loader>

<@hst.link var="inlinejs" path="/js/angular/inline.bundle.js" />
<script type="text/javascript" src="${inlinejs}"></script>
<@hst.link var="polyfillsjs" path="/js/angular/polyfills.bundle.js" />
<script type="text/javascript" src="${polyfillsjs}"></script>
<@hst.link var="vendorjs" path="/js/angular/vendor.bundle.js" />
<script type="text/javascript" src="${vendorjs}"></script>
<@hst.link var="stylesjs" path="/js/angular/styles.bundle.js" />
<script type="text/javascript" src="${stylesjs}"></script>
<@hst.link var="mainjs" path="/js/angular/main.bundle.js" />
<script type="text/javascript" src="${mainjs}"></script>

Code-Listing 2

<#include "../include/imports.ftl">

<h1>This is the contact app module!</h1>
<app-contact-loader></app-contact-loader>

<@hst.link var="inlinejs" path="/js/angular/inline.bundle.js" />
<script type="text/javascript" src="${inlinejs}"></script>
<@hst.link var="polyfillsjs" path="/js/angular/polyfills.bundle.js" />
<script type="text/javascript" src="${polyfillsjs}"></script>
<@hst.link var="vendorjs" path="/js/angular/vendor.bundle.js" />
<script type="text/javascript" src="${vendorjs}"></script>
<@hst.link var="stylesjs" path="/js/angular/styles.bundle.js" />
<script type="text/javascript" src="${stylesjs}"></script>
<@hst.link var="mainjs" path="/js/angular/main.bundle.js" />
<script type="text/javascript" src="${mainjs}"></script>

Der Kernpunkt dieses Ansatzes geschieht in der app.module.ts (s. Code-Listing 3). Wichtig ist hierbei der Abschnitt „provideRoutes", der Routen für die jeweiligen Hauptmodule der beiden Apps anlegt, die wir oben in die FTLs eingebunden haben. Diese Routen enthalten, entgegen des üblichen Vorgehens bei Angular-Routen, nur das Modul, aber keine URL, weswegen man in diesem Fall nicht wie üblich verfahren kann. Wir fügen keine URLs hinzu, da wir keine URL-Verwaltung durch Angular möchten (s. Artikel Teil 1). Wir möchten aber, dass Angular mittels Webpack die Chunks für unsere App automatisch generiert. Deswegen benötigen wir die Routen. Sollte ein weiteres Modul hinzukommen, kann dieses einfach als weitere Route mit ihrem zentralen Modul konfiguriert werden.

Code-Listing 3

import {BrowserModule} from '@angular/platform-browser'
import {ApplicationRef, Inject, NgModule} from '@angular/core'

import {provideRoutes} from "@angular/router"
import {NewsLoaderComponent} from "./news-loader.component"
import {ContactLoaderComponent} from "./contact-loader.component"
import {DOCUMENT} from "@angular/common"

@NgModule({
  declarations: [
    NewsLoaderComponent,
    ContactLoaderComponent
  ],
  imports: [
    BrowserModule
  ],
  providers: [
    provideRoutes([
      {loadChildren : () => import('./news/news.module').then(m => 
m.NewsModule)}, // new dynamic import method
      {loadChildren : () => import('./contact/contact.module').then(m => 
m.ContactModule)}
    ])],
  entryComponents: [
    NewsLoaderComponent,
    ContactLoaderComponent
  ]
})
export class AppModule {
  private browser_document;

  constructor(@Inject(DOCUMENT) private document: any) {
    this.browser_document = document;
  }

  ngDoBootstrap(appRef: ApplicationRef) {

    if (this.browser_document.getElementsByTagName('app-news-loader').length > 0) {
      appRef.bootstrap(NewsLoaderComponent);
    }

    if (this.browser_document.getElementsByTagName('app-contact-loader').length > 0) {
      appRef.bootstrap(ContactLoaderComponent);
    }
  }
}

Ein weiterer wichtiger Punkt ist die Methode „ngDoBootstrap". In dieser prüfen wir, ob das HTML-Dokument, von dem aus die App gestartet wird, einen Tag für die „News"- oder „Contact"-App enthält. Je nachdem, welches Tag gefunden wurde, wird die entsprechende Component geladen.

Innere Angular-Architektur

Im folgenden Bild ist zur Übersicht die interne Architektur der Angular-App(s) dargestellt. Die jeweiligen Loader-Komponenten werden direkt in app.module.ts aufgerufen. Von dort aus werden die in der Route genannten Module geladen (s. Code-Listing 4). An der Loader-Klasse ist nur zu beachten, dass der richtige Selektor im Bereich @Component angegeben wird und in der Methode „ngOnInit" das richtige Modul referenziert wird. Dieses Modul referenziert dann nur noch als eine Art Fassade die eigentliche RootComponent der jeweiligen App (s. Code-Listing 5). Die RootComponent ist dann wiederum eine ganz gewöhnliche Angular-Component mit freiem Handlungsspielraum. In Code-Listing 6 ist sie zu sehen – allerdings noch ohne jeden Inhalt.

Abbildung 2: Erweiterte Detailarchitektur Angular-Integration

Code-Listing 4

import {
  Compiler,
  Component,
  Injector,
  NgModuleFactory, NgModuleRef,
  OnInit,
  ViewChild,
  ViewContainerRef
} from '@angular/core'

export const lazyModule = {
  newsModule: {
    loadChildren:  () => import('./news/news.module').then(m => m.NewsModule)
  }
};

@Component({
  selector: 'app-news-loader',
  template: '<div #container></div>'
})

export class NewsLoaderComponent implements OnInit {

  @ViewChild('container', {static: true, read: ViewContainerRef}) container: 
ViewContainerRef;

  private lazyModuleRef: NgModuleRef<any>

  constructor(private inj: Injector, private compiler: Compiler) {
  }


  ngOnInit() {

    const lazyModuleInjector = Injector.create({
      providers: [],
      parent: this.inj,
      name: 'lazyModuleProviders'
    });

    lazyModule.newsModule.loadChildren().then(moduleOrFactory => {
      if (moduleOrFactory instanceof NgModuleFactory) {
        return moduleOrFactory;
      } else {
        return this.compiler.compileModuleAsync(moduleOrFactory);
      }
    }).then((factory: NgModuleFactory<any>) => {
      this.lazyModuleRef = factory.create(lazyModuleInjector);
      const entryComponent = ( factory.moduleType as any).entry;
      const moduleRef = factory.create(this.inj);

      const compFactory =
moduleRef.componentFactoryResolver.resolveComponentFactory(entryComponent);

      this.container.createComponent(compFactory);
    });
  }
}

Code-Listing 5

import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { NewsRootComponent } from './news-root/news-root.component'

@NgModule({
  imports: [
    CommonModule
  ],
  declarations: [NewsRootComponent],
  bootstrap: [NewsRootComponent]
})
export class NewsModule {
  static entry = NewsRootComponent
}

Code-Listing 6

import { Component, OnInit } from '@angular/core'

@Component({
  selector: 'app-news-root',
  templateUrl: './news-root.component.html',
  styleUrls: ['./news-root.component.css']
})
export class NewsRootComponent implements OnInit {

  constructor() { }

  ngOnInit() {
  }

}

Damit ist die Einbindung des zweiten Angular-Moduls bereits vollständig abgeschlossen. Wie in der Architekturübersicht sichtbar, kann man auch gemeinsam genutzte Teile des Angular-Codes einfach von beiden Modulen aus referenzieren. Webpack kümmert sich dann automatisch darum, dass dieser Code in beiden App-Modulen mit ausgeliefert wird.

Maven-Integration

Die Maven-Integration ist schnell umgesetzt. Hierzu einfach unterhalb des brXM-Hauptordners ein Maven-Modul mit der pom.xml aus Code-Listing 7 anlegen. Diese POM baut über das "frontend-maven-plugin" unsere App. Für das Beispiel habe ich angenommen, dass die App neben dem brXM-Hauptordner unter „angular-app" liegt. Das „maven-resources-plugin" kopiert dann die Ergebnisse des Builds in das „site"-Module, in den Ordner der in der app1-main.ftl und app2-main.ftl für die JS-Dateien genannt ist.

Das Modul muss schließlich in der Haupt-POM des Projektes im Abschnitt „<profiles>“ im Profil mit der ID „default“ unter „<modules>“ hinzugefügt werden. Wenn man nun für brXM ein „mvn clean install" ausführt, wird die Angular-App automatisch mitgebaut.

Code-Listing 7

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0         
http://maven.apache.org/maven-v4_0_0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <parent>
    <groupId>com.diva-e</groupId>
    <artifactId>myangularhippo</artifactId>
    <version>0.1.0-SNAPSHOT</version>
  </parent>

  <name>Angular Frontend</name>
  <description>the Angular part of the my Angular Hippo project</description>
  <artifactId>myangularhippo-angular</artifactId>
  <packaging>pom</packaging>

  <properties>
    <frontend.maven.plugin.version>1.3</frontend.maven.plugin.version>
    <node.home.dir>${NODE_HOME}</node.home.dir>
    <node.version>v10.16.0</node.version>
    <npm.version>6.9.0</npm.version>
  </properties>

   <build>

    <plugins>
      <plugin>
        <groupId>com.github.eirslett</groupId>
        <artifactId>frontend-maven-plugin</artifactId>
        <version>${frontend.maven.plugin.version}</version>
        <configuration>
          <installDirectory>${node.home.dir}/..</installDirectory>
        </configuration>
        <executions>
          <execution>
            <id>install node and npm</id>
            <goals>
<goal>install-node-and-npm</goal>
            </goals>
            <configuration>
              <nodeVersion>${node.version}</nodeVersion>
              <npmVersion>${npm.version}</npmVersion>
            </configuration>
          </execution>
          <execution>
            <id>npm install angular-app</id>
            <goals>
              <goal>npm</goal>
            </goals>
            <configuration>
              <workingDirectory>../../angular-app/</workingDirectory>
              <arguments>install</arguments>
            </configuration>
          </execution>
          <execution>
            <id>npm build angular-app</id>
            <goals>
              <goal>npm</goal>
            </goals>
            <configuration>
              <workingDirectory>../../angular-app/</workingDirectory>
              <arguments>run-script build-prod</arguments>
            </configuration>
          </execution>
        </executions>
      </plugin>

      <plugin>
        <artifactId>maven-resources-plugin</artifactId>
        <version>2.7</version>
        <executions>
          <execution>
            <id>copy angular binaries</id>
            <phase>install</phase>
            <goals>
              <goal>copy-resources</goal>
            </goals>

            <configuration>
              <outputDirectory>${basedir}/../site/src/main/webapp/js/angular/</outputDirectory>
              <resources>
                <resource>
                  <directory>${basedir}/../../angular-app/dist/</directory>
                  <includes>
                    <include>*.js</include>
                  </includes>
                </resource>
              </resources>
            </configuration>
          </execution>
        </executions>
      </plugin>
    </plugins>

  </build>
</project>

Fazit

Angular auf diese Art und Weise in den brXM zu integrieren, mag anfangs vielleicht als Hack erscheinen, aber unser Vorgehen zeigt, dass es ohne große Umwege und mit relativ normalen Mitteln möglich ist. Diese Lösung haben wir ursprünglich für Angular 2 entworfen und bis heute ohne Probleme bis hin zu Angular 8 übertragen. So kann man den Graben zwischen “old-school JSP” und der dynamischen Angular-Welt überbrücken. Wir hoffen, dass unser How-To: Website-Bau mit Bloomreach die Arbeit in dem ein oder anderen Projekt erleichtert.

Das gesamte Projekt mit dem vollständigen, lauffähigen Beispielcode gibt es hier.