vio库的使用与easy-api

Why vio?

vio是一个开源的库,是基于express基础上的插件。
Github代码库:https://github.com/vilic/vio
官方还有一个demo,我认为很好,涵盖了很多内容,可以查看:https://github.com/vilic/vio-demos

它具有以下特点:

  • 基于请求URL,自动尝试匹配路径名、文件名、函数名找到合适的处理函数,不需在app或router中注册任何东西。
  • 支持自动判断更改、动态加载已更改的文件,这样对现有API文件做任何增删改,不需要重启服务就可以应用更改;
  • 装饰器特性,语法更简洁清晰;
  • 支持模版渲染并与普通API的定义语法相统一,更易用;
  • 可以自定义用户和鉴权逻辑,并在request中直接拿到,更方便。
  • 相当于express的插件,具有轻量级的特性,可以与express现有的各种组件和特性兼容。

本人使用该库最经典的用途是有时候想写一些很简单但是经常改或者希望很快的部署的API。静态文件部署是很方便的,只要把文件复制到静态文件目录就可以;所以我一直在想,有没有什么方法可以像静态文件一样简洁的方法来执行基于js的API处理函数?当我发现这个库时,我发现它几乎完全是我所想要的:只要在对应于请求URL路径的位置,插入一个合适名字的文件,无需任何额外操作这个API就会生效;想要修改或删除,直接操作文件就可以。这就是我如此喜欢这个库的原因。

基本使用方法

如同上文所述,vio是一个轻量级的插件,在express的架构里就是一个Router。想要在某一个文件夹和对应的URL使用vio,只需要这样:

1
2
3
4
5
6
7
import * as vio from 'vio'
import * as express from 'express'
var app = express();
new vio.Router(app, {
routesRoot:"./api",
prefix: "/api"
});

