上一篇文章我们讲了怎么用Node.js
原生API来写一个web服务器
,虽然代码比较丑,但是基本功能还是有的。但是一般我们不会直接用原生API来写,而是借助框架来做,比如本文要讲的Express
。通过上一篇文章的铺垫,我们可以猜测,Express
其实也没有什么黑魔法,也仅仅是原生API的封装,主要是用来提供更好的扩展性,使用起来更方便,代码更优雅。本文照例会从Express
的基本使用入手,然后自己手写一个Express
来替代他,也就是源码解析。
本文可运行代码已经上传GitHub,拿下来一边玩代码,一边看文章效果更佳:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/Express
使用Express
搭建一个最简单的Hello World
也是几行代码就可以搞定,下面这个例子来源官方文档:
const express = require('express'); const app = express(); const port = 3000; app.get('/', (req, res) => { res.send('Hello World!'); }); app.listen(port, () => { console.log(`Example app listening at http://localhost:${port}`); });
可以看到Express
的路由可以直接用app.get
这种方法来处理,比我们之前在http.createServer
里面写一堆if
优雅多了。我们用这种方式来改写下上一篇文章的代码:
const path = require("path"); const express = require("express"); const fs = require("fs"); const url = require("url"); const app = express(); const port = 3000; app.get("/", (req, res) => { res.end("Hello World"); }); app.get("/api/users", (req, res) => { const resData = [ { id: 1, name: "小明", age: 18, }, { id: 2, name: "小红", age: 19, }, ]; res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(resData)); }); app.post("/api/users", (req, res) => { let postData = ""; req.on("data", (chunk) => { postData = postData + chunk; }); req.on("end", () => { // 数据传完后往db.txt插入内容 fs.appendFile(path.join(__dirname, "db.txt"), postData, () => { res.end(postData); // 数据写完后将数据再次返回 }); }); }); app.listen(port, () => { console.log(`Server is running on http://localhost:${port}/`); });
Express
还支持中间件,我们写个中间件来打印出每次请求的路径:
app.use((req, res, next) => { const urlObject = url.parse(req.url); const { pathname } = urlObject; console.log(`request path: ${pathname}`); next(); });
Express
也支持静态资源托管,不过他的API是需要指定一个文件夹来单独存放静态资源的,比如我们新建一个public
文件夹来存放静态资源,使用express.static
中间件配置一下就行:
app.use(express.static(path.join(__dirname, 'public')));
然后就可以拿到静态资源了:
手写源码才是本文的重点,前面的不过是铺垫,本文手写的目标就是自己写一个express
来替换前面用到的express api
,其实就是源码解析。在开始之前,我们先来看看用到了哪些API
:
express()
,第一个肯定是express
函数,这个运行后会返回一个app
的实例,后面用的很多方法都是这个app
上的。app.listen
,这个方法类似于原生的server.listen
,用来启动服务器。app.get
,这是处理路由的API,类似的还有app.post
等。app.use
,这是中间件的调用入口,所有中间件都要通过这个方法来调用。express.static
,这个中间件帮助我们做静态资源托管,其实是另外一个库了,叫serve-static,因为跟Express
架构关系不大,本文就先不讲他的源码了。
本文所有手写代码全部参照官方源码写成,方法名和变量名尽量与官方保持一致,大家可以对照着看,写到具体的方法时我也会贴出官方源码的地址。
首先需要写的肯定是express()
,这个方法是一切的开始,他会创建并返回一个app
,这个app
就是我们的web服务器
。
// express.js var mixin = require('merge-descriptors'); var proto = require('./application'); // 创建web服务器的方法 function createApplication() { // 这个app方法其实就是传给http.createServer的回调函数 var app = function (req, res) { }; mixin(app, proto, false); return app; } exports = module.exports = createApplication;
上述代码就是我们在运行express()
的时候执行的代码,其实就是个空壳,返回的app
暂时是个空函数,真正的app
并没在这里,而是在proto
上,从上述代码可以看出proto
其实就是application.js
,然后通过下面这行代码将proto
上的东西都赋值给了app
:
mixin(app, proto, false);
这行代码用到了一个第三方库merge-descriptors
,这个库总共没有几行代码,做的事情也很简单,就是将proto
上面的属性挨个赋值给app
,对merge-descriptors
源码感兴趣的可以看这里:https://github.com/component/merge-descriptors/blob/master/index.js。
Express
这里之所以使用mixin
,而不是普通的面向对象来继承,是因为它除了要mixin proto
外,还需要mixin
其他库,也就是需要多继承,我这里省略了,但是官方源码是有的。
express.js
对应的源码看这里:https://github.com/expressjs/express/blob/master/lib/express.js
上面说了,express.js
只是一个空壳,真正的app
在application.js
里面,所以app.listen
也是在这里。
// application.js var app = exports = module.exports = {}; app.listen = function listen() { var server = http.createServer(this); return server.listen.apply(server, arguments); };
上面代码就是调用原生http
模块创建了一个服务器,但是传的参数是this
,这里的this
是什么呢?回想一下我们使用express
的时候是这样用的:
const app = express(); app.listen(3000);
所以listen
方法的实际调用者是express()
的返回值,也就是上面express.js
里面createApplication
的返回值,也就是这个函数:
var app = function (req, res) { };
所以这里的this
也是这个函数,所以我在express.js
里面就加了注释,这个函数是http.createServer
的回调函数。现在这个函数是空的,实际上他应该是整个web服务器
的处理入口,所以我们给他加上处理的逻辑,在里面再加一行代码:
var app = function(req, res) { app.handle(req, res); // 这是真正的服务器处理入口 };
app.handle
也是挂载在app
下面的,所以他实际也在application.js
这个文件里面,下面我们来看看他干了什么:
app.handle = function handle(req, res) { var router = this._router; // 最终的处理方法 var done = finalhandler(req, res); // 如果没有定义router // 直接结束返回 if (!router) { done(); return; } // 有router,就用router来处理 router.handle(req, res, done); }
上面代码可以看出,实际处理路由的是router
,这是Router
的一个实例,并且挂载在this
上的,我们这里还没有给他赋值,如果没有赋值的话,会直接运行finalhandler
并且结束处理。finalhandler
也是一个第三方库,GitHub链接在这里:https://github.com/pillarjs/finalhandler。这个库的功能也不复杂,就是帮你处理一些收尾的工作,比如所有路由都没匹配上,你可能需要返回404
并记录下error log
,这个库就可以帮你做。
上面说了,在具体处理网络请求时,实际上是用app._router
来处理的,那么app._router
是在哪里赋值的呢?事实上app._router
的赋值有多个地方,一个地方就是HTTP
动词处理方法上,比如我们用到的app.get
或者app.post
。无论是app.get
还是app.post
都是调用的router
方法来处理,所以可以统一用一个循环来写这一类的方法。
// HTTP动词的方法 var methods = ['get', 'post']; methods.forEach(function (method) { app[method] = function (path) { this.lazyrouter(); var route = this._router.route(path); route[method].apply(route, Array.prototype.slice.call(arguments, 1)); return this; } });
上面代码HTTP
动词都放到了一个数组里面,官方源码中这个数组也是一个第三方库维护的,名字就叫methods
,GitHub地址在这里:https://github.com/jshttp/methods。我这个例子因为只需要两个动词,就简化了,直接用数组了。这段代码其实给app
创建了跟每个动词同名的函数,所有动词的处理函数都是一样的,都是去调router
里面的对应方法来处理。这种将不同部分抽取出来,从而复用共同部分的代码,有点像我之前另一篇文章写过的设计模式----享元模式。
我们注意到上面代码除了调用router
来处理路由外,还有一行代码:
this.lazyrouter();
lazyrouter
方法其实就是我们给this._router
赋值的地方,代码也比较简单,就是检测下有没有_router
,如果没有就给他赋个值,赋的值就是Router
的一个实例:
app.lazyrouter = function lazyrouter() { if (!this._router) { this._router = new Router(); } }
app.listen
,app.handle
和methods
处理方法都在application.js
里面,application.js
源码在这里:https://github.com/expressjs/express/blob/master/lib/application.js
写到这里我们发现我们已经使用了Router
的多个API
,比如:
router.handle
router.route
route[method]
所以我们来看下Router
这个类,下面的代码是从源码中简化出来的:
// router/index.js var setPrototypeOf = require('setprototypeof'); var proto = module.exports = function () { function router(req, res, next) { router.handle(req, res, next); } setPrototypeOf(router, proto); return router; }
这段代码对我来说是比较奇怪的,我们在执行new Router()
的时候其实执行的是new proto()
,new proto()
并不是我奇怪的地方,奇怪的是他设置原型的方式。我之前在讲JS的面向对象的文章提到过如果你要给一个类加上类方法可以这样写:
function Class() {} Class.prototype.method1 = function() {} var instance = new Class();
这样instance.__proto__
就会指向Class.prototype
,你就可使用instance.method1
了。
Express.js
的上述代码其实也是实现了类似的效果,setprototypeof
又是一个第三方库,作用类似Object.setPrototypeOf(obj, prototype)
,就是给一个对象设置原型,setprototypeof
存在的意义就是兼容老标准的JS,也就是加了一些polyfill
,他的代码在这里。所以:
setPrototypeOf(router, proto);
这行代码的意思就是让router.__proto__
指向proto
,router
是你在new proto()
时的返回对象,执行了上面这行代码,这个router
就可以拿到proto
上的全部方法了。像router.handle
这种方法就可以挂载到proto
上了,成为proto.handle
。
绕了一大圈,其实就是JS面向对象的使用,给router
添加类方法,但是为什么使用这么绕的方式,而不是像我上面那个Class
那样用呢?这我就不是很清楚了,可能有什么历史原因吧。
Router
的基本结构知道了,要理解Router
的具体代码,我们还需要对Express
的路由架构有一个整体的认识。就以我们这两个示例API来说:
get /api/userspost /api/users
我们发现他们的path
是一样的,都是/api/users
,但是他们的请求方法,也就是method
不一样。Express
里面将path
这一层提取出来作为了一个类,叫做Layer
。但是对于一个Layer
,我们只知道他的path
,不知道method
的话,是不能确定一个路由的,所以Layer
上还添加了一个属性route
,这个route
上也存了一个数组,数组的每个项存了对应的method
和回调函数handle
。整个结构你可以理解成这个样子:
const router = { stack: [ // 里面很多layer { path: '/api/users' route: { stack: [ // 里面存了多个method和回调函数 { method: 'get', handle: function1 }, { method: 'post', handle: function2 } ] } } ] }
知道了这个结构我们可以猜到,整个流程可以分成两部分:注册路由和匹配路由。当我们写app.get
和app.post
这些方法时,其实就是在router
上添加layer
和route
。当一个网络请求过来时,其实就是遍历layer
和route
,找到对应的handle
拿出来执行。
注意route
数组里面的结构,每个项按理来说应该使用一种新的数据结构来存储,比如routeItem
之类的。但是Express
并没有这样做,而是将它和layer
合在一起了,给layer
添加了method
和handle
属性。这在初次看源码的时候可能造成困惑,因为layer
同时存在于router
的stack
上和route
的stack上
,肩负了两种职责。
这个方法是我们前面注册路由的时候调用的一个方法,回顾下前面的注册路由的方法,比如app.get
:
app.get = function (path) { this.lazyrouter(); var route = this._router.route(path); route.get.apply(route, Array.prototype.slice.call(arguments, 1)); return this; }
结合上面讲的路由架构,我们在注册路由的时候,应该给router
添加对应的layer
和route
,router.route
的代码就不难写出了:
proto.route = function route(path) { var route = new Route(); var layer = new Layer(path, route.dispatch.bind(route)); // 参数是path和回调函数 layer.route = route; this.stack.push(layer); return route; }
上面代码新建了Route
和Layer
实例,这两个类的构造函数其实也挺简单的。只是参数的申明和初始化:
// layer.js module.exports = Layer; function Layer(path, fn) { this.path = path; this.handle = fn; this.method = ''; }
// route.js module.exports = Route; function Route() { this.stack = []; this.methods = {}; // 一个加快查找的hash表 }
前面我们看到了app.get
其实通过下面这行代码,最终调用的是route.get
:
route.get.apply(route, Array.prototype.slice.call(arguments, 1));
也知道了route.get
这种动词处理函数,其实就是往route.stack
上添加layer
,那我们的route.get
也可以写出来了:
var methods = ["get", "post"]; methods.forEach(function (method) { Route.prototype[method] = function () { // 支持传入多个回调函数 var handles = flatten(slice.call(arguments)); // 为每个回调新建一个layer,并加到stack上 for (var i = 0; i < handles.length; i++) { var handle = handles[i]; // 每个handle都应该是个函数 if (typeof handle !== "function") { var type = toString.call(handle); var msg = "Route." + method + "() requires a callback function but got a " + type; throw new Error(msg); } // 注意这里的层级是layer.route.layer // 前面第一个layer已经做个path的比较了,所以这里是第二个layer,path可以直接设置为/ var layer = new Layer("/", handle); layer.method = method; this.methods[method] = true; // 将methods对应的method设置为true,用于后面的快速查找 this.stack.push(layer); } }; });
这样,其实整个router
的结构就构建出来了,后面就看看怎么用这个结构来处理请求了,也就是router.handle
方法。
前面说了app.handle
实际上是调用的router.handle
,也知道了router
的结构是在stack
上添加了layer
和router
,所以router.handle
需要做的就是从router.stack
上找出对应的layer
和router
并执行回调函数:
// 真正处理路由的函数 proto.handle = function handle(req, res, done) { var self = this; var idx = 0; var stack = self.stack; // next方法来查找对应的layer和回调函数 next(); function next() { // 使用第三方库parseUrl获取path,如果没有path,直接返回 var path = parseUrl(req).pathname; if (path == null) { return done(); } var layer; var match; var route; while (match !== true && idx < stack.length) { layer = stack[idx++]; // 注意这里先执行 layer = stack[idx]; 再执行idx++; match = layer.match(path); // 调用layer.match来检测当前路径是否匹配 route = layer.route; // 没匹配上,跳出当次循环 if (match !== true) { continue; } // layer匹配上了,但是没有route,也跳出当次循环 if (!route) { continue; } // 匹配上了,看看route上有没有对应的method var method = req.method; var has_method = route._handles_method(method); // 如果没有对应的method,其实也是没匹配上,跳出当次循环 if (!has_method) { match = false; continue; } } // 循环完了还没有匹配的,就done了,其实就是404 if (match !== true) { return done(); } // 如果匹配上了,就执行对应的回调函数 return layer.handle_request(req, res, next); } };
上面代码还用到了几个Layer
和Route
的实例方法:
layer.match(path): 检测当前layer
的path
是否匹配。route._handles_method(method):检测当前
route
的method
是否匹配。layer.handle_request(req, res, next):使用
layer
的回调函数来处理请求。
这几个方法看起来并不复杂,我们后面一个一个来实现。
到这里其实还有个疑问。从他整个的匹配流程来看,他寻找的其实是router.stack.layer
这一层,但是最终应该执行的回调却是在router.stack.layer.route.stack.layer.handle
。这是怎么通过router.stack.layer
找到最终的router.stack.layer.route.stack.layer.handle
来执行的呢?
这要回到我们前面的router.route
方法:
proto.route = function route(path) { var route = new Route(); var layer = new Layer(path, route.dispatch.bind(route)); layer.route = route; this.stack.push(layer); return route; }
这里我们new Layer
的时候给的回调其实是route.dispatch.bind(route)
,这个方法会再去route.stack
上找到正确的layer
来执行。所以router.handle
真正的流程其实是:
- 找到
path
匹配的layer
- 拿出
layer
上的route
,看看有没有匹配的method
layer
和method
都有匹配的,再调用route.dispatch
去找出真正的回调函数来执行。
所以又多了一个需要实现的函数,route.dispatch
。
layer.match
是用来检测当前path
是否匹配的函数,用到了一个第三方库path-to-regexp
,这个库可以将path
转为正则表达式,方便后面的匹配,这个库在之前写过的react-router
源码中也出现过。
var pathRegexp = require("path-to-regexp"); module.exports = Layer; function Layer(path, fn) { this.path = path; this.handle = fn; this.method = ""; // 添加一个匹配正则 this.regexp = pathRegexp(path); // 快速匹配/ this.regexp.fast_slash = path === "/"; }
然后就可以添加match
实例方法了:
Layer.prototype.match = function match(path) { var match; if (path != null) { if (this.regexp.fast_slash) { return true; } match = this.regexp.exec(path); } // 没匹配上,返回false if (!match) { return false; } // 不然返回true return true; };
layer.handle_request
是用来调用具体的回调函数的方法,其实就是拿出layer.handle
来执行:
Layer.prototype.handle_request = function handle(req, res, next) { var fn = this.handle; fn(req, res, next); };
route._handles_method
就是检测当前route
是否包含需要的method
,因为之前添加了一个methods
对象,可以用它来进行快速查找:
Route.prototype._handles_method = function _handles_method(method) { var name = method.toLowerCase(); return Boolean(this.methods[name]); };
route.dispatch
其实是router.stack.layer
的回调函数,作用是找到对应的router.stack.layer.route.stack.layer.handle
并执行。
Route.prototype.dispatch = function dispatch(req, res, done) { var idx = 0; var stack = this.stack; // 注意这个stack是route.stack // 如果stack为空,直接done // 这里的done其实是router.stack.layer的next // 也就是执行下一个router.stack.layer if (stack.length === 0) { return done(); } var method = req.method.toLowerCase(); // 这个next方法其实是在router.stack.layer.route.stack上寻找method匹配的layer // 找到了就执行layer的回调函数 next(); function next() { var layer = stack[idx++]; if (!layer) { return done(); } if (layer.method && layer.method !== method) { return next(); } layer.handle_request(req, res, next); } };
到这里其实Express
整体的路由结构,注册和执行流程都完成了,贴下对应的官方源码:
Router类:https://github.com/expressjs/express/blob/master/lib/router/index.jsLayer类:https://github.com/expressjs/express/blob/master/lib/router/layer.js
Route类:https://github.com/expressjs/express/blob/master/lib/router/route.js
其实我们前面已经隐含了中间件,从前面的结构可以看出,一个网络请求过来,会到router
的第一个layer
,然后调用next
到到第二个layer
,匹配上layer
的path
就执行回调,然后一直这样把所有的layer
都走完。所以中间件是啥?中间件就是一个layer
,他的path
默认是/
,也就是对所有请求都生效。按照这个思路,代码就简单了:
// application.js // app.use就是调用router.use app.use = function use(fn) { var path = "/"; this.lazyrouter(); var router = this._router; router.use(path, fn); };
然后在router.use
里面再加一层layer
就行了:
proto.use = function use(path, fn) { var layer = new Layer(path, fn); this.stack.push(layer); };
Express
也是用原生APIhttp.createServer
来实现的。Express
的主要工作是将http.createServer
的回调函数拆出来了,构建了一个路由结构Router
。layer
组成。layer
。layer
,layer
上有一个path
属性来表示他可以处理的API路径。path
可能有不同的method
,每个method
对应layer.route
上的一个layer
。layer.route
上的layer
虽然名字和router
上的layer
一样,但是功能侧重点并不一样,这也是源码中让人困惑的一个点。layer.route
上的layer
的主要参数是method
和handle
,如果method
匹配了,就执行对应的handle
。router.layer
的一个过程。layer
,匹配上就执行回调,一个请求可能会匹配上多个layer
。Express
代码给人的感觉并不是很完美,特别是Layer
类肩负两种职责,跟软件工程强调的单一职责
原则不符,这也导致Router
,Layer
,Route
三个类的调用关系有点混乱。而且对于继承和原型的使用都是很老的方式。可能也是这种不完美催生了Koa
的诞生,下一篇文章我们就来看看Koa
的源码吧。Express
其实还对原生的req
和res
进行了扩展,让他们变得更好用,但是这个其实只相当于一个语法糖,对整体架构没有太大影响,所以本文就没涉及了。本文可运行代码已经上传GitHub,拿下来一边玩代码,一边看文章效果更佳:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Node.js/Express
Express官方文档:http://expressjs.com/
Express官方源码:https://github.com/expressjs/express/tree/master/lib
文章的最后,感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞和GitHub小星星,你的支持是作者持续创作的动力。
作者博文GitHub项目地址: https://github.com/dennis-jiang/Front-End-Knowledges
我也搞了个公众号[进击的大前端],不打广告,不写水文,只发高质量原创,欢迎关注~