原文連結: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
老規矩,寫入的資料Bufferbuf_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
路徑,同樣可以是Bufferoption
Object物件encoding
編碼格式,可選utf8
(返回字串)buffer
(返回Buffer
)withFileTypes
自帶stat檔案型別的掃描,指定為true,返回的就是NjsDirent[]
了isBlockDevice()
isCharacterDevice()
isDirectory()
isFIFO()
isFile()
isSocket()
isSymbolicLink()
name
檔案(夾)名
- 返回值由option決定,如果什麼都沒指定,返回字串陣列
realpath(): 相對路徑轉絕對路徑
realpath(path, option)
path
路徑,同樣可以是Bufferoption
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同樣可以是Bufferto
路徑,同理,除了string同樣可以是Buffer- 沒有返回值,注意
catch
錯誤情況
unlink() 刪除檔案
unlink(path: PathLike): Promise<void>;
path
路徑,同樣可以是Buffer- 沒有返回值
rmdir() 刪除資料夾
rmdir(path: PathLike, options?: { recursive?: boolean; }): Promise<void>;
path
路徑,同樣可以是Bufferoptions
recursive
遞迴刪除,相當於大名鼎鼎的rm -r
建議體驗這個命令,你就知道什麼是遞迴刪除了:rm -rf /
- 沒有返回值
stat() 獲取檔案(夾)狀態
stat(path: PathLike, options?: { throwIfNoEntry?: boolean; }): Promise<NjsStats>;
path
路徑,同樣可以是Bufferoptions
Object物件throwIfNoEntry
如果設定為true
,檔案不存在時直接報錯,否側返回 undefined
- 返回NjsStat
- isBlockDevice()
- isCharacterDevice()
- isDirectory()
- isFIFO()
- isFile()
- isSocket()
- isSymbolicLink()
dev
: number 處於的檔案系統IDino
: number inode數量mode
: number 檔案模式,8進位制nlink
: number 這個檔案實際地址硬連結數量,即引用數uid
: number 所有者User IDgid
: number 所有者Group IDrdev
: number 這個檔案代表檔案系統時表示此檔案代表的檔案系統IDsize
: number 檔案大小blksize
: numberblocks
: numberatimeMs
: number 最後訪問時間戳mtimeMs
: number 最後修改檔案修飾(模式)時間戳ctimeMs
: number 最後修改時間戳birthtimeMs
: number 建立時間atime
: Date;mtime
: Date;ctime
: Date;birthtime
: Date;
symlink() 建立 軟 連結
symlink(target: PathLike, path: PathLike): Promise<void>;
target
目標(要建立軟連線的)檔案路徑,同樣可以是Bufferpath
新建的軟連線的路徑,同樣可以是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是不是Array
或string
而不能認為只要不是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.headersIn
和h.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的URLvariables: 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很像的fetch
API,只是第二個引數大縮水了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 型別
string
或number
,由js_shared_dict_zone
定義
worker_id
工作程序的ID,對於定時任務指定很有效