Aplicações modernas precisam suportar localizações para garantir que possam atender a uma audiência global. A localização permite que os usuários interajam com o aplicativo em seu idioma nativo, ajustando também formatos de data, moeda e outras preferências regionais. Isso melhora a experiência do usuário e a acessibilidade do software, aumentando o alcance e a aceitação da aplicação em diferentes mercados.

No C#, a localização é facilitada através do uso de arquivos .resx (resource File), que armazenam strings e outros dados localizáveis. Esses arquivos permitem que o conteúdo da aplicação seja traduzido sem modificar o código-fonte, garantindo flexibilidade e escalabilidade. Cada idioma ou região pode ter seu próprio arquivo .resx, e o C# seleciona automaticamente o recurso apropriado com base na cultura definida pelo usuário.

Você pode aprender mais sobre localizações no C# e o uso de arquivos .resx na documentação oficial.

Para começar o projeto, vamos criar um diretório raiz para o projeto, iniciar o repositório Git, configurar o gitignore e criar uma nova solução .NET:

mkdir CreateLocalizationsAutomatically
git init
dotnet new gitignore
dotnet new sln

Para criar o primeiro projeto da solução, vamos criar uma class Library no .NET. Esse projeto será responsável por conter as localizações compartilhadas que poderão ser usadas por outros projetos da solução. Esse tipo de projeto é útil para manter os recursos localizáveis centralizados e organizados.

Certifique-se de estar no diretório raiz da solução criada anteriormente. Execute o seguinte comando para criar uma class Library:

dotnet new classlib -n MySolution.SharedLocalizations

Adicione o projeto criado à solução principal:

dotnet sln add ./MySolution.SharedLocalizations/MySolution.SharedLocalizations.csproj

Primeiro, vamos criar a classe Culture que representará as culturas que os aplicativos suportarão. Essa classe armazena informações como o CultureInfo, o nome de exibição da cultura e se ela é a cultura padrão.

using System.Globalization;

namespace MySolution.SharedLocalizations;

public class Culture(
    CultureInfo cultureInfo, 
    string displayName,
    bool isDefault = false)
{
    public CultureInfo CultureInfo { get; set; } = cultureInfo;
    public string DisplayName { get; set; } = displayName;
    public bool IsDefault { get; set; } = isDefault;
}

Em seguida, definimos as culturas que serão suportadas inicialmente. No exemplo, vamos suportar Português (pt) e Inglês (en).

using System.Globalization;

namespace MySolution.SharedLocalizations;

public static class SupportedCultures
{
    public static List<Culture> Cultures =>
    [
        new(new CultureInfo("pt"), "Português", true),
        new(new CultureInfo("en"), "English")
    ];

    public static List<CultureInfo> CultureInfos => Cultures
        .Select(x => x.CultureInfo)
        .ToList();
}

Vamos criar uma classe de marcação, chamada SharedResources. Ela será usada como referência pelos aplicativos que utilizarão os recursos de localização. Essa classe é essencial para que o sistema de localização saiba onde encontrar os arquivos .resx.

namespace MySolution.SharedLocalizations;

public class SharedResources;

Agora, podemos criar os arquivos de recursos (.resx) que conterão as traduções. Estes arquivos devem ser armazenados em um diretório chamado Resources.

Para o projeto, vamos criar três arquivos:

  • SharedResources.resx (arquivo default)
  • SharedResources.pt.resx (para português)
  • SharedResources.en.resx (para inglês)

Arquivo SharedResources.resx

<?xml version="1.0" encoding="utf-8"?>
<root>
  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
    <xsd:element name="root" msdata:IsDataSet="true"></xsd:element>
  </xsd:schema>
  <resheader name="resmimetype">
    <value>text/microsoft-resx</value>
  </resheader>
  <resheader name="version">
    <value>1.3</value>
  </resheader>
  <resheader name="reader">
    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
  </resheader>
  <resheader name="writer">
    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
  </resheader>
  <data name="Books" xml:space="preserve">
        <value>Livros</value>
    </data>
