Internacionalizando aplicações ASP.NET Core – Parte 2: Customizando (um pouco mais) a Globalização
No artigo anterior, criamos uma nova aplicação e implementamos a globalização e a localização conforme o framework nos permite. Como o gerenciamento dos arquivos de localização nativos não é dos melhores, criamos uma implementação customizada para facilitar esse gerenciamento.
Neste artigo, vamos customizar um pouco mais a nossa aplicação. O objetivo é fazer com que a escolha do idioma atual seja algo mais parecido com o que é utilizado na maioria das aplicações e também simplificar (reduzindo o risco de erros) o processo de resgatar os termos traduzidos.
Atualmente, nossa aplicação está utilizando o QueryStringRequestCultureProvider para identificar o idioma. Isso quer dizer que, se quisermos acessar uma rota, por exemplo /Posts, em português, devemos adicionar a ela a querystring ?culture=pt-BR, ficando /Posts?culture=pt-BR. Não seria melhor (e mais condizente com a grande maioria das outras aplicações da internet) se o idioma fosse informado diretamente pela rota, por exemplo, /pt-BR/Posts?
Criando um novo CultureProvider
Para utilizar a abordagem acima para identificar o idioma, precisamos criar um novo CultureProvider que analise a rota acessada pelo usuário e configure o idioma informado como o atual na aplicação.
Antes de criar o provider, precisamos configurar a aplicação para reconhecer as rotas iniciando pelo código da cultura. Vamos definir a regra que, se o usuário acessar uma rota sem informar a cultura, a aplicação deve acessá-la utilizando a cultura definida como padrão. Para isso acontecer, deixaremos a rota padrão sem ser modificada (para tratar as rotas sem cultura) e adicionaremos uma nova rota, somente adicionando a cultura como parâmetro. A configuração ficará da seguinte maneira:
app.UseMvc(routes => { routes.MapRoute( name: "default-culture", template: "{culture}/{controller=Home}/{action=Index}/{id?}"); routes.MapRoute( name: "default", template: "{controller=Home}/{action=Index}/{id?}"); });
Se executarmos a aplicação, conseguimos acessar a página “Index” tanto pela url / quanto pela url /pt-BR.
Agora precisamos, de alguma maneira, recuperar a url que está sendo acessada, recuperar o código da cultura (se houver) e indicar para a aplicação utilizar a cultura que foi recuperada. Como ponto de partida, podemos analisar a fonte da classe QueryStringRequestCultureProvider que estávamos utilizando anteriormente.
Ao abrir a classe, podemos notar que ela faz um “override” em uma função chamada DetermineProviderCultureResult, sendo que esse método recebe uma variável do tipo HttpContext (informações sobre a requisição HTTP) como parâmetro e deve retornar o tipo ProviderCultureResult (uma classe que diz qual a cultura foi identificada pelo provider). Parece algo que podemos utilizar…
Se olharmos a classe que está sendo herdada pelo provider, RequestCultureProvider, temos o seguinte texto de sumário:
“An abstract base class provider for determining the culture information of an Microsoft.AspNetCore.Http.HttpRequest”
Uma classe provider base abstrata para determinar a informação de cultura a partir de um HttpRequest. É exatamente o que precisamos!
Vamos para nossa pasta Resources e nela criaremos uma classe chamada RouteRequestCultureProvider, que herdará de RequestCultureProvider. Ela deverá ficar da seguinte maneira:
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Localization; using Microsoft.Extensions.Primitives; using System.Text.RegularExpressions; using System.Threading.Tasks; namespace Globalization.Resources { public class RouteRequestCultureProvider : RequestCultureProvider { public override Task<ProviderCultureResult> DetermineProviderCultureResult(HttpContext httpContext) { string pattern = @"^\/([a-z]{2}-[A-Z]{2})"; Match match = Regex.Match(httpContext.Request.Path, pattern); if (!match.Success || match.Groups.Count < 2) return NullProviderCultureResult; string culture = match.Groups[1].Value; return Task.FromResult(new ProviderCultureResult(new StringSegment(culture))); } } }
Na nossa implementação, recuperamos o “path” que está sendo acessado pelo usuário (tudo após o domínio, ex: em http://localhost/pt-BR/Home/Index, o path é /pt-BR/Home/Index) e testamos utilizando uma regex, que valida se a “string” começa com uma “/”, seguida de duas letras minúsculas, traço, duas letras maiúsculas e sendo essa string capturada em um grupo de captura. Caso a regex não ache nenhuma string no formato ou somente retorne um grupo de captura (o regex automaticamente coloca o resultado em um grupo e nosso “pattern” define um segundo), retornamos NullProviderCultureResult, que segundo a nossa classe base:
“Result that indicates that this instance of Microsoft.AspNetCore.Localization.RequestCultureProvider could not determine the request culture”
Caso contrário, retornamos a uma nova instância de ProviderCultureResult, informando a cultura que identificamos na url. Após a criação do provider, altere o arquivo Startup.cs trocando o QueryStringRequestCultureProvider para a nossa classe RouteRequestCultureProvider.
Vamos executar nossa aplicação e checar se tudo continua funcionando como deveria.
Tudo ok! Se acessarmos a aplicação sem informar um código de cultura, nos é apresentada a versão com a cultura padrão configurada (en-US). Ao adicionarmos o código da cultura na url, é apresentada a versão da aplicação com a cultura desejada.
Arquivo Resource fortemente tipado
Para acessarmos os termos de nosso arquivo de tradução, precisamos resgatar a instância de IStringLocalizer e, em seguida, informar (via “string”) qual a chave do termo que queremos. Dessa maneira, se digitarmos errado uma chave, não teremos nenhum feedback da IDE ou do compilador desse nosso erro. Para tentar evitar esse problema, vamos criar uma classe que terá todas as chaves do arquivo de tradução como propriedades.
Na pasta Resources, crie uma classe chamada Resource e adicione o código:
using Microsoft.Extensions.Localization; namespace Globalization.Resources { public static class Resource { private static IStringLocalizer stringLocalizer = null; public static string Index_Page_Title => stringLocalizer["Index_Page_Title"]; public static string Welcome_Title => stringLocalizer["Welcome_Title"]; } }
Nessa classe, temos uma instância de IStringLocalizer (ainda não setada) e criamos manualmente (por enquanto) as propriedades que retornam os valores das chaves de nosso arquivo de tradução.
Primeiro vamos resolver a instância de IStringLocalizer. Devemos de algum modo obter a implementação da interface via DI e atribuir essa implementação na variável stringLocalizer (a declaramos como private a fim de evitar atribuições de valores não previstas).
Um bom local para se atribuir o valor à variável é a função Configure da classe Startup. Ela será executada somente quando a aplicação for inicializada e nos permite o acesso aos serviços configurados no DI. Vamos criar uma nova extensão no arquivo Resources/JsonLocalizerExtensions.cs para efetuar a atribuição da instância de nossa classe de resource.
public static void UseStaticJsonLocalization(this IApplicationBuilder app) { var stringLocalizer = app.ApplicationServices.GetService<IStringLocalizer>(); MemberInfo[] membersInfo = typeof(Resource).GetMember("stringLocalizer", MemberTypes.Field, BindingFlags.NonPublic | BindingFlags.Static); if (!membersInfo.Any()) return; ((FieldInfo)membersInfo.First()).SetValue(null, stringLocalizer); }
Nessa nova extensão, recuperamos a instância configurada de IStringLocalizer e, via Reflection, atribuímos seu valor à variável privada da classe Resource. Agora podemos remover de nosso controller e nossa view a injeção da interface IStringLocalizer e recuperar o valor do arquivo de tradução chamando diretamente a propriedade da classe Resource (não se esqueça de alterar o arquivo Views/_ViewImports.cshtml para adicionar o import do namespace do arquivo Resource).
using System.Diagnostics; using Microsoft.AspNetCore.Mvc; using Globalization.Models; using Globalization.Resources; namespace Globalization.Controllers { public class HomeController : Controller { public IActionResult Index() { ViewBag.PageTitle = Resource.Index_Page_Title; return View(); } public IActionResult Privacy() { return View(); } [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] public IActionResult Error() { return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); } } }
@{ ViewData["Title"] = ViewBag.PageTitle; } <div class="text-center"> <h1 class="display-4">@Resource.Welcome_Title</h1> <p>Learn about <a href="https://docs.microsoft.com/aspnet/core">building Web apps with ASP.NET Core</a>.</p> </div>
A aplicação continua funcionando normalmente utilizando a nova classe, mas ao criar uma nova chave no arquivo Resource.json, obrigatoriamente precisamos criar uma nova propriedade na classe Resource para podermos utilizá-la. Vamos automatizar esse trabalho de criação das propriedades utilizando T4 Templates.
Adicione um novo arquivo dentro da pasta Resources chamado Resource.tt (não se preocupe se você não encontrar um tipo na lista de tipos de arquivo do Visual Studio). Esse arquivo lerá o nosso JSON de tradução, listará todas as chaves que configuramos nele e gerará automaticamente o arquivo Resource com as propriedades correspondentes. Não entrarei em detalhes sobre como o arquivo funciona, mas o conteúdo é o seguinte:
<#@ template debug="true" hostspecific="true" language="C#" #> <#@ assembly name="Newtonsoft.Json" #> <#@ import namespace="Newtonsoft.Json" #> <#@ import namespace="Newtonsoft.Json.Linq" #> <#@ import namespace="System.Collections.Generic" #> <#@ import namespace="System.IO" #> <#@ import namespace="System.Text" #> using Microsoft.Extensions.Localization; using System.Collections.Generic; using System.Globalization; using System.Threading; namespace <#= System.Runtime.Remoting.Messaging.CallContext.LogicalGetData("NamespaceHint") #> { public static class <#= Path.GetFileNameWithoutExtension(Host.TemplateFile) #> { private static IStringLocalizer stringLocalizer = null; public static CultureInfo GetCurrentCulture() => Thread.CurrentThread.CurrentCulture; public static IEnumerable<CultureInfo> GetAllCultures() => ((JsonStringLocalizer)stringLocalizer).GetAllCultures(); <# Dictionary<string, Dictionary<string, string>> resource = null; using (StreamReader reader = File.OpenText(Path.GetDirectoryName(Host.TemplateFile) + @"\Resource.json")) { var jObject = (JObject)JToken.ReadFrom(new JsonTextReader(reader)); resource = JsonConvert.DeserializeObject<Dictionary<string, Dictionary<string, string>>>(jObject.ToString()); foreach (var item in resource.Keys) { #> public static string <#= item #> => stringLocalizer["<#= item #>"]; <# } } #> } }
Ao salvar o arquivo, uma mensagem de alerta será mostrada, clique em OK para executar o T4. Abra novamente o arquivo e note que ele foi gerado conforme havíamos criado anteriormente, com exceção de dois novos métodos, um GetCurrentCulture, que retornará a cultura atual, e um GetAllCultures, que retornará todos os idiomas encontrados em nosso arquivo de tradução. A listagem de idiomas é importante para alterarmos a configuração do middleware de localização na classe Startup para uma lista que será populada dinamicamente sempre que alterarmos nosso arquivo de tradução, facilitando nosso trabalho ao localizar a aplicação para um novo idioma. A nova implementação da classe JsonStringLocalizer e a configuração do middleware se encontram abaixo:
using Microsoft.Extensions.Localization; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; namespace Globalization.Resources { public class JsonStringLocalizer : IStringLocalizer { private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, string>> _resource = new ConcurrentDictionary<string, ConcurrentDictionary<string, string>>(); private readonly HashSet<string> _languages; public JsonStringLocalizer() { string path = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "Resources/Resource.json"); using (StreamReader reader = File.OpenText(path)) { var jObject = (JObject)JToken.ReadFrom(new JsonTextReader(reader)); _resource = JsonConvert.DeserializeObject<ConcurrentDictionary<string, ConcurrentDictionary<string, string>>>(jObject.ToString()); _languages = new HashSet<string>(_resource.SelectMany(r => r.Value).Select(r => r.Key).Distinct()); } } public LocalizedString this[string name] => GetStringResource(name); public LocalizedString this[string name, params object[] arguments] => GetStringResource(name, arguments); public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) { return _resource.Keys.Select(r => new LocalizedString(r, r)); } public IStringLocalizer WithCulture(CultureInfo culture) { return this; } public IEnumerable<CultureInfo> GetAllCultures() { return _languages.Select(c => new CultureInfo(c)); } private LocalizedString GetStringResource(string name, params object[] arguments) { if (string.IsNullOrWhiteSpace(name) || !_resource.TryGetValue(name, out ConcurrentDictionary<string, string> stringByCulture) || !stringByCulture.TryGetValue(CultureInfo.CurrentCulture.Name, out string value)) return new LocalizedString(name, name, true); return new LocalizedString(name, string.Format(value, arguments), false); } } }
List<CultureInfo> supportedCultures = Resource.GetAllCultures().ToList(); app.UseRequestLocalization(new RequestLocalizationOptions { DefaultRequestCulture = new RequestCulture("en-US"), // Formatting numbers, dates, etc. SupportedCultures = supportedCultures, // UI strings that we have localized. SupportedUICultures = supportedCultures, RequestCultureProviders = new List<IRequestCultureProvider> { new RouteRequestCultureProvider() } });
Agora, sempre que uma nova chave for criada no arquivo Resource.json, devemos executar o arquivo Resource.tt abrindo-o e salvando-o ou clicando com o botão direito do mouse em cima do arquivo e escolhendo a opção “Run custom tool”. Mais simples, não é?!
Com isso, finalizamos o básico da internacionalização de nossa aplicação. A partir daqui, podemos começar a implementar as funcionalidades do blog, criando e exibindo posts em múltiplos idiomas. Até a próxima!
Entre em contato com a Iteris e saiba como podemos te ajudar a ter a aplicação que você imagina para o seu negócio.