Django: Как отобразить сообщение о загрузке во время длительной обработки?

Веб-приложения часто сталкиваются с задачами, требующими значительного времени на выполнение: генерация отчетов, обработка больших объемов данных, взаимодействие с внешними API. Выполнение таких задач синхронно в рамках HTTP-запроса приводит к зависанию интерфейса и плохому пользовательскому опыту.

Описание проблемы: медленные запросы и плохой пользовательский опыт

Когда пользователь инициирует действие, ожидающее длительной обработки (например, запуск сложного анализа данных рекламной кампании), браузер остается в состоянии ожидания ответа от сервера. Отсутствие какой-либо обратной связи создает впечатление, что приложение зависло или не работает. Это может привести к повторным отправкам запроса, закрытию вкладки и общему недовольству пользователя.

Почему важно отображать сообщение о загрузке?

Информирование пользователя о том, что его запрос принят в обработку и система работает над ним, критически важно. Отображение индикатора загрузки или прогресс-бара:

  • Снижает неопределенность: Пользователь понимает, что происходит.
  • Улучшает восприятие производительности: Даже если задача выполняется долго, пользователь видит прогресс или хотя бы индикацию активности.
  • Предотвращает ненужные действия: Пользователь с меньшей вероятностью будет обновлять страницу или отправлять запрос повторно.

Обзор возможных подходов и выбор оптимального

Существует несколько подходов для решения этой проблемы:

  1. Асинхронное выполнение с AJAX-опросом (Polling): Задача запускается в фоновом режиме (например, с помощью Celery), а клиент периодически опрашивает сервер о статусе выполнения.
  2. Использование WebSocket (Django Channels): Устанавливается постоянное двунаправленное соединение между клиентом и сервером, позволяющее серверу отправлять обновления статуса клиенту в реальном времени.
  3. Server-Sent Events (SSE): Однонаправленное соединение, где сервер отправляет события клиенту.

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

Реализация отображения сообщения о загрузке с использованием Celery и AJAX

Этот подход является классическим и хорошо подходит для многих сценариев.

Настройка Celery для асинхронных задач

Предполагается, что Celery уже настроен в вашем Django-проекте с брокером сообщений (например, Redis или RabbitMQ) и бэкендом результатов (например, Redis или база данных Django).

# settings.py
CELERY_BROKER_URL = 'redis://localhost:6379/0'
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0'
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = 'Europe/Moscow'

Создание задачи Celery для длительной обработки

Создадим задачу, имитирующую обработку данных рекламной кампании.

# your_app/tasks.py
import time
from typing import Dict, Any
from celery import shared_task, current_task
from celery.result import AsyncResult

@shared_task(bind=True)
def process_campaign_data(self, campaign_id: int, user_id: int) -> Dict[str, Any]:
    """
    Асинхронная задача для обработки данных рекламной кампании.
    Обновляет свое состояние для отображения прогресса.

    Args:
        campaign_id: Идентификатор кампании.
        user_id: Идентификатор пользователя, запустившего задачу.

    Returns:
        Словарь с результатами обработки.
    """
    total_steps = 10
    results = {'processed_keywords': 0, 'analyzed_clicks': 0}

    try:
        for i in range(total_steps):
            # Имитация длительной работы
            time.sleep(2)

            # Пример обновления метаданных для отображения прогресса
            progress = int(((i + 1) / total_steps) * 100)
            current_task.update_state(
                state='PROGRESS',
                meta={'progress': progress, 'step': f'Шаг {i+1} из {total_steps}'}
            )

            # Имитация полезной работы
            results['processed_keywords'] += 100
            results['analyzed_clicks'] += 500

        # Финальный результат
        results['status'] = 'Complete'
        return results
    except Exception as e:
        # Обработка ошибок
        self.update_state(state='FAILURE', meta={'error': str(e)})
        # Можно использовать raise чтобы задача считалась неуспешной в Celery
        raise

Реализация представления Django, запускающего задачу Celery

Это представление инициирует фоновую задачу и возвращает её ID клиенту.

# your_app/views.py
from django.http import JsonResponse, HttpRequest, HttpResponse
from django.views.decorators.http import require_POST
from django.contrib.auth.decorators import login_required
from .tasks import process_campaign_data
from celery.result import AsyncResult

