czwartek, 7 sierpnia 2014

Redirect after post w Spring MVC.

W czasach aplikacji webowych, których front end bazuje głównie na JavaScriptcie mogę zostać wyśmiany za ten post, bo komu jeszcze potrzeba ta smutna wiedza, jak wyświetlić formularz z błędami zgodnie z zasadą redirect after post...

Niemniej, w niektórych przypadkach, już samo przejście na Spring MVC jest sporym skokiem technologicznym. Na początku wrażenia są super. Kontrolery są przyjemnie adnotowane. Ciekawy koncept rozszerzania listy parametrów przekazywanych do metod kontrolera, czyni ten framework praktycznie nieskończenie rozszerzalny. Swoją drogą bardzo interesująca implementacja zasady Open-Closed. Po ostygnięciu pierwszych emocji przychodzi refleksja, że właściwie oprócz podniesienia, o kolejny poziom, abstrakcji nad request i response, z pudełka SpringMVC, dostajemy niewiele więcej. Być może jest to rozsądne, żeby dać podstawowe klocki i niech się ludzie bawią. Być może wstyd, żeby dawać cokolwiek co np. jawnie sugerowałoby użycie sesji, która przecież jest teraz taka passe.


Ok, koniec biadolenia, trzeba zrobić żeby nasza aplikacja wspierała "Redirect after Post", czy też "Post/Redirect/Get", jak zwał tak zwał. Zaczynamy od wujka googla i na pierwszy ogień idzie wykorzystanie RedirectAttributes. Niby ok, ale po napisaniu kilku kontrolerów, wychodzi, że za każdym razem musimy powielić ten sam if:

if (binding.hasErrors()) {
    attr.addFlashAttribute("org.springframework.validation.BindingResult.form", binding);
    attr.addFlashAttribute("form", form);
    return "redirect:/...";
}

Nie wygląda to zbyt pięknie. Oprócz łamania zasady DRY, trzeba pamiętać żeby dodać do mapy zarówno błędy formularza jak i sam formularz. Szukamy dalej. Ktoś wpadł na pomysł wykorzystania interceptorów. Idea jest ok, ale sama implementacja ma kilka wad:
 - działa poprawnie tylko dla formularzy sesyjnych,
 - może dojść do wycieku błędów na inny formularz, jeśli redirect będzie na inny formularz o polach z takimi samymi nazwami

Po licznych walkach z dostosowaniem tej koncepcji wyszło całkiem sporo kodu. Obiekty formularza, który ma być "oporny" na przekierowania adnotujemy jakąś adnotacją markerem, np:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface SurviveRedirect {}
A interceptor tworzymy nieco bardziej skomplikowany:
public class SurviveRedirectInterceptor extends HandlerInterceptorAdapter {
 
     private static final Logger logger = LoggerFactory.getLogger(SurviveRedirectInterceptor.class);
 
     private static final String BINDING_RESULT_FLUSH_ATTRIBUTE_KEY = SurviveRedirectInterceptor.class.getName() + ".flashBindingResult";
     private static final String FLASH_FORM = SurviveRedirectInterceptor.class.getName() + ".flashForm";
     private static final String FLASH_FORM_NAME = SurviveRedirectInterceptor.class.getName() + ".flashFormName";
 
