Developer

Localizing A ASP.Net Web Application (the "Better" Way)

Jan, 2017

In a previous post I discussed the challenges and solutions to localizing a cross platform Xamarin Application, and how to work around the massive issues introduced by Windows Phone 8.1 and Windows 10 store applications.

The Microsoft solution is 80% awesome and 20% hugely painful, due to the introduction of RESW files for Windows Store Apps. Our application QONQR has a large backend cloud infrastructure and some of the application content comes from the server. For example, there are many events that could occur on the server, such as system notifications, that need to match the user's language preferences. It isn't very helpful to show a Hungarian user a notice that they received a gift, if that notice is in English.

So how does your server know what language the end user wants to see? Browsers will send a list of supported languages the user has setup they can read in the Request Header under the key "Accept-Language". Here is an example value you might find:

                    
de, en-gb;q=0.8, en;q=0.7
				
				

You can lookup the full format, but it is comma separated list of languages, optionally followed by a "weighting". In this case German, English Great Brittan, and English are the 3 language preferences this user has selected in their browser settings, in order of preference.

I recommend you use the same header key, and set this value on your API requests from your mobile app. In QONQR, we allow players to select their language in the application, from a list of languages we support (currently 7), and default the selection to whatever language their phone is using. We default to English in the situations where we do not support the language running on the phone.

On the server you can then look for this Request Header and find the header request that is best. Because you use the same Header key for both Web and API requests, you can use the exact same code regardless if you are processing a request for a web page from a web browser, or a request on your API from your mobile app.

Your application may not have any of the languages requested. In those cases you will want to default to your default language, for us that is English. This is the code we use to determine the language for localization.

				
private string GetLanguage()
{
	try
    {
        var languageCookie = HttpContext.Current.Request.Cookies["lang"];
        var userLanguages = HttpContext.Current.Request.UserLanguages;

        if (languageCookie != null)
        {
            return languageCookie.Value;
        }
        else if (userLanguages != null && userLanguages.Length > 0)
        {
            //use the language the browser says to use
            var supported = Qonqr.Server.Helpers.LanguageHelper.SupportedLanguages.Where(x => !x.PreviewOnly).ToList();
            foreach (var lang in userLanguages) //loop through user preferences
            {
                string matchLang = lang;
                if (matchLang.Length > 1)
                {
                    matchLang = matchLang.Substring(0, 2);
                }
                if (supported.Where(x => x.Value.Equals(matchLang, StringComparison.InvariantCultureIgnoreCase)).Count() > 0)
                {
                    return matchLang;
                }
            }
        }
    }
    catch (Exception exc)
    {
        if (System.Diagnostics.Debugger.IsAttached) { System.Diagnostics.Debugger.Break(); }
    }
    return "en";
}
				
				

There are a few important things to note about this code.

We are using a cookie to track the player's reference for all web browser requests. We default to the browser language preference, but we also have the ability on our website to allow the user to pick a different language (stored in a cookie). This is especially important for people who may be on vacation and using a public or friend's computer in a foreign country, or if a player encounter's a translations that doesn't make sense, and wants to review the content in another language they know. This is critical in situations where you may have crowdsourced your translations, or used a machine translator.

Also worth noting, QONQR only currently supports the base language (the 2 digit code such as "en") and not a regional dialect. In the future we may support "en-us" and "en-gb", and this code would need to be modified to look for both the root language and regional dialect preferences.

Finally, QONQR supports "preview" languages. These are languages that are in progress. In our online web preferences, users can go into advanced options and choose a language that might have translation "in-progress" as their preferred language. For example, French users may prefer a badly machine translated version of French, instead of English. This can be tremendously helpful if you are crowdsourcing your translation, allowing reviewers early access to the localized site before it is really ready for everyone.

For those that are concerned with the try/catch block, we will often use this technique on non-critical code. We would never want to fail the request with a HTTP 500, if this code failed, but we add a code breakpoint to catch issues, if something were to happen while debugging. In this way, we gracefully failover to the default value if something goes wrong, but also alert the developer to an issue while debugging if an issue where to present itself in the future.

Once you have the language preference that works for both your user and your server code you need to hold on to it. It is up to you how to do that. For QONQR, we have decided to store it in the Request Context.

