paint-brush
Как реализовать спецификацию идемпотентного ключа в Apache APISIXк@nfrankel
411 чтения
411 чтения

Как реализовать спецификацию идемпотентного ключа в Apache APISIX

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

Слишком долго; Читать

В этом посте я показал простую реализацию спецификации заголовка Idempotency-Key в Apache APISIX с помощью плагина. На этом этапе есть возможности для улучшения: автоматические тесты, возможность настройки Redis для каждого маршрута, настройка домена/пути как часть запроса, настройка кластера Redis вместо одного экземпляра, использование другого K/ Магазин V и т. д.
featured image - Как реализовать спецификацию идемпотентного ключа в Apache APISIX
Nicolas Fränkel HackerNoon profile picture

На прошлой неделе я написал анализ спецификации IETF Idempotency-Key . Спецификация направлена на то, чтобы избежать дублирования запросов. Короче говоря, идея состоит в том, чтобы клиент отправил уникальный ключ вместе с запросом:


  • Если сервер не знает ключа, он действует как обычно, а затем сохраняет ответ.


  • Если сервер знает ключ, он прекращает дальнейшую обработку и немедленно возвращает сохраненный ответ.


В этом посте показано, как реализовать это с помощью Apache APISIX .

Обзор

Прежде чем начать кодирование, нам нужно определить пару вещей. Apache APISIX предлагает архитектуру на основе плагинов. Следовательно, мы закодируем вышеуказанную логику в плагине.


Apache APISIX основан на OpenResty, который основан на nginx. Каждый компонент определяет фазы, которые в той или иной степени соответствуют компонентам. Дополнительную информацию об этапах можно найти в предыдущем посте .


Наконец, мы определимся с приоритетом. Приоритет определяет порядок, в котором APISIX запускает плагины внутри фазы . Я выбрал 1500 , так как все плагины аутентификации имеют приоритет в диапазоне 2000 и выше, но я хочу вернуть кэшированный ответ как можно скорее.


Спецификация требует от нас хранения данных. APISIX предлагает множество абстракций, но хранилище не входит в их число. Нам нужен доступ через ключ идемпотентности, чтобы он выглядел как хранилище значений ключа.


Я произвольно выбрал Redis, так как он довольно широко распространён, а клиент уже входит в дистрибутив APISIX. Обратите внимание, что простой Redis не предлагает хранилище JSON; поэтому я использую образ Docker redis-stack .


Местная инфраструктура следующая:


 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. Статическая конфигурация маршрута
  2. Путь к нашему будущему плагину
  3. Порт Redis Insights (GUI). Само по себе это не обязательно, но очень полезно во время разработки для отладки.


Конфигурация APISIX следующая:

 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. Настройте APISIX для настройки статических маршрутов.
  2. Настройте местоположение нашего плагина
  3. Пользовательские плагины должны быть явно объявлены. Приоритетный комментарий не обязателен, но является хорошей практикой и улучшает ремонтопригодность.
  4. Общая конфигурация плагинов для всех маршрутов
  5. См. ниже