</root>

Arquivo SharedResources.pt.resx

<?xml version="1.0" encoding="utf-8"?>
<root>
  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
    <xsd:element name="root" msdata:IsDataSet="true"></xsd:element>
  </xsd:schema>
  <resheader name="resmimetype">
    <value>text/microsoft-resx</value>
  </resheader>
  <resheader name="version">
    <value>1.3</value>
  </resheader>
  <resheader name="reader">
    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
  </resheader>
  <resheader name="writer">
    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
  </resheader>
  <data name="Books" xml:space="preserve">
        <value>Livros</value>
    </data>
</root>

Arquivo SharedResources.en.resx

<?xml version="1.0" encoding="utf-8"?>
<root>
  <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
    <xsd:element name="root" msdata:IsDataSet="true"></xsd:element>
  </xsd:schema>
  <resheader name="resmimetype">
    <value>text/microsoft-resx</value>
  </resheader>
  <resheader name="version">
    <value>1.3</value>
  </resheader>
  <resheader name="reader">
    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
  </resheader>
  <resheader name="writer">
    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
  </resheader>
  <data name="Books" xml:space="preserve">
        <value>Books</value>
    </data>
</root>

No projeto atual, observamos que todos os arquivos contêm apenas uma localização definida, que tem a chave Books.

Temos uma visão geral do projeto MySolution.SharedLocalizations:

Vamos criar um novo projeto MVC com o seguinte comando:

dotnet new mvc -n MySolution.WebApp

Agora, adicione o projeto criado à solução principal:

dotnet sln add ./MySolution.WebApp/MySolution.WebApp.csproj

Precisamos configurar o suporte a localizações no arquivo Program.cs. Vamos adicionar as linguagens suportadas e as opções de localização, conforme mostrado abaixo:

builder.Services.Configure<RequestLocalizationOptions>(options =>
{
    options.DefaultRequestCulture = new RequestCulture(SupportedCultures.Cultures.First(x => x.IsDefault).CultureInfo);
    options.SupportedCultures = SupportedCultures.CultureInfos;
    options.SupportedUICultures = SupportedCultures.CultureInfos;
});

Também devemos configurar o caminho onde os arquivos de recurso de localização estarão:

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

Agora, precisamos garantir que o ASP.NET Core utilize as localizações configuradas:

app.UseRequestLocalization();

A configuração está feita! Vamos modificar a view Index da Home para exibir a localização Books. Aqui está o conteúdo atualizado da view:

@using Microsoft.Extensions.Localization
@using MySolution.SharedLocalizations
@inject IStringLocalizer<SharedResources> Localizer

@{
    ViewData["Title"] = "Home Page";
}

<div class="text-center">
    <h1 class="display-4">@Localizer["Books"]</h1>
</div>

O IStringLocalizer<SharedResources> está sendo injetado na view, permitindo que o texto localizado de Books seja exibido na linguagem requisitada pelo usuário.

Agora podemos rodar a aplicação e acessá-la no navegador utilizando o seguinte comando:

dotnet run
https://localhost:7131/

Observe que, como não definimos a linguagem ainda (isso será feito por meio de query string), ele exibe a localização na linguagem default.

Agora, vamos modificar a URL, adicionando ?culture=EN à query string. Dessa forma, estamos pedindo ao aplicativo para exibir a localização em inglês:

https://localhost:7131/?culture=EN

Tudo ocorreu perfeitamente! Vamos agora introduzir um problema que encontramos em muitos projetos que utilizam localizações: há muitas localizações no site e para diversas linguagens.

Os desenvolvedores gastam muito tempo traduzindo e adicionando as localizações manualmente nos arquivos .resx. Para resolver esse problema, vamos criar um Console app que automatize esse processo de tradução utilizando o Azure AI Translator.

