지난주에 IETF Idempotency-Key 사양 에 대한 분석을 작성했습니다. 이 사양은 중복 요청을 방지하는 것을 목표로 합니다. 간단히 말해서 클라이언트가 요청과 함께 고유 키를 보내는 아이디어입니다.
이 게시물에서는 Apache APISIX를 사용하여 이를 구현하는 방법을 보여줍니다.
코딩을 시작하기 전에 몇 가지 사항을 정의해야 합니다. Apache APISIX는 플러그인 기반 아키텍처를 제공합니다. 따라서 위의 논리를 플러그인에 코딩하겠습니다.
Apache APISIX는 nginx를 기반으로 하는 OpenResty를 기반으로 합니다. 각 구성 요소는 구성 요소 전반에 걸쳐 어느 정도 매핑되는 단계를 정의합니다. 단계에 대한 자세한 내용은 이전 게시물을 참조하세요.
마지막으로 우선순위를 결정하겠습니다. 우선순위는 APISIX가 단계 내에서 플러그인을 실행하는 순서를 정의합니다. 모든 인증 플러그인은 2000
이상의 범위에서 우선순위를 가지므로 1500
으로 결정했지만 최대한 빨리 캐시된 응답을 반환하고 싶습니다.
사양에 따르면 데이터를 저장해야 합니다. APISIX는 많은 추상화를 제공하지만 스토리지는 그중 하나가 아닙니다. 키-값 저장소처럼 보이도록 멱등성 키를 통해 액세스해야 합니다.
저는 Redis가 꽤 널리 퍼져 있고 클라이언트가 이미 APISIX 배포판의 일부이기 때문에 임의로 선택했습니다. Simple Redis는 JSON 스토리지를 제공하지 않습니다. 따라서 저는 redis-stack
Docker 이미지를 사용합니다.
로컬 인프라는 다음과 같습니다.
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
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
마지막으로 단일 경로를 선언합니다.
routes: - uri: /* plugins: idempotency: ~ #1 upstream: nodes: "httpbin.org:80": 1 #2 #END #3
이 인프라가 준비되면 구현을 시작할 수 있습니다.
Apache APISIX 플러그인의 기초는 매우 기본적입니다.
local plugin_name = "idempotency" local _M = { version = 1.0, priority = 1500, schema = {}, name = plugin_name, } return _M
다음 단계는 구성( 예: Redis 호스트 및 포트)입니다. 우선, 우리는 모든 경로에 걸쳐 단일 Redis 구성을 제공할 것입니다. 이것이 config.yaml
파일의 plugin_attr
섹션 뒤에 있는 아이디어입니다: 공통 구성. 플러그인을 구체화해 봅시다:
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
구성의 모양 정의
구성이 유효한지 확인하세요.
플러그인에서 기본값을 정의했기 때문에 redis
로 host
만 재정의하여 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
OpenResty Redis 모듈의 new
기능을 참조하세요.
인스턴스를 얻으려면 호출하세요.
이제 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
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
요청에서 멱등성 키를 추출합니다.
Lua 테이블에서 응답의 다양한 요소를 정렬합니다.
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을 반환하세요. 그건 쉬웠어요.
다른 요청에 대한 기존 키의 재사용을 확인하는 것은 약간 더 복잡합니다. 먼저 요청을 저장해야 합니다. 더 정확하게는 요청을 구성하는 요소의 지문을 저장해야 합니다. 동일한 메서드, 동일한 경로, 동일한 본문, 동일한 헤더가 있는 경우 두 요청은 동일합니다. 상황에 따라 도메인(및 포트)이 해당 도메인의 일부일 수도 있고 아닐 수도 있습니다. 간단한 구현을 위해 생략하겠습니다.
해결해야 할 몇 가지 문제가 있습니다. 첫째, Java의 Object.hash()
와 같이 나에게 더 익숙한 다른 언어에 있는 것처럼 core.request
객체를 해시하는 기존 API를 찾지 못했습니다. 객체를 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
관련 부분만 포함된 테이블을 만듭니다.
cjson
라이브러리는 여러 호출에서 멤버가 다르게 정렬될 수 있는 JSON을 생성합니다. 따라서 다른 해시가 생성됩니다. core.json.stably_encode
해당 문제를 해결합니다.
해시하세요.
그런 다음 요청을 받을 때 부울 값을 저장하는 대신 결과 해시를 저장합니다.
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
최종 오류 관리는 바로 직후에 발생합니다. 다음 시나리오를 상상해 보세요.
요청에는 멱등성 키 X가 함께 제공됩니다.
플러그인은 Redis에 해시를 지문으로 저장하고 저장합니다.
APISIX는 요청을 업스트림으로 전달합니다.
중복 요청에는 동일한 멱등성 키 X가 포함됩니다.
플러그인은 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
그게 다야.
이 게시물에서는 플러그인을 통해 Apache APISIX의 Idempotency-Key
헤더 사양을 간단하게 구현하는 방법을 보여주었습니다. 이 단계에서는 자동화된 테스트, 경로별로 Redis를 구성하는 기능, 요청의 일부가 되도록 도메인/경로 구성, 단일 인스턴스 대신 Redis 클러스터 구성, 다른 K/사용 등 개선의 여지가 있습니다. V스토어 등
하지만 사양을 구현하고 보다 생산 수준의 구현으로 발전할 가능성이 있습니다.
이 게시물의 전체 소스 코드는 GitHub 에서 찾을 수 있습니다.
더 나아가려면:
원래 2024년 4월 7일 A Java Geek 에 게시되었습니다.