njs最詳細的入門手冊:Nginx JavaScript Engine

imzlh發表於2024-07-18

原文連結:https://hi.imzlh.top/2024/07/08.cgi

關於njs

首先,njs似乎在國內外都不受關注,資料什麼的只有 官網參考手冊,出了個問題只能看到Github Issue
所以,這篇文章將我的探索過程展示給大家,njs對於可用儲存空間較小的裝置真的很友好,相比較於NodeJS、Deno這種80M起步的執行環境真的很輕量
但是,這裡有幾點需要提一下,入坑需謹慎:

  • 不完善的語法
    • for...of不可用
    • import和export只能使用預設匯出
    • try...catch 不能不定義捕獲的內容,比如這個就不合法
      try{
      require('fs').statSync('/')
      }catch{
      ngx.log(ngx.INFO, '找不到模組fs')
      }
    • 沒有Event支援,如addEventListener
    • ...
  • 沒有GC
    這表明NJS VM是一次性的,除非手動垃圾回收
  • 反人類的API設計
    比如,fs.open()後不能seek(),返回的是UInt8Array
  • 社群不完善
    你可能需要自己摸索,甚至有閱讀原始碼和提Issue的勇氣
  • ...

入門第一步:TypeScript

雖然njs不支援TypeScript,但是不影響我們使用TypeScript為程式碼新增型別檢查
NJS官方開發了TypeScript型別定義,開箱即用
將定義放在type資料夾中,然後使用三斜槓ref語法引入

配置

入口上,我們不能使用export function語法(前文提到過),需要定義一個入口函式然後使用預設匯出

async function main(h:NginxHTTPRequest){
    // ...
}
export default { main }

注意
這個時候不能使用njs-cli執行,會顯示SyntaxError: Illegal export statement
解決辦法:njs -c "import M from './main.js'; M.main();"

提示
Nginx的Buffer和NodeJS的Buffer很像,我就不多介紹了

檔案系統(fs)

使用NJS的目標就是代替NginxLUA模組,NJS複用Nginx的事件迴圈,因此支援非同步操作
非同步操作用的最多的就是檔案IO,即fs
使用fs有兩種方式(這一點上和NodeJS很像)

  • ES式 import FS from 'fs';
  • CommonJS式 const FS = require('fs');

FS內有兩種,一種是同步IO(不建議,但API簡單)和非同步IO(共享Nginx的EventLoop)
下面我們以非同步IO為例:

access(): 嘗試獲取檔案

access最大的作用是確保檔案是如你所想的,要知道,Permission Denied很煩人
這個是官方的例項:

import fs from 'fs'
fs.promises.access('/file/path', fs.constants.R_OK | fs.constants.W_OK)
.then(() => console.log('has access'))
.catch(() => console.log('no access'))
  • 第一個引數(字串)是檔名
  • 第二個引數(數字)是檔案模式,允許使用位或(|),官方提供了fs.constants
    fs.constants裡有一些預設變數,方便使用
    • R_OK 可讀 (0b100)
    • W_OK 可寫 (0b10)
    • X_OK 可執行 (0b1)
    • F_OK 好歹是個檔案(夾) (0b0)

注意 這個函式最大的坑就是沒有返回值,如果沒有許可權就丟擲錯誤,千萬別忘記catch

open(): 開啟檔案

這個函式很關鍵,用於開啟檔案

