Pull to refresh

React+Django как написать Hello World

Reading time26 min
Views45K

Написание простого web приложения на react с бэкендом на Django, с БД на postgres, зайцем, nginx и всё завернуть в docker.

Для кого написана эта статья? По большому счету для самого себя, чтобы хоть как-то структурировать знания в своей черепушке. А также она будет полезна таким же начинающим разработчиками, как и я сам. Всё что описано ниже, не претендует на истину в первой инстанции. Более того, местами сделано так, как обычно не делают. Но так как у меня не стояла задача написать enterprise приложение, а хотелось просто пощупать и посмотреть, как это всё работает, то в целом результат меня устроил.

Должен сразу предупредить, что это не 100% личная разработка. Это проект Франкенштейн из разных статей интернета. Одной статьей я не смог найти, и поэтому после сборки начал писать эту, чтобы те, кому это интересно могли бы посмотреть и пощупать.

Начнём пожалуй
Начнём пожалуй

Начну я с описания проекта.

На выходе у нас будет простое WEB приложение на React. Это будет простой список студентов, с пачкой текстовых полей, таких как имя, почта телефон и прочее. Так же к этому всему можно будет прикрепить изображение. Функционал будет так же довольно прост. Можно добавить студента, удалить или же отредактировать данные.

Храниться наши студенты будут как вы уже поняли в БД postgress, а вот бережно их туда будет складывать всеми любимая Django средствами Django rest framework.

Пользователь заходит на веб страницу. React топает на Django через nginx, забирает данные и строит таблицу с пользователями. Мы вольны добавить пользователя и отредактировать его. Закономерный вопрос, зачем в этой схеме Rabbit и два worker’а? Всё дело в изображении. Если пользователь при добавлении/редактировании не добавляет изображение в форму, то данные просто обновятся, не затрагивая текущее изображение. Но если таки мы решим что-нибудь прикрепить, то вступает в действие имитация бурной деятельности, изображению генерируется имя, и поле photo нашей модели записывается путь к нему. Далее мы генерируем сообщение на rabbit и вкладываем туда изображение и отправляем. Worker забирает его, изменяет размер и шлёт обратно в другую очередь. Второй worker забирает его и складывает по уже обозначенному выше пути. В зависимости от размера изображение на это может уйти какое-то время, но приложение продолжает работать и изображение может появиться либо сразу, либо после обновления страницы.

Вот в целом и всё.  Что необходимо знать о приложении.

Теперь к делу, я все подготовительные операции проделывал на windows, а контейнеры собирал уже на centos.

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

Я назвал его ProjectStudent.

1) Django.

Создаем внутри каталог для Django переходим в него.

Открываем в этом каталоге PowerShell (или cmd, кому как удобнее. В bash может незначительно отличаться)

Создаем виртуальное окружение и активируем его

python3 -m venv --copies ./env
.\env\Scripts\activate

Устанавливаем Django, стартуем новый проект и сразу создаем приложение.

pip install Django
django-admin startproject django_project
python manage.py startapp students

Из того что нам еще понадобится в Django сразу установим djangorestframework и django-cors-headers

pip install django djangorestframework django-cors-headers

Для начала достаточно. Давайте настроим.

Откроем файл django\django_project\django_project\settings.py

Настроим импорты на будущее, добавим данные о приложениях, middleware

import os
from pathlib import Path
from os import environ

INSTALLED_APPS = [
    ...
    'rest_framework',
    'corsheaders',
    'students'
]

MIDDLEWARE = [
    ...
    'corsheaders.middleware.CorsMiddleware',
    'django.middleware.common.CommonMiddleware',
]

CORS_ORIGIN_ALLOW_ALL определяет, должен ли Django быть полностью открыт или полностью закрыт по умолчанию, нам для тестов это не принципиально, поэтому мы сделаем его открытым.

CORS_ORIGIN_ALLOW_ALL = True

И добавим настройку для статики

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

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

Отлично. Теперь займемся студентами.

Открываем модель django\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

Готово. Сохраняем всё и создаем файлы миграции и мигрируем.

Python manage.py makemigrations
python manage.py migrate

Давайте чтобы не сильно заморачиваться с данными и фикстурами, сразу создадим ещё один файл миграции и добавим данные туда.

python manage.py makemigrations --empty --name students students

после этой команды надо подправить файл django\django_project\students\migrations\0002_students.py

