Htmx et Django pour les Single Page Applications

Nous ne sommes pas très fans des gros frameworks Javascript chez NLP Cloud. NLP Cloud est une API qui se base sur spaCy et les transformers HuggingFace afin de proposer Named Entity Recognition (NER), analyse de sentiments, classification de texte, summarization, et bien plus.Notre backoffice est très simple. Les utilisateurs peuvent récupérer leur token API, uploader leurs modèles spaCy custom, upgrader leur plan, envoyer un message au support… Rien de très complexe donc nous n’avons pas ressenti le besoin d’utiliser Vue.js ou React.js. Nous avons décider d’opter pour une combinaison très sympa de htmx et Django.

Laissez-moi vous montrer comment ça fonctionne et vous exposer les avantages de cette solution.

Qu’est-ce que htmx et pourquoi l’utiliser ?

htmx est le successeur d’intercooler.js. Le concept derrière ces 2 projets est que vous pouvez effectuer toute sorte d’opération avancée comme de l’AJAX, des transitions CSS, des websockets, etc. uniquement avec du HTML (c’est à dire sans une seule ligne de Javascript). Et la lib est très légère (9Ko seulement).

Une autre chose très intéressante est que, lorsque vous effectuez des appels asynchrones vers votre backend, htmx n’attend pas du JSON mais des fragment de HTML. Donc en gros, contrairement à Vue.js ou React.js, votre frontend n’a pas besoin de gérer du JSON. Il suffit de remplacer les parties du DOM devant être modifiées par le fragment HTML retourné par le serveur. Cela vous permet de tirer parti au mieux de votre bon vieux framework backend (templates, sessions, authentification, etc.) au lieu de le transformer en un framework headless qui retourne uniquement du JSON. L’idée est qu’un fragment HTML étant à peine plus lourd qu’un JSON, la différence est négligeable dans une requête HTTP.

Ainsi, pour résumer, voici pourquoi htmx est intéressant lorsque vous souhaitez construire une Single Page Application (SPA) :

  • pas de Javascript à écrire,
  • les excellents frameworks backend comme Django, Ruby On Rails, Laravel… peuvent être utilisés à plein régime,
  • la lib est très légère (9Ko) comparée aux frameworks comme Vue ou React,
  • aucun pré-processing n’est nécessaire (Webpack, Babel, etc.) ce qui rend l’expérience de développement beaucoup plus sympa.

Installation

L’installation de htmx revient juste à charger le script dans votre <head> HTML:

<script src="https://unpkg.com/htmx.org@1.2.1"></script>

Je n’entrerai pas dans les détails de l’installation de Django ici puisque cet article se concentre sur htmx.

Charger du contenu en asynchrone

Le point le plus important, lorsque vous créez une SPA, c’est que vous vous attendez à ce que tous les chargements d’éléments se fassent en asynchrone. Par exemple, lorsque vous cliquez sur un menu afin d’aller vers une nouvelle page, vous ne voulez pas que l’intégralité de la page se recharge, mais uniquement le contenu qui a changé. Voici comment s’y prendre.

Disons que notre site est fait de 2 pages :

  • la page token qui montre à l’utilisateur son token API,
  • la page support qui en gros montre l’email du support à l’utilisateur.

On souhaite aussi afficher une barre de chargement pendant que la nouvelle page charge.

Frontend

Côté frontend, commençons par créer un menu à 2 entrées. Cliquer sur une entrée va afficher la barre de chargement, et changer le contenu de la page sans la recharger intégralement.

<progress id="content-loader" class="htmx-indicator" max="100"></progress>
<aside>
    <ul>
        <li><a hx-get="/token" hx-push-url="true"
                hx-target="#content" hx-swap="innerHTML" 
                hx-indicator="#content-loader">Token</a></li>
        <li><a hx-get="/support"
                hx-push-url="true" hx-target="#content" hx-swap="innerHTML"
                hx-indicator="#content-loader">Support</a></li>
    </ul>
</aside>
<div id="content">Hello and welcome to NLP Cloud!</div>

Dans l’exemple ci-dessus, la barre de chargement est l’élément <progress>. Il est caché par défaut grâce à la classe htmx-indicator. Lorsqu’un utilisateur clique sur une des deux entrées du menu, cela affiche la barre de chargement, grâce à hx-indicator="#content-loader".

Lorsqu’un utilisateur clique sur l’entrée token, cela déclenche un appel GET asynchrone vers l’url Django token grâce à hx-get="/token". Django retourne un fragment HTML que htmx place dans <div id="content"></div> grâce à hx-target="#content" hx-swap="innerHTML".

Il en est de même pour la page support.

Même si la page ne s’est pas rechargée, nous voulons malgré tout mettre à jour l’URL du navigateur afin d’aider l’utilisateur à se situer. C’est pour cette raison que nous utilisons hx-push-url="true".

Comme vous pouvez le voir, nous avons désormais une SPA qui utilise des fragments HTML au lieu de JSON, grâce à une simple lib de 9Ko, et seulement quelques directives à appliquer dans le HTML.

Backend

Bien entendu, la partie ci-dessus ne fonctionne pas sans le backend Django.

Voici votre urls.py:

from django.urls import path

from . import views

urlpatterns = [
    path('', views.index, name='index'),
    path('token', views.token, name='token'),
    path('support', views.support, name='support'),
]

Maintenant votre views.py:

def index(request):
    return render(request, 'backoffice/index.html')

def token(request):
    api_token = 'fake_token'

    return render(request, 'backoffice/token.html', {'token': api_token})

def support(request):
    return render(request, 'backoffice/support.html')

Et enfin, dans un répertoire templates/backoffice ajoutez les templates suivants.

index.html (c’est à dire le code que nous avons écrit ci-dessus mais cette fois avec des les tags de template {% url %}) :

<!DOCTYPE html>
<html>
    <head>
        <script src="https://unpkg.com/htmx.org@1.2.1"></script>
    </head>

    <body>
        <progress id="content-loader" class="htmx-indicator" max="100"></progress>
        <aside>
            <ul>
                <li><a hx-get="{% url 'home' %}"
                        hx-push-url="true" hx-target="#content" hx-swap="innerHTML"
                        hx-indicator="#content-loader">Home</a></li>
                <li><a hx-get="{% url 'token' %}" hx-push-url="true"
                        hx-target="#content" hx-swap="innerHTML" 
                        hx-indicator="#content-loader">Token</a></li>
            </ul>
        </aside>
        <div id="content">Hello and welcome to NLP Cloud!</div>
    <body>
</html>

token.html:

Here is your API token: {{ token }}

support.html:

For support questions, please contact support@nlpcloud.io

Comme vous pouvez le voir, tout cela est du pur code Django utilisant des routes et des templates, comme à l’accoutumée. Nul besoin d’une API et de Django Rest Framework ici.

Permettre le rafraîchissement manuel des pages

Le souci avec la solution ci-dessus c’est que, si un utilisateur recharge manuellement les pages token ou support, il obtiendra uniquement le fragment HTML au lieu de la page HTML toute entière.

La solution, côté Django, est de retourner 2 templates différents selon que la requête vient d’htmx ou non.

Voici comment faire.

Dans votre views.py, testez si le header HTTP_HX_REQUEST a été passé dans la requête. Si c’est le cas, cela signifie qu’il s’agit d’une requête de htmx et que dans ce cas vous pouvez afficher le fragment HTML seulement. Dans le cas contraire, vous devez afficher la page entière.

