node | express | 一个简易版express的诞生

666 阅读13分钟

前言

文章结构采用【指出阶段目标,然后以需解决问题为入口,以解决思路为手段】达到本文目标,若使诸君稍有启发,不枉此文心力^-^

(滴滴,长文警告,食用慎重)

实现提供http服务功能

先看使用

const express = require('./express');

const app = express();

app.get('/',function (req,res){
    res.end('/')
})

app.get('/hello',function (req,res){
    res.end('/hello');
})


app.listen(3000,function () {
    console.log('server start 3000');
})

两个功能

  1. 执行listen方法时创建服务
  2. 访问方法符合时,访问对应路径,执行相应回调;均不匹配时,返回固定404信息;

实现思路

注意到express是一个函数,其返回值是一个具有listenget方法的对象,我们可以在express的入口进行定义,从而目光转向对listenget方法的实现了

  1. listen方法就是对原生的http模块的一个封装,我们只要在执行时利用原生node模块http创建一个服务就可以了
  2. get方法和【均不匹配兼容】其实是一个路由功能,目前可以先简单的用一个队列去实现,每次执行get等路由方法,就将路径和对应处理函数入队列,然后在请求来时进行遍历匹配即可。至于404兼容,我们可以在初始化时就存入一个处理函数,这样当所有都没有匹配上时就执行即可

具体实现

const http = require('http')
const url = require('url')

function createApplication() {
    const router = [
        {
            path: '*',
            method: '*',
            handler(req,res){
                res.end(`Cannot ${req.method} ${req.url}`)
            }
        }
    ]
    return {
        get(path,handler){
            router.push({
                path,
                method: 'get',
                handler
            })
        },
        listen(port,cb){
            let server = http.createServer(function (req,res) {
                let {
                    pathname
                } = url.parse(req.url); // 获取请求的路径
           
                let requireMethod = req.method.toLowerCase();
                for (let index = 1; index < router.length; index++) {
                    const {method,path,handler} = router[index];
                    if(pathname === path && requireMethod === method){
                        return handler(req, res);
                    }
                }
                return router[0].handler(req,res); 
            })
            server.listen(...arguments)
        }
    }
}

module.exports = createApplication

实现拆分

实现应用的分离

express的入口函数中,只需要创建应用,而不应该关注应用本身的细节

express.js

const Application = require('./Application');

function createApplication() {

    return new Application();
}

module.exports = createApplication

而在应用中,则将之前说的提供【http服务】、【路由服务】包裹在一个构造函数中即可

Application.js

const http = require('http')
const url = require('url')

function Application() {
    // 路由表
    this.router = [{
        path: '*',
        method: '*',
        handler(req, res) {
            res.end(`Cannot ${req.method} ${req.url}`)
        }
    }]
}


Application.prototype.get = function (path, handler) {
    this.router.push({
        path,
        method: 'get',
        handler
    })
}
Application.prototype.listen = function (port, cb) {
    let server = http.createServer( (req, res) => {
        let {
            pathname
        } = url.parse(req.url); // 获取请求的路径

        let requireMethod = req.method.toLowerCase();
        for (let index = 1; index < this.router.length; index++) {
            const {
                method,
                path,
                handler
            } = this.router[index];
            if (pathname === path && requireMethod === method) {
                return handler(req, res);
            }
        }
        return this.router[0].handler(req, res);
    })
    server.listen(...arguments)

}
module.exports = Application;

此时我们完成了入口和应用层的抽离,但应用层和路由还是耦合的

完成路由的抽离

Router.js

const url = require('url')

function Router() {
    this.stack = [];
}

Router.prototype.get = function (path, handler) {
    this.stack.push({
        path,
        handler,
        method: 'get'
    })
}

Router.prototype.handle = function (req,res,out) {
    let {
        pathname
    } = url.parse(req.url); // 获取请求的路径
    let requireMethod = req.method.toLowerCase();
    for (let index = 0; index < this.stack.length; index++) {
        const {
            method,
            path,
            handler
        } = this.stack[index];
        if (pathname === path && requireMethod === method) {
            return handler(req, res);
        }
    }
    return out(req, res);
}

