跳到主要内容
版本:3.10.x

插件开发最佳实践

本指南汇总了让自定义 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 设置了 header,其他插件读到的缓存视图仍然正确;混用裸 ngx.req.set_header 可能会留下过期的缓存值。
  • 替你处理别扭的默认值。 例如 core.request.get_uri_args 读取参数时不做截断,而 ngx.req.get_uri_args() 会在 100 个参数处静默截断。
  • 收敛行为。 日志、JSON 编码与 schema 校验都走统一入口,因此级别过滤、错误处理和格式化都是一致的。

ngxcore 的对照

优先选用 core 等价物。下表覆盖了你最常用到的替换。

ngx API优先使用的 core API说明
ngx.log(ngx.ERR, ...)core.log.error(...).warn.info.debug自动按级别过滤;传多个参数而不是先拼接。
ngx.req.get_headers()core.request.headers(ctx)ctx 上缓存;key 转小写以支持大小写不敏感查找。
ngx.req.get_headers()[name]core.request.header(ctx, name)大小写不敏感;自动展开多值 header。
ngx.req.set_header(k, v)core.request.set_header(ctx, k, v)同时会让缓存的 header 视图失效。
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()也会读取落到临时文件的 body;仍可能返回 nil
ngx.req.get_method()core.request.get_method()
ngx.var.<name>ctx.var.<name>惰性且带缓存;还暴露 route_idconsumer_name 等网关变量。
ngx.header[k] = vcore.response.set_header(k, v)会防护 header 已发送的情况。
ngx.say / ngx.print / ngx.exitcore.response.exit(code, body)table 类型的 body 会被 JSON 编码;调用必须被 return
cjson.encode / cjson.decodecore.json.encode / core.json.decodedecode 在输入非法时返回 nil, err
日志中的 cjson.encodecore.json.delay_encode(data)仅当日志行真正输出时才编码。
JSON Schema 校验core.schema.check(schema, conf)缓存编译后的校验器。
table.insert / table.new / table.clearcore.table.insert / core.table.new / core.table.clearcore.table 也代理了标准 table 库。
ngx.timer.every(周期任务)core.timer.new(name, cb, opts)pcall 中运行回调以隔离错误。

core 库速览

core 把辅助函数按子模块归类。插件作者最常用的有:

子模块用途
core.request读取和修改进入的客户端请求。
core.response设置响应 header 并短路请求。
core.ctx / ctx.var访问请求上下文以及 Nginx 或网关变量。
core.log带级别的日志。
core.json编解码 JSON,含用于日志的惰性编码。
core.schema用 JSON schema 校验插件配置。
core.tabletable 辅助:预分配、清空、计数、深拷贝、合并。
core.string快速的纯文本字符串辅助(前缀、后缀、查找)。
core.utils杂项辅助:变量替换、DNS、UUID。
core.lrucache每 worker 的 LRU 缓存,按插件配置作为键。
core.timer受管理的后台定时器。

读取和修改请求

core.request 中的大多数函数第一个参数都是 ctx

function _M.access(conf, ctx)
-- 读取一个 header(大小写不敏感)和一个查询参数。
local token = core.request.header(ctx, "Authorization")
local args = core.request.get_uri_args(ctx)

-- 在请求被代理到上游前设置一个请求 header。
core.request.set_header(ctx, "X-Consumer-Tier", conf.tier)
end

关键函数:

  • core.request.header(ctx, name) —— 单个 header 值,大小写不敏感。
  • core.request.headers(ctx) —— 所有 header,返回 key 为小写的 table。
  • core.request.set_header(ctx, name, value) —— 设置 header;传 value = nil 可删除它。
  • core.request.get_uri_args(ctx) —— 解析后的查询参数。
  • core.request.get_body() —— 原始请求 body 字符串。它可能返回 nil(例如没有 body 时),使用前务必判空。
  • core.request.get_method() —— HTTP 方法。
  • core.request.get_remote_client_ip(ctx) —— 下游客户端 IP。

以 JSON 读取请求 body 很常见,有专门的辅助函数:

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 上的 header setter 接受 ctx

要在插件内部终止请求并返回响应,直接返回状态码和 body。插件运行器会把返回的这对值转成响应,因此你很少需要直接调用 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 类型的 body 会自动 JSON 编码。如果你自己调用 core.response.exit,必须把它的结果 return

return core.response.exit(429, { message = "rate limit exceeded" })

要在 header_filter 阶段修改响应 header,使用 core.response.set_headercore.response.add_header。如果插件重写了响应 body,请在 header_filter 中调用 core.response.clear_header_as_body_modified(),以便在客户端看到与之不再匹配的 body 之前,清除 Content-LengthContent-EncodingETagLast-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_idroute_nameservice_idservice_nameconsumer_namebalancer_ipbalancer_port 等网关值。首次访问后结果被缓存,重复查找的开销很低。

用合适的级别打日志

core.log 提供 errorwarninfodebug(以及较少用的 noticecritalert)。网关会过滤掉低于配置的 error-log 级别的消息,因此当级别设为 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,对诊断信息用 infodebug