open(path: Buffer|string, flags?: OpenMode, mode?: number): Promise<NjsFsFileHandle>;
  • 第一個引數是檔案位置(string),甚至可以傳入Buffer
  • 第二個引數是開啟模式
    | 檔案模式 | 描述 |
    |-----|-----|
    | "a" | 開啟檔案用於追加。 如果檔案不存在,則建立該檔案|
    | "ax" | 類似於 'a',但如果路徑存在,則失敗 |
    | "a+" | 開啟檔案用於讀取和追加。 如果檔案不存在,則建立該檔案 |
    | "ax+" | 類似於 'a+',但如果路徑存在,則失敗 |
    | "as" | 開啟檔案用於追加(在同步模式中)。 如果檔案不存在,則建立該檔案 |
    | "as+" | 開啟檔案用於讀取和追加(在同步模式中)。 如果檔案不存在,則建立該檔案 |
    | "r" | 開啟檔案用於讀取。 如果檔案不存在,則會發生異常 |
    | "r+" | 開啟檔案用於讀取和寫入。 如果檔案不存在,則會發生異常 |
    | "rs+" | 類似於 'r+',但如果路徑存在,則失敗 |
    | "w" | 開啟檔案用於寫入。 如果檔案不存在則建立檔案,如果檔案存在則截斷檔案 |
    | "wx" | 類似於 'w',但如果路徑存在,則失敗 |
    | "w+" | 開啟檔案用於讀取和寫入。 如果檔案不存在則建立檔案,如果檔案存在則截斷檔案 |
    | "wx+" | 類似於 'w+',但如果路徑存在,則失敗 |

這個函式重點是返回的結果。什麼?看不起?好,那麼我們嘗試讀取檔案的一段
我們先看一下結構

  • close()
    關閉這個檔案fd
  • fd
    檔案fd(file description)
  • read(buffer, buf_offset, read_len, pos)
    • buffer 傳入一個Buffer用於緩衝。當讀取完畢時,這個Buffer裡有我們想要的資料
    • buf_offset 這個Buffer開始填充的位置。可以用這個實現一個Buffer讀取指定大小的內容
    • read_len 讀取長度,但是如果超出了Nginx的Buffer大小,這個數值相對於實際讀取的大小會偏大
    • pos 這個是我們今天的重頭戲
      想要知道如何seek嗎?不行,必須使用pos
      如果設定為數字,將seek到那個地方並開始讀取
      如果設定為null,不改變檔案指標位置,從當前位置開始讀取
      是不是很反人類?
    • 最後返回NjsFsBytesRead,其中有兩個元素
      • bytesRead,讀取的長度
      • buffer,就是你傳入的buffer
  • stat()
    等同於fs.promises.stat()的結果
  • [A] write(buffer, buf_offset, read_len, pos)
    • buffer 老規矩,寫入的資料Buffer
    • buf_offset 這個Buffer開始讀取的位置
    • read_len 從這個Buffer讀取用於寫入長度,但是如果超出了Nginx的Buffer大小,這個數值相對於實際讀取的大小會偏大
    • pos 和上面read()的pos引數一致
    • 最後返回NjsFsBytesWritten,其中有兩個元素
      • bytesWritten,寫入的長度
      • buffer,就是你傳入的buffer
  • [b] write(string, pos, encoding)
    • write()也可以寫入字串
    • string 等待寫入的字串
    • pos 和上面read()的pos引數一致
    • encoding 編碼格式,可選 utf8 hex base64 base64url

這是TypeScript定義

interface NjsFsFileHandle {
    close(): Promise<void>;
    fd: number;
    read(buffer: NjsBuffer, offset: number, length: number, position: number | null): Promise<NjsFsBytesRead>;
    stat(): Promise<NjsStats>;
    write(buffer: NjsBuffer, offset: number, length?: number, position?: number | null): Promise<NjsFsBytesWritten>;
    write(buffer: string, position?: number | null, encoding?: FileEncoding): Promise<NjsFsBytesWritten>;
}

關於使用,可以見 https://github.com/imzlh/vlist-njs/blob/master/main.ts#L130,實現純粹檔案複製

 const st = await fs.promises.open(from,'r'),
    en = await fs.promises.open(to,'w');
