重读Express框架

730 阅读7分钟

最近因项目需要重新使用了开源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,以此为基础构成支持以下三种类型路由:

  1. 注册具体URI路径下的路由处理器:通过Router.METHOD(path, handlers)来定义,handlers就是一组路由处理程序,Express统一用该API表达指定路径下注册一组处理程序
  2. 注册某个层级路由:通过Router.use(path, router)装入子router
  3. 注册中间件,和路径无关:通过Router.use(middleware)来注册,middleware和handler定义类似

那Express内部如何实现上述注册逻辑?考虑到这三种注册类型都和处理器handler相关,所以Express内部通过Layer对象来管理指定path下的handler,Layer内部数据结构如下:

image.png

其中path支持正则表达式,内部使用path-to-regexp库来匹配路径。handle_request方法在分发请求时调用handle函数来响应请求

有了Layer概念,上述三种注册类型都可以通过Layer对象表达,如下图所示,只不过其handle属性值和具体类型有关

image.png

  1. 在指定URI path和http method下,一组handler处理程序也可以封装成对象,Express内部通过Route结构表达,Route对象结构如下图

Route对象

当开发者调用Router.METHOD(path, handlers)注册路由时,内部执行如下逻辑来关联Router和Route对象:

  • 利用METHOD、path和handlers,创建Route对象
  • 创建Layer对象,其中handle的作用是当path匹配时,调用Route.dispatch方法用来执行Route注册过的handler处理程序来响应请求
  • 把Layer对象压入Router内部数组中
  1. 如果要支持子路由层级,则处理逻辑如下
  • 创建Layer对象,handle指向子Router.handle方法,该方法和父Router处理逻辑类似:调用next方法执行子路由内部中间件列表
  • 把Layer对象压入父Router内部数组中
  1. 调用Router.use(middleware)注册路由级别中间件,此时Layer对象中handle就是middleware了,二者接口格式一致。

注:Layer.path 和 Route.path区别

  • Layer.path:用于匹配路由路径时使用
  • Route.path:目前只是作为Route属性存在

最终Express内部由Router、Layer、Route三个对象管理所有注册的中间件和路由,用如下图帮助理解一个实际程序背后的对象关系图

image.png

有了上述对象间关联结构图,当用户发送请求时,请求分发逻辑就好理解,分发逻辑入口在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内部来处理。