|
Персональные инструменты |
|||
|
Подмостки для Вавилонской башни, или О собственных типах данных для многоязычных приложенийМатериал из CustisWikiВладислав Иофе, наш архитектор приложений, опубликовал в корпоративном блоге на портале «Хабрахабр» статью о собственных типах данных для многоязычных приложений. Какие возникают трудности, когда необходимо сделать многоязычное приложение, и какие инструменты могут помочь решить данные проблемы? Об этом — в статье «Подмостки для Вавилонской башни, или О собственных типах данных для многоязычных приложений» на сайте. СодержаниеВведениеВозможно, вы готовы к тому, что ваше приложение будет многоязычным уже на старте проекта. Но скорее всего, новость о необходимости интернационализации, как это однажды уже случилось с человечеством, застанет вас в разгаре строительства Вавилонской башни. В любом случае полезно иметь при себе джентльменский набор средств, дающих шанс завершить стройку века успешно. Спустя четыре тысячи лет после Вавилонского столпотворения технологии предлагают нам несколько замечательных инструментов. Что же у нас есть? Во-первых, сборная — абстракция локали (locale). Локаль включает не только язык, но еще и письменность, календарь, правила форматирования чисел, денежных единиц, дат и пр. Во-вторых, Юникод. Юникод — это не просто таблица кодирования символов. Это еще и различные формы одних и тех же букв, диакритические знаки, порядок сортировки символов, правила изменения регистра, алгоритмы нормализации строк, семейство кодировок UTF и многое другое. Все это большое подспорье. Такие возможности, как правило, уже встроены в операционные системы и доступны в стандартных библиотеках. Программисты и пользователи во всех уголках планеты благополучно применяют одни и те же операционные системы, средства разработки, базы данных. Но, увы, нет в мире совершенства… Если ваше приложение должно одновременно обслуживать пользователей на многих языках, у вас, кем бы вы ни были (аналитиком, архитектором или программистом), возникают новые потребности. Далее мы расскажем вам о некоторых таких часто возникающих в корпоративных приложениях потребностях, основываясь на опыте нашей компании. Примеры кода в статье будут на C#. На GitHub выложен исходный код библиотеки, включающей рассматриваемые типы данных, их работоспособные реализации и не только. Несмотря на то что материал содержит некоторую специфику .NET, изложенные концепции работы с многоязычными данными будут полезны специалистам и на других платформах. А для начала мы рекомендуем ознакомиться с предыдущим материалом, посвященным интернационализации приложений. Условия задачиПредставим, что наше приложение должно работать сразу с несколькими языками. На любом из них в зависимости от окружения пользователя будут не только отображаться пользовательские интерфейсы, но и вводиться оперативные и справочные данные. При этом в одной сессии из нескольких вариантов локализации данных может использоваться и вариант только на одном определенном языке, и локализации для всех языков сразу. Для примера рассмотрим доменную сущность товара, имеющую среди прочих атрибутов артикул и наименование на различных языках. Нам требуется уметь описать доменную сущность, отображать и вводить через пользовательский интерфейс записи о товарах, а также печатать ценники. Многоязычные строкиПервая возникающая мысль о наименовании товара в доменной сущности — словарь с кодом локали в качестве ключа. public class Product { public string Code { get; set; } public IDictionary<string, string> Name { get; set; } } Вариант подкупает своей простотой, но сразу нарушает принципы проектирования публичных контрактов, ведь словарь Если задуматься, то мы с вами наверняка обнаружим случаи, когда со строками сразу на нескольких (или всех одновременно) языках необходимо проделать одну и ту же операцию (например, привести все буквы наименования к заглавным). Казалось бы, что может быть проще? static IDictionary<string, string> ToUpper(IDictionary<string, string> source) { IDictionary<string, string> destination = new Dictionary<string, string>(); foreach (var pair in source) { destination[pair.Key] = pair.Value.ToUpper(); } return destination; } Или совсем коротко: static IDictionary<string, string> ToUpper(IDictionary<string, string> source) { return source.ToDictionary(p => p.Key, p => p.Value.ToUpper()); } Однако в код уже закралась ошибка, правда, она всемирно известная: мы не прошли The Turkey Test. Дело в том, что для изменения регистра символов необходимо применять правила конкретного языка. И если мы его не указываем, то используется текущая локаль (локаль региональных настроек). Здесь оговоримся, что локаль в .NET называется культурой. Для каждого потока доступны две культуры: Поскольку мы заведомо меняем строки для различных локалей, верный код может выглядеть так: static IDictionary<string, string> ToUpper(IDictionary<string, string> source) { IDictionary<string, string> destination = new Dictionary<string, string>(); foreach (var pair in source) { var culture = CultureInfo.GetCultureInfo(pair.Key); destination[pair.Key] = pair.Value.ToUpper(culture); } return destination; } Черновик нового типа данныхЭти два факта: желание следовать хорошему стилю проектирования и высокая вероятность возникновения ошибок при регулярной работе с многоязычными данными — вполне могут и должны побудить нас ввести новый тип данных — многоязычную строку. Что же должна уметь многоязычная строка? Необходимо как минимум:
Вместе с тем интуитивно кажется, что многоязычная строка своим поведением и свойствами должна быть весьма похожа на обычную строку:
Однако многоязычная строка явно не должна поддерживать конкатенацию строк. Конкатенация в локализованных приложениях практически под запретом (по крайней мере, в рамках одного предложения), ведь порядок слов в разных языках может отличаться. Итак, посмотрим, что же у нас получается: /// <summary> Многоязычная строка. </summary> /// <remarks> Этот класс предназначен для хранения различных вариантов строки для разных культур. /// Строки с <see langword="null"/>-значениями не сохраняются. /// </remarks> [Serializable] public sealed class MultiCulturalString { # region Конструкторы /// <summary> Многоязычная строка. Ctor. </summary> private MultiCulturalString() {...} /// <summary> Многоязычная строка. Ctor. </summary> public MultiCulturalString(IEnumerable<KeyValuePair<CultureInfo, string>> localizedStrings) {...} /// <summary> Многоязычная строка. Ctor. Создает многоязычную строку со значением /// для единственной культуры. </summary> public MultiCulturalString(CultureInfo culture, string value) {...} #endregion #region Строковые методы /// <summary> Имеет ли <paramref name="value"/> значение <c>null</c> /// или <see cref="MultiCulturalString"/> только лишь с пустыми значениями? </summary> public static bool IsNullOrEmpty(MultiCulturalString value) {...} /// <summary> Имеет ли <paramref name="value"/> значение <see langword="null"/> /// или <see cref="MultiCulturalString"/> только лишь с пустыми /// или непечатаемыми значениями? </summary> public static bool IsNullOrWhiteSpace(MultiCulturalString value) {...} /// <summary> Объединение нескольких элементов в мультикультурную строку. </summary> public static MultiCulturalString Join(MultiCulturalString separator, params object[] args) {...} /// <summary> Возвращает новый экземпляр многоязычной строки со строкой /// <paramref name="localizedString"/> для культуры <paramref name="culture"/></summary> public MultiCulturalString SetLocalizedString(CultureInfo culture, string localizedString) {...} /// <summary> Слияние двух многоязычных строк с приоритетом данной </summary> public MultiCulturalString MergeWith(MultiCulturalString other) {...} /// <summary> Присутствует ли в данном экземпляре строка с заданной культурой. </summary> public bool ContainsCulture(CultureInfo culture) {...} /// <summary> Возвращает копию данной строки, приведенную к нижнему регистру. /// Для каждой культуры преобразование производится по правилам этой культуры. </summary> public MultiCulturalString ToLower() {...} /// <summary> Возвращает копию данной строки, приведенную к верхнему регистру. /// Для каждой культуры преобразование производится по правилам этой культуры. </summary> public MultiCulturalString ToUpper() {...} /// <summary> Возвращает новую строку, в которой знаки данного экземпляра выровнены /// по правому краю путем добавления слева символов-заполнителей до указанной общей длины. /// </summary> public MultiCulturalString PadLeft(int totalWidth, char paddingChar = ' ') {...} /// <summary> Возвращает новую строку, в которой знаки данного экземпляра выровнены /// по левому краю путем добавления справа символов-заполнителей до указанной общей длины. /// </summary> public MultiCulturalString PadRight(int totalWidth, char paddingChar = ' ') {...} #endregion #region Перегрузки ToString() /// <summary> Возвращает строку в UI-культуре потока </summary> public override string ToString() {...} /// <summary> Возвращает строку в указанной культуре </summary> public string ToString(CultureInfo culture) {...} #endregion #region Свойства /// <summary> Возвращает многоязычную строку, которая не содержит значения /// ни для какой культуры.</summary> public static MultiCulturalString Empty {...} /// <summary> Получает список культур, на которые локализована данная строка. </summary> public IEnumerable<CultureInfo> Cultures {...} /// <summary> Является ли мультикультурная строка пустой? </summary> public bool IsEmpty {...} /// <summary> Содержит ли строка только пустые или непечатаемые значения? </summary> 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)); // ? Какой результат вы бы ожидали получить? Ну никак не исключение! Может быть, Тем не менее нам необходимо уметь отличать ситуацию наличия для заданной локали пустой строки от случая ее отсутствия. Поэтому наш класс многоязычной строки прирастет методами ФорматированиеКак мы уже говорили, конкатенацию строк мы использовать не можем, поэтому строки с подстановками — наше все. Локализованные строки в подавляющем большинстве случаев содержат одни и те же подстановки для всех локалей. Следовательно, хорошо бы уметь многоязычную строку форматировать. Что бы это значило? Ведь мы сразу догадались поддержать перегрузки В качестве поставщика формата для метода // В Индии и некоторых других странах необычная группировка цифр // 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 Прекрасно, мы получили строку 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 А что рассчитывал получить читатель? Автор, например, ожидал бы получить Да-да, мы не должны забывать о разделении региональных настроек и настроек локализации. В .NET Framework есть как минимум три стандартные реализации 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 А реализация string IFormattable.ToString(string format, IFormatProvider formatProvider) { // format не используется var formatInfo = LocalizationFormatInfo.GetInstance(formatProvider); return ToString(formatInfo.Culture ?? CultureInfo.CurrentUICulture); } Зато возможность в Первый набросок /// <summary> Информация о локализации объектов. </summary> [Serializable] public sealed class LocalizationFormatInfo : IFormatProvider { /// <summary> Информация о локализации объектов. </summary> /// <param name="culture">Культура отображения форматируемого объекта.</param> /// <param name="provider">Поставщик других форматов.</param> public LocalizationFormatInfo(CultureInfo culture, IFormatProvider provider = null) { _culture = culture; _provider = provider; } /// <summary> Получает объект с информацией о каком-либо формате по типу этого объекта. </summary> public object GetFormat(Type formatType) { if (formatType == GetType()) { return this; } if (Provider != null) { // Если есть другой поставщик формата, то запрашиваем сведения у него. return Provider.GetFormat(formatType); } return null; } /// <summary> Культура отображения форматируемого объекта. Может быть null. </summary> public CultureInfo Culture { get { return _culture; } } private readonly CultureInfo _culture; /// <summary> Поставщик других форматов. Может быть null. </summary> public IFormatProvider Provider { get { return _provider; } } private readonly IFormatProvider _provider; /// <summary> /// Получить из <paramref name="provider"/> экземпляр <see cref="LocalizationFormatInfo"/>. /// </summary> /// <param name="provider">Поставщик объектов форматирования. Может быть <see langword="null"/>.</param> /// <returns>Экземпляр <see cref="LocalizationFormatInfo"/>.</returns> 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); } Развитием рассмотренного примера может стать превращение строки форматирования ценника в многоязычную строку. Поиск подходящей локализацииДавайте еще раз представим, что мы создали экземпляр товара с наименованием на русском и английском языках, а затем запросили наименование для отсутствующего китайского языка. Вопрос прежний: какой результат вы бы ожидали получить? В предыдущем разделе мы остановились на том, что, возможно, Рассмотрим распространенные ситуации. Новые версии приложений выходят, но цикл перевода не успевает вовремя за всеми изменениями. Многие свободные продукты переводятся энтузиастами, часто локализации от старых версий используются в новых. Как следствие, далеко не все элементы пользовательского интерфейса могут быть переведены или желаемый язык пользователя вообще не поддерживается приложением. Очевидно, что в таких случаях пустoты в интерфейсе недопустимы. Необходимо отобразить ресурсы хотя бы для какого-то языка. При этом желательно, чтобы отображаемый элемент мог быть воспринят пользователем: узнан, прочитан, но не обязательно понят или переведен. Здесь вступает в силу разумное предположение, что для локализованного приложения есть локаль по умолчанию, для которой набор ресурсов всегда актуален и полон. Однако с локалью по умолчанию связана еще одна проблема: для пользователя в Казахстане при отсутствии казахстанской локализации наиболее естественно отобразить ресурс для русской локали, в то время как для пользователя в Китае логично показывать ресурс для английской локали, поскольку в Китае английским языком хоть как-то владеет большая доля населения, чем русским. В документации по локализации в .NET описывается термин resource fallback process, который можно перевести на русский как «обработка альтернативных ресурсов». Суть обработки в том, что если для текущей локали пользовательского интерфейса не найдены соответствующие ресурсы, то будет предпринята попытка найти ресурсы для родительской локали. Так, для локали Увы, в .NET Framework логика обработки альтернативных ресурсов «зашита» глубоко в недра платформы. Наша же задача состоит в том, чтобы научиться кастомизировать процесс поиска ресурсов. Для этого давайте введем абстракцию Представим новый интерфейс: public interface IResourceFallbackProcess { /// <summary> /// Для заданной культуры возвращает цепочку культур в том порядке, /// в котором необходимо искать ресурсы. /// </summary> /// <param name="initial">Начальная культура.</param> IEnumerable<CultureInfo> GetFallbackChain(CultureInfo initial); } Такой интерфейс позволит нам осуществить задуманное для каждой локали пользовательских интерфейсов:
И, конечно же, ЗаключениеВ исходном коде нашей библиотеки приведена работоспособная реализация Очевидно, мы с вами рассмотрели не все возможные «подмостки», а только самые востребованные и универсальные. Например, можно подумать о В следующей статье «Подвалы Вавилонской башни, или Об интернационализации БД с доступом через ORM» мы рассмотрим вопросы хранения локализованных данных в БД и доступа к ним через объектно-реляционный маппер. |
||