njs 是 NGINX 官方的 JavaScript 引擎,目标是「在 NGINX 配置里写一点逻辑」而不是「在 NGINX 里跑一个 Node 应用」。它把 JS 的执行限定在请求生命周期的若干钩子上,因此开销小、部署简单,但能力边界也明显。本文讲清 njs 的运行时约束、常用指令与真实用法,并给出它与 OpenResty/Lua 之间的取舍依据。
1. njs 是什么,不是什么
理解 njs 的第一步是分清它和 Node.js 的差异。两者虽然都是 JavaScript,但设计目标完全相反。
| 维度 | njs | Node.js |
|---|---|---|
| 执行模型 | 事件驱动、无阻塞 I/O,但单次执行必须快速返回 | 完整的事件循环与异步运行时 |
| 标准库 | 极小(无 fs、无 net、无 npm) | 完整生态 |
| 模块系统 | 自有 js_import,非 CommonJS/ESM | CommonJS/ESM |
| 启动方式 | 随 worker 进程加载 | 独立进程 |
| 与请求的关系 | 钩子式嵌入,可读写请求/响应 | 自己就是服务端 |
关键结论:njs 里没有 setTimeout、没有 Promise 的微任务队列、没有文件系统。任何需要等待外部 I/O 的操作必须通过 NGINX 提供的接口(r.subrequest、r.variables)完成,且这些接口的异步回调由 NGINX 的事件循环调度。
1.1 支持的 ES 版本与缺失特性
njs 实现了大部分 ES5.1 与部分 ES6+ 特性:箭头函数、模板字符串、解构、let/const、Map/Set、Promise(在子请求场景可用)都支持。但以下特性不可用或行为受限:
async/await:仅在子请求与js_periodic等有限场景可用,不能用于普通请求钩子。- 正则的后行断言、
String.prototype.replaceAll(新版本已支持,但需确认 njs 版本)。 Intl、Proxy、Reflect、Symbol的大部分用法。- 动态
import()。
写代码前先确认版本:
nginx -V 2>&1 | tr ' ' '\n' | grep njs
# 例如:--add-dynamic-module=/build/nginx-1.25.3/debian/modules/njs
njs -v 命令可以直接跑脚本做快速验证,不必重启 NGINX:
njs -c 'console.log([1,2,3].map(n => n * 2).join(","))'
2. 安装与最小可用配置
官方包需要额外安装 nginx-module-njs,或编译时加 --add-dynamic-module。加载后即可使用 js_import 引入脚本。
load_module modules/ngx_http_js_module.so;
events {}
http {
js_import main from /etc/nginx/njs/main.js;
server {
listen 80;
location /hello {
js_content main.hello;
}
}
}
// /etc/nginx/njs/main.js
function hello(r) {
r.headersOut['Content-Type'] = 'application/json';
r.return(200, JSON.stringify({ msg: 'hello', ua: r.headersIn['User-Agent'] }));
}
export default { hello };
js_import 的语法是 js_import <别名> from <路径>;,脚本必须用 export default { ... } 导出对象,NGINX 通过 别名.函数名 引用。导出方式写错会导致 js_content 找不到函数,报错信息里只会说 failed to evaluate,不容易定位。
3. 核心指令:js_content、js_set 与 js_body_filter
njs 提供三类主要挂载点,对应不同的使用场景。
3.1 js_content:完整接管请求
js_content 让 njs 函数完全负责生成响应,此时 location 里其他内容处理指令(proxy_pass、root)都不生效。
function route(r) {
const uri = r.uri;
if (uri.startsWith('/api/')) {
// 内部子请求转发到上游
r.subrequest('/internal/backend' + uri.slice(4), { method: r.method })
.then(res => {
r.headersOut['Content-Type'] = res.headersOut['Content-Type'] || 'application/json';
r.return(res.status, res.responseBody);
})
.catch(() => r.return(502, 'bad gateway'));
return;
}
r.return(404, 'not found');
}
export default { route };
这里体现了一个重要模式:njs 用 r.subrequest 把请求转发给其他 location,再由那个 location 的 proxy_pass 完成真正的转发。这样 njs 只做决策,不做数据传输,避免把大响应体读进 JS 字符串造成内存峰值。
r.subrequest 返回 Promise,是 njs 里少数支持异步的地方。要注意子请求会继承主请求的变量与头部,但不会自动传递请求体——需要 POST 转发时得显式构造。
3.2 js_set:把 JS 结果写进变量
js_set 在变量求值时执行 JS,结果供 if、proxy_pass、add_header 等指令使用。这是 njs 最轻量的用法。
http {
js_import util from /etc/nginx/njs/util.js;
js_set $ab_bucket util.abBucket;
js_set $cache_key util.cacheKey;
server {
listen 80;
location / {
proxy_cache_key $cache_key;
add_header X-Bucket $ab_bucket always;
proxy_pass http://backend;
}
}
}
function abBucket(r) {
// 用 cookie 或随机数做稳定分桶
const c = r.variables['cookie_bucket'];
if (c === 'a' || c === 'b') return c;
const n = Math.floor(Math.random() * 2);
return n === 0 ? 'a' : 'b';
}
function cacheKey(r) {
// 剔除营销参数,只保留业务参数
const keep = ['id', 'page', 'lang'];
const args = Object.entries(r.args)
.filter(([k]) => keep.includes(k))
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join('&');
return `${r.variables.host}${r.uri}?${args}`;
}
export default { abBucket, cacheKey };
关键性能陷阱:js_set 的变量是惰性求值的——只有在被引用时才执行。但如果同一个变量在一次请求中被多次引用(例如既用于 proxy_cache_key 又用于日志格式),njs 会缓存结果,不会重复执行。反之,若把重逻辑放进 js_set 却只在日志里用一次,性能开销就白白付出了。
3.3 js_body_filter:流式处理响应体
js_body_filter 可以在响应体流出时逐块处理,适合做内容替换而不缓冲整个响应。
location / {
js_body_filter main.rewriteBody buffer_type=buffer;
proxy_pass http://backend;
}
function rewriteBody(r, data, flags) {
if (typeof data === 'string') {
data = data.replace(/http:\/\//g, 'https://');
}
r.sendBuffer(data, flags);
}
export default { rewriteBody };
buffer_type=buffer 表示把若干块合并后再交给 JS(减少调用次数),buffer_type=string 则每块都转成字符串。逐块处理不能跨块匹配:如果替换目标被切在两个块之间,替换会失败。这是流式处理的固有局限,需要严格保证替换时要么用 buffer 合并足够大的窗口,要么改用子请求完整缓冲。
4. 与 Lua 的取舍
OpenResty 的 Lua 生态更成熟,但 njs 有它不可替代的位置。
| 维度 | njs | OpenResty / Lua |
|---|---|---|
| 安装 | 官方模块,版本与 NGINX 同步 | 需替换为 OpenResty 发行版或自编译 |
| 语言 | JavaScript(前端团队可直接写) | Lua(需专门学习) |
| 生态 | 无包管理,需自己实现 | lua-resty-* 丰富(redis、http、dns) |
| 共享内存 | js_shared_dict_zone(较新版本) | lua_shared_dict 成熟稳定 |
| 阻塞外部调用 | 不支持(无 socket 库) | cosocket 支持非阻塞 HTTP/Redis |
| 性能 | 略优于 Lua(JIT 场景相当) | LuaJIT 在热点路径更快 |
| 调试 | njs CLI + js_preload_object | 需 ngx.log 与 resty CLI |
取舍原则:
- 只做请求改写、签名校验、路由决策、简单缓存键计算 → 用 njs,不必引入 OpenResty。
- 需要访问 Redis/MySQL 做鉴权、需要复杂协程调度、需要成熟限流库 → 用 OpenResty。
- 团队是前端背景、维护成本敏感 → njs 的学习曲线几乎为零。
njs 的致命短板是不能在请求中发起任意外部网络调用。它只能通过 r.subrequest 调用本机 NGINX 的其他 location,再由那些 location 去访问外部。这意味着「查 Redis 校验 token」这类需求在纯 njs 里需要绕一圈:写一个 location /internal/auth 用 proxy_pass 指向一个本地鉴权服务,njs 再 r.subrequest 它。相比之下 OpenResty 的 resty.redis 一步到位。
Lua 侧的完整能力与实现方式参见 Nginx Lua 扩展与 OpenResty 实践 。
5. 共享内存与状态管理
跨请求的状态(如限流计数器、AB 分桶结果缓存)需要共享内存。较新版本的 njs 提供 js_shared_dict_zone。
http {
js_shared_dict_zone zone=ab:1M timeout=10s type=string;
js_import counter from /etc/nginx/njs/counter.js;
js_set $req_count counter.incr;
server {
listen 80;
location / {
add_header X-Req-Count $req_count always;
proxy_pass http://backend;
}
}
}
function incr(r) {
const dict = ngx.shared.ab;
const key = 'total';
const cur = dict.get(key) || 0;
dict.set(key, cur + 1);
return String(cur + 1);
}
export default { incr };
注意事项:
js_shared_dict_zone的type只能是string、number或object(取决于版本),number类型对计数器更省内存。timeout是条目过期时间,不是字典整体 TTL。- 共享内存不跨 reload 保留,
nginx -s reload后计数清零。需要持久化的状态必须落到外部存储。 - 共享内存是多 worker 共享的,但也意味着写入有锁竞争,高频写入会成为瓶颈。
js_preload_object 可以在启动时加载静态 JSON 配置,避免每次请求读取文件:
js_preload_object rules from /etc/nginx/njs/rules.json;
location / {
js_content main.apply;
}
function apply(r) {
const rules = ngx.shared.rules; // 由 preload 注入
// ...
r.return(200, 'ok');
}
export default { apply };
6. 性能与调试
6.1 性能开销的三个来源
- 脚本求值:每次
js_set求值都会进入 JS 引擎。热点路径上的正则匹配、JSON 解析是最常见的开销。 - 字符串与字节转换:
r.requestText、r.responseText会把整个请求/响应体读成 JS 字符串,大文件场景下内存与拷贝开销显著。 - 子请求:每次
r.subrequest都是一次完整的内部请求,会走一遍 location 匹配与变量求值。
优化手段:
- 用
js_set而非js_content处理只需一个值的场景。 - 避免在 njs 里读大 body,改用
js_body_filter流式处理。 - 把正则预编译成常量(njs 对字面量正则会缓存,但
new RegExp每次求值都会重建)。 - 用
ngx.log打点测量耗时,定位热点。
6.2 调试手段
njs 的报错通常只在 error.log 里出现一行,信息有限。有效的调试组合:
error_log /var/log/nginx/error.log info;
function debug(r) {
ngx.log(ngx.INFO, `uri=${r.uri} args=${JSON.stringify(r.args)}`);
try {
// 业务逻辑
r.return(200, 'ok');
} catch (e) {
ngx.log(ngx.ERR, `njs error: ${e.message}\n${e.stack}`);
r.return(500, 'internal error');
}
}
export default { debug };
务必用 try/catch 包裹业务逻辑。njs 中未捕获的异常会导致 500,而堆栈只出现在 error.log 且默认级别下不完整。把异常显式捕获并打日志,能让排错效率提升一个量级。
离线验证用 njs CLI 直接跑:
# 直接执行脚本,验证逻辑
njs /etc/nginx/njs/util.js
# 交互式调试
njs -i
njs -i 提供 REPL,可以快速验证字符串处理、正则、JSON 解析等纯逻辑,无需重启 NGINX。
6.3 与 rewrite 的分工
很多 njs 的用途其实 rewrite/map 就能完成,且性能更好。选择依据:
- 条件只依赖 URI、header、参数的简单组合 → 用
map或rewrite。 - 需要字符串处理、签名计算、多条件嵌套逻辑 → 用 njs。
- 需要动态决策转发目标(如按一致性哈希选上游)→ 用 njs +
r.subrequest。
rewrite 与 location 匹配的完整规则参见 Nginx rewrite 与 location 匹配
。把复杂逻辑放进 njs 之前,先确认它不是配置本身能表达的东西——能用声明式配置解决的,不要用脚本,这是 NGINX 运维的基本原则。
7. 总结
njs 的定位是「配置的增强层」:它让 NGINX 在不引入 OpenResty、不重编译、不额外起进程的前提下获得可编程能力。它的约束(无外部 I/O、无异步文件操作、生态缺失)不是缺陷而是设计取舍——这些约束保证了每次请求处理都能在极短时间内完成,从而维持 NGINX 的事件模型。
选型上可以这样判断:逻辑短、无外部依赖、团队熟悉 JS → njs;逻辑长、需访问 Redis/数据库、需要成熟库 → OpenResty。两者也可以共存,同一台 NGINX 上既加载 njs 模块又加载 Lua 模块,按 location 各用所长。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。