poniedziałek, 30 stycznia 2012

Fantomowe tabelki w JSF

W niniejszym poście przyjrzymy się pewnej anomalii występującej w JSF, której skutki potencjalnie mogą być katastrofalne.
Anomalia została dobrze rozpoznana dosyć dawno, jednak jak pokazuje moje doświadczenie w pracy z zespołami korzystającymi z JSF, nie jest ona uświadomiona.
Dlatego dla tych z czytelników, którzy używają JSF i nie spotkali się ze zjawiskiem "klikania w nieodpowiednie wiersze tabelki" lektura posta jest obowiązkowa:)

Problematyczny scenariusz:
1. Użytkownik A wyświetla na stronie tabelkę z listą rekordów (Encje JPA lub DTO)
Na ekranie dla każdego rekordu mamy możliwość jego edycji/usunięcia poprzez Postback.

2. Podczas gdy użytkownik A delektuje się widokiem zaokrąglonych rogów na naszej stronie, użytkownik B (w tak zwanym międzyczasie) dokonuje w bazie zmiany danych, które są prezentowane na ekranie użytkownika A.
Może być to usunięcie danych lub edycja takich atrybutów, które wpłyną na ilość lub kolejność danych zwracanych przez zapytanie, które używa ekran użytkownika A.

3. Użytkownik A, po zaspokojeniu swych potrzeb estetycznych, klika przykładowo w pierwszy wiersz tabelki w celu usunięcia/modyfikacji rekordu.

4. Ku zdziwieniu użytkownika A, system poddał usunięciu/edycji zupełnie inny rekord niż zamierzony.

Zjawisko to pozwoliłem sobie nazwać Fantomową tabelką - ot jako żarcik techniczny będący paralelą (trudne słowo) do Anomalii Transakcji.

Przykład kodu

Managed Bean:
@ManagedBean()
public class UsersControler{ 
 
 @ManagedProperty("#{userFinder}")
 private UserFinder userFinder;
 
 @ManagedProperty("#{userManagement}")
 private UserManagement userManagement;
 
        //Bean o zasięgu sesji pamiętający nasze kryteria wyszukiwania
 @ManagedProperty("#{usersSearchCriteria}")
 private UsersSearchCriteria usersSearchCriteria;

 private List<User> users;
 
 private User selected;
 
 
 @PostConstruct
 public void search(){  
  users = userFinder.findUsers(usersSearchCriteria.getFirstname(), usersSearchCriteria.getLastname(), null, null);  
 } 

 public void remove(){
  userManagement.deleteUser(selected.getId());
  //search(); - zbędne dla zasięgu Request, konieczne dla View aby odświeżyć
 }
 
 public void remove2(Long id){ 
  userManagement.deleteUser(id);
  //search(); - zbędne dla zasięgu Request, konieczne dla View aby odświeżyć
 }   
}

Powyższy ManagedBean ma domyślny zasięg Request (nie chcemy przecież obciążać stanu sesji listą użytkowników).

Nasz bean ma wstrzyknięte 2 obiekty z warstwy aplikacji: UserFinder (wyszukujący użytkowników), UserManagement (operujący na użytkownikach - w przykładzie usuwamy użytkowników, ale problem jest ogólny - generalnie chodzi o jakąkolwiek modyfikację, która zmieni resultat działania UserFinder.findUsers )

Zwróćcie uwagę, iż kryteria wyszukiwania z formularza nie przechowywane w kontrolerze (który ma zasięg Request) a we wstrzykniętym Modelu o zasięgu Sesji.



    
     
     
      imie:  nazwisko: 
       
       
       
           
       
     
   
    
     ID
     #{_u.id}
    
 
    
    
     
      
      
     
                         
    
         
                
    