приведём его к такому виду:

from django.db import migrations

def create_data(apps, schema_editor):
    Student = apps.get_model('students', 'Student')
    Student(name="Joe Silver", email="joe@email.com", document="22342342", phone="00000000", photo='media/photo/nophoto.png').save()
    Student(name="John Smith", email="john@email.com", document="11111111", phone="11111111", photo='media/photo/nophoto.png').save()
    Student(name="Alex Smth", email="alex@email.com", document="22222222", phone="22222222", photo='media/photo/nophoto.png').save()
    Student(name="Kira Night", email="kira@email.com", document="33333333", phone="33333333", photo='media/photo/nophoto.png').save()
    Student(name="Amanda Lex", email="amanda@email.com", document="44444444", phone="44444444", photo='media/photo/nophoto.png').save()
    Student(name="Oni Musha", email="oni@email.com", document="44444444", phone="44444444", photo='media/photo/nophoto.png').save()

class Migration(migrations.Migration):

    dependencies = [
        ('students', '0001_initial'),
    ]

    operations = [
        migrations.RunPython(create_data),
    ]

И выполним ещё одну миграцию

python manage.py migrate

Отлично! Теперь у нас есть данные для тестов.

Перейдем к нашему api

Создадим сериализатор наших данных. файл django\django_project\students\serializers.py

from rest_framework import serializers
from .models import Student

class StudentSerializer(serializers.ModelSerializer):

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

так, теперь настроим в uls.py, адреса по которым будут предоставляться наши данные открываем django\django_project\django_project\urls.py

импортируем дополнительно re_path, наше студенческое view и настройки для статики.

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

urlpatterns = [
    path('admin/', admin.site.urls),
    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)

Теперь перейдём к view

Открываем django\django_project\students\views.py

from rest_framework.response import Response
from rest_framework.decorators import api_view
from rest_framework import status
from .serializers import *

# Create your views here.

@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':
        print('post')
        serializer = StudentSerializer(data=request.data)
        if serializer.is_valid():
            serializer.save()
            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()
            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)

Давайте убедимся, что на данном этапе всё работает.

Запускаем сервер

python manage.py runserver

и открываем в браузере страницу http://127.0.0.1:8000/api/students/

Видим наших студентов. Значит всё ок!

Так на текущий момент мы с django закончили, теперь можно переходить к react.

Там писанины побольше будет, но в основном код. Поехали!

Как будет выглядеть наше приложение?

App будет состоять из двух компонентов, Header и Home. В свою очередь Home состоит из ListStudents и ModalStudent (кнопка добавить студента)

Выходим в корневую папку проекта и выполняем команду

npx create-react-app reactapp

установим пару дополнительных компонентов

npm i axios reactstrap bootstrap

После того как приложение создалось, начнем накидывать в него свои компоненты

Создаем в src каталог components, и в подкаталоги app, appHeader, appHome, appListStudents, appModalStudent, appPhotoModal, appRemoveStudent,appStudentForm.

Как вы поняли, каждый компонент в отдельном каталоге и файле.

В app можно перенести стартовый компонент, и работать с ним, по остальным компонентам создаем по js файлу на каждый.

В итоге получится примерно так.
В итоге получится примерно так.

Комментарии к коду react я к сожалению не делал, там всё в функциональных компонентах. Там где я это подсматривал всё было в классовых, но т.к. я только познаю react простое преобразование из классовых в функциональные у меня не прокатило, пришлось всё написать с нуля, подсматривая в шпаргалку.

Начнем с самого верху и пойдём вниз.

App.js Основное приложение

import './App.css';
import {Fragment} from "react";
import Header from "../appHeader/Header";
import Home from "../appHome/Home";

function App() {
    return (
        <Fragment>
            <Header/>
            <Home/>
        </Fragment>
    );
}

export default App;

Header.js Заголовок

const Header = () => {
    return (
        <div className="text-center">
            <img
                src="https://cdn.worldvectorlogo.com/logos/react-2.svg"
                width="100"
                className="img-thumbnail"
                style={{marginTop: "20px"}}
                alt="logo"
            />
            <hr/>
            <h1>App for project on React + Django</h1>
        </div>)
}

export default Header;

Home.js Данные

import {Container, Row, Col} from "reactstrap";
import ListStudents from "../appListStudents/ListStudents";
import axios from "axios";
import {useEffect, useState} from "react";
import ModalStudent from "../appModalStudent/ModalStudent";
import {API_URL} from "../../index";

