Post-Redirect-Get (nejen) v ASP.NET MVC

Protokol HTTP má určitá specifika, jejichž nerespektování může vést až ke snížení uživatelského komfortu návštěvníků našich webových stránek. V tomto článku si představíme návrhový vzor Post-Redirect-Get a jeho konkrétní použití v ASP.NET MVC.

Post-Redirect-Get

Návrhový vzor Post-Redirect-Get je prostý a říká, že po každé akci, která může mít nějaké side efekty, je třeba provést redirekt (nejčastěji jako HTTP response status code 303). Správně navržená aplikace využívající protokolu HTTP by měla akce se side efekty (vytvoření, úprava, mazání dat apod.) zpřístupňovat pomocí HTTP metod POST, PUT nebo DELETE a redirekt by měl tedy následovat vždy po těchto akcích. Ostatní HTTP metody jsou označované jako safe a není třeba na ně vzor PRG aplikovat.

Ale proč se vůbec snažit o implementaci tohoto vzoru? Akademická odpověď by byla, že POST (stejně jako PUT a DELETE) HTTP metody jsou určeny hlavně pro práci s daty, nikoliv na prezentaci těchto dat, takže bychom měli vždy po POST, PUT a DELETE redirektovat na stránku, která prezentuje relevantní data, informuje o úspěšnosti/ne­úspěšnosti provedené operace apod.

Praktické důvody, proč se následování tohoto vzoru v praxi vyplatí, jsou minimálně dva. Prvním je to, že uživatel v ideálním případě nikdy v adresním řádku prohlížeče neuvidí adresu, jejíž requestování by způsobilo nějaký side efekt (úpravu produktu, odeslání objednávky apod.). A protože ji neuvidí, nemůže si ji uložit do bookmarků ani poslat kamarádovi. Druhou výhodou tohoto nezobrazení adresy je zabránění vícenásobnému odeslání dat. Znáte to, stránka se načítá nějak pomalu, tak dáte F5, prohlížeč se zeptá, jestli odeslat data znovu, OK, a najednou jsem odeslal dva stejné posty do fóra, zaplatil dvakrát účet za telefon nebo koupil dvě auta. Tak přesně tohle se nemůže nikdy stát, když budete jako autor webu ctít vzor PRG.

Hlavním důvod, proč ctít vzor PRG je tedy to, abychom zabránili vícenásobnému vyvolání requestu, který manipuluje s daty, má nějaké side efekty.

PRG v ASP.NET MVC

Když máme nějakou action metodu, kterou máme odekorovánu patřičnými atributy tak, aby byla volatelná pouze pomocí POST (příp. PUT nebo DELETE), tak z ní musíme vždy redirektovat. A přesně toto se neděje ve vygenerovaném kódu, pokud si ve Visual Studiu necháme vytvořit nový controller:

[AcceptVerbs(HttpVerbs.Post)]
public ActionResult Edit(int id, FormCollection collection)
{
    try
    {
        // TODO: Add update logic here
        return RedirectToAction("Index");
    }
    catch

    {
        return View();
    }
} 

Jak je vidět, po úspěšné validaci dojde správně k přesměrování, ale při nějaké chybě (typicky špatné zadání dat uživatelem) dojde k znovuzobrazení formuláře. Při tomto znovuzobrazení není třeba znovu konstruovat model, místo toho se využívá jedna „fičura“ ASP.NET MVC, o které málokdo ví, a proto si dovolím malou, avšak pro zbytek článku důležitou, odbočku.

ModelState

Pokud renderujeme inputí kontrolky pomocí standardních extension metod Html.TextBox, Html.TextArea apod., všechny tyto metody se VŽDY nejprve podívají, zda neexistuje v ModelState záznam s klíčem shodným s jménem právě renderované kontrolky. Pokud ano, tak se použije. To znamená, že i když explicitně zadáme value v nějakém overloadu uvedených extension metod, tak se tato value vůbec nepoužije, pokud existuje hodnota v ModelState.

Příklad: Ve view zavoláme Html.TextBox(„Pri­ce“, „100“). Pokud je v ModelState záznam s klíčem „Price“, použije se tato uložená hodnota a námi zadaný string „100“ se vůbec nedostane ke slovu.

Jak se ale hodnoty do ModelState dostanou? V praxi se vyskytují dva hlavní způsoby: ručně nebo při model-bindigu. A model-binding probíhá vždy, pokud má action metoda nějaké parametry nebo pokud zavoláme metodu [Try]UpdateModel. Model-binder totiž do ModelState neukládá pouze chyby (které se pak testují pomocí ModelState.Is­Valid a renderují pomocí Html.Validati­onSummary apod.), ale informace o všech provedených bindováních. I úspěšných.

Implementace

