Google App Engine HTTP Session vs JSF

Google App Engine jest usługą typu Platform as a Service umożliwiającą (między innymi) uruchamianie aplikacji webowych Java poprzez udostępnienie kontenera servletów. Aplikacja nie jest uruchamiana w jednej instacji lecz zapytania do GAE są propagowane między wiele instacji serwerów. Istnieje możliwość korzystania z sesji HTTP (po zaznaczeniu tego w konfiguracji). Sesja jest replikowana między instancjami serwerów co rodzi poważne problemy w przypadku korzystania z sesyjnych beanów JSF. 

Według dokumentacji atrybuty, które zostaną w czasie przetwarzania requestu ustawione przy użyciu HttpSession#setAttribute() zostaną zapisane do datastore pod koniec jego przetwarzania. W memcache oraz bazie danych (datastore) jest zapisywana zserializowana sesjia. Służy to do jej replikacji między instancjami serwerów.

Problem polega na tym, że beany sesyjne w JSF są umieszczane w sesji tylko raz po ich utworzeniu. Z reguły nie są immutable a zmiana ich stanu nie powoduje wywołania HttpSession#setAttribute(). Co więcej – zaobserwowałem, że stan sesji jest zapisywany tylko wtedy, gdy do HttpSession#setAttribute dla danego klucza przekazano rinny obiekt niż już się w sesji znajdował. Zaobserwowałem, że decyzja o zaznaczeniu, że sesja jest przeznaczona do serializacji jest podejmowana wewnątrz HttpSession#setAttribute() w ten sposób:

 Object previousValue = internalMap.put(beanKey, bean);
 boolean mark = (previousValue == null) ? true : bean.equals(previousValue);

W wyniku tego zmiany wewnętrznego stanu beana sesyjnego mogą nie być widoczne w innych instancjach serwerów. Można ten problem rozwiązać na trzy sposoby:

  1. zrezygnować z użycia beanów sesyjnych, które nie są immutable
  2. wymusić replikację sesji po zakończeniu przetwarzania każdego requestu
  3. wymusić replikację sesji po zakończeniu przetwarzania requetu, jeśli w jego trakcie zmienił się stan beana sesyjnego

Rezygnacja z użycia zmiennostanowych beanów sesyjnych często może się okazać bardzo trudna (aplikacja napisana dla innego serwera) a na pewno uciążliwa.

Wymuszenie replikacji po zakończeniu przetwarzania każdego requestu jest niezbyt korzystne wydajnościowo, ale w prosty sposób pozwala na uzyskanie kompatybilnośći z JSF. Wymuszenie replikacji sesji nie jest jednak proste. Nie jest dostępna metoda przez API. Zaobserwowałem, że trzeba w tym celu umieścić w sesji atrybut i to za każdym razem o innej wartości. Spowoduje to serializację i replikację całej sesji. Jako że replikacja jest uruchamiana po przetworzeniu requestu przez fitr com.google.apphosting.runtime.jetty.SaveSessionFilter to umieszczenie odpowiedniego kodu w PhaseListenerze (lub w filtrze) rozwiązuje problem:

public class ForceSessionSerializationPhaseListener implements PhaseListener {
 
    private static final Logger LOGGER = Logger.getLogger(ForceSessionSerializationPhaseListener.class.getName());
 
    private void touchSession() {
        LOGGER.finest("forcing session serialization");
        final FacesContext facesContext = FacesContext.getCurrentInstance();
        final Map sessionMap = facesContext.getExternalContext().getSessionMap();
        sessionMap.put("forceGaeSessionSerialization", System.currentTimeMillis());
    }
    public void afterPhase(final PhaseEvent event) {
        touchSession();
    }
    public PhaseId getPhaseId() {
        return PhaseId.INVOKE_APPLICATION;
    }
    public void beforePhase(PhaseEvent event) {
    }
}

Ostatni sposób, wymuszenie replikacji po zmianie stanu beana jest najtrudniejszy do realizacji ale korzystniejszy wydajnościowo. Jednym ze sposobów jest udostępnienie we wszystkich beanach sesyjnych takiej metody jak ForceSessionSerializationPhaseListener#touchSession zaprezentowana wyżej. Programista po dokonaniu jakiejkolwiek zmiany w stanie beana powinien ją wywołać. Trzeba przyznać, że jest to wyjątkowo nieporęczne. Najbardziej zaawansowanym rozwiązaniem jest automatyczne wykrywanie zmian stanu beanów sesyjnych. Uzyskałem to przy użyciu AspectJ. Stworzyłem aspekt wykrywający zmiany stanu beanów anotowanych jako @SessionScoped (w ten sposób rozwiązanie ogranicza się do JSF z anotacjami):

