eWorld.UI - Matt Hawley

Ramblings of Matt

ASP.NET MVC - Localization Helpers

May 16, 2008 15:58 by matthaw
In addition to blogging, I'm also using Twitter. Follow me @matthawley

 

Note: This post has been updated to work with MVC 2 RTM. The example code is a bit smelly (use POCOs, please!) but the localization helpers do correctly work with the WebFormViewEngine.

 

You're localizing your application right? Sure, I bet we ALL are - or at least, we're all storing our strings in resource files so that later we can localize. I know, I don't either :) but that doesn't mean if you're working on a large application that needs to be localized in many different languages, you shouldn't be thinking about it. While localization was possible in 1.0/1.1, ASP.NET 2.0 introduced us to a new expression syntax that made localization much easier, simply writing the following code

   1:  <asp:Label Text="<%$ Resources:Strings, MyGlobalResource %>" runat="server" />
   2:  <asp:Label Text="<%$ Resources:MyLocalResource %>" runat="server" />

Of course, you could always use the verbose way and call out to the HttpContext to get local and global resources, but I really enjoy writing the expression syntax much better as it truly implies that the code knows the context of your view / page. So, you could write both of the above examples like

   1:  <%= HttpContext.Current.GetGlobalResourceString("Strings", "MyGlobalResources",
   2:            CultureInfo.CurrentUICulture) %>
   3:  <%= HttpContext.Current.GetLocalResourceString("~/views/products/create.aspx", 
   4:            "MyLocalResource", CultureInfo.CurrentUICulture) %>

So now, you've started on that next big project and have been given the green light to use ASP.NET MVC, but ... your application needs to be localized in Spanish as well. In the current bits, there's really no way of using localized resources aside from (gasp!) using the Literal server control or the verbose method. But, you're moving to MVC to get away from the web forms model & nomenclature, so those are not an option any longer. Well, taking my earlier example of PRG pattern, I decided to "localize" it in an example of your project. First off, you'll need to create your global and local resources. Add a "App_GlobalResources" folder to the root. Add a Strings.resx file, and start to enter your text. Next, we'll add 2 local resources for our views. Under /Views/Products, create a "App_LocalResources", and 2 .resx files named "Create.aspx.resx" and "Confirm.aspx.resx".

 

Okay, now you're all set. Let's start converting our code to use the resources. You'll see that I'm using a new extension method (code will come later) in both the controller actions and in the view itself.

 

   1:  public class ProductsController : Controller
   2:  {
   3:      public ActionResult Create()
   4:      {
   5:          if (TempData["ErrorMessage"] != null)
   6:          {
   7:              ViewData["ErrorMessage"] = TempData["ErrorMessage"];
   8:              ViewData["Name"] = TempData["Name"];
   9:              ViewData["Price"] = TempData["Price"];
  10:              ViewData["Quantity"] = TempData["Quantity"];
  11:          }
  12:          return RenderView();
  13:      }
  14:   
  15:      public ActionResult Submit()
  16:      {
  17:          string error = null;
  18:          string name = Request.Form["Name"];
  19:          if (string.IsNullOrEmpty(name))
  20:              error = this.Resource("Strings, NameIsEmpty");
  21:   
  22:          decimal price;
  23:          if (!decimal.TryParse(Request.Form["Price"], out price))
  24:              error += this.Resource("Strings, PriceIsEmpty");
  25:   
  26:          int quantity;
  27:          if (!int.TryParse(Request.Form["Quantity"], out quantity))
  28:              error += this.Resource("Strings, QuantityIsEmpty");
  29:   
  30:          if (!string.IsNullOrEmpty(error))
  31:          {
  32:              TempData["ErrorMessage"] = error;
  33:              TempData["Name"] = Request.Form["Name"];
  34:              TempData["Price"] = Request.Form["Price"];
  35:              TempData["Quantity"] = Request.Form["Quantity"];
  36:              return RedirectToAction("Create");
  37:          }
  38:   
  39:          return RedirectToAction("Confirm");
  40:      }
  41:   
  42:      public ActionResult Confirm()
  43:      {
  44:          return RenderView();
  45:      }
  46:  }

