Linguagens personalizadas usando recursos incorporados e externos no .NET Framework

· 11 min de leitura

Introdução

No trabalho, encontramos um problema normal com o qual tenho certeza que muitos desenvolvedores têm que lidar, que são linguagens e linguagens personalizadas dos clientes que estão prestando nossos serviços aos seus clientes.

Por exemplo, digamos que a linguagem base seja uma linguagem informal e um cliente queira uma linguagem formal porque seus clientes são pessoas mais velhas.

Observe também que esta solução é para vários idiomas, usaremos inglês e espanhol.

Minha língua materna não é o inglês. Peço desculpas por quaisquer erros no tutorial. Se você encontrar erros e quiser corrigi-los, você pode abrir uma solicitação pull em este repositório e terei prazer em aprová-lo!

Recursos

Recursos são arquivos XML com extensão .resx que possuem uma estrutura chave/valor e se parecem com o seguinte:

<?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:import namespace="http://www.w3.org/XML/1998/namespace" />
    <xsd:element name="root" msdata:IsDataSet="true">
      <xsd:complexType>
        <xsd:choice maxOccurs="unbounded">
          <xsd:element name="metadata">
            <xsd:complexType>
              <xsd:sequence>
                <xsd:element name="value" type="xsd:string" minOccurs="0" />
              </xsd:sequence>
              <xsd:attribute name="name" use="required" type="xsd:string" />
              <xsd:attribute name="type" type="xsd:string" />
              <xsd:attribute name="mimetype" type="xsd:string" />
              <xsd:attribute ref="xml:space" />
            </xsd:complexType>
          </xsd:element>
          <xsd:element name="assembly">
            <xsd:complexType>
              <xsd:attribute name="alias" type="xsd:string" />
              <xsd:attribute name="name" type="xsd:string" />
            </xsd:complexType>
          </xsd:element>
          <xsd:element name="data">
            <xsd:complexType>
              <xsd:sequence>
                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
                <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
              </xsd:sequence>
              <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
              <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
              <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
              <xsd:attribute ref="xml:space" />
            </xsd:complexType>
          </xsd:element>
          <xsd:element name="resheader">
            <xsd:complexType>
              <xsd:sequence>
                <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
              </xsd:sequence>
              <xsd:attribute name="name" type="xsd:string" use="required" />
            </xsd:complexType>
          </xsd:element>
        </xsd:choice>
      </xsd:complexType>
    </xsd:element>
  </xsd:schema>
  <resheader name="resmimetype">
    <value>text/microsoft-resx</value>
  </resheader>
  <resheader name="version">
    <value>2.0</value>
  </resheader>
  <resheader name="reader">
    <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
  </resheader>
  <resheader name="writer">
    <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
  </resheader>
  <data name="Action_cancel" xml:space="preserve">
    <value>Finish</value>
  </data>
  <data name="Action_greeting" xml:space="preserve">
    <value>Hello</value>
  </data>
</root>

Estaremos criando recursos para dois idiomas diferentes: inglês e espanhol, então a nomenclatura dos arquivos será: resources.ISOLANGUAGECODE.resx, por exemplo: resource.es.resx para espanhol e resource.resx para inglês, caso queiramos adicionar posteriormente o alemão, o arquivo será nomeado resource.de.resx.

Recurso incorporado

Recursos incorporados, quando o projeto for compilado, serão adicionados dentro do dll.

Aqui está uma imagem com um recurso incorporado, como você pode ver, o arquivo Resource.resx não pode ser visto na compilação.

Imagem de Gyazo

Recurso externo

Por outro lado os recursos externos ou não incorporados são recursos que serão adicionados à pasta após a compilação.

Os arquivos de recursos (.resx) estarão dentro da pasta Properties

Imagem de Gyazo

Construindo o projeto

Vamos começar com um projeto de console no .NET Framework.

Criando o arquivo de recurso incorporado

