Содержание урока · 10 разделов
Представьте запуск: магазин электроники, витрина на сто двадцать тысяч товаров, фронт просит страницу каталога с фильтрами по категории и цене, сортировкой, поиском и счётчиком «найдено N товаров». Такой mongo проект go-сервис собирает за один урок из всего курса. Подключение и один клиент на процесс — из второго урока; модель товара с bson-тегами — из пятого; фильтры с операторами $in, $gte и $regex — из восьмого и четырнадцатого; сортировка, проекции и лимиты — из тринадцатого; агрегации — из пятнадцатого и шестнадцатого; индексы и текстовый поиск — из семнадцатого; классификация ошибок — из двадцатого; репозиторий — из двадцать первого; пул соединений — из двадцать четвёртого. Нового в уроке мало, и это честно: на выпуске проверяется не знание новых функций, а умение складывать знакомые в работающий сервис и считать цену каждого запроса. По ходу соберём модель и три метода репозитория, разберём два живых места и закончим чек-листом перед выкаткой.
Модель товара и репозиторий#
Всё начинается с модели и её места в слоях. Product — структура с bson-тегами из пятого урока: тег _id с типом bson.ObjectID из двенадцатого, omitempty у необязательных полей, указатель *time.Time у DeletedAt — так различаются «не удаляли» и «удалили тогда-то», мягкое удаление одиннадцатого урока без отдельного булева флага. Цена — int64 в копейках: деньги в целых числах не теряют копейки на округлении, как это бывает у float64, и сортировка по цене предсказуема. Репозиторий — тот же скелет, что в уроке про слой репозитория: структура с дескриптором коллекции и конструктор, принимающий клиента. Database и Collection в драйвере — дескрипторы без сетевых операций: конструктор репозитория ничего не открывает, первая реальная работа произойдёт на первом запросе. Клиент — один на процесс: mongo.Connect из второго урока в main, Ping на старте, дальше клиент передаётся конструкторам всех репозиториев. Такой каталог mongodb golang-код тестируется без базы: интерфейс ProductRepo подменяется моками — механика из двадцать третьего урока. Поверх репозитория живут HTTP-хендлеры: их работа — перевести параметры запроса в ListParams и вернуть JSON, а все решения о типах и условиях принимаются до вызова репозитория.
Модель и конструктор репозитория
GOtype Product struct {
ID bson.ObjectID `bson:"_id,omitempty"`
Name string `bson:"name"`
Brand string `bson:"brand"`
Category string `bson:"category"`
Price int64 `bson:"price"` // копейки
Stock int `bson:"stock"`
Description string `bson:"description"`
Rating float64 `bson:"rating"`
CreatedAt time.Time `bson:"created_at"`
DeletedAt *time.Time `bson:"deleted_at,omitempty"`
}
type ProductRepo struct {
coll *mongo.Collection
}
func NewProductRepo(client *mongo.Client) *ProductRepo {
return &ProductRepo{coll: client.Database("shop").Collection("products")}
}Структура с bson-тегами: _id привязан к bson.ObjectID, omitempty не пишет пустые поля, указатель *time.Time отличает «не удаляли» от даты удаления. Цена — целые копейки: int64 не теряет копейки на округлении и сортируется предсказуемо. Репозиторий держит дескриптор коллекции; клиент создаётся один на процесс и передаётся конструктору.
Фильтры: bson.M собирается по ходу запроса#
Фильтр каталога не известен заранее: пользователь выбрал три бренда, сдвинул ползунок цены, включил «только в наличии». Динамический bson.M собирается по мере прихода параметров: базовое условие deleted_at nil отсекает мягко удалённые товары, дальше категория равенством, бренды через $in, цена диапазоном $gte и $lte, наличие условием stock больше нуля. Карта в фильтре легальна: порядок условий в bson.M не влияет на выборку, поэтому драйвер разрешает мультиключевые карты именно здесь. Сравнение в MongoDB строго по типам BSON: строка «1990» и число 1990 — разные значения, и фильтр по цене со строкой молча вернёт пустую выдачу. Перевод типов — обязанность хендлера: strconv для чисел из query-параметров, time.Parse для дат, и только потом фильтр. Поиск по префиксу названия прикрывается $regex с якорем: анкернутый шаблон работает по индексу, без якоря — сканирует. Фильтр удобно строить отдельным методом: toBSON у ListParams возвращает bson.M, тесты подают параметры и сверяют собранный документ — логика условий проверяется без базы и без HTTP. Логика $and и $or из четырнадцатого урока тоже доступна, но простые поля каталога обходятся равенствами и диапазонами — конвейер фильтрации остаётся плоским и читаемым.
Каталог отдал пустую страницу по запросу с ценой, хотя товары в коллекции были. Причина: хендлер положил в фильтр строку из query-параметра, а в документах цена — число. MongoDB сравнивает строго по типам BSON: строка «1990» не равна числу 1990, и сервер ответил пустой выборкой без ошибки. Правило: если параметр приходит из HTTP, то конвертация типов происходит до фильтра — strconv для чисел, time.Parse для дат. Ловится тестом на двух документах: с числом и со строкой в одном и том же поле.
Сортировка, пагинация и проекция страницы#
Страница каталога собирается одной цепочкой options.Find(): SetSort задаёт порядок, SetSkip и SetLimit режут окно, SetProjection снимает тяжёлое описание с ленты, а массивы в документах режет $slice: галерея из двадцати ссылок карточке в списке не нужна — трёх хватает. Сортировка — только bson.D: порядок полей в ней значим, и карта из двух ключей упадёт с ErrMapForOrderedArgument ещё до отправки запроса — та самая проверка из тринадцатого урока. Для каталога сортировка ходит в паре с фильтром: «внутри категории по цене» — и составной индекс категория плюс цена работает по правилу Equality-Sort-Range из семнадцатого урока. Пагинация математически проста: skip равен номеру страницы минус один, умноженному на размер страницы, а оба аргумента — int64, поэтому переменную int компилятор просит конвертировать явно. У skip есть цена: сервер проходит и отбрасывает все пропущенные документы, значит страница номер пятьсот дорожает линейно. Для кнопки «показать ещё» применяйте keyset-пагинацию по _id из двенадцатого урока: условие _id меньше последнего на экране плюс сортировка по _id убыванию — дёшево на любой глубине, потому что опирается на первичный индекс. Номера страниц оставьте для первых двадцати-тридцати листаний, глубже — только keyset.
Метод List: динамический фильтр и опции Find
GOtype ListParams struct {
Category string
Brands []string
PriceMin int64 // копейки
PriceMax int64 // копейки
Page int
PerPage int
}
func (r *ProductRepo) List(ctx context.Context, p ListParams) ([]Product, error) {
filter := bson.M{"deleted_at": nil}
if p.Category != "" {
filter["category"] = p.Category
}
if len(p.Brands) > 0 {
filter["brand"] = bson.M{"$in": p.Brands}
}
if p.PriceMin > 0 || p.PriceMax > 0 {
price := bson.M{}
if p.PriceMin > 0 {
price["$gte"] = p.PriceMin
}
if p.PriceMax > 0 {
price["$lte"] = p.PriceMax
}
filter["price"] = price
}
skip := int64((p.Page - 1) * p.PerPage)
opts := options.Find().
SetSort(bson.D{{Key: "category", Value: 1}, {Key: "price", Value: 1}}).
SetSkip(skip).
SetLimit(int64(p.PerPage)).
SetProjection(bson.M{"description": 0})
cursor, err := r.coll.Find(ctx, filter, opts)
if err != nil {
return nil, fmt.Errorf("catalog list: %w", err)
}
var out []Product
return out, cursor.All(ctx, &out)
}Фильтр собирается динамически: deleted_at nil отсекает удалённые, бренды идут через $in, цена — диапазоном $gte и $lte. Сортировка — bson.D: карта из двух ключей дала бы ErrMapForOrderedArgument до отправки. Проекция снимает описание с ленты; Skip и Limit требуют int64 — переменные конвертируются явно. cursor.All декодирует страницу и закрывает курсор сам.
Маршруты чтения каталога#
| Задача | Инструмент драйвера | Цена и границы |
|---|---|---|
| Страница каталога | coll.Find + SetSort/SetSkip/SetLimit | опирается на индекс сортировки; глубокий skip дорожает линейно |
| Счётчик под фильтр | coll.CountDocuments(ctx, filter) | внутри агрегация $match плюс $group — цена пропорциональна совпавшим |
| Бейдж «всего товаров» | coll.EstimatedDocumentCount(ctx) | метаданные коллекции, мгновенно, фильтр не принимает |
| Страница и счётчик разом | coll.Aggregate с этапом $facet | один round trip; выходной документ — лимит 16 МБ |
| Полнотекстовый поиск | $text с $search внутри $match | нужен text-индекс; ранжирование через $meta textScore |
Проверка: сортировка картой
Хендлер собрал сортировку «цена по возрастанию, новизна сверху» как SetSort(bson.M{"price": 1, "created_at": -1}). Что произойдёт?
Что выведет счётный фасет на пустом входе?
Прочитайте код и выберите, что он выведет:
// в коллекции нет товаров категории "usb-хабы"
pipeline := mongo.Pipeline{
bson.D{{Key: "$match", Value: bson.M{"category": "usb-хабы"}}},
bson.D{{Key: "$facet", Value: bson.M{
"total": bson.A{
bson.D{{Key: "$count", Value: "value"}},
},
}}},
}
// cursor.All декодировал результат в срез страниц
fmt.Println(len(pages), len(pages[0].Total))Счётчики: три способа и $facet#
Счётчики в каталоге живут в трёх видах, и у каждого своя цена. CountDocuments принимает фильтр, но внутри это агрегация: драйвер строит конвейер из $match и $group со счётчиком $sum, и стоимость пропорциональна числу совпавших документов — под популярной категорией на двадцати тысячах товаров это заметная работа на каждый запрос. EstimatedDocumentCount фильтр не принимает вовсе: он выполняет команду count по метаданным коллекции и отвечает мгновенно — глобальные бейджи «всего товаров» и метрики админки. Панели каталога нужны страница и счётчик под тем же фильтром одновременно, а два запроса — это два round trip'а. Этап $facet решает задачу одним запросом: он держит несколько подконвейеров над одним набором входных документов и складывает результаты в поля выходного документа — items со страницей и total со счётчиком $count. Два правила эксплуатации. Фильтр ставьте этапом $match до $facet: условие сработает по индексу, а фасеты получат суженный набор; $facet первым этапом читает коллекцию целиком. Счётчик $count на пустом входе не выдаёт документа — массив фасета останется пустым, и чтение нулевого элемента без проверки длины паникует. Для популярных сочетаний фильтров счётчик кэшируют на несколько секунд или пересчитывают по Change Streams: витрине не нужна точность до единицы на каждое нажатие. Ограничения мелкие: подконвейеры не принимают $out, $merge и $geoNear, а выходной документ подчиняется лимиту 16 МБ.
SearchPage: поиск и страница одним round trip
GOtype CatalogPage struct {
Items []Product `bson:"items"`
Total []struct {
Value int64 `bson:"value"`
} `bson:"total"`
}
func (r *ProductRepo) SearchPage(
ctx context.Context, query, category string, page, perPage int64,
) (CatalogPage, int64, error) {
match := bson.M{"$text": bson.M{"$search": query}, "deleted_at": nil}
if category != "" {
match["category"] = category
}
pipeline := mongo.Pipeline{
bson.D{{Key: "$match", Value: match}},
bson.D{{Key: "$facet", Value: bson.M{
"items": bson.A{
bson.D{{Key: "$sort", Value: bson.D{
{Key: "score", Value: bson.M{"$meta": "textScore"}},
}}},
bson.D{{Key: "$skip", Value: (page - 1) * perPage}},
bson.D{{Key: "$limit", Value: perPage}},
},
"total": bson.A{
bson.D{{Key: "$count", Value: "value"}},
},
}}},
}
cursor, err := r.coll.Aggregate(ctx, pipeline)
if err != nil {
return CatalogPage{}, 0, fmt.Errorf("catalog search: %w", err)
}
var pages []CatalogPage
if err := cursor.All(ctx, &pages); err != nil {
return CatalogPage{}, 0, fmt.Errorf("decode catalog page: %w", err)
}
if len(pages) == 0 {
return CatalogPage{}, 0, nil
}
var total int64
if len(pages[0].Total) > 0 {
total = pages[0].Total[0].Value
}
return pages[0], total, nil
}Ведущий $match сочетает $text с фильтром категории и мягким удалением: индексы работают до фасета, поиск идёт по составному text-индексу. Внутри $facet два подконвейера над одним входом: items сортирует по релевантности через $meta textScore и режет страницу, total считает $count. $facet возвращает один документ даже на пустом входе, поэтому счётчик читается с проверкой длины массива.
После рефакторинга страница поиска замедлилась с двадцати до двухсот миллисекунд. Разбор показал: фильтр категории перенесли внутрь каждого подконвейера фасета «для универсальности». Если $facet стоит первым этапом конвейера, сервер обрабатывает всю коллекцию целиком — сто двадцать тысяч документов на каждый запрос. Верните $match до $facet: условие отработает по индексу, а фасеты получат уже суженный набор. Диагностика честная: explain покажет COLLSCAN вместо IXSCAN на ведущем этапе конвейера.
Соберите счётную страницу каталога
Восстановите конвейер: сужающий фильтр, развилка на два подконвейера, лимит страницы и счётчик.
Соберите метод List
Расставьте строки метода каталога по порядку: фильтр, математика страницы, опции, запрос, ошибка, декодирование.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Эксплуатация: индексы, ошибки и пул#
Проект заканчивается не кодом запросов, а эксплуатацией. Индексы заводятся при старте методом EnsureIndexes из семнадцатого урока: составной ключ категория плюс цена под сортировку внутри категории, уникальный индекс по sku под импорты из прайс-листов, текстовый индекс по name и description с весами — один на коллекцию. Валидация схемы из двадцать второго урока прикрывает вставки со стороны админки: $jsonSchema не пропустит цену строкой и товар без названия — те же грабли, о которых предупреждал коллаут про типы. Ошибки классифицируются предикатами двадцатого урока и превращаются в HTTP-статусы: IsDuplicateKeyError на импорте даёт 409 с именем индекса, IsTimeout — 503 с ретраем на чтении, ErrNoDocuments при FindOne карточки товара — 404. Пул — из двадцать четвёртого урока: клиент один, SetMaxPoolSize настраивается под пропускную способность, горутины берут соединения и отдают без задержек; длинных ручных итераций по курсору на горячих путях быть не должно — All закрывает курсор сам. Для фоновых сценариев есть Change Streams девятнадцатого урока: витрина узнаёт о новой цене из потока событий, а не опросом.
Чек-лист каталога перед выкаткой#
- Один клиент на процесс: Connect в main, Ping на старте, Disconnect в defer.
- Составной индекс под каждую сортировку: поля равенства вперёд, поле сортировки следом.
- Один text-индекс на коллекцию: name и description в нём же, с весами через SetWeights.
- Счётчики: EstimatedDocumentCount для глобальных бейджей, $facet для страницы и счётчика одним запросом.
- Типы из HTTP переводите до фильтра: сравнение в MongoDB строго по типам BSON.
- Ошибки классифицируйте предикатами драйвера: дубликат — 409, таймаут — 503, пустой FindOne — 404.
Проверка: самый дешёвый счётчик
На главной нужен бейдж «в каталоге 124 380 товаров» — без фильтров. Какой метод дешевле всех?
Карточки: каталог целиком
Скелет собран, и опирается он на весь курс. Интерфейс ProductRepo, доменные ошибки и моки без базы — в уроке про слой репозитория; правила составных индексов и порядок Equality-Sort-Range — в уроке про индексы; строка подключения, Ping и один клиент на процесс — во втором уроке; цифры пула и цена глубоких skip — в уроке про производительность. Что доращивать дальше: транзакции восемнадцатого урока для связки «товар плюс остаток на складе», тесты двадцать третьего урока на живой тестовой базе, Change Streams для инвалидации кеша витрины. Считать цену страниц — привычка: explain на каждую новую сортировку, executionStats в отчёте раз в месяц. Проверять сборку нужно на живой базе: песочница сайта исполняет только чистый Go без сети, поэтому поднимите MongoDB локально в Docker, вставьте пару сотен товаров InsertMany из четвёртого урока и прогоните каталог руками — расхождения с ожиданием ловятся на малых объёмах мгновенно.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Каталог собирается из курса целиком: один клиент, bson-теги, динамические фильтры, опции Find, агрегации и предикаты ошибок.
- Фильтр — динамический bson.M: карты легальны в фильтре; типы из HTTP переводите до фильтра, иначе сравнение молча не совпадёт.
- Сортировка — bson.D в SetSort: мультиключевая карта даёт ErrMapForOrderedArgument ещё на клиенте.
- Пагинация: SetSkip((page-1)*perPage) и SetLimit(int64); без SetSort состав страницы не гарантирован, глубина лечится keyset по _id.
- CountDocuments — агрегация под фильтр, EstimatedDocumentCount — метаданные без фильтра, $facet — страница и счётчик одним round trip.
- $count на пустом входе не выдаёт документа: счётчик читается с проверкой длины массива фасета.
- Фильтр — этапом $match до $facet: условие работает по индексу, фасеты получают суженный набор.
- Перед выкаткой: составные индексы под сортировки, один text-индекс, валидация схемы, классификация ошибок в HTTP-статусы.
Домашнее задание
Соберите каталог на живой базе из второго урока: коллекция products на двести документов через InsertMany, репозиторий с методами List и SearchPage. Проверьте три страницы skip-пагинацией и сравните через explain планы первой и глубокой страниц. Сломайте фильтр строковой ценой и посмотрите на пустую выдачу, затем почините конвертацией типов в хендлере. Уберите $match из конвейера поиска, замерьте разницу и верните фильтр на место. Дополнительно: keyset-страница по _id для кнопки «показать ещё» и сверка её состава со skip-вариантом.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему бы не хранить каталог в Postgres?
Каталог — документы с разными характеристиками по категориям: у телевизора диагональ и HDMI, у наушников импеданс и шумоподавление. В реляционной схеме это EAV или JSONB с ручной валидацией; MongoDB держит разные формы документов в одной коллекции, а $jsonSchema из урока про валидацию прикрывает обязательные поля. Счётчики и агрегации закрываются конвейером без JOIN.
Что дешевле: два запроса Find и CountDocuments или $facet?
Один round trip дешевле двух: на сетевых задержках в пару миллисекунд разница видна на горячих страницах. Но $facet сложнее переиспользовать: если счётчик нужен реже страницы, кэшируйте его отдельно и оставьте два простых запроса.
Почему товар иногда попадает на две страницы сразу?
Сортировка по неуникальному полю не задаёт полного порядка: равные цены сервер выстраивает произвольно, и между запросами возможны сдвиги. Добавьте тай-брейкер вторым ключом — _id — и состав страниц станет стабильным, а дубликаты исчезнут.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Zerolog в Go: быстрое JSON-логирование
Курс zerolog для Go: JSON-события, уровни, поля, ConsoleWriter, контекстные поля, ротация, sampling, HTTP-логи и тесты. 25 уроков с примерами.
- Писать структурированные JSON-логи с нуля аллокаций
- Выбирать уровни и поля события осознанно
- Настраивать ConsoleWriter для dev и JSON для прода
- Строить сублоггеры и контекстные поля на запрос