Une des questions que nous recevons occasionnellement est : comment puis-je valider en masse des listes d'emails avec la validation des destinataires? Il y a deux options ici, l'une consiste à télécharger un fichier via l'interface SparkPost pour validation, et l'autre à effectuer des appels individuels par email à l'API (car l'API valide un seul email).
La première option fonctionne très bien mais a une limitation de 20 Mo (environ 500 000 adresses). Que se passe-t-il si quelqu'un a une liste d'emails contenant des millions d'adresses? Cela pourrait signifier diviser cela en 1 000 fichiers CSV à télécharger.
Comme le téléchargement de milliers de fichiers CSV semble un peu farfelu, j'ai pris ce cas d'utilisation et je me suis mis à réfléchir à la rapidité avec laquelle je pourrais faire fonctionner l'API. Dans cet article de blog, j'expliquerai ce que j'ai essayé et comment j'en suis finalement venu à un programme qui pouvait réaliser environ 100 000 validations en 55 secondes (tandis que dans l'interface, j'ai obtenu environ 100 000 validations en 1 minute 10 secondes). Et bien que cela prendrait encore environ 100 heures pour effectuer environ 654 millions de validations, ce script peut s'exécuter en arrière-plan, ce qui permet de gagner un temps considérable.
La version finale de ce programme peut être trouvée ici.
Mon premier erreur : utiliser Python
Python est l'un de mes langages de programmation préférés. Il excelle dans de nombreux domaines et est incroyablement simple. Cependant, un domaine dans lequel il n'excelle pas est les processus concurrents. Bien que Python ait la capacité d'exécuter des fonctions asynchrones, il a ce qu'on appelle le Global Interpreter Lock de Python ou GIL.
“Le Global Interpreter Lock de Python, en termes simples, est un mutex (ou un verrou) qui permet à un seul thread de contrôler l'interpréteur Python.
Cela signifie qu'à tout moment, un seul thread peut être en état d'exécution. L'impact du GIL n'est pas visible pour les développeurs qui exécutent des programmes à thread unique, mais cela peut devenir un goulot d'étranglement de performance dans le code lié au CPU et multithreadé.
Puisque le GIL permet à un seul thread d'exécuter à la fois même dans une architecture multithreadée avec plus d'un cœur CPU, le GIL a acquis la réputation d'être une caractéristique “infâme” de Python.” (https://realpython.com/python-gil/)”
Au départ, je n'étais pas conscient du GIL, alors j'ai commencé à programmer en Python. À la fin, même si mon programme était asynchrone, il se bloquait, et peu importe combien de threads j'ajoutais, j'obtenais toujours environ 12-15 itérations par seconde.
La partie principale de la fonction asynchrone en Python peut être vue ci-dessous :
async def validateRecipients(f, fh, apiKey, snooze, count): h = {'Authorization': apiKey, 'Accept': 'application/json'} with tqdm(total=count) as pbar: async with aiohttp.ClientSession() as session: for address in f: for i in address: thisReq = requests.compat.urljoin(url, i) async with session.get(thisReq,headers=h, ssl=False) as resp: content = await resp.json() row = content['results'] row['email'] = i fh.writerow(row) pbar.update(1)
Alors j'ai abandonné l'utilisation de Python et je suis retourné à la case départ…
J'ai décidé d'utiliser NodeJS en raison de sa capacité à effectuer des opérations d'I/O non bloquantes extrêmement bien. Je suis aussi assez familier avec la programmation en NodeJS.
En utilisant les aspects asynchrones de NodeJS, cela a bien fonctionné. Pour plus de détails sur la programmation asynchrone dans NodeJS, voir https://blog.risingstack.com/node-hero-async-programming-in-node-js/
Mon deuxième erreur : essayer de lire le fichier en mémoire
Mon idée initiale était la suivante :
Premièrement, ingérer une liste d'emails au format CSV. Deuxièmement, charger les emails dans un tableau et vérifier qu'ils sont au bon format. Troisièmement, appeler de manière asynchrone l'API de validation des destinataires. Quatrièmement, attendre les résultats et les charger dans une variable. Et enfin, sortir cette variable vers un fichier CSV.
Cela a très bien fonctionné pour des fichiers plus petits. Le problème est survenu lorsque j'ai essayé de faire passer 100 000 emails. Le programme s'est bloqué autour de 12 000 validations. Avec l'aide de l'un de nos développeurs front-end, j'ai vu que le problème provenait du chargement de tous les résultats dans une variable (et donc de l'épuisement rapide de la mémoire). Si vous souhaitez voir la première itération de ce programme, je l'ai liée ici : Version 1 (NON RECOMMANDÉE).
Premièrement, ingérer une liste d'emails au format CSV. Deuxièmement, compter le nombre d'emails dans le fichier à des fins de rapport. Troisièmement, à mesure que chaque ligne est lue de manière asynchrone, appeler l'API de validation des destinataires et sortir les résultats dans un fichier CSV.
Ainsi, pour chaque ligne lue, j'appelle l'API et écris les résultats de manière asynchrone afin de ne pas garder ces données en mémoire à long terme. J'ai également supprimé la vérification de la syntaxe des emails après avoir discuté avec l'équipe de validation des destinataires, car ils m'ont informé que la validation des destinataires avait déjà des vérifications intégrées pour déterminer si un email est valide ou non.
Décomposition du code final
Après avoir lu et validé les arguments du terminal, j'exécute le code suivant. Premièrement, je lis le fichier CSV des emails et compte chaque ligne. Cette fonction a deux objectifs : 1) elle me permet de rendre compte avec précision de l'avancement du fichier [comme nous le verrons plus tard], et 2) elle me permet d'arrêter un chronomètre lorsque le nombre d'emails dans le fichier est égal au nombre de validations effectuées. J'ai ajouté un chronomètre afin de pouvoir faire des benchmarks et m'assurer que j'obtenais de bons résultats.
let count = 0; //Compteur de lignes require("fs") .createReadStream(myArgs[1]) .on("data", function (chunk) { for (let i = 0; i < chunk.length; ++i) if (chunk[i] == 10) count++; }) //Lit le fichier d'entrée et augmente le compteur pour chaque ligne .on("close", function () { //À la fin du fichier d'entrée, après que toutes les lignes ont été comptées, exécute la fonction de validation des destinataires validateRecipients.validateRecipients(count, myArgs); });
J'appelle ensuite la fonction validateRecipients. Notez que cette fonction est asynchrone. Après avoir validé que le fichier d'entrée et le fichier de sortie sont au format CSV, j'écris une ligne d'en-tête et je commence un chronomètre à l'aide de la bibliothèque JSDOM.
async function validateRecipients(email_count, myArgs) { if ( //Si les deux fichiers d'entrée et de sortie sont au format .csv extname(myArgs[1]).toLowerCase() == ".csv" && extname(myArgs[3]).toLowerCase() == ".csv" ) { let completed = 0; //Compteur pour chaque appel API email_count++; //Compteur de ligne retourne #lignes - 1, cela est fait pour corriger le nombre de lignes //Démarre un chronomètre const { window } = new JSDOM(); const start = window.performance.now(); const output = fs.createWriteStream(myArgs[3]); //Fichier de sortie output.write( "Email,Valid,Result,Reason,Is_Role,Is_Disposable,Is_Free,Delivery_Confidence\n" ); //Écrit les en-têtes dans le fichier de sortie
Le script suivant constitue vraiment l'essentiel du programme, je vais donc le décomposer et expliquer ce qui se passe. Pour chaque ligne du fichier d'entrée :
Appeler de manière asynchrone cette ligne et appeler l'API de validation des destinataires.
fs.createReadStream(myArgs[1]) .pipe(csv.parse({ headers: false })) .on("data", async (email) => { let url = SPARKPOST_HOST + "/api/v1/recipient-validation/single/" + email; await axios .get(url, { headers: { Authorization: SPARKPOST_API_KEY, }, }) //Pour chaque ligne lue dans le fichier d'entrée, appeler l'API de validation des destinataires de SparkPost
Puis, à la réception de la réponse
Ajoutez l'email au JSON (pour pouvoir imprimer l'email dans le CSV)
Validez si la raison est nulle, et si oui, remplissez une valeur vide (cela permet de garder le format CSV cohérent, car dans certains cas, la raison est donnée dans la réponse)
Définir les options et les clés pour le module json2csv.
Convertir le JSON en CSV et sortir (en utilisant json2csv)
Écrire l'état d'avancement dans le terminal
Enfin, si le nombre d'emails dans le fichier = validations effectuées, arrêter le chronomètre et imprimer les résultats
.then(function (response) { response.data.results.email = String(email); //Ajoute l'email comme un couple valeur/clé au JSON de réponse à utiliser pour la sortie response.data.results.reason ? null : (response.data.results.reason = ""); //Si la raison est nulle, la définir sur vide pour que le CSV soit uniforme //Utilise json-2-csv pour convertir le JSON au format CSV et sortir let options = { prependHeader: false, //Désactive l'ajout des valeurs JSON en tant que lignes d'en-tête pour chaque ligne keys: [ "results.email", "results.valid", "results.result", "results.reason", "results.is_role", "results.is_disposable", "results.is_free", "results.delivery_confidence", ], //Définit l'ordre des clés }; let json2csvCallback = function (err, csv) { if (err) throw err; output.write(`${csv}\n`); }; converter.json2csv(response.data, json2csvCallback, options); completed++; //Augmente le compteur de l'API process.stdout.write(`Fait avec ${completed} / ${email_count}\r`); //Sortie de l'état de Complété / Total sur la console sans afficher de nouvelles lignes //Si tous les emails ont terminé la validation si (completed == email_count) { const stop = window.performance.now(); //Arrête le chronomètre console.log( `Tous les emails validés avec succès en ${ (stop - start) / 1000 } secondes` ); } })
Un dernier problème que j'ai rencontré était que, bien que cela fonctionne très bien sur Mac, j'ai rencontré l'erreur suivante en utilisant Windows après environ 10 000 validations :
Erreur : connecter ENOBUFS XX.XX.XXX.XXX:443 – Local (indéfini:indéfini) avec l'email XXXXXXX@XXXXXXXXXX.XXX
Après avoir effectué des recherches supplémentaires, il apparaît que c'est un problème avec le pool de connexions HTTP de NodeJS ne réutilisant pas les connexions. J'ai trouvé cet article Stackoverflow sur le problème, et après d'autres recherches, j'ai trouvé une bonne configuration par défaut pour la bibliothèque axios qui a résolu ce problème. Je ne suis toujours pas certain pourquoi ce problème ne se produit que sur Windows et pas sur Mac.
Prochaines étapes
Pour quelqu'un qui cherche un programme simple et rapide qui prend un CSV, appelle l'API de validation des destinataires et sort un CSV, ce programme est fait pour vous.
Certaines ajouts à ce programme seraient les suivants :
Construire une interface frontale ou une interface utilisateur plus facile à utiliser
Meilleure gestion des erreurs et des réessais, car si pour une raison quelconque l'API renvoie une erreur, le programme ne réessaie actuellement pas l'appel
Je serais également curieux de voir si des résultats plus rapides pourraient être obtenus avec un autre langage comme Golang ou Erlang/Elixir.
Veuillez me faire part de vos commentaires ou suggestions pour développer ce projet.