Primeiramente, vamos criar um recurso de nuvem. Veja a documentação oficial:
Azure AI Translator Documentation

Primeiramente, vamos criar o recurso de nuvem para utilizar o Azure AI Translator

Agora, vamos criar um projeto console que automatiza a manipulação das localizations:

dotnet new console -n MySolution.LocalizationGenerator

Adicione o projeto criado à solução principal:

dotnet sln add ./MySolution.LocalizationGenerator/MySolution.LocalizationGenerator.csproj

Agora, podemos adicionar os pacotes necessários ao projeto:

dotnet add package Spectre.Console
dotnet add package Spectre.Console.Cli
dotnet add package Azure.AI.Translation.Text

Crie a classe TranslatorService que utilizará o SDK do Azure AI Translator para realizar as traduções automáticas:

using Azure;
using Azure.AI.Translation.Text;

namespace MySolution.LocalizationGenerator;

public class TranslatorService
{
    private const string ApiKey = "<your-api-key>";
    private const string Endpoint = "<your-endpoint>";
    private const string Region = "<your-region>";
    
    
    public string Translate(string value, string sourceLanguage, string targetLanguage)
    {
        var client = new TextTranslationClient(new AzureKeyCredential(ApiKey), new Uri(Endpoint), Region);
        
        if (sourceLanguage == targetLanguage)
        {
            return value;
        }
        
        var result = client.Translate(targetLanguage, value, sourceLanguage);
        return result.Value.First().Translations.First(x => x.TargetLanguage == targetLanguage).Text;
    }
}

Substitua <your-api-key>, <your-endpoint>, e <your-region> pelas credenciais obtidas no Azure Portal.

Agora, crie o ResourceFileService para manipular os arquivos .resx, adicionando e gerando localizações:

using System.Xml.Linq;

namespace MySolution.LocalizationGenerator;

public class ResourceFileService
{
    public string ResourcePath
    {
        get
        {
            string currentDirectory = Environment.CurrentDirectory;
            
            DirectoryInfo parentDirectory = Directory.GetParent(currentDirectory)!;
            
            string newPath = Path.Combine(parentDirectory.FullName, "MySolution.SharedLocalizations","Resources");
            
            return newPath;
        }
    }

    public string?[] ListResourceFiles()
    {
        string[] files = Directory.GetFiles(ResourcePath);
        
        return files
            .Select(Path.GetFileName)
            .Where(x => !string.IsNullOrWhiteSpace(x))
            .ToArray();
    }
    
    public string?[] ListResourceFilesIsoLanguages()
    {
        return ListResourceFiles()
            .Select(ExtractLanguageCode)
            .ToArray();
    }
    
    public string ExtractLanguageCode(string? filename)
    {
        if (string.IsNullOrWhiteSpace(filename))
        {
            return string.Empty;
        }
        
        string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(filename);
        
        string[] parts = fileNameWithoutExtension.Split('.');
        if (parts.Length == 2)
        {
            return parts[1];
        }

        return string.Empty;
    }
    
    public void AddDataLocalization(string filePath, string key, string value)
    {
        XDocument doc = XDocument.Load(filePath);
        
        var resx = doc.Element("root");
        
        resx!.Add(new XElement("data",
            new XAttribute("name", key),
            new XAttribute(XNamespace.Xml + "space", "preserve"),
            new XElement("value", value)
        ));
        
        resx.Save(filePath);
    }
    
    public void AddDataLocalizations(string filePath, IList<(string Key, string Value)> localizations)
    {
        XDocument doc = XDocument.Load(filePath);
        
        var resx = doc.Element("root");

        foreach (var localization in localizations)
        {
            resx!.Add(new XElement("data",
                new XAttribute("name", localization.Key),
                new XAttribute(XNamespace.Xml + "space", "preserve"),
                new XElement("value", localization.Value)
            ));
        }
        
        resx!.Save(filePath);
    }
    
