这个库已经不再建议使用。如有类似需求,请考虑 express-router-dynamic
# 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
,只需要这样:
import * as vio from 'vio' | |
import * as express from 'express' | |
var app = express(); | |
new vio.Router(app, { | |
routesRoot:"./api", | |
prefix: "/api" | |
}); |
其中 vio.Router
的构造函数接受两个参数:第一个是 express
的 app
实例,第二个是一系列参数;由于 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
:
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 则匹配根路径,如果是其他的则匹配函数名转化为小写字母 - 下划线格式之后的路径。
函数只有两个参数 req
和 res
,没有 next
了。但是对于长轮询等不希望立即返回的场景,写成 async 函数是完全没问题的, vio
内部已经支持。res
参数就是 express.Response
类型,各种方法都可用。但是如果当处理函数返回后(包括异步返回,即返回的 Promise resolve 后), res
没有被 end
,vio 则会自动返回一个 JSON {data: value}
来 end 这个请求(value 是函数执行完实际的返回值),不会被转交给 next
的。也就是说,希望自己不处理某个摊派给自己的请求而转交给下一个人处理,是不可能的事情了,任何请求一旦离开你定义的处理函数体以后就一定会 end 掉。因此,可以利用路由机制保证不需要处理的请求不要被路由进来就可以。req
参数是 Request
类型,而实际上 Request
的定义中继承了 express.Request
,也就是说原来 express
的 req
的各种属性和方法可以正常使用。至于带泛型的 Request
,其实是为了用户管理功能而准备的,详见下文所述;在没有使用用户管理功能的情况下,就写成 Request<any>
就可以了。
# 用户管理功能
使用用户管理功能的方法,请见以下示例代码及注释:
//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 的一个实例:
// 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
可以简便鉴权与访问控制。
// 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.io
、 express-ws
之类的包来实现。
然而,经过我的研究,发现了使用 express-ws 包结合 vio
的最好方式。原本的 express-ws
包通过在 app
或 express.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
组件)
// app.ts | |
import * as express from 'express' | |
import * as ExpressWS from 'express-ws' | |
var app = express(); | |
ExpressWS(app); |
// 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
它的使用方法十分简单:
- 下载文件
git clone https://github.com/Starrah/easy-api.git
- 安装依赖
进入下载后的程序目录npm install
- 运行程序
命令行可以带参数指定端口号,不填则默认为 8080node app.js 8080
- 放置你的 API
使用vio
的Controller
语法编写处理请求的程序,然后直接放到 api 文件夹下即可。放置的位置就是请求的 URL。
本文章中有很多代码示例,下载到的文件中 api 文件夹内也已有一定量的示例函数,您可仿照此编写。