Torrengo: un CLI de recherche de torrents concurrent en Go

J’utilise le programme Torrench depuis un moment maintenant et je trouve que c’est un super outil en Python pour facilement rechercher des torrents en console (CLI). Je me suis dit que je pourrais essayer de développer un outil similaire en Go (Golang) puisque ce serait une bonne opportunité pour moi d’apprendre de nouvelles choses et que je pourrais introduire de la concurrence dans ce programme, ce qui augmenterait sensiblement les performances (Torrench est lent à cet égard).

Laissez-moi vous parler des fonctionnalités de ce nouveau programme ici, vous montrer comment ils gère la concurrence, et enfin vous expliquer comment j’ai organisé cette bibliothèque Go en vue d’une réutilisation par des tiers.

Fonctionnalités de Torrengo

Torrengo est un programme en ligne de commande (CLI) qui recherche les torrents (fichiers torrents et liens magnet) de façon concurrente depuis différents sites web. Rechercher des torrents a toujours été une catastrophe pour moi puisque ça signifiait à chaque fois lancer la même recherche sur de nombreux sites, faire face à des sites qui fonctionnent mal, et se retrouver inondé de pub scandaleuses… ce petit outil est là pour tout résoudre. Voici un aperçu des fonctionnalités clés de Torrengo au moment où j’écris ces lignes.

Un programme CLI simple

J’ai réalisé que je n’avais pas beaucoup d’expérience concernant l’écriture d’outils en ligne de commande, je me suis donc dit que ce petit projet serait une bonne façon d’améliorer mes compétences en la matière.

Les programmes CLI dédiés à des cas d’usage très spécifiques comme celui-là et avec très peu d’interaction utilisateur peuvent s’avérer être beaucoup plus clairs et légers qu’un GUI. Ils peuvent aussi facilement se brancher à d’autres programmes.

Vous pouvez lancer une nouvelle recherche de cette façon :

./torrengo Alexandre Dumas

Cela retournera tous les résultats trouvés sur tous les sites, le tout classé par nombre de seeders, et c’est à vous de décider lequel vous souhaitez alors télécharger :

Torrengo output

Vous pouvez aussi décider d’effectuer une recherche sur certains sites uniquement (disons The Pirate Bay et Archive.org) :

./torrengo -s tpb,arc Alexandre Dumas

Si vous souhaitez voir plus de logs (mode verbeux), ajoutez simplement le flag -v.

Rapide

Go étant un langage très performant et avec lequel il est facile de coder des programmes concurrents, Torrengo récupère la liste de torrents très rapidement. Au lieu de chercher sur chaque site un par un de façon séquentielle, il cherche sur tous en parallèle.

Torrengo est aussi rapide que la plus lente des réponse HTTP. Parfois la réponse HTTP la plus lente peut prendre plus de temps que vous ne l’auriez souhaité (par exemple Archive.org est assez lent depuis la France au moment où j’écris ces lignes). Dans ce cas, comme Torrengo supporte les timeouts, il vous suffit de déclarer un timeout comme suit :

./torrengo -t 2000 Alexandre Dumas

La commande ci-dessus stoppe toutes les requêtes HTTP qui prennent plus de 2 secondes et retourne les autres résultats trouvés.

Utilise les proxies pour The Pirate Bay

Les urls pour The Pirate Bay changent assez régulièrement et la plupart d’entre elles fonctionnent de façon erratique. Pour contourner ce problème, le programme lance une recherche concurrente sur toutes les urls The Pirate Bay trouvées sur un site listant tous les proxies (proxybay.bz à l’heure où j’écris ces lignes) et récupère les torrents depuis la réponse la plus rapide.

Le HTML de l’url retournée est aussi contrôlé en détails car certains proxies retournent parfois une page sans aucune erreur HTTP mais la page ne contient en réalité aucun résultat…

Contourner la protection Cloudflare sur Torrent Downloads

Télécharger des fichiers torrent depuis le site de Torrent Downloads est difficile car ce dernier a une protection Cloudflare qui consiste en un challenge Javascript auquel il faut répondre. Torrengo tente de contourner cette protection en résolvant ce challenge Javascript, chose qui fonctionne 90% du temps au moment à l’heure où j’écris cet article.

Authentification

Certains sites de torrents comme Ygg Torrent (anciennement t411) obligent les utilisateurs à s’authentifier afin de télécharger le fichier torrent. Torrengo gère cela en demandant ses identifiants à l’utilisateur (mais bien entendu il vous faut un compte en premier lieu).

Avertissement : ne faites jamais confiance aux programmes qui demandent vos identifiants sans avoir mis votre nez dans le code source au préalable.

Ouvrir dans le client torrent

Au lieu de copier coller les liens magnet trouvés par Torrengo dans votre client torrent ou de chercher le fichier torrent téléchargé pour l’ouvrir, vous pouvez simplement dire au programme d’ouvrir le torrent dans client local directement. Seul Deluge est supporté pour le moment.

Concurrence

Grâce à la simplicité de Go, la façon dont la concurrence est gérée dans ce programme n’est pas difficile à comprendre. C’est particulièrement vrai ici car la concurrence se prête particulièrement bien aux programmes faisant beaucoup d’appels réseau comme celui-ci. En gros la goroutine “main” crée une nouvelle goroutine pour chaque scraper et récupère les résultats dans des structs via des channels dédiés. tpb est un cas particulier puisque la bibliothèque crée à son tour n goroutines, n étant les nombre d’urls trouvées sur le site de proxies.

J’en profite pour dire que j’ai appris 2 petites choses intéressantes en termes de bonnes pratiques concernant les channels :

  • chaque goroutine de recherche retourne à la fois une erreur et un résultat. J’ai décidé de créer un channel dédié pour le résultat (une struct) et un autre pour l’erreur, mais je sais que certains préfèrent retourner les deux au sein d’une même struct. Ça dépend de vous, les deux sont ok.
  • les goroutines créées par tpb n’ont pas besoin de retourner une erreur mais simplement de signaler qu’elles se sont terminées. C’est ce que l’on appelle un “événement”. J’ai modélisé cela via l’envoi d’une struct vide (struct{}{}) sur la channel, ce qui semble le plus pertinent du point de vue de l’utilisation mémoire puisqu’une struct vide ne pèse rien. La seconde option et de passer un booléen ok, ce qui fonctionne parfaitement aussi.

