Привет, меня зовут Андрей, и я Python-разработчик образовательной платформы Учи.ру. Как и во многих компаниях, у нас есть потребность в регулярной аналитике. Часть данных анализируется в специализированных BI-системах или обрабатывается аналитиками вручную. Но иногда возникает необходимость в создании автоматизированного отчета со специфичными параметрами, удобным интерфейсом и возможностью частого обновления. В таких случаях мы разрабатываем отдельные веб-сервисы.

За время работы я написал несколько подобных сервисов, которые во многом похожи. В этом гайде я поделюсь опытом построения таких решений, ориентируясь на коллег уровня junior или middle. В этом проекте мы будем использовать Django для бэкенда и React для фронтенда.

Постановка задачи: аналитический сервис с параметрическим интерфейсом

Предположим, у нас есть аналитическая база данных с различными таблицами (витринами), содержащими данные в сырых и агрегационных форматах. Некоторые таблицы достаточно просмотреть или просто визуализировать. Другие же — "полусырые", предназначенные для построения различных отчетов, адаптируемых под потребности конкретного пользователя. Например, данные о платежах могут храниться в виде «пользователь-дата-сумма», что позволяет группировать их по датам, пользователям, или классам и школам.

Таким образом, на основе одной или нескольких таких таблиц наш сервис должен:

  1. Предоставлять интерфейс для задания параметров выгрузки или расчетов;

  2. Генерировать SQL-запросы на основе указанных параметров и отправлять их в базу;

  3. Отображать данные в интерфейсе или выгружать в файл с кешированием результатов и метаданных.

Где обрабатывать данные: на сервере или в базе?

В продвинутых командах обычно нет вопроса, где обрабатывать данные — в базе или на сервере. Обработка на стороне Python-бэка может показаться привлекательной из-за обилия библиотек, но в долгосрочной перспективе это будет неоптимально. Большие массивы данных загружать на сервер нецелесообразно:

  • Скорость передачи данных по сети значительно снизится на больших объемах;

  • Сеть может давать сбои, из-за чего запрос может завершиться ошибкой;

  • Память сервера ограничена, и большие запросы могут не вписаться в доступный объем памяти.

Поэтому рекомендую не бояться писать SQL-запросы и переносить логику расчетов на сторону базы данных, особенно для многомиллионных строк. Современные базы, такие как ClickHouse, эффективно обрабатывают данные в SQL-запросах. ClickHouse поддерживает расширенный SQL, позволяющий выполнять сложные операции над данными буквально за доли секунд. В этом проекте мы предположим, что выполнение SQL-запросов займет какое-то время, поэтому заранее настроим наш сервис для корректного ожидания результатов запроса.

Начало разработки

Создадим Django-проект и в нем приложение api_v0. В этом приложении будем настраивать обработку запросов из формы и обмен данными между бэкендом и базой данных.

> django-admin startproject analyticsapp
> cd analyticsapp
> python3 manage.py startapp api_v0
> ls

Как работать с большими формами

Обычно в базовых туториалах рассказывают о том, как обработать данные с небольшой формы (или вообще одного компонента). Когда у вас на форме 20-30 компонентов, необходимо позаботиться об их правильной обработке на бэке. Здесь на помощь приходит библиотека Pydantic, позволяющая упростить процесс.

Почему Pydantic?

Представим, что мы собираем JSON с параметрами на фронтенде и отправляем его на бэкенд. Вопросы, с которыми часто сталкиваются разработчики:

  • Все ли нужные параметры были переданы?

  • Совпадают ли типы данных?

  • Что делать с отсутствующими полями?

С Pydantic мы создаем модель, которая автоматически парсит JSON, подставляет значения по умолчанию для пропущенных полей и приводит данные к нужным типам. Это позволяет избежать многих ошибок и упростить разработку, особенно если редактор поддерживает подсказки типов.

Пример Pydantic-модели

Внутри api_v0 у нас есть файл models.py, где мы можем создать Pydantic-модель с параметрами запроса. Пусть у нас будут параметры: тип пользователя, имя, город, дата и время создания записи, есть подписка или нет. Для параметра “Тип” удобно воспользоваться встроенной библиотекой enum:

from pydantic import BaseModel
from enum import Enum
from datetime import datetime as dt
class UserTypeEnum(str, Enum):
    student = 'student'
    teacher = 'teacher'
    
