Пользовательские языки с использованием встроенных и внешних ресурсов в .NET Framework.

· 10 мин чтения

Введение

На работе мы столкнулись с обычной проблемой, с которой, я почти уверен, приходится сталкиваться многим разработчикам, а именно с языками и специальными языками клиентов, которые предоставляют наши услуги своим клиентам.

Например, предположим, что базовый язык использует неформальный язык, а клиент хочет использовать формальный язык, потому что его клиенты — пожилые люди.

Также обратите внимание, что это решение предназначено для нескольких языков, мы будем использовать английский и испанский.

Мой родной язык не английский. Приношу извинения за ошибки в уроке. Если вы обнаружите ошибки и захотите их исправить, вы можете открыть запрос на включение в этот репозиторий, и я с радостью его одобрю!

Ресурсы

Ресурсы — это XML-файлы с расширением .resx со структурой ключ/значение, которые выглядят следующим образом:

[[[ТОК_2]]]

Мы будем создавать ресурсы для двух разных языков: английского и испанского, поэтому номенклатура файлов будет такой: resources.ISOLANGUAGECODE.resx, например: resource.es.resx для испанского и resource.resx для английского. Если позже мы захотим добавить немецкий, файл будет называться resource.de.resx.

Встроенный ресурс

Встроенные ресурсы, при компиляции проекта, они будут добавлены внутрь dll.

Вот изображение со встроенным ресурсом, как видите, файл Resource.resx не виден при компиляции.

Изображение от Gyazo

Внешний ресурс

С другой стороны, внешние или невстроенные ресурсы — это ресурсы, которые будут добавлены в папку после компиляции.

Файлы ресурсов (.resx) будут находиться в папке Properties

Изображение от Gyazo

Сборка проекта

Начнем с консольного проекта в .NET Framework.

Создание встроенного файла ресурсов

Добавьте файл ресурсов с несколькими ключами и убедитесь, что это встроенный ресурс, который позже мы будем использовать для обновления с помощью внешних ресурсов.

Изображение от Гьязо

Создание внешнего файла ресурсов

Чтобы отделить внешние ресурсы от встроенных, мы добавим внешние ресурсы в папку с именем, чтобы мы могли легко получить к ним доступ позже.

Совет: чтобы добавить папку в папку «Свойства», создать ее снаружи и переместить внутрь, Visual Studio не позволяет вам ее создать

Сделайте то же самое, что мы сделали для встроенного ресурса, затем перейдите к properties файла и внутри Advanced, Build action и измените его на: Content и измените Copy to Output Dictionary на Copy if newer.

Изображение от Gyazo

Вот как это должно выглядеть:

Изображение от Gyazo

Добавляем ключ в app.config

Поскольку мы классные разработчики и любим, чтобы все было сделано хорошо и НЕ менялось код для каждого клиента, давайте добавим ключ в appconfig, который будет содержать имя папки, в которой мы будем искать файлы ресурсов.

<?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>
```Поэтому позже, когда мы будем искать ресурсы, они будут искать в `Properties.John` вместо `Properties.Doe`. 

Кроме того, это упрощает внесение изменений, когда приложение уже развернуто, поскольку вы можете легко изменить файл app.config.

## Доступ к встроенным файлам ресурсов

Использовать .NET Framework довольно просто, поэтому для доступа к встроенным свойствам нам просто нужно использовать объект `Properties` , который будет иметь файлы ресурсов в качестве свойства, а внутри этого объекта будут все ключи/значения, которые есть в файле `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();
}

Запустив это, мы должны показать что-то вроде этого:

Action_greeting value: Hello, Action_cancel value: Cancel

Доступ к файлам внешних ресурсов

Эта часть практически такая же: вы можете получить доступ к файлам ресурсов с помощью объекта 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;

Проблема

Основная проблема при использовании этого метода заключается в том, что каждый ключ является свойством внутри объекта, поэтому нам приходится вызывать его так, как мы видели раньше. Если вы хотите вызвать ключ Action_greeting файла ресурсов John мы должны использовать следующий Properties.John и затем Resource.Action_greeting.

Вот в чём проблема.

Это потому, что если мы разрабатываем приложение для большого количества клиентов, менять способ вызова файлов ресурсов для каждого из них — плохая идея.

Можете ли вы себе такое представить? Компилируем приложение для каждого клиента и меняем John на Doe, а затем на что-то еще. Это безумие!

Решение

Наш руководитель группы придумал довольно хороший метод, что-то вроде резервной системы: у нас должна быть базовая модель ресурсов, а затем для каждого из клиентов есть файл ресурсов, который будет обновлять файл своими ресурсами, и в итоге мы получаем единый список ресурсов.

Если клиенту не нужны специальные ресурсы, мы используем базовые ресурсы, а если они им нужны, мы используем их.

Чтобы внести это в контрольный список, нам нужно сделать:

  • Найдите способ сопоставить все ключи/значения со словарем для встроенных ресурсов.
  • [] Найдите способ сопоставить все ключи/значения со словарем для внешних ресурсов.
  • Смешайте оба файла и получите один словарь для каждого языка.
  • [] Создать метод, который обращается к словарю и возвращает значение

Диаграмма

Изображение от Gyazo

Давайте напишем код

Прежде всего, давайте создадим отдельный класс, в котором будет вся наша логика: от получения файлов ресурсов до их смешивания и возврата значения. Этот класс будет называться CustomResources.

Вот как это выглядит:

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

