Зачем нужны словари

ПроблемаТаблица «страна → зона доставки» живёт константой в коде, а список стран с оплатой при получении — в соседней. Каждая новая страна — тикет, ревью и релиз, и второй список при этом забывают поправить. Данные меняются по расписанию бизнеса, а доставляются по расписанию релизов.

Справочные данные часто начинаются как константа: десяток строк, которые кто-то вписал в код при запуске. Константа удобна, пока данные не начинают меняться. Тогда каждое изменение идёт через разработчика и релиз, хотя решение о нём принимает не разработчик.

Словарь в админке разводит два расписания: данные меняются, когда это нужно бизнесу, а код — когда это нужно коду. Но сам по себе перенос в админку ничего не гарантирует. Надёжность словаря определяется тем, как его читает сервис.

Словарь, конфиг и фича-флаг

Все три выносят что-то из кода, но отвечают на разные вопросы.

  • Конфиг — как устроено окружение: адреса, таймауты, размеры пулов. Он разный на стейджинге и в проде и меняется вместе с выкладкой.
  • Фича-флаг — включено ли поведение. Его жизнь короткая: включили, проверили, удалили вместе со старой веткой кода.
  • Словарь — факты о предметной области: коды, соответствия, категории. Он одинаковый во всех окружениях, живёт годами и принадлежит тем, кто за эти факты отвечает.

Если в словарь начинают класть переключатели поведения, это фича-флаг без срока жизни. Если адреса и таймауты — конфиг без выкладки.

Строки не удаляют

Страну, куда перестали возить, хочется просто удалить из словаря. Но на её код ссылаются старые заказы: история покупок, возвраты, отчёты за прошлый год. Удалённая строка делает их код незнакомым, и всё, что сказано в шаге про незнакомое значение, начинает срабатывать на прошлом.

Строку закрывают: полем active или сроком действия valid_from/valid_to. Оформление заказа берёт только действующие строки, история и отчёты — все, и закрытые тоже. Заодно закрытый код остаётся занятым, и под ним не появится другая страна.

Если важно, что значила строка в момент заказа — какая была зона, какая цена, — флага мало: значения в строке переписывают. Тогда либо хранят версии строки со сроками действия, либо копируют нужные поля в сам заказ при оформлении.

Когда словарь не нужен

Если данные меняются вместе с кодом, который их читает, словарь ничего не даст. Новую зону доставки можно добавить в админке строкой, но без кода, который считает для неё цену, она ничего не значит. Выносить стоит то, что код использует как есть, не заводя под каждое значение свою ветку.

