Подмостки для Вавилонской башни, или О собственных типах данных для многоязычных приложений

Гравюра М. Эшера "Вавилонская башня"
Гравюра М. Эшера «Вавилонская башня», 1928
Введение

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


Спустя четыре тысячи лет после Вавилонского столпотворения технологии предлагают нам несколько замечательных инструментов. Что же у нас есть?


Во-первых, сборная солянка — абстракция локали (locale). Локаль включает не только язык, но еще и письменность, календарь, правила форматирования чисел, денежных единиц, дат и пр.


Во-вторых, Юникод. Юникод — это не просто таблица кодирования символов. Это еще и различные формы одних и тех же букв, диакритические знаки, порядок сортировки символов, правила изменения регистра, алгоритмы нормализации строк, семейство кодировок UTF и многое другое.


Все это большое подспорье. Такие возможности, как правило, уже встроены в операционные системы и доступны в стандартных библиотеках. Программисты и пользователи во всех уголках планеты благополучно применяют одни и те же операционные системы, средства разработки, базы данных. Но,  увы,  нет в мире совершенства… Если ваше приложение должно одновременно обслуживать пользователей на многих языках, у вас, кем бы вы ни были (аналитиком, архитектором или программистом), возникают новые потребности.


Далее мы расскажем вам о некоторых таких часто возникающих в корпоративных приложениях потребностях, основываясь на опыте нашей компании. Примеры кода в статье будут на C#. На GitHub выложен исходный код библиотеки, включающей рассматриваемые типы данных, их работоспособные реализации и не только. Несмотря на то что материал содержит некоторую специфику .NET, изложенные концепции работы с многоязычными данными будут полезны специалистам и на других платформах.


А для начала мы рекомендуем ознакомиться с предыдущим материалом, посвященным интернационализации приложений.


Условия задачи


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


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


Многоязычные строки

Первая возникающая мысль о наименовании товара в доменной сущности — словарь с кодом локали в качестве ключа.


public class Product
{
    public string Code { get; set; }
    public IDictionary Name { get; set; }
}

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


Если задуматься, то мы с вами наверняка обнаружим случаи, когда со строками сразу на нескольких (или всех одновременно) языках необходимо проделать одну и ту же операцию (например, привести все буквы наименования к заглавным). Казалось бы, что может быть проще?


static IDictionary ToUpper(IDictionary source)
{
    IDictionary destination = new Dictionary();
    foreach (var pair in source)
    {
        destination[pair.Key] = pair.Value.ToUpper();
    }
    return destination;
}

Или совсем коротко:


static IDictionary ToUpper(IDictionary source)
{
    return source.ToDictionary(p => p.Key, p => p.Value.ToUpper());
}

Однако в код уже закралась ошибка, правда, она всемирно известная: мы не прошли The Turkey Test.


Дело в том, что для изменения регистра символов необходимо применять правила конкретного языка. И если мы его не указываем, то используется текущая локаль (локаль региональных настроек).


Здесь оговоримся, что локаль в .NET называется культурой. Для каждого потока доступны две культуры: CurrentCulture и CurrentUICulture. Первая применяется для форматирования чисел, дат и других региональных настроек, а вторая используется в алгоритме поиска подходящих локализованных ресурсов, таких как строки, изображения, layout пользовательских интерфейсов и пр.


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


static IDictionary ToUpper(IDictionary source)
{
    IDictionary destination = new Dictionary();
    foreach (var pair in source)
    {
        var culture = CultureInfo.GetCultureInfo(pair.Key);
        destination[pair.Key] = pair.Value.ToUpper(culture);
    }
    return destination;
}

Черновик нового типа данных


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


Что же должна уметь многоязычная строка? Необходимо как минимум:


  • Иметь возможность содержать значения для любых доступных локалей.
  • Предоставлять список содержащихся локалей.
  • Предоставлять обыкновенную строку по заданной локали.
  • Возвращать строку в текущей локали при вызове ToString().

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


  • Быть иммутабельной (неизменяемой).
  • Быть сериализуемой.
  • Реализовывать следующие методы:
    • Изменение регистра: ToLower(), ToUpper().
    • Проверка на пустоту и непечатные символы: IsNullOrEmpty(), IsNullOrWhiteSpace().
    • Объединение нескольких строк через разделитель (-и): Join().
    • Набивка пробелами в начале и конце строки: PadLeft(), PadRight().

Однако многоязычная строка явно не должна поддерживать конкатенацию строк. Конкатенация в локализованных приложениях практически под запретом (по крайней мере, в рамках одного предложения), ведь порядок слов в разных языках может отличаться.


Итак, посмотрим, что же у нас получается
///  Многоязычная строка.     
///  Этот класс предназначен для хранения различных вариантов строки для разных культур. 
/// Строки с -значениями не сохраняются.
///     
[Serializable]
public sealed class MultiCulturalString
{

