У вас наверняка уже есть структуры с тегами json — API-слой про них позаботился. Но документ в MongoDB кодируется в BSON, и за него отвечает отдельный тег bson. Между json и bson есть тонкое расхождение: JSON-кодек оставляет имена полей как есть, а BSON-кодек по умолчанию приводит их к нижнему регистру — и в документе внезапно оказывается httpcode вместо HTTPCode. Пропущенные bson-теги не ломают вставку, они ломают читаемость и согласованность: поле сегодня называется one way, а завтра вы пишете фильтр и не помните, как оно легло в коллекцию. В этом уроке возьмём кодек под контроль: имена, пустые значения, встраивание, исключение полей — всё через один тег с запятыми и опциями. Это последний урок фундамента: со следующего курса пойдёт чтение — FindOne и курсоры.
bson-теги: как драйвер называет поля#
Правило по умолчанию стоит запомнить дословно: без тега кодек драйвера пишет имя поля строчными буквами целиком. Name станет name, HTTPCode — httpcode, CreatedAt — createdat. Поэтому первый и главный модификатор — имя в теге: bson:"name" переопределяет ключ документа. Соглашение, которое я встречаю в Go-проектах чаще всего: snake_case — bson:"created_at", bson:"sku"; оно совпадает с традициями MongoDB-мира и с удобством чтения в mongosh. Порядок полей в структуре — тоже контракт: кодек пишет поля в порядке объявления, и BSON порядок сохраняет — это важно для _id, который обязан быть первым полем, и для читаемости документа глазами. Неэкспортируемые поля кодек пропускает молча — их в документе не будет никогда. И отдельно: если поле структуры вообще не должно попадать в базу, ставьте тег bson:"-" — минус явно исключает поле из кодирования и декодирования. Тег разбирается по запятой: первая часть — имя (пустая при ,inline), дальше — модификаторы. Анонимные вложенные структуры без тега кодек вливает автоматически — как неявный inline, поэтому композиция моделей работает и без лишних тегов.
Структура с полным набором тегов
GOtype Specs struct {
Color string `bson:"color"`
Size string `bson:"size"`
}
type Product struct {
ID bson.ObjectID `bson:"_id,omitempty"`
Name string `bson:"name"`
Price int64 `bson:"price,omitempty"`
InStock bool `bson:"in_stock,omitempty"`
Specs Specs `bson:",inline"`
internal string // неэкспортируемое — кодек пропустит
}
func main() {
p := Product{Name: "Клавиатура", InStock: true, Specs: Specs{Color: "серый", Size: "TKL"}}
data, err := bson.MarshalExtJSON(p, false, false)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(data))
// {"name":"Клавиатура","in_stock":true,"color":"серый","size":"TKL"}
}Без тегов драйвер написал бы name, price, instock — строчными целиком. Теги дают контроль: omitempty выбросил нулевую Price, а InStock: true прошёл. ,inline влил поля Specs на верхний уровень документа: color и size стали полями самого продукта. Неэкспортируемое поле internal в документ не попадает никогда. MarshalExtJSON показывает итоговый документ без запуска базы — удобная штука для отладки тегов.
Модификаторы bson-тега#
| Модификатор | Что делает | Пример поля | Эффект в документе |
|---|---|---|---|
| имя | переименовывает поле | bson:"created_at" | ключ created_at вместо createdat |
| omitempty | пропускает пустые значения | bson:"price,omitempty" | нулевая цена не попадает в документ |
| minsize | int64 кодируется как int32, если влезает | bson:"qty,minsize" | поле занимает 4 байта вместо 8 |
| inline | вливает поля вложенной структуры | bson:",inline" | color и size без уровня Specs |
| - | исключает поле | bson:"-" | поля в документе нет вообще |
bson omitempty: что считается пустым#
Модификатор omitempty — источник половины багов с тегами, поэтому разберём его буквально. Пустым кодек считает: false у bool, ноль у любых чисел, пустую строку, nil-указатель, nil-срез, nil-карту и пустую структуру времени. Не пустым — всё остальное: не-nil указатель на ноль, срез с элементами, false нельзя «сделать не пустым» без указателя. Отсюда практические следствия. Число-ноль как легитимное значение — цена со скидкой 0, количество 0 — нельзя декорировать omitempty: поле исчезнет, и при декодировании вернётся тот же ноль, а вот в фильтрах и валидации документ окажется «без цены». Для таких полей используйте указатель int64: nil — пусто, а указатель на ноль — осмысленный ноль, и omitempty сработает честно. Тот же приём спасает bool-флаги, где false и «поле не задано» — разные вещи. Вопрос «bson omitempty когда применять» решается одним тестом: может ли значение быть нулём легитимно? Может — указатель или без omitempty; ноль всегда означает «нет данных» — смело ставьте. Отдельная история — время: нулевое time.Time тоже считается пустым при omitempty, поэтому для полей вроде deleted_at используйте указатель time.Time: nil — метки нет, а не «первое января 1970 года».
Кейс, который бывает у каждой второй команды: поле IsPaid bool bson:"is_paid,omitempty", заказ неоплачен — и поле исчезает из документа. Отчёт считает «оплачено только там, где true», платёжный воркер ищет is_paid: true и пропускает живые заказы. Лекарство: для смыслово-нулевых значений omitempty не ставить, а различать «не задано» указателем.
bson.D, bson.M, bson.E: словарь типов#
Структуры — не единственный способ собрать документ, и в запросах вы постоянно будете видеть три типа из пакета bson. bson.E — пара {Key, Value}, кирпичик документа. bson.D — []E, упорядоченный документ: порядок полей именно такой, как вы написали; его выбирают для команд сервера и фильтров, где порядок операторов важен ($group с сортировкой, составные ключи). bson.M — map[string]any, неупорядоченный документ: короткая запись для быстрых фильтров, где порядок не играет роли. bson.A — слайс значений, массив внутри документа. Моё рабочее разделение: модели — структуры с bson-тегами; ad-hoc запросы и агрегации — bson.D, потому что конвейеры чувствительны к порядку этапов; разовые проверки в тестах — bson.M. Спорное мнение с условием: я не люблю bson.M в продовом коде — опечатка в ключе компилируется и всплывает только на рантайме, — но в тестах и одноразовых скриптах его компактность перевешивает, и там он у меня законен. Если сомневаетесь, какой тип выбрать, — вспомните, важен ли порядок: важен — D, не важен — M, а модели всегда структуры.
Пара типов, которые BSON-кодек откажется кодировать — лучше знать заранее, чем ловить в проде. Каналы, функции и комплексные числа не имеют BSON-представления: кодирование вернёт ошибку вида no encoder found for type, и вставка не состоится. Карты в документе должны быть со строковыми ключами: map[string]any кодируется, а map[int]string — ошибка, потому что ключ документа в BSON всегда строка. Время кодируется типом time.Time: число-миллисекунды вручную лучше не складывать в поле int64, если потом хочется читать его датой. Отсюда рабочее правило: поле модели — либо BSON-нативный тип, либо ваш тип с явной конверсией перед записью; магии «драйвер сам догадается» не существует, и это хорошо — явный код отлаживается быстрее.
D или M: один фильтр двумя способами
GO// bson.M — map[string]any: коротко, порядок ключей не задан
fM := bson.M{"status": "paid", "total": bson.M{"$gt": 1000}}
// bson.D — срез пар: порядок операторов сохранён как написано
fD := bson.D{
{Key: "status", Value: "paid"},
{Key: "total", Value: bson.D{{Key: "$gt", Value: 1000}}},
}
// оба кодируются в одинаковый документ — выбор за читаемостью и порядкомОдин и тот же фильтр двумя типами из пакета bson. bson.M компактнее и читается одним глазом; bson.D многословнее, но гарантирует порядок полей — он обязателен там, где последовательность значима: команды сервера и конвейеры агрегаций. Для моделей остаются структуры с тегами, а полный разбор фильтров с $eq, $gt и $in — восьмой урок.
Проверка: дефолтные имена
Как ляжет в BSON поле HTTPCode структуры без bson-тега?
Проверка: omitempty на нуле
Поле Price int64 с тегом `bson:"price,omitempty"` равно 0. Что попадёт в BSON?
Что напечатает MarshalExtJSON?
Прочитайте код и выберите, что он выведет:
type Item struct {
Name string `bson:"name"`
Price int64 `bson:"price,omitempty"`
Tags []string `bson:"tags,omitempty"`
}
item := Item{Name: "стол"}
data, _ := bson.MarshalExtJSON(item, false, false)
fmt.Println(string(data))Какой порядок у bson.D?
Прочитайте код и выберите, что он выведет:
doc := bson.D{
{Key: "z", Value: 1},
{Key: "a", Value: 2},
}
data, _ := bson.MarshalExtJSON(doc, false, false)
fmt.Println(string(data))minsize и inline: редкие, но полезные#
Два модификатора встречаются реже, но знать их стоит. minsize работает с целыми: int64 в Go кодируется в BSON как int64 — восемь байт, даже если число помещается в int32. Тег bson:"qty,minsize" просит кодек писать значение как int32, когда оно влезает: экономия на размере документа, плюс совместимость, когда другой инструмент ожидает int32. Цена минимальна, и на больших коллекциях счётчик байт заметен. inline — совсем другой инструмент: он вливает поля вложенной структуры на верхний уровень документа, как мы видели в разборе — Specs.Color стал color. Работает со структурами и картами, требует, чтобы имена не конфликтовали с полями родителя. Зачем это нужно: композиция моделей без лишнего уровня вложенности — общий блок аудита, инлайнится во многие документы; при декодировании поля соберутся обратно в ту же структуру. Полезно и для миграций: документ уже плоский, а вы хотите работать с ним как с вложенной структурой — inline сделает это без переписывания данных. Пара ограничений: inline-структура должна быть экспортируемым типом, а имена её полей — не конфликтовать с полями родителя, иначе кодек вернёт ошибку ещё на этапе маршалинга, а не молча испортит документ.
В v1 типы вроде ObjectID и Decimal128 жили в пакете primitive, и импорты были двойные. В v2 пакет влит в bson: bson.ObjectID, bson.NewObjectID(), bson.NilObjectID. Старый код с primitive.ObjectID компилируется только после правки импортов — ещё один маркер возраста туториалов.
Допишите bson-теги
Восстановите теги: имя username для поля Name, omitempty у возраста, inline у метаданных и исключение внутреннего поля.
Соберите структуру и кодирование
Расставьте строки в порядке программы: объявление типа, поля, закрывающая скобка, кодирование, проверка ошибки, вывод.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Проверка: тег со минусом
Поле с тегом `bson:"-"` — что с ним произойдёт?
Декодирование: обратная дорога#
Теги работают в обе стороны, и это стоит проговорить до того, как появится первый FindOne. Декодер берёт BSON-документ и заполняет структуру: имя поля в документе сверяется с bson-тегами, отсутствующие поля остаются нулевыми значениями Go, лишние в документе — игнорируются молча. Последние два факта — источник «тихих» багов: если в структуре нет поля amount, а в документе оно есть, драйвер не пожалуется — данные просто не доедут до кода. Обратное тоже верно: если типы несовместимы — строка в документе и int64 в структуре — Decode вернёт ошибку, и её нужно проверять так же честно, как ошибку запроса. Отсюда правило: структура-модель должна описывать документ целиком, а для гибкости есть поля bson.Raw и bson.M — их разберём по ходу курса. Обратный путь, кстати, доступен и без базы: bson.Unmarshal(data, &product) применяет те же теги к байтам — удобно для тестов кодека. А bson.Raw — это документ без декодирования: байты с ленивым доступом к полям через Lookup, когда нужен один ключ из большого документа и распаковывать всё расточительно. Проверять связку «структура — документ» удобно через MarshalExtJSON, как в этом уроке: он чистый Go без сети. Но полноценный круг вставки и чтения требует живого MongoDB — песочница сайта показывает тематическую программу на чистом Go, поэтому код уроков гоняйте локально: контейнер из второго урока и mongosh показывают всю картину.
Карточки: bson-теги
Кодек под контролем: имена через теги, omitempty с пониманием нулей, inline для композиции, минус для исключений, D против M по ситуации. Правило про пару тегов стоит держать с первого дня: если структура уезжает и в HTTP-ответ, и в коллекцию, json и bson теги ставятся рядом — json:"name" bson:"name" — и имена в API и в базе не разъезжаются; классическая болезнь «в ответе одно, в коллекции другое» лечится ревью-правилом: новое поле структуры без пары тегов — замечание. Дальше это знание работает сразу: в уроке про InsertOne и InsertMany структуры с тегами ложатся в коллекцию, в уроке про FindOne декодируются обратно, а в уроке про слой репозитория модели с тегами отделяются от HTTP-структур — там пара json-bson станет отдельной темой. Идентификаторы заслуживают отдельного внимания: в уроке про ObjectID вскроем 12 байт и покажем, зачем _id с omitempty — стандартный первый тег в каждой модели. Фундамент на этом закончен: дальше курс пойдёт по CRUD-глаголам, и каждая структура, которую вы напишете с тегами, будет работать и в записи, и в чтении без единой правки.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Без тега кодек пишет имя поля строчными: HTTPCode станет httpcode — имена задавайте явно.
- Порядок полей структуры — порядок в документе; _id традиционно первое поле с omitempty.
- omitempty пропускает false, нули, пустые строки, nil и пустые коллекции; легитимные нули — через указатели.
- minsize экономит байты на int64, inline вливает вложенную структуру, "-" исключает поле.
- bson.D — упорядоченный, bson.M — map: структуры для моделей, D для команд и конвейеров, M для набросков.
- Декодирование: отсутствующие поля — нули Go, лишние — игнор; структура-модель описывает документ целиком.
- MarshalExtJSON показывает итоговый документ без базы — быстрый способ проверить теги.
Домашнее задание
Возьмите любую модель из своих проектов и перепишите её на bson-теги: имя в snake_case, _id с omitempty, omitempty только там, где ноль — не данные (легитимные нули переведите на указатели). Проверьте себя через bson.MarshalExtJSON без базы, а потом вставьте документ в локальный MongoDB из второго урока и сравните глазами mongosh с выводом MarshalExtJSON — они обязаны совпасть.
Личные заметкиПусто
Вопросы и ответы по уроку
Нужны ли bson-теги, если имя поля и так подходит?
Дефолт приводит имя к нижнему регистру целиком: Name станет name — совпадение с желаемым случается, но случайно. Явные теги делают контракт читаемым и спасают от сюрпризов с CamelCase-именами вроде HTTPCode.
Можно обойтись тегами json без bson?
Можно — кодек увидит нижнерегистровые имена полей, и для простых случаев даже заработает. Но json и bson расходятся в деталях: имя в API и имя в документе развяжутся незаметно. Пара json+bson — два десятка символов, которые предотвращают целый класс багов.
Как увидеть документ, который драйвер отправит на сервер?
bson.MarshalExtJSON(struct, false, false) вернёт расширенный JSON ровно того кодека, которым драйвер кодирует BSON: имена, omitempty и inline видны без базы. Для обратного пути есть bson.Unmarshal.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Zerolog в Go: быстрое JSON-логирование
Курс zerolog для Go: JSON-события, уровни, поля, ConsoleWriter, контекстные поля, ротация, sampling, HTTP-логи и тесты. 25 уроков с примерами.
- Писать структурированные JSON-логи с нуля аллокаций
- Выбирать уровни и поля события осознанно
- Настраивать ConsoleWriter для dev и JSON для прода
- Строить сублоггеры и контекстные поля на запрос