MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Node.js HTTP 请求响应的生命周期解析

2022-05-275.4k 阅读

Node.js HTTP 请求响应基础

在深入解析 Node.js HTTP 请求响应的生命周期之前,我们先来回顾一下基础概念。HTTP(Hyper - Text Transfer Protocol)是一种用于分布式、协作式和超媒体信息系统的应用层协议。在 Web 开发中,它是客户端(如浏览器)与服务器之间通信的主要方式。

Node.js 提供了内置的 http 模块,使得创建 HTTP 服务器变得相对简单。通过 http.createServer() 方法,我们可以快速搭建一个基本的 HTTP 服务器。以下是一个简单的示例:

const http = require('http');

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

const port = 3000;
server.listen(port, () => {
    console.log(`Server running on port ${port}`);
});

在上述代码中,http.createServer() 接受一个回调函数,这个回调函数会在每次有 HTTP 请求到达服务器时被调用。回调函数的两个参数 reqhttp.IncomingMessage 的实例)和 reshttp.ServerResponse 的实例)分别代表请求和响应。

HTTP 请求的发起

当我们在浏览器中输入一个 URL 或者通过 JavaScript 的 fetchXMLHttpRequest 等方式发起一个 HTTP 请求时,请求就开始了它的旅程。以浏览器为例,浏览器首先会解析 URL,确定协议(如 httphttps)、主机名、端口号(如果未指定,http 默认 80 端口,https 默认 443 端口)以及路径等信息。

然后,浏览器会与服务器建立 TCP 连接(对于 https 还会进行 SSL/TLS 握手以确保安全通信)。一旦连接建立,浏览器会将 HTTP 请求报文发送到服务器。HTTP 请求报文由请求行、请求头、空行和请求体(对于 GET 请求,请求体通常为空)组成。

Node.js 接收请求

在 Node.js 服务器端,http 模块监听指定端口,等待请求到来。当一个请求到达时,http.createServer() 回调函数中的 req 对象就代表了这个请求。

req 对象继承自 stream.Readable,这意味着我们可以像处理可读流一样处理请求数据。例如,如果请求是一个 POST 请求并且包含表单数据,我们可以通过以下方式获取数据:

const http = require('http');

const server = http.createServer((req, res) => {
    let data = '';
    req.on('data', chunk => {
        data += chunk;
    });
    req.on('end', () => {
        console.log('Received data:', data);
        res.writeHead(200, {'Content - Type': 'text/plain'});
        res.end('Data received successfully');
    });
});

const port = 3000;
server.listen(port, () => {
    console.log(`Server running on port ${port}`);
});

在这个例子中,req.on('data') 事件会在有新的数据块可读时触发,req.on('end') 事件则在请求数据全部接收完毕时触发。

请求头的处理

请求头包含了关于请求的各种元信息,例如 User - Agent(客户端信息)、Content - Type(请求体的数据类型)等。在 Node.js 中,我们可以通过 req.headers 对象来访问请求头。

const http = require('http');

const server = http.createServer((req, res) => {
    console.log('Request headers:', req.headers);
    res.writeHead(200, {'Content - Type': 'text/plain'});
    res.end('Headers logged');
});

const port = 3000;
server.listen(port, () => {
    console.log(`Server running on port ${port}`);
});

通过打印 req.headers,我们可以看到类似如下的输出:

{
    "host": "localhost:3000",
    "connection": "keep - alive",
    "upgrade - insecure - requests": "1",
    "user - agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
    "accept": "text/html,application/xhtml+xml,application/xml;q = 0.9,image/avif,image/webp,image/apng,*/*;q = 0.8,application/signed - exchange;v = b3;q = 0.9",
    "sec - fetch - site": "none",
    "sec - fetch - mode": "navigate",
    "sec - fetch - user": "?1",
    "sec - fetch - dest": "document",
    "accept - encoding": "gzip, deflate, br",
    "accept - language": "en - US,en;q = 0.9"
}

我们可以根据这些请求头信息做出不同的响应,比如根据 Accept - Language 头来返回不同语言的内容。

请求方法的判断

HTTP 定义了多种请求方法,如 GETPOSTPUTDELETE 等。在 Node.js 中,我们可以通过 req.method 属性来获取当前请求的方法。

const http = require('http');

const server = http.createServer((req, res) => {
    if (req.method === 'GET') {
        res.writeHead(200, {'Content - Type': 'text/plain'});
        res.end('This is a GET request');
    } else if (req.method === 'POST') {
        res.writeHead(200, {'Content - Type': 'text/plain'});
        res.end('This is a POST request');
    } else {
        res.writeHead(405, {'Content - Type': 'text/plain'});
        res.end('Method Not Allowed');
    }
});

