Ubuntu 22.04 Nginx JavaScript 模組

Ubuntu 22.04 Nginx JavaScript (njs) Module

njs 模組介紹

njs(Nginx JavaScript)是 Nginx 官方開發的 JavaScript 子集實作,專門設計用於擴展 Nginx 的功能。它允許開發者使用 JavaScript 語法來處理 HTTP 請求、操作變數、執行子請求以及實現複雜的業務邏輯。

njs 與傳統 JavaScript 的差異

njs 並非完整的 JavaScript 引擎(如 V8 或 SpiderMonkey),而是針對 Nginx 環境最佳化的輕量級實作:

  • 記憶體效率:每個請求使用獨立的記憶體空間,請求結束後自動釋放
  • 非阻塞設計:原生支援 Nginx 的事件驅動架構
  • 安全性:沒有檔案系統存取和網路 I/O 的直接 API
  • ECMAScript 支援:支援大部分 ES6+ 語法特性

njs 的主要用途

  • 請求與回應的動態修改
  • 複雜的認證與授權邏輯
  • API Gateway 功能實作
  • 請求路由與負載均衡決策
  • 日誌格式化與資料處理

安裝與設定

前置需求

在 Ubuntu 22.04 上安裝 njs 模組前,請確保系統已更新:

1
sudo apt update && sudo apt upgrade -y

方法一:從官方 Nginx 套件庫安裝

首先,新增 Nginx 官方套件庫:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# 安裝必要工具
sudo apt install -y curl gnupg2 ca-certificates lsb-release ubuntu-keyring

# 匯入官方 GPG 金鑰
curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor \
    | sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null

# 驗證金鑰
gpg --dry-run --quiet --no-keyring --import --import-options import-show \
    /usr/share/keyrings/nginx-archive-keyring.gpg

# 新增套件庫
echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \
    http://nginx.org/packages/ubuntu `lsb_release -cs` nginx" \
    | sudo tee /etc/apt/sources.list.d/nginx.list

# 設定套件庫優先權
echo -e "Package: *\nPin: origin nginx.org\nPin: release o=nginx\nPin-Priority: 900\n" \
    | sudo tee /etc/apt/preferences.d/99nginx

安裝 Nginx 和 njs 模組:

1
2
sudo apt update
sudo apt install -y nginx nginx-module-njs

方法二:使用 Ubuntu 預設套件庫

Ubuntu 22.04 的預設套件庫也提供 njs 模組:

1
sudo apt install -y nginx libnginx-mod-http-js

啟用 njs 模組

編輯 Nginx 主設定檔 /etc/nginx/nginx.conf,在最上方新增模組載入指令:

1
2
3
4
5
6
load_module modules/ngx_http_js_module.so;
load_module modules/ngx_stream_js_module.so;

user  nginx;
worker_processes  auto;
# ... 其餘設定

驗證設定並重新載入 Nginx:

1
2
sudo nginx -t
sudo systemctl reload nginx

驗證安裝

確認 njs 模組已正確載入:

1
nginx -V 2>&1 | grep -o 'njs'

檢查 njs 版本:

1
njs -v

基本語法與 API

njs 檔案結構

njs 腳本通常放置於 /etc/nginx/njs/ 目錄下:

1
sudo mkdir -p /etc/nginx/njs

基本的 njs 檔案結構:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// /etc/nginx/njs/http.js

function hello(r) {
    r.return(200, "Hello from njs!\n");
}

function greet(r) {
    let name = r.args.name || "World";
    r.return(200, `Hello, ${name}!\n`);
}

export default { hello, greet };

Nginx 設定整合

在 Nginx 設定中引用 njs 腳本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
http {
    js_path "/etc/nginx/njs/";
    js_import http.js;

    server {
        listen 80;
        server_name example.com;

        location /hello {
            js_content http.hello;
        }

        location /greet {
            js_content http.greet;
        }
    }
}

核心物件與屬性

njs 提供的 HTTP 請求物件 r 包含以下重要屬性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
function requestInfo(r) {
    // 請求資訊
    let info = {
        method: r.method,              // HTTP 方法 (GET, POST, etc.)
        uri: r.uri,                    // 請求 URI
        args: r.args,                  // 查詢參數物件
        headersIn: r.headersIn,        // 請求標頭
        headersOut: r.headersOut,      // 回應標頭
        httpVersion: r.httpVersion,    // HTTP 版本
        remoteAddress: r.remoteAddress, // 客戶端 IP
        requestBody: r.requestBody,    // 請求主體
        variables: r.variables         // Nginx 變數
    };

    r.return(200, JSON.stringify(info, null, 2));
}