class AnalyticsModel(BaseModel):
    type: UserTypeEnum = UserTypeEnum.student
    name: str
    city: str
    created_at: dt = dt.now() # по умолчанию время будет текущим
    is_premium: bool = True

Добавим view в файл views.py, которая будет принимать этот запрос из формы. Обратите внимание, request приходит в виде строки, но Pydantic одной командой парсит ее в модель:

from django.views import View
from django.http import JsonResponse

from .models import AnalyticsModel
from .helpers import get_data

class HandleAnalyticsRequest(View):
    def get(self, request) -> JsonResponse:
        parsed_model = AnalyticsModel.model_validate_json(request.body.decode('utf-8'))

        data = get_data(parsed_model)

        return JsonResponse({'data', data})

Модель заполнена, дальше по ней можно формировать запрос. Теперь поговорим про визуальную часть.

Интерфейс на React

В Django есть отличная система шаблонов: можно писать полноценные сайты с хорошей логикой, интерфейсами и всем прочим. Но я предпочитаю использовать отдельное приложение на React, так как:

  • react — популярная библиотека с интуитивным синтаксисом;

  • под react есть много готовых красивых библиотек компонентов. Я пользуюсь MUI — там есть все необходимые мне компоненты, плюс огромное число ответов на SO - нет шансов не разобраться. Шаблоны Django конечно же тоже можно стилизовать, но кажется, что это более трудоемко. При работе с MUI я даже не смотрю в стили;

  • API бэка занимается только обработкой запросов и не занимается рендером шаблонов, мы не смешиваем фронт и бэк в нашем коде.

Настройка React-приложения

Создадим папку frontend и выполним команды для инициализации react-приложения (мы будем делать это без фреймворка для react, как того требует документация, для упрощения):

> npm install create-react-app 
> npx create-react-app .

Теперь добавим view FrontendAppView, которая будет передавать наш фронтенд в браузер. Для этого откроем analyticsapp/urls.py и добавим следующую конфигурацию:

from django.urls import path, include, re_path

from . import views

urlpatterns = [
    path(r'api/v0/', include('api_v0.urls')), # нужно для работы api
    re_path(r'^', views.FrontendAppView.as_view()),
]

Далее создадим FrontendAppView в файле analyticsapp/views.py, которая будет отдавать статический файл index.html, сгенерированный react:

import os

from django.views.generic import View
from django.http import HttpResponse
from django.conf import settings

class FrontendAppView(View):
    def get(self, request):
            index_path = os.path.join(
                settings.REACT_APP_DIR, 'build', 'index.html'
            )
            with open(index_path) as f:
                return HttpResponse(f.read())

Настройки для интеграции react и Django

То есть при обращении к главному урлу нашего проекта мы отдаем содержимое index.html, который сгенерирован скриптом create-react-app и лежит в папке frontend/build/public. В этой вьюхе фигурирует настройка settings.REACT_APP_DIR, которая должна быть прописана в файле analyticsapp/settings.py. Я сразу приведу здесь все настройки, связанные с путями до фронта:

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MANAGE_DIR = os.path.abspath(os.path.join(BASE_DIR, os.pardir))
REACT_APP_DIR = os.path.join(MANAGE_DIR, 'frontend')

STATICFILES_DIRS = [
    os.path.join(REACT_APP_DIR, 'build', 'static'),
]

STATIC_URL = '/static/'
STATIC_ROOT = '/app/static/' 

В файле index.html есть тег div id=’root’ – это и есть точка входа для нашего фронта. Содержимым этого тега занимается уже сам react, и одним файлом мы, по сути, отдали весь фронтенд в браузер пользователя. У приложения react уже со стороны js точка входа находится в файле frontend/src/index.js. Там есть тег App, в котором собирается наш фронт из react-кода. Далее мы будем создавать нашу форму в файле App.js.

Установим MUI:

> npm install @mui/material @emotion/react @emotion/styled

Приведу текст формы с элементами для нашей модели:

import React, { useState } from "react";
import Checkbox from "@mui/material/Checkbox";
import Container from "@mui/material/Container";
import FormControl from "@mui/material/FormControl";
import FormControlLabel from "@mui/material/FormControlLabel";
import FormLabel from "@mui/material/FormLabel";
import Grid from "@mui/material/Grid";
import Radio from "@mui/material/Radio";
import RadioGroup from "@mui/material/RadioGroup";
import Button from "@mui/material/Button";
import Select from "@mui/material/Select";
import TextField from "@mui/material/TextField";
import MenuItem from "@mui/material/MenuItem";
import OutlinedInput from "@mui/material/OutlinedInput";