def index(request):
    return render(request, 'backoffice/index.html')

def token(request):
    if request.META.get("HTTP_HX_REQUEST") != 'true':
        return render(request, 'backoffice/token_full.html', {'token': api_token})

    return render(request, 'backoffice/token.html', {'token': api_token})

def support(request):
    if request.META.get("HTTP_HX_REQUEST") != 'true':
        return render(request, 'backoffice/support_full.html')

    return render(request, 'backoffice/support.html')

Maintenant dans votre template index.html, utilisez des blocks afin que la page index soit héritée par toutes les autres pages.

<!DOCTYPE html>
<html>
    <head>
        <script src="https://unpkg.com/htmx.org@1.2.1"></script>
    </head>

    <body>
        <progress id="content-loader" class="htmx-indicator" max="100"></progress>
        <aside>
            <ul>
                <li><a hx-get="{% url 'home' %}"
                        hx-push-url="true" hx-target="#content" hx-swap="innerHTML"
                        hx-indicator="#content-loader">Home</a></li>
                <li><a hx-get="{% url 'token' %}" hx-push-url="true"
                        hx-target="#content" hx-swap="innerHTML" 
                        hx-indicator="#content-loader">Token</a></li>
            </ul>
        </aside>
        <div id="content">{% block content %}{% endblock %}</div>
    <body>
</html>

Votre template token.html est le même qu’auparavant mais maintenant vous devez ajouter un second template appelé token_full.html dans le cas où la page est rechargée manuellement :


{% extends "home/index.html" %}

{% block content %}
    {% include "home/token.html" %}
{% endblock %}

Pareil pour support.html, ajoutez un fichier support_full.html :


{% extends "home/index.html" %}

{% block content %}
    {% include "home/support.html" %}
{% endblock %}

En gros l’on étend le template index.html afin de construire la page entière d’un coup côté serveur.

Il s’agit d’un petit bidouillage mais ce n’est pas bien méchant, et un middleware peut même être créé pour l’occasion afin de simplifier encore les choses.

Quoi d’autre ?

Nous avons seulement effleuré la surface de htmx. Cette lib (ou framework ?) inclut des tas de fonctionnalités utiles telles que :

  • Vous pouvez utiliser le verbe HTTP que vous voulez pour vos requêtes. Utilisez hx-get pour GET, hx-post pour POST, etc.
  • Vous pouvez utiliser du polling, des websockets, et des server side events, afin d’écouter les évènements provenant du serveur
  • Vous pouvez n’utiliser qu’une partie du fragment HTML retourné par le serveur (hx-select)
  • Vous pouvez utiliser des transitions CSS
  • Vous pouvez facilement travailler sur les formulaires et l’upload de fichier
  • Vous pouvez utiliser l’hyperscript de htmx, qui est un pseudo-language javascript pouvant facilement être intégré aux tags HTML pour un usage avancé

Conclusion

Je suis enthousiasmé par cette lib htmx comme vous pouvez le voir, et j’espère qu’un nombre croissant de gens va réaliser qu’ils n’ont pas nécessairement besoin d’un énorme framework JS pour leur projet.

Pour le moment j’ai uniquement intégré htmx à des bases de code de taille modeste en production, mais je suis persuadé que htmx s’intègre aussi parfaitement aux projets de grande envergure. Pour le moment je trouve le code facile à maintenir, léger, et son intégration facile à des frameworks backend comme Django est un must !

Si certains d’entre vous utilisent htmx en production, j’adorerais entendre vos retour sur le sujet !

Also available in English
L'orchestration de conteneurs avec Docker Swarm

NLP Cloud est un service auquel j’ai récemment contribué. Il s’agit d’une API qui se base sur spaCy et les transformers HuggingFace afin de proposer Named Entity Recognition (NER), analyse de sentiments, classification de texte, summarization, et bien plus. Il utilise plusieurs technos intéressantes et je me suis donc dit que j’allais lancer une série d’articles à ce sujet. Ce premier de la série traite de l’orchestration de conteneurs et de la façon dont l’avons mise en place grâce à Docker Swarm. J’espère que cela sera utile à certains !

Pourquoi l’orchestration de conteneurs

NLP Cloud utilise un très grand nombre de conteneurs, principalement parce que chaque modèle NLP est exécuté dans un conteneur dédié. Non seulement les modèles spaCy pré-entrainés ont chacun leur propre conteneur, mais c’est aussi le cas de chaque modèle custom uploadé par l’utilisateur. C’est très pratique pour plusieurs raisons:

  • Il est facile de faire tourner un modèle NLP sur le serveur qui a les ressources les plus adaptées. Les modèles de machine learning sont très gourmands en ressources : ils consomment beaucoup de mémoire, et il est parfois intéressant de mettre à profit un GPU (si vous utilisez des transformers NLP par example). Il est donc préférable de les déployer sur des machines spécifiques.
  • La scalabilité horizontale est assurée simplement en ajoutant des répliques du même modèle NLP
  • La haute disponibilité est facilitée par la redondance et le failover automatique
  • Cela aide à limiter les coûts : faire de la scalabilité horizontale sur une myriade de petites machines est bien moins coûteux que de faire de la scalabilité verticale sur quelques grosses machines

Bien entendu, mettre en place une telle architecture prend un peu de temps et des compétences particulières, mais au final cela paye souvent lorsque vous décidez de batir une application complexe.

Pourquoi Docker Swarm

Docker Swarm est constamment opposé à Kubernetes et Kubernetes est censé être le gagnant de la compétition pour l’orchestration de conteneurs aujourd’hui. Mais ce n’est pas si simple…

Kubernetes a des tas de paramètres qui en font un excellent candidat pour les cas très avancés. Mais cette polyvalence a un coût : Kubernetes est difficile à installer, à configurer, et à maintenir. C’est même si compliqué qu’aujourd’hui la plupart des entreprises utilisant Kubernetes ont en fait souscrit une version managée de Kubernetes, sur GCP par exemple, et tous les services cloud n’ont pas la même implémentation de Kubernetes.

N’oublions pas que Google a initialement conçu Kubernetes pour ses besoins internes, de la même façon que Facebook a aussi conçu React pour ses propres besoin. Mais il est fort possible que vous n’ayez pas à gérer le même niveau de complexité pour votre projet. Tant de projets seraient livrés plus rapidement et seraient aujourd’hui plus faciles à maintenir si des outils plus simples avaient été choisis dès le départ…

Chez NLP Cloud, nous avons un grand nombre de conteneurs mais nous n’avons pas besoin des capacités de configuration avancées de Kubernetes. Et nous ne voulons pas utiliser une version managée de Kubernetes non plus : premièrement pour des raisons de coût, mais aussi parce que nous ne voulons pas être coincés dans un cloud particulier, et enfin pour des raisons de confidentialité des données.

Docker Swarm a par ailleurs un avantage significatif : il s’intègre parfaitemenent à Docker et Docker Compose. Cela rend la configuration simple comme bonjour, et les équipes déjà habituées à Docker n’ont aucune difficulté à passer à Swarm.

Installez le cluster

Disons que nous souhaitons avoir un cluster constitué de 5 serveurs :

  • 1 noeud manager qui sera en charge de l’orchestration du cluster. Il hébergera aussi la base de données (juste un exemple, la BDD pouvant aussi parfaitement tourner sur un worker).
  • 1 noeud worker qui hébergera notre backoffice Python/Django
  • 3 noeuds worker qui hébergeront l’API Python FastAPI répliquée servant un modèle NLP