编解码 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) —— 确定性的、按 key 排序的输出,适合做缓存键或签名。

处理 table 和字符串

core.table 代理标准 table 库并增加了一些辅助:

  • core.table.new(narr, nrec) —— 已知大小时提前分配 table。
  • core.table.clear(t) —— 复用 table 而不重新分配。
  • core.table.nkeys(t) —— 统计 key 数量。
  • 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

建议:

  • enumminimummaximumminLengthpatternrequired 约束取值。schema 约束得越多,handler 里需要的防御性代码就越少。

  • 设置合理的 default,让运维只需配置必要的字段。

  • 让 schema 承担校验。一个值一旦通过了 check_schema,就不要在 handler 里再次 clamp 或重复校验。

  • 如果插件还有 consumer 或 metadata 配置,按 schema 类型分派:

    function _M.check_schema(conf, schema_type)
    if schema_type == core.schema.TYPE_METADATA then
    return core.schema.check(metadata_schema, conf)
    end
    return core.schema.check(schema, conf)
    end

选择正确的阶段和优先级

在拥有所需数据的最早阶段执行你的逻辑:

阶段适用于
rewrite在路由匹配完成前重写 URI 或请求行。
access认证、鉴权、限流以及请求改写。
before_proxy请求发往上游前的最后时刻改动。
header_filter读取或修改响应 header。
body_filter读取或修改响应 body。
log响应发送后记录指标和日志。

优先级决定同一阶段内插件的执行顺序:数值越高越先执行。当插件依赖另一个插件时,要相应设置优先级——例如,一个需要读取已认证 consumer 的插件必须在认证插件之后运行,因此它的优先级要更低。选一个不与你依赖的内置插件冲突的优先级,并在插件投入使用后保持稳定。

安全地处理错误与 nil

  • 对每个可能为 nil 的值都做检查:core.request.get_body()core.json.decode() 和 header 读取都可能返回 nil
  • 用返回状态码和 body 的方式干净地拒绝请求(return 400, { message = "..." });不要直接调用 ngx.exit
  • 不要因为预期内的情况就从阶段 handler 里 error()。记录问题并返回一个可控的响应,避免一个坏请求变成 500

性能实践

插件代码在每个匹配请求上都会执行,因此微小的低效会累积:

  • core.lrucache 缓存昂贵的、与配置相关的工作。 编译一个模式、构建一张查找表、解析一份凭证列表,应该按插件配置只做一次,而不是每个请求都做一次:

    local lrucache = core.lrucache.new({ ttl = 300, count = 512 })

    local function create_matcher(conf)
    -- 昂贵:每个唯一的 conf 只执行一次
    return build_matcher(conf.rules)
    end

    function _M.access(conf, ctx)
    local matcher = lrucache(conf.rules, nil, create_matcher, conf)
    -- ...
    end
  • 不要阻塞事件循环。 绝不调用阻塞的 OS 函数或 os.execute。网络或存储访问要使用非阻塞的 OpenResty API 和 lua-resty-* 库。需要故意延时时用 core.sleep,它会让出而非阻塞 worker。

  • 复用 table。 在热路径上用 core.table.newcore.table.clear 减少 GC 压力。

  • 避免在 body_filter 里做重活。 它会对每个响应分片各执行一次。仅在必须时才缓冲(见下文),并把每个分片的工作量降到最低。

何时仍需裸 ngx

core 并未封装一切。下列裸 API 是惯用选择,也贯穿于各内置插件:

  • body_filter 中的 ngx.arg[1]ngx.arg[2] —— 当前 body 分片和流结束标志。要处理完整 body,用 core.response.hold_body_chunk(ctx) 缓冲分片,它在最后一个分片到达前返回 nil

    function _M.body_filter(conf, ctx)
    local body = core.response.hold_body_chunk(ctx)
    if not body then
    return -- 还有分片未到
    end
    ngx.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 必须被 returnreturn core.response.exit(...),或返回 code, body 这对值。裸调用不能可靠地终止请求。
  • core.request.get_body() 可能返回 nil,即使没有错误。要处理 body 为空的情况。
  • ctx.var 写入是受限的。 赋值 ctx.var.x = v 只会对网关允许写入的变量更新真正的 Nginx 变量;否则只更新缓存。
  • 不要从一个上传的自定义插件 require 另一个。 上传的插件不在模块搜索路径上,也不保证按依赖顺序加载。请把共享代码放到 Lua 包搜索路径上——见添加依赖库
  • core.string.find 是纯文本查找,不是模式匹配,且当第一个参数不是字符串时会报错。
  • 不要在 body 已开始发送后设置响应 header。 一旦 header 已发送,core.response.set_header 会报错。

测试与调试

  • 上传前检查 Lua 语法错误:luac -p my-plugin.lua
  • 在路由上验证行为,参考添加自定义插件
  • 查看网关节点上的 error.log(默认 /usr/local/apisix/logs/error.log)以获取你的 core.log 输出。
  • 先部署到测试网关组,再推广到生产——见在网关组之间灰度发布

相关文档