Structure de la bibliothèque

Cette bibliothèque est composée de plusieurs sous bibliothèques : une pour chaque scraper. Le but est que chaque bibliothèque de scraping soit le moins couplée possible au programme général Torrengo afin de pouvoir être réutilisée dans des projets tiers. Chaque scraper a sa propre documentation.

Voici la structure actuelle du projet :

torrengo
├── arc
│   ├── download.go
│   ├── README.md
│   ├── search.go
├── core
│   ├── core.go
├── otts
│   ├── download.go
│   ├── README.md
│   ├── search.go
├── td
│   ├── download.go
│   ├── README.md
│   ├── search.go
├── tpb
│   ├── proxy.go
│   ├── README.md
│   ├── search.go
├── ygg
│   ├── download.go
│   ├── login.go
│   ├── README.md
│   ├── search.go
├── README.md
├── torrengo.go

Je vais vous donner quelques détails sur la façon dont j’ai organisé ce projet Go (j’aurais beaucoup aimé recevoir ce genre de conseils sur les bonnes pratiques à suivre concernant la structuration des projets Go).

Un package Go par répertoire

Comme je l’ai dit plus haut, j’ai créé une bibliothèque pour chaque scraper. Tous les fichiers contenus dans le même répertoire appartiennent au même package Go. Dans le dessin ci-dessus il y a 5 bibliothèques de scraping :

  • arc (Archive.org)
  • otts (1337x)
  • td (Torrent Downloads)
  • tpb (The Pirate Bay)
  • ygg (Ygg Torrent)

Chaque bibliothèque peut-être utilisée dans un autre projet go en l’important comme suit (par exemple pour Archive.org) :

import "github.com/juliensalinas/torrengo/arc"

La bibliothèque core contient les choses communes à toutes les bibliothèques de scraping, et sera ainsi importée par chacune d’elles.

Chaque bibliothèque de scraping a sa propre documentation (README) afin d’être parfaitement découplée.

Plusieurs fichiers par répertoire

Chaque répertoire contient plusieurs fichiers pour faciliter la lisibilité. Avec Go, vous pouvez créer autant de fichiers que vous le souhaitez pour le même package tant que vous mentionnez le nom du package en haut de chaque fichier. Par exemple ici chaque fichier contenu dans le dossier arc possède la ligne suivante tout en haut :

package arc

Par exemple dans arc j’ai organisé les choses de façon à ce que chaque struct, méthode, fonction, variable, liées au téléchargement du fichier torrent final aille dans le fichier download.go.

Commande

Certains développeurs Go mettent tout ce qui est lié aux commandes, c’est-à-dire les parties du programme dédiées à l’interaction avec l’utilisateur, dans un répertoire dédié cmd.

Ici, j’ai préféré mettre le tout dans un unique fichier torrengo.go à la racine du projet, ce qui me semblait plus approprié pour un petit programme comme celui-ci.

Conclusion

J’espère que vous aimerez Torrengo et j’adorerais recevoir des feed-back à ce sujet ! Les feed-backs peuvent concerner les fonctionnalités de Torrengo bien entendu, mais si vous êtes un développeur Go et que vous pensez que certaines parties du code peuvent être améliorées j’aimerais beaucoup entendre vos avis aussi.

Je prévois d’écrire d’autres articles autour de ce que j’ai appris en écrivant ce programme !

Also available in English
Bonnes lectures autour de Go, Python, et le management technique

Il arrive encore souvent que je dégote des informations précieuses dans les livres (informations que je n’aurais pas trouvées sur internet), et c’est pourquoi je lis beaucoup. J’ai eu l’occasion de lire pas mal de choses récemment et certaines ne valaient clairement pas le coup, mais d’autres m’ont beaucoup apporté, je les partage donc avec vous ici !

Go en long et en large

Si vous connaissez déjà les bases de Go (Golang) et que vous souhaitez approfondir, The Go Programming Language (Donovan & Kernighan) est un excellent ouvrage.

Personnellement, avant d’ouvrir ce livre j’avais déjà eu l’opportunité de développer plusieurs projets en Go mais certaines notions essentielles du langage me faisaient toujours défaut, notamment l’esprit du langage et la façon d’écrire du code idiomatique en Go. Ce livre m’a vraiment aidé. Il débute par les bases mais creuse le sujet très en profondeur jusqu’à aborder des points comme la réflexion, les tests, la programmation bas niveau… Les exemples sont très concis (je déteste lire trop de lignes de code dans un livre papier) et systématiquement adaptés de cas utiles de la vie réelle.

Go et la concurrence

Si vous estimez que l’ouvrage ci-dessus ne va pas assez loin en ce qui concerne la concurrence, vous pouvez lire Concurrency In Go (Cox-Buday).

Ce livre est très court et va droit au but. Il met vraiment l’accent sur les bonnes pratiques à appliquer lorsque l’on développe un programme concurrent (en général, et plus particulièrement en Go). Ce livre vous fournit des patterns que vous pouvez reproduire dans vos propres applications.

Astuces et outils autour de Python

Python est un langage relativement ancien, par conséquent de nombreux supers outils existent autour de ce langage (bibliothèques, frameworks, etc.). En revanche il n’est pas facile d’avoir une vision claire de toutes les options existantes. The Hitchhiker’s Guide to Python! (Reitz) résume tout ce que vous devez savoir à propos de l’écosystème Python. Il peut vous faire économiser énormément de temps. Attention : ce livre n’est pas un livre de code !

Le management technique