    public void CreateFile(string filePath)
    {
        XDocument doc = new XDocument(
            new XDeclaration("1.0", "utf-8", null),
            new XElement("root",
                new XElement(XNamespace.Get("http://www.w3.org/2001/XMLSchema") + "schema",
                    new XAttribute("id", "root"),
                    new XAttribute(XNamespace.Xmlns + "xsd", "http://www.w3.org/2001/XMLSchema"),
                    new XAttribute(XNamespace.Xmlns + "msdata", "urn:schemas-microsoft-com:xml-msdata"),
                    new XElement(XNamespace.Get("http://www.w3.org/2001/XMLSchema") + "element",
                        new XAttribute("name", "root"),
                        new XAttribute(XNamespace.Get("urn:schemas-microsoft-com:xml-msdata") + "IsDataSet", "true")
                    )
                ),
                new XElement("resheader",
                    new XAttribute("name", "resmimetype"),
                    new XElement("value", "text/microsoft-resx")
                ),
                new XElement("resheader",
                    new XAttribute("name", "version"),
                    new XElement("value", "1.3")
                ),
                new XElement("resheader",
                    new XAttribute("name", "reader"),
                    new XElement("value", "System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089")
                ),
                new XElement("resheader",
                    new XAttribute("name", "writer"),
                    new XElement("value", "System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089")
                )
            )
        );
         
        doc.Save(filePath);
    }
    
    public IList<(string Key, string Value)> ListLocalization(string filePath)
    {
        XDocument doc = XDocument.Load(filePath);
        
        var dataElements = doc.Descendants("data");
        
        var dataKeys = dataElements
            .Select(data => (
                Key: data.Attribute("name")?.Value ?? string.Empty,
                Value: data.Element("value")?.Value ?? string.Empty
            ))
            .ToList();

        return dataKeys;
    }
}

Já podemos fazer modificações no Program.cs

using MySolution.LocalizationGenerator;
using MySolution.SharedLocalizations;
using Spectre.Console;

const string availableLanguages = "\U0001F440 See available languages";
const string createdResourceFiles = "\U0001F440 See existings resource files";
const string addLocalization = "\U0001F449 Add localization";
const string addNewResourceFile = "\U0001F4D6 Add new resource file";

string[] options = [
    availableLanguages,
    createdResourceFiles,
    addLocalization,
    addNewResourceFile
];

while (true)
{
    AnsiConsole.Clear();

    var panel = new Panel("Localizations generator")
    {
        Header = new PanelHeader("My Solution"),
        Padding = new Padding(3, 0, 3, 0),
        Border = BoxBorder.Rounded
    };

    AnsiConsole.Write(panel);
    
    var option = AnsiConsole.Prompt(
        new SelectionPrompt<string>()
            .PageSize(10)
            .Title("Select an action:")
            .AddChoices(options));

    switch (option)
    {
        case availableLanguages:
            HandleAvailableLanguages();
            break;
    
        case createdResourceFiles:
            HandleCreatedResourceFiles();
            break;
        
        case addLocalization:
            HandleAddLocalization();
            break;
    
        case addNewResourceFile:
            HandleAddNewResourceFile();
            break;
    }
}

void HandleAddLocalization()
{
    AnsiConsole.WriteLine($"Add localization:");

    var key = AnsiConsole.Prompt(
        new TextPrompt<string>("\U0001F449 Type the localization [bold yellow]KEY[/] ?"));
    
    var value = AnsiConsole.Prompt(
        new TextPrompt<string>("\U0001F449 Type the localization [bold green]DEFAULT VALUE[/] ?"));
    
    var resourceFileService = new ResourceFileService();
    var translatorService = new TranslatorService();
    
    var files = resourceFileService.ListResourceFiles();

    AnsiConsole.WriteLine();
    
    foreach (var file in files)
    {
        var path = Path.Combine(resourceFileService.ResourcePath, file!);
        var languageCode = resourceFileService.ExtractLanguageCode(file);

        var targetLanguageValue = string.IsNullOrWhiteSpace(languageCode) ? 
            value : 
            translatorService.Translate(value, "pt", languageCode);
        
        resourceFileService.AddDataLocalization(path, key, targetLanguageValue);
        
        AnsiConsole.MarkupLine(string.Format(
            "\U0001F4AC The localization with key [bold yellow]{0}[/] and value [bold green]{1}[/] was added to [bold white]{2}[/]", 
            key, 
            targetLanguageValue, 
            file));
    }
    
    AnsiConsole.WriteLine();
        
    Continue();
}