Adicione um arquivo de recurso com algumas chaves e certifique-se de que seja um recurso incorporado, que posteriormente usaremos para atualizar com recursos externos.

Imagem de Gyazo

Criando o arquivo de recurso externo

Para separar os recursos externos dos incorporados, iremos adicionar os recursos externos dentro de uma pasta com um nome para que possamos acessá-los facilmente posteriormente.

Dica: para adicionar uma pasta dentro da pasta Propriedades, criá-la fora e movê-la para dentro, o Visual Studio não permite criá-la

Faça o mesmo que fizemos para o recurso incorporado, vá para properties do arquivo e dentro de Advanced, Build action e altere para: Content e altere Copy to Output Dictionary para Copy if newer.

Imagem de Gyazo

É assim que deve ser:

Imagem de Gyazo

Adicionando uma chave ao app.config

Como somos desenvolvedores legais e gostamos de ter tudo bem feito e NÃO alterar o código de cada cliente, vamos adicionar uma chave ao appconfig que terá o nome da pasta onde estaremos procurando os arquivos de recursos

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <startup> 
        <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
    </startup>
  <appSettings>
    <add key="CustomResources.Folder" value="John" />
  </appSettings>
</configuration>
```Então, mais tarde, quando procurarmos os recursos, ele procurará em `Properties.John` em vez de `Properties.Doe`. 

Além disso, isso facilita a alteração quando o aplicativo já está implantado, pois você pode alterar facilmente o app.config.

## Acessando arquivos de recursos incorporados

Usar o .NET Framework é bastante fácil, então para acessar as propriedades do Embedded basta usar o objeto `Properties`, que terá como propriedade os arquivos de recursos, e dentro desse objeto estarão toda a chave/valor que temos no arquivo `resx`.

```csharp
static void Main(string[] args)
{
    var hello = Properties.Resource.Action_greeting;
    var bye = Properties.Resource.Action_cancel;

    Console.WriteLine($"Action_greeting value: {hello}, Action_cancel value: {bye}");

    Console.Read();
}

Executando isso deve nos mostrar algo assim:

Action_greeting value: Hello, Action_cancel value: Cancel

Acessando arquivos de recursos externos

Esta parte é praticamente a mesma, você pode acessar os arquivos de recursos pelo objeto Properties.

var hello = Properties.Resource.Action_greeting;
var bye = Properties.Resource.Action_cancel;

var johnhello = Properties.John.Resource.Action_greeting;
var johnbye = Properties.John.Resource.Action_cancel;

var doehello = Properties.Doe.Resource.Action_greeting;
var doebye = Properties.Doe.Resource.Action_cancel;

Problema

O principal problema ao usar este método é que cada chave é uma propriedade dentro do objeto, então temos que chamá-la como vimos antes. Se você quiser chamar a chave Action_greeting do arquivo de recursos de John temos que usar o seguinte Properties.John e depois Resource.Action_greeting.

Aí está o problema.

Isso porque se estamos desenvolvendo uma aplicação para muitos clientes, é uma má ideia mudar a forma como chamamos os arquivos de recursos de cada um deles.

Você consegue imaginar isso? Compilar a aplicação para cada cliente e mudar John para Doe e depois para outra coisa. Isso é loucura!

Solução

Nosso líder de equipe pensou em um método muito bom, algo como um sistema substituto, devemos ter um modelo base de recursos, e então para cada um dos clientes temos um arquivo de recursos que irá atualizar o arquivo com seus recursos, e terminamos com uma única lista de recursos.

Se o cliente não quiser recursos customizados usamos os recursos base, e se ele quiser, usamos os deles.

Para colocar isso em uma lista de verificação, temos que fazer:

  • [] Encontre uma maneira de mapear todas as chaves/valores para um dicionário para os recursos incorporados.
  • [] Encontre uma maneira de mapear todas as chaves/valores para um dicionário para os recursos externos
  • [] Misture os dois arquivos e tenha um único dicionário para cada idioma
  • Crie um método que acesse o dicionário e retorne o valor