export default { requestInfo };

變數存取

透過 r.variables 存取 Nginx 變數:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
function showVariables(r) {
    let vars = {
        host: r.variables.host,
        request_uri: r.variables.request_uri,
        remote_addr: r.variables.remote_addr,
        server_name: r.variables.server_name,
        server_port: r.variables.server_port,
        scheme: r.variables.scheme,
        request_method: r.variables.request_method
    };

    r.return(200, JSON.stringify(vars, null, 2));
}

export default { showVariables };

HTTP 請求處理

處理 GET 請求

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// /etc/nginx/njs/api.js

function handleGet(r) {
    // 取得查詢參數
    let id = r.args.id;
    let page = parseInt(r.args.page) || 1;
    let limit = parseInt(r.args.limit) || 10;

    if (!id) {
        r.return(400, JSON.stringify({
            error: "Missing required parameter: id"
        }));
        return;
    }

    let response = {
        id: id,
        page: page,
        limit: limit,
        timestamp: new Date().toISOString()
    };

    r.headersOut['Content-Type'] = 'application/json';
    r.return(200, JSON.stringify(response));
}

export default { handleGet };

處理 POST 請求

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
function handlePost(r) {
    // 檢查 Content-Type
    let contentType = r.headersIn['Content-Type'] || '';

    if (!contentType.includes('application/json')) {
        r.return(415, JSON.stringify({
            error: "Unsupported Media Type. Expected application/json"
        }));
        return;
    }

    // 解析請求主體
    let body;
    try {
        body = JSON.parse(r.requestBody);
    } catch (e) {
        r.return(400, JSON.stringify({
            error: "Invalid JSON in request body"
        }));
        return;
    }

    // 驗證必要欄位
    if (!body.username || !body.email) {
        r.return(400, JSON.stringify({
            error: "Missing required fields: username, email"
        }));
        return;
    }

    // 處理業務邏輯
    let response = {
        status: "created",
        data: {
            username: body.username,
            email: body.email,
            createdAt: new Date().toISOString()
        }
    };

    r.headersOut['Content-Type'] = 'application/json';
    r.return(201, JSON.stringify(response));
}

export default { handlePost };

對應的 Nginx 設定:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
server {
    listen 80;
    server_name api.example.com;

    js_path "/etc/nginx/njs/";
    js_import api.js;

    location /api/resource {
        js_content api.handleGet;
    }

    location /api/create {
        js_content api.handlePost;
    }
}

請求標頭操作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
function modifyHeaders(r) {
    // 讀取請求標頭
    let userAgent = r.headersIn['User-Agent'];
    let authorization = r.headersIn['Authorization'];

    // 設定回應標頭
    r.headersOut['X-Powered-By'] = 'njs';
    r.headersOut['X-Request-ID'] = generateRequestId();
    r.headersOut['Cache-Control'] = 'no-cache, no-store, must-revalidate';

    r.return(200, "Headers processed");
}

function generateRequestId() {
    return 'req-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
}

export default { modifyHeaders };

使用 js_set 設定變數

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 設定動態變數值
function setRequestId(r) {
    return 'req-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
}

function setClientInfo(r) {
    let info = {
        ip: r.remoteAddress,
        ua: r.headersIn['User-Agent'] || 'unknown'
    };
    return Buffer.from(JSON.stringify(info)).toString('base64');
}

export default { setRequestId, setClientInfo };

Nginx 設定:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
http {
    js_path "/etc/nginx/njs/";
    js_import utils from utils.js;

    js_set $request_id utils.setRequestId;
    js_set $client_info utils.setClientInfo;

    server {
        listen 80;

        location / {
            add_header X-Request-ID $request_id;
            add_header X-Client-Info $client_info;
            proxy_pass http://backend;
        }
    }
}

子請求與認證

執行子請求

