Многоязычные приложения. Способы хранения динамических данных

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

Несколько слов о статическом контенте

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

Несколько слов о различных кодировках

Кодировок очень много, скажем, для русского можно назвать 3 наиболее распространенных: DOS (866), windows-1251, KOI8-R. Такая же ситуация, возможно, справедлива и для других языков, поэтому здесь необходима некоторая стандартизация. В нашем материале мы будем все языковые ресурсы хранить в кодировке UNICODE, а именно – в UTF-8. Также будут рассмотрены разнообразные манипуляции с кодировками.

Необходимо помнить, что когда мы говорим о разнообразии языков, мы не считаем английский и все языки, чей символьный диапазон попадает под кодировку latin1. Дело в том, что latin1 часть присутствует во всех кодировках и есть всегда, а, следовательно, смысла считать ее отдельным языком нет. Пример, русская кодировка windows-1251 содержит в себе и латинские символы, при этом никаких перекодировок делать не надо.

Вводная часть на этом закончена и мы переходим к основной части нашего повествования.

Составляющие динамического контента

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

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

Давайте введем, для лучшего понимания, несколько терминов (их придумал я, поэтому не ожидайте тут сверх четкой и гладкой формулировки).

Уникальный контент – этим термином мы будем называть контент, который уникален для каждой языковой версии сайта.

Общий контент – этим термином мы будем называть контент, который актуален для всех языковых версий одновременно.

Рассуждения о типах контента

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

Давайте посмотрим, что же можно отнести к уникальному контенту?

Как правило, языковые версии сайта редко полностью совпадают друг с другом. Дерево разделов сайта часто отличается и причем значительно. Следовательно, названия разделов будут у нас уникальны и хранить мы будем их отдельно для разных языковых версий сайта. Отдельность хранения вовсе не означае, что мы будем использовать разные базы данных, просто для хранения каждого элемента у нас будет своя запись в базе данных – сколько элементов – столько записей. И лишь двухбуквенный индекс (например, ‘ru‘, ‘en‘, ‘pl‘ и др.) будет сигнализировать нам к какой языковой версии сайта эта запись относится.

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

Что может относиться к общему контенту? Например, иллюстрации, на которых не нанесен никакой текст, скажем, фотографии. Ведь при выводе на разных языковых версиях сайта иллюстрациям необходимо дать какое-то описание. Это могут быть и голосования, и товары вашего магазина, и так далее. И все эти данные одновременно могут быть представлены сразу на всех языковых версиях. То есть «общим» мы называем контент, который практически без изменений показывается на всех языковых версиях сайта.

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

Как хранить уникальный контент

Используем файлы

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

Если это информационный сайт, каждая страница которого достаточно объемна, то логично хранить такие данные в обычных файлах: 1 файл – одна страница. Тем самым мы с вами избегаем путаницы в языковых версиях (при таком подходе у каждой страницы будет свой уникальный идентификатор), а также даем простоту редактирования таких файлов.

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

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

Используем базу данных

С базами данных, как вы понимаете, все сложнее. Здесь перед нами во всю ширь встает проблема кодировок и совмещения разных языковых данных.

К счастью, на большинстве хостинговых площадок на сегодняшний день установлены базы данных, нормально воспринимающих формат UNICODE, в частности, кодировку UTF-8, а это значит, что, до определенной степени, наша с вами проблема будет решаться проще.

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

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

Перевести контент из любой кодировки в UTF-8 можно следующим образом:

$string = iconv(BROWSER_CHARSET, 'UTF-8', $string);

Здесь константа BROWSER_CHARSET содержит название кодировки, в которой ваши данные будут выводиться на экран, например Windows-1251. UTF-8 – указывает в какой кодировке мы хотим получить результат, а переменная $string содержит контент, который мы переводим из одной кодировки в другую.

Если поменять первый и второй аргумент местами, получите обратный результат.