Next, convert views over to use the new Resource extension method, below is the Create view:

   1:  <% Html.BeginForm("Submit"); %>
   2:    <% if (!string.IsNullOrEmpty((string)ViewData["ErrorMessage"])) { %>
   3:      <div style="color:red;"><%= ViewData["ErrorMessage"] %></div>
   4:    <% } %>
   5:    <%= Html.Resource("Name") %> <%= Html.TextBox("Name", ViewData["Name"]) %><br />
   6:    <%= Html.Resource("Price") %> <%= Html.TextBox("Price", ViewData["Price"]) %><br />
   7:    <%= Html.Resource("Quantity") %> <%= Html.TextBox("Quantity", ViewData["Quantity"]) %><br />
   8:    <input type="submit" value="<%= Html.Resource("Save") %>" />
   9:  <% Html.EndForm(); %>

Here's the Confirm view:

   1:  <%= Html.Resource("Thanks") %><br /><br />
   2:  <%= Html.Resource("CreateNew", Html.ActionLink<ProductsController>(c => c.Create(), 
   3:                             Html.Resource("ClickHere"))) %>

As you can see, I'm using a mixture of resource expressions both within the controller and view implementation. Here are the main implementations:

   1:  // default global resource
   2:  Html.Resource("GlobalResource, ResourceName")
   3:   
   4:  // global resource with optional arguments for formatting
   5:  Html.Resource("GlobalResource, ResourceName", "foo", "bar")
   6:   
   7:  // default local resource
   8:  Html.Resource("ResourceName")
   9:   
  10:  // local resource with optional arguments for formatting
  11:  Html.Resource("ResourceName", "foo", "bar")

As you can see, it supports both Global Resources and Local Resources. When working within your controller actions, only Global Resources work as we don't have a concept of a "local resource." The implementation for Html.Resource is actually a wrapper around the verbose method I previously mentioned. It does, however, take into consideration the expression syntax and the context of where the code is calling from to smartly determine the correct resource call to make. A gotcha in the codebase is that this code will only work with the WebFormViewEngine out of the box for local resources. The reason for this is that the code needs a way to find the associated virtual path for the view it's currently rendering, which is only available for the WebFormsView. Should you be using another View Engine, you'll have to modify the codebase to use derived IView type to find the virtual path. So, here's the code:

   1:  public static string Resource(this HtmlHelper htmlhelper, string expression, params object[] args)
   2:  {
   3:    string virtualPath = GetVirtualPath(htmlhelper);
   4:    return GetResourceString(htmlhelper.ViewContext.HttpContext, expression, virtualPath, args);
   5:  }
   6:   
   7:  public static string Resource(this Controller controller, string expression, params object[] args)
   8:  {
   9:    return GetResourceString(controller.HttpContext, expression, "~/", args);
  10:  }
  11:   
  12:  private static string GetResourceString(HttpContextBase httpContext, string expression, string virtualPath, object[] args)
  13:  {
  14:    ExpressionBuilderContext context = new ExpressionBuilderContext(virtualPath);
  15:    ResourceExpressionBuilder builder = new ResourceExpressionBuilder();
  16:   ResourceExpressionFields fields = (ResourceExpressionFields)builder.ParseExpression(expression, typeof(string), context);
  17:   
  18:    if (!string.IsNullOrEmpty(fields.ClassKey))
  19:      return string.Format((string)httpContext.GetGlobalResourceObject(fields.ClassKey, fields.ResourceKey, CultureInfo.CurrentUICulture), args);
  20:   
  21:    return string.Format((string)httpContext.GetLocalResourceObject(virtualPath, fields.ResourceKey, CultureInfo.CurrentUICulture), args);
  22:  }
  23:   
  24:  private static string GetVirtualPath(HtmlHelper htmlhelper)
  25:  {
  26:    WebFormView view = htmlhelper.ViewContext.View as WebFormView;
  27:   
  28:    if (view != null)
  29:      return view.ViewPath;
  30:   
  31:    return null;
  32:  }

