njs入门手册:Nginx JavaScript Engine

in 前后端开发 with 1 comment

关于njs

首先,njs似乎在国内外都不受关注,资料什么的只有 官网参考手册,出了个问题只能看到Github Issue
所以,这篇文章将我的探索过程展示给大家,njs对于可用存储空间较小的设备真的很友好,相比较于NodeJS、Deno这种80M起步的运行环境真的很轻量
但是,这里有几点需要提一下,入坑需谨慎:

入门第一步: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很像)

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'))
注意 这个函数最大的坑就是没有返回值
如果**没有权限就抛出错误**,千万别忘记`catch`

open(): 打开文件

这个函数很关键,用于打开文件

open(path: Buffer|string, flags?: OpenMode, mode?: number): Promise<NjsFsFileHandle>;
文件模式描述
"a"打开文件用于追加。 如果文件不存在,则创建该文件
"ax"类似于 'a',但如果路径存在,则失败
"a+"打开文件用于读取和追加。 如果文件不存在,则创建该文件
"ax+"类似于 'a+',但如果路径存在,则失败
"as"打开文件用于追加(在同步模式中)。 如果文件不存在,则创建该文件
"as+"打开文件用于读取和追加(在同步模式中)。 如果文件不存在,则创建该文件
"r"打开文件用于读取。 如果文件不存在,则会发生异常
"r+"打开文件用于读取和写入。 如果文件不存在,则会发生异常
"rs+"类似于 'r+',但如果路径存在,则失败
"w"打开文件用于写入。 如果文件不存在则创建文件,如果文件存在则截断文件
"wx"类似于 'w',但如果路径存在,则失败
"w+"打开文件用于读取和写入。 如果文件不存在则创建文件,如果文件存在则截断文件
"wx+"类似于 'w+',但如果路径存在,则失败

这个函数重点是返回的结果。什么?看不起?好,那么我们尝试读取文件的一段
我们先看一下结构

这是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)

realpath(): 相对路径转绝对路径

realpath(path, 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)

unlink() 删除文件

unlink(path: PathLike): Promise<void>;

rmdir() 删除文件夹

rmdir(path: PathLike, options?: { recursive?: boolean; }): Promise<void>;

stat() 获取文件(夹)状态

stat(path: PathLike, options?: { throwIfNoEntry?: boolean; }): Promise<NjsStats>;

symlink() 创建 链接

symlink(target: PathLike, path: PathLike): Promise<void>;

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;

其他你感兴趣的

全局命名空间

njs

NJS有一个全局命名空间njs.*,这里面的东西全局可用不分场合

ngx

还有一个命名空间叫做ngx.*,这里面的东西与nginx相关
东西太多,我就介绍最重要的

Responses
  1. [...]https://hi.imzlh.top/2024/07/08.cgi关于njs首先,njs似乎在国内外都不受关注,资料什么的只有[...]

    Reply