И словарь — не база предметной области. Тысячи записей, которые пишет сам сервис, связи между ними и транзакции — это таблицы сервиса, а не справочник в админке.

  1. Шаг 1 из 8

    Данные, которые меняются релизом

    Покупатель оформляет заказ, и форма присылает страну из адреса: country: CZ. Сервису оформления из неё нужны два факта: в какую зону доставки попадает страна и можно ли там платить при получении.

    Оба факта жили в коде. Зоны — в карте на сорок строк, оплата при получении — в отдельном списке, потому что когда-то она работала в двух странах. История файла — сплошь «ship to …»: каждая новая страна у логистики означала тикет, ревью и релиз.

    Хуже медленного пути — два места для одного решения. Оплату при получении запустили в Чехии, логистика разослала об этом письмо, страну в зоны давно добавили, а список никто не тронул: CODAllowed("CZ") отвечает false.

    checkout/countries.go
    var 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. Шаг 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. Шаг 3 из 8

    Как словарь лежит в админке

    Под словарями в админке три таблицы. dictionaries — сами словари: slug, название и версия. dictionary_fields — схема: по строке на поле, с типом, обязательностью и допустимыми значениями. dictionary_items — данные: строка словаря целиком лежит в data, и админка проверяет её по схеме при каждой записи.

    Схема хранится строками, а не отдельной таблицей на каждый словарь, потому что словари заводят в админке, а не миграцией. Новый словарь — несколько строк в dictionary_fields, без выкладки.

    Ключ вынесен из data в свою колонку и вместе с dictionary_id образует первичный ключ. Дубль страны не пройдёт уже на уровне базы. Закрытая строка остаётся в таблице с active = false и продолжает держать свой ключ — под ним не появится другая страна.

    version растёт с каждой правкой словаря. По нему сервис может понять, что с прошлого раза ничего не изменилось, и не забирать пачку зря.

    admin/migrations/001_dictionaries.sql
    CREATE 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. Шаг 4 из 8

    Ключ — то, что приходит в запросе

    Первое решение о словаре — что в нём ключ. Для людей естественно название страны, оно и в админке смотрится лучше. Но форма присылает код ISO, и искать сервис будет по нему.

    И названия меняются. В 2016 году у Чешской Республики появилось краткое название Czechia, в 2022-м ООН стала писать Турцию как Türkiye. Ключ по названию превращает правку текста в админке в пропажу страны из оформления заказа. Название пусть будет полем для людей, ключом — код.

    Коды тоже не вечные, просто меняются реже. CS в ISO 3166 был кодом Чехословакии, а с 2003 по 2006 год — Сербии и Черногории. Поэтому ключ однажды выданной строки не удаляют и не отдают другой: об этом раздел после разбора.

    Второе решение — проверять ли строку ещё раз, если админка уже проверила её по схеме. Проверять: схема словаря в админке и тип Zone в коде — два описания одного и того же, и меняют их по отдельности. В перечисление зон в админке добавят islands раньше, чем в коде появится цена доставки на острова. Такую строку сервис должен отклонить при загрузке, а не показать покупателю.

    checkout/countries/countries.go
    const 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. Шаг 5 из 8

    Админка не на пути запроса

    Самое важное решение: запрос не ходит в админку. Сервис держит копию словаря в памяти, загружает её при старте и обновляет в фоне раз в несколько минут. Оформление заказа читает только копию.

    Иначе админка — внутренний инструмент, который никто не проектировал под нагрузку оформления заказов, — становится его зависимостью. Её падение, медленный ответ или выкладка превращались бы в брошенные корзины.

    С копией админка может лежать хоть час: обновление пишет предупреждение в лог, сервис отвечает по последней удачной версии. Цена — задержка: новая строка доезжает не сразу, а за интервал обновления. Пять минут против дней на релиз — обмен выгодный, но его стоит назвать логистике вслух.

    Отдельный вопрос — старт. Если админка недоступна при запуске, сервис может подняться с пустым словарём и догрузить его позже — тогда до этого момента никуда не доставляет. Или не стартовать без словаря. Выбор зависит от того, что хуже для этих данных.

    checkout/countries/store.go
    func (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. Шаг 6 из 8

    Пачка целиком или ничего

    Схема и первичный ключ в админке проверяют каждую строку, но не то, что ждёт код. В перечисление зон добавили islands для Фарерских островов, а код про такую зону ещё не знает. Сервис не должен верить словарю на слово.

    Новая версия собирается в стороне, в отдельной карте. Каждая строка проходит проверку, дубли ключей тоже отлавливаются: база админки их не пустит, но сервис не обязан знать, как она устроена. Если хоть одна строка не прошла, вся пачка отклоняется и в работе остаётся прежняя копия. Частично применённый словарь хуже старого: в нём неизвестно что верно.

    Если всё прошло, новая карта встаёт на место старой одной записью указателя. Запросы, читающие словарь в этот момент, видят либо старую карту целиком, либо новую — и без блокировок на чтении.

    Счётчик неудачных обновлений — повод для алерта. Копия в памяти защищает покупателей, но не скажет логистике, что её правка не применилась.

    checkout/countries/store.go
    type 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. Шаг 7 из 8

    Незнакомое значение

    Покупатель выбрал Косово — XK. Этого кода нет в стандарте ISO, но его используют многие системы, и в словаре его пока нет. Что делает оформление заказа?

    Ответ зависит от поля, и здесь их два с разными ответами. Без зоны нет цены доставки — показывать нечего, и страну честнее не предлагать, чем угадывать. А оплата при получении для строки без отметки просто выключена: нулевое значение false здесь и есть безопасный вариант.

    Поэтому Lookup возвращает второй результат — нашлось или нет, — а не молча отдаёт пустую структуру: решение о незнакомом ключе принимает тот, кто знает, чем оно обойдётся. Незнакомые коды стоит считать метрикой — это и есть список строк, которых не хватает в словаре.

    Заметьте, что варианты доставки для каждой зоны остались в коде, zoneOptions. Новая зона — это новая логика, и её одной строкой в админке не добавить.

    checkout/delivery.go
    func (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 из 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