export default function App() {
  const cities = ["Москва", "Санкт-Петербург"];

  const [state, setState] = useState({
    type: "student",
    name: "",
    city: "Москва",
    is_premium: true,
  });

  const handleTypeChange = (value) => {
    setState({
      ...state,
      type: value,
    });
  };

  const handleCheckboxChange = (stateDesc) => {
    setState({
      ...state,
      [stateDesc]: !state[stateDesc],
    });
  };

  const handleCityChange = (value) => {
    setState({
      ...state,
      city: value,
    });
  };

  const handleNameChange = (value) => {
    setState({
      ...state,
      name: value,
    });
  };

  return (
    <div>
      <Container component="main" sx={{ my: 3 }} maxWidth="xl">
        <Grid container spacing={2}>
          <Grid item xs={8}>
            <FormControl>
              <FormLabel>Тип</FormLabel>
              <RadioGroup defaultValue="student" row>
                <FormControlLabel
                  value="student"
                  control={<Radio />}
                  label="Ученик"
                  onChange={(event) => handleTypeChange(event.target.value)}
                />
                <FormControlLabel
                  value="teacher"
                  control={<Radio />}
                  label="Учитель"
                  onChange={(event) => handleTypeChange(event.target.value)}
                />
              </RadioGroup>
            </FormControl>
          </Grid>
          <Grid item xs={8}>
            <FormControl sx={{ width: 500 }}>
              <FormLabel sx={{ my: 1 }}>Имя</FormLabel>
              <TextField
                onChange={(event) => handleNameChange(event.target.value)}
              />
            </FormControl>
          </Grid>
          <Grid item xs={8}>
            <FormControl sx={{ width: 500 }}>
              <FormLabel sx={{ my: 1 }}>Город</FormLabel>
              <Select
                value={state.city}
                onChange={(event) => handleCityChange(event)}
                input={<OutlinedInput />}
              >
                {cities.map((item) => (
                  <MenuItem key={item} value={item}>
                    {item}
                  </MenuItem>
                ))}
              </Select>
            </FormControl>
          </Grid>
          <Grid item xs={8}>
            <FormControlLabel
              control={
                <Checkbox
                  checked={state.is_premium}
                  onChange={() => {
                    handleCheckboxChange("is_premium");
                  }}
                />
              }
              label="Есть подписка"
            />
          </Grid>
          <Grid item xs={8}>
            <Button variant="contained">Отправить</Button>
          </Grid>
        </Grid>
      </Container>
    </div>
  );
}

Для отладки формы и просмотра ее в браузере необходимо выполнить команду npm start в папке frontend. Для того, чтобы фронт знал, где локально находится бэк, надо добавить в frontend/package.json такую строку:

"proxy": "http://127.0.0.1:8000",

Сборка запроса и работа с кэшем

Мы собрали форму и часть бэкенда, которая формирует запрос в базу. Теперь вопрос — как нам этот запрос запустить? Сейчас мы находимся внутри вьюхи. Она призвана ответить нам на запрос и завершиться. Отдельно никаких запросов она ждать не будет. Если мы попытаемся отправить этот запрос в базу, то он от браузера на сервер отрубится по таймауту либо самим браузером, либо окружением вашего бэкенда. Плюс к этому фронт должен понять, в каком статусе находится запрос, чтобы отобразить успех/ошибку с ожиданием. Сразу скажу, здесь нет одного правильного ответа. Попробую порассуждать о том, как тут лучше поступить, и объясню свой личный выбор:

  • можно создать асинхронный обработчик запроса и вызывать его, чтобы он обслуживал наш запрос, а вью вернет нам какой-нибудь ответ "pending". При всем уважении к async-вещам, django не полностью к нему приспособлен. Это хороший путь для fastapi, flask и подобных небольших фреймворков, но я бы не использовал async в связке с django, это может вылиться в дебаг неочевидных вещей, как, напимер, запросы через orm;

  • celery (или аналоги) — это библиотека для выполнения отложенных задач. Хорошая, надежная и классная. Мы создаем таск на запуск, ожидание и получение данных из базы, отправляем ее в celery и дальше все происходит само. Так можно, и многие назовут этот путь наиболее правильным. Меня смущает здесь лишь то, что я призываю на помощь довольно мощный инструмент только для того, чтобы мультиплексировать запросы в базу. На мой взгляд, гораздо органичнее и разумнее здесь воспользоваться потоками;

  • threads - как раз то, что нужно. Логика такая же, как и с async, только без async-контекста. Мы пишем простой обработчик, посылаем его в тред отрабатываться, да и все.

