前端第一次写 Node 服务:一个请求到底经历了什么?

把下面这件事想清楚,后面的框架就不再神秘。

你在浏览器地址栏输入 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"}

这就够我们拆机器了。

刚才究竟发生了什么

先把这趟往返压成一张图。箭头不是"框架层级",只是一次请求真正经过的对象。

sequenceDiagram participant C as 客户端 participant O as 操作系统端口 participant N as Node HTTP 服务 participant H as 请求处理函数 C->>O: 连接 127.0.0.1:3000 并发送 GET /hello O->>N: 把连接与请求数据交给监听进程 N->>H: 调用 callback(request, response) H->>N: writeHead(200) + end(body) N-->>C: 状态码、响应头、JSON 正文

createServer() 创建了一个 HTTP 服务对象。传进去的函数不是立刻执行的;每当新请求到达,Node 才调用它,并交给我们两个对象:

  • request 是客户端送来的消息。这里读了请求方法和路径。
  • response 是我们正在写的回信。这里写了状态、响应头和正文。

listen(3000) 让操作系统把送往 3000 端口的网络连接交给这个进程。端口不像办公室房间,更像总机分机号。同一台机器只有一个进程能占住同一个 IP 与端口组合。

response.end(body) 有两个意思:写出最后一段正文,并宣布这封回信结束。如果你从不结束响应,浏览器会一直等。它不是不聪明,它只是不知道你是不是还要继续说。

为什么把 app.mjsserver.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 服务的骨头怎样动,不适合直接上线。

示例代码如果不说自己省略了什么,就是在骗你。这里的省略是有意的,后续文章会逐项补上。

反自欺检查

关掉文章,回答三个问题:

  1. 为什么运行 npm start 后终端没有返回提示符?
  2. requestresponse 分别代表网络对话的哪一边?
  3. 为什么测试使用端口 0,而不是固定的 3000?

如果回答里只剩"因为 Node 是事件驱动的",那还没搞清楚。重新说成一个进程、一个端口、收到一条消息、写回一条消息。能说清这个画面,才算会了。

官方资料

第一根骨头已经动了。就这么回事。

相关推荐
2601_962055976 小时前
跟据spring boot版本,查看对应的tomcat,并查看可支持的tomcat的版本范围
spring boot·后端·tomcat
Lost of 程序猿7 小时前
ASP.NET Core API 幂等性设计深度实战:从一次重复申领事故说起
后端·asp.net
QQ_21696290968 小时前
【源码编号:project79475】SpringBoot校内二手交易平台:商品发布、分类检索、留言交流、订单管理全流程实战
java·spring boot·后端
郑州光合科技余经理10 小时前
本地生活服务系统:模块边界与结算字段怎么拆
java·开发语言·前端·后端·系统架构·uni-app·php
IT_陈寒10 小时前
Vue的嵌套组件竟然吃掉了我的事件?
前端·人工智能·后端
大辉狼_音频架构11 小时前
进阶:从源码编译 SOF 固件与 topology
后端
大辉狼_音频架构11 小时前
上板:让 SOF 在 FRDM-i.MX8MP 上跑起来
后端
大辉狼_音频架构11 小时前
FRDM-IMX8MP UUU 烧录 eMMC 指南
后端
运行时异常12 小时前
【WMS 仓储系统集成 AI Agent 实战】第 3 讲:Spring Security 6 + JWT——addFilterBefore 一词之差,全站 401
java·后端
LinMINGJing00712 小时前
PageHeaderData:Page 的页面头
后端