And just so you know I'm not lying - here's the output in English and Spanish!

English Spanish

Since this example code is so lengthy, I've zipped up the main code to make things much easier for you to bring into your solution.

kick it on DotNetKicks.com



Comments

May 27. 2008 03:39

For the actual translation, maybe I am allowed to do some self-advertising of my (free) translation tool: www.codeproject.com/.../ZetaResourceEditor.aspx

Uwe

May 29. 2008 11:20

Is there any reason you can't use

<%= GetLocalResourceObject("foo").ToString() %>

to obtain local resources? I tested it while looking for a way to get strongly typed access to the resources and it seems to work in an MVC app.

Harry

May 30. 2008 02:58

@Harry - that works too, but the reason I extended it is for the following

1. Verbocity of the method call (I want short calls)
2. The use with other view engines (your example depends upon a view from Page/UserControl)
3. I really like the way you define the expression like <%$ Resource: Foo, Bar %>, my method is agnostic of Global/Local resources because it encapsulates both.

matthaw

May 31. 2008 03:59

what's the best way get/set localization language (culture) as part of URL, not query string parameter?

thanks,
Al

Al

May 31. 2008 05:26

I would recommend passing it in the URL specifically...

/foo/en-US/bar
/foo/es-MX/bar

And your routing can interpret the language and pass it as a parameter (see Scott's latest MVC push for this example). You'll then just have to set the ui culture on the current thread. Another method is to pull the languages from the browser (via http headers) and use the first language as their default ui culture.

matthaw

July 11. 2008 18:00

I think this extension does not work inside masterpage with local resources.

Zygimantas

August 1. 2008 10:42

Hi... nice code! i've included it in a web i'm making.  However it seems to be impossible to include local (view) resources in the controller... correct? In the view code you say <%= Html.Resource("Name") %>, but in the controller, when you do the error checking you pull the error message from a generic strings resource... what i would like to know if it is possible to do something like the following in the controller...

error += String.Format(this.Resource("Strings, GenericRequiredError"), this.View.Resource("Name"));

where GenericRequiredError is a string like "The {0} field is required"...

No idea if my question is clear... I hope so, and thanks for the informative article!

davidinbcn

August 2. 2008 22:38

Please post the source code sample for this. Thanks!

tl

September 7. 2008 22:08

The new MVC Preview 5 has broken this code. The controller no longer has a reference to the view engine, and the view engine no longer has a ViewLocator. I just started looking at preview 5 so I don't have a fix myself yet or I'd post it.

Paul Wideman

September 11. 2008 16:13

Here is an ugly and temporary implementation which worked for me. I found the virtual folder as Preview 5 finds the view itself, just replace the GetVirtualPath(HtmlHelper htmlhelper) as below.

Hopefully this will be replaced with the next releases of the MVC.

private static string GetVirtualPath(HtmlHelper htmlhelper)
        {
            string virtualPath = null;
            Controller controller = htmlhelper.ViewContext.Controller as Controller;

            if (controller != null)
            {
                string controllerName = controller.ToString();
                controllerName = controllerName.Substring(controllerName.LastIndexOf(".") + 1).Replace("Controller", "");
                string viewName = htmlhelper.ViewContext.ViewName;

                string[] viewLocationFormats = new[] {
                                                       "~/Views/{1}/{0}.aspx",
                                                       "~/Views/{1}/{0}.ascx",
                                                       "~/Views/Shared/{0}.aspx",
                                                       "~/Views/Shared/{0}.ascx"
                                                   };

                foreach (string location in viewLocationFormats)
                {
                    virtualPath = location.Replace("{0}", viewName).Replace("{1}", controllerName);
                    if (File.Exists(HttpContext.Current.Server.MapPath(virtualPath)))
                    {
                        break;
                    }
                }
            }

            return virtualPath;
        }

Kemal Eginci

September 27. 2008 10:04

Preview 5 clean GetVirtualPath method:


using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Compilation;
using System.Globalization;

namespace InternationalizationExample.Extensions
{
    /// <summary>
    /// Source: blog.eworldui.net/.../...T-MVC---Localization.aspx
    ///
    /// Adapted by Maarten Balliauw
    /// </summary>
    public static class ResourceExtensions
    {
        public static string Resource(this HtmlHelper htmlhelper, string expression, params object[] args)
        {
            string virtualPath = GetVirtualPath(htmlhelper);

            return GetResourceString(htmlhelper.ViewContext.HttpContext, expression, virtualPath, args);
        }

        public static string Resource(this Controller controller, string expression, params object[] args)
        {
            return GetResourceString(controller.HttpContext, expression, "~/", args);
        }

        private static string GetResourceString(HttpContextBase httpContext, string expression, string virtualPath, object[] args)
        {
            ExpressionBuilderContext context = new ExpressionBuilderContext(virtualPath);
            ResourceExpressionBuilder builder = new ResourceExpressionBuilder();
            ResourceExpressionFields fields = (ResourceExpressionFields)builder.ParseExpression(expression, typeof(string), context);

            if (!string.IsNullOrEmpty(fields.ClassKey))
                return string.Format((string)httpContext.GetGlobalResourceObject(
                    fields.ClassKey,
                    fields.ResourceKey,
                    CultureInfo.CurrentUICulture),
                    args);

            return string.Format((string)httpContext.GetLocalResourceObject(
                virtualPath,
                fields.ResourceKey,
                CultureInfo.CurrentUICulture),
                args);
        }

        private static string GetVirtualPath(HtmlHelper htmlhelper)
        {
            string virtualPath = null;
            Controller controller = htmlhelper.ViewContext.Controller as Controller;

            if (controller != null)
            {
                ViewEngineResult result = FindView(controller.ControllerContext, htmlhelper.ViewContext.ViewName);
                WebFormView webFormView = result.View as WebFormView;

                if (webFormView != null)
                {
                    virtualPath = webFormView.ViewPath;
                }
            }

            return virtualPath;
        }

        private static ViewEngineResult FindView(ControllerContext controllerContext, string viewName)
        {
            // Result
            ViewEngineResult result = null;

            // Search only for WebFormViewEngine
            WebFormViewEngine webFormViewEngine = null;
            foreach (var viewEngine in ViewEngines.Engines)
            {
                webFormViewEngine = viewEngine as WebFormViewEngine;

                if (webFormViewEngine != null)
                    break;
            }

            result = webFormViewEngine.FindView(controllerContext, viewName, "");
            if (result.View == null)
            {
                result = webFormViewEngine.FindPartialView(controllerContext, viewName);
            }

            // Return
            return result;
        }
    }
}

Maarten Balliauw

September 27. 2008 21:16

Why not just use Resources.<ClassName>.<ResourceKey>?

Steve Andrews

October 27. 2008 07:33

Everything works good except for resources which are stored in controls (.ascx files). GetVirtualPath method gets a reference for the aspx page on which a control is stored. How to get the reference to a control without overloading the helper metod like this public static string Resource(this HtmlHelper htmlhelper, string expression, string virtualPath, params object[] args) where virtual path comes directly from the ascx file?

Serg

December 13. 2008 07:47

Actually, this is enough for the GetVirtualPath method to work in Beta 1:

private static string GetVirtualPath(HtmlHelper htmlHelper)
{
  string virtualPath = null;
  WebFormView view = htmlHelper.ViewContext.View as WebFormView;

  if (view != null)
  {
    virtualPath = view.ViewPath;
  }
  return virtualPath;
}

Dan Lewi Harkestad

January 19. 2009 01:27

Like Steve said:

Why not just use Resources.<ClassName>.<ResourceKey>?

I don't really get this. Why would one use error-prone string keys all over the place when you can get compile-time safe versions for the exact same thing? (+ you'll spend a lot less time implementing things like HtmlHelper extension methods)

