如何为 Node.js 构建自己的 Web 框架
在Twitter上关注我,很高兴接受您对主题或改进的建议/Chris
简而言之:本文教你如何在一定程度上实现 Express 框架。非常适合个人学习,但请勿用于生产环境,除非你在 NPM 安装时遇到空间或带宽问题。希望对你有所帮助。
我写这类文章的目的并非希望人们重复造轮子,而是希望从经验中学习。我敢打赌,如果你搜索 npmjs,你会找到数百个实现,它们或多或少与 Express、Nest、Koa 或 Fastify 等知名框架类似。那么,再创建一个框架又有什么意义呢?这不是浪费时间吗?我不这么认为,因为你可以通过尝试自己实现它学到很多东西。你可以获得一些技能,帮助你应对日常的 Web 开发工作。正如你现在看到的 Matrix 所示,它还能让你更好地应对开源软件 (OSS) 的工作。
实现 Express 框架
在本文中,我选择尝试实现 Express 框架的一部分。具体是哪些部分呢?
- 路由,Express 有一种关联特定路由的方法,当路由命中时会运行特定的代码。你还可以根据 HTTP 动词来区分路由。因此,GET to和to
/products
是不同的。POST
/products
- 中间件是一段可以在请求之前或之后运行的代码,甚至可以控制对请求的处理方式。中间件可以检查请求头中是否存在授权令牌,如果有效则返回请求的资源。如果令牌无效,则请求将停止并返回相应的消息。
- 查询参数是 URL 的末尾部分,可以帮助进一步筛选您希望响应显示的内容。假设 URL 如下所示
/products?page=1&pagesize=20
,查询参数就是其后发生的所有事情?
。 - 使用 Body 发送数据,数据可以从客户端发送到服务器应用。它可以通过 URL 或 body 发送。body 可以包含各种内容,从 JSON 到简单的表单字段,甚至是文件。
一个 Express App 示例
让我们看一下实现 Express 应用程序的几行代码。即使只有几行代码,也发生了很多事情:
const express = require('express')
const app = express();
app.get('/products/:id', (req, res) => {
res.send(`You sent id ${req.params.id}`)
})
app.listen(3000, () => {
console.log('Server up and running on port 3000')
})
一个 Vanilla HTTP 应用
我们该如何实现呢?嗯,我们可以使用 HTTP 模块。那么,让我们看一个非常小的实现,来了解一下还缺少什么:
const http = require('http');
const PORT = 3000;
const server = http.createServer((req, res) => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('hello world');
});
server.listen(PORT, () => {
console.log(`listening on port ${PORT}`)
})
HTTP 模块仅具有非常基本的路由功能。如果您使用 URL 导航到这样的应用程序,http://localhost:3000/products
则将req.url
包含/products
,并且req.method
将包含字符串get
。就是这样,这就是您拥有的全部。
实现路由和 HTTP 动词
我们即将实施以下
- HTTP 动词方法,我们需要诸如
get()
等post()
方法。 - 路由和路由参数,我们需要能够匹配,
/products
并且我们需要能够从像这样的表达式中分离出路由参数 id/products/:id
。 - 查询参数,我们应该能够获取像这样的 URL
http://localhost:3000/products?page=1&pageSize=20
并解析出参数page
,pageSize
以便于使用。
HTTP 动词方法
让我们创建一个server.js
并开始实现我们的服务器,如下所示:
// server.js
const http = require('http')
function myServer() {
let routeTable = {};
http.createServer((req, res) => {
});
return {
get(path, cb) {
routeTable[path] = { 'get': cb }
}
}
}
让我们保留这样的代码并继续实现路由。
解析路由参数
实现起来/products
很简单,就是用或不用正则表达式进行字符串比较。id
从中取出参数/products/:id
稍微有点棘手。一旦我们意识到/product/:id
可以重写为 RegEx ,我们就可以用正则表达式来实现/products/:(?<id>\w+)
。这就是所谓的命名组,当我们运行该match()
方法时,它将返回一个对象,该对象包含一个groups
属性,该属性的内容类似于 so ,{ id: '1' }
用于路由类似于 so /products/1
。让我们展示一下这样的实现:
// url-to-regex.js
function parse(url) {
let str = "";
for (var i =0; i < url.length; i++) {
const c = url.charAt(i);
if (c === ":") {
// eat all characters
let param = "";
for (var j = i + 1; j < url.length; j++) {
if (/\w/.test(url.charAt(j))) {
param += url.charAt(j);
} else {
break;
}
}
str += `(?<${param}>\\w+)`;
i = j -1;
} else {
str += c;
}
}
return str;
}
module.exports = parse;
如何使用它:
const parse = require('./url-to-regex');
const regex = parse("/products/:id")).toBe("/products/(?<id>\\w+)");
const match = "/products/114".match(new RegExp(regex);
// match.groups is { id: '114' }
向服务器添加路由
让我们再次打开我们的server.js
文件并添加路线管理部分。
// server.js
const http = require('http')
const parse = require('./regex-from-url')
function myServer() {
let routeTable = {};
http.createServer((req, res) => {
const routes = Object.keys(routeTable);
let match = false;
for(var i =0; i < routes.length; i++) {
const route = routes[i];
const parsedRoute = parse(route);
if (
new RegExp(parsedRoute).test(req.url) &&
routeTable[route][req.method.toLowerCase()]
) {
let cb = routeTable[route][req.method.toLowerCase()];
const m = req.url.match(new RegExp(parsedRoute));
req.params = m.groups;
cb(req, res);
match = true;
break;
}
}
if (!match) {
res.statusCode = 404;
res.end("Not found");
}
});
return {
get(path, cb) {
routeTable[path] = { 'get': cb }
}
}
}
我们的做法是循环遍历路由字典中的所有路由,直到找到匹配的路由。比较过程如下:
if (
new RegExp(parsedRoute).test(req.url) &&
routeTable[route][req.method.toLowerCase()]
)
还要注意路由器参数是如何解析并放置在params
属性上的,如下所示:
const m = req.url.match(new RegExp(parsedRoute));
req.params = m.groups;
查询参数
我们已经知道,使用 HTTP 模块,URL 将包含我们的路由,如下所示/products?page=1&pageSize
。下一步是挖掘这些参数。这可以通过使用类似正则表达式和以下代码来实现:
// query-params.js
function parse(url) {
const results = url.match(/\?(?<query>.*)/);
if (!results) {
return {};
}
const { groups: { query } } = results;
const pairs = query.match(/(?<param>\w+)=(?<value>\w+)/g);
const params = pairs.reduce((acc, curr) => {
const [key, value] = curr.split(("="));
acc[key] = value;
return acc;
}, {});
return params;
}
module.exports = parse;
现在我们需要把它绑定到服务器代码中。幸运的是,只需要几行代码:
const queryParse = require('./query-params.js')
// the rest omitted for brevity
ress.query = queryParse(req.url);
使用 Body 发送数据
通过识别输入参数的类型,可以读取数据主体req
。需要注意的是,数据是以小块(即所谓的“块”)的形式到达的。监听end
客户端发出的事件,表示传输已完成,不再发送任何数据。
您可以通过监听事件来监听传入的数据data
,如下所示:
req.on('data', (chunk) => {
// do something
})
req.on('end', () => {
// no more data
})
为了实现对从客户端传输的数据的监听,我们可以创建以下辅助方法:
function readBody(req) {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk) => {
body += "" + chunk;
});
req.on("end", () => {
resolve(body);
});
req.on("error", (err) => {
reject(err);
});
});
}
然后在我们的服务器代码中使用它,如下所示:
res.body = await readBody(req);
此时的完整代码应如下所示:
// server.js
const http = require('http')
const queryParse = require('./query-params.js')
const parse = require('./regex-from-url')
function readBody(req) {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk) => {
body += "" + chunk;
});
req.on("end", () => {
resolve(body);
});
req.on("error", (err) => {
reject(err);
});
});
}
function myServer() {
let routeTable = {};
http.createServer(async(req, res) => {
const routes = Object.keys(routeTable);
let match = false;
for(var i =0; i < routes.length; i++) {
const route = routes[i];
const parsedRoute = parse(route);
if (
new RegExp(parsedRoute).test(req.url) &&
routeTable[route][req.method.toLowerCase()]
) {
let cb = routeTable[route][req.method.toLowerCase()];
const m = req.url.match(new RegExp(parsedRoute));
req.params = m.groups;
req.query = queryParse(req.url);
req.body = await readBody(req);
cb(req, res);
match = true;
break;
}
}
if (!match) {
res.statusCode = 404;
res.end("Not found");
}
});
return {
get(path, cb) {
routeTable[path] = { 'get': cb }
},
post(path, cb) {
routeTable[path] = { 'post': cb }
}
}
}
此时您应该能够像这样调用您的代码:
const server = require('./server')
const app = server();
app.get('/products/:id', (req, res) => {
// for route /products/1, req.params has value { id: '1' }
})
app.get('/products/', (req, res) => {
// for route /products?page=1&pageSize=10, req.query has value { page: '1', pageSize: '10' }
})
app.post('/products/', (req, res) => {
// req.body should contain whatever you sent across as client
})
响应助手
至此,很多工作都已完成。但是,如何实际将数据返回给客户端呢?由于您正在实现 HTTP 模块,因此res
可以使用该参数。通过调用它,end()
您可以发送数据。以下是示例:
res.end('some data')
不过,如果你看看 Express 是如何实现的,它有各种各样的辅助函数,比如send()
、等等。你也可以用几行代码来实现json()
:html()
function createResponse(res) {
res.send = (message) => res.end(message);
res.json = (message) => {
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(message));
};
res.html = (message) => {
res.setHeader("Content-Type", "text/html");
res.end(message);
}
return res;
}
并确保将其添加到服务器代码中:
res = createResponse(res);
中间件
有了中间件,我们就可以在请求之前或之后运行代码,甚至可以控制请求本身。请看下面的代码:
server.get("/protected", (req, res, next) => {
if (req.headers["authorization"] === "abc123") {
next();
} else {
res.statusCode = 401;
res.send("Not allowed");
}
}, (req, res) => {
res.send("protected route");
});
第二个参数是中间件。它会检查req.headers
属性authorization
并检查其值。如果一切正常,则会调用next()
。如果异常,则请求在此停止,并res.send()
调用 ,并将状态码设置为401
(不允许)。
最后一个参数是您希望客户端看到的路由响应,前提是他们向您发送了 ok 标头值。
让我们实现它。在中创建以下函数server.js
:
function processMiddleware(middleware, req, res) {
if (!middleware) {
// resolve false
return new Promise((resolve) => resolve(true));
}
return new Promise((resolve) => {
middleware(req, res, function () {
resolve(true);
});
});
}
上面的middleware
参数被调用了,你可以看到它的最后一个参数是一个解析 Promise 的函数,如下所示:
middleware(req, res, function () {
resolve(true);
});
为了使服务器代码能够使用它,我们需要采取以下几个步骤:
- 确保我们注册了中间件
- 当我们有匹配的请求时,获取中间件
- 调用中间件
注册中间件
我们需要稍微改变一下注册路由的方式,首先添加这个辅助方法:
function registerPath(path, cb, method, middleware) {
if (!routeTable[path]) {
routeTable[path] = {};
}
routeTable[path] = { ...routeTable[path], [method]: cb, [method + "-middleware"]: middleware };
}
因此尝试注册如下路线:
server.get('/products', (req, res, next) => {}, (req, res) => {})
导致中间件回调被保存在属性上get-middleware
然后,当我们注册路线时,我们会执行以下操作:
return {
get: (path, ...rest) => {
if (rest.length === 1) {
registerPath(path, rest[0] , "get");
} else {
registerPath(path, rest[1], "get", rest[0]);
}
},
获取中间件的引用
要获取中间件的引用,我们可以使用以下代码:
let middleware = routeTable[route][`${req.method.toLowerCase()}-middleware`];
流程中间件
最后,要运行中间件,请编写以下代码:
const result = await processMiddleware(middleware, req, createResponse(res));
if (result) {
cb(req, res);
}
概括
完整代码可在此 repo 中找到:
也可以通过 NPM 调用以下命令来使用它:
npm install quarkhttp
讲了这么多,路由、路由参数、查询参数、body 解析和中间件。希望你现在能理解这些内容了。记住,市面上有很多优秀的库供你使用,而且它们都经过了充分的测试。然而,了解这些库的实现方式对理解这些内容确实大有裨益。
文章来源:https://dev.to/itnext/how-you-can-build-your-own-web-framework-for-node-js-19e3