Django Server Sent Event Tutorial

Server Sent Event atau biasa disebut SSE merupakan teknologi dalam pengembangan web dimana client kita dapat menerima push message dari server melalui protokol HTTP tanpa melakukan request. Sedikit berbeda dengan konsep web pada umumnya, dimana server akan mengirimkan response apabila menerima request dari client.

Studi Kasus

Sebuah website youtube downloader, dimana user akan memasukkan youtube URL, kemudian akan di proses secara background dikarenakan proses download file yang lama dan Django akan mengirimkan notifikasi/event kepada client saat download file telah selesai.

Konfigurasi Project

Konfigurasi project django pada official documentation sebenarnya sudah disediakan cukup jelas, namun pada project ini saya sedikit merubah struktur project seperti berikut.

django-server-sent-event-example
  |-- src
  |   |-- downloader
  |   |    |-- __init__.py
  |   |    |-- tasks.py
  |   |    |-- forms.py
  |   |    |-- models.py
  |   |    |-- views.py
  |   |-- youtube_downloader
  |   |    |-- __init__.py
  |   |    |-- settings.py
  |   |    |-- celery.py
  |   |-- media
  |   |-- manage.py
  |-- venv
  |-- requirements.txt

Secara pribadi saya selalu memisahkan semua hal yang terkait dengan project ke dalam “src” dan semua konfigurasi deployment saya letakkan diluar “src”. Sehingga bagi yang terbiasa menggunakan struktur project default diharapkan bisa menyesuaikan sendiri.

Langkah-langkah konfigurasi project diatas adalah sebagai berikut.

$ virtualenv venv
$ source venv/bin/activate
(venv) $ pip install Django celery[redis] youtube-dl
(venv) $ mkdir src
(venv) $ cd src
(venv) $ django-admin startproject youtube_downloader .
(venv) $ ./manage.py startapp downloader

Konfigurasi Celery

Celery digunakan untuk melakukan background job dan task queue, dimana proses download video dari youtube akan dilakukan secara background dan dikerjakan secara berurutan sesuai dengan queue (FIFO)

Untuk menjalankan Celery, kita memerlukan broker untuk menyimpan antrian task dan Celery sendiri juga mensupport beberapa jenis broker. Untuk lebih detailnya bisa di cek di dokumentasi celery.

Untuk studi kasus di tutorial ini akan menggunakan redis, dikarenakan redis yang paling ringan untuk dijalankan. Untuk installasi redis sendiri sangat beragam sesuai dengan OS, maka sebaiknya dicek pada dokumentasi redis.

Sekarang kita akan membuat konfigurasi celery pada src/youtube_downloader/celery.py

from __future__ import absolute_import, unicode_literals
import os
from celery import Celery

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "youtube_downloader.settings")

app = Celery("youtube_downloader")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()

@app.task(bind=True)
def debug_task(self):
    print("Request: {0!r}".format(self.request))

pada src/youtube_downloader/__init__.py

from __future__ import absolute_import, unicode_literals
from .celery import app as celery_app

__all__ = ("celery_app",)

dan tambahkan konfigurasi berikut pada src/youtube_downloader/settings.py

CELERY_BROKER_URL = "redis://localhost:6379/0"
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_RESULT_BACKEND = "redis://"
CELERY_TASK_SERIALIZER = "json"

Konfigurasi celery sudah selesai dan celery bisa dijalankan dengan command berikut.

(venv) $ celery -A youtube_downloader worker -l info

Pembuatan SSE

Pada bagian ini adalah bagian cukup penting dalam implementasi SSE pada project ini.
Pertama yang harus kita lakukan adalah membuat model untuk menyimpan informasi dari video youtube yang akan di download pada src/downloader/models.py.

from django.conf import settings
from django.db import models

class DownloadHistory(models.Model):
    PREPARING = "preparing"
    DOWNLOADING = "downloading"
    FINISHED = "finished"

    STATUS_CHOICES = (
        (PREPARING, PREPARING.title()),
        (DOWNLOADING, DOWNLOADING.title()),
        (FINISHED, FINISHED.title()),
    )

    celery_id = models.UUIDField(null=True, blank=True)
    video_file = models.FileField(null=True, blank=True)
    youtube_url = models.URLField()
    status = models.CharField(max_length=15, choices=STATUS_CHOICES, default=PREPARING)
    created_at = models.DateTimeField(auto_now_add=True)

    @property
    def download_url(self):
        if self.video_file:
            return f"{settings.SITE_URL}{self.video_file.url}"
        return None

    def __str__(self):
        return self.celery_id.hex

Setelah model dibuat, maka kita harus menjalankan perintah miggration untuk merefleksi model yang telah dibuat ke database.

(venv) $ ./manage.py makemigrations downloader
(venv) $ ./manage.py migrate

Selanjutnya, kita akan membuat 1 buah celery task yang bertugas melakukan download video dari youtube secara asynchronous di background. Semua task celery biasa dibikin pada file “tasks.py” di setiap Django apps, pada project ini terletak di src/downloader/tasks.py

import os
from celery import shared_task
from django.conf import settings
from youtube_dl import YoutubeDL

from .models import DownloadHistory

@shared_task(bind=True)
def task_download_from_url(self, url: str, history_id: int):
    history = DownloadHistory.objects.get(pk=history_id)
    history.status = DownloadHistory.DOWNLOADING
    history.save()

    try:
        file_name = f"video_{str(history_id).zfill(5)}.%(ext)s"
        downloaded_path = os.path.join(settings.MEDIA_ROOT, file_name)
        with YoutubeDL({"outtmpl": downloaded_path}) as ydl:
            info = ydl.extract_info(history.youtube_url, download=True)
            file_name = ydl.prepare_filename(info)
            file_name = file_name.split("/")[-1]

            ydl.download([url])

        history.video_file = file_name
        history.status = DownloadHistory.FINISHED
        history.save()
    except Exception as err:
        self.retry(countdown=3, max_retries=5, exc=err)