void HandleCreatedResourceFiles()
{
    var service = new ResourceFileService();
    var files = service.ListResourceFiles();
    
    AnsiConsole.WriteLine("Existing resources:");
    
    var table = new Table()
    {
        Border = TableBorder.Rounded,
        ShowRowSeparators = true
    };
    
    table.AddColumn(new TableColumn("File name").Centered());
    table.AddColumn(new TableColumn("ISO language name").Centered());

    foreach (var file in files)
    {
        var isoCode = service.ExtractLanguageCode(file);
        table.AddRow(file!, string.IsNullOrWhiteSpace(isoCode) ? "--//--" : isoCode);
    }
    
    AnsiConsole.Write(table);
    
    Continue();
}

void HandleAvailableLanguages()
{
    AnsiConsole.WriteLine("Available languages:");
    
    var table = new Table()
    {
        Border = TableBorder.Rounded,
        ShowRowSeparators = true
    };
    
    table.AddColumn(new TableColumn("Name").Centered());
    table.AddColumn(new TableColumn("ISO name").Centered());
    table.AddColumn(new TableColumn("Is default?").Centered());

    foreach (var culture in SupportedCultures.Cultures)
    {
        table.AddRow(
            culture.DisplayName, 
            culture.CultureInfo.Name, 
            culture.IsDefault.ToString());
        
    }
    
    AnsiConsole.Write(table);

    Continue();
}

void HandleAddNewResourceFile()
{
    var isoName = AnsiConsole.Prompt(
        new TextPrompt<string>("\U0001F449 Type the language [bold white]ISO name[/] ?"));
    
    var resourceFileService = new ResourceFileService();
    var translatorService = new TranslatorService();
    
    var file = $"SharedResources.{isoName}.resx";
    var defaultFile = $"SharedResources.resx";
    var path = Path.Combine(resourceFileService.ResourcePath, file);
    var defaultPath = Path.Combine(resourceFileService.ResourcePath, defaultFile);
    
    resourceFileService.CreateFile(path);

    var localizations = resourceFileService.ListLocalization(defaultPath);

    var translatedLocalizations = localizations
        .Select(x => (x.Key, translatorService.Translate(x.Value, "pt", isoName)))
        .ToList();
    
    resourceFileService.AddDataLocalizations(path, translatedLocalizations);
    
    AnsiConsole.WriteLine();
    
    AnsiConsole.MarkupLine($"\U0001F4C4 The localization file [bold white]{file}[/] was created");
    
    Continue();
}

void Continue()
{
    while (true)
    {
        AnsiConsole.WriteLine();
        var confirmation = AnsiConsole.Prompt(
            new TextPrompt<bool>("[bold green]Continue[/] ?")
                .AddChoice(true)
                .AddChoice(false)
                .DefaultValue(true)
                .WithConverter(choice => choice ? "y" : "n"));
        
        if (confirmation)
        {
            break;
        }
    }
}

