跳到主要内容
版本: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 设置请求头时,其他插件读取的缓存视图会保持正确;混用原始 ngx.req.set_header 可能留下过时缓存值。
  • **处理不便的默认行为。**例如,core.request.get_uri_args 读取参数时不设截断限制,而 ngx.req.get_uri_args() 会静默限制为 100 个参数。
  • **集中行为。**日志、JSON 编码和 Schema 校验通过统一入口执行,使级别过滤、错误处理和格式保持一致。

ngxcore 的映射

应优先使用对应的 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_idconsumer_name 等网关变量。
ngx.header[k] = vcore.response.set_header(k, v)防止在响应头已发送后继续设置。
ngx.say / ngx.print / ngx.exitcore.response.exit(code, body)Table 响应体会编码为 JSON;必须返回调用结果。
cjson.encode / cjson.decodecore.json.encode / core.json.decode输入无效时,decode 返回 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.request读取和修改传入的客户端请求。
core.response设置响应头并短路请求。
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)
-- 读取请求头(不区分大小写)和查询参数。
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_headercore.response.add_header。如果插件重写响应体,请在 header_filter 中调用 core.response.clear_header_as_body_modified(),在客户端收到已变化的响应体前清除不再匹配的 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。网关会过滤低于已配置错误日志级别的消息,因此生产环境级别设为 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):生成确定性的按键排序输出,适用于缓存键或签名。

使用 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

建议:

  • 使用 enumminimummaximumminLengthpatternrequired 约束值。Schema 执行的约束越多,处理函数需要的防御性代码越少。

  • 设置合理的 default 值,使运维人员只需配置必填内容。

  • 由 Schema 负责校验。值通过 check_schema 后,不要在处理函数中再次限制或检查。

  • 如果插件还有消费者或元数据配置,请根据 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读取或修改响应头。
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)
    end

    function _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.newcore.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 then
    return -- more chunks to come
    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。**使用 return core.response.exit(...),或返回 code, body 元组。仅调用而不返回无法可靠地停止请求。
  • **即使没有错误,core.request.get_body() 也可能返回 nil。**请处理空请求体。
  • **ctx.var 写入受限。**仅对网关允许写入的变量,ctx.var.x = v 才会更新真实 NGINX 变量;否则只更新缓存。
  • **不要从一个已上传的自定义插件require 另一个。**已上传插件不在模块搜索路径中,也不保证按依赖顺序加载。请将共享代码放到 Lua 包路径,详见添加依赖库
  • core.string.find 是明文查找,不是模式匹配;第一个参数不是字符串时会报错。
  • **响应体开始发送后不要设置响应头。**响应头已发送后,core.response.set_header 会抛出错误。

测试和调试

  • 上传前检查 Lua 语法错误:luac -p my-plugin.lua
  • 按照测试插件在路由上验证行为。
  • 在网关节点的 error.log(默认 /usr/local/apisix/logs/error.log)中查看 core.log 输出。
  • 先发布到测试网关组,再推广到生产环境,详见跨网关组发布

后续步骤