module.exports = Router;

完善路由

先看应用

关于路由,上文只是简单的实现了单一请求匹配单一应答,对于express的路由系统而言,其实还有更重要的两个特性没有体现

  • 多匹配机制
  • 多回调机制
多匹配机制

即同一个方法下的同一个路径被监听了多次,此时如果上层函数不执行next方法,下层监听函数是不会被执行的;如下

app.flage = false;
app.get('/hello',function (req,res,next){
    app.flage = !app.flage;
    function isOk() {
        // 判断是否向下执行的逻辑
        return app.flage;
    }
    // res.end('/hello')
    console.log('我是第一个监听函数',app.flage);
    if (isOk()) {
        next()
    }else {
        res.end('not continue')
    }
})

app.get('/hello',function (req,res){
    console.log('我是第二个监听函数');
    res.end('/hello')
})

上面代码很简单,就是设定了一个开关,为true时则执行next函数继续向下执行,为false则直接返回,启动服务请求接口,会发现如下现象

express-多监听

控制台显示如下

image-20210327105603259

多回调机制

即回调函数传递多个,同样采用next控制是否执行栈中的下一个

app.get('/', function (req,res,next) {
    console.log(1);
    next();
},function (req,res,next) {
    console.log(2);
    next();
},function (req,res,next) {
    console.log(3);
    next();
},function (req,res,next) {
    console.log(4);
    next();
    res.end('/ matched')
})

效果和上文相似,调动next则会继续向下执行

再看实现

理解next原理

要实现这些效果,我们先想想这样一个需求

有一个数组,组成数据结构如下,{match(){},handler(next){}} , 我希望当上一个handler调用next了之后,才执行数组的下一项

如何实现?

了解过CO(就是大神TJ实现的【对generator迭代器实现处理】的库,个人认为async+await是这个库的一个官方版)就会发现,逻辑完全一致,都是【将是否继续循环的控制权反转给被循环者本身】

既然要控制,我们自然不能简单的循环了,而是得递归。举例论证:

let stack = [
    {
        handle(next){
          	console.log('1, begin')
            setTimeout(() => {
                next()
                console.log('1, done');
            }, 5000);
           
        }
    },
    {
        handle(next){
            console.log('2,done');
        }
    }
]

那我们需要实现的就是

  • 打印'1, begin'
  • 过五秒打印1, done2, done

首先,我们很自然的能想到,如果next就是下一个函数呢?这样不就满足【执行next时执行下一个函数】的需求了吗?于是我们写出如下代码

let index = 0; // 当前正在执行的数组项索引
function dispatch(stack){
  let item = stack[index];
  item.handle(stack[++index])// 先自增 再取值
}

这样,我们就执行了第一项和第二项;接着,我们要递归所有

let index = 0; // 当前正在执行的数组项索引
function dispatch(stack,out){
  if(stack.length === index) return typeof out == 'Function' && out(); // 递归结束 ,如果存在结束钩子 则执行
  let item = stack[index++]; // 取值完后自增
  item.handle(dispatch.bind(null,stack))
}

image-20210327113953765

有了这个基础,我们再去看express的路由next就能更轻松一点了。

实现express-router

先思考实现思路

  • 每个【路径+方法】对应一个路由表(数组),路由表由处理函数组成,形成一个(Layer)
  • Router持有路由表(数组),路由表由上面的Layer组成

我们先来看张图

image-20210325085909938

角色梳理

这里面有三个角色

  1. 路由表:由层组成,是个数组,其层的组成是指【处理函数】
  2. 路由对象:持有路由表,其路由表的层的组成是指【路径+方法 对应着 路由表】
  3. 层:为了统一数组组成而抽象出来的数据结构

Route.js

const Layer = require('./layer')
const methods = require('methods');