const port = 3000;
server.listen(port, () => {
    console.log(`Server running on port ${port}`);
});

这样,我们可以根据不同的请求方法执行不同的业务逻辑。

URL 解析

在处理请求时,解析 URL 是很重要的一步。Node.js 提供了 url 模块来解析 URL。req.url 包含了请求的完整路径(包括查询字符串)。

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

const server = http.createServer((req, res) => {
    const parsedUrl = url.parse(req.url, true);
    const pathname = parsedUrl.pathname;
    const query = parsedUrl.query;
    console.log('Pathname:', pathname);
    console.log('Query:', query);
    res.writeHead(200, {'Content - Type': 'text/plain'});
    res.end(`Pathname: ${pathname}, Query: ${JSON.stringify(query)}`);
});

const port = 3000;
server.listen(port, () => {
    console.log(`Server running on port ${port}`);
});

如果请求的 URL 是 http://localhost:3000/users?name=John&age=30,那么 pathname 会是 /usersquery 会是 { name: 'John', age: '30' }

响应的准备

一旦服务器处理完请求,就需要准备响应。在 Node.js 中,res 对象(http.ServerResponse 的实例)用于构建和发送响应。

首先,我们需要设置响应头。通过 res.writeHead() 方法可以设置响应状态码和响应头。例如,要设置一个 200 OK 的响应,并指定内容类型为 application/json,可以这样写:

res.writeHead(200, {'Content - Type': 'application/json'});

状态码是 HTTP 响应的重要组成部分,常见的状态码有 200(成功)、404(未找到)、500(服务器内部错误)等。不同的状态码表示不同的响应结果,客户端可以根据状态码做出相应的处理。

响应体的发送

设置好响应头后,就可以发送响应体了。res 对象同样继承自 stream.Writable,我们可以通过 res.write() 方法逐步写入响应数据,最后通过 res.end() 方法结束响应并发送数据。

const http = require('http');

const server = http.createServer((req, res) => {
    res.writeHead(200, {'Content - Type': 'text/plain'});
    res.write('This is the first part of the response. ');
    res.write('This is the second part. ');
    res.end('This is the end of the response.');
});

const port = 3000;
server.listen(port, () => {
    console.log(`Server running on port ${port}`);
});

如果响应数据是一个对象,并且我们设置了 Content - Typeapplication/json,我们需要先将对象转换为 JSON 字符串:

const http = require('http');

const server = http.createServer((req, res) => {
    const data = { message: 'Hello from server' };
    res.writeHead(200, {'Content - Type': 'application/json'});
    res.end(JSON.stringify(data));
});

const port = 3000;
server.listen(port, () => {
    console.log(`Server running on port ${port}`);
});

错误处理

在 HTTP 请求响应的生命周期中,错误处理至关重要。可能会出现各种错误,比如请求方法不被允许、请求数据解析错误、服务器内部错误等。

对于请求方法不被允许的情况,我们已经在前面展示了如何返回 405 Method Not Allowed 的响应。而对于服务器内部错误,通常我们会返回 500 Internal Server Error 响应。

const http = require('http');

const server = http.createServer((req, res) => {
    try {
        // 模拟可能出错的操作
        throw new Error('Internal server error');
        res.writeHead(200, {'Content - Type': 'text/plain'});
        res.end('Success');
    } catch (error) {
        console.error('Error:', error);
        res.writeHead(500, {'Content - Type': 'text/plain'});
        res.end('Internal Server Error');
    }
});

const port = 3000;
server.listen(port, () => {
    console.log(`Server running on port ${port}`);
});

在实际应用中,我们可能会记录错误日志,以便后续排查问题。例如,可以使用 winston 等日志库。

连接的关闭

当响应发送完成后,HTTP 连接的生命周期就接近尾声了。在 HTTP/1.1 中,默认情况下连接是持久化的,即可以在同一个连接上发送多个请求和响应,以减少建立连接的开销。

然而,在某些情况下,我们可能需要主动关闭连接。在 Node.js 中,res 对象有一个 destroy() 方法可以用于关闭连接。例如,当检测到恶意请求或者资源耗尽等情况时,可以调用 res.destroy() 来立即关闭连接。

const http = require('http');

const server = http.createServer((req, res) => {
    // 模拟检测到恶意请求
    if (req.headers['user - agent'].includes('malicious - bot')) {
        res.destroy();
        return;
    }
    res.writeHead(200, {'Content - Type': 'text/plain'});
    res.end('Normal response');
});

const port = 3000;
server.listen(port, () => {
    console.log(`Server running on port ${port}`);
});

另外,server.close() 方法可以用于关闭整个 HTTP 服务器,停止监听指定端口。

中间件的作用

