我们都习惯了 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-Type 为 text/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=1,req.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(...) 的时候,你会知道,它背后,正是我们今天从零构建的这一切。
关于
关注我获取更多资讯