středa 11. března 2015

PRG není na PRD

Někdy se ty zkratky pletou a obdivuji zejména Američany, že se do nich totálně nezapletou. Američany proto, že ve zkracování všeho mají zvláštní oblibu a nejlépe, pokud to jsou jen tři písmena. Zkratku PRG je dobré nezaměnit s RPG, což by hned mohlo evokovat, že tenhle příspěvek bude o hraní her - což nebude. Bude o vzoru Post-Redirect-Get a jeho implementaci v ASP.NET MVC.

Abych vzor PRG trochu přiblížil, použiji zjednodušenou úlohu - má se udělat webová aplikace, která bude zobrazovat seznam citátů a umožní další přidávat. Pokud se přidání nepovede, tak se zobrazí pomocí červené barvy chyba, pokud bude úspěšné, bude zelenou zobrazeno potvrzení operace.

Samozřejmě je to trochu zjednodušené, ale takové požadavky jsou v praxi poměrně běžné - jen místo textu se objevují různé dialogy, potvrzovací pruhy apod.

Takže taková aplikace může být velmi jednoduchá, kód kontrolleru bude mít dvě metody (akce). První slouží zejména pro zobrazení citátů, ale pokud je jí předán citát, tak se jej pokusí uložit - abych simuloval úspěšné či neúspěšné uložení, použil jsem pomocnou metodu - v opravdovém kódu by to bylo jinak, ukládalo by se do databáze apod.

Druhá metoda pak slouží jen pro vykreslení vkládacího formuláře.

public class HomeController : Controller
{
    static List<string> quotes = new List<string>();
    public ActionResult Index(string quote)
    {
        if(!string.IsNullOrEmpty(quote))
        {
            if(this.IsSavedSuccessfuly())
            {
                ViewBag.Success = " ";
                quotes.Add(quote);
            }
            else
            {
                ViewBag.Failure = " ";
            }
            
        }
        return View(quotes);
    }

    public ActionResult Add()
    {
        return View();
    }

    private bool IsSavedSuccessfuly()
    {
        Random rand = new Random();

        return rand.NextDouble() >= 0.5;
    }
}

Kód příslušných view je také jednoduchý:

@model IEnumerable<string>

@{
    ViewBag.Title = "Seznam";
}

@if(!string.IsNullOrEmpty(ViewBag.Success))
{
    <p style="background-color:green">uloženo</p>
}

@if(!string.IsNullOrEmpty(ViewBag.Failure))
{
    <p style="background-color:red">neuloženo</p>
}

<ul>
    @foreach (var quote in Model)
    {
        <li>@quote</li>
    }
</ul>

<p>
    @Html.ActionLink("Přidat", "Add")
</p>

a

@{
    ViewBag.Title = "Add";
}

@using (Html.BeginForm("Index", "Home"))
{
    @Html.AntiForgeryToken()
    @Html.TextBox("quote")
    <input type="submit" value="Přidat" />
}

<div>
    @Html.ActionLink("zpět", "Index")
</div>


A dalo by se říci, že celá aplikace funguje. Lze vidět seznam citátů

Po kliknutí přidat další

A případně vidět hlášení o bezproblémovém zpracování

a nebo selhání

Nicméně nejpozději při testech (a nebo vzápětí od uživatelů - záleží jak se kde testuje - někdo testuje až na uživatelích) se ukáže, že pokud se přidá citát (ať již úspěšně a nebo neúspěšně) a uživatel klikne na obnovení stránky:

tak se mu objeví dialog, který může být pro něj matoucí:

a navíc způsobit duplicitu v datech:


A jak to napravit? Právě o tom je vzor PRG, lze si jej graficky znázornit nějak takto:


A jak upravit kód? Pro přehlednost jsem založil další kontroller a přidal do něj metodu Save - ta odpovídá za uložení dat. Lze ji vyvolat pouze při POST, ostatní metody pak jen přes GET. Nově přidaná metoda Save navíc nevrací do prohlížeče žádná prezentovatelná data, ale rovnou jej přesměřuje na první metodu. Pokud tedy uživatel klikne následně na refresh/obnovit tlačítko svého prohlížeče, tak se provede poslední příkaz, tedy GET a obnoví se seznam citátů - neobjeví se tedy žádná výzva k potvrzení nového odeslání formuláře a nehrozí ani duplicita dat:

public class Home2Controller : Controller
{
    static List<string> quotes = new List<string>();

    [HttpGet]
    public ActionResult Index()
    {
        return View(quotes);
    }

    [HttpGet]
    public ActionResult Add()
    {
        return View();
    }

    [HttpPost]
    public ActionResult Save(string quote)
    {
        if (!string.IsNullOrEmpty(quote) && !quotes.Contains(quote))
        {
            quotes.Add(quote);
        }
        else
        {
            ModelState.AddModelError("", "Is Empty");
        }

        return RedirectToAction("Index");
    }
}

