Однажды в поисках примера на React/Django я нашел на Хабре одну интересную статью. Она показалась мне полезной, и я решил дополнить пример из статьи новыми возможностями. В этом сиквеле мы добавим в веб-проект со списком студентов поддержку авторизации и real-time уведомлений на сокетах, улучшим систему Docker-сборки, оптимизируем модель очередей на RabbitMQ и немного пригладим косметику. В результате получим удобный базовый шаблон, с которого вы сможете начинать свои проекты.

Введение

Типовой набор инструментов для fullstack-разработки состоит из разных полезностей, включая веб-фреймворки, брокеры очередей, системы сборки, развертывания, а также фронтовые библиотеки, UI-виджеты и многое другое. Несмотря на снижение порога входа веб-разработку новичкам порой непросто разобраться в этом разнообразии и тем более собрать все в единый проект. Целью данной статьи является создание простого шаблона из минимального набора полезных инструментов, которые могут стать неплохой стартовой площадкой для решения более серьезных задач.

В статье будет много кода и текста, поэтому желающие могут найти ссылку на готовый проект в конце статьи.

Начальные знания

Данная статья подразумевает наличие базовых знаний веб-разработки на Django, фронт-энд разработки на React, умение запускать и настраивать контейнеры Docker, а также основы реверс-прокси Nginx. Также, все скрипты и примеры кода адаптированы под запуск в среде Ubuntu 20.04. Если вы впервые работаете с одним из перечисленных инструментов, то это не помешает вам пройти туториал, но все же имеет смысл предварительно изучить соответствующие “Hello-world” материалы:

Общая структура

Наш туториал будет состоять из проекта на Django для отгрузки списка студентов и авторизации, фронт-энда на React, сокет-сервера на Node.js для real-time уведомлений об изменениях и связующего брокера сообщений на RabbitMQ. Конечно, можно взять за основу Django Channels в ASGI-режиме и упростить себе задачу, но для лучшего понимания механики взаимодействия мы обойдемся без лишних плюшек и соберем все своими руками.

Основные сценарии

Давайте рассмотрим 3 основных сценария, отражающих типовые механизмы взаимодействия между элементами нашего проекта.

  • Авторизация. Для авторизации пользователей будет использоваться базовый механизм Django Session Authentication. Это удобный механизм, который позволяет выдать пользователю токен сессии в обмен на связку логин/пароль, и этот токен используется во всех последующих запросах. Токен автоматически сохраняется в Cookies и требует минимальных усилий на стороне фронта. Помимо пользователей, нам потребуется авторизировать сокетное соединение, чтобы злоумышленники не смогли перехватывать уведомления. Это также будет реализовано с помощью проверки токена-сессии.

  • Добавление нового студента. При добавлении карточки нового студента его аватар загружается на сервер Django. При этом одновременно происходит генерация миниатюры. Эту задачу выполняет отдельный Worker (оформленный в виде Django management command), работающий в фоновом режиме параллельно с Django. Уведомление о поступлении новой карточки посылается из Django в Worker через очередь RabbitMQ (ключ to_resize).

  • Уведомление об изменениях. После добавления нового студента и создании миниатюры необходимо отправить уведомление на все браузеры, которые подключены в данный момент. В противном случае, пользователям придется постоянно обновлять страницу и ждать прогрузки данных, а это зачастую недопустимо. Для этого мы будем использовать сокетный сервер на Node.js, который будет рассылать уведомления в браузеры активным пользователям при получении сообщения с ключом to_sockets, отправляемые из Worker-а через RabbitMQ.

Временная диаграмма событийного взаимодействия

Среда разработки

Тщательно продуманная среда разработки — это залог комфортной и эффективной работы. Поэтому при формировании окружения нам хотелось бы максимально использовать возможности мгновенной отладки Django и Webpack/React, чтобы правки в коде сразу отражались в интерфейсе, а также избежать необходимости установки любых вспомогательных инструментов (Nginx, RabbitMQ, PostgreSQL). Кроме этого, было бы неплохо, если среды развертывания и отладки были идентичны с точки зрения конфигурации и требовали минимальных изменений.

В итоге для работы над проектом нам потребуется установить только Django, Node.js и их зависимости, а остальное будет запущено в Docker-контейнерах. В режиме развертывания же все компоненты будут запущены в контейнерах.

Давайте рассмотрим несколько вариантов окружений с точки зрения описанных выше требований.

Вариант 1 - все напрямую. Один из наиболее очевидных вариантов — это настроить все запросы напрямую на те endpoint-ы, где они выполняются, т.е. REST-запросы на Django-сервер, index.html запросы на Webpack-сервер, а сокеты на сокетный сервер. Такой вариант доступен “из коробки” и не требует настройки реверс-прокси (Nginx). Но нам потребуется аккуратно прописать CORS-разрешения и прочие ограничительные Header-ы (Allowed Hosts и пр.), поскольку такие запросы по умолчанию не разрешаются политикой безопасности браузера. Кроме того, при переходе в режим развертывания необходимо будет добавить коммутацию endpoint-ов в React-приложении, чтобы фронт-энд переключался в production-режим и стучался на правильные адреса. Все это несложно настроить, но противоречит нашей цели унифицировать среду разработки и развертывания.

Среда разработки (вариант 1 - все напрямую)

Вариант 2 (все через Webpack proxy). Более продвинутый вариант основан на использовании отладочного webpack-proxy, когда запросы приходящие на Webpack перенаправляются либо на REST-сервер Django, либо на сокетный сервер, либо обслуживаются локально из статики. В данном случае с точки зрения фронтэнда различия в режиме разработки и развертывания сводятся к минимуму (т.е. все endpoint-ы остаются одинаковыми), но могут возникнуть определенные сложности с проксированием сокетного соединения (см. ссылку на ошибку). Конечно упомянутая сложность носит локальный и разрешимый характер, мы бы хотели рассмотреть еще более продвинутый вариант и сразу в режиме разработки использовать реверс-прокси Nginx. Это позволит максимально синхронизировать окружения разработки и развертывания и избежать возможных сложностей в будущем.

Среда разработки (вариант 2 - все через Webpack proxy)

Вариант 3 - основной (все через nginx). Наша среда разработки будет включать Nginx как основной маршрутизатор запросов. Помимо унификации режимов разработки, мы сможем использовать дополнительные возможности Nginx, такие как авторизация сокетных запросов с помощью auth_request и ключа сессии. Это позволит на этапе разработки попутно отладить механизм авторизации сокетов.

Среда разработки (вариант 3 - все через Nginx)

Вариант 4 (режим развертывания). В режиме развертывания будут добавлены несколько изменений. Поскольку Django будет запущен в production-режиме (DEBUG=False), отгрузка медиа-статики (пользовательских аватаров в папке media) осуществляться не будет, и мы настроим отгрузку медиа-статики через Nginx. Также, bundle-сборка React проекта будет отгружаться через Nginx и отладочный Webpack-сервер нам больше не потребуется. Помимо этого, будет сделано несколько вспомогательных изменений, типа переключения БД с SQLite на Postgres, но они потребуют уже минимальных усилий.

Среда разработки (режим развертывания)

Настройка проекта

Теперь мы можем приступить к самому проекту. Для начала установим зависимости - Docker, Docker-compose, Python3.10, Pip, Django:

Код 1 - установка зависимостей
# Docker
sudo apt-get install apt-transport-https ca-certificates curl gnupg-agent software-properties-common
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io

# Docker-compose v1
sudo curl -L "https://github.com/docker/compose/releases/download/1.25.5/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
sudo ln -s /usr/local/bin/docker-compose /usr/bin/docker-compose

# Python3.10 + Pip
sudo apt install software-properties-common
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt install python3.10
curl -sS https://bootstrap.pypa.io/get-pip.py | python3.10

# Django
pip install django==4.2.4

Также, нам понадобится node.js. Но мы установим его через среду nvm, которая позволяет динамически переключаться между версиями ноды.

