手把手带你用 82 行代码实现一个简易版 Express 框架

开发 前端
connect 的 repo 描述是:“Connect is a middleware layer for Node.js”,也就是一个 Node.js 的中间件层。中间件层是一个非常有用的机制,它类似一个插件系统,让我们可以通过插拔的方式组合不同功能来处理请求。

本文将带大家实现轻量级 web 框架 connect[1] 的主要功能,只要 82 行代码就能搞定。

我并没有标题党,因为 Express 在 v3 版本之前就是基于 connect 进行封装的,不过在 v4 版本就将 connect 依赖移除了[2],代码被搬到 Express 仓库里,并做了一些细微调整。因此某种程度上,学习 connect 就是在学习 Express。

connect 的 repo 描述是:“Connect is a middleware layer for Node.js”,也就是一个 Node.js 的中间件层。中间件层是一个非常有用的机制,它类似一个插件系统,让我们可以通过插拔的方式组合不同功能来处理请求。

基本使用

先来看看 connect 的使用。

const connect = require('connect')

const app = connect()

// respond to all requests
app.use(function(req, res){
  res.end('Hello from Connect!\n')
})

// create node.js http server and listen on port
http.createServer(app).listen(3000)

跟 Express 一样。

另外,app 上还提供了 .listen() 方法,用于替代 http.createServer(app).listen(3000) 的冗长写法。

app.listen(3000) // 等价于 http.createServer(app).listen(3000)

再看看中间件的使用。

app.use(function middleware1(req, res, next) {
  // middleware 1
  next()
});
app.use(function middleware2(req, res, next) {
  // middleware 2
  next()
});

我们通过 app.use() 方法收集并使用中间件。

中间件就是一个函数,包含 3 个参数:req、res 还有 next()。在一个中间件内调用 next(),就进入到下一个中间件的执行。

同时,我们还可以为中间件指定路由,这样中间件只在特定路径下起作用。

app.use('/foo', function fooMiddleware(req, res, next) {
  // req.url starts with "/foo"
  next()
})
app.use('/bar', function barMiddleware(req, res, next) {
  // req.url starts with "/bar"
  next()
})

本质上,纯中间件的写法就是在设置根路由('/'),所以会对所有请求有效。

app.use(function middleware1(req, res, next) {
  // middleware 1
  next()
})
// 等同于
app.use('/', function middleware1(req, res, next) {
  // middleware 1
  next()
})

不过还有一类特殊中间件——异常中间件,专门用于处理前面流程里的异常错误。

// regular middleware
app.use(function (req, res, next) {
  // i had an error
  next(new Error('boom!'));
});

// error middleware for errors that occurred in middleware
// declared before this
app.use(function onerror(err, req, res, next) {
  // an error occurred!
});

异常中间件必须是 4 个参数,第一个参数就是 error,对应前面流程中传递给 next() 的 Error 对象。

以上,我们就讲完了 connect 库的基本使用。接下来,就着手实现。

代码实现

基于 connect v3.7.0 版本[3]。

刚学 Node.js 的时候,我们学到第一个例子,可能就是启动一个会说“Hello World”的服务器了。

const http = require('node:http')

const hostname = '127.0.0.1'
const port = 3000

const server = http.createServer((req, res) => {
  res.statusCode = 200
  res.setHeader('Content-Type', 'text/plain')
  res.end('Hello World\n')
})

