Как я бэкенд для интернет-магазина пилил…

Привет, читатель! Это моя самая первая статья на тему программирования, на написание которой меня побудил интерес к микросервисной архитектуре.

Моя история начинается с конца июля 2023 года — того времени, когда я имея опыт программирования на Python после онлайн-курса пошёл на собеседование в компанию, занимающуюся продажей электротоваров. На собеседовании я встретился сразу с начальником IT отдела без каких-либо тестовых заданий и прочих проверок знаний перед этим. Я вкратце рассказал о своём опыте, после чего начал уточнять о том, что мне предстоит делать в случае трудоустройства, свою задачу опишу в следующем абзаце, а сейчас скажу, что это собеседование я прошёл и по сей день работаю в этой компании)

В общих чертах мне поставили задачу переписать уже реализованную систему интернет-магазина на современный стек технологий. Старая же система была оооочень странная по архитектуре и имела вряд-ли кому известный стек технологий в текущие будни) Например я услышал такие незнакомые ранее слова, как: язык программирования FoxPro, таблицы DBF (aka база данных). Помимо макаронной архитектуры итоговый сайт работал оооочень медленно и криво. Важно отметить, что в статье не будет никаких упоминаний о названии компании и иных ссылок на ту или иную реализацию.

В мои первые рабочие будни начальник меня познакомил с коллективом, показал рабочее место и мы с ним начали обсуждать задачу более детально. На моё удивление хоть на собеседовании было озвучено, что будет команда для разработки, но они целиком и полностью занимались фиксом багов и поддержкой старой системы, так что можно сказать на проекте я пока один. Для пущего хардкора у меня нет конкретного технического задания и ребят в команде, работающих с современной архитектурой, ТЗ вынужден формировать себе сам исходя из уточнений у начальства.

Первые строки кода…

Для начала я решил написать всё в монолитной архитектуре так как в силу своего опыта не имел дело с микросервисами и выбрал следующий стек технологий:

  1. Python

  2. FastAPI

  3. 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 эндпоинта.

  1. «Получить все товары.» На вход принимает структуру фильтрации товаров с числовыми и текстовыми характеристиками, строкой поиска и прочими фильтрующими параметрами. На выходе выдаёт список товаров.

  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 так как есть удобная штука с автогенерацией документации. Далее тот монолит, что описывал выше я решил разбить на микросервис «Каталог» и микросервис «Склады», в каталоге остаются категории, товары, характеристики, а в «Склады» переезжает промежуточная таблицы «товар-склад» и «склад» (Сервиса склада пока нет, но он будет))

Сервис «каталог» использует следующий стек технологий:

  1. Golang

  2. PostgreSQL

  3. GRPC

  4. Redis

  5. testify (Библиотека тестирования)

Этот сервис выделен для любых взаимодейстивий с товарами, категориями, характеристиками. Фасетный поиск товаров в нём реализован теми же двумя эндпоинтами, но поиск выполняется при помощи ElasticSearch. Далее расскажу более подробно об этом…

Сервис «индексатор» использует следующий стек технологий:

  1. Golang

  2. testify

Этот сервис подключается напрямую к БД каждого из микросервисов для того чтобы агрегировать информацию и при помощи брокера сообщений доставлять их в ElasticSearch

Где мой товар?

Теперь товары ищутся не в PostgreSQL, а в ElasticSearch. И начать стоит с загрузки товаров в него. Это происходит так: Товар добавляется синхронно через GRPC в БД сервиса «Каталог», далее сервис «Индексатор» раз в час получает товары из БД и при необходимости дополняет информацию о них из других сервисов, скажем добавляет в структуру товара дополнительное поле с информацией о наличии его на складах, далее дополненные информацией товары передаются в брокер сообщений Kafka. На этом работа сервиса «Индексатор» закончена.

Следом идёт работа LogStash, который по сути выгребает данные из топика Kafka и пушит их в ElasticSearch.

Таким образом у нас есть индекс (aka таблица в PostgreSQL) products в ElasticSearch, который содержит в себе следующего вида товары:

Пример товара в ElasticSearch

Пример товара в ElasticSearch

Таким образом запрос Юзера на поиск товаров с использованием того механизма, что я описывал выше будет выглядеть примерно так: Запрос прилетел на микросервис каталога, далее конструируется и отправляется запрос на поиск товаров в ElasticSearch, а те ID товаров, что он вернул используются для поиска в PostgreSQL. Запрос же на получение доступных фильтров так же отправляется в ElasticSearch напрямую возвращается обратно без использования PostgreSQL.

Что дальше…

В этой статье я слишком поверхностно разобрал нюансы разработки приложения, но оставляю надежду, что ты, читатель нашёл для себя что-то нужное, что сможешь использовать и в своих проектах :-)

Так как проект только на стадии активной разработки, думаю напишу вторую и очередные части с подробным разбором технологий и архитектуры :-)

© Habrahabr.ru