# 开发环境与运行方式

## 本章目标

建立一个能重复运行、定位文件和解释报错的学习环境。你不需要一开始同时安装所有后端框架、向量数据库和模型运行器。第一阶段只使用编辑器、Git 与 Node；遇到数据库章节再准备 PostgreSQL，遇到 Python 章节再配置 Python。

L2 的环境能力不是记住安装步骤，而是知道程序在哪里运行、当前目录是什么、依赖来自哪里、配置从哪里加载，以及如何让另一个人复现。前端构建工具往往替你隐藏了很多启动细节；后端学习需要把这些细节重新看清楚。

## 运行时、包管理器、项目是不同概念

Node 运行 JavaScript，npm 安装和管理项目依赖，Git 保存源代码历史，终端负责解释你输入的命令。`node app.mjs` 是运行一个文件；`npm run start` 是执行 package.json 中的脚本；`npm install` 是安装依赖，它不会自动证明程序可以正确运行。

PowerShell、Git Bash 和 Linux Bash 的变量、路径与多行语法不同。本手册优先使用跨终端的单行 `node` 命令。PowerShell 若因为脚本策略无法执行 `npm`，可调用 `npm.cmd`，无需为了学习把整个系统的执行策略放宽。Linux 容器内的 `/app` 是容器路径，不是 Windows 的 `C:\app`。

## 第一次环境检查

在终端逐行执行以下命令。预期看到 Node、npm 与 Git 的版本号；具体补丁版本因你的安装而异。本手册的现代 Node 示例使用 Node 22.22+ 或 Node 24，不能把官方 latest 文档中的所有新特性都假设为旧版本可用。

```bash
node --version
npm --version
git --version
```

如果找不到命令，检查是否安装、可执行文件是否在 PATH、编辑器终端是否需要重新打开。安装了多套 Node 时，应先确认当前命令实际指向哪一套。PowerShell 可用 `Get-Command node`，Bash 可用 `command -v node`。不要在尚未确认运行时路径前反复安装依赖。

## 一个完整的零依赖实验

新建一个学习目录，把下面内容保存为 `inspect-runtime.mjs`。`.mjs` 明确告诉 Node 使用 ES Modules，因此可以写顶层 import 和 await，无需先理解打包工具。

```js
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';

// cwd 是你启动命令时所在的目录，不保证等于当前文件目录。
const launchDirectory = process.cwd();

// import.meta.url 指向本模块；转成文件路径后才能交给路径 API。
const currentFile = fileURLToPath(import.meta.url);
const moduleDirectory = dirname(currentFile);

// argv 的前两个元素分别是 Node 路径和当前入口文件路径。
const name = process.argv[2] ?? '开发者';

// 环境变量是外部配置，读取时仍是字符串。
const rawPort = process.env.PORT ?? '3000';
const port = Number(rawPort);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
  console.error('PORT 必须是 1 到 65535 的整数');
  process.exitCode = 1; // 设置退出状态，让外部脚本知道失败。
} else {
  console.log({ name, port, launchDirectory, moduleDirectory });
}
```

运行 `node inspect-runtime.mjs Alice`，预期对象中 `name` 为 Alice、`port` 为 3000，并显示启动目录和模块目录。再从父目录运行 `node 学习目录/inspect-runtime.mjs Alice`，观察 cwd 改变而模块目录不变。这解释了为什么相对文件路径在换启动方式后可能失效。

API 契约上，`process.cwd()` 返回工作目录字符串；`process.argv` 是参数数组；`process.env` 的值通常是字符串或 undefined；`fileURLToPath()` 把文件 URL 转成当前平台的路径。不要用字符串替换 `file://`，那会在 Windows 盘符、空格和转义字符上出错。

## 配置文件怎么组织

项目可以使用 `.env` 提供本地配置。下面是一个没有凭据的示例，保存为 `.env`；此文件不是 JavaScript，右边的值会作为字符串读取。

```dotenv
PORT=4100
APP_ENV=learning
```

