Як припинити деплоїти кожну кому: динамічна локалізація застосунку за допомогою Go та AI
У розробці продуктів є процеси, які здаються простими, доки ти не починаєш масштабуватися або підключати до роботи команду не-технічних спеціалістів. Один із таких процесів — локалізація. Спочатку ви створюєте кілька локальних JSON-файлів у Git. Потім з’являється копірайтер, який просить виправити одрук в англійському тексті. Ви створюєте гілку, робите пул-реквест, чекаєте на CI/CD pipeline, деплоїте на стейджинг, а потім на прод. І все це заради зміни однієї букви.
Згодом додаються нові мови. Копірайтери починають редагувати ті самі JSON-файли паралельно з розробниками, що неминуче призводить до git-конфліктів при злитті гілок.
Я пройшов через цей біль і зрозумів, що локалізацію потрібно виносити за межі кодової бази та процесу деплою. Так з’явився проєкт i18n-saas.com з відкритим вихідним кодом на GitHub. Головна мета була створити систему, яка дозволить:
- Керувати перекладами централізовано.
- Робити автоматичні переклади нових ключів за допомогою AI, але з урахуванням контексту інтерфейсу.
- Доставляти свіжі тексти у додатки миттєво й динамічно без перезбирання коду.
У цьому матеріалі я хочу поділитися технічними деталями побудови такої системи. Ми розберемо архітектуру гібридного AI-перекладача з ланцюжком резервування (fallback chain) та детально розглянемо реалізацію високопродуктивного сервісу доставки локалізації на Go.
Архітектурний дизайн системи
Коли ми проєктували систему, то обрали таку зв’язку: Go на бекенді для швидкої обробки клієнтських запитів та високого конкаренсі, Nuxt 3 на фронтенді та Firebase / Firestore як гнучку базу даних.
Головний виклик у роботі з Firestore полягав у тому, щоб не вилетіти за ліміти безкоштовного тарифу (Free Tier) та не роздути рахунок за операції читання під час навантаження. Для цього ми вирішили розділити процеси:
- Уся адмін-панель та CRUD-операції з ключами працюють з базою даних Firestore напряму через бекенд-сервіс.
- Публічні запити від додатків (які хочуть стягнути свіжі переклади) обробляються через оптимізований CDN-ендпоінт з інвалідним in-memory кешем та підтримкою HTTP ETags.
Нижче детально описано, як саме ми вирішили ці два ключові завдання: якісний переклад інтерфейсів за допомогою AI та миттєва віддача перекладів під великим навантаженням.
Гібридний AI-рушій перекладів з каскадною відмовостійкістю
Звичайний машинний переклад (на кшталт стандартного Google Translate чи DeepL) має фундаментальну проблему при локалізації UI: він абсолютно не розуміє контексту. Наприклад, англійське слово «Save» в інтерфейсі може перекладатися як дієслово «Зберегти», як іменник «Збереження» або як «Заощадити», якщо мова йде про фінансовий застосунок. Або коротке слово «Just now» у списку сповіщень — його автоматичні системи часто перекладають занадто буквально.
Ми вирішили використати великі мовні моделі (LLM), а саме Google Gemini 2.5 Flash, оскільки вони вміють враховувати системні інструкції та контекст.
Для масового автоперекладу нових ключів (Bulk Translate) ми скористалися можливістю Gemini генерувати структурований JSON. Ми передаємо на вхід моделі JSON-об’єкт з вихідними текстами і вимагаємо повернути JSON з точно такою ж структурою ключів, але перекладеними значеннями.
Проте покладатися виключно на одну 429 Too Many Requests через ліміти запитів (Rate Limits) або просто внутрішню помилку 500/503 на боці API. Тому ми реалізували каскадний ланцюжок відмовостійкості (Fallback Chain):
- Первинна спроба — переклад через Gemini 2.5 Flash (найкраща якість та контекст).
- Якщо Gemini повертає помилку ліміту або недоступності — запит перенаправляється на DeepL API.
- Якщо й DeepL недоступний або не налаштований — використовується стандартний Google Cloud Translate API як гарантований резервний варіант.
Ось як виглядає технічна реалізація цього процесу на мові Go:
package handlers
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
)
type TranslateBulkRequest struct {
Items map[string]string `json:"items"` // map[key]text
From string `json:"from"`
To []string `json:"to"`
Provider string `json:"provider"`
}
func TranslateBulk(w http.ResponseWriter, r *http.Request) {
var req TranslateBulkRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if len(req.Items) == 0 {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("{}"))
return
}
apiKey := os.Getenv("GEMINI_API_KEY")
if apiKey == "" {
http.Error(w, "Gemini API key not configured", http.StatusInternalServerError)
return
}
// Структура результату: map[locale]map[key]translated_text
result := map[string]map[string]string{}
var mu sync.Mutex
var wg sync.WaitGroup
apiUrl := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=%s", apiKey)
itemsJSON, _ := json.MarshalIndent(req.Items, "", " ")
for _, lang := range req.To {
wg.Add(1)
go func(l string) {
defer wg.Done()
// 1. Формуємо строгу інструкцію для Gemini
systemPrompt := "You are a professional, context-aware UI translation engine. Translate the JSON key-value pairs of UI texts. Return ONLY a valid JSON object with the exact same keys and translated values. Do NOT translate the keys. No explanations, no introductory text, no markdown formatting."
geminiReq := map[string]interface{}{
"systemInstruction": map[string]interface{}{
"parts": []map[string]interface{}{
{"text": systemPrompt},
},
},
"contents": []map[string]interface{}{
{
"parts": []map[string]interface{}{
{"text": fmt.Sprintf("Translate from %s to %s:\n\n%s", req.From, l, string(itemsJSON))},
},
},
},
"generationConfig": map[string]interface{}{
"responseMimeType": "application/json",
},
}
jsonBody, _ := json.Marshal(geminiReq)
resp, err := http.Post(apiUrl, "application/json", bytes.NewBuffer(jsonBody))
// 2. Якщо сталася помилка з'єднання або API повернуло 429/500+, запускаємо fallback ланцюжок
if err != nil || resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
if resp != nil {
resp.Body.Close()
}
// КРОК 2: Спроба перекладу через DeepL API
if os.Getenv("DEEPL_AUTH_KEY") != "" {
fmt.Printf("⚠️ Gemini rate limit or error, falling back to DeepL for: %s\n", l)
deepLResult, dErr := translateBulkWithDeepL(req.Items, req.From, l)
if dErr == nil && len(deepLResult) > 0 {
mu.Lock()
result[l] = deepLResult
mu.Unlock()
return
}
fmt.Printf("❌ DeepL fallback failed: %v\n", dErr)
}
// КРОК 3: Остання спроба через Google Cloud Translate
fmt.Printf("⚠️ Falling back to Google Cloud Translate for: %s\n", l)
googleResult, gErr := translateBulkWithGoogle(req.Items, req.From, l)
if gErr == nil && len(googleResult) > 0 {
mu.Lock()
result[l] = googleResult
mu.Unlock()
} else {
fmt.Printf("❌ Google Translate fallback failed: %v\n", gErr)
}
return
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Printf("❌ Gemini returned error status %d: %s\n", resp.StatusCode, string(body))
return
}
var aiResp struct {
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
}
err = json.NewDecoder(resp.Body).Decode(&aiResp)
resp.Body.Close()
if err != nil {
return
}
if len(aiResp.Candidates) > 0 && len(aiResp.Candidates[0].Content.Parts) > 0 {
var translatedItems map[string]string
text := aiResp.Candidates[0].Content.Parts[0].Text
// Іноді моделі все одно обгортають JSON у markdown блоки ```json ... ```
// Очищуємо це, шукаючи межі фігурних дужок
start := strings.Index(text, "{")
end := strings.LastIndex(text, "}")
if start != -1 && end != -1 && end > start {
text = text[start : end+1]
}
if err := json.Unmarshal([]byte(text), &translatedItems); err == nil {
mu.Lock()
result[l] = translatedItems
mu.Unlock()
} else {
fmt.Printf("❌ Failed to parse JSON returned by Gemini: %v\n", err)
}
}
}(lang)
}
wg.Wait()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
// --- DeepL Bulk Helper ---
func translateBulkWithDeepL(items map[string]string, from, to string) (map[string]string, error) {
apiKey := os.Getenv("DEEPL_AUTH_KEY")
if apiKey == "" {
return nil, fmt.Errorf("DEEPL_AUTH_KEY not set")
}
targetLang := strings.ToUpper(to)
if targetLang == "EN" {
targetLang = "EN-US"
}
apiUrl := "https://api-free.deepl.com/v2/translate"
if !strings.HasSuffix(apiKey, ":fx") {
apiUrl = "https://api.deepl.com/v2/translate"
}
// Зберігаємо оригінальний порядок ключів
keys := make([]string, 0, len(items))
values := make([]string, 0, len(items))
for k, v := range items {
keys = append(keys, k)
values = append(values, v)
}
// Будуємо x-www-form-urlencoded параметри
form := "target_lang=" + targetLang + "&source_lang=" + strings.ToUpper(from)
for _, v := range values {
form += "&text=" + v
}
req, _ := http.NewRequest("POST", apiUrl, strings.NewReader(form))
req.Header.Set("Authorization", "DeepL-Auth-Key "+apiKey)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("DeepL error: %s", string(b))
}
var dr struct {
Translations []struct {
Text string `json:"text"`
} `json:"translations"`
}
if err := json.NewDecoder(resp.Body).Decode(&dr); err != nil {
return nil, err
}
result := map[string]string{}
for i, t := range dr.Translations {
if i < len(keys) {
result[keys[i]] = t.Text
}
}
return result, nil
}
// --- Google Translate Bulk Helper ---
func translateBulkWithGoogle(items map[string]string, from, to string) (map[string]string, error) {
apiKey := os.Getenv("GOOGLE_TRANSLATE_API_KEY")
if apiKey == "" {
return nil, fmt.Errorf("GOOGLE_TRANSLATE_API_KEY not set")
}
keys := make([]string, 0, len(items))
values := make([]string, 0, len(items))
for k, v := range items {
keys = append(keys, k)
values = append(values, v)
}
apiUrl := "https://translation.googleapis.com/language/translate/v2?key=" + apiKey
reqBody := map[string]interface{}{
"q": values,
"source": from,
"target": to,
"format": "text",
}
jsonBody, _ := json.Marshal(reqBody)
resp, err := http.Post(apiUrl, "application/json", bytes.NewBuffer(jsonBody))
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("Google Translate error: %s", string(b))
}
var gr struct {
Data struct {
Translations []struct {
TranslatedText string `json:"translatedText"`
} `json:"translations"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&gr); err != nil {
return nil, err
}
result := map[string]string{}
for i, t := range gr.Data.Translations {
if i < len(keys) {
result[keys[i]] = t.TranslatedText
}
}
return result, nil
}
У цьому підході ми виграємо двічі: отримуємо високоякісні, контекстно-залежні переклади завдяки великій мовній моделі, а у випадку перевантаження чи обмеження лімітів сервіс прозоро для користувача перемикається на альтернативні перекладачі, не ламаючи робочий процес.
Швидка доставка перекладів на Go: віддача локалізації без навантаження на БД
Коли додаток користувача запускається на телефоні чи в браузері, він робить запит на отримання актуальних текстів. Робити запит до Firestore при кожному такому візиті — це технічне самогубство. Під час хабраефекту чи напливу користувачів ліміти Firestore злетять за лічені хвилини.
Щоб вирішити цю проблему, ми розробили архітектуру віддачі контенту з трьома рівнями оптимізації:
- Збирання деревоподібної структури на льоту: Для зручності адміністрування переклади зберігаються у базі у плоскому вигляді (наприклад, ключ
buttons.confirmта значенняПідтвердити). Проте бібліотекам локалізації на фронтенді (наприклад,vue-i18n) потрібен структурований JSON. Ми реконструюємо його динамічно. - In-Memory Cache на рівні Go-додатка: Зібрані переклади зберігаються у глобальній
mapу пам’яті сервера. Під час запиту ми не йдемо до бази, а миттєво віддаємо дані з ОЗП. Кеш інвалідується (очищується) лише тоді, коли розробник чи копірайтер оновлює переклад в адмінці. - HTTP ETag (304 Not Modified) кешування: Це найважливіший рівень. Замість того, щоб щоразу ганяти мегабайти перекладів по мережі, ми розраховуємо унікальний ідентифікатор версії (ETag) на основі мітки часу
updatedAtпроєкту. Якщо переклади не змінювалися, сервер віддає клієнту порожню відповідь зі статусом304 Not Modified, змушуючи його браузер використовувати локальний кеш.
Ось як реалізовано рекурсивне збирання вкладених об’єктів у Go:
func buildNested(result map[string]interface{}, key string, value string) {
parts := strings.Split(key, ".")
current := result
for i, part := range parts {
if i == len(parts)-1 {
current[part] = value
return
}
if _, ok := current[part]; !ok {
current[part] = map[string]interface{}{}
}
// Перевіряємо тип, щоб уникнути panic, якщо структура ключів некоректна
nestedMap, ok := current[part].(map[string]interface{})
if !ok {
return
}
current = nestedMap
}
}
А ось код публічного HTTP-ендпоінту, який обробляє запити додатків, застосовуючи in-memory кеш та HTTP ETag заголовки:
var (
publicCache = map[string]interface{}{}
projectCache = map[string]models.Project{}
cacheMutex sync.RWMutex
)
func PublicTranslations(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
locale := chi.URLParam(r, "locale")
w.Header().Set("Content-Type", "application/json")
ctx := context.Background()
// 1. Отримуємо метадані проєкту з кешу (або бази)
cacheMutex.RLock()
project, projectCached := projectCache[token]
cacheMutex.RUnlock()
if !projectCached {
// Якщо проєкту немає в кеші, робимо ОДИН запит до Firestore за токеном
iter := fb.DB.Collection("projects").Where("apiToken", "==", token).Limit(1).Documents(ctx)
doc, err := iter.Next()
if err != nil {
http.Error(w, "Project not found", http.StatusNotFound)
return
}
doc.DataTo(&project)
cacheMutex.Lock()
projectCache[token] = project
cacheMutex.Unlock()
}
// 2. Генеруємо ETag на основі Unix-наносекунд останнього оновлення проєкту
etag := fmt.Sprintf("W/\"%d-%s\"", project.UpdatedAt.UnixNano(), locale)
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "public, max-age=0, must-revalidate")
// 3. Якщо клієнт надіслав ETag і він збігається — віддаємо 304 Not Modified
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
// 4. Шукаємо готову структуру перекладів у пам'яті
cacheKey := token + "_" + locale
cacheMutex.RLock()
cached, ok := publicCache[cacheKey]
cacheMutex.RUnlock()
if ok {
json.NewEncoder(w).Encode(cached)
return
}
// 5. Якщо в кеші порожньо — дістаємо плоскі переклади з Firestore та будуємо дерево
result := map[string]interface{}{}
tIter := fb.DB.Collection("translations").
Where("projectId", "==", project.ID).
Where("locale", "==", locale).
Documents(ctx)
for {
tDoc, err := tIter.Next()
if err == iterator.Done {
break
}
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
var t models.Translation
tDoc.DataTo(&t)
if t.Key != "" {
buildNested(result, t.Key, t.Value)
}
}
// Записуємо зібраний об'єкт у кеш
cacheMutex.Lock()
publicCache[cacheKey] = result
cacheMutex.Unlock()
json.NewEncoder(w).Encode(result)
}
Інвалідація кешу: Як це працює?
Коли перекладач чи розробник додає новий ключ або редагує існуючий текст через інтерфейс адмін-панелі, у нас запускається простий механізм очищення кешу. Нам не потрібно скидати всю пам’ять: достатньо видалити конкретні ключі, що починаються з токена проєкту.
func InvalidateCache(token string) {
if token == "" {
return
}
prefix := token + "_"
cacheMutex.Lock()
defer cacheMutex.Unlock()
// Видаляємо проєкт з кешу метаданих
delete(projectCache, token)
// Видаляємо всі згенеровані мовні файли цього проєкту з CDN-кешу
for k := range publicCache {
if strings.HasPrefix(k, prefix) {
delete(publicCache, k)
}
}
}
Завдяки цьому ми отримуємо компроміс:
- База даних Firestore «відпочиває», оскільки до неї йдуть лише поодинокі запити під час першого прогріву кешу або після редагування.
- Клієнтські додатки завантажують переклади миттєво з оперативної пам’яті нашого Go-сервера.
- У 95% випадків користувачі взагалі не чекають завантаження текстів, бо браузер отримує
304 Not Modifiedза кілька мілісекунд.
Продуктивність інтерфейсу: Візуалізація великих обсягів локалізації
Окремий виклик з’явився на фронтенді. Коли у вас великий проєкт (наприклад,
Спроба відрендерити таку таблицю «в лоб» у Vue 3 призводить до катастрофічного просідання FPS. Кожен інпут має свій реактивний стан, двосторонній байндинг (v-model), обробники подій автозбереження тощо. Браузер просто починає лагати при скролі або введенні літер.
Ми вирішили цю проблему завдяки двом речам:
- Віртуальний скролінг (Virtual Scroll): Ми рендеримо в DOM лише ті рядки таблиці, які зараз видимі на екрані користувача (плюс невеликий буфер зверху та знизу). При прокручуванні елементи просто перевикористовуються, що тримає кількість DOM-нод на стабільно низькому рівні.
- Дебаунс запитів автозбереження (Debounce): Замість того, щоб надсилати запит на бекенд при кожному натисканні клавіші розробником, ми збираємо зміни локально та відправляємо їх із затримкою у
500-800 мс після того, як користувач перестав друкувати.
Висновки
Створення власного інструменту локалізації, який переріс у повноцінний опенсорсний проєкт i18n-saas.com (код доступний на GitHub), навчило нас кільком важливим урокам:
- AI чудово працює з короткими текстами інтерфейсів, але лише тоді, коли ви чітко обмежуєте його поведінку через
systemInstructionта вимагаєте структурований JSON. - Каскадна відмовостійкість (Gemini -> DeepL -> Google) рятує від раптових збоїв та перевищення квот сторонніх API.
- Правильне HTTP-кешування (ETags) набагато ефективніше, ніж просто масштабування серверів чи баз даних. Воно економить трафік, гроші за запити до БД та нерви користувачів.
Якщо ви плануєте розробляти власні системи динамічного контенту чи сервіси доставки перекладів, закладайте підтримку кешування та механізми відмовостійкості сторонніх API з самого першого дня розробки. Це вбереже вас від неочікуваних падінь на продакшені та великих рахунків за хмарні послуги.
Діліться в коментарях: як ви вирішуєте питання локалізації у своїх командах? Чи використовуєте ви динамічне завантаження перекладів «на льоту», чи все ж віддаєте перевагу перезбиранню білду через Git?
2 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарівІдея цікава, але ваша реалізація на Go — це збочення. Впевнений, що вже є готові офіційні бібліотеки для роботи з Gemini та іншими сервісами, аніж ваше самописне рішення.
Якщо будете писати продовження, то зверніться до спільноти GolangUA, щоб вони зробили рев’ю вашого рішення.
Дякую за коментар, код це не константа, тому змінити його можна задля покращення в будь який момент, головне розвивати ідею, і робити це не тільки задля коду, а і для власного використання і досвіду