Как я бэкенд для интернет-магазина пилил…
Привет, читатель! Это моя самая первая статья на тему программирования, на написание которой меня побудил интерес к микросервисной архитектуре.
Моя история начинается с конца июля 2023 года — того времени, когда я имея опыт программирования на Python после онлайн-курса пошёл на собеседование в компанию, занимающуюся продажей электротоваров. На собеседовании я встретился сразу с начальником IT отдела без каких-либо тестовых заданий и прочих проверок знаний перед этим. Я вкратце рассказал о своём опыте, после чего начал уточнять о том, что мне предстоит делать в случае трудоустройства, свою задачу опишу в следующем абзаце, а сейчас скажу, что это собеседование я прошёл и по сей день работаю в этой компании)
В общих чертах мне поставили задачу переписать уже реализованную систему интернет-магазина на современный стек технологий. Старая же система была оооочень странная по архитектуре и имела вряд-ли кому известный стек технологий в текущие будни) Например я услышал такие незнакомые ранее слова, как: язык программирования FoxPro, таблицы DBF (aka база данных). Помимо макаронной архитектуры итоговый сайт работал оооочень медленно и криво. Важно отметить, что в статье не будет никаких упоминаний о названии компании и иных ссылок на ту или иную реализацию.
В мои первые рабочие будни начальник меня познакомил с коллективом, показал рабочее место и мы с ним начали обсуждать задачу более детально. На моё удивление хоть на собеседовании было озвучено, что будет команда для разработки, но они целиком и полностью занимались фиксом багов и поддержкой старой системы, так что можно сказать на проекте я пока один. Для пущего хардкора у меня нет конкретного технического задания и ребят в команде, работающих с современной архитектурой, ТЗ вынужден формировать себе сам исходя из уточнений у начальства.
Первые строки кода…
Для начала я решил написать всё в монолитной архитектуре так как в силу своего опыта не имел дело с микросервисами и выбрал следующий стек технологий:
Python
FastAPI
PostgreSQL
Примерная схема БД
На моё удивление я быстро написал методы API к такой структуре БД, даже успел накинуть тесты) Эта схема выглядит вполне расширяемой в случае если потребовалось бы добавить какую-либо другую таблицу. Но есть как минимум 1 существенный недостаток, такой как категории и разделы. В такой реализации уровень вложенности у товаров достигает 2-х уровней, ни меньше, ни больше. К тому же если приглядеться, то категория и раздел по сути описывают примерно одинаковый набор полей.
Переработка категорий
Так то лучше… Теперь имеются рекуррентные категории, что позволяет делать неограниченный уровень вложенности и лучший поиск товаров для клиентов.
Что там по фильтрации и поиску товаров?
К слову о поиске: Обнаружил на сайтах по схожей тематике поиск по характеристикам товаров, такой поиск называется «Фасетный», когда например мы указываем, что хотим видеть список товаров, у которых «Цвет красный или зелёный, а так же цена от 100 до 450 рублей»
Фильтр по характеристикам в одном из интернет-магазинов
Пример части фасетного поиска по характеристикам товаров. Обратить можно внимание на следующие немаловажные вещи: Логика при указании двух и более характеристик одновременно «И», а при указании значений в одной из характеристик «ИЛИ». Есть характеристики с числовым диапазоном значений и характеристики с выбором нескольких из доступных значений. Так же если поиграться с этими фильтрами — можно заметить такую интересную вещь, как если нажимать на характеристики, то они имеют свойство становиться серыми, говоря о том, что при текущем наборе характеристик и их значений, указание серых будет бесполезным в виду того, что в выбранных товарах путём фильтрации их попросту нет.
БД с характеристиками
Теперь в БД есть рекуррентные категории и характеристики, хоть сейчас в продакшн отправляй) поле «values_type» у характеристики имеет enum поле с одним из значений («numeric», «text»)
SELECT
min(rp.price) min_product_price,
max(rp.price) max_product_price,
pc.characteristic_id,
string_agg(DISTINCT pc.text_value::TEXT, ','),
min(pc.numeric_value),
max(pc.numeric_value)
FROM (
SELECT *
FROM product AS p
WHERE (
p.category_id = 'd3659c13-3a5e-4f42-b068-3a91936ad3fb'
AND
p.price BETWEEN 0 AND 120
AND
p.id = ANY (
SELECT product_id
FROM product_characteristic AS pc
WHERE pc.characteristic_id = 'a47667b0-39f3-40e8-bbc0-4374ac78f6d6' AND pc.text_value IN ('Красный', 'Жёлтый')
)
AND
p.id = ANY (
SELECT product_id
FROM product_characteristic AS pc
WHERE pc.characteristic_id = 'd3cf68ca-0aa6-48fe-ab7e-3e59748cf87c' AND pc.numeric_value BETWEEN 25 AND 1000
)
)
) AS rp
LEFT JOIN product_characteristic AS pc ON rp.id = pc.product_id
GROUP BY characteristic_id
SQL — запрос выше — получение агрегированной информации для каждой id характеристики в т.ч. и NULL о том какие минимальные и максимальные цены товаров есть какие уникальные текстовые значения есть у товаров этой характеристики, а так же минимальное и максимальное числовое значение для этой характеристики. Если во внешнем SELECT агрегируется информация по характеристикам товаров, то во внутреннем SELECT фильтруются сами товары. PS: в примере в качестве id используются UUID вместо числовых.
Вот сейчас будет сложно: для реализации фасетного поиска с той фишкой, когда юзер кликает на одну из характеристик, а некоторые другие характеристики становятся серым при ужесточении круга поиска, нам нужны 2 эндпоинта.
«Получить все товары.» На вход принимает структуру фильтрации товаров с числовыми и текстовыми характеристиками, строкой поиска и прочими фильтрующими параметрами. На выходе выдаёт список товаров.
«Применить фильтры». На вход получает всё ту же структуру фильтрации товаров, что и первый эндпоинт, а на выходе даёт не товары, а ту же структуру фильтрации, что могут принять на вход эти два эндпоинта, но с неким логическим значением «Вот эти характеристики и значения ты можешь использовать для ДАЛЬНЕЙШЕЙ фильтрации»
Логика реализации фасетного поиска со стороны фронтенда будет выглядеть примерно так:
Юзер перешёл в список товаров категории «Электроинструменты»
Отправляются параллельно 2 запроса на «Получить все товары.» и «Применить фильтры»
// Request "Получить все товары."
{
"limit": 10,
"offset": 0,
"category_id": "d3cf68ca-0aa6-48fe-ab7e-3e59748c324dv"
}
// Response "Получить все товары."
[
{
"id": "...",
"name": "...",
...
},
...
]
// Request "Применить фильтры"
{
"limit": 10, // Здесь лимит и оффсет игнорируются
"offset": 0,
"category_id": "d3cf68ca-0aa6-48fe-ab7e-3e59748c324dv"
}
// Response "Применить фильтры"
{
"price": {
"from": 12.34,
"to": 43354.25
},
"characteristics": {
"numeric_values": [
{
"id": "...", // id характеристики "Вес товара, кг"
"values": {
"from": 1,
"to": 45
}
},
...
],
"text_values": [
{
"id": "...", // id характеристики "Цвет"
"values": ["Красный", "Синий"]
},
...
]
}
}
Ок, на этом этапе юзер получил и список товаров и список доступных для фильтрации характеристик. Теперь если он захочет уточнить свой поиск и указать, что хочет видеть товары с ценой от 0 до 500 рублей и у которых цвет красный и у которых вес до 30 кг, то отправит очередные 2 параллельных запроса на эти 2 эндпоинта со сделующим содержанием:
// Request "Применить фильтры" и "Получить все товары."
{
"limit": 10, // Здесь лимит и оффсет игнорируются
"offset": 0,
"category_id": "d3cf68ca-0aa6-48fe-ab7e-3e59748c324dv",
"price": {
"from": 0,
"to": 500
},
"characteristics": {
"numeric_values": [
{
"id": "...", // id характеристики "Вес товара, кг"
"values": {
"to": 30
}
}
],
"text_values": [
{
"id": "...", // id характеристики "Цвет"
"values": ["Красный"]
}
]
}
}
// Response для краткости описывать не буду, "Получить все товары." выплюнет товары,
// а "Применить фильтры" выплюнет фильтры, но в меньшем кольчестве, чем при первом запросе
Что там по скорости?
По скорости у меня есть несколько замечаний к реализации ранее.
Первое — это монолит на Python, что само по себе вызовет замыкание процессора на том сервере, где будет находиться этот код. Именно поэтому я решил переписать всю эту кухню на Golang в микросервисной архитектуре. Golang более рационально использует процессорное время и заточен под лёгкую асинхронно-параллельную работу, вдобавок, что меня лично сильно радует, так это строгая типизация в нём, это позволяет не переживать о том, что попадёт в аргументы функции или переменной.
Второе — это конечно скорость поиска. В тестовую БД я залил порядка 45_000 товаров и по хардкору решил применить фасетный и полнотекстовый поиск к ним. Результаты ожидаемо были фатальными. Один лишь поиск по триграммам с индексацией заставлял ждать доооолгое время. Не говоря уже о эндпоинте применения фильтров. PS если упустить такой нюанс как скорость полнотекстового поиска в БД, то тут есть проблема с его неполноценностью…
Ура! Ура! Микросервисы
Хочу признаться, что до переписывания ранее описанного монолита я никогда не писал в микросервисной архитектуре, тем более один. Вдобавок на Golang писал не так много. Так что в этот момент я явно чувствовал страх перед допущением ошибок в архитектуре и корявом коде на Golang. Но сейчас я чувствую уверенность в этих двух вещах, пусть даже не знаю абсолютно все тонкости и нюансы, которые можно использовать.
Пожалуй сразу предоставлю схему той архитектуры, что получилась
Текущая архитектура
Итак… Для микросервсисов характерен такой паттерн, как API-Gateway, его я решил написать на Python + FastAPI так как есть удобная штука с автогенерацией документации. Далее тот монолит, что описывал выше я решил разбить на микросервис «Каталог» и микросервис «Склады», в каталоге остаются категории, товары, характеристики, а в «Склады» переезжает промежуточная таблицы «товар-склад» и «склад» (Сервиса склада пока нет, но он будет))
Сервис «каталог» использует следующий стек технологий:
Golang
PostgreSQL
GRPC
Redis
testify (Библиотека тестирования)
Этот сервис выделен для любых взаимодейстивий с товарами, категориями, характеристиками. Фасетный поиск товаров в нём реализован теми же двумя эндпоинтами, но поиск выполняется при помощи ElasticSearch. Далее расскажу более подробно об этом…
Сервис «индексатор» использует следующий стек технологий:
Golang
testify
Этот сервис подключается напрямую к БД каждого из микросервисов для того чтобы агрегировать информацию и при помощи брокера сообщений доставлять их в ElasticSearch
Где мой товар?
Теперь товары ищутся не в PostgreSQL, а в ElasticSearch. И начать стоит с загрузки товаров в него. Это происходит так: Товар добавляется синхронно через GRPC в БД сервиса «Каталог», далее сервис «Индексатор» раз в час получает товары из БД и при необходимости дополняет информацию о них из других сервисов, скажем добавляет в структуру товара дополнительное поле с информацией о наличии его на складах, далее дополненные информацией товары передаются в брокер сообщений Kafka. На этом работа сервиса «Индексатор» закончена.
Следом идёт работа LogStash, который по сути выгребает данные из топика Kafka и пушит их в ElasticSearch.
Таким образом у нас есть индекс (aka таблица в PostgreSQL) products в ElasticSearch, который содержит в себе следующего вида товары:
Пример товара в ElasticSearch
Таким образом запрос Юзера на поиск товаров с использованием того механизма, что я описывал выше будет выглядеть примерно так: Запрос прилетел на микросервис каталога, далее конструируется и отправляется запрос на поиск товаров в ElasticSearch, а те ID товаров, что он вернул используются для поиска в PostgreSQL. Запрос же на получение доступных фильтров так же отправляется в ElasticSearch напрямую возвращается обратно без использования PostgreSQL.
Что дальше…
В этой статье я слишком поверхностно разобрал нюансы разработки приложения, но оставляю надежду, что ты, читатель нашёл для себя что-то нужное, что сможешь использовать и в своих проектах :-)
Так как проект только на стадии активной разработки, думаю напишу вторую и очередные части с подробным разбором технологий и архитектуры :-)