Introduction
Many of us have endured the problems that localization poses in website projects. Or projects in general.
There are many different ways to approach localization, and each usually has it's advantages and inconveniences.
Let's walk through those options.
App_LocalResources
Use classic App_LocalResources, and you get the flexibility of being able to organize your files quickly and efficiently. All it takes is to create a sub-folder underneath
any file you want to localize called App_LocalResources, and start dropping .resx files in it while naming them the same as the file you want to localize,
just ending with .resx for the base translation and {culture}.resx for each language specific translation.
Insert a key into your .resx called Title, and some value.
And start using your translations:
<asp:Literal runat="server" Text="<%$Resources:Title %>" />
<asp:Button runat="server" Text="<%$Resources:Title %>" />
The Gotcha is that to make the magic work, <%$Resources:Title %> will only work on an element that is declared runat="server".
So in many places you will be inserting our pretty little <asp:Literal /> and that can make for some really ugly syntax in certain situations.
Something more interesting you can do in projects that use .aspx files, is to use the less known meta:resourcekey.
<asp:TextBox runat="server" meta:resourcekey="MyInput" />
This allows you to do nice things like localize the full range of attributes of an element in a resx file while declaring only a single keyword in your .aspx file.
As you probably just guessed, the first part is the key identifier, and the second part is the name of the attribute you want to apply the value to.
This is totally awesome as it saves a lot of ugly syntax from making it into your .aspx files.
And it can really be a savior with custom controls where you have tons of attributes. For example on special error handling controls: tooShortErrorMsg, tooLongErrorMsg,
numericErrorMsg, notPrettyEnoughErrorMsg ..you get the idea.
Inconvienience:
While the above is really cool, you have to set all the elements on which you want to use localization to runat="server".
You cannot use those translations inside code-behind code, nor inside classes or functions.
And you can't use them in JavaScript either ..unless you're willing to do this:
<%@ Page Language="C#" ContentType="text/javascript" %>
var translations = {
Title: "<asp:Literal runat="server"
Text="<%$Resources: Title %>" />"
};
Notice the ContentType declaration on the Page. Even though that's a nice trick to know, i really don't suggest you do that!
The above options are valid for both ASP.NET and MVC projects that use .aspx files instead of .cshtml files, since once we switch to .cshtml files
we loose the ability to use runat="server".
App_GlobalResources
Now this one is interesting in the fact that your translations are contained inside a single folder, instead of being spread out across your entire site.
Not that that's a bad thing, since on many occasions it's quite practical to have your translations just one sub-folder away.
This folder sits at the root of your website.
Another interesting thing, is that App_GlobalResources
actually generates compiled classes, and that means that you can stop using those magic runat="server" tags,
and have access to real properties that you can even use in your code-behind.
<%= Resources.Common.Title %>
<asp:Literal runat="server" Text="<%$Resources: Common, Title %>" />
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
Response.Write(Resources.Common.Title);
}
That said, you can still use what applied above with the runat="server" tags, just now you have to declare in what namespace your translations are living in.
You see, each file you add to App_GlobalResources
will generate a class of the same name. Previously we added a file called common.resx, so we access that with Resources.Common.*,
or <$Resources: Common, * %>
You can even do the little JavaScript trick, just now using real properties.
<%@ Page Language="C#" ContentType="text/javascript" %>
var translations = {
Title: "<%= Resources.Common.Title %>"
};
By adding files of different names to App_GlobalResources
you will generate just as many different classes, which is very convenient for organizing you translations
by category (common, user, registration, etc) !
Inconvienience:
App_GlobalResources
was looking really good.
However by moving you files here, you just lost the possibility to do that cool meta:resourcekey trick. Actually this is because since App_GlobalResources
compiles things to classes. Using a key identifier with punctuations (MyInput.Text) inside your .resx files becomes illegal syntax.
Localizing JavaScript files is still a bit of a bummer.
Even though App_GlobalResources allows you to organize your translations by class, you can add
as many sub-folders as you like in there, still .NET will still only generate a flat namespace for you. Resources.Something, nothing more, nothing less.
And this can be just fine in most cases, but in others can end up with hugely bloated .resx files or tons of KEY entries where one key is very similar
to another but not quite so, etc.
And unfortunately, your translations are still locked to your website. Unless you are willing to add a reference of your website .dll to other assemblies.
That is, if your website even has a .dll that's generated and sharable, which isn't the case with old style website projects.
A little something before moving on to dedicated assemblies
Now what if you could keep using the flexibility of App_LocalResources
, while adding the advantages of App_GlobalResources
?
Well, you can !
It's a bit tedious, but quite simple actually. All you have to do, is select the .resx that represents the base culture, choose properties, and change
the "Custom Tool" to "PublicResXFileCodeGenerator"
And now you can magically do this:
<%= WebApplication1.App_LocalResources.Default_aspx.Title %>
OK, the namespace isn't very pretty, so an additional step you can take is to set the "Custom Tool NameSpace" so something like "MyCompany",
which allows you to invoke it with:
<%= MyCompany.Default_aspx.Title %>
You still lose the ability to do the meta:resourcekey trick, and you still have the problem associated with sharing your translations with other projects.
If you do want to share your website .dll with another project you will also have to set "Build Action" to "Embedded Resource".
This tells the compiler to embed the translations inside the generated dll. And you don't even have to push your .resx files to your production web-server anymore,
since they are now directly inside the final DLL.
However as you saw, it can take quite a bit of manual action here if your site has hundreds of localized files, not to mention unforeseen errors
that can occur if ever you forget to change those settings.
Standalone Assembly
So how about we just put all our translations inside a standalone .dll, where we can organize our translations with as many namespaces as we like,
and gain the possibility to share that .dll with our website, and all our other projects too ?
Like this we can use our translations in our website
to localize our web pages, in our assemblies to return error messages, localize properties or ViewModels. And it even works perfectly in .cshtml Razor files, since everything is compiled.
Now you can do all this:
<%= Resources.Models.User.Pseudo %>
@Resources.Models.User.Pseudo
public string Validate(User user)
{
if (user == null) {
return Resources.Models.User.NotExists;
}
return string.Empty;
}
Nice !
But .. another gotcha here is that like above you will have to set the "Custom Tool" to "PublicResXFileCodeGenerator",
or you translations will be marked as private by .NET by default. And you still can't do the meta:resourcekey trick, and you still have the same problems with JavaScript.
However your translations are now in a standalone assembly where you can efficiently organize your namespaces, not to mention the great advantage you have
to be able to share those translations with all your other projects, as well as being able to use them just as well in html, code-behind, functions & classes.
Let's go for the Ultra-Kill !
As you've seen we have quite a bit of options at our disposal when it comes to localization, each with their advantages and inconveniences.
So can we actually have something perfect, or almost perfect ? Well, sort of. But only if you are willing to code up a custom solution.
Enter: T4 Text Templating
T4 is a nice little technology that actually just boils down to a fancy .bat file with a .tt extension. And instead of executing DOS commands,
you can actually instantiate the entire .NET framework.
Then ..you just start doing WriteLines().
While simple at base, it enables you spit out text, and have that text saved as a .cs file (or any extension you like). You can for example parse a .js file,
apply minification to it, and spit the result out to a .min.js file.
In my case, I'm going to be getting a listing of all those .resx files we have in our project,
instantiate a ResourceManager for each one to be able to extract the KEY and some extra information from them, and then spit out a nice big .cs file
that contains all the namespaces, classes, and keys, in the form of properties so we can combine the best of all the worlds mentioned above.
In addition, since we are already starting to do custom code, we might as well add a bunch of goodies to it, so we can do things we would have
never been able to do using any of the above mentioned methods.
For example i often want to format an error message with a variable.
Other times there are words in the middle of my translations that i want to replace, but with the above solutions this would mean that
I would have to split my text into multiple lines to be able to insert a Pseudo into the middle of a phrase for example. Why not just do "Hello {0}, how are you doing" ?
Also what happens when you have domain names, or brand names in your translations because you are working on some generic project,
and then want to use those same translations in a different project for another customer ? Duplicate all the translations..
Why not just write "Sign up with {DOMAIN} and get the benefits of being a {BRAND} member for only {0}/month !",
and then replace {DOMAIN} & {BRAND} depending on which customer we are dealing with ?
Oh, and our big big problem from the very start. Localized JavaScript ! We've seen how nasty every single solution up to now has been ..can we please just have a
our translations in JSON format whenever we need them ?
T4 makes all this, and much more is possible, downside is you have to code it yourself.
Of course since I'm writing about this, means I've already done most of the heavy lifting for you. And all that's left, is to add a reference
of T4ResX to your "standalone assembly" that contains your .resx files
Then you just need to open the file, followed by a CTRL+S to trigger the build process, and T4ResX.cs will be created.
This code contains quick access to our regular translations, and helper methods to access all the extra goodies mentioned above.
Localized JavaScript now becomes easy, because the generated code contains a small method that (via reflection) is capable of searching our assembly
for properties, and then returning them in the form of a Dictionary<>.
Dictionary<string, Dictionary<string, string>> result = MyAssembly.Resources.Common.GetAsDictionary();
Now once we have that Dictionary<namespace, Dictionary<key, value>, we just need a way to serialize it to JSON, and return the result.
So, now we just need to setup a little helper method in our website that will return the dictionary as JSON so you can start doing script src = ,
and enjoy the benefits of real localized JavaScript.
First we need a way to return JSON, we can do this by creating a new class in our project and declaring a little extension method as follows:
using System.Web.Script.Serialization;
public static partial class ExtensionMethods
{
public static readonly JavaScriptSerializer JavaScriptSerializer = new JavaScriptSerializer();
public static string ToJson(this object value)
{
return JavaScriptSerializer.Serialize(value);
}
}
And in our controller (if using MVC that is), setup a little helper function:
public ActionResult GetNameSpaceAsJs(string ns)
{
return JavaScript(string.Format("var T4ResX = {{ Localization: {0}}};",
Localization.Utilities.GetResourcesByNameSpace(ns).ToJson()));
}
Now all we need to do, is add this to our HTML, and we have localized JavaScript!
<script src="http://www.codeproject.com/home/GetNameSpaceAsJs?ns=OurNamespace"></script>
>Of course I could have auto-generated all the above mentioned helper methods dynamically, but i do not wish to have a reference to System.Web inside
the .tt file at the moment. And the helpers are relatively quick to setup manually.
Almost forgot!
How do you actually switch between cultures in your website ?
I'm not gonna go into the full details because the sample project has some fun stuff for you
to play with. Nonetheless i'll list up your options:
Web.config
<System.Web><Globalization>
- We can declare the culture by default for the user interface (uiCulture)
- And the low level part of the site which impacts date/time/mathematical functions (culture)
- We can also set the culture to auto-detect (uiCulture="auto:en")
- This detects the preferred culture of the user's browser
- There's also an attribute called EnableClientBasedCulture which extracts the culture from the HTTP
Accept-Language
Header of the client browser. But i find it less reliable than setting the first 2 options individually.
- In code you can read
Accept-Language
via a shortcut: Request.UserLanguages
- But again, you can run into problems here..
Page
- On the @Page element we can declare UICulture and/or Culture
Code
- We can set the Thread of the application to whatever culture we like
- System.Threading.Thread.CurrentThread.CurrentUICulture = System.Globalization.CultureInfo.CreateSpecificCulture("en")
- Like in @Page above you can set UICulture and/or Culture inside your OnLoad() event too Page.UiCulture = "en";
- By overriding InitializeCulture() inside a Page
- Actually setting cultures in Pages is a little bit broken, so this is the preferred method in those cases.
- In MVC we have the ability to add custom Filter ?? attributes to our controllers
- For example [LocalizedAttribute]
- It's nice, but you can run into problems, like low level things not being translated, because the attribute kicks in too late.
HttpModule
- We can create a custom HttpModule that sets the Thread of the application to whatever culture we need, based on various input
variables: QueryString, Cookies, User Preferences, etc..
- This is also the most performance efficient method, and triggers early enough so that everything in your site gets localized
- Well ..this, and the web.config method
Hope reading this wasn't too tedious and that you have fun with your future localizations