const Home = () => {
    const [students, setStudents] = useState([])

    useEffect(()=>{
        getStudents()
    },[])

    const getStudents = (data)=>{
        axios.get(API_URL).then(data => setStudents(data.data))
    }

    const resetState = () => {
        getStudents();
    };

    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;

ListStudents.js Таблица со студентами

import {Table} from "reactstrap";
import ModalStudent from "../appModalStudent/ModalStudent";
import AppRemoveStudent from "../appRemoveStudent/appRemoveStudent";
import ModalPhoto from "../appPhotoModal/ModalPhoto";

const ListStudents = (props) => {
    const {students} = props
    return (
        <Table dark>
            <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

ModalStudent.js модалка, отвечающая за редактирование или добавление студента

import {Fragment, useState} from "react";
import {Button, Modal, ModalHeader, ModalBody} from "reactstrap";
import StudentForm from "../appStudentForm/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"}}>
                Добавить студента
            </Button>
        )
    }
    return (
        <Fragment>
            {button}
            <Modal isOpen={visible} toggle={toggle}>
                <ModalHeader
                    style={{justifyContent: "center"}}>{props.create ? "Добавить студента" : "Редактировать студента"}</ModalHeader>
                <ModalBody>
                    <StudentForm
                        student={props.student ? props.student : []}
                        resetState={props.resetState}
                        toggle={toggle}
                        newStudent={props.newStudent}
                    />
                </ModalBody>
            </Modal>
        </Fragment>
    )
}
export default ModalStudent;

ModalPhoto.js модальное окно с изображением

