Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 25 из 25 ~24 мин +50 XP

Итоговый проект: каталог товаров

Каталог товаров — типичный mongodb golang-проект, и итоговый урок собирает его целиком: один клиент из начала курса, bson-теги, динамические фильтры, сортировка и пагинация, счётчики через $facet и полнотекстовый поиск. Каждый приём уже знаком по отдельным урокам; новое здесь — только сборка и её цена: сколько стоит страница каталога, где сервис теряет миллисекунды и какие проверки нужны перед выкаткой. Собранный mongo проект go-кода — рабочий скелет: переносите его в свой сервис и доращивайте по чек-листу из конца урока.

Содержание урока · 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, а все решения о типах и условиях принимаются до вызова репозитория.

Модель и конструктор репозитория

GO
type 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 из четырнадцатого урока тоже доступна, но простые поля каталога обходятся равенствами и диапазонами — конвейер фильтрации остаётся плоским и читаемым.

Сортировка, пагинация и проекция страницы#

Страница каталога собирается одной цепочкой options.Find(): SetSort задаёт порядок, SetSkip и SetLimit режут окно, SetProjection снимает тяжёлое описание с ленты, а массивы в документах режет $slice: галерея из двадцати ссылок карточке в списке не нужна — трёх хватает. Сортировка — только bson.D: порядок полей в ней значим, и карта из двух ключей упадёт с ErrMapForOrderedArgument ещё до отправки запроса — та самая проверка из тринадцатого урока. Для каталога сортировка ходит в паре с фильтром: «внутри категории по цене» — и составной индекс категория плюс цена работает по правилу Equality-Sort-Range из семнадцатого урока. Пагинация математически проста: skip равен номеру страницы минус один, умноженному на размер страницы, а оба аргумента — int64, поэтому переменную int компилятор просит конвертировать явно. У skip есть цена: сервер проходит и отбрасывает все пропущенные документы, значит страница номер пятьсот дорожает линейно. Для кнопки «показать ещё» применяйте keyset-пагинацию по _id из двенадцатого урока: условие _id меньше последнего на экране плюс сортировка по _id убыванию — дёшево на любой глубине, потому что опирается на первичный индекс. Номера страниц оставьте для первых двадцати-тридцати листаний, глубже — только keyset.

Метод List: динамический фильтр и опции Find

GO
type 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

GO
type 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 возвращает один документ даже на пустом входе, поэтому счётчик читается с проверкой длины массива.

Соберите счётную страницу каталога

Восстановите конвейер: сужающий фильтр, развилка на два подконвейера, лимит страницы и счётчик.

pipeline := mongo.Pipeline{ bson.D{{Key: "$", Value: match}}, bson.D{{Key: "$", Value: bson.M{ "items": bson.A{ bson.D{{Key: "$sort", Value: bson.D{{Key: "price", Value: 1}}}}, bson.D{{Key: "$", Value: int64(20)}}, }, "total": bson.A{ bson.D{{Key: "$", Value: "value"}}, }, }}}, }

Соберите метод 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 товаров» — без фильтров. Какой метод дешевле всех?

Карточки: каталог целиком

Термин

Динамический фильтр

нажмите, чтобы перевернуть
Определение

bson.M, собранный по мере прихода параметров. Карта легальна в фильтре — порядок условий не влияет на выборку. $in — списки, $gte и $lte — диапазоны, nil — отсутствие поля.

1 / 5

Скелет собран, и опирается он на весь курс. Интерфейс 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-вариантом.

Личные заметкиПусто
Хранятся только в вашем браузере (MongoDB · Итоговый проект: каталог товаров)

Вопросы и ответы по уроку

Почему бы не хранить каталог в Postgres?

Каталог — документы с разными характеристиками по категориям: у телевизора диагональ и HDMI, у наушников импеданс и шумоподавление. В реляционной схеме это EAV или JSONB с ручной валидацией; MongoDB держит разные формы документов в одной коллекции, а $jsonSchema из урока про валидацию прикрывает обязательные поля. Счётчики и агрегации закрываются конвейером без JOIN.

Что дешевле: два запроса Find и CountDocuments или $facet?

Один round trip дешевле двух: на сетевых задержках в пару миллисекунд разница видна на горячих страницах. Но $facet сложнее переиспользовать: если счётчик нужен реже страницы, кэшируйте его отдельно и оставьте два простых запроса.

Почему товар иногда попадает на две страницы сразу?

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

Термины урока

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

Следующая ступень учебного пути

zerolog25 уроков~12 чпрактика25 уроков уже доступны

Zerolog в Go: быстрое JSON-логирование

Курс zerolog для Go: JSON-события, уровни, поля, ConsoleWriter, контекстные поля, ротация, sampling, HTTP-логи и тесты. 25 уроков с примерами.

  • Писать структурированные JSON-логи с нуля аллокаций
  • Выбирать уровни и поля события осознанно
  • Настраивать ConsoleWriter для dev и JSON для прода
  • Строить сублоггеры и контекстные поля на запрос