while(true){
    // 讀取64k 空間
    const buf = new Uint8Array(64 * 1024),
        readed = await st.read(buf, 0, 64 * 1024, null);

    // 讀取完成
    if(readed.bytesRead == 0) break;

    // 防漏式寫入
    let writed = 0;
    do{
        const write = await en.write(buf, writed, readed.bytesRead - writed, null);
        writed += write.bytesWritten;
    }while(writed != readed.bytesRead);
}

readdir():掃描資料夾

雖然我們建議返回填滿string的陣列,但是返回填充了Buffer的陣列也不是不行

readdir(path, option)
  • path 路徑,同樣可以是Buffer
  • option Object物件
    • encoding 編碼格式,可選 utf8(返回字串) buffer(返回Buffer)
    • withFileTypes 自帶stat檔案型別的掃描,指定為true,返回的就是NjsDirent[]
      • isBlockDevice()
      • isCharacterDevice()
      • isDirectory()
      • isFIFO()
      • isFile()
      • isSocket()
      • isSymbolicLink()
      • name 檔案(夾)名
  • 返回值由option決定,如果什麼都沒指定,返回字串陣列

realpath(): 相對路徑轉絕對路徑

realpath(path, option)
  • path 路徑,同樣可以是Buffer
  • option Object物件
    • encoding 編碼格式,可選 utf8(返回字串) buffer(返回Buffer)
  • 返回值由option決定,如果什麼都沒指定,返回字串

rename(): 移動檔案

注意跨檔案系統(磁碟)移動不能使用rename(),instead,請複製後再刪除
實用技巧 什麼?你告訴我你不會判斷是否跨檔案系統(磁碟)?stat()啊

const from = await fs.promises.stat('...'),
    to = await fs.promises.stat('...');
            
// 相同dev使用rename
if(from.dev == to.dev){
    await fs.promises.rename(...);
}else{
    // copy()
    await fs.promises.unlink('...');
}

例項參考:https://github.com/imzlh/vlist-njs/blob/master/main.ts#L622

rename(from, to)
  • from 路徑,除了string同樣可以是Buffer
  • to 路徑,同理,除了string同樣可以是Buffer
  • 沒有返回值,注意catch錯誤情況
unlink(path: PathLike): Promise<void>;
  • path 路徑,同樣可以是Buffer
  • 沒有返回值

rmdir() 刪除資料夾

rmdir(path: PathLike, options?: { recursive?: boolean; }): Promise<void>;
  • path 路徑,同樣可以是Buffer
  • options
    • recursive 遞迴刪除,相當於大名鼎鼎的rm -r
      建議體驗這個命令,你就知道什麼是遞迴刪除了: rm -rf /
  • 沒有返回值

stat() 獲取檔案(夾)狀態

stat(path: PathLike, options?: { throwIfNoEntry?: boolean; }): Promise<NjsStats>;
  • path 路徑,同樣可以是Buffer
  • options Object物件
    • throwIfNoEntry
      如果設定為true,檔案不存在時直接報錯,否側返回 undefined
  • 返回NjsStat
    • isBlockDevice()
    • isCharacterDevice()
    • isDirectory()
    • isFIFO()
    • isFile()
    • isSocket()
    • isSymbolicLink()
    • dev: number 處於的檔案系統ID
    • ino: number inode數量
    • mode: number 檔案模式,8進位制
    • nlink: number 這個檔案實際地址硬連結數量,即引用數
    • uid: number 所有者User ID
    • gid: number 所有者Group ID
    • rdev: number 這個檔案代表檔案系統時表示此檔案代表的檔案系統ID
    • size: number 檔案大小
    • blksize: number
    • blocks: number
    • atimeMs: number 最後訪問時間戳
    • mtimeMs: number 最後修改檔案修飾(模式)時間戳
    • ctimeMs: number 最後修改時間戳
    • birthtimeMs: number 建立時間
    • atime: Date;
    • mtime: Date;
    • ctime: Date;
    • birthtime: Date;