Nous omettons délibérément le reverse proxy qui répartira les requêtes vers les bons noeuds car cela fera l’objet d’un prochain article.

Provisionnez les serveurs

Commandez 5 serveurs là où bon vous semble. Cela peut être chez OVH, Digital Ocean, AWS, GCP… peu importe.

Il est important que vous réfléchissiez à la performance de chaque serveur en fonction de l’usage auquel il sera dédié. Par exemple, le noeud hébergeant le backoffice n’a probablement pas besoin de performances élevées. Le noeud hébergeant le reverse proxy (non traité dans ce tuto) a certainement besoin d’un bon CPU. Et les noeuds API servant le modèle NLP ont un grand besoin de RAM, et peut-être même de GPU.

Installez une distribution Linux sur chaque serveur. Personnellement je partirais sur la dernière version LTS d’Ubuntu.

Sur chaque serveur, installez Docker.

Maintenant donnez à chaque serveur un hostname intelligible. Ce nom sera utile lors de votre prochaine connexion SSH puisque ce hostname apparaitra dans votre invite de commande, ce qui est une bonne pratique afin d’éviter de travailler sur le mauvais serveur… Ce nom sera aussi utilisé par Docker Swarm comme nom pour votre noeud. Lancez la commande suivante sur chaque serveur :

echo <node name> /etc/hostname; hostname -F /etc/hostname

Sur le manager, logguez vous à votre registre Docker afin que Docker Swarm puisse télécharger vos images (pas besoin de faire cela sur les workers) :

docker login

Initialisez le cluster et rattachez les noeuds

Sur le noeud manager, tapez :

docker swarm init --advertise-addr <server IP address>

--advertise-addr <server IP address> est seulement requis si votre serveur possède plusieurs addresses IP sur la même interface afin que Docker sache laquelle choisir.

Puis, afin de rattacher vos noeuds, tapez ce qui suit sur le manager :

docker swarm join-token worker

Cela vous retournera quelque chose comme docker swarm join --token SWMTKN-1-5tl7ya98erd9qtasdfml4lqbosbhfqv3asdf4p13-dzw6ugasdfk0arn0 172.173.174.175:2377

Copiez cette sortie et collez-là sur un noeud worker. Puis répétez l’opération join-token pour chaque noeud worker.

Vous devriez maintenant être capable de voir tous vos noeuds en tapant :

docker node ls

Donnez des labels à vos noeuds

Il est imporant de donner les bons labels à vos noeuds parce que vous aurez besoin de ces labels plus tard afin que Docker Swarm puisse déterminer sur quel noeud un conteneur doit être déployé. Si vous ne spécifiez pas le noeud sur lequel votre conteneur doit être déployé, Docker Swarm le déploiera sur n’importe que noeud disponible. Ce n’est clairement pas ce que nous voulons.

Disons que votre backoffice requiert peu de ressources et est sans état. Cela signifie que ce dernier peut être déployé sur n’importe quel noeud worker bon marché. Votre API est aussi sans état mais, au contraire, elle est gourmande en mémoire et requiert un hardware spécifique dédié au machine learning. Vous voulez donc la déployer sur un des 3 noeuds workers dédiés au machine learning. Enfin, votre base de données n’est pas sans état, elle doit donc toujours être déployée sur le même serveur : disons que ce serveur sera notre noeud manager (mais cela peut parfaitement aussi être un worker).

Faites ce qui suit sur le manager.

Le manager hébergera la BDD donc donnez lui le label “database” :

docker node update --label-add type=database <manager name>

Donnez le label “cheap” au worker qui a de faibles performances et qui hébergera le backoffice :

docker node update --label-add type=cheap <backoffice worker name>

Enfin, donnez le label “machine-learning” à tous les workers qui hébergeront les modèles NLP :

docker node update --label-add type=machine-learning <api worker 1 name>
docker node update --label-add type=machine-learning <api worker 2 name>
docker node update --label-add type=machine-learning <api worker 3 name>

Mettez en place la configuration avec Docker Compose

Si vous utilisez déjà Docker Compose, il y a des chances pour que vous trouviez la transition vers Swarm extrêmement facile.

Si vous n’ajoutez rien à votre fichier docker-compose.yml existant il fonctionnera avec Docker Swarm, mais vos conteneurs seront déployés n’importe où sur votre cluster sans votre controle, et ils ne pourront pas communiquer entre eux.

Réseau

Afin que vos conteneurs puissent communiquer entre eux, il faut qu’ils se situent sur le même réseau virtuel. Par example une application Python/Django, une API FastAPI, et une base de données PostgreSQL doivent être sur le même réseau afin de travailler ensemble. Nous créerons manuellement le réseau main_network un peu plus tard juste avant de déployer, mais utilisons-le dès maintenant dans notre docker-compose.yml:

version: "3.8"

networks:
  main_network:
    external: true

services:
  backoffice:
    image: <path to your custom Django image>
    depends_on:
      - database
    networks:
      - main_network
  api:
    image: <path to your custom FastAPI image>
    depends_on:
      - database
    networks:
      - main_network
  database:
    image: postgres:13
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=db_name
    volumes:
      - /local/path/to/postgresql/data:/var/lib/postgresql/data
    networks:
      - main_network

Détails de déploiement

Maintenant il faut que vous disiez à Docker Swarm sur quel serveur chaque service doit être dédié. C’est là que vous utiliserez les labels créés un peu plus tôt.

En gros, il s’agit juste d’utiliser la directive constraints de cette façon :

version: "3.8"

networks:
  main_network:
    external: true

services:
  backoffice:
    image: <path to your custom Django image>
    depends_on:
      - database
    networks:
      - main_network
    deploy:
      placement: 
        constraints:
          - node.role == worker
          - node.labels.type == cheap
  api:
    image: <path to your custom FastAPI image>
    depends_on:
      - database
    networks:
      - main_network
    deploy:
      placement: 
        constraints:
          - node.role == worker
          - node.labels.type == machine-learning
  database:
    image: postgres:13
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=db_name
    volumes:
      - /local/path/to/postgresql/data:/var/lib/postgresql/data
    networks:
      - main_network
    deploy:
      placement: 
        constraints:
          - node.role == manager
          - node.labels.type == database

Réservation et limitation des ressources

Il peut être dangereux de déployer vos conteneurs tels quels pour deux raisons :

  • l’orchestrateur va les déployer n’importe où, y compris sur un serveur n’ayant pas les ressources disponibles suffisantes (parce que d’autres conteneurs consomment toute la mémoire disponible par exemple)
  • Un de vos conteneurs peut consommer plus de ressources que prévu et au final causer des problèmes sur votre serveur. Par exemple si votre modèle de machine learning en arrive à consommer trop de mémoire, il peut inciter le serveur hôte à déclencher la protection OOM qui commencera à tuer des processus afin de libérer de la RAM. Par défaut le moteur Docker est un des derniers processus à être tués par le serveur mais, si cela doit arriver, cela signifie que tous vos conteneurs sur cet hôte vont se couper.

Afin de contrer ce risque, vous pouvez utiliser les directives reservations et limits :

  • reservations s’assure qu’un conteneur est uniquement déployé si le serveur cible a assez de ressources disponibles. Si ce n’est pas le cas, l’orchestrateur ne le déploiera pas jusqu’à ce que les ressources nécessaires soient disponibles.
  • limits empêche un conteneur de consommer trop de ressources une fois qu’il est déployé