Не забудем про кеш

Пара слов об обработчике запроса. Для мониторинга состояния запроса нам нужен уникальный ключ. Для таких вещей хорошо подходит uuid4. Назовем его check_id.

Но если кто помнит, мы хотели кешироваться. Что будет ключом кеширования? Идентификатор uuid4 здесь не подходит, потому что на каждый запрос, вне зависимости от его параметров, он будет новый. На самом деле, вполне нормально сделать ключом кеша саму модель запроса целиком (точнее, ее хэш). Ведь если мы что-то поменяем в модели, это будет уже новый запрос.

Python еще в версии 3.7 сделал словари упорядоченными, а это значит, что распакованные в него данные всегда будут иметь один и тот же порядок. И мы можем сделать так: model -> dict -> str -> sha256 hash.

import hashlib

cache_id = hashlib.sha256(
    bytes(
         str(model.dict()), 'utf-8'
    )
).hexdigest()

Тривиальная отправка запроса в тред могла бы выглядеть так:

t = threading.Thread(
                target=handle_query, args=(cache_id, check_id,)
            )
t.start()

А сам обработчик запроса в простейшем варианте можно описать так:

def handle_query(cache_id, check_id): 
	try:
		# если такой запрос уже был, возвращаем его результат
		# здесь result - это django-модель, хранящая некий результат вычислений
		obj = result.objects.exists(pk=cache_id)
		If obj:
			return result.objects.get(pk=cache_id)

		# здесь выполняем запросы в базу и необходимые расчеты
		# в случае успеха сохраняем результат в базу и помечаем check_id как 
		# успешный
	except Exception as e:
		# если что-то пошло не так, то помечаем check_id как неуспешный

Сборка проекта с Docker

Для разворачивания приложения с Django и React на сервере используем Docker. Пример Dockerfile:

FROM node:16.18.1 as builder

ENV APP_PATH=/app/frontend

RUN mkdir /app
COPY frontend $APP_PATH
WORKDIR $APP_PATH

ENV PATH $APP_PATH/node_modules/.bin:$PATH

RUN npm install --silent
RUN npm run build

# main app
FROM python:3.10

RUN apt-get update && apt-get install gunicorn

COPY docker-entrypoint.sh /entrypoint.sh

ENV APP_PATH=/app

RUN mkdir $APP_PATH
WORKDIR $APP_PATH

COPY ./requirements.txt .

RUN pip3 install -U pip \
 && pip3 install --no-cache-dir gunicorn \
 && pip3 install --no-cache-dir -r requirements.txt

# copy built frontend
RUN mkdir -p frontend/build
COPY --from=builder /app/frontend/build /app/frontend/build

COPY . .
RUN chmod +x /entrypoint.sh

ENTRYPOINT [ "/entrypoint.sh" ]

EXPOSE 8080 8000
CMD ["gunicorn", "-b", "0.0.0.0:8000", "analyticsapp.wsgi:application"]

Этот Dockerfile создает два образа: первый собирает React-приложение, второй — запускает основное приложение с Gunicorn для обработки запросов.

Заключение

В статье я рассказал о некоторых моментах, с которыми сталкиваются при разработке аналитических сервисов для внутреннего использования. На мой взгляд, самая большая сложность для разработчиков уровня junior-middle — это интеграция фронтенда с Python-проектом. Работа с Pydantic, Celery и кэшированием в наши дни не вызывает вопросов, так как эти инструменты давно входят в стандартный набор для создания API.

Надеюсь, что эта статья станет отправной точкой для новичков, желающих создать собственные аналитические сервисы и поднять свои проекты на новый уровень.
Если ты хочешь развивать и создавать новые цифровые образовательные продукты для школьников, присоединяйся к команде Учи.ру. Актуальные вакансии доступны по
ссылке.

Комментарии (0)