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
Dockeriser un serveur physique Linux entier

Docker est habituellement utilisé dans le cadre d’architectures orientées micro-services car les conteneurs sont légers (comparés aux VMs tout du moins), faciles à configurer, faciles à faire communiquer, et peuvent être déployés très rapidement. Cependant Docker peut parfaitement être utilisé si vous cherchez à Dockeriser un serveur physique ou un VPS entier au sein d’un seul et unique conteneur. Je vais tenter de vous montrer comment et dans quel but.

Contexte

J’ai récemment eu à travailler sur un projet développé par plusieurs des personnes ayant quitté l’entreprise avant mon arrivée. Je n’ai jamais eu l’opportunité de les rencontrer et les contacter était difficile. Malheureusement une grande partie du projet n’était pas documentée. Et en plus de tout cela, seulement certaines briques du projet étaient gérées via un outil de contrôle de version (Git ici). Bien entendu il n’existait aucun serveur de dev ou de staging : tout était directement déployé sur le serveur de production… Est-ce que vous commencez à sentir les soucis arriver ?

Il s’agissait d’un projet de web scraping accomplissant pas mal de tâches complexes. La stack technique du serveur de prod était en gros la suivante :

  • Ubuntu 16.04
  • Nginx
  • Postgresql 9.3
  • Python/Django
  • Python virtualenvs
  • Gunicorn
  • Celery
  • RabbitMQ
  • Scrapy/Scrapyd

Première tentative : échec

Je me suis arraché les cheveux à essayer de faire la rétro-ingénierie de ce serveur de production. Mon but ultime était d’isoler chaque application, la Dockeriser, et faire communiquer entre eux les différents conteneurs.

Mais ça a été un échec !

J’ai réussi à Dockeriser Nginx, l’application Django, et le système de gestion des tâches asynchrones (Celery), mais impossible de faire fonctionner Scrapy et Scrapyd correctement. Je pense que cela venait principalement du fait que des développements spécifiques avaient été apportés directement aux fichiers sources des bibliothèques Scrapy et Scrapyd par les précédents développeurs (c’est à dire au sein du répertoire python/site-package lui-même !) et le tout bien entendu sans aucune doc. Par ailleurs certaines bibliothèques Python utilisées à l’époque ne sont plus disponibles aujourd’hui, ou bien sont disponibles mais plus dans la bonne version (vous pouvez oublier pip freeze et pip install -r requirements.txt ici).

Deuxième tentative : échec

J’ai en fin de compte abandonné l’idée d’une architecture de type micro-service trop compliquée à monter ici. Mais il n’empêche que j’avais un besoin urgent de sécuriser le serveur de production avant qu’il ne rencontre des problèmes. La base de donnée était sauvegardée à intervalles réguliers mais rien d’autre sur le serveur n’était sauvegardé.

J’ai pensé à créer une image système de tout le serveur en utilisant un outil comme CloneZilla ou une simple commande rsync comme celle-là. Mais un tel backup ne m’aurait pas permis de continuer à faire évoluer facilement le projet en y intégrant de nouvelles fonctionnalités.

C’est pourquoi j’ai aussi étudié la possibilité de convertir le serveur physique en machine virtuelle VMWare en utilisant leur outil VMware vCenter Converter mais le lien de téléchargement était cassé et si peu de personnes mentionnaient cet outil que j’ai pris peur et abandonné.

Enfin, j’ai essayé cette solution de Dockerisation basée sur Blueprint mais sans réussir à vraiment la faire complètement fonctionner, et Blueprint semblait par ailleurs un projet à l’arrêt.

Troisième tentative : réussie

En réalité la solution était relativement simple : j’ai décidé de Dockeriser l’intégralité du serveur de prod par moi-même - à l’exception des données Postgresql - de façon à avoir une sauvegarde du serveur sous forme d’image Docker et à pouvoir ajouter de nouvelles fonctionnalités à mon conteneur sous forme de commits successifs vers de nouvelles images. Le tout sans avoir peur de casser le serveur pour toujours. Voici comment je m’y suis pris :

1. Installez et paramétrez Docker sur le serveur

  1. Installez Docker en suivant ce guide.
  2. Loguez-vous à votre compte Docker Hub: docker login
  3. Créez un réseau Docker (si besoin): docker network create --subnet=172.20.0.0/16 my_network

2. Créez une image Docker de votre serveur

Rendez-vous à la racine de votre serveur :

