一、为什么要在 L4 跑 JavaScript?

  • 协议灵活:Stream 模块工作在传输层,可代理任何二进制流。以前要拦截报文只能写 C 模块或 Lua,如今用 JS 即可快速迭代。
  • 统一栈:HTTP 层已有 ngx_http_js_module;新增的 stream 版本让同一套 njs 代码横跨 L4/L7,方便复用工具库与研发体系。
  • 原生性能:njs/QuickJS 嵌入式解释器体积 < 1 MB,启动快、内存足够小;对 IO 密集的网关类业务非常友好。

二、快速上手:零基础示例

# nginx.conf(精简片段)
stream {
    js_import stream.js;                 # ① 加载脚本

    js_set  $bar       stream.bar;       # 动态变量
    js_set  $req_line  stream.req_line;  # 请求行缓存

    server {
        listen 12345;                    # ② Echo 服务
        js_preread stream.preread;       # 读取首行
        return     $req_line;            # 回射
    }

    server {
        listen 12346;                    # ③ 透明代理
        js_access  stream.access;        # ACL
        proxy_pass 127.0.0.1:8000;
        js_filter  stream.header_inject; # 报文注入
    }
}
// stream.js(核心逻辑)
let line = '';

export function bar(s) {
    const v = s.variables;
    s.log('hello from bar()');
    return `bar-${v.remote_port}; pid=${v.pid}`;
}

export function preread(s) {
    s.on('upload', (data) => {
        const n = data.indexOf('\n');
        if (n !== -1) {
            line = data.substr(0, n);
            s.done();                    // 切换到下一阶段
        }
    });
}

export const req_line = () => line;

export function header_inject(s) {
    const hdr = 'Foo: foo';
    let buf = '';
    s.on('upload', (data, flg) => {
        buf += data;
        const n = buf.search('\n');
        if (n !== -1) {
            const rest = buf.substr(n + 1);
            buf = buf.substr(0, n + 1);
            s.send(buf + hdr + '\r\n' + rest, flg);
            s.off('upload');            // 注入一次即停
        }
    });
}

export function access(s) {
    if (/^192\./.test(s.remoteAddress)) {
        s.deny();
        return;
    }
    s.allow();
}

运行效果

流程 钩子 关键行为
客户端连 12345 js_preread 捕获首行并回射
客户端连 12346 js_access 私网 192.* 拒绝
- js_filter Foo: foo 注入转发流量

三、核心指令拆解

类别 指令 作用与要点
生命周期钩子 js_preread / js_access / js_filter 分别对应握手前、鉴权、内容阶段;s.on() 可多次读写,s.done()/s.allow()/s.deny() 控制流转
变量 js_set $var func [nocache]
js_var $writable
首次引用触发函数;nocache 每次执行
脚本加载 js_import / js_path 支持多文件、命名空间;js_include 已废弃
Fetch 支持 js_fetch_* 系列(缓冲、TLS、超时…) 在脚本内 await ngx.fetch() 调用外部 HTTP/HTTPS
定时任务 js_periodic func interval=60s worker_affinity=all Stream 层 “cron” 功能,可拉黑名单、做健康检查
共享字典 js_shared_dict_zone zone=foo:1M timeout=60s 跨进程 KV 存储,支持 get/set/incr/delete
引擎 & 内存 `js_engine njs qjs<br>js_context_reuse 512` QuickJS 兼容 ES2020 更多特性;合理复用上下文降低 GC

四、典型落地场景

  1. 自定义协议网关

    • 利用 js_preread 解析自研二进制头,提取租户 ID 写入 $tenant 变量;后续 upstream 选路。
  2. 实时 ACL / 令牌鉴权

    • js_accessawait ngx.fetch('https://auth.local/verify', {headers}),校验失败直接 s.deny()
  3. 灰度发布 / A/B Test

    • js_var $bucket hash.bucket; map $bucket 转发至新旧集群,灰度比例写死或由远程配置中心下发。
  4. 报文动态改写

    • MQTT CONNECT 报文中插入追踪 ID;Redis 协议重写数据库序号,解决多租户隔离。
  5. 长连接健康探测

    • js_periodic 每分钟拉取后端状态,结果写入共享字典;js_access 读字典实现熔断。

五、进阶实践

1. 使用 QuickJS 加速复杂脚本

js_engine qjs;              # ES2020、async/await 原生支持
js_context_reuse 256;       # 复用上下文,减少 Per-Session 开销

2. 预加载配置对象

js_preload_object region_map.json;   # 变为全局常量 region_map

在脚本中:

if (region_map[s.remoteAddress]) { ... }

3. 跨进程共享 KV

js_shared_dict_zone zone=limiter:2M timeout=30s evict;

# limiter.js
export function incr(s) {
    const cnt = ngx.shared.limiter.incr(s.remoteAddress, 1, 0, 10);
    if (cnt > 1000) s.deny();
}

六、性能调优与踩坑

现象 原因 解决方案
QPS 高时 CPU 飙升 上下文频繁创建 提高 js_context_reuse 或改用 njs
Fetch 卡顿 默认缓冲 16 kB,TLS 验证全面 调整 js_fetch_buffer_size 32k; js_fetch_timeout 5s;
变量值错乱 js_set 缓存机制误用 需要实时计算时加 nocache
Filter 注入丢包 忘记 s.off('upload') 注入完成后及时解绑事件
QuickJS 占用内存上涨 未复用/GC 周期长 结合 worker_processes + context_reuse,或改回 njs

七、总结

ngx_stream_js_module“用 JS 写 L4 代理” 这件事从概念变成了生产可用的能力:

  • 开发效率:告别 C/Lua,热更新脚本即可上线。
  • 生态一致:与 HTTP 层 njs/Lua 配合,统一监控 & 日志格式。
  • 性能友好:解释器极小、上下文可复用,足以支撑 10⁵ QPS 以上场景。

倘若你正为私有协议网关、流量灰度、动态鉴权或报文改写犯愁,不妨给 njs/QuickJS 一个试水机会——在 Nginx Stream 层加一点点 JavaScript,就能让网关瞬间“活”起来。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