středa 11. února 2015

Validace v ASP.NET potřetí a globálně - 3

Lokalizace cest

Při lokalizaci webových aplikací se zapomíná no možnost lokalizace cest. Nedivím se, ono je to už trochu složitější a náročnější, ale přeci jen je to něco, co často chybí k úplné lokalizaci aplikace.

Pro zjednodušení předpokládám, že příchozí request je zpracováván v kultuře dle klienta, tedy volajícího (lze si nastavit v browseru a dále je uvedeno i jak.


Pro ASP.NET lze takové pravidlo nastavit jednoduše v configu, tedy v souboru v cestě configuration/system.web:


<globalization enableClientBasedCulture="true" culture="auto:en-US" uiCulture="auto:en" resourceProviderFactoryType="MvcSimplyCleverPart3.MvcResourceProviderFactory, MvcSimplyCleverPart3"/>

Výchozí vzor pro cesty

Výchozí vzor používaný v MVC vypadá takto:

{controller}/{action}/{id}

a MVC používá následující výchozí hodnoty

  • controller - Home
  • action - Index
  • Id - nepovinny
Tenhle vzor a  všechny standardně definované cesty jsou zpracovávany objektem MvcRouteHandler - ten využije hodnoty vložené do requestContext.RouteData["Controller"], aby nalezl vhodný controller.

Takto podobně vypadá standardní definice routování v MVC:

public static void RegisterRoutes(RouteCollection routes)
{
    routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

    routes.MapRoute(
        name: "Default",
        url: "{controller}/{action}/{id}",
        defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
    );
}

Pokud si chceme lokalizovat i cesty, musíme použít vlastní třídu pro definici cest a vlastní handler, výše uvedený kód se tedy změní na:

public static void RegisterLocalizableRoutes(RouteCollection routes)
{
    routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

    routes.Add(new LocalizableRoute("{controller}/{action}/{id}", new LocalizableControllerNameMvcRouteHandler())
    {
        Defaults = new RouteValueDictionary(new { controller = "Home", action = "Index", id = UrlParameter.Optional })
    });
}

Poznámka: důvod pro použíti LocalizableRoute je vysvětlen níže v článku

Aby dával vlastní MvcRouteHandler smysl (všimněte si, že v přizpůsobeném kódě je použit LocalizableControllerNameMvcRouteHandler) , musíme nějak zajistit překlad cest - pro co nejjednoduší implementaci budu tedy předpokládate, že všechny v aplikaci používané cesty budou vycházed z výše uvedeného vzoru  {controller}/{action} a aby byl návrh co nejjednodušší, uložím lokalizované názvy controlleru a metod do jazykových resource souborů a pomocí atributu určím každému controlleru a metodě v jakém resource a pod jakým klíčem má vyhledat překlad. Resource soubor tedy vypadá nějak takto:



A nyní  popíši jednotlivé atributy, potřebné pro prosazení lokalizovaných názvů.

Atribut pro lokalizace názvů controllerů a metod

Úlohou tohoto atributu je vrátit název lokalizovaný název controleru a nebo jeho metody. Tento lokalizovaný název vyhledá v resource - a to dle jazyka, :

public class LocalizableControllerNameAttribute : Attribute
{
    public LocalizableControllerNameAttribute(Type resourceType, string name)
    {
        if (string.IsNullOrEmpty(name))
        {
            throw new ArgumentException("Cannot be empty or null", "name");
        }

        if (resourceType == null)
        {
            throw new ArgumentNullException("resourceType");
        }

        this.Name = name;
        this.ResourceType = resourceType;
    }

    public virtual string LocalizedControllerName
    {
        get
        {
            return GetPropertyValue(this.ResourceType, this.Name);
        }
    }

    public string Name { get; private set; }

    public Type ResourceType { get; private set; }

    private static string GetPropertyValue(Type type, string propertyName)
    {
        PropertyInfo propertyInfo = type.GetProperty(propertyName,
            BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public);
        if (propertyInfo == null)
        {
            return null;
        }

        return (string)propertyInfo.GetValue(null, new object[] { });
    }
}

Pro lokalizace metod pak podědíme z atributu ActionNameSelector:

[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public sealed class LocalizableActionNameAttribute : ActionNameSelectorAttribute
{
    public LocalizableActionNameAttribute(Type resourceType, string name)
    {
        if (string.IsNullOrEmpty(name))
        {
            throw new ArgumentException("Cannot be null or empty", "name");
        }

        if (resourceType == null)
        {
            throw new ArgumentNullException("resourceType");
        }

        this.Name = name;
        this.ResourceType = resourceType;
    }

    public override bool IsValidName(ControllerContext controllerContext, string actionName, MethodInfo methodInfo)
    {
        return string.Equals(actionName, this.LocalizedActionName, StringComparison.OrdinalIgnoreCase);
    }

    public string Name { get; private set; }

    public Type ResourceType { get; private set; }

    public string LocalizedActionName
    {
        get
        {
            return GetPropertyValue(this.ResourceType, this.Name);
        }
    }

    private static string GetPropertyValue(Type type, string propertyName)
    {
        PropertyInfo propertyInfo = type.GetProperty(propertyName,
            BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public);
        if (propertyInfo == null)
        {
            return null;
        }

        return (string) propertyInfo.GetValue(null, new object[] { });
    }
}

Vlastní RouteHandler - LocalizableControllerNameMvcRouteHandler

Ten vychází ze standardního MvcRouteHandleru. Ale před tím, než je zavolán, tak se pokusí vyhledat Controller, který má lokalizovaný název jenž odpovídá přijaté hodnotě z requestu.

Pokud tedy přišel požadavek http://adresaaplikace/Kontakty z prohlížeče, nastaveného na CZ, tak sedíky nastavenému routování nastaví hodnota v requestContext.RouteData.Values["Controller"] na Kontakty a zavol se náš LocalizableControllerNamemvcRouteHandler, respektive jeho metoda GetHttpHandler.

V ní se vyhledá controller, u něhož metoda LocalizedControllerName atributu LocalizableControllerName vrátí hodnotu Kontakty.  

Název takto nalezeného controlleru  pak  nahradí původní hodnotu uloženou  v requestContext.RouteData.Values["Controller"] - v našem případě je tedy hodnota Kontakty nahrazena hodnotou Contact

Nalezení metody

V jednom z předchozích příspěvků byl zachycen i postup pří výběru metody kontroleru:

ale zde se používalo ActionMethodSelectorAttribute.

Výběr metody ale ve skutečnosti probíhá ve dvou fázích, v té první se vyberou všechny dostupné a možné metody, v druhé se pak vybere vlastní metoda.

V té první fázi se používá mimo jinéi ActionNameSelectorAttribute, respektive jeho metoda IsValidName. Ve druhé fázi se pak vybere jen jedna metoda a tam se uplatní i ten výše zachycený ActionMethodSelectorAttribute.

Zde je tedy použit atribut v první fázi a ten funguje podobně jako v případě controlleru - pokud se lokalizovaný název metody shoduje s hledaným názvem, tak je takto odekorovaná metoda vybrána a dále zpracovávaná.

Kód controlleru tak za uplatnění všeho předchozího vypadá takto:

[LocalizableControllerName(typeof(SimplyCleverResources), "ControllerContact")]
public class ContactController : Controller
{

    [SetDefaultValue]
    [LocalizableActionName(typeof(SimplyCleverResources), "ControllerContactIndex")]
    public ActionResult Index()
    {
        return View("Index");
    }

    [HttpPost]
    [LocalizableActionName(typeof(SimplyCleverResources), "ControllerContactSave")]
    public ActionResult Save(AddressViewModel address)
    {
        if (!ModelState.IsValid)
            return View("Index", address);

        CountryProvider.Save(address.Cast());
        var list = CountryProvider.GetUserAddresses().Cast();

        return View("List", list);
    }

    [LocalizableActionName(typeof(SimplyCleverResources), "ControllerContactList")]
    public ActionResult List()
    {
        var list = CountryProvider.GetUserAddresses().Cast();
        return View("list",list);
    }
}

Asi je to vše příliš složitě popsáné, takže tady je stručný seznam změn:


  1. Nastavení web.config tak, aby se request zpracovával dle nastavení prohlížeče (zkrátka pokud je prohlížeč nastaven na češtinu, tak v cs-CZ)
  2. Vložení položek do resource tak, abychom měli pojmenování pro každý controller a metodu
  3. Použití atributů pro lokalizované controllery a atributy
  4.  Definice vlastních routů, které budou zpracovávány vlastním handlerem

Výstupní cesty

Takto je ale zpracováno příchozích požadavků, ale každá aplikace si pak vyrábí vlastní linky, typicky takové view může vypadat takto:

<div class="header">
    <ul id="menu">
        <li>@Html.ActionLink(SimplyCleverResources.ContactLinkAdd, "Index", "Contact")</li>
        <li>@Html.ActionLink(SimplyCleverResources.ContactLinkList, "List", "Contact")</li>
        
    </ul>
</div>


Takto vytvořené linky by měly být také lokalizovány, tj. asi by nemělo moc cenu vstupovat do aplikace voláním cesty Kontakty/Výchozí, když například první klikatelný link na úvodní stránce by změnil hodnotu na anglické Contact/List.

Proto při registraci routy (viz ukázka na začátku stránky) používám vlastní třídu  LocalizableRoute - ta dědí od Route, ale přetěžuji její metodu GetVirtualPath - což je právě metoda, která se stará o generování odchozích linků (tedy těch co se generují ve view jako v předchozí ukázce kódu):

public class LocalizableRoute : Route
 {
     public LocalizableRoute(string url, IRouteHandler routeHandler)
         : base(url, routeHandler)
     {
     }

     public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
     {
         if (values.ContainsKey("controller"))
         {
             string fallbackControllerName = values["controller"].ToString();
             string localizedControllerName = this.GetLocalizedControllerName(fallbackControllerName);

             if (!String.IsNullOrEmpty(localizedControllerName))
             {
                 values["Controller"] = this.GetLocalizedControllerName(fallbackControllerName);

                 if (values.ContainsKey("action"))
                 {
                     string fallbackActionName = values["action"].ToString();
                     values["action"] = this.GetLocalizedActionName(fallbackControllerName, fallbackActionName);
                 }
             }
         }

         return base.GetVirtualPath(requestContext, values);
     }

     private object GetLocalizedActionName(string fallbackControllerName, string fallbackActionName)
     {
         Type controllerType = (from t in Assembly.GetExecutingAssembly().GetTypes()
                                where t.Name.EndsWith("Controller", StringComparison.OrdinalIgnoreCase) &&
                                TypeUtilities.InheritsFrom(t, typeof(Controller)) &&
                                t.Name == fallbackControllerName + "Controller"
                                select t).FirstOrDefault();

         if (controllerType == null)
         {
             // There is no controller that matches this fallback name.
             return null;
         }

         // TODO: Find a better approach to matching ambiguous method names (i.e. one for GET
         // and one for POST) than simply picking the first match.
         MethodInfo methodInfo = (from mi in controllerType.GetMethods()
                                  where mi.Name == fallbackActionName
                                  select mi).FirstOrDefault();
         if (methodInfo == null)
         {
             return null;
         }

         return GetLocalizedActionName(methodInfo, fallbackActionName);
     }

     private string GetLocalizedActionName(MethodInfo methodInfo, string fallbackActionName)
     {
         object[] localizableActionNameAttributes = methodInfo.GetCustomAttributes(typeof(LocalizableActionNameAttribute), true);
         if (localizableActionNameAttributes.GetLength(0) == 0)
         {
             // this method does not have a localizable action name attribute
             return fallbackActionName;
         }

         LocalizableActionNameAttribute localizableActionNameAttribute = (LocalizableActionNameAttribute)localizableActionNameAttributes[0];
         return localizableActionNameAttribute.LocalizedActionName;
     }

     private string GetLocalizedControllerName(string fallbackControllerName)
     {
         Type controllerType = (from t in Assembly.GetExecutingAssembly().GetTypes()
                                where t.Name.EndsWith("Controller", StringComparison.OrdinalIgnoreCase) &&
                                TypeUtilities.InheritsFrom(t, typeof(Controller)) &&
                                t.Name == fallbackControllerName + "Controller"
                                select t).FirstOrDefault();

         if (controllerType == null)
         {
             // There is no controller that matches this fallback name.
             // Proceed as normal using the original controller name.
             return fallbackControllerName;
         }

         return GetLocalizedControllerName(controllerType, fallbackControllerName);
     }

     private string GetLocalizedControllerName(Type controllerType, string fallbackControllerName)
     {
         object[] localizableControllerNameAttributes = controllerType.GetCustomAttributes(typeof(LocalizableControllerNameAttribute), true);
         if (localizableControllerNameAttributes.GetLength(0) == 0)
         {
             // this type does not have a localizable controller name attribute
             return fallbackControllerName;
         }

         LocalizableControllerNameAttribute localizableControllerNameAttribute = (LocalizableControllerNameAttribute)localizableControllerNameAttributes[0];
         return localizableControllerNameAttribute.LocalizedControllerName;
     }
 }

Poznámka - další metodu, kterou můžeme přetížit, je GetRouteData - a můžeme si tak zjednodušit routování příchozích požadavků. Je to další způsob, jak řešit, co je popsáno v tomto článku. MVC je prostě hodně otevřený systém, který si lze vhodně přiohýbat.

A jak to funguje?

A je to, aplikace se chová takto - pokud je prohlížeč nastaven na češtinu, tak musí být adresa zadána česky a na oplátku je i formulář zobrazen v češtině a stejně tak i cesty  pod odkazy:


Pokud se změní nastavení jazyka, tedy například v Chrome se jde do Nastavení, Rozšířených nastavení a klikne se na Nastavení jazyků:

a angličtina se nastaví jako první jazyk:

tak musí být adresa zadána anglicky a i odkazy pod linky jsou anglicky (a políčka formuláře pochopitelně také):

Žádné komentáře:

Okomentovat