Express 源码分析及简易封装

Express 源码解析 最近项目上使用了express,有点时间就看了些源码、博客,然后准备整理记录供大家参考一下. 参考: https://cnodejs.org/topic/5746cdcf991011691ef17b88 Express各模块简介 下图是express源码中各模块文件的分布: middleware(中间件)下主要有init.js和query.js,init.js的作用是初始化re... 阅读详情

在这里插入图片描述


阅读原文


前言

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")
express 简单来说,express 是一个封装好的工具包,封装了很多功能,便于我们开发 WEB 应用(HTTP 服务)express 框架封装了一些 API 来方便获取请求报文中的数据,并且兼容原生 HTTP 模块的获取方式。express 框架封装了一些 API 来方便给客户端响应数据,并且兼容原生 HTTP 模块的获取方式。中间件函数 可以像路由回调一样访问 请求对象(request) , 响应对象(response)第四步:设置路由中间件,然后使用 request.body 来获取请求体数据。 阅读详情

相关推荐

Express与MongoDB构建Web服务端全栈开发实战

在现代Web开发中,Express作为Node.js生态中最流行的Web框架,与MongoDB这一领先的NoSQL数据库组合,成为构建高性能服务端的黄金搭档。Express基于中间件管道和洋葱圈模型处理HTTP请求,而MongoDB的文档型数据模型和WiredTiger存储引擎为快速迭代的应用提供了灵活的数据存储方案。通过Mongoose ODM层,开发者可以像操作对象一样管理数据库文档,实现数据验证、关系建模等高级功能。这种技术栈特别适合需要快速开发RESTful API、实时应用或微服务架构的场景。本教

alince20008的博客 553

Flash芯片的VCC上电到可操作时间过长

Flash的VCC上电到操作时间(tVSL)通常有最大值限制,如果超过这个时间,核心风险是状态机不同步和指令锁存错误。比如芯片内部振荡器可能已经尝试启动又休眠了,但主控却在这之后才发送指令,导致完全 misinterpreted。Flash芯片的(从电源上电到可以正常进行读写操作的时间)如果过长,会产生一系列负面影响简单来说,这个时间过长意味着电源上升速度太慢,违反了芯片的要求。

qq_61918649的博客 722

VMware ESXi 7.0下Intel网卡SR-IOV配置实战:解决虚拟化网络延迟问题

本文详细介绍了在VMware ESXi 7.0环境下为Intel网卡配置SR-IOV(单根I/O虚拟化)的完整实战流程。通过硬件辅助直通技术,虚拟机可直接访问物理网卡资源,有效绕过虚拟化层瓶颈,从而显著降低网络延迟和CPU开销,适用于金融交易、实时计算等对性能敏感的场景。

embedding5hiker的博客 553

从零到一:龙芯杯参赛全攻略(含北理工学长经验PDF、B站培训视频与开源代码合集)

本文详细介绍了龙芯杯全国大学生计算机系统能力大赛的参赛全攻略,包括资源整合、技能树构建、团队协作和避坑指南。提供北理工学长经验PDF、B站培训视频与开源代码合集,帮助参赛者从零基础到站上领奖台。重点解析如何高效获取优质资源、避开新手常见陷阱及构建可落地的学习路径,助力普通学生系统学习CPU设计。

weixin_26802933的博客 542

LuaJit分析(十一)去除string.dump函数

因此即使我们的指令顺序被打乱,通过loadfile系统调用,再通过string.dump调用,可以得到字节码文件,再通过与标准的luajit生成的字节码文件比对,即可找出差异所在。Lua脚本中的string.dump函数用于生成字节码文件,根据对 luajit -b命令的分析可以得出,最终dump出字节码文件都是使用的string.dump函数。同时lj_libdef.h和lj_ffdef.h这两个文件都是自动生成的,在msvcbuild.bat中,使用如下命令生成。

a545958498的博客 1132

读懂 ABAP CDS 注解的直接、继承与派生

本文系统梳理了ABAP CDS注解的评估逻辑,重点解析直接注解(Direct)、继承注解(Inherited)和派生注解(Derived)的差异及其在CL_DD_DDL_ANNOTATION_SERVICE中的处理方式。通过视图注解@Metadata.ignorePropagatedAnnotations可调控注解传播行为,避免下游语义影响上层视图。文章还提供了基于标准表的完整示例(包含底层视图、消费视图和ABAP报告程序),帮助开发者在实际系统中验证三层注解的叠加效果与阻断机制。关键点包括:派生注解的数据

2007 年 ~ 2025 年,深耕 SAP 技术 18 年 245

Allegro孔和线被做到器件里面如何解锁操作指导

在做PCB设计的时候,会遇到孔和线被做到器件的情况,当需要移动器件的时候,孔和线也会跟着移动。孔和线被做到器件里面如何解锁操作指导。

weixin_54787054的博客 1144

基于Abaqus的横向各向同性线弹性理想塑性材料模型的umat子程序实现——以Fortran为例,带您揭秘弹性塑性模型编程之道

通过以上步骤,我们已经在Abaqus中使用Fortran实现了横向各向同性线弹性理想塑性材料模型的umat子程序。在实际应用中,我们需要根据具体的材料特性和加载条件来调整模型参数和计算过程。尽管我在这篇文章中尽力提供了详细的教程,但由于我自身的知识限制和经验不足,可能会有误解或错误。我希望大家在学习过程中能多思考,多实践,多交流,以更好地理解和掌握这个主题。

qq_38334677的博客 1690

ThingsBoard教程(七):模拟设备遥测

前言 各位读者好,截止目前,ThingsBoard系列文章已经做了六篇,分别是 ThingsBoard教程(一):ThingBoard介绍及安装 ThingsBoard教程(二):租户,租户配置,部件组模块的使用 ThingsBoard教程(三):系统设置模块的使用 ThingsBoard教程(四):规则链简介与操作 ThingsBoard教程(五):客户,资产管理 ThingsBoard教程(六):设备管理 上一篇我们讲解了如何使用设备管理来对设备进行增删改查,和设备配置的一些基本概念,本篇文章就带大

知无涯 4578

visio安装包

Microsoft Office Visio 是一款专业的制图软件。利用经过更新的形状、协作工具和数据链接图表,创建专业级图表以简化复杂的信息。

jre9下载 jdk9

(Java SE Runtime Environment 9.0) 9.0u175 官方最新版 【x86|x64】

RU DOS版、EFI版32bit/64bit BIOS常用工具

DOS版、EFI版的RU工具,可用来查看IO、MEM、PCI、SMBIOS等资源。亲测在EFI SHELL下可用。

Devexpress16.2.6破解工具

Devexpress16.2.6破解工具Devexpress16.2.6破解工具Devexpress16.2.6破解工具Devexpress16.2.6破解工具

上一篇: Koa2 中间件原理解析 —— 看了就会写
下一篇: Express 中间件 body-parser 原理分析
Panda Shen
博客等级 码龄10年 18粉丝 50原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值