symlink(target: PathLike, path: PathLike): Promise<void>;
  • target 目標(要建立軟連線的)檔案路徑,同樣可以是Buffer
  • path 新建的軟連線的路徑,同樣可以是Buffer
  • 沒有返回值

writeFile和readFile 偷懶讀/寫檔案的好方法

readFile(path: Buffer|string): Promise<Buffer>;
readFile(path: Buffer|string, options?: {
    flag?: "a" | "ax" | "a+" | "ax+" | "as" | "as+" | "r" | "r+" | "rs+" | "w" | "wx" | "w+" | "wx+"
}): Promise<Buffer>;
readFile(path: Buffer|string, options: {
    flag?: "a" | "ax" | "a+" | "ax+" | "as" | "as+" | "r" | "r+" | "rs+" | "w" | "wx" | "w+" | "wx+",
    encoding?: "utf8" | "hex" | "base64" | "base64url"
} | "utf8" | "hex" | "base64" | "base64url"): Promise<string>;
writeFile(path: Buffer|string, data: string | Buffer | DataView | TypedArray | ArrayBuffer, options?: {
    mode?: number;
    flag?: "a" | "ax" | "a+" | "ax+" | "as" | "as+" | "r" | "r+" | "rs+" | "w" | "wx" | "w+" | "wx+"
}): Promise<void>;

不多作介紹,看定義就行

請求(request)

請求,就是傳入主函式的一個引數,函式由export匯出和js_import匯入以供nginx呼叫
這個是函式定義(main.js)

async main(h:NginxHTTPRequest):any;

這個是匯出(main.js)

export { main };

這個是匯入(nginx http)

js_import SCRIPT from 'main.js';

這個是使用(nginx location)

location /@api/{
    js_content SCRIPT.main;
}

這樣,每當請求/@api/時,main()就會被呼叫,所有Promise完成時VM會被回收
這裡講4個很常用的技巧

args GET引數

h.args 是一個陣列,官方是這麼說的

Since 0.7.6, duplicate keys are returned as an array, keys are
case-sensitive, both keys and values are percent-decoded.
For example, the query string
a=1&b=%32&A=3&b=4&B=two%20words
is converted to r.args as:
{a: "1", b: ["2", "4"], A: "3", B: "two words"}

args會自動解碼分割,允許重複且重複的會變成一個Array
這裡就很重要了,每一個請求你都需要檢查你需要的arg是不是Arraystring而不能認為只要不是undefined就是string,下面的程式碼就是最好的反例

if(typeof h.args.action != 'string')
    return h.return(400,'invaild request: Action should be defined');

當請求/@api/?action=a&action=b時,這個函式會錯誤報錯,事實上Action已經定義

headersIO

h.headersInh.headersOut是Nginx分割好的Header,你可以直接使用
但是這兩個常量有很大的限制,必須是Nginx內部專門定義的Header才會出現
其中,headersIn的定義是這樣的

readonly 'Accept'?: string;
readonly 'Accept-Charset'?: string;
readonly 'Accept-Encoding'?: string;
readonly 'Accept-Language'?: string;
readonly 'Authorization'?: string;
readonly 'Cache-Control'?: string;
readonly 'Connection'?: string;
readonly 'Content-Length'?: string;
readonly 'Content-Type'?: string;
readonly 'Cookie'?: string;
readonly 'Date'?: string;
readonly 'Expect'?: string;
readonly 'Forwarded'?: string;
readonly 'From'?: string;
readonly 'Host'?: string;
readonly 'If-Match'?: string;
readonly 'If-Modified-Since'?: string;
readonly 'If-None-Match'?: string;
readonly 'If-Range'?: string;
readonly 'If-Unmodified-Since'?: string;
readonly 'Max-Forwards'?: string;
readonly 'Origin'?: string;
readonly 'Pragma'?: string;
readonly 'Proxy-Authorization'?: string;
readonly 'Range'?: string;
readonly 'Referer'?: string;
readonly 'TE'?: string;
readonly 'User-Agent'?: string;
readonly 'Upgrade'?: string;
readonly 'Via'?: string;
readonly 'Warning'?: string;
readonly 'X-Forwarded-For'?: string;