The Request Context is similar to Server Session, but only lives for that one request. You could choose to store it in the users session, or someplace more permanent and ultimately you will need to decide what best fits your programming model and scalability/performance needs. We have built these two methods as part of a "RequestContextHelper" class which we will discuss further later. The RequestContextHelper is part of the ASP project, so that it has access to the HTTP Request object.

				
private void Store(string key, T item)
{
    try
    {
        HttpContext.Current.Items[key] = item;
    }
    catch (Exception exc)
    {
        if (System.Diagnostics.Debugger.IsAttached) { System.Diagnostics.Debugger.Break(); }
    }
}

internal static T Get(string key) 
    try
    {
        //items lasts for the durration of the request
        object stored = HttpContext.Current.Items[key];
        if (stored == null)
        {
            return default(T);
        }
        else
        {
            return (T)stored;  //if can't cast, exception trap will return null
        }
    }
    catch (Exception exc)
    {
        return default(T);
    }
}

public void Init()
{
	string lang = GetLanguage();
	this.Store("selected_culture", lang);
}
				
				

From wherever you decide to get the language out of the Request Header, you can call Init() on the RequestContextHelper to load the language out of the header and put in the context. For us we have action filters on every controller that log and validated all authenticated and unauthenticated requests. Since this happens before our controllers run, this is the ideal place to initialize the RequestContextHelper.

Now that you have the language, and you can access it anytime you want, it is time to talk about Microsoft out-of-the-box localization. Thankfully, ASP.net still uses REXS files instead of RESW files, as is used by Windows Store Apps. So we can avoid the mess that we experienced in the last blog post, however in a drastically short sighted approach to localization, Microsoft has made another mistake.

Out of the box you have two choices for how to change your language from your default language to the user's preferred language, for example, switching the website or API from English to German.

The first option is to do what you did to set the language in your Xamarin App. Where "WebStrings" in this example is the name of your RESX file.

				
WebStrings.Culture = new System.Globalization.CultureInfo(lang);
				
				

At first glance most developers would be happy with this, do a little testing, perhaps switch their browser or app from English to German and see that the correct text is returned. It won't be until you have multiple testers running at the same time in multiple languages that you will see the problem. In many environments, it is easy to see how this mistake could make it to production.

WebStrings.Culture IS STATIC!!!!!!!! YOU JUST CHANGE THE LANGUAGE TO GERMAN FOR EVERYONE!

Obviously if your website or API has more than one user accessing it at the same time, you can't use this solution.

The alternate solution for switching language is to set the thread culture:

				
Thread.CurrentThread.CurrentCulture  = new System.Globalization.CultureInfo(lang);
Thread.CurrentThread.CurrentUICulture = new System.Globalization.CultureInfo(lang);
				
				

If you do some searching you'll find that this is the "recommended" solution. However, I will say this is a terrible idea. Changing the culture of the thread, just to get a different string is insane overkill. Here is why.

As "good" programmers, we isolate our code. We build libraries that can be reused. For example, let's say we have code that stores values in Redis cache. It is a simple key value pair fast storage. Let's say that we are tracking a players latitude and longitude and we store this string in Redis. For a user called "batman"

				
Key: "batman_latitude"  value = "23.294"
				
				

Makes sense right. We put our double in the cache using a .ToString() on our double variable and pull it out using a .Parse() method. As developers we have done this a hundred thousand times. What could possibly go wrong?

German could go wrong, that's what. If you switch your thread culture to German, and parse this value, the result with be Latitude = 23294;

WAT????

That's right. In Germany the decimal separator is the comma and the thousands separator is the period. String.Parse() parsed the value in German culture.

As "backend" developers we could never tolerate this. This breaks every rule of isolation. How the webpage displays should have absolutely no bearing on how my code works to store data. And yet, the recommended solution does exactly that. We would demand that our front end developers must format their numbers in the model that backs the view (MVC) and never place formatting requirements on the database developers. Number formatting belongs in the UI!

But… in the same breadth, we might have core business logic that may return an error messages that the user's balance is too low. It is very reasonable to simply create an error object and populate the text "Balance too low". The business logic doesn't want to know which language was in the browser preferences. We want to be able to access the correct localized string though the generated RESX code like this.

				
WebStrings.BalanceTooLowWarning
				
				

