Зачем нужны словари
Справочные данные часто начинаются как константа: десяток строк, которые кто-то вписал в код при запуске. Константа удобна, пока данные не начинают меняться. Тогда каждое изменение идёт через разработчика и релиз, хотя решение о нём принимает не разработчик.
Словарь в админке разводит два расписания: данные меняются, когда это нужно бизнесу, а код — когда это нужно коду. Но сам по себе перенос в админку ничего не гарантирует. Надёжность словаря определяется тем, как его читает сервис.
Словарь, конфиг и фича-флаг
Все три выносят что-то из кода, но отвечают на разные вопросы.
- Конфиг — как устроено окружение: адреса, таймауты, размеры пулов. Он разный на стейджинге и в проде и меняется вместе с выкладкой.
- Фича-флаг — включено ли поведение. Его жизнь короткая: включили, проверили, удалили вместе со старой веткой кода.
- Словарь — факты о предметной области: коды, соответствия, категории. Он одинаковый во всех окружениях, живёт годами и принадлежит тем, кто за эти факты отвечает.
Если в словарь начинают класть переключатели поведения, это фича-флаг без срока жизни. Если адреса и таймауты — конфиг без выкладки.
Строки не удаляют
Страну, куда перестали возить, хочется просто удалить из словаря. Но на её код ссылаются старые заказы: история покупок, возвраты, отчёты за прошлый год. Удалённая строка делает их код незнакомым, и всё, что сказано в шаге про незнакомое значение, начинает срабатывать на прошлом.
Строку закрывают: полем active или сроком действия valid_from/valid_to.
Оформление заказа берёт только действующие строки, история и отчёты — все, и
закрытые тоже. Заодно закрытый код остаётся занятым, и под ним не появится
другая страна.
Если важно, что значила строка в момент заказа — какая была зона, какая цена, — флага мало: значения в строке переписывают. Тогда либо хранят версии строки со сроками действия, либо копируют нужные поля в сам заказ при оформлении.
Когда словарь не нужен
Если данные меняются вместе с кодом, который их читает, словарь ничего не даст. Новую зону доставки можно добавить в админке строкой, но без кода, который считает для неё цену, она ничего не значит. Выносить стоит то, что код использует как есть, не заводя под каждое значение свою ветку.
И словарь — не база предметной области. Тысячи записей, которые пишет сам сервис, связи между ними и транзакции — это таблицы сервиса, а не справочник в админке.
Шаг 1 из 8
Данные, которые меняются релизом
Покупатель оформляет заказ, и форма присылает страну из адреса:
country: CZ. Сервису оформления из неё нужны два факта: в какую зону доставки попадает страна и можно ли там платить при получении.Оба факта жили в коде. Зоны — в карте на сорок строк, оплата при получении — в отдельном списке, потому что когда-то она работала в двух странах. История файла — сплошь «ship to …»: каждая новая страна у логистики означала тикет, ревью и релиз.
Хуже медленного пути — два места для одного решения. Оплату при получении запустили в Чехии, логистика разослала об этом письмо, страну в зоны давно добавили, а список никто не тронул:
CODAllowed("CZ")отвечает false.checkout/countries.govar shippingZones = map[string]string{ "DE": "eu", "FR": "eu", "PL": "eu", "CZ": "eu", // ещё тридцать стран "NO": "intl", "CH": "intl", "GE": "remote", } // когда-то оплата при получении была в двух странах var codCountries = []string{"DE", "PL"} func CODAllowed(country string) bool { return slices.Contains(codCountries, country) }Вывод
$ git log --oneline -- checkout/countries.go a41c9e0 ship to Georgia 7d2b513 move Norway to the intl zone c90f4aa ship to Czechia 5e61d07 ship to Slovakia 2f7a8c1 ship to Poland cash on delivery launched in Czechia: CODAllowed("CZ") == falseШаг 2 из 8
Словарь — данные со своим владельцем
Словарь — это таблица в админке с описанными полями: у каждого есть тип, обязательность, для перечислений — допустимые значения. Словарь называется по slug, и по нему же сервис его запрашивает.
Страны добавляют те, кто договаривается о доставке, — логистика. Делают они это строкой в админке, без разработчика. Оба факта о стране теперь в одной строке: список
codCountriesпревратился в полеcod, и забыть его, добавляя страну, уже не выйдет — строка видна целиком.Кандидаты в словарь узнаются по трём признакам: данных немного, меняются они редко и по решению бизнеса, а читают их постоянно. Страны доставки, причины отмены заказа, категории товаров, соответствие одних кодов другим.
admin › dictionaries › shipping_countries{ "slug": "shipping_countries", "fields": [ { "key": "country", "type": "string", "required": true, "pattern": "^[A-Z]{2}$" }, { "key": "zone", "type": "enum", "required": true, "values": ["eu", "intl", "remote"] }, { "key": "cod", "type": "bool", "default": false } ], "items": [ { "country": "DE", "zone": "eu", "cod": true }, { "country": "CZ", "zone": "eu", "cod": true }, { "country": "NO", "zone": "intl", "cod": false }, { "country": "GE", "zone": "remote", "cod": false } ] }Шаг 3 из 8
Как словарь лежит в админке
Под словарями в админке три таблицы.
dictionaries— сами словари: slug, название и версия.dictionary_fields— схема: по строке на поле, с типом, обязательностью и допустимыми значениями.dictionary_items— данные: строка словаря целиком лежит вdata, и админка проверяет её по схеме при каждой записи.Схема хранится строками, а не отдельной таблицей на каждый словарь, потому что словари заводят в админке, а не миграцией. Новый словарь — несколько строк в
dictionary_fields, без выкладки.Ключ вынесен из
dataв свою колонку и вместе сdictionary_idобразует первичный ключ. Дубль страны не пройдёт уже на уровне базы. Закрытая строка остаётся в таблице сactive = falseи продолжает держать свой ключ — под ним не появится другая страна.versionрастёт с каждой правкой словаря. По нему сервис может понять, что с прошлого раза ничего не изменилось, и не забирать пачку зря.admin/migrations/001_dictionaries.sqlCREATE TABLE dictionaries ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, slug text NOT NULL UNIQUE, title text NOT NULL, version bigint NOT NULL DEFAULT 1, updated_at timestamptz NOT NULL DEFAULT now() ); -- схема словаря — строки, а не таблица: новый словарь заводят без миграции CREATE TABLE dictionary_fields ( dictionary_id bigint NOT NULL REFERENCES dictionaries (id), key text NOT NULL, type text NOT NULL CHECK (type IN ('int', 'string', 'bool', 'enum')), required boolean NOT NULL DEFAULT false, enum_values text[], default_value jsonb, is_key boolean NOT NULL DEFAULT false, position int NOT NULL, PRIMARY KEY (dictionary_id, key) ); -- ключевое поле у словаря ровно одно CREATE UNIQUE INDEX ON dictionary_fields (dictionary_id) WHERE is_key; CREATE TABLE dictionary_items ( dictionary_id bigint NOT NULL REFERENCES dictionaries (id), key text NOT NULL, data jsonb NOT NULL, active boolean NOT NULL DEFAULT true, updated_at timestamptz NOT NULL DEFAULT now(), updated_by text NOT NULL, -- закрытая строка остаётся и держит ключ: дубль или повторный код не пройдут PRIMARY KEY (dictionary_id, key) );Шаг 4 из 8
Ключ — то, что приходит в запросе
Первое решение о словаре — что в нём ключ. Для людей естественно название страны, оно и в админке смотрится лучше. Но форма присылает код ISO, и искать сервис будет по нему.
И названия меняются. В 2016 году у Чешской Республики появилось краткое название Czechia, в 2022-м ООН стала писать Турцию как Türkiye. Ключ по названию превращает правку текста в админке в пропажу страны из оформления заказа. Название пусть будет полем для людей, ключом — код.
Коды тоже не вечные, просто меняются реже.
CSв ISO 3166 был кодом Чехословакии, а с 2003 по 2006 год — Сербии и Черногории. Поэтому ключ однажды выданной строки не удаляют и не отдают другой: об этом раздел после разбора.Второе решение — проверять ли строку ещё раз, если админка уже проверила её по схеме. Проверять: схема словаря в админке и тип
Zoneв коде — два описания одного и того же, и меняют их по отдельности. В перечисление зон в админке добавятislandsраньше, чем в коде появится цена доставки на острова. Такую строку сервис должен отклонить при загрузке, а не показать покупателю.checkout/countries/countries.goconst Slug = "shipping_countries" var isoCode = regexp.MustCompile(`^[A-Z]{2}$`) type Country struct { // ключ — код ISO из адреса, а не название страны Code string `json:"country"` Zone Zone `json:"zone"` COD bool `json:"cod"` } func (c Country) validate() error { if !isoCode.MatchString(c.Code) { return fmt.Errorf("%w: %q", ErrBadCode, c.Code) } // схему в админке могут расширить раньше кода — зону проверяем ещё раз if !c.Zone.Valid() { return fmt.Errorf("%s: %w: %q", c.Code, ErrBadZone, c.Zone) } return nil }Шаг 5 из 8
Админка не на пути запроса
Самое важное решение: запрос не ходит в админку. Сервис держит копию словаря в памяти, загружает её при старте и обновляет в фоне раз в несколько минут. Оформление заказа читает только копию.
Иначе админка — внутренний инструмент, который никто не проектировал под нагрузку оформления заказов, — становится его зависимостью. Её падение, медленный ответ или выкладка превращались бы в брошенные корзины.
С копией админка может лежать хоть час: обновление пишет предупреждение в лог, сервис отвечает по последней удачной версии. Цена — задержка: новая строка доезжает не сразу, а за интервал обновления. Пять минут против дней на релиз — обмен выгодный, но его стоит назвать логистике вслух.
Отдельный вопрос — старт. Если админка недоступна при запуске, сервис может подняться с пустым словарём и догрузить его позже — тогда до этого момента никуда не доставляет. Или не стартовать без словаря. Выбор зависит от того, что хуже для этих данных.
checkout/countries/store.gofunc (s *Store) Run(ctx context.Context, every time.Duration) { // первая загрузка, дальше — обновление в фоне s.reload(ctx) t := time.NewTicker(every) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: s.reload(ctx) } } } func (s *Store) reload(ctx context.Context) { items, err := dictionary.Fetch[Country](ctx, s.admin, Slug) if err == nil { err = s.replace(items) } if err != nil { slog.Warn("shipping_countries: keeping previous copy", "err", err) s.reloadFailures.Inc() } }Вывод
level=INFO msg="shipping_countries loaded" items=41 level=WARN msg="shipping_countries: keeping previous copy" err="admin: 502 Bad Gateway" level=INFO msg="checkout" orders=312 errors=0
Шаг 6 из 8
Пачка целиком или ничего
Схема и первичный ключ в админке проверяют каждую строку, но не то, что ждёт код. В перечисление зон добавили
islandsдля Фарерских островов, а код про такую зону ещё не знает. Сервис не должен верить словарю на слово.Новая версия собирается в стороне, в отдельной карте. Каждая строка проходит проверку, дубли ключей тоже отлавливаются: база админки их не пустит, но сервис не обязан знать, как она устроена. Если хоть одна строка не прошла, вся пачка отклоняется и в работе остаётся прежняя копия. Частично применённый словарь хуже старого: в нём неизвестно что верно.
Если всё прошло, новая карта встаёт на место старой одной записью указателя. Запросы, читающие словарь в этот момент, видят либо старую карту целиком, либо новую — и без блокировок на чтении.
Счётчик неудачных обновлений — повод для алерта. Копия в памяти защищает покупателей, но не скажет логистике, что её правка не применилась.
checkout/countries/store.gotype Store struct { current atomic.Pointer[map[string]Country] // ... } // пачка целиком или ничего: при ошибке словарь не меняется func (s *Store) replace(items []Country) error { next := make(map[string]Country, len(items)) for _, c := range items { if err := c.validate(); err != nil { return err } if _, dup := next[c.Code]; dup { return fmt.Errorf("%w: %s", ErrDuplicate, c.Code) } next[c.Code] = c } // одна запись указателя: читатели видят старую карту или новую, но не половину s.current.Store(&next) return nil }Вывод
level=WARN msg="shipping_countries: keeping previous copy" err="FO: unknown zone: \"islands\""
Шаг 7 из 8
Незнакомое значение
Покупатель выбрал Косово —
XK. Этого кода нет в стандарте ISO, но его используют многие системы, и в словаре его пока нет. Что делает оформление заказа?Ответ зависит от поля, и здесь их два с разными ответами. Без зоны нет цены доставки — показывать нечего, и страну честнее не предлагать, чем угадывать. А оплата при получении для строки без отметки просто выключена: нулевое значение false здесь и есть безопасный вариант.
Поэтому
Lookupвозвращает второй результат — нашлось или нет, — а не молча отдаёт пустую структуру: решение о незнакомом ключе принимает тот, кто знает, чем оно обойдётся. Незнакомые коды стоит считать метрикой — это и есть список строк, которых не хватает в словаре.Заметьте, что варианты доставки для каждой зоны остались в коде,
zoneOptions. Новая зона — это новая логика, и её одной строкой в админке не добавить.checkout/delivery.gofunc (s *Store) Lookup(code string) (Country, bool) { m := s.current.Load() if m == nil { return Country{}, false } c, ok := (*m)[code] return c, ok } func (s *Service) DeliveryOptions(country string) ([]Option, error) { c, ok := s.countries.Lookup(country) if !ok { s.unknownCountries.WithLabelValues(country).Inc() // нет зоны — нет цены доставки: страну не предлагаем return nil, ErrNotShippable } opts := zoneOptions[c.Zone] // нет отметки — нет оплаты при получении if c.COD { opts = append(opts, CashOnDelivery) } return opts, nil }Шаг 8 из 8
Что общее, а что у каждого своё
Когда словарь в сервисе первый, хочется сразу сделать общий кеш для всех будущих: с ключом и проверкой параметрами, с методом «отдай всё», с отметкой времени и своим набором ошибок. Сотни строк — ради одного словаря.
Общей стоит сделать только доставку: достать пачку по slug и разобрать её в нужный тип. Остальное — что ключ, как проверять, что отвечать на незнакомое значение — у каждого словаря своё. Страны отказывают на незнакомый код, словарь причин отмены на незнакомую причину может показать «другое». Этот смысл живёт рядом с тем, кто словарь читает: в пакете оформления заказа.
Обобщать кеш стоит, когда появится второй словарь и станет видно, что у них действительно общее.
dictionary/fetch.go// общая часть — только доставка: достать пачку по slug и разобрать в T func Fetch[T any](ctx context.Context, admin *Client, slug string) ([]T, error) { url := admin.base + "/dictionaries/" + slug + "/items" req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } resp, err := admin.http.Do(req) if err != nil { return nil, fmt.Errorf("%s: %w", slug, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("%s: admin: %s", slug, resp.Status) } var body struct { Items []T `json:"items"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return nil, fmt.Errorf("%s: decode: %w", slug, err) } return body.Items, nil }
Листать шаги можно стрелками ← и →.
Проверь себя
Какой признак говорит, что данные пора выносить из кода в словарь?
Словарь нужен, когда расписание изменений данных расходится с расписанием релизов. Объём и число читателей тут вторичны.
Админка недоступна десять минут. Что происходит с оформлением заказов, если словарь сделан, как в уроке?
Запрос читает копию в памяти, админки на его пути нет. Неудачное обновление пишет предупреждение в лог и оставляет прежнюю копию.
В словаре из сорока стран у одной указана зона, которой код не знает. Что делает сервис при обновлении?
Частично применённый словарь хуже прежнего: неизвестно, какие строки в нём верны. Ошибка уходит в лог, работает последняя проверенная версия.
Почему ключом словаря выбран код страны, а не её название?
Искать будут по тому, что прислала форма, — по коду. А название — текст для людей: его переименуют, и ключ по нему потеряет страну.
Отвечено 0 из 4