     @Override
     public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
          if (post(request)) {
                afterPost(request, modelAndView);
                return;
          }
          if (get(request)) {
                afterGet(request, modelAndView);
          }
     }
 
     private void afterGet(HttpServletRequest request, ModelAndView modelAndView) {

          Map<String, ?> inFlash = getInputFlashMap(request);
          if (inFlash == null) {
                logger.debug("brak inFlashMapy");
                return;
          }
 
          boolean success = restoreForm(request, modelAndView, inFlash);
          if (!success) {
                // jeśli nie udało się przywrocić formularza to kończymy, nie ma sensu przywracanie bindingresult, który
                // jest z nim sparowany
                return;
          }
 
          if (bindingResultExists(inFlash)) {

                Entry<String, Object> flashBindingResultEntry = (Entry<String, Object>) inFlash.get(BINDING_RESULT_FLUSH_ATTRIBUTE_KEY);
                Entry<String, Object> bindingResultEntry = getBindingResultEntry(modelAndView, flashBindingResultEntry.getKey());
                if (bindingResultEntry == null) {
                     logger.debug("brak bindingResultEntry podczas przywracania");
                     return;
                }
 
                BindingResult bindingResult = (BindingResult) bindingResultEntry.getValue();
                if (bindingResult == null) {
                     logger.debug("brak bindingResult podczas przywracania");
                     return;
                }
 
                // nadpisujemy rowniez binding result bez tego nie zostana wyświetlone dane fomularza, które zostały
                // prowadzone prawidłowo
                String formName = getFormName(modelAndView);
                BindingResult flashBindingResult = (BindingResult) flashBindingResultEntry.getValue();
                boolean sessionForm = isSessionForm(request, formName);
                modelAndView.addObject(MODEL_KEY_PREFIX + formName,
                          bindingResult(flashBindingResult, bindingResult, sessionForm));
          }
     }
 
     /**
     * zwraca true jeśli udało się poprawnie przywrocić formularz, lub formularz jest sesyjny
     */
     private boolean restoreForm(HttpServletRequest request, ModelAndView modelAndView, Map<String, ?> inFlash) {
 
          String formName = getFormName(modelAndView);
          if (formName == null) {
                logger.debug("Nie znalezlem nazwy formularza");
                return false;
          }
 
          boolean sessionForm = isSessionForm(request, formName);
          if (sessionForm) {
                return true;
          }
 
          Object flashForm = inFlash.get(FLASH_FORM);
          if (flashForm == null) {
                logger.debug("Nie znalezlem formularza w flashMapie (nazwa formularza w modelu: {})", formName);
                return false;
          }
 
          String flashFormName = (String) inFlash.get(FLASH_FORM_NAME);
          // dzięki temu formularza z innych kontrolerów nie są nadpisywane
          if (!formName.equals(flashFormName)) {
                logger.debug("Nazwa formularza w modelu ({}) jest inna niż nazwa w flashmapie ({}), pomijam otwarzanie formularz", formName, flashFormName);
                return false;
          }
 
          Object form = getForm(modelAndView);
          if (!flashForm.getClass().equals(form.getClass())) {
                logger.debug("Typ formularza w modelu ({}) jest inny niż typ w flashmapie ({}), pomijam otwarzanie formularz", form.getClass(), flashForm.getClass());
                return false;
          }
 
          modelAndView.addObject(formName, flashForm);
          return true;
     }
 
     private void afterPost(HttpServletRequest request, ModelAndView modelAndView) {
 
          FlashMap outFlash = getOutputFlashMap(request);
          if (outFlash == null) {
                logger.debug("Brak flashMapy.", request);
                return;
          }
 
          Object form = getForm(modelAndView);
          if (form == null) {
                logger.debug("Brak formularza w modelu: {}", request);
                return;
          }
 
          String formName = getFormName(modelAndView);
          if (isSessionForm(request, formName)) {
                logger.debug("Formularz jest sesyjny - pomijam.");
          }
          else {
                outFlash.put(FLASH_FORM, form);
                outFlash.put(FLASH_FORM_NAME, formName);
                logger.debug("Zapamietałem formularz: {}", form);
          }
          // binding result zapamietujemy niezależnie od tego czy formularz jest sesyjny czy nie
          saveBindingResult(modelAndView, outFlash);
     }
 
     private BindingResult bindingResult(BindingResult flashBindingResult, BindingResult bindingResult, boolean sessionForm) {
          if (sessionForm) {
                // jeśli formularz jest sesyjny to musimy sprawdzić czy nie został usunięty za pomoca SessionStatus
                if (bindingResult.getTarget() == flashBindingResult.getTarget()) {
                     // jeśli formularze są tą samą referencją to możemy przywrócić błędy
                     // nie można bazować na equlasie, ponieważ formularz zresetowany może mieć takie same wartości co
                     // formularz z błędami
                     bindingResult.addAllErrors(flashBindingResult);
                }
                return bindingResult;
 
          }
          return flashBindingResult;
     }
 
     private void saveBindingResult(ModelAndView modelAndView, FlashMap outFlash) {
          Entry<String, Object> bindingResult = getBindingResultEntry(modelAndView, BindingResult.MODEL_KEY_PREFIX);
          if (shouldSaveBindingResult(bindingResult)) {
                outFlash.put(BINDING_RESULT_FLUSH_ATTRIBUTE_KEY, bindingResult);
                logger.debug("Zapamiętałem bindingresult: {}", bindingResult.getKey());
          }
     }
 
     private boolean isSessionForm(HttpServletRequest request, String formName) {
          return request.getSession().getAttribute(formName) != null;
     }
 
     private boolean shouldSaveBindingResult(Entry<String, Object> bindingResult) {
          return bindingResult != null;
     }
 
     private boolean bindingResultExists(Map<String, ?> inFlash) {
          return inFlash != null && inFlash.containsKey(BINDING_RESULT_FLUSH_ATTRIBUTE_KEY);
     }
 
     private Object getForm(ModelAndView modelAndView) {
          Entry<String, Object> formEntry = getFormEntry(modelAndView);
          if (formEntry != null) {
                return formEntry.getValue();
          }
          return null;
     }
 
     private String getFormName(ModelAndView modelAndView) {
          Entry<String, Object> formEntry = getFormEntry(modelAndView);
          if (formEntry != null) {
                return formEntry.getKey();
          }
          return null;
     }
 
     private Entry<String, Object> getFormEntry(ModelAndView modelAndView) {
          if (modelAndView == null) {
                return null;
          }
          for (Entry<String, Object> entry : modelAndView.getModel().entrySet()) {
                Object value = entry.getValue();
                if (value == null) {
                     return null;
                }
                Class<?> formClass = value.getClass();
                if (findAnnotation(formClass, ZachowajPoPrzekierowaniu.class) != null
                          && !entry.getKey().equals(FLASH_FORM)) {
                     return entry;
                }
          }
          return null;
     }
 
     public BindingResult getBindingResult(ModelAndView modelAndView) {
          if (modelAndView == null) {
                return null;
          }
 
          for (Entry<String, ?> key : modelAndView.getModel().entrySet()) {
                if (key.getKey().startsWith(BindingResult.MODEL_KEY_PREFIX)) {
                     return (BindingResult) key.getValue();
                }
          }
          return null;
     }
 
     public Entry<String, Object> getBindingResultEntry(ModelAndView modelAndView, String klucz) {
          if (modelAndView == null) {
                return null;
          }
          for (Entry<String, Object> key : modelAndView.getModel().entrySet()) {
                if (key.getKey().startsWith(klucz)) {
                     return key;
                }
          }
          return null;
     }
}
Może da się łatwiej, może da się w ogóle inaczej. Być może komuś jeszcze się to przyda.

Uwaga! Samo przekazanie do metody RedirectAttributes, nawet jeśli nie będziemy korzystać z tego parametru, niweluje działanie tego interceptora, wynika to z mechaniki Spirng MVC.

1 komentarz: