Trabalhando com múltiplas culturas em aplicações .NET

Leonardo Guilen
6 min readSep 12, 2023

Como criar sistemas que se comunicam com o mundo usando recursos nativos do .NET para globalizar e localizar as suas aplicações.

Photo by Kyle Glenn on Unsplash

Atualmente, a experiência do usuário tem sido um requisito extremamente importante na construção de sistemas e produtos. Para aplicações que serão utilizadas ao redor do mundo, o suporte a múltiplas culturas torna-se algo imprescindível durante a concepção de novos softwares.

No ecossistema do .NET, temos os conceitos de globalização e localização, que são processos que permitem que as aplicações se adaptem às diferentes culturas e idiomas dos seus usuários.

A globalização é a habilidade de construir aplicações que são suportadas e adaptáveis para as mais diferentes culturas, usando os membros de tipos na biblioteca de classes do .NET que retornam valores que refletem as convenções da cultura do usuário atual ou de uma cultura específica.

A localização é a habilidade de localizar a aplicação para uma cultura e região específica, criando traduções para os recursos que a aplicação utiliza em seu interior, como textos, imagens, sons, etc.

Nesse post, trarei exemplos e dicas de como configurar, utilizar e até customizar esses recursos para aplica-los no seu dia-a-dia.

Configurando middleware de localização

Inicialmente, nenhum pacote adicional é necessário.

Para iniciar a configuração, devemos adicionar as dependências bases da funcionalidade de localização.

builder.Services.AddLocalization(opt => opt.ResourcesPath = "Resources");

Caso seja usado os arquivos de recursos (.resx) para salvar as informações de culturas, então a configuração do ResourcesPath é necessária para indicar o caminho desses arquivos para o recurso de localização.

Em seguida, temos que configurar as culturas suportadas e o mecanismo que fornecerá as informações de cultura na aplicação.

builder.Services.Configure<RequestLocalizationOptions>(opt =>
{
var localizationOptions = builder.Configuration.GetRequiredSection("LocalizationOptions");

var defaultCulture = localizationOptions.GetValue<string>("DefaultCulture");
var supportedCultures = localizationOptions.GetSection("SupportedCultures").Get<string[]>();

_ = opt
.SetDefaultCulture(defaultCulture!)
.AddSupportedCultures(supportedCultures!)
.AddSupportedUICultures(supportedCultures!);

opt.ApplyCurrentCultureToResponseHeaders = true;
opt.RequestCultureProviders.Clear();
opt.RequestCultureProviders.Add(new AcceptLanguageHeaderRequestCultureProvider());
});

Note que na configuração acima, a cultura será obtida pelo provedor de cultura via cabeçalho da requisição (AcceptLanguageHeaderRequestCultureProvider). Com isso, o mecanismo de localização olhará para o valor recebido no cabeçalho Accept-Language de cada requisição para atribuir a cultura.

E por ultimo, mas não menos importante, registrar o middleware RequestLocalizationMiddleware para que as informações da cultura sejam automaticamente atribuídas baseado em cada requisição.

app.UseRequestLocalization(app.Services.GetRequiredService<IOptions<RequestLocalizationOptions>>().Value);

A configuração completa fica da seguinte forma:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddLocalization(opt => opt.ResourcesPath = "Resources");
builder.Services.Configure<RequestLocalizationOptions>(opt =>
{
var localizationOptions = builder.Configuration.GetRequiredSection("LocalizationOptions");

var defaultCulture = localizationOptions.GetValue<string>("DefaultCulture");
var supportedCultures = localizationOptions.GetSection("SupportedCultures").Get<string[]>();

_ = opt
.SetDefaultCulture(defaultCulture!)
.AddSupportedCultures(supportedCultures!)
.AddSupportedUICultures(supportedCultures!);

opt.ApplyCurrentCultureToResponseHeaders = true;
opt.RequestCultureProviders.Clear();
opt.RequestCultureProviders.Add(new AcceptLanguageHeaderRequestCultureProvider());
});

var app = builder.Build();

app.UseRequestLocalization(app.Services.GetRequiredService<IOptions<RequestLocalizationOptions>>().Value);

app.Run();

Aplicando os recursos de globalização e localização

Primeiramente, adicionaremos as culturas que iremos suportar nas configurações da aplicação (appsettings.json).

{
"LocalizationOptions": {
"DefaultCulture": "en",
"SupportedCultures": [
"en",
"en-US",
"es",
"es-ES",
"pt",
"pt-BR"
]
}
}

Dica: Sempre adicione suporte as culturas independentes (en, es, pt, etc.), mais conhecidas como InvariantCulture. Pois serviram de fallback, caso uma cultura mais especifica não exista ou não tenha suporte.

Próximo passo é criar os arquivos de recursos correspondentes a cada cultura.

Dentro de uma pasta chamada Resources, devemos criar os arquivos com a seguinte nomenclatura: <nome-recurso>.<cultura>.resx
Exemplo: Messages.en-US.resx

O esperado é algo semelhante a imagem abaixo.

Se estiver usando o Visual Studio, ao clicar em um dos arquivos entrará no modo de edição. Com o modo de edição aberto, podemos salvar as informações no padrão chave-valor.

Com tudo isso configurado, podemos criar um endpoint para testar tudo isso.

app.Map("/info", ([FromServices] IStringLocalizerFactory localizerFactory) =>
{
var culture = CultureInfo.CurrentCulture;
var localizer = localizerFactory.Create("Messages", "<namespace>");

return Results.Ok(new
{
Culture = culture.DisplayName,
Message = localizer.GetString("GreetingMessage").Value,
Currency = culture.NumberFormat.CurrencySymbol,
DateTime = DateTimeOffset.UtcNow.ToString($"{culture.DateTimeFormat.ShortDatePattern} {culture.DateTimeFormat.LongTimePattern}"),
});
});

Precisamos injetar a interface IStringLocalizerFactory para construir nosso localizador através do método Create, onde fornecemos o prefixo do nome do arquivo de recurso e o namespace do projeto.

Após isso, temos um localizador configurado. E tudo que precisamos fazer é usar o método GetString com a chave do valor que queremos obter.

Para testar, vamos executar a aplicação e chamar o endpoint via curl

curl -i "https://localhost:<porta>/info" -H "Accept-Language: en-US"

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Mon, 11 Sep 2023 22:59:51 GMT
Server: Kestrel
Content-Language: en-US
Transfer-Encoding: chunked

{"culture":"English (United States)","message":"Welcome","currency":"$","dateTime":"9/11/2023 10:59:51 PM"}

E voilà! Temos uma aplicação preparada para receber novas culturas e rodar no mundo todo.

Customizando o localizador de strings

Nos exemplos apresentados, vimos como é fácil usar o middleware de localização e os arquivos de recursos para traduções.
Entretanto, se ao invés de utilizar esses arquivos, quiséssemos ter essas informações em tabelas de um banco de dados. É possível? Seria fácil fazer essa adaptação?

A resposta é sim, é possível. Porém, a complexidade dependeria de como seria estruturado as tabelas e afins.

Para poder exemplificar a customização, foi montado uma estrutura de tabelas bem simples.

Depois de estruturado as tabelas, vamos criar uma implementação customizada do IStringLocalizer chamada SqlStringLocalizer. Essa classe será responsável em executar as consultas para obter os textos na cultura atual.

internal sealed class SqlStringLocalizer : IStringLocalizer
{
private const string _getTextQuery =
@"
SELECT text
FROM {0}
WHERE
key = @key AND
culture_code IN (@culture, @parentCulture)
ORDER BY culture_code DESC
LIMIT 1;
";

private const string _getAllQuery =
@"
SELECT key, text
FROM {0}
WHERE culture_code LIKE @searchableCulture
";

private readonly string _baseName;
private readonly IDbContext _dbContext;

public SqlStringLocalizer(
string baseName,
IDbContext dbContext)
{
_baseName = baseName;
_dbContext = dbContext;
}

public LocalizedString this[string name]
{
get
{
var culture = CultureInfo.CurrentCulture;
var parentCulture = culture.Parent.TwoLetterISOLanguageName;

var value = _dbContext
.Connection
.QuerySingleOrDefaultAsync<string?>(
sql: string.Format(_getTextQuery, _baseName),
param: new
{
baseName = _baseName,
key = name,
culture = culture.Name,
parentCulture
})
.GetAwaiter()
.GetResult();

return new LocalizedString(name, value ?? name, string.IsNullOrWhiteSpace(value));
}
}

public LocalizedString this[string name, params object[] arguments]
{
get
{
var localizedString = this[name];
return new LocalizedString(name, string.Format(localizedString.Value, arguments), localizedString.ResourceNotFound);
}
}

public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
{
var culture = CultureInfo.CurrentCulture;
var searchableCulture = includeParentCultures ? $"{culture.Parent.TwoLetterISOLanguageName}%" : culture.Name;

var values = _dbContext
.Connection
.QueryAsync<(string Key, string Text)>(
sql: string.Format(_getAllQuery, _baseName),
param: new
{
baseName = _baseName,
searchableCulture
})
.GetAwaiter()
.GetResult();

return values.Select(v => new LocalizedString(v.Key, v.Text, string.IsNullOrEmpty(v.Text)));
}
}

Além disso, precisamos criar uma implementação customizada para o IStringLocalizerFactory também. Então criaremos uma classe chamada ExtendedStringLocalizerFactory que implementa os métodos necessários para construir um localizador respeitando uma nova regra.

internal sealed class ExtendedStringLocalizerFactory : IStringLocalizerFactory
{
public const string DatabaseLocation = "Database";

private readonly IDbContext _dbContext;
private readonly Lazy<ResourceManagerStringLocalizerFactory> _resourceManagerStringLocalizerFactory;

public ExtendedStringLocalizerFactory(
IOptions<LocalizationOptions> localizationOptions,
ILoggerFactory loggerFactory,
IDbContext dbContext)
{
_dbContext = dbContext;
_resourceManagerStringLocalizerFactory = new(() => new ResourceManagerStringLocalizerFactory(localizationOptions, loggerFactory));
}

public IStringLocalizer Create(
string baseName,
string location)
=> DatabaseLocation.Equals(location, StringComparison.OrdinalIgnoreCase)
? new SqlStringLocalizer(baseName, _dbContext)
: _resourceManagerStringLocalizerFactory.Value.Create(baseName, location);

public IStringLocalizer Create(Type resourceSource)
=> _resourceManagerStringLocalizerFactory.Value.Create(resourceSource);
}

Agora, devemos configurar esse ExtendedStringLocalizerFactory como implementação do IStringLocalizerFactory no nosso container de dependências.

builder.Services.AddSingleton<IStringLocalizerFactory, ExtendedStringLocalizerFactory>();

E pronto! Se testarmos novamente fazendo um curl, devemos receber um retorno de sucesso.

curl -i "https://localhost:<porta>/info" -H "Accept-Language: pt-BR"

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Tue, 12 Sep 2023 00:38:32 GMT
Server: Kestrel
Content-Language: pt-BR
Transfer-Encoding: chunked

{"culture":"português (Brasil)","message":"Bem-vindo","currency":"R$","dateTime":"12/09/2023 00:38:33"}

Conclusão

A globalização e a localização são processos importantes para o desenvolvimento de aplicações .NET que precisam atender às necessidades e preferências de usuários de diferentes idiomas e regiões. Ao globalizar e localizar às aplicações, os desenvolvedores podem criar produtos mais acessíveis, inclusivos e competitivos no mercado global.

--

--

Leonardo Guilen

Software Engineer focused on backend technologies, DevOps and Cloud. Researcher of the best coding standards, performance optimization and security enhancements