I have been developing shrinkwrapped software products for most
of my professional career. Therefore, I have an abiding respect for internationalization
and localization. In fact, it is something of a running joke to warn new developers
who join not to kick me off on the subject. Of course, they will hear from me
soon enough....
Shrinkwrapped products can end up in a bewildering set of environments
and countries. Simply saying that your product will only run on {insert OS}
with {insert language, usually US English} is not a recipe for success. One
of the aspects of .NET that I really like is the sheer pervasiveness of support
for globalization (as it is known in .NET).
Utilizing the support in .NET for satellite assemblies is a powerful
way to approach this problem. The essential idea behind satellite assemblies
is to isolate localizable resources from your main application, and from each
other. If you thoroughly isolate your resources, then you will not need to recompile
your application code in order to support any new culture. (Essentially a culture
represents a language, plus country or region). This is important when you consider
that most of the localization work for an application follows after the main
development period.
I have looked at a number of references, but nothing seemed to
explicitly set out exactly how to localize a standard class. So I dug into the
localization model of WinForms to understand it (thank you ILDASM) and this
is a distillation of what I learned.
Important: The technique described here is only
appropriate for class libraries. Applications require resources to be embedded.
Part 1: The Default Resource
First, let me show the implementation of a default resource.
It is imperative that your application has a default resource. This is one culture
for the framework to fallback on gracefully if the appropriate satellite assemblies
are missing.
This example contains a base Person class
with a single accessor Name, and a derived Employee
class:
Figure 1: The design model
Figure 2: The code 1.0 (boiler plate classes)
[C#]
using System;
namespace CompanyName.SatelliteAssemblyDemo
{
// Base class
public class Person
{
// Member variable
private string theName;
// Accessors
public string Name
{
get
{
return theName;
}
set
{
theName = value;
}
}
}
// Derived class
public class Employee: Person
{
}
}
|
[VB]
' Base class
Public Class Person
' Member variable
Private theName As String
' Accessor
Public Property Name()
Get
Return Me.theName
End Get
Set(ByVal Value)
Me.theName = Value
End Set
End Property
End Class
' Derived class
Public Class Employee : Inherits Person
End Class
|
The obvious way to implement a default name value is to construct
the member variable with a constant string value.
Figure 3: The code 1.1 (constant default value)
[C#]
// Base class
public class Person
{
...
// Constant
private const string theDefaultName = "New Person";
// Constructors
public Person(): this(theDefaultName)
{}
public Person(string aName)
{
this.Name = aName;
}
...
}
// Derived class
public class Employee: Person
{
// Constant
private const string theDefaultName = "New Employee";
// Constructors
public Employee(): base(theDefaultName)
{}
public Employee(string aName): base(aName)
{}
}
|
[VB]
' Base class
Public Class Person
' Constant
Private Const theDefaultName As String = "New Person"
...
' Constructors
Public Sub New()
Me.New(theDefaultName)
End Sub
Public Sub New(ByVal aName As String)
Me.theName = aName
End Sub
...
End Class
' Derived class
Public Class Employee : Inherits Person
' Constant
Private Const theDefaultName As String = "New Employee"
...
' Constructors
Public Sub New()
Me.New(theDefaultName)
End Sub
Public Sub New(ByVal aName As String)
MyBase.theName = aName
End Sub
...
End Class
|
There are a number of problems with this:
- The values are embedded in the code, which this makes maintenance difficult.
- The values will be the same regardless of the culture. The French are certain
to notice that every Nouvelle Personne is being called a New
Person.
- The temptation will be to implement in-line code to handle support for localization
to any new cultures.
A better approach is to use the globalization support built into
the .NET framework. The following code demonstrates the use of the ResourceManagerclass:
Figure 4: The code 1.2 (implement ResourceManager)
[C#]
...
using System.Reflection;
using System.Resources;
...
// Base class
public class Person
{
...
// Constant
private const string theDefaultName = "New Person";
private const string theResourceBaseName = "CompanyName.SatelliteAssemblyDemo.Person";
// Static method
private static string defaultName()
{
ResourceManager _ResourceManager = new ResourceManager(theResourceBaseName, Assembly.GetExecutingAssembly());
return _ResourceManager.GetString("defaultName", Thread.CurrentThread.CurrentUICulture);
}
// Constructors
public Person(): this(theDefaultName)
public Person(): this(defaultName())
{}
...
}
// Derived class
public class Employee: Person
{
// Constant
private const string theDefaultName = "New Employee";
private const string theResourceBaseName = "CompanyName.SatelliteAssemblyDemo.Employee";
// Static method
private static string defaultName()
{
ResourceManager _ResourceManager = new ResourceManager(theResourceBaseName, Assembly.GetExecutingAssembly());
return _ResourceManager.GetString("defaultName", Thread.CurrentThread.CurrentUICulture);
}
// Constructors
public Employee(): base(theDefaultName)
public Employee(): base(defaultName())
{}
}
|
[VB]
...
Imports System.Reflection;
Imports System.Resources;
...
' Base class
Public Class Person
' Constant
Private Const theDefaultName As String = "New Person"
Private Const theResourceBaseName As String = "Demo.Logic.Person"
' Static method
Private Shared Function defaultName() As String
Dim _ResourceManager As ResourceManager
_ResourceManager = New ResourceManager(theResourceBaseName, [Assembly].GetExecutingAssembly())
Return _ResourceManager.GetString("defaultName")
End Function
' Constructors
Public Sub New()
Me.New(theDefaultName)
Me.New(defaultName())
End Sub
...
End Class
' Derived class
Public Class Employee : Inherits Person
' Constant
Private Const theDefaultName As String = "New Employee"
Private Const theResourceBaseName As String = "Demo.Logic.Employee"
' Static method
Private Shared Function defaultName() As String
Dim _ResourceManager As ResourceManager
_ResourceManager = New ResourceManager(theResourceBaseName, [Assembly].GetExecutingAssembly())
Return _ResourceManager.GetString("defaultName")
End Function
' Constructors
Public Sub New()
Me.New(theDefaultName)
Me.New(defaultName())
End Sub
...
End Class
|
In order to 'complete' this code, the minimum needed is to have
an embedded resource for the assembly. This is in order to have at least one
resource set available for the ResourceManager class.
This resource is known as the culture-neutral resource, and it is used as the
failsafe for resource operations. Simply add a new Assembly Resource File called
Person.resx to the project and add the following entry
into the data table:
name = defaultName
value = New Person
Then add an Employee.resx to the project
and add
name = defaultName
value = New Employee
These are now the default resources for the Person
and Employee classes.
For each new class requiring localization, add an assembly resource
file to the solution with the same name as the class.
If you view the compiled assembly using ILDASM, there will be a
section for each resource in the manifest:
Figure 5: Assembly culture-neutral resources viewed in ILDASM
.mresource public CompanyName.SatelliteAssemblyDemo.Person.resources
{ }
.mresource public CompanyName.SatelliteAssemblyDemo.Employee.resources
{ }
|
Note that from a practical point of view, this is enough to carry
on with your main development, extending the resource data table as required.
Of course, data tables can contain other types than strings. I have just demonstrated
a simple example.
Part 2 will show how to manage and compile the resources for additional
cultures. The important point is that you will not need to open up vs .NET to
do this, so you won't need to recompile your code when the sales team comes
over to tell you that they want a Klingon version of the software (don't laugh
too hard, Unicode has a Klingon range...)
Part 2: The Satellite Resources
Time has passed, development has continuedn and now you have to
support localization. Please note that you do not have to run vs .NET at all
for this part.
If you look at the contents of your .resx files, you will find
that internally they are simply XML. The sections created in Part 1 will look
like the following:
Figure 6: Resources file XML
...
<data name="defaultName">
<value>New Person</value>
</data>
...
|
Currently there are only the default resources, so in order to
localize you will need to make new .resx files.
The naming pattern for resx files is
ClassName.Culture.resx
The first localization is language. I find it easiest to use my
culture-neutral resources file as a template, then edit it to a culture. So,
for our example,
Copy Person.resx
to Person.en.resx, Person.fr.resx,
etc.
Copy Employee.resx
to Employee.en.resx, Employee.fr.resx,
etc.
The second localization is to country or region. So for our example,
Copy Person.en.resx
to Person.en-GB.resx, Person.en-US.resx,
etc.
Copy Person.fr.resx
to Person.fr-FR.resx, Person.fr-CA.resx,
etc.
and so on...
These files can then be distributed to localizers for translation.
The files then need to be compiled to the appropriate satellite assemblies.
Compilation is a two-phase process:
- Compile the .resx file to a .resource file with the RESGEN (resource generator) utility.
- Compile one or more .resource files to an assmbly with the AL (assembly linker) utility.
The assemblies have to be emitted to correctly named subdirectories
of the main assembly. Each subdirectory is simply named by culture (e.g., ...\en\,
...\en-GB\, ...\fr\, ...\fr-FR\, etc.). The satellite assembly has the same
name as the main assembly, but with .resources immediately before the
file extension.
For our example, the file structure will look like the following:
- ApplicationDir\CompanyName.ResourceManagerDemonstration.dll
- ApplicationDir\en\CompanyName.ResourceManagerDemonstration.resources.dll
- ApplicationDir\en-GB\CompanyName.ResourceManagerDemonstration.resources.dll
- ApplicationDir\en-US\CompanyName.ResourceManagerDemonstration.resources.dll
- ApplicationDir\fr\CompanyName.ResourceManagerDemonstration.resources.dll
- ApplicationDir\fr-CA\CompanyName.ResourceManagerDemonstration.resources.dll
- ApplicationDir\fr-FR\CompanyName.ResourceManagerDemonstration.resources.dll
RESGEN and AL have fairly complex switches available, so here is
just an example batch file for compilation of English resources for the Person
and Employee classes:
Figure 7: Sample compilation batch file
MD "bin\Release\en"
RESGEN Person.en.resx obj\Release\CompanyName.ResourceManagerDemonstration.Person.en.resources
RESGEN Employee.en.resx obj\Release\CompanyName.ResourceManagerDemonstration.Employee.en.resources
AL /t:lib /culture:en
/embed:obj\Release\CompanyName.ResourceManagerDemonstration.Person.en.resources,CompanyName.ResourceManagerDemonstration.Person.en.resources
/embed:obj\Release\CompanyName.ResourceManagerDemonstration.Employee.en.resources,CompanyName.ResourceManagerDemonstration.Employee.en.resources
/template:bin\Release\CompanyName.ResourceManagerDemonstration.dll
/out:bin\Release\en\CompanyName.ResourceManagerDemonstration.resources.dll
|
When these satellite assemblies are present, the ResourceManager
class will be able to provide the correct resource set for the thread culture.
If the appropriate culture is missing, then the framework will gracefully fall
back to the default culture.
The sample code in both VB and C# is available here