Je développe des web scrapers en Python depuis plusieurs années. La simplicité de Python permet de réaliser des prototypes rapides et ses nombreuses bibliothèques sont très utiles au scraping et au parsing des résultats (Requests, Beautiful Soup, Scrapy, …). Toutefois lorsque l’on commence à s’intéresser à la performance de notre scraper, Python montre certaines limites et Go entre dans la partie !

Pourquoi Go ?

Lorsque l’on essaie d’accélérer la récupération d’informations depuis le web (pour du scraping HTML comme pour du fetching d’API), deux pistes d’optimisation principales s’offrent à nous :

  • accélérer la récupération de la ressource web (ex : télécharger la page http://example.com/hello.html)
  • accélérer le parsing des informations récupérées (ex : récupérer toutes les url présentes sur hello.html)

On peut améliorer la performance du parsing en retravaillant son code, en utilisant un parser plus performant comme lxml, ou en allouant plus de ressources machine à notre scraper. Mais malgré tout cela, on se rend compte que l’optimisation du parsing est souvent négligeable et que le goulot d’étranglement, comme souvent, reste l’accès réseau (c’est à dire le téléchargement de la page web).

La solution est donc de paralléliser le téléchargement des différentes pages web. Et pour cela Go est tout indiqué !

La programmation concurrente est un domaine rapidement compliqué et Go a la capacité de rendre cela plutôt facile. Go est un langage moderne qui a été pensé pour la concurrence dès le début. Au contraire Python est un langages plus ancien qui, malgré de nombreux efforts récents dans ce domaine, reste plus complexe lorsqu’il s’agit d’écrire un scraper concurrent.

Il y a d’autres avantages à utiliser Go, mais ce sera l’objet d’un autre article !

Installez Go

J’ai déjà réalisé un petit tuto sur l’installation de Go sur Ubuntu.

Si vous souhaitez installer l’environnement sur une autre plateforme, référez-vous à la doc officielle.

Un scraper concurrent simple

Notre scraper se contente d’ouvrir une liste de pages web que nous lui donnons en amont et de vérifier qu’il obtient bien un code HTTP 200 (signe que le serveur a retourné la page HTML sans erreur). Pas de parsing HTML ici, le but étant de se focaliser sur la performance liée aux accès réseau. A vous d’écrire la suite de votre scraper !

Code final


/*
Open a series of urls.

Check status code for each url and store urls I could not
open in a dedicated array.
Fetch urls concurrently using goroutines.
*/

package main

import (
    "fmt"
    "net/http"
)

// -------------------------------------

// Custom user agent.
const (
    userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) " +
        "AppleWebKit/537.36 (KHTML, like Gecko) " +
        "Chrome/53.0.2785.143 " +
        "Safari/537.36"
)

// -------------------------------------

// fetchUrl opens a url with GET method and sets a custom user agent.
// If url cannot be opened, then log it to a dedicated channel.
func fetchUrl(url string, chFailedUrls chan string, chIsFinished chan bool) {

    // Open url.
    // Need to use http.Client in order to set a custom user agent:
    client := &http.Client{}
    req, _ := http.NewRequest("GET", url, nil)
    req.Header.Set("User-Agent", userAgent)
    resp, err := client.Do(req)

    // Inform the channel chIsFinished that url fetching is done (no
    // matter whether successful or not). Defer triggers only once
    // we leave fetchUrl():
    defer func() {
        chIsFinished <- true
    }()

    // If url could not be opened, we inform the channel chFailedUrls:
    if err != nil || resp.StatusCode != 200 {
        chFailedUrls <- url
        return
    }

}

func main() {

    // Create a random urls list just as an example:
    urlsList := [10]string{
        "http://example1.com",
        "http://example2.com",
        "http://example3.com",
        "http://example4.com",
        "http://example5.com",
        "http://example10.com",
        "http://example20.com",
        "http://example30.com",
        "http://example40.com",
        "http://example50.com",
    }

    // Create 2 channels, 1 to track urls we could not open
    // and 1 to inform url fetching is done:
    chFailedUrls := make(chan string)
    chIsFinished := make(chan bool)

    // Open all urls concurrently using the 'go' keyword:
    for _, url := range urlsList {
        go fetchUrl(url, chFailedUrls, chIsFinished)
    }

    // Receive messages from every concurrent goroutine. If
    // an url fails, we log it to failedUrls array:
    failedUrls := make([]string, 0)
    for i := 0; i < len(urlsList); {
        select {
        case url := <-chFailedUrls:
            failedUrls = append(failedUrls, url)
        case <-chIsFinished:
            i++
        }
    }

    // Print all urls we could not open:
    fmt.Println("Could not fetch these urls: ", failedUrls)

}


Explications

Ce code est un peu plus long que ce que nous aurions pu obtenir avec un langage comme Python, mais comme vous le voyez cela reste tout à fait raisonnable. Ici nous avons affaire à un langage statique et bien entendu déclarer ses variables prend un peu de place. Mais mesurez le temps d’exécution de ce programme et vous verrez que la récompense est au rendez-vous !

Nous avons pris 10 urls au hasard pour l’exemple.

Ici les mots-clés magiques nous permettant de faire de la concurrence sont go, chan et select:

  • go permet de créer une nouvelle goroutine, c’est à dire que fetchUrl sera à chaque fois exécutée dans une nouvelle goroutine concurrente.
  • chan est le type représentant un channel. C’est par ces channels que l’on va communiquer entre goroutines (main étant en elle-même aussi une goroutine).
  • select ... case est un switch ... case dédié à la réception des messages envoyés via les channels. On ne passe à la suite du programme que lorsque toutes les goroutines en cours d’exécution ont envoyé un message (ici soit pour dire que le fetching de l’url en cours est fini, soit pour dire que le fetching a échoué).

On aurait pu ne créer aucun channel pour ce scraper, c’est à dire se contenter de créer des goroutines et ne pas attendre d’informations en retour de leur part (si par exemple chaque goroutine finissait en stockant le résultat en base de données). Dans ce cas là il est tout à fait possible que notre goroutine principale main se termine alors que d’autres goroutines n’auront pas encore fini de s’exécuter (ce qui n’est pas forcément un problème puisque Go laisse s’exécuter toutes les goroutines, y compris si le main s’est arrêté). Mais en pratique dans la vie réelle il est presque toujours nécessaire d’utiliser les channels afin de faire dialoguer nos goroutines.

Pensez à limiter la vitesse

Ici la vitesse maximum est recherchée, notamment parce que nous scrapons des urls toutes différentes. Cependant si vous scrapez plusieurs fois la même url (dans le cas du fetching d’une API externe par exemple), vous serez certainement contraint de ne pas dépasser un certain nombre de requêtes concurrentes par secondes. Pour cela la mise en place d’un compteur est nécessaire (objet peut-être d’un prochain article !).

Bon scraping !

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

Rate Limiting d'API avec Traefik, Docker, Go, et la mise en cache

Limiter l'utilisation de l'API en fonction d'une règle avancée de limitation du débit n'est pas si facile. Pour y parvenir derrière l'API NLP Cloud, nous utilisons une combinaison de Docker, Traefik (comme proxy inverse) et la mise en cache locale dans un script Go. Si cela est fait correctement, vous pouvez améliorer considérablement les performances de votre limitation de débit et étrangler correctement les demandes d'API sans sacrifier la vitesse des demandes. Continuer de lire