paint-brush
So implementieren Sie die Idempotency-Key-Spezifikation auf Apache APISIXvon@nfrankel
266 Lesungen

So implementieren Sie die Idempotency-Key-Spezifikation auf Apache APISIX

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

Zu lang; Lesen

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.
featured image - So implementieren Sie die Idempotency-Key-Spezifikation auf Apache APISIX
Nicolas Fränkel HackerNoon profile picture

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:


  • Wenn der Server den Schlüssel nicht kennt, geht er wie gewohnt vor und speichert anschließend die Antwort.


  • Wenn der Server den Schlüssel kennt, unterbricht er die weitere Verarbeitung und gibt die gespeicherte Antwort sofort zurück.


Dieser Beitrag zeigt, wie es mit Apache APISIX implementiert wird.

Überblick

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
  1. Statische Routenkonfiguration
  2. Pfad zu unserem zukünftigen Plugin
  3. Port von Redis Insights (GUI). Nicht unbedingt notwendig, aber während der Entwicklung zum Debuggen sehr nützlich.


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
  1. Konfigurieren Sie APISIX für die Konfiguration statischer Routen
  2. Konfigurieren Sie den Speicherort unseres Plugins
  3. Benutzerdefinierte Plugins müssen explizit deklariert werden. Der Prioritätskommentar ist nicht erforderlich, ist aber eine gute Praxis und verbessert die Wartbarkeit
  4. Gemeinsame Plugin-Konfiguration für alle Routen
  5. Siehe unten


Schließlich deklarieren wir unsere einzelne Route:

 routes: - uri: /* plugins: idempotency: ~ #1 upstream: nodes: "httpbin.org:80": 1 #2 #END #3
  1. Deklarieren Sie das Plugin, das wir erstellen werden.
  2. httpbin ist ein nützliches Upstream, da wir verschiedene URIs und Methoden ausprobieren können.
  3. Obligatorisch für die Konfiguration statischer Routen!


Sobald diese Infrastruktur vorhanden ist, können wir mit der Implementierung beginnen.

Layout des Plugins

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
  1. Definieren Sie die Form der Konfiguration


  2. Ü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
  1. Verweisen Sie auf die new Funktion des OpenResty Redis-Moduls.


  2. 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.

Implementieren des Nominalpfads

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
  1. Extrahieren Sie den Idempotenzschlüssel aus der Anfrage.
  2. Setzen Sie dem Schlüssel ein Präfix, damit wir mögliche Kollisionen vermeiden.
  3. Holen Sie sich den in Redis unter dem Idempotenzschlüssel gespeicherten Datensatz.
  4. Wenn der Schlüssel nicht gefunden wird, speichern Sie ihn mit einer Booleschen Markierung.
  5. Transformieren Sie die Daten mithilfe einer benutzerdefinierten Hilfsfunktion in eine Lua-Tabelle.
  6. Die Antwort wird im JSON-Format gespeichert, um Header zu berücksichtigen.
  7. Rekonstruieren Sie die Antwort.
  8. Gibt die rekonstruierte Antwort an den Client zurück. Beachten Sie die 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
  1. Extrahieren Sie den Idempotenzschlüssel aus der Anfrage.


  2. Ordnen Sie die verschiedenen Elemente einer Antwort in einer Lua-Tabelle an.


  3. 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.

Implementieren von Fehlerpfaden

Die Spezifikation definiert mehrere Fehlerpfade:


  • Idempotenz-Schlüssel fehlt.


  • Idempotency-Key wird bereits verwendet.


  • Für diesen Idempotency-Key liegt eine Anfrage vor


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
  1. Erstellen Sie eine Tabelle mit nur den relevanten Teilen.


  2. 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.


  3. 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:


  1. Eine Anfrage kommt mit dem Idempotenzschlüssel X.


  2. Das Plugin erstellt einen Fingerabdruck und speichert den Hash in Redis.


  3. APISIX leitet die Anfrage an den Upstream weiter.


  4. Eine doppelte Anfrage weist den gleichen Idempotenzschlüssel X auf.


  5. 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.

Abschluss

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