在现代 Node.js 开发中,中间件起着非常重要的作用。中间件是一个函数,它可以对请求和响应进行处理,并且可以将控制权传递给下一个中间件。

例如,Express.js 是一个流行的 Node.js Web 框架,它广泛使用中间件。我们可以创建一个简单的中间件来记录每个请求的时间:

const http = require('http');

const loggerMiddleware = (req, res, next) => {
    console.log('Request received at', new Date().toISOString());
    next();
};

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

const port = 3000;
server.listen(port, () => {
    console.log(`Server running on port ${port}`);
});

在 Express 中,中间件的使用更加直观:

const express = require('express');
const app = express();

app.use((req, res, next) => {
    console.log('Request received at', new Date().toISOString());
    next();
});

app.get('/', (req, res) => {
    res.send('Response');
});

const port = 3000;
app.listen(port, () => {
    console.log(`Server running on port ${port}`);
});

中间件可以用于各种目的,如日志记录、身份验证、数据验证等,它使得代码更加模块化和可维护。

与其他模块的交互

在实际开发中,HTTP 请求响应通常会与其他 Node.js 模块交互。例如,可能会与数据库模块交互来查询或存储数据,与文件系统模块交互来读取或写入文件等。

假设我们有一个简单的任务,需要从文件中读取内容并返回给客户端:

const http = require('http');
const fs = require('fs');

const server = http.createServer((req, res) => {
    fs.readFile('example.txt', 'utf8', (err, data) => {
        if (err) {
            res.writeHead(500, {'Content - Type': 'text/plain'});
            res.end('Error reading file');
        } else {
            res.writeHead(200, {'Content - Type': 'text/plain'});
            res.end(data);
        }
    });
});

const port = 3000;
server.listen(port, () => {
    console.log(`Server running on port ${port}`);
});

如果是与数据库交互,比如使用 mongoose 操作 MongoDB:

const express = require('express');
const mongoose = require('mongoose');
const app = express();

mongoose.connect('mongodb://localhost:27017/mydb', { useNewUrlParser: true, useUnifiedTopology: true });

const User = mongoose.model('User', { name: String, age: Number });

app.get('/users', async (req, res) => {
    try {
        const users = await User.find();
        res.json(users);
    } catch (error) {
        res.status(500).send('Error fetching users');
    }
});

const port = 3000;
app.listen(port, () => {
    console.log(`Server running on port ${port}`);
});

这种与其他模块的交互丰富了 HTTP 请求响应的功能,使得我们可以构建复杂的 Web 应用。

性能优化

在处理大量 HTTP 请求时,性能优化是关键。一些常见的优化策略包括:

缓存

可以使用内存缓存(如 node - cache)来缓存经常访问的数据。例如,如果某些 API 响应数据不经常变化,可以将其缓存起来,下次请求时直接从缓存中返回,减少数据库查询或其他昂贵的操作。

const NodeCache = require('node - cache');
const http = require('http');
const cache = new NodeCache();

const server = http.createServer(async (req, res) => {
    const cachedData = cache.get('myCachedData');
    if (cachedData) {
        res.writeHead(200, {'Content - Type': 'application/json'});
        res.end(JSON.stringify(cachedData));
    } else {
        // 模拟从数据库或其他数据源获取数据
        const newData = await fetchData();
        cache.set('myCachedData', newData);
        res.writeHead(200, {'Content - Type': 'application/json'});
        res.end(JSON.stringify(newData));
    }
});

async function fetchData() {
    // 实际的获取数据逻辑
    return { message: 'Data from source' };
}

const port = 3000;
server.listen(port, () => {
    console.log(`Server running on port ${port}`);
});

异步操作优化

Node.js 本身是基于事件驱动和异步 I/O 的,但在编写代码时仍需注意优化异步操作。避免不必要的同步阻塞,合理使用 async/await 或 Promises 来管理异步流程。例如,当有多个异步操作需要依次执行时,使用 async/await 可以使代码更易读:

const http = require('http');

async function asyncTask1() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('Task 1 completed');
        }, 1000);
    });
}

async function asyncTask2() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('Task 2 completed');
        }, 1000);
    });
}

const server = http.createServer(async (req, res) => {
    try {
        const result1 = await asyncTask1();
        const result2 = await asyncTask2();
        res.writeHead(200, {'Content - Type': 'text/plain'});
        res.end(`${result1}, ${result2}`);
    } catch (error) {
        res.writeHead(500, {'Content - Type': 'text/plain'});
        res.end('Error');
    }
});

const port = 3000;
server.listen(port, () => {
    console.log(`Server running on port ${port}`);
});

负载均衡

对于高流量的应用,负载均衡是必要的。可以使用 cluster 模块在多核 CPU 上创建多个工作进程,以充分利用系统资源。例如:

const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
    console.log(`Master ${process.pid} is running`);
    for (let i = 0; i < numCPUs; i++) {
        cluster.fork();
    }

    cluster.on('exit', (worker, code, signal) => {
        console.log(`worker ${worker.process.pid} died`);
    });
} else {
    const server = http.createServer((req, res) => {
        res.writeHead(200, {'Content - Type': 'text/plain'});
        res.end('Hello from worker');
    });

    const port = 3000;
    server.listen(port, () => {
        console.log(`Worker ${process.pid} listening on port ${port}`);
    });
}

这样,每个工作进程可以独立处理请求,提高了服务器的整体性能。

HTTP/2 和 HTTP/3 的影响

随着 HTTP/2 和 HTTP/3 的发展,Node.js 也需要适应这些新协议带来的变化。HTTP/2 引入了多路复用、头部压缩等特性,提高了性能和效率。Node.js 从 v8.4.0 开始支持 HTTP/2。

要在 Node.js 中使用 HTTP/2,可以使用 http2 模块。以下是一个简单的示例:

const http2 = require('http2');
const server = http2.createServer();

server.on('stream', (stream, headers) => {
    stream.respond({
        'content - type': 'text/plain',
        ':status': 200
    });
    stream.end('Hello from HTTP/2');
});

const port = 8080;
server.listen(port, () => {
    console.log(`Server running on port ${port}`);
});

HTTP/3 则基于 UDP 协议,进一步优化了传输性能,特别是在高延迟和不稳定的网络环境下。虽然目前 Node.js 对 HTTP/3 的支持还在不断发展中,但关注这些新协议的特性并适时应用,可以提升应用的性能和用户体验。

在处理 HTTP 请求响应的生命周期时,我们需要不断关注这些协议的发展,以确保我们的应用始终保持高效和竞争力。

安全考虑

在 HTTP 请求响应的过程中,安全是不容忽视的。一些常见的安全问题包括:

跨站脚本攻击(XSS)

XSS 攻击是指攻击者在网页中注入恶意脚本,当用户访问该网页时,脚本会在用户浏览器中执行,从而窃取用户数据或进行其他恶意操作。在 Node.js 应用中,当向客户端返回数据时,要对用户输入进行严格的过滤和转义。例如,使用 DOMPurify 库来清理 HTML 输入:

const http = require('http');
const DOMPurify = require('dompurify');

const server = http.createServer((req, res) => {
    let data = '';
    req.on('data', chunk => {
        data += chunk;
    });
    req.on('end', () => {
        const cleanData = DOMPurify.sanitize(data);
        res.writeHead(200, {'Content - Type': 'text/plain'});
        res.end(`Cleaned data: ${cleanData}`);
    });
});

const port = 3000;
server.listen(port, () => {
    console.log(`Server running on port ${port}`);
});

跨站请求伪造(CSRF)

CSRF 攻击是攻击者诱导用户访问一个包含恶意请求的链接,利用用户已登录的身份在用户不知情的情况下执行操作。为了防范 CSRF 攻击,可以使用 CSRF 令牌。在服务器生成一个唯一的令牌,发送给客户端并存储在用户会话中。当客户端发起请求时,将令牌包含在请求中,服务器验证令牌的有效性。

SQL 注入

如果应用与数据库交互,SQL 注入是一个严重的安全隐患。例如,在使用 SQL 数据库时,不使用参数化查询而直接拼接 SQL 语句可能导致 SQL 注入。使用 Node.js 的数据库驱动时,应始终使用参数化查询。例如,在使用 mysql2 库时:

const mysql = require('mysql2');
const connection = mysql.createConnection({
    host: 'localhost',
    user: 'root',
    password: 'password',
    database: 'test'
});

const username = 'test';
const query = 'SELECT * FROM users WHERE username =?';
connection.query(query, [username], (err, results, fields) => {
    if (err) throw err;
    console.log(results);
});

connection.end();

通过使用参数化查询,数据库驱动会自动处理参数的转义,防止 SQL 注入攻击。

总结

Node.js 的 HTTP 请求响应生命周期涵盖了从请求发起、服务器接收处理到响应返回的一系列复杂过程。了解这个生命周期的各个环节,包括请求的解析、响应的构建、错误处理、性能优化和安全考虑等,对于开发高效、稳定和安全的 Web 应用至关重要。同时,随着 HTTP 协议的不断发展,如 HTTP/2 和 HTTP/3 的出现,我们需要不断跟进和适应这些变化,以提供更好的用户体验。通过合理运用中间件、与其他模块的交互以及各种优化策略,我们可以充分发挥 Node.js 在 HTTP 服务端开发中的优势。