Disons que notre conteneur API - faisant tourner le modèle de machine learning - ne doit être déployé que si 5Go de RAM et la moitié du CPU sont disponibles. Disons aussi que l’API ne doit pas consommer plus de 10Go de RAM et 80% de CPU. Voici ce que nous devons faire :

version: "3.8"

networks:
  main_network:
    external: true

services:
  api:
    image: <path to your custom FastAPI image>
    depends_on:
      - database
    networks:
      - main_network
    deploy:
      placement: 
        constraints:
          - node.role == worker
          - node.labels.type == machine-learning
      resources:
        limits:
          cpus: '0.8'
          memory: 10G
        reservations:
          cpus: '0.5'
          memory: 5G

Réplication

Afin de mettre en place la scalabilité horizontale, il peut être intéressant de répliquer certaines de vos applications sans état. Il vous faut pour ce faire utiliser la directive replicas. Par exemple disons que nous voulons 3 répliques de notre API. Voici comment faire :

version: "3.8"

networks:
  main_network:
    external: true

services:
  api:
    image: <path to your custom FastAPI image>
    depends_on:
      - database
    networks:
      - main_network
    deploy:
      placement: 
        constraints:
          - node.role == worker
          - node.labels.type == machine-learning
      resources:
        limits:
          cpus: '0.8'
          memory: 10G
        reservations:
          cpus: '0.5'
          memory: 5G
      replicas: 3

Plus loin

Plus de paramètres sont disponibles pour plus de controle sur l’orchestration de votre cluster. N’hésitez pas à vous référer à la doc pour plus de détails.

Secrets

Docker Compose dispose d’une façon native très pratique de gérer les secrets en stockant chaque secret dans un fichier externe. Ainsi ces fichiers ne font pas partie de votre configuration et peuvent même être chiffrés si nécessaire, ce qui est excellent pour la sécurité.

Disons que vous souhaitez sécuriser vos identifiants de connexion à la BDD.

Commencez par créer 3 fichiers secrets sur votre machine locale :

  • créez un fichier db_name.secret et mettez le nom de la BDD dedans
  • créez un fichier db_user.secret et mettez le nom d’utilisateur dedans
  • créez un fichier db_password.secret et mettez le mot de passe dedans

Puis dans votre fichier Docker Compose vous pouvez utiliser les secrets ainsi :

version: "3.8"

networks:
  main_network:
    external: true

secrets:
  db_name:
    file: "./secrets/db_name.secret"
  db_user:
    file: "./secrets/db_user.secret"
  db_password:
    file: "./secrets/db_password.secret"

services:
  database:
    image: postgres:13
    secrets:
      - "db_name"
      - "db_user"
      - "db_password"
    # Adding the _FILE prefix makes the Postgres image to automatically
    # detect secrets and properly load them from files.
    environment:
      - POSTGRES_USER_FILE=/run/secrets/db_user
      - POSTGRES_PASSWORD_FILE=/run/secrets/db_password
      - POSTGRES_DB_FILE=/run/secrets/db_name
    volumes:
      - /local/path/to/postgresql/data:/var/lib/postgresql/data
    deploy:
      placement:
        constraints:
          - node.role == manager
          - node.labels.type == database
    networks:
      - main_network

Les fichiers secrets sont automatiquement injectés dans les conteneurs dans le répertoire /run/secrets par Docker Compose. Attention cependant : ces secrets sont situés dans des fichiers, et non pas dans des variables d’environnement. Il vous faudra donc manuellement les ouvrir et lire leur contenu pour accéder aux secrets.

L’image PostgreSQL a une fonctionnalité bien pratique : si vous ajoutez le suffix _FILE à la variable d’environnement, l’image lira automatiquement les secrets depuis les fichiers.

Staging VS Production

Il y a des chances pour que vous ayez besoin d’au moins 2 différents types de configurations Docker Compose :

  • 1 pour votre machine locale qui sera utilisée à la fois pour la création des images Docker, mais aussi comme environnement de staging
  • 1 pour votre production

Vous avez 2 choix. Soit vous tirez parti de l’héritage de fichiers proposé par Docker Compose, ce qui fait que vous n’avez qu’un seul gros fichier de base docker-compose.yml et ensuite deux petits fichiers additionnels : un fichier staging.yml avec les spécificités liées au staging, et un fichier production.yml avec les spécificités liées à la production.

Au final chez NLP Cloud nous avons fini par réaliser que nos configurations de staging et de production étaient si differentes qu’il était plus facile de maintenir 2 gros fichiers séparés : un pour le staging et un pour la production. La raison principale est que notre environnement de production utilise Docker Swarm mais que notre environnement de staging ne l’utilise pas, et que donc jouer avec les deux est peu pratique.

Déployez

Maintenant nous supposons que vous avec localement construit et uploadé vos images sur votre registre Docker. Disons que nous avons un seul fichier production.yml pour la production.

Copiez votre fichier production.yml sur votre serveur en utilisant scp:

scp production.yml <server user>@<server IP>:/remote/path/to/project

Copiez aussi les secrets (et assurez-vous de les uploader dans le répertoire déclaré dans la section secrets de votre fichier Docker Compose):

scp /local/path/to/secrets <server user>@<server IP>:/remote/path/to/secrets

Créez manuellement le réseau que nous utilisons dans notre fichier Docker Compose. Notez qu’il est aussi possible de sauter cette étape et de laisser Docker Swarm s’en charger automatiquement si le réseau est déclaré dans votre fichier Docker Compose. Mais nous avons noté que cela créait des disfonctionnements lors de la recréation de la stack parce que Docker ne re-crée pas le réseau suffisamment vite.

docker network create --driver=overlay main_network

Vous devez aussi créer les répertoires utilisés par les volumes manuellement. Le seul volume que nous ayons dans ce tuto est pour la base de données. Donc créons-le sur le noeud hébergeant la BDD (i.e. le manager):

mkdir -p /local/path/to/postgresql/data

Ok désormais tout est en place, il est donc temps de déployer toute la stack !

docker stack deploy --with-registry-auth -c production.yml <stack name>

L’option --with-registry-auth est requise lorsque vous téléchargez des images depuis des registres protégés par mot de passe.

Attendez un moment car Docker Swarm est maintenant en train de télécharger toutes les images et de les installer sur les noeuds. Puis vérifiez si tout s’est bien déroulé :

docker service ls

Vous devriez voir quelque chose comme ça :

ID             NAME                       MODE         REPLICAS   IMAGE
f1ze8qgf24c7   <stack name>_backoffice    replicated   1/1        <path to your custom Python/Django image>     
gxboram56dka   <stack name>_database      replicated   1/1        postgres:13      
3y1nmb6g2xoj   <stack name>_api           replicated   3/3        <path to your custom FastAPI image>      

L’important est que les REPLICAS soient toutes à leur maximum. Sinon cela signifie que Docker est toujours en train de télécharger et installer vos images, ou alors que quelque chose s’est mal passé.

Gérez le cluster

Maintenant que votre cluster est en marche, voici deux ou trois choses utiles à garder à l’esprit pour administrer le cluster.

  • voir toutes les applications et où elles ont été déployées : docker stack ps <stack name>
  • voir les applications installées sur un noeud spécifique : docker node ps <node name>
  • voir les logs d’une application : docker service logs <stack name>_<service name>
  • supprimer complètement toute la stack : docker stack rm <stack name>