import {Fragment, 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;

appRemovalStudent.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 + props.pk).then(() => {
            props.resetState()
            toggle();
        });
    }
    return (
        <Fragment>
            <Button color="danger" onClick={() => toggle()}>
                Удалить
            </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;

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 + 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, 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;

Подправим так же index.js

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './components/app/App';
import reportWebVitals from './reportWebVitals';
import 'bootstrap/dist/css/bootstrap.min.css'

export const API_URL = "http://127.0.0.1:8000/api/students/"
export const API_STATIC_MEDIA = "http://127.0.0.1:8000/"

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

reportWebVitals();

Пробегитесь по путям импортов, проверьте чтобы всё совпадало с вашей ситуацией, если вы назвали приложения как-то иначе.

После этого стартуем приложение в консоли командой npm start иии...

Топаем на http://localhost:3000

Как видим наши данные прогрузились. Картинок нет, но это потому, что мы её не положили по адресу.

Давайте создадим в каталоге с проектом django пару дополнительных каталогов и файл nophoto.png для стартовой заглушки

После обновления видим что всё ок!
После обновления видим что всё ок!

В целом, это уже работающее приложение, можно удалить/добавить и отредактировать. Не работает только картинка, она уходит на django, но никак не обрабатывается там.

Теперь время добавить изображение. Но непосредственно с ним придётся повременить, так как предполагалось что это действие будет проходить через брокера сообщений. RabbitMQ мы планируем поднять в контейнере. Но тогда уже и пусть само приложение переезжает.

Для контейнеров у меня развёрнута виртуальная машина на Centos 7 на VirtualBox.

В целом ОС не важна, т.к. докер устанавливается на любую удобную для вас ОС, Fedora/Ubuntu/Debian и даже на windows (конечно же надо пользовать wsl).

Для разработки я использую vs code, в ней есть плагины для автоматического деплоя по sftp. Если у вас pycharm то там тоже всё прекрасно настраивается. Так или иначе, настраиваем синхронизацию нашего каталога ProjectStudent с сервером (лучше исключить из синхронизации каталоги виртуального окружения env у django проекта и node_modules у реакт, помешать они не помешают, но там куча файлов, синхронизируются они долго, а толку от них там никакого, мертвый груз.)

Начнем с Django.

Останавливаем сервер и делаем команду pip freeze >requirements.txt

Переходим в каталог django\django_project\django_project.

создаем там Dockerfile

Заполним его

# Стартовый образ
FROM python:3.11-alpine

# рабочая директория
WORKDIR /usr/src/app
RUN mkdir -p $WORKDIR/static
RUN mkdir -p $WORKDIR/media

# переменные окружения для python
#не создавать файлы кэша .pyc
ENV PYTHONDONTWRITEBYTECODE 1
# не помещать в буфер потоки stdout и stderr
ENV PYTHONUNBUFFERED 1

# обновим pip
RUN pip install --upgrade pip

# скопируем и установим зависимости. эта операция закешируется 
# и будет перезапускаться только при изменении requirements.txt
COPY ./requirements.txt .
RUN pip install -r requirements.txt

# копируем всё что осталось.
COPY . .

С Django пока всё. Давайте теперь к react.

Так же, переходим в reactapp и создаем Dockerfile

# так же берём готовый контейнер с node на основе alpine
FROM node:18-alpine as build
# Задаем рабочий каталог
WORKDIR /usr/src/app
# Копируем туда наши json файлы
ADD *.json ./
# Устанавливаем все пакеты и зависимости указанные в json
RUN npm install

# Добавляем каталоги public и src. 
# можно воспользоваться командой COPY . . но если вы синхронизировали node_modules,
# то будете ждать пока зальётся этот каталог целиком. 
# да и потом могут возникнуть проблемы.
ADD ./public ./public
ADD ./src ./src

Итак, описание сборки двух контейнеров готово. Но запускать их руками не так удобно, так что пусть этим занимается docker-compose. Так что если вы установили только докер, то добавьте еще docker-compose.

Вернемся в каталог ProjectStudent и создадим там docker-compose.yml

version: '3.8'
# Поднимаем два сервиса, django И node
services:
  django:
    #говорим что build будет из dockerfile который располагается ./django/django_project/
    build: ./django/django_project/
    # имя контейнера
    container_name: djangoapp
    # перезапускать контейнер при завершении выполнения работы или при аварийном завершении
    restart: always
    # проброс портов внутрь контейнера, 8000 порт на хост машине будет проброшен внутрь контейнера на такой же 8000 порт
    ports:
      - 8000:8000
    # команда при старте контейнера
    command: >
      sh -c "python manage.py runserver 0.0.0.0:8000"
    # Для статики мы подключаем два volume (чтобы при перезапуске наши данные не пропадали)), создадим их ниже.
    volumes:
      - django_static_volume:/usr/src/app/static
      - django_media_volume:/usr/src/app/media
    # подключаем к сети myNetwork (в целом не обязательно, но до кучи чтоб было)
    networks:
      - myNetwork

  node:
    # Аналогично, build из ./reactapp/dockerfile
    build: ./reactapp
    # имя контейнера
    container_name: reactapp
    # рестарт
    restart: always
    # порты
    ports:
      - 3000:3000
    # команда при запуске
    command: npm start
    # Зависимость. нет смысла ноде, если некому отдать ей данные. поэтому сначала стартуем сервис django, а за ней node
    depends_on:
      - django
    # Сеть та же, все контейнеры должны крутиться в однйо сети чтобы видеть друг друга.
    networks:
      - myNetwork
# создаём два volume для статики
volumes:
  django_static_volume:
  django_media_volume:

Первые два контейнера готовы. Запускаем docker-compose up

....
djangoapp | January 23, 2023 - 08:45:29
djangoapp | Django version 4.1.5, using settings 'django_project.settings'
djangoapp | Starting development server at http://0.0.0.0:8000/
djangoapp | Quit the server with CONTROL-C.
....
reactapp  | Compiled successfully!
reactapp  |
reactapp  | You can now view reactapp in the browser.
reactapp  |
reactapp  |   Local:            http://localhost:3000
reactapp  |   On Your Network:  http://192.168.224.3:3000

Отлично, всё запустилось.

Теперь можем проверить.

Т.к. теперь оно находится внутри контейнера на хостовой машине, localhost:3000 уже не катит, но для этого мы и пробрасывали порты, идём на хостовую машину на 3000 порт. В моём случае это http://192.168.56.101:3000/

Видим, что реакт запущен, но вот данных нет. Это, потому что ссылка на API всё еще ведёт на 127.0.0.1:8000

Давайте это исправим. Остановим всё, через ctrl+C

Открываем reactapp\src\index.js

И поправим путь до api

export const API_URL = "http://192.168.56.101:8000/api/students/"

синхронизируем, и чтобы часто не перезапускать давайте подключим src и public как volume к контейнеру, тогда все ваши изменения при синхронизации будут попадать на хостовую машину и т.к. эти каталоги подключены как volume то соответственно все изменения сразу отражаются и в контейнере.

Добавим в docker-compose.yml в сервис node пару строк

    volumes:
      - ./reactapp/public/:/usr/src/app/public/
      - ./reactapp/src/:/usr/src/app/src/

Такую же процедуру можно провернуть и с django 

    volumes:
      - ./django/django_project:/usr/src/app/
      - django_static_volume:/usr/src/app/static
      - django_media_volume:/usr/src/app/media

Все, снова делаем docker-compose up.

Дожидаемся пока лог реакта не напишет successfully! И проверяем ещё раз.

Отлично, данные на месте. Настало время для изображений.

Чтобы не отходить далеко от контейнеров, сделаем сразу rabbit. Останавливаем всё через ctrl + C

Снова открываем yml файл и дописываем ещё один сервис rmq

rmq:
    # на этот раз мы не билдим контейнер а используем полностью готовый из репозитория
    image: rabbitmq:3.10-management
    restart: always
    container_name: rmq
    networks:
      - myNetwork
    # Переменные окружения для настройки. 
    environment:
      - RABBITMQ_DEFAULT_USER=admin
      - RABBITMQ_DEFAULT_PASS=admin
    # volume для хранения данных rmq, можно и без него, но тогда при перезапуске каждый раз будет создаваться новый и они будут потихоньку накапливаться
    volumes:
      - rabbitmq_data_volume:/var/lib/rabbitmq/
    # проброс портов, 15672 для менеджмента, 5671-5672 для работы
    ports:
      - 1234:15672
      - 5671-5672:5671-5672    

И добавляем указанный volume в список volumes

volumes:
  django_static_volume:
  django_media_volume:
  rabbitmq_data_volume:

Отлично.

От реакта в данный момент ничего не зависит, поэтому его не трогаем.

Идем править django

Надо добавить пару новых функций и чуть дописать старые.

Итак, в файл django\django_project\students\views.py

Дописываем следующее:

def save_image_to_media(serializer, request):
    file_name = send_to_rabbit(request).split('separator')[1]
    file_path = 'media\\photo\\' + file_name
    serializer.validated_data['photo'] = file_path
    if serializer.is_valid():
        serializer.save()

Первая отправляет полученный request в функцию send_to_rabbit, из которой возвращается сгенерированное имя файла, это имя дополняется и отправляется в БД.

def send_to_rabbit(data):
    file = data.FILES['file']
    file_name = bytes('separator' + str(uuid.uuid4()) + '.' + file.name[file.name.rfind(".") + 1:], 'utf-8')
    img = file.read() + file_name
    hostname = '192.168.56.101'
    port = 5672
    credentials = pika.PlainCredentials(username='admin', password='admin')
    parameters = pika.ConnectionParameters(host=hostname, port=port, credentials=credentials)
    connection = pika.BlockingConnection(parameters=parameters)
    channel = connection.channel()
    channel.queue_declare(queue='to_resize')
    channel.basic_publish(exchange='',
                          routing_key='to_resize',
                          body=img)
    connection.close()
    return file_name.decode('utf-8')

Тут у нас каждый раз происходит следующее:

1)     Из request вытаскивается поле file, превращается в байтовый массив, к нему через сепаратор прицепляется сгенерированное через uuid имя этого файла.

