A localização é crucial para o desenvolvimento de software moderno. Ao
suportar múltiplos idiomas, os aplicativos podem alcançar um público mais
amplo e se tornar mais inclusivos, alinhando-se com a missão do OnlyCoiners.
No entanto, gerenciar traduções de forma eficiente em um ambiente
multi-thread pode ser desafiador. Neste post, exploraremos como aproveitar o
OnceCell e o Mutex do Rust para lidar com traduções armazenadas em arquivos
JSON, armazenando-as em memória para facilitar a localização eficaz em todo
o aplicativo.
Você pode estar curioso por que escolhemos não usar uma solução estabelecida
como rust-i18n. Queríamos criar uma versão equivalente ao snippet de código
Python implementado no nosso servidor FastAPI, conforme descrito
neste post,
para reutilizar arquivos de tradução e facilitar o processo de reescrever
parte do código Python no nosso servidor em Rust OnlyCoiners API server.
Você pode testá-lo em produção aqui.
Você pode criar um token de API aqui primeiro
depois de criar uma conta no OnlyCoiners.
Se sua empresa está procurando contratar um desenvolvedor Rust ou fornecer
suporte a outra organização que usa Rust em produção, considere postar uma
vaga no OnlyCoiners.
Encontre e poste vagas de Rust no nosso quadro de empregos.
Entre em contato para dúvidas, e teremos o prazer
de oferecer a você e sua empresa descontos exclusivos e outros benefícios se tá contratando e quer ter uma parceria com nossa empresa!
Pode ler versão EN ou ES se quiser.
Pode ver postagem original em nosso site também.
Trecho Completo de Código para translator.rs
Antes de prosseguirmos, gostaríamos de apresentar o trecho completo de
código em Rust para a struct Translator, projetado para facilitar a
internacionalização dentro da sua base de código Rust.
use once_cell::sync::OnceCell;
use serde_json;
use std::collections::HashMap;
use std::sync::Mutex;
use std::{env, fs};
// 1. Global static storage for translation modules
static TRANSLATIONS: OnceCell<Mutex<HashMap<String, HashMap<String, HashMap<String, String>>>>> =
OnceCell::new();
pub struct Translator {
lang: String,
}
impl Translator {
// 2. Initialize the translator with a language
pub fn new(lang: &str) -> Self {
// Ensure translations are loaded once
let _ = TRANSLATIONS.get_or_init(|| Mutex::new(HashMap::new()));
Translator {
lang: lang.to_string(),
}
}
// 3. Load translations from files or other sources
fn load_translation_module(
&self,
file_key: &str,
) -> Option<HashMap<String, HashMap<String, String>>> {
let mut translations = TRANSLATIONS.get().unwrap().lock().unwrap();
// Get the current working directory and construct the full path dynamically
let current_dir = env::current_dir().unwrap();
let module_path =
current_dir.join(format!("src/translations/{}/{}.json", self.lang, file_key));
// If translation is already loaded, return a cloned version
if let Some(file_translations) = translations.get(file_key) {
return Some(file_translations.clone());
}
// Load the translation file - error.json
match fs::read_to_string(module_path) {
Ok(content) => {
// Parse the JSON into a nested HashMap - error -> common -> internal_server_error
let file_translations: HashMap<String, HashMap<String, String>> =
serde_json::from_str(&content).unwrap_or_default();
translations.insert(file_key.to_string(), file_translations.clone());
Some(file_translations)
}
Err(e) => {
tracing::error!("Error loading translation file - {}", e);
None
}
}
}
// 4. Translate based on key and optional variables
pub fn t(&self, key: &str, variables: Option<HashMap<&str, &str>>) -> String {
let parts: Vec<&str> = key.split('.').collect();
let file_key = parts.get(0).unwrap_or(&""); // "error"
let section_key = parts.get(1).unwrap_or(&""); // "common"
let translation_keys = &parts[2..]; // "INTERNAL_SERVER_ERROR"
// Load the correct translation module (e.g., "error.json")
if let Some(translation_module) = self.load_translation_module(file_key) {
if let Some(section) = translation_module.get(*section_key) {
let mut current_value: Option<&String> = None;
// Traverse the translation keys to get the final string value
for translation_key in translation_keys {
if current_value.is_none() {
// At the beginning, current_value is None, so we access the section (a HashMap)
if let Some(next_value) = section.get(*translation_key) {
current_value = Some(next_value);
} else {
return format!("Key '{}' not found in '{}' locale", key, self.lang);
}
}
}
// At this point, current_value should be a &String
if let Some(translation_string) = current_value {
let mut translated_text = translation_string.clone();
// Handle variables if present
if let Some(variables) = variables {
for (variable, value) in variables {
let variable_format = format!("{{{}}}", variable);
translated_text = translated_text.replace(&variable_format, value);
}
}
translated_text
} else {
format!("Key '{}' not found in '{}' locale", key, self.lang)
}
} else {
format!(
"Section '{}' not found in '{}' locale",
section_key, self.lang
)
}
} else {
format!("Module '{}' not found for '{}' locale", file_key, self.lang)
}
}
}
Por que Usar Armazenamento Estático para Traduções?
Ao trabalhar em aplicativos multi-thread, lidar com dados globais requer
atenção cuidadosa. Sem a sincronização adequada, você pode enfrentar
condições de corrida, falhas ou outros problemas. O Rust oferece ferramentas
como OnceCell
e Mutex
para resolver esses problemas de forma segura.
OnceCell
garante que um valor seja inicializado apenas uma vez e
fornece acesso a ele entre as threads. Mutex
garante acesso seguro e
mutável a dados compartilhados entre threads, bloqueando o acesso quando uma
thread está lendo ou escrevendo.
Ao combinar esses dois, podemos criar um armazenamento global estático que
armazena em cache arquivos de tradução em memória, para que sejam carregados
uma vez e reutilizados durante toda a vida útil do programa. Esta abordagem
evita o carregamento repetido de arquivos do disco e garante que as traduções
sejam tratadas de forma segura em um ambiente concorrente.
Explicação do Código
Vamos mergulhar no código que alimenta este sistema de tradução. Ele utiliza
uma combinação de OnceCell
, Mutex
e um HashMap
aninhado para carregar
e armazenar traduções de arquivos JSON. Uma vez que um arquivo é carregado,
ele é armazenado em memória e reutilizado para solicitações subsequentes.
1. Armazenamento Global de Traduções
Os dados de tradução são armazenados em uma variável estática global,
TRANSLATIONS
, que usa OnceCell
e Mutex
para garantir que os dados
sejam thread-safe e inicializados apenas uma vez. A estrutura do HashMap
permite organizar traduções de forma hierárquica.
- O primeiro nível armazena traduções por chave de arquivo como
error.json
. - O segundo nível agrupa traduções por chave de seção, como common.
- O terceiro nível armazena os pares chave-valor da tradução real.
use once_cell::sync::OnceCell;
use std::collections::HashMap;
use std::sync::Mutex;
static TRANSLATIONS: OnceCell<Mutex<HashMap<String, HashMap<String, HashMap<String, String>>>>> =
OnceCell::new();
Aqui está como o HashMap
aninhado funciona
-
Chave de arquivo, como
"error"
, aponta para um mapa de chaves de seção. - Cada chave de seção, como
"common"
, contém as strings de tradução, organizadas por chaves como"internal_server_error"
, com mensagens correspondentes, como"Erro interno no servidor"
, conforme você pode ver no arquivo JSON usado em produção no servidor da API OnlyCoiners.
src/translations/en/error.json
{
"common": {
"internal_server_error": "Internal server error",
"not_authorized": "You are not authorized to use this resource",
"not_found": "{resource} not found"
},
"token": {
"no_api_user_token": "API-USER-TOKEN header is not included",
"invalid_api_user_token": "API-USER-TOKEN header is not valid",
"no_api_admin_token": "API-ADMIN-TOKEN header is not included",
"unable_to_read_api_token": "Unable to read API Token"
},
"database": {
"unable_to_query_database": "Unable to query database"
}
}
2. Inicializando o Translator
A struct Translator
representa um objeto vinculado a um idioma específico,
como "en"
para Inglês ou "pt"
para Português. Quando criamos uma instância
de Translator
, a variável global TRANSLATIONS
é inicializada, caso ainda
não tenha sido.
pub struct Translator {
lang: String,
}
impl Translator {
pub fn new(lang: &str) -> Self {
let _ = TRANSLATIONS.get_or_init(|| Mutex::new(HashMap::new()));
Translator {
lang: lang.to_string(),
}
}
}
Isso garante que o armazenamento global para traduções esteja configurado e
pronto para ser utilizado. O campo lang
na struct Translator
armazena o
código do idioma, como "en"
para Inglês ou "es"
para Espanhol, e é usado
ao carregar arquivos de tradução.
3. Carregando Arquivos de Tradução
A função load_translation_module
é responsável por carregar os dados de
tradução de um arquivo, como src/translations/en/error.json
. Ela lê o
arquivo JSON, faz o parsing dos dados e os armazena no mapa global
TRANSLATIONS
para uso futuro. Se o arquivo já foi carregado, ela simplesmente
retorna a versão armazenada em cache.
use std::{env, fs};
fn load_translation_module(
&self,
file_key: &str,
) -> Option<HashMap<String, HashMap<String, String>>> {
let mut translations = TRANSLATIONS.get().unwrap().lock().unwrap();
let current_dir = env::current_dir().unwrap();
let module_path =
current_dir.join(format!("src/translations/{}/{}.json", self.lang, file_key));
if let Some(file_translations) = translations.get(file_key) {
return Some(file_translations.clone());
}
match fs::read_to_string(module_path) {
Ok(content) => {
let file_translations: HashMap<String, HashMap<String, String>> =
serde_json::from_str(&content).unwrap_or_default();
translations.insert(file_key.to_string(), file_translations.clone());
Some(file_translations)
}
Err(e) => {
tracing::error!("Error loading translation file - {}", e);
None
}
}
}
Esta função faz o seguinte:
-
Verifica se o arquivo já está carregado: Se estiver, ela retorna os
dados armazenados em cache no mapa
TRANSLATIONS
. -
Carrega o arquivo de tradução: Se o arquivo ainda não foi carregado,
ela lê o arquivo JSON no caminho
src/translations/{lang}/{file}.json
, faz o parsing do conteúdo em umHashMap
e o armazena em memória. -
Lida com erros: Se o arquivo não puder ser lido, por exemplo, se não
existir, uma mensagem de erro é registrada, e a função retorna
None
.
4. Traduzindo Chaves com Variáveis
Uma vez que as traduções estão carregadas, você pode recuperá-las usando a
função t
. Esta função recebe uma chave, que é uma string separada por
pontos. Por exemplo, "error.common.internal_server_error"
, e recupera a
string de tradução correspondente. Ela também suporta a substituição de
variáveis, permitindo inserir valores dinâmicos na tradução.
use serde_json;
pub fn t(&self, key: &str, variables: Option<HashMap<&str, &str>>) -> String {
let parts: Vec<&str> = key.split('.').collect();
let file_key = parts.get(0).unwrap_or(&"");
let section_key = parts.get(1).unwrap_or(&"");
let translation_keys = &parts[2..];
if let Some(translation_module) = self.load_translation_module(file_key) {
if let Some(section) = translation_module.get(*section_key) {
let mut current_value: Option<&String> = None;
for translation_key in translation_keys {
if current_value.is_none() {
if let Some(next_value) = section.get(*translation_key) {
current_value = Some(next_value);
} else {
return format!("Key '{}' not found in '{}' locale", key, self.lang);
}
}
}
if let Some(translation_string) = current_value {
let mut translated_text = translation_string.clone();
if let Some(variables) = variables {
for (variable, value) in variables {
let variable_format = format!("{{{}}}", variable);
translated_text = translated_text.replace(&variable_format, value);
}
}
translated_text
} else {
format!("Key '{}' not found in '{}' locale", key, self.lang)
}
} else {
format!(
"Section '{}' not found in '{}' locale",
section_key, self.lang
)
}
} else {
format!("Module '{}' not found for '{}' locale", file_key, self.lang)
}
}
Esta função faz o seguinte:
-
Divide a chave em partes:
file_key
,section_key
e a chave de tradução real. -
Carrega o arquivo de tradução: Ela chama
load_translation_module
para garantir que o arquivo correto seja carregado. -
Percorre as chaves: Navega pelo
HashMap
do arquivo para encontrar a string de tradução desejada. -
Lida com variáveis dinâmicas: Se a tradução contém variáveis como
{username}
, elas são substituídas pelos valores passados no mapavariables
.
Por exemplo, se a string de tradução for "{username}, Crie, Ganhe e Conecte-se
e você fornecer
com OnlyCoiners!"{"username": "Rust"}
, o resultado final será
"Rust, Crie, Ganhe e Conecte-se com OnlyCoiners!"
.
Tratamento de Erros
O sistema foi projetado para fornecer mensagens de erro úteis quando
traduções não são encontradas. Por exemplo, se uma seção ou chave estiver
faltando, ele retorna uma mensagem como:
Key 'error.common.INTERNAL_SERVER_ERROR' not found in 'en' locale
Isso garante que os desenvolvedores possam identificar facilmente traduções ausentes durante o desenvolvimento.
Exemplos de uso em produção
O módulo de tradução é utilizado em produção no OnlyCoiners API server.
Vamos fornecer alguns trechos de código que você pode usar como referência.
Você pode primeiro criar um middleware como este para o axum.
// #[derive(Clone)]
// pub struct Language(pub String);
use std::collections::HashSet;
use crate::{constants::language::{ALLOWED_LANGUAGE_LIST, EN}, schemas::language::Language};
use axum::{extract::Request, middleware::Next, response::Response};
pub async fn extract_client_language(
mut request: Request, // mutable borrow for later modification
next: Next,
) -> Result<Response, String> {
let accept_language = {
// Temporarily borrow the request immutably to get the header
request
.headers()
.get("Accept-Language")
.and_then(|value| value.to_str().ok())
.unwrap_or("")
.to_string() // convert to String to end the borrow
};
let mut locale = accept_language.split(',').next().unwrap_or(EN).to_string();
// Remove any region specifier like en-US to en
locale = locale.split('-').next().unwrap_or(EN).to_string();
// Create a set of allowed languages for quick lookup
let allowed_languages: HashSet<&str> = ALLOWED_LANGUAGE_LIST.iter().cloned().collect();
// Verify if the extracted locale is allowed; if not, default to the default language
if !allowed_languages.contains(locale.as_str()) {
locale = EN.to_string();
}
// Insert the language into request extensions with mutable borrow
request.extensions_mut().insert(Language(locale));
// Proceed to the next middleware or handler
let response = next.run(request).await;
Ok(response)
}
Você pode incluir isso no seu aplicativo axum.
let app = Router::new()
.route("/", get(root))
// Attach `/api` routes
.nest("/bot", bot_routes)
.nest("/admin", admin_routes)
.nest("/api", api_routes)
.layer(from_fn(extract_client_language))
Em seguida, use isso dentro do seu handler.
pub async fn find_user_list(
Extension(session): Extension<SessionData>,
Extension(language): Extension<Language>,
) -> Result<Json<Vec<UserListing>>, (StatusCode, Json<ErrorMessage>)> {
let translator = Translator::new(&language.0);
let not_authorized = translator.t("error.common.not_authorized", None);
Err((
StatusCode::UNAUTHORIZED,
Json(ErrorMessage {
text: not_authorized,
}),
))
}
Opcionalmente, você pode criar testes para o módulo Translator e usar $cargo test
para testá-lo.
#[cfg(test)]
mod tests {
use crate::translations::translator::Translator;
use super::*;
use std::collections::HashMap;
#[test]
fn test_translation_for_english_locale() {
let translator = Translator::new("en");
let translation = translator.t("error.common.internal_server_error", None);
assert_eq!(translation, "Internal server error");
let not_found = translator.t("error.common.non_existent", None);
assert_eq!(not_found, "Key 'error.common.non_existent' not found in 'en' locale");
}
#[test]
fn test_translation_for_portuguese_locale() {
let translator = Translator::new("pt");
// Test known translation
let translation = translator.t("error.common.internal_server_error", None);
println!("translation {}", translation);
assert_eq!(translation, "Erro interno no servidor");
// Test key not found
let not_found = translator.t("error.common.non_existent", None);
assert_eq!(not_found, "Key 'error.common.non_existent' not found in 'pt' locale");
}
#[test]
fn test_translation_with_variables() {
let translator = Translator::new("en");
let mut variables = HashMap::new();
variables.insert("resource", "User");
let translation_with_vars = translator.t("error.common.not_found", Some(variables));
assert_eq!(translation_with_vars, "User not found");
}
#[test]
fn test_translation_module_not_found() {
let translator = Translator::new("es");
// Test loading a non-existent module
let translation = translator.t("non_existent_module.common.internal_server_error", None);
assert_eq!(
translation,
"Module 'non_existent_module' not found for 'es' locale"
);
}
#[test]
fn test_translation_section_not_found() {
let translator = Translator::new("en");
// Test section not found in translation file
let translation = translator.t("error.non_existent_section.internal_server_error", None);
assert_eq!(
translation,
"Section 'non_existent_section' not found in 'en' locale"
);
}
}
Você também pode testar o middleware.
pub async fn test_handler(Extension(language): Extension<Language>) -> Json<serde_json::Value> {
// Return a JSON response with the extracted language
let response_message = match language.0.as_str() {
"en" => "en",
"pt" => "pt",
"es" => "es",
_ => "deafult",
};
Json(json!({ "message": response_message }))
}
#[cfg(test)]
mod tests {
use crate::{
constants::language::{EN, EN_US, ES, PT}, mdware::language::extract_client_language, tests::{test_handler, TRANSLATOR}
};
use axum::{
body::{to_bytes, Body}, http::Request, middleware::from_fn, Router
};
use hyper::StatusCode;
use tower::ServiceExt;
use serde_json::{json, Value};
#[tokio::test]
async fn test_with_valid_accept_language_header() {
let app = Router::new()
.route("/", axum::routing::get(test_handler))
.layer(from_fn(extract_client_language));
// Simulate a request with a valid Accept-Language header like
let request = Request::builder()
.header("Accept-Language", EN_US) // "en-US"
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let body_str = std::str::from_utf8(&body).unwrap();
// println!("Response Body: {:?}", body_str);
let body_json: Value = serde_json::from_str(body_str).unwrap();
assert_eq!(body_json, json!({ "message": EN }));
}
#[tokio::test]
async fn test_with_valid_accept_language_header_wiht_pt() {
let app = Router::new()
.route("/", axum::routing::get(test_handler))
.layer(from_fn(extract_client_language));
let request = Request::builder()
.header("Accept-Language", "pt")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let body_str = std::str::from_utf8(&body).unwrap();
let body_json: Value = serde_json::from_str(body_str).unwrap();
assert_eq!(body_json, json!({ "message": PT }));
}
#[tokio::test]
async fn test_with_valid_accept_language_header_wiht_pt_br() {
let app = Router::new()
.route("/", axum::routing::get(test_handler))
.layer(from_fn(extract_client_language));
let request = Request::builder()
.header("Accept-Language", "pt-BR")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let body_str = std::str::from_utf8(&body).unwrap();
let body_json: Value = serde_json::from_str(body_str).unwrap();
assert_eq!(body_json, json!({ "message": PT }));
}
#[tokio::test]
async fn test_with_valid_accept_language_header_wiht_es() {
let app = Router::new()
.route("/", axum::routing::get(test_handler))
.layer(from_fn(extract_client_language));
let request = Request::builder()
.header("Accept-Language", "es")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let body_str = std::str::from_utf8(&body).unwrap();
let body_json: Value = serde_json::from_str(body_str).unwrap();
assert_eq!(body_json, json!({ "message": ES }));
}
#[tokio::test]
async fn test_with_unsupported_language() {
let app = Router::new()
.route("/", axum::routing::get(test_handler))
.layer(from_fn(extract_client_language));
let request = Request::builder()
.header("Accept-Language", "fr")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let body_str = std::str::from_utf8(&body).unwrap();
let body_json: Value = serde_json::from_str(body_str).unwrap();
assert_eq!(body_json, json!({ "message": EN }));
}
#[tokio::test]
async fn test_without_accept_language_header() {
let app = Router::new()
.route("/", axum::routing::get(test_handler))
.layer(from_fn(extract_client_language));
let request = Request::builder()
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let body_str = std::str::from_utf8(&body).unwrap();
let body_json: Value = serde_json::from_str(body_str).unwrap();
assert_eq!(body_json, json!({ "message": EN }));
}
}
Você pode usar esses arquivos de tradução JSON como referência.
en.json
{
"common": {
"internal_server_error": "Internal server error",
"not_authorized": "You are not authorized to use this resource",
"not_found": "{resource} not found"
},
}
pt.json
{
"common": {
"internal_server_error": "Erro interno no servidor",
"not_authorized": "Você não está autorizado a usar este recurso",
"not_found": "{resource} não encontrado"
},
}
es.json
{
"common": {
"internal_server_error": "Error interno del servidor",
"not_authorized": "No estás autorizado para usar este recurso",
"not_found": "{resource} no encontrado"
},
}
Conclusão
Este sistema de tradução lida de forma eficiente com traduções em uma
aplicação Rust, utilizando armazenamento estático e acesso thread-safe.
Ao aproveitar OnceCell
e Mutex
, podemos garantir que os arquivos de
tradução sejam carregados uma vez e armazenados em cache, melhorando o
desempenho e reduzindo o acesso ao disco. A função t
permite a
recuperação flexível de traduções com suporte para variáveis dinâmicas,
tornando-a uma ferramenta poderosa para a localização.
Se você está construindo um aplicativo que requer localização, essa
abordagem oferece uma solução simples, escalável e eficiente para o
gerenciamento de traduções. Ao usar os recursos de segurança de memória
do Rust, você garante que suas traduções sejam tratadas com segurança e
eficiência em múltiplas threads.
Esperamos que este post tenha ajudado você a implementar um sistema simples
de tradução utilizando Rust. Usamos Rust ativamente em produção e estamos
procurando contratar mais desenvolvedores Rust.
Top comments (0)