Express 源码分析及简易封装

本文通过分析Express源码,逐步实现了一个简易版的Express框架,包括搭建基本服务、路由实现、扩展请求对象属性、响应方法send和sendFile、内置中间件、模板引擎、静态资源中间件和重定向等功能。通过对关键代码的讲解,帮助读者理解Express的工作原理。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

在这里插入图片描述


阅读原文


前言

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 模块创建了服务,并调用了创建服务 serverlisten 方法,将 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.getapp.postapp.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.pathreq.queryreq.hostreq.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")
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值