cd /

Créez le fichier Dockerfile suivant basé sur Ubuntu 16.04 LTS (sans la partie dédiée à Nginx et Rabbitmq bien sûr) :

FROM ubuntu:xenial

# Copy the whole system except what is specified in .dockerignore
COPY / /

# Reinstall nginx and rabbitmq because of permissions issues in Docker
RUN apt remove -y nginx
RUN apt install -y nginx
RUN apt remove -y rabbitmq-server
RUN apt install -y rabbitmq-server

# Launch all services
COPY startup.sh /
RUN chmod 777 /startup.sh
CMD ["bash","/startup.sh"]

Créez le fichier .dockerignore qui mentionnera tous les fichiers ou répertoires que vous souhaitez exclure de la commande COPY ci-dessus. C’est là qu’il vous faut utiliser votre intuition. Excluez autant de fichiers que possible de façon à ce que l’image Docker ne soit pas trop volumineuse, mais n’excluez pas les fichiers vitaux pour votre application. Voici mon fichier mais bien entendu à vous de l’adapter à votre propre serveur :

# Remove folders mentioned here:
# https://wiki.archlinux.org/index.php/Rsync#As_a_backup_utility
/dev 
/proc
/sys
/tmp
/run
/mnt
/media
/lost+found

# Remove database's data
/var/lib/postgresql