Nyní zpět k vygenerovanému, ne úplně dobrému, kódu action metody. Ten jednoduše vylepšíme tím, že místo „return View();“ přesměrujeme na iniciální stránku pro editaci, což je ve většině případů stejný redirekt jako v případě úspěšné editace, tedy něco jako „return RedirectToActi­on(“Edit“, new { id = id });„. Zde ale narážíme na problém – ModelState je záležitost jednoho konkrétního requestu, nepřežije redirekt, takže uživatel nevidí své špatné hodnoty, ale vidí iniciální hodnoty (jako při prvním zobrazení stránky). Jak z toho ven? Inu musíme zajistit, aby ModelState přežil redirekt, čehož se dá velice elegantně dosáhnout následovně pomocí action filterů.

Uděláme si jeden action filter, který se spustí po provedení akce a který si zjistí, jestli redirektujeme. Pokud ano, tak uloží aktuální ModelState do TempData (tam nám data přežijí do dalšího requestu – defaultně používá session). Dále si pak uděláme druhý action filter (příp. to „naprasíme“ všechno do jednoho), který se v případě renderingu stránky pokusí vyzvednout ModelStateTempData.

Když máme hotové tyto dva action filtery, tak je stačí aplikovat na náš bázový controller a od té doby můžeme v klidu redirektovat z POSTů při zachování ModelState. Komu by se nechtělo výše popsané action filtery implementovat, tak může mrknout do tohoto článku. Implementace to je moc pěkná, snad bych jen při ukládání ModelState do TempData navíc ještě zkontroloval, zda byl daný request učiněn jako POST, DELETE nebo PUT. Pokud ne, nemá většinou smysl ModelState ukládat.

Ideálně by tedy akce pro úpravu modelu mohly vypadat nějak takto:

public ViewResult Edit(int id)
{
    return View(repository.LoadModel(id));
}

[HttpPost]
public RedirectToRouteResult Edit(int id, FormCollection formData)
{
    var model = repository.LoadModel(id);
    TryUpdateModel(model, formData.ToValueProvider());
    return RedirectToAction("Edit", new { id = id });
} 

Nemusíme tedy v action metodě ani rozlišovat, zda proběhla úprava modelu v pořádku – o případné přenesení nevalidního ModelState do další akce se postará výše uvedený action filter.

K výše uvedenému bych ještě dodal, že situace se nám trošku komplikuje v případě Ajax requestů. Tam si prohlížeče s redirektem povětšinou neporadí, takže si to řeší každý po svém nějakým workaroundem. Na druhou stranu Ajax requesty nejsou přímo viditelné pro uživatele a tudíž nehrozí problémy popsané v úvodu článku, takže v případě Ajax requestů bych na PRG s klidným srdcem zapomněl.

Perzistence formulářových dat

Výše popsaný způsob je typický a myslím, že pokryje většinu aplikací, protože reflektuje stavění aplikace odspoda. Tedy business vrstva je závislá na tom, jak vypadá datová vrstva a prezentační vrstva je do jisté míry závislá na tom, jak vypadá business vrstva. Poslední větu si lze vyložit i tak, že děláme formuláře na míru business entitám, příp. jejich agregacím. Nikoliv obráceně, abychom podle formuláře stavěli business model. Může se ale vyskytnout i taková situace, kdy pro formulář nemáme odpovídající [View]Model, tedy nedojde k uložení zadaných hodnot do ModelState, protože nemáme na co bindovat zadaná formulářová data, vůbec tedy neproběhne model-binding, který by nám ModelState naplnil. Ale přesto chceme ctít PRG, přičemž logickým požadavkem je nějak přenést ona zadaná formulářová data. Oblíbeným způsobem řešení problému je převedení na předchozí případ, v našem případě tedy uložit formulářová data do ModelState.

I tuto funkcionalitu můžeme implementovat poměrně jednoduše. Stačí, abychom vytvořili model-binder pro třídu FormCollection, kterou uvádíme jako parametr action metody. V tomto model-binderu jednoduše projdeme všechna formulářová pole a hodnoty uložíme do ModelState:

public class PersistingFormCollectionBinder : IModelBinder
{
    public object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
    {
        if (controllerContext == null)
        {
            throw new ArgumentNullException("controllerContext");
        }
        var res = new FormCollection(controllerContext.HttpContext.Request.Form);
        foreach (var key in res.AllKeys)
        {
            if (!bindingContext.ModelState.ContainsKey(key))
            {
                var value = res[key];
                bindingContext.ModelState.Add(key, new ModelState { Value = new ValueProviderResult(value, value, System.Globalization.CultureInfo.CurrentCulture) });
            }
        }
        return res;
    }
} 

Pak už jen stačí tento náš model-binder zaregistrovat, typicky v metodě Application_Start v souboru global.asax.cs:

ModelBinders.Binders[typeof(FormCollection)] = new PersistingFormCollectionBinder(); 

Věděli jste, že nám můžete zasílat zprávičky? (Jen pro přihlášené.)

Zdroj: https://www.zdrojak.cz/?p=3064