Код 2 - установка node.js
curl https://raw.githubusercontent.com/creationix/nvm/master/install.sh | bash
source ~/.bashrc
nvm install 18.16.0

Теперь создадим корневую папку туториала и заведем в ней проекты на Django и React, а также вспомогательные подпапки для хранения медиаданных (аватарок пользователей). Для создания проекта на React мы будем использовать инструмент create-react-app, который позволяет избежать boilerplate-настроек и автоматически подключает такие полезные инструменты как Webpack (для отладки и сборки js-проекта) и Babel (для транспиляции js-кода, обеспечивающей обратную совместимость с устаревшими версиями браузеров).

Код 3 - настройка структуры проекта
# Папки проекта
mkdir demo
cd demo

# Инициализация Django-проекта
django-admin startproject django_project

# Папки для media-файлов
mkdir django_project/media
mkdir django_project/media/photo

# Инициализация React-проекта
npm install create-react-app
npx create-react-app reactapp

Можно также сразу удалить папки /demo/node_modules и файлы /demo/package.json и /demo/package-lock.json, чтобы не путаться с аналогичными в папке /demo/reactapp

Код 4 - удалить лишнее
rm -rf node_modules/
rm package.json
rm package-lock.json

Создадим папку для настроек nginx в режимах development и production.

Код 5 - директории nginx
mkdir nginx
mkdir nginx/development
mkdir nginx/production

В файле /demo/nginx/development/nginx.conf заведем следующие настройки:

Код 6 - /demo/nginx/development/nginx.conf
server {
    listen 80;
    server_name _;
    server_tokens off;
    client_max_body_size 20M;

    location / {
        proxy_pass http:// 172.17.0.1:3000/;
    }

    location /api {
        try_files $uri @proxy_api;
    }
    location /admin {
        try_files $uri @proxy_api;
    }

    location @proxy_api {
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Url-Scheme $scheme;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        proxy_redirect off;
        proxy_pass   http:// 172.17.0.1:8000;
    }

    location /media {
        autoindex on;
        alias /app/backend/server/media/;
    }

    location /django_static/ {
        autoindex off;
        alias /app/backend/server/django_static/;
    }

    location /ws {
        auth_request /auth;
        proxy_pass http:// 172.17.0.1:8010/;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;        
        proxy_set_header Connection "upgrade";
    }

    location /auth {
        proxy_set_header Content-Type 'application/json;charset=utf-8';
        proxy_pass http:// 172.17.0.1:8000/api/students/;
    }


}

Здесь стоит обратить внимание на основные правила маршрутизации:

  • Все корневые запросы / пойдут на отладочный Webpack сервер на порту 3000, который будет возвращать статику React-проекта. Поскольку Nginx будет запущен в контейнере, то для доступа к сервису, запущенному на хостовой машине используется адрес http://172.17.0.1:3000/;

  • Запросы на путь /api (REST API Django) и /admin (Django-админка) будут направлены на сервер Django на порту 8000 через @proxy_api;

  • Запросы на медиа-статику (аватарки) будут подгружаться Nginx из локальной папки /app/backend/server/media, которую мы пробросим через Docker Unnamed Volume к папке с проектом Django на хостовой машине;

  • Запросы на Django-статику /django_static для джанговской админки перенаправим в папку на хостовой машине аналогичным образом;

  • Запросы на сокет-сервер Node.js /ws будут перенаправлены на хостовый порт 8010. При этом стоит особо обратить внимание на упомянутую ранее директиву auth_request – она позволяет осуществить авторизацию сокетного соединения путем встраивания промежуточного запроса на endpoint /auth (перенаправляет на путь /api/students/ в Django) с теми же Cookie-параметрами, которые пришли в запросе на установление сокетного соединения, причем в них уже будет содержаться ключ с токеном сессии. В случае если auth_requst вернет код 200 (т.е. запрос успешный), то сокетное соединение будет перенаправлено на хостовый порт 8010 Node.js, в ином случае соединение будет сброшено на уровне Nginx. Это позволит нам осуществить механизм авторизации сокетов используя готовый механизм токенов сессии без лишнего кода на стороне Node.js.

Теперь сформируем /demo/dev.docker-compose.yml файл для запуска отладочного окружения

Код 7 - /demo/dev.docker-compose.yml
version: '2'

services:
    nginx: 
        restart: unless-stopped
        container_name: demo_nginx
        build:
            context: .
            dockerfile: ./Dockerfile.nginx
        ports:
            - 80:80
        volumes:
            - ./django_project/django_static:/app/backend/server/django_static
            - ./django_project/media:/app/backend/server/media
            - ./nginx/development:/etc/nginx/conf.d

    rmq:
        image: rabbitmq:3.10-management
        restart: always
        container_name: demo_rmq
        environment:
          - RABBITMQ_DEFAULT_USER=${RMQ_USER}
          - RABBITMQ_DEFAULT_PASS=${RMQ_PASS}
        volumes:
          - rabbitmq_data_volume:/var/lib/rabbitmq/
        ports:
          - 1234:15672
          - 5671-5672:5671-5672
        env_file:
            - ./.env

volumes:
    static_volume: {}
    rabbitmq_data_volume: {}

Как мы и обсуждали у нас будет запущено 2 сервиса – Nginx и RabbitMQ. Для сборки контейнера Nginx будем использовать файл Dockerfile.nginx, чтобы в нем сразу собиралась статика для React проекта (понадобится в будущем для production-режима). Также подключим папки media и django_static для отгрузки медиа и статики админки (как обсуждалось выше) и пропишем путь к development/nginx.conf.

Сервис RabbitMQ будет запускаться из готового образа без отдельного Dockerfile, а параметры настроек, логина и пароля подгрузим из .env файла.

Теперь создадим /demo/Dockerfile.nginx

Код 8 - /demo/Dockerfile.nginx
FROM node:18.16.0-alpine as build

WORKDIR /app/frontend
COPY ./reactapp/package.json ./
COPY ./reactapp/package-lock.json ./
RUN npm ci --silent
COPY ./reactapp/ ./
RUN npm run build

FROM nginx:stable-alpine
COPY --from=build /app/frontend/build /usr/share/nginx/html
CMD ["nginx", "-g", "daemon off;"]

Здесь осуществляется т.н. двух-этапная (2 stage) Docker-сборка, т.е. сначала собирается статика React проекта, а потом результаты сборки кладутся в отдельный образ Nginx. Это позволяет не тянуть в конечный образ лишние зависимости.

Также заведем файл с переменными окружения /demo/.env

Код 9 - /demo/.env
# Поменять!
SECRET_KEY=a38d5044-e78a-416e-9bde-8aeabc598286

DEBUG=0

ALLOWED_HOSTS=*

# Поменять POSTGRES_USER и POSTGRES_PASSWORD!
POSTGRES_ENGINE=django.db.backends.postgresql
POSTGRES_DB=django_db
POSTGRES_USER=admin
POSTGRES_PASSWORD=password
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
DATABASE=postgres

# Поменять RMQ_USER и RMQ_PASS!
RMQ_HOST=rmq
RMQ_PORT=5672
RMQ_USER=admin
RMQ_PASS=admin

Обязательно поменяйте SECRET_KEY, он необходим для защиты содержимого Django БД, а также пароли от RabbitMQ (секция RMQ) и POSTGRES.

Django проект

Приступим к Django-проекту. Для начала создадим приложение students внутри Django-проекта.

Код 10 - создаем Django приложение (students)
# В папке demo/django_project
python manage.py startapp students

И создадим модель студентов в файле /demo/django_project/students/models.py

Код 11 - /demo/django_project/students/models.py
from django.db import models

class Student(models.Model):
    name = models.CharField("Name", max_length=240)
    email = models.EmailField()
    document = models.CharField("Document", max_length=20)
    phone = models.CharField(max_length=20)
    registrationDate = models.DateField("Registration Date", auto_now_add=True)
    photo = models.CharField("URL", max_length=512)

    def __str__(self):
        return self.name

