Übungsaufgaben zur Live-Aktualisierung mit Server-Sent Events: Mitfahrgesuche live anzeigen
https://bildung.social/@oerinformatik/
https://oer-informatik.de/vue-change-request-live-uebungsaufgaben
Server-Sent Events, EventSource, SseEmitter, Polling, WebSocket, Verbindungsabbruch, Change Request, Regressionstest, Vue 3, Spring Boot, Übungsaufgaben, Prüfungsvorbereitung
tl/dr; (Bearbeitungszeit insgesamt 58 min, mehrere Aufgaben): Die Web-App des Mitfahrlabors aktualisiert die Fahrtenliste inzwischen live. In diesen Übungen baust Du dasselbe für die Mitfahrgesuche: zuerst in Vue mit einem kleinen Refactoring und einem Komponententest, dann im Backend mit
SseEmitterund MockMvc. Danach findest Du anhand von Symptomen heraus, warum die Live-Aktualisierung nach Funklöchern, Server-Neustarts und hinter nginx versagt, machst sie robust und wägst für neue Wünsche der Schule Polling, Server-Sent Events, WebSocket und Push ab [AI-Assisted]. (Zuletzt geändert am 11.11.2026)
Dieser Artikel ist Teil einer Artikelreihe über die Web-App mit Vue 3 und TypeScript: Projektübersicht / XP-Projektstart / TypeScript für Java-Entwickler*innen / Komponenten testgetrieben / API-Client aus OpenAPI / Router und Continuous Integration / Formulare und Validierung / Refactoring mit Pinia / Anmeldung mit Keycloak / Fahrer-Sicht und Coding Standards / Build und Auslieferung / End-to-End-Tests mit Playwright / Change Request Live-Aktualisierung
Diese Übungsaufgaben gehören zum Artikel Change Request: Live-Aktualisierung mit Server-Sent Events. Alle Aufgaben spielen im Projektteam der Mitfahr-App Mitfahrlabor. Ausgangspunkt für die Aufgaben 2.1 und 3.1 ist das Projekt mitfahrlabor-web im Ordner src/12-vue-change-request-live des Repositorys dieser Reihe mit src/domain/liveaktualisierung.ts und src/composables/useLiveAktualisierung.ts; für Aufgabe 2.2 der Ordner backend-live/ darin mit AngebotEreignisse als Vorbild. Was für die Gesuche fehlt, steht vollständig in den Aufgaben.
Hinweise zur Bearbeitung: Als FIA, FIDP oder FIDV musst Du zur Teil-2-Prüfung Aufgaben auf Kernkompetenz-Ebene lösen können, das sind Aufgabe 2.1 und Aufgabe 2.2. Von FIA wird zusätzlich erwartet, dass ihnen Aufgaben auf Profilkompetenz-Ebene (Aufgabe 3.1 und Aufgabe 3.2) keine Probleme bereiten. Die Operatoren (entwickeln, ergänzen, erklären, analysieren, begründen, beurteilen, Stellung nehmen …) zeigen, wie ausführlich Du antworten sollst. Code in TypeScript prüfst Du mit npm test, npm run type-check und npm run lint, Java-Code mit den Tests des Backends (./gradlew test). Schreib Deine Lösung zuerst vollständig auf, bevor Du sie mit der Beispiellösung vergleichst.
Du möchtest eigene Aufgaben zum Üben von einem KI-Sprachmodell generieren lassen?
Kopiere den Text in ein KI-Sprachmodell Deiner Wahl und passe Kompetenzstufe, Anzahl, Bearbeitungszeit und Szenario bei Bedarf an. Wie die Aufgaben aufgebaut sind, beschreibt der Artikel Wie sind die Aufgaben aufgebaut?. Prüfe die Ergebnisse mit dem Lehrartikel: KI-Sprachmodelle machen Fehler.
Erstelle neue Übungsaufgaben zum Thema „Live-Aktualisierung mit Server-
Sent Events: Mitfahrgesuche live anzeigen“.
Bauplan: Halte Dich an Aufbau, Kompetenzstufen, Operatoren,
Anforderungsbereiche, Punkte und Zeitschätzung, die im Artikel
https://oer-informatik.de/aufgaben-aufbau beschrieben sind.
Fachliche Grundlage:
- https://oer-informatik.de/vue-change-request-live
- https://oer-informatik.de/szenario-mitfahrlabor
- Stilbeispiel: https://oer-informatik.de/vue-change-request-live-uebungsaufgaben
Die Aufgaben sollen Inhalte zu diesen Schlagwörtern abdecken:
- Server-Sent Events
- EventSource
- SseEmitter
- Polling
- WebSocket
- Verbindungsabbruch
- Change Request
- Regressionstest
- Vue 3
- Spring Boot
Kompetenzstufe: Kernkompetenz (alternativ: Basiskompetenz, Profilkompetenz, Expertise)
Anzahl: 1 Aufgabe mit Teilaufgaben
Bearbeitungszeit in Minuten ca.: <5 bis 30 min>
Szenario: eines der Szenarien aus dem Bauplan
Vorgehen:
1. Lies zuerst die verlinkten Artikel. Kannst Du keine Webseiten
abrufen, sag mir das, statt zu raten.
2. Steht bei einer Angabe noch ein Platzhalter in <…>, frag mich
zuerst danach. Richte Punkte und Zahl der Teilaufgaben nach der
Bearbeitungszeit (ca. 0,9 min je Punkt).
3. Gib je Aufgabe Titel mit Kompetenzstufe, Ausgangssituation und
Teilaufgaben mit Operator und Punktzahl aus, dazu die geschätzte
Bearbeitungszeit.
4. Gib die Beispiellösungen mit Punkteverteilung erst aus, wenn ich
danach frage.
Aufgabe 2.1: Live-Anzeige für Mitfahrgesuche (Kernkompetenz)
(ca. 15 min Bearbeitungszeit)
Marie Schulz (Pseudonym Ostwind) fährt jeden Morgen ab Herford. Sie möchte auf einer Seite die offenen Mitfahrgesuche sehen und neue sofort bemerken, um ihre freien Plätze anzubieten. Das Backend-Team kündigt dafür einen Ereignisstrom GET /v1/gesuche/ereignisse an: Er schickt Ereignisse mit dem Namen gesuch-geaendert und Daten wie {"gesuchId":5,"grund":"eingestellt"}; weitere Gründe sind vermittelt und zurueckgezogen.
Das Paar hat schon einen Typ und eine Komponente ohne Live-Aktualisierung gebaut. Lege src/domain/gesuche.ts an:
import type { Richtung } from './typen'
/** Ein Mitfahrgesuch, wie GET /v1/gesuche es liefert */
export interface Gesuch {
id: number
haltepunktId: number
richtung: Richtung
datum: string
zeitVon: string
zeitBis: string
status: 'offen' | 'vermittelt' | 'zurueckgezogen'
}
export type GesuchQuelle = () => Promise<Gesuch[]>und src/components/OffeneGesuche.vue:
<script setup lang="ts">
import { ref } from 'vue'
import type { Gesuch, GesuchQuelle } from '@/domain/gesuche'
const props = defineProps<{ quelle: GesuchQuelle }>()
const gesuche = ref<Gesuch[]>([])
async function laden() {
gesuche.value = (await props.quelle()).filter((g) => g.status === 'offen')
}
laden()
</script>
<template>
<h2>Offene Mitfahrgesuche</h2>
<ul>
<li v-for="g in gesuche" :key="g.id" :data-gesuch-id="g.id">
{{ g.datum }}, {{ g.richtung }}, {{ g.zeitVon }} bis {{ g.zeitBis }} Uhr
</li>
</ul>
</template>a) sseAenderungen() in src/domain/liveaktualisierung.ts kennt nur die Adresse und den Ereignisnamen der Angebote. Ergänze dort durch ein Refactoring eine allgemeine Funktion sseEreignisse<E>(adresse, ereignisname, Klasse), die eine AenderungsQuelle<E> liefert, und baue sseAenderungen() so um, dass es sie nutzt. Nenne, woran Du erkennst, dass das Refactoring das Verhalten nicht verändert hat. (4 Punkte)
/** Abonniert einen beliebigen Ereignisstrom und reicht die Daten jedes Ereignisses ereignisname weiter. */
export function sseEreignisse<E>(
adresse: string,
ereignisname: string,
Klasse: EventSourceKlasse = globalThis.EventSource,
): AenderungsQuelle<E> {
return (beiAenderung) => {
if (!Klasse) return () => {} // ohne EventSource (z. B. jsdom): keine Live-Aktualisierung
const verbindung = new Klasse(adresse)
verbindung.addEventListener(ereignisname, (ereignis) => {
beiAenderung(JSON.parse(ereignis.data) as E)
})
return () => verbindung.close()
}
}
/** Abonniert GET /v1/angebote/ereignisse (unverändertes Verhalten, die Tests bleiben grün). */
export function sseAenderungen(
adresse = `${import.meta.env.VITE_API_BASIS ?? '/v1'}/angebote/ereignisse`,
Klasse: EventSourceKlasse = globalThis.EventSource,
): AenderungsQuelle {
return sseEreignisse<Angebotsereignis>(adresse, ANGEBOT_GEAENDERT, Klasse)
}Bewertung: allgemeine Funktion mit Typparameter und Ereignisname (2 Punkte), sseAenderungen() delegiert mit unveränderter Signatur (1 Punkt). Nachweis: Die bestehenden Tests in liveaktualisierung.spec.ts, FahrtenSuche.live.spec.ts und alle übrigen laufen unverändert grün, ohne dass ein Test angepasst wurde (1 Punkt). Das Refactoring kommt erst jetzt, weil es jetzt eine zweite Verwendung gibt (Simple Design: keine Verallgemeinerung auf Vorrat).
b) Ergänze in src/domain/gesuche.ts einen Typ Gesuchsereignis und eine Funktion sseGesuchsaenderungen(adresse = '/v1/gesuche/ereignisse', Klasse?) und erweitere OffeneGesuche.vue um eine optionale Eigenschaft aenderungen, sodass die Liste bei jedem Ereignis neu lädt und die Verbindung geschlossen wird, wenn die Komponente verschwindet. (5 Punkte)
Ergänzung in src/domain/gesuche.ts (der Import gehört an den Anfang der Datei):
import { sseEreignisse, type AenderungsQuelle, type EventSourceKlasse } from './liveaktualisierung'
export interface Gesuchsereignis {
gesuchId: number
grund: 'eingestellt' | 'vermittelt' | 'zurueckgezogen'
}
export const GESUCH_GEAENDERT = 'gesuch-geaendert'
export function sseGesuchsaenderungen(
adresse = '/v1/gesuche/ereignisse',
Klasse?: EventSourceKlasse,
): AenderungsQuelle<Gesuchsereignis> {
return sseEreignisse<Gesuchsereignis>(adresse, GESUCH_GEAENDERT, Klasse)
}OffeneGesuche.vue:
<script setup lang="ts">
import { ref } from 'vue'
import type { Gesuch, GesuchQuelle, Gesuchsereignis } from '@/domain/gesuche'
import type { AenderungsQuelle } from '@/domain/liveaktualisierung'
import { useLiveAktualisierung } from '@/composables/useLiveAktualisierung'
const props = defineProps<{ quelle: GesuchQuelle; aenderungen?: AenderungsQuelle<Gesuchsereignis> }>()
const gesuche = ref<Gesuch[]>([])
async function laden() {
gesuche.value = (await props.quelle()).filter((g) => g.status === 'offen')
}
laden()
useLiveAktualisierung(props.aenderungen, laden)
</script>Das Template bleibt unverändert. Bewertung: Typ und Funktion (2 Punkte), optionale Eigenschaft (1 Punkt), useLiveAktualisierung() mit laden statt eigenem EventSource in der Komponente (1 Punkt), Abmelden über das Composable (onScopeDispose) statt von Hand (1 Punkt). Ein undefinierter Wert für Klasse ist kein Fehler: Der Standardwert von sseEreignisse() greift dann auf globalThis.EventSource zurück.
c) Entwickle einen Komponententest mit Vitest und Vue Test Utils, der ohne Backend und ohne EventSource nachweist: Kommt ein Ereignis eingestellt, zeigt die Liste das neue Gesuch ohne Neuladen der Seite, und beim Entfernen der Komponente wird abgemeldet. (5 Punkte)
src/components/__tests__/OffeneGesuche.live.spec.ts:
import { describe, it, expect } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import OffeneGesuche from '@/components/OffeneGesuche.vue'
import type { Gesuch, Gesuchsereignis } from '@/domain/gesuche'
import type { AenderungsQuelle } from '@/domain/liveaktualisierung'
const gesuch5: Gesuch = {
id: 5,
haltepunktId: 1,
richtung: 'zur-schule',
datum: '2026-11-19',
zeitVon: '07:00',
zeitBis: '07:30',
status: 'offen',
}
describe('OffeneGesuche mit Live-Aktualisierung', () => {
it('zeigt ein neu eingestelltes Gesuch ohne Neuladen und meldet sich beim Entfernen ab', async () => {
let daten: Gesuch[] = []
let melde!: (e: Gesuchsereignis) => void
let abgemeldet = false
const aenderungen: AenderungsQuelle<Gesuchsereignis> = (beiAenderung) => {
melde = beiAenderung
return () => (abgemeldet = true)
}
const wrapper = mount(OffeneGesuche, { props: { quelle: async () => [...daten], aenderungen } })
await flushPromises()
expect(wrapper.findAll('li')).toHaveLength(0)
daten = [gesuch5]
melde({ gesuchId: 5, grund: 'eingestellt' })
await flushPromises()
expect(wrapper.get('[data-gesuch-id="5"]').text()).toContain('07:00 bis 07:30 Uhr')
wrapper.unmount()
expect(abgemeldet).toBe(true)
})
})Bewertung: Attrappe als AenderungsQuelle, die der Test selbst auslöst (2 Punkte), Daten der Quelle vor dem Ereignis ändern und Ergebnis nach flushPromises() prüfen (2 Punkte), Abmelden nach unmount() geprüft (1 Punkt). Gleichwertig ist eine Attrappe für EventSource, die über sseGesuchsaenderungen(…, Attrappe) eingebunden wird.
d) Jonas Wolff schlägt vor, das Gesuch gleich vollständig in das Ereignis zu schreiben, „dann spart die App eine Anfrage“. Begründe, warum das Team bei ID und Grund bleibt. (2 Punkte)
Ein vollständiges Gesuch enthält Angaben zu einer Person (wer wann wo mitfahren will). Der Ereignisstrom ist ohne Anmeldung lesbar, weil EventSource keinen Header Authorization setzen kann; mit vollständigen Gesuchen würde er diese Angaben jedem zeigen (1 Punkt). Außerdem müsste die App dann selbst entscheiden, ob ein Gesuch zur Anzeige passt, vermittelt oder zurückgezogen ist; mit „benachrichtigen und neu laden“ bleibt diese Logik an einer Stelle, die eine Anfrage mehr fällt kaum ins Gewicht (1 Punkt).
Aufgabe 2.2: Ereignisstrom für Gesuche im Backend (Kernkompetenz)
(ca. 15 min Bearbeitungszeit)
Das Backend-Team hat die Gesuche für den Übungsstrang auf IDs umgestellt, noch ohne Datenbank. Der Service sieht so aus (GesuchZeile ist der Record aus dem Übungsstrang des Backends mit ort, treffpunkt, richtung, datum, fruehestens, spaetestens und notiz):
@Service
public class GesuchService {
private final Map<Long, GesuchZeile> offen = new ConcurrentHashMap<>();
private final AtomicLong naechsteId = new AtomicLong(1);
public long stelleEin(GesuchZeile gesuch) {
long id = naechsteId.getAndIncrement();
offen.put(id, gesuch);
return id;
}
public boolean ziehZurueck(long id) {
return offen.remove(id) != null;
}
public Map<Long, GesuchZeile> offeneGesuche() {
return Map.copyOf(offen);
}
}a) Ergänze einen Record GesuchGeaendert(long gesuchId, String grund) und erweitere GesuchService so, dass stelleEin() und ein erfolgreiches ziehZurueck() dieses Ereignis über Spring veröffentlichen. (4 Punkte)
public record GesuchGeaendert(long gesuchId, String grund) {
}@Service
public class GesuchService {
private final Map<Long, GesuchZeile> offen = new ConcurrentHashMap<>();
private final AtomicLong naechsteId = new AtomicLong(1);
private final ApplicationEventPublisher ereignisse;
public GesuchService(ApplicationEventPublisher ereignisse) {
this.ereignisse = ereignisse;
}
public long stelleEin(GesuchZeile gesuch) {
long id = naechsteId.getAndIncrement();
offen.put(id, gesuch);
ereignisse.publishEvent(new GesuchGeaendert(id, "eingestellt"));
return id;
}
public boolean ziehZurueck(long id) {
boolean gefunden = offen.remove(id) != null;
if (gefunden) {
ereignisse.publishEvent(new GesuchGeaendert(id, "zurueckgezogen"));
}
return gefunden;
}
public Map<Long, GesuchZeile> offeneGesuche() {
return Map.copyOf(offen);
}
}Bewertung: Record (1 Punkt), ApplicationEventPublisher per Konstruktor (1 Punkt), Ereignis in beiden Methoden, beim Zurückziehen nur, wenn es das Gesuch gab (2 Punkte).
b) Entwickle nach dem Vorbild von AngebotEreignisse eine Komponente GesuchEreignisse und einen Controller, der unter GET /v1/gesuche/ereignisse einen Ereignisstrom mit Ereignissen namens gesuch-geaendert und dem Record als JSON anbietet. Ein Lebenszeichen ist hier noch nicht verlangt. (6 Punkte)
@Component
public class GesuchEreignisse {
private final List<SseEmitter> verbindungen = new CopyOnWriteArrayList<>();
public SseEmitter verbinde() {
SseEmitter emitter = new SseEmitter(30 * 60 * 1000L);
verbindungen.add(emitter);
emitter.onCompletion(() -> verbindungen.remove(emitter));
emitter.onTimeout(emitter::complete);
emitter.onError(fehler -> verbindungen.remove(emitter));
return emitter;
}
@EventListener // noch keine Datenbank, also keine Transaktion, auf deren Commit man warten müsste
public void beiAenderung(GesuchGeaendert aenderung) {
for (SseEmitter emitter : verbindungen) {
try {
emitter.send(SseEmitter.event().name("gesuch-geaendert").data(aenderung, MediaType.APPLICATION_JSON));
} catch (IOException | IllegalStateException abgebrochen) {
verbindungen.remove(emitter);
}
}
}
}@RestController
public class GesuchEreignisController {
private final GesuchEreignisse ereignisse;
public GesuchEreignisController(GesuchEreignisse ereignisse) {
this.ereignisse = ereignisse;
}
@GetMapping(path = "/v1/gesuche/ereignisse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter abonniere(HttpServletResponse antwort) {
antwort.setHeader("X-Accel-Buffering", "no");
return ereignisse.verbinde();
}
}Bewertung: thread-sichere Liste der Emitter (1 Punkt), Zeitlimit und Abmelden über onCompletion/onTimeout/onError (2 Punkte), Lauscher für GesuchGeaendert mit Name und JSON-Daten, je Emitter ein neuer Builder (2 Punkte), Controller mit produces = text/event-stream (1 Punkt). Der Header X-Accel-Buffering ist nicht verlangt, aber sinnvoll (siehe Aufgabe 3.1). In der Sicherheitskonfiguration braucht der Pfad eine eigene permitAll-Regel wie der Strom der Angebote.
c) Entwickle einen Test mit MockMvc, der nachweist, dass nach stelleEin() im offenen Strom ein Ereignis gesuch-geaendert mit der richtigen ID und dem Grund eingestellt steht. (4 Punkte)
@SpringBootTest
@AutoConfigureMockMvc
class GesuchEreignisseTest {
@Autowired MockMvc mvc;
@Autowired GesuchService gesuche;
@Test
void meldetEinNeuesGesuchImEreignisstrom() throws Exception {
MvcResult verbindung = mvc.perform(get("/v1/gesuche/ereignisse"))
.andExpect(request().asyncStarted())
.andReturn();
long id = gesuche.stelleEin(new GesuchZeile("Gütersloh", "Hauptbahnhof, ZOB Steig 3", "zur Schule",
LocalDate.of(2026, 11, 19), LocalTime.of(7, 0), LocalTime.of(7, 30), ""));
String strom = verbindung.getResponse().getContentAsString();
assertThat(strom).contains("event:gesuch-geaendert\n")
.contains("data:{\"gesuchId\":" + id + ",\"grund\":\"eingestellt\"}\n\n");
}
}Bewertung: Strom öffnen und mit asyncStarted() prüfen, dass die Antwort offen bleibt (1 Punkt), Ereignis über den Service auslösen statt über die Komponente (1 Punkt), Format von event und data geprüft, ID aus dem Rückgabewert (2 Punkte). Im vollständigen Backend mit Keycloak kommt @WithMockUser bzw. die Prüfung hinzu, dass der Strom ohne Anmeldung erreichbar ist.
d) Begründe, warum GesuchEreignisse hier @EventListener nutzen darf, während AngebotEreignisse @TransactionalEventListener nutzt, und was sich ändert, sobald die Gesuche in der Datenbank liegen. (2 Punkte)
Die Gesuche liegen nur im Arbeitsspeicher; es gibt keine Transaktion, und die Änderung ist sofort sichtbar, wenn das Ereignis veröffentlicht wird (1 Punkt). Sobald stelleEin() in einer Transaktion in die Datenbank schreibt, könnte ein sofort gesendetes Ereignis vor dem Commit ankommen oder bei einem Rollback eine Änderung melden, die es nie gab; dann muss der Lauscher @TransactionalEventListener (Phase AFTER_COMMIT) nutzen (1 Punkt).
Aufgabe 3.1: Live-Aktualisierung, die manchmal versagt (Profilkompetenz)
(ca. 15 min Bearbeitungszeit)
Nach zwei Wochen mit WEB-14 kommen drei Meldungen bei Thomas Becker an. Der Code ist der aus Stand 12 (sseAenderungen() und useLiveAktualisierung() wie im Artikel); er ist in diesen Fällen absichtlich nicht robust.
- Meldung A: Aylin Kaya hat die Suche im Zug offen. Nach einem Funkloch von etwa zwei Minuten zeigt die Liste eine Fahrt mit „1 Platz frei“, die längst voll ist. Erst als später eine andere Fahrt bestätigt wird, stimmt die Liste wieder.
- Meldung B: Auf dem Rechner im Sekretariat steht die Suche den ganzen Tag offen. Seit dem nächtlichen Neustart des Backends, bei dem nginx etwa 20 Sekunden lang mit 502 antwortet, aktualisiert sie sich nicht mehr, bis jemand die Seite neu lädt.
- Meldung C: Jonas hat die Gesuche aus Aufgabe 2.2 auf dem Testserver ausprobiert, dessen nginx nur den allgemeinen Block für
/v1/hat, und inGesuchEreignisControllerden HeaderX-Accel-Bufferingweggelassen. Im Entwicklungsserver kommen die Ereignisse sofort; auf dem Testserver kommen sie gesammelt, und im Netzwerk-Tab des Browsers erscheint ungefähr jede Minute eine neue Anfrage an/v1/gesuche/ereignisse.
a) Analysiere die drei Meldungen: Erkläre jeweils die Ursache und gib an, in welchem Zustand (readyState) sich die EventSource befindet. (6 Punkte)
- A: Die Verbindung ist im Funkloch abgebrochen;
EventSourcegeht nachCONNECTING(0) und verbindet sich nach dem Funkloch selbst neu (OPEN, 1). Ereignisse aus der Pause hat das Backend aber nur an die damals offenen Verbindungen geschickt, und es liefert trotzLast-Event-IDnichts nach. Die App lädt nach der Wiederverbindung nicht neu, also bleibt der alte Stand bis zum nächsten Ereignis (2 Punkte). - B: Bei der Antwort 502 (anderer Status als 200) gibt
EventSourceendgültig auf:readyStateistCLOSED(2), es gibt keinen neuen Versuch. Die Suche lädt bei Ereignissen nicht mehr neu, funktioniert aber ansonsten weiter (KT-14.3), deshalb fällt es erst auf, wenn jemand auf Änderungen wartet (2 Punkte). - C: nginx puffert die Antwort des Backends, weil weder
proxy_buffering offnoch der HeaderX-Accel-Buffering: nogesetzt ist; die Ereignisse kommen erst, wenn der Puffer voll ist oder die Verbindung endet.GesuchEreignisseschickt kein Lebenszeichen, also schließt nginx die Verbindung nachproxy_read_timeout(Standard 60 Sekunden);EventSourcegeht nachCONNECTINGund verbindet sich neu, daher jede Minute eine neue Anfrage. Ereignisse, die zusammen in einen Puffer passen, kommen deshalb gesammelt oder gar nicht an (2 Punkte).
b) Entwickle eine Funktion sseAenderungenRobust(adresse, Klasse, ersteWartezeitMs = 2000, laengsteWartezeitMs = 60000) in src/domain/robusteaktualisierung.ts, die wie sseAenderungen() eine AenderungsQuelle liefert, aber Meldung A und B behebt: Nach einer Wiederverbindung meldet sie einmal eine Änderung, damit die Suche neu lädt; eine aufgegebene Verbindung baut sie nach einer Wartezeit neu auf, die sich bei jedem Fehlschlag verdoppelt (höchstens laengsteWartezeitMs) und nach einer erfolgreichen Verbindung zurückgesetzt wird. Nach dem Abmelden darf sie sich nicht mehr verbinden. Beschreibe, wie Du die Funktion ohne Browser testest. (7 Punkte)
import type { Angebotsereignis } from './typen'
import { ANGEBOT_GEAENDERT, type AenderungsQuelle } from './liveaktualisierung'
/** Was die robuste Fassung zusätzlich von EventSource braucht */
export interface RobusteVerbindung {
readonly readyState: number
addEventListener(typ: string, lauscher: (ereignis: MessageEvent<string>) => void): void
close(): void
}
export type RobusteKlasse = new (adresse: string) => RobusteVerbindung
const GESCHLOSSEN = 2 // EventSource.CLOSED
export function sseAenderungenRobust(
adresse = '/v1/angebote/ereignisse',
Klasse: RobusteKlasse = globalThis.EventSource,
ersteWartezeitMs = 2000,
laengsteWartezeitMs = 60000,
): AenderungsQuelle {
return (beiAenderung) => {
if (!Klasse) return () => {}
let verbindung: RobusteVerbindung | undefined
let unterbrochen = false
let wartezeit = ersteWartezeitMs
let zeitgeber: ReturnType<typeof setTimeout> | undefined
let beendet = false
// Ereignis ohne bestimmte ID: „irgendetwas kann sich geändert haben“ – die Suche lädt einfach neu
const allesNeu: Angebotsereignis = { angebotId: 0, grund: 'geaendert' }
function verbinde() {
verbindung = new Klasse(adresse)
verbindung.addEventListener('open', () => {
wartezeit = ersteWartezeitMs
if (unterbrochen) beiAenderung(allesNeu) // verpasste Ereignisse nachholen
unterbrochen = false
})
verbindung.addEventListener('error', () => {
unterbrochen = true
if (verbindung?.readyState === GESCHLOSSEN && !beendet) {
// EventSource gibt bei HTTP-Fehlern und falschem Content-Type auf: selbst neu verbinden
zeitgeber = setTimeout(verbinde, wartezeit)
wartezeit = Math.min(wartezeit * 2, laengsteWartezeitMs)
}
})
verbindung.addEventListener(ANGEBOT_GEAENDERT, (e) => beiAenderung(JSON.parse(e.data) as Angebotsereignis))
}
verbinde()
return () => {
beendet = true
clearTimeout(zeitgeber)
verbindung?.close()
}
}
}Bewertung: Neuladen nach open, aber nicht bei der ersten Verbindung (2 Punkte), Neuaufbau nur bei readyState 2 (bei 0 verbindet sich der Browser selbst) (2 Punkte), wachsende und zurückgesetzte Wartezeit (1 Punkt), kein Neuaufbau nach dem Abmelden, Zeitgeber gelöscht (1 Punkt). In SucheSeite.vue ersetzt sseAenderungenRobust() dann sseAenderungen(); FahrtenSuche.vue bleibt unverändert.
Test (1 Punkt): eine Attrappe für EventSource mit readyState und Methoden, die open und error auslösen (Abbruch mit readyState 0, Aufgeben mit 2), dazu vi.useFakeTimers() und vi.advanceTimersByTime(). Geprüft wird: nach Abbruch und erneutem open genau ein Aufruf von beiAenderung; nach dem Aufgeben nach 2000 ms eine neue Attrappe, nach erneutem Aufgeben erst nach weiteren 4000 ms; nach dem Abmelden keine neue Attrappe mehr. Dieselbe Attrappe zeigt mit sseAenderungen() die Fehler aus A und B (kein Aufruf, keine neue Verbindung).
c) Gib an, wie Jonas Meldung C behebt, und nenne eine Möglichkeit für den Fall, dass er nginx nicht selbst ändern darf. (3 Punkte)
Ein eigener Block in der nginx-Konfiguration wie für die Angebote (1 Punkt):
location = /v1/gesuche/ereignisse {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_read_timeout 1h;
}Dazu ein Lebenszeichen in GesuchEreignisse (z. B. @Scheduled(fixedRate = 25, timeUnit = TimeUnit.SECONDS) mit SseEmitter.event().comment("lebenszeichen")), damit die Verbindung auch hinter Proxys mit kurzer Lesezeit offen bleibt (1 Punkt). Ohne Zugriff auf nginx: den Header X-Accel-Buffering: no im Controller setzen, den nginx auswertet, und das Lebenszeichen unter 60 Sekunden halten (1 Punkt).
Aufgabe 3.2: Polling, Server-Sent Events oder WebSocket? (Profilkompetenz)
(ca. 13 min Bearbeitungszeit)
Nach der erfolgreichen Abnahme von WEB-14 sammelt Thomas Becker weitere Wünsche der Schule:
- W1: Die Flurmonitore sollen wie bisher offene Fahrten zeigen; das Abnahmekriterium lässt 60 Sekunden Verzögerung zu.
- W2: Wer fährt, und wer mitfährt, sollen sich vor der Fahrt kurze Nachrichten schreiben können („Ich stehe am Steig 3“).
- W3: Marie möchte auf der Detailseite ihrer Fahrt neue Anfragen sofort sehen, mit dem Pseudonym der anfragenden Person.
- W4: Ben möchte auf dem Telefon benachrichtigt werden, wenn jemand bei seiner Fahrt anfragt, auch wenn die App gerade nicht geöffnet ist.
a) Wähle für jeden Wunsch ein Verfahren (Polling, Server-Sent Events, WebSocket oder ein anderes) und begründe Deine Wahl. (8 Punkte)
| Wunsch | Verfahren | Begründung |
|---|---|---|
| W1 Flurmonitore | Polling (bleibt) | Wenige Geräte, fester Takt reicht für das Abnahmekriterium, läuft schon und ist getestet; eine offene Verbindung je Monitor brächte keinen Nutzen für die Abnahme |
| W2 Nachrichten | WebSocket | Beide Seiten schicken häufig kurze Nachrichten; eine Verbindung für beide Richtungen; Wiederverbindung und Nachrichtenformat muss man selbst bauen. Vertretbar ist auch SSE für eingehende Nachrichten plus POST zum Senden, wenn der Chat selten genutzt wird |
| W3 Anfragen live | Server-Sent Events, aber mit Anmeldung | Nur der Server schickt etwas. Das Pseudonym ist ein personenbezogenes Datum, ein offener Strom wie bei WEB-14 kommt nicht in Frage: den Strom per fetch mit Header Authorization lesen (z. B. generierter Client) oder nur „Anfrage zu Fahrt 4“ melden und die Anfragen mit Token laden |
| W4 Benachrichtigung bei geschlossener App | Web Push (Progressive Web App mit Service Worker) | SSE und WebSocket brauchen eine offene Seite; nur ein Push-Dienst erreicht das Gerät bei geschlossener App. Braucht die Zustimmung der Nutzenden und einen Push-Dienst |
Bewertung: je Wunsch 1 Punkt für ein passendes Verfahren und 1 Punkt für die Begründung. Andere Entscheidungen sind richtig, wenn die Begründung Richtung, Häufigkeit, Datenschutz und Aufwand trägt.
b) Jonas Wolff schlägt vor: „Stellen wir doch alles auf WebSocket um, dann haben wir nur noch eine Technik.“ Nimm Stellung. (4 Punkte)
Für eine Technik spricht: weniger Wissen, das das Team pflegen muss, ein gemeinsamer Verbindungsaufbau für W2 und W3 (1 Punkt). Dagegen spricht: WebSocket verlangt eigenes Nachrichtenformat, eigene Wiederverbindung und eigene Anmeldung (auch die Browser-API setzt keinen Header Authorization), Proxy und Tests müssen das Protokoll unterstützen; für Anzeigen, bei denen nur der Server sendet, ist das mehr Aufwand ohne Nutzen (1 Punkt). Die vorhandene, getestete Lösung für WEB-14 und das Polling der Flurmonitore umzubauen, bringt der Kund*in nichts und widerspricht Simple Design; W4 löst WebSocket ohnehin nicht (1 Punkt). Fazit: WebSocket nur dort, wo beide Richtungen gebraucht werden (W2), sonst das jeweils einfachste Verfahren; eine Vereinheitlichung erst, wenn ein echter Bedarf sie trägt (1 Punkt).
c) Thomas möchte W3 mitten in Iteration 7 noch unterbringen. Beschreibe, wie das XP-Team damit umgeht. (2 Punkte)
Das Team schreibt W3 mit Thomas als Karte mit Kundentests und schätzt sie; die laufende Iteration bleibt bei ihrem Umfang. Will Thomas W3 trotzdem jetzt, tauscht er eine noch nicht begonnene Karte mit mindestens gleich vielen Punkten heraus; sonst kommt W3 in das nächste Planning Game (1 Punkt). Die Entscheidung über die Reihenfolge trifft der Kunde, die Schätzung das Team; bestehende Kundentests laufen als Regressionstests weiter (1 Punkt).
Links und weitere Informationen
Change Request: Live-Aktualisierung mit Server-Sent Events: der Artikel zu diesen Übungen
MDN: Using server-sent events und MDN: EventSource readyState
Hinweis zur Nachnutzung als Open Educational Resource (OER)
Dieser Artikel und seine Texte, Bilder, Grafiken, Code und sonstiger Inhalt sind - sofern nicht anders angegeben - lizenziert unter CC BY 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: “Übungsaufgaben zur Live-Aktualisierung mit Server-Sent Events: Mitfahrgesuche live anzeigen” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/vue-change-request-live-uebungsaufgaben veröffentlicht, die Quelltexte sind in weiterverarbeitbarer Form verfügbar im Repository unter https://gitlab.com/oer-informatik/programmierung/vue. Erstellt mit KI-Assistenz; von mir umfassend redaktionell überarbeitet, geprüft und freigegeben. Stand: 11.11.2026.
[Kommentare zum Artikel lesen, schreiben] / [Artikel teilen] / [gitlab-Issue zum Artikel schreiben]
