WebSockets + Spring Boot + Angular (1/2)

Die klassische Kommunikation zwischen JavaScript Anwendung und Backend findet zustandslos getriggert durch den Client statt. Doch was, wenn unser Backend „wichtige Neuigkeiten“ für unser Frontend hat?

„Polling“ mag dem ein oder anderen Pragmatiker dazu einfallen. Dabei sendet der Client in festgelegten Intervallen entsprechende Requests an unser Backend, um festzustellen, ob es neue Daten gibt. Technisch eine einfache Lösung, die jedoch dazu führt, dass (insbesondere bei steigenden Userzahlen) unnötig viele Requests von unserem Server beantwortet werden müssen.

WebSockets dagegen stellen eine konstante bidirektionale Verbindung zwischen Server und Client dar und ermöglichen es uns Nachrichten in Echtzeit auszutauschen. In Spring Boot benötigen wir dazu eine zusätzliche Dependency

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-websocket</artifactId>
		</dependency>

Innerhalb unserer Spring Boot Anwendung werden wir in den meisten Fällen zusätzlich STOMP einsetzen. Stomp ist ein weit verbreitetes Nachrichtenprotokoll, das out-of-the-box von Spring unterstützt wird und das speziell für Skriptsprachen entwickelt wurde und somit auch eine einfache Integration in unsere Angular Anwendung bietet.

Der erste Schritt besteht nun die gewünschten Websocket Endpunkte zu konfigurieren:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/notifier");
        config.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws").setAllowedOriginPatterns("*");
    }

/ws ist der Endpunkt unter dem Websocket Verbindungen zugelassen werden.

/notifier ist der Pfadpräfix unter dem sich Clients registrieren, um Nachrichten von unserem Backend zu empfangen, z.B. http://…/ws/notifier/messages

/app ist der Pfadpräfix den die Clients dazu verwenden können Nachrichten an das Backend zu schicken, z.B. http://…/ws/app/new-message

Empfangen

In unseren Controllern können wir nun, ähnlich wie bei klassischen Rest-Endpunkten, Methoden annotieren, um auf Nachrichten zu reagieren

@Controller
@RequiredArgsConstructor
public class MessageController {

    private final MessageDummyService messageDummyService;

    @MessageMapping("/message")
    public void addMessage(MessageDto message) {
        messageDummyService.addMessage(message);
    }
}

Zusätzlichen könnten wir auch mittels @SendTo(„/notifier/new-message“) und einem entsprechenden Rückgabe-Objekt direkt mittels WebSocket Nachricht antworten

Senden

Innerhalb eines Services lassen sich Nachrichten bequem per Messaging Template absetzen

@Service
@RequiredArgsConstructor
public class MessageDummyServiceImpl implements MessageDummyService {

    private final SimpMessagingTemplate simpMessagingTemplate;

    private void sendMessage() {
        simpMessagingTemplate.convertAndSend("/notifier/message", new MessageDto("Lorem Ipsum", Instant.now()));
    }
}

…aber bitte mit JSON

Damit unser Nachrichtenaustausch über WebSockets analog zu unseren Rest-Endpunkten mit JSON Formaten arbeitet, ergänzen wir unsere WebSocketConfig oben um eine zusätzliche Konfiguration

    @Override
    public boolean configureMessageConverters(List<MessageConverter> messageConverters) {
        DefaultContentTypeResolver resolver = new DefaultContentTypeResolver();
        resolver.setDefaultMimeType(MimeTypeUtils.APPLICATION_JSON);
        MappingJackson2MessageConverter converter = new MappingJackson2MessageConverter();
        converter.setObjectMapper(objectMapper);
        converter.setContentTypeResolver(resolver);
        messageConverters.add(converter);
        return false;
    }

Das war’s erst einmal schon für unser Backend.

Im nächsten Teil schauen wir uns die Gegenseite, unsere Angularanwendung, an.

Alles Live und In Farbe: https://github.com/GEDOPLAN/spring-angular-websocket

Globale Entity-Filter mit Hibernate @Filter

Dank JPQL oder der Criteria API sind Queries keine große Sache. Doch oft entsteht in Projekten die Anforderung, Daten basierend auf z.B. Berechtigungen zu filtern. Alle Abfragen mit entsprechenden where-Clauseln zu versehen mag pragmatisch sein, ist aber sehr anfällig für Fehler und die Wartbarkeit leidet.

Hibernate bietet für genauso diesen Zweck @Filter Annotation an, die es uns erlauben auf Entity-Ebene Filter zu deklarieren, die bei Bedarf aktiviert werden können. Die Definition solcher Filter geschieht per Annotation auf z.B. der Entity selbst oder aber auch auf Package Ebene:

@FilterDefs(
        @FilterDef(
                name = FilterNames.BY_MATERIAL_NUMBER,
                parameters = @ParamDef(name = FilterNames.BY_MATERIAL_NUMBER_P_PREFIX, type = "string"),
                defaultCondition = "material_number like :" + FilterNames.BY_MATERIAL_NUMBER_P_PREFIX
        )
)

Unser Filter definiert einen Namen (der später dazu verwendet, wird die, Aktivierung durchzuführen), ein oder mehrere Parameter und natürlich die konkrete SQL Kondition.

Dieser Filter muss nun lediglich auf Entity-Ebene durch eine entsprechende Annotation konfiguriert werden

