为什么个人站长也该认识 njs
大多数人对 Nginx 的印象是“配置文件里写写 location、proxy_pass、rewrite 就完事了”。可一旦遇到稍微复杂的逻辑——比如根据请求头做灰度、按时间戳做签名校验、对返回的 JSON 做一次改写——配置文件那点正则能力立刻就不够用了。传统做法是上 Lua(OpenResty),但为了几行逻辑装一整套 OpenResty 镜像,对一台 1 核 2G 的小 VPS 来说太重了。
njs(Nginx JavaScript)是 Nginx 官方从 0.4.x 起就在推进的一个轻量脚本模块,它把一个小巧的 JavaScript 引擎嵌进 Nginx,用 js_content、js_set、js_header_filter 这些指令在请求处理的各个阶段插入逻辑。它不需要 LuaJIT,不需要额外的运行时,脚本在 Nginx 的 worker 进程里直接跑,内存开销只有几 MB 量级。对于个人站长这种“逻辑不复杂、但配置文件写不出来”的场景,njs 是非常划算的一块拼图。
这篇文章不谈抽象概念,直接讲三个我实际用过的 njs 场景:按请求头做小流量灰度分流、给回源请求加 HMAC 签名、以及把响应里的旧域名内链批量改写。最后会讲清楚 nginx 官方源、编译还是装包、以及几个我踩过的坑。
先把 njs 装进 Nginx:装包还是编译
先确认你现在的 Nginx 是不是带 njs。执行 nginx -V,看输出里有没有 --add-dynamic-module=.../njs。如果有,说明已经带上了;如果没有,就得自己补。
最省事的做法是用官方预编译包。Debian/Ubuntu 上先加 nginx 官方源:
curl -fsSL https://nginx.org/keys/nginx_signing.key | gpg --dearmor -o /usr/share/keyrings/nginx-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] http://nginx.org/packages/debian bookworm nginx" > /etc/apt/sources.list.d/nginx.list
apt-get update
apt-get install -y nginx nginx-module-njs
装完之后 njs 是以动态模块形式提供的,需要在 /etc/nginx/nginx.conf 顶部的 load_module 段把它加载进来:
load_module modules/ngx_http_js_module.so;
load_module modules/ngx_stream_js_module.so; # 如果要用四层
如果你用的是自己编译的 Nginx,那就在 configure 时加上 --add-dynamic-module=/path/to/njs/nginx,然后 make modules 把生成的 .so 拷到 modules 目录。njs 的源码可以从官方 hg 仓库或者 GitHub 镜像拿到。个人建议:能用包就用包,除非你要的 njs 版本有特性差异。
装完验证一下:nginx -V 2>&1 | tr ' ' '\n' | grep -i js,能看到 ngx_http_js_module 就 OK。别忘了 nginx -t 检查配置语法。
场景一:用 js_set 做按请求头的小流量灰度
假设你要上一版新首页,只想让 10% 的流量看到,而且希望指定用户(比如带 X-Beta: 1 头的内部同事)强制走新版。配置文件只能按 UA、IP 段匹配,这种“固定比例随机”很难优雅实现。njs 里一行 Math.random() 就解决了。
# /etc/nginx/conf.d/gateway.conf
js_import gray from /etc/nginx/njs/gray.js;
map $http_x_beta $force_beta {
"1" "on";
default "off";
}
server {
listen 80;
server_name www.example.com;
js_set $variant gray.pick;
location / {
# $variant 会是 "a"(旧版)或 "b"(新版)
proxy_pass http://127.0.0.1:8080$variant_upstream;
add_header X-Variant $variant always;
}
}
对应的 gray.js:
function pick(r) {
// 内部同事强制走 B
if (r.variables.force_beta === 'on') {
return 'b';
}
// 其余按 10% 比例随机
return Math.random() < 0.1 ? 'b' : 'a';
}
export default { pick };
这里有个关键点:njs 函数接收的 r 是当前请求对象,通过 r.variables.<name> 读取变量,通过 r.headersIn / r.args 读取请求头与查询参数。返回值在 js_set 里会被当成字符串变量值。灰度分流的逻辑就藏在几行 JS 里,比在配置文件里堆一堆 map 和 split_clients 清爽得多。
注意 split_clients 也能做比例分流,但它是按请求特征做一致性哈希的(同一客户端稳定落到同一档),而 njs 里你可以自由选择“每次随机”还是“按 cookie 固定”,灵活度不在一个层级。
场景二:给回源请求加 HMAC 签名
源站不想被绕过 CDN 直连,常见做法是要求回源请求带一个签名。以前这活要写 Lua,现在 njs 内置了 crypto 模块,HMAC-SHA256 直接可用。
// /etc/nginx/njs/sign.js
import crypto from 'crypto';
function sign(r) {
const key = 'your-secret-key';
const path = r.uri;
const expires = Math.floor(Date.now() / 1000) + 60; // 60 秒有效
const data = path + ':' + expires;
const h = crypto.createHmac('sha256', key).update(data).digest('hex');
return 'exp=' + expires + '&sig=' + h;
}
export default { sign };
在 Nginx 里用一个变量把它拼到 proxy_pass 上:
js_import sig from /etc/nginx/njs/sign.js;
js_set $signed_args sig.sign;
location /api/ {
proxy_set_header X-Signature $signed_args;
proxy_pass http://origin$request_uri;
}
源站拿到 X-Signature,取出其中的 exp 与 sig,用同一个 key 重算一遍并比对,同时校验 exp 未过期。这样即使 CDN 被绕过、源站 IP 泄漏,攻击者也无法构造出合法签名。相比在 URL 里挂静态 token,带时间戳的 HMAC 能有效防重放。
场景三:用 body_filter 批量改写旧域名内链
站点改过域名之后,正文里大量的 http://old.example.com/xxx 内链会拖累收录和权重传递,一条条改文章不现实。njs 可以挂在响应的 body filter 阶段,对 HTML 做一次流式替换。
// /etc/nginx/njs/rewrite.js
function rewrite(r, data, flags) {
if (data.length) {
// 只处理最后一块之前,避免截断标签;简单场景直接整块替换即可
return data.replace(/http:\/\/old\.example\.com/g, 'https://www.example.com');
}
}
export default { rewrite };
js_import rw from /etc/nginx/njs/rewrite.js;
js_body_filter rw.rewrite buffer_type=buffer;
注意:body filter 是分块回调的,替换字符串不一定会完整落在同一个 buffer 里,所以生产上别做跨块匹配的复杂正则,最好用 buffer_type=buffer 让 njs 先攒成完整块再处理(代价是延迟和内存略增)。另外 sub_filter 模块其实也能做这个,但它的匹配是逐字面的、不支持正则,且受 sub_filter_once 影响。njs 的优势是你能写条件判断:比如只改写 <article> 区域内的链接。
场景四:用 js_content 换掉一整个 location 块
前面三个场景都是“往配置文件里插一个变量或过滤器”。njs 还有一个更彻底的用法:用 js_content 直接把一个 location 的处理逻辑整个接管。这时候 Nginx 收到请求后不做内容定位,而是直接把请求交给 JS 函数,函数里可以读参数、读请求体、做判断,再返回一段内容。
js_import api from /etc/nginx/njs/api.js;
location /health {
js_content api.health;
}
location /echo {
js_content api.echo;
}
// /etc/nginx/njs/api.js
function health(r) {
r.headersOut['Content-Type'] = 'application/json';
r.return(200, JSON.stringify({ status: 'ok', stamp: Date.now() }));
}
function echo(r) {
const body = r.requestText || '';
const ua = r.headersIn['User-Agent'] || '';
r.headersOut['X-From-Njs'] = 'yes';
r.return(200, 'ua=' + ua + '\nbody=' + body);
}
export default { health, echo };
用 js_content 实现一个健康检查接口、一个 echo 调试接口,比专门起一个后端服务轻得多。它特别适合做“网关层的小工具”:比如返回站点维护页、校验某个一次性 token 后放行、给爬虫返回专门的精简 HTML。注意 r.return(code, body) 会立刻结束请求并发送响应,函数里之后的代码不再执行。用 r.requestText 读请求体时要注意它需要请求体已经被读入内存,大文件上传场景别这么干。
场景五:在 njs 里复用缓存与限流
njs 能配合 Nginx 原生的共享内存做一层轻量缓存。比如你要给某个外部 API 加“60 秒内只请求一次”的本地缓存,不需要装 Redis:用 js_shared_dict_zone 声明一块共享内存,njs 里用 ngx.shared 读写。
js_shared_dict_zone zone=api_cache:2m;
js_import cache from /etc/nginx/njs/cache.js;
// /etc/nginx/njs/cache.js
function get(r) {
const dict = ngx.shared.api_cache;
const key = 'k:' + r.args.q;
const hit = dict.get(key);
if (hit !== undefined) {
return 'cached';
}
dict.set(key, 1, 60); // 60 秒后过期
return 'miss';
}
export default { get };
js_shared_dict_zone 是 Nginx 0.7.0 之后提供的特性,共享内存跨所有 worker 可见,用来做去重、计数、令牌桶都很合适。它的写入是原子性的(dict.incr / dict.add 有原子语义),因此可以用它做简单的限流计数器:dict.incr(key, 1) 后判断是否超过阈值,超过就 r.return(429)。这比在配置文件里用 limit_req 更灵活,因为限流键、阈值、时间窗都可以在 JS 里动态计算。
需要注意共享内存是一块固定大小的区域,写满之后 set 会返回失败或按 LRU 淘汰旧键(取决于版本与配置),容量别设太小。2MB 的 zone 大概能放几万个短键,对个人站足够了。
几个必须知道的坑
第一,别把 njs 当万能胶。它适合“薄薄一层”的逻辑。做复杂的业务编排、连数据库、循环调外部 API,性能会明显下滑,因为 njs 是单线程模型且没有异步 IO 的原生支持(部分版本支持 r.subrequest 发子请求)。重逻辑还是交给后端语言。
第二,变量求值时机。js_set 定义的变量是惰性求值的,只有被引用时才执行。如果你在多个 location 引用同一个 js_set 变量,它每次引用都会重新执行一遍函数,可能触发多次随机、多次签名,结果不一致。需要固定值就用 js_var 或把结果存进缓存。
第三,正则的语法差异。njs 用的正则引擎和 V8 不完全一样,一些后行断言、命名捕获组在旧版本里不支持。写完务必 nginx -t 之后再用真实请求压一下,别等到线上才发现匹配不上。
第四,调试手段。njs 里可以用 r.error("msg") 往 error_log 打日志,也可以用 r.log()。开发阶段把 error_log 调到 debug,配合 r.warn() 观察变量值。没有交互式 REPL,靠日志是主流做法。
第五,模块加载顺序。load_module 必须写在 events 块之前、user 与 worker_processes 之后的位置。写错顺序 Nginx 直接起不来。
小结
njs 的定位很清楚:在“配置文件写不出来、又不想上 OpenResty”的夹缝里,提供一个只有几 MB 开销的 JavaScript 出口。灰度分流、回源签名、正文改写这三类需求,用 njs 都是十几行脚本的事。它的代价是要接受一套和浏览器不太一样的 JS 运行时、以及调试手段相对原始。但对个人站长来说,这种“轻量、够用、不引入重依赖”的方案,往往比为了几行逻辑装一整套框架更务实。装之前先 nginx -V 看一眼模块有没有,装完记得 nginx -t 再 reload,剩下的就交给你的 JS 吧。