2)     Происходит инициализация соединения и сообщение отправляется в очередь на rmq.

Да, мы делаем это через пользователя admin/admin, можно через любого другого, просто надо до настроить.

3)     Имя файла возвращается обратно, для записи в БД.

Отлично, сообщение отправили. Теперь его надо принять.

Создаём в корне проекта worker.py

По идее это должен быть отдельный сервис, не связанный с django. Но мы не будем плодить дополнительные, пусть все в одном контейнере работают, логику это не нарушит.

Worker.py

import pika
from PIL import Image
import io
import time

def initial():
    try:
        hostname = 'rmq'
        port = 5672
        credentials = pika.PlainCredentials(username='admin', password='admin')
        parameters = pika.ConnectionParameters(host=hostname, port=port, credentials=credentials)
        connection = pika.BlockingConnection(parameters=parameters)
        # Создать канал
        channel = connection.channel()
        # На всякий случай создаём очереди
        channel.queue_declare(queue='to_resize')
        channel.queue_declare(queue='from_resize')
        channel.basic_consume(queue='to_resize',
                      auto_ack=True,
                      on_message_callback=callback)

        return True, channel
    except:
        return False, None

def callback(ch, method, properties, body):
    try:
        data = body.split(b'separator')
        file_name = b'separator' + data[1]
        fixed_height = 300
        image = Image.open(io.BytesIO(data[0]))
        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))
        img_width = bytes(f'separator{new.width}', 'utf-8')
        img_height = bytes(f'separator{new.height}', 'utf-8')
        tobyte = new.tobytes() + img_width + img_height + file_name
        return_resize_image(tobyte)
    except Exception as error:
        print(error)