Iconv – это расширение PHP, проверьте, установлено ли оно у вас. Если вы работаете со статическим контентом при помощи GetText, iconv у вас установлен. Если нет – то обратитесь к статье, указанной в первой части этого материала, там описана установка iconv. На большинстве встреченных мною хостинговых площадках эта утилита установлена, так что никаких проблем у вас не будет.

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

define('BASE_CHARSET', 'UTF-8');
$GLOBALS['site_languages'] = array(
'en' => array('English', 'UTF-8', 'en_US'),
'ru' => array('Russian', 'UTF-8', 'ru_RU')
);
function StringToBase($lang, $string) {
    if (BASE_CHARSET <> $GLOBALS['site_languages'][$lang][1]) {
        $string = iconv($GLOBALS['site_languages'][$lang][1], BASE_CHARSET, $string);
    }
    return $string;
}
function StringFromBase($lang, $string) {
    if (BASE_CHARSET <> $GLOBALS['site_languages'][$lang][1]) {
        $string = iconv(BASE_CHARSET, $GLOBALS['site_languages'][$lang][1], $string);
    }
    return $string;
}

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

Помимо функций я привел еще одну константу и массив. Для чего они нам нужны?

Константа BASE_CHARSET задает кодировку, в которой мы будем хранить информацию в базе (в нашем примере – UTF-8).

Массив $GLOBALS[‘site_languages’] содержит в себе данные по каждому языку: ‘ключ’ – двухбуквенное сочетание каждого языка (для его идентификации), его мы будем использовать везде, где необходимо обозначить язык данных; и значения: [0] – полное название языка, [1] – кодировка, в которой информация будет выводиться в браузер и [2] – идентификатор языка для утилиты GetText.

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

Теперь давайте посмотрим, что делают непосредственно наши две функции.

При вызове каждой из функций в качестве параметра мы передаем КЛЮЧ (см. описание массива $GLOBALS[‘site_languages’]) – двухбуквенное сочетание для языка, на котором написан текст в $string. Второй параметр – это текстовая строка на языке, обозначенном в $lang.

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

$string = StringToBase('ru', $string);
$string = StringFromBase('ru', $string);

И в том и в другом случае в переменной $string окажутся нужные (подготовленные) вам данные. Остается только поместить их в базу.

ЗАМЕЧАНИЕ:

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

Общий контент

Многоязыковые данные небольшого объема

Для начала предлагаю обратить свое внимание на объем текста, который нам необходимо сохранить. Для примера возьмем изображение. Как правило, описание иллюстрации – это небольшой по объему текст, ведь мы будем писать его в atl HTML тега , или просто подписывать иллюстрацию. Обычно для этого хватает 100 – 255 символов. Если предположить, что наш сайт говорит на 5 языках, то общий объем текста максимально составит 1,5 килобайта. В связи с чем, мы принимаем решение хранить все это в базе в одном поле типа TEXT.

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

Я предлагаю следующий способ:

ru:=Русский текст~~en:=English text

Мы выбрали определенное сочетание знаков, которое врядли может встретиться в обычном тексте. Каждая языковая строка будет предваряться сочетанием КЛЮЧА (двухбуквенного сочетания языка) и знаков :=, а сами сообщения будут разделяться двойной тильдой (~~). После того, как строка оформлена указанным способом, мы посылаем ее в базу данных.

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

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

Первая функция. Ее задача принять массив определенного формата и сформировать на его основе строку указанного формата.

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

function LangDataPack($data) {
	// Задаем пустую строку – здесь будет результат работы
	$result = '';
	// Получаем список ключей из массива $GLOBALS['site_languages']
	$languages = array_keys($GLOBALS['site_languages']);
	// Организовываем цикл по количеству языков,
	// поддерживаемых сайтом (определяем по количеству ключей)
	foreach ($languages as $lang) {
		// Если $result не пуст, значит мы перешли к очередному языку
		// и надо отделить языковые данные сочетанием '~~'
		if (!empty($result)) {
			$result .= '~~';
		}
		// Если есть необходимость, конвертируем строку в кодировку базы
		$data[$lang] = StringToBase($lang, $data[$lang]);
		// Проверяем, есть ли в нашем массиве строка на языке, принятом
		// поумолчанию, если нет - вернем FALSE (ошибку)
		if (empty($data[$lang]) && DEFAULT_LANG == $lang) {
			return FALSE;
		// Если текста нет, и данный язык не является языком поумолчанию,
		// заносим пустую строку
		} elseif (empty($data[$lang])) {
			$data[$lang] = '';
		}
		// Формируем строку результата, добавляя к ней данные на текущем языке
		$result .= $lang . ':=' . $data[$lang];
	}
	return $result;
}

