Courses
一开始,用一个朴素的待办清单就很好:添加一些任务,完成后勾掉即可。但随着任务越来越多,使用体验会变得糟糕。一个能改善体验的做法是引入分类,这样每个任务都可以归属到 Work、Study、Personal 等类别,您也能按类别筛选列表。
在本教程中,您将学习如何使用 Node.js 和 MongoDB 构建一个可按类别组织任务的 REST API。完成后,您将拥有一个包含五个端点(创建、列表、获取、更新、删除)以及类别筛选功能的可用 Node.js 服务器。您还将看到如何在不依赖 Mongoose 这类 ODM 的情况下,使用原生 MongoDB 驱动来实施类别校验。
您将学到什么
- 如何使用 Node.js、Express 和 MongoDB 构建 REST API,包括完整的 CRUD 端点
- 如何验证输入、强制类别约束,并安全高效地处理 ObjectId 值
- 如何组织并测试 API,然后将其连接到前端
如果您更倾向于克隆代码并边读边看,可在 GitHub 上找到本教程的完整代码。
前置条件
开始之前,您应已具备:
- 已安装 Node.js 18+(默认包含 npm)
- 本地运行的 MongoDB,或免费的 MongoDB Atlas 集群
- Postman 或其他任意 HTTP 客户端
- JavaScript 与 REST 基础知识
步骤 1:设置项目
接下来开始构建我们的简单任务管理器。第一步是在终端中运行以下命令来创建新文件夹并初始化一个 Node 项目:
mkdir task-manager-categories
cd task-manager-categories
npm init -y
然后安装应用运行所需的四个运行时依赖:用于路由的 express、官方 MongoDB 驱动、用于加载环境变量的 dotenv,以及用于安全响应头的 helmet。
npm install express mongodb dotenv helmet
在开发阶段,使用下面的命令将 nodemon 安装为开发依赖,这样文件变更后服务器会自动重载:
npm install --save-dev nodemon
接着,在 IDE 中打开项目文件夹,更新 package.json 中的 scripts,如下所示。之后您可以用 npm start 启动服务器,或用 npm run dev 进入开发模式运行:
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}
下面是我们将逐步搭建的结构。各文件夹职责清晰: db/ 负责数据库连接,lib/ 放置辅助函数,middleware/ 存放 Express 中间件,routes/ 定义请求处理器。稍后我们会在 public/ 中加入一个小型前端。即便是小项目,这样的结构也能让脉络清晰,便于定位新代码应放何处。您可以现在就创建这些文件夹并边走边填充,也可以跳过这步,按后续步骤逐个添加文件:
task-manager-categories/
├── db/
│ └── connect.js
├── lib/
│ └── taskDocument.js
├── middleware/
│ └── parseObjectId.js
├── routes/
│ └── tasks.js
├── public/
│ ├── index.html
│ ├── styles.css
│ └── app.js
├── .env
├── .env.example
├── .gitignore
├── package.json
└── server.js
步骤 2:连接 MongoDB
为了存取任务,我们首先需要将应用连接到 MongoDB。这需要使用连接字符串,它告诉 MongoDB 驱动如何连接到您的数据库。与其将其硬编码在代码中,不如放到环境变量文件中。这样既能避免敏感信息进入代码库,也便于在不同环境间切换。
创建一个 .env.example 文件来记录所需变量,再创建一个 .env 文件填写您的实际值,如下所示:
# Copy this file to `.env` and fill in real values.
# --- Local MongoDB ---
# Use this if you're running MongoDB locally
MONGO_URI=mongodb://127.0.0.1:27017
# --- MongoDB Atlas ---
# Replace <username>, <password>, and <cluster-url> with your actual values
# Example: mongodb+srv://user:pass@cluster0.abcde.mongodb.net/
# MONGO_URI=mongodb+srv://<username>:<password>@<cluster-url>/?retryWrites=true&w=majority
DB_NAME=taskmanager
PORT=3000
现在我们已有连接字符串,可以用它来设置 MongoDB 连接。新建一个 db/connect.js 文件。在这里初始化 MongoDB 客户端,并让应用其他部分可以复用:
const { MongoClient } = require("mongodb");
let client;
let db;
async function connectDB() {
if (!process.env.MONGO_URI) {
throw new Error("MONGO_URI is not set. Check your .env file.");
}
client = new MongoClient(process.env.MONGO_URI, {
appName: "devrel-tutorial-javascript-crud-geeksforgeeks",
});
await client.connect();
db = client.db(process.env.DB_NAME || "taskmanager");
await db.collection("tasks").createIndex({ category: 1 });
console.log(`MongoDB connected (db: ${db.databaseName})`);
}
function getTasksCollection() {
if (!db) {
throw new Error("Database not initialized. Call connectDB() first.");
}
return db.collection("tasks");
}
async function closeDB() {
if (client) await client.close();
}
module.exports = { connectDB, getTasksCollection, closeDB };
该文件设置了一个可在整个应用中复用的 MongoDB 单例客户端。您只需创建一次,因为驱动已在内部处理了连接池。如果每次请求都新建客户端,一开始看似可行,但很快就会产生性能问题。
步骤 3:定义任务结构与校验
此时应用已能连接到 MongoDB。接下来在开始保存数据之前,需要先定义任务的具体结构。
在这里使用原生 MongoDB 驱动会和以往有些不同:不像 Mongoose 那样有独立的 schema 文件。相反,“schema” 就是您插入到数据库中的对象形状。一开始可能显得有些松散,但这其实很有帮助,因为您更贴近 MongoDB 的实际工作方式,逻辑也不被抽象层遮蔽。
但我们仍需要校验。因此我们不将逻辑分散在各个路由中,而是集中在一个地方,让所有操作遵循同一规则。新建一个名为 lib/taskDocument.js 的文件,并加入以下代码:
class ValidationError extends Error {
constructor(message) {
super(message);
this.name = 'ValidationError';
}
}
const ALLOWED_CATEGORIES = ['Work', 'Personal', 'Study', 'Other'];
function assertValidCategory(category) {
if (!ALLOWED_CATEGORIES.includes(category)) {
throw new ValidationError(
`category must be one of: ${ALLOWED_CATEGORIES.join(', ')}`
);
}
}
function buildTaskDocument(body = {}) {
if (!body.title || typeof body.title !== 'string' || !body.title.trim()) {
throw new ValidationError('title is required and must be a non-empty string');
}
if (body.category != null) {
assertValidCategory(body.category);
}
const now = new Date();
return {
title: body.title.trim(),
description: typeof body.description === 'string' ? body.description.trim() : '',
category: body.category != null ? body.category : 'Other',
completed: Boolean(body.completed),
createdAt: now,
updatedAt: now
};
}
function buildTaskUpdate(body = {}) {
const updates = {};
if (typeof body.title === 'string' && body.title.trim()) {
updates.title = body.title.trim();
}
if (typeof body.description === 'string') {
updates.description = body.description.trim();
}
if (body.category != null) {
assertValidCategory(body.category);
updates.category = body.category;
}
if (body.completed != null) {
updates.completed = Boolean(body.completed);
}
if (Object.keys(updates).length === 0) {
throw new ValidationError('no valid fields provided for update');
}
updates.updatedAt = new Date();
return updates;
}
module.exports = {
ALLOWED_CATEGORIES,
buildTaskDocument,
buildTaskUpdate,
ValidationError
};
该文件相当于写入数据库的守门人。所有创建或更新请求都会经过这里,从而在一个位置统一实施规则。
它确保任务的结构始终正确、类别一致,并避免在查询数据时出现意外。自定义的 ValidationError 还能将无效输入与真实的服务器问题区分开,让您的 API 能做出恰当响应。有了这层保障,其余部分就能保持简洁。各路由只需专注于自身职责,因为它们接收到的数据已经是有效的。接下来我们将把路由接起来,并开始把任务保存到 MongoDB。
步骤 4:处理 ObjectId 与请求校验
既然已经知道任务的结构,接下来处理引用任务的方式。每当路由包含 :id 参数时,它会以普通字符串形式传入;但 MongoDB 需要的是 ObjectId。如果字符串格式错误,驱动会抛出不太友好的错误。我们不在每个路由重复处理,而是用中间件统一处理,这样所有端点行为一致。
为此,新建一个名为 middleware/parseObjectId.js 的文件,并加入以下内容:
const { ObjectId } = require('mongodb');
function parseObjectId(req, res, next) {
const { id } = req.params;
if (!ObjectId.isValid(id)) {
return res.status(400).json({ error: 'invalid task id' });
}
req.taskId = new ObjectId(id);
next();
}
module.exports = parseObjectId;
该中间件在路由处理器之前运行。它会校验 id 是否有效、将其转换为 ObjectId,并附加到 req.taskId。这样到达路由逻辑时,您始终在使用正确的 ObjectId,而无效输入会被尽早以清晰的 400 响应拒绝。接下来我们会将它接入路由,把所有部分串联起来。
步骤 5:构建任务路由(CRUD + 筛选)
到这一步,大部分繁重工作已完成。我们定义了有效任务的样子,也处理了 ID 的解析与校验。这意味着路由处理器只需专注做一件事:与数据库交互。
现在创建名为 routes/tasks.js 的文件,并加入以下代码,来搭建实际的 API 路由:
const express = require('express');
const { getTasksCollection } = require('../db/connect');
const {
ALLOWED_CATEGORIES,
buildTaskDocument,
buildTaskUpdate,
ValidationError
} = require('../lib/taskDocument');
const parseObjectId = require('../middleware/parseObjectId');
const router = express.Router();
// POST /tasks
router.post('/', async (req, res, next) => {
try {
const doc = buildTaskDocument(req.body);
const result = await getTasksCollection().insertOne(doc);
res.status(201).json({ _id: result.insertedId, ...doc });
} catch (err) {
if (err instanceof ValidationError) {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
// GET /tasks (optionally ?category=Work)
router.get('/', async (req, res, next) => {
try {
const { category } = req.query;
if (category && !ALLOWED_CATEGORIES.includes(category)) {
return res.status(400).json({
error: `category must be one of: ${ALLOWED_CATEGORIES.join(', ')}`
});
}
const filter = category ? { category } : {};
const tasks = await getTasksCollection()
.find(filter)
.sort({ createdAt: -1 })
.toArray();
res.json(tasks);
} catch (err) { next(err); }
});
// GET /tasks/:id
router.get('/:id', parseObjectId, async (req, res, next) => {
try {
const task = await getTasksCollection().findOne({ _id: req.taskId });
if (!task) return res.status(404).json({ error: 'task not found' });
res.json(task);
} catch (err) { next(err); }
});
// PUT /tasks/:id
router.put('/:id', parseObjectId, async (req, res, next) => {
try {
const updates = buildTaskUpdate(req.body);
const result = await getTasksCollection().findOneAndUpdate(
{ _id: req.taskId },
{ $set: updates },
{ returnDocument: 'after' }
);
if (!result) return res.status(404).json({ error: 'task not found' });
res.json(result);
} catch (err) {
if (err instanceof ValidationError) {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
// DELETE /tasks/:id
router.delete('/:id', parseObjectId, async (req, res, next) => {
try {
const result = await getTasksCollection().deleteOne({ _id: req.taskId });
if (result.deletedCount === 0) {
return res.status(404).json({ error: 'task not found' });
}
res.status(204).end();
} catch (err) { next(err); }
});
module.exports = router;
上面的每个路由都遵循同样的模式:从请求中获取输入,交给我们之前编写的辅助函数处理,再调用 MongoDB,并返回响应。由于校验与 ID 解析已就绪,这里的代码保持小而可预期。实际表现如下:
- 在插入前,POST 路由会使用 buildTaskDocument 构建新任务
- GET 路由可按类别筛选,并按最新优先排序返回结果
- PUT 路由使用 buildTaskUpdate,从而安全一致地进行部分字段更新
- DELETE 路由按已解析的 ObjectId 删除任务
此外,还有一些值得注意的细节:
- find() 返回的是游标而非数组,因此我们在排序后再链式调用 .toArray() 来获取结果
- findOneAndUpdate 配合 returnDocument: 'after' 能直接返回更新后的文档
- 返回合适的 HTTP 状态码:创建用 201,删除用 204,必要时返回 400 或 404
- 任何意外错误都会传给 next(err),从而在一个地方统一处理,而不是在每个路由中重复
步骤 6:在服务器中将所有部分连线
此时各个部件都已就位:有了校验、整洁的路由与可用的数据库连接。现在只需把它们连在一起并启动服务器。创建一个 server.js 文件,作为应用入口:
require('dotenv').config();
const path = require('path');
const express = require('express');
const helmet = require('helmet');
const { connectDB, closeDB } = require('./db/connect');
const tasksRouter = require('./routes/tasks');
if (!process.env.MONGO_URI) {
console.error('Fatal: MONGO_URI is not set. Copy .env.example to .env and fill it in.');
process.exit(1);
}
const PORT = Number(process.env.PORT) || 3000;
const app = express();
app.use(helmet());
app.use(express.json({ limit: '100kb' }));
app.use(express.static(path.join(__dirname, 'public')));
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'task-manager-with-categories' });
});
app.use('/tasks', tasksRouter);
app.use((req, res) => {
res.status(404).json({ error: 'not found' });
});
app.use((err, req, res, next) => {
if (err.type === 'entity.too.large') {
return res.status(413).json({ error: 'payload too large' });
}
if (err.type === 'entity.parse.failed') {
return res.status(400).json({ error: 'invalid JSON body' });
}
console.error(err);
res.status(500).json({ error: 'internal server error' });
});
async function start() {
try {
await connectDB();
const server = app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
const shutdown = () => {
console.log('\nShutting down gracefully...');
server.close(async () => {
await closeDB();
process.exit(0);
});
setTimeout(() => process.exit(1), 10_000).unref();
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
} catch (err) {
console.error('Failed to start server:', err);
process.exit(1);
}
}
if (require.main === module) {
start();
}
module.exports = { app, start };
该文件以整洁的方式把所有内容串起来。它会在运行前检查环境变量是否已设置,使用 helmet 提供基础安全防护,并限制传入的 JSON 体积,避免误接收巨大的负载。它还会服务于 public/ 文件夹,这样前端可以与后端共存于同一应用,无需额外服务器。
所有路由都挂载在 /tasks 下,未命中的请求会返回干净的 404。错误在一个地方统一处理,而不是在每个路由中重复,这让维护更容易。数据库连接会在服务器开始监听之前建立,因此不会在尚未就绪时接收请求;关闭流程也很优雅,能正确地关闭连接。
现在运行 npm run dev,确认一切正常。如果配置正确,您会看到类似输出:
MongoDB connected (db: taskmanager)
Server running on http://localhost:3000
至此,一切已连通并运行!我们已经有一个完整可用的 API,接下来进行测试。
步骤 7:测试 API 端点
在构建 UI 之前,先逐个确认每个端点能单独工作。这能大大简化调试,因为您能快速判断问题来自 API 还是前端。我们将使用 Postman(或任意 HTTP 客户端),按顺序运行几次请求,每一步都基于上一步创建的数据继续进行。
创建任务: 发送一个包含标题、描述和类别的 POST 请求。如果成功,API 将返回 201 Created,连同新任务及其生成的 _id。
POST http://localhost:3000/tasks
Content-Type: application/json
{
"title": "Write GeeksForGeeks article",
"description": "First draft by Friday",
"category": "Work"
}
再创建几个: 用不同的请求体重复发送 POST 请求,以便有足够数据用于测试列表与筛选。完成后,您会有一个可查询的小数据集。
{ "title": "Go for a run", "category": "Personal" }
{ "title": "Read MongoDB docs", "category": "Study" }
{ "title": "Buy groceries" } // category defaults to "Other"
列出所有任务: 返回全部任务,并按最新优先排序。
GET http://localhost:3000/tasks
按类别筛选: 这将仅返回 Work 类别的任务。先前添加的索引能在数据增长时保持该查询的速度。
GET http://localhost:3000/tasks?category=Work
更新任务: 使用刚刚创建的某个任务的 _id(可从 POST 响应或列表端点复制)。这会返回更新后的任务。您只需发送想要更改的字段,因此部分更新很简单。
PUT http://localhost:3000/tasks/<paste-task-id-here>
Content-Type: application/json
{ "completed": true }
删除任务: 使用欲删除任务的 _id。该请求返回 204 No Content。若再次尝试获取该任务,会得到 404 并显示 "task not found"。
DELETE http://localhost:3000/tasks/<paste-task-id-here>
至此,您已确认所有路由均按预期运行。API 的职责已完成,接下来我们在其之上构建一个 UI。
步骤 8:添加一个与 API 交互的前端
Postman 很适合验证 API,但在实际网页上体验会更直观,因此我们为应用加入一些前端代码。
为避免在此粘贴大量 HTML 和 CSS,我已将代码放到 GitHub 仓库。前往那里,将 index.html、styles.css 和 favicon.svg 的内容复制到项目根目录的 public/ 文件夹。保存后访问 localhost:3000。然后将下面的代码粘贴到 app.js 文件:
const form = document.getElementById('task-form');
const titleEl = document.getElementById('title');
const descEl = document.getElementById('description');
const catEl = document.getElementById('category');
const errEl = document.getElementById('form-error');
const listEl = document.getElementById('task-list');
const emptyEl = document.getElementById('empty');
const filterEl = document.getElementById('filter');
const countEl = document.getElementById('task-count');
async function api(method, path, body) {
const res = await fetch(path, {
method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined
});
if (res.status === 204) return null;
const data = await res.json();
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
return data;
}
const fetchTasks = (category) =>
api('GET', '/tasks' + (category ? `?category=${encodeURIComponent(category)}` : ''));
const createTask = (body) => api('POST', '/tasks', body);
const updateTask = (id, body) => api('PUT', `/tasks/${id}`, body);
const deleteTask = (id) => api('DELETE', `/tasks/${id}`);
function updateCount(tasks) {
const active = tasks.filter((t) => !t.completed).length;
const total = tasks.length;
if (total === 0) {
countEl.textContent = '';
return;
}
const scope = filterEl.value ? ` in ${filterEl.value}` : '';
countEl.textContent = `${total} tasks${scope} · ${active} active`;
}
function renderTask(task) {
const li = document.createElement('li');
li.className = 'task' + (task.completed ? ' completed' : '');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = task.completed;
checkbox.addEventListener('change', () =>
updateTask(task._id, { completed: checkbox.checked }).then(refresh)
);
const title = document.createElement('div');
title.textContent = task.title;
const badge = document.createElement('span');
badge.textContent = task.category;
const del = document.createElement('button');
del.textContent = 'Delete';
del.onclick = () => deleteTask(task._id).then(refresh);
li.append(checkbox, title, badge, del);
return li;
}
function render(tasks) {
listEl.innerHTML = '';
emptyEl.classList.toggle('hidden', tasks.length > 0);
for (const task of tasks) {
listEl.appendChild(renderTask(task));
}
updateCount(tasks);
}
async function refresh() {
try {
const tasks = await fetchTasks(filterEl.value);
render(tasks);
} catch (e) {
errEl.textContent = e.message;
}
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
errEl.textContent = '';
const title = titleEl.value.trim();
if (!title) {
errEl.textContent = 'Title is required.';
return;
}
try {
await createTask({
title,
description: descEl.value.trim(),
category: catEl.value
});
form.reset();
await refresh();
} catch (e) {
errEl.textContent = e.message;
}
});
filterEl.addEventListener('change', refresh);
refresh();
这段 JavaScript 代码将一切与之前构建的 API 对接。每个函数都映射到一个路由,使前端易于理解。所有请求也都通过统一的 api() 助手,这样您只需在一个地方处理错误,而无需到处重复逻辑。
筛选通过传递形如 ?category=Work 的查询字符串来实现,这与您已在后端加入的逻辑直接对应。创建、更新或删除任务后,应用会再次获取最新列表,保持 UI 同步。任务计数虽是小细节,却能让应用在使用时更有反馈感。
现在使用 npm run dev 重启服务器,然后打开 http://localhost:3000 体验任务管理器。添加几个任务、更新它们、在类别间切换,并尝试删除。此时前后端已端到端联通并协同工作。
总结
恭喜!您已使用 Node.js、Express 和 MongoDB 从零构建了一个完整的任务管理器 API。您完成了数据校验、保持了路由简洁,并把所有部分连线成一个可用系统。更重要的是,您并未用抽象层遮蔽 MongoDB 的工作方式,而是贴近其本质。从这里出发,您可以继续打磨,让应用走向生产可用。
要点回顾
- 使用中间件并集中化校验,能让代码保持整洁与可预期。
- 原生 MongoDB 驱动在避免不必要抽象的同时,提供足够的可控性。
- 结构良好的 API 易于扩展为生产级应用。
FAQs
构建 MongoDB API 一定要用 Mongoose 吗?
不需要。本教程使用原生 MongoDB 驱动,它提供更高的可控性且更轻量。如果您需要 schema 抽象,之后可以再加入 Mongoose。
没有 Schema 如何校验数据?
您可以将校验集中在辅助函数中(例如 buildTaskDocument),确保每个路由在写入数据库前都执行相同规则。
如果传入无效的 ObjectId 会怎样?
中间件会及早以 400 响应拒绝,从而避免 MongoDB 抛出令人困惑的错误。