【翻译】让我们从零开始变编写一个web服务器

原文链接:Let’s code a web server from scratch with NodeJS Streams!

作者:Ziad Saab

在这篇文章中,我将通过 node.js 的 stream 模块从零开始搭建一个简单的 web 服务器。在完成的过程中,我们会回顾 HTTP 请求和响应的结构,并且会对 Node’s Stream API 做一个简单介绍。

首先,让我们快速回顾一下 Node.js 内置的 http 模块,通过它,我们会学习到一个 HTTP 请求和响应的大致结构。然后使用 Node.js 的 net 模块,创建一个简单的 TCP 服务器,并尝试通过它建立一个可用的 web 服务器。

Node.js 内置的 http 模块

Node.js 自带一个简单的 http 服务器。这个服务器允许我们去监听任意端口,并且对于每个 http 请求提供了一个回调函数去处理。

这个回调函数接受两个参数:request 对象response 对象,request 对象包含 http 请求的相关信息,response 对象是我们要返回给客户端的内容。

Node.js http 服务器使用案例:

const http = require('http');
const server = http.createServer();
server.on('request', (req, res) => {
  console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
  res.setHeader('Content-Type','text/plain');
  res.end('Hello World!');
});
server.listen(3000);

上面代码将会启动一个 web 服务器监听 3000 端口。当接收到一个 http 请求,会打印一段信息:当前的日期和请求的 method、url
这些信息来源于 req 参数,它是 Node.js 创建的 request 对象,request 对象还有一个 .socket 属性,是一个简易的 TCP socket

接下来我们会创建一个 tcp 服务器,并且对于每一个新的连接都会直接访问 socket,然后通过它来创造我们的 web 服务器。
在上面的代码里,我们访问了 req.methodreq.url,但是我们可能还会想要解析 request 其他内容。

接下里这一节,我们先看看 HTTP 请求的结构,方便我们去解析它。

HTTP 解析

下面是一个简单的 http 请求:

POST /posts/42/comments HTTP/1.1\r\n
Host: www.my-api.com\r\n
Accept: application/json\r\n
Authorization: Bearer N2E5NTU2MzQ5MGQ4N2UzNjIxOTY2ZDU1M2YwNjA3OGFjYjgyMjU4NQ\r\n
Accept-Encoding: gzip, deflate, br\r\n
Content-Type: application/json\r\n
Content-Length: 28\r\n
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:58.0)\r\n
\r\n
{"text":"this is a comment"}

需要了解的点:

  • 每一行都使用 \r\n 分割开
  • 第一行被称作请求行,由三个部分组成
    • 请求的方法, POST.标准的请求方法
    • 请求的 url:/posts/42/comments。
    • http 协议版本:HTTP/1.1。
  • 下面每一行都被称作一个请求头,由一个字段和它的值组成,通过 : 分开,标准的请求头
  • 有一行只有 \r\n,这一行标志着请求头的结束,在这之后的内容都是请求体。
  • 这个请求中,请求体是一个 JSON 对象,由 Content-Type 定义,这个 JSON 对象的长度是 28 字节,由 Content-Length 定义。

下面是一个 http 响应:

HTTP/1.1 200 OK\r\n
Server: nginx/1.9.4\r\n
Date: Fri, 20 Apr 2017 16:19:42 GMT\r\n
Content-Type: application/json\r\n
Content-Length: 141\r\n
\r\n
{
  "id": "8fh924b42o",
  "text": "this is a comment",
  "createdAt": "2017-04-20T16:19:42.840Z",
  "updatedAt": "2017-04-20T16:19:42.840Z"
}

同样的:

  • 就像 request,每一行由 \r\n 分开。
  • 第一行称作状态栏,组成:
  • 接下来的每一行都是一个响应头,和请求头的结构一样。
  • 有一行仅包含 \r\n,标志响应头的结束和响应体的开始。
  • 请求体是一个 114 字节的 JSON 对象,都在响应头中定义。

接下来的一节,我们会创建一个 TCP 服务器让我们能够监听到 HTTP 请求,我们将会一步步的获取并且解析这个请求,然后通过 Streams 流返回一个响应。

接受并且解析一个 HTTP 请求

Node.js 提供了内置的 net 模块创建一个流式的 tcp 服务器。“流式” 指的是服务器可以通过 Nodejs Stream API 发送和接收数据。

http 服务器会在触发 request 事件时给回调函数绑定一个 request 对象和 response 对象,使用 net 模块创建的 tcp 服务器则会绑定一个 Socket 对象给 connection 事件回调函数,简而言之,这意味着它们都可以对数据进行读写。

如果我们发送一个 HTTP 请求到我们的 TCP 服务器,读取 socket,我们会得到请求的内容,有两种方式可以读取一个可读的 stream:监听 data 事件或者直接调用 .read() 方法。让我们看看第一种方式:

const net = require('net');
const server = net.createServer();
server.on('connection', handleConnection);
server.listen(3000);

