HTTP 事务的解剖

本指南旨在深入理解 Node.js HTTP 处理流程。我们假设您对 HTTP 请求的工作原理有一定的了解,无论使用何种语言或编程环境。我们还假设您对 Node.js 的 EventEmittersStreams 有所了解。如果您不太熟悉它们,建议您快速浏览一下每个 API 文档。

创建服务器

任何 Node.js 网页服务器应用程序都需要在某个时刻创建一个网页服务器对象。这可以通过使用 createServer 来完成。

const http = require('node:http');

const server = http.createServer((request, response) => {
  // magic happens here!
});

传递给 createServer 的函数,每当有 HTTP 请求发送到该服务器时都会被调用一次,因此被称为请求处理程序。实际上,由 Server 对象返回的 createServer 是一个 EventEmitter,而我们这里只是创建 server 对象并稍后添加监听器的简写方式。

const server = http.createServer();
server.on('request', (request, response) => {
  // the same kind of magic happens here!
});

当 HTTP 请求到达服务器时,Node.js 会调用请求处理程序函数,并传递几个用于处理事务的便捷对象,即 requestresponse。我们很快就会讲到它们。

为了实际处理请求,需要在 server 对象上调用 listen 方法。在大多数情况下,您只需要将要监听的端口号传递给 listen 即可。还有一些其他选项,请参考 API 参考

方法、URL 和标头

处理请求时,您可能首先要做的就是查看方法和 URL,以便采取适当的操作。Node.js 通过在 request 对象上添加便捷属性,使这一过程变得相对简单。

const { method, url } = request;

request 对象是 IncomingMessage 的实例。

这里的 method 将始终是标准的 HTTP 方法/动词。url 是完整的 URL,不包括服务器、协议或端口。对于典型的 URL,这意味着从第三个斜杠开始的所有内容,包括第三个斜杠。

请求头也不远。它们在request对象中,名为headers

const { headers } = request;
const userAgent = headers['user-agent'];

需要注意的是,所有请求头都以小写形式表示,无论客户端实际发送的方式如何。这简化了解析请求头的任务。

如果某些请求头重复,则它们的取值会被覆盖或合并为以逗号分隔的字符串,具体取决于请求头。在某些情况下,这可能会造成问题,因此可以使用rawHeaders

请求体

当接收到POSTPUT请求时,请求体可能对您的应用程序很重要。获取请求体数据比访问请求头要复杂一些。传递给处理程序的request对象实现了ReadableStream接口。这个流可以像其他任何流一样被监听或管道到其他地方。我们可以通过监听流的'data''end'事件来获取流中的数据。

在每个'data'事件中发出的块是一个Buffer。如果您知道它将是字符串数据,最好的做法是将数据收集到一个数组中,然后在'end'事件中将它们连接并转换为字符串。

let body = [];
request
  .on('data', chunk => {
    body.push(chunk);
  })
  .on('end', () => {
    body = Buffer.concat(body).toString();
    // at this point, `body` has the entire request body stored in it as a string
  });

这可能看起来有点繁琐,在很多情况下确实如此。幸运的是,像concat-streambody这样的模块在npm上可以帮助隐藏一些逻辑。在走这条路之前,了解正在发生的事情很重要,这就是你在这里的原因!

关于错误的快速说明

由于request对象是一个ReadableStream,它也是一个EventEmitter,并在发生错误时像一个EventEmitter一样工作。

request 流中的错误会通过在流上发出 'error' 事件来表现出来。如果你没有为该事件添加监听器,错误将会被抛出,这可能会导致你的 Node.js 程序崩溃。 因此,你应该在你的请求流上添加一个 'error' 监听器,即使你只是记录它并继续执行你的程序。(虽然最好是发送某种 HTTP 错误响应。我们稍后会详细介绍。)

request.on('error', err => {
  // This prints the error message and stack trace to `stderr`.
  console.error(err.stack);
});

