CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Node.js 后端入门/课时/综合练习与选型
RawBlame
课时目录12 节

Node.js 后端入门

  1. 01Node.js 是什么
  2. 02运行时与 process
  3. 03模块与 npm
  4. 04文件系统与 path
  5. 05http 模块
  6. 06Express 入门
  7. 07事件循环与异步
  8. 08环境变量与配置
  9. 09Streams 基础
  10. 10错误处理
  11. 11测试 node:test
  12. 12综合练习与选型

综合练习与选型

REST 课程 API;何时选 Node vs Spring Boot。

45 分钟

综合练习与选型

这一课你将能做到

  1. 独立搭出一个「Todo API + 命令行导入脚本」的完整小项目(Express + JSON 存储 + 配置 + 错误处理)
  2. 用本课程的检查清单(分层、配置、错误、日志、退出码)评审自己的 Node 代码
  3. 面对真实需求,说出「选 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 装配:

// 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);
});

准备 todos.txt(每行一条)并运行:

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'
  );
});
npm test

注意这个测试的两个工程细节:临时目录隔离(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——不替换,各就各位。

讲清楚要点

  1. 分层是第一生产力:路由(收参数/发响应)→ store(数据读写)→ config(配置出口)→ AppError(错误契约)。每层只做一件事,测试就只测 store,换存储只改 store。
  2. 错误契约贯穿全链路:store 抛 AppError → 路由 next(err) → 错误中间件按 status/code 输出。新增接口时错误处理是免费的。
  3. 同一份 store 被两种入口复用:HTTP 路由和 CLI 脚本都调 store.create——业务逻辑不写在路由里,才能这样白嫖复用。
  4. 脚本必须认真对待退出码与用法提示:npm run import -- 缺参数时立刻 exit(1) 并打印用法,CI 里才有判断依据。
  5. 原型存储选 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 层

卡点急救

  1. npm run dev 报 --env-file 未知:Node 版本低于 20.6。升级 Node,或临时改 "dev": "node --watch src/server.js" 并在系统里注入变量。
  2. 数据文件写入后读不到:DATA_FILE 是相对路径时参照 cwd,npm run 的 cwd 是项目根但手动 node scripts/import.js 从别的目录跑就偏了——config 里用 path.resolve 统一锚定(本课代码已处理)。
  3. PATCH 返回 404 但数据明明存在:req.params.id 是字符串,store.toggle('1') 找不到数字 id。路由里 Number(req.params.id) 转换(本课代码已处理,排查同类问题先查类型)。
  4. 测试文件改了 store 行为却没生效:require.cache 缓存了旧模块。测试里按步骤 7 的方式删除缓存再 require。
  5. 进程退出码 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 实现、路由层零改动——前提是初期就做了数据访问分层。

自测三问

  1. 本课项目里,一次 POST /api/todos 请求依次经过哪些层?每层的职责一句话概括。
  2. CLI 脚本和 HTTP 路由如何做到复用同一份「创建 todo」逻辑?
  3. 给出三个「该选 Java 而不是 Node」的任务特征。

小结一句话

把 process/npm/fs/path/Express/async/env/streams/errors/test 十二块积木拼成「分层 + 错误契约 + 双入口复用」的项目,选型的答案是按任务特征配工具——Node 管 I/O 与脚本,Java 管事务与计算。

延伸

  • Node.js 官方学习指南
  • Express 生产环境最佳实践
  • 下一站:TypeScript、数据库接入(Prisma/驱动)或 pm2/Docker 部署,按你的实际任务任选一条深入。
加载本节练习…
← 上一课测试 node:test