Нереальная локализация
Сейчас я снова буду шатать вашу психику и основы мироздания — мы будем локализовывать самое обычное корпоративное Java-приложение на.. несуществующие фантастические языки: Клингонский и Р’льех.
Таки Heghlu'meH QaQ jajvam мои дорогие.
На самом деле это сложная и уникальная статья, наработки для которой я собирал три долгих года п̸̼̈́р̸̺̇ӣ̸̗н̴̪͊о̸̜̓с̴̜̀я̶̡̂ ̵̹̓ж̸͖͊е̴̝̉р̵̺̊т̶̡̄в̵͈̌ы̸͍͛ ̶͍̏п̵̩̀о̷̰̓д̷̭̄в̸̼́о̴̧̅д̷̞̀н̶͚͌ы̶̚ͅм̴͓̔ ̸̬̋б̴̖̐о̴̩̅г̶̩̔а̴̥́м̸̳̐, перекапывая документацию, примеры и изучая матчасть.
Сама возможность подобного ставилась под сомнение моими недалекими коллегами, которые по какой-то нелепой причине стали считать меня сумасшедшим.
А все потому что их убогие души не смогли открыться зову п̴̝̒о̵̺͋д̷̰̌в̶̺̈́о̴͕̃д̵͉̔н̶̛͚о̴̯̄ѓ̷͎о̴̫́ ̵̹̌Б̸̲̃о̶͔͗г̴͉̈́а̸̜͝, так что скажем дружно этим несчастным:
и продолжим погружение в̸̗͊о̴̬̀ ̴̮̎т̸̛̼ь̷̭̈́м̶͈̾у̷̛͜ истинного познания.
Проект, который я в итоге скастовал сотворил своими руками является уникальным не просто для русскоязычной аудитории, но и для мирового ИТ-сообщества.
Благо других таких #банутых на всем свете просто не нашлось.
По традиции исходники выложены на Github.
Думаю теперь вам стало интересно как это выглядит?
Что ж, начнем с локализации на клингонский:
А вот версия для любителей глубины на Р'льех:
Ну и наконец обычный английский:
Вот так это выглядит в работе:
Да, это самое обычное веб-приложение на Java, работающее в обычном браузере.
Но только с локализацией на клингонский и Р'льех.
и едем дальше, пока п̸̰̠̦̟͎͇̋̋͒̀о̷̣͌͐͑̌̋̎̇͝д̶̨̛̜̥͍͚̗в̶̻̗̯̅̈͝о̴̜̞̦̤̠̞͕̚͠ͅд̵̢̣͋̃̇̀́͛̇н̷͎̮̻̰̪̟̐̾́̕ы̴̣͙̪͇̻̦͐͆̀е̷͕̰̌̊̉ ̶̢͚̫̝̜͖͔̑͂̊͜б̸̨͍̫̬͚͖̫̞̾̂̀͑̄͌о̴̧̦͇̹̟̽̂̌̾̀͝г̷͓̺̓̈́̽̽̈́̍̓и̵̢̹̻̹̦̿̽̍̎̕͘̚ позволяют.
Матчасть
Компьютеры прошли длинный путь до момента появления самой возможности локализации, тем более до сегодняшних реалий и технологий, которые я буду демонстрировать.
Но стоит иметь ввиду, что вся эта область работ под названием «локализация и интернационализация ПО» сама по себе обширна и рассказать обо всех ее нюансах ни в рамках одной статьи ни даже книги у меня просто нехватит сил.
Вполне возможно, что вы дорогой читатель владеете темой и знаете некоторые аспекты этого процесса лучше меня, либо найдете недочеты в названиях терминов — это нормально и предсказуемо.
С интересом послушаю о вашем опыте и боевых практиках.
Чтобы вы смогли оценить сложность задачи «локализации на язык которого нет», стоит для начала рассказать как происходит обычная локализация — на обычные человеческие языки.
Возьмем для примера классику в виде русско-английской локализации, вот что необходимо реализовать в этом случае:
- Определение текущей локали
- Переключение локали
- Хранение локализованных строк
- Отображение локализованных данных
Собственно именно этот функционал и подразумевается когда производится локализация системы, причем большая часть всей этой логики уже реализована в вашем инструментарии и все что вам нужно сделать это фактически просто ее «включить и использовать».
Вот так например выглядит хранение локализованных строк, взятых из соседней статьи:
Также легко и просто оперировать известным и поддерживаемым языком со стороны прикладного кода:
Locale locale = Locale.forLanguageTag("en_US");
и не менее просто переключаться между поддерживаемыми языками:
FacesContext.getCurrentInstance().getViewRoot().setLocale(locale);
Но вся эта благодать заканчивается, стоит вам только вылезти за границу реальности поддерживаемых локалей и попытаться использовать то чего нет.
Язык которого нет
Начнем с того что символы несуществующих языков очевидно отсутствуют в официальной таблице символов Unicode, их нет в списке поддерживаемых средствами разработки (JDK в первую очередь) ни в браузере.
Что означает невозможность какой-либо работы «из коробки» с таким языком вообще — без специальных шагов и подготовки.
Фактически мы в рамках статьи добавим поддержку целого нового языка — от стадии разработки до отображения средствами браузера.
Это самый настоящий былинный хардкор — как в старые времена.
Теперь немного расскажу о самих языках, благо даже для меня оказалась сюрпризом та степень задротства, до которой дошли фанаты сериала StarTrek.
Клингонский
Институт клингонского языка (англ. the Klingon Language Institute, KLI) — независимая организация в Пенсильвании, США. Её цели — поддержка и развитие клингонского языка и клингонской культуры, представленных в вымышленной вселенной киносериала «Звёздный путь». Она поддерживается Paramount Pictures.
Это вам не «умершая» латынь и не редкие земные языки, отмирающие по сотне в день по мере вымирания последних носителей.
Поскольку большинство фанатов клингонского — самые разнообразные гики и технари, они первым делом (и неоднократно) пытались протолкнуть предмет своего полового влечения куда только можно:
In September 1997, Michael Everson made a proposal for encoding KLI pIqaD in Unicode, based on the Linux kernel source code. The Unicode Technical Committee rejected the Klingon proposal in May 2001
Была попытка добавить символы клингонского в официальный набор Unicode, но заявку не приняли. Зато в качестве временного технического решения, символы клингонского были добавлены в PUA область:
A modified version of the Linux kernel allocation for pIqaD in the Private Use Area of Unicode was added to the ConScript Unicode Registry (U+F8D0 to U+F8FF) by Michael Everson.[4
Удивительно (или нет), но в Microsoft тоже любят клингонский, настолько что добавили его поддержку в свой онлайн-переводчик:
Конечно при таком интересе технически продвинутой общественности, очень быстро появились и готовые TTF-шрифты, использующие PUA область:
Since then several fonts using that encoding have appeared, and software for typing in pIqaD has become available
Это важный момент, поскольку такой шрифт позволяет комбинировать символы клингонского со всеми остальными — одним шрифтом можно отобразить и английский и клингонский.
Вот так выглядит клингонский алфавит, чтобы вы понимали с кем чем имеете дело:
Р'льех
С языком древних Р’льех все обстоит проще — это полностью выдуманный язык, приверженцы которого живут под водой мало интересуются продвижением своего фантастического языка в широкие массы.
Доступные TTF-шрифты для Р'льех не используют PUA-область Unicode, поэтому шрифт превратит все символы в месиво на Р'льех:
Но если переключиться на клингонский, будет ввиден ввод символов на нормальных языках:
В этом и есть главная сила PUA-области и главная фишка.
Поэтому если будете создавать свою локализацию на древнеегипетский или руническое письмо викингов — обязательно используйте шрифт с PUA-областью.
Тестовый проект
Для статьи был специально взят самый лютый «тру-энтерпрайз» стек, чтобы показать насколько далеко продвинулись технологии локализации.
Это не какие-то околонаучные экспериментальные языки или малоизвестные специализированные фреймворки и не дикий «low level» с песьеголовыми программистами на С, это самый настоящий технологический мейнстрим — тот вид разработки и набор технологий, с которыми вы (если конечно занимаетесь разработкой) сталкиваетесь каждый день:
представьте любимый клиент-банк с локализацией на клингонском — вот это оно.
Разумеется будет много специфики именно для Java и выбранных технологий, но описанные идеи и подходы очень даже применимы и для большинства остальных языков и решений.
JakartaEE 10, который в девичестве назывался JavaEE а в далеком детстве J2EE.
В качестве сервера приложений был взят IBM OpenLiberty — современный открытый потомок большой IBM Websphere Application Server, который IBM ныне пихает в светлое корпоративное будущее как платформу для разработки микросервисов.
Технически это веб-приложение (WAR), которое разворачивается на сервере приложений и по полной использует его ресурсы — все как в золотые годы JavaEE.
Но чтобы не загонять читателей в классический гемморой с развертыванием — был приделан автозапуск приложения с автоматическим развертыванием (как в Spring Boot).
Внутри классика корпоративной разработки:
JPA, CDI, JSF и новое Servlet API 6 — уже полностью на аннотациях.
Все прямо как на настоящей работе в банке, где деньги платят. И сейчас мы будем локализовывать все это на несуществующий язык из фантастического сериала 1970х.
Но прежде опишу стандатное — сборку и запуск.
Сборка
Для сборки используется обычный Apache Maven и последняя JDK (22+), забираем проект из репозитория:
git clone https://github.com/alex0x08/javaee-klingon.git
mvn clean package
Готовое приложение будет находиться в каталоге target:
В каталоге liberty находится распакованный сервер приложений Open Liberty, с установленным внутрь нашим приложением — за все эти радости отвечает специальный плагин (см. ниже).
Запуск
Как уже упоминалось выше, наш замечательный проект предназначен для запуска и работы на сервере приложений IBM Open Liberty.
Разумеется вы можете сходить по ссылке выше, прокрутить страницу вниз до раздела Releases, скачать версию 24.0.0.6+ с профилем Jakarta EE 10, развернуть и затем установить туда наше приложение.
Для более-менее реального использования так и делают в энтерпрайзе.
Но поскольку у нас тут жутко технологическое демо, я посчитал что все эти шаги по развертыванию будут слишком сложными и добавил в сборку специальный плагин для автоматического развертывания и запуска.
mvn liberty:dev
Произойдет скачивание IBM Open Liberty, распаковка, настройка, установка внутрь нашего энтерпрайз приложения и запуск.
Вот так это выглядит из среды разработки Intellj Idea:
После запуска открываем страницу:
http://localhost:9080/kligonweb-1.0.1-RELEASE/guestbook.xhtml
Отображение
Начну с самого главного вопроса — с отображения символов несуществующего фантастического языка.
На скриншоте стандартный gedit, в котором просто выбран клингонский шрифт. Как видите использование PUA-области Unicode в шрифте позволяет дружить символы обычного и фантастического языков.
Если приглядитесь — увидите сглаживание, работающее даже для глифов клингонского.
К сожалению для Р’льех не нашлось шрифта, использующего PUA-область Unicode, поэтому при отображении происходит замена всех символов глифами Р’льех:
Разумеется я не #бнулся окончательно не посчитал разумным ради написания статьи разрабатывать целиком новый шрифт, взяв вместо этого готовый TTF-шрифт клингонского:
Klingon pIqaD Mandel takes the Klinzhai or Mandel font glyphs (really a different alphabet from the KLI’s Standard pIqaD) and refits them for use as pIqaD.
и еще один для Р'льех:
I created this font based on the description by H.P. Lovecraft. Click here to download the Rlyehian font package, which includes two version of the font and a guide to understanding its use.
Но для полноты картины, все же расскажу как происходит разработка новых шрифтов, если вдруг вам понадобится локализовать на древнеарамейский или дотракийский.
FontForge
На свете давно и успешно существует отличный открытый редактор шрифтов:
FontForge is a FOSS font editor which supports many common font formats. Developed primarily by George Williams until 2012, FontForge is free software and is distributed under a mix of the GNU General Public License Version 3 and the 3-clause BSD license.[2] It is available for operating systems including Linux, Windows,[3] and macOS,[4] and is localized into 12 languages
Мощи этой штуки вам хватит с запасом, уж точно стадию прототипирования вы с ним пройдете.
Вот так выглядит клингонский шрифт, открытый в FontForge:
Вот так выглядит процесс редактирования отдельного символа:
А вот так для сравнения выглядит в редакторе FontForge шрифт для Р'льех:
Для полного погружения, вот так выглядит редактирование одного из этих стильных глифов:
Разумеется, можно было потратить какое-то время и перенести глифы Р’льех в PAU-область, что позволило бы использование шрифта по аналогии с клингонским — параллельно с другими языками.
Но к сожалению я неверю в Ктулхуобладаю достаточным запасом времени и сил, так что оставил все как есть.
На самом деле есть еще одна важная причина — показать вам два подхода к локализации, а не один:
второй вариант реализации шрифта с полной заменой всех символов на безумные иероглифы чем-то фантастическим (без использования PAU-области) встречается куда чаще.
Его точно стоит учитывать, поскольку скорее всего именно с таким шрифтом вы и столкнетесь, пытаясь работать с фантастическими языками.
Отображение в браузере
Отдельно опишу как происходит отображение наших нереальных языков в браузере — поскольку мы используем именно веб, а не просто отдельное десктоп-приложение.
Разумеется все современные браузеры поддерживают регистрацию и использование кастомных шрифтов на странице, это мягко говоря не новость.
Регистрация TTF-шрифта происходит путем использования CSS-стиля и специальной директивы font-face:
@font-face { font-family: 'Klingon'; src: url("#{resource['Klingon-pIqaD-Mandel.ttf']}"); } @font-face { font-family: 'Rlyeh'; src: url("#{resource['Rlyehian.ttf']}"); }
Сложно выглядящая директива #resource[''] на самом деле уже часть JSF парсера — EL-выражение, преобразующее относительный путь к указанному ресурсу в полный.
А вот так выглядит задание отдельных стилей для использования наших фантастических шрифтов:
.klingon { font-family: 'Klingon'; }
Эти стили применяются выборочно, для включения фантастического шрифта при включенной перекодировке у сообщения:
<p class="card-text"> <h:outputText value="#{record.message}" styleClass="#{record.translateKlingon ? 'klingon' : ''}"/> </p>
Т.е. если сообщение было написано на клингонском pIqaD — оно будет пропущено через транслятор (см. ниже) и при отображении будет использован клингонский же TTF-шрифт.
Таким образом сохраняется обратная совместимость с другими языками и остается возможность ввода на обычном английском.
Но это решение только для отдельных блоков сообщений, ведь есть еще глобальное переключение выбранной локали:
Для решения этой задачи, используется вот такая логика:
<h:outputStylesheet name="style-klingon.css" rendered="#{i18n.locale.variant eq 'KLINGON'}"/> <h:outputStylesheet name="style-rlyeh.css" rendered="#{i18n.locale.variant eq 'RLYEH'}"/>
Суть ее в том что в зависимости от «variant» выбранной локали (см. ниже) подгружается тот или иной глобальный стиль:
* { font-family: 'Klingon', sans-serif; } body { background-image: url('klingon.jpg.xhtml'); }
Звездочка (*) означает что указанный шрифт должен быть применен ко всем элементам на странице, что и дает вот такой эффект глобальной локализации всего:
Также тут задается фоновая картинка в немного странном формате:
klingon.jpg.xhtml
На самом деле файл называется klingon.jpg и находится в каталоге webapp/resources, а постфикс .xhtml — особенность работы ресурсов в JSF, он нужен для правильной работы, хотя и выглядит полной дичью.
Переходим к следующей важной теме.
Транслятор
При локализации на несуществующий и неподдерживаемый язык существует еще одна проблема:
необходимо как-то работать с локализованным на такой язык текстом из стандарного окружения
Вы конечно можете попытаться поставить шрифты, поддерживающие ваш фантастический язык в каждый используемый редактор, каждый терминал и среду разработки — да, это будет работать (см. ниже).
Но с точки зрения промышленной разработки это плохой путь — любая ошибка и вы не сможете увидеть локализованный текст вообще либо он будет отображаться неправильно.
Если вам очень повезет либо вы каким-то образом наберете себе 20+ лет опыта разработки — пойдя этим путем получите что-то такое:
Есть способ лучше
Дело в том что ни один, даже трижды фантастический язык не появляется просто так и не существует в вакууме — для него в обязательном порядке создается:
Транслитера́ция (лат. trans- «через; пере-» + littera — «буква») — точная передача знаков одной письменности знаками другой письменности[1][2], при которой каждый знак (или последовательность знаков) одной системы письма передаётся соответствующим знаком (или последовательностью знаков) другой системы письма.
Даже если речь про например дотракийский — выдуманный сценаристами язык кхала Дрого из «Игры Престолов», к нему все равно в качестве приложения идет транслитерация на английском — актерам же надо было как-то учить произношение.
Более того, такая транслитерация существует и для самих человеческих языков, причем вообще для всех.
Например есть вариант написания кириллицы с помощью символов латиницы:
kotorii nazyvayetsa 'translit'
Нет людей в рунете старше 30ти, которые бы его никогда не видели.
Собственно транслит встречается до сих пор — стоит только сломаться мультиязычному вводу на вашем компьютере или телефоне и все — вам тоже придется его использовать.
Именно транслитерацию в латинские символы мы и будем использовать.
pIqaD
Вариант написания клингонского латинскими символами называется pIqaD, разумеется он куда более широко распространен и популярен чем те сложные клингонские иероглифы, которые я с таким трудом отображал выше.
Настолько популярен, что есть даже «Гамлет» в переводе на клингонский, где используется pIqaD-транслитерация.
Думаю не стоит упоминать, что при такой популярности есть и устоявшиеся правила транслитерации и (что куда более важно) — готовые наработки.
Очень скоро был найден самый популярный (из открытых) перекодировщиков:
На основе его исходного кода (на Javascript) была написана моя реализация на Java, с помощью которой вот такие строковые ресурсы:
превращаются во время работы приложения в те самые фантастические иероглифы:
package com.Ox08.experiments.kligon; import java.util.HashMap; import java.util.Map; /** * This code is based on Javascript implmentation, found here: * <a href="https://dadap.github.io/pIqaD-tools/universal-transliterator/">...</a> * using the standard Okrandian transliteration to pIqaD * * @author <a href="mailto:alex3.145@gmail.com">Alex Chernyshev</a> * @since 1.0 */ public class KlingonTranslator { static final Map<Character, String> x2p = new HashMap<>(), x2tlh = new HashMap<>(); static { /* * // Transliteration tables: don't bother mapping identity * relationships for // tlhIngan-Hol <-> xifan-hol */ x2tlh.put('c', "ch"); x2tlh.put('d', "D"); x2tlh.put('f', "ng"); x2tlh.put('g', "gh"); x2tlh.put('h', "H"); x2tlh.put('i', "I"); x2tlh.put('k', "q"); x2tlh.put('q', "Q"); x2tlh.put('s', "S"); x2tlh.put('x', "tlh"); x2tlh.put('z', "'"); x2p.put('`', "\uf8ff"); x2p.put('a', "\uf8d0"); x2p.put('b', "\uf8d1"); x2p.put('c', "\uf8d2"); x2p.put('d', "\uf8d3"); x2p.put('e', "\uf8d4"); x2p.put('f', "\uf8dc"); x2p.put('g', "\uf8d5"); x2p.put('h', "\uf8d6"); x2p.put('i', "\uf8d7"); x2p.put('j', "\uf8d8"); x2p.put('k', "\uf8df"); x2p.put('l', "\uf8d9"); x2p.put(',', "\uf8fd"); x2p.put('m', "\uf8da"); x2p.put('n', "\uf8db"); x2p.put('.', "\uf8fe"); x2p.put('o', "\uf8dd"); x2p.put('p', "\uf8de"); x2p.put('0', "\uf8f0"); x2p.put('q', "\uf8e0"); x2p.put('1', "\uf8f1"); x2p.put('r', "\uf8e1"); x2p.put('2', "\uf8f2"); x2p.put('s', "\uf8e2"); x2p.put('3', "\uf8f3"); x2p.put('t', "\uf8e3"); x2p.put('4', "\uf8f4"); x2p.put('u', "\uf8e5"); x2p.put('5', "\uf8f5"); x2p.put('v', "\uf8e6"); x2p.put('6', "\uf8f6"); x2p.put('w', "\uf8e7"); x2p.put('7', "\uf8f7"); x2p.put('x', "\uf8e4"); x2p.put('8', "\uf8f8"); x2p.put('y', "\uf8e8"); x2p.put('9', "\uf8f9"); x2p.put('z', "\uf8e9"); } public static String transliterate(String source) { String out = source.replaceAll("[‘’]+", "'"); // Replace {gh} with non-standard {G}, to prevent {ngh} from being // matched as {ng}, *{h}. out = out.replaceAll("gh", "G"); // Handle {ng} and {tlh} first, to prevent {n}, {t}, and {l} from // spuriously being pulled out of these letters. out = out.replaceAll("ng", "f") .replaceAll("tlh", "x"); for (Map.Entry<Character, String> e : x2tlh.entrySet()) out = out.replaceAll(e.getValue(), String.valueOf(e.getKey())); // Now that all {ng}s have been processed, it's safe for {gh} to be 'g' out = out.replaceAll("G", "g"); final StringBuilder out2 = new StringBuilder(); for (int i = 0; i < out.length(); i++) { char before = i > 0 ? out.charAt(i - 1) : (char) -1, c = out.charAt(i), after = i < out.length() - 1 ? out.charAt(i + 1) : (char) -1; // require to solve MessageFormat's substitution patterns if (before == '{' && after == '}') { out2.append(c); continue; } //Process char out2.append(x2p.getOrDefault(c, c + "")); } return out2.toString(); } }
Как видите тут просто происходит замена символов согласно таблице подстановки с латинских на Unicode из PAU-области.
К сожалению (или к счастью — в зависимости от контекста), фантастический язык Р’льех из миров Лавкрафта куда менее популярен, поэтому я смог найти лишь один рабочий транслятор:
Using the digital serpent's package, you can translate english to the language of the "old ones" Spread a̶͙̓̓̓͛̿̓͘ḯ̵̡̲̟̼͎̩͉̬̙̈̀͆͜m̴̨̺̖͇͔̝̤̖͊̏̌̅̔̿͜͜͝ģ̶̺͚̬̣̣̜͉̃̒͜ŗ̷͖͇͖̘͍̹̳̈̑͐͌̇̆͘͜͝ͅ'̴̢͉͎͇͔̬̖̽̈̕͜ļ̷̛̥̹̰͎̤͉̫̱̗͗̈́͗͆̾͒̄̅͠ű̸̖̼͇̏̈́̉̊̌̃̕ḩ̷̧̲̬͔̉ͅ
Занимается им что характерно какой-то поехавший китайский DevOps-инженер, поэтому видимо транслятор и написан на Python:
python test.py -t "I pray to the mother of skin"
Важным моментом является другой принцип работы — вместо транслитерации символов происходит подстановка слов или даже целых фраз:
Я полностью портировал эту логику на Java, реализовав свой транслятор для Р'льех:
package com.Ox08.experiments.kligon; import java.util.HashMap; import java.util.Map; /** * Based on * https://github.com/JLDevOps/Rlyehian/tree/master/rlyehian * @since 1.1 * @author alex0x08 */ public class RlyehTranslator { private final static Map<String, String> compendium = new HashMap<>(); static { // 1 -suffix, 0 - word compendium.put("speak", "1_'ai"); compendium.put("call", "0_'ai"); compendium.put("body", "0_'bthnk"); compendium.put("essence", "0_'bthnk"); compendium.put("mother", "0_fhalma"); compendium.put("sign", "0_athg"); compendium.put("contract", "0_athg"); compendium.put("agree", "0_athg"); compendium.put("go", "0_bug"); compendium.put("cross over", "0_ch'"); compendium.put("travel", "0_ch'"); compendium.put("brotherhood", "0_chtenff"); compendium.put("society", "0_chtenff"); compendium.put("pit", "0_ebumma"); compendium.put("answers", "0_ee"); compendium.put("cohesion", "0_ehye"); compendium.put("integrity", "0_ehye"); compendium.put("later", "0_ep"); compendium.put("then", "0_ep"); compendium.put("wait", "0_fhtagn"); compendium.put("sleep", "0_fhtagn"); compendium.put("burn", "0_fm'latgh"); compendium.put("skin", "0_ftaghu"); compendium.put("boundary", "0_ftaghu"); compendium.put("here", "0_geb"); compendium.put("father", "0_gnaiih"); compendium.put("children", "0_gof'nn"); compendium.put("grant", "0_goka"); compendium.put("wish", "0_gotha"); compendium.put("lost one", "0_grah'n"); compendium.put("larva", "0_grah'n"); compendium.put("priest", "0_hafh'drn"); compendium.put("summoner", "0_hafh'drn"); compendium.put("now", "0_hai"); compendium.put("heretic", "0_hlirgh"); compendium.put("followers", "0_hrii"); compendium.put("born of", "0_hupadgh"); compendium.put("expect", "0_ilyaa"); compendium.put("await", "0_ilyaa"); compendium.put("share", "0_k'yarnak"); compendium.put("exchange", "0_k'yarnak"); compendium.put("understand", "0_kadishtu"); compendium.put("know", "0_kadishtu"); compendium.put("question", "0_kn'a"); compendium.put("on pain of", "0_li'hee"); compendium.put("at", "0_llll"); compendium.put("beside", "0_llll"); compendium.put("mind", "0_lloig"); compendium.put("psyche", "0_lloig"); compendium.put("dream", "0_lw'nafh"); compendium.put("transmit", "0_lw'nafh"); compendium.put("worthless", "0_mnahn'"); compendium.put("death", "0_n'gha"); compendium.put("darkness", "0_n'ghft"); compendium.put("threshold", "0_nglui"); compendium.put("anything", "0_nilgh'ri"); compendium.put("everything", "0_nilgh'ri"); compendium.put("come", "0_nog"); compendium.put("head", "0_nw"); compendium.put("place", "0_nw"); compendium.put("visit", "0_ooboshu"); compendium.put("soul", "0_orr'e"); compendium.put("spirit", "0_orr'e"); compendium.put("realm of information", "0_phlegeth"); compendium.put("secret", "0_r'luh"); compendium.put("hidden", "0_r'luh"); compendium.put("religon", "0_ron"); compendium.put("cult", "0_ron"); compendium.put("pact", "0_s'uhn"); compendium.put("share space", "0_sgn'wahl"); compendium.put("realm of dreams", "0_shagg"); compendium.put("realm of darkness", "0_shogg"); compendium.put("invite", "0_sll'ha"); compendium.put("ask", "0_stell'bsna"); compendium.put("pray for", "0_sll'ha"); compendium.put("eternity", "0_syha'h"); compendium.put("promise", "0_tharanak"); compendium.put("bring", "0_tharanak"); compendium.put("tremble", "0_throd"); compendium.put("finish spell", "0_uaaah"); compendium.put("people", "0_uh'e"); compendium.put("crowd", "0_uh'e"); compendium.put("summon", "0_uln"); compendium.put("pray to", "0_vulgtlagin"); compendium.put("prayer", "0_vulgtm"); compendium.put("reside in", "0_wgah'n"); compendium.put("control", "0_wgah'n"); compendium.put("amen", "0_y'hah"); compendium.put("I", "0_ya"); compendium.put("lift spell", "0_zhro"); compendium.put("time of", "1_-yar"); compendium.put("moment", "1_-yar"); compendium.put("my", "1_y-"); compendium.put("they", "1_f'-"); compendium.put("their", "1_f'-"); compendium.put("it", "1_h'-"); compendium.put("its", "1_h'-"); compendium.put("yet", "2_mg"); compendium.put("not", "3_nafl-"); compendium.put("and", "3_ng-"); compendium.put("watch", "1_nnn-"); compendium.put("protect", "1_nnn-"); compendium.put("servant of", "1_-nyth"); compendium.put("force from", "1_-or"); compendium.put("aspect of", "1_-or"); compendium.put("native of", "1_-oth"); compendium.put("over", "1_ph'-"); compendium.put("beyond", "1_ph'-"); compendium.put("notify", "0_shtunggli"); compendium.put("contact", "0_shtunggli"); compendium.put("realm of Earth", "0_shugg"); compendium.put("we", "3_c-"); compendium.put("our", "3_c-"); compendium.put("with hai", "0_ep"); } public static String translate(String text) { if (text == null || text.isEmpty()) return null; final StringBuilder out = new StringBuilder(); text = text.replaceAll(" +", " ").toLowerCase(); boolean first = true; for (String word : text.split(" ")) { if (word.trim().isEmpty()) continue; if (!compendium.containsKey(word)) { if (first) first = false; else out.append(" "); out.append(word); continue; } final String v = compendium.get(word); char c = v.charAt(0); switch (c) { //suffix, prefix case '1', '2', '3' -> out.append(v.substring(2)); //word case '0' -> { if (first) first = false; else out.append(" "); out.append(v.substring(2)); } } } return out.toString(); } }
Разумеется с таким подходом (в виде зашитого и очень небольшого словаря) нет возможности реализовать перевод технических терминов:
у меня честно нет идей как могут выглядеть слова «Авторизация», «Назад» или «Сохранить» на языке древних.
Поэтому транслятор Р’льех используется только для ввода текста — чтобы найти истинных последователей показать как это работает.
Но перейдем к следующей интересной теме.
Нереальная локаль
Следующей проблемой при работе с фантастическими языками является их регистрация в системе — в том языке, платформе или фреймворке, который вы используете.
Это нужно в первую очередь для того, чтобы как-то сигнализировать внутри приложения о том что используется такой фантастический язык и проводить соответствующую подстройку — например вызывать тот самый транслятор, описанный выше.
Тут может быть огромное количество вариантов, проблем и подводных камней, поскольку такой разработкой мы выходим за рамки обыденного.
Но для Java весь процесс более-менее отработан, описан и предсказуем:
в Java у локалей есть поддержка т. н. "variant" — специальной вариации языка, которая может быть сколь угодно нестандартной.
Сама локаль остается системной (в данном случае — английской), но при этом к ней добавляется специальный постфикс, означающий что используется «вариация»:
<h:form> <h:selectOneMenu styleClass="form-select" style="width: 12em;" value="#{i18n.language}" onchange="submit()"> <f:selectItem itemValue="en" itemLabel="English" /> <f:selectItem itemValue="en-US-KLINGON" itemLabel="Klingon" /> <f:selectItem itemValue="en-US-RLYEH" itemLabel="Rlyeh" /> </h:selectOneMenu> </h:form>
Поскольку такие variants являются частью официального API, они поддерживаются всем прикладным ПО и библиотеками (за редкими кривыми исключениями).
В том числе они используются в механизме работы ResourceBundle:
Если включить «variant» в название файла с ресурсами — он будет найден и загружен при выборе локали с таким «variant».
К сожалени стандартной реализации оказалось недостаточно — хотелось получить перекодированный клингонский сразу из ресурсов, поэтому я сделал свою:
package com.Ox08.experiments.kligon; import jakarta.annotation.Nonnull; import jakarta.faces.context.FacesContext; import java.util.Enumeration; import java.util.Locale; import java.util.ResourceBundle; import java.util.logging.Level; import java.util.logging.Logger; /** * Extended resource bundle, used to inject Klingon glyphs if Klingon locale * used * * @author <a href="mailto:alex3.145@gmail.com">Alex Chernyshev</a> */ public class KlingonedResourceBundle extends ResourceBundle { public KlingonedResourceBundle() { setParent(ResourceBundle.getBundle("i18n.messages", FacesContext.getCurrentInstance().getViewRoot().getLocale())); } @Override public final void setParent(ResourceBundle parent) { super.setParent(parent); } @Override protected Object handleGetObject(@Nonnull String key) { // here will be extracted and substituted value final Object v = parent.getObject(key); if (!(v instanceof String vstring)) { return v; } LOG.log(Level.INFO, "handleGetObject : {0}", vstring); // current locale final Locale l = FacesContext.getCurrentInstance().getViewRoot().getLocale(); // check if its Klingon and transliterate to glyphs if ("KLINGON".equals(l.getVariant())) return KlingonTranslator.transliterate(vstring); // .. and for Rlyeh if ("RLYEH".equals(l.getVariant())) return RlyehTranslator.translate(vstring); // otherwise - just respond 'as-is' return v; } @Override @Nonnull public Enumeration<String> getKeys() { return parent.getKeys(); } private static final Logger LOG = Logger.getLogger("BUNDLE-KLINGON"); }
Как видите, тут происходят достаточно банальные вещи.
Для начала получаем текущую локаль пользователя:
final Locale l = FacesContext.getCurrentInstance() .getViewRoot().getLocale();
затем в зависимости от значения «variant» вызываем перекодировщик для клингонского:
if ("KLINGON".equals(l.getVariant())) return KlingonTranslator.transliterate(vstring);
if ("RLYEH".equals(l.getVariant())) return RlyehTranslator.translate(vstring);
Регистрация кастомной реализации ResourceBundle задается в файле с настройками JSF (webapp/WEB-INF/faces-config.xml):
.. <resource-bundle> <!-- Note that 'base name' points to specific class, not to .properties file --> <base-name>com.Ox08.experiments.kligon.KlingonedResourceBundle</base-name> <var>msgs</var> </resource-bundle> ..
Там же указывается список поддерживаемых локалей, с учетом «variants»:
.. <locale-config> <default-locale>en</default-locale> <supported-locale>en_US_KLINGON</supported-locale> <supported-locale>en_US_RLYEH</supported-locale> </locale-config> ..
Наконец хранение выбранной пользователем локали происходит в отдельном сессионном бине:
package com.Ox08.experiments.kligon; import jakarta.annotation.PostConstruct; import jakarta.enterprise.context.SessionScoped; import jakarta.faces.context.FacesContext; import jakarta.inject.Inject; import jakarta.inject.Named; import java.io.Serializable; import java.util.Locale; import java.util.logging.Logger; /** * This bean stores selected locale, attached to user's session * * @author <a href="mailto:alex3.145@gmail.com">Alex Chernyshev</a> */ @Named("i18n") @SessionScoped public class LocaleBean implements Serializable { @Inject private transient Logger log; // current locale private Locale locale; /** * Initializes current locale value */ @PostConstruct void init() { // take current locale from request locale = FacesContext.getCurrentInstance().getExternalContext().getRequestLocale(); log.log(java.util.logging.Level.INFO, "Current locale {0} , variant: {1}", new Object[]{locale.toLanguageTag(), locale.getVariant()}); } public Locale getLocale() { return locale; } public String getLanguage() { return locale == null ? null : locale.toLanguageTag(); } public void setLanguage(String language) { // get Locale object from language tag locale = Locale.forLanguageTag(language); // set it to current view root FacesContext.getCurrentInstance().getViewRoot().setLocale(locale); log.log(java.util.logging.Level.INFO, "Switched locale to {0} , variant: {1}", new Object[]{locale.toLanguageTag(), locale.getVariant()}); } }
Поле «locale» из данного бина используется со стороны XHTML-страницы:
.. <f:view xmlns="http://www.w3.org/1999/xhtml" xmlns:h="http://xmlns.jcp.org/jsf/html" .. locale="#{i18n.locale}"> ..
Но это еще не все интесное и необычное, что хотелось бы раскрыть в рамках статьи.
Валидация данных
Как каша без масла протеина или водка без закуски — не бывает корпоративных приложений без валидации данных.
В Jakarta EE (как и в ее предшественнике JavaEE) для автоматической валидации входных данных используются механизмы из спецификации JSR 303 «Bean Validation».
В самом простом случае это выглядит как размещение специальных аннотаций на полях класса:
.. @Size(min = 3, max = 255) private String title; // a title @NotBlank(message = "{validation.message.not-blank}") @Lob @Column(length = Integer.MAX_VALUE) private String message; // message, stored as CLOB in database, //so size is almost unlimited @Size(min = 3, max = 30) @Email private String author; // author's email ..
Когда такой класс попадает в качестве входящего аргумента метода CDI-бина, срабатывает автоматическая валидация и в интерфейсе появляются сообщения об ошибках:
Если ошибка имеет привязку к конкретному полю, за ее отображение отвечает отдельный тег:
<h:message for="f_message" errorClass="msg" />
если нет — она отображается через «глобальную свалку»:
<p> <h:messages globalOnly="true" infoClass="msg" errorClass="msg" /> </p>
Теперь обратите внимание вот на эту строчку:
@NotBlank(message = "{validation.message.not-blank}")
Вместо текста сообщения, тут указан некий код, который автоматически заменяется на текст из специального ResourceBundle:
Вся эта логика является частью спецификации JSR303 и вообщем-то отлично работает без вашего участия — до тех пор пока не появляется необходимость сотворить какую-нибудь запредельную дичь.
Разумеется поддержка несуществующих языков в текстах сообщений об ошибках является именно такой дичью:
Поэтому придется немного подумать.
После долгого гугления с камланием над документацией, был найден способ вклиниться в процесс получения текстов сообщений с ошибками валидации:
Message interpolators are used by the validation engine to create user readable error messages from constraint message descriptors.
В итоге была написана собственная реализация такого «интерполятора»:
package com.Ox08.experiments.kligon; import jakarta.validation.MessageInterpolator; import jakarta.validation.Validation; import java.util.Locale; import java.util.logging.Level; import java.util.logging.Logger; /** * Custom JSR 303 Message Interpolator, used to inject Klingon glyphs * into JSR303 validation * * @author <a href="mailto:alex3.145@gmail.com">Alex Chernyshev</a> */ public class JSR303KlingonMessageInterpolator implements MessageInterpolator { // we need to have existing MessageInterpolator, // to being used as parent private final MessageInterpolator delegate; public JSR303KlingonMessageInterpolator() { // take default implementation from JSR303 configuration this.delegate = Validation.byDefaultProvider() .configure().getDefaultMessageInterpolator(); } @Override public String interpolate(String string, Context cntxt) { LOG.log(Level.INFO, "interpolating {0}", string); // without specified locale - just pass interpolation to delegate return delegate.interpolate(string, cntxt); } @Override public String interpolate(String string, Context cntxt, Locale locale) { LOG.log(Level.INFO, "interpolating {0} with locale: {1}", new Object[]{string, locale.toLanguageTag()}); // here will be extracted and substituted value final String result = delegate.interpolate(string, cntxt, locale); // check for Klingon locale and transliterate to glyphs if ("KLINGON".equals(locale.getVariant())) { return KlingonTranslator.transliterate(result); } if ("RLYEH".equals(locale.getVariant())) { return RlyehTranslator.translate(result); } return result; } private static final Logger LOG = Logger.getLogger("JSR303-KLINGON"); }
Основная магия логика заключается вот в этих строках:
.. final String result = delegate.interpolate(string, cntxt, locale); // check for Klingon locale and transliterate to glyphs if ("KLINGON".equals(locale.getVariant())) { return KlingonTranslator.transliterate(result); } if ("RLYEH".equals(locale.getVariant())) { return RlyehTranslator.translate(result); } return result;
Как видите, локаль поступает на вход метода в готовом виде — ее не надо определять из контекста JSF, а вот в этом месте происходит получение оригинальной строки из файла с текстовыми строками:
final String result = delegate.interpolate(string, cntxt, locale);
Дальше в зависимости от наличия «variant» у локали, текст либо пропускается через транслятор либо отдается «как есть».
Регистрация кастомного интерполятора также имеет свою специфику — она происходит в отдельном XML-файле:
<?xml version="1.0" encoding="UTF-8"?> <validation-config xmlns="https://jakarta.ee/xml/ns/validation/configuration" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="https://jakarta.ee/xml/ns/validation/configuration https://jakarta.ee/xml/ns/validation/configuration/validation-configuration-3.0.xsd" version="3.0"> <!-- register custom interpolator, used to retrieve i18n validation messages --> <message-interpolator>com.Ox08.experiments.kligon.JSR303KlingonMessageInterpolator</message-interpolator> </validation-config>
src/main/resources/META-INF/validation.xml
К сожалению даже кастомной корпоративной JSR303 валидации с сообщениями на клингонском оказалось мало и я так и не угомонился пошел еще дальше — к еще одной нереальной теме, с которой вы столкнетесь если действительно упоретесь серьезной локализацией.
Фантастические даты
Никогда не задумывались о том какой смысл закладывается в дату?
Что такое на самом деле 2024й год?
Фактически это означает что прошло 2024 года с рождения Иисуса Христа (по новому летоисчислению).
А что если вам надо использовать альтернативную систему расчета времени?
Миллион лет от последнего динозавра?
3 года от изгнания Моргенштерна?
Озадачившись данным вопросом, я решил что неплохо было бы реализовать для фантастического языка еще и фантастическое летоисчисление.
И использовать его для обычной корпоративной разработки.
Вселенная сериала Star Trek оказалась настолько продуманной и проработанной что имела собственную систему летоисчисления:
A stardate is a fictional system of time measurement developed for the television and film series Star Trek. In the series, use of this date system is commonly heard at the beginning of a voice-over log entry, such as "Captain's log, stardate 41153.7.
Именно ее поддержку я и решил реализовать:
За основу был взят фанатский проект с реализацией StarDate на куче разных языков, код был сильно уменьшен и вычищен от всякой дури.
Но одной голой реализации кастомного календаря оказалось мало — нужен еще один класс-конвертер, непосредственно реализующий конвертацию дат с использованием такого календаря:
package com.Ox08.experiments.kligon; import jakarta.faces.component.UIComponent; import jakarta.faces.context.FacesContext; import jakarta.faces.convert.Converter; import jakarta.faces.convert.FacesConverter; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.Date; import java.util.Locale; /** * A custom converter for StarDate * @author alex0x08 */ @FacesConverter(value = "stardateConverter") public class StarDateConverter implements Converter<Date> { @Override public Date getAsObject(FacesContext fc, UIComponent uic, String string) { final Locale l = fc.getViewRoot().getLocale(); if ("KLINGON".equals(l.getVariant())) return StarDate.parseStarDate(string).getDate(); return Date.from(ZonedDateTime.parse(string, DateTimeFormatter.ISO_DATE_TIME .withZone(ZoneId.systemDefault())).toInstant()); } @Override public String getAsString(FacesContext fc, UIComponent uic, Date t) { final Locale l = fc.getViewRoot().getLocale(); if ("KLINGON".equals(l.getVariant())) return StarDate.newInstance(t).toString(); return DateTimeFormatter.ISO_DATE_TIME .withZone(ZoneId.systemDefault()) .format(t.toInstant()); } }
Активируется этот конвертер автоматически благодаря наличию аннотации:
@FacesConverter(value = "stardateConverter")
И применяется для всех полей с типом Date, проходящих через бины JSF.
Внутри уже традиционная логика получения текущей локали:
final Locale l = fc.getViewRoot().getLocale();
Затем при наличии клингонского «variant» происходит либо преобразование из объекта в строку с учетом кастомного календаря:
if ("KLINGON".equals(l.getVariant())) return StarDate.parseStarDate(string).getDate();
либо из строки в объект (также с учетом StarDate):
if ("KLINGON".equals(l.getVariant())) return StarDate.newInstance(t).toString();
На этом красивая история о нереальном подходит к концу, но хотелось бы рассказать о еще двух интересных штуках, не имеющих прямого отношения к теме локализации.
Работа с базой данных
Как вы возможно успели заметить — в проекте используется определенная магия для работы с базой данных.
В качестве СУБД используется широко известный Apache Derby, причем в виде встроенного сервера — без запуска сетевого соединения.
К сожалению из-за затянувшегося процесса обновления библиотек JDBC-драйверов в Open Liberty и одновременно из-за недавно сломанной обратной совместимости в JDBC-драйвере к Apache Derby — произошел конфликт версий, из-за которого пришлось использовать немного устаревшую версию:
.. <dependency> <groupId>org.apache.derby</groupId> <version>10.14.2.0</version> <scope>provided</scope> </dependency> ..
Затем данная зависимость копируется в профиль сервера:
.. <copyDependencies> <dependencyGroup> <!-- Relative to server config directory --> <location>lib/global/jdbc</location> <stripVersion>true</stripVersion> <dependency> <groupId>org.apache.derby</groupId> <artifactId>derby</artifactId> </dependency> </dependencyGroup> </copyDependencies> ..
и наконец используется со стороны настройки сервера: (src/main/liberty/config/server.xml)
.. <dataSource id="DefaultDataSource"> <jdbcDriver libraryRef="phlegethLib"/> <properties.derby.embedded databaseName="shuggDB" createDatabase="create"/> <containerAuthData user="tharanak" password="y'hah"/> </dataSource> <library id="phlegethLib"> <file name="${server.config.dir}/lib/global/jdbc/derby.jar"/> <file name="${server.config.dir}/lib/global/jdbc/derbyshared.jar"/> </library> ..
Следующий момент — полное удаление и создание заново всех таблиц и данных в тестовой базе, за это отвечает настройка:
(src/main/resources/META-INF/persistence.xml)
.. <property name="jakarta.persistence.schema-generation.database.action" value="drop-and-create"/> ..
А при запуске приложения происходит проверка (BookFilterListener.java) и добавление тестовой записи:
@Override @Transactional(Transactional.TxType.REQUIRED) public void contextInitialized(ServletContextEvent sce) { try { // simple check for records count in database, // also triggers model generation if db is empty if (bs.fetchRecordsCount() == 0) { //create test entity BookRecord r = new BookRecord(); r.setAuthor("system@test.org"); r.setMessage("Test message"); r.setTitle("Test title"); r = bs.save(r); log.log(Level.INFO, "automatically added default record: {0}", r.getId()); } } catch (Exception e) { log.log(Level.WARNING, String.format("Exception on startup: %s", e.getMessage()), e); } }
Авторизация
За время долгой работы над этим проектом, лулзов ради (изначально) была добавлена еще и авторизация, вместе с ролями.
Но лулзогенерация зашла слишком далеко, когда внезапно оказалось что даже мои дорогие коллеги нихрена не знают о современном JavaEE JakartaEE Security API вообще ничего не видели дальше стандарного Spring Security.
@CustomFormAuthenticationMechanismDefinition( loginToContinue = @LoginToContinue( loginPage = "/guestbook.xhtml?login=true", errorPage = "/guestbook.xhtml?login=true") )
А это не что иное как задание «form-based» аутентификации с помощью одних только аннотаций (!):
Annotation used to define a container AuthenticationMechanism
that implements authentication mechanism resembling the Servlet FORM one (Servlet spec 13.6.3).
Какой именно #банат это придумал и кому может такая дичь может понадобиться — другой вопрос, главное что оно такое есть ;)
Следующая аннотация чуть ниже:
@DeclareRoles({"ROLE_ADMIN", "ROLE_USER"})
на самом деле старая — еще из JavaEE, отвечает за описание набора используемых в приложении ролей:
Used by application to declare roles. It can be specified on a class.
Следующая остановка это класс UsersStore:
@ApplicationScoped public class UsersStore implements IdentityStore { .. }
реализующий интерфейс IdentityStore:
IdentityStore
- This interface defines methods for validating a caller’s credentials (such as username and password) and returning group membership information. IdentityStores are invoked under the control of anIdentityStoreHandler
, which, if multiple IdentityStores are present, calls the available IdentityStores in a specific order and aggregates the results.
Важным является автоматическая активация класса при запуске и его автоматическое использование для аутентификации.
@Override public CredentialValidationResult validate(Credential credential) { .. }
внутри которого происходит как получение данных о пользователе так и валидация его данных.
Но в отличие от Spring Security, тут не используется выбрасывание ошибки в качестве элемента логики работы:
if (!USERS.containsKey(login)) return CredentialValidationResult.INVALID_RESULT;
Возвращаются либо заранее созданные константы с ошибочным результатом, либо новый объект с успешным результатом:
return new CredentialValidationResult(user.login,user.roles);