还有其他方法可以处理这些错误,例如其他抽象和工具,但始终要注意,错误可能会发生,而且确实会发生,你必须处理它们。

我们到目前为止所做的事情

到目前为止,我们已经介绍了如何创建服务器,以及如何从请求中获取方法、URL、头信息和主体。将所有这些放在一起,它可能看起来像这样

const http = require('node:http');

http
  .createServer((request, response) => {
    const { headers, method, url } = request;
    let body = [];
    request
      .on('error', err => {
        console.error(err);
      })
      .on('data', chunk => {
        body.push(chunk);
      })
      .on('end', () => {
        body = Buffer.concat(body).toString();
        // At this point, we have the headers, method, url and body, and can now
        // do whatever we need to in order to respond to this request.
      });
  })
  .listen(8080); // Activates this server, listening on port 8080.

如果我们运行这个示例,我们将能够接收请求,但不能响应它们。事实上,如果你在 Web 浏览器中访问这个示例,你的请求会超时,因为没有数据被发送回客户端。

到目前为止,我们还没有接触到 response 对象,它是一个 ServerResponse 的实例,它是一个 WritableStream。它包含许多用于将数据发送回客户端的有用方法。我们将在下一节中介绍。

HTTP 状态码

如果你不设置它,响应中的 HTTP 状态码将始终为 200。当然,并非所有 HTTP 响应都值得此状态码,并且在某些时候你肯定想发送不同的状态码。为此,你可以设置 statusCode 属性。

response.statusCode = 404; // Tell the client that the resource wasn't found.

我们很快就会看到,还有其他一些快捷方式。

设置响应头信息

头信息通过一个名为 setHeader 的便捷方法设置。

response.setHeader('Content-Type', 'application/json');
response.setHeader('X-Powered-By', 'bacon');

在设置响应头信息时,它们的名称不区分大小写。如果你重复设置头信息,你最后设置的值就是发送的值。

显式发送头信息数据

我们已经讨论过的设置头信息和状态码的方法假设你使用的是“隐式头信息”。这意味着你依赖于 node 在你开始发送主体数据之前,在正确的时间为你发送头信息。

如果你愿意,你可以显式地将头信息写入响应流。为此,有一个名为 writeHead 的方法,它将状态码和头信息写入流。

response.writeHead(200, {
  'Content-Type': 'application/json',
  'X-Powered-By': 'bacon',
});

一旦你设置了头信息(无论是隐式还是显式),你就可以开始发送响应数据了。

发送响应主体

由于response对象是一个WritableStream,将响应主体写入客户端只是使用通常的流方法的问题。

response.write('<html>');
response.write('<body>');
response.write('<h1>Hello, World!</h1>');
response.write('</body>');
response.write('</html>');
response.end();

流上的end函数也可以接收一些可选数据作为流的最后一段数据发送,因此我们可以简化上面的示例。

response.end('<html><body><h1>Hello, World!</h1></body></html>');

在开始将数据块写入主体之前设置状态和标头非常重要。这是有道理的,因为标头在 HTTP 响应中位于主体之前。

关于错误的另一件快速事

response流也可以发出'error'事件,并且在某个时候你将不得不处理它。所有关于request流错误的建议在这里仍然适用。

将所有内容放在一起

现在我们已经了解了如何制作 HTTP 响应,让我们将所有内容放在一起。在前面的示例的基础上,我们将创建一个服务器,它将用户发送给我们的所有数据发送回来。我们将使用JSON.stringify将该数据格式化为 JSON。

const http = require('node:http');