@Entity
@Filter(name = FilterNames.BY_MATERIAL_NUMBER)
public class Material {...}

Solche Filter sind per Default deaktiviert und müssen nun für die aktive Hibernate Session aktiviert werden. Dies lässt sich über den Entitymanager ganz leicht durchführen

Session session = em.unwrap(Session.class);
Filter filter = session.enableFilter(FilterNames.BY_MATERIAL_NUMBER);
filter.setParameter(FilterNames.BY_MATERIAL_NUMBER_P_PREFIX, "99%");

em.createQuery(...)

In der laufenden Session werden jetzt alle Queries, die direkt „Material“ adressieren, zusätzlich mit unserem Filter versehen. Wichtig: das betrifft lediglich alle Filter Queries, ein direktes Laden über die Id mittels z.B. „load“ führt keine Filterung durch und auch das Laden abhängiger Entities (OneToMany, ManyToMany) bedarf zusätzlicher Annotationen (@FilterJoinTable).

Filter by Default

Oft wollen wir solche Filter immer aktivieren, um z.B. über die Berechtigungen des Users Einschränkungen durchzuführen. In Spring Boot bietet sich dafür eine Customizer Methode an

@Configuration
public class MaterialFilter {
    @Bean
    @ConditionalOnMissingBean
    public PlatformTransactionManager transactionManager(
            ObjectProvider<TransactionManagerCustomizers> transactionManagerCustomizers) {
        JpaTransactionManager transactionManager = new JpaTransactionManager() {
            @Override
            protected EntityManager createEntityManagerForTransaction() {
                final EntityManager entityManager = super.createEntityManagerForTransaction();
                Session session = entityManager.unwrap(Session.class);
                activateMaterialFilterByRole(session); // read roles, activate filter, set parameters
                return entityManager;
            }
        };
        transactionManagerCustomizers.ifAvailable((customizers) -> customizers.customize(transactionManager));
        return transactionManager;
    }

(als Alternative wäre auch eine Lösung mittels AOP Methoden möglich)

Und wie immer hier noch mal alles Live und in Farbe: https://github.com/GEDOPLAN/hibernate-filter

Angular, besser testen mit Component Harness

Gerade in der Welt von JavaScript kann testen eine mühsame Aufgabe sein. Asynchronität, Timer und Events führen dazu, dass Tests schnell unübersichtlich werden. Um diese Aufgabe zu erleichtern, gibt es in Angular die sogenannten Component Harness

Die Idee ist simple: anstatt sich mit Events und generischen DOM Methoden herumzuärgern definiert eine Komponente eine eindeutige Schnittstelle über die mit dieser Komponente interagiert werden kann. Ein solcher Component Harness kann entweder von uns selber geschrieben werden (für unsere eigenen Komponenten) oder verwendete Bibliotheken bringe diese zum direkten los testen mit, wie z.B. Angular Materials.

Die Verwendung ist denkbar einfach. Im ersten Schritt benötigen wir in unserem Unit-Test einen sogenannten RootLoader der später die einzelnen UI Komponenten für uns lädt. Dieser RootLoader wird auf Basis der aktuell zu testenden Komponente initialisiert:

    fixture = TestBed.createComponent(AppComponent);
    rootLoader = TestbedHarnessEnvironment.documentRootLoader(fixture);

In unseren einzelnen Tests besteht nun die Möglichkeit, auf Basis der Harness Klassen die entsprechenden Elemente innerhalb unserer Komponente zugreifbar zu machen. Dies geschieht über eine entsprechend typisiert Methode, die zusätzlich auch CSS Selektoren entgegennimmt:

  it('...', async() => {
    const input = await rootLoader.getHarness(MatInputHarness.with({selector: '#form1 input'}))
    const button = await rootLoader.getHarness(MatButtonHarness);

Zwei wichtige Punkte: 1) unsere Testmethode wird als asynchrone Methode deklariert (async), um bei der Interaktion mit den Elementen 2) einfach per „await“ auf die asynchrone Abarbeitung warten zu können. Die meisten Methoden in Bezug auf den Test-Harness sind asynchron (Rückgabetyp ist in aller Regel ein „Promise„) und werden mit einem „await“ blockiert. Technisch könnte man hier auch eine altmodische Callbackmethode deklarieren: rootLoader.getHarness(…).then( component => {…}) , was aber bekanntermaßen zu vielen verschachtelten Callbackbackmethoden führen kann.

Die typisierten Component Harness bieten nun spezifische Methoden, um mit der zugrundeliegenden Komponente zu interagieren

    await input.setValue('Tim');
    expect(await checkbox.isDisabled()).toBeFalse();

    const logicSpy = spyOn(app, 'someLogic').and.callThrough();
    await checkbox.check();
    expect(logicSpy).toHaveBeenCalledTimes(1);

Hier noch mal alles, wie immer, live und in Farbe:

https://github.com/GEDOPLAN/angular-test-harness

JCON-ONLINE 2022: Next Level JEE

Ich freue mich, dass mein Vortrag „Next Level JEE: Migration von Jakarta-EE-Anwendungen auf Quarkus“ angenommen wurde ich ich auch in diesem Jahr einen Beitrag zur JCON beisteuern kann. Seid am 22.10.22 mit dabei – ich freue mich auf euch!
https://sched.co/11lDP