Lenguajes personalizados que utilizan recursos integrados y externos en .NET Framework

· 11 min de lectura

Introducción

En el trabajo se nos ocurrió un problema normal con el que estoy bastante seguro que muchos desarrolladores tienen que lidiar, que son los idiomas y los idiomas personalizados de los clientes que brindan nuestro servicio a sus clientes.

Por ejemplo, digamos que el lenguaje base utiliza un lenguaje informal y un cliente quiere un lenguaje formal porque sus clientes son personas mayores.

También tenga en cuenta que esta solución es para varios idiomas, usaremos inglés y español.

Mi lengua materna no es el inglés, así que pido disculpas por cualquier error en el tutorial. Si encuentra errores y desea corregirlos, puede abrir una solicitud de extracción en este repositorio y con gusto la aprobaré.

Recursos

Los recursos son archivos XML con extensión .resx que tienen una estructura clave/valor y se parecen a lo siguiente:

<?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 creando recursos para dos idiomas diferentes: inglés y español, por lo que la nomenclatura de los archivos será: resources.ISOLANGUAGECODE.resx, por ejemplo: resource.es.resx para español y resource.resx para inglés, en caso de que luego queramos agregar alemán, el archivo se llamará resource.de.resx.

Recurso integrado

Los recursos integrados, cuando se compila el proyecto, se agregarán dentro del dll.

Aquí hay una imagen con un recurso incrustado, como puede ver, el archivo Resource.resx no se puede ver en la compilación.

Imagen de Gyazo

Recurso externo

Por otro lado, los recursos externos o no integrados son recursos que se agregarán a la carpeta después de la compilación.

Los archivos de recursos (.resx) estarán dentro de la carpeta Properties

Imagen de Gyazo

Construyendo el proyecto

Comencemos con un proyecto de consola en .NET Framework.

Creando el archivo de recursos incrustado

Agregue un archivo de recursos con algunas claves y asegúrese de que sea un recurso integrado, que luego usaremos para actualizar con recursos externos.

Imagen de Gyazo

Creando el archivo de recursos externos

Para separar los recursos externos de los integrados, agregaremos los recursos externos dentro de una carpeta con un nombre para que podamos acceder a ellos fácilmente más adelante.

Consejo: para agregar una carpeta dentro de la carpeta Propiedades, créala afuera y muévela adentro, Visual Studio no te permite crearla

Haga lo mismo que hicimos para el recurso incrustado, luego vaya al properties del archivo y dentro de Advanced, Build action y cámbielo a: Content y cambie Copy to Output Dictionary a Copy if newer.

Imagen de Gyazo

Así es como debería verse:

Imagen de Gyazo

Agregar una clave a app.config

Debido a que somos desarrolladores geniales y nos gusta tener todo bien hecho y NO cambiar el código para cada cliente, agreguemos una clave al appconfig que tendrá el nombre de la carpeta en la que buscaremos los archivos 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>
```Más adelante, cuando busquemos los recursos, buscará en `Properties.John` en lugar de `Properties.Doe`. 

Además, esto hace que sea más fácil cambiar cuando la aplicación ya está implementada, ya que puede cambiar app.config fácilmente.

## Acceder a archivos de recursos incrustados

Usar .NET Framework es bastante fácil, por lo que para acceder a las propiedades Embedded solo tenemos que usar el objeto `Properties`, que tendrá los archivos de recursos como propiedad, y dentro de ese objeto estará toda la clave/valor que tenemos en el archivo `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();
}

Al ejecutarlo debería mostrarnos algo como esto:

Action_greeting value: Hello, Action_cancel value: Cancel

Acceder a archivos de recursos externos

Esta parte es más o menos lo mismo, puedes acceder a los archivos de recursos mediante el 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

El principal problema al utilizar este método es que cada clave es una propiedad dentro del objeto, por lo que tenemos que llamarla como vimos antes. Si queremos llamar a la clave Action_greeting del archivo de recursos de John tenemos que usar el siguiente Properties.John y luego Resource.Action_greeting.

Ahí está el problema.

Esto se debe a que si estamos desarrollando una aplicación para muchos clientes, es una mala idea cambiar la forma en que llamamos a los archivos de recursos para cada uno de ellos.

¿Te imaginas eso? Compilando la aplicación para cada cliente y cambiando John a Doe y luego a otra cosa. ¡Eso es una locura!

Solución

Nuestro líder de equipo pensó en un método bastante bueno, algo así como un sistema alternativo, debemos tener un modelo base de recursos, y luego para cada uno de los clientes tener un archivo de recursos que actualizará el archivo con sus recursos, y terminamos con una única lista de recursos.

Si el cliente no quiere recursos personalizados usamos los recursos base, y si los quiere, usamos los suyos.

Para poner esto en una lista de verificación, tenemos que hacer:

  • [] Encuentre una manera de asignar todas las claves/valores a un diccionario para los recursos integrados.
  • [] Encuentre una manera de asignar todas las claves/valores a un diccionario para los recursos externos
  • [] Mezclar ambos archivos y tener un único diccionario para cada idioma
  • [] Crea un método que accede al diccionario y devuelve el valor.

Diagrama

Imagen de Gyazo

Codifiquemos

En primer lugar, creemos una clase separada donde tendremos toda nuestra lógica, desde obtener los archivos de recursos hasta mezclarlos y devolver el valor. Esa clase se llamará CustomResources.

Esto es lo 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)
        {
            ...
        }
    }

