Підмостки для Вавілонської вежі, або Про власні типи даних для багатомовних додатків

Гравюра М. Ешера "Вавилонська вежа"Гравюра М. Ешера «Вавилонська вежа», 1928
Введення
Можливо, ви готові до того, що ваш додаток буде багатомовним вже на старті проекту. Але швидше за все, новина про необхідності інтернаціоналізації, як це одного разу вже сталося з людством, застане вас в розпалі будівництва Вавилонської вежі. У будь-якому випадку корисно мати при собі джентльменський набір засобів, що дають шанс завершити будівництво століття успішно.
Через чотири тисячі років після Вавилонського стовпотворіння технології пропонують нам кілька чудових інструментів. Що ж у нас є?
По-перше, збірна солянка — абстракція локалі (locale). Локаль включає не тільки мову, але і писемність, календар, правила форматування чисел, грошових одиниць, дат і тощо
По-друге, Юнікод. Юнікод — це не просто таблиця кодування символів. Це і різні форми одних і тих же букв, діакритичні знаки, порядок сортування символів, правила зміни регістру, алгоритми нормалізації рядків, сімейство кодування UTF і багато іншого.
Все це велика підмога. Такі можливості, як правило, вже вбудовані в операційні системи і доступні в стандартних бібліотеках. Програмісти і користувачі всіх куточках планети благополучно застосовують одні й ті  операційні системи, засоби розробки бази даних. Але, на жаль,   світі досконалості… Якщо ваш додаток має одночасно обслуговувати користувачів на багатьох мовах, у вас, ким би ви  були (аналітиком, архітектором або програмістом), виникають нові потреби.
Далі ми розповімо вам про деяких таких часто виникають у корпоративних додатках потребах, грунтуючись на досвід нашої компанії. Приклади коду статті будуть на C#. На GitHub викладений вихідний код бібліотеки, що включає розглянуті типи даних, їх працездатні реалізації і не тільки. Незважаючи на  що матеріал містить деяку специфіку .NET, викладені концепції роботи з багатомовними даними будуть корисні фахівцям і на інших платформах.
 для початку ми рекомендуємо ознайомитися з попереднім матеріалом, присвяченим інтернаціоналізації додатків.
Умови завдання
Уявімо, що наше додаток має працювати відразу з кількома мовами. На кожному з них в залежно від оточення користувача будуть не тільки відображатися користувальницькі інтерфейси, але і вводитися оперативні і довідкові дані. При в однієї сесії кількох варіантів локалізації даних може використовуватися і варіант тільки на одному певному мовою, і локалізації для всіх мов відразу.
Для прикладу розглянемо доменну сутність товару, що має серед інших атрибутів артикул найменування різних мовах. Нам потрібно вміти описати доменну сутність, відображати і вводити через користувальницький інтерфейс запису про товари, а також друкувати цінники.
Багатомовні рядка
Перша виникає думка про найменування товару в доменної сутності — словник з кодом language якості ключа.
public class Product
{
public string Code { get; set; }
public IDictionary<string, string> Name { get; set; }
}

Варіант підкуповує своєю простотою, але відразу порушує принципи проектування публічних контрактів, адже словник
IDictionary<string, string>
 має чіткої семантики. Трохи врятувати ситуацію може перейменування атрибута сутності
Name
 
MultilingualName
та використання подібної угоди скрізь, де потрібно семантика багатомовного атрибута.
Якщо задуматися, то   вами, напевно, виявимо випадки, коли з рядками відразу на кількох (або всіх одночасно) мовами необхідно виконати одну і ту  операцію (наприклад, привести всі літери найменування до заголовним). Здавалося б, що може бути простіше?
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 називається культурою. Для кожного потоку доступні дві культури:
CurrentCulture
та 
CurrentUICulture
. Перша застосовується для форматування чисел, дат і інших регіональних налаштувань, а друга використовується у алгоритмі пошуку підходящих локалізованих ресурсів, таких як рядки, зображення, макет користувальницьких інтерфейсів і тощо
Оскільки ми завідомо міняємо рядка різних локалей, вірний код може виглядати так:
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;
}

Чернетка нового типу даних
Ці два факти: бажання слідувати хорошому стилю проектування і висока ймовірність виникнення помилок при регулярній роботі з багатомовними даними — цілком можуть і повинні спонукати нас ввести новий тип даних багатомовну рядок.
Що ж повинна вміти багатомовна рядок? Необхідно як мінімум:
  • Мати можливість містити значення для будь-яких доступних локалей.
  • Надавати список містяться локалей.
  • Надавати звичайну рядок заданої локалі.
  • Повертати рядок у поточної локалі при виклик
    ToString()
    .
Разом з тим інтуїтивно здається, що багатомовна рядок своєю поведінкою і властивостями повинна бути досить схожа на звичайний рядок:
  • Бути иммутабельной (незмінною).
  • Бути сериализуемой.
  • Реалізовувати наступні методи:
    • Зміна регістра:
      ToLower()
      ,
      ToUpper()
      .
    • Перевірка на порожнечу і недруковані символи:
      IsNullOrEmpty()
      ,
      IsNullOrWhiteSpace()
      .
    • Об'єднання декількох рядків через роздільник(-і):
      Join()
      .
    • Набивання прогалинами в початку і кінці рядка:
      PadLeft()
      ,
      PadRight()
      .