Diagrama

Imagem de Gyazo

Vamos codificar

Primeiramente, vamos criar uma classe separada onde teremos toda a nossa lógica, desde a obtenção dos arquivos de recursos, até a mistura deles e o retorno do valor. Essa classe será chamada CustomResources.

Isto é o que parece:

    class CustomResources
    {

        private static Dictionary<string, string> _ResourcesEnglish;
        private static Dictionary<string, string> ResourcesEnglish;

        private static Dictionary<string, string> _ResourcesSpanish;
        private static Dictionary<string, string> ResourcesSpanish;


        private static Dictionary<string, string> OverwriteDictionary(Dictionary<string, string> currentDictionary, Dictionary<string, string> newDictionary, bool addIfDoesntExist = false)
        {
            ...
        }

        private static Dictionary<string, string> GetDictionaryFromEmbedded(string embedded, string cultureInfoCode)
        {
            ...
        }

        private static Dictionary<string, string> GetDictionaryFromFile(string file)
        {
            ...
        }

        public static string GetText(string key)
        {
            ...
        }

        public static string GetText(string key, string language)
        {
            ...
        }
    }

Observe que estamos implementando o carregamento lento para propriedades, o que ajuda a aumentar o desempenho e faz com que o dicionário seja carregado uma vez.

  • GetDictionaryFromEmbedded: retorna um dicionário de recursos incorporados.
  • GetDictionaryFromFile: retorna um dicionáriou de recursos externos.
  • OverwriteDictionary: mistura dois dicionários e retorna um único.
  • GetText: retorna um valor dada uma chave

Do recurso incorporado ao dicionário

Temos que pegar todas as propriedades do arquivo xml e retornar um dicionário:

private static Dictionary<string, string> GetDictionaryFromEmbedded(string embedded, string cultureInfoCode)
{
    Dictionary<string, string> res = new Dictionary<string, string>();

    try
    {
        ResourceManager rm = new ResourceManager(embedded, Assembly.GetExecutingAssembly());
        var resourceSet = rm.GetResourceSet(new CultureInfo(cultureInfoCode), true, true);

        var resourceDictionary = resourceSet.Cast<DictionaryEntry>()
                            .ToDictionary(r => r.Key.ToString(),
                                          r => r.Value.ToString());
        res = resourceDictionary;
    }
    catch (Exception e)
    {
        string a = e.Message;
        // Error getting resource file
    }

    return res;
}

Duas coisas:- Observe que ele precisa de um parâmetro chamado embedded, que parementers é o nome do arquivo que você pode ver no designer, no nosso caso é: resources-demo.Properties.Resource.

  • Também temos um parâmetro chamado cultreInfoCode, que é o código do idioma a ser selecionado. Felizmente para nós, o .NET Framework faz o trabalho para nós e não precisamos fazer nada, apenas definir que queremos inglês ou espanhol, e ele selecionará entre resource.es.resx ou resource.resx

Do recurso externo ao dicionário

Sair do arquivo é um pouco hackeado, mas não difícil, temos que obter a localização atual do executável, concatenar a localização do arquivo de recurso e então analisá-lo em um dicionário.

Mas primeiro você deve adicionar a referência a System.Windows.Forms, para poder acessar ResXResourceReader.

Imagem de Gyazo

Agora vamos ao nosso método GetDictionaryFromFile:

private static Dictionary<string, string> GetDictionaryFromFile(string file)
{
    Dictionary<string, string> res = new Dictionary<string, string>();
    string currentPath = (System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().GetName().CodeBase) + file).Replace("file:\\", "");

    try
    {
        using (ResXResourceReader resxReader = new ResXResourceReader(currentPath))
        {
            foreach (DictionaryEntry entry in resxReader)
            {
                res.Add((string)entry.Key, (string)entry.Value);
            }
        }
    }
    catch (Exception e)
    {
        string a = e.Message;
    }

    return res;
}