Sadly, we can't get automatic localized strings in the business logic, without also breaking all number and date parsing.

The Answer

The answer is a convoluted mess of brokers and generated code. First, the QONQR solution works such that all localized text is in a shared library that can be used from the web site, API, and offline worker role in Azure that processes some data and sends notifications and messages. Centralizing your localization increases the complexity of the initial setup, but makes shared business logic so much simpler, especially when you are managing several translations file from translators all over the world.

First, let's assume as .Net developers we like strongly typed code. We try to avoid sprinkling "magic strings" all over our code and we would like to avoid code like this.

				
                    WebStrings.ResourceManager.GetString("BalanceTooLowWarning", culture)
				
				

This is the only way we can get a string in German (culture = "de") without setting it for everyone using the static method, or setting it for the whole thread, which we've discussed is a really bad idea. As a result we need to generate a new accessor file, just like we did for Xamarin in the previous blog post.

This file is very similar to the one we made for Xamarin, but not exactly the same. Create a "ResxToServerAccessorClass.txt" file in the root of your solution (or anywhere you like).

				
<#@ output extension=".cs" #>
<#@ template language="C#" hostSpecific="true" #>
<#@ assembly name="System.Core" #>
<#@ assembly name="System.Xml" #>
<#@ assembly name="System.Xml.Linq" #>
<#@ import namespace="System" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ import namespace="System.Diagnostics" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Xml.Linq" #>
<#@ import namespace="System.Xml.XPath" #>
<#@ import namespace="System.Text.RegularExpressions" #>
<#@ import namespace="System.Globalization" #><#+

public void Process(string resxRelativePath, string namespaceName, string className, string localizationStringMethod)
{
	int pos = resxRelativePath.LastIndexOf("/");
	string resxClassName = resxRelativePath.Substring(pos + 1).Replace(".resx", "");

    WriteLine("using System;");
    WriteLine("namespace " + namespaceName);
    WriteLine("{");
    WriteLine("////////////////////////////////////////THIS IS A GENERATED FILE. NO DOT MODIFY.////////////////////////////////////////////");
    WriteLine("\tpublic static class " + className + " {");
	WriteLine("");

    //throw new Exception(Directory.GetCurrentDirectory());
    XDocument document;
    try
    {
        document = XDocument.Load(resxRelativePath);
    }
    catch (FileNotFoundException)
    {
        WriteLine("<!-- File not found: " + resxRelativePath + " -->");
        WriteLine("}}");
        return;
    }

    IEnumerable<XElement> dataElements = document.XPathSelectElements("//root/data");

    foreach (XElement element in dataElements)
    {
        string elementName = element.Attribute("name").Value;
        string elementValue;

        XElement valueElement = element.Element("value");

        if (valueElement != null)
        {
            elementValue = valueElement.Value;
        }
        else
        {
            continue;
        }

        elementValue = elementValue.Replace("\"", "\\\"");

        WriteLine(string.Format("\t\tpublic static string {0}", elementName));
        WriteLine("\t\t{");
        WriteLine("\t\t\tget");
        WriteLine("\t\t\t{");
        WriteLine("\t\t\t\tstring val = null;");
        WriteLine("\t\t\t\ttry");
        WriteLine("\t\t\t\t{");
		WriteLine(string.Format("\t\t\t\t\t\tval = " + localizationStringMethod + "(\"{0}\");", elementName));

        WriteLine("\t\t\t\t} catch (Exception exc) {");
        WriteLine("\t\t\t\t\tif (System.Diagnostics.Debugger.IsAttached){ System.Diagnostics.Debug.WriteLine(\"LOCALIZE ERROR: \" + exc.Message); }");
        WriteLine("\t\t\t\t}");
        WriteLine("\t\t\t\tif (string.IsNullOrEmpty(val))");
        WriteLine("\t\t\t\t{");
        WriteLine(string.Format("\t\t\t\t\tval = \"{0}\".ToUpper(); //Uppercase will be a clue that localization is broken, but allow app to keep working", elementValue));
        WriteLine("\t\t\t\t}");
        WriteLine("\t\t\t\treturn val;");
        WriteLine("\t\t\t}");
        WriteLine("\t\t}");
    }

    WriteLine("\t}\r\n}");
}
#>
				
				