A chaque fois que vous souhaitez déployer une nouvelle image sur le cluster, commencez par l’uploader sur votre registre, puis relancez la commande docker stack deploy sur le manager.

Conclusion

Comme vous pouvez le voir, mettre en place un cluster Docker Swarm est loin d’être complexe, en particulier si vous pensez à toute la complexité qui doit être gérée en arrière plan dans les systèmes distribués.

Bien sûr, de nombreuses autres options sont disponibles et il y a des chances pour que la documention vous soit utile. Par ailleurs, nous n’avons pas encore évoqué le point du reverse proxy/load balancer mais c’est un aspect primordial. Dans le prochain tutoriel, nous verrons comment réaliser cela avec Traefik.

Chez NLP Cloud notre configuration est évidemment bien plus complexe que ce qui est décrit ci-dessus, et nous avons dû faire face à plusieurs défis afin d’obtenir une architecture à la fois rapide et facile à maintenir. Par exemple, nous avons tellement de conteneurs de machine learning que manuellement écrire les fichiers de configuration pour chaque conteneur n’était pas une option, donc nous avons dû mettre en place de nouveaux mécanismes de génération automatique.

Si vous êtes intéressé par plus de détails n’hésitez pas à demander, ce sera un plaisir de partager.

Also available in English
Crawler un grand volume de pages web

Crawler et scraper les données présentes sur le net est une tâche amusante. C’est relativement facile et gratifiant puisque vous obtenez très vite des résultats concrêts. Cependant, passer d’un crawler basique (écrit sous forme de petit script Python par exemple) à un crawler rapide permettant d’acquérir un grand volume de données, n’est pas quelque chose de simple. Je vais tenter de vous décrire deux ou trois défis que vous rencontrerez lorsque vous vous lancerez dans ce type de projet.

Concurrence

Introduire de la concurrence est absolument central dans la plupart des applications modernes, et c’est particulièrement vrai en ce qui concerne les applications exigeant un grand nombre d’accès réseaux tels que les crawlers. En effet, puisque chaque requête HTTP que vous déclenchez prend un temps monstre avant de répondre, vous avez tout intérêt à les lancer en parallèle plutôt qu’en séquentiel. En gros cela signifie que si vous crawlez 10 pages prenant 1 seconde chacune, cela vous prendra approximativement 1 seconde au total plutôt que 10 secondes.

La concurrence est donc critique, mais comment la mettre en place ?

L’approche naïve, qui fonctionne parfaitement pour une petite application, est de coder vous-même le déclenchement des jobs en parallèle, attendre les résultats, et les traiter. Typiquement en Python vous lanceriez plusieurs processus, et en Go (qui se prête mieux à ce type de travail) vous devriez créer des goroutines. Mais gérer tout cela manuellement peut vite devenir compliqué : comme vos ressources en RAM et CPU sont limitées, vous ne pouvez pas indéfiniment lancer vos jobs en parallèle, donc comment gérer les queues, et comment gérer les retries lorsque des jobs échouent (et vous pouvez être certain que cela arrivera) ou lorsque votre serveur s’arrête pour différentes raisons ?

L’approche la plus robuste est de mettre à profit un système de messaging tel que RabbitMQ. Chaque nouvelle URL récupérée lors du parsing peut désormais être mise en queue dans RabbitMQ, et toute nouvelle page que votre application souhaite crawler doit être prise dans la queue de RabbitMQ. Le nombre de requêtes concurrentes que vous souhaitez atteindre est simplement un paramètre à déclarer dans RabbitMQ.

Bien entendu, même lorsque l’on utilise un système de messaging, le choix du langage de programmation reste important : déclencher 100 jobs en parallèle en Go vous coûtera beaucoup moins de ressources qu’en Python par exemple (raison en partie pour laquelle j’apprécie beaucoup Go !).

Scalabilité

A un certain point, peu importe le degré d’optimisation que votre crawler aura atteint, vous serez limité par des ressources hardware.

La première solution est d’upgrader les performances de votre serveur (ce que l’on appelle “scalabilité verticale”). C’est facile à faire, mais une fois que vous aurez atteint un certain niveau de RAM ou CPU, cela s’avérera moins coûteux de favoriser la “scalabilité horizontale”.

Le principe de la scalabilité horizontale est d’ajouter plusieurs serveurs de taille modeste à votre infrastructure, plutôt que de transformer votre serveur en supercalculateur. Réaliser cela est plus difficile sur le plan technique car vos serveurs auront certainement besoin de communiquer à propos d’un état commun à tous, et un refactoring de votre application peut s’avérer nécessaire. La bonne nouvelle est qu’un crawler peut facilement devenir “stateless” : plusieurs instances de votre application peuvent tourner en parallèle et les informations à partager seront certainement situées dans votre outil de messaging et/ou votre base de données. Il est alors facile d’augmenter/diminuer le nombre de serveurs en fonction de la vitesse que vous souhaitez atteindre. Chaque serveur doit gérer un certain nombre de requêtes concurrentes consommées depuis le serveur de messaging. C’est votre rôle de définir combien de requêtes concurrentes chaque serveur peut absorber en fonction de ses ressources en RAM/CPU.

Les orchestrateurs de conteneurs tels que Kubernetes rendent la scalabilité horizontale plus aisée. Il est plus simple d’accroître le nombre d’instances en quelques clics, et Kubernetes peut même se charger de scaler les instances automatiquement pour vous (en revanche mettez toujours en place des limites pour éviter aux dépenses de déraper).

Si vous souhaitez avoir une connaissance plus pointue des challenge liés à la scalabilité, je vous recommande de lire cet excellent ouvrage de Martin Kleppmann: Data Intensive Applications.

Data Intensive Applications book

Rapporter les erreurs intelligemment

Des tonnes de vilaines choses peuvent arriver au cours d’un crawl : des problèmes de connectivité (côté client comme côté serveur), des congestions réseaux, une page HTML trop lourde, une limite de mémoire…

Il est crucial de gérer ces erreurs correctement et de les rapporter avec discernement afin de ne pas crouler sous les erreurs.

Une bonne pratique est de centraliser toutes les erreurs dans Sentry. Certaines erreurs ne sont jamais envoyées à Sentry car nous ne les considérons pas comme critiques. Par exemple nous souhaitons être alerté lorsqu’une instance connaît un problème de mémoire, mais nous ne voulons pas être prévenu lorsqu’une page ne peut pas être téléchargée à cause d’un site en timeout (ce type d’erreur fait partie du fonctionnement normal d’un crawler). Décidez donc intelligement de quelles erreurs valent le coup d’être rapportées, et lesquelles ne le valent pas.

File descriptors et utilisation mémoire

Lorsque l’on touche au sujet des crawlers, il est intéressant de se familiariser avec le concept de file descriptors. Chaque requête HTTP que vous lancez ouvre un file descriptor, et ce dernier consomme de la mémoire.

Sur les systèmes Linux, le nombre maximal de file descriptors ouverts en parallèle est plafonné par l’OS afin de ne pas mettre le système à genoux. Une fois que cette limite est atteinte, il n’est plus possible d’ouvrir une seule nouvelle page web.

Vous pouvez décider d’augmenter cette limite mais procédez avec attention car cela peut mener à une consommation mémoire excessive.