Pokud si zkontroluji síťovou komunikaci, vidím pěkně zřerelně celý vzor PRG v akci - nejprve se POSTnou data, pak následuje 302 REDIRECT a následně GET:


Ale nějak se nám ztratila indikace úspěšného a neúspěšného uložení. Z kódu je jasné, že pokud se nezadá žádný citát a formulář se odešle, popřípadě citát už existuje, skončí uložení chybou, ale uživatel o tom není nijak informován - a není ani upozorněn na úspěšné uložení.
Existuje určitě mnoho způsobů jak to napravit, já inspiraci našel v kódu kolegy, který se zase inspiroval zde: http://weblogs.asp.net/rashid/asp-net-mvc-best-practices-part-1#prg, kapitola 13 - mimochodem on celý článek včetně odkazů v něm stojí za přečtení.

Udělal jsem si tedy tyto atributy:

public abstract class ModelStateResultTempDataTransferAttribute : ActionFilterAttribute
{
    protected static readonly string Key = typeof( ModelStateResultTempDataTransferAttribute).FullName;
}

public class ExportModelStateResultToTempDataAttribute :  ModelStateResultTempDataTransferAttribute
{
    private readonly string successMessage = string.Empty;

    public ExportModelStateResultToTempDataAttribute() { }

    public ExportModelStateResultToTempDataAttribute(string successMessage)    { this.successMessage = successMessage;}
    public override void OnActionExecuted(ActionExecutedContext filterContext)
    {
        //Export if we are redirecting
        if ((filterContext.Result is RedirectResult) || (filterContext.Result is RedirectToRouteResult))
        {
            var modelState = filterContext.Controller.ViewData.ModelState;

            var redirectedModelState = new RedirectedModelState();

            if (!modelState.IsValid)
            {
                redirectedModelState.ModelState = modelState;
            }
            else if (!string.IsNullOrEmpty(successMessage))
            {
                redirectedModelState.SuccessMessage = this.successMessage;
            }

            filterContext.Controller.TempData[Key] = redirectedModelState;
        }

        base.OnActionExecuted(filterContext);
    }
}

public class ImportModelStateResultFromTempDataAttribute :  ModelStateResultTempDataTransferAttribute
{

    public ImportModelStateResultFromTempDataAttribute() { }
            
    public override void OnActionExecuted(ActionExecutedContext filterContext)
    {
        var redirectedModelState = filterContext.Controller.TempData[Key] as RedirectedModelState;

        if (redirectedModelState != null)
        {
            //Only Import if we are viewing
            if (filterContext.Result is ViewResult)
            {
                filterContext.Controller.ViewData.ModelState.Merge(redirectedModelState.ModelState);
                filterContext.Controller.ViewBag.Success = redirectedModelState.SuccessMessage;

            }

            filterContext.Controller.TempData.Remove(Key);
        }

        base.OnActionExecuted(filterContext);
    }
}

[Serializable]
public class RedirectedModelState
{
    public ModelStateDictionary ModelState { get; set; }

    public string SuccessMessage { get; set; }
}

Všimněte si, že se předává ModelState a to pomocí TempData kolekce. V controlleru pak stačilo jen odekorovat metody:

public class Home2Controller : Controller
{
    static List<string> quotes = new List<string>();

    [HttpGet,  ImportModelStateResultFromTempData]
    public ActionResult Index()
    {
        return View(quotes);
    }

    [HttpGet]
    public ActionResult Add()
    {
        return View();
    }

    [HttpPost, ExportModelStateResultToTempData("Saved")]
    public ActionResult Save(string quote)
    {
        if (!string.IsNullOrEmpty(quote) && !quotes.Contains(quote))
        {
            quotes.Add(quote);
        }
        else
        {
            ModelState.AddModelError("", "Is Empty");
        }

        return RedirectToAction("Index");
    }
}

a mírně upravit Index view:

@model IEnumerable<string>

@{
    ViewBag.Title = "Seznam";
}

@if (!string.IsNullOrEmpty(ViewBag.Success))
{
    <p style="background-color:green">uloženo</p>
}

@if (!ViewData.ModelState.IsValid)
{
    <p style="background-color:red">neuloženo</p>
}

<ul>
    @foreach (var quote in Model)
    {
        <li>@quote</li>
    }
</ul>

<p>
    @Html.ActionLink("Přidat", "Add")
</p>

a vše funguje jak má - v případě chyby vidí uživatel chybové hlášení, je informován i o úspěšném uložení a pokud klikne na obnovit, zobrazí se mu znovu  seznam  bez jakýchkoliv hlášení.

Zdrojový kód, pokud si s ním chcete hrát (kopírovat, podívat se) je volně k dispozici na Codeplex serveru: https://prgexample.codeplex.com/SourceControl/latest#PRGexample/PRGexample/Controllers/Home2Controller.cs

Žádné komentáře:

Okomentovat