使用过node开发web服务系统的同学通常会使用一些web服务框架,例如:express
,koa
,egg.js
等。 想要知道这些web服务框架是怎么实现的吗?在这里将会带你一步一步实现一个web服务端的框架。现在就让我们来一起学习一下吧!
基础知识
node.js 中实现http服务主要是用node内置的http 和http2 库,http
库提供了请求 和服务端创建 功能,使用http库可以很简单地创建一个web服务。
1 2 3 4 5 6 7 8 9 10 11 12 13 import http from 'http' const app = http.createServer((req, res ) => { if (req.url === '/' ) { res.send('hello world' ) res.end() } else { res.send('' ) res.end() } }) app.listen('9988' , () => { console .log('server running at 9988' ) })
可以看到在node中只需要几行代码就能开启一个http服务,但是我们会发现直接调用服务的时候会有一个问题:
每一个用户访问时,都会调用createServer 中的回调函数,当业务逻辑逐渐复杂的时候,回调函数逐渐变得臃肿而难以维护。
如果我们有一个框架,可以帮我们解决url分析
,页面参数提取
,post参数提取
,路由
等方法,拿我们的开发效率肯定会提高很多!那我们现在一起实现这个框架吧~
HTTP服务
功能分析
整理一下需求 上图就是我们的框架需要实现功能
首先我们需要创建一个Server类,server类提供的是最基本的服务:请求捕捉
,封装上下文
和触发中间件
。中间件
是实现业务逻辑的插件,类似webpack
的插件服务一样,当请求进入web服务时就会触发中间件。 所以Server类需要提供最基本的方法:
init
: 实例初始化,创建http实例
use
: 向实例添加中间件
listen
: 启动web服务
createContext
:封装上下文
runMiddleWare
:调用中间件
初始化
初始化需要创建一个HttpFramework
类并实现init
和listen
方法。init
是私有方法,会在构造器中调用 ,代码如下:
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 import http from 'http' ;interface HttpFrameworkMethods { use : (func: middleWareFunc ) => void ; listen: (...data: ListenOption ) => void ; } type ListenOption = [port: number , callback : () => void ];class HttpFramework implements HttpFrameworkMethods { private serverApp: http.Server | null ; private middleWareArr: middleWareFunc[]; constructor ( ) { this .serverApp = null ; this .middleWareArr = []; this .init(); } private init ( ) { const app = http.createServer((req, res ) => { }); this .serverApp = app; } use ( ) {} listen (port: number , callback: () => void = () => {} ) { this .serverApp?.listen(port, callback); } } export default HttpFramework;
此时已经实现了一个基本的架构,我们实例化之后就可以调用listen
来开启web服务
1 2 3 4 5 6 const app = new HttpFramework()app.listen('9988' , () => { console .log('server running at 9988' ) })
创建上下文
接下来需要给回调函数构造一个上下文,实现:url分析
,页面参数提取
的功能 首先定义一下上下文的类型
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 type PickRequestOptionKey = 'method' ;type requestOption<T extends Record<string , any > = {}> = { _req : http.IncomingMessage; headers: http.IncomingHttpHeaders; fullPath: string ; pathName: string ; query: Record<string , any >; } & Pick<http.IncomingHttpHeaders, PickRequestOptionKey> & T; type PickResponseOptionKey = 'statusCode' | 'end' | 'setHeader' ;type responseOption = { _res : http.ServerResponse; send: (chunk: string | Buffer ) => boolean ; } & Pick<http.ServerResponse, PickResponseOptionKey>;
可以看到,上下文需要定义requestOption
和responseOption
分别对应的是请求进入和请求响应的上下文。 在请求进入时,我们把进行URL分析,将页面参数 和请求地址 分离,除此之外,还把源请求实例都放入上下文中。 代码实现如下
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 function createContext (req: http.IncomingMessage, res: http.ServerResponse ) { const { method, url, headers } = req; const { statusCode, write, end, setHeader } = res; const [pathName, query] = (url || '' ).split('?' ); const queryObj: Record<string , any > = {}; if (query) { const queryArr = query.split('&' ); queryArr.forEach((val ) => { const [key, value] = decodeURIComponent (val).split('=' ); if (key) queryObj[key] = value; }); } const reqOption: requestOption = { _req : req, method : method, pathName, query : queryObj, fullPath : url || '' , headers, }; const resOption: responseOption = { _res : res, statusCode, setHeader, send : write.bind(res), end : end.bind(res), }; return { reqOption, resOption, }; }
编写完后在回调函数中调用该方法
1 2 3 4 5 6 7 private init ( ) { const app = http.createServer((req, res ) => { const { reqOption, resOption } = createContext(req, res); }); this .serverApp = app; }
插入中间件
刚刚我们在创建HttpFramework类的时候定义了middleWareArr
参数和use
方法。middleWareArr
的作用是保存插入的中间件。use
方法则是插入中间件方法。use
方法很简单,只需要将传入的中间件存到middleWareArr
中即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 type nextTickFunc = () => Promise <any >;type middleWareFunc<K extends Record<string , any > = {}> = ( req: requestOption<K>, res: responseOption, next: nextTickFunc ) => void ;use (callback: middleWareFunc<any > ) { if (typeof callback !== 'function' ) { throw new Error ('middle ware must be a function' ); } this .middleWareArr.push(callback); }
可以看到中间件传入了三个参数,req
,res
和next
,req,res是刚刚创建的上下文属性,next
则是整个中间件系统中非常重要的一步, 这里参考了koa
的洋葱模型
洋葱模型
洋葱模型可以简单地用一张图来描述
一次定义了三个中间件(最外层的是最先插入的中间件),当请求进入时,先执行最外层的中间件,然后执行第二层的,以此类推。 当执行到最后一个中间件后,就会一次执行中间件后的代码,例如在koa中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import Koa from 'koa' const app = new Koa()app.use(async (ctx, next) => { console .log('1' ) await next(); console .log('1' ) }) app.use(async (ctx, next) => { console .log('2' ) await next(); console .log('2' ) }) app.use(async (ctx, next) => { console .log('3' ) }) app.listen(8080 )
代码会依次输出: 1 -> 2 -> 3 -> 2 -> 1
所以,中间件的作用就是:调用多个预先设置好的业务方法,在请求进入的时候递归地调用他们并输出。
调用中间件
上面我们一句实现了插入中间件,接下来需要实现的是:当请求进入时,如何按顺序地调用插入的中间件。 这里使用了递归
的方法去实现
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 async function runMiddleWare ( middleWareArr: middleWareFunc[], req: requestOption, res: responseOption ) { if (middleWareArr.length === 0 ) { res.send('404 not found' ); res.end(); } let current = 0 ; async function next ( ) { if (middleWareArr[current + 1 ]) { current++; await middleWareArr[current](req, res, next); } } await middleWareArr[0 ](req, res, next); }
最后修改一下init.js
1 2 3 4 5 6 7 8 9 private init ( ) { const app = http.createServer((req, res ) => { const { reqOption, resOption } = createContext(req, res); runMiddleWare(this .middleWareArr, reqOption, resOption); }); this .serverApp = app; }
这样一个基本的Http框架就实现了,来测试一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 const app = new HttpFramework();app.use(async (req, res, next) => { try { await next(); } catch (err) { console .log(err); res.send('error' ); res.end(); } }); app.use(async (req, res) => { res.send(req.fullPath); res.end(); }); app.listen(9988 , () => { console .log('server running at 9988' ); });
访问http://localhost:9988/hello/world
后返回
路由中间件
上文已经实现了一个基本Web框架,但是他没有任何的业务实现。 接下来根据该框架来实现一个路由中间件
,用来匹配路由去做对应业务逻辑
功能分析
也是一样,先来分析一下需要实现的功能:
添加路由
:添加路由匹配规则
路由匹配
:根据请求进入的url匹配路由实例的规则
动态路由
:一个路由规则可以被多个url匹配并收集动态的参数
该中间件主要实现这三个功能,分析完功能后就开始编码吧~
初始化
首先定义中间件类的接口,确定类中需要实现的方法
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 interface AbstractRouter { use : (data: Router ) => void ; get: PublicRouteMethod; put: PublicRouteMethod; delete : PublicRouteMethod; post: PublicRouteMethod; option: PublicRouteMethod; set: ( method: MethodList, path: PathMethod, businessFunc: BusinessFunc ) => void ; routes: () => middleWareFunc<{ route : RouteParam }>; } type MethodList = 'GET' | 'POST' | 'PUT' | 'OPTION' | 'DELETE' ;type PathMethod = string | RegExp ;type BusinessFunc = ( req: requestOption<{ route: RouteParam }>, res: responseOption, next: nextTickFunc ) => void ;type PublicRouteMethod = (path: PathMethod, businessFunc: BusinessFunc ) => void ;type RouterParam = { prefix?: string ; }; type RouteParam = { method : MethodList; path: PathMethod; pathArr: string []; prefix?: string ; businessFunc: BusinessFunc; param: Record<string , any >; };
创建中间件类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 class Router implements AbstractRouter { public routeList: RouteParam[]; public data: RouterParam; constructor (data: RouterParam = {} ) { this .routeList = []; this .data = data; } use (data: Router ) {} set (method: MethodList, path: PathMethod, businessFunc: BusinessFunc ) {} get (path: PathMethod, businessFunc: BusinessFunc ) {} put (path: PathMethod, businessFunc: BusinessFunc ) {} delete (path: PathMethod, businessFunc: BusinessFunc ) {} post (path: PathMethod, businessFunc: BusinessFunc ) {} option (path: PathMethod, businessFunc: BusinessFunc ) {} routes ( ) {} }
路由添加
路由添加的方法很简单,只需要解析一下url,构造路由对象,然后插入到路由列表中。主要实现的是set
方法,get
,post
等都是调用该方法
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 set (method: MethodList, path: PathMethod, businessFunc: BusinessFunc ) { let prefixArr: string [] = []; const pathArr = typeof path === 'string' ? path.split('/' ).filter((val ) => val !== '' ) : []; if (this .data.prefix) { prefixArr = this .data.prefix.split('/' ).filter((val ) => val !== '' ); } this .routeList.push({ method, prefix : this .data.prefix, path : `${path} ` , pathArr : prefixArr.concat(pathArr), businessFunc, param : {}, }); } get (path: PathMethod, businessFunc: BusinessFunc ) { this .set('GET' , path, businessFunc); } put (path: PathMethod, businessFunc: BusinessFunc ) { this .set('PUT' , path, businessFunc); } delete (path: PathMethod, businessFunc: BusinessFunc ) { this .set('DELETE' , path, businessFunc); } post (path: PathMethod, businessFunc: BusinessFunc ) { this .set('POST' , path, businessFunc); } option (path: PathMethod, businessFunc: BusinessFunc ) { this .set('OPTION' , path, businessFunc); }
路由匹配
路由匹配分两种情况:
正则匹配:如果匹配规则传入的是正则表达式,则直接使用正则匹配
字符串匹配:如果传入的是字符串,则将字符串分割后一级一级地匹配,这样做为了匹配到动态路由的参数。
如果路由匹配成功后,调用方法并立即退出匹配。 代码如下:
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 routes ( ) { return ( req: requestOption<{ route: RouteParam }>, res: responseOption, next: nextTickFunc ) => { const url = req.pathName; const urlArr = url.split('/' ).filter((val ) => val !== '' ); for (let item of this .routeList) { if (typeof item.path === 'string' ) { const param: Record<string , any > = {}; const pathArr = item.pathArr; let isMatch = true ; if (pathArr.length !== urlArr.length) continue ; for (let [key, val] of Object .entries(urlArr)) { let index = Number (key); if (/^\:.*$/ .test(pathArr[index]) || val === pathArr[index]) { if (pathArr[index][0 ] === ':' ) { param[pathArr[index].substring(1 , pathArr[index].length)] = val; } } else { isMatch = false ; break ; } } if (isMatch) { item.param = param; req.route = item; item.businessFunc(req, res, next); return ; } } else if (item.path.test(url)) { req.route = item; item.businessFunc(req, res, next); return ; } } res.send('404 not found' ); res.end(); }; }
写好之后插入到中间件
const app = new Server();
const router = new Router({ prefix: 'hello/' });
router.get('/:world/:hello', (req, res) => {
console.log(req.route);
res.send('hello world');
res.end();
});
app.use(router.routes());
app.listen(9988, () => {
console.log('server running at 9988');
});
运行服务后访问http://localhost:9988/hello/world
可以看到,动态路由可以正则匹配,并且动态路由参数也被记录下来了
总结
本文主要介绍了在node中如何实现一个web框架,主要讲了:
node.js的http基础
构造一个Server框架
根据Server框架编写了一个路由模块的中间件
想看源码的同学可以点击这里