Message-Listener
Überblick
Message-Listener ermöglichen es Integratoren, Aktionen asynchron auf der
Serverseite auszuführen und den Client über den Fortschritt und das
Ergebnis zu informieren. Anwendungsfälle sind beispielsweise das
Speichern von clientseitigen Änderungen an Annotationen, der Export bzw.
Druck eines Dokuments, das Auswerten von Attributen der serverseitigen
HttpSession oder beliebige eigene Backend-Aufrufe.
Ein Message-Listener besteht aus drei Bestandteilen:
- einem Nachrichtennamen, über den Client und Server die Nachricht
identifizieren (z.B.
EXPORT), - einem DTO, das die Parameter der Anfrage transportiert, und
- einer Implementierung des Interfaces
com.levigo.jadice.web.transport.server.messaging.MessageListener, die die Anfrage verarbeitet.
Hinweis: Message-Listener ersetzen die frühere ServerOperation-API. Die
Interfaces ServerOperation, ServerOperationParameters,
ServerOperationMessage sowie die ServerOperationRegistry stehen nicht
mehr zur Verfügung.
Funktionsweise
Der Client eröffnet eine Konversation: Er sendet eine Nachricht mit einem Nachrichtennamen und einer Payload an den Server. Jede Konversation besitzt eine ID, über die alle zugehörigen Nachrichten einander zugeordnet werden.
Serverseitig wird die Payload anhand des generischen Typparameters des
Listeners (MessageListener<T>) deserialisiert und an die Methode
consume(T, IncomingMessageContext) übergeben. Diese Methode wird auf
dem Thread aufgerufen, der die eingehende Nachricht verarbeitet.
Länger laufende Arbeit ist daher in einen ExecutorService auszulagern;
zurückgegeben wird ein CancelHandler, über den eine laufende
Verarbeitung abgebrochen werden kann.
Der Server kann eine Konversation mit beliebig vielen Nachrichten
beantworten (IncomingMessageContext#reply) und so insbesondere bei
lang laufenden Operationen den Fortschritt zurückmelden. Beendet wird
eine Konversation mit einer End-of-Conversation-Nachricht (EOC), die
über sendSuccess(), sendFail(...) oder sendEOC(...) erzeugt wird.
Pro Nachrichtenname kann genau ein Listener registriert werden. Eine
zweite Registrierung desselben Namens wird mit einer
IllegalArgumentException abgelehnt.
Der IncomingMessageContext
| Methode | Beschreibung |
|---|---|
reply(String messageName, Object payload) | Sendet eine Nachricht innerhalb der laufenden Konversation an den Client, z.B. ein Zwischenergebnis oder eine Fortschrittsmeldung. |
sendSuccess() | Beendet die Konversation und signalisiert dem Client die erfolgreiche Verarbeitung. |
sendFail(Throwable) bzw. sendFail(String, Throwable) | Beendet die Konversation mit einer Fehlermeldung. |
sendEOC(boolean success, String errorMessage, ObjectNode metadata) | Beendet die Konversation; erlaubt zusätzlich das Mitsenden von Metadaten. |
done() | Markiert die Konversation als abgeschlossen und gibt die zugehörigen Referenzen frei. Ein Abbruch ist danach nicht mehr möglich. Wird von den sendEOC-Varianten automatisch aufgerufen. |
getActive() | Liefert einen BooleanSupplier, über den geprüft werden kann, ob die Konversation noch aktiv ist (z.B. nicht abgebrochen wurde). |
getMessageContext() | Zugriff auf den MessageContext mit HttpSession, Principal, Client-ID, Request-URI und Query-String der Verbindung. |
getClient() | Der Client, der die Nachricht gesendet hat. |
getConversationId() | Die ID der laufenden Konversation. |
Implementierung eines eigenen Message-Listeners
Zunächst wird ein DTO für die Anfrage definiert. Die Serialisierung erfolgt über Jackson, ein Default-Konstruktor sowie Getter und Setter sind daher erforderlich — im Beispiel über Lombok erzeugt:
@Data
@NoArgsConstructor
public final class CustomRequestDTO {
private String name;
}
Anschließend wird der Listener implementiert. Der Nachrichtenname wird
über die Annotation @MessageName festgelegt, @Component sorgt in
einer Spring-Umgebung für die automatische Registrierung:
@Component
@MessageName("CUSTOM")
public class CustomMessageListener implements MessageListener<CustomRequestDTO> {
@Autowired
private ExecutorService executorService;
@Override
public CancelHandler consume(CustomRequestDTO dto, IncomingMessageContext ctx) { // (1)
final Future<?> future = executorService.submit(() -> this.run(dto, ctx)); // (2)
return () -> future.cancel(true); // (3)
}
private void run(CustomRequestDTO dto, IncomingMessageContext ctx) {
try {
final CustomResponseDTO response = new CustomResponseDTO();
response.setGreeting("Hallo, " + dto.getName() + "!");
ctx.reply("CUSTOM_RESPONSE", response); // (4)
ctx.sendSuccess(); // (5)
} catch (Exception e) {
try {
ctx.sendEOC(false, e.getMessage(), null); // (6)
} catch (Throwable ignored) {
}
} finally {
ctx.done(); // (7)
}
}
}
- Diese Methode muss implementiert werden. Sie wird auf dem verarbeitenden Thread der eingehenden Nachricht aufgerufen und sollte daher schnell zurückkehren.
- Die eigentliche Verarbeitung wird asynchron ausgeführt. In einer
Spring-Umgebung kann der vom jadice web toolkit bereitgestellte
ExecutorServiceinjiziert werden. - Der zurückgegebene
CancelHandlerwird aufgerufen, wenn der Client die Konversation abbricht. - Antwortnachricht an den Client. Der Nachrichtenname ist frei wählbar und wird clientseitig ausgewertet. Auf diesem Weg können auch mehrere Zwischenergebnisse gesendet werden.
- Erfolgreicher Abschluss der Konversation.
- Im Fehlerfall wird die Konversation mit einer Fehlermeldung beendet. Der Client erhält die Meldung als Fehler der Konversation.
done()gibt die Referenzen der Konversation frei. DiesendEOC-Varianten rufen die Methode bereits selbst auf; ein zusätzlicher Aufruf ist unschädlich.
Enthält eine eingehende Nachricht neben der Payload einen separaten
Dokumentteil, wird stattdessen die Überladung
consume(T, IncomingMessageContext, Object) aufgerufen. Deren
Default-Implementierung wirft eine IllegalArgumentException; Listener
für solche Nachrichten müssen sie daher überschreiben.
Registrierung
In einer Spring-Umgebung genügt die Annotation @Component zusammen mit
@MessageName — alle MessageListener-Beans werden beim Start
eingesammelt und registriert (siehe
Spring Boot / Spring Framework).
Ohne Spring werden die Listener manuell erzeugt, mit ihren
Abhängigkeiten versorgt und über
JWTServerContext#registerMessageListeners(List) registriert (siehe
Beispiel). Auch in diesem Fall ist die
Annotation @MessageName erforderlich, da der Nachrichtenname daraus
ermittelt wird. Alternativ lässt sich ein Listener auch direkt — ohne
die reflektionsbasierte @MessageName-Auswertung — über
ServerMessageManager.get().registerMessage(name, descriptor)
registrieren; der Nachrichtenname und die DTO-Klasse werden in diesem
Fall explizit angegeben.
Werden beide Wege kombiniert, ist die Reihenfolge zu beachten:
registerMessageListeners(List) ruft intern ServerMessageManager#reset()
auf und verwirft damit alle bereits registrierten Nachrichten. Der Aufruf
muss daher vor registerDefaultMessages() und vor eigenen
registerMessage(...)-Aufrufen erfolgen. Pro Nachrichtenname ist nur eine
Registrierung möglich; eine zweite wird mit einer
IllegalArgumentException abgelehnt.
Aufruf vom Client
Angular / TypeScript
Über ServerConnection.get().initConversation(...) wird eine
Konversation eröffnet. Der Aufruf liefert ein Observable, das die
Antwortnachrichten des Servers ausgibt; die EOC-Nachricht beendet den
Stream bzw. führt im Fehlerfall zu einem error:
ServerConnection.get()
.initConversation("CUSTOM", { name: "User" })
.subscribe((message) => {
if (message.msgName === "CUSTOM_RESPONSE") {
console.log(message.payload.greeting);
}
});
Wird das Abonnement vor dem Ende der Konversation beendet, sendet der
Client automatisch einen Abbruch an den Server; dort wird der vom
Listener zurückgegebene CancelHandler aufgerufen.
GWT
Im GWT-Client wird eine Nachricht über Message.create(...) erzeugt und
mit einem ConversationListener versendet. Der Rückgabewert erlaubt den
Abbruch der Konversation:
final Message message = Message.create("CUSTOM", requestDTO);
final ServerConnection.Cancelable conversation = ServerConnection.get().send(message, false, response -> {
if ("CUSTOM_RESPONSE".equals(response.getMessageName())) {
final CustomResponseDTO dto = response.getPayload().cast();
// ...
} else if (DefaultMessageNames.EOC.equals(response.getMessageName())) {
final ConversationEndDTO eoc = response.getPayload().cast();
// Auswertung von eoc.wasSuccessful()
}
});
Zugriff auf HttpSession, Principal und Dokument
Der IncomingMessageContext stellt über
getMessageContext() den Kontext der Verbindung bereit. Darüber sind
HttpSession (getHTTPSession()), der angemeldete Benutzer
(getUserPrincipal()), die Client-ID (getClientID()) sowie
Request-URI und Query-String zugänglich. Ein Umweg über eine Factory,
wie er in der früheren ServerOperation-API nötig war, entfällt damit.
Übermittelt der Client einen Dokument-Snapshot als Teil der Payload,
lässt sich daraus serverseitig ein Document erzeugen. Verwendet wird
dafür der DocumentSnapshotConverter
(convert(IncomingMessageContext, DocumentSnapshotDTO)), wie es
beispielsweise der Export tut.
Instanziierung mittels einer ContextualFactory
Das Interface ContextualFactory wird weiterhin für
DocumentDataProvider verwendet. Es erlaubt, eine Instanz
abhängig vom Kontext des Aufrufs zu erzeugen: Die Methode
create(InvocationContext) wird auf dem Request-Thread ausgeführt und
kann bei einem ServletInvocationContext u.a. auf die HttpSession und
den Principal zugreifen.
@Component
public class MyDocumentDataProviderFactory
implements ContextualFactory<DocumentDataProvider<MySource, MyPageSegmentHandle>> {
@Override
public DocumentDataProvider<MySource, MyPageSegmentHandle> create(InvocationContext context) {
final ServletInvocationContext servletContext = (ServletInvocationContext) context;
return new MyDocumentDataProvider(servletContext.getSession().getId());
}
}
Zu beachten ist, dass der InvocationContext nur innerhalb der Methode
create gültig ist. Ein späterer Zugriff führt zu einer
IllegalStateException; benötigte Werte sind daher innerhalb der Methode
auszulesen und dem erzeugten Objekt mitzugeben.
Standard-Nachrichtennamen
Die vom jadice web toolkit selbst verwendeten Nachrichtennamen sind in
com.levigo.jadice.web.transport.shared.messaging.DefaultMessageNames
zusammengefasst, u.a.:
| Nachrichtenname | Bedeutung |
|---|---|
CREATE_DOCUMENT | Anlegen eines Dokuments |
GET_TEXT_CONTENT, TEXT_CONTENT, TEXT_BLOCK, TEXT_LINES | Anfrage und Antworten zum Textinhalt |
TEXT_SEARCH, TEXT_SEARCH_RESULT, TEXT_SEARCH_PROGRESS | Textsuche inkl. Fortschrittsmeldungen |
GET_ANNO_PROFILE, ANNO_PROFILE | Anfrage und Antwort zu Annotationsprofilen |
GET_INSTRUCTIONS, GET_INSTRUCTIONS_FINISHED | Links und PDF-Bookmarks |
EXPORT, EXPORT_FINISHED | Export eines Dokuments |
AUTHENTICATION_INFO, ADDITIONAL_HTTP_HEADERS | Authentifizierungsinformationen und zusätzliche HTTP-Header |
LOG_REMOTE | Übermitteln clientseitiger Logausgaben |
CANCEL | Abbruch laufender Konversationen |
EOC | Ende einer Konversation |
Eigene Nachrichtennamen dürfen mit keinem der bereits registrierten Namen kollidieren.
Weiterführende Informationen
Ein eigenes Kapitel zur Realisierung des Exports und des Drucks findet sich in Export aus der Webanwendung heraus.