這個是headersOut

'Age'?: string;
'Allow'?: string;
'Alt-Svc'?: string;
'Cache-Control'?: string;
'Connection'?: string;
'Content-Disposition'?: string;
'Content-Encoding'?: string;
'Content-Language'?: string;
'Content-Length'?: string;
'Content-Location'?: string;
'Content-Range'?: string;
'Content-Type'?: string;
'Date'?: string;
'ETag'?: string;
'Expires'?: string;
'Last-Modified'?: string;
'Link'?: string;
'Location'?: string;
'Pragma'?: string;
'Proxy-Authenticate'?: string;
'Retry-After'?: string;
'Server'?: string;
'Trailer'?: string;
'Transfer-Encoding'?: string;
'Upgrade'?: string;
'Vary'?: string;
'Via'?: string;
'Warning'?: string;
'WWW-Authenticate'?: string;
'Set-Cookie'?: string[];

其中最需要注意的是h.headersOut['Set-Cookie']是一個陣列
當然,大部分情況下這些Header足夠你玩了,但是有的時候還是需要自定義的,這個時候raw開頭的變數上場了

readonly rawHeadersIn: Array<[string, string|undefined]>;
readonly rawHeadersOut: Array<[string, string|undefined]>;

這些都是按照陣列 [key, value] 排的,你可以用下面的程式碼快速找到你想要的

const headers = {} as Record<string, Array<string>>;
h.rawHeadersIn.forEach(item => item[0] in headers ? headers[item[0]].push(item[1]) : headers[item[0]] = [item[1]])
h['X-user-defined'][0]; // 你想要的

如果是自定義輸出的話,第一個想到的是不是應該也是h.rawHeadersOut?
然而,我發現 官方的示例 中用的不是rawHeadersOut而是headersOut
的確,我在rawHeadersOut這些東西的定義下面都發現了

[prop: string]: string | string[] | undefined;

這個讓rawHeaders系列更加意味不明瞭,我也不清楚官方的做法
總之用 headersOut 準沒錯

用這些函式響應客戶端

這個函式傳送的是整個請求,呼叫後這個請求就結束了

return(status: number, body?: NjsStringOrBuffer): void;

這三個函式是用來搭配響應的,但是我不清楚 官方的用意
嘛,大部分時間還是別這麼玩吧

sendHeader(): void;
send(part: NjsStringOrBuffer): void;
finish(): void;

NGINX的特色

internalRedirect(uri: NjsStringOrBuffer): void;
parent?: NginxHTTPRequest;
subrequest(uri: NjsStringOrBuffer, options: NginxSubrequestOptions & { detached: true }): void;
subrequest(uri: NjsStringOrBuffer, options?: NginxSubrequestOptions | string): Promise<NginxHTTPRequest>;
subrequest(uri: NjsStringOrBuffer, options: NginxSubrequestOptions & { detached?: false } | string,
           callback:(reply:NginxHTTPRequest) => void): void;
subrequest(uri: NjsStringOrBuffer, callback:(reply:NginxHTTPRequest) => void): void;

是不是很心動?的確,你可以使用subrequest分割任務,internalRedirect快速服務檔案,parent在子請求內直接操縱響應
舉個例子,你驗證完Token想要傳送給客戶端一個檔案

nginx.conf:

location /@files/{
    internal;
    alias /file/;
}

file.js

// ....
h.internalRedirect('/@files/' + file_path);
// 這個時候客戶端就接收到了`/files/{file_path}`這個檔案

Buffer系列

請注意這一句話

*** if it has not been written to a temporary file.