Widok jest bardzo prosty: tabelka iteruje po kolekcji użytkowników. Dla każdego z nich wyświetla ID (aby organoleptycznie zaobserwować problem) oraz umożliwia usunięcie (w ogólności modyfikację stanu bazy mającą wpływ na listę) użytkownika. Technicznie mamy tutaj dwa podejścia do przekazywania "klikniętego" użytkownika na Serwer: poprzez f:setPropertyActionListener oraz dzięki wywołaniu metody z parametrem.

To czy przekazujemy ID użytkownika czy obiekt klasy User nie ma znaczenia dla eksperymentu.

Dlaczego tak się dzieje

Załóżmy, że podczas pierwszego renderowania strony do pierwszego wiersza tabelki był "podpięty" pierwszy wiersz z wynikowej listy - użytkownik o id = 1.
Załóżmy, że później - po zmianie stanu bazy - pierwszy użytkownik na liście wynikowej pobranej z bazy ma id = 2.

Silnik JSF podczas postback odtwarza drzewo komponentów graficznych. Następnie "rozsmarowuje" na tym drzewie model. Tak więc podczas postback do pierwszego wiersza tabeli może podpiąć użytkownika o id = 2.

Model zdarzeń w JSF (zgodnie z nazwą Java Server Faces) odbywa się w kontekście serwera, czyli silnik ma informację o numerze klikniętego wiersza w tabelce (nie o id klikniętego usera). Dalej na podstawie klikniętego wiersza, zbiera z tego wiersza model i ten model traktuje jako "kliknięty". Jak widać - zakłada optymistycznie, że tak jest:)

Jeżeli przy postbacku model pobrany z bazy będzie inny - trudno... :P


Dlaczego często nie widać problemu

Programiści JSF często z powodu kłopotów z ogarnięciem złożoności zasięgów życia ManagedBeanów decydują się na rozwiązania "bo działa" i rozszerzają zasięg życia niemal wszystkich ich do Sesji. W przypadku zasięgu sesji nie odtwarzamy modelu danych na podstawie bazy lecz na podstawie ViewState zatem problem z klikaniem w fantomy jest po prostu ukryty - nie występuje. Czyli przy okazji, jako skutego uboczny niechlujstwa, zabezpieczamy się przez błędem fantomowych danych:)

Jeżeli zależy nam na zasobach lub świeżości danych, wówczas musimy wysilić się na rezygnację z Sesji gdzie tylko jest to możliwe i zaczyna się zabawa...

Rozwiązania

1. Zasięg View/Session
Przechowywanie list w Sesji to zwykle słaby pomysł, ale możemy zdecydować się na ich przechowywanie w zasięgu View, który trwa dopóki znajdujemy się na tej samej stronie (GET zrywa ten zasięg).
@ManagedBean()
@ViewScoped
public class UsersControler{

Można pokusić się o wydzielenie samego modelu prezentacji do osobnego ManagedBeana o zasięgu View tak aby nie przechowywać tam całego kontrolera, który jest zbędny i powinien żyć w Request. Wówczas wstrzykujemy model do kontrolera, napełniamy go w kontrolerze, po czym kontroler może już "umrzeć".


2. Ukryte Pole

Jeżeli nie możemy pozwolić sobie na przechowywanie stanu widoku, wówczas pewnym rozwiązaniem, jest rezygnacja z mechanizmów JSF do wskazywania klikniętego wiersza. Nasze buttony powinny przy pomocy JS ustawiać ukryte pole formularza na wartość id modelu wiersza i submitować to pole na managedbeana.


//=====================================

Po raz kolejny mamy do czynienia z sytuacją, gdzie próba ukrycia złożoności esencjalnej skutkuje wyciekiem jeszcze większej ilości złożoności przypadkowej.
"...życia nie oszukasz."

2 komentarze:

Mirek pisze...

Dodatkowo używanie @SessionScope sprawia, że użytkownik nie może pracować na wielu zakładkach w ramach tej samej sesji, a nieraz jest to potrzebne

Sławek Sobótka pisze...

Tak, zgadza się. Dlatego właściwie ViewScope można uznać w tym wypadku za jedyne sensowne podejście.