GWT: Asynchrone Requests und Race Conditions

GWT verwendet für die Kommunikation zwischen Client und Server asynchrone Requests, d.h. Clients schicken ihre Anfragen an den Server ab, warten aber nicht auf die Antworten vom Server, sondern bearbeiten diese asynchron. Dadurch bleibt der Browser reaktionsbereit, der Workflow des Benutzers wird nicht eingeschränkt. Es kommt dafür aber zu Verhalten, das für einen Einsteiger in GWT gewöhnungsbedürftig ist und leicht zu Programmierfehlern führt.

Ein einfaches Beispiel: Eine Seite unserer Applikation beinhaltet eine Listbox, die mit Werten aus einer Datenbank befüllt werden soll, sowie einen Button, der das Hinzufügen von neuen Werten ermöglichen soll. Sichtbar soll der Button nur dann sein, wenn sich sowohl weniger als fünf Werte in der Listbox befinden als auch die notwendigen Rechte vorhanden sind. In der zu unserem Beispiel zugehörigen Service-Klasse auf der Serverseite gibt es zwei Methoden: fillListbox() und hasAddRights().

Ein erster naiver Versuch

Man führt einfach beide Requests aus und stellt diese Aufrufe im Quellcode hintereinander. Hier hat man nicht bedacht, dass die Antworten vom Server asynchron zurückkommen und die onSuccess()-Methoden der Requests erst danach ausgeführt werden. Der Client erhält also die Antworten in einer zufälligen Reihenfolge – manchmal wird das Ergebnis wie gewünscht sein, manchmal nicht. Das ist besonders unangenehm, weil das Fehlverhalten im Testbetrieb unter Umständen gar nicht bemerkt wird, z.B. weil die Antwortzeiten der Requests im Testsystem in einem ganz anderen Verhältnis zueinander stehen als im Produktivsystem.

Um das Verhalten wie gewünscht zu implementieren, gibt es mehrere Möglichkeiten, von denen jede ihre Vor- und Nachteile hat (je nach Komplexität des UIs, der Anzahl der Requests und der Anforderungen an Performance).

Lösung 1: Requests schachteln

Bei dieser Lösung ruft man den ersten Request auf und stellt den Aufruf für den zweiten Request in die onSuccess()-Methode des ersten, den Aufruf für den dritten Request in die onSuccess-Methode des zweiten und so weiter.

Vorteile sind hier die schnelle Implementierung, der niedrige zeitliche Aufwand und die geringe Fehleranfälligkeit. Je nach Art der Fragestellung kann man auch Performancegewinne herausholen, wenn sich etwa verschachtelte Requests einsparen lassen. Die großen Nachteile dieser Lösung sind vor allem die Verschlechterung der Lesbarkeit (bei 2 Requests noch nicht relevant, aber bei 5-10 dann schon sehr merklich). Außerdem wird die Performance schlechter, wenn man nichts einsparen kann, weil die Parallelität, die bei mehreren Requests möglich ist, zerstört wird – der nächste Request kann immer erst dann abgearbeitet werden, wenn der vorige abgeschlossen ist.

Lösung 2: Value Proxy

Bei dieser Variante wird davon ausgegangen, dass ein einziger Request an den Server ausreicht, um die notwendigen Informationen zu erhalten. Es wird daher einfach ein zusätzlicher Request erstellt, der (serverseitig) die Funktionalität der bisherigen Requests zusammenfasst, die Ergebnisse in einem Value Proxy wrappt und diesen an den Client zurückliefert.

Das ist vor allem dann eine gute Lösung, wenn die betreffenden Requests thematisch zusammenhängen, führt aber leicht zu unübersichtlichen Services mit einer sehr großen Anzahl an unterschiedlichen Requests. Auch die Wiederverwendbarkeit der Requests sinkt stark.

Lösung 3: Requests clientseitig synchronisieren

Das Kernstück für diese Lösung ist ein für die Situation maßgeschneidertes Latch:

public class CountDownLatch {
   private int target = 0;
   private Runnable action;

   public CountDownLatch(Runnable action) {
         this.action = action;
   }

  private void increase() {
         target++;
   }

   private void countDown() {
         if (target <= 0) throw new IllegalStateException();
         if (--target == 0) action.run();
   }

  private class LatchCallback<T> implements AsyncCallback<T> {
         private AsyncCallback<T> callback;
         public LatchCallback(AsyncCallback<T> callback) {
              this.callback = callback;
              increase();
         }

         @Override
         public void onSuccess(T result) {
              try {
                    callback.onSuccess(result);
              } finally {
                    countDown();
              }
         }

         // onFailure muss analog behandelt werden
   }

   public <T> AsyncCallback<T> track(AsyncCallback<T> asyncCallback) {
         return new LatchCallback<T>(asyncCallback);
   }
}

Das Latch merkt sich alle Requests, die mit track() aufgerufen wurden und führt das Runnable aus, sobald alle Requests beantwortet worden sind. Über das Runnable setzt der Programmierer die notwendigen Schritte im UI.

private CountDownLatch latch = new CountDownLatch(new Runnable() {
         @Override
         public void run() {
              button.setVisible(state.hasRights() && !state.hasTooManyValues());
         }
   });

appService.fillListbox(latch.track(new AsyncCallback<List<String>>() {
         @Override
         public void onSuccess(List<String> result) {
              addItemsToListbox(listbox, result);
              if (result.size() >= 5) {
                    state.setTooManyValues(true);
               } else {
                    state.setTooManyValues(false);
              }
         }
   }));

appService.hasAddRights(user, latch.track(new AsyncCallback<Boolean>() {
         @Override
         public void onSuccess(Boolean hasRights) {
              state.setRights(hasRights);
         }
   }));

Diese Lösung hat als einzigen Nachteil, dass man unter Umständen eine Statusklasse erstellen muss (oft ist das aber gar nicht nötig). Sie bietet abgesehen davon alles, was man sich als Entwickler wünscht: übersichtlichen und gut lesbaren Code, volle Performance der asynchronen Requests, einfache Implementierung und gute Wartbarkeit. Auch falsch machen kann man eigentlich nichts – die Schnittstelle zum CountDownLatch ist so minimalistisch wie möglich, man muss nur daran denken, die Callbacks bei betroffenen Requests auch tatsächlichin latch.track() zu wrappen.

Wir setzen diese Lösung auch manchmal ein, um für den User noch intuitiver zu sein: gerade für Standardbenutzer ist es oft irritierend, wenn unterschiedliche Teile der Seite unterschiedlich lange zum Laden brauchen. Hier kann man, auch wenn keine Race Conditions vorliegen, das Latch dazu verwenden, um das UI in einem Stück nach Erhalt aller Daten anzuzeigen.

Bist du auch schon in die Problematik von Race Conditions zwischen Client und Server betroffen gewesen? Wenn ja, wie hast du diese Probleme gelöst?

Lisi Blümelhuber (Software Developer)