// 每个层都有一个route属性
function Route() {
    this.stack = []
}
// 处理函数
Route.prototype.dispatch = function (req,res,out) {
    let idx = 0;
    let method = req.method.toLowerCase(); // 获取请求的方法
    let dispatch = () => {
        if (idx === this.stack.length) { 
            return out();
        }
        let layer = this.stack[idx++];
        if (layer.method === method) { // 获取内部的第一层判断方法是否匹配
            layer.handle_request(req,res,dispatch)
        }else {
            dispatch()            
        }
    }
    dispatch()
}
methods.forEach(method => {
  	// 被调用时 将对应的处理函数数组映射成多个层,并存入路由表对象持有的队列中
    Route.prototype[method] = function (handlers) {
        handlers.forEach(handler => {
            let layer =  new Layer('',handler);
            layer.method = method;
            this.stack.push(layer)
        })
    }
})


module.exports = Route;

Layer.js

function Layer(path,handler) {
   this.path = path; 
   this.handler = handler; 
   this.method = ""
}

Layer.prototype.match = function (pathname) {
    return this.path === pathname;
}

Layer.prototype.handler_request = function (req,res,next) {
    this.handler((req,res,next))
}

module.exports = Layer;
逻辑梳理

首先,一个应用得有一个对应的路由

function Application() {
    this._router = new Router();
}

其次,就是请求的订阅发布了。

订阅:得在应用初始化时进行方法的监听,即app上的get、post等路由方法交由路由处理

methods.forEach(method => {
    Application.prototype[method] = function (path, ...handlers) {
        this._router[method](path,handlers)
    }
})

进入router层,在方法被触发时做三件事

  1. 创建对应的层,存入自己的队列中
  2. 对层进行初始化,赋值上对应的【path+method】和处理函数
  3. 将处理逻辑交由层里面的路由表(route)进行处理
function Router() {
    this.stack = [];
}
methods.forEach(method => {
    Router.prototype[method] = function (path,handlers) { // 用户调[method]方法时,传递了多个处理函数
        let route = this.route(path); // 构建一个route
      	// 将处理逻辑交由层里面的路由表进行处理
        route[method](handlers);
    }
})
Router.prototype.route = function (path) {
    let route = new Route();
    let layer = new Layer(path,route.dispatch.bind(route)); // 给当前调用get方法 放入一层
    layer.route = route; // 每个层都有一个route属性
    this.stack.push(layer);
    return route;
}

发布:得改写应用对象的监听逻辑,将逻辑处理交由路由

Application.prototype.listen = function (port, cb) {
    let server = http.createServer( (req, res) => { 
        // 应用提供一个对均无法匹配的兼容处理函数
        function done() {
            res.end(`Cannot ${req.method} ${req.url}`);
        }
        this._router.handle(req,res,done);
    })
    server.listen(...arguments)
}

路由的handle方法只做一件事,递归存储的Layer,所有匹配到的Layer依次执行

重点【依次】,其实就是在说【多匹配机制】,这个时候我们就要采用next的思路了。

Router.prototype.handle = function (req,res,out) {
    let {
        pathname
    } = url.parse(req.url); // 获取请求的路径
    let idx = 0;
    let dispatch = () => { // exress 需要通过next函数来迭代
        if (idx === this.stack.length) {
            return out()
        }
        let layer = this.stack[idx++];
        if (layer.match(pathname)) {
            layer.handler_request(req,res,dispatch);
        }else {
            dispatch();
        }
    }
    dispatch();
}

Layerhandler_request其实就是调用了一下初始化Layer时存入的handle函数,回想一下上面,那不就是路由表的next的吗?这就通了,相当了是:Application -> Router -> Layer -> Route -> Layer的处理逻辑

image-20210327122520922

Layer间会需要一次next逻辑,Route间也会需要一次,此时需要注意的是,Layer的out函数是express在listen中提供的done方法,而Route的out函数则是Layer的next方法

至此,我们就基本理解了epxress的运作流程啦

中间件机制

先看应用

应用规则:

  • express中use的第一个参数是匹配路径 不传相当于"/"
  • 中间件匹配机制是惰性匹配,即匹配路径为/a的中间件,访问/aa时同样会被执行(这也意味着不传匹配路径时即所有请求都会应用此中间件)
const express = require('./express');

const app = express();

// 第一个参数是匹配路径 不传相当于"/"
app.use(function (req,res,next) {
    req.a = 1;
    next()
})

