阅读原文
前言
Express
是 NodeJS 的 Web 框架,与 Koa
的轻量相比,功能要更多一些,依然是当前使用最广泛的 NodeJS 框架,本篇参考 Express
的核心逻辑来实现一个简易版,Express
源码较多,逻辑复杂,看一周可能也看不完,如果你已经使用过 Express
,又想快速的了解 Express
常用功能的原理,那读这篇文章是一个好的选择,也可以为读真正的源码做铺垫,本篇内容每部分代码较多,因为按照 Express
的封装思想很难拆分,所以建议以星号标注区域为主其他代码为辅。
搭建基本服务
下面我们使用 Express
来搭建一个最基本的服务,只有三行代码,只能访问不能响应。
// 三行代码搭建的最基本服务
// 引入 Express
const express = require("express");
// 创建服务
const app = express();
// 监听服务
app.listen(3000);
从上面我们可以分析出,express
模块给我们提供了一个函数,调用后返回了一个函数或对象给上面有 listen
方法给我们创建了一个 http
服务,我们就按照官方的设计返回一个函数 app
。
// 文件:express.js
const http = require("http");
function createApplication() {
// 创建 app 函数,身份为总管家,用于将请求分派给别人处理
let app = function (req, res) {
}
// 启动服务的 listen 方法
app.listen = function () {
// 创建服务器
const server = http.createServer(app);
// 监听服务,可能传入多个参数,如第一个参数为端口号,最后一个参数为服务启动后回调
server.listen(...arguments);
}
// 返回 app
return app;
}
module.exports = createApplication;
我们创建一个模块 express.js
,导出了 createApplication
函数并返回在内部创建 app
函数,createApplication
等于我们引入 Express
模块时所调用的那个函数,返回值就是我们接收的 app
,在 createApplication
返回的 app
函数上挂载了静态方法 listen
,用于帮助我们启动 http
服务。
createApplication
函数内我们使用引入的 http
模块创建了服务,并调用了创建服务 server
的 listen
方法,将 app.listen
的所有参数传递进去,这就等于做了一层封装,将真正创建服务器的过程都包在了 app.listen
内部,我们自己封装的 Express
模块只有在调用导出函数并调用 app.listen
时才会真正的创建服务器和启动服务器,相当于将原生的两步合二为一。
路由的实现
在 Express
框架中有多个路由方法,方法名分别对应不同的请求方式,可以帮助我们匹配路径和请求方式,在完全匹配时执行路由内部的回调函数,以、目的是在不同路由不同请求方法的情况下让服务器做出不同的响应,路由的使用方式如下。
// 路由的使用方式
// 引入 Express
const express = require("express");
// 创建服务
const app = express();
// 创建路由
app.get("/", function (req, res) {
res.end("home");
});
app.post("/about", function (req, res) {
res.end("about");
});
app.all("*", function (req, res) {
res.end("Not Found");
});
// 监听服务
app.listen(3000);
如果启动上面的服务,通过浏览器访问定义的路由时可以匹配到 app.get
、app.post
或 app.all
并执行回调,但其实我们可以发现这些方法的名字是与请求类型严格对应的,不仅仅这几个,下面来看看实现路由的核心逻辑(直接找到星号提示新增或修改位置即可)。
// 文件:express.js
const http = require("http");
// ***************************** 以下为新增代码 *****************************
// methods 模块返回存储所有请求方法名称的数组
const methods = require("methods");
// ***************************** 以上为新增代码 *****************************
function createApplication() {
// 创建 app 函数,身份为总管家,用于将请求分派给别人处理
let app = function (req, res) {
// ***************************** 以下为新增代码 *****************************
// 获取方法名统一转换成小写
let method = req.method.toLowerCase();
// 访问路径解构成路由和查询字符串两部分 /user?a=1&b=2
let [reqPath, query = ""] = req.url.split("?");
// 循环匹配路径
for (let i = 0; i < app.routes.lenth; i++) {
// 循环取得每一层
let layer = app.routes[i];
// 如果说路径和请求类型都能匹配,则执行该路由层的回调
if (
(reqPath === layer.pathname || layer.pathname === "*") &&
(method === layer.method || layer.method === "all")
) {
return layer.hanlder(req, res);
}
}
// 如果都没有匹配上,则响应错误信息
res.end(`CANNOT ${
req.method} ${
reqPath}`);
// ***************************** 以上为新增代码 *****************************
}
// ***************************** 以下为新增代码 *****************************
// 存储路由层的请求类型、路径和回调
app.routes = [];
// 返回一个函数体用于将路由层存入 app.routes 中
function createRouteMethod(method) {
return function (pathname, handler) {
let layer = {
method,
pathname, // 不包含查询字符串
handler
};
// 把这一层放入存储所有路由层信息的数组中
app.routes.push(layer);
}
}
// 循环构建所有路由方法,如 app.get app.post 等
methods.forEach(function (method) {
// 匹配路由的 get 方法
app[method] = createRouteMethod(method);
});
// all 方法,通吃所有请求类型
app.all = createRouteMethod("all");
// ***************************** 以上为新增代码 *****************************
// 启动服务的 listen 方法
app.listen = function () {
// 创建服务器
const server = http.createServer(app);
// 监听服务,可能传入多个参数,如第一个参数为端口号,最后一个参数为服务启动后回调
server.listen(...arguments);
}
// 返回 app
return app;
}
module.exports = createApplication;
我们的逻辑大体可以分为两个部分,路由方法的创建以及路由的匹配,首先是路由方法的创建阶段,每一个方法的内部所做的事情就是将路由的路径、请求方式和回调函数作为对象的属性,并将对象存入一个数组中统一管理,所以我们创建了 app.routes
数组用来存储这些路由对象。
方法名对应请求类型,请类型有很多,我们不会一一的创建每一个方法,所以选择引入专门存储请求类型名称的 methods
模块,其实路由方法逻辑相同,我们封装了 createRouteMethod
方法用来生成不同路由方法的函数体,之所以这样做是因为有个特殊的路由方法 app.all
,导致请求类型有差别,其他的可以从 methods
中取,app.all
我们定义类型为 all
通过 createRouteMethod
函数的参数传入。
接着就是循环 methods
调用 createRouteMethod
函数创建路由方法,并单独创建 app.all
方法。
路由匹配阶段实在函数 app
内完成的,因为启动服务接收到请求时会执行 createServer
中的回调,即执行 app
,先通过原生自带的 req.method
取出请求方式并处理成小写,通过 req.path
取出完整路径并分成路由名和查询字符串两个部分。
循环 app.routes
用取到请求的类型和路由名称匹配,两者都相等则执行对应路由对象上的回调函数,在判断条件中,请求方式兼容了我们之前定义的 all
,为了所有的请求类型只要路由匹配都可以执行 app.all
的回调,请求路径兼容了 *
,因为如果某个路由方法定义的路径为 *
,则任意路由都可以执行这个路由对象上的回调。
扩展请求对象属性
且在路由内部可以通过 req
访问一些原生没有的属性如 req.path
、req.query
、req.host
和 req.params
,这说明 Express
在实现的过程中对 req
进行了处理。
// req 属性的使用
// 引入 Express
const express = require("express");
// 创建服务
const app = express();
// 创建路由
app.get("/", function (req, res) {
console.log(req.path);
console.log(req.query);
console.log(req.host);
res.end("home");
});
app.get("/about/:id/:name", function (req, res) {
console.log(req.params);
res.end("about");
});
// 监听服务
app.listen(3000);
在上面的使用中我们写了两个路由,分别打印了原生所不具备而 Express
帮我们处理并新增的属性,下面我们就来在之前自己实现的 express.js
的基础上增加这些属性(直接找到星号提示新增或修改位置即可)。
// 文件:express.js
const http = require("http");
// methods 模块返回存储所有请求方法名称的数组
const methods = require("methods");
// ***************************** 以下为新增代码 *****************************
const querystring = require("querystring");
// ***************************** 以上为新增代码 *****************************
function createApplication() {
// 创建 app 函数,身份为总管家,用于将请求分派给别人处理
let app = function (req, res) {
// 获取方法名统一转换成小写
let method = req.method.toLowerCase();
// 访问路径解构成路由和查询字符串两部分 /user?a=1&b=2
let [reqPath, query = ""] = req.url.split("?");
// *************************** 以下为修改代码 *****************************
req.path = reqPath; // 将路径名赋值给 req.path
req.query = querystring.parse(query); // 将查询字符串转换成对象赋值给 req.query
req.host = req.headers.host.split(":")[0]; // 将主机名赋值给 req.host
// 循环匹配路径
for (let i = 0; i < app.routes.lenth; i++) {
// 循环取得每一层
let layer = app.routes[i];
// 如果路由对象上存在正则说明存在路由参数,否则正常匹配路径和请求类型
if (layer.regexp) {
let result = pathname.match(layer.regexp); // 使用路径配置的正则匹配请求路径
// 如果匹配到结果且请求方式匹配
if (result && (method === layer.method || layer.method === "all")) {
// 则将路由对象 paramNames 属性中的键与匹配到的值构建成一个对象
req.params = layer.paramNames.reduce(function (memo, key, index) {
memo[key] = result[index + 1];
return memo;
}, {
});
// 执行对应的回调
return layer.hanlder(req, res);
}
} else {
// 如果说路径和请求类型都能匹配,则执行该路由层的回调
if (
(reqPath === layer.pathname || layer.pathname === "*") &&
(method === layer.method || layer.method === "all")
) {
return layer.hanlder(req, res);
}
}
// ***************************** 以上为修改代码 *****************************
}
// 如果都没有匹配上,则响应错误信息
res.end(`CANNOT ${
req.method} ${
reqPath}`);
}
// 存储路由层的请求类型、路径和回调
app.routes = [];
// 返回一个函数体用于将路由层存入 app.routes 中
function createRouteMethod(method) {
return function (pathname, handler) {
let layer = {
method,
pathname, // 不包含查询字符串
handler
};
// ***************************** 以下为新增代码 *****************************
// 如果含有路由参数,如 /xxx/:aa/:bb,取出路由参数的键 aa bb 存入数组并挂在路由对象上
// 并生匹配 /xxx/aa/bb 的正则挂在路由对象上
if (pathname.indexOf(":") !== -1) {
let paramNames = []; // 存储路由参数
// 将路由参数取出存入数组,并返回正则字符串
let regStr = pathname.replace(/:(\w+)/g, function (matched, attr) {
paramNames.push(attr);
return "(\\w+)";
});
let regexp = new RegExp(regStr); // 生成正则类型
layer.regexp = regexp; // 将正则挂在路由对象上
layer.paramNames = paramNames; // 将存储路由参数的数组挂载对象上
}
// ***************************** 以上为新增代码 *****************************
// 把这一层放入存储所有路由层信息的数组中
app.routes.push(layer);
}
}
// 循环构建所有路由方法,如 app.get app.post 等
methods.forEach(function (method) {
// 匹配路由的 get 方法
app[method] = createRouteMethod(method);
});
// all 方法,通吃所有请求类型
app.all = createRouteMethod("all");
// 启动服务的 listen 方法
app.listen = function () {
// 创建服务器
const server = http.createServer(app);
// 监听服务,可能传入多个参数,如第一个参数为端口号,最后一个参数为服务启动后回调
server.listen(...arguments);
}
// 返回 app
return app;
}
module.exports = createApplication;
上面代码有些长,我们一点一点分析,首先是 req.path
,就是我们浏览器地址栏里查询字符串前的路径,值其实就是我们之前从 req.url
中解构出来的 pathname
,我们只需要将 pathname
赋值给 req.path
即可。
req.query
是浏览器地址栏的查询字符串传递的参数,就是我们从 req.url
解构出来的查询字符串,借助 querystring
模块将查询字符串处理成对象赋值给 req.query
即可。
req.host
是访问的主机名,请求头中的 host
包含了主机名和端口号,我们只要截取出前半部分赋值给 req.host
即可。
最复杂的是 req.params
的实现,大概分为两个步骤,首先是在路由方法创建时需要检查定义的路由是否含有路由参数,如果有则取出参数的键存入数组 paramNames
中,然后创建一个匹配路由参数的正则,通过 replace
实现正则字符串的创建,再通过 RegExp
构造函数来创建正则,并挂在路由对象上,之所以使用 replace
是因为创建的规则内的分组要和路由参数的个数是相同的,我们将这些逻辑完善进了 createRouteMethod
函数中。
实现响应方法 send 和 sendFile
之前的例子中我们都是用原生的 end
方法响应浏览器,我们知道 end
方法只能接收字符串和 Buffer 作为响应的值,非常不方便,其实在 Express
中封装了一个 send
方法挂在 res
对象下,可以接收数组、对象、字符串、Buffer、数字处理后响应给浏览器,在 Express
内部同样封装了一个 sendFile
方法用于读取请求的文件。
// send 响应
// 引入 Express
const express = require("express");
const path = require("path");
// 创建服务
const app = express();
// 创建路由
app.get("/", function (req, res) {
res.send({
name: "panda", age: 28 });
});
app.get("/test.txt", function (req, res) {
// 必须传入绝对路径
res.sendFile(path.join(__dirname, req.path));
});
// 监听服务
app.listen(3000);
通过我们的分析,封装的 send
方法应该是将 end
不支持的类型数据转换成了字符串,在内部再次调用 end
,而 sendFile
方法规定参数必须为绝对路径,内部实现应该是利用可读流读取文件内容相应给浏览器,下面是两个方法的实现(直接找到星号提示新增或修改位置即可)。
// 文件:express.js
const http = require("http");
// methods 模块返回存储所有请求方法名称的数组
const methods = require("methods");
const querystring = require("querystring");
// ***************************** 以下为新增代码 *****************************
const util = require("util");
const httpServer = require("_http_server")