大家好,我是山月,这是我新开的一个坑:手写源码最小实现,每一行代码都有注释。
当我们在深入学习一个框架或者库时,为了了解它的思想及设计思路,也为了更好地使用和避免无意的 Bug,源码研究是最好的方法。
对于 koa 与 vdom 这种极为简单,而应用却很广泛的框架/库,莫不如是。为了验证是否已足够了解它,可以实现一个仅具备核心功能的迷你的库。正所谓,麻雀虽小,五脏俱全。
对于源码,我将尽可能注释每一行代码,并以文章讲述原理与实现过程。目前已实现列表为:
- mini-koa
- mini-express
- mini-http-router
- mini-vdom
- mini-webpack
由于目前浏览器对 ESM 已支持良好,对于客户端相关源码使用 ESM 书写,比如 vDOM、React 等。而对于服务端使用 CommonJS 书写,如 koa、express 等。
如何实现一个最小版 HTTP Server Router
大家好,我是山月。
实现一个最简的服务端路由,仅需二十行代码。
欲知如何,请往下看。
山月的代码实现
代码置于 shfshanyue/mini-code:code/http-router
可直接读源码,基本每一行都有注释。
使用 npm run example 或者 node example 可运行示例代码
# 或者直接 node example.js
$ npm run example
- 当访问
/api/users/10086时,将正常响应数据{ userId: 10086 } - 当访问
/v2/randomxxxxx时,将相应hello, v2 - 当访问
/api/404时,将返回 404 状态码
目标与示例
- 可定义路由函数
- 可定义路由参数
可在 shfshanyue/mini-code:code/http-router/example.js 查看完整示例。
const http = require('http')
const router = require('.')
router.get('/api/users/:userId', (req, res) => {
res.end(JSON.stringify({
userId: req.params.userId
}))
})
const server = http.createServer((req, res) => {
router.lookup(req, res)
})
server.listen(3000)
如何匹配路由?
服务端业务根据路由分发不同的业务逻辑处理函数,如下代码,不同的路由不同的处理逻辑:
const routes = [
{
path: '/api',
method: 'GET',
handleRequest: (req, res) => {},
},
{
path: '/api/user',
method: 'GET',
handleRequest: (req, res) => {}
},
{
path: '/api/book',
method: 'GET',
handleRequest: (req, res) => {}
}
]
如何匹配路由,分发到正常的请求处理函数?
以上路径较为简单,可直接匹配字符串
function lookup (req, res) {
return routes.find(route => route.path === req.url && route.method === req.method)
}
携带参数的路由与前缀路由
如果路由表中需要匹配参数呢?
const routes = [
{
path: '/api',
method: 'GET',
handleRequest: (req, res) => {}
},
{
path: '/api/user',
method: 'GET',
handleRequest: (req, res) => {}
},
{
// 此处路由携带有参数 userId
path: '/api/users/:userId',
method: 'GET',
handleRequest: (req, res) => {}
}
]
/api/users/:userId: 匹配参数,得到匹配中的userId
正则?这个我熟啊。
把每次注册路由的路径改成正则,进行正则匹配,伪代码如下:
function lookup (req, res) {
return routes.find(route => route.re.test(req.url) && route.method === req.method)
}
问题来了,如何把路径转化为正则表达式?
此时祭出神器 path-to-regexp,将路径转化为正则表达式。无论 Express、Koa 等服务端框架,还是 React、Vue 等客户端框架的路由部分,都是它的忠实用户。
const { pathToRegexp } = require('path-to-regexp')
pathToRegexp('/')
//=> /^\/[\/#\?]?$/i
// 可用以匹配前缀路由
p.pathToRegexp('/', [], { end: false })
//=> /^\/(?:[\/#\?](?=[]|$))?/i
// 对于参数,通过捕获组来捕获参数
pathToRegexp('/api/users/:id')
//=> /^\/api\/users(?:\/([^\/#\?]+?))[\/#\?]?$/i
路由参数与路由前缀问题迎刃而解。
路由的数据结构
使用正则去匹配每次请求的路径,为路由添加一个字段 re,根据 pathToRegexp 生成正则表达式,此时的数据结构如下所示:
const routes = [
{
path: '/api',
method: 'GET',
re: pathToRegexp('/api'),
handleRequest: (req, res) => {}
},
]
function lookup (req, res) {
return routes.find(route => route.re.test(req.url) && route.method === req.method)
}
当处理 /api/users/:userId 等参数路由时,为路由增加一个方法,用以匹配参数,每次处理请求时,将参数解析并携带到 req.params 中。
恰好,path-to-regexp 可以使用 match 直接解析参数,原理是使用带有捕获组的正则去匹配请求路径。
const { match } = require('path-to-regexp')
// 将解析: /api/users/10086 -> { userId: 10086 }
const matchRoute = match('/api/users/:userId', { decode: decodeURIComponent })
//=> { params: { userId: 10086 } }
matchRoute('/api/users/10086')
我们将请求是否能匹配某个路由进行抽象为 match,最终路由的数据结构如下所示:
在生产环境中,每个路由都会在注册时生成正则表达式,当请求来临时,将根据该正则表达式进行匹配并针对参数路由生成 params
const routes = [
{
path: '/api',
method: 'GET',
handleRequest: (req, res) => {}
match: (path) => {}
},
{
path: '/api/user',
method: 'GET',
handleRequest: (req, res) => {}
match: (path) => {}
},
{
path: '/api/users/:id',
method: 'GET',
handleRequest: (req, res) => {}
match: (path) => {}
}
]
function lookup (req, res) {
return routes.find(route => (req.params = route.match(req.url)?.params) && req.method === route.method)
}
源码
const { match } = require('path-to-regexp')
const router = {
routes: [],
// 注册路由,此时路由为前缀路由,将匹配该字符串的所有前缀与 http method
use (path, handleRequest, options) {
// 用以匹配请求路径函数,如果匹配成功则返回匹配成功的参数,否则返回 false
// user/:id -> users/18 (id=18)
const matchRoute = match(path, { decode: decodeURIComponent, end: false, ...options })
// 注册路由,整理数据结构添加入路由数组
this.routes.push({ match: matchRoute, handleRequest, method: options.method || 'GET' })
},
// 注册路由,请求方法为 GET
get (path, handleRequest) {
return this.use(path, handleRequest, { end: true })
},
// 注册路由,请求方法为 POST
post (path, handleRequest) {
return this.use(path, handleRequest, { end: true, method: 'POST' })
},
// 入口函数
lookup (req, res) {
// 遍历路由,找到匹配路由,并解析路由参数
const route = this.routes.find(route => (req.params = route.match(req.url)?.params) && req.method === route.method)
if (route) {
// 找到路由时,处理该路由的处理逻辑
route.handleRequest(req, res)
} else {
// 如果找不到,返回 404
res.statusCode = 404
res.end('NOT FOUND SHANYUE')
}
}
}
module.exports = router
结语
完。
等一下,记得晚上要好好吃饭。