开发环境与运行方式
分清运行时、项目和终端,掌握目录、模块、环境变量和可复现的启动方式。
建议先读:阅读指南与学习路线
本页内容
本章目标#
建立一个能重复运行、定位文件和解释报错的学习环境。你不需要一开始同时安装所有后端框架、向量数据库和模型运行器。第一阶段只使用编辑器、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 文档中的所有新特性都假设为旧版本可用。
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,无需先理解打包工具。
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,右边的值会作为字符串读取。
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 目录全建出来。
runtime-lab/
package.json
.env.example
.gitignore
src/
config.mjs
main.mjs
{
"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 不代表它会自动检查所有文件或测试业务。
PORT=4100
TIMEOUT_MS=5000
APP_ENV=learning
node_modules/
.env
dist/
在项目中复制 .env.example 为 .env 后再启动。PowerShell 可用 Copy-Item .env.example .env,Bash 可用 cp .env.example .env;两者是各自终端语法,不要混抄。样例配置只放无敏感数据的默认值,注释解释用途;真实凭据由你配置在本地或受控运行环境。
把配置解析做成一个可测试模块#
下面的完整离线例子既可以单独保存运行,也可以把 parseConfig 导出到 src/config.mjs。它严格接受十进制整数字符串,拒绝空白、指数形式和小数,规则比直接 Number 转换更明确。实际项目可以按需求放宽,但要写入契约,不依赖转换函数的意外宽容。
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 记录配置用途。
参考答案
先读取 process.env.TIMEOUT_MS ?? '5000',再用 Number 转换;必须同时检查 Number.isInteger(value) 和上下界。校验失败时输出配置名和允许范围,但不打印整个环境对象。使用 process.exitCode = 1 或在启动函数抛错,让脚本调用者识别失败。不要用 parseInt('5000oops'),因为它会接受并不合法的输入。
验收与自测#
- 能从两个目录启动同一个文件,并解释 cwd 与模块目录的区别。
- 无效配置会在启动阶段失败,而不是到第一次业务请求才暴露。
- 能用 README 的命令在一个新目录复现实验。
问:npm install 成功说明接口能工作吗? 答:只说明依赖安装完成,还需要运行和验收。
问:环境变量中的 4100 是 number 吗? 答:读取时是字符串,需要显式转换和校验。
问:为什么不把模型密钥写进前端 .env? 答:被构建进入浏览器的内容可被访问者读取,必须把密钥留在服务端。