# Remove useless heavy files like /var/lib/scrapyd/reports.old
**/*.old
**/*.log
**/*.bak

# Remove docker
/var/lib/lxcfs
/var/lib/docker
/etc/docker
/root/.docker
/etc/init/docker.conf

# Remove the current program
/.dockerignore
/Dockerfile

Créez un script startup.sh afin de lancer tous les services au démarrage du conteneur et de mettre en place les redirections vers la base de donnée. Ici mon script va être radicalement différent du vôtre bien entendu :

# Redirect all traffic from 127.0.0.1:5432 to 172.20.0.1:5432
# so any connection to Postgresql keeps working without any other modification.
# Requires the --privileged flag when creating container:
sysctl -w net.ipv4.conf.all.route_localnet=1
iptables -t nat -A OUTPUT -p tcp -s 127.0.0.1 --dport 5432 -j DNAT --to-destination 172.20.0.1:5432
iptables -t nat -A POSTROUTING -j MASQUERADE

# Start RabbitMQ.
rabbitmq-server -detached

# Start Nginx.
service nginx start

# Start Scrapyd
/root/.virtualenvs/my_project_2/bin/python /root/.virtualenvs/my_project_2/bin/scrapyd >> /var/log/scrapyd/scrapyd.log 2>&1 &

# Use Python virtualenvwrapper
source /root/.profile

# Start virtualenv and start Django/Gunicorn
workon my_project_1
cd /home/my_project_1
export DJANGO_SETTINGS_MODULE='my_project_1.settings.prod'
gunicorn -c my_project_1/gunicorn.py -p /tmp/gunicorn.pid my_project_1.wsgi &

# Start Celery
export C_FORCE_ROOT=True
celery -A my_project_1 beat &
celery -A my_project_1 worker -l info -Q queue1,queue2 -P gevent -c 1000 &

# Little hack to keep the container running in foreground
tail -f /dev/null

Comme vous pouvez le voir, j’utilise des redirections iptables de façon à ce que toutes les connections vers la base de données Postgresql (port 5432) continuent de fonctionner sans modification supplémentaire des fichiers de configuration. En effet ma base de données était initialement située sur le localhost, mais elle est désormais située sur l’hôte Docker dont l’ip est 172.20.0.1 (j’ai tout copié vers le conteneur Docker à l’exception de la base de données). Les redirections au niveau du kernel sont assez pratiques lorsque vous ne savez pas où sont situés tous vos fichiers de configuration, et elles sont indispensables si vous n’êtes pas du tout en mesure de modifier ces fichiers de config (comme dans le cas d’une application compilée dont vous n’avez pas le code source).

Maintenant lancez la création de l’image et patientez… Dans mon cas, l’image pesait environ 3Go et a été créée en 5 minutes. Assurez-vous d’avoir suffisamment d’espace disque à disposition sur votre serveur avant de lancer la commande.

docker build -t your_repo/your_project:your_tag .

Si vous n’avez obtenu aucune erreur ici, alors bravo vous avez fait le plus dur ! A présent testez votre image et voyez si tout fonctionne normalement. Si ce n’est pas le cas il vous faut certainement adapter un des 3 fichiers ci-dessus.

3. Sauvegardez votre nouvelle image Docker

Uploadez simplement votre image sur le Hub Docker :

docker build -t your_repo/your_project:your_tag .

4. Intégrez de nouvelles fonctionnalités à votre image serveur

Désormais, si vous avez besoin de travailler sur cette image serveur, vous pouvez procéder comme suit :

  1. créez un conteneur basé sur cette image avec docker run (n’oubliez pas de spécifier le nom du réseau, l’adresse ip, le forwarding de ports, et d’ajouter l’option --privileged afin que la commande sysctl présente dans startup.sh fonctionne)
  2. travaillez dans votre conteneur
  3. faites un commit des changements effectués vers une nouvelle image Docker avec docker commit
  4. uploadez votre nouvelle image sur le Docker Hub avec docker push et déployez-la en staging ou en production

Conclusion

Cette solution m’a vraiment sauvé la mise et il me semble qu’elle prouve bien que Docker est un excellent outil, pas uniquement pour les architectures micro-service mais aussi dans le cadre d’une conteneurisation d’un serveur entier. Dockeriser un serveur entier peut être une excellente solution si vous avez besoin de sécuriser un serveur de prod existant tel que le mien ici sans documentation, sans dépôt GitHub, et sans les développeurs historiques.

La première image à créer peut être assez lourde, mais par la suite chaque commit n’est pas si gros grâce à l’architecture en couches de Docker.

S’agit-il d’un hack ? Peut-être bien mais qui fonctionne à la perfection alors !

J’aimerais beaucoup entendre l’avis d’autre devs là-dessus.

Also available in English
CTOs, développeurs : comment choisir une bonne API externe ?

De nos jours trouver une API externe permettant d’améliorer la qualité de son service est devenu très facile. De plus en plus d’entreprises mettent à disposition des APIs. Problème : de nombreux développeurs/CTOs se lancent dans l’intégration alors que cette dernière ne devrait être en fait que l’ultime étape ! Avant cela il vous faut déterminer si la qualité de l’API répond à un minimum d’exigences. Voici comment je m’y prends personnellement. J’espère que cela aidera d’autres CTOs et développeurs.

Qualité des données

De nombreuses APIs exposent des données vous permettant d’enrichir votre propre système (ce n’est pas le cas de toutes les APIs bien entendu, Stripe n’est pas une API d’enrichissement par exemple). Il est essentiel que vous vous assuriez de la qualité de ces données. Cela va vous prendre pas mal de temps et je sais déjà que vous n’aimez pas les tests ! Moi non plus, mais néanmoins vous ne pouvez pas vous soustraire à la création d’un scénario de test rigoureux ici. Si vous réalisez que la qualité des données fournies n’était pas suffisamment bonne seulement deux semaines après avoir fini votre intégration, croyez-moi vous le regretterez…

Documentation

Je suis récemment tombé sur une API qui exposait des données de grande qualité (bien meilleure que ce proposait la concurrence selon moi), mais sa documentation était… un cauchemar ! En réalité il n’y avait pour ainsi dire pas de documentation. Par ailleurs l’API ne respectait pas toutes les conventions REST. Comment pouvez-vous réussir à intégrer une API externe si les codes erreurs ne sont pas proprement documentés ? Hé bien l’unique solution qu’il vous reste dans ce cas est de tester encore et encore l’API afin de comprendre son fonctionnement. Le rétro-engineering peut être amusant mais demande aussi beaucoup de temps. Rappelez-vous que dans le cas d’une API vous n’avez pas de dépôt GitHub à explorer puisque le code source n’est pas disponible… Une mauvaise doc est systématiquement synonyme de perte de temps pour les devs, et sûrement de mauvaises surprises à moyen terme.

Bibliothèques

Est-il possible d’intégrer l’API via une bibliothèque disponible dans votre langage favori ? En tant que développeur Python et Go je suis toujours enchanté lorsque je tombe sur une API qui offre une lib Python (je sais qu’en ce qui concerne Go je peux oublier pour l’instant). Cela peut vous faire gagner pas mal de temps, mais avant tout assurez-vous que la bibliothèque est mûre et couvre toutes les fonctionnalités de l’API (pas toujours le cas).

Notoriété de l’éditeur

La notoriété peut vous aider à éclairer votre choix et éviter les mauvaises surprises avec votre API à l’avenir. Par mauvaises surprises j’entends interruption de service, régression de certaines fonctionnalités, ou même arrêt définitif du service… Vous pouvez en partie éviter ces écueils en vous posant les questions suivantes :

  • est-ce que cette API est connue sur le net (en général si vous trouvez peu d’information, fuyez) ? Trouvez-vous de nombreux articles/tutoriels parlant de l’API ? Ces articles sont-ils élogieux ?
  • des entreprises réputées l’utilisent-elles ?
  • si l’entreprise a développé des bibliothèques dédiées, sont-elles bien notées sur GitHub ? Les problèmes remontés sur GitHub sont-ils traités régulièrement ?
  • y a-t-il eu des mises à jour récentes de l’API ou la dernière mis à jour a-t-elle eu lieu il y a longtemps ?

Support technique

Assurez-vous que quelqu’un réponde à vos questions rapidement par email lorsque vous rencontrez un problème et que la réponse est pertinente. Si vous êtes basé en Europe et que l’API est éditée par une entreprise américaine, assurez-vous que le décalage horaire ne pose pas de problème.

Respect des conventions

A mon humble avis, toute API sérieuse aujourd’hui se doit d’être une API RESTful. Si l’API que vous affectionnez ne respecte pas les conventions REST, alors soyez méfiant. Toutefois gardez à l’esprit quel le standard REST n’est pas clair sur tous les sujets et que chaque API peut avoir sa propre déclinaison (codes HTTP, encodage des requêtes POST, …). Il n’empêche que vous devez lire la documentation attentivement et vous assurer que vous ne constatez pas de pratique trop originale. L’originalité vous ralentira…

Prix

Bien entendu le prix est de première importance. Mais attention la tarification d’une API n’est pas toujours facile à comprendre. Allez-vous être facturé au mois pour un nombre illimité de requêtes ? Facturé à la requête ? Dans ce second cas allez-vous être facturé deux fois pour 2 requêtes identiques (dans le cas d’une API d’enrichissement) ou la seconde requête sera-t-elle gratuite ? Serez-vous facturé pour une requête ne retournant aucun résultat (HTTP 404) ? Assurez-vous de bien comprendre toutes ces implications.

Qualité de service (QoS)

La qualité de service est très importante. Votre but est de travailler avec une API aussi rapide que possible et avec le moins d’interruptions de service possible. Malheureusement il ne s’agit pas de performances faciles à tester. En effet la qualité de service varie avec le temps, et de nombreuses APIs offrent deux niveaux de QoS différents selon que vous utilisez la version gratuite ou payante… Parfois vous pouvez même choisir entre différents abonnements avec différents temps de réponse.

Support des requêtes parallèles

Selon la façon dont vous prévoyez d’intégrer l’API, vous pouvez avoir envie d’accélérer les choses en consultant l’API via plusieurs requêtes simultanées au lieu du comportement séquentiel classique. Personnellement j’utilise Golang pour cela. Si c’est le cas soyez vigilant : de nombreuses API ne supportent pas les requêtes parallèles et lorsque c’est le cas elles imposent nécessairement une limite. Dans ce cas assurez-vous d’avoir demandé à l’éditeur quelle était cette limite (pas toujours mentionné dans la doc) et adaptez votre script en fonction.

Cet article sera un bon mémo pour moi, j’espère que pour vous aussi !

Also available in English | También existe en Español
Utilisatation d'une API REST : Go vs Python

Les API sont partout de nos jours. Imaginez : vous souhaitez recueillir des informations sur vos prospects à partir de leurs emails. Hé bien il y a une API pour ceci. Vous avez besoin de géocoder une adresse postale mal formatée ? Il existe aussi une API pour cela. Enfin, vous cherchez à effectuer un paiement de façon automatisée ? De nombreuses API font le job bien entendu. En tant que développeur, je suis régulièrement amené à intégrer des API externes à mon système, en Python ou en Go. Les deux méthodes sont assez différentes. Comparons-les sur un cas un peu “borderline” : l’envoi de données JSON encodées dans le body d’une requête POST.

Un exemple de la vie réelle

Récemment, j’ai utilisé l’API NameAPI.org dans le but de découper un nom entier en prénom et nom de famille, et déterminer le genre de la personne.

Leur API attend que vous lui envoyiez les données en JSON encodées dans le body d’une requête POST. De plus, le Content-Type de la requête doit être application/json et non pas multipart/form-data. Il s’agit d’un cas un peu piégeux puisqu’en général les données POST sont envoyées à travers le header de la requête, et si l’on décide de les envoyer dans le body de la requête (dans le cas de données JSON complexes par exemple) le Content-Type standard est multipart/form-data.

Voici le JSON que l’on veut envoyer :

{
  "inputPerson" : {
    "type" : "NaturalInputPerson",
    "personName" : {
      "nameFields" : [ {
        "string" : "Petra",
        "fieldType" : "GIVENNAME"
      }, {
        "string" : "Meyer",
        "fieldType" : "SURNAME"
      } ]
    },
    "gender" : "UNKNOWN"
  }
}

On peut le faire facilement via cURL :

curl -H "Content-Type: application/json" \
-X POST \
-d '{"inputPerson":{"type":"NaturalInputPerson","personName":{"nameFields":[{"string":"Petra Meyer","fieldType":"FULLNAME"}]}}}' \
http://rc50-api.nameapi.org/rest/v5.0/parser/personnameparser?apiKey=<API-KEY>

Et voici la réponse JSON de NameAPI.org :

{
"matches" : [ {
  "parsedPerson" : {
    "personType" : "NATURAL",
    "personRole" : "PRIMARY",
    "mailingPersonRoles" : [ "ADDRESSEE" ],
    "gender" : {
      "gender" : "MALE",
      "confidence" : 0.9111111111111111
    },
    "addressingGivenName" : "Petra",
    "addressingSurname" : "Meyer",
    "outputPersonName" : {
      "terms" : [ {
        "string" : "Petra",
        "termType" : "GIVENNAME"
      },{
        "string" : "Meyer",
        "termType" : "SURNAME"
      } ]
    }
  },
  "parserDisputes" : [ ],
  "likeliness" : 0.926699401733102,
  "confidence" : 0.7536487758945387
}

Maintenant voyons comment faire cela en Go et en Python !

Réalisation en Go

Code

/*
Fetch the NameAPI.org REST API and turn JSON response into a Go struct.

Sent data have to be JSON data encoded into request body.
Send request headers must be set to 'application/json'.
*/

