Перейти к содержимому
Разработка9 мин чтения

dynamic в Go: как безопасно превращать нестрогие данные в строгие структуры

Как пакет dynamic для Go помогает работать с нестрого типизированными JSON, XML, YAML, TOML и CSV, автоматически преобразовывать значения и при этом не скрывать ошибки конвертации.

#Go #разработка #backend #API #интеграции #высокая нагрузка #поддерживаемость #качество кода

В идеальном мире API всегда возвращают данные строго в соответствии с документацией: число приходит числом, boolean — boolean, массив — массивом, дата всегда имеет один формат. На практике интеграции работают совсем иначе.

Поле age может сегодня прийти как 33, завтра как "33", а старый поставщик вообще способен отправить 33.0. Boolean превращается в 1, "yes" или "on", а список приходит одной строкой a,b,c. Особенно часто это встречается при интеграции с внешними API, импортом данных, legacy-системами, конфигурационными файлами и сервисами, которые развивались годами.

Для Go это создаёт неприятную проблему: язык специально ориентирован на строгую типизацию, а входные данные очень часто ей не соответствуют.

Именно для этого я сделал пакет github.com/pkg-ru/dynamic — набор типизированных обёрток, которые позволяют принимать несколько представлений одного значения, но после декодирования получать обычные нативные типы Go. При этом некорректная или потенциально опасная конвертация не скрывается: пакет возвращает *ConversionError.

Проблема нестрогих данных

Представим типичную структуру:

type User struct {
    Name   string
    Age    int64
    Active bool
    Tags   []string
}

С точки зрения Go всё выглядит идеально. Но внешний сервис может вернуть:

{
    "name": "Ann",
    "age": "33",
    "active": 1,
    "tags": "developer,backend,go"
}

Обычная модель с нативными типами уже сталкивается с несовпадением представлений данных.

Можно начать вручную обрабатывать каждое поле:

age := parseInt(data["age"])
active := parseBool(data["active"])
tags := strings.Split(data["tags"].(string), ",")

Но такой код быстро превращается в отдельный слой преобразований, который приходится поддерживать вместе с бизнес-логикой.

Проблема становится ещё заметнее, когда вариантов входных данных много:

42
"42"
42.0

или:

true
1
"true"
"yes"
"on"

Вместо того чтобы размазывать эту логику по проекту, её можно централизовать в типах dynamic.

Что делает dynamic

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

Например:

type User struct {
    Name   dynamic.String
    Age    dynamic.Int64
    Active dynamic.Bool
    Tags   dynamic.StringSlice
}

Теперь одна и та же структура может принять данные:

{
    "name": "Ann",
    "age": "33",
    "active": 1,
    "tags": "a,b,c"
}

и получить:

Ann
33
true
[a b c]

При этом после декодирования вы работаете уже с нормализованными значениями:

fmt.Println(
    u.Name.Unwrap(),
    u.Age.Unwrap(),
    u.Active.Unwrap(),
    u.Tags.Unwrap(),
)

Такой подход важен именно архитектурно: граница между внешними грязными данными и внутренней строго типизированной моделью становится явной.

Главное преимущество — гибкость без отказа от строгой проверки

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

Конвертация должна быть безопасной.

Например, dynamic.Int64 принимает целые числа, числовые строки, целочисленные дробные значения и некоторые другие допустимые представления. Но если передать:

{
    "age": "abc"
}

декодирование завершится ошибкой:

*dynamic.ConversionError

Это принципиальное отличие от подходов, где некорректное значение может превратиться в ноль и незаметно попасть дальше в систему.

Пакет также не допускает опасное усечение дробной части:

42.9 → int64

не превращается молча в 42.

Для таких случаев существуют отдельные ошибки, которые можно проверять через errors.Is, например ErrInvalidInteger, ErrInvalidFloat, ErrNegativeUnsigned и другие.

Получается полезный компромисс:

входные данные → гибкая конвертация → строгий тип Go

а не:

входные данные → map[string]any → ручные проверки в каждом месте

Один подход для разных форматов

Ещё одна проблема интеграционных систем — разные форматы данных.

Один поставщик использует JSON:

{
    "name": "Ann",
    "age": "33"
}

другой — XML:

<User>
    <Name>Ann</Name>
    <Age>33</Age>
</User>

третий — YAML:

name: Ann
age: '33'

При этом бизнес-смысл структуры один и тот же.

dynamic переносит правила преобразования на сами типы-обёртки, поэтому одна модель может использоваться при декодировании разных форматов.

Для JSON:

err := dynamic.UnmarshalJSON(data, &u)

Для XML:

err := dynamic.UnmarshalXML(data, &u)

Для YAML существует дополнительный пакет:

import "github.com/pkg-ru/dynamic/yamlx"

err := yamlx.Unmarshal(data, &u)

Для TOML используются стандартные интерфейсы encoding.TextUnmarshaler и encoding.TextMarshaler, поэтому пакет можно использовать вместе с совместимыми TOML-декодерами.

Основной пакет при этом не тянет сторонние зависимости: дополнительные зависимости вынесены в адаптеры форматов.

Не только строки и числа

Самая интересная часть dynamic — это не только String, Int64 или Bool.

В пакете есть специализированные типы для данных, которые особенно часто имеют разное представление.

Например:

type Payment struct {
    Amount  dynamic.Decimal
    Counter dynamic.BigInt
}

Decimal предназначен для точных десятичных значений без ошибки плавающей точки, а BigInt позволяет работать с целыми числами произвольного размера.

Есть и специализированные типы для времени:

type Event struct {
    Day dynamic.Date
}

Дата может быть представлена, например:

2024-01-15
15.01.2024
15 января 2024
январь 2024
2024

Причём поддерживаются русские формы месяцев и возможность регистрировать собственные шаблоны дат.

Для длительностей есть dynamic.Duration, который умеет разбирать ISO 8601, Go-форматы, русские и английские текстовые представления:

PT1H30M
1h30m
90
2 часа 30 минут
2 hours 30 minutes

и даже интервалы вида:

2024-01-01/2024-01-02

Это особенно полезно в интеграциях, где формат даты или продолжительности определялся не единым стандартом, а историей конкретной системы.

Парсинг GET-параметров и других входных значений

Необязательно получать нестрогие данные только через JSON или XML. В веб-приложениях один из самых частых источников таких значений — query-параметры HTTP-запроса.

Например:

GET /users?active=yes&date=15.01.2024&limit=100

В стандартном net/http все значения query-параметров представлены строками:

active := r.URL.Query().Get("active")
date := r.URL.Query().Get("date")
limit := r.URL.Query().Get("limit")

Дальше разработчику приходится самостоятельно преобразовывать каждое значение:

active, err := strconv.ParseBool(active)
limit, err := strconv.ParseInt(limit, 10, 64)
date, err := time.Parse("02.01.2006", date)

А если приложение принимает несколько форматов, код быстро начинает разрастаться.

У dynamic для этого есть низкоуровневые функции-конструкторы и парсеры. Например:

active := dynamic.NewBool(r.URL.Query().Get("active"))
date := dynamic.NewDateFromAny(r.URL.Query().Get("date"))
duration := dynamic.NewDurationFromAny(r.URL.Query().Get("duration"))

NewBool принимает произвольное значение и использует те же правила разбора, что и ParseBool: true, false, 1, 0, yes, no, on, off и другие поддерживаемые формы.

Для дат можно использовать dynamic.Date. Он умеет разбирать несколько представлений, включая 2024-01-15, 15.01.2024, русские названия месяцев и другие зарегистрированные форматы. NewDate создаёт дату из компонентов, а NewDateFromAny делегирует разбор произвольного значения ParseDate.

Например, HTTP-обработчик можно построить так:

func usersHandler(w http.ResponseWriter, r *http.Request) {
    query := r.URL.Query()

    active := dynamic.NewBool(query.Get("active"))
    date := dynamic.NewDateFromAny(query.Get("date"))

    // Дальше приложение работает уже с типизированными значениями.
    isActive := active.Unwrap()
    day := date.Unwrap()

    // ...
    _ = isActive
    _ = day
}

На практике это особенно удобно для фильтров, сортировки и пагинации:

GET /orders?
    active=yes
    &from=01.08.2026
    &to=31.08.2026
    &limit=100
    &offset=200

Вместо отдельной ручной логики преобразования каждого параметра можно использовать единый набор правил dynamic.

Парсинг параметров без привязки к конкретному типу источника

Главное преимущество таких конструкторов — они не требуют, чтобы значение изначально было строкой.

Например:

dynamic.NewBool(true)
dynamic.NewBool(1)
dynamic.NewBool("yes")

dynamic.NewDate(time.Now())
dynamic.NewDateFromAny("15.01.2026")

Это делает те же типы пригодными не только для HTTP query-параметров, но и для параметров CLI, переменных окружения, конфигурации, значений из базы данных и других источников.

Получается единый слой нормализации:

HTTP query
CLI
ENV
JSON
XML
YAML
TOML
CSV

    dynamic

строгие типы Go

Это особенно полезно в сервисах, где одно и то же бизнес-значение может поступать из нескольких источников.

Где это особенно удобно

Для HTTP API dynamic хорошо подходит для:

  • фильтров ?active=yes;
  • диапазонов дат ?from=01.08.2026&to=31.08.2026;
  • длительностей и таймаутов ?timeout=1m30s;
  • числовых параметров, которые могут приходить в разных представлениях;
  • legacy API, где boolean передаётся как 0/1;
  • административных API, где даты вводятся пользователями в разных форматах.

При этом стоит различать два режима работы.

Для декодирования структур через dynamic.UnmarshalJSON, dynamic.UnmarshalXML и другие декодеры преобразование выполняется строго: невозможная или приводящая к потере данных конвертация возвращает ConversionError.

Функции Parse* и New* предназначены прежде всего как удобный слой нормализации произвольного входного значения. Например, ParseBool при невозможности разбора возвращает false, поэтому при обработке недоверенных HTTP-параметров важно учитывать эту семантику и отдельно валидировать наличие/корректность значения, когда это требуется бизнес-логикой.

Когда такой пакет особенно полезен

Интеграции с внешними API

Это, пожалуй, самый очевидный сценарий.

Вы не контролируете формат ответа стороннего сервиса. Сегодня API отправляет:

{
    "enabled": true
}

а завтра после очередного обновления начинает отдавать:

{
    "enabled": 1
}

Если изменение считается допустимым на уровне вашего домена, dynamic.Bool позволяет принять оба варианта без появления дополнительного кода в бизнес-логике.

При этом действительно некорректные значения не будут молча проглочены.

Legacy-системы

Особенно неприятны системы, которые формировались десятилетиями.

В одной таблице значение может храниться как:

0 / 1

в API оно превращается в:

false / true

а в выгрузке:

yes / no

Вместо нескольких адаптеров можно описать модель через dynamic.Bool.

Импорт CSV и файлов

Импортируемые файлы часто приходят не из программ, а от людей.

В результате одно и то же поле может выглядеть как:

1000
"1000"
"1 000"

или содержать даты в разных представлениях.

Наличие готовых конвертеров снижает количество ручного кода на входном слое.

Конфигурация сервисов

Конфигурационные данные часто имеют менее строгую типизацию, чем хотелось бы разработчику.

Например, параметр может прийти из YAML или TOML как число, строка или boolean.

dynamic позволяет привести такие значения к ожидаемой модели и оставить бизнес-коду уже нормализованные данные.

Webhook и события от внешних систем

Webhook особенно хорошо показывает проблему границы между системами.

Производитель события может считать 1, "1" и true одинаковыми значениями. Go-программа — нет.

dynamic позволяет принять допустимые варианты на границе системы, а дальше работать со строгим типом.

Коллекции тоже можно нормализовать

Та же концепция работает не только со скалярами.

Например:

type Doc struct {
    Scores dynamic.Int64Map
    IDs    dynamic.Uint64Slice
}

Теперь идентификаторы могут прийти как:

[1, "2", 3]

или строкой:

1,2,3

Пакет предоставляет готовые Map и Slice-обёртки с поддержкой дженериков и набором псевдонимов вроде StringMap, Int64Map, Uint64Slice и других.