app.use('/',function (req,res,next){
    req.a++;
    next();
})
app.get('/',function (req,res,next){
    res.end(req.a + '');
})

app.use('/a',function (req,res,next){
    req.a++;
    next();
})
app.get('/a',function (req,res,next){
    res.end(req.a + '');
})

app.listen(3000)


express-use

实现思路

结合之前的路由实现,其实中间件就是【没有路由表】的Layer,我们只需要

  1. 订阅监听时做下对“不传匹配路径”等情况的处理
  2. 请求发布时根据“是否具有路由表route属性”进行判断从而对中间件区分处理

如此即可

具体实现

订阅监听时做下对“不传匹配路径”等情况的处理

定义use方法

Router.prototype.use = function(path, ...handlers) {
    if (!handlers[0]) { // 只传递了一个函数 
        handlers.push(path); // app.use(function(){})  app.use()
        path = '/'
    }
    handlers.forEach(handler => {
        let layer = new Layer(path, handler);
        layer.route = undefined; // 不写也是undefined , 主要告诉你 中间件没有route
        this.stack.push(layer);
    })
}
请求发布时根据“是否具有路由表route属性”进行判断从而对中间件区分处理

改写handle方法

Router.prototype.handle = function(req, res, done) {
    let { pathname } = url.parse(req.url);
    let method = req.method.toLowerCase()
    let idx = 0;
    const next = (err) => { // 中间件 和内部的next方法 出错都会走这个next
        if (idx >= this.stack.length) return done(); // 路由处理不了 传递给应用层
        let layer = this.stack[idx++];
      	// 无论是路由还是中间件 前提是路径必须匹配
        if (layer.match(pathname)) { // match还没有更改
              if (!layer.route) { // 没有说明是中间件   注意 此处就是对中间件的区分处理
                  layer.handle_request(req, res, next); // 直接执行中间件函数
              } else {
                // 路由必须匹配方法
                if (layer.route.methods[method]) { // 这个next可以让路由层扫描下一个layer
                  layer.handle_request(req, res, next); // route.dispatch
                } else {
                  next();
                }
              }
            } else {
              next();
            }
    }
    next(); // 请求来了取出第一个执行

}

总结流程

image-20210327171705081

错误处理

先看应用

应用规则:

  • 首先进行错误中间件注册(错误中间件区别普通中间件就在于它有四个参数)
app.use((err,req,res,next) => {
    res.header('Content-Type','text/html;charset=utf-8');
    res.end(err)
})
  • 应用时,只要在执行next函数时传递参数,就会执行到错误中间件的回调中,并且会将值传递给err
app.use(function (req,res,next){
    let flag = Math.random() > 0.5;
    if(flag){
        return next('出错了');
    }
    next();
})

访问效果如下

errhandle

实现思路

同样的,对错误中间件进行区别处理,判断逻辑

  1. 根据route属性的有无判断是不是中间件
  2. 根据fn.length是不是4判断是不是错误中间件 (函数的length属性是参数个数)

具体实现

改写Router的handle方法,注意两点:

  1. 如果next有参数,则查找错误中间件以执行
  2. 在执行中间件时要进行判断,从而避免执行了错误中间件
Router.prototype.handle = function(req, res, done) {
    let { pathname } = url.parse(req.url);
    let method = req.method.toLowerCase()
    let idx = 0;
    let removed = '';
    const next = (err) => { // 中间件 和内部的next方法 出错都会走这个next
        if (idx >= this.stack.length) return done(); // 路由处理不了 传递给应用层
        let layer = this.stack[idx++];
        if (err) {
            // 如果有错误 , 找错误处理中间件
            if(!layer.route){ // 中间件
                if(layer.handler.length === 4){
                    layer.handler(err,req,res,next)
                }else{
                    next(err);
                }
            }else{ // 路由
                next(err);
            }
        } else {
            // 无论是路由还是中间件 前提是路径必须匹配
            if (layer.match(pathname)) { // match还没有更改
                if (!layer.route) { // 没有说明是中间件
                    // 正常中间件不走错误
                    if(layer.handler.length !== 4){
                        layer.handle_request(req, res, next); // 直接执行中间件函数
                    }else{
                        next();
                    }
                } else {
                    // 路由必须匹配方法
                    if (layer.route.methods[method]) { // 这个next可以让路由层扫描下一个layer
                        layer.handle_request(req, res, next); // route.dispatch
                    } else {
                        next();
                    }
                }
            } else {
                next();
            }
        }
    }
    next(); // 请求来了取出第一个执行

}