Gino

February 12. 2009 08:27

@Gino, Steve

Resources.<ClassName>.<ResourceKey> is good, but not all resource keys are known at compile time. For example, I have resources to localize enum types. The key is built at runtime from myEnum.GetType().Name + "_" + myEnum.ToString()...

Dominic

February 12. 2009 08:46

Then why not use Resources.<ClassName>.<ResourceKey> where possible and something like this:

public static string Localize(this HtmlHelper helper, Enum value)
{
   return Localize(helper, value, <defaultResourceManager>);
}
public static string Localize(this HtmlHelper helper, Enum value, ResourceManager resourceManager) { ... }

or even

public static string Localize(this Enum value) {...}

for enums?

Gino

March 11. 2009 00:35

As of ASP.NET MVC RC2, this code does not compile.
It fails on 'controller.ViewEngine' and says 'System.Web.Mvc.Controller' does not contain a definition for 'ViewEngine' and no extension method 'ViewEngine' accepting a first argument of type 'System.Web.Mvc.Controller' could be found (are you missing a using directive or an assembly reference?)'

Avi

March 20. 2009 03:20

Here is my solution of control's(.ascx) resources problem:

private static string GetVirtualPath(HtmlHelper htmlhelper) {
      string virtualPath = null;
      TemplateControl tc = htmlhelper.ViewDataContainer as TemplateControl;

      if (tc != null) {
        virtualPath = tc.AppRelativeVirtualPath;
      }

      return virtualPath;
    }