@login_required
@require_POST
def start_campaign_processing(request: HttpRequest) -> JsonResponse:
    """
    Запускает асинхронную обработку данных кампании.

    Args:
        request: HttpRequest объект.

    Returns:
        JsonResponse с ID запущенной задачи.
    """
    campaign_id = request.POST.get('campaign_id')
    if not campaign_id:
        return JsonResponse({'error': 'Не указан ID кампании'}, status=400)

    # Запуск задачи Celery
    task = process_campaign_data.delay(campaign_id=int(campaign_id), user_id=request.user.id)

    return JsonResponse({'task_id': task.id})

@login_required
def get_task_status(request: HttpRequest, task_id: str) -> JsonResponse:
    """
    Возвращает статус выполнения задачи Celery.

    Args:
        request: HttpRequest объект.
        task_id: ID задачи Celery.

    Returns:
        JsonResponse со статусом и результатом задачи.
    """
    task_result = AsyncResult(task_id)

    response_data = {
        'task_id': task_id,
        'status': task_result.status,
        'result': task_result.result, # Содержит результат или ошибку
        'info': task_result.info # Содержит метаданные (прогресс)
    }

    return JsonResponse(response_data)

# your_app/urls.py
# Не забудьте добавить URL-маршруты
# path('process-campaign/', views.start_campaign_processing, name='start_campaign_processing'),
# path('task-status/<str:task_id>/', views.get_task_status, name='get_task_status'),

Frontend: AJAX-запрос для запуска задачи и отображения статуса загрузки

Используем JavaScript для взаимодействия с бэкендом.

<!-- Фрагмент шаблона Django -->
<button id="start-processing-btn" data-campaign-id="123">Запустить обработку</button>
<div id="loading-indicator" style="display: none;">
    <p>Идет обработка... <span id="progress-status"></span></p>
    <div class="progress-bar" style="width: 0%; background-color: blue; height: 20px;"></div>
</div>
<div id="result-area"></div>

<script>
    const startBtn = document.getElementById('start-processing-btn');
    const loadingIndicator = document.getElementById('loading-indicator');
    const progressStatus = document.getElementById('progress-status');
    const progressBar = loadingIndicator.querySelector('.progress-bar');
    const resultArea = document.getElementById('result-area');
    const campaignId = startBtn.dataset.campaignId;
    const startUrl = '/process-campaign/'; // Замените на ваш URL
    const statusUrlBase = '/task-status/'; // Замените на ваш URL

    let intervalId = null;

    function checkTaskStatus(taskId) {
        const statusUrl = `${statusUrlBase}${taskId}/`;
        fetch(statusUrl)
            .then(response => response.json())
            .then(data => {
                console.log('Task status:', data);
                if (data.status === 'PROGRESS') {
                    const progress = data.info?.progress || 0;
                    const stepInfo = data.info?.step || '';
                    progressStatus.textContent = `${stepInfo} (${progress}%)`;
                    progressBar.style.width = `${progress}%`;
                } else if (data.status === 'SUCCESS') {
                    clearInterval(intervalId);
                    loadingIndicator.style.display = 'none';
                    resultArea.textContent = `Обработка завершена: ${JSON.stringify(data.result)}`;
                    startBtn.disabled = false;
                } else if (data.status === 'FAILURE') {
                    clearInterval(intervalId);
                    loadingIndicator.style.display = 'none';
                    resultArea.textContent = `Ошибка обработки: ${data.result?.error || 'Неизвестная ошибка'}`;
                    startBtn.disabled = false;
                } else if (data.status === 'PENDING' || data.status === 'STARTED') {
                     progressStatus.textContent = 'Задача в очереди или запущена...';
                     progressBar.style.width = `0%`;
                }
            })
            .catch(error => {
                console.error('Error fetching task status:', error);
                clearInterval(intervalId);
                loadingIndicator.style.display = 'none';
                resultArea.textContent = 'Ошибка получения статуса задачи.';
                startBtn.disabled = false;
            });
    }

    startBtn.addEventListener('click', () => {
        startBtn.disabled = true;
        resultArea.textContent = '';
        loadingIndicator.style.display = 'block';
        progressStatus.textContent = 'Запуск...';
        progressBar.style.width = '0%';

        // Получение CSRF токена (стандартный способ Django)
        const csrftoken = document.querySelector('[name=csrfmiddlewaretoken]').value;

        const formData = new FormData();
        formData.append('campaign_id', campaignId);

        fetch(startUrl, {
            method: 'POST',
            headers: {
                'X-CSRFToken': csrftoken,
                'X-Requested-With': 'XMLHttpRequest', // Часто используется для идентификации AJAX
            },
            body: formData
        })
        .then(response => {
             if (!response.ok) {
                 throw new Error(`HTTP error! status: ${response.status}`);
             }
             return response.json();
         })
        .then(data => {
            if (data.task_id) {
                // Начинаем опрос статуса каждые 2 секунды
                intervalId = setInterval(() => checkTaskStatus(data.task_id), 2000);
            } else {
                 throw new Error(data.error || 'Не удалось получить ID задачи.');
            }
        })
        .catch(error => {
            console.error('Error starting task:', error);
            loadingIndicator.style.display = 'none';
            resultArea.textContent = `Ошибка запуска задачи: ${error.message}`;
            startBtn.disabled = false;
        });
    });
