In the recent four years I did quite a bit of web development also using Microsoft's new web framework called Blazor. Blazor adds component-first support to ASP.NET by introducing "Razor components". With Razor components Blazor is capable of providing a full single-page application framework.
Since .NET already comes with options for localization and internationalization out of the box it makes sense to start our journey there.
.NET Support for i18n
In developing for .NET, localization and internationalization are essential concepts for creating applications that can adapt to different languages, cultures, and regions.
Let's look at these two concepts.
Internationalization (i18n)
i18n is the process of designing and developing software in a way that makes it capable of adapting to different languages and regions without requiring significant changes to the underlying codebase. In .NET, internationalization involves separating the user interface elements, such as strings and images, from the core application logic.
Key aspects of i18n in .NET include:
- Resource files: .NET applications typically use resource files (.resx files) to store localized strings, images, and other culture-specific content. These resource files allow us to maintain different versions of the content for each supported language or culture.
- Culture-specific formatting: .NET provides classes like
CultureInfo
andNumberFormatInfo
to handle culture-specific formatting of dates, numbers, currencies, and other data types. - Unicode support: .NET framework fully supports Unicode, which enables us to handle a wide range of languages and character sets.
Localization (l10n)
l10n is the process of adapting a software application to a specific language and culture by providing translated content and customizing certain aspects of the user experience to align with cultural expectations. In .NET, localization involves creating and maintaining separate resource files for each target language and culture.
Key aspects of l10n in .NET include:
- Translation of resources: Developers translate the content of resource files (strings, messages, labels, etc.) into the target languages.
- Cultural adaptations: Besides language translation, localization may involve adapting aspects such as date and time formats, currency symbols, text direction, and other cultural conventions.
- Regional settings: .NET applications can use regional settings provided by the
CultureInfo
class to adjust behavior based on the user's locale, such as date and time formats, number formats, and calendar systems.
Internationalization focuses on making the application capable of supporting multiple languages and cultures, while localization involves the actual adaptation of the application to specific languages and cultural norms.
Together, these practices us to create software that can effectively serve users from diverse linguistic and cultural backgrounds.
Example
Let's consider a simple example of a .NET application—a weather forecasting app—that we want to i18n and l10n.
First, we would create resource files to store strings and other culture-specific content. For example, we might have a Strings.resx file for the default (e.g., English) language and additional resource files like Strings.fr.resx for French, Strings.de.resx for German, and so on.
In each resource file, we would provide translations for the strings used in the application. For instance, in Strings.fr.resx, we'd translate "Weather Forecast" to "Prévisions météorologiques" and "Temperature" to "Température."
Subsequently, we would use the CultureInfo
class to format dates, numbers, and currencies according to the user's culture. For instance, we might use CultureInfo.CurrentCulture
to determine the user's preferred culture and format temperature values and date formats accordingly.
We might need to adjust certain aspects of the application's UI and behavior to align with cultural norms. For example, if the application displays dates, we'd format them using the appropriate date format for each culture. In French culture, dates are typically displayed in the format "dd/MM/yyyy" instead of "MM/dd/yyyy" used in the US culture.
Here's a simplified code snippet demonstrating how we might implement internationalization and localization in our weather forecasting app:
@page "/weather"
@inject IStringLocalizer<WeatherApp> _localizer
<h1>@_localizer["WeatherForecast"]</h1>
<p>@_localizer["Temperature"]: @FormattedTemperature</p>
@code {
[Inject] protected IJSRuntime JSRuntime { get; set; }
protected string FormattedTemperature { get; set; }
protected override async Task OnInitializedAsync()
{
// Simulate fetching temperature data
double temperature = await GetTemperatureFromAPI();
// Format temperature value using current culture
FormattedTemperature = temperature.ToString("N1", CultureInfo.CurrentCulture);
}
private async Task<double> GetTemperatureFromAPI()
{
// Simulated API call to fetch temperature data
// In a real-world scenario, we would make an actual HTTP request
await Task.Delay(100); // Simulate delay
// Return example temperature
return 25.5;
}
}
The IStringLocalizer<T>
is a service that can be used to localize a string to the current locale. We inject the service to allow us render locale-aware strings within the component.
Inside the component, we use the _localizer["Key"]
syntax to access localized strings. The Key corresponds to the key used in the resource files for localization.
The FormattedTemperature
property is bound to display the formatted temperature value. In the OnInitializedAsync
method, we simulate fetching temperature data from an API. Once the temperature data is retrieved, we format it using the current culture before displaying it.
For making this example work you need to ensure that you have set up the necessary resource files for localization, such as WeatherApp.resx for the default language and WeatherApp.fr.resx, WeatherApp.de.resx, etc., for other languages. These resource files should contain translations for the respective languages. Blazor will automatically select the appropriate resource file based on the user's preferred language settings.
But how does it do it?
Satellite Assemblies
In .NET localization, satellite assemblies are a crucial concept for organizing and managing localized resources. When we compile a .NET application, the compiler embeds all non-localizable resources (such as code files, images, etc.) into the main assembly. However, for localized resources like strings, messages, and other culture-specific content, .NET uses satellite assemblies.
Here's how satellite assemblies work and why they're important:
Separation of Localizable Resources: Satellite assemblies allow us to separate localizable resources from the main application assembly. This separation is essential because it enables us to update or add new translations without modifying the main application code or assembly.
Efficient Deployment: By using satellite assemblies, we can deploy a single main application assembly along with multiple satellite assemblies, each containing resources for a specific language or culture. This approach makes deployment more efficient because we only need to distribute the necessary satellite assemblies for the languages supported by our application.
Dynamic Loading: .NET runtime can dynamically load the appropriate satellite assembly based on the user's preferred culture or the culture specified in the application's configuration. This means that when a user with a specific language preference launches the application, .NET automatically loads the corresponding satellite assembly containing the localized resources for that language.
Resource Lookup: When an application requests a localized resource, .NET searches for that resource first in the main assembly. If the resource is not found there, it looks for it in the satellite assemblies corresponding to the user's preferred culture. This process ensures that the application retrieves the correct localized content based on the user's language or culture settings.
Optimization: Satellite assemblies allow for optimization in terms of resource management. Since each satellite assembly contains resources specific to a particular language or culture, only the necessary resources for each language are kept, reducing the overall size of an application's deployment package.
Satellite assemblies provide a flexible and efficient mechanism for managing localized resources in .NET applications, enabling us to create multilingual applications that can adapt to the preferences of users from different linguistic and cultural backgrounds.
We'll need to use the information about satellite assemblies when we want to implement dynamic language changes.
Change Language on the Fly
Out of the box, Blazor uses a static model for language detection and display. This means, that the language is selected when Blazor (WebAssembly) starts up. How is the language then decided?
The following steps are followed to decide on the language where Blazor (WebAssembly) runs in:
- The start options are evaluated. These can be supplied when the autostart is turned off. The options can have a key
applicationCulture
which is a string following the BCP 47 format. - In case no culture was specified the
Accept-Language
header of the document is used to read out the set of languages to use - the first one to be available in the application is then taken.
In general, the behavior can be overridden within Blazor by setting the locale explicitly:
CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("en-US");
CultureInfo.DefaultThreadCurrentUICulture = new CultureInfo("en-US");
In this case we'll have an issue when we want to apply this while the application is running, i.e., when the language change was triggered within the application / when we already render components on the screen.
For the Blazor integration in Piral we required a automatic (or dynamic) refresh of the language, i.e., while Blazor is running. This was required as the rest of the application is usually developed with other frameworks such as Angular or React - all classic SPA frameworks that are capable of refreshing while the application is running. It would be odd if everything changes immediately - except the components rendered from Blazor.
What can we do to automatically refresh also these components? We can introduce an abstraction:
public static class Localization
{
public static event EventHandler LanguageChanged;
public static string Language
{
get
{
return CultureInfo.CurrentCulture.Name;
}
set
{
var culture = CultureInfo.GetCultures(CultureTypes.AllCultures).FirstOrDefault(c => c.Name == value);
if (culture is not null)
{
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
}
LanguageChanged?.Invoke(null, EventArgs.Empty);
}
}
}
Using this class we have a central point within our Blazor application to handle a language change. Using this we can write components that handle the language change automatically:
@inject IStringLocalizer<MyComponent> loc
<h2>@loc["greeting"]</h2>
@code {
protected override void OnInitialized()
{
Localization.LanguageChanged += (s, e) => this.StateHasChanged();
base.OnInitialized();
}
}
There is, however, one more thing that we need to take care of: Loading the respective strings. When initializing, Blazor loads the needed (satellite) assemblies. Unfortunately, in the case we are now constructing this phase would already be passed and these resources would not be available. Therefore, we need to load these assemblies ourselves.
This process could look as follows (taken from Piral.Blazor):
public sealed class LanguageService
{
private readonly Uri _baseUrl;
private readonly HttpClient _client;
private readonly List<string> _loadedLanguages = [];
private readonly Dictionary<string, List<string>> _satellites;
public LanguageService(HttpClient client, string baseUrl, Dictionary<string, List<string>> satellites)
{
_client = client;
_baseUrl = baseUrl;
_satellites = satellites;
}
public async Task LoadLanguage(string language)
{
if (!_loadedLanguages.Contains(language))
{
_loadedLanguages.Add(language);
if (_satellites?.TryGetValue(language, out var satellites) ?? false)
{
foreach (var satellite in satellites)
{
var url = GetUrl(satellite);
var dep = await _client.GetStreamAsync(url);
AssemblyLoadContext.Default.LoadFromStream(dep);
}
}
// we also support loading region specific languages, e.g., "de-AT"
// in this case we also need to take the "front" to load all satellites
var idx = language.IndexOf('-');
if (idx != -1)
{
var primaryLanguage = language.Substring(0, idx);
await LoadLanguage(primaryLanguage);
}
}
}
public string GetUrl(string localPath)
{
if (localPath.StartsWith("/") && !localPath.StartsWith("//"))
{
localPath = localPath.Substring(1);
}
return new Uri(_baseUrl, localPath).AbsoluteUri;
}
}
So we pass in the necessary base information (URL, available satellites for each language) and the HttpClient
for loading the satellite assemblies. Using this we either load the missing satellite assemblies or take the already loaded ones.
Conclusion
One of the key advantages of Blazor is that it is standing on the foundations of .NET, which already figured out pretty much all crucial aspects of a standard line of business application.
This way, things like i18n or l10n are fully embedded and can just be used as they have been beforehand. This increases our productivity as we don't have to learn something new.
By leveraging interesting ideas such as dynamic language changes we can also provide much more dynamic experiences as beforehand - making our applications not only interesting from a content point of view, but highly-interactive as well.
Top comments (0)