Letzte Woche habe ich eine Analyse der IETF Idempotency-Key-Spezifikation geschrieben. Ziel der Spezifikation ist es, doppelte Anfragen zu vermeiden. Kurz gesagt besteht die Idee darin, dass der Client zusammen mit der Anfrage einen eindeutigen Schlüssel sendet:
Dieser Beitrag zeigt, wie es mit Apache APISIX implementiert wird.
Bevor wir mit dem Codieren beginnen, müssen wir ein paar Dinge definieren. Apache APISIX bietet eine Plugin-basierte Architektur. Daher werden wir die obige Logik in einem Plugin codieren.
Apache APISIX baut auf OpenResty auf, das wiederum auf nginx aufbaut. Jede Komponente definiert Phasen, die mehr oder weniger auf die Komponenten abgebildet werden. Weitere Informationen zu Phasen finden Sie in diesem vorherigen Beitrag .
Zum Schluss legen wir eine Priorität fest. Die Priorität definiert die Reihenfolge, in der APISIX Plugins innerhalb einer Phase ausführt. Ich habe mich für 1500
entschieden, da alle Authentifizierungs-Plugins eine Priorität im Bereich von 2000
und höher haben, ich aber die zwischengespeicherte Antwort so schnell wie möglich zurückgeben möchte.
Die Spezifikation verlangt von uns, Daten zu speichern. APISIX bietet viele Abstraktionen, aber Speicherung gehört nicht dazu. Wir benötigen Zugriff über den Idempotenzschlüssel, damit es wie ein Schlüssel-Wert-Speicher aussieht.
Ich habe mich willkürlich für Redis entschieden, da es ziemlich weit verbreitet ist und der Client bereits Teil der APISIX-Distribution ist. Beachten Sie, dass einfaches Redis keinen JSON-Speicher bietet. Daher verwende ich das Docker-Image redis-stack
.
Die Infrastruktur vor Ort ist wie folgt:
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
Die APISIX-Konfiguration ist die folgende:
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
Schließlich deklarieren wir unsere einzelne Route:
routes: - uri: /* plugins: idempotency: ~ #1 upstream: nodes: "httpbin.org:80": 1 #2 #END #3
Sobald diese Infrastruktur vorhanden ist, können wir mit der Implementierung beginnen.
Die Grundlagen eines Apache APISIX-Plugins sind ziemlich einfach:
local plugin_name = "idempotency" local _M = { version = 1.0, priority = 1500, schema = {}, name = plugin_name, } return _M
Der nächste Schritt ist die Konfiguration, z. B. Redis-Host und -Port. Zunächst bieten wir eine einzige Redis-Konfiguration für alle Routen an. Das ist die Idee hinter dem Abschnitt plugin_attr
in der Datei config.yaml
: gemeinsame Konfiguration. Lassen Sie uns unser Plugin ausarbeiten:
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
Definieren Sie die Form der Konfiguration
Überprüfen Sie, ob die Konfiguration gültig ist
Da ich im Plug-In Standardwerte definiert habe, kann ich nur den host
überschreiben, damit redis
innerhalb meiner Docker Compose-Infrastruktur ausgeführt wird und den Standardport verwendet.
Als Nächstes muss ich den Redis-Client erstellen. Beachten Sie, dass die Plattform mir in keiner Phase nach dem Abschnitt „Umschreiben/Zugriff“ eine Verbindung ermöglicht. Daher erstelle ich ihn in der init()
Methode und behalte ihn bis zum Ende.
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
Verweisen Sie auf die new
Funktion des OpenResty Redis-Moduls.
Rufen Sie es auf, um eine Instanz zu erhalten.
Der Redis-Client ist nun für den Rest des Plugin-Ausführungszyklus in der redis
Variable verfügbar.
In meinem früheren Leben als Softwareentwickler habe ich normalerweise zuerst den nominalen Pfad implementiert. Anschließend habe ich den Code robuster gemacht, indem ich Fehlerfälle einzeln verwaltet habe. Auf diese Weise konnte ich, wenn ich zu irgendeinem Zeitpunkt eine Veröffentlichung vornehmen musste, immer noch Geschäftswerte liefern – mit Warnungen. Ich werde dieses Miniprojekt auf die gleiche Weise angehen.
Der Pseudoalgorithmus auf dem nominalen Pfad sieht wie folgt aus:
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
Wir müssen die Logik der oben erwähnten Phase zuordnen. Vor dem Upstream stehen zwei Phasen zur Verfügung: rewrite und access ; drei danach: header_filter , body_filter und log . Die access -Phase schien für die vorherige Arbeit offensichtlich, aber ich musste mich zwischen den drei anderen entscheiden. Ich habe den body_filter zufällig ausgewählt, aber ich bin gerne bereit, mir vernünftige Argumente für andere Phasen anzuhören.
Beachten Sie, dass ich Protokolle entfernt habe, um den Code lesbarer zu machen. Fehler- und Informationsprotokolle sind erforderlich, um das Debuggen von Produktionsproblemen zu erleichtern.
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
Anweisung: APISIX überspringt die späteren Lebenszyklusphasen.
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
Extrahieren Sie den Idempotenzschlüssel aus der Anfrage.
Ordnen Sie die verschiedenen Elemente einer Antwort in einer Lua-Tabelle an.
Speichern Sie die JSON-codierte Antwort in einem Redis-Set
Tests zeigen, dass es wie erwartet funktioniert.
Versuchen:
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
Versuchen Sie außerdem, einen nicht übereinstimmenden Idempotenzschlüssel wiederzuverwenden, z. B. A
für die dritte Anfrage. Da wir noch kein Fehlermanagement implementiert haben, erhalten Sie die zwischengespeicherte Antwort für eine andere Anfrage. Es ist Zeit, unser Spiel zu verbessern.
Die Spezifikation definiert mehrere Fehlerpfade:
Lassen Sie uns sie nacheinander implementieren. Prüfen wir zunächst, ob die Anfrage einen Idempotenzschlüssel hat. Beachten Sie, dass wir das Plugin für jede Route einzeln konfigurieren können. Wenn die Route also das Plugin enthält, können wir davon ausgehen, dass es obligatorisch ist.
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 -- ...
Geben Sie bei Verlust des Schlüssels einfach die entsprechende 400 zurück. Das war ganz einfach.
Die Überprüfung der Wiederverwendung eines vorhandenen Schlüssels für eine andere Anfrage ist etwas komplizierter. Wir müssen zuerst die Anfrage speichern, oder genauer gesagt den Fingerabdruck dessen, was eine Anfrage ausmacht. Zwei Anfragen sind gleich, wenn sie dieselbe Methode, denselben Pfad, denselben Text und dieselben Header haben. Je nach Ihrer Situation kann die Domäne (und der Port) Teil davon sein oder auch nicht. Für meine einfache Implementierung werde ich sie weglassen.
Es gibt mehrere Probleme zu lösen. Erstens habe ich keine vorhandene API zum Hashen des core.request
Objekts gefunden, wie es sie in anderen Sprachen gibt, mit denen ich besser vertraut bin, z. B. Javas Object.hash()
. Ich habe beschlossen, das Objekt in JSON zu kodieren und den String zu hashen. Das vorhandene core.request
hat jedoch Unterelemente, die nicht in JSON konvertiert werden können. Ich musste die oben genannten Teile extrahieren und die Tabelle konvertieren.
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
Erstellen Sie eine Tabelle mit nur den relevanten Teilen.
Die cjson
Bibliothek erzeugt JSON, dessen Mitglieder bei mehreren Aufrufen unterschiedlich sortiert sein können. Daher entstehen unterschiedliche Hashes. core.json.stably_encode
behebt dieses Problem.
Hat geschlagen.
Dann speichern wir beim Empfang der Anfrage keinen Booleschen Wert, sondern stattdessen den resultierenden Hash.
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 -- ...
Wir lesen den Hash, der unter dem Idempotenzschlüssel auf dem anderen Zweig gespeichert ist. Wenn sie nicht übereinstimmen, beenden wir den Vorgang mit dem entsprechenden Fehlercode:
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
Die abschließende Fehlerbehandlung erfolgt direkt im Anschluss. Stellen Sie sich folgendes Szenario vor:
Eine Anfrage kommt mit dem Idempotenzschlüssel X.
Das Plugin erstellt einen Fingerabdruck und speichert den Hash in Redis.
APISIX leitet die Anfrage an den Upstream weiter.
Eine doppelte Anfrage weist den gleichen Idempotenzschlüssel X auf.
Das Plugin liest die Daten aus Redis und findet keine zwischengespeicherte Antwort.
Der Upstream hat die Verarbeitung der Anfrage nicht abgeschlossen. Daher hat die erste Anfrage die body_filter
Phase noch nicht erreicht.
Wir fügen dem obigen Snippet den folgenden Code hinzu:
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
Das ist es.
In diesem Beitrag habe ich eine einfache Implementierung der Idempotency-Key
Header-Spezifikation auf Apache APISIX über ein Plugin gezeigt. In diesem Stadium besteht noch Verbesserungsbedarf: automatisierte Tests, die Möglichkeit, Redis pro Route zu konfigurieren, die Domäne/den Pfad als Teil der Anfrage zu konfigurieren, einen Redis-Cluster anstelle einer einzelnen Instanz zu konfigurieren, einen anderen K/V-Speicher zu verwenden usw.
Dennoch implementiert es die Spezifikation und verfügt über das Potenzial, sich zu einer produktionstauglicheren Implementierung weiterzuentwickeln.
Den vollständigen Quellcode für diesen Beitrag finden Sie auf GitHub .
Um weiter zu gehen:
Ursprünglich veröffentlicht bei A Java Geek am 7. April 2024