其中vio.Router的构造函数接受两个参数:第一个是expressapp实例,第二个是一系列参数;由于app被传进去了,执行完这句话以后就自动相当于进行了app.use,因此不用额外的use了。
上例即建立了一个Router,它仅处理前缀为/api的请求,并在文件夹./api下面找寻对应的处理函数。
在找寻处理函数时,你写的某一个函数,可以匹配到的URL的路径为:这个js文件相对于routesRoot的路径+定义的处理函数的指定路径(详见下文所述)。其中,default是保留关键字,以default命名的文件或函数在匹配时不会匹配default字符串,而是作为其上级URL的默认匹配。
例如: (以下路径均指相对于routesRoot、URL均指相对于prefix

  • qwq/yyy.js中的hhh函数与URL/qwq/yyy/hhh匹配;
  • qwq/yyy.js中的default函数与URL/qwq/yyy匹配;
  • qwq/default.js中的aaa函数与URL/qwq/aaa匹配;
  • qwq/default.js中的default函数与URL/qwq匹配;
  • ccc.js中的ddd函数与URL/ccc/ddd匹配;
  • ccc.js中的default函数与URL/ccc匹配;
  • default.js中的bbb函数与URL/bbb匹配;
  • default.js中的default函数与URL/匹配;

而在每一个文件里,express原有的Router实例的写法不能使用了,取而代之的是只写一个类继承vio.Controller、无需实例化,并把这个类export default

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import {Controller, Request, ExpressResponse, get, post} from 'vio'

export default class Test extends Controller{
@get()
default(req: Request<any>, res:ExpressResponse){
res.json({
method: "GET",
result: "qwq"
})
}

@post({
path: "i_am_real_path"
})
name_not_important(req: Request<any>, res:ExpressResponse){
res.json({
method: "POST",
result: "qwq/yyy"
})
}
}

所需要的所有处理函数都写成上述类的一个成员函数,并用@get@post等修饰器修饰(其他HTTP方法可以用@route('put')这类的修饰)。然后可以传入一个可选的参数,参数中可以包含path字段就是该函数在被匹配时对应的路径。如果没有写path字段,那么函数名如果是default则匹配根路径,如果是其他的则匹配函数名转化为小写字母-下划线格式之后的路径。
函数只有两个参数reqres,没有next了。但是对于长轮询等不希望立即返回的场景,写成async函数是完全没问题的,vio内部已经支持。
res参数就是express.Response类型,各种方法都可用。但是如果当处理函数返回后(包括异步返回,即返回的Promise resolve后),res没有被end,vio则会自动返回一个JSON{data: value}来end这个请求(value是函数执行完实际的返回值),不会被转交给next的。也就是说,希望自己不处理某个摊派给自己的请求而转交给下一个人处理,是不可能的事情了,任何请求一旦离开你定义的处理函数体以后就一定会end掉。因此,可以利用路由机制保证不需要处理的请求不要被路由进来就可以。
req参数是Request类型,而实际上Request的定义中继承了express.Request,也就是说原来expressreq的各种属性和方法可以正常使用。至于带泛型的Request,其实是为了用户管理功能而准备的,详见下文所述;在没有使用用户管理功能的情况下,就写成Request<any>就可以了。

用户管理功能

使用用户管理功能的方法,请见以下示例代码及注释:

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
//userDef.ts
import {ExpressRequest, PermissionDescriptor, UserProvider} from "vio";

/**
* (可选的)定义任意的Permission接口,不需要继承任何东西,相当于用户分组
* 如果不需要使用自动鉴权功能的话那就不用定义这个接口
*/
export declare type MyPermission = "superadmin" | "admin" | "user"

/**
* 再定义一个任意的用户接口,无需继承任何类
* 但是如果想要用自带的PermissonCheck功能的话,那么需要把permission: MyPermission字段定义在里面。
*/
export interface MyUser{
name: string,
password: string
session?: string,
permission: MyPermission
}

/**
* 道理上,用户列表应该存在数据库之类的地方,但我们为了简便起见就搞一个静态的数组存着,并且明文存储密码。
*/
export var AllUsers: Array<MyUser> = [
{
name: "张三",
password: "qwq",
permission: "admin"
}
];

/**
* 定义一个用户产生器类,实现UserProvider接口并至少定义get方法、可选定义authenticate方法。这些方法都可以是异步的。
* get方法用于在一般的请求函数中获取用户信息,
* 而authenticate方法用于在装饰器中指定了authentication: true的请求函数中获取用户信息,
* 这时往往还要加上一个设置session之类的操作以便之后的get方法的使用。
*
* 实践中一般只有loginAPI才会是authentication: true,从请求中附带的用户名密码找到用户。
* 通常login接口的处理函数中会有setCookie,设置一个session共之后get方法的鉴权使用。
* 实现了上述方法之后,一般的API则会在进入处理函数前自动调用MyUserProvider的get方法,
* 并把得到的MyUser结果加进req.user字段,从而在处理函数里面就可以直接取用了。
*/
export class MyUserProvider implements UserProvider<MyUser>{
async authenticate(req: ExpressRequest) {
let user = AllUsers.find((u)=>u.name === req.body["name"]);
if(user.password === req.body["password"])return user;
else return null;
}

async get(req: ExpressRequest) {
return AllUsers.find((u)=>u.session === req.cookies.session);
}
}

/**
* 可选的,如果需要使用自动鉴权功能,就定义一个继承了PermissionDescriptor<MyPermission>的类
* 并实现抽象方法validate,接受的参数是MyPermission。
*
* 之后若某个请求函数有permission字段,类型为MyPermissionDescriptor的话,则会自动用get获得用户、
* 自动取用户的permission、自动传入validate函数进行验证、验证失败自动返回403。
* 例如以下定义,则只需要在请求函数的装饰器的参数加入字段:permission: MyPermissionDescriptor(["admin"]),
* 即可实现自动鉴权管理员。
*/
export class MyPermissionDescriptor extends PermissionDescriptor<MyPermission>{
allowedPermissions: Array<MyPermission>;
constructor(allowedPermissions: Array<MyPermission>){
super();
this.allowedPermissions = allowedPermissions;
}
validate(userPermission: MyPermission): boolean | string {
return !!this.allowedPermissions.find((u)=>u === userPermission)
}
}

之后,记得在app.ts的路由定义中,加上userProvider的一个实例:

1
2
3
4
5
6
7
8
9
10
// app.ts
// ...
let userRouter = new vio.Router(app, {
routesRoot: path.join(__dirname, "with-user"),
prefix: "with-user",
production: false
});
import {MyUserProvider} from "./with-user/userDef";
userRouter.userProvider = new MyUserProvider();
// ...

有了以上用户和权限的定义,登录、获得用户、鉴权就十分的简单了:通过req.user即可直接拿到用户对象,而authenticate可以简便登录、permission可以简便鉴权与访问控制。

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
// default.ts
import {Controller, ExpressResponse, get, post, Request} from "vio";
import {MyPermissionDescriptor, MyUser} from "./userDef";

export default class Default extends Controller{
@post({
authentication: true
})
login(req: Request<MyUser>, res: ExpressResponse){
if(req.user){
//user非null即为authenticate方法鉴权通过
let session = new Date().getTime().toString();
req.user.session = session;
res.cookie("session", session);
}else{
res.sendStatus(403);
}
}

@get()
all(req: Request<MyUser>, res: ExpressResponse){
//所有请求都会进来,而如果用户判断失败user是undefined
res.json({
result: "success",
name: req.user && req.user.name
})
}

@get({
permission: new MyPermissionDescriptor(["admin"])
})
onlyadmin(req: Request<MyUser>, res: ExpressResponse){
//鉴权失败的会直接403掉,进不到函数里面
res.json({result: "successAdmin"})
}
}

使用WebSocket

vio原生是不支持WebSocket的。这很可以理解,毕竟事实上express都没有对WebSocket的原生支持,而是要通过socket.ioexpress-ws之类的包来实现。
然而,经过我的研究,发现了使用express-ws包结合vio的最好方式。原本的express-ws包通过在appexpress.Router()的原型上植入ws方法,使得我们可以像平时.get.post一样.ws,简单的完成WebSocket连接的建立。具体的express-ws包用法请参看官方文档,这里不再赘述。
事实上,每个vio.Router内部,都封装了一个express.Router实例,vio的路由正是以此实现的。当我们写一个装饰器@route(xxx)时(@get等价于@route("get")),行为正是对一个被封装的express.Router实例调用xxx方法。
因此,我们只要在Controller里这样定义,就可以实现ws的连接了:(当然,前提是要在app.ts里注入express-ws组件)

1
2
3
4
5
// app.ts
import * as express from 'express'
import * as ExpressWS from 'express-ws'
var app = express();
ExpressWS(app);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ws.ts
import {Controller, Request, route} from 'vio'
import * as WebSocket from 'ws';

export default class Test extends Controller{
//必须用@ts-ignore,因为vio包中HttpMethod的声明没有包括ws。
//@ts-ignore
@route("ws")
ws(ws: WebSocket, req: Request<any>){
ws.send("hello client!");
ws.on('message', (data)=>{
console.log(data);
});
ws.on('close', (data)=>{
console.log("ws closed");
});
// 因为vio会自动把函数的第二个参数当成res并在函数返回后检查res是否有被end,
// 因此我们可以添加这样一个属性假装这个res被end了,以免vio对req对象调用并不存在的end方法引起报错。
req["headersSent"] = true;
}

}

上述的原理是装饰器@route("ws")中的ws会被直接当作方法名,在其内的express.Router实例中调用,这样就实现了利用express-ws包的特点完成WebSocket。
补充:如果遇到程序无任何报错、设置的@route(ws)处理函数不会被调用、WebSocket连接能被建立但建立后瞬间被服务器关闭的问题,这其实是一个express-ws包的bug(也可能是特性),与vio无关。由于express-ws包操作了app的原型,被express-ws注入的app必须使用app.listen方法监听,而不能手动通过http.createServer(app).listen方法监听,否则会造成上述ws函数无法被路由到、连接建立后立即关闭的问题。

开箱即用的多功能后端程序

虽然vio如此方便,但是仍然需要一些配置成本和学习成本。为了降低学习成本,我编写了此博客;为了降低配置成本,我打造了一个开箱即用的多功能后端程序:https://github.com/Starrah/easy-api
它的使用方法十分简单:

  1. 下载文件

    1
    git clone https://github.com/Starrah/easy-api.git
  2. 安装依赖
    进入下载后的程序目录

    1
    npm install
  3. 运行程序
    命令行可以带参数指定端口号,不填则默认为8080

    1
    node app.js 8080
  4. 放置你的API
    使用vioController语法编写处理请求的程序,然后直接放到api文件夹下即可。放置的位置就是请求的URL。
    本文章中有很多代码示例,下载到的文件中api文件夹内也已有一定量的示例函数,您可仿照此编写。