package main

import (
    "encoding/json"
    "io/ioutil"
    "log"
    "net/http"
    "strings"
)

// url of the NameAPI.org endpoint:
const (
    url = "http://rc50-api.nameapi.org/rest/v5.0/parser/personnameparser?" +
        "apiKey=<API-KEY>"
)

func main() {

    // JSON string to be sent to NameAPI.org:
    jsonString := `{
        "inputPerson": {
            "type": "NaturalInputPerson",
            "personName": {
                "nameFields": [
                    {
                        "string": "Petra",
                        "fieldType": "GIVENNAME"
                    }, {
                        "string": "Meyer",
                        "fieldType": "SURNAME"
                    }
                ]
            },
            "gender": "UNKNOWN"
        }
    }`
    // Convert JSON string to NewReader (expected by NewRequest)
    jsonBody := strings.NewReader(jsonString)

    // Need to create a client in order to modify headers
    // and set content-type to 'application/json':
    client := &http.Client{}
    req, err := http.NewRequest("POST", url, jsonBody)
    if err != nil {
        log.Println(err)
    }
    req.Header.Add("Content-Type", "application/json")
    resp, err := client.Do(req)

    // Proceed only if no error:
    switch {
    default:
        // Create a struct dedicated to receiving the fetched
        // JSON content:
        type Level5 struct {
            String   string `json:"string"`
            TermType string `json:"termType"`
        }
        type Level41 struct {
            Gender     string  `json:"gender"`
            Confidence float64 `json:"confidence"`
        }
        type Level42 struct {
            Terms []Level5 `json:"terms"`
        }
        type Level3 struct {
            Gender           Level41 `json:"gender"`
            OutputPersonName Level42 `json:"outputPersonName"`
        }
        type Level2 struct {
            ParsedPerson Level3 `json:"parsedPerson"`
        }
        type RespContent struct {
            Matches []Level2 `json:"matches"`
        }

        // Decode fetched JSON and put it into respContent:
        respContentBytes, err := ioutil.ReadAll(resp.Body)
        if err != nil {
            log.Println(err)
        }
        var respContent RespContent
        err = json.Unmarshal(respContentBytes, &respContent)
        if err != nil {
            log.Println(err)
        }
        log.Println(respContent)
    case err != nil:
        log.Println("Network error:", err)
    case resp.StatusCode != 200:
        log.Println("Bad HTTP status code:", err)
    }

}