function handleConnection(socket) {
  socket.on('data', (chunk) => {
    console.log('Received chunk:\n', chunk.toString());
  });
  socket.write('HTTP/1.1 200 OK\r\nServer: my-web-server\r\nContent-Length: 0\r\n\r\n');
}

将这段代码放到一个叫 server.js 的文件中,通过你的命令行执行它: node server.js,然后在另一个命令行中,通过 curl 请求这个服务器:

curl -v localhost:3000/some/url

你会看到一个很长的输出,展示你的请求头和响应头。在 node 命令行中,你会看到下面的内容:

Received chunk:
GET /some/url HTTP/1.1
Host: localhost:3000
User-Agent: curl/7.54.0
Accept: */*

通过 post 请求发送一些数据:

curl -v -XPOST -d'hello=world' localhost:3000/some/url

在 node 命令行中接收到这些输出:

Received chunk:
POST /some/url HTTP/1.1
Host: localhost:3000
User-Agent: curl/7.54.0
Accept: */*
Content-Length: 11
Content-Type: application/x-www-form-urlencoded

hello=world

如果你发送一个更长的请求体,你应该通过多个 chunks 去接收它,但是一个 web server 并不总是需要去解析一个请求体。

很多时候,请求头部分已经足够去处理这个请求了。如果我们要编写一个通用的 web server,我们需要一种方式去停止从 socket 中接收 \r\n 以后的 request 数据。

如果我们需要控制什么时候停止从 Stream 中拿取数据,我们可以使用 socket.read() 方法,下面是一些代码:

const net = require('net');
const server = net.createServer();
server.on('connection', handleConnection);
server.listen(3000);

function handleConnection(socket) {
  // 监听一次 readable 事件,回调函数中调用 .read()
  socket.once('readable', function() {
    // 声明一个 buffer 存储 request 数据
    let reqBuffer = new Buffer('');
    // 声明一个临时的 buffer 从 socket 中读取数据
    let buf;
    let reqHeader;
    while(true) {
      // 从 socket 中读取数据
      buf = socket.read();
      // 如果没有数据可再次读取,结束循环
      if (buf === null) break;

      // 将新数据存入 reqBuffer 中
      reqBuffer = Buffer.concat([reqBuffer, buf]);

      // 检查是否读取到 \r\n\r\n (请求头的结束)
      let marker = reqBuffer.indexOf('\r\n\r\n')
      if (marker !== -1) {
        // 如果读取到 \ r\n\r\n,获取它之后的额外数据
        let remaining = reqBuffer.slice(marker + 4);
        // 请求头是我们读取到所有数据,在 \ r\n\r\n 之前,不包括 \r\n
        reqHeader = reqBuffer.slice(0, marker).toString();
        // 将额外的数据放回到 socket 中
        socket.unshift(remaining);
        break;
      }
    }
    console.log(`Request header:\n${reqHeader}`);

    // 此时,我们停止从 socket 中读取数据,且获取到了请求头字符串
    // 如果我们还想要获取请求体,我们可以这样做:

    reqBuffer = new Buffer('');
    while((buf = socket.read()) !== null) {
      reqBuffer = Buffer.concat([reqBuffer, buf]);
    }
    let reqBody = reqBuffer.toString();
    console.log(`Request body:\n${reqBody}`);

    // 返回一个响应
    socket.end('HTTP/1.1 200 OK\r\nServer: my-custom-server\r\nContent-Length: 0\r\n\r\n');
  });
}

代码有点长,因为我们需要一些逻辑去决定是否停止从 stream 中读取数据。我们将请求头和请求体分开,让使用我们的 web server 的开发者去决定如何对请求操作。

关键的部分是 socket.unshift,往这个可读流中放回额外的数据。它让使用我们 web server 的用户可以继续获取请求体的数据从 socket 中。

下面是我们这个简单的 web server 完整的代码,将前面的所有内容合并在一起。通过函数 createWebServer(requestHandler) 暴露出我们的服务器,这个函数接收一个处理器,例如:(req, res) => void,就像 Node 的 web 服务器,代码中的注释解释了每一个步骤的内容:

const net = require('net');