http
  .createServer((request, response) => {
    const { headers, method, url } = request;
    let body = [];
    request
      .on('error', err => {
        console.error(err);
      })
      .on('data', chunk => {
        body.push(chunk);
      })
      .on('end', () => {
        body = Buffer.concat(body).toString();
        // BEGINNING OF NEW STUFF

        response.on('error', err => {
          console.error(err);
        });

        response.statusCode = 200;
        response.setHeader('Content-Type', 'application/json');
        // Note: the 2 lines above could be replaced with this next one:
        // response.writeHead(200, {'Content-Type': 'application/json'})

        const responseBody = { headers, method, url, body };

        response.write(JSON.stringify(responseBody));
        response.end();
        // Note: the 2 lines above could be replaced with this next one:
        // response.end(JSON.stringify(responseBody))

        // END OF NEW STUFF
      });
  })
  .listen(8080);

回声服务器示例

让我们简化前面的示例,创建一个简单的回声服务器,它只是将请求中接收到的任何数据直接发送回响应中。我们只需要从请求流中获取数据并将该数据写入响应流,类似于我们之前所做的。

const http = require('node:http');

http
  .createServer((request, response) => {
    let body = [];
    request
      .on('data', chunk => {
        body.push(chunk);
      })
      .on('end', () => {
        body = Buffer.concat(body).toString();
        response.end(body);
      });
  })
  .listen(8080);

现在让我们调整一下。我们希望仅在以下情况下发送回声

  • 请求方法是 POST。
  • URL 是/echo

在任何其他情况下,我们只想简单地以 404 响应。

const http = require('node:http');

http
  .createServer((request, response) => {
    if (request.method === 'POST' && request.url === '/echo') {
      let body = [];
      request
        .on('data', chunk => {
          body.push(chunk);
        })
        .on('end', () => {
          body = Buffer.concat(body).toString();
          response.end(body);
        });
    } else {
      response.statusCode = 404;
      response.end();
    }
  })
  .listen(8080);

通过这种方式检查 URL,我们正在进行一种形式的“路由”。其他形式的路由可以像switch语句一样简单,也可以像express这样的完整框架一样复杂。如果你正在寻找只做路由而不做其他事情的东西,试试router.

太棒了!现在让我们尝试简化它。请记住,request对象是一个ReadableStream,而response对象是一个WritableStream。这意味着我们可以使用pipe将数据从一个流定向到另一个流。这正是我们对回声服务器的要求!

const http = require('node:http');

http
  .createServer((request, response) => {
    if (request.method === 'POST' && request.url === '/echo') {
      request.pipe(response);
    } else {
      response.statusCode = 404;
      response.end();
    }
  })
  .listen(8080);

万岁流!

不过我们还没有完全完成。正如本指南中多次提到的,错误可能发生,并且确实会发生,我们需要处理它们。

为了处理请求流上的错误,我们将错误记录到stderr并发送 400 状态代码以指示Bad Request。然而,在实际应用中,我们希望检查错误以确定正确的状态代码和消息。与往常一样,遇到错误时,你应该查阅Error 文档.

在响应中,我们只需将错误记录到stderr

const http = require('node:http');

http
  .createServer((request, response) => {
    request.on('error', err => {
      console.error(err);
      response.statusCode = 400;
      response.end();
    });
    response.on('error', err => {
      console.error(err);
    });
    if (request.method === 'POST' && request.url === '/echo') {
      request.pipe(response);
    } else {
      response.statusCode = 404;
      response.end();
    }
  })
  .listen(8080);

我们现在已经涵盖了处理 HTTP 请求的大部分基础知识。此时,您应该能够

  • 使用请求处理函数实例化 HTTP 服务器,并使其监听某个端口。
  • request对象中获取标头、URL、方法和主体数据。
  • 根据request对象中的 URL 和/或其他数据进行路由决策。
  • 通过response对象发送标头、HTTP 状态代码和主体数据。
  • 将数据从request对象管道传输到response对象。
  • 处理requestresponse流中的流错误。

从这些基础知识出发,可以构建用于许多典型用例的 Node.js HTTP 服务器。这些 API 提供了大量其他功能,因此请务必阅读有关EventEmittersStreamsHTTP的 API 文档。