Table of Contents
Introduction
Over a year ago, I wrote a two part article on creating multilingual web applications in ASP.NET. The first article focused on a custom resource manager which solved a lot of problems associated with the built-in functionality, as well as a set of custom server controls which made creating multilingual websites painless. The second article covered a number of issues, including URL rewriting, data model design and enhanced custom server controls. This third part won't focus on the fundamental but rather enhancements to what we've already covered. Some of the features, notably the first, will greatly increase the complexity of our solution. If you don't need it, I recommend you don't implement it - none of the other features require it.
Readers who haven't read the first two parts of this series will be utterly left out, so go read them now! (Part 1, Part 2)
Database Driven
The first and most significant change will be to modify the ResourceManager
to support a variety of sources for localized content. Making the ResourceManager
support SQL Server had always been intended from the start, but in the end, a simpler codebase was chosen. In the projects I'm involved with, XML files are still my preference, but your project might have good reasons to use something else.
Provider Factory Pattern
The approach we'll use to make our ResourceManager
agnostic of the underlying storage mechanism is the Provider Factory Pattern. This pattern is at the center of many ASP.NET 2.0 features, so you should get familiar with it. Without going into too much detail, the Provider Factory Pattern works by using an abstract class to publish an interface and relies on other classes to implement the functionality. In other words, we'll still have our ResourceManager
class which will be a shell as well as a ResourceManagerXml
class and a ResourceManagerSql
class. The design pattern is powerful because it allows third parties to create their own implementation. Anyone could create a new class called ResourceManagerAccess
which inherits from ResourceManager
and implements the logic needed to work with an Access database. You can configure which implementation you'll use via the web.config. The code we'll go over should give you a good hands-on feel for the design pattern, but if you are interested in knowing more, make sure to visit MSDN.
ResourceManager and ResourceManagerXml
The first step is to turn our ResourceManager
into an abstract class which defines what members our providers (those that implement the actual logic) will have to implement.
public abstract class ResourceManager
{
protected abstract string RetrieveString(string key);
}
It'd be nice to reuse the name GetString
, but since we want to keep our ResourceManager
compatible with the previous version, we must use a new name. If you are unfamiliar with the abstract
keyword on a member, it simply means that any classes which inherit from ResourceManager
must implement the RetrieveString
function. Note also that if any member of a class is abstract
, the class itself must be marked as abstract
. This means you can't create a new instance of the class. What would happen if you created a new instance of ResourceManager
and then tried to call the unimplemented RetrieveString
function? Having a class that you can't create an instance of might seem like a waste of bytes, but let's see how it's actually a solid use of OO design. We now create our ResourceManagerXml
class, which is XML-aware.
public class ResourceManagerXml: ResourceManager
{
protected override string RetrieveString(string key)
{
NameValueCollection messages = GetResource();
if (messages[key] == null)
{
messages[key] = string.Empty;
#if DEBUG
throw new ApplicationException("Resource value not found for key: " + key);
#endif
}
return messages[key];
}
}
As you can see, all we've done is create a layer of abstraction and move the GetString
functionality from the previous version of the ResourceManager
here. We've also moved all supporting functions out of the ResourceManager
and into the ResourceManagerXml
class, namely the private functions GetResource
and LoadResource
(not shown here).
Since ResourceManagerXml
inherits from ResourceManager
, we can cast it to ResourceManager
, much like a string
can be cast to an object
. This is where we tie the two classes together. Inside the ResourceManager
class, we create a property called Instance
:
public abstract class ResourceManager
{
internal static ResourceManager Instance
{
get { return new ResourceManagerXml(); }
}
public static string GetString(string key)
{
return Instance.RetrieveKey(key);
}
protected abstract string RetrieveString(string key);
}
As is hopefully clear, GetString
now calls RetrieveString
of the ResourceManagerXml
class, through the Instance
method. If we now create a ResourceManagerSql
and make the Instance
property return an instance of it, the call to RetrieveString
will be handled by the SQL implementation. To be truly useful, our ResourceManager
's implementation is slightly more complicated. It dynamically creates the child class based on values in the web.config. That means if you want to switch from XML to SQL Server, you don't need to change the class and recompile, but simply change values in the configuration file. Here's the actual implementation of the relevant code:
public abstract class ResourceManager
{
private static ResourceManager instance = null;
static ResourceManager()
{
Provider provider = LocalizationConfiguration.GetConfig().Provider;
Type type = Type.GetType(provider.Type);
if (type == null)
{
throw new ApplicationException(string.Format("Couldn't" +
" load type: {0}", provider.Type));
}
object[] arguments = new object[] {provider.Parameters};
instance = (ResourceManager) Activator.CreateInstance(type, arguments);
}
internal static ResourceManager Instance
{
get { return instance; }
}
}
The static
construct (which is automatically private and guaranteed by .NET to be called only once when any member of the ResourceManager
is first called) gets a provider from the configuration, tries to get the type and instantiates the object. The Type.GetType
method is able to take a string in the form or Namespace.ClassName, AssemblyName and create a Type
object, which can then be instantiated with the Activator.CreateInstance
. There's a performance penalty to this type of dynamic invocation, so we store the instance in a private static variable which will be used throughout the lifetime of the application (in other words, the expensive code only fires once). We won't go over the changes made to the LocalizationConfiguration
to support the provider (the downloadable code is well documented), but it basically supports the following type of data in our web.config:
<Localization
...
providerName="XmlLocalizationProvider"
>
<Provider>
<add
name="XmlLocalizationProvider"
type="Localization.ResourceManagerXml, Localization"
languageFilePath="c:\inetpub\wwwroot\localizedSample\Language"
/>
<add
name="SqlLocalizationProvider"
type="Localization.ResourceManagerSql, Localization"
connectionString="Data Source=(local);Initial
Catalog=DATABASE;User Id=sa;Password=PASSWORD;"
/>
</Provider>
</Localization>
Multiple providers are supported, but only the one specified by the providerName
will be loaded. The name
and the type
attributes of each provider is used to load the actual instance, all other attributes are passed to the constructor of the provider as a NameValueCollection
. We'll see an example of this next, when we take a look at our ResourceManagerSql
.
ResourceManagerSql
The framework is now in place to use any technology for storing localized content. Creating one to work with SQL Server requires only three steps: creating our database model, the ResourceManagerSql
class and the necessary stored procedure. The model we'll use is similar to the approach for normalizing content discussed in Part 2 of this series. A simpler, less normalized model could also be adopted. Also, we've specified a value of 1024 characters, but we could pick a NVARCHAR
up to 4196 or even a Text
field. It would even be possible to use a VARCHAR
and Text
column and pull from one or the other (not the prettiest design, but it might be necessary and practical).
Next we create the ResourceManagerSql
class. This class is very similar to the XML one, except that the LoadResources
method interacts with SQL Server via the System.Data
classes rather than XML files. We start with the constructor which, as we saw in the previous section, is dynamically called and passed a NameValueCollection
:
private string connectionString;
private int cacheDuration;
public ResourceManagerSql(NameValueCollection parameters)
{
if (parameters == null || parameters["connectionString"] == null)
{
throw new ApplicationException("ResourceManagerSql" +
" requires connectionString attribute in configuraiton.");
}
connectionString = parameters["connectionString"];
if (parameters["cacheDuration"] != null)
{
cacheDuration = Convert.ToInt32(parameters["cacheDuration"]);
}
else
{
cacheDuration = 30;
}
}
No magic is happening here, read the mandatory connectionString
parameter (or throw an exception if there isn't one) and read the optional cacheDuration
parameter or use a default value.
Since our class inherits from ResourceManager
, it must implement the RetrieveString
method. This method is identical to the XML equivalent:
protected override string RetrieveString(string key)
{
NameValueCollection messages = GetResources();
if (messages[key] == null)
{
messages[key] = string.Empty;
#if DEBUG
throw new ApplicationException("Resource value" +
" not found for key: " + key);
#endif
}
return messages[key];
}
The real difference happens in the GetResources
and LoadResources
methods:
private NameValueCollection GetResources()
{
string currentCulture = ResourceManager.CurrentCultureName;
string defaultCulture = LocalizationConfiguration.GetConfig().DefaultCultureName;
string cacheKey = "SQLLocalization:" + defaultCulture + ':' + currentCulture;
NameValueCollection resources = (NameValueCollection) HttpRuntime.Cache[cacheKey];
if (resources == null)
{
resources = LoadResources(defaultCulture, currentCulture);
HttpRuntime.Cache.Insert(cacheKey, resources, null,
DateTime.Now.AddMinutes(cacheDuration),
Cache.NoSlidingExpiration);
}
return resources;
}
private NameValueCollection LoadResources(string defaultCulture,
string currentCulture)
{
SqlConnection connection = null;
SqlCommand command = null;
SqlDataReader reader = null;
NameValueCollection resources = new NameValueCollection();
try
{
connection = new SqlConnection(connectionString);
command = new SqlCommand("LoadResources", connection);
command.CommandType = CommandType.StoredProcedure;
command.Parameters.Add("@DefaultCulture",
SqlDbType.Char,5).Value = defaultCulture;
command.Parameters.Add("@CurrentCulture",
SqlDbType.Char,5).Value = currentCulture;
connection.Open();
reader = command.ExecuteReader(CommandBehavior.SingleResult);
int nameOrdinal = reader.GetOrdinal("Name");
int valueOrdinal = reader.GetOrdinal("Value");
while(reader.Read())
{
resources.Add(reader.GetString(nameOrdinal),
reader.GetString(valueOrdinal));
}
}
finally
{
if (connection != null)
{
connection.Dispose();
}
if (command != null)
{
command.Dispose();
}
if (reader != null && !reader.IsClosed)
{
reader.Close();
}
}
return resources;
}
This should be similar to any SQL code you've written before. The main difference with this approach and the XML one is that we've pushed the fallback logic to the stored procedure. This helps reduce calls to the database. Also, since we can't add a FileDependency
for our cache, we set an absolute expiration time, changeable via the web.config.
Finally, all that's left is the LoadResources
stored procedure:
CREATE PROCEDURE LoadResources
(
@DefaultCulture CHAR(5),
@CurrentCulture CHAR(5)
)
AS
SET NOCOUNT ON
SELECT R.[Name], RL.[Value]
FROM Resources R
INNER JOIN ResourcesLocale RL ON R.Id = RL.ResourceId
INNER JOIN Culture C ON RL.CultureId = C.CultureId
WHERE C.Culture = @CurrentCulture
UNION ALL
SELECT R.[Name], RL.[Value]
FROM Resources R
INNER JOIN ResourcesLocale RL ON R.Id = RL.ResourceId
INNER JOIN Culture C ON RL.CultureId = C.CultureId
WHERE C.Culture = @DefaultCulture
AND R.[Name] NOT IN (
SELECT [Name] FROM Resources R2
INNER JOIN ResourcesLocale RL2 ON R2.Id = RL2.ResourceId
INNER JOIN Culture C2 ON RL2.CultureId = C2.CultureId
WHERE C2.Culture = @CurrentCulture
)
SET NOCOUNT OFF
The stored procedure is made a little more complex than might seem necessary. However, our data model is well normalized, meaning we need multiple JOIN
s and we decided to push the fallback logic to the stored procedure.
Final Considerations
There's some shared code between the XML and SQL implementations. This functionality could be pushed into the abstract ResourceManager
. For example, the RetrieveString
method (which is identical in both cases) could be placed in ResourceManager
and instead the GetResource
method could be abstract
. Similarly, caching could be implemented in the ResourceManager
rather than in each implementation. However, you can never tell how a specific provider will be implemented, and I'd hate to make an assumption that would make a provider difficult to develop. For example, just because the SQL and XML providers make use of a NameValueCollection
, doesn't mean an Oracle one would.
Something else to keep in mind is that the interface of ResourceManager
is well defined, but the internal implementation is totally private. This means you can make changes and tweaks as you see fit without having to worry about breaking existing code. The provider model itself promotes good programming practices that you should try to emulate, when appropriate, in your own code.
Image Support
The next feature we'll add is support for localizing images. This is a useful demonstration of how to localize a group of content. An image has three typical values that need to be localized, the height
, width
and alt
tags. Height and width might not make any sense, but images of words will often be of different length depending on the culture. The solution I've seen for this in the past is to reuse the GetString
method and a custom naming convention. For example, I've often seen:
img.Alt = ResourceManager.GetString("Image_Welcome_Alt");
img.Width = Convert.ToInt32(ResourceManager.GetString("Image_welcome_Width));
img.Height = Convert.ToInt32(ResourceManager.GetString("Image_Welcome_Height"));
This solution is both inelegant and error prone. Instead we'll build a GetImage
method which returns a LocalizedImageData
. LocalizedImageData
is a simple class that contains all our localized properties:
public class LocalizedImageData
{
private int width;
private int height;
private string alt;
public int Width
{
get { return width; }
set { width = value; }
}
public int Height
{
get { return height; }
set { height = value; }
}
public string Alt
{
get { return alt; }
set { alt = value; }
}
public LocalizedImageData()
{
}
public LocalizedImageData(int width,
int height, string alt)
{
this.width = width;
this.height = height;
this.alt = alt;
}
}
Now our GetImage
function has something to return - a class that groups all the localized content. We implement the GetImage
function in the ResourceManager
. Like the newest GetString
we covered in the first section of this article, it'll rely on an abstract RetrieveImage
function which each provider will have to implement. We'll only cover the XML implementation in this article, but the downloadable code also has it implemented in the SQL class.
First we create the GetImage
function in ResourceManager
:
public static LocalizedImageData GetImage(string key)
{
return Instance.RetrieveImage(key);
}
Next we create the abstract RetrieveImage
function:
protected abstract LocalizedImageData RetrieveImage(string key);
Finally, we implement RetrieveImage
in the ResourceManagerXml
class:
protected override LocalizedImageData RetrieveImage(string key)
{
Hashtable imageData = GetImages();
if (imageData[key] == null)
{
imageData[key] = new LocalizedImageData(0,0,string.Empty);
#if DEBUG
throw new ApplicationException("Resource value not found for key: " + key);
#endif
}
return (LocalizedImageData) imageData[key];
}
We go over this code quickly because it's almost identical to the GetString
method. Instead of calling LoadResources
however, GetImages
is called. This is where the code starts to get a little different. We could have used the same XML file to store a new type of data, but decided a different file might help keep things clean. The main difference happens in the parsing of the XML file:
private void LoadImage(Hashtable resource, string culture, string cacheKey)
{
string file = string.Format("{0}\\{1}\\Images.xml", fileName, culture);
XmlDocument xml = new XmlDocument();
xml.Load(file);
foreach (XmlNode n in xml.SelectSingleNode("Images"))
{
if (n.NodeType != XmlNodeType.Comment)
{
LocalizedImageData data = new LocalizedImageData();
data.Alt = n.InnerText;
data.Height = Convert.ToInt32(n.Attributes["height"].Value);
data.Width = Convert.ToInt32(n.Attributes["width"].Value);
resource[n.Attributes["name"].Value] = data;
}
}
HttpRuntime.Cache.Insert(cacheKey, resource,
new CacheDependency(file),
DateTime.MaxValue, TimeSpan.Zero);
}
Since the XML structure is a little more complex, there's more work to be done in the LoadImages
function, but in reality, it's all pretty straightforward. The XML file we use looks something like:
<Images>
<item name="Canada" width="10" height="10">Canada!</item>
...
...
</Images>
Finally, the last step is to create our localized controls:
public class LocalizedHtmlImage : HtmlImage, ILocalized
{
private const string imageUrlFormat = "{0}/{1}/{2}";
private string key;
private bool colon = false;
public bool Colon
{
get { return colon; }
set { colon = value; }
}
public string Key
{
get { return key; }
set { key = value; }
}
protected override void Render(HtmlTextWriter writer)
{
LocalizedImageData data = ResourceManager.GetImage(key);
if (data != null)
{
base.Src = string.Format(imageUrlFormat,
LocalizationConfiguration.GetConfig().ImagePath,
ResourceManager.CurrentCultureName, base.Src);
base.Width = data.Width;
base.Height = data.Height;
base.Alt = data.Alt;
}
if (colon)
{
base.Alt += ResourceManager.Colon;
}
base.Render(writer);
}
}
Like all localized controls, our class implements the ILocalized
interface which defines the two properties Key
and Colon
. Rather than calling GetString
in the Render
method however, we call GetImage
and set the appropriate image values based on the returned LocalizedImageData
class. Finally, note that the src
of the image is also localized. Basically, if you specify src="Welcome.gif"
, it'll be turned into src="/images/en-CA/welcome.gif"
assuming your web.config specified "/images/" as the ImagePath
and en-Ca as the current culture.
We went through the image localization exercise rather quickly. This is in large part due to the similarity with the existing code. Almost all other groups of localized data can be done the same way. For example, you could localize emails (subject and body) using the same approach.
JavaScript Localization
We've done a good job of providing all the necessary tools one would need to provide a rich multilingual experience to our users. One aspect of our UI still needs to have some localization capabilities: JavaScript. With the growing popularity of AJAX, JavaScript's role will only grow. Even the most common JavaScript validation needs to be localized. We need to build some functionality directly into the client.
Our design will be to try and emulate in JavaScript what we've already built server-side. Ideally, we want to be able to do ResourceManager.GetString(XYZ);
in JavaScript and get the localized value. One solution would be to use AJAX, but that might be too intensive for many applications. Instead, we'll create a couple utility functions which dump localized content into a JavaScript array. We'll wrap the array in a JavaScript object which exposes a GetString
method. Thanks to some unique features of JavaScript, the code is surprisingly compact. The downside is that the entire localized content won't be available, rather we'll have to specify which values we want available during Page_Load
. First we'll look at the JavaScript methods:
var ResourceManager = new RM();
function RM()
{
this.list = new Array();
};
RM.prototype.AddString = function(key, value)
{
this.list[key] = value;
};
RM.prototype.GetString = function(key)
{
var result = this.list[key];
for (var i = 1; i < arguments.length; ++i)
{
result = result.replace("{" + (i-1) + "}", arguments[i]);
}
return result;
};
If you aren't familiar with JavaScript objects, the above code might seem a little odd. Basically we create a new instance of the RM
class (client-side), which just contains an array. Next we create two members, AddString
and GetString
. You may not know this, but JavaScript arrays don't need to be indexed by integers. Rather, they can be associative (like Hashtable
). This is obviously fundamental to how our client-side ResourceManager
works. If we add a value to an array based on a key, we can easily retrieve the value via that same key. The above GetString
function also supports placeholders (something we worked hard at to achieve in Part 2). JavaScript allows a dynamic amount of parameters to be passed into a function. GetString()
assumes the first parameter is the key of the resource to get, and all subsequent parameters to be placeholder values. For example, to use the "InvalidEmail" resource of "{0} is not a valid email", we'd do:
ResourceManager.GetString("InvalidEmail", email.value);
We'll use a little server-side utility function to dump localized content into the client-side array:
public static void RegisterLocaleResource(string[] keys)
{
if (keys == null || keys.Length == 0)
{
return;
}
Page page = HttpContext.Current.Handler as Page;
if (page == null)
{
throw new InvalidOperationException("RegisterResourceManager" +
" must be called from within a page");
}
StringBuilder sb = new StringBuilder("<script language="\""JavaScript\">");
sb.Append(Environment.NewLine);
foreach (string key in keys)
{
sb.Append("ResourceManager.AddString('");
sb.Append(PrepareStringForJavaScript(key));
sb.Append("', '");
sb.Append(PrepareStringForJavaScript(ResourceManager.GetString(key)));
sb.Append("');");
sb.Append(Environment.NewLine);
}
sb.Append("</script>");
page.RegisterStartupScript("RM:" + string.Join(":", keys), sb.ToString());
}
The function is called with one or more keys. But instead of returning a localized value, the value is added to the client-side ResourceManager
. In other words, before we can use the InvalidEmail
resource, we need to call ResourceManager.RegisterLocaleResource("InvalidEmail");
. Again, we can pass multiple values if we want, such as ResourceManager.RegisterLocaleResource("InvalidEmail", "InvalidUsername", "Success");
. You can call RegisterLocaleResource
multiple times as well. This is ideal if your user controls require specific localized content.
LocalizedNoParametersLiteral Haunts Me!
Nothing we've done so far should break existing code. However, in Part 2 we created a LocalizedNoParametersLiteral
server control. This was a mistake - to be honest I don't know what I was thinking. The downloadable sample renames LocalizedNoParametersLiteral
to LocalizedLiteral
and LocalizedLiteral
is now LocalizedLabel
. If necessary, you can rename them to their old names to avoid broken code, but it's something that I just had to fix this time around.
Conclusion
In this part we covered three major enhancements:
- The Provider Model and SQL capabilities,
- The built-in image support, and
- The JavaScript functionality.
In addition to changing the names of the the LocalizedNoParametersLiteral
and LocalizdLiteral
around, the downloadable sample has a few other minor code changes. These changes should have no impact on your existing code as they are merely minor improvements.
Aside from being an actual useful library, it's my hope that the Creating Multilingual Websites series made use of strong design practices that you'll be able to make use of in your own code.