def return_resize_image(data):
    channel.basic_publish(exchange='',
                          routing_key='from_resize',
                          body=data)

if __name__ == '__main__':
    count_try=0
    conn=False
    while not conn:
        count_try+=1
        print(f'I`m WORKER. Попытка присоединится №{count_try}')
        conn, channel=initial()  
        if not conn:
            time.sleep(2)  
    print(' [*] I`m WORKER and i`m Waiting for messages. To exit press CTRL+C')
    channel.start_consuming()

Что тут происходит.

Для начала при старте worker в бесконечном цикле пытается присоединиться к rabbit, при неудаче спит 2 секунды и снова пытается.

В initial() помимо попыток соединения прописана callback функция в которой прописано что собственно делать с полученным сообщением.

Полученное сообщение сначала разбивается на массив через separator часть с данными загружается в объект Image, средствами библиотеки pillow. Дальше оно пропорционально ресайзится, чтобы высота была не больше 300 пикселей.

Снова превращается в байтовый массив, так же через сепараторы добавляется размер изображения и имя файла и отправляется уже в другую очередь. Для укладывания в нужный каталог. Размер нужен чтобы на другой стороне pillow снова могла собрать его из байтового массива в нормальное изображение. (способ через сепараторы странный, я понимаю, но чет не пришло в голову ничего лучше. Но это работает.)

Теперь второй воркер.

Тут странный на первый взгляд момент. Так конечно же лучше не делать.

Изначально я хотел, чтобы второй воркер работал в непосредственной близости с django и укладывал не только файл на место, но и обновлял БД прописывая имя и путь к файлу в соответствующее поле модели. В то же время он должен был работать как сервис, независимо запущен django сервер или нет. Поэтому была создана такая структура.

Далее через python manage.py my_command запускался worker. В последствии от идеи изменения БД из worker я отказался, и он стал просто укладывать файлы по нужному мне пути. Так что можно сделать просто worker2 рядом с первым и перенести функционал в него. Но я уже не стал.

Так что продолжим как есть.

my_command.py

from django.core.management.base import BaseCommand
from PIL import Image
import pika
import time

class Command(BaseCommand):
    def handle(self, *args, **options):
        count_try=0
        conn=False
        while not conn:
            count_try+=1
            print(f'I`m DJANGO WORKER. Попытка присоединится №{count_try}')
            conn, channel=self.initial()
            if not conn:
                time.sleep(2)
        print(' [*] I`m DJANGO WORKER and i`m Waiting for messages. To exit press CTRL+C')
        channel.start_consuming()

    def initial(self):
        try:
            hostname = 'rmq'
            port = 5672
            credentials = pika.PlainCredentials(username='admin', password='admin')
            parameters = pika.ConnectionParameters(host=hostname, port=port, credentials=credentials)
            connection = pika.BlockingConnection(parameters=parameters)
            # Создать канал
            channel = connection.channel()
            channel.queue_declare(queue='to_resize')
            channel.queue_declare(queue='from_resize')
            try:
                channel.basic_consume(queue='from_resize',
                                auto_ack=True,
                                on_message_callback=self.callback)
            except Exception as error:
                print(error)
            return True, channel
        except:
            return False, None

    @staticmethod
    def callback(ch, method, properties, body):
        try:
            data = body.split(b'separator')
            image = Image.frombytes("RGB", (int(data[1]), int(data[2])), data[0])
            file_name = data[3].decode('UTF-8')
            image.save('media/photo/' + file_name)
        except Exception as error:
            print(error)

Тут происходит то же самое, попытки присоединиться и callback для полученных сообщений.

В callback тело сообщение пилится по сепаратору, строится из байтового массива в Image и сохраняется.

В целом всё хорошо, но не хватает только импортов и установленных библиотек.

надо установить их и добавить в requirements

pip install pika uuid pillow

pip freeze >requirements.txt