Setelah semua keperluan untuk menyimpan data dan memproses task selesai dibuat, langkah terakhir yang perlu dibuat adalah views yang akan melakukan polling data ke database dan melakukan streaming response, views untuk submit youtube URL dan views untuk menampilkan UI pada src/downloader/views.py.

import json
import time

from celery.result import AsyncResult
from django.http import (
    HttpRequest,
    HttpResponseNotAllowed,
    JsonResponse,
    StreamingHttpResponse,
)
from django.shortcuts import render

from .forms import SubmitDownloadForm
from .models import DownloadHistory
from .tasks import task_download_from_url

def index_view(request: HttpRequest):
    context = {"form": SubmitDownloadForm()}
    return render(request, "index.html", context)

def submit_download_view(request: HttpRequest):
    if request.method == "POST" and request.is_ajax():
        data = json.loads(request.body)
        form = SubmitDownloadForm(data)
        if form.is_valid():
            url = form.cleaned_data.get("url")

            history = DownloadHistory()
            history.youtube_url = url
            history.save()

            task_response = task_download_from_url.delay(url, history.pk)

            history.celery_id = task_response.id
            history.save()

            return JsonResponse({"id": task_response.id}, status=201)
        return JsonResponse({"error": form.errors.as_json()}, status=400)
    return HttpResponseNotAllowed(["POST"])

def monitor_view(request: HttpRequest):
    def pooling_status():
        while True:
            time.sleep(1)

            results = []
            files = DownloadHistory.objects.all()
            for file in files:
                processing_status = AsyncResult(str(file.celery_id)).state
                meta = {
                    "id": file.id,
                    "youtube_url": file.youtube_url,
                    "status": file.get_status_display(),
                    "url": file.download_url,
                    "is_downloaded": processing_status == "SUCCESS",
                }
                results.append(meta)

            yield f"data: {json.dumps(results)}\n\n"

    return StreamingHttpResponse(pooling_status(), content_type="text/event-stream")

3 views yang telah dibuat memiliki peranan sendiri-sendiri, seperti:

  • index_view

    Menampilkan UI HTML pada browser

  • submit_download_view

    Karena pada project ini proses submit video menggunakan AJAX, saya membuat views khusus untuk melakukan submit URL video.

    Pada saat submit video, kita menyimpan informasi berupa youtube URL kemudian memanggil task untuk download video secara asynchronous menggunakan “.delay()” dan menyimpan task id dari celery task yang dipanggil.

  • monitor_view

    Views ini yang akan melakukan push event ke client / server side event.
    Terdapat 1 buah fungsi generator yang melakukan pengecekan ke database dan celery task setiap 1 detik untuk dikembalikan hasilnya ke browser.

    Perlu diketahui bahwa server sent event memiliki format sendiri untuk mengirimkan data, bahwa setiap data harus memiliki prefix “data:” dan diakhiri 2 buah newline “\n\n”.

    Langkah terakhir yang perlu kita lakukan adalah melakukan streaming hasilnya menggunakan StreamingHttpResponse dan wajib menggunakan content type “text/event-stream”

Setelah 3 views dibuat, langkah yang perlu dilakukan selanjutnya adalah mendaftarkan views pada url routing.

from django.contrib import admin
from django.urls import path
from downloader.views import index_view, monitor_view, submit_download_view

urlpatterns = [
    path("admin/", admin.site.urls),
    path("", index_view),
    path("monitor/", monitor_view),
    path("submit/", submit_download_view),
]

Menerima Event

Sekarang saatnya mengolah data di frontend yang menerima event dari server pada src/downloader/templates/index.html.

Berhubung kode frontend terlalu panjang, maka tutorial ini akan saya persingkat pada bagian consume SSE, dan kode lengkapnya bisa dilihat pada akhir artikel.

const source = new EventSource('monitor/')
source.onmessage = (event) => {
  let results = JSON.parse(event.data)
  // your logic goes here.
}

Untuk menerima update dari SSE, kita harus membuat instansiasi object dari kelas “EventSource” dan kita akan menerima semua update dari server pada callback “.onmessage” dan data bisa kita olah sesuai keperluan.

Demo

Penggunaan project ini, kita harus menjalankan server kita dan worker celery kita.

(venv) $ ./manage.py runserver
(venv) $ celery -A youtube_downloader worker -l info -E

demo

Penutup

Server sent event adalah teknologi web yang berjalan diatas protokol HTTP, maka tidak perlu tambahan library khusus untuk membuat web dengan teknologi SSE dan Django sendiri sudah bisa mengimplementasi fitur SSE.

Source code lengkap ini juga bisa di download di github dan bila tutorial ini sangat bermanfaat, dukung saya di karyakarsa.

 

Referensi:
https://www.ably.io/concepts/server-sent-events
https://docs.djangoproject.com/en/3.0/ref/request-response/#streaminghttpresponse-objects
https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format
https://docs.celeryproject.org/en/stable/django/first-steps-with-django.html
https://docs.celeryproject.org/en/latest/faq.html#how-do-i-get-the-result-of-a-task-if-i-have-the-id-that-points-there
https://medium.com/conectric-networks/a-look-at-server-sent-events-54a77f8d6ff7

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.