La semana pasada, escribí un análisis de la especificación IETF Idempotency-Key . La especificación tiene como objetivo evitar solicitudes duplicadas. En definitiva, la idea es que el cliente envíe una clave única junto con la solicitud:
Esta publicación muestra cómo implementarlo con Apache APISIX .
Antes de comenzar a codificar, debemos definir un par de cosas. Apache APISIX ofrece una arquitectura basada en complementos. Por lo tanto, codificaremos la lógica anterior en un complemento.
Apache APISIX se basa en OpenResty, que a su vez se basa en nginx. Cada componente define fases, que se asignan más o menos a través de los componentes. Para obtener más información sobre las fases, consulte esta publicación anterior .
Finalmente decidiremos una prioridad. La prioridad define el orden en el que APISIX ejecuta los complementos dentro de una fase . Me decidí por 1500
, ya que todos los complementos de autenticación tienen una prioridad en el rango 2000
y más, pero quiero devolver la respuesta almacenada en caché lo antes posible.
La especificación requiere que almacenemos datos. APISIX ofrece muchas abstracciones, pero el almacenamiento no es una de ellas. Necesitamos acceso a través de la clave de idempotencia para que parezca un almacén de valores-clave.
Elegí Redis arbitrariamente porque está bastante extendido y el cliente ya forma parte de la distribución APISIX. Tenga en cuenta que Redis simple no ofrece almacenamiento JSON; por lo tanto, uso la imagen de Docker redis-stack
.
La infraestructura local es la siguiente:
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 configuración de APISIX es la siguiente:
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
Finalmente, declaramos nuestra ruta única:
routes: - uri: /* plugins: idempotency: ~ #1 upstream: nodes: "httpbin.org:80": 1 #2 #END #3
Con esta infraestructura implementada, podemos comenzar la implementación.
Los fundamentos de un complemento Apache APISIX son bastante básicos:
local plugin_name = "idempotency" local _M = { version = 1.0, priority = 1500, schema = {}, name = plugin_name, } return _M
El siguiente paso es la configuración, por ejemplo, el host y el puerto de Redis. Para empezar, ofreceremos una única configuración de Redis en todas las rutas. Esa es la idea detrás de la sección plugin_attr
en el archivo config.yaml
: configuración común. Desarrollemos nuestro complemento:
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
Definir la forma de la configuración.
Compruebe que la configuración sea válida.
Debido a que definí valores predeterminados en el complemento, solo puedo anular el host
para redis
para que se ejecute dentro de mi infraestructura Docker Compose y usar el puerto predeterminado.
A continuación, necesito crear el cliente Redis. Tenga en cuenta que la plataforma me impide conectarme en cualquier fase después de la sección de reescritura/acceso. Por lo tanto, lo crearé en el método init()
y lo guardaré hasta el final.
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
Haga referencia a la new
función del módulo OpenResty Redis.
Llámelo para obtener una instancia.
El cliente Redis ahora está disponible en la variable redis
durante el resto del ciclo de ejecución del complemento.
En mi vida anterior de ingeniero de software, generalmente implementaba primero la ruta nominal. Luego, hice el código más sólido administrando los casos de error individualmente. De esta manera, si tuviera que publicar en algún momento, seguiría entregando valores comerciales, con advertencias. Abordaré este miniproyecto de la misma manera.
El pseudoalgoritmo en la ruta nominal se parece al siguiente:
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
Necesitamos asignar la lógica a la fase que mencioné anteriormente. Hay dos fases disponibles antes del upstream, reescritura y acceso ; tres después, header_filter , body_filter y log . La fase de acceso parecía obvia para el trabajo antes, pero necesitaba decidir entre las otras tres. Elegí al azar body_filter , pero estoy más que dispuesto a escuchar argumentos sensatos para otras fases.
Tenga en cuenta que eliminé los registros para que el código sea más legible. Los registros de errores e informativos son necesarios para facilitar la depuración de problemas de producción.
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 omite las fases posteriores del ciclo de vida.
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
Extraiga la clave de idempotencia de la solicitud.
Organizar los diferentes elementos de una respuesta en una tabla Lua.
Almacene la respuesta codificada en JSON en un conjunto de Redis
Las pruebas revelan que funciona como se esperaba.
Intentar:
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
Además, intente reutilizar una clave de idempotencia que no coincida, por ejemplo , A
para la tercera solicitud. Como todavía no hemos implementado ninguna gestión de errores, obtendrá la respuesta en caché para otra solicitud. Es hora de mejorar nuestro juego.
La especificación define varias rutas de error:
Implementémoslos uno por uno. Primero, verifiquemos que la solicitud tenga una clave de idempotencia. Tenga en cuenta que podemos configurar el complemento por ruta, por lo que si la ruta incluye el complemento, podemos concluir que es obligatorio.
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 -- ...
Simplemente devuelva el 400 correspondiente si falta la clave. Ese fue fácil.
Verificar la reutilización de una clave existente para una solicitud diferente es un poco más complicado. Primero debemos almacenar la solicitud, o más precisamente, la huella digital de lo que constituye una solicitud. Dos solicitudes son iguales si tienen: el mismo método, la misma ruta, el mismo cuerpo y los mismos encabezados. Dependiendo de su situación, el dominio (y el puerto) pueden o no ser parte de ellos. Para mi implementación simple, lo dejaré fuera.
Hay varios problemas a resolver. En primer lugar, no encontré una API existente para aplicar hash al objeto core.request
como ocurre en otros lenguajes con los que estoy más familiarizado, por ejemplo , Object.hash()
de Java. Decidí codificar el objeto en JSON y aplicar hash a la cadena. Sin embargo, el core.request
existente tiene subelementos que no se pueden convertir a JSON. Tuve que extraer las partes mencionadas anteriormente y convertir la tabla.
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
Cree una tabla con solo las partes relevantes.
La biblioteca cjson
produce JSON cuyos miembros pueden ordenarse de manera diferente en varias llamadas. Por lo tanto, da como resultado diferentes hashes. core.json.stably_encode
soluciona ese problema.
Ha golpeado.
Luego, en lugar de almacenar un valor booleano al recibir la solicitud, almacenamos el hash resultante.
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 -- ...
Leemos el hash almacenado bajo la clave de idempotencia en la otra rama. Si no coinciden, salimos con el código de error correspondiente:
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 gestión final de errores se produce justo después. Imagine el siguiente escenario:
Una solicitud viene con la clave de idempotencia X.
El complemento toma las huellas digitales y almacena el hash en Redis.
APISIX reenvía la solicitud al nivel ascendente.
Una solicitud duplicada viene con la misma clave de idempotencia, X.
El complemento lee los datos de Redis y no encuentra ninguna respuesta almacenada en caché.
El upstream no terminó de procesar la solicitud; por lo tanto, la primera solicitud aún no ha llegado a la fase body_filter
.
Agregamos el siguiente código al fragmento anterior:
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
Eso es todo.
En esta publicación, mostré una implementación simple de la especificación del encabezado Idempotency-Key
en Apache APISIX a través de un complemento. En esta etapa, tiene margen de mejora: pruebas automatizadas, la capacidad de configurar Redis por ruta, configurar el dominio/ruta para que sea parte de la solicitud, configurar un clúster de Redis en lugar de una sola instancia, usar otro K/ Tienda V, etc.
Sin embargo, implementa la especificación y tiene el potencial de evolucionar hacia una implementación de mayor nivel de producción.
El código fuente completo de esta publicación se puede encontrar en GitHub .
Para llegar más lejos:
Publicado originalmente en A Java Geek el 7 de abril de 2024