插件开发最佳实践
本指南汇总了让开发自定义 Lua 插件达到与内置插件相同行为和质量的 编码实践,包括可预测的配置、正确的请求和响应处理,以及负载下的良好性能。
最重要的习惯是基于 apisix.core 库构建插件,而不是直接调用原始 OpenResty(ngx.*)API。所有内置插件都采用这种方式。apisix.core 是网关的插件工具箱,它在底层 OpenResty 原语之上提供请求级缓存、一致的错误处理和辅助函数,并已处理许多容易遗漏的边界情况。
local core = require("apisix.core")
使用 apisix.core,不要直接使用原始 ngx API
原始 ngx.* 调用在快速测试中通常可用,但在生产环境可能产生隐蔽问题。使用 core.* 封装的原因包括:
- 缓存请求范围内的读取结果。
core.request.headers(ctx)和ctx.var.<name>只计算一次并在当前请求中复用,而重复调用ngx.req.get_headers()每次都会重新解析。 - **与网关其他部分保持一致。**插件通过
core.request.set_header设置请求头时,其他插件读取的缓存视图会保持正确;混用原始ngx.req.set_header可能留下过时缓存值。 - **处理不便的默认行为。**例如,
core.request.get_uri_args读取参数时不设截断限制,而ngx.req.get_uri_args()会静默限制为 100 个参数。 - **集中行为。**日志、JSON 编码和 Schema 校验通过统一入口执行,使级别过滤、错误处理和格式保持一致。
ngx 到 core 的映射
应优先使用对应的 core API。下表列出最常见的替代关系。
原始 ngx API | 推荐的 core API | 说明 |
|---|---|---|
ngx.log(ngx.ERR, ...) | core.log.error(...) (.warn, .info, .debug) | 自动按级别过滤;传入多个参数,不要拼接。 |
ngx.req.get_headers() | core.request.headers(ctx) | 缓存在 ctx 中;键转换为小写,以便不区分大小写地查找。 |
ngx.req.get_headers()[name] | core.request.header(ctx, name) | 不区分大小写;解包多值请求头。 |
ngx.req.set_header(k, v) | core.request.set_header(ctx, k, v) | 同时让缓存的请求头视图失效。 |
ngx.req.get_uri_args() | core.request.get_uri_args(ctx) | 无截断限制;结果会被缓存。 |
ngx.req.set_uri_args(a) | core.request.set_uri_args(ctx, a) | 接受 table 或字符串。 |
ngx.req.read_body() + ngx.req.get_body_data() | core.request.get_body() | 也可读取缓冲到临时文件的请求体;仍可能返回 nil。 |
ngx.req.get_method() | core.request.get_method() | |
ngx.var.<name> | ctx.var.<name> | 延迟加载并缓存;还提供 route_id、consumer_name 等网关变量。 |
ngx.header[k] = v | core.response.set_header(k, v) | 防止在响应头已发送后继续设置。 |
ngx.say / ngx.print / ngx.exit | core.response.exit(code, body) | table 响应体会编码为 JSON;必须返回调用结果。 |
cjson.encode / cjson.decode | core.json.encode / core.json.decode | 输入无效时,decode 返回 nil, err。 |
日志行中的 cjson.encode | core.json.delay_encode(data) | 仅在实际输出日志行时编码。 |
| JSON Schema 校验 | core.schema.check(schema, conf) | 缓存已编译的校验器。 |
table.insert / table.new / table.clear | core.table.insert / core.table.new / core.table.clear | core.table 也代理标准 table 库。 |
ngx.timer.every(周期任务) | core.timer.new(name, cb, opts) | 在 pcall 下运行回调,以隔离错误。 |
核心库概览
core 将辅助函数分组到多个子模块中。插件开发者最常使用以下模块:
| 子模块 | 用途 |
|---|---|
core.request | 读取和修改传入的客户端 请求。 |
core.response | 设置响应头并短路请求。 |
core.ctx / ctx.var | 访问请求上下文及 NGINX 或网关变量。 |
core.log | 按日志级别记录。 |
core.json | 编码和解码 JSON,包括日志的延迟编码。 |
core.schema | 根据 JSON Schema 校验插件配置。 |
core.table | table 辅助函数:预分配、清空、计数、深拷贝和合并。 |
core.string | 高效的明文字符串辅助函数(前缀、后缀、查找)。 |
core.utils | 变量替换、DNS、UUID 等通用辅助函数。 |
core.lrucache | 以插件配置为键的 Worker 级 LRU 缓存。 |
core.timer | 托管的后台定时器。 |
读取和修改请求
core.request 中的大多数函数都以 ctx 作为第一个参数。
function _M.access(conf, ctx)
-- 读取请求头(不区分大小写)和查询参数。
local token = core.request.header(ctx, "Authorization")
local args = core.request.get_uri_args(ctx)
-- 在请求代理到上游之前设置请求头。
core.request.set_header(ctx, "X-Consumer-Tier", conf.tier)
end
关键函数:
core.request.header(ctx, name):读取单个请求头值,不区分大小写。core.request.headers(ctx):以table返回所有请求头,键为小写。core.request.set_header(ctx, name, value):设置请求头;传入value = nil可将其删除。core.request.get_uri_args(ctx):获取解析后的查询参数。core.request.get_body():以字符串返回原始请求体。它可能返回nil(例如没有请求体时),使用前必须检查。core.request.get_method():获取 HTTP 方法。core.request.get_remote_client_ip(ctx):获取下游客户端 IP。
读取 JSON 请求体十分常见,因此提供了专用辅助函数:
local body, err = core.request.get_json_request_body_table()
if not body then
core.log.warn("failed to read JSON body: ", core.json.delay_encode(err))
return 400, { message = "invalid request body" }
end
设置响应并短路请求
core.response 的响应头设置函数不接收 ctx。
要在插件内停止请求并返回响应,请返回状态码和响应体。插件运行器会把返回的元组转换为响应,因此通常无需直接调用 core.response.exit:
function _M.access(conf, ctx)
if not is_authorized(ctx) then
core.response.set_header("WWW-Authenticate", "ApiKey")
return 401, { message = "unauthorized" }
end
end
table 响应体会自动编码为 JSON。如果自行调用 core.response.exit,必须返回其结果:
return core.response.exit(429, { message = "rate limit exceeded" })
要在 header_filter 阶段修改响应头,请使用 core.response.set_header 和 core.response.add_header。如果插件重写响应体,请在 header_filter 中调用 core.response.clear_header_as_body_modified(),在客 户端收到已变化的响应体前清除不再匹配的 Content-Length、Content-Encoding、ETag 和 Last-Modified。
访问上下文变量
ctx.var.<name> 通过延迟加载且带缓存的统一接口读取 NGINX 变量和网关专用变量,应优先于 ngx.var 使用:
function _M.log(conf, ctx)
core.log.info("request to ", ctx.var.uri,
" matched route ", ctx.var.route_id,
" for consumer ", ctx.var.consumer_name)
end
除原始 NGINX 变量外,ctx.var 还提供 route_id、route_name、service_id、service_name、consumer_name、balancer_ip 和 balancer_port 等网关值。首次访问后会缓存读取结果,因此重复查找成本很低。
使用正确的日志级别
core.log 提供 error、warn、info 和 debug,以及较少使用的 notice、crit、alert。网关会过滤低于已配置错误日志级别的消息,因此生产环境级别设为 warn 时,core.log.info 调用几乎没有成本。
请将值作为独立参数传入而非拼接,并对 table 使用 core.json.delay_encode,使 JSON 只在实际写入日志时生成:
-- 推荐:如果未启用 info 日志,则不执行任何操作。
core.log.info("plugin conf: ", core.json.delay_encode(conf))
-- 避免:即使日志被丢弃,每个请求仍会执行编码和拼接。
core.log.info("plugin conf: " .. core.json.encode(conf))
需要运维人员处理的情况使用 error,可恢复问题使用 warn,诊断信息使用 info 或 debug。
编码和解码 JSON
core.json.encode(data):编码为 JSON 字符串。传入core.json.encode(data, true)可强制序列化通常无法编码的值。core.json.decode(str):解码;失败时返回nil, err,因此应检查两个返回值。core.json.delay_encode(data):为日志行延迟编码。core.json.canonical_encode(data):生成确定性的按键排序输出,适用于缓存键或签名。
使用 table 和字符串
core.table 代理标准 table 库并增加辅助函数:
core.table.new(narr, nrec):已知大小时预先分配table。core.table.clear(t):无需重新分配即可复用table。core.table.nkeys(t):统计键数。core.table.insert/core.table.insert_tail:追加一个或多个值。core.table.deepcopy(t)和core.table.clone(t):复制不得修改的配置。core.table.try_read_attr(t, "a", "b", "c"):安全读取嵌套字段,任一级缺失时返回nil。
core.string 代理标准 string 库并增加高效的明文辅助函数:
core.string.has_prefix(s, prefix)和core.string.has_suffix(s, suffix):检查前缀和后缀。core.string.find(haystack, needle):明文查找,不是 Lua 模式匹配。
使用 Schema 校验配置
每个插件都必须定义 JSON Schema 和 check_schema 函数。Schema 是插件的契约:它会在写入时拒绝无效配置、应用默认值并说明允许的字段。
local schema = {
type = "object",
properties = {
tier = { type = "string", enum = { "free", "pro" }, default = "free" },
timeout = { type = "integer", minimum = 1, default = 30 },
},
required = { "tier" },
}
function _M.check_schema(conf)
return core.schema.check(schema, conf)
end
建议:
-
使用
enum、minimum、maximum、minLength、pattern和required约束值。Schema 执行的约束越多,处理函数需要的防御性代码越少。 -
设置合理的
default值,使运维人员只需配置必填内容。 -
由 Schema 负责校验。值通过
check_schema后,不要在处理函数中再次限制或检查。 -
如果插件还有消费者或元数据配置,请根据 Schema 类型分派:
function _M.check_schema(conf, schema_type)if schema_type == core.schema.TYPE_METADATA thenreturn core.schema.check(metadata_schema, conf)endreturn core.schema.check(schema, conf)end
选择正确的阶段和优先级
请在能够获得所需数据的最早阶段运行逻辑:
| 阶段 | 用途 |
|---|---|
rewrite | 路由匹配完成前重写 URI 或请求行。 |
access | 身份认证、授权、限流和请求修改。 |
before_proxy | 请求发往上游前的最后修改。 |
header_filter | 读取或修改响应头。 |
body_filter | 读取或修改响应体。 |
log | 响应发送后记录指标和日志。 |
优先级决定插件在同一阶段的执行顺序:数值越大越先执行。如果插件依赖其他插件,请相应设置优先级。例如,读取已通过身份认证的消费者的插件必须在身份认证插件之后运行,因此需要更低的优先级。请选择不与所依赖内置插件冲突的优先级,并在插件投入使用后保持稳定。
安全处理错误和 nil
- 检查所有可能为
nil的值:core.request.get_body()、core.json.decode()和请求头读取都可能返回nil。 - 返回状态码和响应体以干净地拒绝请求(
return 400, { message = "..." }),不要直接调用ngx.exit。 - 对预期情况不要在阶段处理函数中调用
error()。请记录问题并返回受控响应,避免单个异常请求变成500。
性能实践
插件代码会在每个匹配请求上运行,因此微小的低效也会不断累积:
-
使用
core.lrucache缓存每份配置对应的高成本工作。编译模式、构建查找表或解析 凭证列表应针对每份插件配置执行一次,而不是每个请求执行一次:local lrucache = core.lrucache.new({ ttl = 300, count = 512 })local function create_matcher(conf)-- 开销较大:每个唯一配置只运行一次return build_matcher(conf.rules)endfunction _M.access(conf, ctx)local matcher = lrucache(conf.rules, nil, create_matcher, conf)-- ...end -
**不要阻塞事件循环。**切勿调用阻塞式操作系统函数或
os.execute。网络或存储访问请使用非阻塞 OpenResty API 和lua-resty-*库;主动延迟请使用会让出执行权而非阻塞 Worker 的core.sleep。 -
**复用
table。**在代码热路径中使用core.table.new和core.table.clear,减少垃圾回收。 -
**避免在
body_filter中执行繁重工作。**它会对每个响应块运行一次。只在必要时进行缓冲,并尽量减少每个块的处理。
仍需使用原始 ngx 的情况
core 并未封装所有功能。以下原始 API 是惯用选择,内置插件中也广泛使用:
-
body_filter中的ngx.arg[1]和ngx.arg[2]分别表示当前响应体块和流结束标记。要处理完整响应体,请使用core.response.hold_body_chunk(ctx)缓冲数据块;最终块到达前该函数返回nil:function _M.body_filter(conf, ctx)local body = core.response.hold_body_chunk(ctx)if not body thenreturn -- 还有更多数据块endngx.arg[1] = transform(body)end -
ngx.re.match/ngx.re.gsub/ngx.re.find:正则表达式,没有对应的core.re。 -
ngx.shared.<dict>:用于跨 Worker 状态的共享内存字典,没有对应的core.shared。必须在网关配置中声明字典。 -
lua-resty-*库和非阻塞 Socket API:让出执行权而不是阻塞 Worker 的网络客户端。 -
ngx.timer.at:一次性定时器可直接使用;周期性后台任务应优先使用core.timer.new。
常见陷阱
- **必须返回
core.response.exit。**使用return core.response.exit(...),或返回code, body元组。仅调用而不返回无法可靠地停止请求。 - **即使没有错误,
core.request.get_body()也可能返回nil。**请处理空请求体。 - **
ctx.var写入受限。**仅对网关允许写入的变量,ctx.var.x = v才会更新真实 NGINX 变量;否则只更新缓存。 - **不要从一个已上传的自定义插件
require另一个。**已上传插件不在模块搜索路径中,也不保证按依赖顺序加载。请将共享代码放到 Lua 包路径,详见开发自定义 Lua 插件。 core.string.find是明文查找,不是模式匹配;第一个参数不是字符串时会报错。- **响应体开始发送后不要设置响应头。**响应头已发送后,
core.response.set_header会抛出错误。
测试和调试
- 上传前检查 Lua 语法错误:
luac -p my-plugin.lua。 - 按照开发自定义 Lua 插件在路由上验证行为。
- 在网关节点的
error.log(默认/usr/local/apisix/logs/error.log)中查看core.log输出。 - 先发布到测试网关组,再推广到生产环境,详见开发自定义 Lua 插件。
后续步骤
- 开发自定义 Lua 插件——创建、注册和测试插件的端到端流程。
- Serverless 函数还是自定义插件——为用例选择合适的扩展机制。
- 插件——插件执行顺序和作用域。