詳情請參看我的這篇踩坑文章 https://hi.imzlh.top/2024/07/09.cgi
總之,這是Nginx的Buffer,而客戶端的上傳如果大於client_body_buffer_size會被寫入檔案並暴露在變數中 h.variables.request_body_file

readonly requestBuffer?: Buffer;
readonly requestText?: string;

需要注意的是,下面的兩項是subrequest返回的內容而不會寫入客戶端Buffer
想要給客戶端則需要這樣: r.return(res.status, res.responseText)
這個是Nginx官方的例子

readonly responseBuffer?: Buffer;
readonly responseText?: string;

輸出到日誌的函式

error(message: NjsStringOrBuffer): void;
log(message: NjsStringOrBuffer): void;
warn(message: NjsStringOrBuffer): void;

這些很好理解,就是log warn error三個等級的日誌

這些函式不要碰

這些函式是js_body_filter才能使用的,對於新手像我一樣找不到為什麼出錯的很致命

sendBuffer(data: NjsStringOrBuffer, options?: NginxHTTPSendBufferOptions): void;
done(): void;

其他你感興趣的

  • httpVersion: string HTTP版本號
  • method: string HTTP方法,是大寫的
  • remoteAddress: string 客戶端地址
  • uri: string 請求的URL,在subrequest則是subrequest的URL
  • variables: NginxVariables Nginx變數,是UTF8字串
  • rawVariables: NginxRawVariables Nginx變數,不同的是值是Buffer

全域性名稱空間

njs

NJS有一個全域性名稱空間njs.*,這裡面的東西全域性可用不分場合

  • version: string njs版本
  • version_number: number njs版本,字串版本
  • on(event: "exit", callback: () => void): void VM退出時的回撥
  • dump(value: any, indent?: number): string pre列印,輸出到日誌

ngx

還有一個名稱空間叫做ngx.*,這裡面的東西與nginx相關
東西太多,我就介紹最重要的

  • fetch(init: NjsStringOrBuffer | Request, options?: NgxFetchOptions): Promise<Response>
    和Web很像的fetchAPI,只是第二個引數大縮水了
    • body?: string
    • headers?: NgxHeaders
    • method?: string
    • verify?: boolean 是否驗證SSL證書,預設驗證,不符合會報錯
  • log(level: number, message: NjsStringOrBuffer): void
    寫入到Nginx日誌,level可以是這些
    • ngx.INFO
    • ngx.WARN
    • ngx.ERR
  • readonly shared: NgxGlobalShared
    共享池,這個很有用,重點介紹下
    當多個VM需要共享一個資料時,我們第一個想到的解決方法時資料庫(DataBase)
    但是njs現在不支援資料庫,作為過渡,這個shared就是解決方法
    透過共享池,共享同樣的資料,再使用共享鎖就可以實現了
    其中共享池名稱 大小 型別由js_shared_dict_zone定義
    這些是可利用的所有函式
    • ngx.shared.[共享池名稱].add()
    • ngx.shared.[共享池名稱].capacity 共享池大小
    • ngx.shared.[共享池名稱].clear()
    • ngx.shared.[共享池名稱].delete()
    • ngx.shared.[共享池名稱].freeSpace()
    • ngx.shared.[共享池名稱].get()
    • ngx.shared.[共享池名稱].has()
    • ngx.shared.[共享池名稱].incr() 增大一個鍵對應的值的大小
    • ngx.shared.[共享池名稱].items()
    • ngx.shared.[共享池名稱].keys()
    • ngx.shared.[共享池名稱].name
    • ngx.shared.[共享池名稱].pop()
    • ngx.shared.[共享池名稱].replace()
    • ngx.shared.[共享池名稱].set()
    • ngx.shared.[共享池名稱].size() 這個共享池元素的數量
    • ngx.shared.[共享池名稱].type 型別stringnumber,由js_shared_dict_zone定義
  • worker_id 工作程序的ID,對於定時任務指定很有效

相關文章