Интернационализация в Yesod

17 июля 2012

А тем временем работы над русским переводом книги «Developing Web Applications with Haskell and Yesod» идут с бешеной скоростью. В этой заметке я предлагаю вашему вниманию черновой вариант перевода шестнадцатой главы книги, посвященной интернационализации. Есть подозрения, что эта глава заинтересует вас даже в том случае, если в обозримом будущем вы не собираетесь использовать ни Haskell, ни Yesod.

Интернационализация

Пользователи ожидают от наших программ, что те будут разговаривать с ними на одном языке. К несчастью, для этого скорее всего потребуется поддержка более, чем одного языка. В то время, как простая замена строк не представляет собой большой проблемы, корректное формирование фраз в соответствии со всеми правилами грамматики может оказаться нетривиальной задачей. В конце концов, кому из нас приятно видеть в выводе программы «Список 1 файл(ов)»?

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

  • Определять язык пользователя, основываясь на информации, переданной в HTTP запросе, с возможностью перезаписи;
  • Простой синтаксис для формирования переводов, не требующий знания Haskell (в конце концов, не всякий переводчик является еще и программистом);
  • Возможность при необходимости использовать всю мощь языка Haskell для нетривиальных грамматических проблем, а также выбор функций-хелперов по умолчанию;
  • Полное отсутствие проблем с порядком слов;

Обзор

Большинство существующих решений, таких как gettext или пакеты сообщений Java, работают на принципе поиска строк. Для подстановки переменных в строки обычно используются функции, похожие на printf. Вместо этого в Yesod, как вы могли догадаться, делается ставка на типы. Это дает нам все обычные преимущества, такие как обнаружение ошибок на этапе компиляции.

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

data MyMessage = MsgHello | MsgUsersLoggedIn Int

Мы без труда можем написать функцию, преобразующий приведенный тип в строку на английском языке:

toEnglish :: MyMessage -> String
toEnglish MsgHello = "Hello there!"
toEnglish (MsgUsersLoggedIn 1) =
  "There is 1 user logged in."
toEnglish (MsgUsersLoggedIn i) =
  "There are " ++ show i ++ " users logged in."

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

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

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

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

Чтобы немного упростить нам жизнь, в Hamlet предусмотрен специальный синтаксис интерполяции, _{…}, который отвечает за все вызовы функций перевода. Чтобы связать функцию перевода с вашим приложением, используйте класс типов YesodMessage.

Файлы сообщений

Самый простой подход к созданию переводов заключается в использовании файлов сообщений. Идея простая — имеется каталог, содержащий все файлы перевода, по одному файлу на каждый язык. Каждый файл называется в соответствии с кодом языка, например en.msg. При этом каждая строка файла содержит одну фразу, которая соответствует одному конструктору типа данных «сообщение».

Примечание: Сайт, созданный с помощью генератора кода, содержит полностью настроенный каталог сообщений.

Для начала поговорим о кодах для обозначения языков. На самом деле их существует два вида — двухбуквенные коды языков и коды вида «язык-страна». Например, когда я загружаю страницу в веб-браузере, он передает два кода: «en-US» и «en». Это означает «я бы предпочел американский английский, но если вы его не поддерживаете, сойдет и английский».

Так какой же формат следует использовать в своем приложении? Скорее всего это двухбуквенные коды, если только вы на самом деле не создаете отдельные переводы для разных стран. Это гарантирует, что если кто-то запросит канадский английский, он все равно получит английский. За сценой Yesod добавляет двухбуквенные коды там, где это уместно. Допустим, пользователь передал следующий список языков:

pt-BR, es, he

Это означает «я предпочел бы бразильский португальский, затем испанский, а затем иврит». Допустим, ваше приложение поддерживает языки pt (общий португальский) и английский, притом английский используется по умолчанию. Если строго следовать списку, предоставленному пользователем, будет выбран английский язык. Однако Yesod преобразует список в следующий:

pt-BR, es, he, pt

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

А что там на счет файлов сообщений? После работы с Hamlet и Persistent синтаксис должен показаться вам очень знакомым. Строка начинается с имени сообщения. Поскольку это конструктор данных, оно должно начинаться с заглавной буквы. Далее можно указать параметры. Их следует набирать в нижнем регистре. Они будут использованы в качестве аргументов конструктора данных.

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

Определяем типы

Поскольку мы собираемся создавать тип данных «сообщение» из файла сообщений, для каждого параметра конструктора данных должен быть определен тип. Для этого можно использовать символ @ («собака»). Например, для создания типа данных:

