- PVSM.RU - https://www.pvsm.ru -
Эта статья посвящена тому, как я делал библиотеку локализации на 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 должно быть максимально гибким
Исключить любые возможные ошибки при создании выражений
Добавить поддержку различных способов сериализации и десериализации
Добавить возможность задавать подписи вариантам локали
($($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 [1].)
($($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 и Locale осуществляется через новые встроенные методы.
Добавлен макрос expressions!, который позволяет создавать выражения пачками, не вызывая 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 хотелось избежать. В итоге я пришёл к более простому и надёжному решению - создавать массив непосредственно из переданных элементов в том порядке, в котором они указаны, а после переопределять их в нужном порядке. Такой подход позволил убрать отдельную проверку пропуска вариантов локали, оставив только проверку на дубли, объединённую с установкой элементов в нужном порядке.
Локализация по-прежнему представляет собой простейшую операцию взятия по индексу, однако теперь добавлена возможность вызывать выражения как функции.
($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 - для упрощения интеграции с существующими решениями
Автор: Zen_Kerr
Источник [4]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/biblioteka/444252
Ссылки в тексте:
[1] Compiler Explorer: https://rust.godbolt.org/z/bf88bzxW1
[2] GitHub: https://github.com/ZenKerr/localize_it
[3] Crates.io: http://Crates.io
[4] Источник: https://habr.com/ru/articles/994098/?utm_source=habrahabr&utm_medium=rss&utm_campaign=994098
Нажмите здесь для печати.