Explications

Comme vous pouvez le voir, nous faisons face à 2 problèmes désagréables :

  • la bibliothèque http n’est pas si simple d’utilisation lorsqu’il s’agit d’encoder des données JSON au sein du body et de changer le header Content-Type. La doc de Go n’est pas très claire à ce sujet. Ainsi nous ne pouvons pas invoquer http.Post qui est plus simple d’utilisation et nous sommes contraints de créer un http.Client puis par la suite d’utiliser la fonction NewRequest() que l’on déclenche avec client.Do(req). C’est l’unique façon de créer un Content-Type personnalisé dans notre cas : req.Header.Add("Content-Type", "application/json")
  • décoder le JSON reçu de NameAPI en Go est assez long et fastidieux (procédé appelé Unmarshalling en Go). Cela vient du fait que, Go étant un langage statique, nous avons besoin de savoir à l’avance à quoi ressemblera le JSON final. Par conséquent nous avons besoin de créer une struct dédiée dont la structure sera la même que celle du JSON et qui recevra les données. En cas de JSON imbriqué tel que celui retourné par NameAPI.org, qui mélange des array et des maps, cela devient très délicat. Heureusement, notre struct n’a pas besoin de mapper tout le JSON mais seulement les champs qui nous intéressent. Une autre approche, si nous n’avons aucune idée de la structure de notre JSON, serait de deviner les types de données. Voici un bon article sur le sujet.