运行 `node --env-file=.env inspect-runtime.mjs Alice`，预期 port 为 4100。这个参数让 Node 读取环境文件；文件路径仍受 cwd 影响。真实项目把 `.env` 加入 `.gitignore`，提交不含密钥的 `.env.example`，并在服务启动时检查所有必要配置。环境变量本身不是秘密保险箱，日志和错误页面仍可能泄露它。

不同配置来源的优先级应写在 README 中。不要依靠开发者个人机器上碰巧存在的全局变量。生产环境通过部署平台注入配置，浏览器构建工具中暴露给客户端的变量不能保存模型密钥。

## 安装依赖与锁文件

学到第三方模块时，先阅读它用于什么、是否需要运行时依赖、许可证和维护状态，再安装到当前项目。初次安装可用 `npm install 包名`；生成的 package-lock.json 应随项目提交。在有锁文件的持续集成环境中通常使用 `npm ci`，它要求清单与锁文件匹配，并按锁定依赖重建安装。

不要把删除锁文件和重装依赖作为所有故障的第一步。先记录运行版本、命令、错误和复现输入；检查是否路径不对、进程权限不对、模块格式不一致或依赖 API 已变。一个可靠的实验只改变一个主要因素。

## 常见问题

**Cannot use import statement outside a module**：文件的模块方式与语法不一致。入门例子使用 `.mjs`；真实项目也可在 package.json 设置 type，但这会影响目录下相关 `.js` 文件。

**ENOENT**：通常是路径所指的文件不存在；打印非敏感的 cwd 和最终绝对路径，确认启动位置。**EADDRINUSE**：指定端口被占用；先识别属于哪个服务，再决定复用端口或修改配置。不要随意结束不认识的进程。

**启动没有输出**：程序可能还在等待 I/O，也可能本身没有日志；检查是否进入入口、是否有未结束的定时器或服务监听。后续章节会介绍结构化日志和资源释放。

## 从一个目录重建可复现的后端项目

环境问题最值得练习的能力是“在空目录重新启动”，而不是记住自己电脑上当前能跑的命令。你熟悉的前端项目通常已配置脚本、类型检查和开发代理；离开这些配置后，先把最少文件和每个文件的作用写清楚。一个 Node 教学项目至少有入口、依赖清单、配置样例、忽略规则和启动说明。

下面的布局用于说明项目组织，不是一套可直接启动的完整项目；本节可以独立运行的是后面的 config-contract.mjs，完整多文件实验见流式章节。入口和配置模块是源码，package.json 描述运行方式，package-lock.json 由安装生成。不要把 node_modules 放入版本库；也不要为了看起来像企业工程，把还没有职责的 controller、service、repository 目录全建出来。

```text
runtime-lab/
  package.json
  .env.example
  .gitignore
  src/
    config.mjs
    main.mjs
```

```json package.json
{
  "name": "runtime-lab",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node --env-file=.env src/main.mjs",
    "check": "node --check src/main.mjs"
  }
}
```

private 防止误把练习项目作为包发布，type 明确 .js 文件的模块方式；本例依旧使用 .mjs，便于脱离项目时运行。scripts 的值是实际命令，npm run 只是从这个表里找到它并执行。check 只检查入口语法，名字叫 check 不代表它会自动检查所有文件或测试业务。

```dotenv .env.example
PORT=4100
TIMEOUT_MS=5000
APP_ENV=learning
```

```text .gitignore
node_modules/
.env
dist/
```

在项目中复制 .env.example 为 .env 后再启动。PowerShell 可用 `Copy-Item .env.example .env`，Bash 可用 `cp .env.example .env`；两者是各自终端语法，不要混抄。样例配置只放无敏感数据的默认值，注释解释用途；真实凭据由你配置在本地或受控运行环境。

### 把配置解析做成一个可测试模块

下面的完整离线例子既可以单独保存运行，也可以把 parseConfig 导出到 src/config.mjs。它严格接受十进制整数字符串，拒绝空白、指数形式和小数，规则比直接 Number 转换更明确。实际项目可以按需求放宽，但要写入契约，不依赖转换函数的意外宽容。