Однак багатомовна рядок явно не повинна підтримувати конкатенацию рядків. Конкатенація в локалізованих додатках практично під забороною (за принаймні, в межах одного речення), адже порядок слів у різних мовах може відрізнятися.
Отже, подивимося, що ж у нас виходить
/// < 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(ua "Шоколад Аліна")
.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(format string, 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(ua "Шоколад Аліна")
.SetLocalizedString(en "Chocolate Alina")
};
IFormatProvider localizationFormatProvider = en;
Console.WriteLine(string.Format(localizationFormatProvider, 
"Артикул: {0}\r\пНаименование: {1}", 
product.Code, 
product.Name));
// Артикул: V0016887
// Найменування: Chocolate Alina

Чудово, ми отримали рядок "
Артикул: V0016887\r\пНаименование: Chocolate Alina
", а і очікували! Тепер трохи ускладнимо завдання, додавши в цінник дату його створення і помістивши користувача англомовний інтерфейс з російськими регіональними параметрами:
Thread.CurrentThread.CurrentCulture = ru;
IFormatProvider localizationFormatProvider = en;
Console.WriteLine(string.Format(localizationFormatProvider,
"Артикул: {0}\r\пНаименование: {1}\r\пДата: {2:d}", 
product.Code, 
product.Name, 
DateTime.Now));
// Артикул: V0016887
// Найменування: Chocolate Alina
// Дата: 11/25/2016 

 що розраховував отримати читач? Автор, наприклад, чекав би отримати "
Артикул: V0016887\r\пНаименование: Chocolate Alina\r\пДата: 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\пНаименование: {1}\r\пДата: {2:d}",
product.Code,
product.Name,
DateTime.Now));
// Артикул: V0016887
// Найменування: Chocolate Alina
// Дата: 25.11.2016

А реалізація
IFormattable
 
MultiCulturalString
виглядає приблизно так:
string IFormattable.ToString(format string, IFormatProvider formatProvider)
{
// format не використовується
var formatInfo = LocalizationFormatInfo.GetInstance(formatProvider);
return ToString(formatInfo.Culture ?? CultureInfo.CurrentUICulture);
}

Зате можливість у 
LocalizationFormatInfo
делегувати форматування не багатомовних рядків (дат, чисел і всього чого завгодно іншим постачальникам буде вельми корисна.
Перший начерк LocalizationFormatInfo може виглядати так
/// < 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);
}

Розвитком розглянутого прикладу може стати перетворення рядка форматування цінника в багатомовну рядок.
Пошук підходящої локалізації
Давайте ще раз уявімо, що  створили екземпляр товару з найменуванням російською та англійською мовами, а потім запросили найменування для відсутнього китайської мови. Питання колишній: який результат ви  очікували отримати?
 попередньому розділі ми зупинилися на тому, що, можливо,
null
прийнятний.
Розглянемо найпоширеніші ситуації. Нові версії додатків виходять, але цикл перекладу не встигає вчасно за усіма змінами. Багато вільні продукти переводяться ентузіастами, часто локалізації від старих версій використовуються у нових. Як наслідок, далеко не  елементи користувальницького інтерфейсу можуть бути переведені або бажаний мова взагалі не підтримується програмою.
Очевидно, що в таких випадках порожнечі в інтерфейсі неприпустимі. Необхідно відобразити ресурси хоча б для якоїсь мови. При цьому бажано, щоб відображається елемент міг бути сприйнятий користувачем: упізнаний, прочитаний, але не обов'язково зрозумілий або переведений.
Приклад відсутності деяких перекладів, заміна ресурсів локалі pt на en
Тут вступає в силу розумне припущення, що для локалізованого програми є локаль за замовчуванням, для якої набір ресурсів завжди актуальний і повний.
Проте з локаллю замовчуванням пов'язана ще одна проблема: для користувача Казахстані при відсутність казахстанської локалізації найбільш природно відобразити ресурс для російської мови, в  час як для користувача Китаї логічно відображати ресурс для англійської мови, оскільки в Китаї англійською мовою хоч якось володіє велика частка населення, ніж російським.
 документації локалізації в .NET описується термін resource fallback process, який можна перекласти на російська як «обробка альтернативних ресурсів». Суть обробки в те, що якщо для поточної локалі інтерфейсу не знайдені відповідні ресурси, то буде зроблена спроба знайти ресурси для батьківського локалі. Так, для language
en-IN
батьківського буде нейтральна локаль
en
(нейтральна — не містить специфіки регіону). Тому в більшості випадків для зберігання універсальною для діалектів однієї мови локалізації можна порекомендувати саме нейтральні локалі. А для language
en
, свою чергу, батьківського буде інваріантної, при виборі якої відбудеться спроба знайти ресурси за замовчуванням, які повинні існувати завжди.
на Жаль, в .NET Framework логіка опрацювання альтернативних ресурсів «зашита» глибоко в надра платформи.
Наша ж завдання полягає в тому, щоб навчитися кастомизировать процес пошуку ресурсів. Для цього давайте введемо абстракцію
IResourceFallbackProcess
. Єдиною її відповідальністю буде генерація зручних нам послідовностей локалей для пошуку відповідних ресурсів. При за пошук і завантаження ресурсів (в файловій системі, БД і т. д.) відповідають зовсім інші класи, наприклад, ResourceManager.
Представимо новий інтерфейс:
public interface IResourceFallbackProcess
{
/// <summary> 
/// Для заданої культури повертає ланцюжок культур в тому порядку, 
/// в якому необхідно шукати ресурси. 
/ / / < /summary>
/ / / < param name="initial">Початкова культура.</param>
IEnumerable<CultureInfo> 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» ми розглянемо питання зберігання локалізованих даних в БД і доступу до ним через об'єктно-реляційний маппер.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.