把下面这件事想清楚,后面的框架就不再神秘。
你在浏览器地址栏输入 http://127.0.0.1:3000/hello,按回车。某个一直活着的程序接到一条消息,读出里面的 /hello,再写回另一条消息。浏览器把返回内容显示出来。
服务端最小的骨头就是这些。没有控制器,没有依赖注入,也没有"企业级架构"。先让骨头动起来。
今天只搞清楚一件事
Node 服务是一个持续运行、监听端口并处理消息的进程。
"启动服务器"只是名字。名字不值钱。我们要亲眼看见它监听、接收和响应。
环境
- Node.js 24 LTS
- 一个终端
- 浏览器或
curl
先检查:
bash
node --version
输出应以 v24. 开头。示例在较新的 Node 22 上也能运行,但整套系列以 Node 24 LTS 为准。
新建一个空目录,放入四个文件:
text
01-first-server/
├── package.json
├── app.mjs
├── server.mjs
└── app.test.mjs
第一个实验:让进程开始接客
package.json:
json
{
"name": "01-first-server",
"private": true,
"type": "module",
"scripts": {
"start": "node server.mjs",
"test": "node --test"
}
}
app.mjs:
js
import { createServer } from 'node:http';
export function buildServer() {
return createServer((request, response) => {
const body = JSON.stringify({
message: 'Hello from Node',
method: request.method,
path: request.url,
});
response.writeHead(200, {
'content-type': 'application/json; charset=utf-8',
'content-length': Buffer.byteLength(body),
});
response.end(body);
});
}
server.mjs:
js
import { buildServer } from './app.mjs';
const port = Number(process.env.PORT ?? 3000);
const server = buildServer();
server.listen(port, '127.0.0.1', () => {
console.log(`Listening on http://127.0.0.1:${port}`);
});
运行:
bash
npm start
你会看到:
text
Listening on http://127.0.0.1:3000
注意,终端没有回到输入提示符。程序没卡死。它只是在等消息。
打开第二个终端:
bash
curl -i http://127.0.0.1:3000/hello
你会看到类似结果:
http
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 60
{"message":"Hello from Node","method":"GET","path":"/hello"}
这就够我们拆机器了。
刚才究竟发生了什么
先把这趟往返压成一张图。箭头不是"框架层级",只是一次请求真正经过的对象。
createServer() 创建了一个 HTTP 服务对象。传进去的函数不是立刻执行的;每当新请求到达,Node 才调用它,并交给我们两个对象:
request是客户端送来的消息。这里读了请求方法和路径。response是我们正在写的回信。这里写了状态、响应头和正文。
listen(3000) 让操作系统把送往 3000 端口的网络连接交给这个进程。端口不像办公室房间,更像总机分机号。同一台机器只有一个进程能占住同一个 IP 与端口组合。
response.end(body) 有两个意思:写出最后一段正文,并宣布这封回信结束。如果你从不结束响应,浏览器会一直等。它不是不聪明,它只是不知道你是不是还要继续说。
为什么把 app.mjs 和 server.mjs 分开
不是为了显得有架构。
server.mjs 决定"在哪个端口开始监听",app.mjs 决定"收到请求后做什么"。测试只关心第二件事,没必要抢占固定的 3000 端口。这个边界带来了一个可以观察的好处:测试能让操作系统随便挑一个空闲端口。
现在写 app.test.mjs:
js
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildServer } from './app.mjs';
test('GET /hello returns a real HTTP response', async (t) => {
const server = buildServer();
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise((resolve, reject) => {
server.close((error) => error ? reject(error) : resolve());
}));
const address = server.address();
assert.notEqual(address, null);
assert.equal(typeof address, 'object');
const response = await fetch(`http://127.0.0.1:${address.port}/hello`);
const body = await response.json();
assert.equal(response.status, 200);
assert.match(response.headers.get('content-type'), /^application\/json/);
assert.deepEqual(body, {
message: 'Hello from Node',
method: 'GET',
path: '/hello',
});
});
运行:
bash
npm test
测试不是直接调用我们的处理函数。它真的监听了一个端口,真的用 fetch 发出 HTTP 请求,再检查真实响应。listen(0) 里的 0 不是"不开端口",而是请操作系统挑一个当前空闲的端口。
故意弄坏一次
把 app.mjs 最后的:
js
response.end(body);
临时改成:
js
response.write(body);
重启服务,再执行 curl。正文也许已经写出来,但请求不会正常结束。测试也会一直等。现在你亲眼看见了 end 的意义,而不只是记住一个方法名。
改回去。
再开一个终端,用同一个端口启动第二份服务:
bash
PORT=3000 npm start
它会报 EADDRINUSE。这句话去掉名字以后就是:"这个地址和端口已经被别的进程占住了。"
换一个端口就能启动:
bash
PORT=3001 npm start
前端经验在哪里有效
浏览器里的 fetch 和服务端的请求对象看的是同一条消息的两端:
js
await fetch('http://127.0.0.1:3000/hello');
浏览器构造请求,Node 读取请求。Node 写响应,浏览器读取响应。前后端不是两个魔法世界,它们只是在消息两头干活。
但有一件事不同:前端代码通常由一次页面加载唤醒;服务端进程要长期存活并接待许多互不认识的客户端。某个请求留下的全局变量,可能被下一个用户看到。某个请求做了很久的同步计算,也可能让其他请求一起等。这两件事会在后面亲手复现。
生产边界
这个服务故意省略了路由、超时、请求体限制、错误处理、日志、优雅退出和安全响应头。它适合证明 HTTP 服务的骨头怎样动,不适合直接上线。
示例代码如果不说自己省略了什么,就是在骗你。这里的省略是有意的,后续文章会逐项补上。
反自欺检查
关掉文章,回答三个问题:
- 为什么运行
npm start后终端没有返回提示符? request和response分别代表网络对话的哪一边?- 为什么测试使用端口 0,而不是固定的 3000?
如果回答里只剩"因为 Node 是事件驱动的",那还没搞清楚。重新说成一个进程、一个端口、收到一条消息、写回一条消息。能说清这个画面,才算会了。
官方资料
第一根骨头已经动了。就这么回事。