Omen

March 23. 2009 04:03

Been working on an MVC project which i want it to be multilingual. People really say resources are the best way for multilingual in ASP.NET MVC. Thats have i came to this post. I makes sense to me and i do understand more about resources thanks for the post

Bayram Çelik

April 12. 2009 07:50

NIce,    soooo   1 ?)  while in an English screen,    can the user go to a combo box on the screen choose to see the page in Spanish,   and then have the page rendered in Spanish,   2 ?)   how about changing to spanish and not lossing data?

Fred

April 27. 2009 02:50

Yep, all implementations of GetVirtualPath() here simply do not work in ASP.NET MVC Release 1.

Dmitri

May 17. 2009 22:21

I cannot get this to pick up browser language. I've tried CultureInfo.CurrentCulture but it always reports en-GB or en-US. Am I missing something?

Paul

May 29. 2009 14:20

Something has happened to the download.  When I grab it all the zip contains is 1 file (LocalizationHelpers.cs)

Jordan

June 6. 2009 08:02

Why not just use TemplateControl.GetLocalResourceObject/GetGlobalResourceObject in views or inherit ViewPage/ViewUserControl/ViewMasterPage and implement there something like:

    public string GetLocalResourceString(string key)
    {
      var o = GetLocalResourceObject(key);
      if (o != null)
      {
        return o.ToString();
      }
      return "?" + key + "?";
    }

    public string GetGlobalResourceString(string className, string key)
    {
      var o = GetGlobalResourceObject(className, key);
      if (o != null)
      {
        return o.ToString();
      }
      return "?" + key + "?";
    }

I think it's better than this approach which has major flaws when dealing with partial views and master pages.

Filip Kinsky

June 10. 2009 04:00

The light gray color of the font makes it hard to read... you should consider a better contrast

hugo

June 14. 2009 14:31

Can anyone of you tell me how to use extension in controller. I have tried it in the view and it works fine but not in the controller. In the view we create the resource file as index.aspx.resx but for controller what will be the name of the file and where we need to place the file.

Samoj Bhattarai

July 28. 2009 01:21

has happened to the download. When I grab it all the zip contains is 1 file

evden eve

July 28. 2009 05:29

Have you considered avoiding .Net localization techniques completely?

I am working on a project right now that has language localization in ASP.Net MVC. For this I decided to create a separate DLL that holds the resources in XML format. In the main project I have a special class that handles text retrieval. So all I have to do is call my code like this:

<%=lang.Get("header/about") %>

I find this technique quite easy to use and there's no verbosity.

Cyril Gupta

October 13. 2009 18:32

I made a change that got this working for me with MVC 1.0 and master pages if anyone is interested.

I replaced the code to find the VirtualPath with:

((WebFormView)htmlhelper.ViewContext.View).ViewPath


Great idea with the HTML helpers by the way, this works a charm.

Marshall Jones

Comments are closed

Copyright © 2000 - 2014 , Excentrics World