Eviter certains pièges

Voici 2 pièges typiques dans lesquels il faut éviter de tomber si vous souhaiter améliorer la performance de votre crawler :

  • stopper la requête si la page à télécharger est trop lourde : c’est important non seulement pour des questions de stabilité (vous n’avez certainement pas envie de remplir tout votre disque) mais aussi pour des questions d’efficacité.
  • paramétrer les timeouts avec attention : une requête web peut être en timeout pour plusieurs raisons et il est important de comprendre les concepts qu’il y a là-dessous afin d’adopter différent niveaux de timeouts. Jetez un oeil à cet excellent article de Cloudflare pour plus de détails. En Go vous pouvez mettre en place un timeout lorsque vous créez un client net/http, mais une façon plus idiomatique (et peut-être plus moderne) de faire est d’utiliser les contexts.

DNS

Lorsque l’on crawl des millions de pages web, le serveur DNS que l’on utilise par défaut a des chances de finir par rejeter les requêtes. Il devient alors intéressant de commencer à utiliser un serveur DNS plus robuste comme celui de Google ou de Cloudflare, ou même d’effectuer une rotation sur ces différents serveurs.

Rafraichir les données

Crawler des données une seule fois est souvent de peu d’intérêt. Les données doivent être rafraichies de façon asynchrone régulièrement en utilisant des tâches périodique, ou de façon synchrone à chaque requête utilisateur.

Une application sur laquelle j’ai travaillée récemment rafraichissait les données de façon asynchrone. Chaque fois que l’on crawlait un domaine, la date du crawl était stockée en base de données, et par la suite chaque jour une tâche périodique scannait en base tous les domaines nécessitant un update. Comme Cron est très limité, nous utilisions cet lib permettant de faire du Cron plus avancé dans notre application Go: https://github.com/robfig/cron.

Etre juste

Crawler le web doit être fait respectueusement. Cela signifie en gros 2 choses :

  • ne pas crawler une page si le robots.txt l’interdit
  • ne pas harceler de requêtes un seul serveur : baissez drastiquement votre niveau de concurrence lorsque vous crawlez plusieurs page d’un même domaine, et faites des pauses entre chaque requête.

Conclusion

Mettre en place un crawler permettant de crawler un grand volume de pages web est une aventure fascinante qui demande des réflexions avancées à la fois sur le code et sur l’infrastructure.

Dans ce poste je me contente d’aborder les bases mais j’espère malgré tout que ces concepts vous aideront lors de votre prochain projet ! Si vous avec des remarques ou des questions n’hésitez pas, ce sera avec plaisir.

Also available in English
Créer une PWA avec notifications push grâce à Vue.js et Django

Mettre en place une Progressive Web App (PWA) est simple comme bonjour grâce à Vue.js, en particulier grâce au CLI Vue v3. Toutefois la mise en place des notifications push peut s’avérer piégeuse.

Vue.js sera utilisé pour la partie frontend, Python/Django et Django Rest Framework pour le backend, et Google Firebase Messaging (FCM) comme intermédiaire de messaging. FCM est nécessaire puisqu’il fera office de tierce partie en charge de pousser les notifications vers l’appareil utilisateur. Je sais, c’est assez décevant de devoir ajouter ce service externe à notre architecture, mais il n’y a malheureusement pas d’autre choix. Bien entendu il existe des alternatives à Firebase, telles que Pusher par exemple.

Firebase devra être intégré à plusieurs endroits dans votre code :

  • côté frontend afin que le navigateur écoute les nouvelles notifications en provenance de Firebase
  • côté frontend aussi sur la page où vous voulez demander à l’utilisateur s’il souhaite autoriser les notifications et, s’il accepte, obtenir un token de notification de la part de Firebase à envoyer à notre backend pour le stocker en base de données. Si un utilisateur utilise plusieurs navigateurs (ex : Chromium mobile sur smartphone, et Firefox desktop sur PC), plusieurs tokens lui seront associés en base de données, et les notifications seront reçues à différents endroits au même moment.
  • côté backend afin d’envoyer les notifications push à l’utilisateur via l’envoi d’un message à l’API Firebase. Firebase se chargera de récupérer votre message et de le router vers les bons navigateurs associés.

Veuillez garder à l’esprit que le standard PWA est encore en pleine évolution et pas encore intégré de la même façon par tous les navigateurs sur toutes les plateformes. Par exemple les notifications push ne sont pas encore acceptées par iOS à l’heure où j’écris cet article !

PWA Vue.js

Installez le CLI Vue.js via la commande npm suivante (installez NPM en premier lieu si nécessaire) :

npm i -g @vue/cli

Créez un nouveau projet PWA :

vue create <My Project Name>

Sélectionnez l’option “Manually select features” puis sélectionnez “Progressive Web App (PWA) support” :

Vue CLI v3

Sélectionnez toutes les autres options dont vous avez besoin et patientez le temps que le CLI Vue crée votre projet. Veuillez noter que le CLI crée automatiquement un registerServiceWorker.js dans le répertoire src et l’importe en haut de votre main.js. Ce fichier prendra en charge la génération automatique d’un fichier service-worker.js à la racine de votre projet au cours du build de production. Ce fichier est nécessaire pour permettre au navigateur de reconnaître votre site comme PWA.

Dans votre répertoire public, créez un fichier manifest.json qui décrit votre PWA : le nom de votre app, les icônes de l’app pour différentes tailles d’écrans, les couleurs… Les éléments importants sont start_url qui est l’url à ouvrir par défaut au lancement de votre PWA par l’utilisateur sur son smartphone, et gcm_sender_id qui est l’ID que toutes les web apps utilisant Firebase doivent utiliser (donc ne le changez pas). Vous pouvez spécifier beaucoup plus d’information dans ce fichier, pour cela jetez un oeil à la doc. Au final votre fichier devrait ressembler à ça :

{
  "name": "My App Name",
  "short_name": "My App Short Name",
  "icons": [{
      "src": "./img/icons/android-chrome-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "./img/icons/android-chrome-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    },
    {
      "src": "./img/icons/apple-touch-icon-60x60.png",
      "sizes": "60x60",
      "type": "image/png"
    },
    {
      "src": "./img/icons/apple-touch-icon-76x76.png",
      "sizes": "76x76",
      "type": "image/png"
    },
    {
      "src": "./img/icons/apple-touch-icon-120x120.png",
      "sizes": "120x120",
      "type": "image/png"
    },
    {
      "src": "./img/icons/apple-touch-icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png"
    },
    {
      "src": "./img/icons/apple-touch-icon-180x180.png",
      "sizes": "180x180",
      "type": "image/png"
    },
    {
      "src": "./img/icons/apple-touch-icon.png",
      "sizes": "180x180",
      "type": "image/png"
    },
    {
      "src": "./img/icons/favicon-16x16.png",
      "sizes": "16x16",
      "type": "image/png"
    },
    {
      "src": "./img/icons/favicon-32x32.png",
      "sizes": "32x32",
      "type": "image/png"
    },
    {
      "src": "./img/icons/msapplication-icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "./img/icons/mstile-150x150.png",
      "sizes": "150x150",
      "type": "image/png"
    }
  ],
  "start_url": ".",
  "display": "standalone",
  "background_color": "#000000",
  "theme_color": "#210499",
  "gcm_sender_id": "103953800507"
}

