La localización es crucial para el desarrollo de software moderno. Al soportar
múltiples idiomas, las aplicaciones pueden llegar a una audiencia más amplia y
volverse más inclusivas, alineándose con la misión de OnlyCoiners.
Sin embargo, gestionar traducciones de manera eficiente en un entorno multi-hilo
puede ser un desafío. En este post, exploraremos cómo aprovechar OnceCell y
Mutex de Rust para manejar traducciones almacenadas en archivos JSON,
almacenándolas en memoria para facilitar la localización efectiva en toda la
aplicación.
Podrías preguntarte por qué no utilizamos una solución ya establecida como
rust-i18n. Queríamos crear una versión equivalente al fragmento de código
Python implementado en nuestro servidor FastAPI, como se describe
en este post,
para reutilizar archivos de traducción y simplificar el proceso de reescribir
parte del código Python en nuestro servidor Rust OnlyCoiners API server.
Puedes probarlo en producción aquí.
Puedes crear un token de API aquí primero
después de crear una cuenta en OnlyCoiners.
Si tu empresa busca contratar a un desarrollador de Rust o brindar soporte a otra
organización que utilice Rust en producción, considera publicar una oferta de
trabajo en OnlyCoiners.
Encuentra y publica trabajos de Rust en nuestro tablón de empleos.
Contáctanos para cualquier consulta, estaremos encantados
de ofrecer a ti y a tu empresa descuentos exclusivos y otros beneficios si estás contratando o quieres una colaboración con nuestra empresa..
Puedes leer versión EN ou PT se quieres.
Puedes leer publicación original en nuestro sitio web.
Fragmento completo de código para translator.rs
Antes de continuar, nos gustaría presentar el fragmento completo de
código en Rust para la estructura Translator, diseñado para facilitar la
internacionalización dentro de tu base de código en 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 qué usar almacenamiento estático para traducciones?
Al trabajar en aplicaciones multi-hilo, manejar datos globales requiere
especial cuidado. Sin la sincronización adecuada, podrías enfrentar
condiciones de carrera, fallos u otros problemas. Rust ofrece herramientas
como OnceCell
y Mutex
para resolver estos problemas de manera segura.
OnceCell
garantiza que un valor se inicialice solo una vez y
proporciona acceso a él entre los hilos. Mutex
garantiza acceso seguro y
mutable a los datos compartidos entre hilos, bloqueando el acceso cuando un
hilo está leyendo o escribiendo.
Al combinar estos dos, podemos crear un almacenamiento global estático que
almacena en caché los archivos de traducción en memoria, para que se carguen
una vez y se reutilicen durante toda la vida útil del programa. Este enfoque
evita cargar repetidamente archivos desde el disco y garantiza que las
traducciones se manejen de manera segura en un entorno concurrente.
Explicación del Código
Vamos a sumergirnos en el código que impulsa este sistema de traducción.
Utiliza una combinación de OnceCell
, Mutex
y un HashMap
anidado para
cargar y almacenar traducciones de archivos JSON. Una vez que se carga un
archivo, se almacena en memoria y se reutiliza para solicitudes posteriores.
1. Almacenamiento Global de Traducciones
Los datos de traducción se almacenan en una variable estática global,
TRANSLATIONS
, que utiliza OnceCell
y Mutex
para garantizar que los datos
sean seguros para hilos y se inicialicen solo una vez. La estructura del
HashMap
permite organizar las traducciones de manera jerárquica.
- El primer nivel almacena traducciones por clave de archivo, como
error.json
. - El segundo nivel agrupa traducciones por clave de sección, como common.
- El tercer nivel almacena los pares clave-valor de la traducción 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();
Aquí está cómo funciona el HashMap
anidado:
-
Clave de archivo, como
"error"
, apunta a un mapa de claves de sección. - Cada clave de sección, como
"common"
, contiene las cadenas de traducción, organizadas por claves como"internal_server_error"
, con mensajes correspondientes, como"Error interno del servidor"
, como puedes ver en el archivo JSON utilizado en producción en el servidor de API de 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 el Translator
La estructura Translator
representa un objeto vinculado a un idioma específico,
como "en"
para inglés o "pt"
para portugués. Cuando creamos una instancia
de Translator
, la variable global TRANSLATIONS
se inicializa, en caso de
que aún no lo haya 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(),
}
}
}
Esto garantiza que el almacenamiento global para traducciones esté configurado y
listo para ser utilizado. El campo lang
en la estructura Translator
almacena
el código del idioma, como "en"
para inglés o "es"
para español, y se usa
al cargar archivos de traducción.
3. Cargando Archivos de Traducción
La función load_translation_module
es responsable de cargar los datos de
traducción de un archivo, como src/translations/en/error.json
. Lee el
archivo JSON, analiza los datos y los almacena en el mapa global
TRANSLATIONS
para uso futuro. Si el archivo ya ha sido cargado, simplemente
devuelve la versión almacenada en caché.
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 función hace lo siguiente:
-
Verifica si el archivo ya está cargado: Si lo está, devuelve los
datos almacenados en caché en el mapa
TRANSLATIONS
. -
Carga el archivo de traducción: Si el archivo aún no ha sido cargado,
lee el archivo JSON en la ruta
src/translations/{lang}/{file}.json
, analiza el contenido en unHashMap
y lo almacena en memoria. -
Maneja errores: Si el archivo no se puede leer, por ejemplo, si no
existe, se registra un mensaje de error y la función devuelve
None
.
4. Traduciendo Claves con Variables
Una vez que las traducciones están cargadas, puedes recuperarlas usando la
función t
. Esta función recibe una clave, que es una cadena separada por
puntos. Por ejemplo, "error.common.internal_server_error"
, y recupera la
cadena de traducción correspondiente. También admite la sustitución de
variables, lo que permite insertar valores dinámicos en la traducción.
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 función hace lo siguiente:
-
Divide la clave en partes:
file_key
,section_key
y la clave de traducción real. -
Carga el archivo de traducción: Llama a
load_translation_module
para garantizar que se cargue el archivo correcto. -
Recorre las claves: Navega a través del
HashMap
del archivo para encontrar la cadena de traducción deseada. -
Maneja variables dinámicas: Si la traducción contiene variables como
{username}
, estas se reemplazan por los valores proporcionados en el mapavariables
.
Por ejemplo, si la cadena de traducción es "{username}, Crea, Gana y Conéctate
y proporcionas
con OnlyCoiners!"{"username": "Rust"}
, el resultado final será
"Rust, Crea, Gana y Conéctate con OnlyCoiners!"
.
Manejo de Errores
El sistema está diseñado para proporcionar mensajes de error útiles cuando
no se encuentran traducciones. Por ejemplo, si falta una sección o clave,
retorna un mensaje como:
Key 'error.common.INTERNAL_SERVER_ERROR' not found in 'en' locale
Esto garantiza que los desarrolladores puedan identificar fácilmente las
traducciones faltantes durante el desarrollo.
Ejemplos de uso en producción
El módulo de traducción se utiliza en producción en el OnlyCoiners API server.
Proporcionaremos algunos fragmentos de código que puedes usar como referencia.
Puedes comenzar creando un middleware como este para 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)
}
Puedes incluir esto en tu aplicación 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))
A continuación, usa esto dentro de tu 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, puedes crear pruebas para el módulo Translator y usar $cargo test
para probarlo.
#[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"
);
}
}
También puedes probar el 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 }));
}
}
Puedes usar estos archivos de traducción JSON como referencia.
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"
},
}
Conclusión
Este sistema de traducción maneja eficientemente las traducciones en una
aplicación Rust, utilizando almacenamiento estático y acceso seguro para
hilos. Al aprovechar OnceCell
y Mutex
, podemos garantizar que los archivos
de traducción se carguen una vez y se almacenen en caché, mejorando el
rendimiento y reduciendo el acceso al disco. La función t
permite la
recuperación flexible de traducciones con soporte para variables dinámicas,
lo que la convierte en una herramienta poderosa para la localización.
Si estás construyendo una aplicación que requiere localización, este
enfoque ofrece una solución simple, escalable y eficiente para la gestión de
traducciones. Al utilizar las características de seguridad de memoria de Rust,
garantizas que tus traducciones se manejen de manera segura y eficiente en
múltiples hilos.
Esperamos que este post te haya ayudado a implementar un sistema simple de
traducción utilizando Rust. Utilizamos Rust activamente en producción y estamos
buscando contratar más desarrolladores de Rust.
Top comments (0)