О чём статья
Эта статья посвящена тому, как я делал библиотеку локализации на Rust. Фокус будет на изменении проекта от минимального решения для себя до полноценной библиотеки.
Термины
В рамках статьи я буду использовать:
локализация - перевод
локаль - язык
выражение - значение (в начале работы &'static str), которое меняется в зависимости от локали
Они введены, чтобы было проще сопоставлять код и его описание.
Как появилась идея
Всё началось с того, что я решил погрузиться в Rust и для практики решил сделать GUI-приложение. В процессе работы я решил добавить поддержку нескольких языков. Для этого я придумал систему, в которой выражения хранятся в виде массивов, а локаль представлена как usize, и в момент локализации происходит простое обращение по индексу.
Решение показалось мне удачным, но я предположил, что подобный подход уже реализован в какой-нибудь библиотеке. Я начал искать и обнаружил, что все рассмотренные решения основаны на JSON, YAML или TOML (из них наиболее близкой по идее оказалась rust-i18n). В итоге я решил оформить свой подход в виде отдельной библиотеки.
Начало работы
Решение, написанное для моего приложения
use lib::system::get_locale; use std::sync::LazyLock; use strum::EnumCount; use strum_macros::EnumCount; pub type Expression = [&'static str; Locale::COUNT]; #[derive(Clone, EnumCount)] pub enum Locale { RU, EN, ES, } pub static CURRENT_LOCALE: LazyLock<Locale> = LazyLock::new(|| match get_locale().as_str() { "ru" => Locale::RU, "es" => Locale::ES, _ => Locale::EN, }); macro_rules! localize { ($pack: ident::$expression: ident) => { lib::locale::$pack::$expression[(*lib::locale::CURRENT_LOCALE).clone() as usize] }; }
В этом варианте локаль бралась из системных настроек и не менялась во время выполнения программы, а путь к выражениям был жёстко задан в коде. Для первой версии библиотеки нужно было решить эти две проблемы, а также сделать удобный способ создания enum Locale.
Для этого была придумана архитектура с тремя макросами:
Макрос для инициализации локали
macro_rules! init_locale { ($($variant: ident),+) => { use core::sync::atomic::{AtomicUsize, Ordering}; use core::mem::transmute; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[repr(usize)] pub enum Locale { $($variant),+ } impl Locale { pub const COUNT: usize = [$(stringify!($variant)),+].len(); } pub type Expression = [&'static str; Locale::COUNT]; pub static LOCALE: AtomicUsize = AtomicUsize::new(0); #[inline] pub fn get_locale() -> Locale { unsafe { transmute(match LOCALE.load(Ordering::Relaxed) { locale if locale < Locale::COUNT => locale, _ => 0, }) } } #[inline] pub fn set_locale(locale: Locale) { LOCALE.store(locale as usize, Ordering::Relaxed) } }; }
Макрос для создания выражения
macro_rules! expression { ($name: ident => {$($lang: ident: $expression: expr),+}) => { pub static $name: [&str; Locale::COUNT] = { let mut expression = [""; Locale::COUNT]; $( expression[Locale::$lang as usize] = $expression; )+ expression }; }; }
Макрос локализации
macro_rules! localize { ($expression: path) => { $expression[get_locale() as usize] }; }
В результате получилось no_std- совместимое решение. По сравнению с исходным вариантом в нём появились следующие изменения:
enum Localeтеперьrepr(usize)Добавлен макрос, которому достаточно передать список локалей - он сгенерирует
enum LocaleТекущая локаль стала храниться как
AtomicUsize, что позволяет безопасно использовать библиотеку в многопоточных приложениях. Преобразование изLocaleвusizeна этом этапе выполнялось черезtransmute, для предотвращения неопределённого поведения была добавлена проверка диапазонаДобавлен макрос для создания выражений, чтобы не создавать выражения вручную и избежать ошибок в порядке локалей
В макрос локализации можно передавать произвольный путь к выражению
Полностью убраны зависимости от сторонних crate
В таком виде была опубликована 1.0.0 версия.
Формирование плана по доработке решения
После релиза я опубликовал посты на Reddit, Rust-форуме и в Rust-сервере Discord. Целью было собрать фидбек и понять, какие задачи стоит решать в первую очередь. По итогам обсуждений сформировался следующий список:
Возможность передавать локаль в локализацию вручную, например для серверных сценариев. Изначально я не учёл этот кейс, так как в первую очередь ориентировался на приложения
Возможность подставлять значения в выражения. Эту функциональность я и так планировал добавить
Убрать
unsafeкод. Я сомневался в необходимостиtransmuteс самого начала, поэтому поставил задачу избавиться от негоВозможность создавать выражения пачками, а не по одному. Получив это предложение, я удивился, что не подумал об этом сразу
Также поступали предложения добавить поддержку склонений по родам, числительным и т.д., однако такие задачи не входят в цели библиотеки.
Помимо фидбека, я сформировал собственный список идей для доработки:
Localeдолжен поддерживать все стандартные traits (на тот момент были реализованы почти все, но, например, отсутствовалDisplay)Преобразование между
Localeиusize,Localeи&strдолжно быть максимально гибкимИсключить любые возможные ошибки при создании выражений
Добавить поддержку различных способов сериализации и десериализации
Добавить возможность задавать подписи вариантам локали
Итоговая библиотека
Инициализация без хранилища локали
init_locale!
($($variant: ident),+ $(,)? $(; [$($derive: path),+])?) => { localize_it::init_locale!($($variant => stringify!($variant)),+ $(; [$($derive),+])?); }; ($($variant: ident => $label: expr),+ $(,)? $(; [$($derive: path),+])?) => { #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash $(,$($derive),+)?)] #[repr(usize)] pub enum Locale { #[default] $($variant),+ } impl Locale { pub const COUNT: usize = [$(stringify!($variant)),+].len(); pub const VARIANTS: [Self; Self::COUNT] = [$(Self::$variant),+]; pub const LABELS: [&'static str; Self::COUNT] = [$($label),+]; localize_it::__init_locale_default_const!($($variant),+); #[inline] pub fn iter() -> impl Iterator<Item = (Self, &'static str)> { Self::iter_variants().zip(Self::iter_labels()) } #[inline] pub fn iter_variants() -> impl Iterator<Item = Self> { Self::VARIANTS.into_iter() } #[inline] pub fn iter_labels() -> impl Iterator<Item = &'static str> { Self::LABELS.into_iter() } #[inline] pub const fn to_usize(self) -> usize { self as usize } #[inline] pub const fn from_usize(value: usize) -> Option<Self> { match value { $( _ if value == Self::$variant.to_usize() => Some(Self::$variant), )+ _ => None, } } #[inline] pub fn from_usize_or_default(value: usize) -> Self { Self::from_usize(value).unwrap_or_default() } #[inline] pub const fn to_str(self) -> &'static str { match self { $( Self::$variant => stringify!($variant), )+ } } #[inline] pub fn from_str(str: &str) -> Option<Self> { match str { $( stringify!($variant) => Some(Self::$variant), )+ _ => None, } } #[inline] pub fn from_str_or_default(str: &str) -> Self { Self::from_str(str).unwrap_or_default() } #[inline] pub const fn from_caseless_str(str: &str) -> Option<Self> { match str { $( _ if str.eq_ignore_ascii_case(stringify!($variant)) => Some(Self::$variant), )+ _ => None, } } #[inline] pub fn from_caseless_str_or_default(str: &str) -> Self { Self::from_caseless_str(str).unwrap_or_default() } } impl core::fmt::Display for Locale { #[inline] fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { formatter.write_str(self.to_str()) } } impl From<Locale> for usize { #[inline] fn from(locale: Locale) -> Self { locale.to_usize() } } impl TryFrom<usize> for Locale { type Error = &'static str; #[inline] fn try_from(value: usize) -> Result<Self, Self::Error> { Self::from_usize(value).ok_or("Invalid numeric value for Locale") } } impl From<Locale> for &str { #[inline] fn from(locale: Locale) -> Self { locale.to_str() } } impl core::str::FromStr for Locale { type Err = &'static str; #[inline] fn from_str(str: &str) -> Result<Self, Self::Err> { Self::from_str(str).ok_or("Invalid locale identifier") } } impl TryFrom<&str> for Locale { type Error = &'static str; #[inline] fn try_from(str: &str) -> Result<Self, Self::Error> { Self::from_str(str).ok_or("Invalid locale identifier") } } };
По функционалу в нём появились следующие возможности:
Возможность указывать подписи локалей с тем же синтаксисом, что и при создании выражений. Эта функциональность опциональна и не требует писать лишний код, если подписи не нужны
Возможность передавать свои
derive, например для поддержкиserdeРеализации traits
Default(первый вариант локали),DisplayиFromStrКонстанты для доступа к вариантам, их подписям, default-локали.
Default::defaultнедоступен в compile-time, константа решает эту проблему. Она создаётся через вспомогательный макрос, который выбирает первую локаль из списка всех локалейРеализованы итераторы по вариантам локалей, подписям, а также по обоим массивам одновременно (например, для отображения списка локалей пользователю)
Все возможные варианты для преобразования между
usizeиLocale, а также&strиLocaleвключая варианты с игнорированием регистраПолностью убран
unsafe- оказалось, чтоtransmuteиmatchв данном случае компилируются в идентичный код, поэтому использованиеunsafeне имеет смысла (проверял через Compiler Explorer)
Вариант с хранилищем локали
init_locale_with_storage!
($($variant: ident),+ $(,)? $(; [$($derive: path),+])?) => { localize_it::init_locale_with_storage!($($variant => stringify!($variant)),+ $(; [$($derive),+])?); }; ($($variant: ident => $label: expr),+ $(,)? $(; [$($derive: path),+])?) => { localize_it::init_locale!($($variant => $label),+ $(; [$($derive),+])?); mod storage { use super::Locale; use core::sync::atomic::{AtomicUsize, Ordering}; static CURRENT_LOCALE: AtomicUsize = AtomicUsize::new(Locale::DEFAULT.to_usize()); #[inline] pub fn get_locale() -> Locale { Locale::from_usize_or_default(CURRENT_LOCALE.load(Ordering::Relaxed)) } #[inline] pub fn set_locale(locale: Locale) { CURRENT_LOCALE.store(locale.to_usize(), Ordering::Relaxed) } #[inline] pub fn get_locale_as_usize() -> usize { get_locale().to_usize() } #[inline] pub fn set_locale_from_usize(value: usize) { set_locale(Locale::from_usize_or_default(value)) } #[inline] pub fn get_locale_as_str() -> &'static str { get_locale().to_str() } #[inline] pub fn set_locale_from_str(str: &str) { set_locale(Locale::from_str_or_default(str)) } #[inline] pub fn set_locale_from_caseless_str(str: &str) { set_locale(Locale::from_caseless_str_or_default(str)) } } pub use storage::*; }
Он также создаёт Locale через описанный выше макрос и предоставляет функции для установки и получения текущей локали. Отличия хранилища минимальны:
Переменная-хранилище изолирована от прямого доступа и может использоваться только через предоставленные функции
Добавлена возможность устанавливать и получать текущую локаль как
usizeили&str
Создание выражений
Добавлен макрос expressions!, который позволяет создавать выражения пачками, не вызывая expression! для каждого из них. Я рассматривал вариант оставить только его, однако в итоге решил сохранить оба макроса: один - для одиночных выражений, второй - для массового объявления.
expression!
( $name: ident => { $( $lang: ident: $expression: expr ),+ $(,)? } ) => { localize_it::expression!($name: &'static str => {$($lang: $expression),+}); }; ( $name: ident: $content_type: ty => { $( $lang: ident: $content: expr ),+ $(,)? } ) => { pub static $name: [$content_type; Locale::COUNT] = { let mut expression = [$($content),+]; let mut empty = [true; Locale::COUNT]; $( let i = Locale::$lang.to_usize(); if empty[i] { expression[i] = $content; empty[i] = false; } else { panic!(concat!( "Initialize Error: Locale variant ", stringify!($lang), " is duplicated" )); } )+ expression }; };
В создании выражений наиболее важные изменения:
Теперь можно указывать тип значения, хранящегося в выражении. Это может быть любой compile-time тип, что позволяет не только форматировать строки, но и добавлять пользовательскую логику. При этом тип указывать необязательно - если выражение имеет тип
&'static str, то можно пропускать его объявлениеЕсли указана несуществующая локаль, локаль отсутствует или дублируется, это приводит к ошибке на этапе компиляции
Решение последнего пункта потребовало отдельного внимания. В предыдущей версии массив выражений инициализировался пустыми строками, но при поддержке произвольных типов такой подход невозможен: даже Default не подходит, так как, например, у функций его нет. Первой идеей было создать массив через std::mem::MaybeUninit::uninit().assume_init() и далее заполнить его. Поскольку присутствовали проверки неинициализированная память инициализировалась. Однако использование unsafe хотелось избежать. В итоге я пришёл к более простому и надёжному решению - создавать массив непосредственно из переданных элементов в том порядке, в котором они указаны, а после переопределять их в нужном порядке. Такой подход позволил убрать отдельную проверку пропуска вариантов локали, оставив только проверку на дубли, объединённую с установкой элементов в нужном порядке.
Локализация
Локализация по-прежнему представляет собой простейшую операцию взятия по индексу, однако теперь добавлена возможность вызывать выражения как функции.
localize!
($expression: path $(as ($($arg: expr),* $(,)?))?) => { $expression[get_locale().to_usize()]$(($($arg),*))? }; ($expression: path $(as ($($arg: expr),* $(,)?))?, $locale: expr) => { $expression[$locale.to_usize()]$(($($arg),*))? };
Изначально я хотел использовать синтаксис localize!(EXPRESSION(arg1, arg2)), но в макросах Rust после path параметра нельзя использовать скобки. В итоге был выбран вариант localize!(EXPRESSION as (arg1, arg2)). Использование as здесь логично по смыслу поскольку выражение используется как вызываемое.
Итог
Таким образом получилась гибкая и быстрая система локализации, подходящая как для использования на сервере, так и для GUI-приложений. Она работает в no_std, не требует аллокаций во время выполнения, не имеет зависимостей и выполняет все проверки на этапе компиляции.
Пример использования из README к библиотеке:
use localize_it::{expressions, init_locale_with_storage, localize}; use std::io::{stdin, stdout, Write}; // Определение доступных локалей init_locale_with_storage!(EN, RU); // Объявление простых выражений и выражений-функций expressions!( ENTER_LANGUAGE => { EN: "Enter EN or RU: ", RU: "Введите EN или RU: ", }, ENTER_YOU_NAME => { EN: "Please, enter your name: ", RU: "Пожалуйста, введите ваше имя: ", }, HELLO: fn (&str) -> String => { EN: |name: &str| format!("Hello, {name}!"), RU: |name: &str| format!("Привет, {name}!"), }, ); // Вспомогательная функция упрощающая ввод fn input() -> String { let mut temp = String::new(); stdout().flush().unwrap(); stdin().read_line(&mut temp).unwrap(); temp.trim().to_string() } fn main() { // Ручная установка локали print!("{}", localize!(ENTER_LANGUAGE, Locale::EN)); let lang = input(); // Установка текущей локали set_locale(Locale::from_str_caseless_or_default(&lang)); // Автоматическое использование локали print!("{}", localize!(ENTER_YOU_NAME)); let name = input(); // Вызов выражения-функции println!("{}", localize!(HELLO as (&name))); }
Дальнейшие планы
Сделать возможность разделять объявление выражений по принципу 1 файл - 1 язык
Добавить поддержку создания локалей с JSON, YAML, TOML - для упрощения интеграции с существующими решениями