    #region Конструкторы

    ///  Многоязычная строка. Ctor. 
    private MultiCulturalString() {...}

    ///  Многоязычная строка. Ctor.   
    public MultiCulturalString(IEnumerable> localizedStrings) 
    {...}

    ///  Многоязычная строка. Ctor. Создает многоязычную строку со значением 
    /// для единственной культуры. 
    public MultiCulturalString(CultureInfo culture, string value) 
    {...}

    #endregion

    #region Строковые методы

    ///  Имеет ли  значение null 
    /// или  только лишь с пустыми значениями? 
    public static bool IsNullOrEmpty(MultiCulturalString value) {...}

    ///  Имеет ли  значение  
    /// или  только лишь с пустыми 
    /// или непечатаемыми значениями? 
    public static bool IsNullOrWhiteSpace(MultiCulturalString value) {...}

    ///  Объединение нескольких элементов в мультикультурную строку. 
    public static MultiCulturalString Join(MultiCulturalString separator, params object[] args) 
    {...}

    ///  Возвращает новый экземпляр многоязычной строки со строкой 
    ///  для культуры 
    public MultiCulturalString SetLocalizedString(CultureInfo culture, string localizedString) 
    {...}

    ///  Слияние двух многоязычных строк с приоритетом данной 
    public MultiCulturalString MergeWith(MultiCulturalString other) {...}

    ///  Присутствует ли в данном экземпляре строка с заданной культурой. 
    public bool ContainsCulture(CultureInfo culture) {...}

    ///  Возвращает копию данной строки, приведенную к нижнему регистру. 
    /// Для каждой культуры преобразование производится по правилам этой культуры. 
    public MultiCulturalString ToLower() {...}

    ///  Возвращает копию данной строки, приведенную к верхнему регистру. 
    /// Для каждой культуры преобразование производится по правилам этой культуры. 
    public MultiCulturalString ToUpper() {...}

    ///  Возвращает новую строку, в которой знаки данного экземпляра выровнены 
    /// по правому краю путем добавления слева символов-заполнителей до указанной общей длины. 
    /// 
    public MultiCulturalString PadLeft(int totalWidth, char paddingChar = ' ') {...}
    
    ///  Возвращает новую строку, в которой знаки данного экземпляра выровнены 
    /// по левому краю путем добавления справа символов-заполнителей до указанной общей длины. 
    /// 
    public MultiCulturalString PadRight(int totalWidth, char paddingChar = ' ') {...}

    #endregion

    #region Перегрузки ToString()

    ///  Возвращает строку в UI-культуре потока 
    public override string ToString() {...}

    ///  Возвращает строку в указанной культуре 
    public string ToString(CultureInfo culture) {...}

    #endregion

    #region Свойства

    ///  Возвращает многоязычную строку, которая не содержит значения 
    /// ни для какой культуры.
    public static MultiCulturalString Empty {...}

    ///  Получает список культур, на которые локализована данная строка. 
    public IEnumerable Cultures {...}

    ///  Является ли мультикультурная строка пустой? 
    public bool IsEmpty {...}

    ///  Содержит ли строка только пустые или непечатаемые значения? 
    public bool IsWhiteSpace {...}

    #endregion

}

Ну, а внутри класса скрываются все тот же словарь и незатейливые манипуляции с ним.


И описание товара выглядит вполне благопристойно:


public class Product
{
    public string Code { get; set; }
    public MultiCulturalString Name { get; set; }
}

Усовершенствования


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


ToString () недостаточно


Представим, что при заведении товара мы заполнили наименование только для некоторых из требуемых языков:


var ru = CultureInfo.GetCultureInfo("ru");
var en = CultureInfo.GetCultureInfo("en");
var product = new Product
{
    Code = "V0016887",
    Name = new MultiCulturalString(ru, "Шоколад Алина")
        .SetLocalizedString(en, "Chocolate Alina")
};

А затем запросили наименование для отсутствующего языка:


var zhHans = CultureInfo.GetCultureInfo("zh-Hans");
Console.WriteLine(product.Name.ToString(zhHans));
// ?

Какой результат вы бы ожидали получить?


Ну никак не исключение! Может быть, null? Вероятно! Но документация по Object.ToString() не рекомендует возвращать ни null, ни пустую строку. А Code Contracts прямо запрещают возвращать null.


Тем не менее нам необходимо уметь отличать ситуацию наличия для заданной локали пустой строки от случая ее отсутствия. Поэтому наш класс многоязычной строки прирастет методами GetString(...), которые будут уметь возвращать null и имеют те же сигнатуры, что и методы ToString(...).


Форматирование


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