Then, anywhere in your project where you would like to drop a generated localization accessor, create a T4 template with the name that suits you. Example "WebStrings.tt"

				
<#@ include file="..\..\ResxToServerAccessorClass.txt" #>
<#@ template language="C#" hostSpecific="true" #>
<#
	Process(Path.GetDirectoryName(Host.TemplateFile) + "../../../Qonqr.Server.Localization/WebAndApiStrings.resx", "Qonqr.Server.Core.Resources", "WebStrings", "Qonqr.Server.Core.Factories.FactoryBroker.RequestContext.GetWebString");
#>
				
				

Note: in this example you'll want to change the namespace and path to your txt file you created earlier, as well as the few other parameters. More on this in a bit.

The T4 template will create a file that is full of accessor methods like this.

				
public static string MessagingPermanentlySuspendedForAbuse
{
	get
	{
		string val = null;
		try
		{
			val = Qonqr.Server.Core.Factories.FactoryBroker.RequestContextHelper.GetWebString("MessagingPermanentlySuspendedForAbuse");
		} catch (Exception exc) {
			if (System.Diagnostics.Debugger.IsAttached){ System.Diagnostics.Debug.WriteLine("LOCALIZE ERROR: " + exc.Message); }
		}
		if (string.IsNullOrEmpty(val))
		{
			val = "Your messaging privileges have been permanently suspended for abuse".ToUpper(); //Uppercase will be a clue that localization is broken, but allow app to keep working
		}
		return val;
	}
}
				
				

If you have read the previous blog post, the reasoning for some of this code will be familiar, so I will not cover that again. However note, that this generated file is not going directly to the RESX file to get the string. Instead it is going through the RequestContectHelper we discussed at the top of the article. Why?

RequestContextHelper knows what language the user wants, it tracks the language per request. The localized strings need to be accessible from the business logic, which is shared code. It is "deep" in the application, and needs to "reach up" to the web (MVC) code.

At this point, you might say this should be done with IOC and Dependency Injections. That is true, if you use IOC, definitely do that. It doesn't really matter for the purposes of this article how you get access to the class that keeps track of the language. In this case we use DI to push the RequestContextHelper from the MVC project, down into a custom Factory Broker in the middle tier (re-used accross web/api/worker projects). I did not include that factory code here to avoid imposing architectural design decisions on the reader. A word of caution if you are using IOC, you may want to verify your IOC framework is not creating dozens or hundreds of instances of the helper class, one for every string you need to localize in the application, especially if you are in a highly scalable environment.

In your RequestContextHelper class you can add a small method called GetWebString()

				
private string _language = null;
public string GetWebString(string key)
{

    if (string.IsNullOrEmpty(_language))
    {
        _language = this.Get("selected_culture");
    }
    CultureInfo culture = new CultureInfo(_language);
    return WebAndApiStrings.ResourceManager.GetString(key, culture);

}
				
				

Note: the accessor class we generated from the T4 template has Try/Catch statements, so this class does not need one. If something fails in this method, the accessor will default to English in all uppercase. Uppercase to indicate to the developer or QA person that something went wrong, but still allow the application to function. Also note, this file accesses a resource file called WebAndApiStrings.resx. WebStrings.resx was renamed to WebAndApiStrings.resx so our TT file could build a replacement WebStrings.cs class. Therefore, no other code that previously accessed "WebStrings.SomeAppText" had to change.

Finally, every time the resx file is modified, right click on the T4 (WebStrings.tt) file and click "Run Custom Tool"

In Review

  • A class in the web (MVC) project maintains the language the user wants on the request context.
  • Localization needs never change the expected behavior of business logic or data access (number and date parsing).
  • Shared business logic can get localized strings when needed with automatic language selection
  • Use IOC or Factory Brokers to give shared core business logic access to the class that retains the UI culture
  • Have all access to RESX files go through the Request Context manager to ensure proper language is used.
  • Use generated (T4) classes to eliminate "magic strings" and keep "hard references" to strings.
  • You will need to figure out how to expose the RequestContextHelper in your MVC project to lower level projects in your solution, if your web application is not one big project. Dependency Injection with either an IOC Framework, or creating a custom Factory Broker (Service Broker) are just two options. Those architectural decisions are beyond the scope of this article.

Best of Luck.