Добавим запуск наших worker’ов в yml файле.

Для этого расширим команду запуска django

    command: >
      sh -c "nohup python worker.py & nohup python manage.py my_command & python manage.py runserver 0.0.0.0:8000"

так же добавим в зависимости django контейнера rmq, ведь ему надо куда-то слать и откуда-то принимать сообщения

depends_on:
      - rmq

Так как у нас изменился requirements надо пересобрать контейнер django

Выполняем команду

docker-compose up -d --build

--build пересоберёт все контейнеры, которые этого потребуют. В нашем случае у react ничего не изменилось, он полностью подтянется из кэша, django же возьмет из кэша только те слои что были до установки requirements, остальные создаст новые.

-d позволит нам не следить за логами как раньше, а запустит всё в фоне. Так как добавился rmq количество логов резко возрастёт, и следить за ними уже не так просто.

Ожидаем пока все три контейнера запустятся

Creating rmq ... done

Creating djangoapp ... done

Creating reactapp  ... done

Давайте взглянем что нам сказала django при запуске:

docker-compose logs | grep djangoapp

Мы видим, что помимо успешно запущенного сервера оба воркера отрапортовали нам что они готовы принимать сообщения, подключились они не сразу, конечно, но в целом всё прошло успешно.

Давайте попробуем отредактировать какого ни будь студента и добавим ему изображение.

Отлично! Мы добавили студента и отредактировали парочку. Схема с кроликом работает.

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

 Начнем с БД.

Остановим все наши контейнера командой docker-compose down, откроем yml и добавим ещё один сервис.

  postgres:
    # Так же разворачиваем с готового контейнера
    image: postgres:15-alpine
    container_name: postgresdb
    # Чтобы наши данные не пропадали при перезапуске подключим volume
    volumes:
      - postgres_volume:/var/lib/postgresql/data/
    # Переменные окружения. их надо будет передавать в django.
    environment:
      - POSTGRES_USER=admin
      - POSTGRES_PASSWORD=strong_password
      - POSTGRES_DB=django_db
    # Сеть
    networks:
      - myNetwork

Volume конечно де надо создать

volumes:
  postgres_volume:
  django_static_volume:
  django_media_volume:
  rabbitmq_data_volume:

Готово. Теперь надо настроить django.

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

Создадим в ProjectStudent файл .env

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

Закинем в него несколько переменных. В том числе и параметры БД

SECRET_KEY=django-insecure-cfr4pr9x3dbm9vnmvclxn&^a^ml-cl*=c#scbsxn_+m*5mt%z1
DEBUG=1
ALLOWED_HOSTS=*

POSTGRES_ENGINE=django.db.backends.postgresql
POSTGRES_DB=django_db
POSTGRES_USER=admin
POSTGRES_PASSWORD=strong_password
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
DATABASE=postgres

Теперь открываем settings.py

Вносим изменения:

SECRET_KEY = environ.get('SECRET_KEY')
DEBUG = int(environ.get('DEBUG', default=0))
ALLOWED_HOSTS = environ.get('ALLOWED_HOSTS').split(' ')

...

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'),
    }
}

Теперь вернёмся в yml и добавим наш файл переменных, а также зависимость от postgress сервису django.

    depends_on:
      - postgres
      - rmq
    env_file:
      - ./.env

Отлично! Но есть одно НО. У нас в докер файле прописано что при создании контейнера надо выполнить миграцию. Но нюанс в том, что теперь нам надо мигрировать не в SQLite которая по умолчанию лежала рядом, а на postgress, которая хоть и указана в зависимостях неизвестно, когда будет готова к работе.

Это можно и нужно подправить. Создадим рядом с dockerfile скрипт entrypoint.sh который будет говорить контейнеру что ему нужно сделать при запуске.

entrypoint.sh

#!/bin/sh
if [ "$DATABASE" = "postgres" ]
then
    # если база еще не запущена
    echo "Рано..."
    # Проверяем доступность хоста и порта
    while ! nc -z $POSTGRES_HOST $POSTGRES_PORT; do
      sleep 0.1
    done
    echo "Пора!"
fi
# Выполняем миграции
python manage.py migrate
exec "$@"

так же уже в хостовой ОС, необходимо сделать этот скрипт исполняемым. Перейдите в каталог где он находится и выполните команду chmod +x ./ entrypoint.sh