Следовательно, хорошо бы уметь многоязычную строку форматировать. Что бы это значило? Ведь мы сразу догадались поддержать перегрузки GetString(CultureInfo) / ToString(CultureInfo). Но стандартным для .NET способом преобразования любых объектов в строковое представление с возможностью настройки (!) является реализация интерфейса IFormattable. Если аргументы, участвующие в подстановках, реализуют этот интерфейс, то именно он будет использоваться для преобразования аргумента в строку. Таким образом, нам предстоит реализовать IFormattable в многоязычной строке.


В качестве поставщика формата для метода IFormattable.ToString(string format, IFormatProvider formatProvider) можно как раз использовать локаль (культуру). А первый параметр позволяет задать параметры форматирования, не зависящие от локали. Например, вы можете задать отображение доли в виде процентов на английском для Индии:


// В Индии и некоторых других странах необычная группировка цифр
// https://en.wikipedia.org/wiki/Indian_numbering_system
12345.6789.ToString("P", CultureInfo.GetCultureInfo("en-IN"));
// 12,34,567.89%

Итак, попробуем сформировать ценник для того же товара:


var ru = CultureInfo.GetCultureInfo("ru");
var en = CultureInfo.GetCultureInfo("en");
var product = new Product
{
    Code = "V0016887",
    Name = new MultiCulturalString(ru, "Шоколад Алина")
        .SetLocalizedString(en, "Chocolate Alina")
};
IFormatProvider localizationFormatProvider = en;
Console.WriteLine(string.Format(localizationFormatProvider, 
    "Артикул: {0}\r\nНаименование: {1}", 
    product.Code, 
    product.Name));
// Артикул: V0016887
// Наименование: Chocolate Alina

Прекрасно, мы получили строку »Артикул: V0016887\r\nНаименование: Chocolate Alina», как и ожидали! Теперь немного усложним задачу, добавив в ценник дату его создания и поместив пользователя в англоязычный интерфейс с русскими региональными настройками:


Thread.CurrentThread.CurrentCulture = ru;
IFormatProvider localizationFormatProvider = en;
Console.WriteLine(string.Format(localizationFormatProvider,
    "Артикул: {0}\r\nНаименование: {1}\r\nДата: {2:d}", 
    product.Code, 
    product.Name, 
    DateTime.Now));
// Артикул: V0016887
// Наименование: Chocolate Alina
// Дата: 11/25/2016 

А что рассчитывал получить читатель? Автор, например, ожидал бы получить »Артикул: V0016887\r\nНаименование: Chocolate Alina\r\nДата: 25.11.2016».


Да-да, мы не должны забывать о разделении региональных настроек и настроек локализации.


В .NET Framework есть как минимум три стандартные реализации IFormatProvider (CultureInfo, NumberFormatInfo, DateTimeFormatInfo), и ни одна из них нам не подходит. Нам необходима собственная реализация, которая будет нести в себе информацию о требуемой локали для локализации, в частности, для многоязычных строк, но не будет применяться для форматирования чисел и дат. Назовем ее LocalizationFormatInfo. Использование выглядит не сложнее, чем код ранее:


Thread.CurrentThread.CurrentCulture = ru;
IFormatProvider localizationFormatProvider = new LocalizationFormatInfo(en);
Console.WriteLine(string.Format(localizationFormatProvider,
    "Артикул: {0}\r\nНаименование: {1}\r\nДата: {2:d}",
    product.Code,
    product.Name,
    DateTime.Now));
// Артикул: V0016887
// Наименование: Chocolate Alina
// Дата: 25.11.2016

А реализация IFormattable в MultiCulturalString выглядит примерно так:


string IFormattable.ToString(string format, IFormatProvider formatProvider)
{
    // format не используется
    var formatInfo = LocalizationFormatInfo.GetInstance(formatProvider);
    return ToString(formatInfo.Culture ?? CultureInfo.CurrentUICulture);
}

Зато возможность в LocalizationFormatInfo делегировать форматирование не многоязычных строк (дат, чисел и всего чего угодно) другим поставщикам будет весьма полезна.


Первый набросок LocalizationFormatInfo может выглядеть так
///  Информация о локализации объектов. 
[Serializable]
public sealed class LocalizationFormatInfo : IFormatProvider
{
    ///  Информация о локализации объектов. 
    /// Культура отображения форматируемого объекта.
    /// Поставщик других форматов.
    public LocalizationFormatInfo(CultureInfo culture, IFormatProvider provider = null)
    {
        _culture = culture;
        _provider = provider;
    }

    ///  Получает объект с информацией о каком-либо формате по типу этого объекта. 
    public object GetFormat(Type formatType)
    {
        if (formatType == GetType())
        {
            return this;
        }

        if (Provider != null)
        {
            // Если есть другой поставщик формата, то запрашиваем сведения у него.
            return Provider.GetFormat(formatType);
        }

        return null;
    }

