凌晨2点,生产环境告警。一个刚刚上线的 Kong 自定义插件导致了部分核心 API 流量的5xx错误风暴。经过紧张的排查,问题定位到一个极其微不足道的原因:在一个鲜少被触发的逻辑分支里,一个变量被错误地写成了 ngx.ctx.flwo_id 而不是 ngx.ctx.flow_id。这个简单的拼写错误,绕过了单元测试,躲过了 Code Review,最终在生产环境中造成了损失。这次事故迫使我们团队正视一个长期被忽视的问题:Kong 的 Lua 插件开发,在代码质量保障方面,几乎还处于刀耕火种的时代。
我们团队的技术栈以 JavaScript/TypeScript 为主,早已习惯了 ESLint、Prettier、Jest 这一套成熟的工具链。每次提交代码,CI/CD 流水线中的静态检查关卡都能拦截掉大量的低级错误和不规范的写法。然而,当我们切换到为 Kong 编写 Lua 插件时,这种现代化的开发体验便荡然无存。团队成员依赖的只是个人的细心和 Code Review,但这显然无法系统性地保证代码质量。
初步的构想是引入 Lua 社区的静态检查工具,比如 luacheck。它确实能解决一部分问题,比如未定义的变量。但它有两个核心缺陷:首先,它无法与我们现有的、基于 Node.js 的前端和后端工程体系无缝融合,我们需要为它单独配置环境和 CI 流程,增加了维护成本。其次,更重要的是,luacheck 的规则扩展能力远不如 ESLint 生态强大。我们需要的不仅仅是语法检查,更是能够强制执行团队内部针对 Kong 开发制定的最佳实践。例如,我们规定日志必须使用 kong.log.err() 而非 print(),或者在插件的 access 阶段必须检查上游服务的健康状态。这些业务相关的规则,luacheck 难以胜任。
一个大胆的想法浮出水面:我们能否用我们最熟悉的 ESLint 来检查 Lua 代码?这听起来有些异想天开,毕竟 ESLint 是为 JavaScript 而生的。但经过一番探索,我们找到了一个关键的桥梁:eslint-plugin-lua。这个插件通过将 Lua 代码解析成 ESTree 兼容的抽象语法树(AST),使得 ESLint 核心引擎能够理解并检查 Lua 代码。这彻底改变了游戏规则。它意味着我们可以将 Kong Lua 插件的开发,完全纳入到我们现有的、成熟的 Node.js 工具链中。
第一步:搭建基础环境
我们的目标是创建一个集中的、可复用的 linting 配置,供所有 Kong 插件项目使用。首先,初始化一个新的 Node.js 项目作为我们的 linting 工具库。
# 创建项目目录
mkdir kong-lua-linter
cd kong-lua-linter
# 初始化 package.json
npm init -y
# 安装核心依赖
npm install eslint eslint-plugin-lua @babel/eslint-parser --save-dev
-
eslint: ESLint 核心。 -
eslint-plugin-lua: 关键插件,让 ESLint 支持 Lua。 -
@babel/eslint-parser:eslint-plugin-lua推荐使用的解析器,因为它能更好地处理一些非标准的语法结构。
接下来,创建我们的核心配置文件 .eslintrc.js。这是一个起点,我们将逐步完善它。
// .eslintrc.js
module.exports = {
// 指定解析器
parser: '@babel/eslint-parser',
// 使用 eslint-plugin-lua 提供的推荐配置
extends: ['plugin:lua/recommended'],
// 插件列表
plugins: ['lua'],
// 全局环境设置
env: {
browser: false,
node: false, // 明确指出这不是 Node.js 环境
es6: false,
},
// 解析器选项
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
requireConfigFile: false, // 禁用 Babel 配置文件查找
},
// 规则配置
rules: {
// 这里可以覆盖或添加自定义规则
},
};
现在,让我们用一个有问题的 Kong 插件代码片段来测试一下效果。
-- file: ./kong/plugins/my-auth/handler.lua
local BasePlugin = require "kong.plugins.base_plugin"
local MyAuthHandler = BasePlugin:extend()
MyAuthHandler.PRIORITY = 1000
MyAuthHandler.VERSION = "1.0.0"
function MyAuthHandler:new()
MyAuthHandler.super.new(self, "my-auth")
end
function MyAuthHandler:access(conf)
MyAuthHandler.super.access(self)
local authorization = kong.request.get_header("Authorization")
if not authorization then
-- 故意引入一个未定义的变量
return kong.response.exit(401, { message = "Unauthorized Request" }, unknow_variable)
end
-- 使用了 print 函数,这在生产中是不推荐的
print("Authorization header found")
local user_id = self:validate_token(authorization)
if not user_id then
return kong.response.exit(403, { message = "Invalid Token" })
end
kong.ctx.shared.user_id = user_id
end
return MyAuthHandler
在 package.json 中添加一个 lint 脚本:
{
"name": "kong-lua-linter",
"version": "1.0.0",
"scripts": {
"lint": "eslint --ext .lua ."
},
"devDependencies": {
"@babel/eslint-parser": "^7.22.15",
"eslint": "^8.51.0",
"eslint-plugin-lua": "^1.4.0"
}
}
运行 npm run lint,我们得到了预期的错误:
/path/to/project/kong/plugins/my-auth/handler.lua
18:71 error 'unknow_variable' is not defined lua/no-undef
22:3 error 'print' is not defined lua/no-undef
✖ 2 problems (2 errors, 0 warnings)
lua/no-undef 规则成功捕获了未定义的变量 unknow_variable。但是,它也错误地将 print 标记为未定义。更严重的是,它完全没有意识到 kong 这个全局对象。如果我们在代码中使用 ngx,同样会报错。这是因为 ESLint 默认不知道 Kong 插件运行时的特定环境。
第二步:定制 Kong 全局环境
为了让 ESLint 理解 Kong 的执行上下文,我们需要在 .eslintrc.js 中定义所有 Kong 插件可以访问的全局变量。这包括 LuaJIT 的内建函数、Nginx Lua 模块的 ngx 对象,以及 Kong 注入的 kong 对象。
这是一个真实项目中整理出的比较完整的全局变量列表:
// .eslintrc.js
module.exports = {
// ... 其他配置 ...
globals: {
// Lua Built-ins (部分)
'assert': 'readonly',
'collectgarbage': 'readonly',
'dofile': 'readonly',
'error': 'readonly',
'getmetatable': 'readonly',
'ipairs': 'readonly',
'load': 'readonly',
'loadfile': 'readonly',
'next': 'readonly',
'pairs': 'readonly',
'pcall': 'readonly',
'print': 'readonly', // 尽管我们不推荐使用,但它确实是全局存在的
'rawequal': 'readonly',
'rawget': 'readonly',
'rawlen': 'readonly',
'rawset': 'readonly',
'require': 'readonly',
'select': 'readonly',
'setmetatable': 'readonly',
'tonumber': 'readonly',
'tostring': 'readonly',
'type': 'readonly',
'xpcall': 'readonly',
'_G': 'readonly',
'_VERSION': 'readonly',
// Lua Standard Libraries
'coroutine': 'readonly',
'string': 'readonly',
'table': 'readonly',
'math': 'readonly',
'io': 'readonly',
'os': 'readonly',
'package': 'readonly',
'debug': 'readonly',
// LuaJIT specific
'jit': 'readonly',
'bit': 'readonly',
'ffi': 'readonly',
// OpenResty / ngx_lua
'ngx': 'readonly',
'ndk': 'readonly',
// Kong Globals
'kong': 'readonly',
},
rules: {
// ...
}
};
配置好 globals 后,再次运行 npm run lint:
/path/to/project/kong/plugins/my-auth/handler.lua
18:71 error 'unknow_variable' is not defined lua/no-undef
✖ 1 problem (1 error, 0 warnings)
现在,print 和 kong 不再报错了,只剩下我们真正关心的 unknow_variable。问题解决了一半。
第三步:编写自定义规则,强制团队最佳实践
静态检查的真正威力在于能够编码团队的领域知识和最佳实践。我们的痛点之一就是开发人员滥用 print 进行调试,并将这些代码遗留到生产环境,这会降低性能并产生大量无用日志。我们的规范是:所有日志输出必须通过 kong.log 系列函数。
为了在 CI 中强制执行这条规则,我们需要编写一个自定义的 ESLint 规则。
首先,创建规则文件。在我们的 kong-lua-linter 项目中新建一个目录 rules,并创建文件 no-global-print.js。
// rules/no-global-print.js
/**
* @fileoverview Disallows the use of the global `print` function.
* @author Your Team Name
*/
"use strict";
module.exports = {
// 规则元数据
meta: {
type: "problem", // 这是一类问题,不是格式建议
docs: {
description: "Disallow the use of the global `print` function in favor of `kong.log`",
category: "Best Practices",
recommended: true, // 建议在推荐配置中启用
url: "https://your-internal-docs/eslint-rule-no-global-print", // 指向内部文档
},
fixable: "code", // 此规则不可自动修复
schema: [], // 此规则没有选项
messages: {
// 错误信息模板
avoidPrint: "Do not use global `print`. Use `kong.log()` or `kong.log.err()` instead.",
},
},
// 规则的创建函数
create(context) {
return {
// 访问者模式:定义对特定 AST 节点的处理函数
// 我们要寻找的是函数调用表达式
CallExpression(node) {
// 检查被调用的对象是否是一个标识符 (Identifier)
// 并且该标识符的名字是否是 'print'
if (node.callee.type === 'Identifier' && node.callee.name === 'print') {
// 在 ESLint 的作用域分析中,检查 'print' 是否被重新定义过
// 如果它引用的是全局变量,scope.through 将会包含它
const scope = context.getScope();
const printVar = scope.set.get('print');
// 如果 printVar 未定义或其定义为空,说明它可能是一个未在当前作用域声明的全局变量
// 这是判断它是否为全局 print 的一个可靠方式
if (!printVar || printVar.defs.length === 0) {
context.report({
node: node.callee, // 高亮 'print' 这个标识符
messageId: "avoidPrint", // 使用 meta 中定义的消息
});
}
}
},
};
},
};
这个规则的核心是 AST 遍历。ESLint 将代码解析成 AST,我们的规则就像一个访问者,遍历这棵树。当遇到 CallExpression (函数调用) 节点时,就检查被调用的函数 callee 是不是名为 print 的 Identifier。同时,通过 context.getScope() 确保我们捕获的是全局的 print 调用,而不是用户自定义的同名局部函数。
为了启用这个规则,我们需要做两件事:
- 创建一个插件来承载我们的自定义规则。
- 在
.eslintrc.js中配置使用这个插件和规则。
创建一个简单的插件入口文件 index.js:
// index.js (in the root of kong-lua-linter)
"use strict";
module.exports = {
rules: {
"no-global-print": require("./rules/no-global-print"),
},
};
然后,修改 .eslintrc.js,将其变成一个可共享的配置。我们将这个项目本身作为一个 ESLint 插件来使用。
// .eslintrc.js
module.exports = {
// ... 其他配置 ...
plugins: ['lua', 'custom-kong'], // 添加我们的自定义插件
// ... 全局变量 ...
rules: {
// 启用我们的自定义规则,并设置为 error 级别
'custom-kong/no-global-print': 'error',
// 也可以调整其他 lua 插件的规则
'lua/no-undef': 'error',
'lua/no-unused-vars': 'warn', // 未使用的变量给警告而不是错误
},
};
为了让 ESLint 找到我们的 custom-kong 插件,我们需要在 package.json 中声明它,并通过 npm link 或发布到私有 npm registry 的方式让其他项目使用。在一个单体仓库中,可以直接通过相对路径引用。
现在,当 ESLint 运行时,它会加载我们的自定义规则。再次对之前的 handler.lua 文件运行检查:
/path/to/project/kong/plugins/my-auth/handler.lua
18:71 error 'unknow_variable' is not defined lua/no-undef
22:3 error Do not use global `print`. Use `kong.log()` or `kong.log.err()` instead. custom-kong/no-global-print
✖ 2 problems (2 errors, 0 warnings)
成功了!我们不仅捕获了未定义变量,还强制执行了团队的日志规范。
第四步:集成到 CI/CD 流水线
建立起强大的本地 linting 能力只是第一步,真正的价值在于将其自动化,成为代码合入主干前的强制卡点。下面是一个在 GitLab CI 中集成的示例。
# .gitlab-ci.yml
stages:
- lint
- test
- deploy
variables:
# 使用 Node.js 18 的镜像
NODE_IMAGE: node:18-slim
# 定义一个可复用的 job 模板,用于安装 Node.js 依赖
.node_dependencies:
image: ${NODE_IMAGE}
before_script:
# 集中管理 linting 工具,在 CI 中安装
- npm ci --prefix ./tools/kong-lua-linter
cache:
key:
files:
- ./tools/kong-lua-linter/package-lock.json
paths:
- ./tools/kong-lua-linter/node_modules/
# Linting 作业
lint-kong-plugins:
stage: lint
extends: .node_dependencies
script:
# 进入 linting 工具目录,执行 lint 命令
# --ext .lua 指定只检查 lua 文件
# ../kong/plugins/ 指向我们的插件代码目录
- cd ./tools/kong-lua-linter && npm run lint -- ../kong/plugins/
rules:
# 仅在合并请求或 master 分支上有变更时运行
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
# ... 后续的 test 和 deploy stages ...
这里的 CI/CD 流程设计体现了几个关键思想:
- 阶段化构建:
lint阶段是所有阶段的第一步。如果代码风格或质量不符合规范,流水线会立即失败,开发者会收到快速反馈,避免了在运行耗时更长的单元测试或集成测试后才发现问题。 - 依赖缓存: 通过缓存
node_modules目录,后续的 CI 作业可以显著提速。 - 职责分离:
kong-lua-linter作为一个独立的工具包存在,所有 Kong 插件的 linting 都依赖它。当我们需要更新 linting 规则时,只需修改这一个地方,所有插件项目都能自动享受到更新,极大地降低了维护成本。
下面是整个工作流的示意图:
graph TD
A[Developer Pushes Code] --> B{GitLab CI Pipeline Triggered};
B --> C[Stage: lint];
C --> D{Run lint-kong-plugins job};
D -- Linting Errors --> E[Pipeline Fails & Notifies Developer];
D -- No Errors --> F[Stage: test];
F --> G[Run Unit & Integration Tests];
G -- Tests Fail --> E;
G -- Tests Pass --> H[Stage: deploy];
H --> I[Deploy to Staging/Production];
通过这套机制,任何带有低级错误或违反团队规范的 Lua 代码都将在合并请求阶段被自动拦截,从根本上杜绝了类似开头提到的那种低级错误流入生产环境的可能性。
方案的局限性与未来展望
尽管这套基于 ESLint 的方案极大地提升了我们 Kong Lua 插件的开发质量和效率,但它并非完美无瑕。eslint-plugin-lua 是一个社区驱动的项目,其对 Lua 语言特性(尤其是 LuaJIT FFI 等高级特性)的支持可能不如专门的 Lua 工具链完善。它依赖于将 Lua AST 转换为 ESTree 兼容格式,这个转换过程本身可能存在信息损失或边界情况下的解析错误。在真实项目中,我们确实遇到过一些复杂的元编程写法导致解析失败的情况,需要对代码进行微调以适应 linter。
另一个考量是性能。对于非常庞大的 Lua 代码库,通过 Node.js 运行 ESLint 的性能可能会略低于原生的 luacheck。但在 CI 环境中,这种毫秒级的差异通常可以忽略不计。
未来的迭代方向是明确的。首先,我们可以为团队贡献更多、更精细化的自定义 ESLint 规则。例如,可以编写规则来检查 Kong 插件配置 schema.lua 的正确性,或者分析插件的性能热点,比如在 access 阶段避免耗时操作。其次,可以探索将 TypeScript to Lua 的转译器(如 TypeScriptToLua)与这套 linting 体系结合。这样团队成员就可以用他们更熟悉的 TypeScript 来编写 Kong 插件,享受静态类型检查带来的好处,同时在 CI 流程中,我们依然可以用这套 ESLint 方案来检查最终生成的 Lua 代码,形成双重保障。