Uma breve explicação:

  • O que faz: esse código é um gerador de traduções para vários idiomas, onde você pode adicionar e gerenciar traduções nos arquivos de recursos (.resx) da aplicação.
  • Bibliotecas que aparecem:Spectre.Console: deixa o console mais estiloso e interativo.
  • Menu principal:Mostra 4 opções:Ver os idiomas que a aplicação suporta.Ver os arquivos de recursos já criados.Adicionar uma nova tradução.Criar um novo arquivo de recursos para um idioma novo.
  • Funções principais:HandleAddLocalization: pergunta uma chave e o valor da tradução, traduz o valor para outros idiomas e adiciona nos arquivos certos.HandleCreatedResourceFiles: lista os arquivos de recursos que já existem e mostra eles em uma tabela.HandleAvailableLanguages: mostra todos os idiomas que a aplicação suporta, com nome e código ISO.HandleAddNewResourceFile: cria um novo arquivo de recursos para um idioma com base no código ISO e já preenche com traduções automáticas.
  • Serviços usados:ResourceFileService: faz o trabalho pesado de listar, criar e adicionar traduções nos arquivos.TranslatorService: traduz os textos para o idioma certo automaticamente.
  • Visual no console:Usa painéis e tabelas com bordas estilosas e mensagens formatadas para ficar bonito no terminal.
  • Função para seguir em frente:Continue(): Fica esperando você confirmar se quer continuar ou não.


Para iniciar o processo, vamos rodar o Console app com o comando:

dotnet run

Agora, dentro do menu do Console app, selecionamos a opção para verificar as linguagens disponíveis. Em seguida, verificamos os arquivos .resx que já existem no projeto, onde vemos as localizações atuais configuradas.

Podemos adicionar uma nova localização com a Key: Library e Value: biblioteca.

O Console app automaticamente irá adicionar essa chave nos arquivos .resx para todas as linguagens disponíveis, e as traduções serão geradas com base na linguagem de cada arquivo.

Arquivo .resx em Português:

  <data name="Library" xml:space="preserve">
    <value>Biblioteca</value>
  </data>

Arquivo .resx em Inglês:

  <data name="Library" xml:space="preserve">
    <value>Library</value>
  </data>

No aplicativo MVC, vamos adicionar a nova localização na página Index.cshtml:

<h1 class="display-4">@Localizer["Library"]</h1>

Agora, vamos rodar a aplicação com o comando:

https://localhost:7131/

Definindo a cultura para inglês (culture=EN):

https://localhost:7131/?culture=EN

Verificamos que tudo ocorreu bem e as localizações estão funcionando conforme esperado.

Agora, vamos adicionar o suporte ao idioma espanhol na classe SupportedCultures do projeto MySolution.SharedLocalizations:

new(new CultureInfo("es"), "español")

No Console App, podemos ver que agora temos espanhol como uma nova linguagem suportada, porém, ainda não temos o arquivo SharedResources.es.resx.

No menu principal do Console App, selecionamos a opção "Add new resource file" e escolhemos a linguagem "es" (espanhol). O arquivo SharedResources.es.resx será criado automaticamente, e as traduções já serão aplicadas a partir do Azure AI Translator.

 <data name="Books" xml:space="preserve">
    <value>Libros</value>
  </data>
  <data name="Library" xml:space="preserve">
    <value>Biblioteca</value>
  </data>

Ao selecionar a opção de listar os arquivos .resx, podemos verificar que o arquivo SharedResources.es.resx foi criado corretamente e as localizações já estão traduzidas.

Agora podemos testar a nova linguagem no aplicativo MVC utilizando a query string para o idioma espanhol.

https://localhost:7131/?culture=ES

E é isso, pessoal! Esta foi uma breve apresentação sobre como configurar e automatizar localizações em um projeto .NET utilizando o Azure AI Translator. Este projeto pode ser extremamente útil e economizar muito tempo no desenvolvimento, ao gerenciar localizações automaticamente com base nas linguagens suportadas.

Você já pode baixar o projeto por esse link, e não esquece de me seguir no LinkedIn!

Até a próxima, abraços!

💡
Podemos te ajudar com uma revisão 100% gratuita do seu ambiente cloud.
Share this post