Сразу создадим и прогоним миграции. Они будут сгенерированы для режима отладки в базе SQLite. Для прогона миграций в Postgresql в production-режиме будет использоваться Dockerfile, мы создадим его позже.

Код 12 - создаем миграции
python manage.py makemigrations
python manage.py migrate

Модель представляет собой профиль студента с именем, электронной почтой и несколькими дополнительными полями.

Заведем фикстуры для отладки в файле /demo/django_project/fixtures/student.json

Код 13 - /demo/django_project/fixtures/student.json
[
{
    "model": "students.student",
    "pk": 1,
    "fields": {
        "name": "Mark Castillo",
        "email": "mark.castillo@example.com",
        "document": "---",
        "phone": "(790) 550-2323",
        "registrationDate": "2023-08-29",
        "photo": "/media/photo/f5bd4ecb-aaf7-4d4c-ade3-c35c289297d7.jpeg"
    }
},
{
    "model": "students.student",
    "pk": 2,
    "fields": {
        "name": "Margie Marshall",
        "email": "margie.marshall@example.com",
        "document": "---",
        "phone": "(522) 796-6968",
        "registrationDate": "2023-08-29",
        "photo": "/media/photo/5fad3cb0-e7c2-4074-a869-1502a8c517d3.jpeg"
    }
},
{
    "model": "students.student",
    "pk": 3,
    "fields": {
        "name": "Kylie Bishop",
        "email": "kylie.bishop@example.com",
        "document": "---",
        "phone": "(732) 324-6893",
        "registrationDate": "2023-08-30",
        "photo": "/media/photo/93d42334-cce8-4a2e-a54f-bcff9bdf4dc7.jpeg"
    }
},
{
    "model": "students.student",
    "pk": 4,
    "fields": {
        "name": "Carlos Hudson",
        "email": "carlos.hudson@example.com",
        "document": "---",
        "phone": "(801) 431-1682",
        "registrationDate": "2023-09-02",
        "photo": "/media/photo/3c4894e5-5202-45b3-ad65-9ae4ec5e1eda.jpeg"
    }
}
]

И прогрузим их в базу

python manage.py loaddata fixtures/students.json --app students

Теперь создадим View в папке /demo/django_project/students/views.py

Код 14 - /demo/django_project/students/views.py
from rest_framework.response import Response
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from django.contrib.auth import authenticate, login, logout
from rest_framework import status
from .serializers import *
from utils import connect
import uuid
import base64
import json

_, channel = connect()

@api_view(['POST',])
@permission_classes([AllowAny])
def login_view(request):
    serializer = LoginRequestSerializer(data=request.data)
    if serializer.is_valid():
        authenticated_user = authenticate(**serializer.validated_data)
        if authenticated_user is not None:
            login(request, authenticated_user)
            return Response({'status': 'Success'})
        else:
            return Response({'error': 'Invalid credentials'}, status=403)
    else:
        return Response(serializer.errors, status=400)

def logout_view(request):
    logout(request)

@api_view(['GET', 'POST'])
def students_list(request):
    if request.method == 'GET':
        data = Student.objects.all()
        serializer = StudentSerializer(data, context={'request': request}, many=True)
        return Response(serializer.data)
    elif request.method == 'POST':
        serializer = StudentSerializer(data=request.data)
        if serializer.is_valid():
            student = serializer.save()
            if bool(request.FILES.get('file', False)) == True:
                save_image(student.pk, request.FILES['file'])
            return Response(status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

@api_view(['PUT', 'DELETE'])
def students_detail(request, pk):
    try:
        student = Student.objects.get(pk=pk)
    except Student.DoesNotExist:
        return Response(status=status.HTTP_404_NOT_FOUND)
    if request.method == 'PUT':
        serializer = StudentSerializer(student, data=request.data, context={'request': request})
        if serializer.is_valid():
            serializer.save()
            if bool(request.FILES.get('file', False)) == True:
                save_image(student.pk, request.FILES['file'])
            return Response(status=status.HTTP_204_NO_CONTENT)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
    elif request.method == 'DELETE':
        student.delete()
        return Response(status=status.HTTP_204_NO_CONTENT)

def save_image(pk, file):
    global channel
    filename = str(uuid.uuid4()) + '.' + file.name[file.name.rfind(".") + 1:]
    msg = {"filename": filename, "image": base64.b64encode(file.read()).decode("UTF-8"), "pk": pk}
    try:
        if (channel == None) or (channel.is_open == False): 
            _, channel = connect(retry = False)
        if (channel != None) and (channel.is_open == True):
            channel.basic_publish(exchange='', routing_key='to_resize', \
                    body=bytes(json.dumps(msg), "UTF-8"))
    except:
        print("Connection error")

Здесь реализованы типовые методы для добавления, изменения и отображения списка студентов, а также методы для логина и логаута. Стоит обратить внимание на пару моментов:

  • Декоратор permission_classes([AllowAny]) перед методом login_view необходим для отключения механизма SessionAuth, иначе пользователь не сможет залогиниться.

  • Глобальная переменная channel содержит указатель на соединение с RabbitMQ. Он необходим для отправки сообщений на генерацию миниатюры аватарки через метод save_image().

  • Сам метод save_image() вызывается в методах student_list и students_detail в случае если в карточке пользователя присутствует аватарка. В таком случае происходит подключение к каналу RabbitMQ и отправка запроса на создание миниатюры изображения с помощью функции channel.basic_publish().

  • Дополнительно, стоит пояснить что поскольку мы используем Django в WSGI режиме, то асинхронные вызовы и подключения нам недоступны. Поэтому мы вынуждены использовать синхронное блокирующее подключение к RabbitMQ, оно не осуществляет отправку heartbeat-ов, поэтому если в течение интервала heartbeat-а не отправлено ни одного сообщения, то соединение разрывается. В связи с этим в методе save_image происходит проверка на активность соединения и если оно разорвано, то происходит повторное подключение. Сам дескриптор канала хранится в глобальной переменной channel, чтобы при повторных REST-запросах переиспользовать соединение пока оно не разорвалось. Если количество запросов будет слишком велико, то соединение будет естественным образом поддерживаться в активном состоянии и не создавать лишних издержек на переподключения. А если в течение интервала heartbeat-ов не придет ни одного запроса, то переподключение также не займет много времени, поскольку в нашем случае RabbitMQ и Django находятся на одной физической машине.

В файл /demo/django_project/utils.py вынесем метод connect(), поскольку он понадобится нам не только в файле views.py, но и при запуске Worker-а.

Код 15 - /demo/django_project/utils.py
from django.conf import settings
import pika
import time

def connect(queues = ["to_resize", "to_socket"], retry = True):
    connected = False
    while connected == False:
        try:
            credentials = pika.PlainCredentials(username=settings.RMQ_USER,
                password=settings.RMQ_PASS)
            parameters = pika.ConnectionParameters(host=settings.RMQ_HOST,
                port=settings.RMQ_PORT, credentials=credentials)
            connection = pika.BlockingConnection(parameters=parameters)
            channel = connection.channel()
            for queue in queues:
                channel.queue_declare(queue = queue)
            connected = True
            return connection, channel
        except:
            pass
        if retry == False: return None, None
        time.sleep(5)

Далее, нам необходимо определить новую служебную команду для запуска Worker-а. Он будет запускаться одновременно с Django и работая в отдельном процессе будет прослушивать сообщения от RabbitMQ. В случае поступления сообщений с ключом to_resize будет производиться сжатие аватарки в миниатюру, отправка сообщения to_socker обратно в RabbitMQ (для рассылки уведомлений на socket-клиенты) и обновления поля photo в БД.

Определение служебной команды осуществляется добавлением файла /demo/django_project/students/management/commands/worker.py. После этого вызывать Worker можно будет командой python manage.py worker, мы пропишем ее в стартовом скрипте запуска далее.

Код 16 - /demo/django_project/students/management/commands/worker.py
from students.models import Student
from django.core.management.base import BaseCommand
from django.conf import settings
from PIL import Image
from utils import connect
import io
import os
from io import BytesIO
import urllib.parse
import base64
import pika
import time
import json

class Command(BaseCommand):
    def handle(self, *args, **options):
        connection, channel = connect()
        print("I am DJANGO WORKER and I am Waiting for messages. To exit press CTRL+C")
        channel.basic_consume(queue='to_resize', auto_ack=True,\
                on_message_callback=self.callback)
        channel.start_consuming()

    @staticmethod
    def callback(ch, method, properties, body):
        try:
            print("Processing image 'to_resize'")
            msg = json.loads(body.decode("UTF-8"))
            filepath = os.path.join(settings.MEDIA_ROOT, 'photo/', msg["filename"])
            fixed_height = 300
            image = Image.open(BytesIO(base64.b64decode(msg["image"])))
            height_percent = (fixed_height / float(image.size[1]))
            width_size = int((float(image.size[0]) * float(height_percent)))
            new = image.resize((width_size, fixed_height))
            image.save(filepath)
            student = Student.objects.get(pk=msg["pk"])
            student.photo = settings.MEDIA_URL + "photo/" + msg["filename"]
            student.save(update_fields=['photo'])
            socket_msg = {"type": "refresh"}
            ch.basic_publish(exchange='', routing_key='to_socket',\
                    body=bytes(json.dumps(socket_msg), "UTF-8"))
            print("Processing image 'to_resize' complete")
        except Exception as error:
            print(error)

Теперь создадим Serializer в файле /demo/django_project/students/serializers.py для сериализации и десериализации списка студентов и логина/пароля при авторизации:

Код 17 - /demo/django_project/students/serializers.py
from rest_framework.serializers import ModelSerializer, Serializer, CharField
from django.contrib.auth.models import User
from .models import Student

class StudentSerializer(ModelSerializer):

    class Meta:
        model = Student
        fields = ('pk', 'name', 'email', 'document', 'phone', 'registrationDate','photo')

class LoginRequestSerializer(Serializer):
    model = User
    username = CharField(required=True)
    password = CharField(required=True)

Чтобы можно было редактировать список студентов в админке нужно добавить следующую команду в файле /demo/django_project/students/admin.py:

Код 18 - /demo/django_project/students/admin.py
from django.contrib import admin
from .models import Student

admin.site.register(Student)

Теперь пропишем настройки раутинга URL в файле /demo/django_project/django_project/urls.py

Код 19 - /demo/django_project/django_project/urls.py
from django.contrib import admin
from django.urls import path, re_path
from students import views
from django.conf import settings
from django.conf.urls.static import static
from django.contrib.auth.views import LogoutView

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/login', views.login_view, name='login'),
    path('api/logout', LogoutView.as_view(next_page='/'), name='logout'),
    re_path(r'^api/students/$', views.students_list),
    re_path(r'^api/students/(\d+)$', views.students_detail),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

Здесь указываются мапинги URL-адресов к методам, описанным во views.py. Только для метода logout мы использовали готовый LogoutView из библиотеки django.contrib.

Следующим шагом обновим настройки проекта в /demo/django_project/django_project/settings.py

Код 20 - /demo/django_project/django_project/settings.py
import os
from pathlib import Path
from os import environ

BASE_DIR = Path(__file__).resolve().parent.parent

SECRET_KEY = environ.get('SECRET_KEY', "82c1e306-2cdc-412c-9f0e-1ae9fae14126")

DEBUG = int(environ.get('DEBUG', default = 1))

ALLOWED_HOSTS = environ.get('ALLOWED_HOSTS', '*').split(' ')

if "CSRF_TRUSTED_ORIGINS" in environ:
    CSRF_TRUSTED_ORIGINS = environ.get('CSRF_TRUSTED_ORIGINS').split(' ')

RMQ_HOST = environ.get('RMQ_HOST', '127.0.0.1')
RMQ_PORT = environ.get('RMQ_PORT', 5672)
RMQ_USER = environ.get('RMQ_USER', 'admin')
RMQ_PASS = environ.get('RMQ_PASS', 'admin')

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'rest_framework',
    'students'
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]


