njs 模块与 JavaScript 扩展

系统讲解 NGINX njs 模块的运行时约束与用法,覆盖 njs 运行时与 ES 支持边界、js_content 与 js_set 指令、请求改写与响应处理、子请求与共享内存、与 Lua 的能力取舍,以及内存模型、性能开销、脚本调试与常见坑点的排错实践。

njs 是 NGINX 官方的 JavaScript 引擎,目标是「在 NGINX 配置里写一点逻辑」而不是「在 NGINX 里跑一个 Node 应用」。它把 JS 的执行限定在请求生命周期的若干钩子上,因此开销小、部署简单,但能力边界也明显。本文讲清 njs 的运行时约束、常用指令与真实用法,并给出它与 OpenResty/Lua 之间的取舍依据。

1. njs 是什么,不是什么

理解 njs 的第一步是分清它和 Node.js 的差异。两者虽然都是 JavaScript,但设计目标完全相反。

维度njsNode.js
执行模型事件驱动、无阻塞 I/O,但单次执行必须快速返回完整的事件循环与异步运行时
标准库极小(无 fs、无 net、无 npm)完整生态
模块系统自有 js_import,非 CommonJS/ESMCommonJS/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 有它不可替代的位置。

维度njsOpenResty / 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 性能开销的三个来源

  1. 脚本求值:每次 js_set 求值都会进入 JS 引擎。热点路径上的正则匹配、JSON 解析是最常见的开销。
  2. 字符串与字节转换:r.requestText、r.responseText 会把整个请求/响应体读成 JS 字符串,大文件场景下内存与拷贝开销显著。
  3. 子请求:每次 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 各用所长。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nginx」更多文章

  1. NGINX 容器镜像精简与加固
  2. 多租户虚拟主机与配置生成
  3. 从 Apache 与 Traefik 迁移到 NGINX