</script>

Использование Channels (WebSocket) для обновления статуса в реальном времени

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

Настройка Django Channels

Требуется установка channels и channels_redis (или другого бэкенда канала), настройка ASGI-приложения и CHANNEL_LAYERS в settings.py.

# settings.py
ASGI_APPLICATION = 'your_project.asgi.application'
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            'hosts': [('localhost', 6379)],
        },
    },
}

Необходимо также настроить ASGI-сервер (Daphne, Uvicorn) для обработки WebSocket соединений.

Реклама

Создание Consumer для обработки событий WebSocket

Consumer будет управлять WebSocket-соединениями и пересылать сообщения от задач Celery клиентам.

# your_app/consumers.py
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync # Для вызова из синхронного кода

class TaskStatusConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        # Присоединяемся к группе, уникальной для пользователя или сессии
        # Здесь для примера используем user_id
        self.user = self.scope['user']
        if not self.user.is_authenticated:
            await self.close()
            return

        self.group_name = f'user_{self.user.id}_tasks'

        await self.channel_layer.group_add(
            self.group_name,
            self.channel_name
        )
        await self.accept()

    async def disconnect(self, close_code):
        if hasattr(self, 'group_name'):
            await self.channel_layer.group_discard(
                self.group_name,
                self.channel_name
            )

    # Этот метод вызывается из задачи Celery через channel_layer.group_send
    async def task_update(self, event: dict):
        """Отправляет сообщение клиенту через WebSocket."""
        message = event['message']
        await self.send(text_data=json.dumps(message))

# your_project/asgi.py (или где настроен роутинг Channels)
# ... настройка application = ProtocolTypeRouter(...)
# 'websocket': AuthMiddlewareStack(
#     URLRouter([
#         path('ws/task_status/', consumers.TaskStatusConsumer.as_asgi()),
#     ])
# ),

Отправка обновлений статуса из задачи в Consumer

Модифицируем задачу Celery, чтобы она отправляла обновления через channel_layer.

# your_app/tasks.py (модифицированная версия)
import time
from typing import Dict, Any
from celery import shared_task
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync

@shared_task
def process_campaign_data_ws(campaign_id: int, user_id: int) -> Dict[str, Any]:
    """
    Асинхронная задача с отправкой прогресса через WebSocket.

    Args:
        campaign_id: Идентификатор кампании.
        user_id: Идентификатор пользователя, запустившего задачу.

    Returns:
        Словарь с финальным статусом.
    """
    channel_layer = get_channel_layer()
    group_name = f'user_{user_id}_tasks'
    total_steps = 10
    task_id = process_campaign_data_ws.request.id # Получаем ID текущей задачи

    def send_status_update(status: str, progress: int = 0, step: str = '', result: Any = None, error: str = None):
        """Вспомогательная функция для отправки обновлений."""
        message = {
            'type': 'task_update', # Соответствует методу в Consumer
            'message': {
                'task_id': task_id,
                'status': status,
                'progress': progress,
                'step': step,
                'result': result,
                'error': error
            }
        }
        # Вызов асинхронной функции из синхронного кода Celery
        async_to_sync(channel_layer.group_send)(group_name, message)

    try:
        send_status_update(status='STARTED')
        results = {'processed_keywords': 0, 'analyzed_clicks': 0}

        for i in range(total_steps):
            time.sleep(2) 
            progress = int(((i + 1) / total_steps) * 100)
            step_info = f'Шаг {i+1} из {total_steps}'
            send_status_update(status='PROGRESS', progress=progress, step=step_info)

            results['processed_keywords'] += 100
            results['analyzed_clicks'] += 500

        results['status'] = 'Complete'
        send_status_update(status='SUCCESS', progress=100, result=results)
        return {'status': 'Complete', 'task_id': task_id}

    except Exception as e:
        error_message = str(e)
        send_status_update(status='FAILURE', error=error_message)
        # Возвращаем информацию об ошибке (или можно рейзить исключение)
        return {'status': 'Failed', 'error': error_message, 'task_id': task_id}

# Представление Django для запуска задачи должно теперь вызывать process_campaign_data_ws
# и может возвращать только task_id, так как статус будет приходить по WebSocket.

Frontend: Подключение WebSocket и отображение статуса

<!-- Фрагмент шаблона Django -->
<button id="start-processing-ws-btn" data-campaign-id="456">Запустить обработку (WebSocket)</button>
<div id="loading-indicator-ws" style="display: none;">
    <p>Идет обработка... <span id="progress-status-ws"></span></p>
    <div class="progress-bar-ws" style="width: 0%; background-color: green; height: 20px;"></div>
</div>
<div id="result-area-ws"></div>

<script>
    const startWsBtn = document.getElementById('start-processing-ws-btn');
    const loadingIndicatorWs = document.getElementById('loading-indicator-ws');
    const progressStatusWs = document.getElementById('progress-status-ws');
    const progressBarWs = loadingIndicatorWs.querySelector('.progress-bar-ws');
    const resultAreaWs = document.getElementById('result-area-ws');
    const campaignIdWs = startWsBtn.dataset.campaignId;
    // URL для запуска задачи (должен вызывать process_campaign_data_ws)
    const startWsUrl = '/process-campaign-ws/'; 

    // WebSocket URL
    const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
    const wsUrl = `${wsScheme}://${window.location.host}/ws/task_status/`;
    let socket = null;

    function connectWebSocket() {
        console.log('Connecting to WebSocket...');
        socket = new WebSocket(wsUrl);

        socket.onopen = function(e) {
            console.log('WebSocket connection established.');
            startWsBtn.disabled = false; // Можно разблокировать кнопку после установки соединения
        };

        socket.onmessage = function(event) {
            const data = JSON.parse(event.data);
            console.log('Message from server:', data);

            // Ищем нужный индикатор по task_id, если на странице может быть несколько задач
            // В данном примере предполагаем одну задачу

            if (data.status === 'PROGRESS') {
                 loadingIndicatorWs.style.display = 'block';
                 progressStatusWs.textContent = `${data.step} (${data.progress}%)`;
                 progressBarWs.style.width = `${data.progress}%`;
            } else if (data.status === 'SUCCESS') {
                 loadingIndicatorWs.style.display = 'none';
                 resultAreaWs.textContent = `Обработка завершена: ${JSON.stringify(data.result)}`;
                 // Кнопку можно снова активировать или изменить ее состояние
            } else if (data.status === 'FAILURE') {
                 loadingIndicatorWs.style.display = 'none';
                 resultAreaWs.textContent = `Ошибка обработки: ${data.error || 'Неизвестная ошибка'}`;
            } else if (data.status === 'STARTED'){
                loadingIndicatorWs.style.display = 'block';
                progressStatusWs.textContent = 'Задача запущена...';
                progressBarWs.style.width = '0%';
            }
        };

        socket.onclose = function(event) {
            console.error('WebSocket closed unexpectedly:', event);
            // Попытка переподключения через некоторое время
            setTimeout(connectWebSocket, 5000);
        };

        socket.onerror = function(error) {
            console.error('WebSocket error:', error);
            // Ошибки обычно приводят к onclose
        };
    }

    startWsBtn.addEventListener('click', () => {
        if (!socket || socket.readyState !== WebSocket.OPEN) {
            alert('WebSocket не подключен. Пожалуйста, подождите.');
            return;
        }

        startWsBtn.disabled = true;
        resultAreaWs.textContent = '';
        loadingIndicatorWs.style.display = 'block';
        progressStatusWs.textContent = 'Запуск...';
        progressBarWs.style.width = '0%';

        const csrftoken = document.querySelector('[name=csrfmiddlewaretoken]').value;
        const formData = new FormData();
        formData.append('campaign_id', campaignIdWs);

        // Запускаем задачу через обычный HTTP POST запрос
        fetch(startWsUrl, { 
            method: 'POST',
            headers: {
                'X-CSRFToken': csrftoken,
                'X-Requested-With': 'XMLHttpRequest',
            },
            body: formData
        })
        .then(response => {
             if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
             return response.json();
         })
        .then(data => {
            if (data.task_id) {
                console.log(`Task ${data.task_id} started. Waiting for WebSocket updates.`);
                // Статус будет приходить через WebSocket, кнопка останется неактивной
            } else {
                 throw new Error(data.error || 'Не удалось получить ID задачи.');
            }
        })
        .catch(error => {
            console.error('Error starting task:', error);
            loadingIndicatorWs.style.display = 'none';
            resultAreaWs.textContent = `Ошибка запуска задачи: ${error.message}`;
            startWsBtn.disabled = false;
        });
    });

    // Инициируем подключение WebSocket при загрузке страницы
    connectWebSocket();