ROOT_URLCONF = 'django_project.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

WSGI_APPLICATION = 'django_project.wsgi.application'


# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases

DATABASES = {
    'default': {
        'ENGINE': environ.get('POSTGRES_ENGINE', 'django.db.backends.sqlite3'),
        'NAME': environ.get('POSTGRES_DB', BASE_DIR / 'db.sqlite3'),
        'USER': environ.get('POSTGRES_USER', 'user'),
        'PASSWORD': environ.get('POSTGRES_PASSWORD', 'password'),
        'HOST': environ.get('POSTGRES_HOST', 'localhost'),
        'PORT': environ.get('POSTGRES_PORT', '5432'),
    }
}


# Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
    },
]


# Internationalization
# https://docs.djangoproject.com/en/4.2/topics/i18n/

LANGUAGE_CODE = 'en-us'

TIME_ZONE = 'UTC'

USE_I18N = True

USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/

STATIC_URL = 'django_static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'django_static')

MEDIA_URL = 'media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')

# Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework.authentication.SessionAuthentication',
    ],
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ],
}

LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'filters': {
        'require_debug_true': {
            '()': 'django.utils.log.RequireDebugTrue',
        }
    },
    'handlers': {
        'console': {
            'level': 'INFO',
            'filters': ['require_debug_true'],
            'class': 'logging.StreamHandler',
        }
    },
    'loggers': {
        '': {
            'level': 'INFO',
            'handlers': ['console'],
        }
    }
}

Из основных изменений относительно настроек по умолчанию мы внесли:

  • Чтение всех основных параметров из переменной окружения. Они будут подгружаться через docker-compose в режиме production и устанавливаться по умолчанию, если переменные окружения не заданы (в режиме отладки);

  • В секцию INSTALLED_APPS добавили rest_framework и students;

  • Поменяли STATIC_URL и STATIC_ROOT на django_static, чтобы избежать коллизии с React проектом;

  • Добавили секцию MEDIA_URL и MEDIA_ROOT для отгрузки медиа-статики (аватарок) в режиме отладки. В режиме production отгрузка будет через Nginx;

  • Добавили секцию LOGGING.

Теперь сохраним необходимые нам зависимости в файл /demo/django_project/requirements.txt

Код 21 - /demo/django_project/requirements.txt
Django==4.2.4
djangorestframework==3.14.0
pika==1.3.2
psycopg2-binary==2.9.7
Pillow==10.0.0

И установим их:

pip install -r requirements.txt

А также сразу соберем статику для Django админки. Поскольку на данном этапе мы лишь проверим доступность Django-админки в обход nginx, то этот шаг нам не понадобится. Но он пригодится на следующих этапах отладки (когда мы запустим сразу несколько сервисов, включая Django, React и Node.js).

python manage.py collectstatic

Мы практически готовы запустить наш Django-сервер. Но сперва нам понадобится запустить RabbitMQ, который обеспечит обмен сообщениями между основным Django-проектом и Worker-ом. Сделаем это с помощью вызова docker-compose в корне проекта /demo. Попутно будет собран и запущен Nginx, хотя он нам пока не понадобится.

docker-compose -f dev.docker-compose.yml build
docker-compose -f dev.docker-compose.yml up -d

Теперь мы готовы запустить Django и Worker. Чтобы не делать это вручную, подготовим автоматический скрипт /demo/start.sh

Код 22 - /demo/start.sh
run_cmd="cd django_project && python -u manage.py runserver 0.0.0.0:8000  > ../log_server.log 2>&1"
run_cmd+="& cd django_project && python -u manage.py worker > ../log_worker.log 2>&1"
nohup sh -c "$run_cmd" &

Не забудем поставить разрешения:

chmod 700 /demo/start.sh

Этот скрипт будет запускать Django и Worker в фоновом режиме и выводить логи в log_server.log и log_worker.log, чтобы мы могли контролировать логи отладки, например в консоли с помощью команды tail -f log_server.log.

Сделаем аналогичный скрипт /demo/stop.sh