import java.lang.reflect.Field;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
 
/**
 * @author Zacheusz
 */
@Aspect
public class WatchSessionBeanModification {
 
    private static ThreadLocal<Boolean> threadLocalFlag = new ThreadLocal<Boolean>();
 
    public static boolean wasModified() {
        return Boolean.TRUE.equals(threadLocalFlag.get());
    }
    private void markChanged() {
        threadLocalFlag.set(true);
    }
    @Around("set(!static !final !transient * (@javax.faces.bean.SessionScoped *) . *) "
        + "&& args(newVal) && target(t)")
    public final void afterSetField(final ProceedingJoinPoint jp,
            final Object t, final Object newVal) throws Throwable {
        final Object oldVal = getOldVal(jp, t);
        if((oldVal != null && !oldVal.equals(newVal) )||
            (newVal != null && !newVal .equals(oldVal))) {
            markChanged();
        }
        jp.proceed();
    }
    private Object getOldVal(final ProceedingJoinPoint jp, final Object t) throws Exception{
        final Field field = t.getClass().getDeclaredField(jp.getSignature().getName());
        field.setAccessible(true);
        return field.get(t);
    }
}

Ze względów wydajnościowych zastosowałem tkanie w czasie kompilacji, ale to rozwiązanie tego nie wymusza. Moim zdaniem warto rozważyć użycie Perscope zamiast bezpośredniego ThreadLocal w aspekcie. Do uruchomiania replikacji sesji użyłem podobnego PhaseListenera jak wcześniej:

public class ForceModifiedSessionSerializationPhaseListener implements PhaseListener {
 
    private static final Logger LOGGER = Logger.getLogger(ForceModifiedSessionSerializationPhaseListener.class.getName());
 
    private void touchSession() {
        LOGGER.finest("forcing session serialization");
        final FacesContext facesContext = FacesContext.getCurrentInstance();
        final Map<String, Object> sessionMap = facesContext.getExternalContext().getSessionMap();
        sessionMap.put("forceGaeSessionSerialization", System.currentTimeMillis());
    }
    public void afterPhase(final PhaseEvent event) {
        if(WatchSessionBeanModification.wasModified()) {
            touchSession();
        }
    }
    public PhaseId getPhaseId() {
        return PhaseId.INVOKE_APPLICATION;
    }
    public void beforePhase(PhaseEvent event) {
    }
}

Aspekt wykrywa zmiany stanu benów sesyjnych i zapala flagę. PhaseListener sprawdza ją i wymusza replikację sesji pod koniec przetwarzania requestu. PhaseListenera z powodzeniem możnaby zastąpić filtrem dla servletu.

Powyższe rozwiązania działają i są przetestowane przy ustawieniach w appengine-web.xml:

<sessions-enabled>true</sessions-enabled>
<threadsafe>false</threadsafe> <!-- domyślna wartość - jeden serwer przetwarza w danym momencie tylko jedno zapytanie -->
<async-session-persistence enabled="false" queue-name=""/>  <!-- domyślna wartość - synchroniczna replikacja sesji -->

Domyślnie GAE zapobiega równoległemu przetwarzaniu zapytań przez dany serwer. Można to zachowanie wyłączyć, ale wtedy będziemy musieli się uporać ze znacznie większą ilością problemów związanych ze współbieżnością aplikacji webowych. W przypadku przełączenia zapisu sesji na asynchroniczny (tak, że nie będzie ona replikowana w bieżącym wątku) przy użyciu opcji async-session-persistence należy rozważyć dodatkowe ryzyka wynikające z opóźnionej replikacji. Zwłaszcza, że dokumentacja tego mechanizmu nie została jeszcze opublikowana. Warto także zwracać uwagę na zmiany implementacji replikacji sesji. Opisane rozwiązania opierają się bowiem na fakcie, że przy każdej zmianie sesja jest zapisywana i replikowana w całości.

Szczególnie zastanawiające dla mnie jest to, że wiele osób używa JSF na GAE, niewiątpliwie większość używa beanów sesyjnych a w Internecie nie ma śladu po opisach omówionego problemu. Przypuszczam, że może on również występować w innych frameworkach.

Zacheusz Siedlecki

Komentarze

Chcesz coś napisać?





*