Маленькое потокобезопасное i18n хранилище ключ–значение с горячей сменой языка и простой моделью событий.
language-wizard — минималистичный помощник для приложений, которым нужна простая словарная i18n. Хранит текущий
ISO-код языка и словарь строк перевода в памяти, позволяет атомарно переключать активный язык и предоставляет
небольшой механизм событий для фоновых горутин, которые должны реагировать на смену языка или закрытие объекта.
Внутреннее состояние защищено sync.RWMutex для конкурентного доступа.
stateDiagram-v2
[*] --> Active: New(isoLang, words)\n✓ возвращает *LanguageWizardObj
Active --> Active: SetLanguage(lang, words)\n• заменяет словарь\n• закрывает changedCh\n• создаёт новый changedCh\n• уведомляет всех ожидающих
Active --> Closed: Close()\n• closed.Store(true)\n• close(changedCh)\n• очищает словарь
Closed --> Closed: Close()\nидемпотентно — no-op
Active --> Active: Get / CurrentLanguage / Words\nтолько чтение, состояние не меняют
Closed --> Closed: Get / CurrentLanguage / Words\nработают, но словарь пуст
note right of Closed
SetLanguage → ErrClosed
Wait → EventClose (немедленно)
end note
- Простой словарь ключ–значение для переводов.
- Горячая смена языка с атомарной заменой словаря.
- Потокобезопасное чтение/запись через RWMutex.
- Защитное копирование при передаче словаря вызывающему коду.
- Блокирующее ожидание смены языка или закрытия через модель событий.
- Подключаемый логгер для отсутствующих ключей.
go get github.com/voluminor/language_wizardИли скопируйте пакет language_wizard в дерево исходников вашего проекта.
package main
import (
"fmt"
"log"
"github.com/voluminor/language_wizard"
)
func main() {
obj, err := language_wizard.New("en", map[string]string{
"hi": "Hello",
})
if err != nil {
log.Fatal(err)
}
// Поиск с дефолтным значением
fmt.Println(obj.Get("hi", "DEF")) // "Hello"
fmt.Println(obj.Get("bye", "Bye")) // "Bye" (и логирует "undef: bye")
// Опционально: подключить логгер для пропущенных ключей
obj.SetLog(func(s string) {
log.Printf("language-wizard: %s", s)
})
// Смена языка во время работы
_ = obj.SetLanguage("de", map[string]string{
"hi": "Hallo",
})
fmt.Println(obj.CurrentLanguage()) // "de"
fmt.Println(obj.Get("hi", "DEF")) // "Hallo"
}New проверяет, что ISO-код языка не пустой, а словарь не равен nil и не пустой. Переданный словарь
защитно копируется.
Get возвращает дефолтное значение, если ключ пустой или отсутствует, и логирует неизвестные ключи через
настроенный логгер.
obj, err := language_wizard.New(isoLanguage string, words map[string]string)- Возвращает
ErrNilIsoLang, еслиisoLanguageпустой. - Возвращает
ErrNilWords, еслиwordsравенnilили пустой. - При успехе сохраняет код языка и копию
words, инициализирует внутренний канал событий и устанавливает логгер-заглушку (no-op).
lang := obj.CurrentLanguage() // возвращает текущий ISO-код
m := obj.Words() // возвращает КОПИЮ словаря
v := obj.Get(id, def) // возвращает def, если ключ пустой или отсутствуетCurrentLanguageиWordsберут read-блокировку;Wordsвозвращает защитную копию, чтобы внешние изменения не затронули внутреннее состояние.Getлогирует промахи в формате"undef: <id>"через настроенный логгер и возвращает переданное дефолтное значение.
flowchart TD
A([Get(id, def)]) --> B{id == ""?}
B -- да --> Z1([вернуть def])
B -- нет --> C[RLock]
C --> D[val, ok = words[id]\nlogFn = obj.log]
D --> E[RUnlock]
E --> F{ok?}
F -- да --> Z2([вернуть val])
F -- нет --> G[logFn("undef: " + id)]
G --> Z3([вернуть def])
logFnснимается под тем жеRLock, что и поиск по словарю, поэтому конкурентные вызовыSetLogне могут создать гонку данных.
err := obj.SetLanguage(isoLanguage string, words map[string]string)- Валидирует входные данные как в
New; возвращаетErrNilIsoLang/ErrNilWordsпри невалидных значениях. - Возвращает
ErrClosed, если объект был закрыт. - Возвращает
ErrLangAlreadySet, еслиisoLanguageсовпадает с текущим. - При успехе атомарно заменяет язык и копию переданного словаря, закрывает внутренний канал событий для уведомления ожидающих горутин, затем создаёт новый канал для будущих ожиданий.
type EventType byte
const (
EventClose EventType = 0
EventLanguageChanged EventType = 4
)
ev := obj.Wait() // блокирует до смены языка или закрытия объекта
ok := obj.WaitUntilClosed() // true, если объект был закрытWaitснимает снимок текущего канала под короткимRLock, блокируется на нём, затем атомарно проверяет флагclosed, чтобы вернутьEventCloseилиEventLanguageChanged.WaitUntilClosed— удобная обёртка, возвращающаяtrue, если получено событие закрытия.
sequenceDiagram
participant C as Вызывающий код
participant W as LanguageWizardObj
participant G1 as Горутина A (Wait)
participant G2 as Горутина B (Wait)
G1 ->> W: Wait()
W -->> G1: RLock → снимок ch₀ → RUnlock
G1 ->> G1: блокировка на ← ch₀
G2 ->> W: Wait()
W -->> G2: RLock → снимок ch₀ → RUnlock
G2 ->> G2: блокировка на ← ch₀
C ->> W: SetLanguage("de", words)
W ->> W: Lock
W ->> W: валидация + обновление currentLanguage + cloneWords
W ->> W: close(ch₀) ← разблокирует G1 и G2
W ->> W: ch₁ = make(chan struct{})
W ->> W: Unlock
W -->> G1: ← ch₀ сработал → closed.Load()=false → EventLanguageChanged
W -->> G2: ← ch₀ сработал → closed.Load()=false → EventLanguageChanged
Note over G1, G2: Обе горутины снова вызывают Wait(),\nна этот раз блокируясь на новом ch₁
sequenceDiagram
participant C as Вызывающий код
participant W as LanguageWizardObj
participant G1 as Горутина A (Wait)
participant G2 as Горутина B (Wait)
G1 ->> W: Wait()
W -->> G1: снимок ch₀ → блокировка на ← ch₀
G2 ->> W: WaitChan()
W -->> G2: RLock → снимок ch₀ → RUnlock
G2 ->> G2: блокировка на ← ch₀ (в select)
C ->> W: Close()
W ->> W: Lock
W ->> W: closed.Store(true)
W ->> W: close(ch₀) ← разблокирует G1 и G2
W ->> W: words = пустой map
W ->> W: Unlock
Note right of W: ch₀ остаётся закрытым навсегда,\nновый канал не создаётся
W -->> G1: ← ch₀ сработал → closed.Load()=true → EventClose
W -->> G2: ← ch₀ сработал → IsClosed()=true → обработать закрытие
Note over G1, G2: Любой последующий Wait() / ← WaitChan()\nвернётся немедленно (закрытый канал)
Типичный цикл:
go func () {
for {
switch obj.Wait() {
case language_wizard.EventLanguageChanged:
// Пересобрать кэши / обновить UI здесь.
case language_wizard.EventClose:
// Очистить ресурсы и выйти.
return
}
}
}()Цикл с контекстом:
go func () {
for {
select {
case <-ctx.Done():
return
case <-obj.WaitChan():
if obj.IsClosed() {
// Очистить ресурсы и выйти.
return
}
// Пересобрать кэши / обновить UI здесь.
}
}
}()Каждая итерация вызывает
obj.WaitChan(), получая свежий снимок текущего канала — цикл корректно переключается на новый канал после каждогоSetLanguage.
obj.SetLog(func (msg string) { /* ... */ })- Устанавливает пользовательский логгер для промахов при поиске ключа. Передача
nilсбрасывает логгер обратно на встроенную заглушку (no-op). Логгер сохраняется под write-блокировкой. - Логгер вызывается только в
Get(при промахе).
obj.Close()- Идемпотентно. Устанавливает флаг
closed, закрывает канал событий (разблокируя всеWait), и очищает словарь до пустого map. Последующие вызовыSetLanguageвернутErrClosed.
Экспортированные ошибки:
ErrNilIsoLang— ISO-код языка обязателен вNew/SetLanguage.ErrNilWords—wordsдолжен быть не-nil и не пустым вNew/SetLanguage.ErrLangAlreadySet— попытка установить тот же язык, что уже активен.ErrClosed— объект закрыт; обновления недопустимы.
graph LR
subgraph Readers ["Read lock (RLock) — конкурентный доступ"]
CL["CurrentLanguage()"]
WO["Words()"]
GE["Get()"]
WC["WaitChan()"]
WA["Wait() — только снимок канала"]
end
subgraph Writers ["Write lock (Lock) — эксклюзивный доступ"]
SL["SetLanguage()"]
SLG["SetLog()"]
CL2["Close()"]
end
subgraph Atomic ["Без блокировки — атомарная операция"]
IC["IsClosed()"]
WA2["Wait() — проверка флага closed"]
end
MX["sync.RWMutex"] --> Readers
MX --> Writers
AB["atomic.Bool (closed)"] --> Atomic
Ключевые гарантии:
SetLanguageзакрывает текущий канал событий для уведомления всех ожидающих, затем сразу заменяет его новым каналом — последующиеWaitбудут блокироваться до следующего события.WaitиWaitChanснимают снимок канала под минимальнымRLock— блокировка снимается до начала ожидания, поэтому ожидающие горутины никогда не конкурируют с писателями.- Флаг
closed— этоatomic.Bool: чтение (IsClosed, проверка после разблокировки вWait) не требует никакой блокировки. Getснимает под однимRLockкак значение из словаря, так и указатель на функциюlog— это предотвращает гонку с конкурентными вызовамиSetLog.
func greet(obj *language_wizard.LanguageWizardObj) string {
return obj.Get("hi", "Hello")
}Защищает от отсутствующих ключей, при этом выводя их в лог через логгер.
func watch(obj *language_wizard.LanguageWizardObj) {
for {
switch obj.Wait() {
case language_wizard.EventLanguageChanged:
// например, прогреть шаблоны или инвалидировать кэши
case language_wizard.EventClose:
return
}
}
}Запускайте из горутины, чтобы держать вспомогательное состояние в синхронизации с активным языком.
_ = obj.SetLanguage("fr", map[string]string{"hi": "Bonjour"})Все текущие ожидающие горутины получат уведомление; последующие ожидания переключатся на новый канал.
obj.SetLog(func (s string) {
// s выглядит так: "undef: some.missing.key"
})Удобно для сбора телеметрии по пропущенным переводам. Передайте nil, чтобы сбросить логгер обратно на заглушку.
Запустите тесты с детектором гонок:
go test -race ./...Что покрывается тестами:
- Успешное создание и базовый поиск по ключам.
- Семантика защитного копирования для
Words(). - Возврат дефолтного значения в
Getи логирование промахов. SetLog(nil)сбрасывает логгер на заглушку без паники.- Валидация и обработка ошибок в
New/SetLanguage. - Смена языка и обновление текущего языка.
- Обработка событий:
Wait,WaitUntilClosedи поведение при закрытии. Closeочищает словарь и блокирует дальнейшие обновления.
В: Почему Wait иногда возвращается немедленно при повторном вызове?
Потому что SetLanguage и Close закрывают текущий канал событий; если вызвать Wait снова без последующего
SetLanguage, вы всё ещё можете наблюдать уже закрытый канал. Реализация заменяет канал после закрытия;
вызывайте Wait в цикле и воспринимайте каждый возврат как единственное событие.
В: Можно ли изменять map, возвращённый Words()?
Да, это копия. Её изменение не затронет внутреннее состояние. Используйте SetLanguage для замены внутреннего
словаря.
В: Что происходит после Close()?
Wait разблокируется с EventClose, словарь очищается, а SetLanguage возвращает ErrClosed. Методы чтения
продолжают работать, но словарь пуст, если вы не сохранили внешнюю копию.
После вызова Close() методы чтения (Get, CurrentLanguage, Words) остаются полностью работоспособными и
не возвращают ошибок и не вызывают панику. Однако Close() очищает внутренний словарь до пустого map, поэтому:
Get(id, def)будет всегда возвращатьdefдля любого ключа и логировать"undef: <id>"при каждом вызове.CurrentLanguage()по-прежнему вернёт последний код языка, установленный до закрытия, даже если объект больше не пригоден для обновлений.Words()вернёт пустой map.
Это означает, что по возвращаемому значению Get невозможно отличить "ключ действительно отсутствует в текущем
переводе" от "объект был закрыт". Если ваш код должен обнаруживать закрытие, явно проверяйте IsClosed():
if obj.IsClosed() {
// обработать закрытое состояние
return
}
val := obj.Get("greeting", "Hello")После вызова Close() внутренний канал событий закрывается навсегда и никогда не заменяется. Это имеет
следующие последствия:
- Первый вызов
Wait(), заблокированный в моментClose(), корректно разблокируется и вернётEventClose. - Любые последующие вызовы
Wait()послеClose()также вернутEventCloseнемедленно (чтение из закрытого канала в Go возвращает нулевое значение без блокировки). - Если ваш код вызывает
Wait()в цикле, он будет крутиться вхолостую бесконечно после закрытия, если явно не проверятьEventCloseи не выходить:
for {
switch obj.Wait() {
case language_wizard.EventLanguageChanged:
// обработать смену языка
case language_wizard.EventClose:
return // ВАЖНО: здесь необходимо выйти из цикла
}
}Без return (или break) на EventClose цикл превращается в активное ожидание, потребляющее 100% ядра
процессора, так как Wait() больше никогда не блокируется после закрытия объекта.
- Только словарная i18n: нет правил ICU/plural, интерполяции или цепочек fallback — намеренно минималистично.
Wait()не принимает параметр таймаута; используйтеWaitChan()сselectиctx.Done()для отменяемых ожиданий.- Сравнение языков строковое;
SetLanguage("en", …)при уже активном"en"вернётErrLangAlreadySet.