Код 23 - /demo/stop.sh
pkill -f "python -u manag[e].py"

Для доступа к админке нам предварительно понадобится создать аккаунт администратора (в папке demo/django_project/)

python manage.py createsuperuser

Теперь запускаем Django командой ./start.sh и заходим по адресу 127.0.0.1:8000/admin, логинимся и видим панель администратора. Здесь вы можете завести несколько тестовых записей

Логи окно Django-админки
Логи окно Django-админки
Django-админка
Django-админка

React проект

Теперь приступим к React-проекту. Для начала пропишем базовые URL в файле /demo/reactapp/src/index.js

Код 24 - /demo/reactapp/src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './components/App';
import reportWebVitals from './reportWebVitals';
import 'bootstrap/dist/css/bootstrap.min.css'
import axios from "axios";

export const API_URL = "/api";
export const API_STATIC_MEDIA = "";
export const WS_URL = window.location.href.replace('http://', 'ws://').replace('https://', 'wss://') + 'ws';

axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = "X-CSRFToken"

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

reportWebVitals();

Также, создадим вспомогательный файл /demo/reactapp/src/utils.js для вспомогательных функций (парсинг Cookie).

Код 25 - /demo/reactapp/src/utils.js
const getCookie = function(name) {
  const value = `; ${document.cookie}`;
  const parts = value.split(`; ${name}=`);
  if (parts.length === 2) return parts.pop().split(';').shift();
}

export default getCookie;

Обратите внимание на параметры xsrfCookieName и xsrfHeaderName – эти параметры необходимы для отправки CSRF-токена в запросах к Django.

Удалим файл /demo/reactapp/src/App.js

rm src/App.js

Добавим файл /demo/reactapp/src/components/App.css

Код 26 - /demo/reactapp/src/components/App.css
.App {
  text-align: center;
}

.App-logo {
  height: 40vmin;
  pointer-events: none;
}

@media (prefers-reduced-motion: no-preference) {
  .App-logo {
    animation: App-logo-spin infinite 20s linear;
  }
}

.App-header {
  background-color: #282c34;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: calc(10px + 2vmin);
  color: white;
}

.App-link {
  color: #61dafb;
}

@keyframes App-logo-spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

И создадим /demo/reactapp/src/components/App.js, в котором будет описана основная точка входа в интерфейс.

Код 27 - /demo/reactapp/src/components/App.js
import { Fragment, useState, useEffect } from 'react';
import './App.css';
import Header from "./Header";
import Home from "./Home";
import Login from "./Login";
import ToastMsg from "./ToastMsg";
import useWebSocket from 'react-use-websocket';
import {API_URL, WS_URL} from "../index.js";
import axios from "axios";
import getCookie from "../utils.js";

function App() {
  const [loading, setLoading] = useState()
  const [isLoggedIn, setIsLoggedIn] = useState(true)
  const [formUsername, setFormUsername] = useState()
  const [formPassword, setFormPassword] = useState()
  const [ students, setStudents] = useState([])
  const [ error, setError] = useState()
  const [showToast, setShowToast] = useState(false)
  const [toastMsg, setToastMsg] = useState("")
  const csrftoken = getCookie('csrftoken')
  const [socketUrl, setSocketUrl] = useState(null);

  useEffect(() => {
    if (isLoggedIn) {
        fetch(API_URL + "/students/", {headers: {'Content-Type': 'application/json;charset=utf-8'}})
          .then(response => {
            if (response.ok) {
              return response.json()
            } else if (response.status === 403) {
              throw Error("Access denied")
            } else {
              throw Error(`Something went wrong: code ${response.status}`)
            }
          }).then(responseData => {
            setStudents(responseData)
          })
          .catch(error => {
            console.log(error)
            if (error.message !== "Access denied") setError('Ошибка, подробности в консоли')
            setIsLoggedIn(false)
          })
    }
  }, [isLoggedIn])

  const submitHandler = e => {
    e.preventDefault();
    setLoading(true);
    fetch(
      API_URL + "/login",
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json;charset=utf-8',
          'X-CSRFToken': csrftoken,
        },
        body: JSON.stringify({
          username: formUsername,
          password: formPassword,
        })
      }
    )
      .then(response => {
        if (response.ok) {
          return response.json()
        } else {
          throw Error(`Something went wrong: code ${response.status}`)
        }
      })
      .then(({key}) => {
          setIsLoggedIn(true)
          setError(null)
          setToastMsg("Successfully logged in")
          setShowToast(true)
          setSocketUrl(WS_URL)
      })
      .catch(error => {
        console.log(error)
        setToastMsg("Network error. Check console")
        setShowToast(true)
      })
      .finally(setLoading(false))
    }

  const resetState = () => {
      axios.get(API_URL + "/students/").then(data => setStudents(data.data))
  };

  useWebSocket(socketUrl, {
    onOpen: () => {
      console.log('WebSocket connection established.');
    },
    onMessage: (e) => {
      const msg = JSON.parse(e.data);
      if (msg["type"] === 'refresh') {
          setToastMsg("Refreshing list")
          setShowToast(true)
          resetState();
      }
    }
  });

  useEffect(() => {
    if (isLoggedIn) {
        setSocketUrl(WS_URL);
    } else {
        setSocketUrl(null);
    }
  }, [isLoggedIn]);

  return (
    <Fragment>
      <Header isLoggedIn={isLoggedIn} setIsLoggedIn={setIsLoggedIn}/>
        <ToastMsg show={showToast} setShow={setShowToast} msg={toastMsg}/>
        {error? <p>{error}</p> : null}
        {isLoggedIn?
            error?
                null
            :
                <div>
                    <Home students={students} setStudents={setStudents} resetState={resetState}/>
                </div>
          :
            loading? "Загрузка..." :
            <Login onSubmit={submitHandler} setFormUsername={setFormUsername} setFormPassword={setFormPassword}/>
        }
    </Fragment>
  );

}

export default App;

Здесь нужно обратить внимание на флаг isLoggedIn, который по умолчанию имеет значение True. Т.е. при первом запуске приложение попытается сделать запрос на URL /api/students и получит ошибку, тогда флаг isLoggedIn будет установлен в False. В результате данная функция отобразит компонент Login. Данный компонент получает метод submitHandler через props параметр onSubmit. Поэтому при вводе логина и пароля произойдет вызов SubmitHandler, который в свою очередь постучится на /api/login и в случае успеха получит Cookie с SessionToken-ом. Для этого от нас не требуется никаких дополнительных действий, потому что браузер будет автоматически прицеплять его ко всем последующим запросам.

Также стоит обратить внимание, что в момент изменения переменной isLoggedIn на True срабатывает хук useEffect, который обновляет значение WS_URL с null на URL-сокет сервера Node.js. Это вызовет по цепочке хук useWebSocket, который осуществит подключение к сокет-серверу и начнет прослушивать входящие сообщения. В момент подключения в сокет-серверу также будет передан Cookie с токеном сессии, что позволит авторизовать наш сокет-клиент. Подробнее этот механизм был описан в начале статьи (см. ключевое слово auth_request).

Если же пользователь уже залогинился, то будет отображен компонент Home со списком студентов и Toast уведомление ToastMsg, сообщающее об успешном входе.

Для удобства общая структура компонент отображена на скриншотах ниже.

Создадим компонент /demo/reactapp/src/components/Login.js

Код 28 - /demo/reactapp/src/components/Login.js
import {
  Container, Row, Col, Card, CardBody, Button,
  Form, FormGroup, Label, Input
} from "reactstrap";

