最近因项目需要重新使用了开源Express框架,用过它的开发者都知道Express是Node世界里使用最广的Web框架。最新版本移除了内置中间件,整个框架越来越轻量,适合再次重读。本文以常用的Express V4版本为例,记录该版本的设计理念,希望帮助初学者理解Express框架的工作原理,为了便于理解,本文要求开发者对Express框架有过了解。
设计理念
express的设计理念是以中间件作为基本原子单位,所有处理逻辑以中间件形式存在,通过路由管理中间件,当请求到时来按照注册顺序遍历执行中间件来响应请求。
怎么理解,这里从内部实现角度介绍中间件和路由两个核心概念,理解了它们后就知道这背后实现其实挺精巧。
中间件
中间件(middleware):这是express透出最重要的概念。它是一个函数接口声明,规定输入参数的顺序和含义,接口声明如下
// req:http请求对象
// res:http响应对象
// next:遍历下一个middleware的驱动函数
function fn(req, res, next) {}
中间件看成是Express暴露的最小原子单位,官方提倡中间件内部只做一件事。整个程序通过组合中间件来完成复杂业务功能。Express内部通过数组存储中间件,执行时遍历数组并依次执行中间件,而next就是遍历数组的驱动函数。next方法执行逻辑简化成如下代码,面试时也经常会考察next如何实现,本质上是通过递归模拟实现for循环逻辑:
const stack = [fn1, fn2, ...]
let idx = 0;
function next() {
if (idx >= stack.length) {
done();
return;
}
const fn = stack[idx++];
try {
fn(req, res, next);
}catch(e) {
next(e);
}
}
next()
根据next方法实现,用户编写中间件时要注意
- 执行完中间件代码,如果要通知框架继续执行下一个中间件,要记得继续调用next方法
- 如果调用next方法后,最好不要再后面添加额外逻辑
中间件主要作用
- 作为一个express透传的最底层抽象概念,只要代码实现middleware接口,代码就可以以npm包的形式独立发布出去,其他开发者通过import/require引入中间件并通过调用app.use或者router.use方法注入使用。正因为有中间件,丰富了Express框架的生态体系。
- 增强req、res对象:原始http服务透传的这两个对象内部包含基本属性,依靠动态语言的特性,可以在middleware内部直接在req、res对象上注入更多属性和方法,方便更上层业务使用
路由
路由和http协议相关,http协议规定通过URI来访问资源,通过http方法指示针对给定资源的期望动作,一次http请求就要包括URI、http方法等信息。作为一个Web服务端,Express通过路由将http请求提交到对应函数处理,路由相当于一个事件处理器,事件包括
- http请求的路径URI。
- http请求方法,如GET、PUT、POST、DELETE等
从http请求角度,请求包含的URI是完整的,但是从程序设计角度,为了高效处理,URI路径需要考虑到:
- 最简单情况直接匹配一个完整的URI路径,类似'/auth/login'
- 路径支持模糊匹配;类似'/delete/userId',请求时userId是一个具体的数字,程序表达时需要匹配一组这样的数字
- 按照业务层级关系来组织URI,层级关系符合现实世界业务领域模型,所以框架需要提供API来表达这样的层级关系
处理器就是事件处理函数,处理器的形式可以由一个处理程序(handler)组成,handler内表达具体业务逻辑,也可以支持组合一组handler。事实上,正如接下来要介绍,handler和中间件定义类似。
基于以上前提,Express内部通过Router对象来管理路由,Router提供注册路由和分发请求处理逻辑功能。
路由注册的基础信息包括:path、method和handler,以此为基础构成支持以下三种类型路由:
- 注册具体URI路径下的路由处理器:通过Router.METHOD(path, handlers)来定义,handlers就是一组路由处理程序,Express统一用该API表达指定路径下注册一组处理程序
- 注册某个层级路由:通过Router.use(path, router)装入子router
- 注册中间件,和路径无关:通过Router.use(middleware)来注册,middleware和handler定义类似
那Express内部如何实现上述注册逻辑?考虑到这三种注册类型都和处理器handler相关,所以Express内部通过Layer对象来管理指定path下的handler,Layer内部数据结构如下:
其中path支持正则表达式,内部使用path-to-regexp库来匹配路径。handle_request方法在分发请求时调用handle函数来响应请求
有了Layer概念,上述三种注册类型都可以通过Layer对象表达,如下图所示,只不过其handle属性值和具体类型有关
- 在指定URI path和http method下,一组handler处理程序也可以封装成对象,Express内部通过Route结构表达,Route对象结构如下图
当开发者调用Router.METHOD(path, handlers)注册路由时,内部执行如下逻辑来关联Router和Route对象:
- 利用METHOD、path和handlers,创建Route对象
- 创建Layer对象,其中handle的作用是当path匹配时,调用Route.dispatch方法用来执行Route注册过的handler处理程序来响应请求
- 把Layer对象压入Router内部数组中
- 如果要支持子路由层级,则处理逻辑如下
- 创建Layer对象,handle指向子Router.handle方法,该方法和父Router处理逻辑类似:调用next方法执行子路由内部中间件列表
- 把Layer对象压入父Router内部数组中
- 调用Router.use(middleware)注册路由级别中间件,此时Layer对象中handle就是middleware了,二者接口格式一致。
注:Layer.path 和 Route.path区别
- Layer.path:用于匹配路由路径时使用
- Route.path:目前只是作为Route属性存在
最终Express内部由Router、Layer、Route三个对象管理所有注册的中间件和路由,用如下图帮助理解一个实际程序背后的对象关系图
有了上述对象间关联结构图,当用户发送请求时,请求分发逻辑就好理解,分发逻辑入口在Router.handle方法内:遍历保存Layer的数组,找到第一个路径path匹配请求路由的Layer,执行handle,然后handle内再继续调用next方法继续遍历下一个符合条件的Layer。如果找不到匹配的路由就返回错误
function handle() {
const stack = this.stack; // 上述注册的Layer,包括middleware、Route、Router
let idx = 0;
const pathname = parseUrl(req).pathname;
next();
function next(err) {
// 边界条件,
let isMatch
// 遍历Layer,找到符合指定匹配条件的Layer
while(isMatch !== true && idx < stack.length) {
const layer = stack[idx++];
// 调用layer.match方法判断当前req.path是否与layer.path匹配
isMatch = layer.match(pathname);
if (!isMatch) continue;
}
if (isMatch) {
//调用
layer.handle_request(req, res, next)
} else {
done(err)
}
}
}
讲到这里,整个Express内部注册和请求处理核心流程基本理清了。有了这些基础再去看Express官方文档和API使用方式,就理解这些API背后的原理,知其然而知其所以然。框架对外暴露的app对象,类似一个代理对象,内部逻辑最后都流转到Router内部来处理。