В качестве параметра эта функция принимает массив следующего вида:

array('lang1' => 'string', 'lang2' => 'string')

Где lang1 и lang2 – это двухбуквенное сочетание языка, на котором написан string (например, array(‘en’ => ‘english’, ‘ru’ => ‘русский’)). В результате работы функции вы получите строку в кодировке UTF-8 вида en:=english~~ru:=русский.

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

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

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

Переходим к следующей функции. Ее задача – восстановить «упакованный» массив, который обработала первая функция.

function LangDataUnPack($data) {
	// Задаем пустую строку – здесь будет результат работы
	$result = '';
	// В начале создаем массив с перечнем языковых данных
	// Для этого мы разрезаем строку по оставленным ранее меткам (~~)
	$data = explode('~~', $data);
	// Организовываем цикл на основе количества выделенных выше элементов
	for ($i = 0;$i < count($data); $i++) {
		// Теперь отделяем собственно языковые данные
		// от двухбуквенного кода языка
		$temp = explode(':=', $data[$i]);
		// При необходимости производим изменение кодировки текста
		$result[$temp[0]] = StringFromBase($temp[0], $temp[1]);
	}
	return $result;
}

В качестве параметра наша функция принимает «упакованную» строку. В результате ее работы вы получите восстановленный массив, формат которого описан выше.

И, наконец, третья функция, которая позволит быстро и эффективно получать нужные языковые данные, при этом она будет обеспечивать некоторый сервис. Сервисные функции будут заключаться в том, что в случае, когда вы запрашиваете строку на языке, на котором ее не вводили, она вернет вам строку на языке поумолчанию (вот он нам и пригодился). То есть, если вам нужен текст на английском, языком поумолчанию у вас является русский, и строки на английском языке нет – вы получите строку на русском языке. Таким образом, добавление нового языка в проект не вызовет сбоев сайта. Просто временно, пока вы не введете строки на новом языке, они будут выдаваться на языке поумолчанию.

function LangDataString($lang, $data) {
	// Задаем пустую строку – здесь будет результат работы
	$result = '';
	// В начале создаем массив с перечнем языковых данных
	// Для этого мы разрезаем строку по оставленным ранее меткам (~~)
	$data = explode('~~', $data);
	// Организовываем цикл на основе количества выделенных выше элементов
	for ($i = 0;$i < count($data); $i++) {
		// Теперь отделяем собственно языковые данные
		// от двухбуквенного кода языка
		$temp = explode(':=', $data[$i]);
		// Если текущий язык - язык поумолчанию,
		// сохраняем языковые данные
		if (DEFAULT_LANG == $temp[0]) {
			// В случае необходимости, меняем кодировку
			$default = StringFromBase($temp[0], $temp[1]);
		}
		// Если текущая строка на языке, который вам нужен,
		// сохраняем языковые данные
		if ($lang == $temp[0]) {
			// В случае необходимости, меняем кодировку
			$result = StringFromBase($temp[0], $temp[1]);
		}
	}
	// Проверяем содержимое нужного вам языка
	// Если пусто (языковых данных нет),
	// Присваиваем языковые данные языка поумолчанию
	if (empty($result)) {
		$result = $default;
	}
	return $result;
}

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

ЗАМЕЧАНИЕ:

Очень важно помнить, что если вы выводите информацию в браузер в кодировке отличной от UTF-8, последняя функция даст сбой в том случае, если язык по умолчанию у вас не английский. Это произойдет потому, что кодировка, скажем, русского языка может не совпадать с китайской или испанской кодировкой – в такой кодировке просто нет русских символов. В таком случае вы должны каким-то образом предусмотреть подобный вариант и решить самим, что для вас в данном случае будет лучше. Вы всегда можете определить языком поумолчанию английсикй, при этом проблемы с кодировками не возникнет (см. пункт «Несколько слов о различных кодировках»).

Как отмечено выше, возможности поиска также сохраняются. Искать в таких данных вы можете командой LIKE. Разумеется, придется ограничить поисковый текст символами %, например, ‘%поиск%’.

Многоязыковые данные большого объема

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

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

  • id – уникальный идентификатор;
  • date_start – дата публикации;
  • date_stop – дата, по достижении которой, новость убирается с главной страницы и помещается в архив;
  • visibe – признак видимости новости на сайте;
  • subject – тема новости (заголовок);
  • short_body – короткий вариант новости;
  • full_body – полный вариант новости;

Для наших исследований такой структуры будет достаточно. Совершенно очевидно, что у каждой языковой версии поля id, date_start, date_stop и visible будут одинаковыми и нам нет никакой необходимости их дублировать. У нас есть несколько вариантов решения проблемы.

Первый вариант – создать в таблице, наряду с общими полями, отдельные поля под каждую языковую версию. У этого подхода есть один недостаток – при добавлении в проект нового языка мы столкнемся с тем, что нам придется расширять нашу таблицу, добавляя к ней новые поля. Не всегда это можно сделать безболезненно и могут возникнуть отдельные проблемы, такие же проблемы возникнут при удалении какого-либо языка из проекта. К тому же, это просто неудобно. А что делать, с новостями, которые были добавлены значительно раньше добавления нового языка и, следовательно, тексты к которым для данного языка отсутствуют? Также могут возникнуть трудности с поиском (в том случае, если вы попытаетесь искать сразу во всех языковых версиях сайта) – поиск даст значительную нагрузку на сервер и, думается, будет достаточно медленным, потомучто вместо 1 поля придется обрабатывать Х*колличество языков полей. Разумеется, любую проблему можно решить, но я предлагаю пойти другим путем.

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

Давайте посмотрим, как будут выглядеть наши таблицы:

1 таблица (table1)
news_id
news_start
news_stop
news_visible
2 таблица (table2)
news_id – уникальный номер новости из первой таблицы
news_lang – уникальный двухбуквенный идентификатор языковых данных
news_subject – тема (заголовок) новости
news_short – короткая новость на языке с кодом из news_lang
news_full – полная новость на языке с кодом из news_lang

Таким образом, на каждую запись из первой таблицы у нас может быть сколько угодно записей во второй таблице. У всех этих записей будет одинаковый news_id, но разный идентификатор языка news_lang.

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

Давайте посмотрим, каким образом можно работать с этой связкой таблиц.

Попробуем выбрать новости для главной страницы русской версии сайта.

SELECT * FROM table1, table2
WHERE
		news_visible = 'Y'
AND
		news_start <= NOW()
AND
		news_stop > NOW()
AND
		news_lang = 'ru'
AND
		table1.news_id = table2.news_id
ORDER BY
		news_start DESC

Жирным шрифтом я выделил основные связывающие элементы.

NOW() в данном случае означает сегодняшнюю дату, например, вы можете ее получить следующей PHP командой: date(‘Y-m-d’).

Таким образом, если вы только что добавили к своему проекту китайскую версию, но не сделали перевода новостей для это версии, ничего выведено не будет, так как в базе не будет записи с буквенным кодом ‘zh‘ (это код китайского языка).

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

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

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

SELECT * FROM table1, table2
WHERE
		table1.news_id = 10
AND
		table2.news_id = 10
AND
		news_lang = 'ru'

А теперь вызовем на редактирование все языковые варианты новости с номером 10.

SELECT * FROM table1, table2
WHERE
		table1.news_id = 10
AND
		table2.news_id = 10

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

Подведем итоги

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

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