Une des questions que nous recevons occasionnellement est : comment puis-je valider en masse les 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 utilisateur SparkPost pour validation, et l'autre est de faire des appels individuels par email à l'API (car l'API valide un email à la fois).
La première option fonctionne très bien mais a une limitation de 20Mo (environ 500 000 adresses). Que faire si quelqu'un a une liste d'emails contenant des millions d'adresses? Cela pourrait signifier de diviser cela en milliers de téléchargements de fichiers CSV.
Comme télécharger des milliers de fichiers CSV semble un peu tiré par les cheveux, j'ai pris ce cas d'utilisation et j'ai commencé à me demander à quelle vitesse je pouvais faire fonctionner l'API. Dans cet article de blog, je vais expliquer ce que j'ai essayé et comment j'en suis finalement venu à un programme qui pouvait effectuer 100 000 validations en 55 secondes (Quand dans l'interface utilisateur, j'ai obtenu environ 100 000 validations en 1 minute 10 secondes). Et bien que cela prenne toujours environ 100 heures pour terminer environ 654 millions de validations, ce script peut fonctionner en arrière-plan, économisant ainsi un temps significatif.
La version finale de ce programme peut être trouvée ici.
Ma première 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 verrou global de l'interpréteur Python ou GIL.
“Le verrou global de l'interpréteur Python ou GIL, en termes simples, est un mutex (ou un verrou) qui permet à un seul thread de contrôler l'interpréteur Python.
Cela signifie qu'un seul thread peut être en état d'exécution à tout moment. L'impact du GIL n'est pas visible pour les développeurs qui exécutent des programmes à un seul thread, mais cela peut constituer un goulet d'étranglement en matière de performances dans le code à usage CPU ou multi-threadé.
Puisque le GIL permet à un seul thread d'exécuter à la fois même dans une architecture multi-threadée avec plus d'un cœur CPU, le GIL a acquis une réputation de « caractéristique infâme » de Python.” (https://realpython.com/python-gil/)”
Au début, 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, je n'obtenais toujours que 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)
J'ai donc abandonné l'utilisation de Python et réfléchi à de nouvelles solutions…
J'ai choisi d'utiliser NodeJS en raison de sa capacité à effectuer des opérations I/O non-bloquantes très efficacement. Je suis également assez familiarisé 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/
Ma deuxième erreur : essayer de lire le fichier en mémoire
Mon idée initiale était la suivante :
Tout d'abord, ingérer une liste CSV d'emails. Deuxièmement, charger les emails dans un tableau et vérifier qu'ils sont au bon format. Troisièmement, appeler l'API de validation des destinataires de manière asynchrone. Quatrièmement, attendre les résultats et les charger dans une variable. Et enfin, sortir cette variable dans un fichier CSV.
Cela a très bien fonctionné pour des fichiers plus petits. Le problème est survenu lorsque j'ai essayé de traiter 100 000 emails. Le programme s'est arrêté autour de 12 000 validations. Avec l'aide d'un de nos développeurs front-end, j'ai vu que le problème était lié au chargement de tous les résultats dans une variable (et donc à un manque de mémoire rapide). Si vous souhaitez voir la première itération de ce programme, je l'ai liée ici : Version 1 (NON RECOMMANDÉE).
Tout d'abord, ingérer une liste CSV d'emails. Deuxièmement, compter le nombre d'emails dans le fichier à des fins de reporting. Troisièmement, alors que chaque ligne est lue de manière asynchrone, appeler l'API de validation des destinataires et exporter les résultats vers 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 conserver 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 a déjà des vérifications intégrées pour savoir si un email est valide ou non.
Décomposition du code final
Après avoir lu et validé les arguments terminaux, j'exécute le code suivant. Tout d'abord, je lis le fichier CSV des emails et compte chaque ligne. Cette fonction a deux objectifs : 1) elle me permet de rapporter avec précision 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 correspond aux validations complétées. J'ai ajouté un chronomètre afin de pouvoir exécuter des benchmarks et m'assurer que j'obtiens 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 de sortie sont au format CSV, j'écris une ligne d'en-tête et commence un chronomètre d'exécution à l'aide de la bibliothèque JSDOM.
async function validateRecipients(email_count, myArgs) { if ( //Si le fichier d'entrée et le fichier de sortie sont tous deux au format .csv extname(myArgs[1]).toLowerCase() == ".csv" && extname(myArgs[3]).toLowerCase() == ".csv" ) { let completed = 0; //Compteur pour chaque appel API email_count++; //Le compteur de lignes retourne le nombre de lignes - 1, ce qui est fait pour corriger le nombre de lignes //Démarrer un chronomètre const { window } = new JSDOM(); const start = window.performance.now(); const output = fs.createWriteStream(myArgs[3]); //Fichier de sortie output.write( "Email,Valide,Résultat,Raison,Est_Role,Est_Disposable,Est_Gratuit,Confiance_Livraison\n" ); //Écrit les en-têtes dans le fichier de sortie
Le script suivant constitue vraiment la majeure partie du programme, je vais donc le décomposer et expliquer ce qui se passe. Pour chaque ligne du fichier d'entrée :
Asynchrone, prends cette ligne et appelle 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 du fichier d'entrée, appelle l'API de validation des destinataires de SparkPost
Ensuite, sur la réponse
Ajoute l'email au JSON (pour pouvoir imprimer l'email dans le CSV)
Valide si la raison est nulle, et si oui, donne une valeur vide (cela garantit que le format CSV est cohérent, car dans certains cas, une raison est fournie dans la réponse)
Définir les options et les clés pour le module json2csv.
Convertir le JSON en CSV et le sortir (en utilisant json2csv)
Écrire la progression dans le terminal
Enfin, si le nombre d'emails dans le fichier = validations complétées, arrêter le chronomètre et afficher les résultats
.then(function (response) { response.data.results.email = String(email); //Ajoute l'email comme une paire 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 en blanc pour que le CSV soit uniforme //Utilise json-2-csv pour convertir le JSON en format CSV et le 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 API process.stdout.write(`Terminé avec ${completed} / ${email_count}\r`); //Sortie de l'état de Terminé / Total à la console sans afficher de nouvelles lignes //Si tous les emails ont terminé la validation if (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 ait très bien fonctionné sur Mac, j'ai rencontré l'erreur suivante sur Windows après environ 10 000 validations :
Erreur : connect ENOBUFS XX.XX.XXX.XXX:443 – Local (undefined:undefined) avec l'email XXXXXXX@XXXXXXXXXX.XXX
Après avoir effectué quelques recherches supplémentaires, il semble que cela soit un problème avec le pool de connexions HTTP de NodeJS qui ne réutilise pas les connexions. J'ai trouvé cet article sur Stackoverflow concernant le problème, et après un examen plus approfondi, j'ai trouvé une configuration par défaut pour la bibliothèque axios qui a résolu ce problème. Je ne suis pas encore certain pourquoi cette erreur ne se produit que sur Windows et pas sur Mac.
Prochaines étapes
Pour quelqu'un qui recherche un programme simple et rapide qui prend en entrée un CSV, appelle l'API de validation des destinataires et sort un CSV, ce programme est fait pour vous.
Quelques ajouts à ce programme pourraient être les suivants :
Construire un front-end ou une interface utilisateur plus facile à utiliser
Meilleure gestion des erreurs et des tentatives de réessai car si, pour une raison quelconque, l'API renvoie une erreur, le programme ne tente actuellement pas de rappeler
Je serais également curieux de voir si des résultats plus rapides pourraient être obtenus avec un autre langage comme Golang ou Erlang/Elixir.
Merci de me faire part de vos retours ou suggestions pour étoffer ce projet.