Si vous êtes en charge d’une équipe de développeurs vous savez déjà à quel point la tâche est complexe. Si vous n’êtes pas encore dans cette situation, alors soyez préparé ! Dans les deux cas il est important de se documenter sur le sujet. Manager et faire preuve de leadership est un job difficile, en particulier dans le domaine de la programmation (lead developer, manager technique, CTO, …). Ce livre a été d’une grande aide pour moi : Managing the Unmanageable (Mantle & Lichty). Il ne vous noie pas dans des concepts théoriques autour du management mais au contraire il vous donne de nombreux trucs et astuces faciles à appliquer concrètement.

Conclusion

Si vous avez lu l’un de ces ouvrages alors dîtes-moi ce que vous en avez pensé !

Also available in English
Python Flask vs Django

Mon expérience de Flask n’est pas aussi poussée que celle de Django, mais il se trouve que j’ai été récemment amené à développer plusieurs projets en Flask et il m’était impossible de ne pas comparer les deux. Je vais mener ici une comparaison très succincte, non pas en montrant du code mais plutôt en discutant de certains aspects plus “philosophiques”. Bien entendu mon opinion peut changer !

Les deux restent des frameworks Python…

Quand je suis passé de Django à Flask, ce qui m’a tout de suite frappé était la similitude entre les deux frameworks. Vous ne serez pas perdu. Cela tient principalement au fait que tous deux utilisent le langage Python. Bien que Flask soit censé être plus Pythonique (en utilisant beaucoup les décorateurs par exemple), je n’ai pas noté énormément de différences. Par ailleurs, les deux sont des frameworks web et la plupart des frameworks web fonctionnent à peu près de la même manière :

  • un ORM
  • du routage
  • un objet user request utilisé abondamment
  • des templates
  • la gestion des formulaires
  • la gestion des fichiers statiques

Même en passant de Python à un autre langage, l’utilisation d’un web framework vous aidera grandement parce que cette structure MVC sera toujours là (bon d’accord ce n’est pas toujours du vrai MVC mais peu importe) et vous vous sentirez comme chez vous en un instant. Par exemple, voyez comme c’est facile de passer de Python/Django à PHP/Laravel.

Framework vs micro framework

Flask est censé être un micro framework moins dirigiste et moins clé en main que Django. D’une certaine façon vous êtes censé être plus libre avec Flask. Et c’est le cas ! Django propose une structure de fichiers/dossiers que tout le monde respecte. Django contient aussi son propre ORM, son système de template, sa gestion des formulaires, son système d’inscription/authentification… qui sont utilisés par 99% des gens. Le problème est que, selon moi, cette liberté crée plus de problèmes qu’elle n’en résout…

Ce manque de structure implique que vous sachiez à l’avance exactement quelle sera la meilleure structure pour votre projet, ce qui est très difficile si vous n’avez jamais encore développé un projet entier avec Flask et que vous n’avez jamais eu l’occasion de mettre à l’épreuve cette structure sur un projet dont la taille grossit mois après mois. J’ai constaté que beaucoup de gens finissent par demander conseil concernant la structure de leur projet sur StackOverflow et enfin de compte il se trouve que la plupart des gens utilisent… la structure de Django (un fichier de configuration central et un dossier par application contenant chacun un views.py + models.py + forms.py + des templates) ! J’ai aussi eu pas mal de problèmes, comme beaucoup d’autres, d’imports circulaires en Python, ce qui n’arrive jamais avec Django puisque des gens intelligents ont conçu la structure à votre place justement pour vous éviter ce genre d’ennuis.

La structure d’un projet est une partie très importante de la documentation d’un projet. Une structure standardisée aidera considérablement les nouveaux venus sur votre projet. Chaque fois que je commence à travailler sur un projet Django existant, je suis complètement opérationnel en quelques minutes parce que je sais à l’avance comment les choses sont organisées. Ce n’est pas le cas avec Flask donc les développeurs d’un projet Flask doivent absolument écrire de la documentation supplémentaire afin d’expliquer la structure de leur projet. Le problème est le même avec de nombreuses bibliothèques natives sous Django mais pas sous Flask : chaque projet Flask peut être construit avec des bibliothèques différentes (pour les formulaires, les templates, l’inscription/login, etc.) ce qui fait que les nouveaux développeurs qui arrivent sur un projet peuvent avoir à se former sur de nouveaux outils avant de commencer à travailler.

Documentation

La documentation de Flask est bonne mais clairement trop difficile d’accès pour des développeurs débutants en comparaison de Django qui a une documentation s’adressant à tous types de niveaux (du pur débutant au plus avancé). Par ailleurs, Flask étant un micro framework, une grande partie du travail est basée sur l’utilisation de dépendances externes comme SQLAlchemy pour l’ORM, Flask login pour le login, Flask WTF pour les formulaires, etc. et certaines de ces dépendances n’ont pas le même standard de documentation que Flask. Dit autrement : je trouve la documentation de SQLAlchemy imbuvable, et les outils comme Flask login ont vraiment besoin de faire grossir leur doc (résoudre mes problèmes en parcourant le code source de la bibliothèque ne devrait jamais arriver selon moi).

Communauté

La communauté de Flask est bien plus petite que celle de Django (mais très accueillante malgré tout). La conséquence est que vous ne trouverez pas toutes vos réponses sur StackOverflow ou Github. Cependant vous finissez pas réaliser que la plupart des problèmes sont soit des problèmes purement Python (pas de souci dans ce cas) ou des problèmes rencontrés aussi par les utilisateurs de Django (dans ce cas on est aidé par la communauté de Django).

Conclusion

Flask est un micro framework qui ne recommande aucun standard ou convention. Selon moi il s’agit plus d’un problème que d’une solution si vous espérez que votre projet soit facilement maintenable par d’autre gens. En revanche, si la liberté vous attire, alors essayez Flask ! Si vous êtes déjà développeur Django, le changement sera très facile.

Also available in English
Construire une application moderne avec une API backend en Golang + un frontend SPA Vue.js en utilisant Docker