data MyMessage = MsgHello | MsgSayAge Int

… следует написать:

Hello: Привет!
SayAge age@Int: Ваш возраст: #{show age}

Но тут имеют место две проблемы:

  1. Определять типы параметров в каждом файле не очень-то соответствует принципу DRY;
  2. Переводчикам будет затруднительно определить правильный тип;

По этим причинам определение типов данных требуется только в главном языковом файле. Он задается третьим аргументом функции mkMessage. Этот же файл является «запасным», то есть он используется в случае, если приложение не поддерживает ни один из языков, указанных в списке пользователя.

Класс типов RenderMessage

Вызов функции mkMessage создает экземпляр класса типов RenderMessage, который представляет собой ядро интернационализации в Yesod. Вот его определение:

class RenderMessage master message where
    renderMessage :: master
                  -> [Text] -- ^ языки
                  -> message
                  -> Text

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

Функция renderMessage принимает аргументы каждого из типов-параметров класса — master и message. Дополнительный параметр представляет собой список языков, переданный пользователем, в порядке убывания приоритета. Функция возвращает текст, который должен быть показан пользователю.

Простейший экземпляр класса RenderMessage может в действительности не реализовывать никакого перевода. Вместо этого он может просто отображать одни и те же значения для любого языка. Например:

data MyMessage = Hello | Greet Text
instance RenderMessage MyApp MyMessage where
    renderMessage _ _ Hello = "Привет"
    renderMessage _ _ (Greet name) =
      "Добро пожаловать, " <> name <> "!"

Обратите внимание, что мы проигнорировали два первых аргумента renderMessage. А теперь изменим этот экземпляр так, чтобы он поддерживал несколько языков:

renderEn Hello = "Hello"
renderEn (Greet name) = "Welcome, " <> name <> "!"
renderRu Hello = "Привет"
renderRu (Greet name) = "Добро пожаловать, " <> name <> "!"
instance RenderMessage MyApp MyMessage where
    renderMessage _ ("en":_) = renderEn
    renderMessage _ ("ru":_) = renderRu
    renderMessage master (_:langs) = renderMessage master langs
    renderMessage _ [] = renderEn

Идея довольно проста. Сначала мы объявляем функции-хелперы для поддержки каждого языка. Затем для каждого языка мы добавляем соответствующее условие в определении функции renderMessage. Последние два случая в renderMessage — если текущий язык не совпал ни с одним из поддерживаемых, взять из списка следующий по приоритету язык, а если в списке больше не осталось языков, использовать язык по умолчанию (в приведенном примере — английский).

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

Интерполяция

Один из способов использования только что созданного экземпляра класса RenderMessage заключаете в вызове функции renderMessage напрямую. Это работает, хотя и является несколько утомительным, поскольку вам придется передавать основное значение и список языков самостоятельно. Вместо этого Hamlet предоставляет интерполяцию, записываемую, как _{…}.

Примечание: Почему подчеркивание? Этот символ стал общепринятым для интернационализации, поскольку он используется в библиотеке gettext.

Hamlet автоматически преобразует это в вызов renderMessage. Получив результат типа Text, Hamlet использует функцию toHtml для получения HTML-значения. Таким образом, специальные символы (<, &, >) автоматически экранируются.

Фразы, а не слова

В заключение мне хотелось бы дать вам небольшой совет. Допустим, есть интернет-магазин, продающий черепах. Вы собираетесь использовать слово «черепаха» во многих предложениях, например «Вы добавили 4 черепахи в вашу корзину» и «Поздравляем, вы заказали 4 черепахи». Как программист, вы наверняка обратили внимание на дублирование кода — фраза «4 черепахи» встречается дважды. В связи с этим вы можете сосатвить следующий файл сообщений:

AddStart: Вы добавили
AddEnd: в вашу корзину.
PurchaseStart: Поздравляем, вы заказали
PurchaseEnd: .
Turtles count@Int: #{show count} #{plural count "черепаху" "черепахи" "черепах"}

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

  • В некоторых языках «в вашу корзину» может идти перед «Вы добавили»;
  • Возможно, «добавили» будет преобразовываться в зависимости от того, добавили вы одну черепаху или несколько;
  • Также может возникнуть множество других проблем;

Общее правило таково: переводите целые предложения, а не отдельные слова.

Дополнение: См также Поиск по сайту с использованием Sphinx в Yesod.

Метки: , , , .


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