Table of Contents
I'd like to thank Jean-Claude Manoli for developing his C# Code format,
which Ii used in writing this tutorial.
Introduction
Developing websites to support multiple languages can be a challenging and
time-consuming process. With standard HTML pages, this involves creating and
maintaining duplicate versions of each page for each supported language as well
as having the language content embedded into the HTML, where content can’t
easily be edited. While the process improved slightly with the introduction of
scripting technologies such as ASP and PHP, no significant development or
maintenance time was saved. For those of you who have to develop multi-lingual
interfaces and applications, you’ll be glad to know that ASP.NET makes things
considerably easier.
ASP.NET and the .NET framework ship with support for multilingual
applications, namely in the form of Resource Files, the CultureInfo
class, and the System.Globalization and
System.Resources.ResourceManager namespaces. Unfortunately, in its
present state, localizing content in ASP.NET applications is still a tedious
process. Like everything else in .NET though, the object model and sheer power
available makes extending what's already available and developing new
functionality to support better localization easy as 1 - 2 - 3.
In this first part, we'll develop a custom resource manager which avoids the
limitation of .NET Assembly Resource Files as well as extend a number of classes
to easily support localization. In the second part, we’ll spend more time
talking about creating multilingual applications, specifically looking at
database implementations and techniques.
By the end of this tutorial, you should be able to create multilingual
applications with a minimum of work and maintenance, and be able to easily add
new languages to it later on.
Before we get started
If you aren't familiar with localization in .NET, don't worry. This tutorial
mostly skips what's available in .NET and talks about alternatives to make the
job easier. There are a couple of core principals you should know though. (Even
if you don't know the basics, you can skip this section and download the simple application I've created to
showcase the core functionality, playing with it will probably be the best way
to understand).
Basics
The way localization works in .NET is fairly straightforward. Content is
stored in pretty simple XML files called Resource Files. You create a Resource
File for each supported language (more can be added later on). When the
application is compiled, the resource files are embedded into assemblies - the
default resource file is embedded in the main assembly (.dll file);
language-specific resource files are embedded into their own assemblies called
satellite assemblies. Resource files are pretty simple and look a lot like a
hashtable, they have a name and a value - the name is the same for all resource
files, and the value is a language specific translation of some content. In
essence, this allows you to use the
System.Resources.ResourceManager class to do things like:
1: UserNameLabel.Text = myResourceManager.GetString("Username");
2: UserNameValidator.ErrorMessage =
myResourceManager.GetString("RequiredUsername");
The resource manager will automatically load the right resource file based on
the current thread's CurrentCulture value - more on this in the
next section. Hopefully, you are already seeing a lot of potential. Some of the
key highlights are:
- Content is separated into simple XML files
- There's a separate XML file for each supported language
- The code to load values is relatively simple and short
- The
ResourceManager class automatically retrieves the content
from the right XML file based on the thread's CurrentCulture value
- You can easily have 1 actual page, for N supported languages.
Cultures
It's important to have a good understanding of Cultures since our new code
will make use of them - specifically the
System.Globalization.CultureInfo class, and the culture name value
which follows the RFC 1766 naming standard. Basically, you create a new
CultureInfo instance by specifying the culture name in the
constructor:
1: CultureInfo c = new CultureInfo("en-US");
2: CultureInfo c = new CultureInfo("en-AU");
3: Cultureinfo c = new CultureInfo("he-IL");
Once you have a CultureInfo instance, you can set the current
thread's UIculture to it, which will make your
ResourceManager in the above code automatically fetch the content
from the right XML resource file.
1: CultureInfo c = new CultureInfo("en-US");
2: System.Threading.Thread.CurrentThread.CurrentCulture = c;
3: System.Threading.Thread.CurrentThread.CurrentUICulture = c;
In part 2, we'll discuss ways to figure out which culture to load, but for
now, it can be as simple as passing a code in the QueryString. For example, when
lang=f is present, the French Canadian culture should be used. The other
key factor is where to do all of this. The simplest and most logical place is in
the Global.Asax's Begin_Request.
Download dummy application
The best way to understand the basics is to play with some code. I've created
an extremely basic VB.NET web application to demonstrate the basic principles.
Download it and play with it. Look at the structure of the 3 resource files, the
codebehind for index.aspx, and the code in global.asax. Download sample - 16.6
Kb.
Why not use what's available as-is?
While it's certainly possible to develop a multilingual application with the
tools provided with ASP.NET, there are a number of limitations which make the
task less than streamlined. Some of the key problems are:
- Resource files are embedded into [satellite] assemblies
- Resource files can't return strongly-typed objects
- Web controls aren't easily hooked with resource files
While the list might seem small, the above three issues can be quite serious
- with the first being the worst. For example, since resource files are embedded
into assemblies, it's very difficult to ship a product which provides the client
with the flexibility to change the content - a feature offered by many products.
At my previous job, every time the translation department wanted to change some
text, we'd need to recompile the entire application, stop 20 web servers, and
copy the .dll into the bin folder - a frustrating process.
Building a better Resource Manager
Our first task is to build a better Resource Manager which won't cause our
Resource Files to be embedded into assemblies. This will allow Resource Files to
be easily edited in a production or client environment. Our core functionality
will be located in three functions:
- The public method
GetString which is used throughout the
application to access the required resource.
- The private method
GetResource which gets a
HashTable either from the cache or by calling
LoadResource.
- The private method
LoadResource which parses the XML file and
stores it into the cache.
GetString()
1: public static string GetString( string key) {
2: Hashtable messages = GetResource();
3: if (messages[key] == null){
4: messages[key] = string.Empty;
5: #if DEBUG
6: throw new ApplicationException("Resource" +
" value not found for key: " + key);
7: #endif
8: }
9: return (string)messages[key];
10: }
The method accepts a single argument, the key of the resource we want to get.
It then retrieves a HashTable of content using
GetResource, which is culture-aware and returns us the correct
HashTable. If the requested key doesn’t exist, we’ll throw an
exception if the application is in DEBUG mode, else we’ll simply return an empty
string.
GetResource()
1: private static Hashtable GetResource() {
2: string currentCulture = CurrentCultureName;
3: string defaultCulture =
LocalizationConfiguration.GetConfig().DefaultCultureName;
4: string cacheKey = "Localization:" +
defaultCulture + ':' + currentCulture;
5: if (HttpRuntime.Cache[cacheKey] == null){
6: Hashtable resource = new Hashtable();
7:
8: LoadResource(resource, defaultCulture, cacheKey);
9: if (defaultCulture != currentCulture){
10: try{
11: LoadResource(resource, currentCulture, cacheKey);
12: }catch (FileNotFoundException){}
13: }
14: }
15: return (Hashtable)HttpRuntime.Cache[cacheKey];
16: }
The GetResource() method is slightly more complicated. Its goal
is to retrieve a HashTable which can be looked up by a key to
retrieve a value. The method will first look to see if the
HashTable has already been loaded and cached [line: 5]. If
so, it simply returns the value from the cache. Otherwise, it will use
LoadResource() to parse the appropriate XML file [lines:
6-13]. Something worthy of noting is that the "appropriate XML file" is
actually a mix of the XML file for the current culture as well as the one for
the default culture. The default culture is specified in the configuration file
[line: 3], and the current culture is retrieved from the current thread's
current culture [line: 2].
First, the default culture is loaded [line: 8], and then the current
culture is loaded [line: 11]. This means if a key is defined in both XML
files (which most should be), the default value will be overridden by the
culture-specific value. But if it doesn’t exist in the culture-specific value,
the default value will be used.
LoadResource()
1: private static void LoadResource(Hashtable resource,
string culture, string cacheKey) {
2: string file =
LocalizationConfiguration.GetConfig().LanguageFilePath +
'\\' + culture + "\\Resource.xml";
3: XmlDocument xml = new XmlDocument();
4: xml.Load(file);
5: foreach (XmlNode n in xml.SelectSingleNode("Resource")) {
6: if (n.NodeType != XmlNodeType.Comment){
7: resource[n.Attributes["name"].Value] = n.InnerText;
8: }
9: }
10: HttpRuntime.Cache.Insert(cacheKey, resource,
new CacheDependency(file), DateTime.MaxValue, TimeSpan.Zero);
11: }
LoadResource loads the XML file [line: 4] (it gets the
root path from our configuration file [line: 2]) and simply parses it
while loading the values into our HashTable [line: 5 - 9].
Finally, the HashTable is stored in the Cache [line:
10].
Other enhancements
Wrappers
There are a number of minor enhancements which can be done to our Resource
Manager class. For example, I build bilingual webpages in English and French.
Annoyingly, in English, a colon is always glued to the word it follows, but in
French there has to be a space. For example:
Username: Nom d'utilisateur : //French
This means, the colon needs to be localized. Instead of using the
GetString() method, we can simply build a wrapper:
1: public static string Colon {
2: get { return GetString("colon"); }
3: }
In our English resource file, the colon would simply be ':', while in the
French one, it would have a space ' :'.
Strongly-typed resources
The reason we use HashTable instead of a
NameValueCollection is because the Resource Manager class can be
expanded to return strongly-typed objects. For example, you might have localized
help content which is more than just a single value. It might have a title, an
example, and the help text. While exploring this is beyond the scope of this
article (perhaps a part 3??), the capability exists.
Localized Controls
Our next goal is to make our life easier when developing a website by
expanding existing server controls (literals, labels, buttons) to be
localization-aware. We begin by creating a very simple interface our new
controls will implement.
ILocalized
1: public interface ILocalized{
2: string Key {get; set; }
3: bool Colon {get; set; }
4: }
ILocalized defines a Key property which will be
passed to our ResourceManager's GetString() method. In
order to show how you can expand these classes to fit your own needs, I've also
included a Colon property as a boolean, which will tell our
controls if they should append a colon at the end of their value.
LocalizedLiteral
1: public class LocalizedLiteral : Literal, ILocalized {
2: #region fields and properties
3: private string key;
4: private bool colon = false;
5:
6: public bool Colon {
7: get { return colon; }
8: set { colon = value; }
9: }
10:
11: public string Key {
12: get { return key; }
13: set { key = value; }
14: }
15: #endregion
16:
17:
18: protected override void Render(HtmlTextWriter writer) {
19: base.Text = ResourceManager.GetString(key);
20: if (colon){
21: base.Text += ResourceManager.Colon;
22: }
23: base.Render(writer);
24: }
25: }
The first web control that we'll look at making localization-aware is the
oft-used System.Web.UI.WebControls.Literal. First, we make our
class inherit from the Literal control and inherit our
ILocalized interface [line: 1]. Next, we implement the
Key and Colon properties as defined in the
ILocalized interface [line: 3 - 14]. Finally, we override
the Render method of our base Literal class and use
the ResourceManager's GetString() method and
Colon property to fully localize our control [line: 19 -
22]. Don't forget to call the base class' Render() method
afterwards to let it work its magic [line: 23].
Rinse, wash and repeater
You can copy and paste the same code over and over again and simply change
the name of the class and what it inherits from; for example, let's do a
localized button:
1: public class LocalizedButton : Button, ILocalized {
2:
3: #region Fields and Properties
4: private string key;
5: private bool colon = false;
6:
7: public string Key {
8: get { return key; }
9: set { key = value; }
10: }
11:
12: public bool Colon {
13: get { return colon; }
14: set { colon = value; }
15: }
16:
17: #endregion
18:
19: protected override void Render(HtmlTextWriter writer) {
20: base.Text = ResourceManager.GetString(key);
21: if (colon){
22: base.Text += ResourceManager.Colon;
23: }
24: base.Render(writer);
25: }
26: }
Notice that only the two bolded words have changed.
When desired, you can expand the functionality. For example, it isn't
uncommon to have a LinkButton which pops up a JavaScript
confirmation box when deleting something. We can easily achieve this by creating
a 2nd key property:
1: using System.Web.UI;
2: using System.Web.UI.WebControls;
3:
4: namespace Localization {
5: public class LocalizedLinkButton : LinkButton, ILocalized {
6: #region Fields and Properties
7: private string key;
8: private bool colon;
9: private string confirmKey;
10:
11: public string ConfirmKey {
12: get { return confirmKey; }
13: set { confirmKey = value; }
14: }
15: public string Key {
16: get { return key; }
17: set { key = value; }
18: }
19: public bool Colon {
20: get { return colon; }
21: set { colon = value; }
22: }
23: #endregion
24:
25: protected override void Render(HtmlTextWriter writer) {
26: if(key != null){
27: Text = ResourceManager.GetString(key);
28: if (colon) {
29: Text += ResourceManager.Colon;
30: }
31: }
32: if (confirmKey != null) {
33: Attributes.Add("onClick", "return confirm('" +
ResourceManager.GetString(confirmKey).Replace("'",
"\'") + "');");
34: }
35:
36: base.Render(writer);
37: }
38:
39: }
40: }
Using Localized Controls
You use the localized controls like any other server control. First, register
the control on the page:
1: <%@ Register TagPrefix="Localized"
Namespace="Localization" Assembly="Localization" %>
Then, without having to write any code, you can simply add the control either
by drag and dropping it in the designer, or in the HTML mode by typing:
1: <Localized:LocalizedLiteral id="passwordLabel"
runat="server" Key="password" Colon="True" />
2: <Localized:LocalizedButton id="login"
runat="server" colon="false" Key="login" />
Download
The best thing to do now is to play a bit with some code. I've again created
a sample site (similar to the previous one), but this time using our new
Resource Manager class and Localized controls. You might need to change the
web.config's languageFilePath property to point to the right
folder. Download sample site
code - 30.6 Kb.
Contact
Karl Seguin – karlseguin@hotmail.com - 8/14/2004.