Le but de cet article est de montrer une application réelle que j’ai développée récemment utilisant Go, Vue.js, et Docker, et qui est en production aujourd’hui. Les tutoriels sont parfois décevants dans la mesure où ils ne traitent pas de situations réelles. J’ai donc essayé de présenter les choses un peu différemment ici. Je ne vais pas commenter l’intégralité du code car cela prendrait une éternité mais je vais expliquer la structure générale du projet, quels choix critiques j’ai fait, et pourquoi. Je vais aussi essayer de mettre en avant certains bouts de codes qui valent le coup d’être commentés.

L’intégralité du code de l’application se trouve sur mon GitHub, peut-être que vous pourriez l’ouvrir en parallèle de la lecture de cet article.

But de l’application

Cette application est dédiée à la présentation de données issues de différentes bases de données de façon user friendly. En voici les principales fonctionnalités :

  • l’utilisateur doit s’identifier pour pouvoir utiliser la Single Page Application (SPA)
  • l’utilisateur peut sélectionner différentes interfaces via un panneau à gauche dans le but de récupérer des données issues de plusieurs tables de bdd
  • l’utilisateur peut décider de seulement compter les résultats retournés par la bdd, ou d’obtenir les résultats complets
  • si les résultats retournés par la bdd sont suffisamment légers, alors ils sont retournés par l’API et affichés au sein de la SPA à l’intérieur d’un tableau de données. L’utilisateur peut aussi décider de les exporter comme CSV.
  • si les résultats sont trop lourds, alors ils sont envoyés à l’utilisateur par email au sein d’une archive zippée de façon asynchrone
  • comme critères de recherche, l’utilisateur peut entrer du texte ou des fichiers CSV contenant un grand nombre de critères
  • certains inputs utilisateur sont des listes select dont les valeurs sont chargées dynamiquement depuis la bdd

Structure du projet et outils

Le projet est constitué de 2 conteneurs Docker:

  • un conteneur pour une API backend écrite en Go. Pas besoin ici d’un serveur HTTP puisque Go possède déjà un serveur HTTP très efficace natif (net/http). Cette application expose une API RESTful afin de recueillir des requêtes depuis le frontend et de retourner les résultats renvoyés par plusieurs bdd.
  • un conteneur pour une interface frontend utilisant une SPA Vue.js. Ici un serveur Nginx est nécessaire afin de servir les fichiers statiques.

Voici le Dockerfile de mon application Go :

FROM golang
VOLUME /var/log/backend
COPY src /go/src
RUN go install go_project
CMD /go/bin/go_project
EXPOSE 8000

Terriblement simple comme vous pouvez le voir. J’utilise une image Docker Golang déjà construite basée sur Debian.

Le Dockerfile de mon frontend est légèrement plus gros car j’ai besoin d’installer Nginx, mais il reste très simple malgré tout :

FROM ubuntu:xenial