Обратите внимание, что мы реализуем отложенную загрузку свойств, что помогает повысить производительность и позволяет однократно загружать словарь.

  • GetDictionaryFromEmbedded: возвращает словарь из встроенных ресурсов.
  • GetDictionaryFromFile: возвращает словарь из внешних ресурсов.
  • OverwriteDictionary: смешивает два словаря и возвращает один.
  • GetText: возвращает значение по ключу.

От встроенного ресурса к словарю

Нам нужно получить все свойства из XML-файла и вернуть словарь:

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

Две вещи:- Обратите внимание, что ему нужен параметр с именем embedded, который parementers — это имя файла, который вы можете увидеть в дизайнере, в нашем случае это: resources-demo.Properties.Resource.

  • Также у нас есть параметрcultreInfoCode, который является кодом выбираемого языка. К счастью для нас, .NET Framework делает всю работу за нас, и нам не нужно ничего делать, просто установите английский или испанский язык, и он выберет между resource.es.resx или resource.resx

Из внешнего ресурса в словарь

Получение файла из файла немного сложное, но несложное: нам нужно получить текущее местоположение исполняемого файла, соединить местоположение файла ресурсов, а затем проанализировать его в словаре.

Но сначала вам нужно добавить ссылку на System.Windows.Forms, чтобы получить доступ к ResXResourceReader.

Изображение от Гьязо

Теперь перейдем к нашему методу 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;
}

В параметрах файла необходимо указать расположение от исполняемого файла до файла ресурсов, в нашем случае это: "\\Properties\\John\\Resource.resx".

Смешивание словарей

Мы почти закончили, во-первых, не забудьте добавить System.Configuration к ссылкам, чтобы вы могли получить доступ к 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;
}

Получение текста из словаря

Создайте общедоступный метод, который вызывает закрытый метод, который выбирает язык:

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

Обратите внимание, что вам придется изменить переключатель, если вы добавляете больше языков.

Добавление кода в метод получения свойств

Поскольку сейчас у нас есть все методы, мы можем изменить метод получения публичного свойства, чтобы получать значения.

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. Сначала мы получаем идентификатор из файла app.settings.
  2. Затем получаем базовые ресурсы, встроенные.
  3. После этого мы получаем пользовательские ресурсы, для этого нам нужно имя папки (которое является идентификатором).
  4. Затем смешиваем их и возвращаем значение.

Все это будет сделано один раз, из-за ленивой загрузки.

Тестирование

Все, что связано с кодом, закончено, теперь давайте его протестируем, чтобы получить значение из словаря нам нужно вызвать метод CustomResources.GetText(string key) который возвращает значение.

Обновление целых файлов ресурсов

Это тестирование представляет собой тот случай, когда мы хотим обновить весь ключ/значение файлов ресурсов, как вы можете видеть на изображениях, у нас одинаковые ключи, но разные значения.

Мы будем тестировать John, и для того, чтобы это установить, нам нужно установить в app.config значение <add key="CustomResources.Folder" value="John" />.

Теперь давайте проверим наш базовый файл ресурсов (Properties/Resource.es.resx):

Изображение от Gyazo

И затем наш внешний файл ресурсов (Properties/John/Resource.es.resx):

Изображение от Gyazo

Окей, все настроено, запускаем консольное приложение, остановимся на части свойств get и все проверим

folderIdentifier имеет значение настройки приложения:

Изображение от Gyazo

baseResources имеет значение базовых ресурсов, встроенных:

Изображение от Gyazo

customResources содержит значения внешних ресурсов внутри папки John:Изображение от Gyazo

И, наконец, _ResourcesSpanish имеет значение, смешанное с базовыми ресурсами и внешними ресурсами.

Изображение от Gyazo

Обновление только одного

Теперь давайте протестируем то же самое, но по другому сценарию: просто обновим один ключ, а другой оставим таким же.

Изображение от Gyazo

Как вы можете видеть, файлы имеют одинаковое значение значения для Action_greeting, но другое значение для Action_cancel, поэтому следует обновлять только Action_cancel.

Изображение от Gyazo

Отсутствует файл ресурсов

Если вы не предоставите внешний файл ресурсов, это вообще не имеет значения, потому что, поскольку мы ожидаем наличие файла, в случае сбоя он вернет пустой словарь, а при смешивании обоих словарей в конечном итоге получится базовый.

Отсутствует пара во встроенном ресурсе

Если у вас есть пара во внешнем файле ресурсов, по умолчанию она не будет добавлена в окончательный словарь. Вы можете изменить это, вызвав метод OverwriteDictionary() при смешивании обоих словарей и установив для параметра addIfDoesntExist значение true.

Разные языки

Как вы можете видеть, мы не указали какой-либо язык, потому что все это делается функцией GetText(string key), которая вызывает GetText(string key, string language), а параметр language заполняется TwoLetterISOLanguageName который возвращает текущий язык нашего потока.

В моем случае испанский язык является языком по умолчанию, поэтому он всегда отображается на испанском, но мы можем попробовать использовать и английский.

Давайте немного напишем код и создадим что-нибудь для проверки испанского и английского языков.

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

Запустив это, результат должен быть примерно таким:

[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

Изображение от Gyazo

Первые два значения взяты из resources.resx на английском языке, а последние оба на испанском, и значения извлекаются из resources.es.resx.

Вот и все

В этом уроке мы нашли способ объединить встроенные и внешние ресурсы для языков. Это было решением проблемы, которая возникла у нас в команде, и с тех пор все работает без каких-либо проблем.

Вы можете проверить исходный код здесь.

Если у вас есть какие-либо вопросы, напишите мне в Твиттере по адресу @emimontesdeocaa, и я свяжусь с вами, когда у меня будет время.