I’m back working on a localization project and so I’ve been spending a lot of time on my resource provider in my ample spare time adding a few features that are required for smooth localization of an application. I talked about this in a lot of detail in my Data Drive Resource Provider article a couple of years back. This article address the server side of localization.
However, that article didn’t address the client side and so there are no real provisions for client side localization. In the samples and the localization admin form specifically I relied on ASP.NET script tag embedding using <%= ResC("MyResourceString" %> to embed resource strings where ResC() embeds a properly encoded string into the page when the ASPX page renders. There are lots of problems with this approach including the nastiness that results from script tags in code, the fact that script tags don’t always work even in ASPX pages (specifically the good old The Controls Collection cannot be modified error), and maybe most importantly that it doesn’t work at all if you want to externalize your script into a separate .js file as <% %> tags only work in ASP.NET pages.
ASP.NET AJAX includes some localization support via ScriptManager, but personally I don’t like this approach because it works separately from the server side localization features already built into ASP.NET. You’re also bound to ScriptManager which I don’t use. With script manager you have to create localized .js files which then get loaded for the given locale which frankly is lame. The biggest issue with this is that if you hand resources to be localized to localizers they now have to work with two separate sets of localization resources. The other issue is that if you already have tools that work with server resources you can’t use them for client resources. Personally I’d like to have all resources in one place and then be able to access them from both client and server code as needed.
Creating an ASP.NET JavaScriptResourceHandler HttpHandler
So what I want to do is fairly simple: I want to be able to maintain my resources in standards ASP.NET style resources – Resx or in my case also the DbResourceProvider based resources – and be able to feed these to the client to consume. In other words use a single mechanism to create my resources, and also be able to administer those same resource through my resource administration tool described in the article mentioned earlier. Then serve those resources on the server as well as on the client.
It turns out this is fairly straight forward to implement. What I ended up with a is an HttpHandler that serves resources as one or more JavaScript objects (one for each resource set you decide publish to the client explicitly). The object has a property for each string resource Id in the resource set with values that are normalized for the active locale. By normalized I mean that the culture has been set based on resource fallback – if a specific resource is missing the next less specific resource is used following the resource hierarchy (ie. en-Us –> en –> Invariant). In effect this is the same behavior you’d see in resources on the server assigned with meta:resource keys or explicit calls to GetLocal/GlobalResourceObject() based on the current UI culture of the request.
The process to work with this is pretty straight forward:
Create your resources as standard .Resx or DbResourceProvider resources
You can create local or global resources or re-use the same resource you are already using on the server in resx files just like you normally do.
To make life easier I tend to use my resource administration form with the DbResourceProvider (included in the download) which is more interactive and can be context sensitive from ASP.NET content pages:
This tool allows using a database resource provider to modify and add resources and then optionally lets you export them back into Resx resources (or you can use the DbResourceProvider.
This is purely optional though – any way you want to create your Resx resources works just as well.
Add the HttpHandler to Web.Config
In order to use the handler the handler has to be added to Web.Config:
<!-- IIS 7 -->
<system.webServer>
<handlers>
<add name="JavaScriptResourceHandler" verb="GET" path="JavascriptResourceHandler.axd"
type="Westwind.Globalization.JavaScriptResourceHandler,Westwind.Globalization"/>
</handlers>
</system.webServer>
<!-- IIS 6/5/5.5 and Cassini -->
<system.web>
<httpHandlers>
<add name="JavaScriptResourceHandler" verb="GET" path="JavascriptResourceHandler.axd"
type="Westwind.Globalization.JavaScriptResourceHandler,Westwind.Globalization"/>
</httpHandlers>
</system.web>
Add Handler Reference to Pages that require Localized JavaScript Resources
The HttpHandler is driven through querystring parameters that specify the resourceSet, the localeID, what type of resources to load from whether you’re accessing global resources and so on. There are many options, so to make accessing the handler easier there are various static methods that embed the scripts into the page automatically. The easiest two versions that take all default behaviors look like this:
protected void Page_Load(object sender, EventArgs e)
{
JavaScriptResourceHandler.RegisterJavaScriptGlobalResources(this, "globalRes","resources");
JavaScriptResourceHandler.RegisterJavaScriptLocalResources(this, "localRes");
}
This embeds two script references into the page each of which contains an object – globalRes and localRes respectively – that contains all of the resourceids as properties. These versions assume several defaults: They choose the current UI culture, auto-detect either the Resx or DbResourceProvider and read resources out of either of these providers. Local resources assume you want the resources for the current page and that control resources are not included (control resources are those that have a . in the name and refer to Control.Property typically). All of this can be overridden with the more specific overrides but most of the time these short versions are appropriate.
The script references generated into the page are pretty long and nasty:
<script src="JavaScriptResourceHandler.axd?ResourceSet=resources&
LocaleId=de-DE&VarName=globalRes&ResourceType=resdb&IsGlobal=1"
type="text/javascript"></script>
<script src="JavaScriptResourceHandler.axd?ResourceSet=localizationadmin/localizationadmin.aspx&
;LocaleId=de-DE&VarName=localRes&ResourceType=resdb"
type="text/javascript"></script>
which is why those RegisterXXX methods exist to make things easy from within page code.
You can also use this from MVC pages for which there’s are methods that just retrieve the URL. In an MVC page you can embed Client Resources like this:
<%=
"<script src='" +
Westwind.Globalization.JavaScriptResourceHandler.GetJavaScriptGlobalResourcesUrl("globalRes","Resources") +
"' type='text/javascript'></script>"
%>
The result JavaScript content of those script references is JavaScript that contains an object each with the resources as properties of that object. Here’s what the globalRes reference looks like:
var globalRes = {
CouldNotCreateNewCustomer: "Neuer Kunde konnte nicht erstellt werden",
CouldNotLoadCustomer: "Kunde konnte nicht erstellt werden",
CustomerSaved: "Kunde wurde gespeichert",
NoGermanResources: "This entry contains no German Resources",
Today: "Heute",
Yesterday: "Gestern"
};
Notice that the cultures are normalized. I’m running in german locale (de-de) and I get the de resources from the resources except for the NoGermanResources key which doesn’t include any german resources and so falls back. In short you get the same behavior as you would expect from GetLocalResourceObject or GetGlobalResourceObject on the server.
Here’s the localRes version which is more of the same, but served in a separate handler link.
var localRes = {
AreYouSureYouWantToRemoveValue: "Sind Sie sicher dass Sie diesen Wert l\u00F6schen wollen?",
BackupComplete: "Der Backup f\u00FChrte erfolgreich durch",
BackupFailed: "Der Backup konnte nicht durchgef\u00FChrt werden",
BackupNotification: "Diese Operation macht einen Backup von der Lokalisationtabelle. \nM\u00F6chten Sie fortfahren?",
Close: "Schliessen",
FeatureDisabled: "Diese Funktion ist nicht vorhanden im on-line Demo ",
InvalidFileUploaded: "Unzul\u00E4ssige Akten Format hochgeladen.",
InvalidResourceId: "Unzul\u00E4ssige ResourceId",
Loading: "Laden",
LocalizationTableCreated: "Lokalisations Akte wurde erfolgreich erstellt.",
LocalizationTableNotCreated: "Die Localizations Akte konnte nicht erstellt werden."
};
Notice that the local resources by default don’t include controls (txtName.Text, txtName.Tooltip etc.), which is a common scenario. I tend to localize pages with local resources using both control based meta:resource tags and also some fixed values for messages. JavaScript code tends to need only those messages and not the control values. If you need them however you can provide them with the more specific versions of these methods. For example to explicitly specify all the options on a local resource retrieval you can use:
JavaScriptResourceHandler.RegisterJavaScriptLocalResources(this, "localRes",
CultureInfo.CurrentUICulture.IetfLanguageTag,
"localizationadmin/localizationadmin.aspx",
ResourceProviderTypes.AutoDetect, true);
Here you get to specify the name of the variable, the explicit culture (normalized), the name of the resource (which is the relative path without the leading /) and autodetection, as well as the option to include control resources.
Using the Embedded Controls in JavaScript Code
Once the script references have been embedded those localized JavaScript resource objects are available anywhere in client code, including in external .JS files as long as you ensure that you access resources after all scripts have loaded (use jQuery and $(document).ready( function() {} or hook window.onload).
Using the objects is as simple as this.
$(document).ready(function() {
alert(globalRes.CustomerSaved);
// local resources from admin form
alert(localRes.BackupFailed);
});
This should work either or inside of an ASPX page or an external script .js file.
Using the Handler and embedding resources is real easy as you can see. You do have a couple of dependencies: Westwind.Globalization.dll, Westwind.Web.dll and Westwind.Utilities.dll which provide some of the core utilities used in the resource provider. If you’re adventurous you can break out the resource handler on its own – there are only a handful of dependencies in the actual handler and resx parsing code, but I was too lazy to separate this out. I leave that excercise to you if you want just the handler and resource retrieval code.
Handler Implementation
If you want to dig a little deeper, here are a few of the implementation highlights. The HttpHandler itself is fairly straight forward, however some of the resource retrieval, especially for Resx resources is a bit of a pain. The problem is that ASP.NET doesn’t expose the ResourceProvider that is in use so there’s no way from within the native ASP.NET framework to retrieve resources for a given ResourceSet. This means that custom code is needed to access resources explicitly from their source. With the DbResourceProvider this is fairly straight forward because the resources are coming from a database and there’s a DbResourceDataManager that can return resources easily. For Resx resources the handler has to go out and query the Resx resources on disk as XML and parse out the values. Further, both mechanisms need to normalize the resources which means actually retrieving resources from up to 3 separate resourcesets as each ResourceSets contains all resources for one exact locale in a non-normalized fashion.
You can look at the full code in the accompanying download (or in the repository), but here is the core HttpHandler operation (partial code):
/// <summary>
/// Http Handler that can return ASP.NET Local and Global Resources as a JavaScript
/// object. Supports both plain Resx Resources as well as DbResourceProvider driven
/// resources.
///
/// Objects are generated in the form of:
///
/// var localRes = {
/// BackupFailed: "Backup was not completed",
/// Loading: "Loading"
/// );
///
/// where the resource key becomes the property name with a string value.
///
/// The handler is driven through query string variables determines which resources
/// are returned:
///
/// ResourceSet - Examples: "resource" (global), "admin/somepage.aspx" "default.aspx" (local)
/// LocaleId - Examples: "de-de","de","" (empty=invariant)
/// ResourceType - Resx,ResDb
/// IncludeControls - if non-blank includes control values (. in name)
/// VarName - name of hte variable generated - if omitted localRes or globalRes is created.
/// IsGlboalResource - Flag required to find Resx resources on disk
///
/// Resources retrieved are aggregated for the locale Id (ie. de-de returns de-de,de and invariant)
/// whichever matches first.
/// </summary>
public class JavaScriptResourceHandler : IHttpHandler
{
public bool IsReusable
{
get { return true; }
}
public void ProcessRequest(HttpContext context)
{
HttpRequest Request = HttpContext.Current.Request;
string resourceSet = Request.Params["Resourceset"];
string localeId = Request.Params["LocaleId"] ?? "";
string resourceType = Request.Params["ResourceType"] ?? "Resx"; // Resx/ResDb
bool includeControls = (Request.Params["IncludeControls"] ?? "") != "";
string varname = Request.Params["VarName"] ?? "localRes";
bool isGlobalResource = (Request.Params["IsGlobal"] ?? "") != "";
string cacheKey = (resourceSet + localeId + resourceType + includeControls.ToString() +
varname + isGlobalResource.ToString()).ToLower();
string content = HttpContext.Current.Cache[cacheKey] as string;
if (content != null)
this.SendTextOutput(content, "application/javascript"); // Done!
// varname is embedded into script so validate to avoid script injection
// it's gotta be a valid C# and valid JavaScript name
Match match = Regex.Match(varname, @"^[\w|\d|_|$|@]*$");
if (match.Length < 1 || match.Groups[0].Value != varname)
this.SendErrorResponse("Invalid variable name passed.");
if ( string.IsNullOrEmpty(resourceSet) )
this.SendErrorResponse("Invalid ResourceSet specified.");
Dictionary<string, object> resDict = null;
if (resourceType.ToLower() == "resdb")
{
DbResourceDataManager manager = new DbResourceDataManager();
resDict = manager.GetResourceSetNormalizedForLocaleId(localeId, resourceSet)
as Dictionary<string,object>;
}
else // Resx Resources
{
DbResXConverter converter = new DbResXConverter();
// must figure out the path
string resxPath = converter.FormatResourceSetPath(resourceSet,!isGlobalResource);
resDict = converter.GetResXResourcesNormalizedForLocale(resxPath, localeId)
as Dictionary<string,object>;
}
if (!isGlobalResource && !includeControls)
{
// filter the list to strip out controls (anything that contains a . in the ResourceId
resDict = resDict.Where(res => !res.Key.Contains('.') && res.Value is string)
.ToDictionary(dict => dict.Key, dict => dict.Value);
}
else
{
// must fix up . syntax which is not a valid variable name
resDict = resDict.Where( res => res.Value is string)
.ToDictionary(dict => dict.Key, dict => dict.Value);
}
string javaScript = this.SerializeResourceDictionary(resDict,varname);
// cache the text for this locale and settings
HttpContext.Current.Cache[cacheKey] = javaScript;
this.SendTextOutput(javaScript, "application/javascript");
}
/// <summary>
/// Generates the actual JavaScript object map string makes up the
/// handler's result content.
/// </summary>
/// <param name="resxDict"></param>
/// <param name="varname"></param>
/// <returns></returns>
private string SerializeResourceDictionary(Dictionary<string, object> resxDict, string varname)
{
StringBuilder sb = new StringBuilder(2048);
sb.Append("var " + varname + " = {\r\n");
int anonymousIdCounter = 0;
foreach (KeyValuePair<string, object> item in resxDict)
{
string value = item.Value as string;
if (value == null)
continue; // only encode string values
string key = item.Key;
if (string.IsNullOrEmpty(item.Key))
key = "__id" + anonymousIdCounter++.ToString();
key = key.Replace(".", "_");
if (key.Contains(" "))
key = StringUtils.ToCamelCase(key);
sb.Append("\t" + key + ": ");
sb.Append(WebUtils.EncodeJsString(value) );
sb.Append(",\r\n");
}
sb.Append("}");
// strip off ,/r/n at end of string (if any)
sb.Replace(",\r\n}","\r\n}");
sb.Append(";\r\n");
return sb.ToString();
}
/// <summary>
/// Returns an error response to the client. Generates a 404 error
/// </summary>
/// <param name="Message">Error message to display</param>
private void SendErrorResponse(string Message)
{
if (!string.IsNullOrEmpty(Message))
Message = "Invalid Web Resource";
HttpContext Context = HttpContext.Current;
Context.Response.StatusCode = 404;
Context.Response.StatusDescription = Message;
Context.Response.End();
}
/// <summary>
/// Writes text output to server using UTF-8 encoding and GZip encoding
/// if supported by the client
/// </summary>
/// <param name="text"></param>
/// <param name="useGZip"></param>
/// <param name="contentType"></param>
private void SendTextOutput(string text, string contentType)
{
byte[] Output = Encoding.UTF8.GetBytes(text);
HttpResponse Response = HttpContext.Current.Response;
Response.ContentType = contentType;
Response.Charset = "utf-8";
// Trigger Gzip encoding and headers if supported
WebUtils.GZipEncodePage();
if (!HttpContext.Current.IsDebuggingEnabled)
{
Response.Cache.SetCacheability(HttpCacheability.Public);
Response.ExpiresAbsolute = DateTime.UtcNow.AddDays(1);
Response.Cache.SetLastModified(DateTime.UtcNow);
}
Response.BinaryWrite(Output);
Response.End();
}
}
The handler provides basic caching of the resources returned based on the full parameter list which is necessary that each of the locale combinations get cached which can be a lot of combination potentially if you have a lot of languages.The process of creating these resource lists – especially from Resx resources is fairly expensive so caching is important. The output generated is also cached in the browser (in non-debug mode anyway). It’s also GZipped if the client supports it.
Again it’s a shame we don’t get access to the native ASP.NET ResourceProvider because the providers themselves cache the resource sets internally so they typically don’t get reloaded from the underlying resource store. Alas we don’t have access to the resource provider so we either duplicate the caching or simply reload the lists. The retrieval methods here go back to the source data:
if (resourceType.ToLower() == "resdb")
{
DbResourceDataManager manager = new DbResourceDataManager();
resDict = manager.GetResourceSetNormalizedForLocaleId(localeId, resourceSet)
as Dictionary<string,object>;
}
else // Resx Resources
{
DbResXConverter converter = new DbResXConverter();
// must figure out the path
string resxPath = converter.FormatResourceSetPath(resourceSet,!isGlobalResource);
resDict = converter.GetResXResourcesNormalizedForLocale(resxPath, localeId)
as Dictionary<string,object>;
}
to retrieve resources and then try to cache the result instead using the ASP.NET Cache object to avoid the round trip to the underlying resource store.
The core task of the handler wraps the actual resource set export routines that are handled by DbResxConverter (which is used for other things like exporting from Db <—> Resx) and the DbResourceManager respectively. Both of those functions return a dictionary of ResourceId strings and resource values. For JavaScript usage non-string objects are dropped. The actual creation of the JavaScript object then is trivial by simply looping through the dictionary as shown in SerializeResourceDictionary which creates the JavaScript object map as a string. Note that values need to be properly encoded as JavaScript code strings. For this I use a simple EncodeJs function that encodes strings properly for Javascript usage.
Resx Resource Retrieval
All of the above is pretty straight forward. The worst part of the process however is to retrieve the Resx resources and normalize them. It really bugs me that ASP.NET doesn’t allow access to the ASP.NET ResourceProvider, because the provider actually contains methods to do this for you. But alas there appears to be no way to access the ResourceProvider directly so you have to resort to retrieve resources manually (or re-instatiate the provider which is a bit of a pain).
Actually there’s an easier way than manually parsing the Xml files: System.Windows.Forms.ResXResourceReader provides access to Resx resources, but it pulls in System.Windows.Forms. It also doesn’t provide caching unless you manually cache the resource sets returned statically which in effect duplicates what the ResourceProvider already does natively. In any case I decided against using ResXResourceReader because of the System.Windows.Forms dependency. Instead I used some straight forward code to parse the Resx resources manually. This code already exists in the library for importing ResX resources into the Databased Resource provider anyway.
To manually import ResX files is pretty straight forward using XML parsing. The following two methods retrieve an individual resource set from a .Resx file and then normalize the set by retrieving all the resource sets in the chain and combining the resource dictionaries:
/// <summary>
/// Gets a specific List of resources as a list of ResxItems.
/// This list only retrieves items for a specific locale. No
/// resource normalization occurs.
/// </summary>
/// <param name="FileName"></param>
/// <returns></returns>
public List<ResxItem> GetResXResources(string FileName)
{
string FilePath = Path.GetDirectoryName(FileName) + "\\";
XmlDocument Dom = new XmlDocument();
try
{
Dom.Load(FileName);
}
catch (Exception ex)
{
this.ErrorMessage = ex.Message;
return null;
}
List<ResxItem> resxItems = new List<ResxItem>();
XmlNodeList nodes = Dom.DocumentElement.SelectNodes("data");
foreach (XmlNode Node in nodes)
{
string Value = null;
XmlNodeList valueNodes = Node.SelectNodes("value");
if (valueNodes.Count == 1)
Value = valueNodes[0].InnerText;
else
Value = Node.InnerText;
string Name = Node.Attributes["name"].Value;
string Type = null;
if (Node.Attributes["type"] != null)
Type = Node.Attributes["type"].Value;
ResxItem resxItem = new ResxItem() { Name = Name, Type = Type, Value = Value };
resxItems.Add(resxItem);
}
return resxItems;
}
/// <summary>
/// Returns all resources for a given locale normalized down the hierarchy for
/// a given resource file. The resource file should be specified without the
/// .resx and locale identifier extensions.
/// </summary>
/// <param name="baseFile">The base Resource file without .resx and locale extensions</param>
/// <param name="LocaleId"></param>
/// <returns>Dictionary of resource keys and values</returns>
public Dictionary<string, object> GetResXResourcesNormalizedForLocale(string baseFile, string LocaleId)
{
string LocaleId1 = null;
if (LocaleId.Contains('-'))
LocaleId1 = LocaleId.Split('-')[0];
List<ResxItem> localeRes = new List<ResxItem>();
List<ResxItem> locale1Res = new List<ResxItem>();
List<ResxItem> invariantRes = null;
if (!string.IsNullOrEmpty(LocaleId))
{
localeRes = this.GetResXResources(baseFile + "." + LocaleId + ".resx");
if (localeRes == null)
localeRes = new List<ResxItem>();
}
if (!string.IsNullOrEmpty(LocaleId1))
{
locale1Res = this.GetResXResources(baseFile + "." + LocaleId1 + ".resx");
if (locale1Res == null)
locale1Res = new List<ResxItem>();
}
invariantRes = this.GetResXResources(baseFile + ".resx");
if (invariantRes == null)
invariantRes = new List<ResxItem>();
IEnumerable<ResxItem> items =
from loc in localeRes
.Concat(from loc1 in locale1Res select loc1)
.Concat(from invariant in invariantRes select invariant)
.OrderBy(loc => loc.Name)
select loc;
Dictionary<string, object> resxDict = new Dictionary<string, object>();
string lastName = "@#XX";
foreach (ResxItem item in items)
{
if (lastName == item.Name)
continue;
lastName = item.Name;
resxDict.Add(item.Name, item.Value);
}
return resxDict;
}
GetResXResources retrieves a list of all resources for a specific ResX file. This will be locale specific. GetResXResourcesNormalizedForLocale then figures out the resource fallback locales and retrieves those resource sets as well. Up to 3 resource sets are then merged using LINQ’s .Concat() function, ordered and filtered to produce a single normalized ResourceSet for what should be all the resources available.
LINQ made this filtering process a real breeze although I did run into a snag doing the final filtering. I couldn’t figure out how to do the final filtering to get just the first unique Resource Id as a group. Sometimes the old fashioned way is just quicker than farting around with trying to make LINQ work and so i fell back to an old fashioned foreach loop. <g>
The process for DbResourceProvider is similar although it’s a little easier to retrieve since the data is coming from the database and a bit easier to combine. You can check out the code in DbResourceDataManager for GetResourceSet() and GetResourceSetNormalizedForLocaleId() to see the same logic with the database provider. The idea is the same – each resourceset is retrieved and combined and filtered.
Check it out
I’ve provided updated code to my Westwind.Globalization project which is an updated version of the Localization provider article’s code. There are many improvements in this code from the article’s code. The handler and ResourceProvider are also part of the West Wind Web Toolkit for ASP.NET which is a separate product that’s still under construction for documentation and samples etc. That site includes source repository access for updates as well.
Download code (Westwind.Globalization only)
Download code (Westwind.WebToolkit)
Other Posts you might also like