W zeszłym tygodniu napisałem artykuł analizę analizy Specyfikacja IETF Idempotency-Key. Specyfikacja ma na celu uniknięcie zduplikowanych żądań. Krótko mówiąc, chodzi o to, aby klient wysyłał unikalny klucz wraz z żądaniem:
- Jeśli serwer nie zna klucza, postępuje jak zwykle, a następnie przechowuje odpowiedź
- Jeśli serwer zna klucz, skraca dalsze przetwarzanie i natychmiast zwraca zapisaną odpowiedź.
Ten post pokazuje, jak to zaimplementować za pomocą Apache APISIX.
Przygotowanie do tworzenia wtyczek Apache APISIX
Przed rozpoczęciem kodowania musimy zdefiniować kilka rzeczy.
- Apache APISIX oferuje architekturę opartą na wtyczkach. W związku z tym zakodujemy powyższą logikę we wtyczce.
- Apache APISIX opiera się na OpenResty, który opiera się na nginx. Każdy komponent definiuje fazy, które w mniejszym lub większym stopniu mapują poszczególne komponenty. Aby uzyskać więcej informacji na temat faz, proszę zobaczyć ten poprzedni post.
- Na koniec zdecydujemy o priorytecie. Priorytet określa kolejność, w jakiej APISIX uruchamia wtyczki wewnątrz fazy. Zdecydowałem się na
1500
, ponieważ wszystkie wtyczki uwierzytelniające mają priorytet w2000
i więcej, ale chcę zwrócić buforowaną odpowiedź JAK NAJSZYBCIEJ. - Specyfikacja wymaga od nas przechowywania danych. APISIX oferuje wiele abstrakcji, ale przechowywanie nie jest jedną z nich. Potrzebujemy dostępu poprzez klucz idempotencji, więc wygląda to jak magazyn klucz-wartość.
- Arbitralnie wybrałem Redis, ponieważ jest on dość rozpowszechniony i klient jest już częścią dystrybucji APISIX. Proszę zauważyć, że zwykły Redis nie oferuje przechowywania JSON; dlatego używam rozszerzenia
redis-stack
Docker image.
- Arbitralnie wybrałem Redis, ponieważ jest on dość rozpowszechniony i klient jest już częścią dystrybucji APISIX. Proszę zauważyć, że zwykły Redis nie oferuje przechowywania JSON; dlatego używam rozszerzenia
Lokalna infrastruktura jest następująca:
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
- Konfiguracja trasy statycznej
- Ścieżka do naszej przyszłej wtyczki
- Port Redis Insights (GUI). Nie jest to konieczne per se, ale bardzo przydatne podczas programowania do debugowania
Konfiguracja APISIX jest następująca:
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
- Konfiguracja APISIX dla konfiguracji tras statycznych
- Proszę skonfigurować lokalizację naszej wtyczki
- Wtyczki niestandardowe muszą być wyraźnie zadeklarowane. Komentarz dotyczący priorytetu nie jest wymagany, ale jest dobrą praktyką i poprawia łatwość konserwacji
- Wspólna konfiguracja wtyczek dla wszystkich tras
- Proszę zobaczyć poniżej
Na koniec deklarujemy naszą pojedynczą trasę:
routes:
- uri: /*
plugins:
idempotency: ~ #1
upstream:
nodes:
"httpbin.org:80": 1 #2
#END #3
- Proszę zadeklarować wtyczkę, którą zamierzamy utworzyć
- httpbin jest przydatny, ponieważ możemy wypróbować różne URI i metody.
- Obowiązkowe dla konfiguracji tras statycznych!
Mając tę infrastrukturę na miejscu, możemy rozpocząć implementację.
Układ wtyczki
Podstawy wtyczki Apache APISIX są dość podstawowe:
local plugin_name = "idempotency"
local _M = {
version = 1.0,
priority = 1500,
schema = {},
name = plugin_name,
}
return _M
Następnym krokiem jest konfiguracja, np. Host i port Redis. Na początek będziemy oferować pojedynczą konfigurację Redis na wszystkich trasach. Taka jest idea stojąca za plugin_attr
w sekcji config.yaml
plik: wspólna konfiguracja. Rozwińmy naszą wtyczkę:
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
- Proszę zdefiniować kształt konfiguracji
- Proszę sprawdzić poprawność konfiguracji
Ponieważ zdefiniowałem wartości domyślne we wtyczce, mogę nadpisać tylko wartości host
do redis
aby działać w mojej infrastrukturze Docker Compose i używać domyślnego portu.
Następnie muszę utworzyć klienta Redis. Proszę zauważyć, że platforma uniemożliwia mi łączenie się w dowolnej fazie po sekcji przepisywania/dostępu. Dlatego utworzę go w sekcji init()
i zachowam ją do końca.
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
- Proszę odnieść się do
new
funkcji modułu OpenResty Redis - Proszę ją wywołać, aby uzyskać instancję
Klient Redis jest teraz dostępny w redis
przez resztę cyklu wykonywania wtyczki.
Implementacja ścieżki nominalnej
W moim poprzednim życiu inżyniera oprogramowania zwykle najpierw wdrażałem ścieżkę nominalną. Następnie uczyniłem kod bardziej niezawodnym, zarządzając przypadkami błędów indywidualnie. W ten sposób, gdybym musiał zwolnić w dowolnym momencie, nadal dostarczałbym wartości biznesowe – z ostrzeżeniami. W ten sam sposób podejdę do tego mini-projektu.
Pseudoalgorytm na ścieżce nominalnej wygląda następująco:
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
Musimy zmapować logikę do fazy, o której wspomniałem powyżej. Przed upstreamem dostępne są dwie fazy, przepisanie oraz dostęp; trzy po, header_filter, body_filter oraz log. The dostęp faza wydawała się oczywista dla wcześniejszej pracy, ale musiałem ustalić między trzema innymi. Losowo wybrałem body_filter, ale jestem bardziej niż chętny do wysłuchania sensownych argumentów za innymi fazami.
Proszę zauważyć, że usunąłem logi, aby kod był bardziej czytelny. Dzienniki błędów i informacyjne są niezbędne, aby ułatwić debugowanie problemów produkcyjnych.
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
- Proszę wyodrębnić klucz idempotencji z żądania
- Proszę poprzedzić klucz prefiksem, aby uniknąć potencjalnych kolizji.
- Proszę pobrać zestaw danych przechowywany w Redis pod kluczem idempotency
- Jeśli klucz nie zostanie znaleziony, proszę zapisać go ze znakiem logicznym
- Przekształcenie danych w tabeli Lua za pomocą niestandardowej funkcji narzędziowej
- Odpowiedź jest przechowywana w formacie JSON, aby uwzględnić nagłówki
- Proszę zrekonstruować odpowiedź
- Proszę zwrócić zrekonstruowaną odpowiedź do klienta. Proszę zwrócić uwagę na
return
statement: APISIX pomija późniejsze fazy cyklu życia
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
- wyodrębnienie klucza idempotencji z żądania
- Uporządkowanie różnych elementów odpowiedzi w tabeli Lua
- Przechowywanie odpowiedzi zakodowanej w JSON w zestawie Redis
Testy wykazały, że działa zgodnie z oczekiwaniami.
Proszę spróbować:
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
Proszę również spróbować ponownie użyć niedopasowanego klucza idempotency, np., A
dla trzeciego żądania. Ponieważ nie zaimplementowaliśmy jeszcze żadnego zarządzania błędami, otrzymają Państwo buforowaną odpowiedź na kolejne żądanie. Nadszedł czas, aby poprawić naszą grę.
Wdrażanie ścieżek błędów
Specyfikacja definiuje kilka ścieżek błędów:
- Brak klucza Idempotency-Key
- Idempotency-Key jest już używany
- Żądanie jest zaległe dla tego klucza Idempotency-Key
Zaimplementujmy je jeden po drugim. Najpierw sprawdźmy, czy żądanie ma klucz idempotency. Proszę zauważyć, że możemy skonfigurować wtyczkę na podstawie trasy, więc jeśli trasa zawiera wtyczkę, możemy stwierdzić, że jest ona obowiązkowa.
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
-- ...
Wystarczy zwrócić odpowiednie 400, jeśli brakuje klucza. To było łatwe.
Sprawdzanie ponownego użycia istniejącego klucza dla innego żądania jest nieco bardziej skomplikowane. Najpierw musimy przechowywać żądanie, a dokładniej odcisk palca tego, co stanowi żądanie. Dwa żądania są takie same, jeśli mają: tę samą metodę, tę samą ścieżkę, to samo ciało i te same nagłówki. W zależności od sytuacji, domena (i port) mogą, ale nie muszą być ich częścią. W mojej prostej implementacji pominę to.
Jest kilka problemów do rozwiązania. Po pierwsze, nie znalazłem istniejącego API do haszowania wartości core.request
tak jak w innych językach, które znam, np., Java’s Object.hash()
. Postanowiłem zakodować obiekt w JSON i skrócić ciąg znaków. Jednak istniejący core.request
ma podelementy, których nie można przekonwertować na JSON. Musiałem wyodrębnić wspomniane części i przekonwertować tabelę.
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
- Proszę utworzyć tabelę zawierającą tylko odpowiednie części
- The
cjson
tworzy JSON, którego elementy mogą być posortowane inaczej w kilku wywołaniach. W związku z tym skutkuje to różnymi hashami. Thecore.json.stably_encode
rozwiązuje ten problem. - Hash it
Następnie, zamiast przechowywać wartość logiczną po otrzymaniu żądania, przechowujemy wynikowy 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 -- ...
Odczytujemy hash przechowywany pod kluczem idempotency w drugiej gałęzi. Jeśli się nie zgadzają, wychodzimy z odpowiednim kodem błędu:
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
Ostateczne zarządzanie błędami następuje zaraz potem. Proszę wyobrazić sobie następujący scenariusz:
- Przychodzi żądanie z kluczem idempotencji X
- Wtyczka pobiera odciski palców i przechowuje hash w Redis
- APISIX przekazuje żądanie do serwera upstream
- Zduplikowane żądanie ma ten sam klucz idempotencji, X
- Wtyczka odczytuje dane z Redis i nie znajduje buforowanej odpowiedzi
Upstream nie zakończył przetwarzania żądania; w związku z tym pierwsze żądanie nie dotarło jeszcze do pliku body_filter
.
Do powyższego fragmentu dołączamy następujący kod:
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
To wszystko.
Wnioski
W tym poście pokazałem prostą implementację funkcji Idempotency-Key
na Apache APISIX za pomocą wtyczki. Na tym etapie można go ulepszyć: zautomatyzowane testy, możliwość konfigurowania Redis na podstawie trasy, konfigurowanie domeny / ścieżki jako części żądania, konfigurowanie klastra Redis zamiast pojedynczej instancji, korzystanie z innego magazynu K / V itp.
Mimo to implementuje specyfikację i ma potencjał, aby ewoluować w bardziej produkcyjną implementację.
Pełny kod źródłowy tego wpisu można znaleźć na stronie GitHub.