RUN apt-get update && apt-get install -y \
    nginx \
    && rm -rf /var/lib/apt/lists/*

COPY site.conf /etc/nginx/sites-available
RUN ln -s /etc/nginx/sites-available/site.conf /etc/nginx/sites-enabled
COPY .htpasswd /etc/nginx

COPY startup.sh /home/
RUN chmod 777 /home/startup.sh
CMD ["bash","/home/startup.sh"]

EXPOSE 9000

COPY vue_project/dist /home/html/

Le fichier startup.sh se contente de démarrer le serveur Nginx. Voici ma configuration Nginx (site.conf):

server {

    listen 9000;

    server_name api.example.com;

    # In order to avoid favicon errors on some navigators like IE
    # which would pollute Nginx logs (do use the "=")
    location = /favicon.ico { access_log off; log_not_found off; }

    # Static folder that Nginx must serve
    location / {
        root /home/html;
        auth_basic "Restricted";
        auth_basic_user_file /etc/nginx/.htpasswd;
    }

    # robots.txt file generated on the fly
    location /robots.txt {
        return 200 "User-agent: *\nDisallow: /";
    }

}

Comme vous pouvez le voir, l’authentification est requise pour utiliser l’appli frontend. J’ai mis cela en place via un fichier .htpasswd.

En réalité, l’utilisation de Docker pour l’application Go n’apporte pas vraiment grand chose puisque Go n’a besoin d’aucune dépendance externe une fois compilé, ce qui rend le déploiement très facile. Parfois, intégrer Go à Docker peut être utile si vous avez des fichiers à charger en plus de votre binaire Go (comme un template HTML, ou des fichiers de configuration). Ce n’est pas le cas ici mais il n’empêche que j’ai préféré utiliser Docker pour des raisons de cohérence : tous mes services tournent sous Docker et je ne veux donc pas avoir à traiter de cas particuliers.

L’application Go est constituée de plusieurs fichiers. La raison est uniquement une question de lisibilité et tout aurait pu parfaitement être placé dans un seul et même fichier. Il faut garder à l’esprit que lorsque vous découpez l’application de cette façon, vous avez des éléments à exporter (variables, strucs, fonctions,…) si vous voulez les réutiliser à travers toute l’application (en passant la première lettre en majuscule). Durant le développement vous devrez aussi utiliser go run avec une wildcard comme cela:

go run src/go_project/*.go

J’utilise quelques bibliothèques Go externes (mais tellement peu grâce à l’excellente bibliothèque standard de Go !) :

  • gorilla/mux pour le routing des requêtes de l’API, en particulier pour les points d’accès nécessitant des arguments positionnels
  • rs/cors pour une gestion plus facile de CORS (qui peut vite devenir un cauchemar)
  • gopkg.in/gomail.v2 pour la gestion des emails, en particulier pour les pièces jointes

La structure et les outils utilisés par le frontend est beaucoup plus complexe. Voici un article dédié au sujet. En réalité cette complexité n’affecte que la partie développement parce qu’au final, une fois que tout est compilé, vous obtenez seulement des fichiers HTML/CSS/JS classiques que vous copiez collez simplement dans votre conteneur Docker.

Dev vs Prod

La configuration n’est pas la même selon que l’on est en développement ou en production. Pour le développement j’utilise une base de données répliquée localement, je logue les erreurs en console et non pas au sein d’un fichier, j’utilise des serveurs locaux, … Comment gérer tout cela de façon intégrée ?

Dans l’application Vue.js j’ai besoin de me connecter soit à un serveur d’API local pour le développement (127.0.0.1), soit à un serveur d’API de production (api.example.com). J’ai donc créé un fichier http-constants.js qui retourne soit une adresse locale, soit une adresse de production, selon que l’on a lancé npm run dev ou npm run build. Pour plus de détails, consultez cet article où j’ai déjà tout expliqué.

Au sein de l’appli Go, plusieurs paramètres changent selon que l’on est en dev ou en prod. Afin de gérer cela, j’utilise des variables d’environnement transmises à l’appli Go par Docker. Configurer son appli via des variables d’environnement est censé être une bonne pratique d’après l’appli à 12 facteurs. Premièrement nous devons initialiser ces variables d’environnement au cours de la création du conteneur grâce à l’option -e:

docker run --net my_network \
--ip 172.50.0.10 \
-p 8000:8000 \
-e "CORS_ALLOWED_ORIGIN=http://api.example.com:9000" \
-e "REMOTE_DB_HOST=10.10.10.10" \
-e "LOCAL_DB_HOST=172.50.0.1" \
-e "LOG_FILE_PATH=/var/log/backend/errors.log" \
-e "USER_EMAIL=me@example.com" \
-v /var/log/backend:/var/log/backend \
-d --name backend_v1_container myaccount/myrepo:backend_v1

Par la suite ces variables sont récupérées par le programme Go grâce à la fonction os.getenv(). Voici comment j’ai géré le tout dans main.go:

// Initialize db parameters
var localHost string = getLocalHost()
var remoteHost string = getRemoteHost()
const (
	// Local DB:
	localPort     = 5432
	localUser     = "my_local_user"
	localPassword = "my_local_pass"
	localDbname   = "my_local_db"

	// Remote DB:
	remotePort     = 5432
	remoteUser     = "my_remote_user"
	remotePassword = "my_remote_pass"
	remoteDbname   = "my_remote_db"
)

// getLogFilePath gets log file path from env var set by Docker run
func getLogFilePath() string {
	envContent := os.Getenv("LOG_FILE_PATH")
	return envContent
}

// getLocalHost gets local db host from env var set by Docker run.
// If no env var set, set it to localhost.
func getLocalHost() string {
	envContent := os.Getenv("LOCAL_DB_HOST")
	if envContent == "" {
		envContent = "127.0.0.1"
	}
	return envContent
}

// getRemoteHost gets remote db host from env var set by Docker run.
// If no env var set, set it to localhost.
func getRemoteHost() string {
	envContent := os.Getenv("REMOTE_DB_HOST")
	if envContent == "" {
		envContent = "127.0.0.1"
	}
	return envContent
}

// getRemoteHost gets remote db host from env var set by Docker run.
// If no env var set, set it to localhost.
func getCorsAllowedOrigin() string {
	envContent := os.Getenv("CORS_ALLOWED_ORIGIN")
	if envContent == "" {
		envContent = "http://localhost:8080"
	}
	return envContent
}

// getUserEmail gets user email of the person who will receive the results
// from env var set by Docker run.
// If no env var set, set it to admin.
func getUserEmail() string {
	envContent := os.Getenv("USER_EMAIL")
	if envContent == "" {
		envContent = "admin@example.com"
	}
	return envContent
}

Comme vous pouvez le voir, si la variable d’environnement n’est pas déclarée, on la remplace par une valeur par défaut. On peut ensuite utiliser ces fonctions dédiées partout dans le programme. Voici par exemple comment je gère la fonctionnalité de logging (loguer en console pour le développement, et loguer dans un fichier en production):

log.SetFlags(log.LstdFlags | log.Lshortfile)            // add line number to logger
if logFilePath := getLogFilePath(); logFilePath != "" { // write to log file only if logFilePath is set
	f, err := os.OpenFile(logFilePath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
	if err != nil {
		log.Fatal(err)
	}
	defer f.Close()
	log.SetOutput(f)
}

Notez que le logging implique aussi l’utilisation d’un volume partagé. En effet je veux que mes fichiers de logs soient accessibles depuis l’hôte Docker directement. C’est pourquoi j’ai ajouté -v /var/log/backend:/var/log/backend à la commande docker run ci-dessus et mis une directive VOLUME spécifique dans le Dockerfile.

Design de l’application frontend avec Vuetify.js

Je n’ai jamais été un fan du design, surtout quand il faut y passer des jours pour des petites applications telles que celle-là. C’est pourquoi j’utilise Vuetify.js qui est un super framework à utiliser par dessus Vue.js et qui vous fournit de magnifiques composants prêts à l’emploi. Vuetify utilise le material design de Google, qui a beaucoup de style je trouve.

Utilisation mémoire

J’ai dû faire face à plusieurs défis liés à la mémoire lors de la création de ce programme en raison du fait que certaines requêtes SQL peuvent parfois retourner un très grand nombre de lignes.

Côté backend Go

Les lignes retournées par la bdd sont placées dans un array de structs. Lorsque des millions de lignes sont retournées, la manipulation de cet array devient très coûteuse en termes de mémoire. La solution est de déporter autant que faire se peut la logique côté SQL plutôt que de la laisser dans votre programme Go. PostgreSQL est très performant en ce qui concerne l’optimisation des performances et dans mon cas les bases de données tournent sous PostgreSQL 10 qui améliore considérablement les performances de certaines requêtes grâce aux opérations parallèles. En plus de cela, mes bases de données ont des ressources qui leur sont dédiées donc autant en profiter.

Concernant la génération de CSV, il vous faut aussi étudier si il est préférable de stocker le CSV en mémoire ou sur le disque. Personnellement je l’écris sur le disque afin de réduire l’utilisation mémoire.

Mais malgré tout cela, j’ai aussi été contraint d’augmenter la RAM de mon serveur.

Côté frontend Vue.js

Il est clair qu’un navigateur ne peut pas se permettre d’afficher trop de contenu. Si vous cherchez à afficher un trop grand nombre de lignes au sein du navigateur, ça va planter ou faire ramer le navigateur. La première solution (qui est celle que j’ai adoptée) est d’envoyer les résultats dans une archive .zip lorsqu’un trop grand nombre de résultats est retourné par la base de données. Une autre solution pourrait être de paginer les résultats dans le navigateur et que chaque nouvelle page déclenche en fait une nouvelle requête vers la base de données afin de charger plus de résultats (ce qui nécessiterait l’utilisation de LIMIT dans votre requête SQL).

Quelques bouts de code un peu délicats

Voici certaines parties qu’il me semble intéressant de commenter soit parce qu’elles sont particulièrement piégeuses, ou tout simplement originales.

Réaliser plusieurs requêtes asynchrones avec Axios

Mon frontend contient plusieurs selects HTML et je veux que les valeurs proposées par ces listes soient chargées dynamiquement depuis l’API. Pour ce faire j’ai besoin d’utiliser axios.all() et axios.spread() afin de réaliser plusieurs appels à l’API en parallèle avec Axios. La documentation d’Axios est quelque peu erratique sur le sujet. Il est important de comprendre que vous avez 2 options :

  • attraper les erreurs pour chaque requête dans axios.all: HTTP.get('/get-countries-list').catch(...)
  • attraper les erreurs de façon globale après axios.spread: .then(axios.spread(...)).catch(...)

La première option vous permet d’afficher des messages d’erreur précis selon la requête qui a généré l’erreur, mais cette option est non bloquante donc on entre malgré tout dans axios.spread() en dépit de l’erreur et certains paramètres se retrouveront undefined dans axios.spread() ce qui vous oblige à gérer cela intelligemment. Dans la deuxième option, une erreur globale est générée dès qu’au moins une des requête échoue, et nous n’entrons jamais dans axios.spread().

J’ai choisi la seconde option : si au moins une des requêtes à l’API échoue, alors toutes les requêtes s’arrêtent :

created () {
    axios.all([
      HTTP.get('/get-countries-list'),
      HTTP.get('/get-companies-industries-list'),
      HTTP.get('/get-companies-sizes-list'),
      HTTP.get('/get-companies-types-list'),
      HTTP.get('/get-contacts-industries-list'),
      HTTP.get('/get-contacts-functions-list'),
      HTTP.get('/get-contacts-levels-list')
    ])
    // If all requests succeed
    .then(axios.spread(function (
      // Each response comes from the get query above
      countriesResp,
      companyIndustriesResp,
      companySizesResp,
      companyTypesResp,
      contactIndustriesResp,
      contactFunctionsResp,
      contactLevelsResp
    ) {
      // Put countries retrieved from API into an array available to Vue.js
      this.countriesAreLoading = false
      this.countries = []
      for (let i = countriesResp.data.length - 1; i >= 0; i--) {
        this.countries.push(countriesResp.data[i].countryName)
      }
      // Remove France and put it at the top for convenience
      let indexOfFrance = this.countries.indexOf('France')
      this.countries.splice(indexOfFrance, 1)
      // Sort the data alphabetically for convenience
      this.countries.sort()
      this.countries.unshift('France')

      // Put company industries retrieved from API into an array available to Vue.js
      this.companyIndustriesAreLoading = false
      this.companyIndustries = []
      for (let i = companyIndustriesResp.data.length - 1; i >= 0; i--) {
        this.companyIndustries.push(companyIndustriesResp.data[i].industryName)
      }
      this.companyIndustries.sort()

    [...]

    }
    // bind(this) is needed in order to inject this of Vue.js (otherwise
    // this would be the axios instance)
    .bind(this)))
    // In case one of the get request failed, stop everything and tell the user
    .catch(e => {
      alert('Could not load the full input lists in form.')
      this.countriesAreLoading = false
      this.companyIndustriesAreLoading = false
      this.companySizesAreLoading = false
      this.companyTypesAreLoading = false
      this.contactIndustriesAreLoading = false
      this.contactFunctionsAreLoading = false
      this.contactLevelsAreLoading = false
    })
},

Générer un CSV en javascript

Je regrette qu’il n’y ait pas de solution plus simple. Voici comment j’ai dû m’y prendre pour créer un CSV en javascript et le servir à l’utilisateur sous forme de téléchargement :

generateCSV: function () {
      let csvArray = [
        'data:text/csv;charset=utf-8,' +
        'Company Id;' +
        'Company Name;' +
        'Company Domain;' +
        'Company Website;' +
        [...]
        'Contact Update Date'
      ]
      this.resultsRows.forEach(function (row) {
        let csvRow = row['compId'] + ';' +
          row['compName'] + ';' +
          row['compDomain'] + ';' +
          row['compWebsite'] + ';' +
          [...]
          row['contUpdatedOn']
        csvArray.push(csvRow)
      })
      let csvContent = csvArray.join('\r\n')
      let encodedUri = encodeURI(csvContent)
      let link = document.createElement('a')
      link.setAttribute('href', encodedUri)
      link.setAttribute('download', 'companies_and_contacts_extracted.csv')
      document.body.appendChild(link)
      link.click()
    }
}

Récupérer les données envoyées par Axios en Go

Les données POST envoyées par Axios sont nécessairement encodées en JSON. Malheureusement il n’y a actuellement aucun moyen de modifier ce comportement. Go possède une fonction très utile appelée PostFormValue qui se charge de récupérer facilement les données POST encodées en “form data”, mais malheureusement il ne gère pas les données encodées en JSON. J’ai donc dû faire un unmarshal JSON vers une struct afin de récupérer les données POST :

body, err := ioutil.ReadAll(r.Body)
if err != nil {
	err = CustErr(err, "Cannot read request body.\nStopping here.")
	log.Println(err)
	http.Error(w, "Internal server error", http.StatusInternalServerError)
	return
}

// Store JSON data in a userInput struct
var userInput UserInput
err = json.Unmarshal(body, &userInput)
if err != nil {
	err = CustErr(err, "Cannot unmarshall json.\nStopping here.")
	log.Println(err)
	http.Error(w, "Internal server error", http.StatusInternalServerError)
	return
}

Les fonctions “variadique” en Go

L’utilisateur peut entrer un nombre variable de critères qui seront par la suite utilisés dans une seule et même requête SQL. En gros, chaque nouveau critère est une nouvelle clause SQL WHERE. Comme nous ne connaissons pas à l’avance le nombre de paramètres qui seront passés à la fonction database/sql query(), nous devons utiliser la propriété “variadique” de la fonction query() ici. Une fonction variadique est une fonction qui accepte un nombre variable de paramètres. En Python vous utiliseriez *args or *kwargs. Ici on utilise la notation .... Le premier argument de query() est une requête SQL sous forme de texte, et le second argument est un array d’interfaces vides qui contient tous les paramètres :

rows, err := db.Query(sqlStmtStr, sqlArgs...)
if err != nil {
	err = CustErr(err, "SQL query failed.\nStopping here.")
	log.Println(err)
	http.Error(w, "Internal server error", http.StatusInternalServerError)
	return compAndContRows, err
}
defer rows.Close()

Gérer CORS

En deux mots, CORS est une mesure de sécurité qui empêche le frontend de récupérer des informations depuis un backend qui n’est pas situé à la même URL. Voici une explication bien faîte de l’importance de CORS. Afin de vous conformer à ces règles, vous devez gérer CORS comme il se doit du côté de l’API serveur. La propriété la plus importante de CORS à gérer est la propriété Allowed Origins. Il n’est pas si facile de gérer cela en Go parce que cela implique en premier lieu une requête “preflight” (utilisant l’OPTION HTTP) et en second lieu de paramétrer les headers HTTP correctement.

La meilleure solution en Go selon moi est d’utiliser la bibliothèque rs/cors nous permettant de gérer CORS comme suit :

router := mux.NewRouter()

c := cors.New(cors.Options{
	AllowedOrigins: []string{"http://localhost:8080"},
})
handler := c.Handler(router)

Les valeurs NULL en Go

Lorsque vous requêtez la bdd, vous avez de grandes chances d’obtenir des valeurs NULL. Ces valeurs NULL doivent être explicitement gérées en Go, en particulier si vous voulez convertir ces résultats en JSON. Vous avez pour cela 2 solutions :

  • utiliser les pointeurs pour les valeurs pouvant être NULL dans la struct qui va recevoir les valeurs. Cela fonctionne mais les valeurs NULL ne sont pas détectées par le mot-clé 'omitempty' durant le marshalling JSON ce qui fait qu’une chaîne vide sera quand même affichée dans votre JSON
  • utiliser les types NULL de la bibliothèque sql : remplacer string par sql.NullString, int par sql.NullInt64, bool par sql.NullBool, et time par sql.NullTime, mais alors vous obtenez quelque chose comme {"Valid":true,"String":"Smith"}, ce qui n’est pas immédiatement valide en JSON. Il faut donc ajouter quelques étapes avant de faire du marshaling JSON.

J’ai mis en place la seconde option et créé un type et une méthode custom qui implémentent le json.Marshaler. Notez que, en utilisant cette méthode, j’aurais pu transformer NULL en une chaîne vide afin qu’elle ne soit pas incluse dans le JSON final, mais ici je veux justement que les valeurs NULL soient gardées et envoyées au frontend en JSON comme null :

type JsonNullString struct {
	sql.NullString
}

func (v JsonNullString) MarshalJSON() ([]byte, error) {
	if v.Valid {
		return json.Marshal(v.String)
	} else {
		return json.Marshal(nil)
	}
}

type CompAndContRow struct {
	CompId                       string         `json:"compId"`
	CompName                     JsonNullString `json:"compName"`
	CompDomain                   JsonNullString `json:"compDomain"`
	CompWebsite                  JsonNullString `json:"compWebsite"`
	[...]
}

La concaténation de plusieurs lignes en SQL

Le SQL est un langage très vieux mais toujours très puissant aujourd’hui. Par ailleurs, PostgreSQL nous fournit des fonctions très utiles qui nous permettent de faire des tas de choses en SQL au lieu d’appliquer des scripts aux résultats (ce qui n’est pas efficace d’un point de vue mémoire/CPU). Ici j’ai un grand nombre de LEFT JOIN SQL cumulés qui retournent de nombreuses lignes similaires. Le problème est que je veux que certaines de ces lignes soient concaténées entre elles au sein d’une seule ligne. Par exemple, une entreprise peut avoir plusieurs emails et je veux que tous ces emails apparaissent au sein de la même ligne séparés par le symbole ¤. Faire cela en Go impliquerait de parcourir l’array contenant les résultats un grand nombre de fois. Dans le cas de millions de lignes ce serait fort long au point même peut-être de crasher si le serveur ne dispose pas d’assez de mémoire. Heureusement, accomplir cela avec PostgreSQL est très facile en utilisant la fonction string_agg() combinée à GROUP BY et DISTINCT :

SELECT comp.id, string_agg(DISTINCT companyemail.email,'¤')
FROM company AS comp
LEFT JOIN companyemail ON companyemail.company_id = comp.id
WHERE comp.id = $1
GROUP BY comp.id

Conclusion

J’essaye de couvrir ici un large panel de sujets au sein d’un seul et même article : Go, Vue.js, Javascript, SQL, Docker, Nginx… J’espère que vous y avez trouvez des éléments utiles que vous pourrez réutiliser dans votre propre application.

Si vous avez des question à propos de l’appli, n’hésitez pas. Et si vous pensez que certaines parties de mon code auraient pu être mieux optimisées j’adorerais avoir votre avis. Cet article est aussi une façon pour moi de recevoir des feedbacks critiques sur mon propre travail !

Also available in English
Connecter un frontend SPA Vue.js à une API backend

Vue.js est un excellent framework javascript frontend et sa documentation est très claire et va droit au but. Vous pouvez soit choisir d’intégrer Vue à une application préexistante (à la JQuery) ou construire une Single Page Application (SPA) basée sur Webpack façon React.js. Mettons en place une SPA très simple ici qui appelle une API REST distante en utilisant Node.js, Webpack, Vue Loader, Vue Router, et Axios. La mise en place de ce type de projet n’est pas si évidente que cela selon moi, j’essaye donc ici de faire un petit tuto sur la façon de procéder sur Ubuntu. Pour information, voici pourquoi l’on utilise Webpack.

Mettre en place un projet Vue.js

Installer Node.js et npm.

Puis utiliser npm pour installer vue-cli et utiliser vue-cli pour installer le loader pour Webpack appelé vue-loader qui vous permet d’utiliser les components Vue mixant HTML, CSS, et JS au sein d’un même fichier .vue. Cela installe aussi tout le reste de l’écosystème nécessaire à une SPA comme par exemple vue-router. Enfin, installer Axios que nous utiliserons pour la connexion à l’API.

sudo npm install -g vue-cli
vue init webpack vue_project  # Answer various questions here
cd vue_project
npm install
npm install --save axios

Plus d’informations sur la structure du projet ici.

Workflow

Dev

Le dev est grandement facilité par la présence d’un serveur web local permettant le rechargement à chaud (la page web est actualisée à la volée lorsque vous modifiez votre code). Lancez simplement :

npm run dev

et commencez à coder.

Note : personnellement j’ai rencontré un bug qui m’empêchait d’utiliser la rechargement à chaud à cause d’un problème de permission Linux (je suis sur Ubuntu 17.10). J’ai réglé le problème avec la commande suivante :

echo 100000 | sudo tee /proc/sys/fs/inotify/max_user_watches

Déploiement

Une fois que vous avez besoin de déployer votre app en production, lancez :

npm run build

et votre app est maintenant compilée dans le dossier dist. C’est à vous de décider comment vous souhaitez la déployer sur votre serveur. Personnellement je place mon app dans un conteneur Docker avec Nginx et je fais pointer Nginx vers le dossier contenant mon app grâce au bloc suivant :

location / {
    root /my_app;
}

Bien entendu, si vous avez déjà un autre service tournant sur le port 80 du même serveur, il vous faudra réfléchir à la façon dont vous devez organiser vos services et modifier votre config Nginx en conséquence.

Se connecter à l’API backend

Tout se passe à l’intérieur du dossier src à partir de maintenant.

Paramétrer les noms de serveurs dev et prod une fois pour toutes

Mon serveur de développement API tourne à l’adresse http://127.0.0.1 tandis que mon serveur de production API tourne sur http://api.example.com donc afin d’éviter un changement de ma config à chaque déploiement j’ai créé le fichier http-constants.js suivant à la racine du dossier src :

import axios from 'axios'

let baseURL

if (!process.env.NODE_ENV || process.env.NODE_ENV === 'development') {
  baseURL = 'http://127.0.0.1/'
} else {
  baseURL = 'http://api.example.com'
}

export const HTTP = axios.create(
  {
    baseURL: baseURL
  })

et ensuite dans chaque fichier vue nécessitant Axios, importez HTTP au lieu d’Axios :

import {HTTP} from '../http-constants'

HTTP.get(...).then(...).catch(...)

Note : cette fonctionnalité de proxying devrait en théorie pouvoir se faire plus facilement au sein du fichier de config config/index.js en utilisant la directive proxyTable mais cela n’a pas fonctionné pour moi.

Créer l’app

Créons une app appelée ShowGreetings qui récupère un message Hello World depuis l’API. Le point d’accès de l’API est /greetings et retourne le message JSON suivant lorsque l’on envoie une requête GET :

{message: "Hello World"}

Créez en premier le nouveau component Vue appelé ShowGreetings.vue dans src/components :

<template>
  <div>
    <button @click="getGreetings">Get Greetings</button>
    <h1 v-if="greetings"></h1>
    <p class="error" v-if="errorMessage"></p>
  </div>
</template>

<script>
import {HTTP} from '../http-constants'
export default {
  name: 'ShowGreetings',
  data () {
    return {
      greetings: '',
      errors: ''
    }
  },
  methods: {
    getGreetings: function () {
      HTTP.get('/greetings')
        .then(response => {
          this.greetings = response.data.message
        })
        .catch(e => {
          this.errors = e
        })
    }
  }
}
</script>

<style scoped>
.error {
  color: red;
}
</style>

Ce component essaie de se connecter à l’API backend lorsque vous cliquez sur un bouton et affiche le message en retour. Si une erreur est retournée, on affiche une erreur.

Maintenant mettez à jour le routeur afin de prendre en compte ce nouveau component. Voici notre nouvel index.js dans src/router:

import Vue from 'vue'
import Router from 'vue-router'
import HelloWorld from '@/components/HelloWorld'
import ShowGreetings from '@/components/ShowGreetings'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/',
      name: 'HelloWorld',
      component: HelloWorld
    },
    {
      path: '/show-greetings',
      name: 'ShowGreetings',
      component: ShowGreetings
    }
  ]
})

On a créé une route appelée “ShowGreetings” de façon à pouvoir appeler la route par son nom plutôt que par son chemin (beaucoup plus flexible).

Enfin, éditez le component App.vue dans src afin qu’un lien vers notre nouveau component apparaisse sur la page d’accueil :

<template>
  <div id="app">
    <img src="./assets/logo.png">
    <router-link :to="{ name: 'ShowGreetings'}">Show Greetings</router-link>
    <router-view/>
  </div>
</template>
<script>
export default {
  name: 'App'
}
</script>
<style>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

Ici on s’est contenté d’ajouter un nouveau tag router-link.

Conclusion

Une fois que l’on comprend comment interagissent les différents couches en action, cela devient très facile de construire une SPA avec Vue.js qui passe à l’échelle facilement d’un petit projet à un gros projet en production.

L’intégralité de ce petit projet est disponible sur mon GitHub si besoin.

Also available in English