paint-brush
Comment implémenter la spécification Idempotency-Key sur Apache APISIXpar@nfrankel
266 lectures

Comment implémenter la spécification Idempotency-Key sur Apache APISIX

par Nicolas Fränkel14m2024/04/11
Read on Terminal Reader

Trop long; Pour lire

Dans cet article, j'ai montré une implémentation simple de la spécification d'en-tête Idempotency-Key sur Apache APISIX via un plugin. A ce stade, il peut être amélioré : tests automatisés, possibilité de configurer Redis par route, configurer le domaine/chemin pour faire partie de la requête, configurer un cluster Redis au lieu d'une seule instance, utiliser un autre K/ V magasin, etc.
featured image - Comment implémenter la spécification Idempotency-Key sur Apache APISIX
Nicolas Fränkel HackerNoon profile picture

La semaine dernière, j'ai écrit une analyse de la spécification IETF Idempotency-Key . La spécification vise à éviter les demandes en double. En bref, l'idée est que le client envoie une clé unique avec la requête :


  • Si le serveur ne connaît pas la clé, il procède comme d'habitude puis stocke la réponse.


  • Si le serveur connaît la clé, il court-circuite tout traitement ultérieur et renvoie immédiatement la réponse stockée.


Cet article montre comment l'implémenter avec Apache APISIX .

Aperçu

Avant de commencer le codage, nous devons définir quelques éléments. Apache APISIX propose une architecture basée sur des plugins. Par conséquent, nous coderons la logique ci-dessus dans un plugin.


Apache APISIX s'appuie sur OpenResty, qui s'appuie sur nginx. Chaque composant définit des phases, qui se répartissent plus ou moins entre les composants. Pour plus d'informations sur les phases, veuillez consulter ce post précédent .


Finalement, nous déciderons d'une priorité. La priorité définit l'ordre dans lequel APISIX exécute les plugins au sein d'une phase . J'ai opté pour 1500 , car tous les plugins d'authentification ont une priorité comprise entre 2000 et plus, mais je souhaite renvoyer la réponse mise en cache dès que possible.


La spécification nous oblige à stocker des données. APISIX propose de nombreuses abstractions, mais le stockage n'en fait pas partie. Nous avons besoin d'un accès via la clé d'idempotence pour qu'elle ressemble à un magasin clé-valeur.


J'ai arbitrairement choisi Redis, car il est assez répandu et le client fait déjà partie de la distribution APISIX. Notez que Redis simple n'offre pas de stockage JSON ; par conséquent, j'utilise l'image Docker redis-stack .


L'infrastructure locale est la suivante :


 services: apisix: image: apache/apisix:3.9.0-debian volumes: - ./apisix/config.yml:/usr/local/apisix/conf/config.yaml:ro - ./apisix/apisix.yml:/usr/local/apisix/conf/apisix.yaml:ro #1 - ./plugin/src:/opt/apisix/plugins:ro #2 ports: - "9080:9080" redis: image: redis/redis-stack:7.2.0-v9 ports: - "8001:8001" #3
  1. Configuration d'itinéraire statique
  2. Chemin vers notre futur plugin
  3. Port de Redis Insights (interface graphique). Pas nécessaire en soi , mais très utile lors du développement pour le débogage.


La configuration APISIX est la suivante :

 deployment: role: data_plane role_data_plane: config_provider: yaml #1 apisix: extra_lua_path: /opt/?.lua #2 plugins: - idempotency # priority: 1500 #3 plugin_attr: #4 idempotency: host: redis #5
  1. Configurer APISIX pour la configuration des routes statiques
  2. Configurer l'emplacement de notre plugin
  3. Les plugins personnalisés doivent être explicitement déclarés. Le commentaire prioritaire n'est pas obligatoire mais constitue une bonne pratique et améliore la maintenabilité
  4. Configuration de plugin commune sur toutes les routes
  5. Voir ci-dessous