Veuillez noter que votre site doit absolument être en HTTPS afin que le navigateur accepte de lire le manifest.json et ainsi se comporter comme une PWA.

Si tout se passe correctement, la PWA est maintenant installable sur votre smartphone. Rendez-vous sur votre site via un navigateur mobile moderne comme Chrome. Si le navigateur détecte le manifest.json il vous propose normalement d’installer la PWA sur votre smartphone comme une application (ce n’est pas encore supporté par tous les navigateurs à l’heure où j’écris cet article).

Mise en place de Firebase

Afin que votre PWA supporte les notifications push, il faut que vous intégriez un service externe comme Firebase Cloud Messaging (FCM). Veuillez noter que FCM n’est qu’une petite partie de Firebase mais vous n’aurez pas besoin des autres fonctionnalités de Firebase (comme les bases de données, l’hébergement, …).

Veuillez donc créer un compte Firebase, rendez-vous sur votre console Firebase, créez un projet pour votre site, et récupérez les informations suivantes depuis les réglages de votre projet (attention, ces infos ne sont pas forcément faciles à identifier; pensez à ouvrir les différents onglets) :

  • Project ID
  • Web API Key
  • Messaging Sender ID
  • Server Key
  • créez un certificat web push et récupérez la Public Vapid Key générée

Backend Django

Je pars du principe ici que vous connaissez Django Rest Framework.

Dans Django, utilisez l’app tierce FCM Django afin de faciliter l’intégration de FCM (cette app prendra soin d’automatiquement sauvegarder et supprimer les tokens de notifications en BDD, et vous fournira des fonctions toutes prêtes pour envoyer les notifications à FCM).

Installez l’application grâce à pip install fcm-django, ajoutez-la à vos apps Django, et configurez-la (n’hésitez pas à adapter les réglages ci-dessous, le seul requis est FCM_SERVER_KEY pour l’authentification auprès de FCM) :

INSTALLED_APPS = (
        ...
        "fcm_django"
)

FCM_DJANGO_SETTINGS = {
        # authentication to Firebase
        "FCM_SERVER_KEY": "<Server Key>",
        # true if you want to have only one active device per registered user at a time
        # default: False
        "ONE_DEVICE_PER_USER": False,
        # devices to which notifications cannot be sent,
        # are deleted upon receiving error response from FCM
        # default: False
        "DELETE_INACTIVE_DEVICES": True,
}

Ajoutez une route dans urls.py vers le point d’accès FCM Django qui se chargera de recevoir le token de notification et de le stocker en BDD.

from fcm_django.api.rest_framework import FCMDeviceAuthorizedViewSet

urlpatterns = [
  path('register-notif-token/',
    FCMDeviceAuthorizedViewSet.as_view({'post': 'create'}), name='create_fcm_device'),
]

Désormais, lorsque vous souhaitez envoyer une notification push à un utilisateur faites ce qui suit (il y a des chances pour que ce soit dans votre views.py) :

from fcm_django.models import FCMDevice

user = <Retrieve the user>
fcm_devices = FCMDevice.objects.filter(user=user)
fcm_devices.send_message(
  title="<My Title>", body="<My Body>", time_to_live=604800,
  click_action="<URL of the page that opens when clicking the notification>")

C’est à vous d’adapter la requête vers la BDD pour définir précisément à qui vous souhaitez envoyer la notification push. Ici j’envoie des notifications push vers tous les navigateurs d’un utilisateur, mais je pourrais aussi bien décider de n’envoyer les notifications qu’à un navigateur spécifique (appelé “device” dans la terminologie Django FCM).

Il existe d’autres paramètres disponibles à passer à la méthode send_message, n’hésitez pas à jeter un oeil à la doc mais aussi à la doc du projet Python sous-jacent sur lequel cette lib est basée.

Déclarer un time_to_live était nécessaire dans mon cas : Firebase dit qu’il existe un TTL par défaut mais il s’est avéré que ce n’était pas le cas me concernant (bug ?), ce qui fait que les notifications envoyées lorsque le device de l’utilisateur était coupé n’arrivaient jamais lorsque le device était rallumé.

Mettre en place les notifications push dans Vue.js

Créez un fichier firebase-messaging-sw.js dans votre répertoire public et mettez ce qui suit dedans :

importScripts('https://www.gstatic.com/firebasejs/5.5.6/firebase-app.js');
importScripts('https://www.gstatic.com/firebasejs/5.5.6/firebase-messaging.js');

var config = {
    apiKey: "<Web API Key>",
    authDomain: "<Project ID>.firebaseapp.com",
    databaseURL: "https://<Project ID>.firebaseio.com",
    projectId: "<Project ID>",
    storageBucket: "<Project ID>.appspot.com",
    messagingSenderId: "<Messenging Sender ID>"
};

firebase.initializeApp(config);

const messaging = firebase.messaging();

Vous disposez maintenant d’un service worker valide qui se chargera d’écouter Firebase en arrière plan en attente de nouvelles notifications push.

Il est désormais temps de demander sa permission à l’utilisateur afin de lui envoyer des notifications et, s’il est d’accord, récupérer un token de notification de FCM et le stocker en BDD côté backend. Votre backend utilisera ce token pour envoyer des notifications push via FCM. C’est à vous de décider sur quelle page de votre app vous souhaitez recueillir la permission de l’utilisateur. Par exemple vous pourriez mettre en place cela sur la page d’accueil de votre application une fois l’utilisateur loggué. Vous pourriez faire quelque chose dans ce style :

import firebase from 'firebase/app'
import 'firebase/app'
import 'firebase/messaging'

export default {
  methods: {
    saveNotificationToken(token) {
      const registerNotifTokenURL = '/register-notif-token/'
      const payload = {
        registration_id: token,
        type: 'web'
      }
      axios.post(registerNotifTokenURL, payload)
        .then((response) => {
          console.log('Successfully saved notification token!')
          console.log(response.data)
        })
        .catch((error) => {
          console.log('Error: could not save notification token')
          if (error.response) {
            console.log(error.response.status)
            // Most of the time a "this field must be unique" error will be returned,
            // meaning that the token already exists in db, which is good.
            if (error.response.data.registration_id) {
              for (let err of error.response.data.registration_id) {
                console.log(err)
              }
            } else {
              console.log('No reason returned by backend')
            }
            // If the request could not be sent because of a network error for example
          } else if (error.request) {
            console.log('A network error occurred.')
            // For any other kind of error
          } else {
            console.log(error.message)
          }
        })
      },
    },
  mounted() {
    var config = {
      apiKey: "<Web API Key>",
      authDomain: "<Project ID>.firebaseapp.com",
      databaseURL: "https://<Project ID>.firebaseio.com",
      projectId: "<Project ID>",
      storageBucket: "<Project ID>.appspot.com",
      messagingSenderId: "<Messenging Sender ID>"
    }
    firebase.initializeApp(config)

    const messaging = firebase.messaging()

    messaging.usePublicVapidKey("<Public Vapid Key>")

    messaging.requestPermission().then(() => {
      console.log('Notification permission granted.')
      messaging.getToken().then((token) => {
        console.log('New token created: ', token)
        this.saveNotificationToken(token)
      })
    }).catch((err) => {
      console.log('Unable to get permission to notify.', err)
    })

    messaging.onTokenRefresh(function () {
      messaging.getToken().then(function (newToken) {
        console.log('Token refreshed: ', newToken)
        this.saveNotificationToken(newToken)
      }).catch(function (err) {
        console.log('Unable to retrieve refreshed token ', err)
      })
    })
  }
}

