|
|||||||||||||||||||||||
|
|||||||||||||||||||||||
|
Announcements
Chapters
Services
Feature Zones
|
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 DrivenThe first and most significant change will be to modify the Provider Factory PatternThe approach we'll use to make our ResourceManager and ResourceManagerXmlThe first step is to turn our public abstract class ResourceManager
{
protected abstract string RetrieveString(string key);
}
It'd be nice to reuse the name 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 Since 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, 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 <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 ResourceManagerSqlThe 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
Next we create the 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"];
//load the optional cacheDuration parameter,
//else we'll cache for 30 minutes
if (parameters["cacheDuration"] != null)
{
cacheDuration = Convert.ToInt32(parameters["cacheDuration"]);
}
else
{
cacheDuration = 30;
}
}
No magic is happening here, read the mandatory Since our class inherits from 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 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 Finally, all that's left is the 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 Final ConsiderationsThere's some shared code between the XML and SQL implementations. This functionality could be pushed into the abstract Something else to keep in mind is that the interface of Image SupportThe 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 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 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 First we create the public static LocalizedImageData GetImage(string key)
{
return Instance.RetrieveImage(key);
}
Next we create the abstract protected abstract LocalizedImageData RetrieveImage(string key);
Finally, we implement 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 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 <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 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 LocalizationWe'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 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 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 LocalizedNoParametersLiteral Haunts Me!Nothing we've done so far should break existing code. However, in Part 2 we created a ConclusionIn this part we covered three major enhancements:
In addition to changing the names of the the 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. | ||||||||||||||||||||||