```js config-contract.mjs
import assert from 'node:assert/strict';

function integerConfig(source, key, fallback, minimum, maximum) {
  const raw = source[key] ?? String(fallback);
  if (typeof raw !== 'string' || !/^\d+$/.test(raw)) throw new Error(`${key}: 整数字符串无效`);
  const value = Number(raw);
  if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
    throw new Error(`${key}: 应为 ${minimum} 到 ${maximum}`);
  }
  return value;
}

function parseConfig(source) {
  const appEnv = source.APP_ENV ?? 'learning';
  if (!['learning', 'test', 'production'].includes(appEnv)) throw new Error('APP_ENV: 不支持的环境');
  return Object.freeze({
    port: integerConfig(source, 'PORT', 4100, 1, 65535),
    timeoutMs: integerConfig(source, 'TIMEOUT_MS', 5000, 100, 60000),
    appEnv,
  });
}

assert.deepEqual(parseConfig({}), { port: 4100, timeoutMs: 5000, appEnv: 'learning' });
assert.equal(parseConfig({ TIMEOUT_MS: '8000' }).timeoutMs, 8000);
for (const bad of ['oops', '-1', '0', '60001', '5000ms', '5e3', ' ']) {
  assert.throws(() => parseConfig({ TIMEOUT_MS: bad }), /TIMEOUT_MS/);
}
assert.throws(() => parseConfig({ APP_ENV: 'prodution' }), /APP_ENV/);
console.log('通过：默认配置、正常覆盖、范围与格式错误');
```

source 从参数传入，使测试不必反复修改整个进程的环境变量。业务入口调用一次 parseConfig(process.env)，后续模块只接收已校验的配置对象。这样，一个拼错的端口或超时会在启动时失败，不会直到第一个用户请求才暴露。Object.freeze 在本例只需要冻结一层，因为配置值都是原始值；嵌套对象需要另行考虑。

### 入口应该做什么

入口负责读取配置、建立外部连接、创建应用、开始监听并注册关闭流程。业务模块不应一被 import 就自动监听端口或发模型请求，否则测试导入一个函数也会启动服务。流式章节采用 createDemoServer 返回未监听对象，就是为了分开创建与启动，允许测试选择空闲端口。

普通逻辑错误可以向上传播到统一处理层，配置错误则应明确终止启动。不要在入口捕获异常后只打印“有点问题”并继续运行，这会产生一个端口存在、业务却无法工作的半启动进程。外部脚本通常根据退出码判断成功失败，因此打印错误与设置失败退出状态是两件事。

## 三种执行方式，三种不同的证据

| 命令 | 实际发生的事 | 不能据此证明什么 |
| --- | --- | --- |
| node app.mjs | Node 执行源码入口 | 不证明所有分支都运行过 |
| npm run build | 执行清单定义的构建脚本 | 不证明数据库或模型服务可用 |
| npm test | 执行清单定义的测试脚本 | 不证明没覆盖的场景正确 |
| node --check app.mjs | 检查该文件语法 | 不执行函数，不验证网络与权限 |
| npm ci | 依据锁文件安装项目依赖 | 不自动配置环境变量和数据库 |

后端 TypeScript 项目还需要决定是开发时由工具运行 TS，还是先编译为 JS 再由 Node 执行。两者可以共存：开发命令便于热更新，生产命令运行构建产物。不要把依赖于开发工具的命令直接复制到没有 devDependencies 的运行环境，最后才发现入口解释器根本不存在。

模块导入路径也要按真正的运行结果理解。TypeScript paths 可以帮助编译器解析别名，但不自动保证原生 Node 理解同样的别名。构建工具、运行器和输出目录怎样处理它，应查当前项目配置并实际运行构建后的入口。类型检查成功却启动失败，常常就发生在这类边界。

## Windows、终端与容器最常见的混淆

PowerShell 中设置当前会话环境变量的写法是 `$env:PORT = '4100'`；Bash 常见 `PORT=4100 node app.mjs`。README 要标明适用终端，或统一使用 Node env-file 参数减少差异。复制一整段命令前先看它是在 Windows、Linux、容器内还是远程主机执行。