路由参数

先看应用

应用规则:对于路由传参,通常有两种写法

  1. 请求时以?key=val的形式,获取时req.query
  2. 定义时以/:key1/:key2的形式,请求时以/val1/val2的形式,获取时req.params
app.get('/wzy1/:id/:name',function (req,res){
    console.log(req.params);
    res.end(JSON.stringify(req.params))
})

app.get('/wzy2',function (req,res){
    console.log(req.query);
    res.end(JSON.stringify(req.query))
})

路由传参

实现思路

第一种,其实只要进行下以?截取,然后进行split,组装成为对象赋值在req.query属性上即可

重点实现第二种,关键是1. 如何获取请求路径上的参数 2. 如何根据这种规定进行路由的匹配

很自然的,我们可以想到用正则,正则中的分组模式可以让我们获取到符合条件情况下的某部分的值

具体实现

首先看个例子

let configUrl = '/wzy/:id/:name';
let keys = [];


configUrl = configUrl.replace(/:([^\/]+)/g,function (){
    keys.push(arguments[1]);
    return '([^\/]+)'
})


let reg = new RegExp(configUrl);
let requestUrl = '/wzy/1/2';
let [,...args] = requestUrl.match(reg);
let params = {};
keys.forEach((key ,i) => {
    params[key] = args[i]
})

console.log(params);// {id: 1, name: 2} 

其实这种功能是有第三方包的,path-to-regexp,改写后如下

const {pathToRegexp} = require('path-to-regexp')
console.log(pathToRegexp);
let configUrl = '/wzy/:id/:name';
let keys = [];
regExp = pathToRegexp(configUrl,keys);
console.log(regExp, keys);

image-20210328121752394

核心功能实现了,那我们开始接入express中,主要是改写路由匹配逻辑

接入正则

Layer.js

const pathToRegExp = require('path-to-regexp')

function Layer(path, handler) {
    this.path = path;
    this.regExp = pathToRegExp(this.path, this.keys = []);
    console.log(this.regExp, this.keys)
    this.handler = handler;
}
改写macth

Layer.js

Layer.prototype.match = function(pathname) {
    // pathname = /user   /user
    if (this.path == pathname) {
        return true;
    }
    // 中间件只有开头就可以

    let r = pathname.match(this.regExp);
    if(r){
       let  [, ...matches] = r; // 正则匹配的结果 1个是匹配到的整个结果 2第一个分组 3 第二个分组
       this.params = {};
       this.keys.forEach((item, index) => {
           this.params[item.name] = matches[index]
       });
       return true;
    }
    if (!this.route) {
        if (this.path == '/') { // / 可以匹配任何路径
            return true;
        }
        // /user/add   
        return pathname.startsWith(this.path + '/')
    }
    // todo ...
    return false;
}

子路由

先看应用

应用规则:

  • 创建子路由系统从而进行解耦合,项目更加模块化
const express = require('express');


const app = express();

const user = express.Router();
user.get('/add',function (req,res){
    res.end('user add')
})
user.get('/remove',function (req,res){
    res.end('user remove')
})
const article = express.Router();
article.get('/add',function (req,res){
    res.end('article add')
})
article.get('/remove',function (req,res){
    res.end('article remove')
})


app.use('/user', user);
app.use('/article', article);

app.listen(3000)

子路由系统

实现思路

首先,得明白它是一个中间件,这也就意味着express.Router返回的是一个函数;且它是路由,意味着Router函数不仅可以被new还得支持直接执行时返回一个路由系统。其二,子路由需要拼接父路由在进行匹配

express.Router返回的是一个函数,同时被new时又得返回一个对象