const Login = (props) => {
  let loginHandler = props.onSubmit;
  let setUsername = props.setFormUsername;
  let setPassword = props.setFormPassword;

  return (
    <Container style={{'maxWidth':500, 'padding': 20}}>
      <Row>
        <Col>
          <Card>
            <CardBody>
              <Form onSubmit={loginHandler}>
                <FormGroup className="pb-2 mr-sm-2 mb-sm-0">
                  <Label for="exampleUsername" className="mr-sm-2"> Username </Label>
                  <Input type="text" name="email" id="exampleUsername"
                    onChange={(ev) => setUsername(ev.currentTarget.value)}
                  />
                </FormGroup>
                <FormGroup className="pb-2 mr-sm-2 mb-sm-0">
                  <Label for="examplePassword" className="mr-sm-2"> Password </Label>
                  <Input type="password" name="password" id="examplePassword"
                    onChange={(ev) => setPassword(ev.currentTarget.value)}
                  />
                </FormGroup>
                <Button type="submit" color="primary">
                  Login
                </Button>
              </Form>
            </CardBody>
          </Card>
        </Col>
      </Row>
    </Container>
  );
};

export default Login;

И /demo/reactapp/src/components/Home.js

Код 29 - /demo/reactapp/src/components/Home.js
import {Container, Row, Col} from "reactstrap";
import ListStudents from "./ListStudents";
import ModalStudent from "./ModalStudent";

const Home = (props) => {
    let students = props.students;
    let resetState = props.resetState;

    return (
        <Container style={{marginTop: "20px"}}>
            <Row>
                <Col>
                    <ListStudents students={students} resetState={resetState} newStudent={false}/>
                </Col>
            </Row>
            <Row>
                <Col>
                    <ModalStudent
                    create={true}
                    resetState={resetState}
                    newStudent={true}/>
                </Col>
            </Row>
        </Container>
    )
}

export default Home;

Код 30 - /demo/reactapp/src/components/Header.js
import axios from "axios";
import { Navbar, NavbarBrand, Nav, Button } from 'reactstrap';
import {API_URL} from "../index.js";

function Header (props) {
    let isLoggedIn = props.isLoggedIn;
    let setIsLoggedIn = props.setIsLoggedIn;

    const handleLogout = e => {
        axios.post(API_URL + "/logout",)
        localStorage.clear();
        window.location.href = '/';
        setIsLoggedIn(false);
    }

    return (
      <div>
        <Navbar color="light" light expand="md">
          <NavbarBrand className='m-auto'>Students demo</NavbarBrand>
            <Nav navbar>
              {isLoggedIn? <Button onClick={handleLogout}>Logout</Button> : null}
            </Nav>
        </Navbar>
      </div>
    );
}

export default Header;

Код 31 - /demo/reactapp/src/components/ListStudents.js
import {Table} from "reactstrap";
import ModalStudent from "./ModalStudent";
import AppRemoveStudent from "./appRemoveStudent";
import ModalPhoto from "./ModalPhoto";

const ListStudents = (props) => {
    const {students} = props
    return (
        <Table>
            <thead>
            <tr>
                <th>Name</th>
                <th>Email</th>
                <th>Document</th>
                <th>Phone</th>
                <th>Registration</th>
                <th>Photo</th>
                <th></th>
            </tr>
            </thead>
            <tbody>
            {!students || students.length <= 0 ? (
                <tr>
                    <td colSpan="6" align="center">
                        <b>Пока ничего нет</b>
                    </td>
                </tr>
            ) : students.map(student => (
                    <tr key={student.pk}>
                        <td>{student.name}</td>
                        <td>{student.email}</td>
                        <td>{student.document}</td>
                        <td>{student.phone}</td>
                        <td>{student.registrationDate}</td>
                        <td><ModalPhoto
                            student={student}
                        /></td>
                        <td>
                            <ModalStudent
                                create={false}
                                student={student}
                                resetState={props.resetState}
                                newStudent={props.newStudent}
                            />
                            &nbsp;&nbsp;
                            <AppRemoveStudent
                                pk={student.pk}
                                resetState={props.resetState}
                            />
                        </td>
                    </tr>
                )
            )}
            </tbody>
        </Table>
    )
}

export default ListStudents

Код 32 - /demo/reactapp/src/components/ModalStudent.js
import {Fragment, useState} from "react";
import {Button, Modal, ModalHeader, ModalBody} from "reactstrap";
import StudentForm from "./StudentForm";

const ModalStudent = (props) => {
    const [visible, setVisible] = useState(false)
    var button = <Button onClick={() => toggle()}>Редактировать</Button>;

    const toggle = () => {
        setVisible(!visible)
    }

    if (props.create) {
        button = (
            <Button
                color="primary"
                className="float-right"
                onClick={() => toggle()}
                style={{minWidth: "200px"}}>
                Add student
            </Button>
        )
    }
    return (
        <Fragment>
            {button}
            <Modal isOpen={visible} toggle={toggle}>
                <ModalHeader
                    style={{justifyContent: "center"}}>{props.create ? "Add student" : "Edit student"}</ModalHeader>
                <ModalBody>
                    <StudentForm
                        student={props.student ? props.student : []}
                        resetState={props.resetState}
                        toggle={toggle}
                        newStudent={props.newStudent}
                    />
                </ModalBody>
            </Modal>
        </Fragment>
    )
}
export default ModalStudent;

Код 33 - /demo/reactapp/src/components/ModalPhoto.js
import {useState} from "react";
import {API_STATIC_MEDIA} from "../index";
import {Button, Modal, ModalBody, ModalFooter, ModalHeader} from "reactstrap";

const ModalPhoto = (props) => {
    const [visible, setVisible] = useState(false)
    const toggle = () => {
        setVisible(!visible)
    }
    return (
        <>
            <img onClick={toggle} src={API_STATIC_MEDIA + props.student.photo} alt='loading' style={{height: 50}}/>
            <Modal isOpen={visible} toggle={toggle}>
                <ModalHeader  style={{color:"white",justifyContent: "center", backgroundColor:"#212529"}}>Фото</ModalHeader>
                <ModalBody style={{display:"flex", justifyContent:"center", backgroundColor:"#212529"}}><img src={API_STATIC_MEDIA + props.student.photo} alt="loading"/></ModalBody>
                <ModalFooter style={{display:"flex", justifyContent:"center", backgroundColor:"#212529"}}> <Button type="button" onClick={() => toggle()}>Закрыть</Button></ModalFooter>
            </Modal>
        </>
    )
}

export default ModalPhoto;

Код 34 - /demo/reactapp/src/components/appRemoveStudent.js
import {Fragment, useState} from "react";
import {Button, Modal, ModalHeader, ModalFooter} from "reactstrap";
import axios from "axios";
import {API_URL} from "../index";

const AppRemoveStudent = (props) => {
    const [visible, setVisible] = useState(false)
    const toggle = () => {
        setVisible(!visible)
    }
    const deleteStudent = () => {
        axios.delete(API_URL + "/students/" + props.pk).then(() => {
            props.resetState()
            toggle();
        });
    }
    return (
        <Fragment>
            <Button color="danger" onClick={() => toggle()}>
                Delete
            </Button>
            <Modal isOpen={visible} toggle={toggle} style={{width: "300px"}}>
                <ModalHeader style={{justifyContent: "center"}}>Вы уверены?</ModalHeader>
                <ModalFooter style={{display: "flex", justifyContent: "space-between"}}>
                    <Button
                        type="button"
                        onClick={() => deleteStudent()}
                        color="primary"
                    >Удалить</Button>
                    <Button type="button" onClick={() => toggle()}>Отмена</Button>
                </ModalFooter>
            </Modal>
        </Fragment>
    )
}
export default AppRemoveStudent;

Код 35 - /demo/reactapp/src/components/StudentForm.js
import {useEffect, useState} from "react";
import {Button, Form, FormGroup, Input, Label} from "reactstrap";
import axios from "axios";
import {API_URL} from "../index";

