Справочник по MongoDB · практика · после модуля 1
ПР-01
Каталог товаров:
витрина и правки
Две задачи, которые бэкендер решает в первую неделю на проекте: отдать фронтенду страницу списка товаров и провести приёмку поставки — с отчётом, повторным запуском и проверкой, что ничего лишнего не изменилось.
- После модуля
- 1 · подключение и первые данные
- Базы
shop— читаем,sandbox— меняем- Время
- 90 минут
- Результат
solution.pyиREADME.mdс числами- Результат
solution.cppиREADME.mdс числами- Результат
solution.goиREADME.mdс числами- Результат
solution.rbиREADME.mdс числами
github.com/MaximBytecamp/mongodb-practice → practice01
В репозитории — задание, разобранное решение на четырёх языках (solution.py, solution.cpp, solution.go, solution.rb) и одна самопроверка check.py: она смотрит не на код, а на состояние базы после работы, поэтому годится для любого языка. Стенд и данные там же.
git clone https://github.com/MaximBytecamp/mongodb-practice.git cd mongodb-practice\practice01 docker compose run --rm reset # чистые данные: 21 товар, 120 заказов # без Docker: bash ../stend/load.sh в Git Bash python solution.py # решение python check.py # проверка, одна на все языки
git clone https://github.com/MaximBytecamp/mongodb-practice.git cd mongodb-practice/practice01 bash ../stend/load.sh # чистые данные: 21 товар, 120 заказов python3 solution.py # решение python3 check.py # проверка, одна на все языки
git clone https://github.com/MaximBytecamp/mongodb-practice.git cd mongodb-practice/practice01 bash ../stend/load.sh # чистые данные: 21 товар, 120 заказов python3 solution.py # решение python3 check.py # проверка, одна на все языки
git clone https://github.com/MaximBytecamp/mongodb-practice.git cd mongodb-practice\practice01 # драйвер C++ под Windows собирается долго — всё делаем в контейнерах docker compose run --rm reset docker compose run --rm cpp practice01/solution.cpp docker compose run --rm python practice01/check.py # проверка, одна на все языки
git clone https://github.com/MaximBytecamp/mongodb-practice.git cd mongodb-practice/practice01 bash ../stend/load.sh # чистые данные: 21 товар, 120 заказов g++ -std=c++17 solution.cpp -o solution \ $(pkg-config --cflags --libs libmongocxx1) # brew install mongo-cxx-driver ./solution # решение python3 check.py # проверка, одна на все языки
git clone https://github.com/MaximBytecamp/mongodb-practice.git cd mongodb-practice/practice01 # в пакетах дистрибутива драйвер C++ старый — всё делаем в контейнерах docker compose run --rm reset docker compose run --rm cpp practice01/solution.cpp docker compose run --rm python practice01/check.py # проверка, одна на все языки
git clone https://github.com/MaximBytecamp/mongodb-practice.git cd mongodb-practice\practice01 docker compose run --rm reset # чистые данные: 21 товар, 120 заказов # без Docker: bash ../stend/load.sh в Git Bash go run solution.go # решение python check.py # проверка, одна на все языки
git clone https://github.com/MaximBytecamp/mongodb-practice.git cd mongodb-practice/practice01 bash ../stend/load.sh # чистые данные: 21 товар, 120 заказов go run solution.go # решение python3 check.py # проверка, одна на все языки
git clone https://github.com/MaximBytecamp/mongodb-practice.git cd mongodb-practice/practice01 bash ../stend/load.sh # чистые данные: 21 товар, 120 заказов go run solution.go # решение python3 check.py # проверка, одна на все языки
git clone https://github.com/MaximBytecamp/mongodb-practice.git cd mongodb-practice\practice01 docker compose run --rm reset # чистые данные: 21 товар, 120 заказов # без Docker: bash ../stend/load.sh в Git Bash ruby solution.rb # решение python check.py # проверка, одна на все языки
git clone https://github.com/MaximBytecamp/mongodb-practice.git cd mongodb-practice/practice01 bash ../stend/load.sh # чистые данные: 21 товар, 120 заказов ruby solution.rb # решение python3 check.py # проверка, одна на все языки
git clone https://github.com/MaximBytecamp/mongodb-practice.git cd mongodb-practice/practice01 bash ../stend/load.sh # чистые данные: 21 товар, 120 заказов ruby solution.rb # решение python3 check.py # проверка, одна на все языки
Сервер или драйвер не ставится — те же шаги выполняются в контейнерах, команды одинаковые в PowerShell и bash. Как поднять стенд и что делать при ошибках — глава 1.1а.
docker compose up -d # один раз: сервер и базы курса docker compose run --rm reset # чистые данные: 21 товар, 120 заказов docker compose run --rm python practice01/solution.py docker compose run --rm python practice01/check.py
docker compose up -d # один раз: сервер и базы курса docker compose run --rm reset # чистые данные: 21 товар, 120 заказов docker compose run --rm cpp practice01/solution.cpp docker compose run --rm python practice01/check.py
docker compose up -d # один раз: сервер и базы курса docker compose run --rm reset # чистые данные: 21 товар, 120 заказов docker compose run --rm go practice01/solution.go docker compose run --rm python practice01/check.py
docker compose up -d # один раз: сервер и базы курса docker compose run --rm reset # чистые данные: 21 товар, 120 заказов docker compose run --rm ruby practice01/solution.rb docker compose run --rm python practice01/check.py
§1Что уже умеем
В работе не появится ни одного оператора, которого не было в модуле 1. Вот весь инструмент, который понадобится:
Один оператор в задании встретится раньше своего модуля — $in в фильтре {"sku": {"$in": [...]}}. Читается он ровно так, как выглядит: «значение поля есть в этом списке». Подробно — в главе 2.3.
Одна страница списка товаров
Нужна функция, которая возвращает страницу каталога и общее число товаров под фильтром. Это самая частая функция в любом магазине, и в ней собирается почти весь модуль 1.
catalog_page(category=None, page=1, per_page=5, sort_field="price", desc=True) → (items, total)
std::pair<std::vector<Product>, std::int64_t> catalog_page(mongocxx::collection& products, const std::string& category, std::int64_t page, std::int64_t per_page, const std::string& sort_field = "price", bool desc = true);
func catalogPage(category string, page, perPage int64, sortField string, desc bool) ([]Product, int64, error)
catalog_page(category: nil, page: 1, per_page: 5, sort_field: "price", desc: true) → [items, total]
- Фильтр собирается, а не пишется целикомКатегория необязательна: без неё возвращается весь каталог. Фильтр — обычный словарь, поэтому он собирается по условию, а не склеивается строкой.
- Проекция обязательнаВ ответ идут только
sku,title,price,ratingи без_id. Массивstockи характеристики на странице списка не нужны — значит, их не запрашивают. - Сортировка по двум ключамВторой ключ —
sku. Без него товары с одинаковой ценой меняются местами между запросами, и один и тот же товар появляется на двух страницах подряд. - total считает сервер
count_documents(query)с тем же фильтром. Неlen(list(...)): иначе ради одного числа на клиент приедет вся коллекция. - Страница за пределами данных — пустой списокНе ошибка и не исключение.
totalпри этом остаётся прежним.
def catalog_page(category=None, page=1, per_page=5, sort_field="price", desc=True): query = {} if category: query["category"] = category total = products.count_documents(query) cursor = (products .find(query, {"_id": 0, "sku": 1, "title": 1, "price": 1, "rating": 1}) # второй ключ делает порядок однозначным .sort([(sort_field, -1 if desc else 1), ("sku", 1)]) .skip((page - 1) * per_page) .limit(per_page)) return list(cursor), total
bsoncxx::builder::basic::document query{};
if (!category.empty()) query.append(kvp("category", category));
auto filter = query.extract();
auto total = products.count_documents(filter.view());
mongocxx::options::find options;
options.projection(make_document(kvp("_id", 0), kvp("sku", 1),
kvp("title", 1), kvp("price", 1), kvp("rating", 1)));
// второй ключ делает порядок однозначным
options.sort(make_document(kvp(sort_field, desc ? -1 : 1), kvp("sku", 1)));
options.skip((page - 1) * per_page);
options.limit(per_page);
std::vector<Product> items;
for (const auto& doc : products.find(filter.view(), options))
items.push_back(to_product(doc));
return {items, total};
query := bson.D{}
if category != "" {
query = append(query, bson.E{Key: "category", Value: category})
}
total, err := products.CountDocuments(ctx, query)
if err != nil {
return nil, 0, err
}
direction := 1
if desc {
direction = -1
}
cursor, err := products.Find(ctx, query, options.Find().
SetProjection(bson.D{{Key: "_id", Value: 0}, {Key: "sku", Value: 1},
{Key: "title", Value: 1}, {Key: "price", Value: 1}, {Key: "rating", Value: 1}}).
// второй ключ делает порядок однозначным
SetSort(bson.D{{Key: sortField, Value: direction}, {Key: "sku", Value: 1}}).
SetSkip((page - 1) * perPage).
SetLimit(perPage))
var items []Product
return items, total, cursor.All(ctx, &items)
query = {}
query["category"] = category if category
total = PRODUCTS.count_documents(query)
# второй ключ делает порядок однозначным
order = { sort_field => desc ? -1 : 1, "sku" => 1 }
items = PRODUCTS
.find(query, projection: { "_id" => 0, "sku" => 1, "title" => 1,
"price" => 1, "rating" => 1 })
.sort(order)
.skip((page - 1) * per_page)
.limit(per_page)
.to_a
[items, total]
Ноутбуки, страница 1 из 2 (всего 5):
114990 ₽ Ноутбук Apple MacBook Air 13 рейтинг 4.1
89990 ₽ Ноутбук Acer Nitro V15 рейтинг 4.6
67900 ₽ Ноутбук HP Pavilion 14 рейтинг 4.5
Страница 2:
51490 ₽ Ноутбук ASUS VivoBook 15 рейтинг 4.3
42990 ₽ Ноутбук Lenovo IdeaPad 3 рейтинг 4.2
Страница 9 из существующих 2: найдено 0 товаров, total по-прежнему 5
Кажется избыточным — два обращения вместо одного. Но альтернатива хуже: чтобы узнать общее число из самой выборки, её пришлось бы получить целиком, без limit. На каталоге из 21 товара разницы нет, на 200 000 — это разница между 4 миллисекундами и минутой.
Скрипт, который принимает поставку
Приехала машина. Нужно обновить остатки, завести новые позиции, снять с продажи то, что больше не возим, и напечатать отчёт. Работаем в песочнице.
| Шаг | Что делаем | Чем | Что проверяем |
|---|---|---|---|
| 1 | Прибавить пришедшее к остаткам трёх артикулов | $inc | matched_count: артикул мог не найтись |
| 2 | Завести две новые позиции | insert_many | повтор не создаёт дублей |
| 3 | Снять с продажи SKU-CP-018 | delete_many | сначала посчитать и посмотреть |
| 4 | Бесплатная доставка всем аксессуарам | update_many | modified_count совпал с ожиданием |
Почему $inc, а не «прочитать и записать»
Соблазн понятный: прочитать текущий остаток, прибавить в Python, записать обратно. Так делать нельзя, и вот почему: пока вы читаете, вторая приёмка на соседнем складе делает то же самое. Оба прочитали 10, оба записали 15 — а приехало 5 и 5, должно быть 20. Пять штук исчезли, и никакая ошибка в логах об этом не скажет.
for sku, qty in ARRIVED.items(): result = box.update_one({"sku": sku}, {"$inc": {"qty_total": qty}}) if result.matched_count == 0: print(f" ВНИМАНИЕ: артикул {sku} не найден в каталоге") updated += result.modified_count
for (const auto& [sku, qty] : arrived) { auto res = box.update_one( make_document(kvp("sku", sku)), make_document(kvp("$inc", make_document(kvp("qty_total", qty))))); if (res->matched_count() == 0) std::cout << " ВНИМАНИЕ: артикул " << sku << " не найден" << std::endl; updated += res->modified_count(); }
for _, item := range arrived { res, err := box.UpdateOne(ctx, bson.D{{Key: "sku", Value: item.SKU}}, bson.D{{Key: "$inc", Value: bson.D{{Key: "qty_total", Value: item.Qty}}}}) if err != nil { log.Fatal(err) } if res.MatchedCount == 0 { fmt.Printf(" ВНИМАНИЕ: артикул %s не найден\n", item.SKU) } updated += res.ModifiedCount }
ARRIVED.each do |sku, qty| result = BOX.update_one({ "sku" => sku }, { "$inc" => { "qty_total" => qty } }) puts " ВНИМАНИЕ: артикул #{sku} не найден" if result.matched_count.zero? updated += result.modified_count end
Повторный запуск не должен ломать данные
Скрипт приёмки запускают повторно чаще, чем кажется: упала сеть, оператор нажал дважды, задача перезапустилась. Поэтому у новых позиций задан свой _id — вторая попытка упрётся в E11000, а не создаст копии. Ключ ordered=False позволяет вставить всё, что можно, а про остальное честно сообщить.
try: result = box.insert_many(NEW_ITEMS, ordered=False) report["новых позиций"] = len(result.inserted_ids) except BulkWriteError as error: already = len(error.details["writeErrors"]) report["новых позиций"] = len(NEW_ITEMS) - already print(f" {already} позиций уже были в каталоге — пропущены")
mongocxx::options::insert insert_options; insert_options.ordered(false); try { auto many = box.insert_many(new_items, insert_options); report.emplace_back("новых позиций", many->inserted_count()); } catch (const mongocxx::bulk_write_exception& error) { // список неудач лежит в raw_server_error()["writeErrors"] auto already = count_write_errors(error); std::cout << " " << already << " позиций уже были в каталоге" << std::endl; report.emplace_back("новых позиций", new_items.size() - already); }
many, err := box.InsertMany(ctx, newItems, options.InsertMany().SetOrdered(false)) switch { case err == nil: report = append(report, [2]any{"новых позиций", len(many.InsertedIDs)}) default: var bulkErr mongo.BulkWriteException if !errors.As(err, &bulkErr) { log.Fatal(err) } already := len(bulkErr.WriteErrors) fmt.Printf(" %d позиций уже были в каталоге\n", already) report = append(report, [2]any{"новых позиций", len(newItems) - already}) }
begin result = BOX.insert_many(NEW_ITEMS, ordered: false) report["новых позиций"] = result.inserted_count rescue Mongo::Error::BulkWriteError => error # частичный отказ пачки — именно BulkWriteError, а не OperationFailure already = error.result["writeErrors"].size report["новых позиций"] = NEW_ITEMS.size - already puts " #{already} позиций уже были в каталоге — пропущены" end
Перед удалением — посмотреть
Три строки, которые отделяют рабочий скрипт от опасного: посчитать, показать, удалить. На приёмке эти строки спрашивают отдельно.
query = {"sku": {"$in": DISCONTINUED}}
to_remove = box.count_documents(query)
print(f" под снятие с продажи попало: {to_remove}")
for doc in box.find(query, {"_id": 1, "title": 1, "price": 1}):
print(f" {doc}")
report["снято с продажи"] = box.delete_many(query).deleted_count
auto query = make_document(kvp("sku", make_document(kvp("$in", make_array("SKU-CP-018"))))); std::cout << " под снятие попало: " << box.count_documents(query.view()) << std::endl; mongocxx::options::find preview; preview.projection(make_document(kvp("_id", 1), kvp("title", 1), kvp("price", 1))); for (const auto& doc : box.find(query.view(), preview)) std::cout << " " << bsoncxx::to_json(doc) << std::endl; report.emplace_back("снято с продажи", box.delete_many(query.view())->deleted_count());
query := bson.D{{Key: "sku",
Value: bson.D{{Key: "$in", Value: discontinued}}}}
toRemove, _ := box.CountDocuments(ctx, query)
fmt.Printf("\n под снятие с продажи попало: %d\n", toRemove)
cursor, _ := box.Find(ctx, query, options.Find().SetProjection(
bson.D{{Key: "_id", Value: 1}, {Key: "title", Value: 1}, {Key: "price", Value: 1}}))
var doomed []bson.M
cursor.All(ctx, &doomed)
for _, doc := range doomed {
fmt.Printf(" %v\n", doc)
}
deleted, _ := box.DeleteMany(ctx, query)
query = { "sku" => { "$in" => DISCONTINUED } }
puts " под снятие с продажи попало: #{BOX.count_documents(query)}"
BOX.find(query, projection: { "_id" => 1, "title" => 1, "price" => 1 })
.each { |doc| puts " #{doc.inspect}" }
report["снято с продажи"] = BOX.delete_many(query).deleted_count
под снятие с продажи попало: 1
{'_id': 'p-018', 'title': 'Блок питания be quiet! 650 Вт', 'price': 8490}
бесплатная доставка коснётся: 5 товаров
Отчёт о приёмке:
остатки обновлены 3
новых позиций 2
снято с продажи 1
бесплатная доставка 5
итого в каталоге 22
под снятие с продажи попало: 1
{ "_id" : "p-018", "title" : "Блок питания be quiet! 650 Вт", "price" : 8490 }
бесплатная доставка коснётся: 5 товаров
Отчёт о приёмке:
остатки обновлены 3
новых позиций 2
снято с продажи 1
бесплатная доставка 5
итого в каталоге 22
под снятие с продажи попало: 1
{"title":"Блок питания be quiet! 650 Вт","price":{"$numberInt":"8490"},"_id":"p-018"}
бесплатная доставка коснётся: 5 товаров
Отчёт о приёмке:
остатки обновлены 3
новых позиций 2
снято с продажи 1
бесплатная доставка 5
итого в каталоге 22
под снятие с продажи попало: 1
{"_id"=>"p-018", "title"=>"Блок питания be quiet! 650 Вт", "price"=>8490}
бесплатная доставка коснётся: 5 товаров
Отчёт о приёмке:
остатки обновлены 3
новых позиций 2
снято с продажи 1
бесплатная доставка 5
итого в каталоге 22
§2Самопроверка
Скрипт check.py проверяет не ваш код, а результат — состояние базы. Так же работает и приёмка: смотрят на данные, а не на текст программы.
Задача 1 · витрина каталога ✓ товаров в каталоге shop.products 21 ✓ ноутбуков 5 ✓ самый дорогой товар 'Ноутбук Apple MacBook Air 13' Задача 2 · приёмка поставки ✓ всего позиций в песочнице 22 ✓ новинка p-101 заведена 1 ✓ новинка p-102 заведена 1 ✓ снятый с продажи SKU-CP-018 удалён 0 ✓ бесплатная доставка у аксессуаров 5 ✓ остаток по SKU-NB-001 5 ✓ остаток по SKU-PH-006 12 ✓ остаток по SKU-PR-010 3 ✓ эталонная база shop не тронута 0 Пройдено 12 из 12
Последняя проверка — главная. Она смотрит, не появилось ли поле free_shipping в эталонной базе shop. Если появилось, значит, правки ушли не туда: в базе курса это лечится одной командой, в рабочей — разговором с руководителем.
§3Где писать решение и как сдать
Решения в папке practice01 — эталонные: они показывают, к чему прийти. Своё решение вы пишете в собственном репозитории на GitHub и сдаёте ссылкой на него.
mongodb-practice/
check.py — отсюда запускается проверка- Заведите свой репозиторийНа GitHub: New repository, имя вроде
mongodb-ivanov, без README. Один репозиторий на все работы модуля. - Склонируйте его в папку
workВнутриmongodb-practice:git clone https://github.com/<ваш-логин>/mongodb-ivanov.git work. Папкаworkисключена из репозитория практик — ваши файлы в него не попадут, а стенд и Docker видят их так же, как всё остальное. - Напишите решениеСоздайте папку
work/practice01и файлsolution.pysolution.cppsolution.gosolution.rb. Эталон изpractice01открывайте после своей попытки. Для Go один раз в папкеwork:go mod init mongodb-workиgo get go.mongodb.org/mongo-driver/v2/mongo. - Проверьте на чистых данныхСброс баз, запуск решения,
check.py— команды ниже. Должно получиться «Пройдено 12 из 12». - Опишите результатРядом с решением —
README.md: как запустить и какие числа получились. Пяти строк достаточно. - Отправьте и пришлите ссылкуИз папки
work:git add practice01,git commit -m "ПР-01: каталог товаров",git push. Преподавателю — ссылку на репозиторий.
mongodb-ivanov, без README и .gitignore, затем Create repositorydocker compose run --rm reset docker compose run --rm python work/practice01/solution.py docker compose run --rm python practice01/check.py # Пройдено 12 из 12
docker compose run --rm reset docker compose run --rm cpp work/practice01/solution.cpp docker compose run --rm python practice01/check.py # Пройдено 12 из 12
docker compose run --rm reset docker compose run --rm go work/practice01/solution.go docker compose run --rm python practice01/check.py # Пройдено 12 из 12
docker compose run --rm reset docker compose run --rm ruby work/practice01/solution.rb docker compose run --rm python practice01/check.py # Пройдено 12 из 12
work/practice01/solution.py, запуск и check.py — «Пройдено 12 из 12» (в кадре вместо вашего решения — эталонное)
work/practice01/solution.cpp, запуск и check.py — «Пройдено 12 из 12» (в кадре вместо вашего решения — эталонное)
work/practice01/solution.go, запуск и check.py — «Пройдено 12 из 12» (в кадре вместо вашего решения — эталонное)
work/practice01/solution.rb, запуск и check.py — «Пройдено 12 из 12» (в кадре вместо вашего решения — эталонное)Без Docker команды те же, что в начале страницы, только решение запускается из своей папки: cd work/practice01, а проверка — из practice01. Скриншот вывода check.py прикладывать не нужно: на приёмке решение запускается заново на свежих данных.
git push: папка practice01 с решением и README внутри неёПочему сортировка по двум ключам, а не по одному. Что вернёт функция для девятой страницы. Почему остатки меняются через $inc. Что произойдёт при повторном запуске скрипта приёмки. Ответ «так было в примере» не принимается — на эти четыре вопроса и написана работа.
Дальше
Следующий шаг — контрольная точка: та же предметная область, но без разбора и с отчётом, который читает человек, не заглядывающий в базу.