路径包含空格时要正确引用路径，文件 URL 要用标准 API 转换。不要通过删掉 file:// 前缀来转换 URL：Windows 盘符、中文路径和百分号转义都可能出错。cwd 是进程属性，不随你 import 到另一个目录自动改变；相对读取配置或数据文件时，明确它相对项目还是模块。

宿主机 localhost 指向宿主机，容器内 localhost 指向那个容器自己。如果 API 与数据库分别在两个容器，API 里连接 localhost 往往连接不到数据库。需要使用该容器网络里可解析的服务名，并确认端口是服务内部端口还是映射到宿主机的端口。先画出进程和网络边界，再尝试改连接字符串。

这些差异不是让你立刻学完 Linux 运维。入门阶段能说明“这个命令在谁的文件系统、哪个目录、哪个网络环境中运行”，就能避免大量盲目重装。遇到权限失败时先确认对象和执行主体，不用管理员权限掩盖错误的目录设计。

## 排错记录怎样写才有价值

每次环境故障保留五件事：运行时版本、当前目录、完整命令、脱敏后的报错、最小复现。ENOENT 先看最终路径是否存在；MODULE_NOT_FOUND 看依赖是否装在当前项目与导入名是否正确；EADDRINUSE 看端口所属进程；连接超时看目的地址与网络；语法报错看运行版本是否支持该语法。

不要先删除锁文件、重装全部包、换运行时，再发现故障不见了。这会同时改变多个变量，既无法解释根因，也无法向同事提供可复现修复。更好的实验是先从正确 cwd 运行同一条命令，或只修正一个已确认的配置值，再记录前后差异。

启动说明必须让一个不了解你电脑状态的人完成同样步骤：需要什么运行时，在哪个目录安装，复制哪个配置，是否要准备数据库，先运行哪条迁移，最后访问哪个端口。把“我记得先启动过一次某个脚本”写成显式依赖。最终验收时可以新建一个干净的项目副本，按 README 从零运行；不用删除正在工作的目录来做实验。

## 本章的学习终点

你不需要背诵所有 Node 命令行参数，也不需要安装手册里每一种工具。达到 L2 的证据是：独立建立一个可运行项目，解释入口和脚本；配置错误能提前失败；可以从报错定位目录、模块、端口或依赖；换一个干净目录后能够复现。后续数据库和模型章节只是在这个清晰的运行基础上增加依赖。


## 练习

增加一个 `TIMEOUT_MS` 配置，默认 5000，只允许 100 到 60000 的整数。分别测试未配置、有效值、非数字、负数和超出范围。用 `.env.example` 记录配置用途。

<details>
<summary>参考答案</summary>

先读取 `process.env.TIMEOUT_MS ?? '5000'`，再用 Number 转换；必须同时检查 `Number.isInteger(value)` 和上下界。校验失败时输出配置名和允许范围，但不打印整个环境对象。使用 `process.exitCode = 1` 或在启动函数抛错，让脚本调用者识别失败。不要用 `parseInt('5000oops')`，因为它会接受并不合法的输入。

</details>

## 验收与自测

- 能从两个目录启动同一个文件，并解释 cwd 与模块目录的区别。
- 无效配置会在启动阶段失败，而不是到第一次业务请求才暴露。
- 能用 README 的命令在一个新目录复现实验。

**问：npm install 成功说明接口能工作吗？** 答：只说明依赖安装完成，还需要运行和验收。

**问：环境变量中的 4100 是 number 吗？** 答：读取时是字符串，需要显式转换和校验。

**问：为什么不把模型密钥写进前端 `.env`？** 答：被构建进入浏览器的内容可被访问者读取，必须把密钥留在服务端。

## 官方参考

- [Node：环境变量](https://nodejs.org/api/environment_variables.html)
- [Node：ECMAScript modules](https://nodejs.org/api/esm.html)
- [Node：process](https://nodejs.org/api/process.html)
- [npm ci](https://docs.npmjs.com/cli/v11/commands/npm-ci)
