如何为 Node.js 构建自己的 Web 框架

2025-05-25

如何为 Node.js 构建自己的 Web 框架

在Twitter上关注我,很高兴接受您对主题或改进的建议/Chris

简而言之:本文教你如何在一定程度上实现 Express 框架。非常适合个人学习,但请勿用于生产环境,除非你在 NPM 安装时遇到空间或带宽问题。希望对你有所帮助。

我写这类文章的目的并非希望人们重复造轮子,而是希望从经验中学习。我敢打赌,如果你搜索 npmjs,你会找到数百个实现,它们或多或少与 Express、Nest、Koa 或 Fastify 等知名框架类似。那么,再创建一个框架又有什么意义呢?这不是浪费时间吗?我不这么认为,因为你可以通过尝试自己实现它学到很多东西。你可以获得一些技能,帮助你应对日常的 Web 开发工作。正如你现在看到的 Matrix 所示,它还能让你更好地应对开源软件 (OSS) 的工作。

 实现 Express 框架

在本文中,我选择尝试实现 Express 框架的一部分。具体是哪些部分呢?

  • 路由,Express 有一种关联特定路由的方法,当路由命中时会运行特定的代码。你还可以根据 HTTP 动词来区分路由。因此,GET to和to/products是不同的POST/products
  • 中间件是一段可以在请求之前或之后运行的代码,甚至可以控制对请求的处理方式。中间件可以检查请求头中是否存在授权令牌,如果有效则返回请求的资源。如果令牌无效,则请求将停止并返回相应的消息。
  • 查询参数是 URL 的末尾部分,可以帮助进一步筛选您希望响应显示的内容。假设 URL 如下所示/products?page=1&pagesize=20,查询参数就是其后发生的所有事情?
  • 使用 Body 发送数据,数据可以从客户端发送到服务器应用。它可以通过 URL 或 body 发送。body 可以包含各种内容,从 JSON 到简单的表单字段,甚至是文件。

一个 Express App 示例

让我们看一下实现 Express 应用程序的几行代码。即使只有几行代码,也发生了很多事情:

const express = require('express')
const app = express();
app.get('/products/:id', (req, res) => {
  res.send(`You sent id ${req.params.id}`)
})

app.listen(3000, () => {
  console.log('Server up and running on port 3000')
})

一个 Vanilla HTTP 应用

我们该如何实现呢?嗯,我们可以使用 HTTP 模块。那么,让我们看一个非常小的实现,来了解一下还缺少什么:

const http = require('http');
const PORT = 3000;

const server = http.createServer((req, res) => {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('hello world');
});

server.listen(PORT, () => {
  console.log(`listening on port ${PORT}`)
})

HTTP 模块仅具有非常基本的路由功能。如果您使用 URL 导航到这样的应用程序,http://localhost:3000/products则将req.url包含/products,并且req.method将包含字符串get。就是这样,这就是您拥有的全部。

实现路由和 HTTP 动词

我们即将实施以下

  • HTTP 动词方法,我们需要诸如get()post()方法。
  • 路由和路由参数,我们需要能够匹配,/products并且我们需要能够从像这样的表达式中分离出路由参数 id /products/:id
  • 查询参数,我们应该能够获取像这样的 URLhttp://localhost:3000/products?page=1&pageSize=20并解析出参数pagepageSize以便于使用。

HTTP 动词方法

让我们创建一个server.js并开始实现我们的服务器,如下所示:

// server.js
const http = require('http')

function myServer() {
  let routeTable = {};
  http.createServer((req, res) => {

  });   

  return {
    get(path, cb) {
      routeTable[path] = { 'get': cb }
    }
  }
}

让我们保留这样的代码并继续实现路由。

解析路由参数

实现起来/products很简单,就是用或不用正则表达式进行字符串比较。id从中取出参数/products/:id稍微有点棘手。一旦我们意识到/product/:id可以重写为 RegEx ,我们就可以用正则表达式来实现/products/:(?<id>\w+)。这就是所谓的命名组,当我们运行该match()方法时,它将返回一个对象,该对象包含一个groups属性,该属性的内容类似于 so ,{ id: '1' }用于路由类似于 so /products/1。让我们展示一下这样的实现:

// url-to-regex.js

function parse(url) {
  let str = "";

  for (var i =0; i < url.length; i++) {
    const c = url.charAt(i);
    if (c === ":") {
      // eat all characters
      let param = "";
      for (var j = i + 1; j < url.length; j++) {
        if (/\w/.test(url.charAt(j))) {
          param += url.charAt(j);
        } else {
          break;
        }
      }
      str += `(?<${param}>\\w+)`;
      i = j -1;
    } else {
      str += c;
    }
  }
  return str;
}

