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 :
Cet article montre comment l'implémenter avec Apache APISIX .
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
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
Enfin, nous déclarons notre itinéraire unique :
routes: - uri: /* plugins: idempotency: ~ #1 upstream: nodes: "httpbin.org:80": 1 #2 #END #3
Avec cette infrastructure en place, nous pouvons commencer la mise en œuvre.
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
Définir la forme de la configuration
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
Référencez la new
fonction du module OpenResty Redis.
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.
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
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
Extrayez la clé d’idempotence de la demande.
Organisez les différents éléments d'une réponse dans un tableau Lua.
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.
La spécification définit plusieurs chemins d'erreur :
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
Créez un tableau avec uniquement les parties pertinentes.
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.
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 :
Une demande est accompagnée de la clé d'idempotence X.
Le plugin prend les empreintes digitales et stocke le hachage dans Redis.
APISIX transmet la requête à l'amont.
Une demande en double est accompagnée de la même clé d'idempotence, X.
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.
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