Node.js 开发指南 / 第 25 章 · 实战项目
第 25 章 · 实战项目
25.1 项目一:全栈待办应用
技术栈
| 层 | 技术 |
|---|---|
| 前端 | React / Vue(可选) |
| 后端 | Express + Prisma |
| 数据库 | PostgreSQL |
| 认证 | JWT |
| 部署 | Docker + GitHub Actions |
项目结构
todo-app/
├── src/
│ ├── config/
│ │ └── index.js
│ ├── api/
│ │ ├── routes/
│ │ │ ├── auth.js
│ │ │ ├── todos.js
│ │ │ └── index.js
│ │ ├── controllers/
│ │ ├── middlewares/
│ │ │ ├── auth.js
│ │ │ └── errorHandler.js
│ │ └── validators/
│ ├── services/
│ ├── repositories/
│ ├── utils/
│ ├── app.js
│ └── server.js
├── prisma/
│ └── schema.prisma
├── tests/
├── Dockerfile
└── docker-compose.yml
Prisma Schema
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
email String @unique
password String
name String
todos Todo[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Todo {
id Int @id @default(autoincrement())
title String
description String?
completed Boolean @default(false)
priority String @default("medium") // low, medium, high
dueDate DateTime?
user User @relation(fields: [userId], references: [id])
userId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
API 路由
// src/api/routes/todos.js
const { Router } = require('express');
const { authenticate } = require('../middlewares/auth');
const { validate } = require('../middlewares/validator');
const { todoSchema } = require('../validators/todo');
const todoController = require('../controllers/todoController');
const router = Router();
router.use(authenticate); // 所有路由都需要认证
router.get('/', todoController.getAll);
router.post('/', validate(todoSchema), todoController.create);
router.get('/:id', todoController.getById);
router.put('/:id', validate(todoSchema), todoController.update);
router.patch('/:id/toggle', todoController.toggle);
router.delete('/:id', todoController.delete);
module.exports = router;
Service 层
// src/services/todoService.js
const todoRepo = require('../repositories/todoRepo');
const { NotFoundError, ForbiddenError } = require('../utils/errors');
exports.getAll = async (userId, query) => {
const { page = 1, limit = 20, completed, priority, search } = query;
return todoRepo.findAll({
where: {
userId,
...(completed !== undefined && { completed: completed === 'true' }),
...(priority && { priority }),
...(search && { title: { contains: search, mode: 'insensitive' } }),
},
orderBy: { createdAt: 'desc' },
skip: (Number(page) - 1) * Number(limit),
take: Number(limit),
});
};
exports.create = async (userId, data) => {
return todoRepo.create({ ...data, userId });
};
exports.update = async (userId, todoId, data) => {
const todo = await todoRepo.findById(todoId);
if (!todo) throw new NotFoundError('待办事项');
if (todo.userId !== userId) throw new ForbiddenError();
return todoRepo.update(todoId, data);
};
exports.toggle = async (userId, todoId) => {
const todo = await todoRepo.findById(todoId);
if (!todo) throw new NotFoundError('待办事项');
if (todo.userId !== userId) throw new ForbiddenError();
return todoRepo.update(todoId, { completed: !todo.completed });
};
exports.delete = async (userId, todoId) => {
const todo = await todoRepo.findById(todoId);
if (!todo) throw new NotFoundError('待办事项');
if (todo.userId !== userId) throw new ForbiddenError();
return todoRepo.delete(todoId);
};
25.2 项目二:网页爬虫
技术栈
| 工具 | 用途 |
|---|---|
| axios | HTTP 请求 |
| cheerio | HTML 解析(类 jQuery) |
| puppeteer | 无头浏览器(动态页面) |
| p-limit | 并发控制 |
基本爬虫
npm install axios cheerio p-limit
// crawler.js
const axios = require('axios');
const cheerio = require('cheerio');
const fs = require('fs/promises');
async function crawlArticle(url) {
const { data: html } = await axios.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; MyBot/1.0)',
},
timeout: 10000,
});
const $ = cheerio.load(html);
return {
url,
title: $('h1').first().text().trim(),
content: $('article').text().trim().substring(0, 5000),
author: $('meta[name="author"]').attr('content') || '未知',
date: $('time').attr('datetime') || null,
images: $('article img')
.map((_, el) => $(el).attr('src'))
.get()
.filter(Boolean),
};
}
async function crawlSitemap(baseUrl) {
const { data } = await axios.get(`${baseUrl}/sitemap.xml`);
const $ = cheerio.load(data, { xmlMode: true });
return $('url > loc')
.map((_, el) => $(el).text())
.get();
}
// 批量爬取(带并发控制)
const pLimit = require('p-limit');
const limit = pLimit(3); // 最多 3 个并发
async function crawlMultiple(urls) {
const tasks = urls.map((url) =>
limit(async () => {
try {
console.log(`爬取: ${url}`);
const article = await crawlArticle(url);
return article;
} catch (err) {
console.error(`失败: ${url} - ${err.message}`);
return null;
}
})
);
const results = await Promise.all(tasks);
return results.filter(Boolean);
}
// 主函数
async function main() {
const urls = [
'https://example.com/article/1',
'https://example.com/article/2',
'https://example.com/article/3',
];
const articles = await crawlMultiple(urls);
await fs.writeFile(
'articles.json',
JSON.stringify(articles, null, 2),
'utf8'
);
console.log(`共爬取 ${articles.length} 篇文章`);
}
main().catch(console.error);
Puppeteer 爬虫(动态页面)
const puppeteer = require('puppeteer');
async function crawlDynamicPage(url) {
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
try {
const page = await browser.newPage();
// 设置视窗和 UA
await page.setViewport({ width: 1280, height: 800 });
await page.setUserAgent('Mozilla/5.0 (compatible; MyBot/1.0)');
// 请求拦截(阻止图片/样式加载,提升速度)
await page.setRequestInterception(true);
page.on('request', (req) => {
if (['image', 'stylesheet', 'font'].includes(req.resourceType())) {
req.abort();
} else {
req.continue();
}
});
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
// 等待特定元素加载
await page.waitForSelector('.article-content', { timeout: 5000 });
// 提取数据
const data = await page.evaluate(() => {
return {
title: document.querySelector('h1')?.textContent?.trim(),
content: document.querySelector('.article-content')?.textContent?.trim(),
images: Array.from(document.querySelectorAll('.article-content img'))
.map(img => img.src),
};
});
return data;
} finally {
await browser.close();
}
}
爬虫礼仪
// robots.txt 解析
const robotsParser = require('robots-txt-parser');
async function canCrawl(url, userAgent) {
const robots = await robotsParser.parse(url);
return robots.isAllowed(url, userAgent);
}
// 爬虫配置
const config = {
delay: 1000, // 请求间隔(毫秒)
maxRetries: 3, // 最大重试次数
timeout: 10000, // 请求超时
concurrency: 3, // 并发数
userAgent: 'MyBot/1.0 ([email protected])',
};
25.3 项目三:CLI 工具
技术栈
| 工具 | 用途 |
|---|---|
| commander | 命令行参数解析 |
| inquirer | 交互式提示 |
| chalk | 终端彩色输出 |
| ora | 加载动画 |
| cli-table3 | 终端表格 |
npm install commander inquirer chalk ora cli-table3
项目结构
my-cli/
├── bin/
│ └── index.js # 入口(#!/usr/bin/env node)
├── src/
│ ├── commands/
│ │ ├── init.js # init 命令
│ │ ├── build.js # build 命令
│ │ └── deploy.js # deploy 命令
│ ├── utils/
│ │ ├── logger.js
│ │ └── config.js
│ └── index.js
├── package.json
└── README.md
package.json
{
"name": "my-cli",
"version": "1.0.0",
"bin": {
"my-cli": "./bin/index.js"
},
"type": "module",
"engines": {
"node": ">=20"
}
}
CLI 实现
#!/usr/bin/env node
// bin/index.js
import { Command } from 'commander';
import chalk from 'chalk';
import { initCommand } from '../src/commands/init.js';
import { buildCommand } from '../src/commands/build.js';
import { deployCommand } from '../src/commands/deploy.js';
const program = new Command();
program
.name('my-cli')
.description('我的 CLI 工具')
.version('1.0.0');
program
.command('init')
.description('初始化项目')
.option('-t, --template <name>', '模板名称', 'default')
.option('-y, --yes', '跳过确认', false)
.action(initCommand);
program
.command('build')
.description('构建项目')
.option('--minify', '压缩代码', false)
.option('--sourcemap', '生成 sourcemap', false)
.action(buildCommand);
program
.command('deploy')
.description('部署项目')
.option('-e, --env <environment>', '部署环境', 'staging')
.option('--dry-run', '模拟运行', false)
.action(deployCommand);
program.parse();
交互式命令
// src/commands/init.js
import inquirer from 'inquirer';
import chalk from 'chalk';
import ora from 'ora';
import fs from 'fs/promises';
import path from 'path';
export async function initCommand(options) {
console.log(chalk.bold('\n🚀 项目初始化\n'));
let config = {};
if (!options.yes) {
config = await inquirer.prompt([
{
type: 'input',
name: 'name',
message: '项目名称:',
default: 'my-project',
validate: (input) => input.length > 0 || '请输入项目名称',
},
{
type: 'list',
name: 'template',
message: '选择模板:',
choices: ['default', 'typescript', 'express', 'monorepo'],
default: options.template,
},
{
type: 'checkbox',
name: 'features',
message: '选择特性:',
choices: [
{ name: 'ESLint', checked: true },
{ name: 'Prettier', checked: true },
{ name: 'Docker', checked: false },
{ name: 'GitHub Actions', checked: false },
],
},
]);
}
const spinner = ora('创建项目...').start();
try {
// 创建目录
await fs.mkdir(config.name || 'my-project', { recursive: true });
await fs.writeFile(
path.join(config.name, 'package.json'),
JSON.stringify({ name: config.name, version: '1.0.0' }, null, 2)
);
spinner.succeed(chalk.green('项目创建成功!'));
console.log(`
${chalk.bold('下一步:')}
cd ${config.name}
npm install
npm run dev
`);
} catch (err) {
spinner.fail(chalk.red('创建失败'));
console.error(err.message);
process.exit(1);
}
}
发布 CLI 工具
# 注册 npm 账号后
npm login
# 发布
npm publish
# 使用
npx my-cli init
# 或全局安装
npm install -g my-cli
my-cli init
25.4 项目四:微服务架构
架构设计
┌──────────────┐
│ API 网关 │ :3000
│ (Express) │
└──────┬───────┘
│
┌──────────────┼──────────────┐
│ │ │
┌──────┴──────┐ ┌────┴──────┐ ┌─────┴─────┐
│ 用户服务 │ │ 订单服务 │ │ 通知服务 │
│ :3001 │ │ :3002 │ │ :3003 │
└──────┬──────┘ └────┬──────┘ └─────┬─────┘
│ │ │
┌──────┴──────┐ ┌────┴──────┐ ┌─────┴─────┐
│ 用户 DB │ │ 订单 DB │ │ Redis │
└─────────────┘ └───────────┘ └───────────┘
Docker Compose 编排
# docker-compose.yml
version: '3.8'
services:
api-gateway:
build: ./services/gateway
ports: ["3000:3000"]
environment:
USER_SERVICE_URL: http://user-service:3001
ORDER_SERVICE_URL: http://order-service:3002
NOTIFICATION_SERVICE_URL: http://notification-service:3003
depends_on: [user-service, order-service]
user-service:
build: ./services/users
environment:
DATABASE_URL: postgresql://user:pass@user-db:5432/users
depends_on:
user-db: { condition: service_healthy }
order-service:
build: ./services/orders
environment:
DATABASE_URL: postgresql://user:pass@order-db:5432/orders
USER_SERVICE_URL: http://user-service:3001
depends_on:
order-db: { condition: service_healthy }
notification-service:
build: ./services/notifications
environment:
REDIS_URL: redis://redis:6379
depends_on: [redis]
user-db:
image: postgres:16-alpine
environment:
POSTGRES_DB: users
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
volumes: [user-data:/var/lib/postgresql/data]
healthcheck:
test: pg_isready -U user -d users
interval: 5s
order-db:
image: postgres:16-alpine
environment:
POSTGRES_DB: orders
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
volumes: [order-data:/var/lib/postgresql/data]
healthcheck:
test: pg_isready -U user -d orders
interval: 5s
redis:
image: redis:7-alpine
volumes:
user-data:
order-data:
服务间通信
// services/gateway/routes/index.js
const { Router } = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const router = Router();
// 代理到用户服务
router.use('/api/users', createProxyMiddleware({
target: process.env.USER_SERVICE_URL,
changeOrigin: true,
pathRewrite: { '^/api/users': '/users' },
}));
// 代理到订单服务
router.use('/api/orders', createProxyMiddleware({
target: process.env.ORDER_SERVICE_URL,
changeOrigin: true,
pathRewrite: { '^/api/orders': '/orders' },
}));
module.exports = router;
// services/orders/services/userClient.js
const axios = require('axios');
class UserClient {
constructor(baseUrl) {
this.client = axios.create({
baseURL: baseUrl,
timeout: 5000,
});
}
async getUser(userId) {
try {
const { data } = await this.client.get(`/users/${userId}`);
return data;
} catch (err) {
if (err.response?.status === 404) return null;
throw new Error(`用户服务不可用: ${err.message}`);
}
}
}
module.exports = new UserClient(process.env.USER_SERVICE_URL);
事件驱动通信
// 使用 Redis Pub/Sub 进行异步通信
const { createClient } = require('redis');
// 发布者(订单服务)
const publisher = createClient();
await publisher.connect();
async function createOrder(orderData) {
const order = await db.orders.create(orderData);
// 发布事件
await publisher.publish('order:created', JSON.stringify({
orderId: order.id,
userId: order.userId,
total: order.total,
timestamp: Date.now(),
}));
return order;
}
// 订阅者(通知服务)
const subscriber = createClient();
await subscriber.connect();
await subscriber.subscribe('order:created', (message) => {
const data = JSON.parse(message);
console.log(`订单创建: ${data.orderId}`);
sendNotification(data.userId, `您的订单 ${data.orderId} 已创建`);
});
25.5 学习路线图
入门(1-3 个月)
├── JavaScript 基础
├── Node.js 核心模块
├── Express 基础
└── 简单 REST API
进阶(3-6 个月)
├── 数据库(PostgreSQL/MongoDB)
├── 认证授权(JWT)
├── 测试(Jest)
├── Docker 基础
└── TypeScript 入门
高级(6-12 个月)
├── 微服务架构
├── 消息队列(RabbitMQ/Kafka)
├── CI/CD 流水线
├── 性能优化
├── 安全加固
└── Kubernetes 基础
专家(12+ 个月)
├── 分布式系统设计
├── 高并发架构
├── 开源贡献
└── 技术团队管理
注意事项
⚠️ 爬虫要遵守 robots.txt 和法律法规:不要对目标网站造成过大负载。
⚠️ CLI 工具要有良好的错误提示:用户看不懂的错误是糟糕的用户体验。
⚠️ 微服务不是银弹:小型项目使用单体架构更高效,当团队和业务规模增长时再考虑微服务。
⚠️ 实战中持续学习:每个项目都会遇到新问题,保持好奇心和学习习惯。
扩展阅读
上一章:第 24 章 · 最佳实践
🎉 恭喜! 你已经完成了全部 25 章的 Node.js 开发指南。继续实践,不断精进!