Tuần trước, tôi đã viết một bài phân tích về đặc tả IETF Idempotency-Key . Thông số kỹ thuật nhằm mục đích tránh các yêu cầu trùng lặp. Nói tóm lại, ý tưởng là khách hàng gửi một khóa duy nhất cùng với yêu cầu:
Bài đăng này cho thấy cách triển khai nó với Apache APISIX .
Trước khi bắt đầu viết mã, chúng ta cần xác định một số điều. Apache APISIX cung cấp kiến trúc dựa trên plugin. Do đó, chúng tôi sẽ mã hóa logic trên trong một plugin.
Apache APISIX được xây dựng dựa trên OpenResty, được xây dựng dựa trên nginx. Mỗi thành phần xác định các giai đoạn, ánh xạ ít nhiều qua các thành phần. Để biết thêm thông tin về các giai đoạn, vui lòng xem bài viết trước này .
Cuối cùng, chúng ta sẽ quyết định mức độ ưu tiên. Mức độ ưu tiên xác định thứ tự APISIX chạy các plugin bên trong một giai đoạn . Tôi đã quyết định 1500
, vì tất cả các plugin xác thực đều có mức độ ưu tiên trong phạm vi 2000
trở lên, nhưng tôi muốn trả về phản hồi được lưu trong bộ nhớ cache càng sớm càng tốt.
Đặc tả yêu cầu chúng ta lưu trữ dữ liệu. APISIX cung cấp nhiều khái niệm trừu tượng, nhưng lưu trữ không phải là một trong số đó. Chúng tôi cần quyền truy cập thông qua khóa bình thường để nó trông giống như một kho lưu trữ khóa-giá trị.
Tôi đã tùy ý chọn Redis vì nó khá phổ biến và ứng dụng khách đã là một phần của bản phân phối APISIX. Lưu ý rằng Redis đơn giản không cung cấp bộ lưu trữ JSON; do đó, tôi sử dụng hình ảnh Docker redis-stack
.
Cơ sở hạ tầng địa phương như sau:
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
Cấu hình APISIX như sau:
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
Cuối cùng, chúng tôi khai báo lộ trình duy nhất của mình:
routes: - uri: /* plugins: idempotency: ~ #1 upstream: nodes: "httpbin.org:80": 1 #2 #END #3
Với cơ sở hạ tầng này đã có, chúng tôi có thể bắt đầu triển khai.
Nền tảng của plugin Apache APISIX khá cơ bản:
local plugin_name = "idempotency" local _M = { version = 1.0, priority = 1500, schema = {}, name = plugin_name, } return _M
Bước tiếp theo là cấu hình, ví dụ: máy chủ và cổng Redis. Để bắt đầu, chúng tôi sẽ cung cấp một cấu hình Redis duy nhất trên tất cả các tuyến. Đó là ý tưởng đằng sau phần plugin_attr
trong tệp config.yaml
: cấu hình chung. Hãy bổ sung thêm plugin của chúng tôi:
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
Xác định hình dạng của cấu hình
Kiểm tra cấu hình có hợp lệ không
Vì tôi đã xác định các giá trị mặc định trong plugin nên tôi chỉ có thể ghi đè host
thành redis
để chạy bên trong cơ sở hạ tầng Docker Compose của mình và sử dụng cổng mặc định.
Tiếp theo, tôi cần tạo ứng dụng khách Redis. Lưu ý rằng nền tảng ngăn tôi kết nối trong bất kỳ giai đoạn nào sau phần viết lại/truy cập. Do đó, tôi sẽ tạo nó trong phương thức init()
và giữ nó cho đến hết.
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
Tham khảo chức năng new
của mô-đun OpenResty Redis.
Gọi nó để lấy một ví dụ.
Ứng dụng khách Redis hiện có sẵn trong biến redis
trong suốt phần còn lại của chu kỳ thực thi plugin.
Trong cuộc đời kỹ sư phần mềm trước đây của tôi, tôi thường triển khai đường dẫn danh nghĩa trước tiên. Sau đó, tôi làm cho mã trở nên mạnh mẽ hơn bằng cách quản lý từng trường hợp lỗi riêng lẻ. Bằng cách này, nếu tôi phải phát hành vào bất kỳ thời điểm nào, tôi vẫn sẽ mang lại các giá trị kinh doanh - kèm theo cảnh báo. Tôi sẽ tiếp cận dự án nhỏ này theo cách tương tự.
Thuật toán giả trên đường dẫn danh nghĩa trông như sau:
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
Chúng ta cần ánh xạ logic đến giai đoạn tôi đã đề cập ở trên. Hai giai đoạn có sẵn trước khi ngược dòng, viết lại và truy cập ; ba sau, header_filter , body_filter và log . Giai đoạn truy cập trước đây có vẻ rõ ràng đối với công việc, nhưng tôi cần phải tìm ra giữa ba giai đoạn còn lại. Tôi đã chọn ngẫu nhiên body_filter , nhưng tôi sẵn sàng lắng nghe những lập luận hợp lý cho các giai đoạn khác.
Lưu ý rằng tôi đã xóa nhật ký để mã dễ đọc hơn. Nhật ký lỗi và thông tin là cần thiết để dễ dàng gỡ lỗi các vấn đề sản xuất.
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 bỏ qua các giai đoạn sau của vòng đời.
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
Trích xuất khóa bình thường từ yêu cầu.
Sắp xếp các thành phần khác nhau của phản hồi trong bảng Lua.
Lưu trữ phản hồi được mã hóa JSON trong bộ Redis
Các thử nghiệm cho thấy nó hoạt động như mong đợi.
Thử:
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
Ngoài ra, hãy thử sử dụng lại khóa bình thường không khớp, ví dụ : A
cho yêu cầu thứ ba. Vì chúng tôi chưa triển khai bất kỳ hoạt động quản lý lỗi nào nên bạn sẽ nhận được phản hồi được lưu trong bộ nhớ đệm cho một yêu cầu khác. Đã đến lúc bắt đầu trò chơi của chúng ta.
Thông số kỹ thuật xác định một số đường dẫn lỗi:
Hãy thực hiện từng cái một. Trước tiên, hãy kiểm tra xem yêu cầu có khóa tạm thời hay không. Lưu ý rằng chúng tôi có thể định cấu hình plugin trên cơ sở từng tuyến đường, vì vậy nếu tuyến đường đó bao gồm plugin thì chúng tôi có thể kết luận rằng đó là điều bắt buộc.
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 -- ...
Chỉ cần trả lại 400 thích hợp nếu thiếu chìa khóa. Cái đó thật dễ dàng.
Việc kiểm tra việc sử dụng lại khóa hiện có cho một yêu cầu khác có liên quan nhiều hơn một chút. Trước tiên, chúng ta cần lưu trữ yêu cầu, hay chính xác hơn là dấu vân tay của những gì cấu thành một yêu cầu. Hai yêu cầu giống nhau nếu chúng có: cùng một phương thức, cùng một đường dẫn, cùng một nội dung và cùng một tiêu đề. Tùy thuộc vào tình huống của bạn, tên miền (và cổng) có thể là một phần của chúng hoặc không. Để thực hiện đơn giản, tôi sẽ bỏ nó đi.
Có một số vấn đề cần giải quyết. Đầu tiên, tôi không tìm thấy API hiện có để băm đối tượng core.request
giống như trong các ngôn ngữ khác mà tôi quen thuộc hơn, ví dụ : Object.hash()
của Java. Tôi quyết định mã hóa đối tượng bằng JSON và băm chuỗi. Tuy nhiên, core.request
hiện tại có các phần tử phụ không thể chuyển đổi thành JSON. Tôi đã phải trích xuất các phần được đề cập ở trên và chuyển đổi bảng.
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
Tạo một bảng chỉ có các phần có liên quan.
Thư viện cjson
tạo ra JSON mà các thành viên của nó có thể được sắp xếp khác nhau qua một số lệnh gọi. Do đó, nó dẫn đến các giá trị băm khác nhau. core.json.stably_encode
khắc phục vấn đề đó.
Đã trúng.
Sau đó, thay vì lưu trữ boolean khi nhận được yêu cầu, chúng tôi lưu trữ hàm băm kết quả.
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 -- ...
Chúng tôi đọc hàm băm được lưu trữ dưới khóa bình thường trên nhánh khác. Nếu chúng không khớp, chúng tôi sẽ thoát với mã lỗi liên quan:
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
Việc quản lý lỗi cuối cùng diễn ra ngay sau đó. Hãy tưởng tượng kịch bản sau:
Một yêu cầu đi kèm với khóa bình thường X.
Dấu vân tay của plugin và lưu trữ hàm băm trong Redis.
APISIX chuyển tiếp yêu cầu lên thượng nguồn.
Một yêu cầu trùng lặp đi kèm với cùng một khóa tạm thời, X.
Plugin đọc dữ liệu từ Redis và không tìm thấy phản hồi nào được lưu trong bộ nhớ đệm.
Thượng nguồn chưa xử lý xong yêu cầu; do đó, yêu cầu đầu tiên vẫn chưa đến giai đoạn body_filter
.
Chúng tôi nối thêm đoạn mã sau vào đoạn mã trên:
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
Đó là nó.
Trong bài đăng này, tôi đã trình bày cách triển khai đơn giản đặc tả tiêu đề Idempotency-Key
trên Apache APISIX thông qua một plugin. Ở giai đoạn này, nó còn chỗ để cải thiện: kiểm tra tự động, khả năng định cấu hình Redis trên cơ sở từng tuyến, định cấu hình miền/đường dẫn trở thành một phần của yêu cầu, định cấu hình cụm Redis thay vì một phiên bản duy nhất, sử dụng K/ khác Cửa hàng V, v.v.
Tuy nhiên, nó thực hiện đặc tả kỹ thuật và có tiềm năng phát triển thành một triển khai ở cấp độ sản xuất cao hơn.
Mã nguồn hoàn chỉnh cho bài đăng này có thể được tìm thấy trên GitHub .
Để đi xa hơn:
Được xuất bản lần đầu tại A Java Geek vào ngày 7 tháng 4 năm 2024