Tareas asíncronas en Django con Redis y Celery

En este tutorial, proporcionaré una comprensión general de por qué las colas de mensajes de apio son valiosas junto con cómo utilizar el apio junto con Re...

Introducción

En este tutorial, proporcionaré una comprensión general de por qué las colas de mensajes de apio son valiosas junto con cómo utilizar apio junto con Redis en una aplicación Django. Para demostrar los detalles de la implementación, crearé una aplicación de procesamiento de imágenes minimalista que genere miniaturas de las imágenes enviadas por los usuarios.

Se tratarán los siguientes temas:

El código de este ejemplo se puede encontrar en GitHub junto con las instrucciones de instalación y configuración si solo desea pasar directamente a una aplicación funcionalmente completa; de lo contrario, durante el resto de el artículo que le mostraré cómo construir todo desde cero.

Antecedentes sobre las colas de mensajes con Celery y Redis {#antecedentes sobre las colas de mensajes con apio y Redis}

Celery es un paquete de software de cola de tareas basado en Python que permite la ejecución de cargas de trabajo computacionales asincrónicas impulsadas por la información contenida en los mensajes que se producen en el código de la aplicación (Django en este ejemplo) destinado a una cola de tareas de Celery. El apio también se puede usar para ejecutar tareas repetibles, periódicas (es decir, programadas), pero ese no será el enfoque de este artículo.

El apio se utiliza mejor junto con una solución de almacenamiento que a menudo se conoce como intermediario de mensajes. Un intermediario de mensajes común que se utiliza con el apio es Redis, que es un almacén de datos clave-valor de alto rendimiento en la memoria. Específicamente, Redis se utiliza para almacenar mensajes producidos por el código de la aplicación que describen el trabajo que se realizará en la cola de tareas de Celery. Redis también sirve como almacenamiento de los resultados que salen de las colas de apio que luego recuperan los consumidores de la cola.

Configuración de desarrollo local con Django, Celery y Redis

Comenzaré con la parte más difícil primero, que es instalar Redis.

Instalación de Redis en Windows

  1. Descarga Redis archivo zip y descomprímelo en algún directorio
  2. Busque el archivo llamado redis-server.exe y haga doble clic para iniciar el servidor en una ventana de comandos
  3. Del mismo modo, busque otro archivo llamado redis-cli.exe y haga doble clic en él para abrir el programa en una ventana de comando separada
  4. Dentro de la ventana de comando que ejecuta el cliente cli, pruebe para asegurarse de que el cliente pueda hablar con el servidor emitiendo el comando ping y, si todo va bien, se devolverá una respuesta de PONG.

Instalación de Redis en Mac OSX/Linux

  1. Descargue Redis [archivo tarball] (https://redis.io/download) y extráigalo en algún directorio
  2. Ejecute el archivo make con make install para construir el programa
  3. Abra una ventana de terminal y ejecute el comando redis-server
  4. En otra ventana de terminal, ejecute redis-cli
  5. Dentro de la ventana de terminal que ejecuta el cliente cli, pruebe para asegurarse de que el cliente pueda hablar con el servidor emitiendo el comando ping y, si todo va bien, debería devolverse una respuesta PONG.

Instalar Python Virtual Env y Dependencias

Ahora puedo pasar a crear un entorno virtual de Python3 e instalar los paquetes de dependencia necesarios para este proyecto.

Para comenzar, crearé un directorio para albergar cosas llamado image_parroter y luego, dentro de él, crearé mi entorno virtual. Todos los comandos de ahora en adelante serán del estilo de Unix solamente, pero la mayoría, si no todos, serán los mismos para un entorno de Windows.

1
2
3
4
$ mkdir image_parroter
$ cd image_parroter
$ python3 -m venv venv
$ source venv/bin/activate

Con el entorno virtual ahora activado, puedo instalar los paquetes de Python.

1
2
(venv) $ pip install Django Celery redis Pillow django-widget-tweaks
(venv) $ pip freeze > requirements.txt
  • Pillow es un paquete de Python no relacionado con el apio para el procesamiento de imágenes que usaré más adelante en este tutorial para demostrar un caso de uso real para tareas de apio.
  • Django Widget Tweaks es un complemento de Django para proporcionar flexibilidad en la forma en que se representan las entradas de formulario.

Configurando el Proyecto Django

Continuando, creo un proyecto de Django llamado image_parroter y luego una aplicación de Django llamada thumbnailer.

1
2
3
(venv) $ django-admin startproject image_parroter
(venv) $ cd image_parroter
(venv) $ python manage.py startapp thumbnailer

En este punto, la estructura de directorios se ve de la siguiente manera:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
$ tree -I venv
.
└── image_parroter
    ├── image_parroter
       ├── __init__.py
       ├── settings.py
       ├── urls.py
       └── wsgi.py
    ├── manage.py
    └── thumbnailer
        ├── __init__.py
        ├── admin.py
        ├── apps.py
        ├── migrations
           └── __init__.py
        ├── models.py
        ├── tests.py
        └── views.py

Para integrar Celery dentro de este proyecto de Django agrego un nuevo módulo image_parroter/image_parrroter/celery.py siguiendo las convenciones descritas en los [documentos de apio](http://docs.celeryproject.org/en/latest/django/first-steps- with-django.html#django-primeros-pasos). Dentro de este nuevo módulo de Python importo el paquete os y la clase Celery del paquete celery.

El módulo os se usa para asociar una variable de entorno Celery llamada DJANGO_SETTINGS_MODULE con el módulo de configuración del proyecto Django. A continuación, creo una instancia de la clase Celery para crear la variable de instancia celery_app. Luego actualizo la configuración de la aplicación Celery con la configuración que pronto colocaré en el archivo de configuración del proyecto Django identificable con un prefijo 'CELERY_'. Finalmente, le digo a la instancia celery_app recién creada que descubra automáticamente las tareas dentro del proyecto.

El módulo celery.py completado se muestra a continuación:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# image_parroter/image_parroter/celery.py

import os
from celery import Celery

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'image_parroter.settings')

celery_app = Celery('image_parroter')
celery_app.config_from_object('django.conf:settings', namespace='CELERY')
celery_app.autodiscover_tasks()

Ahora, en el módulo settings.py del proyecto, en la parte inferior, defino una sección para la configuración de apio y agrego la configuración que ves a continuación. Esta configuración le dice a Celery que use Redis como intermediario de mensajes y también dónde conectarse. También le dicen a Celery que espere que los mensajes se transmitan de un lado a otro entre las colas de tareas de Celery y el agente de mensajes de Redis para que estén en el tipo mime de aplicación/json.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# image_parroter/image_parroter/settings.py

... skipping to the bottom

# celery
CELERY_BROKER_URL = 'redis://localhost:6379'
CELERY_RESULT_BACKEND = 'redis://localhost:6379'
CELERY_ACCEPT_CONTENT = ['application/json']
CELERY_RESULT_SERIALIZER = 'json'
CELERY_TASK_SERIALIZER = 'json'

A continuación, debo asegurarme de que la aplicación de apio creada y configurada previamente se inyecte en la aplicación Django cuando se ejecute. Esto se hace importando la aplicación Celery dentro del script principal __init__.py del proyecto Django y registrándolo explícitamente como un símbolo de espacio de nombres dentro del paquete Django "image_parroter".

1
2
3
4
5
# image_parroter/image_parroter/__init__.py

from .celery import celery_app

__all__ = ('celery_app',)

Continúo siguiendo las convenciones sugeridas al agregar un nuevo módulo llamado tasks.py dentro de la aplicación "thumbnailer". Dentro del módulo tasks.py, importo el decorador de funciones shared_tasks y lo uso para definir una función de tarea de apio llamada adding_task, como se muestra a continuación.

1
2
3
4
5
6
7
# image_parroter/thumbnailer/tasks.py

from celery import shared_task

@shared_task
def adding_task(x, y):
    return x + y

Por último, necesito agregar la aplicación de miniaturas a la lista de INSTALLED_APPS en el módulo settings.py del proyecto image_parroter. Mientras estoy allí, también debo agregar la aplicación "widget_tweaks" para controlar la representación de la entrada del formulario que usaré más adelante para permitir que los usuarios carguen archivos.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# image_parroter/image_parroter/settings.py

... skipping to the INSTALLED_APPS

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'thumbnailer.apps.ThumbnailerConfig',
    'widget_tweaks',
]

Ahora puedo probar cosas usando algunos comandos simples en tres terminales.

En una terminal, necesito tener el servidor redis en ejecución, así:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
$ redis-server
48621:C 21 May 21:55:23.706 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
48621:C 21 May 21:55:23.707 # Redis version=4.0.8, bits=64, commit=00000000, modified=0, pid=48621, just started
48621:C 21 May 21:55:23.707 # Warning: no config file specified, using the default config. In order to specify a config file use redis-server /path/to/redis.conf
48621:M 21 May 21:55:23.708 * Increased maximum number of open files to 10032 (it was originally set to 2560).
                _._                                                  
           _.-``__ ''-._                                             
      _.-``    `.  `_.  ''-._           Redis 4.0.8 (00000000/0) 64 bit
  .-`` .-```.  ```\/    _.,_ ''-._                                   
 (    '      ,       .-`  | `,    )     Running in standalone mode
 |`-._`-...-` __...-.``-._|'` _.-'|     Port: 6379
 |    `-._   `._    /     _.-'    |     PID: 48621
  `-._    `-._  `-./  _.-'    _.-'                                   
 |`-._`-._    `-.__.-'    _.-'_.-'|                                  
 |    `-._`-._        _.-'_.-'    |           http://redis.io        
  `-._    `-._`-.__.-'_.-'    _.-'                                   
 |`-._`-._    `-.__.-'    _.-'_.-'|                                  
 |    `-._`-._        _.-'_.-'    |                                  
  `-._    `-._`-.__.-'_.-'    _.-'                                   
      `-._    `-.__.-'    _.-'                                       
          `-._        _.-'                                           
              `-.__.-'                                               

48621:M 21 May 21:55:23.712 # Server initialized
48621:M 21 May 21:55:23.712 * Ready to accept connections

En una segunda terminal, con una instancia activa del entorno virtual Python instalada previamente, en el directorio raíz del paquete del proyecto (el mismo que contiene el módulo manage.py) lanzo el programa celery.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
(venv) $ celery worker -A image_parroter --loglevel=info
 
 -------------- [correo electrónico protegido] v4.3.0 (rhubarb)
---- **** ----- 
--- * ***  * -- Darwin-18.5.0-x86_64-i386-64bit 2019-05-22 03:01:38
-- * - **** --- 
- ** ---------- [config]
- ** ---------- .> app:         image_parroter:0x110b18eb8
- ** ---------- .> transport:   redis://localhost:6379//
- ** ---------- .> results:     redis://localhost:6379/
- *** --- * --- .> concurrency: 8 (prefork)
-- ******* ---- .> task events: OFF (enable -E to monitor tasks in this worker)
--- ***** ----- 
 -------------- [queues]
                .> celery           exchange=celery(direct) key=celery
                

[tasks]
  . thumbnailer.tasks.adding_task

En la tercera y última terminal, de nuevo con el entorno virtual de Python activo, puedo iniciar el shell de Django Python y probar mi adding_task, así:

1
2
3
4
5
6
7
8
(venv) $ python manage.py shell
Python 3.6.6 |Anaconda, Inc.| (default, Jun 28 2018, 11:07:29) 
>>> from thumbnailer.tasks import adding_task
>>> task = adding_task.delay(2, 5)
>>> print(f"id={task.id}, state={task.state}, status={task.status}") 
id=86167f65-1256-497e-b5d9-0819f24e95bc, state=SUCCESS, status=SUCCESS
>>> task.get()
7

Tenga en cuenta el uso del método .delay(...) en el objeto adding_task. Esta es la forma habitual de pasar los parámetros necesarios al objeto de tarea con el que se está trabajando, así como de iniciar el envío al agente de mensajes y la cola de tareas. El resultado de llamar al método .delay(...) es un valor de retorno similar a una promesa del tipo celery.result.AsyncResult. Este valor devuelto contiene información como la identificación de la tarea, su estado de ejecución y el estado de la tarea junto con la capacidad de acceder a los resultados producidos por la tarea a través del método .get() como se muestra en el ejemplo.

Creación de miniaturas de imágenes en una tarea de apio

Ahora que la configuración de la placa de caldera para integrar una instancia de Celery respaldada por Redis en la aplicación Django está fuera del camino, puedo pasar a demostrar algunas funciones más útiles con la aplicación de miniaturas mencionada anteriormente.

De vuelta en el módulo tasks.py, importo la clase Imagen del paquete PIL, luego agrego una nueva tarea llamada make_thumbnails, que acepta una ruta de archivo de imagen y una lista de dimensiones de ancho y alto de 2 tuplas para crear miniaturas de.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# image_parroter/thumbnailer/tasks.py
import os
from zipfile import ZipFile

from celery import shared_task
from PIL import Image

from django.conf import settings

@shared_task
def make_thumbnails(file_path, thumbnails=[]):
    os.chdir(settings.IMAGES_DIR)
    path, file = os.path.split(file_path)
    file_name, ext = os.path.splitext(file)

    zip_file = f"{file_name}.zip"
    results = {'archive_path': f"{settings.MEDIA_URL}images/{zip_file}"}
    try:
        img = Image.open(file_path)
        zipper = ZipFile(zip_file, 'w')
        zipper.write(file)
        os.remove(file_path)
        for w, h in thumbnails:
            img_copy = img.copy()
            img_copy.thumbnail((w, h))
            thumbnail_file = f'{file_name}_{w}x{h}.{ext}'
            img_copy.save(thumbnail_file)
            zipper.write(thumbnail_file)
            os.remove(thumbnail_file)

        img.close()
        zipper.close()
    except IOError as e:
        print(e)

    return results

La tarea de miniaturas anterior simplemente carga el archivo de imagen de entrada en una instancia de Imagen de almohada, luego recorre la lista de dimensiones pasada a la tarea creando una miniatura para cada una, agregando cada miniatura a un archivo zip y limpiando los archivos intermedios. Se devuelve un diccionario simple que especifica la URL desde la que se puede descargar el archivo zip de miniaturas.

Con la tarea de apio definida, paso a construir las vistas de Django para servir una plantilla con un formulario de carga de archivos.

Para comenzar, le doy al proyecto Django una ubicación MEDIA_ROOT donde pueden residir los archivos de imagen y los archivos zip (utilicé esto en la tarea de ejemplo anterior), así como especificar la MEDIA_URL desde donde se puede servir el contenido. En el módulo image_parroter/settings.py agrego las ubicaciones de configuración MEDIA_ROOT, MEDIA_URL, IMAGES_DIR y luego proporciono la lógica para crear estas ubicaciones si no existen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# image_parroter/settings.py

... skipping down to the static files section

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

STATIC_URL = '/static/'
MEDIA_URL = '/media/'

MEDIA_ROOT = os.path.abspath(os.path.join(BASE_DIR, 'media'))
IMAGES_DIR = os.path.join(MEDIA_ROOT, 'images')

if not os.path.exists(MEDIA_ROOT) or not os.path.exists(IMAGES_DIR):
    os.makedirs(IMAGES_DIR)

Dentro del módulo thumbnailer/views.py, importo la clase django.views.View y la uso para crear una clase HomeView que contiene los métodos get y post, como se muestra a continuación.

El método get simplemente devuelve una plantilla home.html, que se creará en breve, y le entrega un FileUploadForm compuesto por un campo ImageField como se ve arriba de la clase HomeView.

El método post construye el objeto FileUploadForm utilizando los datos enviados en la solicitud, verifica su validez, luego, si es válido, guarda el archivo cargado en IMAGES_DIR e inicia una tarea make_thumbnails mientras toma la tarea id y el estado para pasar a la plantilla, o devuelve el formulario con sus errores a la plantilla home.html.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# thumbnailer/views.py

import os

from celery import current_app

from django import forms
from django.conf import settings
from django.http import JsonResponse
from django.shortcuts import render
from django.views import View

from .tasks import make_thumbnails

class FileUploadForm(forms.Form):
    image_file = forms.ImageField(required=True)

class HomeView(View):
    def get(self, request):
        form = FileUploadForm()
        return render(request, 'thumbnailer/home.html', { 'form': form })
    
    def post(self, request):
        form = FileUploadForm(request.POST, request.FILES)
        context = {}

        if form.is_valid():
            file_path = os.path.join(settings.IMAGES_DIR, request.FILES['image_file'].name)

            with open(file_path, 'wb+') as fp:
                for chunk in request.FILES['image_file']:
                    fp.write(chunk)

            task = make_thumbnails.delay(file_path, thumbnails=[(128, 128)])

            context['task_id'] = task.id
            context['task_status'] = task.status

            return render(request, 'thumbnailer/home.html', context)

        context['form'] = form

        return render(request, 'thumbnailer/home.html', context)


class TaskView(View):
    def get(self, request, task_id):
        task = current_app.AsyncResult(task_id)
        response_data = {'task_status': task.status, 'task_id': task.id}

        if task.status == 'SUCCESS':
            response_data['results'] = task.get()

        return JsonResponse(response_data)

Debajo de la clase HomeView he colocado una clase TaskView que se usará a través de una solicitud AJAX para verificar el estado de la tarea make_thumbnails. Aquí notará que importé el objeto current_app del paquete de apio y lo usé para recuperar el objeto AsyncResult de la tarea asociado con task_id de la solicitud. Creo un diccionario response_data del estado y la identificación de la tarea, luego, si el estado indica que la tarea se ha ejecutado con éxito, obtengo los resultados llamando al método get() del objeto AsynchResult asignándolo al Clave resultados de response_data que se devolverá como JSON al solicitante HTTP.

Antes de poder crear la interfaz de usuario de la plantilla, necesito asignar las clases de vistas de Django anteriores a algunas URL sensibles. Comienzo agregando un módulo urls.py dentro de la aplicación de miniaturas y definiendo las siguientes URL:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# thumbnailer/urls.py

from django.urls import path

from . import views

urlpatterns = [
  path('', views.HomeView.as_view(), name='home'),
  path('task/<str:task_id>/', views.TaskView.as_view(), name='task'),
]

Luego, en la configuración de la URL principal del proyecto, necesito incluir las URL del nivel de la aplicación, así como hacer que conozca la URL de los medios, así:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# image_parroter/urls.py

from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('thumbnailer.urls')),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

A continuación, empiezo a crear una vista de plantilla simple para que un usuario envíe un archivo de imagen y verifique el estado de las tareas make_thumbnails enviadas e inicie una descarga de las miniaturas resultantes. Para comenzar, necesito crear un directorio para albergar esta única plantilla dentro del directorio de miniaturas, de la siguiente manera:

1
(venv) $ mkdir -p thumbnailer/templates/thumbnailer

Luego, dentro de este directorio templates/thumbnailer, agrego una plantilla llamada home.html. Dentro de home.html empiezo cargando las etiquetas de plantilla "widget_tweaks", luego defino el HTML importando un marco CSS llamado Bulma CSS, así como un Biblioteca JavaScript llamada Axios.js. En el cuerpo de la página HTML, proporciono un título, un marcador de posición para mostrar un mensaje de resultados en progreso y el formulario de carga de archivos.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
<!-- templates/thumbnailer/home.html -->
{% load widget_tweaks %}
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Thumbnailer</title>
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.5/css/bulma.min.css">
  <script src="https://cdn.jsdelivr.net/npm/vue"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.18.0/axios.min.js"></script>
  <script defer src="https://use.fontawesome.com/releases/v5.0.7/js/all.js"></script>
</head>
<body>
  <nav class="navbar" role="navigation" aria-label="main navigation">
    <div class="navbar-brand">
      <a class="navbar-item" href="/">
        Thumbnailer
      </a>
    </div>
  </nav>
  <section class="hero is-primary is-fullheight-with-navbar">
    <div class="hero-body">
      <div class="container">
        <h1 class="title is-size-1 has-text-centered">Thumbnail Generator</h1>
        <p class="subtitle has-text-centered" id="progress-title"></p>
        <div class="columns is-centered">
          <div class="column is-8">
            <form action="{% url 'home' %}" method="POST" enctype="multipart/form-data">
              {% csrf_token %}
              <div class="file is-large has-name">
                <label class="file-label">
                  {{ form.image_file|add_class:"file-input" }}
                  <span class="file-cta">
                    <span class="file-icon"><i class="fas fa-upload"></i></span>
                    <span class="file-label">Browse image</span>
                  </span>
                  <span id="file-name" class="file-name" 
                    style="background-color: white; color: black; min-width: 450px;">
                  </span>
                </label>
                <input class="button is-link is-large" type="submit" value="Submit">
              </div>
              
            </form>
          </div>
        </div>
      </div>
    </div>
  </section>
  <script>
  var file = document.getElementById('{{form.image_file.id_for_label}}');
  file.onchange = function() {
    if(file.files.length > 0) {
      document.getElementById('file-name').innerHTML = file.files[0].name;
    }
  };
  </script>

  {% if task_id %}
  <script>
  var taskUrl = "{% url 'task' task_id=task_id %}";
  var dots = 1;
  var progressTitle = document.getElementById('progress-title');
  updateProgressTitle();
  var timer = setInterval(function() {
    updateProgressTitle();
    axios.get(taskUrl)
      .then(function(response){
        var taskStatus = response.data.task_status
        if (taskStatus === 'SUCCESS') {
          clearTimer('Check downloads for results');
          var url = window.location.protocol + '//' + window.location.host + response.data.results.archive_path;
          var a = document.createElement("a");
          a.target = '_BLANK';
          document.body.appendChild(a);
          a.style = "display: none";
          a.href = url;
          a.download = 'results.zip';
          a.click();
          document.body.removeChild(a);
        } else if (taskStatus === 'FAILURE') {
          clearTimer('An error occurred');
        }
      })
      .catch(function(err){
        console.log('err', err);
        clearTimer('An error occurred');
      });
  }, 800);

  function updateProgressTitle() {
    dots++;
    if (dots > 3) {
      dots = 1;
    }
    progressTitle.innerHTML = 'processing images ';
    for (var i = 0; i < dots; i++) {
      progressTitle.innerHTML += '.';
    }
  }
  function clearTimer(message) {
    clearInterval(timer);
    progressTitle.innerHTML = message;
  }
  </script> 
  {% endif %}
</body>
</html>

En la parte inferior del elemento cuerpo, agregué JavaScript para proporcionar un comportamiento adicional. Primero, creo una referencia al campo de entrada del archivo y registro un detector de cambios, que simplemente agrega el nombre del archivo seleccionado a la interfaz de usuario, una vez seleccionado.

Luego viene la parte más relevante. Utilizo el operador lógico if de plantilla de Django para verificar la presencia de un task_id que se transmite desde la vista de clase HomeView. Esto indica una respuesta después de que se haya enviado una tarea make_thumbnails. Luego uso la etiqueta de plantilla url de Django para construir una URL de verificación de estado de tarea apropiada y comienzo una solicitud AJAX de intervalo de tiempo a esa URL usando la biblioteca Axios que mencioné anteriormente.

Si el estado de una tarea se informa como "ÉXITO", inyecto un enlace de descarga en el DOM y provoco que se active, activando la descarga y borrando el temporizador de intervalo. Si el estado es "FALLA", simplemente borro el intervalo, y si el estado no es ni "ÉXITO" ni "FALLA", entonces no hago nada hasta que se invoque el siguiente intervalo.

En este punto, puedo abrir otro terminal, una vez más con el entorno virtual de Python activo, e iniciar el servidor de desarrollo de Django, como se muestra a continuación:

1
(venv) $ python manage.py runserver
  • Los terminales de tareas de redis-server y celery descritos anteriormente también deben estar ejecutándose, y si no ha reiniciado el trabajador de Celery desde que agregó la tarea make_thumbnails, querrá Ctrl+C para detener el trabajador y luego emitir trabajador de apio -A image_parroter --loglevel=info de nuevo para reiniciarlo. Los trabajadores de apio deben reiniciarse cada vez que se realiza un cambio de código relacionado con la tarea de apio.

Ahora puedo cargar la vista home.html en mi navegador en http://localhost:8000, enviar un archivo de imagen y la aplicación debería responder con un archivo results.zip que contiene la imagen original y una miniatura de 128x128 píxeles.

Implementación en un servidor Ubuntu

Para completar este artículo, demostraré cómo instalar y configurar esta aplicación Django que utiliza Redis y Celery para tareas asíncronas en segundo plano en un servidor Ubuntu v18 LTS.

Una vez que SSH 'd en el servidor, lo actualizo y luego instalo los paquetes necesarios.

1
2
# apt-get update
# apt-get install python3-pip python3-dev python3-venv nginx redis-server -y

También creo un usuario llamado "webapp", que me da un directorio de inicio para instalar el proyecto Django.

1
# adduser webapp

Después de ingresar los datos del usuario, agrego el usuario de la aplicación web a los grupos sudo y www-data, cambio al usuario de la aplicación web, luego ‘cd’ en su directorio de inicio.

1
2
3
4
# usermod -aG sudo webapp
# usermod -aG www-data webapp
$ su webapp
$ cd

Dentro del directorio de la aplicación web, puedo clonar el repositorio de GitHub de image_parroter, cd en el repositorio, crear un entorno virtual de Python, activarlo y luego instalar las dependencias del archivo requirements.txt.

1
2
3
4
$ git clone https://github.com/amcquistan/image_parroter.git
$ python3 -m venv venv
$ . venv/bin/activate
(venv) $ pip install -r requirements.txt

Además de los requisitos que acabo de instalar, quiero agregar uno nuevo para el contenedor de la aplicación web uwsgi que servirá a la aplicación Django.

1
(venv) $ pip install uWSGI

Antes de continuar, sería un buen momento para actualizar el archivo settings.py para cambiar el valor DEBUG a False y agregar la dirección IP a la lista de ALLOWED_HOSTS.

Después de eso, vaya al directorio del proyecto Django image_parroter (el que contiene el módulo wsgi.py) y agregue un nuevo archivo para almacenar los ajustes de configuración de uwsgi, llamado uwsgi.ini, y coloque lo siguiente en él:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# uwsgi.ini
[uwsgi]
chdir=/home/webapp/image_parroter/image_parroter
module=image_parroter.wsgi:application
master=True
processes=4
harakiri=20

socket=/home/webapp/image_parroter/image_parroter/image_parroter/webapp.sock  
chmod-socket=660  
vacuum=True
logto=/var/log/uwsgi/uwsgi.log
die-on-term=True 

Antes de que se me olvide, debo seguir adelante y agregar el directorio de registro y otorgarle los permisos y la propiedad adecuados.

1
2
(venv) $ sudo mkdir /var/log/uwsgi
(venv) $ sudo chown webapp:www-data /var/log/uwsgi 

A continuación, creo un archivo de servicio systemd para administrar el servidor de aplicaciones uwsgi, que se encuentra en /etc/systemd/system/uwsgi.service y contiene lo siguiente:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# uwsgi.service
[Unit]
Description=uWSGI Python container server  
After=network.target

[Service]
User=webapp
Group=www-data
WorkingDirectory=/home/webapp/image_parroter/image_parroter
Environment="/home/webapp/image_parroter/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin"
ExecStart=/home/webapp/image_parroter/venv/bin/uwsgi --ini image_parroter/uwsgi.ini

[Install]
WantedBy=multi-user.target

Ahora puedo iniciar el servicio uwsgi, verificar que su estado sea correcto y habilitarlo para que se inicie automáticamente al arrancar.

1
2
3
(venv) $ sudo systemctl start uwsgi.service
(venv) $ sudo systemctl status uwsgi.service
(venv) $ sudo systemctl enable uwsgi.service

En este punto, la aplicación Django y el servicio uwsgi están configurados y puedo pasar a configurar el servidor redis.

Personalmente, prefiero usar los servicios de systemd, por lo que editaré el archivo de configuración /etc/redis/redis.conf configurando el parámetro supervisado igual a systemd. Después de eso, reinicio redis-server, compruebo su estado y habilito que se inicie en el arranque.

1
2
3
(venv) $ sudo systemctl restart redis-server
(venv) $ sudo systemctl status redis-server
(venv) $ sudo systemctl enable redis-server

El siguiente paso es configurar el apio. Comienzo este proceso creando una ubicación de registro para Celery y doy a esta ubicación los permisos y la propiedad apropiados, así:

1
2
(venv) $ sudo mkdir /var/log/celery
(venv) $ sudo chown webapp:www-data /var/log/celery

A continuación, agrego un archivo de configuración de Celery, llamado apio.conf, en el mismo directorio que el archivo uwsgi.ini descrito anteriormente, y coloco lo siguiente en él:

1
2
3
4
5
6
7
8
9
# celery.conf

CELERYD_NODES="worker1 worker2"
CELERY_BIN="/home/webapp/image_parroter/venv/bin/celery"
CELERY_APP="image_parroter"
CELERYD_MULTI="multi"
CELERYD_PID_FILE="/home/webapp/image_parroter/image_parroter/image_parroter/%n.pid"
CELERYD_LOG_FILE="/var/log/celery/%n%I.log"
CELERYD_LOG_LEVEL="INFO"

Para terminar de configurar el apio, agrego su propio archivo de servicio systemd en /etc/systemd/system/celery.service y coloco lo siguiente en él:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# celery.service
[Unit]
Description=Celery Service
After=network.target

[Service]
Type=forking
User=webapp
Group=webapp
EnvironmentFile=/home/webapp/image_parroter/image_parroter/image_parroter/celery.conf
WorkingDirectory=/home/webapp/image_parroter/image_parroter
ExecStart=/bin/sh -c '${CELERY_BIN} multi start ${CELERYD_NODES} \
  -A ${CELERY_APP} --pidfile=${CELERYD_PID_FILE} \
  --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL}'
ExecStop=/bin/sh -c '${CELERY_BIN} multi stopwait ${CELERYD_NODES} \
  --pidfile=${CELERYD_PID_FILE}'
ExecReload=/bin/sh -c '${CELERY_BIN} multi restart ${CELERYD_NODES} \
  -A ${CELERY_APP} --pidfile=${CELERYD_PID_FILE} \
  --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL}'

[Install]
WantedBy=multi-user.target

Lo último que debe hacer es configurar nginx para que funcione como un proxy inverso para la aplicación uwsgi/django, así como para servir el contenido en el directorio de medios. Hago esto agregando una configuración de nginx en /etc/nginx/sites-available/image_parroter, que contiene lo siguiente:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
server {
  listen 80;
  server_name _;

  location /favicon.ico { access_log off; log_not_found off; }
  location /media/ {
    root /home/webapp/image_parroter/image_parroter;
  }

  location / {
    include uwsgi_params;
    uwsgi_pass unix:/home/webapp/image_parroter/image_parroter/image_parroter/webapp.sock;
  }
}

A continuación, elimino la configuración predeterminada de nginx, lo que me permite usar server_name _; para capturar todo el tráfico http en el puerto 80, luego creo un enlace simbólico entre la configuración que acabo de agregar en el directorio "sites-available" al directorio "sites-enabled" junto a él.

1
2
$ sudo rm /etc/nginx/sites-enabled/default
$ sudo ln -s /etc/nginx/sites-available/image_parroter /etc/nginx/sites-enabled/image_parroter

Una vez hecho esto, puedo reiniciar nginx, verificar su estado y permitir que se inicie en el arranque.

1
2
3
$ sudo systemctl restart nginx
$ sudo systemctl status nginx
$ sudo systemctl enable nginx

En este punto, puedo apuntar mi navegador a la dirección IP de este servidor Ubuntu y probar la aplicación de miniaturas.

{.img-responsive}

Conclusión

Este artículo describió por qué usar Celery, así como también cómo usarlo, con el propósito común de iniciar una tarea asíncrona, que se inicia y se ejecuta en serie hasta su finalización. Esto conducirá a una mejora significativa en la experiencia del usuario, reduciendo el impacto de las rutas de código de ejecución prolongada que impiden que el servidor de aplicaciones web maneje más solicitudes.

Hice todo lo posible para proporcionar una explicación detallada del proceso de principio a fin desde la configuración de un entorno de desarrollo, la implementación de tareas de apio, la producción de tareas en el código de la aplicación Django, así como el consumo de resultados a través de Django y JavaScript simple.

Gracias por leer y, como siempre, no se avergüence de comentar o criticar a continuación.