| |
Back to list of Articles
Basics of creating server controls. Part II. Events.
Author: Ozornin Artiom aspnetman@aspnetmania.com
1. Introduction.
This article speaks about different ways of developing multilingual support;
emphasis is given on method using resource files and satellite assemblies.
2. Possible solutions and terms definition.
2.1. Localization and databases.
Well, what choices do we have? Since any modern dynamic web application is just unthinkable without connecting
to a database, the most evident solution probably - to store multilingual presentation of one and the same
information in a database. Indeed, where else can we store arrays of information, substantial in volume and needed
to be changed from time to time, such as articles, chats info, adverts, different news and analytical reviews.
It's logical all the more, as for entity its localization versions in several languages we can consider as different
kinds of this entity's dynamics. Let's call data described above application's target dynamic content (it's clear
that it is not the only type of changeable information, but if we estimate the speed of changing info displayed to a user,
this info type is subject to the most frequent changes). How will the structure of storing entities in the database change in
this case? Let's consider the following example. On the figure 2.1 the table is given implementing Document entity which intent
is to store static html and text documents that need localization.
There are 2 ways: first - change primary table key and make it composite - comprising 2 fields: DocumentId and LanguageId:
Worth mentioning, that not only the structure of storing data has been changed, types of the fields storing localized info has been
changed also - Title and Body fields store Unicode characters. So, with such data structure, we can store each version of document
localization in the separate table row. But what should we do, if data structure of the document demands changes, for example, we
will need to add Author, Creation Date, Expire Date attributes to the Document entity. In this case structure described above turns
into the storage of large number of excessive data, which generates number of difficulties with their synchronization and changing,
apart from everything else. Therefore, if there is no absolute guarantee, that given structure of the entity will not be changed,
or there is no need in certain de-normalization of data, it is better to take another way of dynamic content localization - to extract
localization into a separate entity.
The scheme is quite clear, and probably, the only point that needs explanation - Id field in tblDocumentLocalizations table.
This autoincrement field is a unique table index. The Id field is optional, I use it for table data editing convenience.
Apart from target dynamic content any application contains various additional info that needs localization - controls, containing
text info, some static parts of text and similar text info segments, which we can name additional dynamic content. "Static parts of
text" and "additional dynamic content"? Contradiction, at the first glance, but recall, we are talking about developing multilingual
support and in the problem definition itself there is a deny of any text info statics - cause any static text that has several presentations
in different languages can not be considered as static. While creating web applications of average and high complexity the total number of
elements implementing additional dynamic content can be quite big, and sub-tasks of development, support and management of localization of
the content of such type with the help of database needs considerable efforts and can result in substantial additional load on database server.
Development of localized display of additional dynamic content directly in application code is also quite routine and non flexible solution.
So what should we do? Quite simple - .NET Framework developers have provided tools to resolve this problem.
2.2. Satellite assemblies, resource files and System.Resources.
Satellite assemblies are used in the .NET Framework to store compiled localized application resources (i.e. there is no any program code in
these assemblies): strings, pictures, video and similar binary information; as for resources, there is special System.Resources namespace to
work with them, and of all its members the following classes are the most interesting for developers:
1. ResourceManager - is used to access current culture resources from satellite assembly.
2. ResourceSet - is used to access all resources of one specific culture, this class instance scans all resources of specific culture and stores results in HashTable.
Let's sort out how is it possible to use satellite assemblies in your applications and access data from them (I'll tell you upfront,
that will use Visual Studio.NET for this purpose, those who like "handmade" products, can read al.exe and resgen.exe utilities descriptions
in MSDN and Creating Satellite Assemblies topic there as well). So, let's create new project LocalizingDemo, rename WebForm1.aspx into
Default.aspx (and corresponding class into Default), add Label control to this form, name it headerLabel, and then let's see what is displayed
on Solution Explorer panel (click Show All Files and Refresh buttons on its toolbar).
As you can see, nice:) Visual Studio have already created default resource file in resx format. ResX - is a resource file format that allows to store defined entities, such as strings and objects as XML-tags, and therefore it is XML file of particular structure. To make sure, let's open it and choose
XML design mode.
As you see ResX file consists of two parts - xsd scheme, defining data structure in the file, and the data itself, consisted of set of resource file headers () and data set (), consisted of pairs name/value, structured according to the following xsd-scheme:
<data name = "Name">
<value> Value </value>
</data>
Resource file Default.aspx.resx is a neutral resource file that stores resources that depend on localization. Let's add a resource file
with a given culture and "English-USA" region to our project (File -> Add New Item -> Assembly Resource File) and call it Default.en-US.resx
(property Build Action should be set to Embedded Resource), then let's compile application. We will notice that the structure of our
application's bin directory has been changed.
En-US catalog appeared, containing LocalizingDemo.resources.dll file- it is that satellite assembly, which was compiled together with major
assembly of the application, and which will include all resource files, belonging to this culture/region. En-US combination which was used to
name resource file in Default class and which was used by VisualStudio to form bin directory sub catalog - this is nothing else but culture
name according RFC 1766 standard: - , where - is two-letter combination as per ISO 639-1
Standard (international standard for representation of names of language), consisted of lowercase letters and - two-letter
combination as per ISO 3166 (international standard for representation of names of countries/regions ) consisted of uppercase letters.
(I would like to stress once again that Studio substantially simplifies creating, deployment (in appropriate directory) and compiling
satellite assemblies. If you work with command line you would need to do several quite wearisome operations in order to reach described
result. So, resource file we added corresponds to English-USA culture -region. Into this resource file let's add text value to display
headerLabel element.
Let's add the following code to Page_Load event:
private void Page_Load(object sender, System.EventArgs e)
{
// Set culture "en-US" for current user request thread
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture("en-US");
// Set culture "en-US" that is used by ResourceManager to search
// localized resources
Thread.CurrentThread.CurrentUICulture = CultureInfo.CreateSpecificCulture("en-US");
// Set encoding of response to user request
this.Response.ContentEncoding = Encoding.GetEncoding(Thread.CurrentThread.CurrentCulture.TextInfo.ANSICodePage);
// Create instance of ResourceManager class for current class and current assembly
ResourceManager _resourceManager = new ResourceManager(this.GetType().BaseType.FullName,
this.GetType().BaseType.Assembly);
//Get localized value from localized resources assembly this.headerLabel.Text = _resourceManager.GetString("headerLabel");
}
Compile, run, and get the following result approximately:
Event handler's code was commented quite carefully, we will not speak about it any longer, but the only thing I would like to draw your
attention to is that overriden version of ResourceManager.GetString method is used, which accepts CultureInfo instance as second parameter,
i.e. we could just write this.headerLabel.Text = _resourceManager.GetString("headerLabel", �ultureInfo.CreateSpecificCulture("en-US")) and
not override Thread.CurrentThread.CurrentCulture with Thread.CurrentThread.CurrentUICulture, but since usually homogeneous localization of
all page elements/user control is needed, implementation of this version of method's reloading is not optimal (at least, imho :) ). However,
nobody needs localization for one language/culture, that is why let's add resource file Default.ru-RU.resx to our project, to store Russian
localization resources in it , and add render parameter headerLabel.
Then let's add two HyperLink controls to the page (I'll give
code of the whole page, as it's quite short).
Let's adjust Page_Load event code to the following:
private void Page_Load(object sender, System.EventArgs e)
{
if (this.Request.QueryString["Culture"] == null)
{
return;
}
string _sCulture = this.Request.QueryString["Culture"];
// Set culture for current user request thread
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture(_sCulture);
// Set culture that is used by ResourceManager to search
// localized resources
Thread.CurrentThread.CurrentUICulture = CultureInfo.CreateSpecificCulture(_sCulture);
// Set encoding of response to user request
this.Response.ContentEncoding = Encoding.GetEncoding(Thread.CurrentThread.CurrentCulture.TextInfo.ANSICodePage);
// Create instance of ResourceManager class for current class and current assembly
ResourceManager _resourceManager = new ResourceManager(this.GetType().BaseType.FullName,
this.GetType().BaseType.Assembly);
// Get localized value from localized resources assembly
this.headerLabel.Text = _resourceManager.GetString("headerLabel");
}
Now, depending this or that link has been clicked, we get string value from this or that localized assembly to render headerLabel element:
So, we got quite operable application demonstrating localization mechanism in .NET, and with this we could have finished this section,
but I want to focus on one more aspect of ResourceManager class. Let's add to our project resource file Default.en.resx and the same pair
name/value that we have added to Default.en-US.resx for headerLabel control, and let's delete this pair from Default.en-US.resx (or delete
this file at all). While re-compiling and starting this application we will find out its behavior was not changed. Let's go further on, and
add the same pair name/value to Default.aspx.resx file and delete it from Default.en.resx file (or delete the file itself). Once again compile
and start - application is still working without any changes. Explanation is in ResourceManager class speciality - when it can not find
necessary value in resource assembly for specific culture/region (in our case en-US), it tries to find these data in resource assembly for
culture without specifying region (en), and then in neutral resources assembly. This mechanism is quite logical, and allows to distribute
rationally all info that is localized in such a way that, for example, for cultures/regions en-US (USA) and en-GB (Great Britain) common info
is stored in assembly for bin /en/AssemblyName.resources.dll culture, and different info in assemblies bin/en-US/AssemblyName.resources.dll
and bin/en-GB/AssemblyName.resources.dll correspondingly, resources that do not depend on localization can be stored in neutral assembly.
3. Development of (one-page) multilingual application.
I'll tell you straight off, that I am going to speak about localization at a language level, not at a region level, cause I never had a
chance to resolve that kind of tasks, but the principles of creating an application that is localized at the region level are identical
and solution described here can be easily extended up to the necessary level of localization. To start with, I would like to show diagram
of data storing structure for one-page portal
Structure and purpose of tables tblPages, tblModules, tblContainers and tblPageConfigurations are similar to those that I described in
my article "Creating a simple one-page portal", tblModuleDefinitions and tblDocuments - is and influence of IBuySpy Portal (I finally
managed to do it :) ). TblDocuments table and its descendant tblDocumentLocalizations are the only of these tables, not related to the
structure info of the portal itself and are shown on the diagram just as the representatives of information part of site, in real life
here can be announcement tables, forum tables etc. As we can see from the diagram, each entity that has info to be rendered, has additional
localization table, and all localization tables have relationships (:) !) many-to-one with tblLanguages table. The description of main fields
of tblLanguages table is given on the figure 2.3., the only thing I want to add is that Alias field serves for storing language name as per
ISO 639-1 standard. So, the first thing we want is to get information on all languages supported by the application upon its start. Therefore,
we write the following in Global.asax.cs:
protected void Application_Start(Object sender, EventArgs e)
{
this.Application.Add("AvailableLanguages", this.localizationsData.GetLanguages());
}
LocalizationsData.GetLanguages() - it is a business component method that returns result of the following protected procedure as a HashTable:
CREATE PROCEDURE dbo.spSelectLanguages
AS
BEGIN
SELECT Alias AS LanguageAlias,
EnglishName AS LanguageName
FROM tblLanguages
END
If the application has relatively high speed of dynamics, probably it is reasonable to run this operation not only while starting the
application but from time to tile while running application. Then, after each user request we need to define, information in what language
he wants to get:
protected void Application_BeginRequest(Object sender, EventArgs e)
{
string _sUserLanguage;
bool _bLanguageCookieExist = false;
// Check if language cookie exists
if (this.Request.Cookies["Language"] == null)
{
Hashtable _languageHashtable = (Hashtable)this.Application["AvailableLanguages"];
// Check if default language of client's browser is
// among the languages supported by your site
if (_languageHashtable[this.Request.UserLanguages[0].Substring(0,2)] != null)
{
_sUserLanguage = this.Request.UserLanguages[0].Substring(0,2);
}
else
{
// If default language is not supported, we choose English locale
_sUserLanguage = "en";
}
}
else
{
_sUserLanguage = this.Request.Cookies["Language"].Value;
_bLanguageCookieExist = true;
}
// Set current culture for current user request thread
Thread.CurrentThread
.CurrentCulture = CultureInfo.CreateSpecificCulture(_sUserLanguage);
// Set culture that is used by ResourceManager to search
// localized resources
Thread.CurrentThread
.CurrentUICulture = CultureInfo.CreateSpecificCulture(_sUserLanguage);
// Set encoding of response to user request
this.Response.ContentEncoding =
Encoding.GetEncoding(Thread.CurrentThread.CurrentCulture.TextInfo.ANSICodePage);
if (!_bLanguageCookieExist)
{
// Set value for cookie storing language selected by the user
this.Response.Cookies["Language"].Value =
Thread.CurrentThread.CurrentCulture.TwoLetterISOLanguageName;
this.Response.Cookies["Language"].Expires = DateTime.Now.AddYears(5);
}
}
Everything is quite clear here, the only thing I would like to draw your attention to is that you better don't use such expressions as
Thread.CurrentThread.CurrentCulture = CultureInfo.CreateSpecificCulture(this.Request.UserLanguages[0]); - cause some unconcerned Opera
can send something like "en;q=0,1", instead of this.Request.UserLanguages[0], and this for sure will rise an exception, because parameter
sent to CultureInfo.CreateSpecificCulture should be RFC 1766 string. With the code written above in Global.asax.cs, and any control that
sets cookie for language select, we will get system for centralized management and support of localization of Multilingual application. So,
at any moment of application run-time we can get target dynamic content from the database server in accordance with user's current language
setting and display it correctly. As per additional dynamic content stored in satellite assemblies, I think you'll agree, that it is quite
tiresome to write similar code for each control at each page or each user control that is why I use slightly
changed class, which implements
localization of the whole page or user control:
using System;
using System.Web;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Globalization;
using System.Threading;
using System.Resources;
using System.Collections.Specialized;
namespace Volortho.Configuration
{
///
/// Static methods encapsulation for localization of page
/// controls / user control
///
public class WebUILocalizer
{
#region Private static methods
///
/// Loading key/value collection from resource file of element that is localized
///
///
/// Localization target object, usually derives
/// from System.Web.UI.Page or System.Web.UI.UserControl
///
///
/// Set of some additional parameters, stored in
/// resource file
///
///
/// Key/value collection from resource file of element that is localized
///
private static NameValueCollection LoadLabels(Control mainControl, string[] sExceptions)
{
string _sBaseName = mainControl.GetType().BaseType.FullName;
string _sTwoLetterISOLanguageName = Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName;
NameValueCollection _labelsCollection =
(NameValueCollection)HttpContext.Current.Cache[_sBaseName + "_" + _sTwoLetterISOLanguageName];
if (_labelsCollection == null)
{
ResourceManager _resourceManager =
new ResourceManager(_sBaseName,typeof(WebUILocalizer).Assembly);
_labelsCollection = new NameValueCollection();
FillResourcesCollection(mainControl, _resourceManager, _labelsCollection);
if(sExceptions != null && sExceptions.Length > 0)
{
foreach(string ex in sExceptions)
{
_labelsCollection.Add(ex, _resourceManager.GetString(ex));
}
}
HttpContext.Current.Cache.Insert(_sBaseName + "_" + _sTwoLetterISOLanguageName,
_labelsCollection, null, DateTime.MaxValue, TimeSpan.FromMinutes(2));
}
return _labelsCollection;
}
///
/// Load key/value collection from resource file of element that is localized
///
///
/// Localization target object, usually derives
/// from System.Web.UI.Page or System.Web.UI.UserControl
///
///
/// Current localization resource manager
///
///
/// Key/value collection from resource file of element that is localized
///
private static void FillResourcesCollection
(Control control, ResourceManager resourceManager, NameValueCollection labelsCollection)
{
foreach(Control _childControls in control.Controls)
{
FillResourcesCollection(_childControls, resourceManager, labelsCollection);
}
if (control is WebControl)
{
string _sKey = control.ID;
string _sVal = resourceManager.GetString(_sKey);
if(_sVal != null)
{
if(_sVal != "array")
{
labelsCollection.Add(_sKey, _sVal);
}
else
{
labelsCollection.Add(_sKey, _sVal);
int _iArrayItemCounter = 1;
while(resourceManager.GetString(_sKey + "_" + _iArrayItemCounter.ToString()) != null)
{
labelsCollection.Add(_sKey + "_" + _iArrayItemCounter.ToString(),
resourceManager.GetString(_sKey + "_" + _iArrayItemCounter.ToString()));
_iArrayItemCounter++;
}
}
}
}
}
///
/// Set localized property values taken from labelsCollection for
/// appropriate controls
///
///
/// Target object of localization
///
/// Key/value collection from resource file of element that is localized
///
private static void SetLabels(Control control, NameValueCollection labelsCollection)
{
foreach(Control _childControl in control.Controls)
{
SetLabels(_childControl, labelsCollection);
}
if(control is WebControl)
{
string _sKey = control.ID;
string _sVal = labelsCollection[_sKey];
if(_sVal != null)
switch(control.GetType().Name)
{
case "Label":
((Label)control).Text = _sVal;
break;
case "CheckBox":
((CheckBox)control).Text = _sVal;
break;
case "TextBox":
((TextBox)control).Text = _sVal;
break;
case "LinkButton":
((LinkButton)control).Text = _sVal;
break;
case "Button":
((Button)control).Text = _sVal;
break;
case "HyperLink":
((HyperLink)control).Text = _sVal;
break;
case "DropDownList":
case "RadioButtonList":
case "CheckBoxList":
case "ListBox":
ListControl _listControl = (ListControl)control;
int _iItemCounter = 0;
foreach(ListItem _listItem in _listControl.Items)
{
_listItem.Text = labelsCollection[_sKey + "_" + (_iItemCounter + 1).ToString()];
_iItemCounter++;
}
break;
case "RequiredFieldValidator":
case "RegularExpressionValidator":
case "CusomValidator":
case "CompareValidator":
((BaseValidator)control).ErrorMessage = _sVal;
break;
}
}
}
#endregion Private static methods
#region Public static methods
///
/// Localizes control as per the current culture
///
///
/// Localization target object, usually derives
/// from System.Web.UI.Page or System.Web.UI.UserControl
///
///
/// Set of some additional parameters, stored in
/// resource file
public static void Localize(Control mainControl, string[] sExceptions)
{
if (Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName == "en")
{
return;
}
WebUILocalizer.SetLabels(mainControl,
WebUILocalizer.LoadLabels(mainControl, sExceptions));
}
///
/// Localizes control as per the current culture
///
///
/// Localization target object, usually derives
/// from System.Web.UI.Page or System.Web.UI.UserControl
///
public static void Localize(Control mainControl)
{
if (Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName == "en")
{
return;
}
WebUILocalizer.SetLabels(mainControl,
WebUILocalizer.LoadLabels(mainControl, null));
}
#endregion Public static methods
}
}
To use this code it is enough to add it into the class of the object that is localized:
private void Page_Load(object sender, System.EventArgs e)
{
if (!this.IsPostBack)
{
WebUILocalizer.Localize(this);
}
}
Back to top
|