返璞归真:从零用 Node.js 原生 `http` 模块构建 Web 服务器

我们都习惯了 npm install express,但你是否想过,Node.js 是如何赤手空拳处理网络请求的?本文将带你回归本源,仅使用 Node.js 内置的 http 模块,从零开始构建一个功能完备、安全可靠的 Web 服务器。

阅读时长: 5 分钟
共 2401字
作者: eimoon.com

我们都习惯了 npm install express 或者 npm install fastify,但你是否想过,在这些强大的框架背后,Node.js 是如何赤手空拳处理网络请求的?有时候,为了一个极简的 API、一个轻量级的微服务,或者仅仅是为了真正理解 HTTP 的工作原理,回归本源是最好的选择。

本文将带你剥开框架的糖衣,仅使用 Node.js 内置的 http 模块,一步步构建一个功能完备的 Web 服务其。你会发现,这不仅不难,而且能让你对 Node.js 的异步、事件驱动和流式处理有更深的理解。

万事开头难?不,一个服务器只需几行代码

让我们从一个最简单的 “Hello World” 服务器开始。它只做一件事:对任何请求,都返回一句纯文本。

创建一个 hello.js 文件:

// 导入 Node.js 内置的 http 模块
const http = require('http');

const host = 'localhost'; // 仅本机可访问
const port = 8000;      // 一个常见的开发端口

// 这是请求监听函数,每个进来的 HTTP 请求都会触发它
const requestListener = function (req, res) {
    // 设置响应头,状态码为 200 (OK)
    res.writeHead(200);
    
    // 发送响应内容并结束响应
    res.end("My first server!");
};

// 用我们的监听函数创建服务器实例
const server = http.createServer(requestListener);

// 启动服务器,开始监听指定端口和地址的连接
server.listen(port, host, () => {
    console.log(`Server is running on http://${host}:${port}`);
});

在终端运行 node hello.js,然后用 curl http://localhost:8000 或在浏览器中访问,你就能看到那句 “My first server!"。

短短十几行代码,一个服务器就跑起来了。http.createServer() 创建了一个服务器对象,server.listen() 则让它开始工作。核心是那个 requestListener 函数,它接收两个关键对象:req (IncomingMessage) 包含了所有请求信息,res (ServerResponse) 则是我们用来构建和发送响应的工具。

内容为王:返回不同类型的数据

Web 世界是多姿多彩的,光有纯文本可不够。服务器需要能返回 JSON、HTML、CSV 等各种格式的数据。这里的关键是正确设置 Content-Type HTTP 响应头,它告诉客户端(比如浏览器)该如何解析收到的数据。

返回 JSON

对于 API 来说,JSON 是事实上的标准。

// file: json-server.js
const http = require('http');

const requestListener = function (req, res) {
    // 关键一步:告诉客户端我们返回的是 JSON
    res.setHeader("Content-Type", "application/json");
    res.writeHead(200);

    const data = { 
        message: "This is a JSON response",
        timestamp: Date.now()
    };
    
    // res.end() 只能接受字符串或 Buffer,所以必须序列化
    res.end(JSON.stringify(data));
};

// ... 服务器创建和监听代码同上
const server = http.createServer(requestListener);
server.listen(8000, 'localhost', () => {
    console.log(`Server is running...`);
});

一个常见的坑res.end() 不接受 JavaScript 对象。你必须用 JSON.stringify() 将其转换为字符串。直接传入对象会导致 TypeError

返回 HTML

同样,返回 HTML 只需要设置 Content-Typetext/html

// file: html-server.js
const requestListener = function (req, res) {
    res.setHeader("Content-Type", "text/html");
    res.writeHead(200);
    res.end(`<html><body><h1>This is HTML</h1></body></html>`);
};
// ...

但把大段 HTML 塞进字符串里显然不是个好主意。更专业的做法是从文件中读取。

从文件系统提供 HTML 页面

我们可以用 Node.js 内置的 fs (File System) 模块来读取文件。

先创建一个 index.html 文件,内容随意。然后在服务器代码中读取它:

// file: html-file-server.js
const http = require('http');
const fs = require('fs').promises; // 使用 promise 版本的 fs

const requestListener = function (req, res) {
    fs.readFile(__dirname + "/index.html")
        .then(contents => {
            res.setHeader("Content-Type", "text/html");
            res.writeHead(200);
            res.end(contents);
        })
        .catch(err => {
            res.writeHead(500);
            res.end('Internal Server Error');
            return;
        });
};
// ...

这种写法有个性能问题:每次请求都会触发一次文件读取操作。对于高并发的服务器,磁盘 I/O 会成为瓶颈。

优化方案:在服务器启动时,只读取一次文件,将内容缓存到内存里。

let indexFile;

// 在启动服务器前预加载文件
fs.readFile(__dirname + "/index.html")
    .then(contents => {
        indexFile = contents;
        server.listen(port, host, () => { /* ... */ });
    })
    .catch(err => {
        console.error(`Could not read index.html file: ${err}`);
        process.exit(1); // 如果主页都加载不了,服务启动也没意义
    });

const requestListener = function (req, res) {
    res.setHeader("Content-Type", "text/html");
    res.writeHead(200);
    res.end(indexFile); // 直接从内存返回
};

