综合练习与选型
这一课你将能做到
- 独立搭出一个「Todo API + 命令行导入脚本」的完整小项目(Express + JSON 存储 + 配置 + 错误处理)
- 用本课程的检查清单(分层、配置、错误、日志、退出码)评审自己的 Node 代码
- 面对真实需求,说出「选 Node 还是选 Java」的判断依据与理由
为什么要学
前面 11 课每一课都是一块积木:process 接参数、npm 管依赖、fs 存数据、Express 提供路由、async 编排并发、env 管配置、pipeline 处理大文件、错误处理兜底、test 保质。这课把它们拼成一个真的能跑的项目——面试时「你写过什么 Node 项目」的答案就藏在这节课的产物里。
同时回答压轴问题:本站后端是 Spring Boot,那 Node 学到什么程度、用在哪?选型不是站队,是按任务特征匹配工具:I/O 密集、脚本化、前端生态内 → Node;复杂事务、强类型协作、重计算 → Java。
先建立图像
todo-api/ 项目结构(本课的完整产物)
├── package.json scripts: start / dev / test / import
├── .env / .env.example 配置(.env 进 .gitignore)
├── .gitignore
├── src/
│ ├── server.js 入口:装配 + 全局兜底
│ ├── config.js 唯一配置出口(第 8 课)
│ ├── routes/todos.js 路由层:只做参数接收与响应(第 6 课)
│ ├── store.js 数据访问层:JSON 文件读写(第 4 课)
│ └── app-error.js 业务错误类(第 10 课)
├── scripts/import.js 命令行导入脚本(第 2 课)
└── test/store.test.js 数据层测试(第 11 课)
请求旅程:curl POST /api/todos → 路由收参数 → store 落盘 → 201 返回;出错则统一进错误中间件。每一层都对应你学过的一课——这就是「会了」的检验标准。
跟着做
步骤 1:搭骨架
mkdir todo-api && cd todo-api
npm init -y
npm install express
package.json 的 scripts 与 package.json 声明整理成:
{
"name": "todo-api",
"version": "1.0.0",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "node --watch --env-file=.env src/server.js",
"import": "node --env-file=.env scripts/import.js",
"test": "node --test"
}
}
建 .env 与 .env.example:
PORT=3000
DATA_FILE=./data/todos.json
DEBUG=false
.gitignore:
node_modules/
.env
data/
步骤 2:配置出口与错误类
src/config.js(第 8 课同款):
// src/config.js
const path = require('node:path');
function envStr(key, d) {
const v = process.env[key];
return v === undefined || v === '' ? d : v;
}
function envBool(key, d) {
const v = process.env[key];
return v === undefined ? d : v === '1' || v.toLowerCase() === 'true';
}
const config = {
port: Number.parseInt(process.env.PORT ?? '3000', 10),
dataFile: path.resolve(envStr('DATA_FILE', './data/todos.json')),
debug: envBool('DEBUG', false),
};
module.exports = Object.freeze(config);
src/app-error.js(第 10 课同款):
// src/app-error.js
class AppError extends Error {
constructor(message, status = 500, code = 'INTERNAL_ERROR') {
super(message);
this.name = 'AppError';
this.status = status;
this.code = code;
}
}
module.exports = { AppError };
步骤 3:数据层 store.js
JSON 文件当数据库(原型期完全够用),只暴露增删改查:
// src/store.js
const fs = require('node:fs/promises');
const path = require('node:path');
const { AppError } = require('./app-error');
const config = require('./config');
let cache = null; // 内存缓存,写穿到磁盘
async function load() {
if (cache) return cache;
try {
cache = JSON.parse(await fs.readFile(config.dataFile, 'utf8'));
} catch (err) {
if (err.code === 'ENOENT') {
cache = [];
} else {
throw err; // 非缺失类错误不吞
}
}
return cache;
}
async function save() {
await fs.mkdir(path.dirname(config.dataFile), { recursive: true });
await fs.writeFile(config.dataFile, JSON.stringify(cache, null, 2), 'utf8');
}
async function list() {
return load();
}
async function create(title) {
const todos = await load();
const todo = { id: Date.now(), title, done: false, createdAt: new Date().toISOString() };
todos.push(todo);
await save();
return todo;
}
async function toggle(id) {
const todos = await load();
const todo = todos.find((t) => t.id === id);
if (!todo) {
throw new AppError(`todo ${id} 不存在`, 404, 'TODO_NOT_FOUND');
}
todo.done = !todo.done;
await save();
return todo;
}
async function remove(id) {
const todos = await load();
const index = todos.findIndex((t) => t.id === id);
if (index === -1) {
throw new AppError(`todo ${id} 不存在`, 404, 'TODO_NOT_FOUND');
}
const [removed] = todos.splice(index, 1);
await save();
return removed;
}
module.exports = { list, create, toggle, remove };
步骤 4:路由层与装配
src/routes/todos.js(Router 拆分,第 6 课套路):
// src/routes/todos.js
const express = require('express');
const store = require('../store');
const router = express.Router();
router.get('/', async (req, res) => {
res.json({ data: await store.list() });
});
router.post('/', async (req, res, next) => {
const title = req.body?.title?.trim();
if (!title) {
return next(new (require('../app-error').AppError)('title 必填', 400, 'TITLE_REQUIRED'));
}
const todo = await store.create(title);
res.status(201).json(todo);
});
router.patch('/:id/toggle', async (req, res, next) => {
try {
const todo = await store.toggle(Number(req.params.id));
res.json(todo);
} catch (err) {
next(err); // AppError 直达错误中间件
}
});
router.delete('/:id', async (req, res, next) => {
try {
await store.remove(Number(req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
});
module.exports = router;
(顺手优化:把 AppError 在文件顶部 require 一次,行内 require 只是演示——你自己写时统一放顶部。)
// src/server.js
const express = require('express');
const config = require('./config');
const todosRouter = require('./routes/todos');
const { AppError } = require('./app-error');
const app = express();
app.use(express.json());
app.use('/api/todos', todosRouter);
// 404
app.use((req, res) => {
res.status(404).json({ error: 'NOT_FOUND' });
});
// 统一错误出口
app.use((err, req, res, next) => {
if (err instanceof AppError) {
return res.status(err.status).json({ error: err.code, message: err.message });
}
console.error('[unexpected]', err);
res.status(500).json({ error: 'INTERNAL_ERROR' });
});
app.listen(config.port, () => {
console.log(`todo-api 启动: http://localhost:${config.port}`);
});
// 进程兜底
process.on('uncaughtException', (err) => {
console.error('[uncaughtException]', err);
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
console.error('[unhandledRejection]', reason);
process.exit(1);
});
步骤 5:跑通全流程
npm run dev
# todo-api 启动: http://localhost:3000
另开终端验证(Node 20 内置 fetch,直接当 HTTP 客户端用):
node -e "fetch('http://localhost:3000/api/todos',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({title:'学完 Node 12 课'})}).then(r=>r.json()).then(console.log)"
# { id: 1728..., title: '学完 Node 12 课', done: false, createdAt: '...' }
node -e "fetch('http://localhost:3000/api/todos').then(r=>r.json()).then(console.log)"
# { data: [ { ... } ] }
浏览器访问 http://localhost:3000/api/todos 直接看 JSON;data/todos.json 文件里能看到落盘的数据——重启进程数据还在。
步骤 6:命令行导入脚本
复用 store 层,做一个 CLI(第 2 课的 argv + 退出码全套上阵):
// scripts/import.js
const path = require('node:path');
const fs = require('node:fs/promises');
const config = require('../src/config');
const file = process.argv[2];
if (!file) {
console.error('用法: npm run import -- <todo.txt>');
console.error('文件格式: 每行一条 todo');
process.exit(1); // 参数缺失
}
async function main() {
const raw = await fs.readFile(path.resolve(file), 'utf8');
const todos = require('../src/store');
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
if (lines.length === 0) {
console.error('文件里没有有效内容');
process.exitCode = 2;
return;
}
for (const line of lines) {
await todos.create(line);
console.log('已导入:', line);
}
console.log(`完成,共 ${lines.length} 条,数据在 ${config.dataFile}`);
}
main().catch((err) => {
console.error('导入失败:', err.message);
process.exit(1);
});
node -e "require('fs').writeFileSync('todos.txt','学 Streams\n学选型\n')"
npm run import -- todos.txt
# 已导入: 学 Streams
# 已导入: 学选型
# 完成,共 2 条,数据在 h:\work\todo-api\data\todos.json
npm run import -- todos.txt 里的 -- 是 npm 语法:把参数透传给脚本本身。
步骤 7:给数据层补测试
// test/store.test.js
const { test } = require('node:test');
const assert = require('node:assert');
const path = require('node:path');
const os = require('node:os');
const fs = require('node:fs/promises');
// 每个用例用独立的临时文件,避免互相污染
async function freshStore() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'todo-'));
process.env.DATA_FILE = path.join(dir, 'todos.json');
delete require.cache[require.resolve('../src/store')]; // 清模块缓存重载
return require('../src/store');
}
test('create 后 list 能查到', async () => {
const store = await freshStore();
const todo = await store.create('测试项');
const all = await store.list();
assert.strictEqual(all.length, 1);
assert.strictEqual(all[0].title, '测试项');
assert.strictEqual(todo.done, false);
});
test('toggle 不存在的 id 抛 404 AppError', async () => {
const store = await freshStore();
await assert.rejects(
() => store.toggle(12345),
(err) => err.status === 404 && err.code === 'TODO_NOT_FOUND'
);
});
注意这个测试的两个工程细节:临时目录隔离(mkdtemp)保证用例独立;清 require.cache 让每例拿到全新 store——都是「用例独立性」的落地。
步骤 8:选型——Node 还是 Java
| 判断维度 | 倾向 Node | 倾向 Java/Spring |
|---|
| 任务特征 | I/O 密集:网关、BFF、实时推送、爬虫、脚本 | 重业务规则、复杂事务、批处理计算 |
| 交付形态 | CLI 工具、原型验证、内部小服务 | 长周期演进的领域系统 |
| 团队背景 | 前端团队全栈化、需要快速迭代 | 强类型约束、大团队协作、成熟规范 |
| 生态依赖 | 前端工程链、npm 生态 | Spring 全家桶、大数据、成熟中间件 |
| 典型红线 | CPU 密集计算、长事务、多线程共享状态 | ——(Java 也能写脚本,只是重) |
结论人话:Node 的本质优势是「JS 全栈一套语言 + 事件循环对 I/O 的天然亲和」;Java 的本质优势是「类型系统、并发模型与久经考验的企业级设施」。 本站的落法:Spring Boot 承载业务 API,Node 承载内容导入脚本、构建链路与轻量 BFF——不替换,各就各位。
讲清楚要点
- 分层是第一生产力:路由(收参数/发响应)→ store(数据读写)→ config(配置出口)→ AppError(错误契约)。每层只做一件事,测试就只测 store,换存储只改 store。
- 错误契约贯穿全链路:store 抛 AppError → 路由 next(err) → 错误中间件按 status/code 输出。新增接口时错误处理是免费的。
- 同一份 store 被两种入口复用:HTTP 路由和 CLI 脚本都调
store.create——业务逻辑不写在路由里,才能这样白嫖复用。
- 脚本必须认真对待退出码与用法提示:
npm run import -- 缺参数时立刻 exit(1) 并打印用法,CI 里才有判断依据。
- 原型存储选 JSON 文件是合理的:零部署、可 git 查看、备份即复制文件。数据量/并发上来再迁 SQLite/MySQL——迁移成本已通过 store 接口隔离。
你可能会懵的地方
-
JSON 文件存储并发写会不会坏?
会。两个请求同时 writeFile 互相覆盖,最后一写胜出。原型期量小可接受;需要严谨时就上真正的数据库或加写队列(同一时刻只有一个 save 在跑)。知道边界,才敢用它。
-
Node 学到这个程度,下一步学什么?
三条路按需选:① TypeScript(大型 Node 项目事实标配);② 数据库接入(MySQL/MongoDB 驱动或 Prisma/TypeORM);③ 部署运维(pm2、Docker、日志收集)。本课程目标已达成,进阶方向由你的实际任务决定。
-
写 Todo API 不用数据库,面试会嫌寒酸吗?
面试官在意的是「你能否讲清设计决策」:为什么文件存储、错误怎么分层、测试怎么隔离、什么时候会迁移。能把边界条件说清楚,比堆技术名词有说服力。
-
npm run import -- todos.txt 的 -- 能省吗?
不能。没有 --,npm 会把 todos.txt 当作 npm 自己的参数解析掉。npm 7+ 部分场景能透传,但显式 -- 永远正确。
-
这个项目能直接当生产服务跑吗?
不能直接跑:缺鉴权、缺请求日志持久化、JSON 存储无并发保护。它的定位是「完整骨架 + 课程全部知识点的落位」,往生产走需要按上面清单逐项补齐。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 把业务逻辑全写在路由函数里 | CLI 无法复用、测试必须起 HTTP | 逻辑下沉到 store/service 层 |
| store 里 catch 后返回 [] 装没事 | 数据文件损坏被静默清空 | 按 err.code 分类:ENOENT 才初始化空表 |
config 里散落 process.env.XXX 读取 | 类型/默认值不可控 | 唯一 config 模块 + Object.freeze |
| 测试共享同一个 data 文件 | 用例互相污染,偶发失败 | mkdtemp 临时目录 + 清 require.cache |
npm run import todos.txt | 参数被 npm 吞掉,脚本报「用法」 | npm run import -- todos.txt |
| 服务与脚本各写一套「创建 todo」逻辑 | 两处行为不一致 | 复用同一 store 层 |
卡点急救
npm run dev 报 --env-file 未知:Node 版本低于 20.6。升级 Node,或临时改 "dev": "node --watch src/server.js" 并在系统里注入变量。
- 数据文件写入后读不到:
DATA_FILE 是相对路径时参照 cwd,npm run 的 cwd 是项目根但手动 node scripts/import.js 从别的目录跑就偏了——config 里用 path.resolve 统一锚定(本课代码已处理)。
- PATCH 返回 404 但数据明明存在:
req.params.id 是字符串,store.toggle('1') 找不到数字 id。路由里 Number(req.params.id) 转换(本课代码已处理,排查同类问题先查类型)。
- 测试文件改了 store 行为却没生效:
require.cache 缓存了旧模块。测试里按步骤 7 的方式删除缓存再 require。
- 进程退出码 0 但导入实际失败:catch 里只 log 不设置退出码。
process.exitCode = 1 或 process.exit(1) 必须落在失败分支。
面试常问
-
介绍你写的这个 Node 项目,亮点是什么?
骨架:Express 分层(路由/数据/配置/错误契约)+ JSON 文件存储 + CLI 导入脚本复用同一数据层 + node:test 覆盖数据层。亮点讲法:错误契约让 HTTP 与 CLI 两个入口共享同一套业务行为;配置模块统一类型转换与默认值;测试用临时目录与模块缓存清理保证独立性。
-
什么场景选 Node,什么场景选 Java?
Node:I/O 密集(BFF、网关、实时推送)、脚本与工程链、前端团队全栈快速交付。Java:复杂业务事务、重计算批处理、大型团队强规范协作。核心判断维度是任务特征、团队背景与生态依赖,而不是语言偏好。
-
Node 项目的错误处理体系怎么设计?
定义携带 status/code 的业务错误类;数据层抛契约错误、路由转发、错误中间件统一出口(业务错误返回细节、未知错误打日志回模糊信息);进程级 uncaughtException/unhandledRejection 兜底记录后退出,重启交给进程管理器。
-
原型期的存储方案如何演进?
从 JSON 文件起步(零部署、易备份),把读写收敛在 store 接口后面;数据量或并发增长后迁移 SQLite/MySQL,只替换 store 实现、路由层零改动——前提是初期就做了数据访问分层。
自测三问
- 本课项目里,一次
POST /api/todos 请求依次经过哪些层?每层的职责一句话概括。
- CLI 脚本和 HTTP 路由如何做到复用同一份「创建 todo」逻辑?
- 给出三个「该选 Java 而不是 Node」的任务特征。
小结一句话
把 process/npm/fs/path/Express/async/env/streams/errors/test 十二块积木拼成「分层 + 错误契约 + 双入口复用」的项目,选型的答案是按任务特征配工具——Node 管 I/O 与脚本,Java 管事务与计算。
延伸