Наконец, мы объявляем наш единственный маршрут:

 routes: - uri: /* plugins: idempotency: ~ #1 upstream: nodes: "httpbin.org:80": 1 #2 #END #3
  1. Объявите плагин, который мы собираемся создать.
  2. httpbin — полезный восходящий поток, поскольку мы можем пробовать разные URI и методы.
  3. Обязательно для настройки статических маршрутов!


Имея эту инфраструктуру, мы можем начать реализацию.

Размещение плагина

Основы плагина Apache APISIX довольно просты:


 local plugin_name = "idempotency" local _M = { version = 1.0, priority = 1500, schema = {}, name = plugin_name, } return _M


Следующим шагом является настройка, например, хоста и порта Redis. Для начала мы предложим единую конфигурацию Redis для всех маршрутов. В этом и заключается идея раздела plugin_attr в файле config.yaml : общая конфигурация. Давайте усовершенствуем наш плагин:


 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. Определите форму конфигурации


  2. Убедитесь, что конфигурация действительна


Поскольку я определил значения по умолчанию в плагине, я могу переопределить только host для redis , чтобы он работал внутри моей инфраструктуры Docker Compose и использовал порт по умолчанию.


Далее мне нужно создать клиент Redis. Обратите внимание, что платформа не позволяет мне подключиться на любом этапе после раздела перезаписи/доступа. Следовательно, я создам его в методе init() и сохраню до конца.


 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. Ссылка на new функцию модуля OpenResty Redis.


  2. Вызовите его, чтобы получить экземпляр.


Клиент Redis теперь доступен в переменной redis на протяжении всего оставшегося цикла выполнения плагина.

Реализация номинального пути

В своей предыдущей жизни инженера-программиста я обычно сначала реализовал номинальный путь. После этого я сделал код более надежным, управляя случаями ошибок индивидуально. Таким образом, если бы мне в какой-то момент пришлось выпустить релиз, я бы все равно предоставил бизнес-ценности — с предупреждениями. Точно так же я подхожу к этому мини-проекту.


Псевдоалгоритм на номинальном пути выглядит следующим образом:


 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


Нам нужно сопоставить логику с фазой, о которой я упоминал выше. Доступны две фазы перед восходящим потоком, перезаписью и доступом ; три после, header_filter , body_filter и log . Фаза доступа раньше казалась очевидной для работы, но мне нужно было выбрать между тремя другими. Я случайно выбрал body_filter , но я более чем готов выслушать разумные аргументы в пользу других этапов.


Обратите внимание, что я удалил логи, чтобы сделать код более читабельным. Журналы ошибок и информационные журналы необходимы для облегчения отладки производственных проблем.


 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. Извлеките ключ идемпотентности из запроса.
  2. Префикс ключа, чтобы избежать потенциальных коллизий.
  3. Получите набор данных, хранящийся в Redis под ключом идемпотентности.
  4. Если ключ не найден, сохраните его с логической отметкой.
  5. Преобразуйте данные в таблице Lua с помощью специальной служебной функции.
  6. Ответ сохраняется в формате JSON для учета заголовков.
  7. Восстановите ответ.
  8. Верните восстановленный ответ клиенту. Обратите внимание на оператор return : APISIX пропускает более поздние фазы жизненного цикла.


 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. Извлеките ключ идемпотентности из запроса.


  2. Расположите различные элементы ответа в таблице Lua.


  3. Сохраните ответ в кодировке JSON в наборе Redis.


Тесты показывают, что он работает так, как ожидалось.


Пытаться:

 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


Кроме того, попробуйте повторно использовать несовпадающий ключ идемпотентности, например , A для третьего запроса. Поскольку мы еще не реализовали систему управления ошибками, вы получите кэшированный ответ на другой запрос. Пришло время улучшить нашу игру.

Реализация путей ошибок

Спецификация определяет несколько путей ошибок:


  • Идемпотентный ключ отсутствует.


  • Идемпотентный ключ уже используется.


  • Для этого ключа идемпотентности невыполнен запрос.


Давайте реализуем их один за другим. Сначала проверим, что запрос имеет ключ идемпотентности. Обратите внимание, что мы можем настроить плагин для каждого маршрута, поэтому, если маршрут включает плагин, мы можем сделать вывод, что он обязателен.


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


Просто верните соответствующие 400, если ключ отсутствует. Это было легко.


Проверка повторного использования существующего ключа для другого запроса немного сложнее. Сначала нам нужно сохранить запрос или, точнее, отпечаток того, что представляет собой запрос. Два запроса считаются одинаковыми, если они имеют: один и тот же метод, один и тот же путь, одно и то же тело и одинаковые заголовки. В зависимости от вашей ситуации домен (и порт) могут быть их частью, а могут и не быть. Для моей простой реализации я оставлю это.


Есть несколько проблем, которые необходимо решить. Во-первых, я не нашел существующего API для хэширования объекта core.request как в других языках, с которыми я более знаком, например , в Java Object.hash() . Я решил закодировать объект в формате JSON и хешировать строку. Однако существующий core.request содержит подэлементы, которые невозможно преобразовать в JSON. Мне пришлось извлечь упомянутые выше части и преобразовать таблицу.


 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. Создайте таблицу только с соответствующими частями.


  2. Библиотека cjson создает JSON, элементы которого могут сортироваться по-разному при нескольких вызовах. Следовательно, это приводит к различным хешам. core.json.stably_encode устраняет эту проблему.


  3. Хэш это.


Затем вместо сохранения логического значения при получении запроса мы сохраняем полученный хэш.


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


Мы читаем хеш, хранящийся под ключом идемпотентности в другой ветке. Если они не совпадают, мы выходим с соответствующим кодом ошибки:


 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


Сразу после этого происходит окончательная обработка ошибок. Представьте себе следующий сценарий:


  1. Запрос приходит с ключом идемпотентности X.


  2. Плагин распознает отпечатки пальцев и сохраняет хэш в Redis.


  3. APISIX перенаправляет запрос в восходящий поток.


  4. Дубликат запроса поставляется с тем же ключом идемпотентности X.


  5. Плагин считывает данные из Redis и не находит кэшированного ответа.


Восходящий поток не завершил обработку запроса; следовательно, первый запрос еще не достиг фазы body_filter .


Мы добавляем следующий код к приведенному выше фрагменту:


 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


Вот и все.

Заключение

В этом посте я показал простую реализацию спецификации заголовка Idempotency-Key в Apache APISIX с помощью плагина. На этом этапе есть возможности для улучшения: автоматические тесты, возможность настройки Redis для каждого маршрута, настройка домена/пути как часть запроса, настройка кластера Redis вместо одного экземпляра, использование другого K/ магазин V и т. д.


Тем не менее, он реализует спецификацию и имеет потенциал для развития в реализацию более промышленного уровня.


Полный исходный код этого поста можно найти на GitHub .


Чтобы пойти дальше:



Первоначально опубликовано на сайте A Java Geek 7 апреля 2024 г.