这样处理的很好,性能会得到极大提升。

路由:让服务器响应不同路径

一个真正的应用需要处理不同的 URL 路径,比如 /users/products。这就要用到路由。在原生 http 模块里,我们需要自己解析请求的 URL。

const books = JSON.stringify([
    { title: "The Alchemist", author: "Paulo Coelho" },
]);
const authors = JSON.stringify([
    { name: "Paulo Coelho", country: "Brazil" },
]);

const requestListener = function (req, res) {
    res.setHeader("Content-Type", "application/json");

    switch (req.url) {
        case "/books":
            res.writeHead(200);
            res.end(books);
            break;
        case "/authors":
            res.writeHead(200);
            res.end(authors);
            break;
        default:
            res.writeHead(404);
            res.end(JSON.stringify({ error: "Resource not found" }));
    }
}

又一个坑:直接比较 req.url 相当脆弱。如果用户访问 /books?id=1req.url 的值是 /books?id=1,你的 case "/books" 就匹配不上了。

正确的做法:使用 URL API 来解析路径,它能帮你把路径、查询参数等都分离开。

const { URL } = require('url');

const requestListener = function (req, res) {
    const url = new URL(req.url, `http://${req.headers.host}`);
    
    // 只用 pathname 进行路由判断
    switch (url.pathname) {
        // ... case 语句
    }
}

接收数据:处理 POST 请求

处理 POST 请求比 GET 复杂,因为数据在请求体(request body)里,而且是以数据流(Stream)的形式到达的。这意味着数据是分块(chunk)来的,你必须把所有块都收齐了,才能开始解析。

const requestListener = function (req, res) {
    if (req.method === 'POST' && req.url === '/echo') {
        let body = [];
        
        // 监听 'data' 事件,收集数据块
        req.on('data', (chunk) => {
            body.push(chunk);
        });

        // 监听 'end' 事件,表示数据接收完毕
        req.on('end', () => {
            body = Buffer.concat(body).toString();
            
            // 在这里可以解析 body,比如 JSON.parse(body)
            
            res.writeHead(200, { 'Content-Type': 'application/json' });
            res.end(JSON.stringify({ received: body }));
        });

        req.on('error', (err) => {
            console.error(err);
            res.statusCode = 400;
            res.end();
        });

    } else {
        res.statusCode = 404;
        res.end();
    }
};

在生产环境中,你还需要检查 Content-Type,并限制请求体的大小,防止恶意的大 payload 耗尽服务器内存。

走向生产:错误处理与静态文件

健壮的错误处理

一个生产级的服务器必须能优雅地处理错误,而不是直接崩溃。你可以用 try...catch 包裹你的请求处理逻辑,并为 server 对象本身添加错误监听。

const server = http.createServer(async (req, res) => {
    try {
        // 你的路由和业务逻辑...
        if (req.url === '/error') {
            throw new Error('This is a simulated error');
        }
        // ...
    } catch (error) {
        console.error('Unhandled error in request handler', error);
        res.writeHead(500);
        res.end('Internal Server Error');
    }
});

server.on('clientError', (err, socket) => {
    socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
});

安全地提供静态文件

提供 CSS、JS、图片等静态文件时,最大的风险是目录遍历攻击。如果你简单地把用户请求的路径和你的静态文件目录拼接起来,攻击者可能通过 ../ 访问到你服务器上的敏感文件。

一个相对安全的实现如下:

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

const publicDir = path.resolve(__dirname, 'public');

const requestListener = function (req, res) {
    const requestedPath = path.join(publicDir, req.url);

    // 标准化路径,防止 '..' 攻击
    const safePath = path.normalize(requestedPath);
    
    // 确保最终路径仍然在 public 目录下
    if (!safePath.startsWith(publicDir)) {
        res.writeHead(403, { 'Content-Type': 'text/plain' });
        res.end('Forbidden');
        return;
    }
    
    // 使用 stream 提供文件,对大文件更友好
    const readStream = fs.createReadStream(safePath);
    readStream.on('open', () => {
        // 在这里根据文件扩展名设置正确的 MIME type
        res.writeHead(200, { 'Content-Type': '...'}); 
        readStream.pipe(res);
    });
    readStream.on('error', () => {
        res.writeHead(404, { 'Content-Type': 'text/plain' });
        res.end('Not Found');
    });
};

结语:我们为什么还要用框架?

既然只用原生 http 模块就能做这么多事,那为什么 Express、Fastify 这类框架依然是主流选择?

答案是抽象和效率。框架为我们处理了路由、中间件、请求体解析、错误处理等所有繁琐的细节,让我们能专注于业务逻辑。它们是经过社区千锤百炼的轮子,提供了更高级、更方便的 API。

然而,了解 http 模块的工作原理,会让你在使用这些框架时不再把它当成一个黑盒。你会更清楚中间件的本质,也更能排查一些疑难杂症。下次当你再写下 app.get(...) 的时候,你会知道,它背后,正是我们今天从零构建的这一切。

关于

关注我获取更多资讯

公众号
📢 公众号
个人号
💬 个人号
使用 Hugo 构建
主题 StackJimmy 设计