Manchmal ist es notwendig, dass ein Webservice mehrere Ergebnisse nacheinander zurückliefert. Zum Beispiel, wenn ein Request abgesetzt wird, der zunächst validiert werden muss, bevor tatsächlich eine Prozessierung beginnt und die Validierung selbst eine längere Zeit dauert. Dann soll der Server nach Möglichkeit den Request direkt beenden und die Validierung im Hintergrund ablaufen. Andernfalls ist die Webseite nicht responsiv, was den Nutzer verunsichern könnte und keine gute User Experience ist. In einem Spring Boot basierten Webservice kann das zum Beispiel mit asynchroner Ausführung und long polling zusammen mit einem Deferred Result erreicht werden, wie im folgenden beschrieben.
Das Szenario
Im folgenden soll ein kleiner Webservice in Spring Boot implementiert werden, der eine Anfrage auslöst, die zwei Stadien durchläuft. Direkt bei der Anfrage wird in einen Zustand INITIALIZING
gewechselt, bis nach einigen Sekunden der Zustand PHASE1
erreicht wird. Den Abschluss markiert der Zustand DONE
. Die zugehörige Webseite soll jeweils den aktuellen Status anzeigen und bei einem Statuswechsel automatisch aktualisiert werden.
Eine Beispielimplementation des Webservers habe ich auf GitHub hochgeladen. Es kann nach dem Auschecken mit mvn spring-boot:run
gestartet werden, der Webservice ist unter http://localhost:8080 erreichbar.
Bei einem Klick auf die Schaltfläche geht es los.
Die serverseitige Implementierung
Zu Beginn wird ein POST-Request über an /request geschickt. Die Methode soll sofort ein Ergebnis zurückliefern, auch wenn die auszuführende Aktion länger dauert. Dazu wird nicht der tatsächliche Ergebnistyp zurückgeliefert sondern ein DeferredResult. Die Webseite kann daraufhin direkt aktualisiert werden, da die Anfrage schnell bearbeitet wird, das tatsächliche Ergebnis ist im Ergebnis erst vorhanden, sobald setResult
aufgerufen wird.
@RequestMapping(name = "/request", method = RequestMethod.POST) @ResponseBody public DeferredResult request() { // Prepare already for the first state change DeferredResult result = service.getStatus(); // Actually let the asynchronous service do something service.doSomething(); // Return the deferred result that will be set in the above asynchronous call return result; }
Beim Long Polling kann der Webserver verspätet antworten. Typischerweise wird ein Timeout gesetzt, so dass bei einem Verbindungsabbruch eine neue Anfrage gestartet werden kann. Da unklar ist, wie lange die Antwort tatsächlich dauert, sollte mit dem Verbindungsabbruch gerechnet werden. Die Statusabfrage für das Long Polling wird über den Endpunkt /poll
durchgeführt. Das Ergebnis wird vom ausführenden Service geholt und zurückgeliefert. Das zurückgelieferte Object vom Typ DeferredResult
wird automatisch so lange zurückgehalten, bis der Service über einen Aufruf von setStatus()
ein Ergebnis bereitstellt.
@RequestMapping(name = "/poll", method = RequestMethod.GET) @ResponseBody public DeferredResult requestStatus() { // Simply forward the request to the service return service.getStatus(); }
Asynchrone Ausführung der Anfrage
Damit der ganze Prozess funktioniert, muss die Arbeit des Services (service.doSomething()
im Beispiel oben) asynchron ablaufen. Das heißt, nach dem Aufruf von doSomething
kann direkt etwas zurückgeliefert werden. Spring Boot ermöglicht durch die Annotation @Async
, dass eine Methode eines Services automatisch in einem anderen Thread asynchron ausgeführt wird. Die folgende Methode simuliert, dass der Status zunächst auf INITIALIZING
gesetzt wird, anschließend eine längere Zeit gearbeitet wird und zuletzt beendet wird sobald der Status auf DONE
steht.
@Async void doSomething() { // Initialize the current status and notify any listener that is already present currentStatus = LongRequestStatus.INITIALIZING; publishStatus(currentStatus); // Perform some work until the final state is reached. do { currentStatus = doNecessaryWork(currentStatus); publishStatus(currentStatus); } while (currentStatus != LongRequestStatus.DONE); }
Die Methode wird bei einem POST-Request an /request
aufgerufen. Den Status der parallel ausgeführten Methode wird mit publishStatus
gesetzt. Falls es in der Zwischenzeit eine eventuell vorhandene Long Polling-Anfrage (mit einem Status als DeferredResult
) gab, wird der Status gesetzt.
private synchronized void publishStatus(LongRequestStatus nextToReturn) { if (waitingResponse != null) { waitingResponse.setResult(nextToReturn.toString()); } waitingResponse = null; }
Eine Polling-Anfrage ruft ausgehend vom Controller die Methode getStatus
auf. Diese erzeugt das DeferredResult
waitingResponse
, das direkt beantwortet wird, wenn bereits Informationen da sind. Andernfalls wird die Anfrage gespeichert und das Ergebnis gegebenenfalls in obiger Methode publishStatus
gesetzt.
public synchronized DeferredResult getStatus() { waitingResponse = new DeferredResult<>(); // If the request completed, immediately return the status because it will never change if (currentStatus == LongRequestStatus.DONE) { waitingResponse.setResult(currentStatus.toString()); } return waitingResponse;
Die Webseite
Auf der Clientseite wird das Statusupdate mit JavaScript durchgeführt. Bei einem Click auf die Schaltfläche wird zunächst wird die Anfrage ausgeführt und bei Erfolg die Long Polling-Abfrage gestartet.
function request() { var xhr = new XMLHttpRequest(); xhr.open("POST", "/request", true); xhr.onreadystatechange = function() { if(xhr.readyState === 4 && xhr.status === 200) { // Do something with the response console.log(xhr.responseText); // Start long polling and get the next status poll(); } } xhr.send(); }
Die Methode poll
sendet die Anfrage an den Server um den aktuellen Status abzuholen. Die Anfrage wird solange erneut ausgeführt, bis das Endergebnis DONE
zurückgeliefert wird. Als Timeout ist eine Sekunde gesetzt, was relativ kurz ist und dafür sorgt, dass jede Sekunde erneut beim Server nachgefragt wird.
function poll() { var xhr = new XMLHttpRequest(); xhr.open("GET", "/poll", true); xhr.onreadystatechange = function() { if(xhr.readyState == 4 && xhr.status == 200) { if (xhr.responseText !== "DONE") { // Start the next polling request setTimeout(poll, 1000); } else { // Stop polling } } } xhr.send(); }
Was fehlt?
Das Grundgerüst für mehrere konsekutive Ergebnisse, die auf eine Anfrage zurückgegeben werden sollen steht damit. Es schließen sich nun einige Möglichkeiten der Erweiterung an.
Da Server normalerweise mehrere Nutzer haben, sollte es natürlich möglich sein, dass mehrere Anfragen parallel ablaufen. Dazu müssen für alle diese Anfragen die Stati gespeichert werden. Der erste Request kann dann ein Token (zum Beispiel eine UUID
) zurückgeben, mit der anschließend beim Polling der Status der passenden Anfrage abgefragt werden kann. Gegebenenfalls muss hier natürlich noch mit Authentifizierung gearbeitet werden, so dass jeder Nutzer nur seine eigenen Requests anfragen kann.