const StudentForm = (props) => {
    const [student, setStudent] = useState({})

    const onChange = (e) => {
        const newState = student
        if (e.target.name === "file") {
            newState[e.target.name] = e.target.files[0]
        } else newState[e.target.name] = e.target.value
        setStudent(newState)
    }

    useEffect(() => {
        if (!props.newStudent) {
            setStudent(student => props.student)
        }
        // eslint-disable-next-line
    }, [props.student])

    const defaultIfEmpty = value => {
        return value === "" ? "" : value;
    }

    const submitDataEdit = async (e) => {
        e.preventDefault();
        // eslint-disable-next-line
        const result = await axios.put(API_URL + "/students/" + student.pk, student, {headers: {'Content-Type': 'multipart/form-data'}})
            .then(() => {
                props.resetState()
                props.toggle()
            })
    }
    const submitDataAdd = async (e) => {
        e.preventDefault();
        const data = {
            name: student['name'],
            email: student['email'],
            document: student['document'],
            phone: student['phone'],
            photo: "/",
            file: student['file']
        }
        // eslint-disable-next-line
        const result = await axios.post(API_URL + "/students/", data, {headers: {'Content-Type': 'multipart/form-data'}})
            .then(() => {
                props.resetState()
                props.toggle()
            })
    }
    return (
        <Form onSubmit={props.newStudent ? submitDataAdd : submitDataEdit}>
            <FormGroup>
                <Label for="name">Name:</Label>
                <Input
                    type="text"
                    name="name"
                    onChange={onChange}
                    defaultValue={defaultIfEmpty(student.name)}
                />
            </FormGroup>
            <FormGroup>
                <Label for="email">Email</Label>
                <Input
                    type="email"
                    name="email"
                    onChange={onChange}
                    defaultValue={defaultIfEmpty(student.email)}
                />
            </FormGroup>
            <FormGroup>
                <Label for="document">Document:</Label>
                <Input
                    type="text"
                    name="document"
                    onChange={onChange}
                    defaultValue={defaultIfEmpty(student.document)}
                />
            </FormGroup>
            <FormGroup>
                <Label for="phone">Phone:</Label>
                <Input
                    type="text"
                    name="phone"
                    onChange={onChange}
                    defaultValue={defaultIfEmpty(student.phone)}
                />
            </FormGroup>
            <FormGroup>
                <Label for="photo">Photo:</Label>
                <Input
                    type="file"
                    name="file"
                    onChange={onChange}
                    accept='image/*'
                />
            </FormGroup>
            <div style={{display: "flex", justifyContent: "space-between"}}>
                <Button>Send</Button> <Button onClick={props.toggle}>Cancel</Button>
            </div>
        </Form>
    )
}

export default StudentForm;

Код 36 - /demo/reactapp/src/components/ToastMsg.js
import Row from 'react-bootstrap/Row';
import Toast from 'react-bootstrap/Toast';
import ToastContainer from 'react-bootstrap/ToastContainer';

function ToastMsg(props) {
  const show = props.show;
  const setShow = props.setShow;
  const msg = props.msg;

  return (
    <Row>
      <ToastContainer position='top-center'>
        <Toast onClose={() => setShow(false)} show={show} delay={3000} autohide>
          <Toast.Header>
            <img
              src="holder.js/20x20?text=%20"
              className="rounded me-2"
              alt=""
            />
            <strong className="me-auto">Notification</strong>
          </Toast.Header>
          <Toast.Body>{msg}</Toast.Body>
        </Toast>
      </ToastContainer>
    </Row>
  );
}

export default ToastMsg;

Теперь пропишем дополнительные зависимости в файл /demo/reactapp/package.json (в секцию dependencies)

Код 37 - /demo/reactapp/package.json (секция dependencies)
"dependencies": {
    "axios": "^1.5.0",
    "bootstrap": "^5.3.2",
    "react-bootstrap": "^2.8.0",
    "react-use-websocket": "^4.4.0",
    "reactstrap": "^9.2.0",
    "amqplib": "^0.10.3",
}

И установим их для запуска режима отладки. Напомним, что в режиме развертывания эта команда будет выполнена автоматически при сборке контейнера Nginx.

npm install

Для полного комплекта нам не хватает только сокет-сервера. Создадим его в файле /demo/reactapp/src/socket.js

Код 38 - /demo/reactapp/src/socket.js
var amqp = require('amqplib');
var uuid = require('uuid');
var SOCKET_OPEN = 1;

const queue = "to_socket";