Tenga en cuenta que estamos implementando la carga diferida para las propiedades, lo que ayuda a aumentar el rendimiento y hace que el diccionario se cargue una vez.

  • GetDictionaryFromEmbedded: devuelve un diccionario de recursos integrados.
  • GetDictionaryFromFile: devuelve un diccionario de recursos externos.
  • OverwriteDictionary: mezcla dos diccionarios y devuelve uno solo.
  • GetText: devuelve un valor dada una clave

Del recurso incrustado al diccionario

Tenemos que obtener todas las propiedades del archivo xml y devolver un diccionario:

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;
}

Dos cosas:- Tenga en cuenta que necesita un parámetro llamado embedded, que parementers es el nombre del archivo que puede ver en el diseñador, en nuestro caso es: resources-demo.Properties.Resource.

  • También tenemos un parámetro llamado cultreInfoCode, que es el código del idioma a seleccionar. Por suerte para nosotros, .NET Framework hace el trabajo por nosotros y no tenemos que hacer nada, simplemente configurar que queremos inglés o español, y seleccionará entre resource.es.resx o resource.resx

Del recurso externo al diccionario

Obtener un archivo un poco complicado pero no difícil, tenemos que obtener la ubicación actual del ejecutable, concatenar la ubicación del archivo de recursos y luego analizarlo en un diccionario.

Pero primero hay que añadir la referencia a System.Windows.Forms, para poder acceder a ResXResourceReader.

Imagen de Gyazo

Ahora a nuestro 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;
}

Los parámetros del archivo deben completarse con la ubicación desde el ejecutable hasta el archivo de recursos, en nuestro caso es: "\\Properties\\John\\Resource.resx".

Mezclar diccionarios

Ya casi hemos terminado, primero, recuerda agregar System.Configuration a las referencias para que puedas acceder a 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;
}

Obtener texto del diccionario

Cree un método público que llame a un método privado que seleccione un 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}";
    }
}

Tenga en cuenta que debe modificar el interruptor si agrega más idiomas.

Agregar código al captador de propiedades

Como ahora tenemos todos los métodos, podemos modificar el captador de la propiedad pública para obtener los 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. Primero obtenemos el identificador de la configuración de la aplicación.
  2. Luego obtenemos los recursos base, los integrados.
  3. Después de eso obtenemos los recursos personalizados y para eso necesitamos el nombre de la carpeta (que es el identificador).
  4. Luego los mezclamos y devolvemos el valor.

Todo esto se hará una vez, por ejemplo, la carga diferida.

Pruebas

Todo lo relacionado con el código está terminado, así que ahora probémoslo, para obtener un valor del diccionario tenemos que llamar al método CustomResources.GetText(string key) que devuelve el valor.

Actualización de archivos de recursos completos

Esta prueba es un caso en el que queremos actualizar toda la clave/valor de los archivos de recursos, como puede ver en las imágenes, tenemos las mismas claves pero diferentes valores.

Probaremos John y, para configurarlo, tendremos app.config configurado en <add key="CustomResources.Folder" value="John" />.

Ahora revisemos nuestro archivo de recursos base (Properties/Resource.es.resx):

Imagen de Gyazo

Y luego nuestro archivo de recursos externos (Properties/John/Resource.es.resx):

Imagen de Gyazo

Bien, con todo configurado, ejecutamos la aplicación de consola, paremos en la parte get de las propiedades y verifiquemos todo.

folderIdentifier tiene el valor del appsetting:

Imagen de Gyazo

baseResources tiene el valor de los recursos base, los incrustados:

Imagen de Gyazo

customResources tiene los valores de los recursos externos dentro de la carpeta John:Imagen de Gyazo

Y finalmente, _ResourcesSpanish tiene el valor mezclado desde los recursos base hasta los recursos externos.

Imagen de Gyazo

Actualizando solo uno

Ahora probemos lo mismo pero con un escenario diferente, simplemente actualice una clave y deje que la otra sea la misma.

Imagen de Gyazo

Como puede ver, los archivos tienen el mismo significado de valor para Action_greeting pero un valor diferente para Action_cancel, por lo que debería actualizarse solo Action_cancel.

Imagen de Gyazo

Falta el archivo de recursos

Si no proporcionas el archivo de recursos externo no importa en absoluto porque como esperamos tener un archivo, si falla devolverá un diccionario vacío y al mezclar ambos diccionarios terminará con el base.

Falta par en recurso incrustado

Si tiene un par en el archivo de recursos externo, de forma predeterminada no lo agregará al diccionario final, puede cambiar eso llamando al método OverwriteDictionary() al mezclar ambos diccionarios y estableciendo el parámetro addIfDoesntExist en true.

Diferentes idiomas

Como puede ver, no especificamos ningún idioma, porque todo eso lo hace la función GetText(string key) que llama a GetText(string key, string language), y el parámetro language se completa con TwoLetterISOLanguageName que devuelve el idioma actual de nuestro hilo.

En mi caso tengo el español como idioma predeterminado y por eso siempre lo muestra en español, pero podemos intentar usar inglés también.

Codifiquemos un poco y construyamos algo para verificar tanto en español como en 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)}");
}

Al ejecutar esto, el resultado debería ser algo como esto:

[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

Imagen de Gyazo

Los primeros dos valores son de resources.resx que están en inglés y los últimos ambos están en español y los valores se recuperan de resources.es.resx.

Eso es todo

En este tutorial encontramos una manera de combinar recursos integrados y externos para idiomas, esta fue una solución para un problema que teníamos en el equipo y ha estado funcionando desde entonces sin ningún problema.

Puedes consultar el código fuente aquí.

Si tienes alguna pregunta, no dudes en enviarme un tweet a @emimontesdeocaa y te responderé cuando tenga tiempo.