    ///  Культура отображения форматируемого объекта. Может быть null. 
    public CultureInfo Culture
    {
        get { return _culture; }
    }
    private readonly CultureInfo _culture;

    ///  Поставщик других форматов. Может быть null. 
    public IFormatProvider Provider
    {
        get { return _provider; }
    }
    private readonly IFormatProvider _provider;

        ///  
    /// Получить из  экземпляр .
    /// 
    /// Поставщик объектов форматирования. Может быть .
    /// Экземпляр .
    public static LocalizationFormatInfo GetInstance(IFormatProvider provider)
    {
        LocalizationFormatInfo lfi = null;
        // Сначала пытаемся получить из поставщика
        if (provider != null)
        {
            lfi = provider.GetFormat(typeof(LocalizationFormatInfo)) as LocalizationFormatInfo;
        }
        return lfi ?? Default;
    }

    private static readonly LocalizationFormatInfo Default = new LocalizationFormatInfo(null);
}

Развитием рассмотренного примера может стать превращение строки форматирования ценника в многоязычную строку.


Поиск подходящей локализации

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


В предыдущем разделе мы остановились на том, что, возможно, null приемлем.


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


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


Пример отсутствия некоторых переводов, замена ресурсов локали pt на en
0b922977cac448419e306c60db2239dd.png

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


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


В документации по локализации в .NET описывается термин resource fallback process, который можно перевести на русский как «обработка альтернативных ресурсов». Суть обработки в том, что если для текущей локали пользовательского интерфейса не найдены соответствующие ресурсы, то будет предпринята попытка найти ресурсы для родительской локали. Так, для локали en-IN родительской будет нейтральная локаль en (нейтральная — не содержащая специфики региона). Поэтому в большинстве случаев для хранения универсальной для диалектов одного языка локализации можно порекомендовать именно нейтральные локали. А для локали en, в свою очередь, родительской будет инвариантная, при выборе которой произойдет попытка найти ресурсы по умолчанию, которые должны существовать всегда.


Увы, в .NET Framework логика обработки альтернативных ресурсов «зашита» глубоко в недра платформы.


Наша же задача состоит в том, чтобы научиться кастомизировать процесс поиска ресурсов. Для этого давайте введем абстракцию IResourceFallbackProcess. Единственной ее ответственностью будет генерация удобных нам последовательностей локалей для поиска подходящих ресурсов. При этом за поиск и загрузку ресурсов (в файловой системе, БД и т. д.) отвечают совершенно другие классы, например ResourceManager.


Представим новый интерфейс:


public interface IResourceFallbackProcess
{
    ///  
    /// Для заданной культуры возвращает цепочку культур в том порядке, 
    /// в котором необходимо искать ресурсы. 
    /// 
    /// Начальная культура.
    IEnumerable GetFallbackChain(CultureInfo initial);
}

Такой интерфейс позволит нам осуществить задуманное для каждой локали пользовательских интерфейсов:


  • для initial локали zh-CH мы можем вернуть цепочку zh-CH -> zh-CHS -> zh-Hans -> zh -> en,
  • для initial локали kz-KZ мы можем вернуть цепочку kz-KZ -> kz -> ru.

И, конечно же, IResourceFallbackProcess нужно активно применять в многоязычной строке. Вполне уместными смотрятся перегрузки методов GetString(...) / ToString(...) с параметрами IResourceFallbackProcess resourceFallbackProcess и bool useFallback, причем перегрузки без useFallback используют значение true, а перегрузки без resourceFallbackProcess — некий стандартный для вашего приложения порядок поиска.


Заключение

В исходном коде нашей библиотеки приведена работоспособная реализация IResourceFallbackProcess. Читателю может быть полезным сделать эту реализацию конфигурируемой, а также создать собственный CustomizedResourceManager, использующий IResourceFallbackProcess. Кроме того, можно написать расширение для Visual Studio, чтобы автоматически генерируемые для файлов ресурсов классы использовали ваш CustomizedResourceManager.


Очевидно, мы с вами рассмотрели не все возможные «подмостки», а только самые востребованные и универсальные. Например, можно подумать о MultiCulturalStringBuilder, а для форматирования — об IMultiCulturalFormattable.


В следующей статье «Подвалы Вавилонской башни, или Об интернационализации БД с доступом через ORM» мы рассмотрим вопросы хранения локализованных данных в БД и доступа к ним через объектно-реляционный маппер.

Комментарии (1)

  • 5 декабря 2016 в 11:20

    0

    А что делать если я хочу перевести: «Should contain X character (s)»? где Х — неопределенное число, которое задается позже?… в зависимости от >1 или 1 будут разные переводы. И если в русском и английском переводе в этом случае будет всего по два соответствующих варианта, то в других языках и других предложениях, количество возможных переводов может отличаться.

© Habrahabr.ru