module.exports = parse;

如何使用它:

const parse = require('./url-to-regex');

const regex = parse("/products/:id")).toBe("/products/(?<id>\\w+)");
const match = "/products/114".match(new RegExp(regex);
// match.groups is { id: '114' }     

向服务器添加路由

让我们再次打开我们的server.js文件并添加路线管理部分。

// server.js
const http = require('http')
const parse = require('./regex-from-url')

function myServer() {
  let routeTable = {};
  http.createServer((req, res) => {
    const routes = Object.keys(routeTable);
    let match = false;
    for(var i =0; i < routes.length; i++) {
       const route = routes[i];
       const parsedRoute = parse(route);
       if (
         new RegExp(parsedRoute).test(req.url) &&
         routeTable[route][req.method.toLowerCase()]
       ) {
         let cb = routeTable[route][req.method.toLowerCase()];

         const m = req.url.match(new RegExp(parsedRoute));

         req.params = m.groups;

         cb(req, res);

         match = true;
         break;
       }
    }
    if (!match) {
      res.statusCode = 404;
      res.end("Not found");
    }
  });   

  return {
    get(path, cb) {
      routeTable[path] = { 'get': cb }
    }
  }
}

我们的做法是循环遍历路由字典中的所有路由,直到找到匹配的路由。比较过程如下:

if (
  new RegExp(parsedRoute).test(req.url) &&
  routeTable[route][req.method.toLowerCase()]
)

还要注意路由器参数是如何解析并放置在params属性上的,如下所示:

const m = req.url.match(new RegExp(parsedRoute));
req.params = m.groups;

查询参数

我们已经知道,使用 HTTP 模块,URL 将包含我们的路由,如下所示/products?page=1&pageSize。下一步是挖掘这些参数。这可以通过使用类似正则表达式和以下代码来实现:

// query-params.js

function parse(url) {
  const results = url.match(/\?(?<query>.*)/);
  if (!results) {
    return {};
  }
  const { groups: { query } } = results;

  const pairs = query.match(/(?<param>\w+)=(?<value>\w+)/g);
  const params = pairs.reduce((acc, curr) => {
    const [key, value] = curr.split(("="));
    acc[key] = value;
    return acc;
  }, {});
  return params;
}

module.exports = parse;

现在我们需要把它绑定到服务器代码中。幸运的是,只需要几行代码:

const queryParse = require('./query-params.js')

// the rest omitted for brevity
ress.query = queryParse(req.url);

使用 Body 发送数据

通过识别输入参数的类型,可以读取数据主体req。需要注意的是,数据是以小块(即所谓的“块”)的形式到达的。监听end客户端发出的事件,表示传输已完成,不再发送任何数据。

您可以通过监听事件来监听传入的数据data,如下所示:

req.on('data', (chunk) => {
  // do something
})

req.on('end', () => {
  // no more data
})

为了实现对从客户端传输的数据的监听,我们可以创建以下辅助方法:

function readBody(req) {
    return new Promise((resolve, reject) => {
      let body = "";
      req.on("data", (chunk) => {
        body += "" + chunk;
      });
      req.on("end", () => {
        resolve(body);
      });
      req.on("error", (err) => {
        reject(err);
      });
    });
  }

然后在我们的服务器代码中使用它,如下所示:

res.body = await readBody(req);

此时的完整代码应如下所示:

// server.js

const http = require('http')
const queryParse = require('./query-params.js')
const parse = require('./regex-from-url')

function readBody(req) {
    return new Promise((resolve, reject) => {
      let body = "";
      req.on("data", (chunk) => {
        body += "" + chunk;
      });
      req.on("end", () => {
        resolve(body);
      });
      req.on("error", (err) => {
        reject(err);
      });
    });
  }

function myServer() {
  let routeTable = {};
  http.createServer(async(req, res) => {
    const routes = Object.keys(routeTable);
    let match = false;
    for(var i =0; i < routes.length; i++) {
       const route = routes[i];
       const parsedRoute = parse(route);
       if (
         new RegExp(parsedRoute).test(req.url) &&
         routeTable[route][req.method.toLowerCase()]
       ) {
         let cb = routeTable[route][req.method.toLowerCase()];

         const m = req.url.match(new RegExp(parsedRoute));

         req.params = m.groups;
         req.query = queryParse(req.url);
         req.body = await readBody(req);

         cb(req, res);

         match = true;
         break;
       }
    }
    if (!match) {
      res.statusCode = 404;
      res.end("Not found");
    }
  });   

  return {
    get(path, cb) {
      routeTable[path] = { 'get': cb }
    },
    post(path, cb) {
      routeTable[path] = { 'post': cb }
    }
  }
}

此时您应该能够像这样调用您的代码:

const server = require('./server')
const app = server();

app.get('/products/:id', (req, res) => {
  // for route /products/1, req.params has value  { id: '1' }

})
app.get('/products/', (req, res) => {
  // for route /products?page=1&pageSize=10, req.query has value  { page: '1', pageSize: '10' }
})
app.post('/products/', (req, res) => {
  // req.body should contain whatever you sent across as client
})

响应助手

至此,很多工作都已完成。但是,如何实际将数据返回给客户端呢?由于您正在实现 HTTP 模块,因此res可以使用该参数。通过调用它,end()您可以发送数据。以下是示例:

res.end('some data')

不过,如果你看看 Express 是如何实现的,它有各种各样的辅助函数,比如send()等等。你也可以用几行代码来实现json()html()

function createResponse(res) {
  res.send = (message) => res.end(message);
  res.json = (message) => {
    res.setHeader("Content-Type", "application/json");
    res.end(JSON.stringify(message));
  };
  res.html = (message) => {
    res.setHeader("Content-Type", "text/html");
    res.end(message); 
  }
  return res;
}

并确保将其添加到服务器代码中:

res = createResponse(res);

中间件

有了中间件,我们就可以在请求之前或之后运行代码,甚至可以控制请求本身。请看下面的代码:

server.get("/protected", (req, res, next) => {
  if (req.headers["authorization"] === "abc123") {
    next();
  } else {
    res.statusCode = 401;
    res.send("Not allowed");
  }
 }, (req, res) => {
   res.send("protected route");
 });

第二个参数是中间件。它会检查req.headers属性authorization并检查其值。如果一切正常,则会调用next()。如果异常,则请求在此停止,并res.send()调用 ,并将状态码设置为401(不允许)。

最后一个参数是您希望客户端看到的路由响应,前提是他们向您发送了 ok 标头值。

让我们实现它。在中创建以下函数server.js

function processMiddleware(middleware, req, res) {
  if (!middleware) {
    // resolve false
    return new Promise((resolve) => resolve(true));
  }

  return new Promise((resolve) => {
    middleware(req, res, function () {
      resolve(true);
    });
  });
}

上面的middleware参数被调用了,你可以看到它的最后一个参数是一个解析 Promise 的函数,如下所示:

middleware(req, res, function () {
  resolve(true);
});

为了使服务器代码能够使用它,我们需要采取以下几个步骤:

  1. 确保我们注册了中间件
  2. 当我们有匹配的请求时,获取中间件
  3. 调用中间件

注册中间件

我们需要稍微改变一下注册路由的方式,首先添加这个辅助方法:

function registerPath(path, cb, method, middleware) {
    if (!routeTable[path]) {
      routeTable[path] = {};
    } 
    routeTable[path] = { ...routeTable[path], [method]: cb, [method + "-middleware"]: middleware };
  }

因此尝试注册如下路线:

server.get('/products', (req, res, next) => {}, (req, res) => {})

导致中间件回调被保存在属性上get-middleware

然后,当我们注册路线时,我们会执行以下操作:

return {
    get: (path, ...rest) => {
      if (rest.length === 1) {
        registerPath(path, rest[0] , "get");
      } else {
        registerPath(path, rest[1], "get", rest[0]);
      }
    },

获取中间件的引用

要获取中间件的引用,我们可以使用以下代码:

let middleware = routeTable[route][`${req.method.toLowerCase()}-middleware`]; 

流程中间件

最后,要运行中间件,请编写以下代码:

const result = await processMiddleware(middleware, req, createResponse(res));
if (result) {
  cb(req, res);
} 

概括

完整代码可在此 repo 中找到:

https://github.com/softchris/mini-web

也可以通过 NPM 调用以下命令来使用它:

npm install quarkhttp

讲了这么多,路由、路由参数、查询参数、body 解析和中间件。希望你现在能理解这些内容了。记住,市面上有很多优秀的库供你使用,而且它们都经过了充分的测试。然而,了解这些库的实现方式对理解这些内容确实大有裨益。

文章来源:https://dev.to/itnext/how-you-can-build-your-own-web-framework-for-node-js-19e3
PREV
使用容器和 VS Code 立即设置新的开发环境
NEXT
我真的需要 SPA 框架吗?