</script>

Альтернативные подходы и библиотеки

Использование django-rq

django-rq предоставляет простую интеграцию с RQ (Redis Queue), которая является легковесной альтернативой Celery. Подход к отображению статуса будет аналогичен Celery/AJAX: запуск задачи, получение ID, опрос статуса через специальное представление.

Server-Sent Events (SSE) вместо WebSocket

SSE — это стандарт W3C, позволяющий серверу отправлять данные клиенту по HTTP-соединению после установления первоначального соединения. В отличие от WebSocket, SSE является однонаправленным (сервер -> клиент). Реализация на Django может потребовать использования ASGI или специфических библиотек (django-sse). Это может быть проще WebSocket, если не требуется двунаправленная связь.

Библиотеки для прогресс-баров (например, django-progressbar-upload)

Для специфической задачи отслеживания прогресса загрузки файлов существуют готовые решения, такие как django-progressbar-upload. Эта библиотека интегрируется с механизмом загрузки файлов Django и предоставляет JavaScript-код и представления для отображения прогресс-бара во время загрузки файла на сервер, что отличается от отслеживания прогресса обработки данных на сервере.

Заключение: Выбор подходящего метода и лучшие практики

Сравнение Celery/AJAX, Channels и других подходов

  • Celery/AJAX Polling:
    • Плюсы: Относительно простая реализация, широкая поддержка браузерами, не требует ASGI-сервера (если Celery уже используется).
    • Минусы: Создает дополнительную нагрузку на сервер из-за периодических запросов, задержка в обновлении статуса равна интервалу опроса.
  • Channels (WebSocket):
    • Плюсы: Обновление статуса в реальном времени, меньше HTTP-запросов после установки соединения, подходит для интерактивных приложений.
    • Минусы: Более сложная настройка (ASGI, Channels), требует поддержки WebSocket на клиенте и сервере, управление состоянием соединений.
  • SSE:
    • Плюсы: Проще WebSocket, стандартный протокол поверх HTTP, автоматическое переподключение.
    • Минусы: Однонаправленная связь, ограничения на количество одновременных соединений в некоторых браузерах.
  • django-rq:
    • Плюсы: Проще в настройке, чем Celery, для несложных задач.
    • Минусы: Меньше возможностей и масштабируемости по сравнению с Celery.

Выбор зависит от конкретных требований: для простого индикатора


Добавить комментарий