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

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

我们都习惯了 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(...) 的时候,你会知道,它背后,正是我们今天从零构建的这一切。

关于

关注我获取更多资讯

月球基地博客公众号二维码,扫码关注获取更多 AI 与编程资讯
📢 公众号
月球基地博客作者个人微信二维码,扫码交流 AI 与编程话题
💬 个人号
使用 Hugo 构建
主题 StackJimmy 设计