Enfin, nous déclarons notre itinéraire unique :

 routes: - uri: /* plugins: idempotency: ~ #1 upstream: nodes: "httpbin.org:80": 1 #2 #END #3
  1. Déclarez le plugin que nous allons créer.
  2. httpbin est un outil utile en amont car nous pouvons essayer différents URI et méthodes.
  3. Obligatoire pour la configuration des routes statiques !


Avec cette infrastructure en place, nous pouvons commencer la mise en œuvre.

Présentation du plugin

Les fondations d’un plugin Apache APISIX sont assez basiques :


 local plugin_name = "idempotency" local _M = { version = 1.0, priority = 1500, schema = {}, name = plugin_name, } return _M


L'étape suivante est la configuration, par exemple l'hôte et le port Redis. Pour commencer, nous proposerons une configuration Redis unique sur toutes les routes. C'est l'idée derrière la section plugin_attr du fichier config.yaml : configuration commune. Développons notre plugin :


 local core = require("apisix.core") local plugin = require("apisix.plugin") local attr_schema = { --1 type = "object", properties = { host = { type = "string", description = "Redis host", default = "localhost", }, port = { type = "integer", description = "Redis port", default = 6379, }, }, } function _M.init() local attr = plugin.plugin_attr(plugin_name) or {} local ok, err = core.schema.check(attr_schema, attr) --2 if not ok then core.log.error("Failed to check the plugin_attr[", plugin_name, "]", ": ", err) return false, err end end
  1. Définir la forme de la configuration


  2. Vérifiez que la configuration est valide


Parce que j'ai défini des valeurs par défaut dans le plugin, je peux remplacer uniquement l' host vers redis pour qu'il s'exécute dans mon infrastructure Docker Compose et utilise le port par défaut.


Ensuite, je dois créer le client Redis. Notez que la plateforme m'empêche de me connecter dans n'importe quelle phase après la section réécriture/accès. Par conséquent, je vais le créer dans la méthode init() et le conserver jusqu'à la fin.


 local redis_new = require("resty.redis").new --1 function _M.init() -- ... redis = redis_new() --2 redis:set_timeout(1000) local ok, err = redis:connect(attr.host, attr.port) if not ok then core.log.error("Failed to connect to Redis: ", err) return false, err end end
  1. Référencez la new fonction du module OpenResty Redis.


  2. Appelez-le pour obtenir une instance.


Le client Redis est désormais disponible dans la variable redis pendant le reste du cycle d'exécution du plugin.

Mise en œuvre du chemin nominal

Dans ma vie précédente d’ingénieur logiciel, j’implémentais généralement en premier le chemin nominal. Par la suite, j'ai rendu le code plus robuste en gérant les cas d'erreurs individuellement. De cette façon, si je devais publier à un moment donné, je fournirais toujours des valeurs commerciales – avec des avertissements. J'aborderai ce mini-projet de la même manière.


Le pseudo-algorithme sur le chemin nominal ressemble à ceci :


 DO extract idempotency key from request DO look up value from Redis IF value doesn't exist DO set key in Redis with empty value ELSE RETURN cached response DO forward to upstream DO store response in Redis RETURN response


Nous devons mapper la logique à la phase que j'ai mentionnée ci-dessus. Deux phases sont disponibles avant l'amont, la réécriture et l'accès ; trois après, header_filter , body_filter et log . La phase d'accès me paraissait évidente pour le travail d'avant, mais il fallait que je trouve ma part entre les trois autres. J'ai choisi au hasard le body_filter , mais je suis plus que disposé à écouter des arguments sensés pour d'autres phases.


Notez que j'ai supprimé les journaux pour rendre le code plus lisible. Des journaux d’erreurs et d’informations sont nécessaires pour faciliter le débogage des problèmes de production.


 function _M.access(conf, ctx) local idempotency_key = core.request.header(ctx, "Idempotency-Key") --1 local redis_key = "idempotency#" .. idempotency_key --2 local resp, err = redis:hgetall(redis_key) --3 if not resp then return end if next(resp) == nil then --4 local resp, err = redis:hset(redis_key, "request", true ) --4 if not resp then return end else local data = normalize_hgetall_result(resp) --5 local response = core.json.decode(data["response"]) --6 local body = response["body"] --7 local status_code = response["status"] --7 local headers = response["headers"] for k, v in pairs(headers) do --7 core.response.set_header(k, v) end return core.response.exit(status_code, body) --8 end end
  1. Extrayez la clé d’idempotence de la demande.
  2. Préfixez la clé afin d’éviter les collisions potentielles.
  3. Obtenez l'ensemble de données stocké dans Redis sous la clé d'idempotence.
  4. Si la clé n'est pas trouvée, stockez-la avec une marque booléenne.
  5. Transformez les données dans une table Lua via une fonction utilitaire personnalisée.
  6. La réponse est stockée au format JSON pour tenir compte des en-têtes.
  7. Reconstruisez la réponse.
  8. Renvoyez la réponse reconstruite au client. Notez l'instruction return : APISIX ignore les phases ultérieures du cycle de vie.


 function _M.body_filter(conf, ctx) local idempotency_key = core.request.header(ctx, "Idempotency-Key") --1 local redis_key = "idempotency#" .. idempotency_key if core.response then local response = { --2 status = ngx.status, body = core.response.hold_body_chunk(ctx, true), headers = ngx.resp.get_headers() } local redis_key = "idempotency#" .. redis_key local resp, err = red:set(redis_key, "response", core.json.encode(response)) --3 if not resp then return end end end
  1. Extrayez la clé d’idempotence de la demande.


  2. Organisez les différents éléments d'une réponse dans un tableau Lua.


  3. Stocker la réponse codée en JSON dans un ensemble Redis


Les tests révèlent que cela fonctionne comme prévu.


Essayer:

 curl -i -X POST -H 'Idempotency-Key: A' localhost:9080/response-headers\?freeform=hello curl -i -H 'Idempotency-Key: B' localhost:9080/status/250 curl -i -H 'Idempotency-Key: C' -H 'foo: bar' localhost:9080/status/250


Essayez également de réutiliser une clé d'idempotence incompatible, par exemple , A pour la troisième requête. Comme nous n'avons pas encore implémenté de gestion des erreurs, vous obtiendrez la réponse en cache pour une autre requête. Il est temps d'améliorer notre jeu.

Implémentation des chemins d'erreur

La spécification définit plusieurs chemins d'erreur :


  • La clé d'idempotence est manquante.


  • Idempotency-Key est déjà utilisé.


  • Une demande est en attente pour cette clé d'idempotence


Implémentons-les un par un. Vérifions d’abord que la requête possède une clé d’idempotence. Notez que nous pouvons configurer le plugin route par route, donc si la route inclut le plugin, nous pouvons conclure qu'il est obligatoire.


 function _M.access(conf, ctx) local idempotency_key = core.request.header(ctx, "Idempotency-Key") if not idempotency_key then return core.response.exit(400, "This operation is idempotent and it requires correct usage of Idempotency Key") end -- ...


Renvoyez simplement le 400 approprié si la clé est manquante. Celui-là était facile.


Vérifier la réutilisation d'une clé existante pour une requête différente est légèrement plus complexe. Il faut d'abord stocker la demande, ou plus précisément l'empreinte digitale de ce qui constitue une demande. Deux requêtes sont identiques si elles ont : la même méthode, le même chemin, le même corps et les mêmes en-têtes. Selon votre situation, le domaine (et le port) peuvent en faire partie ou non. Pour ma mise en œuvre simple, je vais le laisser de côté.


Il y a plusieurs problèmes à résoudre. Premièrement, je n'ai pas trouvé d'API existante pour hacher l'objet core.request comme c'est le cas dans d'autres langages que je connais mieux, par exemple Object.hash() de Java. J'ai décidé d'encoder l'objet en JSON et de hacher la chaîne. Cependant, le core.request existant comporte des sous-éléments qui ne peuvent pas être convertis en JSON. J'ai dû extraire les pièces mentionnées ci-dessus et convertir le tableau.


 local function hash_request(request, ctx) local request = { --1 method = core.request.get_method(), uri = ctx.var.request_uri, headers = core.request.headers(), body = core.request.get_body() } local json = core.json.stably_encode(request) --2 return ngx.encode_base64(json) --3 end
  1. Créez un tableau avec uniquement les parties pertinentes.


  2. La bibliothèque cjson produit du JSON dont les membres peuvent être triés différemment sur plusieurs appels. Par conséquent, cela donne des hachages différents. Le core.json.stably_encode résout ce problème.


  3. Hachez-le.


Ensuite, au lieu de stocker un booléen lors de la réception de la requête, nous stockons le hachage résultant.


 local hash = hash_request(core.request, ctx) if next(resp) == nil then core.log.warn("No key found in Redis for Idempotency-Key, set it: ", redis_key) local resp, err = redis:hset(redis_key, "request", hash) if not resp then core.log.error("Failed to set data in Redis: ", err) return end then -- ...


Nous lisons le hachage stocké sous la clé d'idempotence sur l'autre branche. S'ils ne correspondent pas, nous quittons avec le code d'erreur correspondant :


 local data = normalize_hgetall_result(resp) local stored_hash = data["request"] if hash ~= stored_hash then return core.response.exit(422, "This operation is idempotent and it requires correct usage of Idempotency Key. Idempotency Key MUST not be reused across different payloads of this operation.") end


La gestion finale des erreurs intervient juste après. Imaginez le scénario suivant :


  1. Une demande est accompagnée de la clé d'idempotence X.


  2. Le plugin prend les empreintes digitales et stocke le hachage dans Redis.


  3. APISIX transmet la requête à l'amont.


  4. Une demande en double est accompagnée de la même clé d'idempotence, X.


  5. Le plugin lit les données de Redis et ne trouve aucune réponse en cache.


L'amont n'a pas fini de traiter la demande ; par conséquent, la première requête n'a pas encore atteint la phase body_filter .


Nous ajoutons le code suivant à l'extrait ci-dessus :


 if not data["response"] then return core.response.exit(409, " request with the same Idempotency-Key for the same operation is being processed or is outstanding.") end


C'est ça.

Conclusion

Dans cet article, j'ai montré une implémentation simple de la spécification d'en-tête Idempotency-Key sur Apache APISIX via un plugin. A ce stade, il peut être amélioré : tests automatisés, possibilité de configurer Redis par route, configurer le domaine/chemin pour faire partie de la requête, configurer un cluster Redis au lieu d'une seule instance, utiliser un autre K/ V magasin, etc.


Pourtant, il implémente la spécification et a le potentiel d’évoluer vers une implémentation davantage de niveau production.


Le code source complet de cet article peut être trouvé sur GitHub .


Pour aller plus loin:



Publié initialement sur A Java Geek le 7 avril 2024