前端第一次写 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 是事件驱动的",那还没搞清楚。重新说成一个进程、一个端口、收到一条消息、写回一条消息。能说清这个画面,才算会了。

官方资料

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

相关推荐
后除1 小时前
Debian 安装与使用 Certbot
后端·nginx·https
小林敲代码77882 小时前
Spring Boot 3 升级踩坑:aj-captcha 行为验证码底图加载失效
java·spring boot·后端
雪碧5132 小时前
基于afsim的训练多个智能体算法控制(持续关注和收藏后续会同步整个源码包)
后端
保加利亚的风2 小时前
Docker 学习文档(Mac + Docker Desktop 版)
前端·后端
刘名喜2 小时前
第30篇-Spring-Security-7核心概念
后端·kotlin·springboot
明月_清风2 小时前
🚀 AI Agent 完全入门指南:从 LLM 到生产落地,新手必懂的 34 个核心概念
前端·后端·ai编程
程序员阿明2 小时前
spring boot3访问resources下的文件,使得在浏览器url中可以直接访问
java·spring boot·后端
掘金者阿豪2 小时前
MongoDB迁移不想重写代码?一次国产数据库替换踩坑记录
后端