Přejít k navigační liště

Zdroják » Různé » Post-Redirect-Get (nejen) v ASP.NET MVC

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

Články Různé

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(); 

Komentáře

Subscribe
Upozornit na
guest
17 Komentářů
Nejstarší
Nejnovější Most Voted
Inline Feedbacks
View all comments
architekt_

Pěkný článek, jen si myslím, že Post-Redirect-Get je obecně rozšířený omyl, který dává pocit bezpečnosti, ale prakticky nic moc neřeší.

Hlavním jeho nedostatkem je, že neřeší situaci, kdy uživatel použije v prohlížeči zpět a formulář odešle znovu (což je častější případ než že zmáčkne F5). Takže je to jen polovičaté řešení, které vůbec nemusí řešit to, co si klade za cíl. Proto se taky skoro nepoužívá (tedy ne z důvodu ochrany proti opakovanému odeslání dat, jak je prezentováno v článku).

Tento problém se řeší až na straně aplikace. A to tak, že každý formulář obsahuje jedinečný klíč (jak je dnes dobrým zvykem i v rámci ochrany proti CSRF), podle kterého aplikace pozná, jestli se jedná o opakovaně odeslaný formulář.

developer_

Používáme Post-Redirect-Get ve všech aplikacích tak asi 10 let a nemáme jediný problém s duplicitou odeslaných dat. Neznám ani framework s podporou jedinečných klíčů. To je pro změnu můj názor za léta programování pro web.

Ladislav Thon

IMHO pán architekt měl na mysli situaci, kdy se uživatel po regulérním odeslání formuláře vrátí na předchozí stránku s formulářem a znovu klikne na „Odeslat“. To opravdu redirect after POST nevyřeší.

Což pravda nic nemění na tom, že redirect after POST je naprosto základní technika, která by se měla aplikovat vždy a všude :-)

architekt_

Jde o to, že po tom použití tlačítka zpět uživatel znovu odešle formulář. Typicky stránka (skript) která formulář zpracuje je v pořádku (všechny data se zpracovaly v pořádku) a až na té přesměrované se něco „pokazí“ (nenačte se správně, internal server error, atp.). Když dá uživatel F5, nic se nestane, protože to řeší PRG. Ale spousta (většina) uživatelů F5 nezná/nepoužívá, dají místo toho zpět a pošlou formulář znovu.

Mimochodem s tím jedinečným klíčem ve formuláři se dá pořešit i situace, kdy dojde k neočekávané chybě už během zpracovávání dat z formuláře. Né vždy může celé zpracování probíhat jako transakce, která když se nedokončí, tak se nic nestane. Po opětovném odeslání formuláře uživatelem může aplikace ověřit/dokončit předchozí pokus o zpracování dat.

xurfa

Jasně, ale je tu jeden zásadní rozdíl: při Post-Redirect-Get je jediná možnost, jak může uživatel formulář odeslat ta, že vyvolá explicitní odeslání formuláře (tj. např. siskne tlačítko ve formuláří, nebo odentruje pole ve formuláři). Nikdy se to nestane mimoděk (např. tlačítkem zpět, apod.).

Honza77

Opakované odeslání klávesou F5 to neřeší, pokud F5 stiskneme ještě v době, kde se zpracovává první stránka (tedy ta, která zpracovává přijmutá data a následně odesílá redirect).

Ale jinak samozřejmě je základní technika, i když ji nelze použít pro zabránění odeslání formuláře vícekrát.

Borek Bernard

Dík za moc pěkný článek, jen bych PRG rozhodně nenazval návrhovým vzorem.

51><

Zdravim,

PRG je nahodou celkem pekne reseni (asi otazka nazoru). Rad bych ale nadhodil k diskuzi, jak resit vetsnou uzivatelu ocekavanou zpravu o uspesnosti / neuspesnosti pozadavku.

situace rekneme pridani uzivatele v nejakem foru: GET /user/add
– uzivatel vyplni formular POST /user/add
– server zpracuje pozadavek a uzivatele do DB prida, posle 303 na /user/list GET /user/list
– uzivatel vidi seznam jiz existujicich uzivatelu

ovsem u tohoto seznamu urcite nechce hledat sveho uzivatele, zda byl pridan nebo ne. Tedy rad by videl krasnou zdlenou hlasku „uzivatel uspesne pridan“

jedno reseni, ktere me napada je poslat si zpravu jako get parametr, napriklad /user/list?msg=us­pesne%20pridan

toto reseni ma ale tu nevyhodu, ze ted kdyz uzivatel provede reload, uvidi zpravu znovu a zdesi se, ze uzivatel byl pridan znovu. Jine reseni me napada pridat si treba hlavicku do HTTP, kterou pak parsuji napr. javascriptem a na zaklade ni uzivateli zobrazim zpravu. Ale to mi zase neprijde moc koser vuci HTTP

jak toto resite vy?

51><

Vida, do session si ukladat veci, o kterych pri nejblizsi prilezitosti informovat … proc me to nenapadlo driv :)

Dobry napad, dik

Viktor

A jak řešíte situace kdy není jednoznačné kam chce dále uživatel pokračovat, například nechce zobrazit seznam, ale přímo záznam vytvořeného uživatele, nebo chce ten seznam, nebo dokonce zůstat na stránce a přidat dalšího uživatele, atd…? Na straně serveru při zpracování post vyberete ten správný redirect, pokud ano, dává Vám to smysl? ;)

Ruthion

Redirekty a různé sofistikované ochrany před XSRF jsou pěkná věc, ale jen do chvíle, než opravdu potřebujete nějakou akci udělat 10× a místo 10-ti zmáčknutí F5 musíte 10× vyplňovat tatáž data. Proto mám raději po vyplnění formuláře stránku, na které je jen a pouze výsledek operace a bez redirektu (automatického).

Enum a statická analýza kódu

Mám jednu univerzální radu pro začínající programátorty. V učení sice neexistují rychlé zkratky, ovšem tuhle radu můžete snadno začít používat a zrychlit tak tempo učení. Tou tajemnou ingrediencí je statická analýza kódu. Ukážeme si to na příkladu enum.