function createWebServer(requestHandler) {
  const server = net.createServer();
  server.on('connection', handleConnection);

  function handleConnection(socket) {
    // 监听一次 readable 事件,调用. read()
    socket.once('readable', function() {
      // 声明一个 buffer 存储 request 数据
      let reqBuffer = new Buffer('');
      // 声明一个临时的 buffer 从 socket 中读取数据
      let buf;
      let reqHeader;
      while(true) {
        // 从 socket 中读取数据
        buf = socket.read();
        // 如果没有数据可再次读取,结束循环
        if (buf === null) break;

        // 将新数据存入 reqBuffer 中
        reqBuffer = Buffer.concat([reqBuffer, buf]);

        // 检查是否读取到 \r\n\r\n (请求头的结束)
        let marker = reqBuffer.indexOf('\r\n\r\n')
        if (marker !== -1) {
          // 如果读取到 \ r\n\r\n,获取它之后的额外数据
          let remaining = reqBuffer.slice(marker + 4);
          // 请求头是我们读取到所有数据,在 \ r\n\r\n 之前,不包括 \r\n
          reqHeader = reqBuffer.slice(0, marker).toString();
          // 将额外的数据放回到 socket 中
          socket.unshift(remaining);
          break;
        }
      }

      /* 请求相关业务 */
      // 开始解析请求头
      const reqHeaders = reqHeader.split('\r\n');
      // 第一行比较特殊
      const reqLine = reqHeaders.shift().split(' ');
      // 接下来的每一行都是一个请求头,创建一个对象存储它
      const headers = reqHeaders.reduce((acc, currentHeader) => {
        const [key, value] = currentHeader.split(':');
        return {
          ...acc,
          [key.trim().toLowerCase()]: value.trim()
        };
      }, {});

      // 这个对象会被传入请求处理函数中
      const request = {
        method: reqLine[0],
        url: reqLine[1],
        httpVersion: reqLine[2].split('/')[1],
        headers,
        // 使用这个 web 服务器的用户可以直接从 socket 中获取请求体数据
        socket
      };

      /* 响应相关业务 */
      // 初始化值
      let status = 200, statusText = 'OK', headersSent = false, isChunked = false;
      const responseHeaders = {
        server: 'my-custom-server'
      };

      function setHeader(key, value) {
        responseHeaders[key.toLowerCase()] = value;
      }

      function sendHeaders() {
        // 只触发一次
        if (!headersSent) {
          headersSent = true;
          // 添加 date 响应头
          setHeader('date', new Date().toGMTString());
          // 将状态栏存入 socket 中
          socket.write(`HTTP/1.1 ${status} ${statusText}\r\n`);
          // 将所有的响应头存入 socket 中
          Object.keys(responseHeaders).forEach(headerKey => {
            socket.write(`${headerKey}: ${responseHeaders[headerKey]}\r\n`);
          });
          // 存入 \r\n 在 socket 中分割响应头和响应体
          socket.write('\r\n');
        }
     }

     // response 对象
      const response = {
        write(chunk) {
          if (!headersSent) {
            // 如果没有 content-length 响应头,定义 Transfer-Encoding 响应头为 chunked
            if (!responseHeaders['content-length']) {
              isChunked = true;
              setHeader('transfer-encoding', 'chunked');
            }
            sendHeaders();
          }
          if (isChunked) {
            const size = chunk.length.toString(16);
            socket.write(`${size}\r\n`);
            socket.write(chunk);
            socket.write('\r\n');
          }
          else {
            socket.write(chunk);
          }
        },
        end(chunk) {
            if (!headersSent) {
                // 设置响应的 content-length
                if (!responseHeaders['content-length']) {
                    // 假设 chunk 是一个 buffer,不是一个字符串!
                    setHeader('content-length', chunk ? chunk.length : 0);
                }
                sendHeaders();
            }

            if (isChunked) {
                if (chunk) {
                const size = (chunk.length).toString(16);
                socket.write(`${size}\r\n`);
                socket.write(chunk);
                socket.write('\r\n');
                }
                socket.end('0\r\n\r\n');
            }
            else {
                socket.end(chunk);
            }
        },
        setHeader,
        setStatus(newStatus, newStatusText) { status = newStatus, statusText = newStatusText },
        // 通过服务器发送 JSON
        json(data) {
            if (headersSent) {
            throw new Error('Headers sent, cannot proceed to send JSON');
          }
          const json = new Buffer(JSON.stringify(data));
          setHeader('content-type', 'application/json; charset=utf-8');
          setHeader('content-length', json.length);
          sendHeaders();
          socket.end(json);
        }
      };

      // 传递 request 和 response 给处理器函数
      requestHandler(request, response);
    });
  }

  return {
    listen: (port) => server.listen(port)
  };
}

const webServer = createWebServer((req, res) => {
  // 这是我们最开始使用 http 模块的代码
  console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
  res.setHeader('Content-Type','text/plain');
  res.end('Hello World!');
});

webServer.listen(3000);

该服务器代码不可用于商业生产。它的目的仅仅是帮助对 streamsbuffers 的学习

大部分这里的代码含义应该都很清楚了,我们使用前面章节学习到的规则解析请求,返回响应。

仅仅有一点点新增的内容:Transfer-Encoding: chunked,当我们不能提前知道响应的长度时很有用。你可以 在维基百科上查看它的内容


总结

通过使用简单的模块,例如:net,Stream,Buffer,我们能够基于一个 TCP 服务器创建一个基本的 HTTP 服务器,实现一些解析逻辑。在过程中,我们学习一些内容关于 HTTP 请求和响应,可读写的 streams 和 buffers 的基础知识。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值