Os parâmetros do arquivo precisam ser preenchidos com a localização do executável até o arquivo de recurso, no nosso caso é: "\\Properties\\John\\Resource.resx".

Misturando dicionários

Já terminamos, primeiro lembre-se de adicionar System.Configuration às referências para que você possa acessar app.settings.

 private static Dictionary<string, string> OverwriteDictionary(Dictionary<string, string> currentDictionary, Dictionary<string, string> newDictionary, bool addIfDoesntExist = false)
{
    var identifier = ConfigurationManager.AppSettings["CustomResources.Folder"];
    if (String.IsNullOrEmpty(identifier))
        return currentDictionary;

    foreach (var item in newDictionary)
    {
        try
        {
            currentDictionary[item.Key] = item.Value;
        }
        catch (Exception)
        {
            if(addIfDoesntExist)
            currentDictionary.Add(item.Key, item.Value);
        }
    }

    return currentDictionary;
}

Obtendo texto do dicionário

Crie um método público que chame um método privado que selecione um idioma:

private static string GetText(string key, string language)
{
    try
    {
        switch (language)
        {
            case "es":
                return ResourceSpanish[key];
            case "en":
            default:
                return ResourceEnglish[key];
        }
    }
    catch (Exception)
    {
        return $"No value with key: {key} and language: {language}";
    }
}
public static string GetText(string key)
{
    try
    {
        return GetText(key, Thread.CurrentThread.CurrentCulture.TwoLetterISOLanguageName);
        }
    catch (Exception)
    {
        return $"No value with key: {key}";
    }
}

Observe que você terá que modificar a opção se estiver adicionando mais idiomas.

Adicionando código ao getter de propriedades

Como temos todos os métodos agora, podemos modificar o getter da propriedade pública para obter os valores.

private static Dictionary<string, string> _ResourcesEnglish;
private static Dictionary<string, string> ResourcesEnglish
{
    get
    {
        if (_ResourcesEnglish == null)
        {
            var folderIndentifier = ConfigurationManager.AppSettings["CustomResources.Folder"]; ;
            var baseResources = GetDictionaryFromEmbedded("resources-demo.Properties.Resource");
            var customResources = GetDictionaryFromFile($"\\Properties\\{folderIndentifier}\\Resource.resx", "en");
            _ResourcesEnglish = OverwriteDictionary(baseResources, customResources);
        }

        return _ResourcesEnglish;
    }
}

private static Dictionary<string, string> _ResourcesSpanish;
private static Dictionary<string, string> ResourcesSpanish
{
    get
    {
        if (_ResourcesSpanish == null)
        {
            var folderIndentifier = ConfigurationManager.AppSettings["CustomResources.Folder"]; ;
            var baseResources = GetDictionaryFromEmbedded("resources-demo.Properties.Resources");
            var customResources = GetDictionaryFromFile($"\\Properties\\{folderIndentifier}\\Resources.es.resx", "es");
            _ResourcesSpanish = OverwriteDictionary(baseResources, customResources);
        }

        return _ResourcesSpanish;
    }
}
  1. Primeiro obtemos o identificador de app.settings.
  2. Depois obtemos os recursos básicos, os incorporados.
  3. Depois disso obtemos os recursos customizados e para isso precisamos do nome da pasta (que é o identificador).
  4. Em seguida, misturamos e retornamos o valor.

Tudo isso será feito uma vez, após o carregamento lento.

Teste

Tudo relacionado ao código está finalizado, então agora vamos testar, para obter um valor do dicionário temos que chamar o método CustomResources.GetText(string key) que retorna o valor.

Atualizando arquivos de recursos inteiros

Este teste é um caso em que queremos atualizar toda a chave/valor dos arquivos de recursos, como você pode ver nas imagens temos as mesmas chaves, mas valores diferentes.