Conclusion

Mettre en place les notifications push dans une PWA n’est clairement PAS évident ! De nombreuses briques de votre application doivent être impliquées et vous devez comprendre comment fonctionne le service tiers que vous avez choisi (ici Firebase).

Veuillez garder en tête que la PWA est une techno encore jeune en pleine évolution. Plus important : ne basez pas la communication d’informations critiques uniquement sur les notifications push car elles sont moins fiables que d’autres systèmes tels que les SMS ou les emails…

Par ailleurs, n’oubliez pas d’utiliser les notifications push avec circonspection car un excès de notifications peut vite devenir fort agaçant pour l’utilisateur !

J’espère que vous avez apprécié ce tuto. N’hésitez pas à me faire un retour ou à partager vos idées via les commentaires !

Also available in English
Tirer parti de Django Rest Framework et des vues génériques pour accélérer le développement d'API

En tant que développeur expérimenté, vous êtes certainement à la recherche d’outils permettant de supprimer les tâches répétitives et d’accélérer le développement. En tant que novice, vous cherchez peut-être un moyen de respecter les standards REST sans trop vous poser de questions.

Dans les deux cas, Django Rest Framework (DRF) est une excellente solution. DRF est un framework d’API très utilisé, respectant les standards, et regorgeant de fonctionnalités, qui va non seulement vous faire gagner beaucoup de temps mais aussi vous montrer la bonne direction pour développer vos API RESTful. Plus particulièrement, DRF propose des vues génériques, c’est à dire des points d’accès à votre API déjà pré-construits.

Vous pourrez trouver le code ci-dessous dans un petit projet Django fonctionnel à cette adresse.

Concept

Les vues génériques de DRF sont parfaites pour les API simples qui font du CRUD (create, read, update, delete) basique sur la base de données sans trop de retraitement. Par exemple, disons que vous avez une table produit qui contient tous les produits de votre boutique et que vous souhaitez exposer ces produits tels quels à vos clients via une API. Alors c’est un cas idéal pour utiliser ListAPIView (cf. plus bas).

A partir de maintenant je vais supposer que vous avez installé Python, Django, DRF, et que vous maîtrisez les bases de Django.

Exemple de base 1 : lire les données

Créons un point d’accès API exposant tous les produits aux utilisateurs authentifiés seulement. Dans votre views.py faites ce qui suit :

from rest_framework import generics
from .serializers import ProductsSerializer

class GetProducts(generics.ListAPIView):
    """Return all products."""
    serializer_class = ProductsSerializer

ProductsSerializer est le serializer qui va convertir vos données issues de la base de données en données au format API. Ce serializer doit être écrit dans le fichier serializers.py et sera en charge de la récupération des données du model Product et de les transformer :

from rest_framework import serializers
from .models import Product

class ProductsSerializer(serializers.ModelSerializer):
    """Serialize products."""

    class Meta:
        model = Product
        fields = ("__all__")

Maintenant dans votre urls.py créez la route vers votre point d’accès :

from django.urls import path
from .views import GetProducts

urlpatterns = [
    path('get-products/', GetProducts.as_view(), name='get_products'),
]

Comme vous pouvez le voir, c’est simple comme bonjour puisque DRF se charge de presque tout tout seul. Vous avez désormais un point d’accès (/get-products/) que vous pouvez consommer via des requêtes HTTP get, et qui affiche vos produits au format API (habituellement json mais cela dépend, là aussi, de vos settings).

Exemple basique 2 : supprimer des données

Maintenant créons un point d’accès dédié à la suppression de produits par les utilisateurs authentifiés seulement. C’est encore plus simple puisque cela ne nécessite pas de sérialiser les données (en effet une fois le produit supprimé aucune donnée ne peut plus être retournée à l’utilisateur).

Dans views.py :

from rest_framework import generics

class DeleteProduct(generics.DestroyAPIView):
    """Remove product"""
    permission_classes = (permissions.IsAuthenticated,) # Limit to authenticated users only

Dans urls.py

from django.urls import path
from .views import DeleteProduct

urlpatterns = [
    path('delete-product/', DeleteProduct.as_view(), name='delete_product'),
]

Vous avez maintenant un point d’accès /delete-product/ qui permet de supprimer les produits un par un en utilisant les requêtes HTTP delete, et qui accepte seulement les requêtes authentifiées (le mécanisme d’authentification dépend de vos settings).

Personnaliser le comportement des vues génériques

Chaque vue générique peut être affinée via l’écriture d’une méthode get_queryset(). Par exemple disons que vous souhaitez uniquement afficher les produits ayant un flag active à True en base de données. Vous pouvez faire comme suit :

from rest_framework import generics
from .serializers import ProductsSerializer
from .model import Product

class GetActiveProducts(generics.ListAPIView):
    """Return all active products."""
    permission_classes = (permissions.IsAuthenticated,)
    serializer_class = ProductsSerializer

    def get_queryset(self):
        """Filter active products."""
        return Product.objects.filter(active=True)

get_queryset() est une méthode commune à toutes les vues génériques. Certaines vues génériques ont aussi leurs propres méthodes afin de contrôler plus précisément le comportement du point d’accès. Par exemple, disons que vous ne souhaitez pas vraiment supprimer les produits mais juste les marquer comme inactifs. Vous pourriez utiliser la méthode destroy() :

from django.shortcuts import get_object_or_404
from rest_framework.response import Response
from rest_framework import status

class DisableProduct(generics.DestroyAPIView):
    """Remove product"""
    permission_classes = (permissions.IsAuthenticated,)

    def destroy(self, request, pk):
        """
        By default, DestroyAPIView deletes the product from db.
        Here we only want to flag it as inactive.
        """
        product = get_object_or_404(self.get_queryset(), pk=pk)
        product.active = False
        product.save()
        return Response(status=status.HTTP_204_NO_CONTENT)

Dans l’exemple ci-dessus nous essayons en premier de rechercher le produit que l’utilisateur veut supprimer. Si nous ne parvenons pas à le trouver, nous retournons un code 404 à l’utilisateur. Si le produit est marqué comme inactif avec succès, nous retournons un code 204 à l’utilisateur signifiant que le produit a été supprimé avec succès.

Les vues génériques sont parfaites pour les cas simples, et il est parfois plus sage d’utiliser les classiques APIView pour les cas spéciaux. Par exemple, disons que vous souhaitez non seulement retourner les produits à l’utilisateur mais aussi les enrichir avec des données ne se trouvant pas dans le model Product (ex : les commandes liées à ce produit, le fournisseur du produit, etc.). Dans ce cas, si vous vouliez utiliser les vues génériques, il vous faudrait définir de nouveaux champs dans le serializer grâce à des méthodes get_new_field() additionnelles qui peuvent facilement rendre votre serializer inutilement complexe…

Conclusion

Comme vous l’avez vu, les vues génériques de DRF rendent le développement d’API extrêmement simple grâce à un peu de magie. Toutefois gardez à l’esprit que les vues génériques ne peuvent pas s’appliquer à tous les cas d’usage et que parfois essayer à tout prix d’adapter les vue génériques à vos besoins sera plus difficile que de re-développer les choses de zéro par vous-même !

J’espère que vous avez aimé ce petit tuto. J’adorerais avoir vos retours !

Also available in English