njs 支援使用 r.subrequest() 發送內部子請求:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
async function fetchUserData(r) {
    try {
        // 發送子請求到內部 API
        let reply = await r.subrequest('/internal/user', {
            method: 'GET',
            args: 'id=' + r.args.userId
        });

        if (reply.status !== 200) {
            r.return(reply.status, "User not found");
            return;
        }

        let userData = JSON.parse(reply.responseBody);
        r.headersOut['Content-Type'] = 'application/json';
        r.return(200, JSON.stringify({
            success: true,
            user: userData
        }));
    } catch (e) {
        r.return(500, "Internal server error: " + e.message);
    }
}

export default { fetchUserData };

Nginx 設定:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
server {
    listen 80;

    js_path "/etc/nginx/njs/";
    js_import auth from auth.js;

    location /user {
        js_content auth.fetchUserData;
    }

    # 內部位置,僅供子請求使用
    location /internal/user {
        internal;
        proxy_pass http://user-service:8080/api/user;
    }
}

JWT 認證實作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// /etc/nginx/njs/jwt.js

function base64UrlDecode(str) {
    str = str.replace(/-/g, '+').replace(/_/g, '/');
    let padding = str.length % 4;
    if (padding) {
        str += '='.repeat(4 - padding);
    }
    return Buffer.from(str, 'base64').toString();
}

function verifyJWT(r) {
    let auth = r.headersIn['Authorization'];

    if (!auth || !auth.startsWith('Bearer ')) {
        return JSON.stringify({ valid: false, error: 'Missing token' });
    }

    let token = auth.slice(7);
    let parts = token.split('.');

    if (parts.length !== 3) {
        return JSON.stringify({ valid: false, error: 'Invalid token format' });
    }

    try {
        let header = JSON.parse(base64UrlDecode(parts[0]));
        let payload = JSON.parse(base64UrlDecode(parts[1]));

        // 檢查過期時間
        let now = Math.floor(Date.now() / 1000);
        if (payload.exp && payload.exp < now) {
            return JSON.stringify({ valid: false, error: 'Token expired' });
        }

        // 檢查簽發時間
        if (payload.iat && payload.iat > now) {
            return JSON.stringify({ valid: false, error: 'Token not yet valid' });
        }

        return JSON.stringify({
            valid: true,
            payload: payload
        });
    } catch (e) {
        return JSON.stringify({ valid: false, error: 'Token parse error' });
    }
}

function authorize(r) {
    let result = JSON.parse(verifyJWT(r));

    if (!result.valid) {
        r.return(401, JSON.stringify({ error: result.error }));
        return;
    }

    // 設定使用者資訊到標頭,供後端使用
    r.headersOut['X-User-ID'] = result.payload.sub || '';
    r.headersOut['X-User-Role'] = result.payload.role || '';

    return 'authorized';
}

export default { verifyJWT, authorize };

使用 auth_request 進行認證:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
server {
    listen 80;

    js_path "/etc/nginx/njs/";
    js_import jwt from jwt.js;

    js_set $jwt_status jwt.verifyJWT;

    location /protected {
        auth_request /auth;
        auth_request_set $auth_status $upstream_status;

        proxy_pass http://backend;
        proxy_set_header X-Auth-Status $auth_status;
    }

    location = /auth {
        internal;
        js_content jwt.authorize;
    }
}

API Key 認證

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// /etc/nginx/njs/apikey.js

// API 金鑰對照表(實際環境應從外部來源讀取)
const apiKeys = {
    'sk-prod-abc123': { name: 'Production App', tier: 'premium', rateLimit: 10000 },
    'sk-dev-xyz789': { name: 'Development App', tier: 'basic', rateLimit: 1000 }
};

function validateApiKey(r) {
    let apiKey = r.headersIn['X-API-Key'] || r.args.api_key;

    if (!apiKey) {
        r.return(401, JSON.stringify({
            error: 'API key is required',
            hint: 'Provide X-API-Key header or api_key query parameter'
        }));
        return;
    }

    let keyInfo = apiKeys[apiKey];

    if (!keyInfo) {
        r.return(403, JSON.stringify({
            error: 'Invalid API key'
        }));
        return;
    }

    // 設定回應標頭供日誌記錄
    r.headersOut['X-API-Client'] = keyInfo.name;
    r.headersOut['X-Rate-Limit'] = keyInfo.rateLimit.toString();

    // 回傳空內容表示認證成功
    r.return(200);
}

function getApiKeyInfo(r) {
    let apiKey = r.headersIn['X-API-Key'] || r.args.api_key;
    let keyInfo = apiKeys[apiKey];

    if (keyInfo) {
        return keyInfo.name;
    }
    return 'unknown';
}

