I ran into an issue recently while working on a web project regarding how journey distances should be displayed. The app I was working on, gets a journey distance in kilometres from a third party service. Given that the app is currently for use within the Great Britain, users would much rather see the distance displayed in miles, but pretty soon, it will be utilised in countries that work in kilometres.
I realised I’d need to write a FormatProvider that enabled the distance to be passed in as the object to be formatted appended with either “miles” or “km” depending on the language of the browser that the site was being viewed in. In essence I was wanting to build a LocalisedDistanceFormatProvider that could be used as so:
double distance = 40;
var strOut = string.Format(new LocalisedDistanceFormatProvider(),"{0}",distance);
console.WriteLine(strOut);
and strOut would be “25 miles” if I was British or American or “40 km” if I was anywhere else. To satisfy the method signature required by the String.Format() method, I realised I’d have to pass in a format string value of “{0}” even though the output format won’t be determined by this value. In fact the format of the output string would be based on the Culture of the executing thread.
Here’s what I wrote.
public class LocalisedDistanceFormatProvider : IFormatProvider, ICustomFormatter
{
#region Implementation of IFormatProvider
public object GetFormat(Type formatType)
{
return formatType == typeof(ICustomFormatter) ? this : null;
}
#endregion
#region Implementation of ICustomFormatter
public string Format(string format, object arg, IFormatProvider formatProvider)
{
if (format != null)
{
throw new FormatException("No formatting string is required");
}
double distanceKm;
if (arg is double)
{
distanceKm = (double)arg;
}
else
{
if (!double.TryParse(arg.ToString(), out distanceKm))
{
throw new FormatException("Incorrect type");
}
}
var s = System.Threading.Thread.CurrentThread.CurrentCulture.Name;
if (s == "en-GB" || s == "en-US")
{
var miles = distanceKm * 0.621371192;
return String.Format("{0} miles", miles.ToString("0"));
}
return String.Format("{0} km", distanceKm.ToString("0"));
}
#endregion
}
There’s a couple of interfaces used here that I normally take for granted but thought I’d dig a little deeper into them to see what they’re all about.
An object that implements IFormatProvider only has one method - “GetFormat”, which acts as a call back is to return an instance of an ICustomFormatter. It is this type that which contains a “Format” method that actually does all the work.
While writing this post, I had to stop and think about why we have to create an IFormatProvider which then returns an ICustomFormatter. I suppose that the task of analysing the CurrentCulture of the thread could have been done in the GetFormat method which could then return a different instance of ICustomFormatter based on whether the value needed converting into miles or not. But it works bundled up in one object and it won’t need to be extended (….don’t quote me on that!)
Testing
There are two ways we can test our new format provider. The first one is via unit tests:
[TestFixture]
public class LocalisedDistanceFormatProviderTests
{
private const double Forty = 40;
private const string Expected40 = "40 km";
private const string Expected25 = "25 miles";
private const string ValidFormat = "{0}";
private const string InValidFormat = "{0:fred}";
[Test]
public void Format40_UICultureOf_frFR_Returns_40km()
{
/*** Arrange ***/
System.Threading.Thread.CurrentThread.CurrentCulture = new CultureInfo("fr-FR");
/*** Act ***/
var actual = string.Format(new LocalisedDistanceFormatProvider(), ValidFormat, Forty);
/*** Assert ***/
Assert.AreEqual(Expected40, actual);
}
[Test]
public void Format40_UICultureOf_enGB_Returns_25miles()
{
/*** Arrange ***/
System.Threading.Thread.CurrentThread.CurrentCulture = new CultureInfo("en-GB");
/*** Act ***/
var actual = string.Format(new LocalisedDistanceFormatProvider(), ValidFormat, Forty);
/*** Assert ***/
Assert.AreEqual(Expected25, actual);
}
[Test]
public void Format40_UICultureOf_enUS_Returns_25miles()
{
/*** Arrange ***/
System.Threading.Thread.CurrentThread.CurrentCulture = new CultureInfo("en-US");
/*** Act ***/
var actual = string.Format(new LocalisedDistanceFormatProvider(), ValidFormat, Forty);
/*** Assert ***/
Assert.AreEqual(Expected25, actual);
}
[Test]
[ExpectedException(typeof(FormatException))]
public void InvalidFormatUsed_Throws_FormatException()
{
/*** Arrange ***/
System.Threading.Thread.CurrentThread.CurrentCulture = new CultureInfo("en-US");
/*** Act ***/
string.Format(new LocalisedDistanceFormatProvider(), InValidFormat, Forty);
}
[Test]
[ExpectedException(typeof(FormatException))]
public void CalledOnNonNumericeType_Throws_FormatException()
{
/*** Arrange ***/
System.Threading.Thread.CurrentThread.CurrentCulture = new CultureInfo("en-US");
/*** Act ***/
string.Format(new LocalisedDistanceFormatProvider(), ValidFormat, "fred");
}
}
The second way is to see the code in action along with how we can combine it with some more localised content from resource files.
NB: I’ve localised (!) this demo for GB and France. Other locales are available…
- Fire up Visual Studio and create a new ASP.NET MVC3 Web application (If you haven’t got ASP.NET MVC3 yet, you can download it from here)
- When prompted, select “Internet Application”, using Razor as the view engine.
- In solution explorer, add a folder to the root of the application called _Code.
- Add a new class to the _Code folder, called LocalisedDistanceFormatProvider.cs and copy the first chunk of code displayed above, into it.
- Now we need to add some locale specific text, so right click on the project icon in the solution explorer.
- Select Add > Add ASP.NET Folder > App_GlobalResources.
- The Global Resources folder should now be visible within the project in the solution explorer.
- Right click on App_GlobalResources and Select Add > New Item > Resources File
- Name the file Resource.resx
- Now add another resx file named Resource.FR.resx
- In each resource file, add a string value called JourneyDistance.
- In Resource.resx, set the value to be “The journey distance is”
- In Resource.FR.resx set the value to be “La distance voyage est à”
So now we should have something like this:
What we’ve done so far is to define a global resource that can be accessed throughout our site. Resource.resx is our default or invariant culture version of the resource file. While the Resource.fr.resx version is the French language version.
If we wanted to localise the app event further, we could add locale versions of the resource files. For instance, French is spoken in many places other than just France. Take Canada for instance. If I wanted to have a resource file for French spoken in France and French spoken in Canada I would add the following files:
- Resouce.fr-FR.resx
- Resouce.fr-CA.resx
This example however, only requires a neutral language resource file.
In our web application, the server will look at the Accept-Language field in the header of the HTTP request, to see what language the browser wants the page to be displayed in. An .NET class called the ResourceManager will look for a matching resource file for the language requested. If such a file exists, the values will be read from it. If the file doesn’t exist the value will be read from the invariant (default) file.
To programmatically read the value of JourneyDistance from our resource file, we can use the following syntax:
var localisedMessage = Resources.Resource.JourneyDistance;
This functionality is not enabled out of the box, so we must enable the globalisation* functionality by adding the following line into the system.web section of our web.config:
<globalization culture="auto" uiCulture="auto" />
Setting the culture attribute to auto sets the CurrentThread.CurrentCulture.Name value to equal the preferred language value in the request header. The culture of a thread determines the specific calendar, currency and string fomatting rules for that particular language. Setting uiCulture to auto sets the CurrentThread.CurrentUICulture.Name value to equal this value. The uiCulture is used by the ResourceManager to locate the relevant resource files.
Open the HomeController in our project and replace the index method with the following:
public ActionResult Index()
{
const int DistanceInKm = 40;
var localisedDistance = string.Format(new LocalisedDistanceFormatProvider(), "{0}", DistanceInKm);
var localisedMessage = Resources.Resource.JourneyDistance;
ViewBag.Message = String.Format("{0} {1}", localisedMessage, localisedDistance);
return View();
}
As you can see in this example, we’re combining a localised message with a localised distance value and writing it out to the ViewBag.Message field. Using my browser with the default language set up to be English, I get the following (I enabled the fire bug Net panel so you can see the Accept-Language field in the request header)
I then went into my browsers settings and added French to my language list and moved it to the top of the list so it became the preferred language.
Refreshing the page then gave me this…
…and as the French would say… “Voila!”
Further Reading
- MSDN - ASP.NET Web Page Resources Overview
- MSDN - IFormatProvider Interface
- MSDN - ICustomFormatter Interface
* I’m British, and I’m going to spell it properly! (My blog, my rules)