Téléchargeur Asynchrone
Un petit peu de contexte avant de commencer :
Mon site Layback et un autre logiciel (secret pour l'instant) que je suis en train de faire ont besoin de télécharger les flux RSS des sites auxquels l'utilisateur est abonné et en fonction des mises à jour de ces flux doivent ensuite télécharger les nouvelles pages pour les analyser, en extraire le contenu etc. Pour cela, le serveur qui héberge ce service à donc besoin d'un programme qui télécharge le plus rapidement possible les contenus pointés par une série d'URLs. C'est de ce module de téléchargement dont il va être question ici.
Un des sites, faisant partie des sources des logiciels précédents, ayant banni mon serveur des IP autorisées à télécharger des pages, j'ai dû revoir l'outil principal qui rendait possible le processus : le programme de téléchargement asynchrone de pages. Effectivement, à télécharger trop rapidement les ressources, ils m'ont identifié comme un abuseur et pour ne plus l'être je dois donc être capable d'espacer les requêtes à destination de ce site (et d'autres qui imposeraient les mêmes contraintes).
Il y a une dizaine d'années, j'avais réalisé des outils programmés en PHP pour télécharger des pages à la demande (un équivalent de wget ou cURL) mais le PHP étant essentiellement mono-tâche, les limites étaient patentes :
- un seul téléchargement à la fois
- une gestion intermédiaire lourde basée sur libcurl : beaucoup de traitement d'erreurs, énormément de cas particuliers etc
- un code source long et pénible à modifier
Quelques années plus tard, étant un amateur de Javascript au sein des navigateurs, j'ai donc refait la chose en Node.js ce qui m'a permis de constater que le mode asynchrone était supérieur pour tout ce qui touchait aux IO réseau (on ne rigole pas) :
- la durée du téléchargement ne dépend (quasiment) plus du nombre d'adresses dont on veut télécharger les pages
- il y a une séparation bien plus nette entre le lancement du processus et sa fin (vive les closures !)
- le code a nettement diminué de taille et sa maintenance est donc devenue moins pénible
Néanmoins, la volonté de Node.js de généraliser la philosophie asynchrone pour tout et de fonctionner avec des évènements pour TOUT, rend de mon point de vue le code horrible. Par exemple, le pseudo-code suivant, pour lancer le téléchargement des pages dont les URLs sont dans un fichier toto.txt :
file.open("toto.txt", function(contents, err) {
if (err) return;
contents.split("\n", function(line) {
var URL = trim(line);
http.download(URL).on("receive", function(body) {
file.save("tutu.html", body, function(err) {
if (err) ???
//Ici le fichier téléchargé est sauvegardé
})
}).on("error", function() {
//Gestion des 4XX et 5XX
}).on("redirect", function() {
//Gestion des 3XX
});
});
});�a n'est pas si difficile à comprendre et à maintenir, c'est simplement qu'au bout d'un moment j'ai réalisé que c'était ABSOLUMENT HIDEUX ! Un if remplacé par une closure peut avoir du sens en haut-niveau mais tout en bas (ex: un fichier ne peut être ouvert car il n'existe pas) c'est complètement imbécile, c'est une philosophie de développement poussée trop loin.
Recevoir une erreur fichier via une closure peut être utile pour des opérations lourdes mais rendre "obsolète" la version synchrone de exists m'a clairement fait comprendre que je n'était pas en train d'utiliser une techno viable pour moi.
Comme d'autre, en voulant changer ce mode de fonctionnement et retrouver un peu de santé mentale, je suis tombé sur Go et après avoir parcouru le Go Tour et appris les rudiments du langage, j'ai écrit le programme suivant :
package main
import "fmt"
import "strings"
import "io/ioutil"
import "net/http"
type FeedRequest struct {
url string
fileName string
//
fileSize int
httpCode int
body string
}
func (r *FeedRequest) load(done chan int) {
Ret, err := http.Get(r.url)
defer Ret.Body.Close()
r.httpCode = Ret.StatusCode
if err != nil {
fmt.Println("FeedRequest : erreur durant le chargement de", r.url, "avec le code", r.httpCode)
return
}
body, err := ioutil.ReadAll(Ret.Body)
if err != nil {
fmt.Println("FeedRequest : erreur durant la lecture-mémoire de la réponse de", r.url)
return
}
r.fileSize = len(body)
fmt.Println("FeedRequest : le flux", r.url, "a été chargé (Retour :", r.httpCode, "), sa taille est de", r.fileSize)
//
err = ioutil.WriteFile(r.fileName, body, 0777)
if err != nil {
fmt.Println("FeedRequest : erreur durant la sauvegarde du fichier", r.fileName, "correspondant au flux", r.url)
}
done <- 1
}
//Fonction équivalente de php::file_get_contents
func ReadTextFile(filePath string) (string, error) {
b, err := ioutil.ReadFile(filePath)
if err != nil {
return "", err
}
return string(b), nil
}
func retrieveWebPagesFromFile(textFilePath string, completion chan int) {
done := make(chan int) //Channel pour la complétion de chacun des téléchargements
//
lines, _ := ReadTextFile(textFilePath)
Lines := strings.Split(lines, "\n")
for _, Line := range Lines {
URLComps := strings.SplitN(Line, ":", 2)
FeedId, URL := URLComps[0], URLComps[1]
R := FeedRequest{url:URL, fileName:FeedId+".xml"}
go R.load(done)
}
NbFeeds := len(Lines)
for {
<- done
NbFeeds--
if NbFeeds == 0 { break; }
}
completion <- 1
}
func main() {
done := make(chan int) //Channel utilisé pour la complétion du processus
go self.retrieveWebPagesFromFile("rss.urls", done)
<- done
fmt.Println("Le processus est terminé")
}
- on peut convenir que l'ensemble est très lisible
- il y a une claire séparation entre le téléchargement et la réception (les channels y sont pour quelques chose, plus besoin de closures à tout bout de champ !)
- les goroutines sont très agréable à utiliser pour ajouter simplement du parallélisme à un code existant
- la performance est au pire identique à mon code JS
Ce programme étant fonctionnel, il ne me reste plus qu'à y ajouter la possibilité d'attendre un certain temps entre deux requêtes pour un site donné, le fameux site dont je parlais au tout début et l'objectif de ce post...
La première version de l'amélioration consiste à initialiser mon téléchargeur avec une liste de noms d'hôtes qui nécessitent un délai et à remplacer la première boucle de retrieveWebPagesFromFile par :
for _, Line := range Lines {
URLComps := strings.SplitN(Line, ":", 2)
FeedId, URL := URLComps[0], URLComps[1]
R := PageRequest{url:URL, fileName:FeedId+".xml"}
TargetURL, _ := url.Parse(URL)
v := self.punishingHosts[TargetURL.Host]
if v == true {
DelayedRequests = append(DelayedRequests, R)
} else {
go R.load(done)
}
}
//
go func () {
for ReqIdx:=range DelayedRequests {
R := DelayedRequests[ReqIdx]
time.Sleep(time.Duration(5) * time.Second)
go R.load(done)
}
}()
Je devrais améliorer cela en exécutant autant de goroutines serveur qu'il y a d'hôtes demandant un délai dans les requêtes mais en première approximation cela me suffit.
Après ce petit voyage initiatique au pays du Go, je retiens que ce langage me permet de remplacer de façon plus lisible les closures omni-présentes du JS par des constructions plus adaptées suivant les contextes :
- des channels pour l'échange d'informations : une queue d'URLs à télécharger, une notification de complétion etc.
- des go routines : pour des tâches parallèles à la thread principale etc.
- des closures : et oui, elles sont toujours très utiles, mais pas tout le temps !
Concernant les reproches que je peux faire à Go (du haut de mes deux jours d'expérience) :
- la syntaxe n'est pas heureuse pour définir des méthodes de struct/class.
- les communications via channels m'évoque une syntaxe tirée du Bash. La concision extrême du script n'est pas forcément heureuse pour un langage plus bas-niveau.
- les interfaces en grand nombre ne facilitent pas l'apprentissage du langage et sont invisibles dans la définition des structs.
- la notion de constructeur ou d'initialiseur n'existe pas, là pour faire bien il faudrait une interface Initialisable mais comme on ne peut pas non plus déclarer une structure comme devant suivre une interface donnée...
- la différence entre le langage et les APIs n'est pas très nette.
- il n'y a pas de type générique, de template au sens du C++ ou de Generic au sens de Swift.
- les slices, très utile, sont vraiment un hack et montre le niveau de vision contenue dans le design de Go : très faible.
- ne parlons pas de la taille des binaires une fois compilé, là c'est complètement lamentable. Je conserve donc mes scripts Go et je les compile-exécute quand j'en ai besoin...
Rob Pike, co-créateur de Go, parle de tout cela dans un post très intéressant et même si je pense qu'il se trompe concernant la simplicité de Go, il est très clair dans ses explications.
Tout cela fait que je suis vraiment sûr que Swift sur mes serveurs va vraiment BEAUCOUP me plaire. Mais en attendant, il faut faire avec ce qu'on a et le meilleur compromis actuel pour du run-time efficace semble être Go.
En attendant, on notera l'ironie qui m'a fait passer à Go, parce que Node.js était trop rapide et ne permettait pas d'intégrer élégamment un code de séquentialisation d'opérations comme en PHP...