export default { validateApiKey, getApiKeyInfo };

變數與共享資料

使用 Shared Dictionary

njs 支援透過 Nginx 的共享記憶體區域來儲存和共享資料:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
http {
    js_path "/etc/nginx/njs/";
    js_import cache from cache.js;

    # 定義共享記憶體區域
    js_shared_dict_zone zone=cache:10m timeout=60s evict;
    js_shared_dict_zone zone=counters:1m;

    server {
        listen 80;

        location /cache/set {
            js_content cache.set;
        }

        location /cache/get {
            js_content cache.get;
        }

        location /counter/increment {
            js_content cache.increment;
        }
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// /etc/nginx/njs/cache.js

function set(r) {
    let key = r.args.key;
    let value = r.args.value;

    if (!key || !value) {
        r.return(400, "Missing key or value parameter");
        return;
    }

    let cache = ngx.shared.cache;

    // 設定快取值,預設 TTL 由 zone 設定決定
    cache.set(key, value);

    r.return(200, JSON.stringify({
        status: "cached",
        key: key,
        value: value
    }));
}

function get(r) {
    let key = r.args.key;

    if (!key) {
        r.return(400, "Missing key parameter");
        return;
    }

    let cache = ngx.shared.cache;
    let value = cache.get(key);

    if (value === undefined) {
        r.return(404, JSON.stringify({
            status: "not_found",
            key: key
        }));
        return;
    }

    r.return(200, JSON.stringify({
        status: "found",
        key: key,
        value: value
    }));
}

function increment(r) {
    let key = r.args.key || 'default';
    let counters = ngx.shared.counters;

    // 原子操作遞增計數器
    let current = counters.incr(key, 1, 0);

    r.return(200, JSON.stringify({
        key: key,
        count: current
    }));
}

export default { set, get, increment };

請求間資料傳遞

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// /etc/nginx/njs/context.js

function setContext(r) {
    // 使用 r.variables 設定自訂變數
    r.variables.custom_user_id = r.args.user_id || 'anonymous';
    r.variables.custom_request_time = Date.now().toString();

    return 'context_set';
}

function getContext(r) {
    let context = {
        userId: r.variables.custom_user_id,
        requestTime: r.variables.custom_request_time,
        processingTime: Date.now() - parseInt(r.variables.custom_request_time)
    };

    r.return(200, JSON.stringify(context));
}

export default { setContext, getContext };

Nginx 設定:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
server {
    listen 80;

    js_path "/etc/nginx/njs/";
    js_import context from context.js;

    # 定義自訂變數
    set $custom_user_id "";
    set $custom_request_time "";

    js_set $context_status context.setContext;

    location /api {
        # 觸發 context 設定
        set $dummy $context_status;

        js_content context.getContext;
    }
}

效能考量

記憶體管理

njs 為每個請求分配獨立的記憶體池,需要注意以下事項:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 避免在全域範圍儲存大量資料
// 錯誤示範:
// let globalCache = {};  // 這不會在請求間共享

// 正確做法:使用共享記憶體
function efficientCaching(r) {
    let sharedCache = ngx.shared.cache;

    let key = r.args.key;
    let cached = sharedCache.get(key);

    if (cached) {
        r.return(200, cached);
        return;
    }

    // 處理並快取結果
    let result = expensiveOperation();
    sharedCache.set(key, result);
    r.return(200, result);
}

function expensiveOperation() {
    // 模擬耗時操作
    return JSON.stringify({ data: "processed" });
}

export default { efficientCaching };

避免阻塞操作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 使用非同步子請求而非同步等待
async function nonBlockingRequest(r) {
    try {
        // 並行發送多個子請求
        let [userReply, configReply] = await Promise.all([
            r.subrequest('/internal/user'),
            r.subrequest('/internal/config')
        ]);

        let userData = JSON.parse(userReply.responseBody);
        let configData = JSON.parse(configReply.responseBody);

        r.return(200, JSON.stringify({
            user: userData,
            config: configData
        }));
    } catch (e) {
        r.return(500, "Error: " + e.message);
    }
}

export default { nonBlockingRequest };

效能最佳化建議

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// /etc/nginx/njs/optimized.js

// 1. 預先編譯正則表達式
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

// 2. 重複使用常數
const CONTENT_TYPE_JSON = 'application/json';
const HTTP_OK = 200;
const HTTP_BAD_REQUEST = 400;

function validateEmail(r) {
    let email = r.args.email;

    if (!email || !emailRegex.test(email)) {
        r.headersOut['Content-Type'] = CONTENT_TYPE_JSON;
        r.return(HTTP_BAD_REQUEST, JSON.stringify({
            valid: false,
            error: 'Invalid email format'
        }));
        return;
    }

    r.headersOut['Content-Type'] = CONTENT_TYPE_JSON;
    r.return(HTTP_OK, JSON.stringify({
        valid: true,
        email: email
    }));
}

// 3. 減少字串操作
function buildResponse(data) {
    // 避免多次字串連接
    return JSON.stringify(data);
}

// 4. 早期返回減少巢狀
function processRequest(r) {
    let auth = r.headersIn['Authorization'];
    if (!auth) {
        r.return(401, 'Unauthorized');
        return;
    }

    let contentType = r.headersIn['Content-Type'];
    if (!contentType || !contentType.includes('application/json')) {
        r.return(415, 'Unsupported Media Type');
        return;
    }

    // 主要處理邏輯
    r.return(200, 'Processed');
}

export default { validateEmail, processRequest };

Worker 進程設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# nginx.conf
worker_processes auto;
worker_cpu_affinity auto;

events {
    worker_connections 4096;
    use epoll;
    multi_accept on;
}

http {
    js_path "/etc/nginx/njs/";
    js_import main from main.js;

    # 共享記憶體設定
    js_shared_dict_zone zone=cache:32m timeout=300s evict;
    js_shared_dict_zone zone=rate_limit:16m;

    # ... 其他設定
}

實際應用範例

範例一:API Gateway 實作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// /etc/nginx/njs/gateway.js

const routes = {
    '/api/users': { backend: 'http://user-service:8080', auth: true },
    '/api/products': { backend: 'http://product-service:8080', auth: false },
    '/api/orders': { backend: 'http://order-service:8080', auth: true }
};

async function route(r) {
    let path = r.uri;

    // 尋找匹配的路由
    let routeConfig = null;
    for (let route in routes) {
        if (path.startsWith(route)) {
            routeConfig = routes[route];
            break;
        }
    }

    if (!routeConfig) {
        r.return(404, JSON.stringify({
            error: 'Route not found',
            path: path
        }));
        return;
    }

    // 認證檢查
    if (routeConfig.auth) {
        let authResult = await checkAuth(r);
        if (!authResult.valid) {
            r.return(401, JSON.stringify({
                error: 'Authentication required',
                message: authResult.error
            }));
            return;
        }

        // 設定使用者資訊標頭
        r.headersOut['X-User-ID'] = authResult.userId;
    }

    // 設定後端變數供 proxy_pass 使用
    r.variables.backend_url = routeConfig.backend;
    r.return(200);
}

async function checkAuth(r) {
    let token = r.headersIn['Authorization'];

    if (!token) {
        return { valid: false, error: 'Missing token' };
    }

    try {
        let reply = await r.subrequest('/internal/validate-token', {
            method: 'POST',
            body: token
        });

        if (reply.status === 200) {
            let data = JSON.parse(reply.responseBody);
            return { valid: true, userId: data.userId };
        }

        return { valid: false, error: 'Invalid token' };
    } catch (e) {
        return { valid: false, error: 'Auth service error' };
    }
}

export default { route };

Nginx 設定:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
http {
    js_path "/etc/nginx/njs/";
    js_import gateway from gateway.js;

    upstream user-service {
        server user-service:8080;
    }

    upstream product-service {
        server product-service:8080;
    }

    upstream order-service {
        server order-service:8080;
    }

    server {
        listen 80;
        server_name api.example.com;

        set $backend_url "";

        location /api/ {
            js_content gateway.route;
        }

        location @proxy {
            proxy_pass $backend_url;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }

        location /internal/validate-token {
            internal;
            proxy_pass http://auth-service:8080/validate;
        }
    }
}

範例二:速率限制器

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// /etc/nginx/njs/ratelimit.js

function checkRateLimit(r) {
    let clientId = r.remoteAddress;
    let apiKey = r.headersIn['X-API-Key'];

    // 優先使用 API Key 作為識別
    if (apiKey) {
        clientId = 'key:' + apiKey;
    }

    let limits = ngx.shared.rate_limit;
    let now = Math.floor(Date.now() / 1000);
    let windowKey = clientId + ':' + now;

    // 取得目前計數
    let count = limits.get(windowKey) || 0;
    let maxRequests = 100;  // 每秒最大請求數

    if (count >= maxRequests) {
        r.headersOut['X-RateLimit-Limit'] = maxRequests.toString();
        r.headersOut['X-RateLimit-Remaining'] = '0';
        r.headersOut['X-RateLimit-Reset'] = (now + 1).toString();
        r.headersOut['Retry-After'] = '1';

        r.return(429, JSON.stringify({
            error: 'Too Many Requests',
            retryAfter: 1
        }));
        return;
    }

    // 遞增計數
    limits.incr(windowKey, 1, 0);

    // 設定回應標頭
    r.headersOut['X-RateLimit-Limit'] = maxRequests.toString();
    r.headersOut['X-RateLimit-Remaining'] = (maxRequests - count - 1).toString();
    r.headersOut['X-RateLimit-Reset'] = (now + 1).toString();

    r.return(200);
}

function getRateLimitStatus(r) {
    let clientId = r.remoteAddress;
    let limits = ngx.shared.rate_limit;
    let now = Math.floor(Date.now() / 1000);
    let windowKey = clientId + ':' + now;

    let count = limits.get(windowKey) || 0;

    return count.toString();
}

export default { checkRateLimit, getRateLimitStatus };

範例三:請求日誌與監控

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// /etc/nginx/njs/logging.js

function logRequest(r) {
    let startTime = Date.now();

    // 建立日誌物件
    let logEntry = {
        timestamp: new Date().toISOString(),
        method: r.method,
        uri: r.uri,
        query: r.variables.query_string || '',
        clientIp: r.remoteAddress,
        userAgent: r.headersIn['User-Agent'] || '',
        referer: r.headersIn['Referer'] || '',
        requestId: generateRequestId(),
        contentLength: r.headersIn['Content-Length'] || 0
    };

    // 設定請求 ID 供追蹤
    r.variables.request_id = logEntry.requestId;

    return JSON.stringify(logEntry);
}

function logResponse(r) {
    let logEntry = {
        requestId: r.variables.request_id,
        status: r.variables.status,
        bodyBytesSent: r.variables.body_bytes_sent,
        upstreamResponseTime: r.variables.upstream_response_time || '0',
        requestTime: r.variables.request_time
    };

    return JSON.stringify(logEntry);
}

function generateRequestId() {
    let timestamp = Date.now().toString(36);
    let random = Math.random().toString(36).substr(2, 9);
    return `${timestamp}-${random}`;
}

function healthCheck(r) {
    let health = {
        status: 'healthy',
        timestamp: new Date().toISOString(),
        uptime: process.uptime ? process.uptime() : 'N/A',
        version: '1.0.0'
    };

    r.headersOut['Content-Type'] = 'application/json';
    r.return(200, JSON.stringify(health));
}

export default { logRequest, logResponse, healthCheck };

Nginx 設定整合:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
http {
    js_path "/etc/nginx/njs/";
    js_import logging from logging.js;

    js_set $request_log logging.logRequest;
    js_set $response_log logging.logResponse;

    log_format json_combined escape=json
        '{'
        '"request":$request_log,'
        '"response":$response_log'
        '}';

    access_log /var/log/nginx/access.json json_combined;

    server {
        listen 80;

        set $request_id "";

        location /health {
            js_content logging.healthCheck;
        }

        location / {
            proxy_pass http://backend;
            proxy_set_header X-Request-ID $request_id;
        }
    }
}

範例四:A/B 測試路由

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// /etc/nginx/njs/abtest.js

const experiments = {
    'homepage-redesign': {
        variants: {
            'control': 50,
            'variant-a': 25,
            'variant-b': 25
        },
        active: true
    },
    'checkout-flow': {
        variants: {
            'control': 70,
            'variant-a': 30
        },
        active: true
    }
};

function getVariant(r) {
    let experimentId = r.args.experiment || 'homepage-redesign';
    let experiment = experiments[experimentId];

    if (!experiment || !experiment.active) {
        return 'control';
    }

    // 使用客戶端 IP 或 Cookie 確保一致性
    let userId = r.headersIn['X-User-ID'] || r.remoteAddress;
    let hash = simpleHash(userId + experimentId);

    let cumulative = 0;
    let roll = hash % 100;

    for (let variant in experiment.variants) {
        cumulative += experiment.variants[variant];
        if (roll < cumulative) {
            return variant;
        }
    }

    return 'control';
}

function simpleHash(str) {
    let hash = 0;
    for (let i = 0; i < str.length; i++) {
        let char = str.charCodeAt(i);
        hash = ((hash << 5) - hash) + char;
        hash = hash & hash;
    }
    return Math.abs(hash);
}

function routeByVariant(r) {
    let variant = getVariant(r);

    // 設定變數供 Nginx 路由使用
    r.variables.ab_variant = variant;

    // 設定回應標頭供前端追蹤
    r.headersOut['X-AB-Variant'] = variant;

    return variant;
}

function getExperimentInfo(r) {
    let experimentId = r.args.experiment;

    if (experimentId && experiments[experimentId]) {
        r.return(200, JSON.stringify({
            id: experimentId,
            config: experiments[experimentId],
            yourVariant: getVariant(r)
        }));
    } else {
        r.return(200, JSON.stringify({
            experiments: Object.keys(experiments),
            hint: 'Use ?experiment=<id> to get specific experiment info'
        }));
    }
}

export default { getVariant, routeByVariant, getExperimentInfo };

除錯與疑難排解

啟用除錯日誌

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
error_log /var/log/nginx/error.log debug;

http {
    js_path "/etc/nginx/njs/";
    js_import debug from debug.js;

    server {
        listen 80;

        location /debug {
            js_content debug.showDebugInfo;
        }
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// /etc/nginx/njs/debug.js

function showDebugInfo(r) {
    let debug = {
        request: {
            method: r.method,
            uri: r.uri,
            httpVersion: r.httpVersion,
            remoteAddress: r.remoteAddress,
            args: Object.fromEntries(
                Object.entries(r.args).map(([k, v]) => [k, v])
            )
        },
        headers: {},
        variables: {}
    };

    // 收集所有請求標頭
    for (let h in r.headersIn) {
        debug.headers[h] = r.headersIn[h];
    }

    // 收集常用變數
    let varNames = [
        'host', 'server_name', 'server_port', 'scheme',
        'request_uri', 'document_root', 'realpath_root'
    ];

    varNames.forEach(name => {
        try {
            debug.variables[name] = r.variables[name];
        } catch (e) {
            debug.variables[name] = 'N/A';
        }
    });

    r.headersOut['Content-Type'] = 'application/json';
    r.return(200, JSON.stringify(debug, null, 2));
}

function logError(r) {
    try {
        // 可能出錯的操作
        let data = JSON.parse(r.requestBody);
        ngx.log(ngx.INFO, 'Parsed data: ' + JSON.stringify(data));
        r.return(200, 'OK');
    } catch (e) {
        ngx.log(ngx.ERR, 'Error parsing request: ' + e.message);
        r.return(400, 'Parse error: ' + e.message);
    }
}

export default { showDebugInfo, logError };

常見問題解決

  1. 模組載入失敗
1
2
3
4
5
# 檢查模組檔案是否存在
ls -la /usr/lib/nginx/modules/ngx_http_js_module.so

# 檢查 Nginx 設定語法
sudo nginx -t
  1. 腳本語法錯誤
1
2
3
4
5
# 使用 njs 命令列工具測試
echo 'console.log("test")' | njs

# 測試腳本檔案
njs /etc/nginx/njs/test.js
  1. 權限問題
1
2
3
# 確保 njs 檔案可被 Nginx 讀取
sudo chown -R nginx:nginx /etc/nginx/njs/
sudo chmod 644 /etc/nginx/njs/*.js

結語

Nginx JavaScript(njs)模組提供了一個強大且輕量級的方式來擴展 Nginx 的功能。透過 JavaScript 語法,開發者可以輕鬆實作複雜的請求處理邏輯、認證機制、API Gateway 功能等。

相較於傳統的 Lua 模組(OpenResty),njs 具有以下優勢:

  • 使用更普遍的 JavaScript 語法
  • 官方原生支援,與 Nginx 整合更緊密
  • 記憶體管理更加安全
  • 持續更新與維護

在生產環境中使用 njs 時,請注意效能考量,善用共享記憶體和非同步子請求,並確保適當的錯誤處理和日誌記錄。

comments powered by Disqus
Built with Hugo
Theme Stack designed by Jimmy