Vamos testar John, e para definir isso teremos o app.config definido como <add key="CustomResources.Folder" value="John" />.

Agora vamos verificar nosso arquivo de recurso base (Properties/Resource.es.resx):

Imagem de Gyazo

E então nosso arquivo de recurso externo (Properties/John/Resource.es.resx):

Imagem de Gyazo

Ok com tudo definido, rodamos a aplicação console, vamos parar na parte get das propriedades e verificar tudo

folderIdentifier tem o valor do appseting:

Imagem de Gyazo

baseResources tem o valor dos recursos base, os incorporados:

Imagem de Gyazo

customResources possui os valores dos recursos externos dentro da pasta John:Imagem de Gyazo

E por fim, _ResourcesSpanish tem o valor misturado dos recursos base aos recursos externos.

Imagem de Gyazo

Atualizando apenas um

Agora vamos testar o mesmo mas com cenário diferente, basta atualizar uma chave e deixar a outra igual.

Imagem de Gyazo

Como você pode ver, os arquivos têm o mesmo significado de valor para Action_greeting, mas valores diferentes para Action_cancel, portanto, devem ser atualizados apenas Action_cancel.

Imagem de Gyazo

Faltando o arquivo de recurso

Se você não fornecer o arquivo de recurso externo, não importa nada porque como esperamos ter um arquivo, se falhar retornará um dicionário vazio e ao misturar os dois dicionários terminará com o base.

Par ausente no recurso incorporado

Se você tiver um par no arquivo de recurso externo, por padrão ele não o adicionará ao dicionário final, você pode alterar isso chamando o método OverwriteDictionary() ao misturar os dois dicionários e definir o parâmetro addIfDoesntExist como true.

Diferentes idiomas

Como você pode ver, não especificamos nenhum idioma, porque tudo isso está sendo feito pela função GetText(string key) que está chamando GetText(string key, string language), e o parâmetro language é preenchido por TwoLetterISOLanguageName que retorna o idioma atual do nosso thread.

No meu caso tenho o espanhol como idioma padrão e por isso sempre mostra em espanhol, mas podemos tentar usar o inglês também.

Vamos codificar um pouco e construir algo para verificar espanhol e inglês.

static void Main(string[] args)
{

    // System.Threading.Thread.CurrentThread.CurrentUICulture = new CultureInfo("en-US");
    System.Threading.Thread.CurrentThread.CurrentCulture = new CultureInfo("en-US");
    WriteText("Action_greeting");
    WriteText("Action_cancel");

    // System.Threading.Thread.CurrentThread.CurrentUICulture = new CultureInfo("es-ES");
    System.Threading.Thread.CurrentThread.CurrentCulture = new CultureInfo("es-ES");
    WriteText("Action_greeting");
    WriteText("Action_cancel");

    Console.Read();
}

private static void WriteText(string key) {
    Console.WriteLine($"[Culture: {System.Threading.Thread.CurrentThread.CurrentCulture}] Key: {key} -> value: {CustomResources.GetText(key)}");
}

Executando isso, a saída deve ser algo assim:

[Culture: en-US] Key: Action_greeting -> value: Hello
[Culture: en-US] Key: Action_cancel -> value: Finish
[Culture: es-ES] Key: Action_greeting -> value: Hola
[Culture: es-ES] Key: Action_cancel -> value: Terminar

Imagem de Gyazo

Os dois primeiros valores são de resources.resx que estão em inglês e os últimos ambos estão em espanhol e os valores são recuperados de resources.es.resx.

#É isso

Neste tutorial encontramos uma forma de mesclar recursos embarcados e externos para idiomas, essa foi uma solução para um problema que tivemos na equipe e está rodando desde então sem problemas.

Você pode verificar o código fonte aqui.

Se você tiver alguma dúvida, sinta-se à vontade para me enviar um tweet para @emimontesdeocaa e entrarei em contato com você quando tiver tempo.