function sleep(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

(async () => {
  var connected = false
  const RMQ_HOST=process.env.RMQ_HOST || "127.0.0.1"
  const RMQ_PORT=process.env.RMQ_PORT || 5672
  const RMQ_USER=process.env.RMQ_USER || "admin"
  const RMQ_PASS=process.env.RMQ_PASS || "admin"
  while (!connected) {
      await sleep(1000);
      console.log("Rabbitmq connection attempt")
      try {
        const connection = await amqp.connect("amqp://"+RMQ_USER+":"+RMQ_PASS+"@"+RMQ_HOST+":"+RMQ_PORT);
        const channel = await connection.createChannel();

        process.once("SIGINT", async () => {
          await channel.close();
          await connection.close();
        });

        await channel.assertQueue(queue, { durable: false });
        await channel.consume(
          queue,
          (message) => {
            if (message) {
              console.log("Received refresh message");
              for (const [key, ws] of Object.entries(clients)) {
                    if (ws.readyState === SOCKET_OPEN) {
                      ws.send(message.content.toString());
                    } else {
                      delete clients[key];
                    }
              }
            }
          },
          { noAck: true }
        );
        connected = true;
        console.log("Connected to RabbiqMQ, waiting for messages");
      } catch (err) {
        console.warn(err);
      }
  }
})();

function getCookiesMap(cookiesString) {
  return cookiesString.split(";")
    .map(function(cookieString) {
        return cookieString.trim().split("=");
    })
    .reduce(function(acc, curr) {
        acc[curr[0]] = curr[1];
        return acc;
    }, {});
}

console.log("Server started");
var Msg = '';
var clients = {};
var WebSocketServer = require('ws').Server
    , wss = new WebSocketServer({port: 8010});
    wss.on('connection', function(ws, req) {
        if (req.headers.cookie) {
            var cookies = getCookiesMap(req.headers.cookie);
            var sessionid = cookies["sessionid"];
            var socketid = uuid.v4();
            console.log(sessionid);
            clients[socketid] = ws;
            console.log("New connection from %s", sessionid);
            ws.on('message', function(message) {
              console.log('Received from client: %s', message);
              ws.send('Server received from client: ' + message);
            });
        } else {console.log("New connection");}
 });

Логика его работы довольно проста. При запуске он подключается к RabbitMQ и начинает прослушивать сообщения на канале to_socket. Одновременно запускается сокетный сервер, который записывает дескрипторы всех подключений в словарь clients. При поступлении сообщения to_socket всем клиентам рассылается уведомление о необходимости подгрузить новые данные. Таким образом при изменении данных студентов одним из клиентов, они мгновенно отобразятся у всех остальных пользователей. Если у одного пользователя открыто несколько вкладок, то они также обновятся.

Теперь мы готовы запустить наш проект целиком. Для этого нам нужно обновить наш скрипт /demo/start.sh и добавить в него команды запуска Webpack и Node.js

Код 39 - /demo/start.sh
run_cmd="cd django_project && python -u manage.py runserver 0.0.0.0:8000 > ../log_server.log 2>&1"
run_cmd+="& cd django_project && python -u manage.py worker > ../log_worker.log 2>&1"
run_cmd+="& cd reactapp && node socket.js > ../log_socket.log 2>&1"
run_cmd+="& cd reactapp && npm run start > ../log_react.log 2>&1"
nohup sh -c "$run_cmd" &

И сразу stop.sh для их остановки

Код 40 - /demo/stop.sh
pkill -f "node.*react-scripts start"
pkill -f "node socket.js"
pkill -f "node.*start.js"
pkill -f "python -u manag[e].py"

В файле /demo/reactapp/package.json сделаем небольшую правку (BROWSER=none), чтобы webpack не пытался запустить браузер при запуске. Это нам не понадобится, поскольку наш проект будет доступен по адресу - http://127.0.0.1, а не 127.0.0.1:3000, куда webpack пытается нас перебросить.

Код 41 - /demo/reactapp/package.json (строчка start)
  "scripts": {
    "start": "BROWSER=none react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
},

Теперь запускаем все вместе командой start.sh. Не забудьте предварительно вызвать stop.sh и перезапустить nginx, если у вас уже что-то запущено с предыдущих шагов.

docker-compose -f dev.docker-compose.yml restart nginx
./start.sh

Наконец можете открыть в браузере http://127.0.0.1 и потестировать сервис в сборке.

Production-режим

Как мы и обсуждали ранее в режиме развертывания все сервисы будут запущены в контейнерах. Для этого создадим еще пару Dockerfile-ов:

Код 42 - /demo/Dockerfile.django
FROM python:3.11-alpine

WORKDIR /usr/src/app
RUN mkdir -p $WORKDIR/static
RUN mkdir -p $WORKDIR/media

ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

RUN apk update \
    && apk add postgresql-dev gcc python3-dev musl-dev

RUN pip install --upgrade pip

COPY ./django_project/requirements.txt .
RUN pip install -r requirements.txt

COPY ./django_project .

ENTRYPOINT ["/usr/src/app/entrypoint.sh" ]

Здесь идет подготовка директорий, установка зависимостей и вызов скрипта entrypoint.sh

Код 43 - /demo/django_project/entrypoint.sh
#!/bin/sh

until cd /usr/src/app/
do
    echo "Waiting for server volume..."
done

if [ "$DATABASE" = "postgres" ]
then
    while ! nc -z $POSTGRES_HOST $POSTGRES_PORT; do
      echo "Waiting for db to be ready..."
      sleep 0.1
    done
fi

python manage.py migrate

./manage.py collectstatic --noinput
./manage.py loaddata fixtures/students.json --app students

nohup python manage.py worker & python manage.py runserver 0.0.0.0:8000

Этот скрипт вызывается при запуске контейнера Django, дожидается подключения Volume, запуска БД, выполняет прогон миграций (если их нет, то команда завершится без изменений), собирает Django статику админки (напомним, что статика React-проекта собирается в контейнере Nginx), загружает фикстуры со студентами (если их нет) и запускает Django-сервер с Worker-ом.

Не забудем установить права на запуск скрипта.

chmod 700 django_project/entrypoint.sh
Код 44 - /demo/Dockerfile.socket
FROM node:18-alpine
WORKDIR /usr/src/app
ADD ./reactapp/*.json ./
ADD ./reactapp/socket.js ./
RUN npm install
CMD node socket.js

Здесь все довольно просто, установка зависимостей и запуск сокет-сервера.

Сформируем production-настройки для Nginx.
/demo/web-demo/nginx/production/nginx.conf

Код 45 - /demo/web-demo/nginx/production/nginx.conf
server {
    listen 80;
    server_name _;
    server_tokens off;
    client_max_body_size 20M;

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
        try_files $uri $uri/ /index.html;
    }

    location /ws {
        auth_request /auth;
        proxy_pass http://socket:8010;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;        
        proxy_set_header Connection "upgrade";
    }

    location /auth {
        proxy_set_header Content-Type 'application/json;charset=utf-8';
        proxy_pass http://django:8000/api/students/;
    }

    location /api {
        try_files $uri @proxy_api;
    }
    location /admin {
        try_files $uri @proxy_api;
    }

    location /media {
        autoindex on;
        alias /app/backend/server/media/;
    }

    location @proxy_api {
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Url-Scheme $scheme;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        proxy_redirect off;
        proxy_pass   http://django:8000;
    }

    location /django_static/ {
        autoindex on;
        alias /app/backend/server/django_static/;
    }

}

Основные отличия от режима разработки заключается в том, что при запросе по корневому URL (/) будет отгружаться React-статика заранее собранная в bundle (с помощью Webpack) и предварительно скопированная в Nginx-контейнер во время его сборки. Все остальные пути работают примерно так же с тем изменением, что endpoint-ы ведут не на хостовые сервисы, а на hostname соответствующих им Docker-контейнеров (см. django:8000, socket:8010).

Теперь создадим Compose-файл для развертывания проекта в режиме production.

Код 46 - /demo/docker-compose.yml
version: '2'

services:
    nginx: 
        restart: unless-stopped
        container_name: demo_nginx
        build:
            context: .
            dockerfile: ./Dockerfile.nginx
        ports:
            - 80:80
        volumes:
            - static_volume:/app/backend/server/django_static
            - media_volume:/app/backend/server/media
            - ./nginx/production:/etc/nginx/conf.d
        depends_on: 
            - django

    socket:
        restart: unless-stopped
        container_name: demo_socket
        build:
            context: .
            dockerfile: Dockerfile.socket
        depends_on:
            - rmq
        env_file:
            - ./.env

    django:
        restart: unless-stopped
        container_name: demo_django
        build:
            context: .
            dockerfile: Dockerfile.django
        volumes:
            - static_volume:/usr/src/app/django_static
            - media_volume:/usr/src/app/media
        expose:
            - 8000        
        depends_on:
            - postgres
            - rmq
        env_file:
            - ./.env

    rmq:
        image: rabbitmq:3.10-management
        restart: always
        container_name: demo_rmq
        environment:
          - RABBITMQ_DEFAULT_USER=${RMQ_USER}
          - RABBITMQ_DEFAULT_PASS=${RMQ_PASS}
        volumes:
          - rabbitmq_data_volume:/var/lib/rabbitmq/
        ports:
          - 1234:15672
          - 5671-5672:5671-5672
        env_file:
            - ./.env

    postgres:
        image: postgres:15-alpine
        container_name: demo_postgres
        volumes:
          - postgres_volume:/var/lib/postgresql/data/
        environment:
          - POSTGRES_USER=${POSTGRES_USER}
          - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
          - POSTGRES_DB=${POSTGRES_DB}
        env_file:
            - ./.env

volumes:
    static_volume: {}
    media_volume: {}
    postgres_volume: {}
    rabbitmq_data_volume: {}

В этом файле описывается запуск всех 5-ти сервисов. Стоит обратить внимание, что мы изменили настройки некоторых Volume-ов. Два именованных Docker Volume (static_volume и media_volume) теперь предоставляют совместный доступ для контейнеров Django и Nginx. Это необходимо для того, чтобы аватарки, загруженные в хранилище Django были видны Nginx, который будет выдавать их клиентам. Аналогичный механизм распространяется на Django статику админки.

Теперь мы готовы запустить проект:

docker-compose build
docker-compose up -d

Нам нужно будет повторно зарегистрировать админ-аккаунт, поскольку теперь загрузка данных происходит из БД Postgres и сведений и созданном ранее аккаунте в ней уже нет.

docker exec -it demo_django /bin/sh
python manage.py createsuperuser

Наконец результат можно проверить в браузере как и раньше по адресу http://127.0.0.1

Конечный результат
Конечный результат

Можете попробовать открыть несколько окон браузера и обновить аватарку одного из пользователей. Изменения мгновенно прогрузятся во всех остальных окнах. Используя данный механизм можно строить более интересные сценарии уведомлений, например для чатов, игр и др.

На этом наш туториал завершен. Надеюсь, он будет полезен в создании ваших будущих проектов. Репозитарий с готовым проектом доступен по ссылке https://github.com/aboev/web-demo

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

Использованные материалы

Всем хорошей недели и продуктивной разработки!

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


  1. 0Bannon
    18.09.2023 10:53
    +3

    Ну всё, залил такой на гитхаб и можно вкатываться в айтишечку.


  1. xzeexcz
    18.09.2023 10:53

    Как же на пайтоне все легко делается, но я люблю себя мучать с настройкой nginx на Джаве.


  1. DrNiklas
    18.09.2023 10:53

    Если честно, я не понимаю смысл таких статей. Это и не обзор архитектуры и стратегии, и это не детальный разбор какой-то конкретной темы. Больше смахивает на дневник разработчика, понятный только ему. Какие-то непонятные названия переменных и концептуальный npm install на целый абзац :) Некоторые темы из перечисленных мне неплохо знакомы, и я вообще не понимаю, что хотел сказать автор и зачем. А на выходе фактически "Hello, World".