Плюсом ко всему с postgress надо как то общаться. Для этого необходимо установить дополнительные зависимости как в саму ОС контейнера, так и в pip.

открываем dockerfile джанги и дописываем перед тем как обновлять pip

RUN apk update \
    && apk add postgresql-dev gcc python3-dev musl-dev
    
# обновим pip
RUN pip install --upgrade pip

И в самом конце где была миграция добавим

# Сделаем первую миграцию.
ENTRYPOINT ["/usr/src/app/entrypoint.sh" ]

Отлично, теперь топаем снова к django и устанавливаем pip install psycopg2-binary

И обновляем зависимости pip freeze > app/requirements.txt

Синхронизируем и пересоберём: docker-compose up -d –build

Открываем лог django docker-compose logs | grep djangoapp

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

Ну что, осталась только nginx. В этой теме я, к сожалению, пока плаваю, но давайте попробуем добавить.

Для начала создадим в корне проекта третий каталог, с именем nginx

Создадим dockerfile

# Собираемся из готового образа nginx:1.23-alpine
FROM nginx:1.23-alpine
# Удаляем дефолтный конфиг
RUN rm /etc/nginx/conf.d/default.conf
# Подкидываем наш
COPY ./nginx.conf /etc/nginx/conf.d/

Теперь наш конфиг

nginx.conf

upstream django_app {
    # Список бэкэнд серверов для проксирования
    server django:8000;
}
server {
    listen 80;
    # Параметры проксирования
    location / {
        # Если будет открыта корневая страница
        # все запросу пойдут к одному из серверов
        # в upstream django_proj
        proxy_pass http://django_app;
        # Устанавливаем заголовки
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
        # Отключаем перенаправление
        proxy_redirect off;
    }
    # Статика и медиа
    location /static/ {
        alias /home/src/app/static/;
    }
    location /media/ {
        alias /home/src/app/media/;
   }
}

Отличненько. Теперь давайте добавим ещё один сервис в yml

nginx:
    build: ./nginx
    container_name: nginx
    networks:
      - myNetwork
    ports:
      - 1337:80
    depends_on:
      - django
    volumes:
      - django_static_volume:/home/src/app/static
      - django_media_volume:/home/src/app/media 

А так же подправим немного сервис django поменяем ports: -8000:8000 на

    expose:
      - 8000

Выполняем команду docker-compose up -d –build

И ждем пока запустится реакт и проверяем.

Всё норм, но где данные? Всё дело в том, что приложение react работает в браузере и соответственно все запросы отсылает на адрес бэкэнда от имени вашего компьютера и вашего браузера. И раньше это работало, потому что бэкэнд публиковал свой порт контейнера наружу и хост пробрасывал свой 8000 порт на порт контейнера. Сейчас мы это убрали и оставили команду expose: -8000 которая извещает окружающих что используется 8000 порт, но извне на него больше не попасть. Но этим могут пользоваться остальные контейнеры вокруг, например nginx.

Сменим адрес привязки API на реакте, заходим в index.js и правим

export const API_URL = "http://192.168.56.101:1337/api/students/"
export const API_STATIC_MEDIA = "http://192.168.56.101:1337/"

Проверяем.

Работает! Казалось бы…

Кое-что я всё-таки забыл.

Давайте зайдем в админку django

Не похоже на неё, правда?

Да, я забыл собрать статику.

Остановим всё docker-compose down,

Перейдем в django_project, и выполним команду

python manage.py collectstatic

Статики там немного и она, как ни странно, статична и редко меняется. Все остальные настройки уже внесены.

Запускаем обратно

docker-compose up -d –build

Проверяем админку.

Да, всё ОК.

Ну вот в целом и всё. Небольшой Hello World написан и в целом его можно совершенствовать и развивать.

Да, тут многое можно подправить, например при внезапном перезапуске rmq воркеры отвалятся и придётся перезапускать весь контейнер. Надо дальше по изучать nginx. Да и вообще много чего.

ссылка на проект:

https://github.com/eldalex/ProjectStudent

Три команды для того чтобы поднять:

1) git clone https://github.com/eldalex/ProjectStudent

перейти в ProjectStudent

2) chmod +x django/django_project/entrypoint.sh

3) docker-compose up -d

Конструктивная критика приветствуется)

Спасибо что дочитали до конца!

Tags:
Hubs:
Total votes 14: ↑12 and ↓2+13
Comments15

Articles