L’input jsonString est déjà une string ici. Mais pour une comparaison encore plus rigoureuse avec Python, cela aurait dû être une struct que l’on aurait par la suite convertie en string. Toutefois cela aurait rallongé l’article inutilement.

Réalisation en Python

Code

"""
Fetch the NameAPI.org REST API and turn JSON response into Python dict.

Sent data have to be JSON data encoded into request body.
Send request headers must be set to 'application/json'.
"""

import requests

# url of the NameAPI.org endpoint:
url = (
    "http://rc50-api.nameapi.org/rest/v5.0/parser/personnameparser?"
    "apiKey=<API-KEY>"
)

# Dict of data to be sent to NameAPI.org:
payload = {
    "inputPerson": {
        "type": "NaturalInputPerson",
        "personName": {
            "nameFields": [
                {
                    "string": "Petra",
                    "fieldType": "GIVENNAME"
                }, {
                    "string": "Meyer",
                    "fieldType": "SURNAME"
                }
            ]
        },
        "gender": "UNKNOWN"
    }
}

# Proceed, only if no error:
try:
    # Send request to NameAPI.org by doing the following:
    # - make a POST HTTP request
    # - encode the Python payload dict to JSON
    # - pass the JSON to request body
    # - set header's 'Content-Type' to 'application/json' instead of
    #   default 'multipart/form-data'
    resp = requests.post(url, json=payload)
    resp.raise_for_status()
    # Decode JSON response into a Python dict:
    resp_dict = resp.json()
    print(resp_dict)
except requests.exceptions.HTTPError as e:
    print("Bad HTTP status code:", e)
except requests.exceptions.RequestException as e:
    print("Network error:", e)

Explications

La bibliothèque Request est une bibliothèque incroyablement pratique qui nous épargne beaucoup d’efforts comparé à Go ! En une ligne, resp = requests.post(url, json=payload), presque tout est fait :

  • construire une requête POST HTTP
  • convertir le dictionnaire Python en JSON
  • passer le JSON au body de la requête
  • passer le 'Content-Type' du header de 'multipart/form-data' à 'application/json' grâce au kwarg json
  • envoyer la requête

Décoder le JSON retourné par NameAPI se fait aussi en une ligne : resp_dict = resp.json(). Nul besoin de créer une structure de données compliquée à l’avance ici !

Conclusion

Python est clairement le gagnant. La simplicité de Python combinée à sa quantité de bibliothèques disponibles nous épargne pas mal de temps de développement !

Nous n’abordons pas la question de la performance ici. Si vous recherchez une intégration d’API hautement performante utilisant la concurrence, Go pourrait être un excellent choix. Mais simplicité et performance vont rarement de paire…

N’hésitez pas à commenter, je serais heureux d’avoir votre avis sur ce comparatif.

Also available in English | También existe en Español