那我们就可以根据【函数被new时如果存在返回值且是一个引用类型的话,则返回此引用类型对象】的特点,首先定义Router的返回值是一个中间件函数;然后定义一个对象,将所有之前的属性和方法放在这个对象身上,将这个对象放置在中间件函数的原型链上;这样就实现了new和直接执行返回值都符合的情况。

实现父子路径拼接

在请求到来时,会符合中间件的匹配逻辑,这时我们直接执行子路由的handle从而让请求进入子路由的处理中;

注意一点,子路由中定义的是不包含前缀的路径,所以需要记录下中间件的path,从而先截取,再匹配;

具体实现

express.Router返回的是一个函数,同时被new时又得返回一个对象
const url = require('url');
const Layer = require('./layer');
const Route = require('./route');
const methods = require('methods');

function Router() { // 能充当构造函数 也可以充当类 , 无论是new 还是call 都返回一个函数
    router.stack = [];
    router.methods = {};
    let router = (req,res,next) => {
        router.handle(req,res,next)
    }
    router.removed = ""
    router.__proto__ = proto;
    return router
}

let proto = {};
// 外层的layer 考虑路径   里层的layer考虑方法 = 同一个类
proto.prototype.route = function(path) {
    let route = new Route();
    let layer = new Layer(path, route.dispatch.bind(route)); // 每次调用get方法, 都会产生一个layer实例和一个route实例

    // 这个关联目的是可以在layer获取route的信息
    layer.route = route; // 路由中的layer 都有一个route属性 和 我们的route关联起来
    this.stack.push(layer)
    return route;
}
proto.prototype.use = function(path, ...handlers) {
    if (!handlers[0]) { // 只传递了一个函数 
        handlers.push(path); // app.use(function(){})  app.use()
        path = '/'
    }
    handlers.forEach(handler => {
        let layer = new Layer(path, handler);
        layer.route = undefined; // 不写也是undefined , 主要告诉你 中间件没有route
        this.stack.push(layer);
    })
}
// app.get
methods.forEach(method => {
    // app.get => handlers   rouer.get
    proto.prototype[method] = function(path, handlers) { // handlers 是用户定义get时传递过来的所有执行函数  (数组)
        if(!Array.isArray(handlers)){
            handlers = Array.from(arguments).slice(1);
        }
        let route = this.route(path); // 创建一个route实例
        // 创建一个layer  还要创建一个route,将handlers 传递给route
        route[method](handlers);

    }
})

proto.prototype.handle = function(req, res, done) {
    let { pathname } = url.parse(req.url);
    let method = req.method.toLowerCase()
    let idx = 0;
    let removed = '';
    const next = (err) => { // 中间件 和内部的next方法 出错都会走这个next
        if (idx >= this.stack.length) return done(); // 路由处理不了 传递给应用层
        let layer = this.stack[idx++];
        if(removed.length){
            req.url = removed + req.url;
           removed = '';
        }
        if (err) {
            // 如果有错误 , 找错误处理中间件
            if(!layer.route){ // 中间件
                if(layer.handler.length === 4){
                    layer.handler(err,req,res,next)
                }else{
                    next(err);
                }
            }else{ // 路由
                next(err);
            }
        } else {
            // 无论是路由还是中间件 前提是路径必须匹配
            if (layer.match(pathname)) { // match还没有更改
            
                req.params =  layer.params
                if (!layer.route) { // 没有说明是中间件
                    // 正常中间件不走错误
                    if(layer.handler.length !== 4){
                        // 进入到中间件的时候 需要将中间件的路径移除掉
                        //add
                        if(layer.path !== '/'){
                            removed = layer.path; // 要删除的部分  中间件要是/ 就不要删除了
                            req.url = req.url.slice(layer.path.length) ;
                        }


                        layer.handle_request(req, res, next); // 直接执行中间件函数
                    }else{
                        next();
                    }
                } else {
                    // 路由必须匹配方法
                    if (layer.route.methods[method]) { // 这个next可以让路由层扫描下一个layer
                        layer.handle_request(req, res, next); // route.dispatch
                    } else {
                        next();
                    }
                }
            } else {
                next();
            }
        }
    }
    next(); // 请求来了取出第一个执行

}
module.exports = Router;

结尾

代码仓库