server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`)
})

回顾 connect 的使用。

const connect = require('connect')

const app = connect()

// respond to all requests
app.use(function(req, res){
  res.end('Hello from Connect!\n')
})

// create node.js http server and listen on port
app.listen(3000)

实现 app.listen()

我们已经知道 app.listen(3000) 内部实现就是 http.createServer(app).listen(3000)。

因此,我们先实现 .listen() 方法。

module.exports = function createApplication() {
  const app = {}

  app.listen = function listen(...args) {
    const server = require('node:http').createServer(/* ? */)
    return server.listen(...args);
  }

  return app
}

假设 app 是一个对象。不过,http.createServer(/* ? */) 中的 ? 内容该如何实现呢?

实现 app.use()

前一步,我们做了 app.use() 的调用。

// respond to all requests
app.use(function(req, res){
  res.end('Hello from Connect!\n')
})

所以,当服务启动后,访问 localhost:3000 时,应该返回 "Hello from Connect!" 的文本。

同时,app.use() 又支持重复调用。

// respond to all requests
app.use(function(req, res, next) {
  console.log('log req.url', req.url)
  next()
})

// respond to all requests
app.use(function(req, res) {
  res.end('Hello from Connect!\n')
})

那我们就考虑先用个数组,把通过 app.use() 调用传入进来的回调函数存起来。

module.exports = function createApplication() {
  const app = {}
 app.stack = []
  
  app.use = function use(route, fn) {
   let path = route
   let handle = fn

        // default route to '/'
   if (typeof route !== 'string') {
      path = '/'
      handle = route
    }
    
    this.stack.push({ route: path, handle })
    return this
  }
  
  app.listen = function listen(...args) {
    const server = require('node:http').createServer(/* ? */)
    return server.listen(...args)
  }

  return app
}

我们把调用 app.use() 传入的中间件都存到了 app.stack 里。

根据定义可知,http.createServer(/* ? */) 中的 ? 内容应该是一个函数。针对当前场景,它是用来处理 stack 中的这些中间件的。

实现 app.handle()

我们把这些逻辑写在 app.handle() 内。

module.exports = function createApplication() {
  const app = {}
  app.stack = []

  // ...

  app.listen = function listen(...args) {
    const server = require('node:http').createServer(app.handle.bind(app))
    return server.listen(...args)
  }

  app.handle = function handle(res, res) {
    // TODO
  }

  return app
}

每当请求来临,都由 app.handle 负责处理。

app.handle 的主要逻辑主要是处理 3 件事情。

  1. 获取当前要处理的路由,没有的话就交由最终处理函数 done
  2. 路由不匹配就跳过
  3. 路由匹配就执行当前中间件
app.handle = function handle(req, res) {
  let index = 0

  const done = function (err) { /* ... */ }

  function next(err) {
    // next callback
    const layer = app.stack[index++]

    // 1) all done
    if (!layer) {
      setImmdiate(done, err)
      return
    }

    // route data
    const path = require('node:url').parse(req.url).pathname
    const route = layer.route

    // 2) skip this layer if the route doesn't match
    if (!path.toLowerCase().startsWith(route.toLowerCase())) {
      return next(err)
    }

    // 3) call the layer handle
    const arity = handle.length
    const hasError = !!err
    let error = err

    try {
      if (hasError && arity === 4) {
        // error-handling middleware
        layer.handle(err, req, res, next)
        return
      } else if (!hasError && arity < 4) {
        // request-handling middleware
        layer.handle(req, res, next)
        return
      }
    } catch (e) {
      error = e
    }

    next(error)
  }

  next()
}

以上的关键处理就封装在 next() 函数中。而 next() 函数就是传递给 connect 中间件的 next 参数。

这样,每次请求进来,我们都会从 app.stack 的第一个中间件(stack[0])开始处理,就实现了以 next 参数为连接桥梁的中间件机制。

值得注意的是调用当前中间件的逻辑,当我们调用 layer.handle(err, req, res, next)/layer.handle(req, res, next) 时,处理流程会流入中间件内部,当内部调用 next() 函数后,控制权会重新回到 app.handle,继续处理队列中的下一个中间件。

当请求最终没有任何中间件可以处理时,就会流入到 done,这是最终处理器。处理器内部,会根据是否存在错误,分别返回 404 或 5xx 响应。

const done = function (err) {
  if (err) {
    res.statusCode = err.status ?? err.statusCode ?? 500
    res.statusMessage = require('node:http').STATUS_CODES[404]
  } else {
    res.statusCode = 404
    res.statusMessage = `Cannot ${req.method} ${require('node:url').parse(req.url).pathname}`
  }
  res.end(`${res.statusCode} ${res.statusMessage}`)
}

至此,我们基本写完了所有的逻辑。

当然,有一个地方,可以做一个小小的优化。将 http.createServer(app.handle.bind(app)) 简化成 http.createServer(this),不过此时 app 就不能是对象,而是函数了。

module.exports = function createApplication() {
 function app(req, res) { app.handle(req, res) }

  // ...
  
  app.listen = function listen(...args) {
    const server = require('node:http').createServer(app)
    return server.listen(...args)
  }

  // ...
  
 return app
}

最后,我们整体来回顾一下。

module.exports = function createApplication() {
  function app(req, res) { app.handle(req, res) }
  app.stack = []

  app.use = function use(route, fn) {
    let path = route
    let handle = fn
    
    // default route to '/'
    if (typeof route !== 'string') {
      path = '/'
      handle = route
    }

    this.stack.push({ route: path, handle })
    return this
  }

  app.listen = function listen(...args) {
    const server = require('node:http').createServer(app)
    return server.listen(...args)
  }

  app.handle = function handle(req, res) {
    let index = 0

    const done = function (err) {
      if (err) {
        res.statusCode = err.status ?? err.statusCode ?? 500
        res.statusMessage = require('node:http').STATUS_CODES[404]
      } else {
        res.statusCode = 404
        res.statusMessage = `Cannot ${req.method} ${require('node:url').parse(req.url).pathname}`
      }
      res.end(`${res.statusCode} ${res.statusMessage}`)
    }

    function next(err) {
      // next callback
      const layer = app.stack[index++]

      // 1) all done
      if (!layer) {
        setImmediate(done, err)
        return
      }

      const path = require('node:url').parse(req.url).pathname
      const route = layer.route
      
      // 2) skip this layer if the route doesn't match
      if (!path.toLowerCase().startsWith(route.toLowerCase())) {
        return next(err)
      }

      // 3) call the layer handle
      const arity = handle.length
      const hasError = !!err
      let error = err

      try {
        // error-handling middleware
        if (hasError && arity === 4) {
          layer.handle(err, req, res, next)
          return
        // request-handling middleware
        } else if (!hasError && arity < 4) { 
          layer.handle(req, res, next)
          return
        }
      } catch (e) {
        error = e
      }

      next(error)
    }

    next()
  }
  
  return app
}

连上注释,我们只用了 82 行代码,就实现了 connect 的主要功能。

总结

本文带大家实现了轻量级 Web 框架 connect 的主要功能,同样这也是一个简易版本  Express!

实现核心是 2 个函数。

  • app.use(route, fn):用于收集中间件
  • app.handle(res, req):用于消费中间件。主要逻辑位于 next() 函数,这是传递给中间件的 next 参数。每一次接收请求来临时,都由 app.handle 负责处理

而这两个函数之间的桥梁就是 app.stack。

行文最后,给大家留一个思考题。

connect() 实例的真实实现,是支持作为子应用,挂载到父应用之上的,也就是下面的用法。

const connect = require('connect')
const app = connect()
const blogApp = connect()

app.use('/blog', blogApp)
app.listen(3000)

甚至 http.Server 实例也支持挂载。

const connect = require('connect')
const app = connect()

const blog = http.createServer(function(req, res){
  res.end('blog')
})

app.use('/blog', blog)

那是如何实现呢?

大家可以参照 app.use()[4] 函数的源码进行学习。

感谢的你的阅读,再见~

参考资料

[1]connect: https://github.com/senchalabs/connect

[2]在 v4 版本就将 connect 依赖移除了: https://github.com/expressjs/express/compare/3.21.2...4.0.0#diff-7ae45ad102eab3b6d7e7896acd08c427a9b25b346470d7bc6507b6481575d519

[3]connect v3.7.0 版本: https://github.com/senchalabs/connect/blob/3.7.0/index.js

[4]app.use(): https://github.com/senchalabs/connect/blob/3.7.0/index.js#L76

责任编辑:武晓燕 来源: 写代码的宝哥
相关推荐

2022-11-07 18:36:03

组件RPC框架

2017-10-29 21:43:25

人脸识别

2020-12-15 08:58:07

Vue编辑器vue-cli

2022-10-20 11:49:49

JS动画帧,CSS

2017-11-27 15:17:24

PythonMonkey脚本

2022-02-28 12:07:56

RxJS函数式

2014-01-22 09:19:57

JavaScript引擎

2017-07-19 13:27:44

前端Javascript模板引擎

2017-10-27 10:29:35

人脸识别UbuntuPython

2022-06-28 15:29:56

Python编程语言计时器

2024-07-16 11:26:35

微前端代码JS

2022-09-22 12:38:46

antd form组件代码

2017-09-20 12:36:50

云主机网站MySQL

2017-09-20 16:39:31

云主机网站MySQL

2020-05-09 09:59:52

Python数据土星

2023-03-29 10:02:36

2023-01-30 09:27:57

开发自动化配置

2021-06-22 10:43:03

Webpack loader plugin

2021-10-09 13:08:58

C++STLSet

2020-05-21 14:50:48

代码机器学习图像
点赞
收藏

51CTO技术栈公众号