Веб-приложения часто сталкиваются с задачами, требующими значительного времени на выполнение: генерация отчетов, обработка больших объемов данных, взаимодействие с внешними API. Выполнение таких задач синхронно в рамках HTTP-запроса приводит к зависанию интерфейса и плохому пользовательскому опыту.
Описание проблемы: медленные запросы и плохой пользовательский опыт
Когда пользователь инициирует действие, ожидающее длительной обработки (например, запуск сложного анализа данных рекламной кампании), браузер остается в состоянии ожидания ответа от сервера. Отсутствие какой-либо обратной связи создает впечатление, что приложение зависло или не работает. Это может привести к повторным отправкам запроса, закрытию вкладки и общему недовольству пользователя.
Почему важно отображать сообщение о загрузке?
Информирование пользователя о том, что его запрос принят в обработку и система работает над ним, критически важно. Отображение индикатора загрузки или прогресс-бара:
- Снижает неопределенность: Пользователь понимает, что происходит.
- Улучшает восприятие производительности: Даже если задача выполняется долго, пользователь видит прогресс или хотя бы индикацию активности.
- Предотвращает ненужные действия: Пользователь с меньшей вероятностью будет обновлять страницу или отправлять запрос повторно.
Обзор возможных подходов и выбор оптимального
Существует несколько подходов для решения этой проблемы:
- Асинхронное выполнение с AJAX-опросом (Polling): Задача запускается в фоновом режиме (например, с помощью Celery), а клиент периодически опрашивает сервер о статусе выполнения.
- Использование WebSocket (Django Channels): Устанавливается постоянное двунаправленное соединение между клиентом и сервером, позволяющее серверу отправлять обновления статуса клиенту в реальном времени.
- 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.
Выбор зависит от конкретных требований: для простого индикатора