Это позволяет сохранять ту же модель поведения и для вложенных коллекций.

Nullable и различие между null и значением

Ещё одна практическая проблема — различие между отсутствующим значением, null и обычным нулевым значением.

Для этого существует:

dynamic.Nullable[T]

Он позволяет сохранить информацию о том, было ли значение действительно передано.

При этом обычный null для базовых обёрток не считается ошибкой и приводит к нулевому значению.

Это удобно при работе с PATCH-запросами, частичными обновлениями и интеграциями, где null имеет отдельную семантику.

Enum с alias-ами

Отдельно стоит отметить Enum.

Во внешних системах одно состояние может иметь несколько представлений:

active
ACTIVE
on
включён

Внутри приложения при этом хочется получить строгое значение:

type Status string

dynamic.Enum позволяет задать канонические значения и их alias-ы, причём сравнение выполняется без учёта регистра.

Это хороший вариант для интеграций, где словарь значений формально существует, но сторонняя система использует собственные названия.

Почему не просто map[string]any

На первый взгляд можно решить ту же задачу через:

map[string]any

Но вместе с этим решением вы переносите всю ответственность на код, который работает с картой.

Получается примерно такой путь:

JSON

map[string]any

проверка типа

ручная конвертация

проверка ошибок

бизнес-логика

И со временем такие проверки оказываются в десятках мест.

Подход dynamic меняет границу:

JSON / XML / YAML / TOML / CSV

       dynamic-конвертация

      строгая структура Go

        бизнес-логика

В результате грязные данные остаются проблемой интеграционного слоя, а не распространяются по приложению.

Когда dynamic применять не стоит

Пакет не предназначен для того, чтобы сделать любую модель максимально «всеядной».

Если вы полностью контролируете API и его контракт строго определён, обычные типы Go лучше:

type User struct {
    ID   int64
    Name string
}

В таком случае дополнительная гибкость не нужна.

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

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

Как установить

Пакет устанавливается стандартным способом:

go get github.com/pkg-ru/dynamic

Требуется Go 1.21 или новее.

Минимальный пример выглядит так:

type User struct {
    Name   dynamic.String
    Age    dynamic.Int64
    Active dynamic.Bool
    Tags   dynamic.StringSlice
}

func main() {
    var user User

    data := []byte(`{
        "name": "Ann",
        "age": "33",
        "active": 1,
        "tags": "a,b,c"
    }`)

    if err := dynamic.UnmarshalJSON(data, &user); err != nil {
        panic(err)
    }

    fmt.Println(
        user.Name.Unwrap(),
        user.Age.Unwrap(),
        user.Active.Unwrap(),
        user.Tags.Unwrap(),
    )
}

После декодирования структура содержит уже нормализованные значения, а не исходные варианты их представления.

Итог

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

Пакет позволяет оставить гибкость на границе приложения:

"42" → 42
1 → true
"a,b,c" → []string{"a", "b", "c"}

но не превращать эту гибкость в бесконтрольные преобразования.

Основная ценность здесь не в самом наборе конвертеров, а в архитектурном разделении ответственности: внешняя система может присылать данные в разных формах, а внутри приложения остаются нормальные типы Go и явные ошибки, если преобразование действительно невозможно или приводит к потере информации.

Именно поэтому dynamic особенно хорошо подходит для API-интеграций, legacy-систем, webhook, импорта данных, конфигураций и других мест, где контракт между системами на практике оказывается значительно менее строгим, чем хотелось бы разработчику.

Релевантные разделы

Архитектура

Архитектура веб-платформ: как проектировать систему, которая растёт

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

#архитектура #архитектура веб-приложений #проектирование
9 мин
Архитектура

Высоконагруженные веб-системы: как проектировать архитектуру под рост нагрузки

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

#highload #высокая нагрузка #масштабирование
12 мин
Разработка

Чистый код и поддерживаемость: почему читаемость важнее скорости

Как писать поддерживаемый код: понятные имена, небольшие функции, явные зависимости, простые интерфейсы, тесты и рефакторинг без усложнения проекта.

#чистый код #поддерживаемость #рефакторинг
10 мин